From aba2ae9c009d947752359c493e1ce4cfa40f6aa6 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 4 Jul 2026 09:49:42 +0200 Subject: [PATCH 01/10] =?UTF-8?q?fix(visuals):=20round-2=20quick=20wins=20?= =?UTF-8?q?=E2=80=94=20water=20grazing,=20backlit=20trees,=20fog,=20shafts?= =?UTF-8?q?,=20title?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated on the audit poses (region-luma vs the round-2 baselines): - water at grazing: clean-water rect now 1.00x sky luma (was 1.16-1.23x — the river measured BRIGHTER than the sky); the fat white band at the waterline is gone - backlit treeline: canopy/background 1.35 (audit) -> 1.26, and crowns now read as shaded volumes (lit tops, darker sides, saturated green) instead of flat pale-mint blobs; the remainder is sun-struck top facets water.wgsl (audit F2): - re-land the lost grazing calibration as min(schlick, 0.60) - lift reflected rays off the horizon (r.y >= 0.08): the GGX-prefiltered horizon mip is a pale wash that does not exist in the real sky - fade crest foam by view angle (measured ~5% of the milk stripe) - screen UV from the fragment position, not a per-vertex perspective divide (wrong snapshot texel at grazing distances) tree.wgsl (audit F3): - crown self-occlusion on the sky fill (sides 35%, tops 100%) — env irradiance by normal alone gave side facets half the sky dome - rim-gated, shadowed, cloud-modulated leaf transmission (a full-crown version brightens exactly the silhouettes the audit flagged) - direct term carries sun_dir.w; leaf shadow floor deepens with distance main.ts: - fog heightRef 0 -> -0.6, density 0.024 -> 0.016: the reference sat at water level, so grazing views integrated the densest slab down the whole valley — that WAS the visible milk band (it spilled over banks) - sun shafts 0.4/0.96 -> 0.18/0.90 (up to ~+0.14 HDR silhouette veil) - title centering via measureText (exact in the current engine; the 0.58 hand estimate was itself the off-centering) - stop engaging the drifted inline water fallback (stale lighting AND a mismatched params layout); SH-005 codegen is the durable fix Adds docs/audit-round2.md — the full round-2 audit with the evidence index (tools/.testout/audit2/) these numbers come from. --- .gitignore | 3 + assets/materials/tree.wgsl | 46 +++++-- assets/materials/water.wgsl | 46 +++++-- docs/audit-round2.md | 266 ++++++++++++++++++++++++++++++++++++ src/main.ts | 34 +++-- 5 files changed, 359 insertions(+), 36 deletions(-) create mode 100644 docs/audit-round2.md diff --git a/.gitignore b/.gitignore index 2863305..7ca9b17 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ _compile.log _gamepid.txt _run_err.txt _run_out.txt + +# lld /DEBUG output from perry compile --debug-symbols +main.pdb diff --git a/assets/materials/tree.wgsl b/assets/materials/tree.wgsl index 685c687..68257ce 100644 --- a/assets/materials/tree.wgsl +++ b/assets/materials/tree.wgsl @@ -91,22 +91,48 @@ fn fs_main(in: VsOut) -> OpaqueOut { let albedo = mix(tp.trunk.rgb, draw.model_tint.rgb, trunk_t); let sun_dir = normalize(view.sun_dir.xyz); + let v = normalize(view.camera_pos.xyz - in.world_pos); let n_dot_l = max(dot(n, sun_dir), 0.0); let cloud = cloud_shadow(in.world_pos.xz, frame.time); // Cascaded sun shadow — trees shade themselves and each other now. - // Leaves take it at HALF strength: a low-poly canopy's flat facets + // Leaves take it at reduced strength: a low-poly canopy's flat facets // terrace hard against their own blocky shadow-map silhouette (slat // stripes), and real foliage scatters enough light that full-depth - // self-shadow reads wrong anyway. The trunk keeps the full factor, - // and the ground below still receives the full canopy shadow from - // its own material. + // self-shadow reads wrong anyway. Round-2 (audit F3): the floor + // deepens with distance — the flat 0.5 kept far crowns uniformly + // half-lit, one third of why the backlit treeline measured +35% + // brighter than the hills behind it. Facet terracing is sub-pixel + // past ~15 m, so the deep floor is safe there. let sun_shadow_raw = sample_sun_shadow_n(in.world_pos, n); - let sun_shadow = mix(sun_shadow_raw, 0.5 + 0.5 * sun_shadow_raw, trunk_t); - let direct = view.sun_color.rgb * n_dot_l * cloud * sun_shadow; - // Sky-fill: HDR irradiance by the surface normal — canopy tops read - // sky-lit, undersides shade toward ground bounce — plus a small floor. - let fill = sample_env_diffuse(n) + view.ambient.rgb * 0.20; - let lit = albedo * (fill + direct); + let cam_dist = length(view.camera_pos.xyz - in.world_pos); + let leaf_floor = mix(0.5, 0.15, smoothstep(15.0, 45.0, cam_dist)); + let sun_shadow = mix(sun_shadow_raw, + leaf_floor + (1.0 - leaf_floor) * sun_shadow_raw, + trunk_t); + // sun_dir.w carries the sun intensity (1.1 in the shipped world); + // dropping it tilted the fill:direct ratio toward the pale sky fill. + let direct = view.sun_color.rgb * view.sun_dir.w * n_dot_l * cloud * sun_shadow; + // Backlit transmission — RIM-gated. A first cut applied it to the + // whole camera-facing crown; on a backlit tree that is every visible + // pixel (-n·sun > 0 exactly where the camera looks), which brightened + // the very silhouettes the audit flagged. Real crowns glow at thin + // edges only, so gate by silhouette proximity. + let back = max(dot(-n, sun_dir), 0.0); + let view_align = max(dot(v, -sun_dir), 0.0); + let rim = pow(1.0 - abs(dot(n, v)), 2.0); + let trans = pow(back, 2.0) * pow(view_align, 1.5) * 0.6 * rim * trunk_t; + let trans_col = albedo * vec3(1.10, 1.20, 0.85); + // Sky-fill: HDR irradiance by the surface normal, with a crown + // self-occlusion proxy on the leaf band (audit F3): a solid canopy's + // sides see far less sky than its top, but env irradiance by normal + // alone gave every side facet ~half the sky dome — the core of why + // backlit crowns measured +35% over the hills behind them. Tops keep + // the full dome; sides drop to 35%. + let crown_ao = mix(1.0, 0.35 + 0.65 * max(n.y, 0.0), trunk_t); + let fill = (sample_env_diffuse(n) + view.ambient.rgb * 0.20) * crown_ao; + let lit = albedo * (fill + direct) + + trans_col * trans * view.sun_color.rgb * view.sun_dir.w + * cloud * sun_shadow_raw; var out: OpaqueOut; out.hdr = vec4(lit, 1.0); diff --git a/assets/materials/water.wgsl b/assets/materials/water.wgsl index 684e85f..195e5d4 100644 --- a/assets/materials/water.wgsl +++ b/assets/materials/water.wgsl @@ -27,7 +27,6 @@ struct VsOut { @builtin(position) clip_pos: vec4, @location(0) world_pos: vec3, @location(1) world_normal: vec3, - @location(2) screen_uv: vec2, }; fn gerstner( @@ -71,7 +70,6 @@ fn vs_main(in: VertexInput) -> VsOut { out.world_pos = world.xyz; out.world_normal = normalize((draw.model * vec4(normal, 0.0)).xyz); out.clip_pos = view.view_proj * world; - out.screen_uv = out.clip_pos.xy / out.clip_pos.w * vec2(0.5, -0.5) + vec2(0.5); return out; } @@ -97,12 +95,20 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let n = normalize(mix(n_base, micro, water_params.knobs.w)); let v = normalize(view.camera_pos.xyz - in.world_pos); + // Round-2 fix: derive screen UV from the fragment position instead of + // a per-vertex perspective-divided varying — the varying interpolates + // linearly across the 1 m grid cells and lands on the wrong snapshot + // texel at grazing distances, exactly where the look was worst. + // @builtin(position) in the fragment stage is window pixels at the + // render extent, which is also the snapshot resolution. + let scr_dims = vec2(textureDimensions(scene_color_tex)); + let screen_uv = in.clip_pos.xy / scr_dims; + // Phase 4c — shoreline fade. Compute depth + water column FIRST // so Tier 4's Beer-Lambert absorption can use it. - let depth_dims = textureDimensions(scene_depth_tex); - let depth_ix = vec2(in.screen_uv * vec2(depth_dims)); + let depth_ix = vec2(in.clip_pos.xy); let scene_d = textureLoad(scene_depth_tex, depth_ix, 0); - let ndc_xy = vec2(in.screen_uv.x * 2.0 - 1.0, 1.0 - in.screen_uv.y * 2.0); + let ndc_xy = vec2(screen_uv.x * 2.0 - 1.0, 1.0 - screen_uv.y * 2.0); let floor_v = view.inv_proj * vec4(ndc_xy, scene_d, 1.0); let surf_v = view.inv_proj * vec4(ndc_xy, in.clip_pos.z, 1.0); let floor_z = floor_v.z / floor_v.w; @@ -112,7 +118,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { // Refraction — perturb screen UV by wave normal xz; the offset // scales with column so deep water bends light more. let refr_offset = clamp(column * 0.05, 0.0, 0.06); - let refract_uv = clamp(in.screen_uv + n.xz * refr_offset, vec2(0.001), vec2(0.999)); + let refract_uv = clamp(screen_uv + n.xz * refr_offset, vec2(0.001), vec2(0.999)); var refracted = textureSampleLevel(scene_color_tex, scene_color_samp, refract_uv, 0.0).rgb; // Caustics — sun-rays focused on the river bed by the wavy water @@ -137,18 +143,30 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let trans = exp(-water_params.absorption.rgb * column); let absorbed = refracted * trans + water_params.deep_tint.rgb * (1.0 - trans); - // Sky reflection from the engine env. - let r = reflect(-v, n); - let sky = sample_env(r, water_params.knobs.z); - - // Schlick Fresnel — F0 ≈ 0.02 for water. + // Sky reflection from the engine env — with two round-2 grazing-angle + // fixes (audit F2: the river measured 16-23% BRIGHTER than the sky): + // (1) lift the reflected ray off the horizon before sampling; the + // GGX-prefiltered mip around v≈0.5 is a flat pale wash that does + // not exist in the actual sky above the horizon; + // (2) cap Schlick at 0.60 — re-lands the 2026-06-20 calibration lost + // in the round-1 merge, so grazing water keeps body colour + // instead of going over-unity mirror. + let r_raw = reflect(-v, n); + let r = normalize(vec3(r_raw.x, max(r_raw.y, 0.08), r_raw.z)); + let sky = sample_env(r, water_params.knobs.z); + + // Schlick Fresnel — F0 ≈ 0.02 for water, capped (see above). let cos_theta = max(dot(n, v), 0.0); - let fresnel = 0.02 + (1.0 - 0.02) * pow(1.0 - cos_theta, 5.0); + let fresnel = min(0.02 + (1.0 - 0.02) * pow(1.0 - cos_theta, 5.0), 0.60); var water = mix(absorbed, sky, fresnel); - // Foam on wave crests (slope proxy: low n.y). + // Foam on wave crests (slope proxy: low n.y), faded toward grazing + // views — without the fade the whole far half of the river picks up + // a uniform white film from wave flanks (measured ~5% of the audit's + // "milk stripe" luma). let crestness = clamp(1.0 - n.y, 0.0, 1.0); - let foam = smoothstep(0.08, 0.25, crestness); + let foam = smoothstep(0.08, 0.25, crestness) + * smoothstep(0.06, 0.35, cos_theta); water = mix(water, vec3(0.95, 0.98, 1.0), foam * water_params.knobs.x); // Shoreline rim: bright where the water column is thin. diff --git a/docs/audit-round2.md b/docs/audit-round2.md new file mode 100644 index 0000000..6e4340d --- /dev/null +++ b/docs/audit-round2.md @@ -0,0 +1,266 @@ +# Round-2 Audit — Bloom Shooter + Engine (2026-07-04) + +Second full audit of the shooter (`main` @ af98dbe) and engine (`main` @ +f8614c6) after the round-1 merge. Goal unchanged: **Unreal-level graphics +with raylib/Unity simplicity.** + +Method: five parallel code investigations (frame-cost/profiler coverage, 2D +text, water+SSR specular, GI stack, foliage/cutout distance quality) plus a +**scripted self-driving measurement build** (temporary `AUDIT` block in +main.ts, reverted; copy kept in the session scratchpad as +`audit2/main.audit-tour.ts`) that ran staged camera poses, an SSGI A/B, +water fog/foam A/Bs, pool-max combat, and a feature-cost ladder at native +4K fullscreen, captured externally with PMv2 DPI-aware full-frame grabs. + +Evidence: `tools/.testout/audit2/` — 4K frames (`gi_on/gi_off`, +`water_nofog/water_nofoam`, `treeline_backlit`, `combat_profiler`, +`combat_noprof`, `ladder_ssroff`, `title_4k`, `crash_dialog`) + profiler +stdout logs. Region-luma numbers below were measured on those exact files +(System.Drawing LockBits mean over stated rects). + +Hardware context (matters for every number here): **AMD Radeon 760M** — +8-CU RDNA3 *integrated* GPU, shared DRAM. This is a modest iGPU, not a +dGPU. 31–45 fps at 3840×2160 output with a full deferred + GI stack is +already a strong result on this silicon; "locked 4K60" is not a realistic +target on this box (see Decision D1). + +--- + +## Headline results + +1. **The game AVs (access violation) under runtime feature toggling** — + three crashes in three scripted runs, all `main.exe+0xe8e5`, read of + `0x…FFF8` (page-boundary read → buffer overrun/use-after-free class). + Each crash followed a *pipeline-scale* runtime toggle by 0.4–6 s: + `setSsgiEnabled(false)` (run 1), `setProfilerEnabled(false)` (run 2), + `setShadowsEnabled(false)` (run 3). SSR/SSAO/bloom toggles survived + repeatedly. Never seen before because nothing ever toggled features at + runtime. Any future settings menu hits this. Evidence: + `crash_dialog.png`, Windows Application log Id 1000 ×3, run logs. + +2. **SSGI contributes ~nothing visible in the shipped config** (the round-1 + GI-proxy investment currently has no visual payoff on this machine). + Measured at a fixed pose, SSGI on→off (settled, ship config): + shaded building wall Δ −0.7 % (= noise; sky control drifted −0.5 %), + shaded ground −2.1 %, sunlit grass −2.6 %, and **G/R ratio at the wall + base identical to 4 decimal places (0.9463)** → zero colored bounce. + Root cause chain (verified in code + the live profiler pass list): + this box runs **`probe_trace_sdf_pass`** — the HW ray-query tier never + engages (never logged, silently falls back; + `native/windows/src/lib.rs:449-474`), the SDF path's mesh-card lookup + compares world-space hits against **object-space** AABBs so every + transformed proxy degrades to flat gray 0.55 albedo + (`shaders/ssgi.rs:914-956, 996-1010`), and the software world-radiance + cache bake is **pure analytic sun+sky, no geometry** + (`gi.rs:329-455`). The ~267 gi_only proxies feed a pipeline whose + colored output can't reach the screen on SW adapters. Cost of the probe + stack in combat: ~1.2–1.9 ms GPU. + +3. **The water grazing-angle calibration was lost in the round-1 merge** + (regression). `assets/materials/water.wgsl:145-146` is back to uncapped + Schlick (`0.02 + 0.98·pow(1-cosθ,5)`); the June fix (cap 0.70 + horizon + dim) is in neither the file nor the inline fallback. Measured: the river + at grazing reads **L≈113–120 — 16–23 % brighter than the sky above the + horizon (L≈97.5)** and ~24 % above adjacent grass; an over-unity + "milk stripe". A/B isolation: foam-off −5.5 %, fog-off ≈ 0 → the + dominant term is Fresnel→1 × the pale GGX-prefiltered horizon mip + (`sample_env(r, lod 2.0)`). Also found: `screen_uv` is perspective- + divided per-vertex then linearly interpolated (wrong at grazing; + water.wgsl:74) and the inline fallback shader in main.ts has drifted + (older UBO layout — if it ever engages, params land on wrong fields). + +4. **Backlit treeline paleness is real, quantified, and is NOT the round-1 + hypothesis.** There is no cutout treeline anymore (prop_tree/tuft path + is dead code; the perimeter is solid Kenney trees through `tree.wgsl`). + Measured on `treeline_backlit.png`: backlit canopies **L=126 vs the + sunlit hills behind them L=93 (+35 %)** — silhouettes render *brighter* + than their background. Verified chain: `tree.wgsl:105-109` backlit + crowns get sky-fill only (no transmission term, leaf shadow floored at + 50 %, and the sun's 1.1× intensity `sun_dir.w` is dropped) → sun-shaft + compose adds a warm veil up to ~+0.14 HDR on sunward silhouettes at + strength 0.4/decay 0.96 (`post.rs:646-670`) → TSR's luma-only history + clamp retains sky *chroma* on thin features + a hardcoded 0.8 sharpen + halo (no FFI setter; `mod.rs:5923`). Fog is measurably innocent + (≤0.0002 % at canopy height). Bonus engine bug found: the cutout + transmission block in `fs_main_scene` is **pasted twice** + (`core.rs:852-864`) — unshadowed, 1.7× strength; dormant today, will + blow out any future MASK foliage. + +5. **Real combat load costs ~6 fps vs title.** At pool-max load (10 + skinned enemies + projectiles + sparks, worst case the code can + produce): **34 fps clean / 31 fps with profiler** vs ~40 title. + GPU pass sum ~13.8 ms: TAA 3.15, main_hdr 2.45, composite 2.45, + shadows 2.22, SSAO 0.73, probe stack ~1.6, bloom 0.46, SSR 0.19. + No single feature is a silver bullet — the ladder's per-feature fps + deltas sat within noise; the cost is spread across TAA+composite + (output-res at 4K) and the skinned-geometry passes. + **Shipped waves never show more than 2 enemies at once** (each wave is + a single kind × BODIES_PER_KIND=2 pool slots) — the "19 enemies" plan + is 19 *total*, max 2 concurrent. Gameplay reads emptier than intended + and the shipped perf load is far below what the engine handles. + +6. **Two significant profiler/measurement traps** (now facts, were + unknowns): (a) enabling the profiler inserts a **blocking GPU sync every + frame** (`profiler.frame_end`: map_async + `device.poll(Wait)`, + engine.rs:195) — all round-1 fps readings taken with F3 open were + pessimistic (~3 fps at combat load, more when GPU-bound); (b) the + overlay's rolling averages **freeze stale values for passes that stop + running** — a disabled feature's row keeps showing its old cost. + Also: `post_fx` CPU bracket has no matching begin (dead), scene_compose + is deliberately untimestamped, `MAX_GPU_PAIRS=32` is nearly exhausted + (28 used), and `getFPS()` returns 0 on the title screen for many + seconds. + +7. **2D/HUD: `measureText` is exact in the current build** — verified to + the disassembly level (measure ≡ draw advance; `0.6001·size·len` for + the embedded Courier New). The visible title off-centering is caused by + the **hand-tuned 0.58 workaround itself** (main.ts:2101-2110; ~11 px + right at 4K). Real 2D issues: glyphs rasterize at *logical* 2560×1440 + then stretch ×1.5 bilinear (soft HUD at 4K), 2D vertex colors are + double-sRGB-encoded (washed bright), and the glyph atlas silently + wraps UVs when full (Repeat sampler, no eviction). + +8. **Frame-cost structure at 4K** (title, render 1920×1080 → output 4K): + GPU pass sum ~11.1 ms of a ~25 ms frame. The unaccounted remainder is + now explained by a *list*, not one villain: profiler serialization + (only while measuring), untimestamped passes (scene_compose with 16-step + fog march + 32-tap sun-shaft march, SSR temporal, SSAO blur, 2D pass at + 4K, water snapshot copies), FIFO present + `desired_maximum_frame_latency + 2` (present mode hardcoded; `setTargetFPS` is inert under vsync), iGPU + shared-bandwidth contention, and a **new mystery: `translucent_pass` + CPU grows 0.4 ms (title) → 2.8–4.7 ms (building pose) → 6.4–6.7 ms + (combat)** while the water draw is constant — top CPU line in gameplay + frames, unexplained, worth ~15–20 % of the frame. + +9. **Stale/closed leads:** the "grey boundary-wall gizmo boxes" no longer + exist (all 43 box entities are category-1 → baked into the building + mesh; nothing draws them — verified in code and captures). SSR+IBL + double-count is real but confined to **metals at roughness ≈0.05–0.85 + on `fs_main_scene`** — in this game that's only the aliens/weapon per + their glTF factors; the world's custom materials are unaffected. The + authored river zig-zags (6 segments, cz 11↔13) while the game draws one + straight 80×5 plane — bank mismatch at the edges. + +10. **New gameplay-visual issue:** when the mob surrounds the player, the + orbit camera's collision shortening puts the camera *inside* enemy + volumes — near-plane clipping fills the screen with polygon soup + (`combat_profiler.png`). Happens in real play whenever cornered. + +--- + +## Ranked findings (visual impact × effort) + +| # | Finding | Impact | Effort | Evidence | +|---|---|---|---|---| +| F1 | AV on runtime disable of shadows/SSGI/profiler (teardown UAF class) | Blocker for settings/robustness; blocks future A/B tooling | M (debug session; repro exists) | 3× WER events, crash_dialog.png | +| F2 | Water grazing regression (lost cap) — river brighter than sky | High — hero element reads broken | **S** (shader edit, hot-reloadable) | water_* PNGs + luma table | +| F3 | Backlit treeline +35 % over background (no transmission, shaft veil, sharpen halo) | High — every screenshot shows it | **S–M** (tree.wgsl + one param + engine dup-block delete) | treeline_backlit.png + luma | +| F4 | SSGI invisible on SW path (gray cards, analytic WSRC, silent HW fallback) | High strategic — round-1 investment inert; ~1.6 ms GPU spent | M–L (engine) or **S** (reallocate) | GI A/B luma, pass list | +| F5 | HUD soft + double-sRGB + title workaround; measureText exact | Med — polish visible at 4K | S (game) + S–M (engine text) | disasm + code refs | +| F6 | Operating point: 34 fps combat on 760M iGPU at 4K; FIFO hardcoded, no cap that works | High decision | Decision + S | combat_noprof.png | +| F7 | translucent_pass CPU mystery (0.4→6.7 ms with load) | Med-high perf | M (investigate) | run1/run3 tables | +| F8 | Material-world velocity=0 → TSR shimmer/ghost under motion, motion clamp never engages | Med-high in-motion quality | M (engine + shaders) | code (tree/grass wgsl:114/155, post.rs:870) | +| F9 | Profiler hygiene: per-frame sync, stale averages, dead bracket, pair budget, getFPS=0 | Med (trust in all future measurement) | S | run logs | +| F10 | SSR+IBL double-count (metals only here) + SSR miss=black inconsistency | Low-med visual, correctness | S–M | code refs | +| F11 | Waves cap at 2 concurrent enemies; river plane vs authored zig-zag; camera-in-mob soup | Med gameplay feel | S / S / M | world.ts, captures | +| F12 | Content gap (terrain textures, grass shape, tree/leaf quality, weapon cubes, plaster box building) | **Largest single lever toward "Unreal look"** | content track | all captures | + +--- + +## Phased plan (proposed — awaiting approval) + +### Phase 0 — Stability + measurement trust (~1–2 days, engine) +Gate for everything else; every later fix gets validated by re-running the +audit tour, which currently crashes the game. +1. Build engine lib with debug symbols, repro F1 with the preserved tour + (`audit2/main.audit-tour.ts`), fix the teardown UAF(s) behind + `setShadowsEnabled(false)` / `setSsgiEnabled(false)` / + `setProfilerEnabled(false)`. Acceptance: full 3-toggle tour ×3 runs, no AV. +2. Profiler hygiene batch: decay/clear stale rolling averages on disable; + remove dead `post_fx` end; timestamp scene_compose (or split it); + log the GI trace backend + a one-line adapter/features print at boot; + fix title-screen `getFPS()=0`. +3. Optional (cheap while in there): make the per-frame profiler sync + opt-in (`snapshot` on demand) so measuring stops distorting. + +### Phase 1 — Visual quick wins (~2–4 days, mostly game-side shaders, all A/B-able with the tour + luma rects) +1. **Water re-calibration** (F2): re-land Fresnel cap ~0.6–0.7 **plus** + horizon-avoidance on the env sample (`r.y = max(r.y, 0.06)` or luma + clamp), add view-angle foam fade; fix per-vertex `screen_uv` → + per-fragment; delete or regenerate the drifted inline fallback (SH-005). + Acceptance: far-water luma ≤ sky−10 % at the S4 pose; teal saturation + returns; predicted −30–50 % stripe luma. +2. **Treeline/backlit fix** (F3): grass-style transmission term in + tree.wgsl leaves, multiply direct by `sun_dir.w`, deepen leaf shadow + floor with distance; sun shafts 0.4 → ~0.15–0.2 (or decay 0.96→0.85). + Engine: delete the duplicated cutout transmission block + (core.rs:857-864) and shadow the survivor. Acceptance: backlit canopy + luma ≤ background hills at the S5B pose. +3. **HUD**: delete the 0.58 title estimate (use measureText); 2D gamma fix + (sRGB-decode vertex colors); [engine-small] rasterize glyphs at + physical scale for crisp 4K text. +4. **SSGI decision follow-through** (see D2): short-term on this box, + either `setSsgiIntensity(≈2)` after verifying it scales (cheap + visibility test) or disable SSGI and bank ~1.6 ms; long-term per D2. +5. Close stale leads in docs/tickets (boundary boxes; measureText myth). + +### Phase 2 — Engine projects (ordered, ~0.5–1.5 weeks each) +1. **Material-path velocity** (F8): prev-frame model matrix per material + draw → real motion vectors for the whole world; unlocks TSR motion + clamp + fixes sway ghosting. Biggest in-motion quality item. +2. **translucent_pass CPU investigation** (F7): bracket-split snapshot + copies / bind-group rebuild / impulse pass; fix the scaling term. + Likely double-digit % CPU frame win in combat. +3. **GI on SW adapters** (D2 path A): fix SDF card lookup transform bug, + make SW WSRC sample cards (or at least sun-shadowed colored ground + bounce), log backend; acceptance = the same GI A/B shows a hue shift at + the wall base (G/R moves) and ≥8–10 % luma in shaded receivers. +4. **SSR/IBL exclusive-ownership crossfade + env-miss fallback** (F10). +5. **Present/pacing options** (F6): Mailbox/Immediate FFI, working fps cap + under vsync-off, expose composite sharpen strength; consider half-res + composite/CA tap reduction at 4K. +6. **2D text at physical resolution + atlas eviction/clamp sampler** (F5). + +### Phase 3 — Content track (parallel, flagged separately — this is the biggest lever toward the goal) +Existing tickets confirmed as the right moves, in order: **SH-009** +splat-mapped PBR terrain (single largest look upgrade; the ground is +texture-less at every scale), SH-010 detail normals, **SH-016** GGX sun +glint (pairs with the F2 recalibration), SH-002 scrolling water normal map, +SH-020/EN-010 leaf-card trees (kills the "smooth blob" canopies), SH-011 +density-ring grass + blade-shape/tint pass (current blades read as sparse +cones near-field — see any capture), weapon viewmodel model (cubes today), +building texture/trim pass (flat plaster box), terrain-skirt seam polish. +New content items from this audit: wave design (mix kinds per wave or raise +BODIES_PER_KIND so >2 enemies are concurrent), river plane vs authored +zig-zag reconciliation, mob-camera handling (fade/limit instead of +clipping through enemies). + +### Decisions needed (blocking parts of the plan) +- **D1 — Operating point on the 760M:** ship 4K/TSR0.5 at ~40 (uncapped, + current) vs target 1440p output (likely 50–60, sharper per-pixel cost + ratio) vs enable the existing DRS. Recommendation: test 1440p output for + one session (one-line window change); pick by eye. +- **D2 — GI strategy on iGPU:** (A) invest in the SW path (Phase 2.3) so + bounce is visible on this class of hardware; (B) accept SSGI-as-subtle-AO + on iGPU, reallocate its ~1.6 ms, and treat colored bounce as a + dGPU/HW-RT feature (validate HW tier on a dGPU box when available). + Recommendation: B short-term, A as the durable engine investment. +- **D3 — Content sourcing:** how much budget for CC0 texture/model packs + vs procedural generation for Phase 3. + +--- + +## Appendix: key numbers + +| Measurement | Value | +|---|---| +| Title fps (profiler on / historical clean) | 39–40 / ~45 | +| Combat fps (10 enemies; clean / profiler) | **34 / 31** | +| Title GPU pass sum | ~11.1 ms (TAA 2.62, composite 2.62, material 2.10, shadows 1.14, hdr 0.95, SSAO 0.61, bloom 0.42) | +| Combat GPU pass sum | ~13.8 ms (TAA 3.15, hdr 2.45, composite 2.45, shadows 2.22, probes ~1.6, SSAO 0.73) | +| SSGI on→off, shaded wall / ground / sky control | −0.7 % / −2.1 % / −0.5 % (noise floor); G/R unchanged (0.9463) | +| Water stripe vs sky vs grass (grazing) | 113–120 vs 97.5 vs 91.5 (water ≫ sky) | +| Water foam-off / fog-off delta | −5.5 % / ≈0 | +| Backlit canopy vs hills behind | 126.2 vs 93.3 (+35 %) | +| translucent_pass CPU (title → building pose → combat) | 0.4 → 2.8–4.7 → 6.4–6.7 ms | +| Crashes | 3/3 runs, main.exe+0xe8e5, after shadows/ssgi/profiler off | diff --git a/src/main.ts b/src/main.ts index 374b069..8b3b445 100644 --- a/src/main.ts +++ b/src/main.ts @@ -117,10 +117,17 @@ setAutoExposureKey(0.155); // clears within ~10 m above, so river dips and tree-base shadows // pool low fog while ridges stay clear — adds depth without // muddying the whole frame. -setFog(0.78, 0.84, 0.90, 0.024, 0.0, 6.0); -// Tier 1.7 — warm god-rays through the trees. Optional polish; -// strength 0.4 keeps it subtle. -setSunShafts(0.4, 0.96, 1.0, 0.95, 0.7); +// Round-2 (audit F2): heightRef sat exactly at water level (y≈0), so a +// grazing look along the river integrated the densest fog slab for the +// full valley length — the visible "milk band" hugged the waterline and +// spilled over the banks. Drop the reference below ground and thin the +// density: low pooling survives, the white sheet does not. +setFog(0.78, 0.84, 0.90, 0.016, -0.6, 6.0); +// Tier 1.7 — warm god-rays through the trees. Round-2 retune (audit F3): +// at 0.4/0.96 the 32-tap shaft march added up to ~+0.14 HDR of warm veil +// on every sunward silhouette — a big share of the "pale backlit +// treeline". 0.18/0.90 keeps the god-ray read without the wash. +setSunShafts(0.18, 0.90, 1.0, 0.95, 0.7); // EN-013 — global wind UBO. All foliage materials (grass, trees, // future ferns/clovers) read these values from PerFrame.wind so // one source of truth drives the whole scene's swing. @@ -563,9 +570,12 @@ const WATER_WGSL = // zero), so we set the params explicitly. // tint rgb absorption_mix foam rim sky_lod pad const matWaterFromFile = compileMaterialFromFile('assets/materials/water.wgsl', 'refractive'); -const matWater = matWaterFromFile > 0 - ? matWaterFromFile - : compileRefractiveMaterial(WATER_WGSL); +// Round-2 audit (F2): the inline WATER_WGSL fallback above has drifted from +// the on-disk shader — Tier-3-era lighting AND a 2-vec4 params layout that +// silently misreads the 3-vec4 WATER_PARAMS, so engaging it ships wrong +// water. Until SH-005 auto-generates the inline copies from the .wgsl +// files, prefer NO water over wrong water in binary-only builds. +const matWater = matWaterFromFile; // Tier 4 layout: absorption coefficient (red dies fastest, blue // slowest), deep-water colour (greenish-teal), then knobs: // foam, rim, sky_lod, micro_normal_strength. @@ -2098,14 +2108,14 @@ while (!windowShouldClose()) { // Title screen — the live world renders as the backdrop, menu.wav plays, // waves/firing/movement are gated off. Any input starts the run. if (gameState === 0) { - // measureText under-reports at these sizes (the WAVE HUD drifts right - // for the same reason), so centre with an explicit monospace estimate: - // glyph advance ≈ 0.58 × size. + // Round-2 audit: measureText is exact in the current engine (verified + // measure ≡ draw advance down to the binary) — the old 0.58 hand + // estimate was itself the source of the visible off-centering. const title = 'BLOOM SHOOTER'; - const tw = title.length * 54 * 0.58; + const tw = measureText(title, 54); drawText(title, (sw - tw) / 2, 170, 54, { r: 236, g: 226, b: 178, a: 255 }); const sub = 'press any key'; - const subw = sub.length * 22 * 0.58; + const subw = measureText(sub, 22); const pulse = Math.floor(175 + Math.sin(getTime() * 3.0) * 70); drawText(sub, (sw - subw) / 2, 244, 22, { r: 225, g: 225, b: 225, a: pulse }); if (isAnyInputPressed()) { From cbd719095bb9070c8707f905e58ea23e336a93d7 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 4 Jul 2026 10:14:11 +0200 Subject: [PATCH 02/10] feat(gameplay): mixed enemy waves, mob-safe camera, one river source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 audit F11 items — the gameplay-shaped findings: Waves: with one kind per wave and BODIES_PER_KIND=2 pool slots, the shipped game never showed more than 2 enemies at once — "19 enemies" was only ever a total. build-world.ts now accepts a comma-separated kind cycle per wave (an explicit spawn sequence when the list length equals count), arena_02 authors 3 mixed waves (22 enemies, dragoons + a tyrant finale that never appeared before), and MAX_CONCURRENT rises 4 -> 6 so the cap is the real limit again. Camera: the orbit-shortening raycast now hits STATIC geometry only. Enemy bodies (MOVING layer) used to shorten the orbit, pulling the camera inside the mob whenever the player got surrounded — near-plane clipping filled the screen with polygon soup (see the audit's combat captures). Enemies briefly occluding the camera reads far better. River: the world file authored six overlapping zig-zag water volumes (with a 2 m gap) that the runtime silently ignored in favour of hardcoded constants — two drifted sources of truth. The world file now carries the one real 80x5 band and main.ts reads extents from it. Boot-smoked 20 s at 4K on the round-2 engine lib; world generator output verified (1 water volume, 3 waves / 22 enemies, sequence matches the authored cycles). --- assets/worlds/arena_02.world.json | 31 +++---------------------------- src/generated/world.ts | 30 +++++++++++++++--------------- src/main.ts | 28 +++++++++++++++++++++------- tools/build-world.ts | 14 +++++++++++--- 4 files changed, 50 insertions(+), 53 deletions(-) diff --git a/assets/worlds/arena_02.world.json b/assets/worlds/arena_02.world.json index 0d0d873..f149d38 100644 --- a/assets/worlds/arena_02.world.json +++ b/assets/worlds/arena_02.world.json @@ -227,7 +227,7 @@ "modelRef": "assets/models/_gizmo_box.glb", "prefabRef": null, "transform": { "position": [0, 0, 0], "rotation": [0, 0, 0], "scale": [1, 1, 1] }, "tint": null, "tags": ["logic"], - "userData": { "kind": "wave_config", "waves": "[{\"count\":3,\"enemy\":\"dretch\"},{\"count\":6,\"enemy\":\"mantis\"},{\"count\":10,\"enemy\":\"marauder\"}]" } }, + "userData": { "kind": "wave_config", "waves": "[{\"count\":4,\"enemy\":\"dretch,dretch,mantis,dretch\"},{\"count\":7,\"enemy\":\"mantis,dretch,marauder,mantis,dretch,marauder,mantis\"},{\"count\":11,\"enemy\":\"marauder,dragoon,mantis,marauder,dragoon,dretch,marauder,mantis,dragoon,marauder,tyrant\"}]" } }, { "id": "h_f0_wall_n", "name": "h_f0_wall_n", "modelRef": "assets/models/_gizmo_box.glb", "prefabRef": null, "transform": { "position": [-21.000, 1.500, -19.000], "rotation": [0, 0, 0], "scale": [1, 1, 1] }, @@ -445,33 +445,8 @@ "userData": { "kind": "static_mesh", "collider": "box", "halfExtents": "0.150, 1.500, 1.500" } } ], "water": [ - { "id": "river_1", "kind": "box", - "center": [32, 0, 13], "size": [14, 1, 3], - "surfaceHeight": 0.05, - "color": [0.20, 0.42, 0.62, 0.75], - "waveAmplitude": 0.05, "waveSpeed": 1.4 }, - { "id": "river_2", "kind": "box", - "center": [20, 0, 11], "size": [12, 1, 3], - "surfaceHeight": 0.05, - "color": [0.20, 0.42, 0.62, 0.75], - "waveAmplitude": 0.05, "waveSpeed": 1.4 }, - { "id": "river_3", "kind": "box", - "center": [8, 0, 13], "size": [12, 1, 3], - "surfaceHeight": 0.05, - "color": [0.20, 0.42, 0.62, 0.75], - "waveAmplitude": 0.05, "waveSpeed": 1.4 }, - { "id": "river_4", "kind": "box", - "center": [-4, 0, 11], "size": [12, 1, 3], - "surfaceHeight": 0.05, - "color": [0.20, 0.42, 0.62, 0.75], - "waveAmplitude": 0.05, "waveSpeed": 1.4 }, - { "id": "river_5", "kind": "box", - "center": [-16, 0, 13], "size": [12, 1, 3], - "surfaceHeight": 0.05, - "color": [0.20, 0.42, 0.62, 0.75], - "waveAmplitude": 0.05, "waveSpeed": 1.4 }, - { "id": "river_6", "kind": "box", - "center": [-30, 0, 11], "size": [12, 1, 3], + { "id": "river", "kind": "box", + "center": [0, 0, 12], "size": [80, 1, 5], "surfaceHeight": 0.05, "color": [0.20, 0.42, 0.62, 0.75], "waveAmplitude": 0.05, "waveSpeed": 1.4 } diff --git a/src/generated/world.ts b/src/generated/world.ts index 15a8380..8d2b049 100644 --- a/src/generated/world.ts +++ b/src/generated/world.ts @@ -72,18 +72,18 @@ export const TREE_HEIGHT = []; export const TREE_RADIUS = []; // Water volumes — flat animated quads at surfaceHeight -export const WATER_COUNT = 6; -export const WATER_CX = [32.0, 20.0, 8.0, -4.0, -16.0, -30.0]; -export const WATER_CY = [0.05, 0.05, 0.05, 0.05, 0.05, 0.05]; -export const WATER_CZ = [13.0, 11.0, 13.0, 11.0, 13.0, 11.0]; -export const WATER_SX = [14.0, 12.0, 12.0, 12.0, 12.0, 12.0]; -export const WATER_SZ = [3.0, 3.0, 3.0, 3.0, 3.0, 3.0]; -export const WATER_R = [0.2, 0.2, 0.2, 0.2, 0.2, 0.2]; -export const WATER_G = [0.42, 0.42, 0.42, 0.42, 0.42, 0.42]; -export const WATER_B = [0.62, 0.62, 0.62, 0.62, 0.62, 0.62]; -export const WATER_A = [0.75, 0.75, 0.75, 0.75, 0.75, 0.75]; -export const WATER_WAVE_AMP = [0.05, 0.05, 0.05, 0.05, 0.05, 0.05]; -export const WATER_WAVE_SPD = [1.4, 1.4, 1.4, 1.4, 1.4, 1.4]; +export const WATER_COUNT = 1; +export const WATER_CX = [0.0]; +export const WATER_CY = [0.05]; +export const WATER_CZ = [12.0]; +export const WATER_SX = [80.0]; +export const WATER_SZ = [5.0]; +export const WATER_R = [0.2]; +export const WATER_G = [0.42]; +export const WATER_B = [0.62]; +export const WATER_A = [0.75]; +export const WATER_WAVE_AMP = [0.05]; +export const WATER_WAVE_SPD = [1.4]; // Scene point lights export const LIGHT_COUNT = 5; @@ -111,7 +111,7 @@ export const PICKUP_KIND = [0.0, 1.0, 0.0, 1.0]; // Wave plan — parallel arrays: WAVE_COUNT[i] enemies of the kind sequence // starting at WAVE_OFFS[i] in WAVE_KIND[]. export const WAVE_PLAN_COUNT = 3; -export const WAVE_SIZE = [3.0, 6.0, 10.0]; -export const WAVE_OFFS = [0.0, 3.0, 9.0]; -export const WAVE_KIND = [0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0]; +export const WAVE_SIZE = [4.0, 7.0, 11.0]; +export const WAVE_OFFS = [0.0, 4.0, 11.0]; +export const WAVE_KIND = [0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 2.0, 1.0, 0.0, 2.0, 1.0, 2.0, 3.0, 1.0, 2.0, 3.0, 0.0, 2.0, 1.0, 3.0, 2.0, 4.0]; // Enemy kind id → string: 0=dretch 1=mantis 2=marauder 3=dragoon 4=tyrant diff --git a/src/main.ts b/src/main.ts index 374b069..7c783e2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -581,11 +581,15 @@ if (matWater > 0) setMaterialParams(matWater, WATER_PARAMS); // Drawn at origin with scale 1, so the mesh's native dimensions are the // visible river size. Subdivide finely enough that the longest // Gerstner wave (~5 m wavelength) shows smooth wave peaks. -const WATER_W = 80; // metres along X — spans the arena's boundary walls -const WATER_D = 5; // metres along Z — river width -const WATER_CX = 0; // world X centre -const WATER_CZ = 12; // world Z centre (matches arena_02's river band) -const WATER_Y = 0.05; +// Round-2 audit (F11): these used to be hardcoded here while the world +// file authored six overlapping zig-zag volumes the runtime ignored — +// two sources of truth that had already drifted. The world file now +// carries the one real river volume and the runtime reads it. +const WATER_W = W.WATER_COUNT > 0 ? W.WATER_SX[0] : 80; // metres along X +const WATER_D = W.WATER_COUNT > 0 ? W.WATER_SZ[0] : 5; // metres along Z +const WATER_CX = W.WATER_COUNT > 0 ? W.WATER_CX[0] : 0; // world X centre +const WATER_CZ = W.WATER_COUNT > 0 ? W.WATER_CZ[0] : 12; // world Z centre +const WATER_Y = W.WATER_COUNT > 0 ? W.WATER_CY[0] + 0.05 : 0.05; const WATER_COLS = 80; const WATER_ROWS = 10; const _wvc = (WATER_COLS + 1) * (WATER_ROWS + 1); @@ -1123,7 +1127,12 @@ const WAVE_OFFS = W.WAVE_OFFS; const WAVE_KINDS = W.WAVE_KIND; const WAVE_SPAWN_DELAY = 1.2; const WAVE_BREAK_DELAY = 2.5; -const MAX_CONCURRENT = 4; +// Round-2 audit (F11): with one kind per wave and BODIES_PER_KIND=2 pool +// slots, the shipped game never showed more than 2 enemies at once — the +// arena felt empty and the measured pool-max load never occurred in play. +// Waves now mix kinds (see arena_02.world.json), so the concurrency cap +// is the real limit again. +const MAX_CONCURRENT = 6; let waveIdx = 0; let waveSpawned = 0; @@ -1455,11 +1464,16 @@ while (!windowShouldClose()) { // than TP_ORBIT_DIST, shorten the orbit so the camera zooms // in instead of clipping through geometry. Leave a small skin // so we don't kiss the wall exactly. + // Round-2 audit (F11): STATIC geometry only. Enemy bodies are in the + // MOVING layer; letting them shorten the orbit pulled the camera + // inside the mob whenever the player got surrounded — near-plane + // clipping filled the screen with polygon soup. Enemies briefly + // occluding the camera reads far better than being inside them. let orbitDist = TP_ORBIT_DIST; const hit = raycast(physics, vec3(fX, fY, fZ), vec3(dxRaw, dyRaw, dzRaw), - TP_ORBIT_DIST, ALL_LAYERS_MASK); + TP_ORBIT_DIST, 1 << Layer.NON_MOVING); if (hit !== null) { orbitDist = Math.max(0.8, hit.fraction * TP_ORBIT_DIST - 0.25); } diff --git a/tools/build-world.ts b/tools/build-world.ts index 1f1904b..002cb5a 100644 --- a/tools/build-world.ts +++ b/tools/build-world.ts @@ -292,9 +292,17 @@ function main() { for (const w of waves) { offs.push(kinds.length); plan.push(w.count); - const kid = ENEMY_KINDS.indexOf(w.enemy); - const safe = kid < 0 ? 0 : kid; - for (let i = 0; i < w.count; i++) kinds.push(safe); + // `enemy` may be a comma-separated cycle ("dretch,mantis") so a + // single wave can mix kinds — with one kind per wave and only + // BODIES_PER_KIND pool slots each, the shipped game never showed + // more than 2 enemies at once (round-2 audit F11). When the list + // length equals `count` it reads as an explicit spawn sequence. + const names = w.enemy.split(',').map(s => s.trim()).filter(s => s.length > 0); + for (let i = 0; i < w.count; i++) { + const name = names.length > 0 ? names[i % names.length] : 'dretch'; + const kid = ENEMY_KINDS.indexOf(name); + kinds.push(kid < 0 ? 0 : kid); + } } } catch (err) { console.warn('wave_config userData.waves is not valid JSON; skipping:', err); From e43ae32e504e5c051d11d3123445cba43f7f7948 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 4 Jul 2026 17:23:24 +0200 Subject: [PATCH 03/10] =?UTF-8?q?feat(materials):=20EN-022=20shooter=20hal?= =?UTF-8?q?f=20=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'; From b26ecf8b6b1a0e700398a0663b2a55baf9722ac1 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 4 Jul 2026 19:28:22 +0200 Subject: [PATCH 04/10] docs(perry-quirks): #5 split/parseFloat runtime overread (EN-020 root cause) --- docs/perry-quirks.md | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/perry-quirks.md b/docs/perry-quirks.md index 0f0a1e7..1b2929b 100644 --- a/docs/perry-quirks.md +++ b/docs/perry-quirks.md @@ -1,8 +1,9 @@ -# Perry 0.5.158 codegen quirks +# Perry 0.5.x codegen/runtime quirks -Three reproducible bugs observed while building this shooter. All of -them affect `bloom/world` in the engine and force us to hardcode -world data in TypeScript until they're fixed. +Reproducible bugs observed while building this shooter. Most affect +`bloom/world` in the engine and force us to hardcode world data in +TypeScript until they're fixed; #5 is a runtime memory-safety bug that +crashed the shipped game. ## 1. Reachable `throw new Error` segfaults at startup @@ -111,6 +112,36 @@ the arena walls, wave composition, pickup positions). The `assets/worlds/arena_01.world.json` is kept as a reference for when the editor pipeline eventually works. +## 5. `split()` + `parseFloat()` overread heap allocations (EN-020) + +**Symptom.** Access violation (c0000005) with empty stderr, no WER +event on some paths — the game dies silently or freezes (last +presented frame stays on screen, input looks dead). Faulting reads +land just past the end of a heap page (`0x…FFF8`-style addresses). +Layout-sensitive: a relink can hide or resurface it, which made it +look "unreproducible" for a whole audit round. + +**Trigger.** Running a string through `split()` and `parseFloat()` +every frame. Perry's runtime scanners read a word past the end of +their own exact-sized slice allocations; with enough fresh +allocations per second, one eventually lands flush against an +unmapped page. The shooter hit it via `getProfilerOverlay()` / +`getProfilerFrameHistory()` (F3 overlay): 6/6 crashes within 7–29 s +of overlay time, in two different link layouts. One-shot parses +(e.g. the engine's OBJ text loader) carry the same risk per call, +just with lottery odds instead of per-frame odds. + +**Workaround.** Never parse packed text across the FFI on a hot +path. The engine now exposes a numeric profiler ABI +(`bloom_profiler_row_count/_label/_cpu_us/_gpu_us`, `_hist_*`) and +`getProfilerOverlay`/`getProfilerFrameHistory` are rewritten on it — +numbers cross as f64, label strings cross whole and are only drawn. +Engine-allocated FFI strings are also tail-padded 16 zero bytes +(defense for engine-side allocations only — padding cannot protect +Perry-internal slices, which is why the ABI change was required). +If you add an FFI that returns data for per-frame consumption: +return numbers, not delimited text. + ## Impact on the shooter's design The combination of 1 and 3 means we can't use `bloom/world`'s From 6e5b664ffe73a6c32ee8e3f0565d2ed4ecca7e57 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 6 Jul 2026 05:08:11 +0200 Subject: [PATCH 05/10] docs: sync all docs to post-round-2 + EN-020 reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md: Windows-specifics build section (bun world build, --debug-symbols, engine rebuild + relink/perry-cache rules, self-reporting crashes, batch-run note), five quirks incl. the EN-020 string rule, inline-grass sync warning, real main.ts size. - perry-quirks.md: Impact section now describes the shipped build-time world generator (not the pre-generator hardcoded-world era); quirk-verification section updated; quirk #5 cross-refs. - tickets.md: round-2 PR status header; SH-012 shipped (round-2 tree shading, evolved form); SH-022 shipped (planar-reflection water + round-2 recalibration); SH-021 drift warning now cites the real EN-022 incident. - audit-round2.md: status addendum — PR roster, F1/EN-020 root-caused and fixed (toggle correlation was a red herring; per-frame string parsing was the trigger), decisions D1-D3 still open. - engine-notes.md: all three prerequisites are landed in the engine repo; doc kept as the record of why. - visual-quality.md: status header (tiers 1-4 + round-2 pass landed; numbers in tier tables are historical; where current truth lives). - README.md: stale engine-patch prerequisite replaced, F3 profiler row added, Windows --debug-symbols pointer. --- CLAUDE.md | 47 ++++++++++++++++++++++++++++++++++-------- README.md | 10 +++++---- docs/audit-round2.md | 25 ++++++++++++++++++++++ docs/engine-notes.md | 11 +++++++--- docs/perry-quirks.md | 45 ++++++++++++++++++++-------------------- docs/tickets.md | 32 +++++++++++++++++++++++++--- docs/visual-quality.md | 11 ++++++++++ 7 files changed, 139 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ff65144..0be0c95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,8 +11,11 @@ runnable with `perry compile src/main.ts -o main && ./main`. ## Where things live -- `src/main.ts` — single-file game (~900 LOC). Flat-array state per - Perry convention. +- `src/main.ts` — single-file game (~2,200 LOC). Flat-array state per + Perry convention. Also carries an inline copy of + `grass_instanced.wgsl` (`GRASS_INSTANCED_WGSL`) that MUST be kept in + sync with the on-disk file when either changes (SH-005 will automate + this; the drift bit for real once already). - `src/input.ts` / `src/player.ts` — small single-purpose modules. - `src/generated/world.ts` — **generated, do not edit**. Built from `assets/worlds/arena_02.world.json` by `tools/build-world.ts`. The @@ -27,14 +30,17 @@ runnable with `perry compile src/main.ts -o main && ./main`. ## Read before changing things -- **`docs/perry-quirks.md`** — three Perry 0.5.158 bugs we work around. +- **`docs/perry-quirks.md`** — five Perry 0.5.x bugs we work around. Never add `throw new Error(...)`, never rely on object-shorthand - return values, never `JSON.parse(...).arr.length`. Use flat arrays - and explicit keys. A well-meaning refactor can silently crash this - on startup if these rules are forgotten. -- **`docs/engine-notes.md`** — the engine needs `default = ["jolt"]` - in `native/macos/Cargo.toml` and `"libs": ["c++"]` in - `package.json`'s macOS nativeLibrary target. Not yet upstream. + return values, never `JSON.parse(...).arr.length`, and **never + `split()`/`parseFloat()` an FFI string on a per-frame path** (quirk + #5 / EN-020 — the runtime overreads its own slice allocations; this + crashed the shipped game). Use flat arrays, explicit keys, and + numeric FFIs. A well-meaning refactor can silently crash this on + startup if these rules are forgotten. +- **`docs/engine-notes.md`** — the macOS Jolt/`libc++` link + requirements are landed in the engine repo now; the doc records why + they exist. - **`docs/asset-pipeline.md`** — the IQE → glTF conversion notes, especially the Z-up → Y-up rotation scheme. The naive "conjugate every joint" approach doesn't work; only the root joint @@ -57,6 +63,26 @@ bun tools/convert-arena.ts # regenerate textured arena bun tools/validate-glb.ts # glTF-validator report ``` +### Windows specifics (the current dev box) + +- `npm` may not be on the PowerShell PATH — run the generator directly: + `bun tools/build-world.ts assets/worlds/arena_02.world.json`. +- Compile with `perry compile src/main.ts -o main --debug-symbols` so + `main.pdb` lands next to the exe — crash reports then symbolize. +- Engine rebuild: + `cargo build --release --manifest-path ..\engine\native\windows\Cargo.toml` + with `INTERPROCEDURAL_OPTIMIZATION=OFF` in the environment. After an + engine-only rebuild, **touch `src/main.ts`** or perry skips the + relink; after any engine `package.json` manifest change, also delete + `.perry-cache/`. +- Crashes self-report: the engine prints + `bloom: FATAL unhandled exception … (main.exe+0x…)` to stderr and + writes a minidump to `tools/.testout/dumps/` (WER LocalDumps is armed + there too). Triage runbook: engine `docs/crash-triage-windows.md`. +- The game runs borderless-fullscreen at native res — test runs are + batch runs (launch with stdout/stderr redirected; the screen is + gone while it runs). + ## World pipeline `assets/worlds/.world.json` is authored by hand (later: by the @@ -80,6 +106,9 @@ come from the top-level fields. Adding a new kind = add a bucket in - Return `{ ok: boolean, error: string }` instead of throwing. - Prefer hardcoded constants over JSON-loaded config until the Perry JSON.parse bug is resolved. +- Data crossing the FFI on a hot path is numbers, not delimited + strings (perry-quirks #5). Strings that do cross are drawn whole, + never parsed. ## License diff --git a/README.md b/README.md index 452da72..cfb2fe8 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Controls: | 1 / 2 | Switch weapon (rifle / blaster) | | R | Reload / restart after death | | Tab | Toggle cursor capture | +| F3 | Profiler overlay (per-pass CPU/GPU µs) | | F12 | Screenshot to `shooter_.png` | | Esc | Quit | @@ -40,10 +41,11 @@ tyrant. Ammo crates respawn at the four arena corners. - **Perry** `0.5.158+` on `$PATH`. - **Bloom engine** at `../engine/` relative to this repo, built at commit `0137335` (drop debug-green hardcode in `fs_main_3d`) or - later. The engine's `native/macos/Cargo.toml` needs `default = - ["jolt"]` and its `package.json` needs `"libs": ["c++"]` on the - macOS target so Jolt physics links cleanly (see - [docs/engine-notes.md](docs/engine-notes.md)). + later. The Jolt/`libc++` link requirements are committed in the + engine repo these days — [docs/engine-notes.md](docs/engine-notes.md) + records why they exist. On Windows, prefer + `perry compile src/main.ts -o main --debug-symbols` so crash + reports symbolize (see `CLAUDE.md`). - **Bun** for the asset converters and dev scripts. - **ffmpeg** and macOS's `sips` for audio + texture resizing during asset conversion. diff --git a/docs/audit-round2.md b/docs/audit-round2.md index 6e4340d..f30ec35 100644 --- a/docs/audit-round2.md +++ b/docs/audit-round2.md @@ -26,6 +26,31 @@ target on this box (see Decision D1). --- +## Status addendum (2026-07-06) + +The plan below was executed as PRs: engine **#75** (profiler stale +averages + eviction), **#76** (2D gamma + sharpen), **#77** (transient +depth leak — the F7 "translucent mystery", 16–26× improvement), **#78** +(EN-021 SSR/IBL ownership), **#79** (EN-023 GI data path, partial), +**#80** (present-mode FFI), **#81** (2D text at physical resolution), +**#82** (EN-022 material motion vectors); shooter **#2** (water/tree/ +fog/title), **#3** (waves/camera/water bounds), **#4** (material +velocity). All open against their mains; local `round2/integration` +branches carry the merged set. + +**Finding F1 (the AV) is root-caused and fixed** — and the audit's +toggle correlation was a red herring. The real trigger was per-frame +profiler *string parsing*: Perry 0.5.x `split()`/`parseFloat()` read +past their own slice allocations (EN-020). With the overlay as a rate +amplifier it reproduced 6/6 in 7–29 s; the fix (numeric profiler ABI + +padded engine strings + in-engine crash reporting) validated 3/3 × 90 s +clean. Full story: engine `docs/tickets.md` § EN-020 and +`docs/crash-triage-windows.md`; quirk write-up in +`docs/perry-quirks.md` § 5. + +Decisions D1 (operating point), D2 (GI strategy on SW adapters), D3 +(content budget) remain open — they are the user's call. + ## Headline results 1. **The game AVs (access violation) under runtime feature toggling** — diff --git a/docs/engine-notes.md b/docs/engine-notes.md index 7a758be..2f5c08e 100644 --- a/docs/engine-notes.md +++ b/docs/engine-notes.md @@ -1,8 +1,13 @@ # Engine-side prerequisites -This shooter needs three things on the Bloom engine side before it -will build and run cleanly. Two of them were one-line edits applied -locally during development; they are not upstreamed yet. +**Status 2026-07-06: all three items below are landed in the engine +repo** — `native/macos/Cargo.toml` ships +`default = ["jolt", "models3d", "image-extras"]` and `package.json` +carries `"libs": ["c++"]` for the macOS target (the Windows target +has its equivalent libDirs/libs for Jolt). Nothing needs local +patching anymore; this doc is kept as the record of why those lines +exist. The "teach Perry a `features` field" improvement at the bottom +of §2 is still open on the Perry side. ## 1. `fs_main_3d` debug-green hardcode diff --git a/docs/perry-quirks.md b/docs/perry-quirks.md index 1b2929b..e98a72c 100644 --- a/docs/perry-quirks.md +++ b/docs/perry-quirks.md @@ -144,29 +144,28 @@ return numbers, not delimited text. ## Impact on the shooter's design -The combination of 1 and 3 means we can't use `bloom/world`'s -`loadWorld` at all. For now the shooter keeps world structure in -`src/main.ts`: - -```ts -const debugBoxes: DebugBox[] = [ /* floor + 4 walls */ ]; -const wavePlan = [3, 6, 10]; -const WAVE_KINDS = [ /* flat concat of per-wave kind sequences */ ]; -const spawnerX = [-18, 18, -18, 18]; -const spawnerZ = [-18, -18, 18, 18]; -const pickupX = [-18, 18, -18, 18]; -const pickupZ = [18, -18, -18, 18]; -const pickupKind = [PICKUP_RIFLE, PICKUP_BLASTER, PICKUP_RIFLE, PICKUP_BLASTER]; -``` - -Once Perry's JSON behaviour is fixed, the plan is to move this into -`assets/worlds/arena_01.world.json` (same schema as Bloom Garden) -and load it via a wrapper that reads the file + validates without -throws. +The combination of 1, 3 and 4 means we can't use `bloom/world`'s +`loadWorld` or runtime `JSON.parse` at all. The shipped answer is the +**build-time world generator**: `assets/worlds/arena_02.world.json` +is authored in the standard engine schema (the editor round-trips +it), and every `npm run dev` / `npm run world` runs +`tools/build-world.ts` under **bun** (real JS — Perry never parses +the JSON), which emits Perry-safe parallel flat arrays at +`src/generated/world.ts`. The runtime reads geometry, lighting, +spawners, pickups, water and the wave plan exclusively from that +generated module. Adding an entity kind = a bucket in +`tools/build-world.ts` + consuming the arrays in `main.ts`. + +Quirk #5 shapes the FFI surface the same way: per-frame data crosses +as numbers (see the engine's `bloom_profiler_row_*` ABI), never as +delimited text. ## Verifying a Perry fix -Flip `SELFTEST = true` in `src/main.ts`, force-spawn a test scenario, -and screenshot. If `loadWorld` starts working, restoring the -JSON-driven path is a ~30 LOC change in `main.ts` — the JSON file -already exists. +For quirks 1–4: point the generator pipeline at a runtime +`JSON.parse` path in a scratch branch and see whether entities load +with correct counts (`.length` populated) and no startup segfault +with a reachable `throw` in scope. For quirk #5: return a +`"1.23|4.56\n"`-style blob from any FFI and `split`+`parseFloat` it +every frame — on a broken runtime this AVs within a minute (see +engine `docs/tickets.md` § EN-020 for the validated repro numbers). diff --git a/docs/tickets.md b/docs/tickets.md index ef86c03..fc41ce5 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -6,6 +6,15 @@ engine repo's `docs/tickets.md`. Status legend: 🟢 ready · 🟡 needs engine support · 🔴 needs an asset or a design decision +> **Round-2 status (2026-07-04, see `docs/audit-round2.md`):** shooter +> PRs #2 (water fresnel/foam recalibration, tree crown shading, fog + +> sun shafts, exact title centering), #3 (mixed-kind wave plan via +> comma-cycle world data, MAX_CONCURRENT 6, third-person camera ray, +> water bounds from world data) and #4 (motion vectors in all four +> world materials incl. the inline grass copy) are open against main. +> The EN-020 crash root-cause work (2026-07-04/06) additionally +> hardened the engine FFI — see `docs/perry-quirks.md` § 5. + --- ## SH-001 — Wind-coupled ambient audio 🔴 @@ -284,7 +293,15 @@ work landed in the SH-021 instanced-grass material rewrite. --- -## SH-012 — Two-sided foliage lighting in `tree.wgsl` 🟢 *(Phase A)* +## SH-012 — Two-sided foliage lighting in `tree.wgsl` ✅ *(shipped in round-2 PR #2, evolved form)* + +> **Status 2026-07-04:** landed, in a better-tuned shape than sketched +> below: crown self-occlusion on the sky-fill (`crown_ao`), rim-gated +> transmission (`pow(back,2)·pow(view_align,1.5)·rim`, leaf-gated via +> the trunk/leaf discriminator), and a distance-based leaf-shading +> floor. Validated by region-luma: backlit canopies dropped from a +> +35% flat wash to +26% concentrated on sun-struck tops. The +> "proper ABI" version remains SH-023 (gated on EN-012). **Why:** `tree.wgsl` is plain Lambert with cloud noise. The canopy half facing away from the sun goes pure black on every @@ -561,11 +578,20 @@ scatter is uniform.) which has no from-file/hot-reload path today, so the WGSL is also inlined in `main.ts` as `GRASS_INSTANCED_WGSL`. Source of truth is the `.wgsl` file; the inline copy must track edits to it. SH-005's -auto-generation will close that gap. +auto-generation will close that gap. *(This bit for real during +EN-022: the motion-vector rewrite had to be applied to both copies +by hand — the drift risk is not hypothetical.)* --- -## SH-022 — Planar-reflective river 🟡 *(Phase B — gated on EN-011)* +## SH-022 — Planar-reflective river ✅ *(shipped — planar-reflection water landed pre-round-2)* + +> **Status:** the water material samples a real reflection of the +> scene (with env fallback at grazing angles) — trees/buildings on +> the bank reflect. Round-2 PR #2 then recalibrated fresnel (clamped +> 0.60), reflection-ray floor, and grazing-angle foam fade after the +> mirror band regression (audit F: water measured 1.16–1.23× sky +> luma, now 1.00×). Original sketch kept below for context. **Why:** today the water reflects only the static HDR sky panorama via `sample_env()`. Trees lining the bank don't show up in the diff --git a/docs/visual-quality.md b/docs/visual-quality.md index 339f285..63915ee 100644 --- a/docs/visual-quality.md +++ b/docs/visual-quality.md @@ -1,5 +1,16 @@ # Visual quality plan +> **Status 2026-07-06:** Tiers 1–4 below are landed and merged +> (round 1), plus the round-2 pass on top: water fresnel/foam +> recalibration, tree crown self-occlusion + rim-gated transmission, +> fog + sun-shaft retune, real motion vectors for all world materials +> under TSR, and physical-resolution 2D text. The *numbers* in the +> tier tables are the values as first landed — current values live in +> `src/main.ts` and `assets/materials/*.wgsl`, and the measured +> round-2 evidence lives in `docs/audit-round2.md`. The forward +> roadmap ("Tier 6+" / UE5-tier) is ticketed as SH-009+ in +> `docs/tickets.md`. + This doc tracks the multi-phase work to take the shooter from "structurally correct but flat" to "looks like a real outdoor scene." Player + enemies are explicitly **out of scope** here — From 24cb88f6f5d08fd1d0a05a3f9bc001011ff3e14c Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 6 Jul 2026 05:09:22 +0200 Subject: [PATCH 06/10] chore: ignore perry/lld *.tmp0 relink leftovers --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 7ca9b17..2af9213 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ _run_out.txt # lld /DEBUG output from perry compile --debug-symbols main.pdb +# perry/lld intermediate files left behind when a relink races a stale handle +*.tmp0 From 62b20d9c144f32fcfe9a06d4239c98a29ab87616 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 6 Jul 2026 12:06:14 +0200 Subject: [PATCH 07/10] feat(perf): dormant PERFTEST harness + GI stays on (engine bake fix landed) Companion to engine 713e2a1 (fullscreen lag + flicker root causes). - SSGI remains enabled: the Lumen SW-GI camera-follow bakes that stalled the GPU 1-2.4s per ~5s of mouse-look on the 760M are now amortized engine-side (binned + sliced clipmap bake). Measured here: rotating gameplay locked at 59-60fps with GI on, startup freeze gone. - PERFTEST harness (dormant, PERFTEST=false): mode 0 staged feature bisect, mode 1 gameplay timeline with per-phase frame brackets (begin/A/B/C/D/E attribution; E=endDrawing catches GPU submit stalls), PERFSPIKE dumps, scripted enemy kills, camera-rotation load, optional swapchain screenshot schedule. This instrumentation found the clipmap stall, the stale shadow VPs, the AE hunting cycle, the EN-022 jitter velocity, and the GTAO phase pulse -- keep it for future regressions. - Notes-to-future-self in comments: injected keys never reach isAnyInputPressed (edge consumed by the pump) -- flip gameState directly; F3 profiler overlay costs ~5.5ms/frame at 4K, do not judge perf with it open. --- src/main.ts | 259 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) diff --git a/src/main.ts b/src/main.ts index f858e31..4e77c0d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,6 +22,9 @@ import { setVignette, setFilmGrain, setEnvIntensity, setAutoExposure, setAutoExposureKey, setFog, setSunShafts, setWind, setTaaEnabled, setRenderScale, + setPresentMode, setSsgiEnabled, setSsaoEnabled, setSsrEnabled, + setShadowsEnabled, setBloomEnabled, setShadowsAlwaysFresh, + setManualExposure, } from 'bloom/core'; import { addPointLight, enableShadows, @@ -145,6 +148,12 @@ setWind(0.85, 0.50, 0.10, 1.6); // reconstruction softness. setTaaEnabled(true); setRenderScale(0.5); +// 2026-07-06 fullscreen-lag investigation: the Lumen SW-GI camera-follow +// bakes (SDF clipmap + WSRC) used to re-run as single full-volume +// dispatches whenever the view moved — a 1-2.4 s GPU stall every ~5 s of +// mouse-look on the 760M. The engine now amortizes both (binned + sliced +// clipmap bake into a staging volume, one WSRC cascade per frame), so +// SSGI stays enabled. // Static box colliders — invisible physics walls that bound the plaza // and carry the ground plane. @@ -1389,9 +1398,151 @@ setFilmGrain(0.018); // barely-there noise — 0.05+ reads as heavy speckl const SELFTEST = false; let testFrame = 0; +// ---- PERFTEST harness (temporary diagnostic) -------------------------------- +// Bisects the fullscreen slowdown: measures wall-clock FPS over 120-frame +// windows on the title screen (full world renders as the backdrop), toggling +// one pipeline feature per stage. Stage 0 is the shipped config under Fifo +// vsync; every later stage runs Mailbox (uncapped) so the vsync cap can't +// mask differences. The final stage re-enables the shipped config with the +// engine profiler on and dumps the per-pass CPU/GPU table. Prints PERF / +// PERFPASS lines to stdout — run batch with output redirected. Flip to false +// (or delete) when the investigation closes. +const PERFTEST = false; +// Mode 0 — staged feature bisect on the title screen (deterministic backdrop). +// Mode 1 — gameplay timeline: injects a keypress to start the run, keeps the +// player alive, and logs fps / worst-frame / alive-enemy count per 60-frame +// window; the profiler turns on for the final third to get a combat per-pass +// table. +const PERF_MODE = 1; +// false = stay on the title screen (stationary world backdrop — used for +// external flicker captures); true = auto-start the run at frame 20. +const PERF_START_GAME = false; +const PERF_SETTLE = 30; // frames to let a config change settle +const PERF_MEASURE = 120; // frames per measurement window +const PERF_STAGES = 11; +let perfStage = 0; +let perfStageFrame = 0; +let perfT0 = 0; +let perfDone = false; +let perfDtMax = 0; +let perfWindows = 0; +// Phase brackets for spike attribution (mode 1). A frame is split into: +// begin (loop-around + beginDrawing), A (music/input/physics), B (game +// logic up to the 3D pass), C (3D draws + anim updates), D (2D HUD), +// E (endDrawing = submit/present). On a dt spike the previous frame's +// phase times get dumped. +let perfTTop = 0; +let perfTA = 0; +let perfTB = 0; +let perfTC = 0; +let perfTD = 0; +let perfPrevEnd = 0; +let perfMsBegin = 0; +let perfMsA = 0; +let perfMsB = 0; +let perfMsC = 0; +let perfMsD = 0; +let perfMsE = 0; +let perfPrevAlive = 0; + +// Scripted kill for mode 1 — mirrors the rifle-kill death path exactly so +// death-triggered work (first die-anim playback, body teleport, sound) can +// be correlated with frame spikes deterministically. +function perfKillOne(): void { + for (let i = 0; i < MAX_ENEMIES; i++) { + if (enAlive[i] > 0) { + enAlive[i] = 0; + enDying[i] = 1; + enDeathT[i] = 0; + enDeathYaw[i] = 0; + setBodyPosition(enBody[i], vec3(enX[i], -100, enZ[i]), false); + playSound(sfxAttack); + console.log('PERFKILL t=' + getTime().toFixed(2) + + ' slot=' + i + ' kind=' + enKind[i]); + return; + } + } +} + +function perfDumpProfiler(): void { + const rows = getProfilerOverlay(); + for (let i = 0; i < rows.length; i++) { + const r = rows[i]; + const gpuStr = r.gpuUs < 0 ? '-1' : r.gpuUs.toFixed(1); + console.log('PERFPASS ' + r.label + + ' cpu_us=' + r.cpuUs.toFixed(1) + ' gpu_us=' + gpuStr); + } + const hist = getProfilerFrameHistory(); + let cpuSum = 0; + let gpuSum = 0; + let cpuMax = 0; + let gpuMax = 0; + for (let i = 0; i < hist.length; i++) { + cpuSum = cpuSum + hist[i].cpuUs; + gpuSum = gpuSum + hist[i].gpuUs; + if (hist[i].cpuUs > cpuMax) cpuMax = hist[i].cpuUs; + if (hist[i].gpuUs > gpuMax) gpuMax = hist[i].gpuUs; + } + if (hist.length > 0) { + console.log('PERFHIST frames=' + hist.length + + ' cpu_avg_us=' + (cpuSum / hist.length).toFixed(1) + + ' cpu_max_us=' + cpuMax.toFixed(1) + + ' gpu_avg_us=' + (gpuSum / hist.length).toFixed(1) + + ' gpu_max_us=' + gpuMax.toFixed(1)); + } + console.log('PERF done'); +} + +function perfStageName(s: number): string { + if (s === 0) return 'baseline-fifo-rs0.5'; + if (s === 1) return 'mailbox-rs0.5'; + if (s === 2) return 'mailbox-no-ssgi'; + if (s === 3) return 'mailbox-no-ssao'; + if (s === 4) return 'mailbox-no-ssr'; + if (s === 5) return 'mailbox-no-shadows'; + if (s === 6) return 'mailbox-no-bloom'; + if (s === 7) return 'mailbox-rs0.25'; + if (s === 8) return 'mailbox-rs1.0'; + if (s === 9) return 'mailbox-all-off'; + return 'mailbox-profiler-on'; +} + +function perfStageApply(s: number): void { + // Restore the shipped config first so each stage isolates one change. + setSsgiEnabled(true); + setSsaoEnabled(true); + setSsrEnabled(true); + setShadowsEnabled(true); + setBloomEnabled(true); + setRenderScale(0.5); + setProfilerEnabled(false); + if (s === 0) { setPresentMode(0); return; } + setPresentMode(1); + if (s === 2) setSsgiEnabled(false); + if (s === 3) setSsaoEnabled(false); + if (s === 4) setSsrEnabled(false); + if (s === 5) setShadowsEnabled(false); + if (s === 6) setBloomEnabled(false); + if (s === 7) setRenderScale(0.25); + if (s === 8) setRenderScale(1.0); + if (s === 9) { + setSsgiEnabled(false); + setSsaoEnabled(false); + setSsrEnabled(false); + setShadowsEnabled(false); + setBloomEnabled(false); + } + if (s === 10) setProfilerEnabled(true); +} + while (!windowShouldClose()) { beginDrawing(); + if (PERFTEST) { + const nowTop = getTime(); + perfMsBegin = perfPrevEnd > 0 ? (nowTop - perfPrevEnd) * 1000 : 0; + perfTTop = nowTop; + } const dt = getDeltaTime(); const sw = getScreenWidth(); const sh = getScreenHeight(); @@ -1416,6 +1567,101 @@ while (!windowShouldClose()) { const input = readInput(); testFrame = testFrame + 1; + + // PERFTEST stage driver — see the harness block above the loop. + if (PERFTEST && !perfDone && PERF_MODE === 0) { + if (perfStageFrame === 0) perfStageApply(perfStage); + perfStageFrame = perfStageFrame + 1; + if (perfStageFrame === PERF_SETTLE) perfT0 = getTime(); + if (perfStageFrame === PERF_SETTLE + PERF_MEASURE) { + const wall = getTime() - perfT0; + const fps = PERF_MEASURE / wall; + const msf = 1000 * wall / PERF_MEASURE; + console.log('PERF ' + perfStageName(perfStage) + + ' fps=' + fps.toFixed(1) + ' ms=' + msf.toFixed(2)); + if (perfStage === PERF_STAGES - 1) { + perfDumpProfiler(); + perfDone = true; + } else { + perfStage = perfStage + 1; + perfStageFrame = 0; + } + } + } + if (PERFTEST && !perfDone && PERF_MODE === 1) { + // Start the run once the mouse-settle guard has passed (mirrors the + // title-screen input handler), then hold the player immortal so the + // timeline never hits the game-over overlay. + if (PERF_START_GAME && testFrame === 20 && gameState === 0) { + gameState = 1; + stopMusic(musicMenu); + playMusic(musicAmbient); + waveBreakTimer = WAVE_BREAK_DELAY; + } + playerHP = PLAYER_HP_MAX; + gameOver = false; + // Spike attribution: dt covers the previous frame, so dump the phase + // brackets recorded during it (plus this frame's beginDrawing time). + if (dt > 0.1 && perfPrevEnd > 0) { + console.log('PERFSPIKE t=' + getTime().toFixed(2) + + ' dt_ms=' + (dt * 1000).toFixed(1) + + ' begin_ms=' + perfMsBegin.toFixed(1) + + ' A_ms=' + perfMsA.toFixed(1) + + ' B_ms=' + perfMsB.toFixed(1) + + ' C_ms=' + perfMsC.toFixed(1) + + ' D_ms=' + perfMsD.toFixed(1) + + ' E_ms=' + perfMsE.toFixed(1) + + ' alive=' + countAlive()); + } + const perfAliveNow = countAlive(); + if (perfAliveNow !== perfPrevAlive) { + console.log('PERFALIVE t=' + getTime().toFixed(2) + + ' alive=' + perfAliveNow + ' wave=' + waveIdx); + perfPrevAlive = perfAliveNow; + } + // Warm the death-thud sound early so later kill hitches can't be the + // audio path; then kill one enemy every 3 windows to march the waves + // forward and correlate deaths with spikes. + if (perfStageFrame === 30 && perfWindows === 8) playSound(sfxAttack); + if (perfStageFrame === 30 && perfWindows >= 12 && perfWindows % 3 === 0) { + perfKillOne(); + } + // In-engine flicker arbitration: swapchain screenshots (now that + // takeScreenshot works on Windows) — 4 consecutive frames at 6 + // sample points. Consecutive-frame diffs catch real frame-to-frame + // change; cross-window diffs catch slower state alternation. The + // desktop-capture path (DWM) is bypassed entirely. + if (perfWindows >= 6 && perfWindows <= 21 && perfWindows % 3 === 0 + && perfStageFrame >= 10 && perfStageFrame < 14) { + takeScreenshot('tools/.testout/eng_w' + perfWindows + + '_f' + perfStageFrame + '.png'); + } + if (perfStageFrame === 1) { + if (perfWindows === 23) { + console.log('PERF done'); + perfDone = true; + } + } + if (perfWindows === 25 && perfStageFrame === 1) setProfilerEnabled(true); + perfStageFrame = perfStageFrame + 1; + if (perfStageFrame === 1) { perfT0 = getTime(); perfDtMax = 0; } + if (perfStageFrame > 1 && dt > perfDtMax) perfDtMax = dt; + if (perfStageFrame === 61) { + const wall = getTime() - perfT0; + console.log('PERFT t=' + getTime().toFixed(1) + + ' fps=' + (60 / wall).toFixed(1) + + ' avg_ms=' + (1000 * wall / 60).toFixed(2) + + ' max_ms=' + (perfDtMax * 1000).toFixed(1) + + ' alive=' + countAlive() + + ' wave=' + waveIdx); + perfStageFrame = 0; + perfWindows = perfWindows + 1; + if (perfWindows === 35) { + perfDumpProfiler(); + perfDone = true; + } + } + } // Selftest: drive the player forward so screenshots can verify // walk direction. Runs before the player controller update so // the override actually reaches updatePlayerController. @@ -1450,6 +1696,7 @@ while (!windowShouldClose()) { updatePlayerController(dt, input.moveX, input.moveZ, fwd, rgt, input.jump); } stepPhysics(physics, dt); + if (PERFTEST) perfTA = getTime(); // Phase 7 — footstep / water-entry splats. When the player is // inside the river band, submit an impulse at their XZ every @@ -1760,6 +2007,7 @@ while (!windowShouldClose()) { b: Math.floor(W.ENV_SUN_B * 255), a: 255 }, W.ENV_SUN_I); + if (PERFTEST) perfTB = getTime(); beginMode3D({ position: vec3(CAM[2], CAM[3], CAM[4]), target: vec3(CAM[5], CAM[6], CAM[7]), @@ -2077,6 +2325,7 @@ while (!windowShouldClose()) { } } endMode3D(); + if (PERFTEST) perfTC = getTime(); // Crosshair — brighten while firing const crossA = muzzleFlashT > 0 ? 240 : 160; @@ -2240,5 +2489,15 @@ while (!windowShouldClose()) { } if (isKeyPressed(Key.ESCAPE)) break; + if (PERFTEST) perfTD = getTime(); endDrawing(); + if (PERFTEST) { + perfPrevEnd = getTime(); + perfMsA = (perfTA - perfTTop) * 1000; + perfMsB = (perfTB - perfTA) * 1000; + perfMsC = (perfTC - perfTB) * 1000; + perfMsD = (perfTD - perfTC) * 1000; + perfMsE = (perfPrevEnd - perfTD) * 1000; + } + if (PERFTEST && perfDone) break; } From b3ac5e6a6545e0535efd7c751b7c02cb8ec8e6bc Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 6 Jul 2026 12:23:20 +0200 Subject: [PATCH 08/10] fix(game): enemies follow terrain height; player faces the camera yaw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enemies were steered in XZ only and kept their spawn Y forever — on any slope they walked INTO the hill and attacked the player from inside the ground. New terrainHeightAt() bilinearly samples the same heightfield grid the Jolt collider uses; spawn and the per-frame AI step snap enY to it. - Player model yaw offset was -pi/2, which faced the character INTO the camera (180 degrees off the aim direction). Verified empirically with windowed screenshots at yaws 0.00/0.31/0.78/4.29: +pi/2 lines the bsuit front up with camera forward at every heading. --- src/main.ts | 46 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/src/main.ts b/src/main.ts index 4e77c0d..e098a32 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1169,6 +1169,30 @@ let waveBreakTimer = WAVE_BREAK_DELAY; let spawnTimer = 0; let gameWon = false; +// Bilinear terrain height at a world XZ, sampled from the same +// heightfield grid the Jolt collider uses. Enemies are kinematic and +// steered in XZ only — without this they kept their spawn height and +// walked straight INTO hills (attacking the player "from inside the +// ground"). Clamps to the grid edge outside the covered area. +function terrainHeightAt(x: number, z: number): number { + const n = T.TERRAIN_SAMPLE_COUNT; + const fx = (x - T.TERRAIN_ORIGIN_X) / T.TERRAIN_CELL_SIZE; + const fz = (z - T.TERRAIN_ORIGIN_Z) / T.TERRAIN_CELL_SIZE; + const cx = fx < 0 ? 0 : (fx > n - 1.001 ? n - 1.001 : fx); + const cz = fz < 0 ? 0 : (fz > n - 1.001 ? n - 1.001 : fz); + const x0 = Math.floor(cx); + const z0 = Math.floor(cz); + const tx = cx - x0; + const tz = cz - z0; + const h00 = T.TERRAIN_HEIGHTS[z0 * n + x0]; + const h10 = T.TERRAIN_HEIGHTS[z0 * n + x0 + 1]; + const h01 = T.TERRAIN_HEIGHTS[(z0 + 1) * n + x0]; + const h11 = T.TERRAIN_HEIGHTS[(z0 + 1) * n + x0 + 1]; + const h0 = h00 + (h10 - h00) * tx; + const h1 = h01 + (h11 - h01) * tx; + return T.TERRAIN_ORIGIN_Y + h0 + (h1 - h0) * tz; +} + function countAlive(): number { let c = 0; for (let i = 0; i < MAX_ENEMIES; i++) if (enAlive[i] > 0) c = c + 1; @@ -1191,8 +1215,8 @@ function spawnEnemy(): void { if (slot < 0) return; // all bodies of this kind busy; retry next tick const sp = waveSpawned % 4; enX[slot] = spawnerX[sp]; - enY[slot] = 0; enZ[slot] = spawnerZ[sp]; + enY[slot] = terrainHeightAt(enX[slot], enZ[slot]); enHP[slot] = KIND_HP[kind]; enAlive[slot] = 1; enAttackCD[slot] = 0; @@ -1785,6 +1809,9 @@ while (!windowShouldClose()) { const move = step < dist ? step : dist; enX[i] = enX[i] + (dx / dist) * move; enZ[i] = enZ[i] + (dz / dist) * move; + // Follow the terrain surface — enemies are steered in XZ, so + // their Y must track the heightfield or they walk into hills. + enY[i] = terrainHeightAt(enX[i], enZ[i]); setBodyPosition(enBody[i], vec3(enX[i], enY[i] + KIND_Y_OFF[k], enZ[i]), true); } else if (enAttackCD[i] <= 0) { @@ -2163,14 +2190,15 @@ while (!windowShouldClose()) { const pp = playerPosition(); const moving = input.moveX !== 0 || input.moveZ !== 0; const camYaw = CAM[0]; - // Player_bsuit's rest pose faces +X in model space (Unvanquished - // convention, preserved through our X90 Z-up→Y-up root fix). - // Camera-yaw forward at yaw=0 is -Z, so rotate the model by - // -π/2 about Y to line the character's front with the camera - // direction. The bsuit's only "attack" animation is a melee - // swing — a ranged shooter shouldn't use it; keep the walk/idle - // pose and fake recoil + muzzle flash on the weapon itself. - const modelYaw = camYaw - Math.PI / 2; + // Yaw offset verified EMPIRICALLY (windowed screenshot at camYaw=0): + // with -π/2 the character faced +Z — straight into the camera — so + // it read as 180° off from the aim direction. +π/2 lines the + // bsuit's front up with camera forward. (The old comment reasoned + // from a rest pose "facing +X"; the converter's X90 root fix lands + // it facing -X instead.) The bsuit's only "attack" animation is a + // melee swing — a ranged shooter shouldn't use it; keep the + // walk/idle pose and fake recoil + muzzle flash on the weapon. + const modelYaw = camYaw + Math.PI / 2; const panim = moving ? PLAYER_ANIM_WALK : PLAYER_ANIM_IDLE; updateModelAnimation(animPlayer, panim, playerAnimT, PLAYER_SCALE, pp.x, pp.y + PLAYER_MODEL_Y_OFFSET, pp.z, modelYaw); From d9c3d54f573b2e1251e5975d77157cd236c2b7bb Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 6 Jul 2026 16:09:27 +0200 Subject: [PATCH 09/10] fix(render): building GLB no longer double-drawn under the material shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE building flicker (user-visible gray lines/areas): the category-1 skip in the static-mesh loop only covered MODEL_IS_BOX placeholders, so the real building_floor.glb fell through to drawModel — coplanar with the procedural matBuilding shell. The two surfaces z-fought and the GLB window-slat texture rows flickered through the plaster whenever TAA jitter flipped the per-pixel depth winner. Root-caused from native F12 screenshots (4x zoom showed the slat rows, not shadow acne or text). The skip now covers every branch of the loop. --- src/main.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main.ts b/src/main.ts index e098a32..2a8d2f9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2131,10 +2131,16 @@ while (!windowShouldClose()) { // tint (0 generic / 1 building / 2 terrain / 3 prop). for (let i = 0; i < W.MESH_COUNT; i++) { const mi = W.MESH_MODEL_IDX[i]; + // Buildings (category 1) are rendered through the baked matBuilding + // mesh below — skip them here to avoid a coplanar double-draw. This + // must cover BOTH the placeholder boxes AND real GLBs: the textured + // building_floor.glb used to slip through to the drawModel branch + // and z-fight with the material shell — its window-slat texture + // rows flickered through the plaster whenever the TAA jitter + // flipped the per-pixel depth winner (the long-hunted "gray lines" + // flicker on the building). + if (W.MESH_CATEGORY[i] === 1 && matBuilding > 0) continue; if (W.MODEL_IS_BOX[mi] === 1) { - // Buildings (category 1) are rendered through the baked - // matBuilding mesh below — skip here to avoid double-draw. - if (W.MESH_CATEGORY[i] === 1 && matBuilding > 0) continue; const c = W.MESH_CATEGORY[i]; const col = { r: MESH_TINT_R[c], g: MESH_TINT_G[c], b: MESH_TINT_B[c], a: 255 }; drawCube(vec3(W.MESH_X[i], W.MESH_Y[i], W.MESH_Z[i]), From cad3dc9d47f80daff9be280bc6b548b951c31941 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 6 Jul 2026 16:42:02 +0200 Subject: [PATCH 10/10] fix(materials): antialias building mortar bands (kills gray-line flicker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the reported building flicker, found via native F12 swapchain screenshots: the mortar bands were pow(max(cos(y*f),0), band.w*8) — a razor-sharp procedural pulse with zero filtering. Re-evaluated each frame at TAA-jittered positions on the half-res buffer, its hard edges produced an aliasing beat pattern (~11 crawling lines out of 2-3 real bands) that flickered across the whole face, worst up close. Replaced with a distance-field band antialiased over one pixel via fwidth, with a sub-pixel coverage fade. The face is now stable plaster with a single clean band; verified by consecutive-frame diff (face went from bright-lines-everywhere to near-zero). --- assets/materials/building.wgsl | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/assets/materials/building.wgsl b/assets/materials/building.wgsl index e50eb1d..4daf9dd 100644 --- a/assets/materials/building.wgsl +++ b/assets/materials/building.wgsl @@ -75,10 +75,25 @@ fn fs_main(in: VsOut) -> OpaqueOut { + value_noise(sample_uv * bp.knobs.x * 2.5) * 0.3; let speckled = mix(bp.base.rgb, bp.base.rgb * 1.18, nz); - // Horizontal band lines every `band_period` metres. Use a - // sharp cosine pulse — the higher band.w, the thinner the line. - let band_phase = cos(in.world_pos.y * 6.28318 / bp.knobs.y); - let band_t = pow(max(band_phase, 0.0), bp.band.w * 8.0); + // Horizontal mortar lines every `band_period` metres, analytically + // antialiased. The old form — pow(max(cos(y·f),0), band.w·8) — was a + // razor-sharp procedural pulse with no filtering. Re-evaluated each + // frame at TAA-jittered sample positions on the half-res buffer, its + // hard edges flipped pixels every frame: the reported gray-line + // flicker crawling across the wall (worst up close, where the bands + // are large). Here the line is a distance field antialiased over + // exactly one pixel via fwidth, and it fades toward the wall colour + // once a line packs below the pixel rate — so it stays rock-stable + // at every distance and grazing angle. + let band_coord = in.world_pos.y / bp.knobs.y; // 1 unit per period + let band_dist = abs(fract(band_coord + 0.5) - 0.5) * 2.0; // 0 at a line, 1 between + let band_fw = max(fwidth(band_coord), 1e-5); // pixel footprint (periods) + // Line half-width as a fraction of the period, matched to the old + // pow(band.w·8) thickness. + let band_hw = 0.5 / (bp.band.w * 8.0 + 1.0); + let band_edge = 1.0 - smoothstep(band_hw - band_fw, band_hw + band_fw, band_dist); + let band_cov = clamp(band_hw / band_fw, 0.0, 1.0); // fade when sub-pixel + let band_t = band_edge * band_cov; let albedo = mix(speckled, bp.band.rgb, band_t * 0.6); // Lambert vs the engine sun + ambient, with the same cloud-shadow