Extend retail environment shading and refresh texture workflow

This commit is contained in:
chazmm committed 2026-09-08 18:30:31 +01:00
1 parent b5a00ede43
commit 96adff5d96
31 files changed
+969 -90

No files matched your search

+1 -1
View File
@@ -32,5 +32,5 @@ pub(crate) fn spawn_backdrop(
return; return;
} }
info!("SKATE_BACKDROP: {name} authored foliage triangles={}", map.geometry.indices.len() / 3); info!("SKATE_BACKDROP: {name} authored foliage triangles={}", map.geometry.indices.len() / 3);
crate::skate_world::spawn(&map, commands, meshes, materials, retail_materials, images); crate::skate_world::spawn(&map, commands, meshes, materials, retail_materials, images, &super::MaterialTuning::load(asset_root));
} }
+17 -4
View File
@@ -26,6 +26,7 @@ struct MaterialData {
shader: String, shader: String,
params: Vec<[f32; 4]>, params: Vec<[f32; 4]>,
specular: Option<String>, specular: Option<String>,
coverage: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct LightingData { struct LightingData {
@@ -37,6 +38,7 @@ struct Lighting {
data: LightingData, data: LightingData,
probes: Irradiance, probes: Irradiance,
light: Vec4, light: Vec4,
display_sh: Option<[Vec4; 9]>,
} }
#[derive(Clone, ShaderType)] #[derive(Clone, ShaderType)]
struct CharacterParams { struct CharacterParams {
@@ -60,6 +62,9 @@ struct CharacterMaterial {
#[texture(5)] #[texture(5)]
#[sampler(6)] #[sampler(6)]
mask: Option<Handle<Image>>, mask: Option<Handle<Image>>,
#[texture(7)]
#[sampler(8)]
coverage: Option<Handle<Image>>,
alpha: AlphaMode, alpha: AlphaMode,
} }
impl Material for CharacterMaterial { impl Material for CharacterMaterial {
@@ -141,6 +146,7 @@ fn load(mut commands: Commands, config: Res<crate::config::Config>) {
data, data,
probes, probes,
light, light,
display_sh: None,
}); });
} }
fn spawn_shadow_sources(commands: &mut Commands, light: Vec3) { fn spawn_shadow_sources(commands: &mut Commands, light: Vec3) {
@@ -218,8 +224,8 @@ fn bind(
options: Vec4::new( options: Vec4::new(
f32::from(m.normal_map_texture.is_some()), f32::from(m.normal_map_texture.is_some()),
f32::from(data.specular.is_some()), f32::from(data.specular.is_some()),
alpha_cutoff, if data.coverage.is_some() { -1. } else { alpha_cutoff },
f32::from(data.shader == "character.hair"), if data.coverage.is_some() { 2. } else { f32::from(data.shader == "character.hair") },
), ),
rows: std::array::from_fn(|i| Vec4::from_array(data.params[i])), rows: std::array::from_fn(|i| Vec4::from_array(data.params[i])),
sh: lighting sh: lighting
@@ -237,7 +243,9 @@ fn bind(
}, },
) )
}), }),
alpha: m.alpha_mode, coverage: data.coverage.as_ref().map(|path| server.load_with_settings(
path.clone(), |settings: &mut bevy::image::ImageLoaderSettings| { settings.is_srgb = false; })),
alpha: if data.coverage.is_some() { AlphaMode::Blend } else { m.alpha_mode },
}); });
commands commands
.entity(entity) .entity(entity)
@@ -278,9 +286,14 @@ fn update(
.default_sh .default_sh
.map(|v| Vec3::from_array(v).extend(0.)); .map(|v| Vec3::from_array(v).extend(0.));
let sh = lighting.probes.sample(root.translation, fallback); let sh = lighting.probes.sample(root.translation, fallback);
let displayed = lighting.display_sh.map_or(sh, |old| {
let weight = 1. - (-time.delta_secs().clamp(0., 0.05) / 0.35).exp();
std::array::from_fn(|i| old[i].lerp(sh[i], weight))
});
for (_, material) in materials.iter_mut() { for (_, material) in materials.iter_mut() {
material.params.sh = sh; material.params.sh = displayed;
} }
lighting.display_sh = Some(displayed);
// Adapter floor: the local probe's direction-independent ambient term. // Adapter floor: the local probe's direction-independent ambient term.
// The native per-frame c8 shadow-colour controller remains unrecovered. // The native per-frame c8 shadow-colour controller remains unrecovered.
shadow.approach(sh[0].truncate(), time.delta_secs()); shadow.approach(sh[0].truncate(), time.delta_secs());
+8 -1
View File
@@ -12,6 +12,8 @@ struct CharacterParams {
@group(#{MATERIAL_BIND_GROUP}) @binding(5) var mask_map: texture_2d<f32>; @group(#{MATERIAL_BIND_GROUP}) @binding(5) var mask_map: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(6) var mask_sampler: sampler; @group(#{MATERIAL_BIND_GROUP}) @binding(6) var mask_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(7) var coverage: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(8) var coverage_sampler: sampler;
@fragment @fragment
fn fragment(i: VertexOutput) -> @location(0) vec4<f32> { fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
let albedo = textureSample(diffuse,diffuse_sampler,i.uv); let albedo = textureSample(diffuse,diffuse_sampler,i.uv);
@@ -29,7 +31,12 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
let hl=p.rows[1].rgb*(saturate(ndl*0.75+0.25)+p.rows[5].w*0.25) let hl=p.rows[1].rgb*(saturate(ndl*0.75+0.25)+p.rows[5].w*0.25)
+p.rows[6].rgb*fres*saturate(ndl*1.75+0.25); +p.rows[6].rgb*fres*saturate(ndl*1.75+0.25);
if albedo.a<p.options.z { discard; } if albedo.a<p.options.z { discard; }
return vec4<f32>(d*hl*p.light.w,albedo.a*p.tint.a); var strand_alpha=albedo.a;
#ifdef VERTEX_UVS_B
// cac_hair: raw second UV, red coverage, m_params[6].x scale.
if p.options.w>1.5 { strand_alpha=saturate(textureSample(coverage,coverage_sampler,i.uv_b).r*p.rows[6].x); }
#endif
return vec4<f32>(d*hl*p.light.w,strand_alpha*p.tint.a);
} }
let dp1=dpdx(rpos); let dp2=dpdy(rpos); let dp1=dpdx(rpos); let dp2=dpdy(rpos);
let du1=dpdx(i.uv); let du2=dpdy(i.uv); let du1=dpdx(i.uv); let du2=dpdy(i.uv);
@@ -9,6 +9,8 @@ struct CharacterParams { light: vec4<f32>, tint: vec4<f32>, options: vec4<f32>,
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> p: CharacterParams; @group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> p: CharacterParams;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d<f32>; @group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler; @group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(7) var coverage: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(8) var coverage_sampler: sampler;
@fragment @fragment
fn fragment(i: VertexOutput) fn fragment(i: VertexOutput)
#ifdef PREPASS_FRAGMENT #ifdef PREPASS_FRAGMENT
@@ -16,6 +18,10 @@ fn fragment(i: VertexOutput)
#endif #endif
{ {
if textureSample(diffuse,diffuse_sampler,i.uv).a < p.options.z { discard; } if textureSample(diffuse,diffuse_sampler,i.uv).a < p.options.z { discard; }
#ifdef VERTEX_UVS_B
// Retain strand holes in depth-only passes. Blended colour uses continuous coverage.
if p.options.w>1.5 && textureSample(coverage,coverage_sampler,i.uv_b).r*p.rows[6].x < 30.0/255.0 { discard; }
#endif
#ifdef PREPASS_FRAGMENT #ifdef PREPASS_FRAGMENT
var out: FragmentOutput; var out: FragmentOutput;
#ifdef NORMAL_PREPASS #ifdef NORMAL_PREPASS
+1 -1
View File
@@ -5,7 +5,7 @@
#ifdef MOTION_VECTOR_PREPASS #ifdef MOTION_VECTOR_PREPASS
#import bevy_pbr::pbr_prepass_functions::calculate_motion_vector #import bevy_pbr::pbr_prepass_functions::calculate_motion_vector
#endif #endif
struct WorldParams { mode: vec4<f32>, foliage_debug: vec4<f32>, surface: vec4<f32>, family: vec4<f32>, fog_ramp: vec4<f32>, fog_color: vec4<f32>, shadow_color: vec4<f32>, sun_direction: vec4<f32> } struct WorldParams { mode: vec4<f32>, foliage_debug: vec4<f32>, surface: vec4<f32>, family: vec4<f32>, fog_ramp: vec4<f32>, fog_color: vec4<f32>, shadow_color: vec4<f32>, sun_direction: vec4<f32>, water: array<vec4<f32>, 4> }
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> p: WorldParams; @group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> p: WorldParams;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d<f32>; @group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler; @group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler;
+268
View File
@@ -0,0 +1,268 @@
//! GPU-only exposure adaptation. Native evaluator, portable luminance sampling.
use bevy::{
asset::embedded_asset,
core_pipeline::{
FullscreenShader,
core_3d::graph::{Core3d, Node3d},
},
ecs::query::QueryItem,
prelude::*,
render::{
Render, RenderApp, RenderStartup, RenderSystems,
extract_component::ExtractComponentPlugin,
extract_resource::{ExtractResource, ExtractResourcePlugin},
render_graph::{
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
},
render_resource::{binding_types::*, *},
renderer::{RenderContext, RenderDevice, RenderQueue},
view::ViewTarget,
},
};
#[derive(Resource, Clone, ExtractResource)]
struct Settings {
tuning: Vec4,
timing: Vec4,
}
impl Default for Settings {
fn default() -> Self {
// Missing metadata retains the existing fixed exposure.
Self {
tuning: Vec4::new(0., 2.5, 2.5, 0.),
timing: Vec4::ZERO,
}
}
}
#[derive(serde::Deserialize)]
struct Authored {
target_luminance: f32,
min: f32,
max: f32,
damping: f32,
}
pub(super) fn install(app: &mut App) {
embedded_asset!(app, "retail_exposure.wgsl");
app.init_resource::<Settings>()
.add_plugins((
ExtractResourcePlugin::<Settings>::default(),
ExtractComponentPlugin::<super::RetailTone>::default(),
))
.add_systems(Startup, load)
.add_systems(Update, advance);
if let Some(render) = app.get_sub_app_mut(RenderApp) {
render
.add_systems(RenderStartup, initialize)
.add_systems(Render, upload.in_set(RenderSystems::PrepareResources))
.add_render_graph_node::<ViewNodeRunner<ExposureNode>>(Core3d, ExposureLabel)
.add_render_graph_edges(
Core3d,
(
Node3d::Tonemapping,
ExposureLabel,
Node3d::EndMainPassPostProcessing,
),
);
}
}
fn load(config: Res<crate::config::Config>, mut settings: ResMut<Settings>) {
if std::env::var_os("SKATE_FIXED_EXPOSURE").is_some_and(|v| v == "1") {
info!("RETAIL_EXPOSURE: fixed 2.5 comparison mode");
return;
}
let data = std::fs::read(config.asset_root.join("private/exposure.json"));
if let Some(a) = data
.ok()
.and_then(|b| serde_json::from_slice::<Authored>(&b).ok())
{
if [a.target_luminance, a.min, a.max, a.damping]
.iter()
.all(|x| x.is_finite())
&& a.min > 0.
&& a.max >= a.min
&& a.target_luminance > 0.
&& a.damping >= 0.
{
settings.tuning = Vec4::new(a.target_luminance, a.min, a.max, a.damping);
info!(
"RETAIL_EXPOSURE: authored target={} range={}..{} damping={}; portable GPU meter",
a.target_luminance, a.min, a.max, a.damping
);
}
}
}
fn advance(time: Res<Time>, mut settings: ResMut<Settings>) {
settings.timing.x = time.delta_secs().clamp(0., 0.05);
}
#[derive(Resource)]
struct Pipeline {
compute_layout: BindGroupLayoutDescriptor,
tone_layout: BindGroupLayoutDescriptor,
compute: CachedComputePipelineId,
tone: CachedRenderPipelineId,
settings: Buffer,
state: Buffer,
sampler: Sampler,
}
fn initialize(
mut commands: Commands,
device: Res<RenderDevice>,
cache: Res<PipelineCache>,
assets: Res<AssetServer>,
fullscreen: Res<FullscreenShader>,
) {
let compute_layout = BindGroupLayoutDescriptor::new(
"retail exposure meter",
&BindGroupLayoutEntries::sequential(
ShaderStages::COMPUTE,
(
texture_2d(TextureSampleType::Float { filterable: true }),
sampler(SamplerBindingType::Filtering),
uniform_buffer::<[Vec4; 2]>(false),
storage_buffer::<Vec4>(false),
),
),
);
let tone_layout = BindGroupLayoutDescriptor::new(
"retail exposed tone",
&BindGroupLayoutEntries::sequential(
ShaderStages::FRAGMENT,
(
texture_2d(TextureSampleType::Float { filterable: true }),
sampler(SamplerBindingType::Filtering),
storage_buffer_read_only::<Vec4>(false),
),
),
);
let compute = cache.queue_compute_pipeline(ComputePipelineDescriptor {
label: Some("retail exposure".into()),
layout: vec![compute_layout.clone()],
shader: assets.load("embedded://skate3rust/retail_exposure.wgsl"),
entry_point: Some("meter".into()),
..default()
});
let tone = cache.queue_render_pipeline(RenderPipelineDescriptor {
label: Some("retail exposed tone".into()),
layout: vec![tone_layout.clone()],
vertex: fullscreen.to_vertex_state(),
fragment: Some(FragmentState {
shader: assets.load("embedded://skate3rust/retail_tone.wgsl"),
targets: vec![Some(ColorTargetState {
format: ViewTarget::TEXTURE_FORMAT_HDR,
blend: None,
write_mask: ColorWrites::ALL,
})],
..default()
}),
..default()
});
let settings = device.create_buffer(&BufferDescriptor {
label: Some("retail exposure settings"),
size: 32,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let state = device.create_buffer_with_data(&BufferInitDescriptor {
label: Some("retail exposure state"),
contents: &bytes([Vec4::new(2.5, 0., 0., 0.)]),
usage: BufferUsages::STORAGE,
});
commands.insert_resource(Pipeline {
compute_layout,
tone_layout,
compute,
tone,
settings,
state,
sampler: device.create_sampler(&SamplerDescriptor {
mag_filter: FilterMode::Linear,
min_filter: FilterMode::Linear,
..default()
}),
});
}
fn bytes<const N: usize>(values: [Vec4; N]) -> Vec<u8> {
values
.into_iter()
.flat_map(|v| v.to_array())
.flat_map(f32::to_le_bytes)
.collect()
}
fn upload(settings: Res<Settings>, pipeline: Res<Pipeline>, queue: Res<RenderQueue>) {
queue.write_buffer(
&pipeline.settings,
0,
&bytes([settings.tuning, settings.timing]),
);
}
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct ExposureLabel;
#[derive(Default)]
struct ExposureNode;
impl ViewNode for ExposureNode {
type ViewQuery = (&'static ViewTarget, &'static super::RetailTone);
fn run<'w>(
&self,
_: &mut RenderGraphContext,
context: &mut RenderContext,
(view, _): QueryItem<Self::ViewQuery>,
world: &'w World,
) -> Result<(), NodeRunError> {
let p = world.resource::<Pipeline>();
let cache = world.resource::<PipelineCache>();
let (Some(compute), Some(tone)) = (
cache.get_compute_pipeline(p.compute),
cache.get_render_pipeline(p.tone),
) else {
return Ok(());
};
let post = view.post_process_write();
let meter = context.render_device().create_bind_group(
"retail meter",
&cache.get_bind_group_layout(&p.compute_layout),
&BindGroupEntries::sequential((
post.source,
&p.sampler,
p.settings.as_entire_binding(),
p.state.as_entire_binding(),
)),
);
let output = context.render_device().create_bind_group(
"retail exposed tone",
&cache.get_bind_group_layout(&p.tone_layout),
&BindGroupEntries::sequential((post.source, &p.sampler, p.state.as_entire_binding())),
);
{
let mut pass = context
.command_encoder()
.begin_compute_pass(&ComputePassDescriptor {
label: Some("retail exposure meter"),
timestamp_writes: None,
});
pass.set_pipeline(compute);
pass.set_bind_group(0, &meter, &[]);
pass.dispatch_workgroups(1, 1, 1);
}
let mut pass = context.begin_tracked_render_pass(RenderPassDescriptor {
label: Some("retail exposed tone"),
color_attachments: &[Some(RenderPassColorAttachment {
view: post.destination,
depth_slice: None,
resolve_target: None,
ops: Operations::default(),
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_render_pipeline(tone);
pass.set_bind_group(0, &output, &[]);
pass.draw(0..3, 0..1);
Ok(())
}
}
@@ -0,0 +1,30 @@
// Native multiplicative evaluator (827F0D00) and centre weighting (827F0B78).
// Portable meter: 16x16 bilinear HDR samples, not the console resolve surface.
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var source_sampler: sampler;
struct Settings { tuning: vec4<f32>, timing: vec4<f32> }
@group(0) @binding(2) var<uniform> settings: Settings;
@group(0) @binding(3) var<storage, read_write> state: vec4<f32>;
var<workgroup> sums: array<f32, 256>;
@compute @workgroup_size(256)
fn meter(@builtin(local_invocation_index) id: u32) {
let uv=(vec2<f32>(f32(id%16u),f32(id/16u))+0.5)/16.0;
let xy=uv*2.0-1.0;
let weight=pow(1.0-abs(xy.x*xy.y),2.0);
// Material output currently includes baseline exposure 2.5. Meter the
// current adapted image, with a portable Rec.709 luminance conversion.
let c=max(textureSampleLevel(source,source_sampler,uv,0.0).rgb,vec3<f32>(0.0))*state.x/2.5;
sums[id]=min(dot(c,vec3<f32>(0.2126,0.7152,0.0722)),16.0)*weight;
workgroupBarrier();
for(var stride=128u;stride>0u;stride/=2u) {
if id<stride { sums[id]+=sums[id+stride]; }
workgroupBarrier();
}
if id==0u {
let average=sums[0]/256.0*2.515;
let factor=max(0.001,1.0+settings.tuning.w*(settings.tuning.x-average));
// Native evaluator ticks per frame; adapt to a 30 Hz time basis here.
state.x=clamp(state.x*pow(factor,settings.timing.x*30.0),settings.tuning.y,settings.tuning.z);
state.y=average;
}
}
+45 -23
View File
@@ -1,14 +1,9 @@
//! Retail world shading. See docs/retail-renderer.md for provenance and gaps. //! Retail world shading. See docs/retail-renderer.md for provenance and gaps.
use bevy::{ use bevy::{
asset::embedded_asset, asset::embedded_asset,
core_pipeline::{
core_3d::graph::Node3d,
fullscreen_material::{FullscreenMaterial, FullscreenMaterialPlugin},
},
prelude::*, prelude::*,
render::{ render::{
extract_component::ExtractComponent, extract_component::ExtractComponent,
render_graph::{InternedRenderLabel, RenderLabel},
render_resource::{AsBindGroup, ShaderType}, render_resource::{AsBindGroup, ShaderType},
}, },
shader::ShaderRef, shader::ShaderRef,
@@ -19,6 +14,7 @@ pub(crate) struct RetailRenderPlugin;
impl Plugin for RetailRenderPlugin { impl Plugin for RetailRenderPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
shadow::install(app); shadow::install(app);
exposure::install(app);
app.add_plugins(crate::retail_character::CharacterLightingPlugin); app.add_plugins(crate::retail_character::CharacterLightingPlugin);
if std::env::var_os("SKATE_DEBUG_FOLIAGE").is_some_and(|v| v == "1") { if std::env::var_os("SKATE_DEBUG_FOLIAGE").is_some_and(|v| v == "1") {
eprintln!("SKATE_FOLIAGE_DEBUG: solid cyan tree-wall cards, magenta other foliage; alpha rejection disabled for foliage only"); eprintln!("SKATE_FOLIAGE_DEBUG: solid cyan tree-wall cards, magenta other foliage; alpha rejection disabled for foliage only");
@@ -30,11 +26,13 @@ impl Plugin for RetailRenderPlugin {
app.add_plugins(( app.add_plugins((
MaterialPlugin::<RetailWorldMaterial>::default(), MaterialPlugin::<RetailWorldMaterial>::default(),
MaterialPlugin::<RetailSkyMaterial>::default(), MaterialPlugin::<RetailSkyMaterial>::default(),
FullscreenMaterialPlugin::<RetailTone>::default(),
)); ));
} }
} }
#[path = "retail_exposure.rs"]
mod exposure;
#[path = "retail_shadow.rs"] #[path = "retail_shadow.rs"]
mod shadow; mod shadow;
pub(crate) use shadow::ShadowState; pub(crate) use shadow::ShadowState;
@@ -54,18 +52,6 @@ pub(crate) use backdrop::spawn_backdrop;
pub(crate) struct RetailTone { pub(crate) struct RetailTone {
pub enabled: Vec4, pub enabled: Vec4,
} }
impl FullscreenMaterial for RetailTone {
fn fragment_shader() -> ShaderRef {
"embedded://skate3rust/retail_tone.wgsl".into()
}
fn node_edges() -> Vec<InternedRenderLabel> {
vec![
Node3d::Tonemapping.intern(),
Self::node_label().intern(),
Node3d::EndMainPassPostProcessing.intern(),
]
}
}
#[derive(Clone, Debug, ShaderType)] #[derive(Clone, Debug, ShaderType)]
pub(crate) struct WorldParams { pub(crate) struct WorldParams {
@@ -81,6 +67,7 @@ pub(crate) struct WorldParams {
pub fog_color: Vec4, pub fog_color: Vec4,
pub shadow_color: Vec4, pub shadow_color: Vec4,
pub sun_direction: Vec4, pub sun_direction: Vec4,
pub water: [Vec4; 4],
} }
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)] #[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
@@ -162,6 +149,7 @@ pub(crate) struct Binding {
} }
#[derive(Clone, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct Definition { pub(crate) struct Definition {
pub shader: String,
pub family: u32, pub family: u32,
pub flags: u32, pub flags: u32,
pub bindings: BTreeMap<String, Binding>, pub bindings: BTreeMap<String, Binding>,
@@ -186,7 +174,7 @@ impl Definition {
} }
let mut r = Reader(bytes); let mut r = Reader(bytes);
r.take(16)?; r.take(16)?;
let _shader = r.text()?; let shader = r.text()?;
let family = r.u32()?; let family = r.u32()?;
let flags = r.u32()?; let flags = r.u32()?;
let mut bindings = BTreeMap::new(); let mut bindings = BTreeMap::new();
@@ -217,6 +205,7 @@ impl Definition {
return None; return None;
} }
Some(Self { Some(Self {
shader,
family, family,
flags, flags,
bindings, bindings,
@@ -231,12 +220,18 @@ impl Definition {
.ok() .ok()
.filter(|v| v.is_finite()) .filter(|v| v.is_finite())
} }
pub fn supported(&self) -> bool { pub fn supported(&self, tuning: &MaterialTuning) -> bool {
(1..=12).contains(&self.family) || self.family == 13 (1..=13).contains(&self.family) || match self.family {
14 | 32 => tuning.rows.get(&self.shader).is_some_and(|r| !r.is_empty()),
31 => tuning.pca_available && tuning.rows.get(&self.shader).is_some_and(|r| r.len() == 3),
30 => tuning.rows.get(&self.shader).is_some_and(|r| r.len() == 4),
_ => false,
}
} }
pub fn build( pub fn build(
&self, &self,
m: &skate_data::skate_map::Material, m: &skate_data::skate_map::Material,
tuning: &MaterialTuning,
texture: &mut impl FnMut(u32, u8) -> Option<Handle<Image>>, texture: &mut impl FnMut(u32, u8) -> Option<Handle<Image>>,
) -> RetailWorldMaterial { ) -> RetailWorldMaterial {
let mut fetch = |role: &str, fallback: u32, clamp: bool| { let mut fetch = |role: &str, fallback: u32, clamp: bool| {
@@ -253,7 +248,7 @@ impl Definition {
let diffuse = fetch("diffuse", m.textures[0], false); let diffuse = fetch("diffuse", m.textures[0], false);
let lightmap = fetch("lightmap", m.textures[1], true); let lightmap = fetch("lightmap", m.textures[1], true);
let normal = fetch("normal", m.textures[2], false); let normal = fetch("normal", m.textures[2], false);
let detail = fetch("detail", 0, false); let detail = fetch(if self.family == 31 { "normal2" } else { "detail" }, 0, false);
let macro_map = fetch("macrooverlay", 0, false); let macro_map = fetch("macrooverlay", 0, false);
let decal = fetch("decal", 0, self.family == 3); let decal = fetch("decal", 0, self.family == 3);
let specular = fetch("specular", 0, false); let specular = fetch("specular", 0, false);
@@ -263,7 +258,7 @@ impl Definition {
let detail_scale = self.scalar("detailNormalUVScale").unwrap_or(0.); let detail_scale = self.scalar("detailNormalUVScale").unwrap_or(0.);
let flags = u32::from(normal.is_some()) let flags = u32::from(normal.is_some())
| (u32::from(detail.is_some() && detail_scale > 0.) << 1) | (u32::from(detail.is_some() && detail_scale > 0.) << 1)
| (u32::from(macro_map.is_some() && macro_scale > 0. && macro_opacity > 0.) << 2) | (u32::from(macro_map.is_some() && macro_scale > 0. && (macro_opacity > 0. || self.family == 31)) << 2)
| (u32::from(decal.is_some()) << 3) | (u32::from(decal.is_some()) << 3)
| (u32::from(specular.is_some()) << 4) | (u32::from(specular.is_some()) << 4)
| (u32::from(lightmap.is_some()) << 5) | (u32::from(lightmap.is_some()) << 5)
@@ -282,6 +277,16 @@ impl Definition {
(_, 2) => AlphaMode::Blend, (_, 2) => AlphaMode::Blend,
_ => AlphaMode::Opaque, _ => AlphaMode::Opaque,
}; };
let alpha = if self.family == 32 || (self.family == 30 && self.shader.ends_with("alpha")) {
AlphaMode::Blend
} else { alpha };
let mut water = [Vec4::ZERO; 4];
if let Some(rows) = tuning.rows.get(&self.shader) {
for (to, from) in water.iter_mut().zip(rows) { *to = Vec4::from_array(*from); }
}
if self.family == 14 {
water[1] = Vec4::new(self.scalar("uAnimationSpeed").unwrap_or(0.), self.scalar("vAnimationSpeed").unwrap_or(0.), 0., 0.);
}
RetailWorldMaterial { RetailWorldMaterial {
params: WorldParams { params: WorldParams {
mode: Vec4::new( mode: Vec4::new(
@@ -303,6 +308,7 @@ impl Definition {
fog_color: Vec4::ZERO, fog_color: Vec4::ZERO,
shadow_color: Vec4::ZERO, shadow_color: Vec4::ZERO,
sun_direction: Vec3::new(4., 7., 4.).normalize().extend(0.), sun_direction: Vec3::new(4., 7., 4.).normalize().extend(0.),
water,
}, },
diffuse, diffuse,
lightmap, lightmap,
@@ -358,3 +364,19 @@ pub(crate) fn mip_chain(rgba: &[u8], width: u32, height: u32, layers: u32) -> (V
} }
(bytes, count) (bytes, count)
} }
#[derive(Default)]
pub(crate) struct MaterialTuning {
rows: BTreeMap<String, Vec<[f32; 4]>>,
pca_available: bool,
}
impl MaterialTuning {
pub(crate) fn load(root: &std::path::Path) -> Self {
let path = root.join("private/render-parameters.json");
match std::fs::read(&path).ok().and_then(|b| serde_json::from_slice::<BTreeMap<String, Vec<[f32; 4]>>>(&b).ok()) {
Some(rows) if rows.values().flatten().flatten().all(|x| x.is_finite()) => Self { rows, pca_available: shadow::pca_available(root) },
_ => { warn!("Retail water/scroll tuning unavailable: {}", path.display()); Self::default() }
}
}
}
+41 -8
View File
@@ -1,4 +1,4 @@
//! Shared, in-place shadow state: moving probes must not rebuild world materials. //! Shared frame state: shadow floor, animation clock and authored ocean PCA.
use bevy::{ use bevy::{
prelude::*, prelude::*,
render::{ render::{
@@ -15,7 +15,7 @@ pub(super) const BUFFER: Handle<ShaderStorageBuffer> =
bevy::asset::uuid_handle!("cd736f89-4882-4a5d-8fcb-32273beeaaf4"); bevy::asset::uuid_handle!("cd736f89-4882-4a5d-8fcb-32273beeaaf4");
#[derive(Resource, Clone, Default, ExtractResource)] #[derive(Resource, Clone, Default, ExtractResource)]
pub(crate) struct ShadowState(pub Vec4); pub(crate) struct ShadowState(pub Vec4, pub Vec4, pub [Vec4; 7]);
impl ShadowState { impl ShadowState {
pub(crate) fn approach(&mut self, target: Vec3, dt: f32) { pub(crate) fn approach(&mut self, target: Vec3, dt: f32) {
@@ -36,14 +36,15 @@ impl ShadowState {
pub(super) fn install(app: &mut App) { pub(super) fn install(app: &mut App) {
app.init_resource::<ShadowState>() app.init_resource::<ShadowState>()
.add_plugins(ExtractResourcePlugin::<ShadowState>::default()) .add_plugins(ExtractResourcePlugin::<ShadowState>::default())
.add_systems(Startup, initialize); .add_systems(Startup, (initialize, load_pca))
.add_systems(Update, clock);
if let Some(render) = app.get_sub_app_mut(RenderApp) { if let Some(render) = app.get_sub_app_mut(RenderApp) {
render.add_systems(Render, upload.in_set(RenderSystems::PrepareResources)); render.add_systems(Render, upload.in_set(RenderSystems::PrepareResources));
} }
} }
fn initialize(mut buffers: ResMut<Assets<ShaderStorageBuffer>>) { fn initialize(mut buffers: ResMut<Assets<ShaderStorageBuffer>>) {
let mut buffer = ShaderStorageBuffer::from(Vec4::ZERO); let mut buffer = ShaderStorageBuffer::from([Vec4::ZERO; 9]);
buffer.buffer_description.usage |= BufferUsages::COPY_DST; buffer.buffer_description.usage |= BufferUsages::COPY_DST;
buffers buffers
.insert(BUFFER.id(), buffer) .insert(BUFFER.id(), buffer)
@@ -56,13 +57,13 @@ fn upload(
queue: Res<RenderQueue>, queue: Res<RenderQueue>,
) { ) {
if let Some(buffer) = buffers.get(BUFFER.id()) { if let Some(buffer) = buffers.get(BUFFER.id()) {
let bytes: Vec<u8> = state let bytes: Vec<u8> = [state.0, state.1]
.0
.to_array()
.into_iter() .into_iter()
.chain(state.2)
.flat_map(|v| v.to_array())
.flat_map(f32::to_le_bytes) .flat_map(f32::to_le_bytes)
.collect(); .collect();
// Keep the buffer and every material bind group alive; only 16 bytes change. // Keep the buffer and all material bind groups alive; upload 144 frame bytes.
queue.write_buffer(&buffer.buffer, 0, &bytes); queue.write_buffer(&buffer.buffer, 0, &bytes);
} }
} }
@@ -87,3 +88,35 @@ mod tests {
assert!((state.0.x - 0.3).abs() < 0.0001); assert!((state.0.x - 0.3).abs() < 0.0001);
} }
} }
#[derive(Resource, serde::Deserialize)]
struct OceanPca {
hz: f32,
frames: Vec<[[f32; 4]; 7]>,
}
fn read_pca(root: &std::path::Path) -> Option<OceanPca> {
let pca = std::fs::read(root.join("private/ocean-pca.json"))
.ok()
.and_then(|b| serde_json::from_slice::<OceanPca>(&b).ok())?;
(pca.hz == 30.
&& pca.frames.len() == 30
&& pca.frames.iter().flatten().flatten().all(|v| v.is_finite()))
.then_some(pca)
}
pub(super) fn pca_available(root: &std::path::Path) -> bool {
read_pca(root).is_some()
}
fn load_pca(mut commands: Commands, config: Res<crate::config::Config>) {
if let Some(pca) = read_pca(&config.asset_root) {
info!("RETAIL_OCEAN: loaded 30 authored PCA frames");
commands.insert_resource(pca);
}
}
fn clock(mut state: ResMut<ShadowState>, time: Res<Time>, pca: Option<Res<OceanPca>>) {
state.1.x = time.elapsed_secs();
if let Some(pca) = pca {
let frame = ((time.elapsed_secs_f64() * f64::from(pca.hz)) as usize) % pca.frames.len();
state.2 = pca.frames[frame].map(Vec4::from_array);
state.1.y = 1.;
}
}
+19
View File
@@ -51,6 +51,12 @@ impl Material for RetailSkyMaterial {
} }
} }
#[derive(serde::Deserialize)]
struct FogFrame {
ramp: [f32; 4],
colour: [f32; 4],
}
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct Environment { struct Environment {
anchor_height: f32, anchor_height: f32,
@@ -58,6 +64,8 @@ struct Environment {
sun_scale: f32, sun_scale: f32,
multiplier: f32, multiplier: f32,
location_chain: Vec<String>, location_chain: Vec<String>,
#[serde(default)]
fog_frame: Option<FogFrame>,
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct Sky { struct Sky {
@@ -116,6 +124,13 @@ fn load(root: &std::path::Path, name: &str) -> Result<(Sky, Vec<u8>, Option<Vec<
{ {
return Err("Invalid authored sky environment".into()); return Err("Invalid authored sky environment".into());
} }
if let Some(fog) = &env.fog_frame {
if !fog.ramp.iter().chain(fog.colour.iter()).all(|v| v.is_finite())
|| fog.ramp[0] < 0. || fog.ramp[2] <= 0.
|| !(-1. ..=0.).contains(&fog.colour[3]) {
return Err("Invalid authored fog frame".into());
}
}
let bytes = read(".sun.rgba")?; let bytes = read(".sun.rgba")?;
if u64::from(sky.sun_width) * u64::from(sky.sun_height) * 4 != bytes.len() as u64 { if u64::from(sky.sun_width) * u64::from(sky.sun_height) * 4 != bytes.len() as u64 {
return Err("Invalid sun-gradient payload".into()); return Err("Invalid sun-gradient payload".into());
@@ -175,6 +190,10 @@ pub(crate) fn spawn_sky(
// Only the existing tangent-sign term uses this vector. No added // Only the existing tangent-sign term uses this vector. No added
// directional light energy or imported-world shadow map. // directional light energy or imported-world shadow map.
material.params.sun_direction = params.sun_direction; material.params.sun_direction = params.sun_direction;
if let Some(fog) = &env.fog_frame {
material.params.fog_ramp = Vec4::from_array(fog.ramp);
material.params.fog_color = Vec4::from_array(fog.colour);
}
} }
info!( info!(
"SKATE_SKY_READY map={name:?} location={:?} anchor={} sun_scale={} multiplier={} authored_direction={:?}", "SKATE_SKY_READY map={name:?} location={:?} anchor={} sun_scale={} multiplier={} authored_direction={:?}",
+2 -2
View File
@@ -1,12 +1,12 @@
#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput #import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput
@group(0) @binding(0) var source: texture_2d<f32>; @group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var source_sampler: sampler; @group(0) @binding(1) var source_sampler: sampler;
@group(0) @binding(2) var<uniform> enabled: vec4<f32>; @group(0) @binding(2) var<storage, read> exposure: vec4<f32>;
@fragment @fragment
fn fragment(i: FullscreenVertexOutput) -> @location(0) vec4<f32> { fn fragment(i: FullscreenVertexOutput) -> @location(0) vec4<f32> {
let c = textureSample(source,source_sampler,i.uv); let c = textureSample(source,source_sampler,i.uv);
let xe = max(c.rgb,vec3<f32>(0.0)); let xe = max(c.rgb*exposure.x/2.5,vec3<f32>(0.0));
let t = saturate(1.0-xe); let t = saturate(1.0-xe);
let tm = max(xe*0.25+0.75,vec3<f32>(1.0))-t*t; let tm = max(xe*0.25+0.75,vec3<f32>(1.0))-t*t;
let gamma = saturate(sqrt(max(tm*0.5,vec3<f32>(0.0)))*1.41); let gamma = saturate(sqrt(max(tm*0.5,vec3<f32>(0.0)))*1.41);
+81 -6
View File
@@ -3,7 +3,7 @@
struct WorldParams { struct WorldParams {
mode: vec4<f32>, foliage_debug: vec4<f32>, surface: vec4<f32>, family: vec4<f32>, mode: vec4<f32>, foliage_debug: vec4<f32>, surface: vec4<f32>, family: vec4<f32>,
fog_ramp: vec4<f32>, fog_color: vec4<f32>, shadow_color: vec4<f32>, sun_direction: vec4<f32>, fog_ramp: vec4<f32>, fog_color: vec4<f32>, shadow_color: vec4<f32>, sun_direction: vec4<f32>, water: array<vec4<f32>, 4>,
} }
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> p: WorldParams; @group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> p: WorldParams;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d<f32>; @group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d<f32>;
@@ -22,13 +22,16 @@ struct WorldParams {
@group(#{MATERIAL_BIND_GROUP}) @binding(14) var specular_sampler: sampler; @group(#{MATERIAL_BIND_GROUP}) @binding(14) var specular_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(15) var environment_map: texture_cube<f32>; @group(#{MATERIAL_BIND_GROUP}) @binding(15) var environment_map: texture_cube<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(16) var<storage, read> shadow_state: vec4<f32>; struct FrameState { shadow: vec4<f32>, clock: vec4<f32>, pca: array<vec4<f32>, 7> }
@group(#{MATERIAL_BIND_GROUP}) @binding(16) var<storage, read> frame_state: FrameState;
@fragment @fragment
fn fragment(i: VertexOutput) -> @location(0) vec4<f32> { fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
let fam = u32(p.mode.x); let fam = u32(p.mode.x);
let flags = u32(p.mode.y); let flags = u32(p.mode.y);
let a = textureSample(diffuse, diffuse_sampler, i.uv); var diffuse_uv=i.uv;
if fam==14u { diffuse_uv+=fract(frame_state.clock.x*p.water[1].xy*vec2<f32>(1.0,-1.0)); }
let a = textureSample(diffuse, diffuse_sampler, diffuse_uv);
let lm = textureSampleLevel(lightmap, lm_sampler, i.uv_b, 0.0).rgb; let lm = textureSampleLevel(lightmap, lm_sampler, i.uv_b, 0.0).rgb;
// Sample before alpha rejection: implicit derivatives must be uniform. // Sample before alpha rejection: implicit derivatives must be uniform.
var nm = vec3<f32>(0.5,0.5,1.0); var nm = vec3<f32>(0.5,0.5,1.0);
@@ -74,18 +77,90 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
var alpha = 1.0; var alpha = 1.0;
var lin = vec3<f32>(0.0); var lin = vec3<f32>(0.0);
var baked = lm*lm; var baked = lm*lm;
if shadow_state.w>0.0 && (fam<=8u || fam==13u) { if frame_state.shadow.w>0.0 && (fam<=8u || fam==13u) {
let view_z=(frame::view.view_from_world*i.world_position).z; let view_z=(frame::view.view_from_world*i.world_position).z;
for (var light_id=0u; light_id<frame::lights.n_directional_lights; light_id+=1u) { for (var light_id=0u; light_id<frame::lights.n_directional_lights; light_id+=1u) {
// The lightmapped receiver source contains only player/board casters. // The lightmapped receiver source contains only player/board casters.
if (frame::lights.directional_lights[light_id].flags & 5u)==5u { if (frame::lights.directional_lights[light_id].flags & 5u)==5u {
let visibility=fetch_directional_shadow(light_id,i.world_position,wn,view_z); let visibility=fetch_directional_shadow(light_id,i.world_position,wn,view_z);
baked=min(baked,vec3<f32>(visibility)+shadow_state.rgb); baked=min(baked,vec3<f32>(visibility)+frame_state.shadow.rgb);
break; break;
} }
} }
} }
if fam == 9u || fam == 10u { if fam==14u {
lin=d*p.water[0].y;
} else if fam==32u {
lin=d*p.water[0].x;
alpha=saturate((i.world_position.y-p.water[0].z)/max(p.water[0].y-p.water[0].z,1e-4));
} else if fam==31u {
let raw_uv=vec2<f32>(i.uv.x,1.0-i.uv.y);
let uv=raw_uv*p.water[1].w;
let sample_uv=vec2<f32>(uv.x,1.0-uv.y);
let c0=textureSample(normal_map,normal_sampler,sample_uv)*2.0-1.0;
let c1=textureSample(detail_map,detail_sampler,sample_uv)*2.0-1.0;
let pca=(vec3<f32>(dot(c0,frame_state.pca[1])+dot(c1,frame_state.pca[2]),
dot(c0,frame_state.pca[3])+dot(c1,frame_state.pca[4]),
dot(c0,frame_state.pca[5])+dot(c1,frame_state.pca[6]))+frame_state.pca[0].xyz)*2.0-1.0;
var overlay=1.0;
if (flags & 4u)!=0u {
let uv_overlay=raw_uv*p.surface.x;
overlay=textureSample(macro_map,macro_sampler,vec2<f32>(uv_overlay.x,1.0-uv_overlay.y)).r;
}
let tonedown=2.0*p.water[1].z*saturate(dot(c1,frame_state.pca[6])+overlay);
let nt=normalize(max(abs(normalize(mix(vec3<f32>(0.0,0.0,1.0),pca,tonedown))),vec3<f32>(0.001)));
let n=nt.xzy;
let rv=vd-2.0*n*dot(vd,n);
var cube=vec3<f32>(0.0);
if (flags & 64u)!=0u { cube=textureSampleBias(environment_map,diffuse_sampler,vec3<f32>(-rv.x,-rv.y,rv.z),log2(frame::view.viewport.w/640.0)).rgb; }
let olm=textureSampleLevel(lightmap,lm_sampler,i.uv_b+0.01*nt.xy*vec2<f32>(1.0,-1.0),0.0).rgb;
let fres=0.25+0.75*pow(1.0-abs(dot(n,vd)),5.0);
let u=normalize(vec3<f32>(-n.z,0.0,n.x));
let v=cross(n,u);
let ax=p.water[2].z/p.water[2].x;
let ay=p.water[2].z/p.water[2].y;
let h=normalize(sun+vd);
let eu=dot(h,u)/ax;
let ev=dot(h,v)/ay;
let den=sqrt(abs(dot(n,sun)*dot(n,vd)))*12.566371*ax*ay;
let ward=exp(-2.0*(eu*eu+ev*ev)/(1.0+dot(h,n)))/max(den,1e-6);
lin=(cube*olm*olm*fres+ward*p.water[0].rgb)*p.water[1].y;
alpha=1.0;
} else if fam==30u {
let t=frame_state.clock.x;
// Convert to original UVs for scale/scroll, then back to flipped rows.
let raw_uv=vec2<f32>(i.uv.x,1.0-i.uv.y);
let uv1=raw_uv*p.water[2].xy+p.water[1].xy*t;
let uv2=raw_uv*p.water[2].zw+p.water[1].zw*t;
let n1=textureSample(normal_map,normal_sampler,vec2<f32>(uv1.x,1.0-uv1.y)).rgb;
let n2=textureSample(normal_map,normal_sampler,vec2<f32>(uv2.x,1.0-uv2.y)).rgb;
let vn=normalize((2.0*n1+2.0*n2-2.0)*p.water[0].xzw);
let water_n=normalize(vn.x*kt+vn.y*kb+vn.z*wn);
let wlm=textureSampleLevel(lightmap,lm_sampler,i.uv_b+0.01*vn.xz*vec2<f32>(1.0,-1.0),0.0).rgb;
var lml=wlm*wlm;
if frame_state.shadow.w>0.0 {
let vz=(frame::view.view_from_world*i.world_position).z;
for(var id=0u;id<frame::lights.n_directional_lights;id+=1u) {
if (frame::lights.directional_lights[id].flags & 5u)==5u {
lml=min(lml,vec3<f32>(fetch_directional_shadow(id,i.world_position,wn,vz))+frame_state.shadow.rgb); break;
}
}
}
let kd=dot(water_n,vec3<f32>(0.58*sign(sun.x),0.62*sign(sun.y),0.39))*2.39562;
var wm=vec2<f32>(0.0);
if (flags & 16u)!=0u { wm=saturate(textureSample(specular_map,specular_sampler,i.uv).xz-p.water[3].y); }
let reflected_light=2.0*water_n*dot(water_n,sun)-sun;
let ks=pow(max(saturate(dot(vd,reflected_light)),1e-6),p.water[3].z);
var spec=ks*wm.x*vec3<f32>(2.1,1.8,1.5)*saturate(lml.g-0.1);
if (flags & 64u)!=0u {
let rv=vd-2.0*water_n*dot(vd,water_n);
let cube=textureSampleBias(environment_map,diffuse_sampler,vec3<f32>(-rv.x,-rv.y,rv.z),log2(frame::view.viewport.w/640.0)).rgb;
let lum=0.3*saturate(4.0*wm.y-2.6);
spec+=cube*(lml.g+lum*(1.0-lml.g))*wm.y*1.5;
}
lin=(lml*kd*d+spec)*p.water[0].y;
alpha=max(spec.g,p.water[3].w);
} else if fam == 9u || fam == 10u {
lin = d * max(lm*lm,vec3<f32>(p.family.y)) * p.family.x; lin = d * max(lm*lm,vec3<f32>(p.family.y)) * p.family.x;
if fam == 9u { lin *= p.family.z; } if fam == 9u { lin *= p.family.z; }
alpha = a.a; alpha = a.a;
+5 -4
View File
@@ -395,8 +395,7 @@ fn material_ids(
m.alpha_mode, m.alpha_mode,
m.alpha_cutoff.to_bits(), m.alpha_cutoff.to_bits(),
]; ];
let retail = m.retail_definition.as_deref().and_then(crate::retail_render::Definition::parse) 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) *unique.entry((key, retail)).or_insert(i)
}) })
.collect() .collect()
@@ -427,6 +426,7 @@ pub(crate) fn spawn(
materials: &mut Assets<StandardMaterial>, materials: &mut Assets<StandardMaterial>,
retail_materials: &mut Assets<crate::retail_render::RetailWorldMaterial>, retail_materials: &mut Assets<crate::retail_render::RetailWorldMaterial>,
images: &mut Assets<Image>, images: &mut Assets<Image>,
tuning: &crate::retail_render::MaterialTuning,
) { ) {
// Texture roles have different transfer functions even when sharing a record. // Texture roles have different transfer functions even when sharing a record.
let texture_ids = render_texture_ids(&map.textures); let texture_ids = render_texture_ids(&map.textures);
@@ -574,8 +574,8 @@ pub(crate) fn spawn(
} }
} }
if let Some(definition) = m.retail_definition.as_deref() if let Some(definition) = m.retail_definition.as_deref()
.and_then(crate::retail_render::Definition::parse).filter(|d| d.supported()) { .and_then(crate::retail_render::Definition::parse).filter(|d| d.supported(tuning)) {
let material = definition.build(m, &mut texture); let material = definition.build(m, tuning, &mut texture);
let material = retail_materials.add(material); let material = retail_materials.add(material);
commands.spawn((Name::new(m.name.clone()), Mesh3d(meshes.add(mesh)), MeshMaterial3d(material), Transform::default())); commands.spawn((Name::new(m.name.clone()), Mesh3d(meshes.add(mesh)), MeshMaterial3d(material), Transform::default()));
continue; continue;
@@ -862,6 +862,7 @@ mod tests {
&mut materials, &mut materials,
&mut retail_materials, &mut retail_materials,
&mut images, &mut images,
&crate::retail_render::MaterialTuning::default(),
); );
queue.apply(&mut world); queue.apply(&mut world);
assert_eq!(meshes.len(), 1); assert_eq!(meshes.len(), 1);
+1 -1
View File
@@ -38,7 +38,7 @@ fn spawn(
if let Some(map) = config.map.take() { if let Some(map) = config.map.take() {
// Physics has already consumed the package. Render assets own their // Physics has already consumed the package. Render assets own their
// uploaded data; keeping another full city package wastes gigabytes. // 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::skate_world::spawn(&map, &mut commands, &mut meshes, &mut materials, &mut retail_materials, &mut images, &crate::retail_render::MaterialTuning::load(&config.asset_root));
crate::retail_render::spawn_backdrop(&map.name, &config.asset_root, &mut commands, &mut meshes, &mut materials, &mut retail_materials, &mut images); crate::retail_render::spawn_backdrop(&map.name, &config.asset_root, &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, &mut retail_materials); crate::retail_render::spawn_sky(&map.name, &config.asset_root, &mut commands, &mut meshes, &mut images, &mut sky_materials, &mut retail_materials);
return; return;
+7
View File
@@ -174,3 +174,10 @@ including smooth probe transitions, hitch limiting, caster isolation and cascade
configuration. Release compilation passes. The updated private University configuration. Release compilation passes. The updated private University
launcher records stdout/stderr, backtraces and the exit code in launcher records stdout/stderr, backtraces and the exit code in
`logs/renderer-work/player-shadow-runtime.log`. It has not been executed. `logs/renderer-work/player-shadow-runtime.log`. It has not been executed.
## Later visual pass
See [visual-parity-status.md](visual-parity-status.md) for the subsequent SH
transition smoothing, second-UV blended hair coverage, GPU exposure meter and
remaining shadow/CAC fidelity gaps. Earlier implementation sections are historical.
+14 -30
View File
@@ -86,36 +86,19 @@ selection and regional/streaming state also remain unverified.
## Remaining differences ## Remaining differences
This is not a claim of pixel parity. Native frame constants are supplied by The current pass enables authored distance fog, GPU exposure adaptation, flowing
game hooks in the reference; this engine does not yet recover that controller. water, scrolling emissive signs, the global reflective water sheets and additional
Scene exposure 2.5, world material multiplier 1, and tree/proxy values still use hair strand coverage. See [visual-parity-status.md](visual-parity-status.md) for
documented day-capture defaults. Imported maps have no additive directional sun: provenance, validation and the distinction between native equations and adapters.
their world lighting comes from baked lightmaps, with a separate player-only
projected shadow receiver described in the character-lighting notes. Normal-map
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.
Native water, SSAO, SSR, bloom and volumetric lighting are This is not pixel parity. Native regional environment selection is not ported;
not yet ported. Character lighting now has a dedicated adapter; see static map selection supplies the fog/sun and the default exposure tuning. The
[character-lighting-investigation.md](character-lighting-investigation.md) for exposure meter uses portable HDR luminance samples instead of the native resolve
implemented terms, spatial data, validation and remaining parity differences. surface. Shadow colour/filtering and CAC hair colour-register composition still
The water arrays are recoverable now, but flowing-water have documented approximations. Ocean PCA wave reconstruction now uses the owned 30-frame table; its native
normal/tangent unpacking, ocean PCA input bindings and the separate horizon phase/reset controller and the separate non-flowing water shader remain unported. SSAO, SSR, bloom and volumetric lighting
water resource still need their own adapters. Character parity needs the are also outside the currently implemented renderer. Missing parameters retain
native CAC-composed texture/mask inputs plus verified key/rim/specular and fallback materials; these effects are not silently labelled complete.
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.
Characters without the new lighting sidecars 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 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. from the user's game and must never be committed or bundled with releases.
@@ -163,7 +146,8 @@ the tree-family presentation meshes into `private/native-backdrops/<map>.skate`.
Mesh-to-material and material-to-texture selection use binary GUIDs: texture Mesh-to-material and material-to-texture selection use binary GUIDs: texture
name suffixes differ from the global texture resource GUIDs. Original positions, name suffixes differ from the global texture resource GUIDs. Original positions,
indices, UVs, masked alpha, two-sided rendering and baked lightmaps are retained. indices, UVs, masked alpha, two-sided rendering and baked lightmaps are retained.
No water surfaces or synthetic forest placements are added. At that stage only tree-family meshes were selected. The later visual pass also
retains the authored global reflective water sheets; no synthetic placements are added.
The renderer loads this supplemental package after the district and before sky The renderer loads this supplemental package after the district and before sky
setup, through the existing retail mesh/material path. `parse_render_only` is setup, through the existing retail mesh/material path. `parse_render_only` is
+124
View File
@@ -0,0 +1,124 @@
# Visual parity status — 2026-09-08␍
␍
Implemented and compiled offline; game/recomp not launched. User-run checks are␍
still needed for the previously reported crash, shadow silhouette and performance.␍
No physics, collision, gameplay or animation logic was changed.␍
␍
## Authored distance fog␍
␍
**Observed:** TU3 `0x828012D0` loads the fog collection at manager +180. Schema␍
`material_fog` offsets are colour=0, power=16, near=20, max=24, far=28. The routine␍
writes ramp `(1/(far-near), -near/(far-near), power)` and colour␍
`(colour.rgb*max, -max)`. The negative-one vector at `0x830BD4A0` is initialized by␍
`0x82F825F0` from float -1 at `0x8216DEE0`; its zero bytes in a static dump must␍
not be interpreted as the initialized value. Colour is not squared here.␍
␍
The reference world shader applies `f=pow(saturate(distance*scale+bias),power)`␍
and `linear*(1-max*f)+colour.rgb*max*f`, before exposure. The exporter retains␍
both the authored inputs and these derived rows. All ten private sky metadata␍
files are refreshed. Regional transitions remain unimplemented.␍
␍
## Exposure␍
␍
**Observed:** `0x827F0D00` reads the GPU measurement, scales the weighted mean by␍
2.515 (`0x821A01E8`), and evaluates␍
`clamp(previous*(1 + damping*(target-measured)), min, max)`.␍
The tuning schema independently confirms offsets 120/124/128/132 for␍
`auto_exposure_target_luminance/min/max/damping`. The owned default collection␍
contains target .25, min .75, max 2.5, damping .5. `0x827F0B78` uses squared␍
`1-abs(x*y)` centre weighting. The comparison renderer normally pins exposure at␍
zone max because its resolve readbacks are disabled; that is not console adaptation.␍
␍
**Adapter:** one 256-thread compute group meters a 16x16 bilinear sample grid of␍
linear HDR using Rec.709 luminance, native centre weighting and the native update␍
formula. It uses a 30 Hz time basis and caps hitch contribution at 50 ms. This is␍
not the native packed, resolved measurement surface or regional tuning selection.␍
The compute result stays on the GPU; the existing final tone pass consumes it.␍
There is no synchronous CPU readback and no per-material exposure update.␍
`SKATE_FIXED_EXPOSURE=1` retains 2.5 for comparison. Missing metadata also retains␍
fixed exposure. No claim is made that this meter's measured values match console.␍
␍
## Water and other world materials␍
␍
The material adapter now retains its shader key and loads authored VLT parameter␍
arrays from `private/render-parameters.json`. Additional supported families:␍
␍
- 30, `water.flowing*`: two time-scrolled normal taps, mapped normal, refracted␍
lightmap coordinates, player-shadow minimum clamp, sun specular, cube reflection␍
and native alpha floor. Follows reference `scene_water.hlsli`'s flowing shader.␍
Texture row/UV flips are accounted for in scale, scroll and refraction offsets.␍
- 14, `incandescent.backlituvscroll`: authored `uAnimationSpeed/vAnimationSpeed`,␍
squared diffuse and material multiplier, followed by fog/exposure.␍
- 32, `ocean.reflection`: shader support for squared horizon colour and the authored␍
height fade. This is not the animated ocean-surface shader (family 31).␍
␍
Global model export also includes authored `environment.reflective_simple` water␍
sheets previously excluded by the foliage-only filter. Their original GUID-bound␍
textures and mesh positions are retained in render-only supplements. These have␍
no collision and never enter physics. Three maps now have global supplements.␍
␍
**Ocean PCA follow-up:** `0x827905B0` initializes a 30-frame table with means at␍
`0x830118D8` (3 floats/frame) and weights at `0x83011A40` (24 floats/frame).␍
`0x82790858` selects frames at 1/30-second intervals, scales mean/weights by␍
1/255, and publishes the seven rows. The reference shader binding swizzles mean␍
X/Z/Y and weight pairs R/B/G. The build-checked `ocean_pca` extractor preserves␍
those tables in a private sidecar. Family 31 now reconstructs normals from both␍
PCA component textures and these animated rows, then evaluates Fresnel cube␍
reflection, squared lightmap and Ward sun specular from the native shader.␍
The adapter selects the authored frame by elapsed time; native phase/reset and␍
hitch advancement are not reproduced. It requires the supported owned mapped␍
image for extraction; missing/invalid PCA metadata retains the prior fallback.␍
It does not invent procedural waves.␍
␍
**Still unported:** the distinct non-flowing `water.default`/`water.alpha` shader.␍
The local reference also uses an empirical fallback for this family, so its exact␍
shader contract needs separate recovery.␍
␍
## Character and shadows␍
␍
The native nearest-probe selection/cache remains intact. Displayed SH coefficients␍
now ease between samples using the same bounded .35-second adapter transition as␍
the shadow floor. All ten models retain their existing skinning and poses.␍
␍
The GLB exporter now preserves the hair's raw second UV set. RX2 declaration
`0x002C23A5` is format 37 (big-endian float2), confirmed against the native
vertex decoder. The old skeleton parser incorrectly read it as four normalized
shorts. That decoder is corrected, and glTF accessor shape checks now reject
four-component data declared as VEC2. The character␍
sidecar references the original coverage texture; the shader samples its red␍
channel on that second UV, scales it by `m_params[6].x` (CAC hair c15.x), and uses␍
blended strand coverage. Its depth path retains strand holes with the world␍
30/255 threshold, an adapter choice. Existing dye/rim composition remains an␍
approximation: live CAC colour-register derivation is still outstanding.␍
␍
The previous player-only shadow source and smooth shared shadow floor remain.␍
Its shared storage buffer is now 144 bytes to carry animation time and ocean PCA; neither␍
world materials nor their bind groups are rebuilt as these values change. Exact␍
console atlas/filtering and the per-frame shadow-colour controller remain pending.␍
␍
## Existing maps␍
␍
Freshly decoded RX2 caches refreshed 2,019 of 7,204 texture payloads across ten␍
private maps. Each map passes the offline reader, and its entire geometry,␍
collision and metadata tail is byte-identical to the installed input. Material␍
prefixes are preserved. Original installed maps are untouched. New refresh tooling␍
publishes atomically and refuses in-place input overwrite.␍
␍
## Evidence and validation␍
␍
PPC source and raw dump identity: base `0x82000000`, SHA-256␍
`F4AA113EB541BFBA03DBC108CF5AB43F58C965B20FA3B82F9C40938A0AD841C4`.␍
Local native renderer reference commit␍
`f6e0ae87fdfecbadb5c1e36c55d66a744187a3cd`, shaders `scene.hlsl`,␍
`scene_water.hlsli`, `scene_char.hlsli`, plus `skate3_native_render.cpp`.␍
Private evidence lives under `logs/renderer-work`: `exposure-evaluator.txt`,␍
`sub_828012D0-fog-candidate.txt`, per-map texture reports and␍
`visual-refresh-validation.json`. Owned data is not committed.␍
␍
Offline checks cover shader composition, exporter fog boundaries, invalid refresh␍
rollback and existing asset regressions, plus the windowless Rust rendering tests.␍
These do not validate GPU execution, visual parity, frame time or the prior crash.␍
Final offline validation: 20 Python regression tests, six filtered Rust tests,
three render batching tests, six composed shaders and the release build passed.
All ten refreshed maps passed the offline reader and preserved geometry tails.
+1 -1
View File
@@ -72,7 +72,7 @@ def convert(game_root, assets, converted):
raw, prep._group_material_parameters(model.materials), len(model.meshes), raw, prep._group_material_parameters(model.materials), len(model.meshes),
allow_import_order_fallback=False) allow_import_order_fallback=False)
selected = [(i, prep._material_metadata(groups, i)) for i in range(len(model.meshes))] selected = [(i, prep._material_metadata(groups, i)) for i in range(len(model.meshes))]
selected = [(i, m) for i, m in selected if m['shader_name'] in ('tree.default', 'animated.tree')] selected = [(i, m) for i, m in selected if m['shader_name'] in ('tree.default', 'animated.tree', 'ocean.reflection', 'environment.reflective_simple')]
if not selected: if not selected:
continue continue
textures = rx2_parser.parse_rx2(archive.read(entries['data/content/' + texture_name + '.rx2'])) textures = rx2_parser.parse_rx2(archive.read(entries['data/content/' + texture_name + '.rx2']))
+5
View File
@@ -16,6 +16,9 @@ class Glb:
return len(self.doc['bufferViews'])-1 return len(self.doc['bufferViews'])-1
def accessor(self,values,kind,component=5126,bounds=False): def accessor(self,values,kind,component=5126,bounds=False):
a=np.asarray(values,dtype={5126:'<f4',5123:'<u2',5125:'<u4'}[component]) a=np.asarray(values,dtype={5126:'<f4',5123:'<u2',5125:'<u4'}[component])
width={'SCALAR':1,'VEC2':2,'VEC3':3,'VEC4':4,'MAT4':16}[kind]
if (kind=='SCALAR' and a.ndim!=1) or (kind!='SCALAR' and (a.ndim!=2 or a.shape[1]!=width)):
raise ValueError('Character accessor shape does not match '+kind)
if component==5126 and not np.isfinite(a).all():raise ValueError('Non-finite character data') if component==5126 and not np.isfinite(a).all():raise ValueError('Non-finite character data')
item={'bufferView':self.view(a.tobytes()),'componentType':component,'count':len(a),'type':kind} item={'bufferView':self.view(a.tobytes()),'componentType':component,'count':len(a),'type':kind}
if bounds:item.update(min=a.min(axis=0).tolist(),max=a.max(axis=0).tolist()) if bounds:item.update(min=a.min(axis=0).tolist(),max=a.max(axis=0).tolist())
@@ -107,6 +110,8 @@ def convert(models,private,recipe):
attributes={'POSITION':glb.accessor(positions,'VEC3',bounds=True),'NORMAL':glb.accessor(normals,'VEC3'), attributes={'POSITION':glb.accessor(positions,'VEC3',bounds=True),'NORMAL':glb.accessor(normals,'VEC3'),
'TEXCOORD_0':glb.accessor(raw['uvs'],'VEC2'),'JOINTS_0':glb.accessor(joints,'VEC4',5123), 'TEXCOORD_0':glb.accessor(raw['uvs'],'VEC2'),'JOINTS_0':glb.accessor(joints,'VEC4',5123),
'WEIGHTS_0':glb.accessor(weights,'VEC4')} 'WEIGHTS_0':glb.accessor(weights,'VEC4')}
if raw.get('uvs2'):
attributes['TEXCOORD_1']=glb.accessor(raw['uvs2'],'VEC2')
primitives.append({'attributes':attributes,'indices':glb.accessor(np.asarray(mesh['tris']).ravel(),'SCALAR',5125), primitives.append({'attributes':attributes,'indices':glb.accessor(np.asarray(mesh['tris']).ravel(),'SCALAR',5125),
'material':len(glb.doc['materials'])-1}) 'material':len(glb.doc['materials'])-1})
mesh_node=len(nodes);nodes.append({'name':'Skate3_SkaterAndBoard','mesh':0,'skin':0}) mesh_node=len(nodes);nodes.append({'name':'Skate3_SkaterAndBoard','mesh':0,'skin':0})
@@ -30,6 +30,10 @@ def convert(models, private, converted):
rows = fields[key_hash('m_params')]['array']['items'] rows = fields[key_hash('m_params')]['array']['items']
result['materials']['Retail_'+slot.name] = dict(shader=shader, result['materials']['Retail_'+slot.name] = dict(shader=shader,
params=[struct.unpack('>4f', bytes.fromhex(row)) for row in rows]) params=[struct.unpack('>4f', bytes.fromhex(row)) for row in rows])
coverage = components[slot.name]['textures'].get('alpha')
if shader == 'character.hair' and coverage:
result['materials']['Retail_'+slot.name]['coverage'] = (
'private/default_skater/textures/decoded/'+coverage+'.png')
specular = components[slot.name]['textures'].get('specular') specular = components[slot.name]['textures'].get('specular')
if specular: if specular:
# The native skin shader consumes R, not luminance or roughness. # The native skin shader consumes R, not luminance or roughness.
+18 -2
View File
@@ -62,8 +62,7 @@ def world_environment(collections, world_key):
'anchor_height': floats(field(location, 'Hash_2E9AAECD1C29F81F'), 1)[0], 'anchor_height': floats(field(location, 'Hash_2E9AAECD1C29F81F'), 1)[0],
'sky_model': field(world, 'Hash_C7A0A84F018E87BA'), 'sky_model': field(world, 'Hash_C7A0A84F018E87BA'),
'sky_textures': field(world, 'Hash_2A0BF629355AFB15'), 'sky_textures': field(world, 'Hash_2A0BF629355AFB15'),
# These are authored inputs, not shader constants. The controller's # Keep the authored values alongside the derived native shader rows.
# colour/ramp transformation still needs to be recovered.
'fog': {'chain': fog_chain, **{n: floats(field(fog, n), 4 if n == 'fog_colour' else 1) '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')}}, for n in ('fog_near', 'fog_far', 'fog_colour', 'fog_power', 'fog_max')}},
} }
@@ -81,3 +80,20 @@ def sky_parameters(collections, shader):
if row[0] <= 0 or row[1] < 0: if row[0] <= 0 or row[1] < 0:
raise ValueError('Invalid authored sky parameters') raise ValueError('Invalid authored sky parameters')
return {'material_chain': chain, 'sun_scale': row[0], 'multiplier': row[1]} return {'material_chain': chain, 'sun_scale': row[0], 'multiplier': row[1]}
def fog_parameters(fog):
"""TU3 0x828012D0: schema offsets 20/28/16/24 -> ramp and colour.
0x82F825F0 initializes the negative-one vector used for colour alpha.
Colour RGB is already linear here; do not square it again.
"""
near, far, power, maximum = (fog[n][0] for n in
('fog_near', 'fog_far', 'fog_power', 'fog_max'))
colour = fog['fog_colour']
if (len(colour) != 4 or not all(math.isfinite(v) for v in
[near, far, power, maximum, *colour]) or far <= near or near < 0
or power <= 0 or not 0 <= maximum <= 1):
raise ValueError('Invalid authored fog range/colour')
return {'ramp': [1 / (far - near), -near / (far - near), power, 0],
'colour': [v * maximum for v in colour[:3]] + [-maximum]}
+2
View File
@@ -197,6 +197,8 @@ def install(iso,base,game_exe,report,game_root=None):
report('Preparing retail sky domes') report('Preparing retail sky domes')
from .sky import convert as write_skies from .sky import convert as write_skies
write_skies(game_root,stage/'assets',converted) write_skies(game_root,stage/'assets',converted)
from .render_parameters import convert as write_render_parameters
write_render_parameters(stage/'assets',converted)
report('Preparing global foliage backdrops') report('Preparing global foliage backdrops')
from .backdrop import convert as write_backdrops from .backdrop import convert as write_backdrops
write_backdrops(game_root,stage/'assets',converted) write_backdrops(game_root,stage/'assets',converted)
+41
View File
@@ -0,0 +1,41 @@
"""Extract TU3's built-in PCA ocean animation from an owned mapped image.
Addresses are corroborated by cPCAWaterAnimationData::Init (827905B0) and
its update routine (82790858). No game content is embedded in this tool.
"""
import hashlib
import json
import math
import struct
from pathlib import Path
def extract(image, output, base=0x82000000):
data = Path(image).read_bytes()
identity = hashlib.sha256(data).hexdigest()
if identity != 'f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4':
raise ValueError('Unsupported mapped executable build for PCA table extraction')
frames = []
for frame in range(30):
mean = struct.unpack_from('>3f', data, 0x830118D8-base+frame*12)
weights = struct.unpack_from('>24f', data, 0x83011A40-base+frame*96)
if not all(math.isfinite(v) for v in (*mean, *weights)):
raise ValueError('Non-finite ocean PCA table')
# Shader model order: mean X/Z/Y and weight pairs R/B/G.
rows = [[mean[0]/255., mean[2]/255., mean[1]/255., 0.]]
for start in (0, 4, 16, 20, 8, 12):
rows.append([v/255. for v in weights[start:start+4]])
frames.append(rows)
output = Path(output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(dict(source_sha256=identity, hz=30., frames=frames)), encoding='utf-8')
return len(frames)
if __name__ == '__main__':
import argparse
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--image', type=Path, required=True)
p.add_argument('--output', type=Path, required=True)
a = p.parse_args()
print('Extracted', extract(a.image, a.output), 'ocean PCA frames')
+105
View File
@@ -0,0 +1,105 @@
"""Refresh SKATE14 texture payloads from a newly decoded RX2 manifest cache.
Material, geometry, collision and metadata bytes remain unchanged. Publication
is atomic and never overwrites the input map. No renderer is launched.
"""
import hashlib
import json
import os
import struct
import tempfile
import zlib
from pathlib import Path
import numpy as np
def refresh(manifest, source, output):
manifest, source, output = map(Path, (manifest, source, output))
if source.resolve() == output.resolve():
raise ValueError('Refresh requires a separate output map')
data = json.loads(manifest.read_text())
output.parent.mkdir(parents=True, exist_ok=True)
temporary = None
try:
with source.open('rb') as src:
def read(n):
value = src.read(n)
if len(value) != n:
raise ValueError('Truncated SKATE14 package')
return value
def uint():
return struct.unpack('<I', read(4))[0]
def text():
return read(uint()).decode('utf-8')
if read(8) != b'SKATE14\0' or uint() != 0x12345678:
raise ValueError('Texture refresh requires SKATE14')
text()
read(49 * 4)
counts = [uint() for _ in range(9)]
for _ in range(counts[0]):
text()
read(4 + 7*4 + 8 + 4 + 16 + 4 + 16)
if uint():
read(16)
text()
read(8)
for _ in range(uint()):
text()
read(16)
for _ in range(uint()):
text()
for _ in range(uint()):
text()
text()
prefix_end = src.tell()
src.seek(0)
prefix = read(prefix_end)
changed = []
with tempfile.NamedTemporaryFile(dir=output.parent, suffix='.skate.tmp', delete=False) as dst:
temporary = Path(dst.name)
dst.write(prefix)
for _ in range(counts[1]):
start = src.tell()
name = text()
width, height, _space = uint(), uint(), uint()
data_start = src.tell()
method, size = uint(), uint()
packed = read(size)
if method not in (0, 1):
raise ValueError('Unsupported texture compression')
old = zlib.decompress(packed) if method else packed
end = src.tell()
entry = data['textures'][name]
if (width, height) != (entry['width'], entry['height']):
raise ValueError('Texture dimensions changed: ' + name)
cache = (manifest.parent / entry['rgba']).read_bytes()
if len(cache) != width*height*4 or len(old) != len(cache):
raise ValueError('Texture byte count mismatch: ' + name)
array = np.frombuffer(cache, np.uint8).reshape(height, width, 4)
new = (array if entry.get('cube_faces') == 6 else array[::-1]).tobytes()
src.seek(start)
dst.write(read(data_start-start))
src.seek(end)
if new != old:
replacement = zlib.compress(new, 1)
dst.write(struct.pack('<II', 1, len(replacement)))
dst.write(replacement)
changed.append(dict(name=name, width=width, height=height, format=entry.get('format'),
changed_pixels=int(np.any(np.frombuffer(old, np.uint8).reshape(-1, 4) !=
np.frombuffer(new, np.uint8).reshape(-1, 4), axis=1).sum())))
else:
dst.write(struct.pack('<II', method, size))
dst.write(packed)
old_tail_offset, new_tail_offset = src.tell(), dst.tell()
tail_hash = hashlib.sha256()
while chunk := src.read(1024*1024):
tail_hash.update(chunk)
dst.write(chunk)
os.replace(temporary, output)
temporary = None
return dict(changed=changed, count=len(changed), textures=counts[1],
prefix_sha256=hashlib.sha256(prefix).hexdigest(), tail_sha256=tail_hash.hexdigest(),
old_tail_offset=old_tail_offset, new_tail_offset=new_tail_offset)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
+29
View File
@@ -0,0 +1,29 @@
"""Export complete authored parameter rows for additional presentation shaders."""
import json
import struct
from .environment import Collections, key_hash
def convert(assets, converted):
collections = Collections(converted)
result = {}
aliases = ('default', 'reflection', 'backlituvscroll', 'transparent',
'flowing', 'flowingalpha', 'alpha', 'skatepark', 'videoscreen')
for (cls, key), row in collections.rows.items():
for family in ('water', 'ocean', 'incandescent'):
if cls != key_hash('material_' + family):
continue
fields, _ = collections.resolve(cls, key)
raw = fields.get(key_hash('m_params'), {}).get('array', {}).get('items', [])
if not raw:
continue
name = next((n for n in aliases if key_hash(n) == key), row['key'])
result[family + '.' + name] = [struct.unpack('>4f', bytes.fromhex(r)) for r in raw]
path = assets / 'private/render-parameters.json'
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(result), encoding='utf-8')
fields, _ = collections.resolve('Hash_1FFDC8E3ACA07C1F', 'auto_exposure')
exposure = {n: struct.unpack('>f', bytes.fromhex(fields[key_hash('auto_exposure_'+n)]['data']))[0]
for n in ('target_luminance', 'min', 'max', 'damping')}
(assets / 'private/exposure.json').write_text(json.dumps(exposure), encoding='utf-8')
return len(result)
+2 -1
View File
@@ -4,7 +4,7 @@ import struct
import sys import sys
from pathlib import Path from pathlib import Path
from tools.owned_game.big import BigArchive from tools.owned_game.big import BigArchive
from .environment import Collections, NAMES, field, key_hash, sky_parameters, world_environment from .environment import Collections, NAMES, field, key_hash, sky_parameters, world_environment, fog_parameters
def _material(raw, table): def _material(raw, table):
@@ -72,6 +72,7 @@ def convert(game_root, assets, converted):
sun = _texture(textures, channels['specular']) sun = _texture(textures, channels['specular'])
shader = next(p.value for p in model.materials if p.kind == 'AttribulatorMaterialName') shader = next(p.value for p in model.materials if p.kind == 'AttribulatorMaterialName')
env.update(sky_parameters(collections, shader)) env.update(sky_parameters(collections, shader))
env['fog_frame'] = fog_parameters(env['fog'])
if len(model.meshes) != 1 or model.meshes[0].uvs is None: if len(model.meshes) != 1 or model.meshes[0].uvs is None:
raise ValueError('Unexpected sky mesh layout') raise ValueError('Unexpected sky mesh layout')
mesh = model.meshes[0] mesh = model.meshes[0]
+31
View File
@@ -0,0 +1,31 @@
"""Regressions for the RX2 hair float UV declaration and glTF layout."""
import struct
import unittest
from tools.asset_pipeline.character_glb import Glb, RX2
class CharacterUvTests(unittest.TestCase):
def test_float_uv_preserves_signed_values_and_stride(self):
fmt, size = RX2.VTX_FORMATS[0x002C23A5]
self.assertEqual((fmt, size), ('FLOAT2', 8))
source = b'head' + struct.pack('>I2fI2f', 0, -0.25, 1.5, 0, 0.125, 0.75)
descriptor = {'stride': 12, 'elements': [
{'usage_name': 'TEXCOORD', 'usage_index': 1,
'format_name': fmt, 'offset': 4}]}
uv = RX2._decode_vertices(source, 4, 2, descriptor)['uvs2']
self.assertEqual(uv, [(-0.25, 1.5), (0.125, 0.75)])
glb = Glb()
index = glb.accessor(uv, 'VEC2')
accessor = glb.doc['accessors'][index]
self.assertEqual(accessor['count'], 2)
self.assertEqual(glb.doc['bufferViews'][accessor['bufferView']]['byteLength'], 16)
self.assertEqual(struct.unpack('<4f', glb.data), (-0.25, 1.5, 0.125, 0.75))
def test_accessor_rejects_wrong_component_count(self):
with self.assertRaisesRegex(ValueError, 'shape'):
Glb().accessor([(1, 2, 3, 4)], 'VEC2')
if __name__ == '__main__':
unittest.main()
+17 -1
View File
@@ -1,7 +1,7 @@
"""Synthetic environment references, independent of owned game assets.""" """Synthetic environment references, independent of owned game assets."""
import struct import struct
import unittest import unittest
from .environment import Collections, field, key_hash, sky_parameters, world_environment from .environment import Collections, field, key_hash, sky_parameters, world_environment, fog_parameters
def raw(*values): def raw(*values):
@@ -35,6 +35,22 @@ class EnvironmentTests(unittest.TestCase):
self.assertEqual(env['fog']['fog_far'], [1000]) self.assertEqual(env['fog']['fog_far'], [1000])
self.assertEqual(env['sky_model'], 'world/models/sky') self.assertEqual(env['sky_model'], 'world/models/sky')
def test_native_fog_rows_and_distance_boundaries(self):
fog = dict(fog_near=[100.], fog_far=[2000.], fog_power=[2.],
fog_max=[.5], fog_colour=[.1, .2, .3, 1.])
frame = fog_parameters(fog)
self.assertEqual(frame['colour'], [.05, .1, .15, -.5])
def opacity(distance):
x = max(0., min(1., distance * frame['ramp'][0] + frame['ramp'][1]))
return .5 * x ** frame['ramp'][2]
self.assertEqual(opacity(0.), 0.)
self.assertAlmostEqual(opacity(100.), 0.)
self.assertAlmostEqual(opacity(1050.), .125)
self.assertAlmostEqual(opacity(2000.), .5)
self.assertAlmostEqual(opacity(3000.), .5)
fog['fog_far'] = [100.]
with self.assertRaises(ValueError): fog_parameters(fog)
def test_unnamed_hashes_and_cycle_rejection(self): def test_unnamed_hashes_and_cycle_rejection(self):
cls = f'Hash_{key_hash("world"):016X}' cls = f'Hash_{key_hash("world"):016X}'
c = Collections({'collections': [row(cls, 'a', 'b', value='child'), c = Collections({'collections': [row(cls, 'a', 'b', value='child'),
@@ -0,0 +1,35 @@
import json
import struct
import tempfile
import unittest
from pathlib import Path
from .refresh_textures import refresh
class RefreshTests(unittest.TestCase):
def test_payload_refresh_preserves_tail_and_failed_refresh_preserves_output(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
string = lambda x: struct.pack('<I', len(x)) + x
prefix = b'SKATE14\0' + struct.pack('<I', 0x12345678) + string(b'fixture') + bytes(49*4)
prefix += struct.pack('<9I', 0, 1, 0, 0, 0, 0, 0, 0, 0)
old = bytes(16)
header = string(b'texture') + struct.pack('<3I', 2, 2, 0)
tail = b'geometry/collision/metadata-preservation-fixture'
source = root/'input.skate'
source.write_bytes(prefix + header + struct.pack('<II', 0, 16) + old + tail)
cache = bytes(range(16))
(root/'new.rgba').write_bytes(cache)
manifest = root/'manifest.json'
manifest.write_text(json.dumps({'textures': {'texture': dict(width=2, height=2, rgba='new.rgba')}}))
output = root/'out.skate'
report = refresh(manifest, source, output)
self.assertEqual(report['count'], 1)
self.assertEqual(output.read_bytes()[:len(prefix)], prefix)
self.assertEqual(output.read_bytes()[report['new_tail_offset']:], tail)
saved = output.read_bytes()
(root/'new.rgba').write_bytes(b'bad')
with self.assertRaises(ValueError): refresh(manifest, source, output)
self.assertEqual(output.read_bytes(), saved)
with self.assertRaises(ValueError): refresh(manifest, source, source)
self.assertFalse(list(root.glob('*.tmp')))
+6
View File
@@ -4,6 +4,12 @@
`https://github.com/chasmlol/skate-3-trick-toolkit` commit `https://github.com/chasmlol/skate-3-trick-toolkit` commit
`85d9d679bc35462ce66fdbba95007b56a94579f0`. `85d9d679bc35462ce66fdbba95007b56a94579f0`.
Local correction (2026-09-08): `rx2_skeleton.py` decodes vertex declaration
`0x002C23A5` as big-endian FLOAT2 (format 37), replacing the incorrect SHORT4N
interpretation. This preserves the original secondary UV coordinates used by
hair strand coverage. See `docs/visual-parity-status.md` and the synthetic
`tools/asset_pipeline/test_character_uv.py` regression.
`blender_rx2_abin_export.py` is the project-maintained extension of that `blender_rx2_abin_export.py` is the project-maintained extension of that
exporter (SHA-256 exporter (SHA-256
`58BF997204228E6C25942AFCB7BACE48E19BD478EBBD2E2E8C83695BB8417363`). `58BF997204228E6C25942AFCB7BACE48E19BD478EBBD2E2E8C83695BB8417363`).
+3 -4
View File
@@ -398,7 +398,7 @@ VTX_FORMATS: Dict[int, Tuple[str, int]] = {
0x001A215A: ("SHORT4", 8), # raw int16[4] -- position 0x001A215A: ("SHORT4", 8), # raw int16[4] -- position
0x001A2286: ("UBYTE4", 4), # raw uint8[4] -- blend indices / weights 0x001A2286: ("UBYTE4", 4), # raw uint8[4] -- blend indices / weights
0x002C2159: ("SHORT2N", 4), # int16[2] / 32768 -- texcoord 0x002C2159: ("SHORT2N", 4), # int16[2] / 32768 -- texcoord
0x002C23A5: ("SHORT4N", 8), # int16[4] / 32768 -- texcoord pair 0x002C23A5: ("FLOAT2", 8), # format 37: big-endian float2, hair strand UV
0x002A2190: ("PACKED11_11_10N", 4), # signed 11/11/10 -- tangent, binormal 0x002A2190: ("PACKED11_11_10N", 4), # signed 11/11/10 -- tangent, binormal
} }
@@ -514,10 +514,9 @@ def _decode_vertices(d: bytes, vb: int, count: int, vdesc: dict) -> dict:
for v in range(count): for v in range(count):
u, w = struct.unpack_from(">2h", d, vb + v * stride + off) u, w = struct.unpack_from(">2h", d, vb + v * stride + off)
vals.append((u / 32768.0, w / 32768.0)) vals.append((u / 32768.0, w / 32768.0))
elif fmt == "SHORT4N": elif fmt == "FLOAT2":
for v in range(count): for v in range(count):
q = struct.unpack_from(">4h", d, vb + v * stride + off) vals.append(struct.unpack_from(">2f", d, vb + v * stride + off))
vals.append(tuple(c / 32768.0 for c in q))
elif fmt == "UBYTE4" and e["usage_name"] == "BLENDWEIGHT": elif fmt == "UBYTE4" and e["usage_name"] == "BLENDWEIGHT":
for v in range(count): for v in range(count):
q = struct.unpack_from(">4B", d, vb + v * stride + off) q = struct.unpack_from(">4B", d, vb + v * stride + off)