From e43ae32e504e5c051d11d3123445cba43f7f7948 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 4 Jul 2026 17:23:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(materials):=20EN-022=20shooter=20half=20?= =?UTF-8?q?=E2=80=94=20real=20motion=20vectors=20for=20the=20world?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 audit F8: every world material wrote velocity = (0,0), so TAA's motion-adaptive clamp never engaged for terrain / building / trees / grass — the primary mechanism behind TSR-0.5 shimmer on thin grass and sway ghosting. With the engine half (Bloom-Engine/engine PR #82: per-slot prev transforms + abi_motion_vector), the four world materials now output real motion vectors: - terrain.wgsl / building.wgsl: static geometry — previous clip = view.prev_view_proj x world (pure camera reprojection). - tree.wgsl: wind sway re-evaluated at frame.time - frame.delta_time, projected by the previous VP — sway finally moves the velocity buffer instead of ghosting through TAA at velocity 0. - grass_instanced.wgsl + the inline copy in main.ts (kept in sync, as the SH-005 note requires): per-blade sway one frame back. All velocity writes go through the ABI's abi_motion_vector (the core path's exact NDC-delta x 0.5 convention). Requires the engine lib from PR #82 (older libs compile these shaders fine — prev_view_proj and prev_mvp always existed in the ABI — but prev_mvp was filled with the current mvp there, so object motion would read zero; camera reprojection works either way). Smoked at 4K on the live game: all four materials compile and render clean with wind sway active — no smearing or warp, which a wrong velocity sign/scale would show immediately under TAA. --- assets/materials/building.wgsl | 9 ++++++++- assets/materials/grass_instanced.wgsl | 20 +++++++++++++++++++- assets/materials/terrain.wgsl | 11 ++++++++++- assets/materials/tree.wgsl | 19 ++++++++++++++++++- src/main.ts | 12 +++++++++++- 5 files changed, 66 insertions(+), 5 deletions(-) diff --git a/assets/materials/building.wgsl b/assets/materials/building.wgsl index ad85a1e..e50eb1d 100644 --- a/assets/materials/building.wgsl +++ b/assets/materials/building.wgsl @@ -19,6 +19,9 @@ struct VsOut { @builtin(position) clip_pos: vec4, @location(0) world_pos: vec3, @location(1) world_normal: vec3, + // EN-022 — clip positions for motion vectors. + @location(2) curr_clip: vec4, + @location(3) prev_clip: vec4, }; @vertex @@ -28,6 +31,9 @@ fn vs_main(in: VertexInput) -> VsOut { out.world_pos = world.xyz; out.world_normal = normalize((draw.model * vec4(in.normal, 0.0)).xyz); out.clip_pos = view.view_proj * world; + // EN-022 — static geometry: camera-reprojection motion vector. + out.curr_clip = out.clip_pos; + out.prev_clip = view.prev_view_proj * world; return out; } @@ -99,7 +105,8 @@ fn fs_main(in: VsOut) -> OpaqueOut { var out: OpaqueOut; out.hdr = vec4(lit, 1.0); out.material = vec2(0.0, 0.78); // non-metal, mid-roughness stone - out.velocity = vec2(0.0, 0.0); + // EN-022 — real motion vectors (see terrain.wgsl). + out.velocity = abi_motion_vector(in.curr_clip, in.prev_clip); out.albedo = vec4(albedo, 1.0); return out; } diff --git a/assets/materials/grass_instanced.wgsl b/assets/materials/grass_instanced.wgsl index 4453aa0..30afce8 100644 --- a/assets/materials/grass_instanced.wgsl +++ b/assets/materials/grass_instanced.wgsl @@ -54,6 +54,9 @@ struct VsOut { @location(1) world_normal: vec3, @location(2) blade_tint: vec3, @location(3) tip_weight: f32, + // EN-022 — clip positions for motion vectors. + @location(4) curr_clip: vec4, + @location(5) prev_clip: vec4, }; @vertex @@ -94,6 +97,20 @@ fn vs_main(in: InstancedVertexInput) -> VsOut { out.clip_pos = view.view_proj * vec4(world, 1.0); out.tip_weight = tip; out.blade_tint = grass.base.rgb * in.instance_tint.rgb; + + // EN-022 — previous-frame position: same sway one frame back + // (PerFrame.delta_time), projected by the previous VP. Sub-pixel + // blades finally carry real motion vectors so TAA's motion clamp + // engages (the audit's thin-grass shimmer mechanism). + let t_prev = frame.time - frame.delta_time; + let phase_p = dot(in.instance_pos.xz, frame.wind.xy * 0.6) + + t_prev * frame.wind.w; + let sway_p = sin(phase_p) * frame.wind.z * tip; + let world_p = rotated + + vec3(frame.wind.x, 0.0, frame.wind.y) * sway_p + + in.instance_pos; + out.curr_clip = out.clip_pos; + out.prev_clip = view.prev_view_proj * vec4(world_p, 1.0); return out; } @@ -152,7 +169,8 @@ fn fs_main(in: VsOut) -> OpaqueOut { var out: OpaqueOut; out.hdr = vec4(lit, 1.0); out.material = vec2(0.0, 0.92); - out.velocity = vec2(0.0, 0.0); + // EN-022 — real motion vectors, including per-blade sway. + out.velocity = abi_motion_vector(in.curr_clip, in.prev_clip); out.albedo = vec4(albedo, 1.0); return out; } diff --git a/assets/materials/terrain.wgsl b/assets/materials/terrain.wgsl index f0172c1..a44ebbd 100644 --- a/assets/materials/terrain.wgsl +++ b/assets/materials/terrain.wgsl @@ -28,6 +28,9 @@ struct VsOut { @builtin(position) clip_pos: vec4, @location(0) world_pos: vec3, @location(1) world_normal: vec3, + // EN-022 — clip positions for motion vectors. + @location(2) curr_clip: vec4, + @location(3) prev_clip: vec4, }; @vertex @@ -37,6 +40,10 @@ fn vs_main(in: VertexInput) -> VsOut { out.world_pos = world.xyz; out.world_normal = normalize((draw.model * vec4(in.normal, 0.0)).xyz); out.clip_pos = view.view_proj * world; + // EN-022 — static geometry: previous world == current world, so the + // motion vector is pure camera reprojection. + out.curr_clip = out.clip_pos; + out.prev_clip = view.prev_view_proj * world; return out; } @@ -117,7 +124,9 @@ fn fs_main(in: VsOut) -> OpaqueOut { var out: OpaqueOut; out.hdr = vec4(lit, 1.0); out.material = vec2(0.0, 0.95); - out.velocity = vec2(0.0, 0.0); + // EN-022 — real motion vectors: lets TAA's motion-adaptive clamp + // engage on the terrain (audit F8's shimmer mechanism). + out.velocity = abi_motion_vector(in.curr_clip, in.prev_clip); out.albedo = vec4(final_albedo, 1.0); return out; } diff --git a/assets/materials/tree.wgsl b/assets/materials/tree.wgsl index 68257ce..5081814 100644 --- a/assets/materials/tree.wgsl +++ b/assets/materials/tree.wgsl @@ -29,6 +29,9 @@ struct VsOut { @location(1) world_normal: vec3, // local-y of the vertex — used in fs to blend trunk vs leaf colour @location(2) local_y: f32, + // EN-022 — clip positions for motion vectors. + @location(3) curr_clip: vec4, + @location(4) prev_clip: vec4, }; @vertex @@ -61,6 +64,19 @@ fn vs_main(in: VertexInput) -> VsOut { out.world_normal = world_normal; out.clip_pos = view.view_proj * vec4(world_pos, 1.0); out.local_y = in.position.y; + + // EN-022 — previous-frame position: the same sway evaluated one + // frame back (PerFrame carries delta_time), projected by the + // previous VP. Wind sway finally produces real motion vectors + // instead of ghosting through TAA with velocity 0. + let t_prev = frame.time - frame.delta_time; + let big_p = sin(t_prev * tp.wind.w + phase); + let small_p = sin(t_prev * tp.wind.w * 2.7 + phase * 1.6) * 0.35; + let sway_prev = (big_p + small_p) * tp.wind.z * w; + let prev_world = (draw.model * vec4(in.position, 1.0)).xyz + + vec3(tp.wind.x, 0.0, tp.wind.y) * sway_prev; + out.curr_clip = out.clip_pos; + out.prev_clip = view.prev_view_proj * vec4(prev_world, 1.0); return out; } @@ -137,7 +153,8 @@ fn fs_main(in: VsOut) -> OpaqueOut { var out: OpaqueOut; out.hdr = vec4(lit, 1.0); out.material = vec2(0.0, 0.85); - out.velocity = vec2(0.0, 0.0); + // EN-022 — real motion vectors, including the wind sway. + out.velocity = abi_motion_vector(in.curr_clip, in.prev_clip); out.albedo = vec4(albedo, 1.0); return out; } diff --git a/src/main.ts b/src/main.ts index 8b3b445..922f9bc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -675,6 +675,8 @@ const GRASS_INSTANCED_WGSL = ' @location(1) world_normal: vec3,\n' + ' @location(2) blade_tint: vec3,\n' + ' @location(3) tip_weight: f32,\n' + + ' @location(4) curr_clip: vec4,\n' + + ' @location(5) prev_clip: vec4,\n' + '};\n' + '\n' + '@vertex fn vs_main(in: InstancedVertexInput) -> VsOut {\n' + @@ -694,6 +696,14 @@ const GRASS_INSTANCED_WGSL = ' out.clip_pos = view.view_proj * vec4(world, 1.0);\n' + ' out.tip_weight = tip;\n' + ' out.blade_tint = grass.base.rgb * in.instance_tint.rgb;\n' + + // EN-022 — previous-frame sway → real motion vectors (mirrors + // assets/materials/grass_instanced.wgsl; keep in sync). + ' let t_prev = frame.time - frame.delta_time;\n' + + ' let phase_p = dot(in.instance_pos.xz, frame.wind.xy * 0.6) + t_prev * frame.wind.w;\n' + + ' let sway_p = sin(phase_p) * frame.wind.z * tip;\n' + + ' let world_p = rotated + vec3(frame.wind.x, 0.0, frame.wind.y) * sway_p + in.instance_pos;\n' + + ' out.curr_clip = out.clip_pos;\n' + + ' out.prev_clip = view.prev_view_proj * vec4(world_p, 1.0);\n' + ' return out;\n' + '}\n' + '\n' + @@ -736,7 +746,7 @@ const GRASS_INSTANCED_WGSL = ' var out: OpaqueOut;\n' + ' out.hdr = vec4(lit, 1.0);\n' + ' out.material = vec2(0.0, 0.92);\n' + - ' out.velocity = vec2(0.0, 0.0);\n' + + ' out.velocity = abi_motion_vector(in.curr_clip, in.prev_clip);\n' + ' out.albedo = vec4(albedo, 1.0);\n' + ' return out;\n' + '}\n';