facett-map3d/src/velocity.wgsl
// ── CAMERA-REPROJECTION VELOCITY (the motion vectors TAA reprojects with) ────
//
// TAA's resolve asks one question per pixel: *where was this surface last frame?*
// Answering it needs a motion vector, and a map needs no per-object one — the city
// is static, the camera is what moves. So the velocity is pure camera reprojection
// and is derived from the depth buffer the main pass already wrote: no second
// geometry pass, no per-vertex previous-position attribute.
//
// 1. take this pixel's NDC + its stored depth;
// 2. unproject with the inverse of the CURRENT frame's **jittered** view-projection
// — the matrix the geometry was actually rasterised with, so this recovers the
// exact world point that landed on this pixel;
// 3. project that world point with the current and the previous frame's
// **unjittered** view-projections;
// 4. the velocity is the difference of those two screen positions, in UV.
//
// **WHY STEP 3 DROPS THE JITTER AND STEP 2 KEEPS IT.** They are answering different
// questions. Step 2 is "which world point is under this pixel", and the jitter is
// part of that: the geometry moved by it. Step 3 is "how far did the image move",
// and the jitter must NOT be part of that — with a still camera the jitter alone
// would report a half-pixel of motion every frame, the resolve would reproject the
// history off the pixel it is meant to be accumulating on, and TAA would converge on
// nothing. Differencing two unjittered projections makes a still camera give exactly
// zero, which is the condition convergence rests on.
//
// Background pixels (depth == 1, where no geometry drew) are NOT special-cased: the
// far plane here is bounded (`View::far_plane` = distance + 4), so a far-plane point
// unprojects finitely and reprojects to the parallax-free motion of a distant point —
// which is what the sky's velocity is.
struct VelU {
/// Inverse of the CURRENT frame's jittered view-projection (clip → world).
inv_vp_jitter: mat4x4<f32>,
/// Current frame's UNJITTERED view-projection (world → clip).
vp_cur: mat4x4<f32>,
/// Previous frame's UNJITTERED view-projection. Equal to `vp_cur` on the first
/// frame after a reset, which makes the velocity identically zero there.
vp_prev: mat4x4<f32>,
/// res_x, res_y, 1/res_x, 1/res_y.
res: vec4<f32>,
};
@group(0) @binding(0) var<uniform> U: VelU;
@group(0) @binding(1) var scene_depth: texture_depth_2d;
struct VsOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vel_vs(@builtin(vertex_index) vi: u32) -> VsOut {
var p = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
var out: VsOut;
let xy = p[vi];
out.clip = vec4<f32>(xy, 0.0, 1.0);
out.uv = vec2<f32>(xy.x * 0.5 + 0.5, 1.0 - (xy.y * 0.5 + 0.5));
return out;
}
/// Clip → UV (y down, matching the resolve's `uv`), or the caller's fallback when the
/// point is behind the eye.
fn clip_to_uv(c: vec4<f32>) -> vec2<f32> {
let n = c.xy / c.w;
return vec2<f32>(n.x * 0.5 + 0.5, 0.5 - n.y * 0.5);
}
@fragment
fn vel_fs(in: VsOut) -> @location(0) vec2<f32> {
let px = vec2<i32>(in.clip.xy);
let d = textureLoad(scene_depth, px, 0);
let ndc = vec3<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, d);
let wp = U.inv_vp_jitter * vec4<f32>(ndc, 1.0);
// A degenerate unprojection has no honest velocity; zero means "no motion", which
// the resolve treats as "accumulate on this same pixel" — the safe answer.
if (abs(wp.w) < 1e-12) {
return vec2<f32>(0.0, 0.0);
}
let world = wp.xyz / wp.w;
let c = U.vp_cur * vec4<f32>(world, 1.0);
let p = U.vp_prev * vec4<f32>(world, 1.0);
// Behind either eye: the reprojection is meaningless (and would flip sign through
// the divide). Report no motion; the resolve's own box clamp guards the rest.
if (c.w <= 1e-6 || p.w <= 1e-6) {
return vec2<f32>(0.0, 0.0);
}
return clip_to_uv(c) - clip_to_uv(p);
}