From 21844a3a377ed0468974e2bf696f7b1164595a8f Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 15:25:59 +0100 Subject: [PATCH] Resolve authored world skies, sun gradients and location directions --- crates/skate-game/src/retail_depth.wgsl | 2 +- crates/skate-game/src/retail_render.rs | 122 +------------ crates/skate-game/src/retail_sky.rs | 209 +++++++++++++++++++++++ crates/skate-game/src/retail_sky.wgsl | 28 ++- crates/skate-game/src/retail_world.wgsl | 9 +- crates/skate-game/src/world.rs | 2 +- docs/retail-renderer.md | 89 ++++++++-- tools/asset_pipeline/environment.py | 83 +++++++++ tools/asset_pipeline/install.py | 2 +- tools/asset_pipeline/sky.py | 146 +++++++++++----- tools/asset_pipeline/test_environment.py | 61 +++++++ 11 files changed, 573 insertions(+), 180 deletions(-) create mode 100644 crates/skate-game/src/retail_sky.rs create mode 100644 tools/asset_pipeline/environment.py create mode 100644 tools/asset_pipeline/test_environment.py diff --git a/crates/skate-game/src/retail_depth.wgsl b/crates/skate-game/src/retail_depth.wgsl index ba943a42..4730bf54 100644 --- a/crates/skate-game/src/retail_depth.wgsl +++ b/crates/skate-game/src/retail_depth.wgsl @@ -5,7 +5,7 @@ #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 } +struct WorldParams { mode: vec4, surface: vec4, family: vec4, fog_ramp: vec4, fog_color: vec4, shadow_color: vec4, sun_direction: 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; diff --git a/crates/skate-game/src/retail_render.rs b/crates/skate-game/src/retail_render.rs index ed162da5..cc2cb68d 100644 --- a/crates/skate-game/src/retail_render.rs +++ b/crates/skate-game/src/retail_render.rs @@ -33,123 +33,9 @@ impl Plugin for RetailRenderPlugin { #[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, - )); -} +#[path = "retail_sky.rs"] +mod sky; +pub(crate) use sky::{spawn_sky, RetailSkyMaterial}; #[derive(Component, ExtractComponent, Clone, Copy, ShaderType, Default)] pub(crate) struct RetailTone { @@ -179,6 +65,7 @@ pub(crate) struct WorldParams { pub fog_ramp: Vec4, pub fog_color: Vec4, pub shadow_color: Vec4, + pub sun_direction: Vec4, } #[derive(Asset, TypePath, AsBindGroup, Debug, Clone)] @@ -385,6 +272,7 @@ impl Definition { fog_ramp: Vec4::new(0., 0., 1., 0.), fog_color: Vec4::ZERO, shadow_color: Vec4::ZERO, + sun_direction: Vec3::new(4., 7., 4.).normalize().extend(0.), }, diffuse, lightmap, diff --git a/crates/skate-game/src/retail_sky.rs b/crates/skate-game/src/retail_sky.rs new file mode 100644 index 00000000..61671d5e --- /dev/null +++ b/crates/skate-game/src/retail_sky.rs @@ -0,0 +1,209 @@ +//! Authored camera-relative sky and its radial sun gradient. +use super::RetailWorldMaterial; +use bevy::{ + prelude::*, + render::render_resource::{AsBindGroup, ShaderType}, + shader::ShaderRef, +}; + +#[derive(Clone, Debug, ShaderType)] +pub(crate) struct SkyParams { + // anchor height, scene exposure, sky multiplier, sun angular scale (0 disables). + pub settings: Vec4, + pub sun_direction: Vec4, +} + +#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)] +pub(crate) struct RetailSkyMaterial { + #[uniform(0)] + pub params: SkyParams, + #[texture(1)] + #[sampler(2)] + pub diffuse: Handle, + #[texture(3)] + #[sampler(4)] + pub sun: Option>, +} +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(()) + } +} + +#[derive(serde::Deserialize)] +struct Environment { + anchor_height: f32, + sun_direction: [f32; 3], + sun_scale: f32, + multiplier: f32, + location_chain: Vec, +} +#[derive(serde::Deserialize)] +struct Sky { + width: u32, + height: u32, + positions: Vec<[f32; 3]>, + uvs: Vec<[f32; 2]>, + indices: Vec, + #[serde(default)] + sun_width: u32, + #[serde(default)] + sun_height: u32, + environment: Option, +} +fn load(root: &std::path::Path, name: &str) -> Result<(Sky, Vec, Option>), String> { + if name.is_empty() || name.contains(['/', '\\', ':']) || matches!(name, "." | "..") { + return Err("Invalid retail sky name".into()); + } + let base = root.join("private/native-skies"); + let read = |suffix: &str| { + std::fs::read(base.join(format!("{name}{suffix}"))).map_err(|e| e.to_string()) + }; + let sky: Sky = serde_json::from_slice(&read(".json")?).map_err(|e| e.to_string())?; + let rgba = read(".rgba")?; + if sky.width == 0 + || sky.height == 0 + || u64::from(sky.width) * u64::from(sky.height) * 4 != rgba.len() as u64 + || sky.positions.is_empty() + || sky.positions.len() != sky.uvs.len() + || sky.indices.is_empty() + || sky.indices.len() % 3 != 0 + || sky + .indices + .iter() + .any(|&i| i as usize >= sky.positions.len()) + || sky + .positions + .iter() + .flatten() + .chain(sky.uvs.iter().flatten()) + .any(|v| !v.is_finite()) + { + return Err("Invalid retail sky dimensions/geometry".into()); + } + let sun = if let Some(env) = &sky.environment { + let direction = Vec3::from_array(env.sun_direction); + if !env.anchor_height.is_finite() + || !direction.is_finite() + || !(0.99..=1.01).contains(&direction.length_squared()) + || !env.sun_scale.is_finite() + || env.sun_scale <= 0. + || !env.multiplier.is_finite() + || env.multiplier < 0. + || sky.sun_width == 0 + || sky.sun_height != 16 + { + return Err("Invalid authored sky environment".into()); + } + let bytes = read(".sun.rgba")?; + if u64::from(sky.sun_width) * u64::from(sky.sun_height) * 4 != bytes.len() as u64 { + return Err("Invalid sun-gradient payload".into()); + } + Some(bytes) + } else { + None + }; + Ok((sky, rgba, sun)) +} +fn image(rgba: Vec, width: u32, height: u32, repeat: bool) -> Image { + use bevy::render::render_resource::*; + let mut image = Image::new( + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + rgba, + TextureFormat::Rgba8Unorm, + bevy::asset::RenderAssetUsages::RENDER_WORLD, + ); + let mut sampler = bevy::image::ImageSamplerDescriptor::linear(); + if repeat { + sampler.address_mode_u = bevy::image::ImageAddressMode::Repeat; + } + image.sampler = bevy::image::ImageSampler::Descriptor(sampler); + image +} +pub(crate) fn spawn_sky( + name: &str, + root: &std::path::Path, + commands: &mut Commands, + meshes: &mut Assets, + images: &mut Assets, + materials: &mut Assets, + world_materials: &mut Assets, +) { + let (sky, rgba, sun) = match load(root, name) { + Ok(v) => v, + Err(e) => { + warn!("Retail sky unavailable for {name}: {e}"); + return; + } + }; + // Legacy sky packages retain their original parameters. The scene exposure + // remains the adapter's capture default until auto-exposure is recovered. + let mut params = SkyParams { + settings: Vec4::new(165., 2.5, 1., 0.), + sun_direction: Vec4::ZERO, + }; + if let Some(env) = &sky.environment { + params.settings = Vec4::new(env.anchor_height, 2.5, env.multiplier, env.sun_scale); + params.sun_direction = Vec3::from_array(env.sun_direction).extend(0.); + for (_, material) in world_materials.iter_mut() { + // Only the existing tangent-sign term uses this vector. No added + // directional light energy or imported-world shadow map. + material.params.sun_direction = params.sun_direction; + } + info!( + "SKATE_SKY_READY map={name:?} location={:?} anchor={} sun_scale={} multiplier={} authored_direction={:?}", + env.location_chain, env.anchor_height, env.sun_scale, env.multiplier, env.sun_direction + ); + } else { + warn!( + "Retail sky {name} has legacy metadata; reconvert skies for authored parameters and sun" + ); + } + let sun = sun.map(|bytes| images.add(image(bytes, sky.sun_width, sky.sun_height, false))); + 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, + diffuse: images.add(image(rgba, sky.width, sky.height, true)), + sun, + })), + Transform::default(), + bevy::camera::visibility::NoFrustumCulling, + bevy::light::NotShadowCaster, + bevy::light::NotShadowReceiver, + )); +} diff --git a/crates/skate-game/src/retail_sky.wgsl b/crates/skate-game/src/retail_sky.wgsl index a9631d95..2a296ba2 100644 --- a/crates/skate-game/src/retail_sky.wgsl +++ b/crates/skate-game/src/retail_sky.wgsl @@ -1,20 +1,38 @@ #import bevy_pbr::{forward_io::Vertex, mesh_view_bindings::view} -@group(#{MATERIAL_BIND_GROUP}) @binding(0) var settings: vec4; +struct SkyParams { settings: vec4, sun_direction: vec4 } +@group(#{MATERIAL_BIND_GROUP}) @binding(0) var p: SkyParams; @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 } +@group(#{MATERIAL_BIND_GROUP}) @binding(3) var sun_gradient: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(4) var sun_sampler: sampler; +struct SkyOut { + @builtin(position) position: vec4, + @location(0) uv: vec2, + @location(1) dome_position: vec3, +} @vertex fn vertex(v: Vertex) -> SkyOut { var out: SkyOut; - let world = v.position + vec3(view.world_position.x,settings.x,view.world_position.z); + let world = v.position + vec3(view.world_position.x,p.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. + // Reverse-Z far plane: the authored dome is larger than the camera's far distance. out.position.z = 0.0000001*out.position.w; out.uv = v.uv; + out.dome_position = v.position; 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); + var linear = d*d; + if p.settings.w > 0.0 { + // Native sky_defaultPS: sine-angle lookup in the material's specular + // gradient, alpha controls the HDR core. World position minus the sky + // anchor is exactly the local dome position used here. + let dot_pl = saturate(dot(normalize(i.dome_position),p.sun_direction.xyz)); + let sin_angle = sqrt(saturate(1.0-dot_pl*dot_pl)); + let sun = textureSample(sun_gradient,sun_sampler,vec2(sin_angle/p.settings.w,0.5/16.0)); + linear += sun.rgb*sun.rgb/saturate(sun.a+0.01); + } + return vec4(linear*p.settings.y*p.settings.z,1.0); } diff --git a/crates/skate-game/src/retail_world.wgsl b/crates/skate-game/src/retail_world.wgsl index e0cb4dc0..4f0d740f 100644 --- a/crates/skate-game/src/retail_world.wgsl +++ b/crates/skate-game/src/retail_world.wgsl @@ -2,7 +2,7 @@ struct WorldParams { mode: vec4, surface: vec4, family: vec4, - fog_ramp: vec4, fog_color: vec4, shadow_color: vec4, + fog_ramp: vec4, fog_color: vec4, shadow_color: vec4, sun_direction: vec4, } @group(#{MATERIAL_BIND_GROUP}) @binding(0) var p: WorldParams; @group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d; @@ -64,10 +64,9 @@ fn fragment(i: VertexOutput) -> @location(0) vec4 { #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)); + // Authored render-location direction for the tangent-space sign terms. + // This does not add directional light energy or a shadow source. + let sun = p.sun_direction.xyz; var d = a.rgb*a.rgb; var alpha = 1.0; var lin = vec3(0.0); diff --git a/crates/skate-game/src/world.rs b/crates/skate-game/src/world.rs index a8debf70..f14639dd 100644 --- a/crates/skate-game/src/world.rs +++ b/crates/skate-game/src/world.rs @@ -39,7 +39,7 @@ fn spawn( // 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 retail_materials, &mut images); - crate::retail_render::spawn_sky(&map.name, &config.asset_root, &mut commands, &mut meshes, &mut images, &mut sky_materials); + crate::retail_render::spawn_sky(&map.name, &config.asset_root, &mut commands, &mut meshes, &mut images, &mut sky_materials, &mut retail_materials); return; } let colors = [ diff --git a/docs/retail-renderer.md b/docs/retail-renderer.md index 0f24d5dd..cb3c78c6 100644 --- a/docs/retail-renderer.md +++ b/docs/retail-renderer.md @@ -18,10 +18,19 @@ Implemented: 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. +- All ten installed worlds resolve sky geometry/texture paths through their + authored `world` collection inheritance. The parks inherit the default + panorama; Downtown and Industrial override it. Sky X/Z follow the camera; + Y comes from the selected `render_locations` chain, not camera height. +- The sky uses its material's diffuse and specular GUID bindings. The latter + is the 512x16 radial sun gradient: sine-angle lookup, squared RGB divided + by saturated alpha + 0.01, then the material multiplier and scene exposure. + `material_sky/default.m_params[0]` supplies scale 0.75 and multiplier 0.35; + these are decoded inputs, not constants copied from one frame. This follows + `scene.hlsl`'s sky branch and `skate3_native_scene.cpp`'s sky-bank layout. +- The selected render location supplies the normalized sun vector used by + the sky and existing world tangent-sign terms. This adds no directional + light energy or shadow map to imported worlds. The converter reads scalar material channels at record offset 0x10. Earlier exports incorrectly read their string pointer and saved empty strings. @@ -29,19 +38,77 @@ 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. +The VLT converter now adds `array` to array fields, retaining the existing +`type` and `data` unchanged. `array.items` contains complete big-endian element +hex strings without padding; `capacity`, `element_size` and `alignment` +describe the source layout. Text arrays additionally retain decoded +`text_items` before the binary bank is discarded. The layout follows +VaultLib's `VLTArrayType`: four big-endian u16 header lanes followed by +individually aligned elements. Alignment in the schema is a base-two exponent. + +Skies can be refreshed independently of map/character conversion: + +```text +python -m tools.asset_pipeline.sky --game-root OWNED_EXTRACTED_GAME --assets PRIVATE_ASSET_ROOT +``` + +Use a private staging asset directory for experiments. This reads only the +database pair and sky resources; it does not require an ISO or Blender. +Legacy sky metadata retains its previous multiplier and has no sun gradient. + +## Treeline investigation + +The missing forest in the supplied University / Super Ultra Mega Park +comparison is not resolved by this change. The camera-relative +`WorldPresEntityOptimesh` anchor from `sub_82792900` is consistent with the sky +placement above, but the decoded University panorama has mountains and city +scenery, not a dense forest layer. Its RX2 contains one 380-triangle dome. +The separate `DIST_MegaPark` archive contains 102 mesh parts and no tree mesh; +it is not a source for additional trees at University's park. + +The main University export retains 479 model assets, 8,546 mesh parts and 86 +named TreeWall parts. Those named walls are outside the immediate park area; +their absence at that location does not prove geometry is missing. Nearby +proxy geometry demonstrably overlaps the main trees: in cell 350/-750, +451 of 456 proxy long-tree triangles match triangles in the 912-triangle +main tree after quantization to one millimetre, independent of winding. +The five nonmatching triangles are not evidence for an extra forest. +The main tree's diffuse and transparent channels resolve the same DXT5 +texture, with nonconstant alpha, and its baked lightmap is retained. The +renderer already uses the retail cutoff in colour/depth and two-sided foliage. + +No proxy geometry has been added. `skate3_draw_distance.cpp` documents the +ProxyWorld/full-detail exchange; rendering both whole sets is not a valid +fix. Identifying the specific missing silhouette now requires matched-camera +reference evidence connecting it to a submitted mesh/material or a streaming +collection. The two supplied images have different viewpoints. Native mip +selection and regional/streaming state also remain unverified. + ## 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 +Scene exposure 2.5, world material multiplier 1, and tree/proxy values still 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. +sign terms use the initial world's authored direction when new sky metadata +is available; old packages retain the previous direction. Regional changes +and the runtime environment controller are not implemented. Fog selection +and its authored near/far/colour/power/max are retained in sky metadata, but +the CPU transformation to native frame rows is not yet proven, so shader fog +remains disabled. The selected `material_fog/default` must not be confused +with the different `fog_default` collection. -Sky sun-gradient shading, park sky selection, native water, character shaders, -SSAO, SSR, bloom and volumetric lighting are not yet ported. The character and +Native water, character shaders, SSAO, SSR, bloom and volumetric lighting are +not yet ported. The water arrays are recoverable now, but flowing-water +normal/tangent unpacking, ocean PCA input bindings and the separate horizon +water resource still need their own adapters. Character parity needs the +native CAC-composed texture/mask inputs plus verified key/rim/specular and +nine SH rows; portable GLB materials do not retain that contract. Reference +post effects need their depth/normal/reflection inputs and native pass ordering; +volumetric sun visibility also conflicts with the currently disabled world +sun-shadow source. These gaps are not enabled using guessed settings. +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. diff --git a/tools/asset_pipeline/environment.py b/tools/asset_pipeline/environment.py new file mode 100644 index 00000000..e72a2cfa --- /dev/null +++ b/tools/asset_pipeline/environment.py @@ -0,0 +1,83 @@ +"""Resolve authored world/environment references without inventing frame rows.""" +import math +import struct +from .vlt import hash64 + +# Names recovered from schema/debug strings; unnamed fields keep their hashes. +NAMES = ('world', 'render_locations', 'material_sky', 'material_fog', + 'sun_position', 'm_params', 'fog_far', 'fog_near', 'fog_colour', + 'fog_power', 'fog_max') + + +def key_hash(name): + return int(name[5:], 16) if name.startswith('Hash_') else hash64(name) + + +class Collections: + def __init__(self, data): + self.rows = {(key_hash(r['class']), key_hash(r['key'])): r + for r in data['collections']} + + def resolve(self, cls, key): + cls = key_hash(cls) if isinstance(cls, str) else cls + key = key_hash(key) if isinstance(key, str) else key + fields, chain, seen = {}, [], set() + while key: + if key in seen: + raise ValueError('Cyclic environment inheritance') + seen.add(key) + row = self.rows[cls, key] + chain.append(row['key']) + for name, value in row['fields'].items(): + fields.setdefault(key_hash(name), value) + key = key_hash(row['parent']) + return fields, chain + + +def field(fields, name): + return fields[key_hash(name)]['data'] + + +def floats(raw, count): + values = struct.unpack('>' + 'f' * count, bytes.fromhex(raw)) + if not all(math.isfinite(v) for v in values): + raise ValueError('Non-finite environment value') + return list(values) + + +def world_environment(collections, world_key): + world, world_chain = collections.resolve('world', world_key) + location_key = struct.unpack('>Q', bytes.fromhex( + field(world, 'Hash_7E23D10785C43717'))[:8])[0] + location, location_chain = collections.resolve('render_locations', location_key) + sun = floats(field(location, 'sun_position'), 3) + length = math.sqrt(sum(v*v for v in sun)) + if length < 1e-6: + raise ValueError('Invalid authored sun direction') + fog_cls, fog_key, _ = struct.unpack('>3Q', bytes.fromhex(field(location, 'material_fog'))) + fog, fog_chain = collections.resolve(fog_cls, fog_key) + return { + 'world_chain': world_chain, 'location_chain': location_chain, + 'sun_direction': [v/length for v in sun], + 'anchor_height': floats(field(location, 'Hash_2E9AAECD1C29F81F'), 1)[0], + 'sky_model': field(world, 'Hash_C7A0A84F018E87BA'), + 'sky_textures': field(world, 'Hash_2A0BF629355AFB15'), + # These are authored inputs, not shader constants. The controller's + # colour/ramp transformation still needs to be recovered. + 'fog': {'chain': fog_chain, **{n: floats(field(fog, n), 4 if n == 'fog_colour' else 1) + for n in ('fog_near', 'fog_far', 'fog_colour', 'fog_power', 'fog_max')}}, + } + + +def sky_parameters(collections, shader): + effect, key = shader.split('.', 1) + if effect != 'sky': + raise ValueError('Not a sky material: ' + shader) + fields, chain = collections.resolve('material_sky', key) + params = fields[hash64('m_params')].get('array') + if not params or not params['items']: + raise ValueError('Sky parameters require complete VLT array payloads') + row = floats(params['items'][0], 4) + if row[0] <= 0 or row[1] < 0: + raise ValueError('Invalid authored sky parameters') + return {'material_chain': chain, 'sun_scale': row[0], 'multiplier': row[1]} diff --git a/tools/asset_pipeline/install.py b/tools/asset_pipeline/install.py index 1a4c9319..4ad5cd10 100644 --- a/tools/asset_pipeline/install.py +++ b/tools/asset_pipeline/install.py @@ -194,7 +194,7 @@ def install(iso,base,game_exe,report,game_root=None): (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') + write_skies(game_root,stage/'assets',converted) 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/sky.py b/tools/asset_pipeline/sky.py index 3740f277..206cace2 100644 --- a/tools/asset_pipeline/sky.py +++ b/tools/asset_pipeline/sky.py @@ -1,43 +1,111 @@ -"""Extract retail sky geometry and textures through RX2 GUID bindings.""" -import json,struct,sys +"""Extract authored retail skies through world references and RX2 GUID bindings.""" +import json +import struct +import sys from pathlib import Path from tools.owned_game.big import BigArchive +from .environment import Collections, NAMES, field, key_hash, sky_parameters, world_environment -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) + +def _material(raw, table): + channels = {} + for section in table.entries: + if section.type_id != 0x00eb0005: + continue + o = section.f0 + h = struct.unpack_from('>8I', raw, o) + if not h[1] or (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)].decode('ascii') + if kind in ('diffuse', 'specular'): + channels[kind] = (v[4]<<32)|v[5] + return channels + + +def _texture(textures, guid): + 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, _, index = struct.unpack_from('>6I', textures.data, o+start+i*24) + if (hi<<32)|lo == guid: + handle = index + texture = next((t for t in textures.textures if t.index == handle), None) + if texture is None: + raise ValueError(f'Sky texture GUID did not resolve: {guid:016X}') + return texture + + +def convert(game_root, assets, converted): + sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'vendor/utt')) + import mdl_parser + import rx2_parser + collections = Collections(converted) + 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) + available = {p.stem.removeprefix('world') for p in (game_root/'data/content').glob('worldDIST_*.big')} + count = 0 + for (cls, _), row in collections.rows.items(): + if cls != key_hash('world'): + continue + own_stream = next((v['data'] for k, v in row['fields'].items() + if key_hash(k) == key_hash('WorldStream')), None) + if own_stream not in available: + continue + name = own_stream.removeprefix('DIST_') + env = world_environment(collections, row['key']) + raw = archive.read(entries['data/content/'+env['sky_model']+'.rx2']) + model = mdl_parser.parse_rx2(raw) + table = rx2_parser.RX2File(raw) + table.parse() + channels = _material(raw, table) + textures = rx2_parser.parse_rx2(archive.read(entries['data/content/'+env['sky_textures']+'.rx2'])) + diffuse = _texture(textures, channels['diffuse']) + sun = _texture(textures, channels['specular']) + shader = next(p.value for p in model.materials if p.kind == 'AttribulatorMaterialName') + env.update(sky_parameters(collections, shader)) + if len(model.meshes) != 1 or model.meshes[0].uvs is None: + raise ValueError('Unexpected sky mesh layout') + mesh = model.meshes[0] + if len(sun.rgba) != sun.width*sun.height*4 or sun.height != 16: + raise ValueError('Unexpected sky sun-gradient dimensions') + (output/(name+'.rgba')).write_bytes(diffuse.rgba) + (output/(name+'.sun.rgba')).write_bytes(sun.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') + 'width': diffuse.width, 'height': diffuse.height, + 'positions': mesh.vertices.tolist(), 'uvs': mesh.uvs.tolist(), + 'indices': mesh.faces.reshape(-1).tolist(), + 'environment': env, 'sun_width': sun.width, 'sun_height': sun.height, + 'source_sha256': row['sha256'], + }, separators=(',', ':')), encoding='utf-8') + count += 1 + if not count: + raise ValueError('No authored world skies resolved') + return count + + +if __name__ == '__main__': + import argparse + import tempfile + from .vlt import convert as convert_vlt + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--game-root', type=Path, required=True) + parser.add_argument('--assets', type=Path, required=True) + args = parser.parse_args() + # A targeted update reads only the small database pair and sky resources. + with tempfile.TemporaryDirectory(prefix='skate-sky-') as work: + database = BigArchive(args.game_root/'data/big/db.big') + needed = {'skaterschema.bin', 'skaterschema.vlt', 'skatercollections.bin', 'skatercollections.vlt'} + database.extract_entries([e for e in database.entries if Path(e.path).name.lower() in needed], Path(work)) + stem = Path(work)/'data/db' + names = (Path(__file__).parent/'names.txt').read_text(encoding='utf-8').splitlines() + converted = convert_vlt(stem/'skaterschema', stem/'skatercollections', [*names, *NAMES]) + print('Prepared', convert(args.game_root, args.assets, converted), 'authored skies') diff --git a/tools/asset_pipeline/test_environment.py b/tools/asset_pipeline/test_environment.py new file mode 100644 index 00000000..34f3b93b --- /dev/null +++ b/tools/asset_pipeline/test_environment.py @@ -0,0 +1,61 @@ +"""Synthetic environment references, independent of owned game assets.""" +import struct +import unittest +from .environment import Collections, field, key_hash, sky_parameters, world_environment + + +def raw(*values): + return struct.pack('>'+'f'*len(values), *values).hex() + + +def row(cls, key, parent='', **fields): + return {'class': cls, 'key': key, 'parent': parent, + 'fields': {k: {'type': 'fixture', 'data': v} for k, v in fields.items()}} + + +class EnvironmentTests(unittest.TestCase): + def test_world_reference_and_inheritance_select_default_fog(self): + rows = [ + row('world', 'base', Hash_C7A0A84F018E87BA='world/models/sky', + Hash_2A0BF629355AFB15='world/models/sky_Textures', + Hash_7E23D10785C43717=struct.pack('>2Q', key_hash('park'), 0).hex()), + row('world', 'test', 'base'), + row('render_locations', 'default', sun_position=raw(1, 0, 0), + Hash_2E9AAECD1C29F81F=raw(150), + material_fog=struct.pack('>3Q', key_hash('material_fog'), key_hash('default'), 0).hex()), + row('render_locations', 'park', 'default', sun_position=raw(0, 3, 4)), + row('material_fog', 'default', fog_near=raw(20), fog_far=raw(1000), + fog_colour=raw(.1, .2, .3, 1), fog_power=raw(2), fog_max=raw(.5)), + row('material_fog', 'fog_default', 'default', fog_far=raw(200)), + ] + env = world_environment(Collections({'collections': rows}), 'test') + self.assertEqual(env['sun_direction'], [0, .6, .8]) + self.assertEqual(env['anchor_height'], 150) + self.assertEqual(env['location_chain'], ['park', 'default']) + self.assertEqual(env['fog']['fog_far'], [1000]) + self.assertEqual(env['sky_model'], 'world/models/sky') + + def test_unnamed_hashes_and_cycle_rejection(self): + cls = f'Hash_{key_hash("world"):016X}' + c = Collections({'collections': [row(cls, 'a', 'b', value='child'), + row(cls, 'b', value='parent')]}) + values, _ = c.resolve('world', 'a') + self.assertEqual(field(values, 'value'), 'child') + c.rows[key_hash('world'), key_hash('b')]['parent'] = 'a' + with self.assertRaises(ValueError): + c.resolve('world', 'a') + + def test_sky_requires_complete_array_and_shader_collection(self): + r = row('material_sky', 'default') + r['fields']['m_params'] = {'type': 'Attrib::Types::Vector4', 'data': 'header'} + c = Collections({'collections': [r]}) + with self.assertRaises(ValueError): + sky_parameters(c, 'sky.default') + r['fields']['m_params']['array'] = {'items': [raw(.5, .25, 0, 0)]} + self.assertEqual(sky_parameters(c, 'sky.default')['multiplier'], .25) + with self.assertRaises(KeyError): + sky_parameters(c, 'sky.missing') + + +if __name__ == '__main__': + unittest.main()