Skip to content

Distance fog: measure the height above the camera in view space - #1645

Merged
obiot merged 2 commits into
masterfrom
fix/fog-height-view-space
Sep 5, 2026
Merged

obiot merged 2 commits into
masterfrom
fix/fog-height-view-space

Conversation

@obiot

@obiot obiot commented Sep 5, 2026

Copy link
Copy Markdown
Member

Closes #1641.

The bug

The height-falloff integral read the fragment's altitude off uModelMatrix * position. That is not the world: Container.draw folds 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 tuning fogHeight fixed it consistently across differently-scaled groups.

Measured against the real Matrix3d before writing any code:

ancestor error in the height difference
none 0 (exact)
uniform scale 0.5 250 world units
uniform scale 2.5 + translate 773
non-uniform scale 325

The fix

Take the height difference in view space: 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 — including rotation and non-uniform scale, neither of which the old form could express at all.

It costs nothing. cameraY now only ever 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 + clamp moves 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, offset included. Otherwise camera.shake() walks them apart and modulates the whole scene's fog thickness for the duration of the shake.

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.

Verified by reading the uniforms back off both programs — one LitMeshBatcher, non-instanced then instanced, true eye at (0,0,400):

before   instanced|0: uShininess=0  uSpecular=[0,0,0]  uEyePosition=[0,0,0]
         default:     uShininess=64 uSpecular=[1,1,1]  uEyePosition=[0,0,400]

after    both programs: uShininess=64 uSpecular=[1,1,1] uEyePosition=[0,0,400]

uShininess/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. uHasAlphaMap defaults 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:

mutation now
mesh-lit.vert reverted to the bug 2 failed
fog deleted from mesh-instanced.vert / mesh-lit-instanced.vert 2 failed each
fog deleted from mesh-shadow-instanced.vert 1 failed
heightBase never uploaded (WebGL) 2 failed
axis z dropped (WebGPU) 1 failed
WGSL f32/vec3f mismatch in the string-built module 1 failed
material-uniform invalidation dropped 1 failed
rotation composition order flipped 2 failed

Adds 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.gpu headless or headed, so the WGSL contract is pinned structurally.

Unrelated rider

testTimeout is raised alongside the hookTimeout that 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 a Test 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.

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
Copilot AI lite review requested due to automatic review settings September 5, 2026 01:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 via dot(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 Vitest testTimeout to 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
Copilot AI review requested due to automatic review settings September 5, 2026 01:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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

@obiot
obiot merged commit 23f524a into master Sep 5, 2026
7 checks passed
@obiot
obiot deleted the fix/fog-height-view-space branch September 5, 2026 02:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Distance fog: the height reference ignores ancestor scale

2 participants