Distance fog: measure the height above the camera in view space - #1645
Conversation
The height integral read the fragment's altitude off `uModelMatrix * position`, which is not the world: `Container.draw` folds every ancestor into the VIEW matrix, so that position is the mesh's parent space. Under a scaled container the fog floor sat at the wrong altitude — measured 250 to 773 world units out, and no amount of tuning `fogHeight` fixed it consistently across differently-scaled groups. The height difference is now taken in view space instead: `dot(k * u, viewPos)`, where `u` is the world-up axis expressed in view space. That collapses to the Y components of the camera's own basis axes, so it needs no matrix inversion and holds under any ancestor transform — rotation and non-uniform scale included, neither of which the old form could express. Costs nothing: `cameraY` now only appears as `cameraY - fogHeight`, whose `exp(clamp(...))` is constant per frame, and the falloff folds into the axis. So the same vec4 carries `xyz = k * u` and `w` = the pre-baked altitude term, the shader loses a subtraction, and a per-vertex `exp` and `clamp` move to once per frame. The block could not have grown anyway — the WebGPU uniform block is exactly 256 bytes and the arena aligns every region to 256. Both ends of the integral are anchored at the world Y the view maps to its origin, which includes `offset` — otherwise `camera.shake()` walks them apart and modulates the whole scene's fog thickness for its duration. Also fixes a shipped defect this exposed. `useShader` re-issues the placement uniforms when the program changes under the batcher, but the material and eye caches were missing from that list. The omission hid behind the sentinels: an untouched GL uniform reads back as 0, and a cache holding a value the fresh program already defaults to agrees with it by accident. The new height neutral carries a 1, so it cannot hide that way. Consequences, all present since 20.0.0 — `uShininess` and `uSpecular` default to 0, which is exactly "no highlight", so an instanced set drawn behind a lit prop at the same shininess lost its specular outright; `uEyePosition` defaults to the origin, so the other tier shaded its highlight from world zero and it shifted as the camera moved; and `uHasAlphaMap` defaults to 0, so instanced foliage rendered as opaque rectangles. Verified by reading the uniforms back off both programs. Test coverage for paths that had none. The instanced tiers' fog could be deleted outright and the whole suite stayed green; so could the lit tier's half of this fix, the baked altitude term on WebGL, the axis z component on WebGPU, and a WGSL type mismatch in the string-built instanced module. Every one of those mutations now fails. Adds an end-to-end test through a real Camera3d — the camera's convention and the shader's were pinned independently and never against each other, so a sign flipped in both would have passed everything. `testTimeout` is raised alongside `hookTimeout`, which was set for the same contention one level up. Test bodies driving software GL are as CPU-bound as the hook that creates the context: with 15 of 16 cores busy, eight specs blow the default 15s, every one a `Test timed out` rather than an assertion, and every one passing with seconds to spare when idle. Closes #1641 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
There was a problem hiding this comment.
🔵 Needs a closer look
It changes cross-backend shader math and uniform packing in core rendering paths (with WGSL not compiled on a real device in CI), so a final human review is warranted despite the strong test additions.
Pull request overview
This PR fixes 3D distance fog height falloff so it measures altitude in view space (robust under ancestor transforms like scale/rotation), and also fixes a long-standing WebGL batcher bug where program switches could leave material/eye/fog uniforms stale on the second tier (instanced vs non-instanced) within the same frame.
Changes:
- Rework fog height falloff to use a baked
(heightAxis.xyz, heightBase.w)formulation and compute height viadot(heightAxis, viewPos)in shaders (WebGL + WebGPU). - Ensure WebGL mesh batcher invalidates all relevant uniform caches on program changes (material, eye position, and fog height block).
- Add/expand regression tests (including end-to-end via a real
Camera3d) and raise VitesttestTimeoutto reduce contention-related timeouts.
File summaries
| File | Description |
|---|---|
| packages/melonjs/vitest.config.ts | Raises per-test timeout to reduce false timeouts under CPU contention. |
| packages/melonjs/tests/webgpu_mesh_fog.spec.js | Updates WebGPU fog packing expectations; adds structural WGSL contract tests. |
| packages/melonjs/tests/webgl_mesh_fog.spec.js | Adds transformed-ancestor and end-to-end camera coverage; asserts view-space height semantics. |
| packages/melonjs/tests/eye_position.spec.js | Adds regression test ensuring lit uniforms/eye/material state are uploaded across program switches. |
| packages/melonjs/tests/camera3d_fog.spec.js | Adds focused tests for the new baked height integral operands and anchoring behavior. |
| packages/melonjs/src/video/webgpu/shaders/mesh.wgsl | Switches height factor to take viewPos and use dot(fogHeight.xyz, viewPos) + baked base. |
| packages/melonjs/src/video/webgpu/shaders/mesh-shadow-instanced.wgsl | Same view-space height factor update for shadow-instanced tier. |
| packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl | Same view-space height factor update for lit tier. |
| packages/melonjs/src/video/webgpu/shaders/mesh-instanced.js | Updates derived instanced WGSL snippets to pass view-space positions to the new signature. |
| packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js | Packs heightAxis/heightBase into the uniform block; keeps neutral base at 1 when fog absent. |
| packages/melonjs/src/video/webgl/shaders/mesh.vert | Updates fog height factor to take viewPos and use axis/base packing. |
| packages/melonjs/src/video/webgl/shaders/mesh-shadow-instanced.vert | Same view-space height factor update for shadow-instanced tier. |
| packages/melonjs/src/video/webgl/shaders/mesh-lit.vert | Same view-space height factor update for lit tier. |
| packages/melonjs/src/video/webgl/shaders/mesh-lit-instanced.vert | Same view-space height factor update for lit-instanced tier. |
| packages/melonjs/src/video/webgl/shaders/mesh-instanced.vert | Same view-space height factor update for instanced tier. |
| packages/melonjs/src/video/webgl/batchers/mesh_batcher.js | Updates fog uniform packing and fixes cache invalidation on program switches (material/eye/fog). |
| packages/melonjs/src/camera/fog.ts | Updates Fog3dState contract to heightAxis + heightBase. |
| packages/melonjs/src/camera/camera3d.ts | Bakes view-space height operands per frame (axis + base), clamps exponent input, and updates validation. |
| packages/melonjs/src/camera/camera2d.ts | Passes the view-origin Y (translateY) into _fog3dState so camera offset/container offset are correctly anchored. |
| packages/melonjs/CHANGELOG.md | Adds a changelog entry for the shipped uniform-cache invalidation defect. |
Review details
- Files reviewed: 20/20 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The fix list had grown to paragraph-length entries, against the project's own convention of `Subsystem: what changed` in one or two sentences. The longest was 1463 characters. Also drops the in-cycle bug narration from the feature entries — the transparency entry described the black-silhouette defect "until it was fixed", duplicating the `alpha = 0` entry in the fix list. Every remaining fix entry was checked against shipped source: each documents a defect present in 20.3.0 or earlier, so none describes a regression introduced and fixed inside this release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
There was a problem hiding this comment.
🟢 Approval recommended
The changes are internally consistent across Camera3d → renderer fog state → batchers → shaders, and the PR adds strong regression coverage (pixel + packing/contract) for the previously missed failure modes.
Review details
- Files reviewed: 20/20 changed files
- Comments generated: 0 new
- Review effort level: Lite
Closes #1641.
The bug
The height-falloff integral read the fragment's altitude off
uModelMatrix * position. That is not the world:Container.drawfolds every ancestor into the view matrix, so the position a vertex stage holds is the mesh's parent space. Under a scaled container the fog floor sat at the wrong altitude, and no amount of tuningfogHeightfixed it consistently across differently-scaled groups.Measured against the real
Matrix3dbefore writing any code:The fix
Take the height difference in view space:
dot(k·u, viewPos), whereuis the world-up axis expressed in view space. That collapses to the Y components of the camera's own basis axes, so it needs no matrix inversion and holds under any ancestor transform — including rotation and non-uniform scale, neither of which the old form could express at all.It costs nothing.
cameraYnow only ever appears ascameraY - fogHeight, whoseexp(clamp(…))is constant per frame, and the falloff folds into the axis. So the same vec4 carriesxyz = k·uandw= the pre-baked altitude term: the shader loses a subtraction, and a per-vertexexp+clampmoves to once per frame. The block could not have grown anyway — the WebGPU uniform block is exactly 256 bytes and the arena aligns every region to 256, so a 17th float would have cost 512 bytes per mesh draw.Both ends of the integral are anchored at the world Y the view maps to its origin,
offsetincluded. Otherwisecamera.shake()walks them apart and modulates the whole scene's fog thickness for the duration of the shake.A shipped defect this exposed
useShaderre-issues the placement uniforms when the program changes under the batcher, but the material and eye caches were missing from that list. The omission hid behind the sentinels: an untouched GL uniform reads back as0, and a cache holding a value the fresh program already defaults to agrees with it by accident. The new height neutral carries a1, so it cannot hide that way.Verified by reading the uniforms back off both programs — one
LitMeshBatcher, non-instanced then instanced, true eye at(0,0,400):uShininess/uSpeculardefault to 0, which is exactly "no highlight", so an instanced set drawn behind a lit prop at the same shininess lost its specular outright.uHasAlphaMapdefaults to 0, so instanced foliage rendered as opaque rectangles. Present since 20.0.0, so this half gets the changelog entry; the fog half never shipped and correctly gets none.Coverage
These all previously survived the entire suite. Every one now fails:
mesh-lit.vertreverted to the bugmesh-instanced.vert/mesh-lit-instanced.vertmesh-shadow-instanced.vertheightBasenever uploaded (WebGL)f32/vec3fmismatch in the string-built moduleAdds an end-to-end test through a real
Camera3d—setFog→camera.draw→ pixels. The camera's sign convention and the shader's were pinned independently and never against each other, so a flip in both would have passed everything. It now produces 7 failures.Three of my own tests were vacuous on the first pass and were rewritten; a fourth (a shadow-tier pixel test) could not be made to bite — the blob composites multiplicatively over the floor, so its own fog is not observable — and was deleted rather than shipped green. The shadow tier keeps source-contract coverage, which does catch both its mutations.
Not covered, and stated plainly: nothing compiles WGSL against a real device. The local harness has no
navigator.gpuheadless or headed, so the WGSL contract is pinned structurally.Unrelated rider
testTimeoutis raised alongside thehookTimeoutthat was already set for the same contention one level up. Test bodies driving software GL are as CPU-bound as the hook that creates the context: with 15 of 16 cores busy, eight specs blow the default 15 s — every one aTest timed out, never an assertion, and every one passing with seconds to spare when idle. A/B under identical load: 10 timeouts before, 0 after.Verification
274 files, 6692 tests, 0 failures — idle, three times back-to-back, and under full CPU saturation. Lint 0 errors, build clean.