facett-map3d/src/map3d.wgsl
// 3D OSM map — depth-tested, lit prism/terrain mesh. The genuine game/flight-sim
// occlusion: every triangle is transformed by the camera view-projection and the
// GPU **depth buffer** (a depth32float attachment, LESS test) decides which
// fragment wins per pixel — NOT a CPU painter's-algorithm sort.
struct Uniforms {
view_proj: mat4x4<f32>, // column-major camera view·projection
light: vec4<f32>, // world-space SUN dir (xyz, surface→sun) + ambient (w)
fog: vec4<f32>, // background fog colour (rgb) + max fog strength (w)
fog_range: vec4<f32>, // near (x), far (y) camera depth; height-haze strength k (z); eye.y (w)
sun_color: vec4<f32>, // directional sun colour (rgb) + intensity (w)
sky_color: vec4<f32>, // sky/ambient tint (rgb) + horizon bias (w)
eye: vec4<f32>, // world-space eye position (xyz) + HAZE SCALE
// HEIGHT in design units (w) — a metric length
// (`backend::haze_scale_height`), never an absolute.
pbr: vec4<f32>, // roughness (x), metallic (y), time (z), shadow_strength (w)
// **Cascaded shadow maps** (T3.3): one world→light-clip per cascade (tight→wide
// ortho around the scene centre). The fragment picks the tightest cascade that
// covers it (high-res near, coarse far → softens with distance).
light_vp: array<mat4x4<f32>, 3>,
// cascade split distances in camera depth (x = cascade0→1, y = cascade1→2),
// PCSS light size in light-uv units (z), soft-shadow flag (w: 1 = PCSS, 0 = hard).
cascade: vec4<f32>,
// SSAO apply (T3.1): target resolution (xy, px) + AO strength (z) + AO-on (w).
ao_params: vec4<f32>,
// ── IBL (image-based lighting, split-sum) ──────────────────────────────────
// Diffuse irradiance as **SH L2** (9 vec3 coeffs, raw radiance projection),
// integrated from the analytic sky on the CPU each frame. `.xyz` used; `.w` pad.
sh: array<vec4<f32>, 9>,
// IBL knobs: diffuse scale (x), specular scale (y), spec-AO/horizon strength (z),
// reflection sun-glow scale (w).
ibl: vec4<f32>,
};
@group(0) @binding(0) var<uniform> U: Uniforms;
// Cascaded shadow maps: the sun's depth render of the scene (one array layer per
// cascade) + a comparison sampler (PCF) AND a plain sampler (PCSS blocker search).
@group(0) @binding(1) var shadow_tex: texture_depth_2d_array;
@group(0) @binding(2) var shadow_cmp: sampler_comparison;
@group(0) @binding(3) var shadow_depth: sampler;
// SSAO (T3.1): the screen-space ambient-occlusion target the SSAO pass wrote.
@group(0) @binding(4) var ao_tex: texture_2d<f32>;
@group(0) @binding(5) var ao_smp: sampler;
struct VsIn {
@location(0) pos: vec3<f32>,
@location(1) nrm: vec3<f32>,
@location(2) col: vec4<f32>, // per-vertex source colour (from per-tri colour)
};
// ── G-BUFFER NORMAL PREPASS (T3.1): render the mesh into a normal+depth target
// the SSAO pass reads. rgb = world-space normal, a = clip-space NDC depth. ──────
struct GbufOut {
@builtin(position) clip: vec4<f32>,
@location(0) nrm: vec3<f32>,
};
@vertex
fn gbuf_vs(in: VsIn) -> GbufOut {
var out: GbufOut;
out.clip = U.view_proj * vec4<f32>(in.pos, 1.0);
out.nrm = in.nrm;
return out;
}
@fragment
fn gbuf_fs(in: GbufOut) -> @location(0) vec4<f32> {
// a = NDC depth (the builtin position already carries clip.z/clip.w in .z).
return vec4<f32>(normalize(in.nrm), in.clip.z);
}
// ── Cascaded PCSS shadow (T3.3) ──────────────────────────────────────────────
// 16-tap Poisson disk (unit), reused for the blocker search + the PCF filter.
fn poisson(i: i32) -> vec2<f32> {
var d = array<vec2<f32>, 16>(
vec2<f32>(-0.6116, -0.2389), vec2<f32>( 0.2007, -0.5871),
vec2<f32>( 0.6859, 0.1153), vec2<f32>(-0.1734, 0.6411),
vec2<f32>(-0.9109, 0.2310), vec2<f32>( 0.4953, -0.8210),
vec2<f32>( 0.1132, 0.1369), vec2<f32>(-0.3651, -0.7424),
vec2<f32>( 0.9009, -0.3187), vec2<f32>(-0.5318, 0.8118),
vec2<f32>( 0.2470, 0.9251), vec2<f32>(-0.8517, -0.4943),
vec2<f32>( 0.7335, 0.6334), vec2<f32>( 0.0316, -0.9783),
vec2<f32>(-0.2226, 0.1146), vec2<f32>( 0.4015, 0.4538),
);
return d[i];
}
struct CascadeHit {
idx: i32,
uv: vec2<f32>,
z: f32,
ok: bool,
};
// Pick the tightest cascade whose light-space uv covers `world`: a camera-depth
// hint chooses the start cascade, then fall through coarser ones until coverage.
fn pick_cascade(world: vec3<f32>, view_depth: f32) -> CascadeHit {
var start = 0;
if (view_depth > U.cascade.x) { start = 1; }
if (view_depth > U.cascade.y) { start = 2; }
var out: CascadeHit;
out.ok = false;
for (var c = start; c < 3; c = c + 1) {
let lc = U.light_vp[c] * vec4<f32>(world, 1.0);
let p = lc.xyz / lc.w;
let uv = vec2<f32>(p.x * 0.5 + 0.5, 1.0 - (p.y * 0.5 + 0.5));
// A small margin keeps the PCF/PCSS kernel inside the cascade.
if (uv.x >= 0.02 && uv.x <= 0.98 && uv.y >= 0.02 && uv.y <= 0.98 && p.z <= 1.0) {
out.idx = c;
out.uv = uv;
out.z = p.z;
out.ok = true;
return out;
}
}
return out;
}
// PCSS: blocker search → penumbra estimate (contact hardening) → Poisson PCF.
// Returns 1.0 = fully lit, 0.0 = fully shadowed. `cascade.z` is the light size
// (penumbra scale); `cascade.w` toggles soft (PCSS) vs a tight hard filter.
fn shadow_factor(world: vec3<f32>, n_dot_l: f32, view_depth: f32) -> f32 {
let hit = pick_cascade(world, view_depth);
if (!hit.ok) {
return 1.0; // outside every cascade → no shadow data → lit
}
let layer = hit.idx;
let uv = hit.uv;
let bias = clamp(0.0016 * (1.0 - n_dot_l), 0.0008, 0.004);
let recv = hit.z;
// `cascade.z` is the **softness scale** (how fast the penumbra grows with the
// occluder→receiver gap). The blocker SEARCH itself stays a small fixed radius so
// a flat lit surface never finds false blockers in its own depth gradient (the
// cause of flat self-shadowing when the search spans too many texels).
let softness = U.cascade.z;
let search = 0.022;
// ── 1. Blocker search: average the depth of samples in front of the receiver.
// `textureLoad` reads raw occluder depth at integer texels (no sampler, valid in
// the non-uniform control flow below). An extra search bias guards flat surfaces.
let dims_u = textureDimensions(shadow_tex);
let dims_f = vec2<f32>(dims_u);
let search_bias = bias + 0.0015;
var blocker_sum = 0.0;
var blocker_cnt = 0.0;
for (var i = 0; i < 16; i = i + 1) {
let o = poisson(i) * search;
let c = vec2<i32>(clamp((uv + o) * dims_f, vec2<f32>(0.0), dims_f - 1.0));
let d = textureLoad(shadow_tex, c, layer, 0);
if (d < recv - search_bias) {
blocker_sum = blocker_sum + d;
blocker_cnt = blocker_cnt + 1.0;
}
}
if (blocker_cnt < 0.5) {
return 1.0; // no occluder → fully lit (flat open ground hits this)
}
let avg_blocker = blocker_sum / blocker_cnt;
// ── 2. Penumbra: the larger the receiver↔blocker gap, the wider (soft) — the
// contact-hardening law. Near contact (recv≈blocker) → tiny → crisp; a high
// occluder → wide. `softness` scales the gap ratio into a uv filter radius.
let gap = (recv - avg_blocker) / max(avg_blocker, 1e-4);
var filter_radius = clamp(gap * softness, 0.0009, 0.03);
if (U.cascade.w < 0.5) {
// Hard control: a fixed minimal kernel (≈ the old 3×3 PCF footprint).
filter_radius = 1.2 / dims_f.x;
}
// ── 3. Poisson PCF at the penumbra radius. `textureSampleCompareLevel` samples
// at LOD 0 (no implicit derivatives) so it is valid in this non-uniform flow.
var sum = 0.0;
for (var i = 0; i < 16; i = i + 1) {
let o = poisson(i) * filter_radius;
sum = sum + textureSampleCompareLevel(shadow_tex, shadow_cmp, uv + o, layer, recv - bias);
}
return sum / 16.0;
}
struct VsOut {
@builtin(position) clip: vec4<f32>,
@location(0) nrm: vec3<f32>,
@location(1) col: vec3<f32>,
@location(2) depth: f32, // camera-space depth (clip.w) for fog
@location(3) world: vec3<f32>, // world-space position (PBR view vector + height fog)
// Emissive intensity, decoded from the vertex colour's w (V2.5 D.3 overlay
// channel): every basemap vertex carries w = 1.0 → 0 here (the lit path,
// byte-identical); an overlay vertex may carry w = 1 + e → e, the self-lit HDR
// scale the fragment shader shortcuts on.
@location(4) emissive: f32,
};
@vertex
fn vs(in: VsIn) -> VsOut {
var out: VsOut;
out.clip = U.view_proj * vec4<f32>(in.pos, 1.0);
out.nrm = in.nrm;
out.col = in.col.rgb;
out.world = in.pos;
// clip.w is the perspective camera-space depth — a clean fog distance proxy.
out.depth = out.clip.w;
out.emissive = max(in.col.a - 1.0, 0.0);
return out;
}
const PI: f32 = 3.14159265359;
// Dielectric reflectance (Filament): f0 = 0.16·reflectance² → default 0.5 gives the
// canonical ~0.04 (4%) normal-incidence reflectance of common insulators.
const REFLECTANCE: f32 = 0.5;
// ── PBR BRDF core — Filament metallic-roughness (Cook-Torrance) ───────────────
// Author *perceptual* roughness; the specular lobe is driven by α = perceptualRoughness².
// These are Filament's numerically-stable formulations (matching Bevy 0.19's
// `pbr_lighting.wgsl`, wgpu-29): specular = D · V · F, where V already folds the
// 1/(4·NoV·NoL) denominator (no extra divide in the caller). `a` below is ALPHA (α),
// NOT perceptual roughness — the caller squares perceptualRoughness first.
//
// D_GGX — GGX/Trowbridge-Reitz normal distribution (the specular highlight shape).
fn D_GGX(n_dot_h: f32, a: f32) -> f32 {
let one_minus_noh2 = 1.0 - n_dot_h * n_dot_h;
let k = a / (one_minus_noh2 + n_dot_h * n_dot_h * a * a);
return k * k * (1.0 / PI);
}
// V_SmithGGXCorrelated — height-correlated Smith visibility (geometry masking +
// shadowing) with the 4·NoV·NoL denominator folded in (Filament / Heitz 2014).
fn V_SmithGGXCorrelated(n_dot_v: f32, n_dot_l: f32, a: f32) -> f32 {
let a2 = a * a;
let ggx_v = n_dot_l * sqrt(n_dot_v * n_dot_v * (1.0 - a2) + a2);
let ggx_l = n_dot_v * sqrt(n_dot_l * n_dot_l * (1.0 - a2) + a2);
return 0.5 / max(ggx_v + ggx_l, 1e-5);
}
// F_Schlick — Schlick Fresnel (grazing-angle reflectance rise), vector f0.
fn F_Schlick(v_dot_h: f32, f0: vec3<f32>) -> vec3<f32> {
return f0 + (vec3<f32>(1.0) - f0) * pow(clamp(1.0 - v_dot_h, 0.0, 1.0), 5.0);
}
// ── IBL split-sum helpers ─────────────────────────────────────────────────────
// DIFFUSE: reconstruct irradiance E(n) from the CPU-integrated SH-L2 radiance
// coefficients (Ramamoorthi & Hanrahan, "An Efficient Representation for Irradiance
// Environment Maps"). The 9 `U.sh[i].rgb` are the raw radiance projection L_lm; the
// per-band constants below fold in the cosine (Lambert) convolution, so this returns
// irradiance directly (caller applies albedo/π).
fn sh_irradiance(n: vec3<f32>) -> vec3<f32> {
let x = n.x; let y = n.y; let z = n.z;
let L00 = U.sh[0].rgb; let L1m1 = U.sh[1].rgb; let L10 = U.sh[2].rgb;
let L11 = U.sh[3].rgb; let L2m2 = U.sh[4].rgb; let L2m1 = U.sh[5].rgb;
let L20 = U.sh[6].rgb; let L21 = U.sh[7].rgb; let L22 = U.sh[8].rgb;
let c1 = 0.429043; let c2 = 0.511664; let c3 = 0.743125;
let c4 = 0.886227; let c5 = 0.247708;
let E = c1 * L22 * (x * x - y * y) + c3 * L20 * (z * z) + c4 * L00 - c5 * L20
+ 2.0 * c1 * (L2m2 * x * y + L21 * x * z + L2m1 * y * z)
+ 2.0 * c2 * (L11 * x + L1m1 * y + L10 * z);
return max(E, vec3<f32>(0.0));
}
// SPECULAR: the prefiltered environment for the reflection direction. The sky here
// is an analytic function (no cubemap texture exists), so "prefiltering" is the sky
// gradient (which survives any blur) plus a sun glow whose sharpness & strength fall
// as roughness rises — a matte wall blurs the sun into the sky, a glossy one keeps a
// tight reflection. Below the world horizon the sample darkens to the ground haze, so
// ambient specular does NOT leak up from under surfaces (horizon occlusion).
fn env_gradient(dir: vec3<f32>) -> vec3<f32> {
let bias = clamp(U.sky_color.w, 0.05, 4.0);
let t = pow(clamp(dir.y, 0.0, 1.0), bias);
var col = mix(U.fog.rgb, U.sky_color.rgb, t);
let below = clamp(-dir.y * 3.0, 0.0, 1.0);
col = mix(col, U.fog.rgb * 0.6, below);
return col;
}
fn env_prefiltered(dir: vec3<f32>, perceptual: f32) -> vec3<f32> {
var col = env_gradient(dir);
let l = normalize(U.light.xyz);
let cosang = clamp(dot(dir, l), 0.0, 1.0);
let sharp = mix(64.0, 4.0, perceptual);
let glow = pow(cosang, sharp) * (1.0 - perceptual) * U.ibl.w;
col = col + U.sun_color.rgb * U.sun_color.w * glow;
return col;
}
// DFG: Karis' analytic environment-BRDF approximation ("Physically Based Shading on
// Mobile") — the split-sum second factor without a baked DFG LUT. Returns (scale,
// bias) so specular = prefiltered · (f0·scale + bias).
fn env_brdf_approx(n_dot_v: f32, perceptual: f32) -> vec2<f32> {
let c0 = vec4<f32>(-1.0, -0.0275, -0.572, 0.022);
let c1 = vec4<f32>(1.0, 0.0425, 1.04, -0.04);
let r = perceptual * c0 + c1;
let a004 = min(r.x * r.x, exp2(-9.28 * n_dot_v)) * r.x + r.y;
return vec2<f32>(-1.04, 1.04) * a004 + r.zw;
}
// Filament specular occlusion from the (diffuse) AO + view — keeps ambient specular
// out of creases/contacts where the environment is occluded (multi-scatter AO).
fn spec_occlusion(n_dot_v: f32, ao: f32, a: f32) -> f32 {
return clamp(pow(n_dot_v + ao, exp2(-16.0 * a - 1.0)) - 1.0 + ao, 0.0, 1.0);
}
@fragment
fn fs(in: VsOut) -> @location(0) vec4<f32> {
// ── EMISSIVE shortcut (V2.5 D.3, the overlay's eyes): a self-lit surface owes
// the BRDF nothing — its own linear albedo scaled by the emissive intensity goes
// straight into the linear-HDR offscreen. Scaled past 1.0 it crosses the bloom
// bright-pass threshold and the EXISTING bright/blur/composite chain grows the
// halo — no new pass, no fog (an eye LED shines through haze).
if (in.emissive > 0.0) {
return vec4<f32>(in.col * in.emissive, 1.0);
}
let n = normalize(in.nrm);
let l = normalize(U.light.xyz); // surface → sun
let v = normalize(U.eye.xyz - in.world); // surface → eye
let h = normalize(l + v);
// Perceptual roughness authored in U.pbr.x → α = perceptualRoughness² drives the
// GGX lobe (Filament). Clamp perceptual to ≥0.045 so the specular highlight stays
// finite (a mirror-sharp α→0 aliases on a low-res highlight).
let perceptual = clamp(U.pbr.x, 0.045, 1.0);
let a = perceptual * perceptual; // α (alpha)
let metallic = clamp(U.pbr.y, 0.0, 1.0);
let albedo = in.col;
let n_dot_l = max(dot(n, l), 0.0);
let n_dot_v = max(dot(n, v), 1e-4);
let n_dot_h = max(dot(n, h), 0.0);
let v_dot_h = max(dot(v, h), 0.0);
// F0 (Filament): dielectrics = 0.16·reflectance² (≈0.04), metals tint f0 by albedo
// and carry no diffuse. Metals also darken the diffuse albedo to zero (k_d below).
let f0 = mix(vec3<f32>(0.16 * REFLECTANCE * REFLECTANCE), albedo, metallic);
// Cook-Torrance specular: D · V · F. V_SmithGGXCorrelated already folds the
// 1/(4·NoV·NoL) visibility denominator, so there is no separate divide here.
let ndf = D_GGX(n_dot_h, a);
let vis = V_SmithGGXCorrelated(n_dot_v, n_dot_l, a);
let f = F_Schlick(v_dot_h, f0);
let spec = ndf * vis * f;
// Diffuse (energy-conserving Lambert: metals have no diffuse; kd removes the
// fraction of light reflected specularly).
let k_d = (vec3<f32>(1.0) - f) * (1.0 - metallic);
let diffuse = k_d * albedo / PI;
// Direct sun radiance — gated by the cascaded shadow map (PCF). A fragment in
// shadow loses its direct sun (blocks cast onto the street + onto each other),
// keeping only the sky ambient. `pbr.w` scales how dark shadows get.
let sun = U.sun_color.rgb * U.sun_color.w;
let shadow = mix(1.0, shadow_factor(in.world, n_dot_l, in.depth), clamp(U.pbr.w, 0.0, 1.0));
let direct = (diffuse + spec) * sun * n_dot_l * shadow;
// ── SSAO (T3.1): sample the screen-space AO for the ambient terms (creases/
// contacts come back darker). ao_params.z scales it, .w gates it (1 on / 0 off).
let suv = in.clip.xy / max(U.ao_params.xy, vec2<f32>(1.0));
let ao_raw = textureSampleLevel(ao_tex, ao_smp, suv, 0.0).r;
let ao = mix(1.0, ao_raw, U.ao_params.z * U.ao_params.w);
// ── IBL split-sum ambient (replaces the flat hemisphere fill) ──────────────
// DIFFUSE: SH-L2 irradiance from the analytic sky → directional sky bounce (the
// sun-lit side of the sky warms the fill). Lambert radiosity = albedo·E/π, scaled
// by the ambient strength (light.w) so the old ambient knob still applies.
let irradiance = sh_irradiance(n);
let diffuse_ibl = irradiance * albedo * (1.0 - metallic) / PI * U.light.w * U.ibl.x;
// SPECULAR: prefiltered sky reflection × the split-sum DFG term (f0·dfg.x + dfg.y),
// plus multi-scatter energy compensation and specular occlusion. This is the new
// "expensive look": buildings pick up sky colour + soft ambient reflections, and
// low-roughness faces get a real skyline/sun reflection.
let r_refl = reflect(-v, n);
let prefiltered = env_prefiltered(r_refl, perceptual);
let dfg = env_brdf_approx(n_dot_v, perceptual);
var spec_ibl = prefiltered * (f0 * dfg.x + vec3<f32>(dfg.y));
// Multi-scatter energy compensation (Filament). The Karis *analytic* DFG under-
// estimates the bias on rough dielectrics, so 1/dfg.y would explode (~4×) and
// flood the ambient — floor dfg.y so the compensation stays a small (~≤1.4×) lift.
spec_ibl = spec_ibl * (1.0 + f0 * (1.0 / max(dfg.y, 0.1) - 1.0));
let spec_ao = spec_occlusion(n_dot_v, ao, a);
let spec_ibl_final = spec_ibl * U.ibl.y * mix(1.0, spec_ao, clamp(U.ibl.z, 0.0, 1.0));
let ambient = diffuse_ibl * ao + spec_ibl_final;
var lit = direct + ambient;
// ── Aerial perspective: EXPONENTIAL extinction through a HEIGHT-GRADED atmosphere ──
//
// Distance fog is exp(−dist·density) (real atmospheric extinction, Filament):
// near faces stay crisp, distant ones desaturate smoothly into the sky.
//
// Height haze modulates the **density along the ray** — it is never ADDED to the
// result. That is the whole fix, and it is two defects deep:
//
// 1. The layer was `0.25` DESIGN UNITS tall. A design unit is 625 m in the
// rabbit fixture and 1875 m in the 3 km town window korp flies, so the layer
// was 469 m of air over a city whose tallest house stands 0.013 du. The term
// `0.25 − world.y` then varied by under 6 % across the whole scene: not a
// ramp, a CONSTANT. It is now `eye.w` — HAZE_SCALE_HEIGHT_METRES through the
// scene's own du_per_m (`backend::haze_scale_height`), the same cure
// `ground_band` and `building_rise_eps` already applied to their constants.
//
// 2. Being ADDITIVE, it hazed a fragment ONE METRE from the camera exactly as
// hard as one at the far plane. MEASURED (2026-08-04, 480 561-way Stockholm
// extract, chromaticity probe): a flat +0.268 of sky on every ground pixel
// before distance fog was counted, taking the pane's green cover from a
// 16.64 % mesh area to **0.14 % of pixels**. Optical depth is density ×
// PATH LENGTH; a zero-length path is never hazed, at any altitude. So the
// height term multiplies `density` and the distance term keeps its zero.
//
// The ray's mean height (eye ↔ fragment) is what sets the density it travels
// through, which also folds in the old eye-altitude thinning: climb and the whole
// column thins, one term instead of two.
let near = U.fog_range.x;
let span = max(U.fog_range.y - near, 1e-3);
let dist = max(in.depth - near, 0.0);
let density = 2.5 / span; // ~92% hazed at the far plane
let k = U.fog_range.z; // height-haze strength (dimensionless)
let haze_h = max(U.eye.w, 1e-6); // haze scale height (du, from metres)
let h_mid = max(0.0, 0.5 * (in.world.y + U.fog_range.w));
let ground_air = 1.0 + k * exp(-h_mid / haze_h);
let dist_t = 1.0 - exp(-dist * density * ground_air);
let fog_t = clamp(dist_t * U.fog.w, 0.0, 1.0);
// Inscatter colour: distant geometry recedes into the *sky* (bg fog → sky tint,
// by the horizon bias), warmed toward the sun with distance (forward scatter) —
// the readability win that separates far buildings from near ones.
let sky_inscatter = mix(U.fog.rgb, U.sky_color.rgb, clamp(U.sky_color.w, 0.0, 1.0));
let haze = mix(sky_inscatter, U.sun_color.rgb, 0.18 * dist_t);
let out_col = mix(lit, haze, fog_t);
// HDR out: write **linear radiance** (no tonemap here). The post stack
// (bright-pass → bloom → ACES) in `gpu.rs` exposes + tonemaps the Rgba16Float
// offscreen down to the egui surface, so emissive/highlight values above 1.0
// survive to drive real bloom instead of clipping in the fragment shader.
return vec4<f32>(out_col, 1.0);
}
// ── Blit: sample the offscreen colour target into egui's render pass ──────────
@group(0) @binding(0) var blit_tex: texture_2d<f32>;
@group(0) @binding(1) var blit_smp: sampler;
struct BlitOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn blit_vs(@builtin(vertex_index) vi: u32) -> BlitOut {
// Fullscreen triangle.
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: BlitOut;
let xy = p[vi];
out.clip = vec4<f32>(xy, 0.0, 1.0);
// Map clip [-1,1] → uv [0,1]; flip y (texture origin top-left).
out.uv = vec2<f32>(xy.x * 0.5 + 0.5, 1.0 - (xy.y * 0.5 + 0.5));
return out;
}
@fragment
fn blit_fs(in: BlitOut) -> @location(0) vec4<f32> {
return textureSample(blit_tex, blit_smp, in.uv);
}