diff --git a/Build-Release.ps1 b/Build-Release.ps1 index b59a50a4..d4587f78 100644 --- a/Build-Release.ps1 +++ b/Build-Release.ps1 @@ -46,6 +46,7 @@ try { New-Item -ItemType Directory -Path "$stage/docs/images" -Force | Out-Null Copy-Item -LiteralPath docs/images/skating-crab.png -Destination "$stage/docs/images/skating-crab.png" Copy-Item -LiteralPath docs/installation.md -Destination "$stage/docs/installation.md" + Copy-Item -LiteralPath docs/retail-renderer.md -Destination "$stage/docs/retail-renderer.md" New-Item -ItemType Directory -Path "$stage/licenses" -Force | Out-Null Copy-Item -LiteralPath tools/vendor/utt/LICENSE -Destination "$stage/licenses/UTT.txt" Copy-Item -LiteralPath tools/vendor/university/LICENSE-PROJECT.md -Destination "$stage/licenses/CustomEngineLayer.txt" diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c9180b20..e5bdd7fb 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -2,6 +2,10 @@ Bevy sources retain their MIT and Apache licenses under `vendor/`. +Retail world shading equations were referenced against Alex's Skate3Recomp +native renderer. Source revision and implementation scope are documented in +`docs/retail-renderer.md`. No game shaders or texture payloads are bundled. + `tools/vendor/utt` contains the MIT-licensed UTT RX2/model parsers by duckyinnit. The full license is alongside the source. No UTT game payloads or executables are bundled. diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index 7676e14c..7f970160 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -37,6 +37,7 @@ pub(crate) fn build( physics: GamePhysics, skater: SkaterRuntime, ) -> App { + let retail_scene = config.map.as_ref().is_some_and(|map| map.materials.iter().any(|m| m.retail_definition.is_some())); let mut app = App::new(); app.add_plugins( DefaultPlugins @@ -64,6 +65,7 @@ pub(crate) fn build( }), ) .insert_resource(config) + .insert_resource(crate::retail_render::RetailScene(retail_scene)) .insert_resource(assets::AssetManifest(manifest)) .insert_resource(graphs) .insert_resource(physics) @@ -88,6 +90,7 @@ pub(crate) fn build( .chain(), ) .add_plugins(( + crate::retail_render::RetailRenderPlugin, input::InputPlugin, PhysicsPlugin, crate::presentation::PresentationPlugin, diff --git a/crates/skate-game/src/camera.rs b/crates/skate-game/src/camera.rs index f3a3bddf..2e6b5d9f 100644 --- a/crates/skate-game/src/camera.rs +++ b/crates/skate-game/src/camera.rs @@ -33,16 +33,20 @@ impl Plugin for CameraPlugin { .add_systems(Update, present.after(FrameSet::Animation).before(FrameSet::Verification)); } } -fn spawn(mut commands: Commands, config: Res) { +fn spawn(mut commands: Commands, config: Res, retail: Res) { let runtime = CameraRuntime::load(&config.asset_root) .unwrap_or_else(|error| panic!("Cannot initialize normal gameplay camera: {error}")); commands.insert_resource(runtime); - commands.spawn(( + let mut camera = commands.spawn(( GameplayCamera, Camera3d::default(), Camera { is_active: false, ..default() }, Transform::default(), )); + if retail.0 { + camera.insert((bevy::render::view::Hdr, bevy::core_pipeline::tonemapping::Tonemapping::None, + crate::retail_render::RetailTone::default())); + } } fn present(mut runtime: ResMut, windows: Query<&Window>, diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index 4062ec95..8d93deda 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -16,6 +16,7 @@ mod verification; mod performance; mod graphics_menu; mod render_capacity; +mod retail_render; mod presentation; mod replay; mod world; diff --git a/crates/skate-game/src/retail_depth.wgsl b/crates/skate-game/src/retail_depth.wgsl new file mode 100644 index 00000000..ba943a42 --- /dev/null +++ b/crates/skate-game/src/retail_depth.wgsl @@ -0,0 +1,32 @@ +#import bevy_pbr::prepass_io::VertexOutput +#ifdef PREPASS_FRAGMENT +#import bevy_pbr::prepass_io::FragmentOutput +#endif +#ifdef MOTION_VECTOR_PREPASS +#import bevy_pbr::pbr_prepass_functions::calculate_motion_vector +#endif +struct WorldParams { mode: vec4, surface: vec4, family: vec4, fog_ramp: vec4, fog_color: vec4, shadow_color: vec4 } +@group(#{MATERIAL_BIND_GROUP}) @binding(0) var p: WorldParams; +@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler; +@fragment +fn fragment(i: VertexOutput) +#ifdef PREPASS_FRAGMENT + -> FragmentOutput +#endif +{ + if textureSample(diffuse,diffuse_sampler,i.uv).a < p.mode.z { discard; } +#ifdef PREPASS_FRAGMENT + var out: FragmentOutput; +#ifdef NORMAL_PREPASS + out.normal = vec4(normalize(i.world_normal)*0.5+0.5,1.0); +#endif +#ifdef MOTION_VECTOR_PREPASS + out.motion_vector = calculate_motion_vector(i.world_position,i.previous_world_position); +#endif +#ifdef UNCLIPPED_DEPTH_ORTHO_EMULATION + out.frag_depth = i.unclipped_depth; +#endif + return out; +#endif +} diff --git a/crates/skate-game/src/retail_render.rs b/crates/skate-game/src/retail_render.rs new file mode 100644 index 00000000..ed162da5 --- /dev/null +++ b/crates/skate-game/src/retail_render.rs @@ -0,0 +1,441 @@ +//! Retail world shading. See docs/retail-renderer.md for provenance and gaps. +use bevy::{ + asset::embedded_asset, + core_pipeline::{ + core_3d::graph::Node3d, + fullscreen_material::{FullscreenMaterial, FullscreenMaterialPlugin}, + }, + prelude::*, + render::{ + extract_component::ExtractComponent, + render_graph::{InternedRenderLabel, RenderLabel}, + render_resource::{AsBindGroup, ShaderType}, + }, + shader::ShaderRef, +}; +use std::collections::BTreeMap; + +pub(crate) struct RetailRenderPlugin; +impl Plugin for RetailRenderPlugin { + fn build(&self, app: &mut App) { + embedded_asset!(app, "retail_world.wgsl"); + embedded_asset!(app, "retail_tone.wgsl"); + embedded_asset!(app, "retail_depth.wgsl"); + embedded_asset!(app, "retail_sky.wgsl"); + app.add_plugins(( + MaterialPlugin::::default(), + MaterialPlugin::::default(), + FullscreenMaterialPlugin::::default(), + )); + } +} + +#[derive(Resource)] +pub(crate) struct RetailScene(pub bool); + +#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)] +pub(crate) struct RetailSkyMaterial { + #[uniform(0)] + pub params: Vec4, + #[texture(1)] + #[sampler(2)] + pub diffuse: Handle, +} +impl Material for RetailSkyMaterial { + fn enable_prepass() -> bool { + false + } + fn enable_shadows() -> bool { + false + } + fn vertex_shader() -> ShaderRef { + "embedded://skate3rust/retail_sky.wgsl".into() + } + fn fragment_shader() -> ShaderRef { + Self::vertex_shader() + } + fn specialize( + _: &bevy::pbr::MaterialPipeline, + descriptor: &mut bevy::render::render_resource::RenderPipelineDescriptor, + _: &bevy::mesh::MeshVertexBufferLayoutRef, + _: bevy::pbr::MaterialPipelineKey, + ) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> { + descriptor.primitive.cull_mode = None; + if let Some(depth) = &mut descriptor.depth_stencil { + depth.depth_write_enabled = false; + } + Ok(()) + } +} + +pub(crate) fn spawn_sky( + name: &str, + root: &std::path::Path, + commands: &mut Commands, + meshes: &mut Assets, + images: &mut Assets, + materials: &mut Assets, +) { + // Parks need their authored sky selection from the environment controller; + // only these three district identities are currently resolved. + if !matches!(name, "University" | "DownTown" | "Industrial") { + return; + } + #[derive(serde::Deserialize)] + struct Sky { + width: u32, + height: u32, + positions: Vec<[f32; 3]>, + uvs: Vec<[f32; 2]>, + indices: Vec, + } + let load = || -> Result<(Sky, Vec), String> { + let base = root.join("private/native-skies"); + let sky: Sky = serde_json::from_slice( + &std::fs::read(base.join(format!("{name}.json"))).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + let rgba = std::fs::read(base.join(format!("{name}.rgba"))).map_err(|e| e.to_string())?; + if sky.width == 0 + || sky.height == 0 + || u64::from(sky.width) * u64::from(sky.height) * 4 != rgba.len() as u64 + || sky.positions.len() != sky.uvs.len() + || sky + .indices + .iter() + .any(|&i| i as usize >= sky.positions.len()) + { + return Err("Invalid retail sky dimensions/geometry".into()); + } + Ok((sky, rgba)) + }; + let (sky, rgba) = match load() { + Ok(v) => v, + Err(e) => { + warn!("Retail sky unavailable: {e}"); + return; + } + }; + let mut image = Image::new( + bevy::render::render_resource::Extent3d { + width: sky.width, + height: sky.height, + depth_or_array_layers: 1, + }, + bevy::render::render_resource::TextureDimension::D2, + rgba, + bevy::render::render_resource::TextureFormat::Rgba8Unorm, + bevy::asset::RenderAssetUsages::RENDER_WORLD, + ); + let mut sampler = bevy::image::ImageSamplerDescriptor::linear(); + sampler.address_mode_u = bevy::image::ImageAddressMode::Repeat; + image.sampler = bevy::image::ImageSampler::Descriptor(sampler); + let mesh = Mesh::new( + bevy::mesh::PrimitiveTopology::TriangleList, + bevy::asset::RenderAssetUsages::RENDER_WORLD, + ) + .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, sky.positions) + .with_inserted_attribute(Mesh::ATTRIBUTE_UV_0, sky.uvs) + .with_inserted_indices(bevy::mesh::Indices::U32(sky.indices)); + commands.spawn(( + Name::new("Retail sky dome"), + Mesh3d(meshes.add(mesh)), + MeshMaterial3d(materials.add(RetailSkyMaterial { + params: Vec4::new(165., 2.5, 1., 0.), + diffuse: images.add(image), + })), + Transform::default(), + bevy::camera::visibility::NoFrustumCulling, + bevy::light::NotShadowCaster, + bevy::light::NotShadowReceiver, + )); +} + +#[derive(Component, ExtractComponent, Clone, Copy, ShaderType, Default)] +pub(crate) struct RetailTone { + pub enabled: Vec4, +} +impl FullscreenMaterial for RetailTone { + fn fragment_shader() -> ShaderRef { + "embedded://skate3rust/retail_tone.wgsl".into() + } + fn node_edges() -> Vec { + vec![ + Node3d::Tonemapping.intern(), + Self::node_label().intern(), + Node3d::EndMainPassPostProcessing.intern(), + ] + } +} + +#[derive(Clone, Debug, ShaderType)] +pub(crate) struct WorldParams { + // family, texture flags, alpha cutoff (-1 for opaque), exposure + pub mode: Vec4, + // macro UV scale, opacity, detail UV scale, material multiplier + pub surface: Vec4, + // tree LM scale/floor/tint, proxy multiplier: reference day capture + pub family: Vec4, + pub fog_ramp: Vec4, + pub fog_color: Vec4, + pub shadow_color: Vec4, +} + +#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)] +#[bind_group_data(RetailKey)] +pub(crate) struct RetailWorldMaterial { + #[uniform(0)] + pub params: WorldParams, + #[texture(1)] + #[sampler(2)] + pub diffuse: Option>, + #[texture(3)] + #[sampler(4)] + pub lightmap: Option>, + #[texture(5)] + #[sampler(6)] + pub normal: Option>, + #[texture(7)] + #[sampler(8)] + pub detail: Option>, + #[texture(9)] + #[sampler(10)] + pub macro_map: Option>, + #[texture(11)] + #[sampler(12)] + pub decal: Option>, + #[texture(13)] + #[sampler(14)] + pub specular: Option>, + #[texture(15, dimension = "cube")] + pub environment: Option>, + pub alpha: AlphaMode, + pub two_sided: bool, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct RetailKey { + two_sided: bool, +} +impl From<&RetailWorldMaterial> for RetailKey { + fn from(m: &RetailWorldMaterial) -> Self { + Self { + two_sided: m.two_sided, + } + } +} +impl Material for RetailWorldMaterial { + fn fragment_shader() -> ShaderRef { + "embedded://skate3rust/retail_world.wgsl".into() + } + fn prepass_fragment_shader() -> ShaderRef { + "embedded://skate3rust/retail_depth.wgsl".into() + } + fn alpha_mode(&self) -> AlphaMode { + self.alpha + } + fn specialize( + _: &bevy::pbr::MaterialPipeline, + descriptor: &mut bevy::render::render_resource::RenderPipelineDescriptor, + _: &bevy::mesh::MeshVertexBufferLayoutRef, + key: bevy::pbr::MaterialPipelineKey, + ) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> { + // Retail foliage is two-sided; opaque world triangles retain winding. + descriptor.primitive.cull_mode = if key.bind_group_data.two_sided { + None + } else { + Some(bevy::render::render_resource::Face::Back) + }; + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct Binding { + pub texture: u32, + pub uv: u32, + pub u: u32, + pub v: u32, +} +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct Definition { + pub family: u32, + pub flags: u32, + pub bindings: BTreeMap, + pub parameters: BTreeMap>, +} +impl Definition { + pub fn parse(bytes: &[u8]) -> Option { + struct Reader<'a>(&'a [u8]); + impl<'a> Reader<'a> { + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + let v = self.0.get(..n)?; + self.0 = &self.0[n..]; + Some(v) + } + fn u32(&mut self) -> Option { + Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?)) + } + fn text(&mut self) -> Option { + let n = self.u32()? as usize; + String::from_utf8(self.take(n)?.to_vec()).ok() + } + } + let mut r = Reader(bytes); + r.take(16)?; + let _shader = r.text()?; + let family = r.u32()?; + let flags = r.u32()?; + let mut bindings = BTreeMap::new(); + for _ in 0..r.u32()? { + bindings.insert( + r.text()?, + Binding { + texture: r.u32()?, + uv: r.u32()?, + u: r.u32()?, + v: r.u32()?, + }, + ); + } + let mut parameters = BTreeMap::new(); + for _ in 0..r.u32()? { + let name = r.text()?; + let values = (0..r.u32()?) + .map(|_| r.text()) + .collect::>>()?; + // Names/GUIDs do not affect shading or batching. + if !matches!(name.as_str(), "Name" | "AttribulatorMaterialName") { + parameters.insert(name, values); + } + } + r.text()?; + if !r.0.is_empty() { + return None; + } + Some(Self { + family, + flags, + bindings, + parameters, + }) + } + pub fn scalar(&self, name: &str) -> Option { + self.parameters + .get(name)? + .first()? + .parse::() + .ok() + .filter(|v| v.is_finite()) + } + pub fn supported(&self) -> bool { + (1..=12).contains(&self.family) || self.family == 13 + } + pub fn build( + &self, + m: &skate_data::skate_map::Material, + texture: &mut impl FnMut(u32, u8) -> Option>, + ) -> RetailWorldMaterial { + let mut fetch = |role: &str, fallback: u32, clamp: bool| { + let b = self.bindings.get(role); + texture( + b.map_or(fallback, |b| b.texture), + if clamp || b.is_some_and(|b| b.u == 1 && b.v == 1) { + 4 + } else { + 3 + }, + ) + }; + let diffuse = fetch("diffuse", m.textures[0], false); + let lightmap = fetch("lightmap", m.textures[1], true); + let normal = fetch("normal", m.textures[2], false); + let detail = fetch("detail", 0, false); + let macro_map = fetch("macrooverlay", 0, false); + let decal = fetch("decal", 0, self.family == 3); + let specular = fetch("specular", 0, false); + let environment = texture(self.bindings.get("environment").map_or(0, |b| b.texture), 5); + let macro_scale = self.scalar("macroOverlayUVScale").unwrap_or(0.); + let macro_opacity = self.scalar("macroOverlayOpacity").unwrap_or(0.); + let detail_scale = self.scalar("detailNormalUVScale").unwrap_or(0.); + let flags = u32::from(normal.is_some()) + | (u32::from(detail.is_some() && detail_scale > 0.) << 1) + | (u32::from(macro_map.is_some() && macro_scale > 0. && macro_opacity > 0.) << 2) + | (u32::from(decal.is_some()) << 3) + | (u32::from(specular.is_some()) << 4) + | (u32::from(lightmap.is_some()) << 5) + | (u32::from(environment.is_some()) << 6) + | (u32::from(detail.is_some()) << 7); + // scene.hlsl's retail world ALPHAREF is 30, not the portable 0.5. + let cutoff = 30. / 255.; + let alpha = match m.alpha_mode { + 1 => AlphaMode::Mask(cutoff), + 2 => AlphaMode::Blend, + _ => AlphaMode::Opaque, + }; + RetailWorldMaterial { + params: WorldParams { + mode: Vec4::new( + self.family as f32, + flags as f32, + if m.alpha_mode == 1 { cutoff } else { -1. }, + 2.5, + ), + surface: Vec4::new(macro_scale, macro_opacity, detail_scale, 1.), + family: Vec4::new(0.3435, 0.02, 1., 0.45), + fog_ramp: Vec4::new(0., 0., 1., 0.), + fog_color: Vec4::ZERO, + shadow_color: Vec4::ZERO, + }, + diffuse, + lightmap, + normal, + detail, + macro_map, + decal, + specular, + environment, + alpha, + two_sided: self.flags & 4 != 0, + } + } +} + +/// Native renderer box-filters decoded UNORM cube faces down to 1x1. +/// The same chain prevents aliasing of repeating world maps at distance. +pub(crate) fn mip_chain(rgba: &[u8], width: u32, height: u32, layers: u32) -> (Vec, u32) { + let count = 32 - width.max(height).leading_zeros(); + let mut bytes = Vec::with_capacity(rgba.len() * 4 / 3 + 64); + for face in rgba + .chunks_exact((width * height * 4) as usize) + .take(layers as usize) + { + let mut level = face.to_vec(); + let (mut w, mut h) = (width as usize, height as usize); + bytes.extend_from_slice(&level); + while w > 1 || h > 1 { + let (nw, nh) = ((w / 2).max(1), (h / 2).max(1)); + let mut next = vec![0; nw * nh * 4]; + for y in 0..nh { + for x in 0..nw { + for c in 0..4 { + let mut sum = 0u32; + for dy in 0..2 { + for dx in 0..2 { + sum += level[((y * 2 + dy).min(h - 1) * w + + (x * 2 + dx).min(w - 1)) + * 4 + + c] as u32; + } + } + next[(y * nw + x) * 4 + c] = ((sum + 2) / 4) as u8; + } + } + } + bytes.extend_from_slice(&next); + level = next; + w = nw; + h = nh; + } + } + (bytes, count) +} diff --git a/crates/skate-game/src/retail_sky.wgsl b/crates/skate-game/src/retail_sky.wgsl new file mode 100644 index 00000000..a9631d95 --- /dev/null +++ b/crates/skate-game/src/retail_sky.wgsl @@ -0,0 +1,20 @@ +#import bevy_pbr::{forward_io::Vertex, mesh_view_bindings::view} +@group(#{MATERIAL_BIND_GROUP}) @binding(0) var settings: vec4; +@group(#{MATERIAL_BIND_GROUP}) @binding(1) var panorama: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(2) var panorama_sampler: sampler; +struct SkyOut { @builtin(position) position: vec4, @location(0) uv: vec2 } +@vertex +fn vertex(v: Vertex) -> SkyOut { + var out: SkyOut; + let world = v.position + vec3(view.world_position.x,settings.x,view.world_position.z); + out.position = view.clip_from_world*vec4(world,1.0); + // Reverse-Z far plane: the authored dome is larger than the world camera's far distance. + out.position.z = 0.0000001*out.position.w; + out.uv = v.uv; + return out; +} +@fragment +fn fragment(i: SkyOut) -> @location(0) vec4 { + let d = textureSample(panorama,panorama_sampler,i.uv).rgb; + return vec4(d*d*settings.y*settings.z,1.0); +} diff --git a/crates/skate-game/src/retail_tone.wgsl b/crates/skate-game/src/retail_tone.wgsl new file mode 100644 index 00000000..626c635e --- /dev/null +++ b/crates/skate-game/src/retail_tone.wgsl @@ -0,0 +1,20 @@ +#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput +@group(0) @binding(0) var source: texture_2d; +@group(0) @binding(1) var source_sampler: sampler; +@group(0) @binding(2) var enabled: vec4; + +@fragment +fn fragment(i: FullscreenVertexOutput) -> @location(0) vec4 { + let c = textureSample(source,source_sampler,i.uv); + let xe = max(c.rgb,vec3(0.0)); + let t = saturate(1.0-xe); + let tm = max(xe*0.25+0.75,vec3(1.0))-t*t; + let gamma = saturate(sqrt(max(tm*0.5,vec3(0.0)))*1.41); + // Bevy's final output attachment performs sRGB encoding. The retail + // curve already includes gamma; invert sRGB here to avoid encoding twice. + let linear = select(gamma/12.92,pow((gamma+0.055)/1.055,vec3(2.4)),gamma>vec3(0.04045)); + // The reference's final tone pass is opaque. Material coverage has + // already been resolved; carrying it into the presentation blit would + // composite foliage a second time against the window's clear colour. + return vec4(linear,1.0); +} diff --git a/crates/skate-game/src/retail_world.wgsl b/crates/skate-game/src/retail_world.wgsl new file mode 100644 index 00000000..e0cb4dc0 --- /dev/null +++ b/crates/skate-game/src/retail_world.wgsl @@ -0,0 +1,136 @@ +#import bevy_pbr::{forward_io::VertexOutput, mesh_view_bindings as frame} + +struct WorldParams { + mode: vec4, surface: vec4, family: vec4, + fog_ramp: vec4, fog_color: vec4, shadow_color: vec4, +} +@group(#{MATERIAL_BIND_GROUP}) @binding(0) var p: WorldParams; +@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(3) var lightmap: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(4) var lm_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(5) var normal_map: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(6) var normal_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(7) var detail_map: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(8) var detail_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(9) var macro_map: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(10) var macro_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(11) var decal_map: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(12) var decal_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(13) var specular_map: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(14) var specular_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(15) var environment_map: texture_cube; + +@fragment +fn fragment(i: VertexOutput) -> @location(0) vec4 { + let fam = u32(p.mode.x); + let flags = u32(p.mode.y); + let a = textureSample(diffuse, diffuse_sampler, i.uv); + let lm = textureSampleLevel(lightmap, lm_sampler, i.uv_b, 0.0).rgb; + // Sample before alpha rejection: implicit derivatives must be uniform. + var nm = vec3(0.5,0.5,1.0); + var detail = vec2(0.5); + var overlay_sample = vec3(0.5); + var art = vec4(0.0); + var masks = vec3(0.0); + if (flags & 1u) != 0u && (fam <= 6u || fam == 13u) { nm = textureSample(normal_map,normal_sampler,i.uv).rgb; } + if (flags & 2u) != 0u && fam != 2u { + detail = textureSample(detail_map,detail_sampler,i.uv*p.surface.z).rg; + } + if fam == 5u || fam == 6u || fam == 13u { + // The reference folds the reflective material's constant detail texel. + if (flags & 128u) != 0u { detail = textureLoad(detail_map,vec2(0),0).rg; } + } + if (flags & 4u) != 0u { overlay_sample = textureSample(macro_map,macro_sampler,i.uv*p.surface.x).rgb; } + if (flags & 8u) != 0u && (fam == 3u || fam == 4u) { art = textureSample(decal_map,decal_sampler,i.color.xy); } + if (flags & 16u) != 0u { masks = textureSample(specular_map,specular_sampler,i.uv).rgb; } + var wn = normalize(i.world_normal); + let dp1 = dpdx(i.world_position.xyz); + let dp2 = dpdy(i.world_position.xyz); + // Exporter flips V and texture rows; recover original UV derivatives. + let du1 = dpdx(i.uv * vec2(1.0,-1.0)); + let du2 = dpdy(i.uv * vec2(1.0,-1.0)); + let dp2p = cross(dp2,wn); + let dp1p = cross(wn,dp1); + var kt = dp2p * du1.x + dp1p * du2.x; + var kb = dp2p * du1.y + dp1p * du2.y; + kt *= -inverseSqrt(max(dot(kt,kt),1e-12)); + kb *= inverseSqrt(max(dot(kb,kb),1e-12)); +#ifdef VERTEX_TANGENTS + if dot(i.world_tangent.xyz,i.world_tangent.xyz) > 0.01 { + kt = normalize(i.world_tangent.xyz); + kb = normalize(cross(wn,kt)) * i.world_tangent.w; + } +#endif + let rpos = i.world_position.xyz - frame::view.world_position; + let vd = -normalize(rpos); + // Static reference axis for the tangent-space sign terms. This is not + // an additive light or a shadow source. Native frame direction remains + // an extraction gap; preserve the adapter's prior direction for now. + let sun = normalize(vec3(4.0,7.0,4.0)); + var d = a.rgb*a.rgb; + var alpha = 1.0; + var lin = vec3(0.0); + if fam == 9u || fam == 10u { + lin = d * max(lm*lm,vec3(p.family.y)) * p.family.x; + if fam == 9u { lin *= p.family.z; } + alpha = a.a; + } else if fam == 11u || fam == 12u { + lin = d; + if fam == 11u { lin *= p.family.w; } + } else { + if (fam == 3u || fam == 4u) && (flags & 8u) != 0u { d = mix(d,art.rgb*art.rgb,art.a); } + if (flags & 4u) != 0u && fam < 13u { d *= saturate((overlay_sample-0.5)*p.surface.y+0.5); } + var kd = 0.93429; + if (fam <= 6u || fam == 13u) && (flags & 1u) != 0u { + var dxy = vec2(0.5); + if (flags & 2u) != 0u && fam != 2u { dxy = detail; } + if (fam == 5u || fam == 6u || fam == 13u) && (flags & 128u) != 0u { dxy = detail; } + let raw = vec3(nm.xy*2.0+dxy*2.0-2.0,nm.z*2.0-1.0); + var vnd = raw; + if fam != 2u && fam != 6u { vnd = raw * inverseSqrt(max(dot(raw,raw),1e-12)); } + var tt = kt; + var bb = kb; + if fam == 5u || fam == 6u || fam == 13u { + let up = vec3(0.0,1.0,0.0)-wn*wn.y; + if length(up) > 0.05 { + let b2 = normalize(up); + let t2 = cross(b2,wn); + tt = t2*select(-1.0,1.0,dot(t2,kt)>=0.0); + bb = b2*select(-1.0,1.0,dot(b2,kb)>=0.0); + } + } + wn = normalize(raw.x*tt+raw.y*bb+wn*max(raw.z,0.05)); + kd = (vnd.x*0.58*sign(dot(kt,sun))+vnd.y*0.62*sign(dot(kb,sun))+vnd.z*0.39)*2.39562; + } + var lml = lm*lm; + lin = lml*kd*d; + if fam == 13u { lin = lml*d*a.a; } + if (flags & 16u) != 0u { + let lit = vec3(-0.14,0.5,0.9); + let reflected = lit-2.0*wn*dot(wn,lit); + let bp = saturate(dot(vd,-reflected)); + let ks = pow(max(bp,1e-6),10.0+290.0*masks.y); + lin += ks*vec3(2.1,1.8,1.5)*lml.g*masks.x; + } + if (fam == 5u || fam == 6u || fam == 13u) && (flags & 64u) != 0u { + let rv = vd-2.0*wn*dot(vd,wn); + let dir = vec3(-rv.x,-rv.y,rv.z); + let cube = textureSampleBias(environment_map,diffuse_sampler,dir,log2(frame::view.viewport.w/640.0)).rgb; + let rl = 0.3*saturate(4.0*masks.z-2.6); + let lum = lml.g+rl*(1.0-lml.g); + lin += cube*lum*masks.z*1.5; + } + if fam >= 7u { alpha = a.a; } + if fam == 13u { alpha *= alpha; } + } + var f = saturate(length(rpos)*p.fog_ramp.x+p.fog_ramp.y); + if p.fog_ramp.z != 1.0 { f = pow(max(f,1e-6),p.fog_ramp.z); } + var fog_a = 1.0+p.fog_color.a*f; + if fam <= 8u || fam == 13u { fog_a *= p.surface.w; } + var xe = max((lin*fog_a+p.fog_color.rgb*f)*p.mode.w,vec3(0.0)); + // Reduced curve is the full curve with the linear input capped at one. + if fam == 8u { xe = min(xe,vec3(1.0)); } + if a.a < p.mode.z { discard; } + return vec4(xe,alpha); +} diff --git a/crates/skate-game/src/skate_world.rs b/crates/skate-game/src/skate_world.rs index 96a32a9e..ee1903d2 100644 --- a/crates/skate-game/src/skate_world.rs +++ b/crates/skate-game/src/skate_world.rs @@ -64,7 +64,7 @@ pub(crate) fn validate_runtime(map: &SkateMap) -> Result<(), String> { } if map.materials.iter().any(|m| m.retail_definition.is_some()) { eprintln!( - "SKATE LIMITATION: retail shader definitions retained; rendering uses the package's portable PBR material fields." + "SKATE_RENDER: retail world material adapter enabled; unsupported families retain portable PBR rendering." ); } if map.textures.iter().any(|t| t.width == 0) { @@ -85,7 +85,7 @@ pub(crate) fn validate_runtime(map: &SkateMap) -> Result<(), String> { ); } eprintln!( - "SKATE LIMITATION: using existing game directional/ambient lighting with map horizon color; authored sky/day-night controller is not yet connected." + "SKATE LIMITATION: native frame-lighting/day-night controller is not connected; using package lighting and retail district sky where available." ); eprintln!( "SKATE_MAP_LOADED name={:?} version={} render_triangles={} collision_triangles={} textures={} spawn={:?}", @@ -395,7 +395,9 @@ fn material_ids( m.alpha_mode, m.alpha_cutoff.to_bits(), ]; - *unique.entry(key).or_insert(i) + let retail = m.retail_definition.as_deref().and_then(crate::retail_render::Definition::parse) + .filter(|d| d.supported()); + *unique.entry((key, retail)).or_insert(i) }) .collect() } @@ -423,6 +425,7 @@ pub(crate) fn spawn( commands: &mut Commands, meshes: &mut Assets, materials: &mut Assets, + retail_materials: &mut Assets, images: &mut Assets, ) { // Texture roles have different transfer functions even when sharing a record. @@ -433,6 +436,10 @@ pub(crate) fn spawn( if id == 0 { return None; } + if role == 5 && map.textures[id as usize - 1].height != map.textures[id as usize - 1].width * 6 { + // Older .skate exports contain only face zero. Never treat it as a cube. + return None; + } Some( cache .entry((id, role)) @@ -460,19 +467,32 @@ pub(crate) fn spawn( source.rgba.clone(), ) }; + let cube = role == 5; + let height = if cube { source.height / 6 } else { source.height }; + let layers = if cube { 6 } else { 1 }; let mut image = Image::new( Extent3d { width: source.width, - height: source.height, - depth_or_array_layers: 1, + height, + depth_or_array_layers: layers, }, TextureDimension::D2, bytes, format, RenderAssetUsages::RENDER_WORLD, ); + if role == 3 || cube { + let (bytes, levels) = crate::retail_render::mip_chain(&source.rgba, source.width, height, layers); + image.data = Some(bytes); + image.texture_descriptor.mip_level_count = levels; + } + if cube { + image.texture_view_descriptor = Some(bevy::render::render_resource::TextureViewDescriptor { + dimension: Some(bevy::render::render_resource::TextureViewDimension::Cube), ..default() + }); + } let mut sampler = bevy::image::ImageSamplerDescriptor::linear(); - if role != 1 { + if role != 1 && role != 4 && !cube { sampler.address_mode_u = bevy::image::ImageAddressMode::Repeat; sampler.address_mode_v = bevy::image::ImageAddressMode::Repeat; } @@ -529,6 +549,10 @@ pub(crate) fn spawn( Mesh::ATTRIBUTE_UV_1, vertices.iter().map(|v| v.lightmap_uv).collect::>(), ) + .with_inserted_attribute( + Mesh::ATTRIBUTE_COLOR, + vertices.iter().map(|v| { let uv = v.decal_uv.unwrap_or(v.uv); [uv[0], uv[1], 0., 1.] }).collect::>(), + ) .with_inserted_indices(bevy::mesh::Indices::U32(local)); if vertices.iter().all(|v| v.tangent_frame.is_some()) { let tangents: Vec<[f32; 4]> = vertices @@ -549,6 +573,15 @@ pub(crate) fn spawn( warn!("SKATE material {} tangent generation: {error}", m.name); } } + if let Some(definition) = m.retail_definition.as_deref() + .and_then(crate::retail_render::Definition::parse).filter(|d| d.supported()) { + let material = definition.build(m, &mut texture); + let material = retail_materials.add(material); + commands.spawn((Name::new(m.name.clone()), Mesh3d(meshes.add(mesh)), MeshMaterial3d(material), Transform::default())); + continue; + } + // Vertex colours above carry retail decal coordinates, never PBR tint. + mesh.remove_attribute(Mesh::ATTRIBUTE_COLOR); let material = material_handles[pbr_ids[material_index]] .get_or_insert_with(|| { let orm = texture(m.textures[3], 2); @@ -630,14 +663,6 @@ pub(crate) fn spawn( map.environment[4], map.environment[5], ))); - commands.spawn(( - DirectionalLight { - illuminance: 11000., - shadows_enabled: true, - ..default() - }, - Transform::from_xyz(4., 7., 4.).looking_at(Vec3::ZERO, Vec3::Y), - )); info!( "SKATE_WORLD_READY name={:?} render_triangles={} collision_triangles={}", map.name, diff --git a/crates/skate-game/src/world.rs b/crates/skate-game/src/world.rs index 2a3b6d67..a8debf70 100644 --- a/crates/skate-game/src/world.rs +++ b/crates/skate-game/src/world.rs @@ -23,6 +23,8 @@ fn spawn( manifest: Res, mut meshes: ResMut>, mut materials: ResMut>, + mut retail_materials: ResMut>, + mut sky_materials: ResMut>, mut images: ResMut>, mut config: ResMut, ) { @@ -36,7 +38,8 @@ fn spawn( if let Some(map) = config.map.take() { // Physics has already consumed the package. Render assets own their // uploaded data; keeping another full city package wastes gigabytes. - crate::skate_world::spawn(&map, &mut commands, &mut meshes, &mut materials, &mut images); + crate::skate_world::spawn(&map, &mut commands, &mut meshes, &mut materials, &mut retail_materials, &mut images); + crate::retail_render::spawn_sky(&map.name, &config.asset_root, &mut commands, &mut meshes, &mut images, &mut sky_materials); return; } let colors = [ diff --git a/docs/retail-renderer.md b/docs/retail-renderer.md new file mode 100644 index 00000000..0f24d5dd --- /dev/null +++ b/docs/retail-renderer.md @@ -0,0 +1,50 @@ +# Retail renderer + +The world material adapter follows Alex's native renderer at +[`f6e0ae8`](https://github.com/mchughalex/skate3recomp/tree/f6e0ae87fdfecbadb5c1e36c55d66a744187a3cd/src/native/shaders). +The reference files are `scene.hlsl`, `scene_common.hlsli`, +`scene_frame_cb.hlsli`, and `hdr.hlsl`. This is a Bevy implementation of +those shading equations, not a port of the game hooks or the whole renderer. + +Implemented: + +- World families 1–13: diffuse/lightmap squaring, the family lighting terms, + decal art, macro overlay, normal/detail lighting and specular masks. +- Reflective-family cube sampling with all six faces and a complete mip chain. +- The shared HDR tone curve, including environmentdiffuse's reduced curve. + The final output accounts for Bevy's sRGB conversion instead of applying + gamma twice. The HUD is drawn after this pass. +- Alpha-tested world materials use the reference's 30/255 threshold in both + colour and depth passes. Foliage is two-sided. +- Lightmaps remain bilinear, clamped and fixed at mip zero. Repeating world + textures have mipmaps. +- The three major districts use their actual sky geometry and diffuse texture + from `miscload.big`. Texture selection follows material GUIDs through the + RX2 reference table. Sky X/Z follow the camera; Y uses the reference's + documented 165-metre default. + +The converter reads scalar material channels at record offset 0x10. +Earlier exports incorrectly read their string pointer and saved empty strings. +Reconvert maps to obtain those constants and complete reflection cubes. +Old packages still load; missing constants disable the relevant layer and +single-face environment textures are not treated as cubes. + +## Remaining differences + +This is not a claim of pixel parity. Native frame constants are supplied by +game hooks in the reference; this engine does not yet recover that controller. +Exposure 2.5, material multiplier 1, and tree/proxy values use documented +day-capture defaults. Imported maps have no dynamic directional sun or realtime +sun shadows: their world lighting comes from the baked lightmaps. Normal-map +sign terms retain the adapter's fixed direction until native frame data is +available; this adds no light energy or shadow map. The fog inputs +are wired into the shader but remain disabled without authored frame rows. + +Sky sun-gradient shading, park sky selection, native water, character shaders, +SSAO, SSR, bloom and volumetric lighting are not yet ported. The character and +unsupported world families still use Bevy materials beneath the shared tone +curve. Reflection normals use the reference's analytic world-up construction; +there has been no matched-camera pixel comparison against the recomp. + +The shaders are embedded in the executable. Sky and map payloads are extracted +from the user's game and must never be committed or bundled with releases. diff --git a/tools/asset_pipeline/install.py b/tools/asset_pipeline/install.py index d95e3860..1a4c9319 100644 --- a/tools/asset_pipeline/install.py +++ b/tools/asset_pipeline/install.py @@ -192,6 +192,9 @@ def install(iso,base,game_exe,report,game_root=None): 'action_graph':'private/stock/data/state/ActionGraph_OnBoard.stategraph', 'motion_graph':'private/stock/data/state/MotionGraph_OnBoard.stategraph'} (private/'game.json').write_text(json.dumps(game_manifest),encoding='utf-8') + report('Preparing retail sky domes') + from .sky import convert as write_skies + write_skies(game_root,stage/'assets') report('Validating skater, input and animation data') run([game_exe,'--assets',stage/'assets','--test-world','--check-assets'],log,report) archives=list((game_root/'data/content').glob('worldDIST_*.big')) diff --git a/tools/asset_pipeline/map_writer.py b/tools/asset_pipeline/map_writer.py index fa92c3ee..a17f776c 100644 --- a/tools/asset_pipeline/map_writer.py +++ b/tools/asset_pipeline/map_writer.py @@ -129,9 +129,11 @@ def write(manifest_path,output,collision,report=lambda _:None): entry=textures[name] if 'rgba' in entry: width,height=entry['width'],entry['height'] - rgba=np.frombuffer((root/entry['rgba']).read_bytes(),dtype=np.uint8).reshape(height,width,4)[::-1].tobytes() + pixels=np.frombuffer((root/entry['rgba']).read_bytes(),dtype=np.uint8).reshape(height,width,4) + rgba=(pixels if entry.get('cube_faces')==6 else pixels[::-1]).tobytes() else: - image=Image.open(root/entry['png']).convert('RGBA').transpose(Image.Transpose.FLIP_TOP_BOTTOM) + image=Image.open(root/entry['png']).convert('RGBA') + if entry.get('cube_faces')!=6:image=image.transpose(Image.Transpose.FLIP_TOP_BOTTOM) width,height=image.size;rgba=image.tobytes() string(f,name);u(f,width,height,1);stored(f,rgba) stored(f,vertices.getvalue());stored(f,indices.getvalue());stored(f,b'') diff --git a/tools/asset_pipeline/sky.py b/tools/asset_pipeline/sky.py new file mode 100644 index 00000000..3740f277 --- /dev/null +++ b/tools/asset_pipeline/sky.py @@ -0,0 +1,43 @@ +"""Extract retail sky geometry and textures through RX2 GUID bindings.""" +import json,struct,sys +from pathlib import Path +from tools.owned_game.big import BigArchive + +def convert(game_root, assets): + sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'vendor/utt')) + import mdl_parser,rx2_parser + archive=BigArchive(game_root/'data/big/miscload.big') + entries={e.path:e for e in archive.entries} + output=assets/'private/native-skies';output.mkdir(parents=True,exist_ok=True) + for name,suffix in [('University',''),('DownTown','_downtown'),('Industrial','_industrial')]: + prefix='data/content/world/models/DIST_skybox'+suffix + raw=archive.read(entries[prefix+'.rx2']) + model=mdl_parser.parse_rx2(raw) + table=rx2_parser.RX2File(raw);table.parse() + diffuse=None + for section in table.entries: + if section.type_id!=0x00eb0005:continue + o=section.f0;h=struct.unpack_from('>8I',raw,o) + if (h[4]-h[3])//h[1]!=32:raise ValueError('Unexpected sky material layout') + for i in range(h[1]): + v=struct.unpack_from('>8I',raw,o+h[3]+i*32) + at=o+v[0];kind=raw[at:raw.index(b'\0',at)] + if kind==b'diffuse':diffuse=(v[4]<<32)|v[5] + textures=rx2_parser.parse_rx2(archive.read(entries[prefix+'_Textures.rx2'])) + handle=None + for section in textures.entries: + if section.type_id!=0x00eb000b:continue + o=section.f0;n,start=struct.unpack_from('>2I',textures.data,o) + for i in range(n): + _,_,hi,lo,kind,index=struct.unpack_from('>6I',textures.data,o+start+i*24) + if (hi<<32)|lo==diffuse:handle=index + texture=next((t for t in textures.textures if t.index==handle),None) + if texture is None:raise ValueError('Sky diffuse GUID did not resolve: '+name) + if len(model.meshes)!=1 or model.meshes[0].uvs is None:raise ValueError('Unexpected sky mesh layout') + mesh=model.meshes[0] + (output/(name+'.rgba')).write_bytes(texture.rgba) + (output/(name+'.json')).write_text(json.dumps({ + 'width':texture.width,'height':texture.height, + 'positions':mesh.vertices.tolist(),'uvs':mesh.uvs.tolist(), + 'indices':mesh.faces.reshape(-1).tolist(), + },separators=(',',':')),encoding='utf-8') diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/prepare_hawaiian_dream.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/prepare_hawaiian_dream.py index 4abf477b..bc820902 100644 --- a/tools/vendor/university/tools/vanilla_map_extraction/tools/prepare_hawaiian_dream.py +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/prepare_hawaiian_dream.py @@ -490,11 +490,11 @@ def prepare( texture.data_offset : texture.data_offset + texture.buffer_size ] - texture.rgba = decode_b5g6r5( - raw_texture, - texture.width, - texture.height, - ) + face_height = texture.height // texture.cube_faces + texture.rgba = b''.join(decode_b5g6r5( + raw_texture[face*texture.face_stride:] if texture.cube_faces == 6 else raw_texture, + texture.width, face_height, + ) for face in range(texture.cube_faces)) texture_decoder = B5G6R5_DECODER_NAME texture_id = ( base_texture_id @@ -518,6 +518,7 @@ def prepare( "texture_index": texture_index, "width": texture.width, "height": texture.height, + "cube_faces": texture.cube_faces, "format": texture.fmt_name, "warnings": list(parsed.warnings), } diff --git a/tools/vendor/utt/mdl_parser/parser.py b/tools/vendor/utt/mdl_parser/parser.py index a3763a70..989c01ab 100644 --- a/tools/vendor/utt/mdl_parser/parser.py +++ b/tools/vendor/utt/mdl_parser/parser.py @@ -393,7 +393,16 @@ def _parse_material_table( cursor += block_size kind = _read_cstring(data, offset + type_offset) - value = _read_cstring(data, offset + value_offset) + # RX2 32-byte constant channels store their float in the first GUID + # word (+0x10), not at the string pointer (+0x18). Native scene capture + # uses the same field for these named scalar channels. + if block_size == 32 and kind in { + "detailNormalUVScale", "macroOverlayUVScale", "macroOverlayOpacity", + "uAnimationSpeed", "vAnimationSpeed", + }: + value = repr(struct.unpack_from(">f", data, cursor - block_size + 0x10)[0]) + else: + value = _read_cstring(data, offset + value_offset) parameters.append(MaterialParameter(kind=kind, value=value)) if kind == "Name": mesh_names.append(value) diff --git a/tools/vendor/utt/rx2_parser.py b/tools/vendor/utt/rx2_parser.py index 96315d5b..651c99a6 100644 --- a/tools/vendor/utt/rx2_parser.py +++ b/tools/vendor/utt/rx2_parser.py @@ -130,7 +130,7 @@ class Texture(object): """A decoded texture: width x height RGBA8888 pixels in `rgba`.""" __slots__ = ("index", "width", "height", "fmt_id", "rgba", - "data_offset", "buffer_size") + "data_offset", "buffer_size", "cube_faces", "face_stride") def __init__(self, index, width, height, fmt_id, rgba, data_offset, buffer_size): self.index = index @@ -140,6 +140,8 @@ class Texture(object): self.rgba = rgba self.data_offset = data_offset self.buffer_size = buffer_size + self.cube_faces = 1 + self.face_stride = 0 @property def name(self): @@ -263,7 +265,27 @@ class RX2File(object): except Exception as exc: self.warnings.append("texture entry %d: decode failed: %s" % (entry.index, exc)) return None - return Texture(entry.index, width, height, fmt, rgba, base, size) + # Xbox fetch word 5, bits 9..10: kCube=3. A base mip contains + # six consecutive tiled face slices. Keep their native face/row order + # in a vertical strip; the map writer marks this as a cube source. + cube = hdr_off + 52 <= len(data) and (int.from_bytes(data[hdr_off+48:hdr_off+52], 'big') >> 9) & 3 == 3 + stride = 0 + if cube: + if width != height or fmt not in (FMT_DXT1, FMT_A8R8G8B8, FMT_B5G6R5): + raise RX2ParseError('Unsupported cube texture shape/format: %dx%d fmt=%x fetch=%s' % (width,height,fmt,data[hdr_off+28:hdr_off+52].hex())) + block = 4 if fmt == FMT_DXT1 else 1 + pitch = 8 if fmt == FMT_DXT1 else 2 if fmt == FMT_B5G6R5 else 4 + cols = ((width + block - 1)//block + 31) & ~31 + rows = ((height + block - 1)//block + 31) & ~31 + stride = (cols * rows * pitch + 4095) & ~4095 + if len(raw) < stride * 6: + raise RX2ParseError('Truncated cube base faces') + rgba = b''.join(_decode_texture_data(raw[face*stride:(face+1)*stride], width, height, fmt, dxt5_variant) for face in range(6)) + height *= 6 + result = Texture(entry.index, width, height, fmt, rgba, base, size) + result.cube_faces = 6 if cube else 1 + result.face_stride = stride + return result @staticmethod def _genrx2_geometry(hdr):