mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Extend retail environment shading and refresh texture workflow
This commit is contained in:
1 parent
b5a00ede43
commit
96adff5d96
31 files changed
+969
-90
No files matched your search
@@ -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));
|
||||||
}
|
}
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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={:?}",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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']))
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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]}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user