From a717b86d9180c018b7df02289eb9dd9ade24e038 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 1 Sep 2026 09:22:56 +0800 Subject: [PATCH] Distance fog: height falloff, so mist pools in low ground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uniform fog gives one dial for two jobs. Tune it so a valley has atmosphere and the skyline washes out with it; tune it so the peaks stay crisp and the low ground has no air in it. The fog cannot tell which is high and which is low, because density is the same everywhere and only the distance through it varies. `heightFalloff` adds the second falloff. Density drops exponentially with altitude, so mist fills the hollows and thins over the ridges. It multiplies the DISTANCE rather than adding a third curve, so "linear" and "exp2" are untouched and both gain it for free. An exponential integrates analytically along a straight segment, so the whole ray costs one `exp` in the vertex stage — no marching, no volume texture. `heightFalloff` defaults to 0, and that is not a special case: `kdy` is zero, the series limit is taken, `exp(0)` is one, and the factor is exactly 1. A scene that omits it renders identically, which the first test asserts byte for byte. Three things the issue predicted would bite, all handled and two of them mutation-checked: - Y-DOWN. Every published form assumes Y-up, so density rises as `y` INCREASES here. Flipping it puts the mist on the peaks instead of in the valley, and the test catches it. - The horizontal ray. `(exp(x) - 1) / x` is 0/0 when the view ray is level, which is the common case looking across a valley. The series limit is taken rather than a guard, or the fog would step as the ray flattened; removing it fails seven tests. - Overflow with the camera far below the reference height. Clamped. Noted honestly in the test: this one is NOT observable through the fog curve, which clamps an infinite distance anyway. It is insurance against a driver carrying `Inf` into an interpolated varying. The WebGPU uniform block grows 240 -> 256; the three specs that pin its size are re-pinned deliberately. Verified on screen on both backends: mist filling the run while the wooded walls stay crisp, and the unfogged examples unchanged. Closes #1633 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/skills/melonjs-3d/SKILL.md | 18 +++- packages/melonjs/src/camera/camera3d.ts | 46 +++++++++ packages/melonjs/src/camera/fog.ts | 26 +++++ .../src/video/webgl/batchers/mesh_batcher.js | 23 +++++ .../video/webgl/shaders/mesh-instanced.vert | 34 ++++++- .../webgl/shaders/mesh-lit-instanced.vert | 35 ++++++- .../src/video/webgl/shaders/mesh-lit.vert | 35 ++++++- .../webgl/shaders/mesh-shadow-instanced.vert | 34 ++++++- .../melonjs/src/video/webgl/shaders/mesh.vert | 34 ++++++- .../src/video/webgpu/batchers/mesh_batcher.js | 15 ++- .../video/webgpu/shaders/mesh-instanced.js | 6 +- .../src/video/webgpu/shaders/mesh-lit.wgsl | 34 ++++++- .../webgpu/shaders/mesh-shadow-instanced.wgsl | 34 ++++++- .../src/video/webgpu/shaders/mesh.wgsl | 34 ++++++- packages/melonjs/tests/webgl_mesh_fog.spec.js | 97 +++++++++++++++++++ .../melonjs/tests/webgpu_mesh_batcher.spec.js | 6 +- .../melonjs/tests/webgpu_mesh_fog.spec.js | 12 ++- .../melonjs/tests/webgpu_mtl_material.spec.js | 4 +- 19 files changed, 506 insertions(+), 22 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index b0a5f6171..c92ca82ee 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,6 +3,7 @@ ## [20.4.0] (melonJS 2) - _unreleased_ ### Added +- **Height falloff for distance fog** ([#1633](https://github.com/melonjs/melonJS/issues/1633)): `camera.setFog({ ..., fogHeight, heightFalloff })` makes fog density drop with altitude, so mist pools in low ground instead of hanging as thickly over a ridge as over the valley floor. Uniform fog gives you one dial for two jobs — tune it so a valley has atmosphere and the skyline washes out with it, tune it so the peaks stay crisp and the low ground has no air in it. This separates them. `heightFalloff` defaults to `0`, which is not a special case but the same integral with the dial at zero, so a scene that omits it renders exactly as before. Costs one `exp` per vertex: the density falls off exponentially with height, and an exponential integrates analytically along a straight segment, so there is no ray marching and no volume texture. Render space is **Y-down**, so `fogHeight` is the floor and density rises below it — the opposite sign to the usual published form - **Distance fog for the 3D tier** ([#1622](https://github.com/melonjs/melonJS/issues/1622)): `camera.setFog({ mode, near, far, density, color })` fades mesh geometry toward a colour with distance — `"linear"` between two distances, or `"exp2"` from a single density, the two parameterisations inherited from fixed-function graphics pipelines. It is the cheapest thing that stops a 3D scene reading as flat cut-outs, and it hides the far plane so props can appear without a visible edge. Every parameter is optional and the omitted ones resolve **live**: the distances track the camera's own clip planes, so fog cannot silently disagree with them after a later `setClipPlanes`, and the colour tracks `renderer.backgroundColor`, so geometry dissolves into the sky you already set — including through a day/night fade. Pass `color` only when the fog should differ from the backdrop. Measured radially and applied per fragment, so it neither slides as the camera turns nor bands across large triangles. Fog belongs to the camera, so split-screen and minimap views fog independently and a `Camera2d` never fogs; a mesh opts out with `fog: false`, for a marker that must stay readable at any distance. **Off by default**, and not merely skipped at runtime: fog is compiled out on both backends — `#define FOG` on WebGL, an `enable_fog` pipeline-overridable constant on WebGPU — so a scene that never calls `setFog` runs the shader it ran before fog existed - Mesh: `settings.vertexColors` and `setVertexColor(index, color)` give procedural geometry a per-vertex colour, multiplied into `tint`. Both batchers already wrote a per-vertex `aColor` on WebGL and WebGPU, but the array could only ever be built internally from a multi-material OBJ — so a mesh you built yourself had no way to reach it. `tint` is per *object*, so a terrain built as one mesh could only be tinted whole; this is what lets it fade toward the sky with distance, or darken in a crease, without splitting the mesh or writing a shader. Takes packed RGBA8 (`Uint32Array`, the form the batchers read) or one `Color` per vertex; a length that does not match the vertex count throws rather than mis-colouring the tail ([#1624](https://github.com/melonjs/melonJS/issues/1624)) - Mesh: normals are generated from the geometry when a `lit` mesh is built without them. A lit mesh with no normals had nothing for the shader to light with and rendered **fullbright** — asking for lighting and silently getting flat colour — and every hand-built mesh had to write the same accumulate-and-normalize loop first. Flat versus smooth is decided by the geometry rather than a flag: face normals accumulate into their vertices weighted by area, so shared vertices average into smooth shading while a triangle soup (each face owning its three vertices) resolves to the face normal and shades flat. An explicit `settings.normals` still wins, and an unlit mesh gets none diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index 93afd83d4..04e864ee0 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -1,6 +1,6 @@ --- name: melonjs-3d -description: "Use this skill for anything 3D or 2.5D in melonJS — Camera3d, Mesh, InstancedMesh, Sprite3d billboards, Light3d, ground shadows, glTF/GLB scenes, and depth sorting. Covers the Y-down/+Z-forward convention that is the inverse of OpenGL, the cameraClass opt-in, clip planes, and what does not work on the Canvas fallback. Triggers on: Camera3d, Mesh, InstancedMesh, Sprite3d, Light3d, billboard, glTF, glb, 3D, 2.5D, depth, cameraClass, fov, setClipPlanes, setFog, fog, distance fog, castGroundShadow, lit." +description: "Use this skill for anything 3D or 2.5D in melonJS — Camera3d, Mesh, InstancedMesh, Sprite3d billboards, Light3d, ground shadows, glTF/GLB scenes, and depth sorting. Covers the Y-down/+Z-forward convention that is the inverse of OpenGL, the cameraClass opt-in, clip planes, and what does not work on the Canvas fallback. Triggers on: Camera3d, Mesh, InstancedMesh, Sprite3d, Light3d, billboard, glTF, glb, 3D, 2.5D, depth, cameraClass, fov, setClipPlanes, setFog, fog, distance fog, height fog, heightFalloff, castGroundShadow, lit." license: MIT --- @@ -138,6 +138,20 @@ camera.setFog({ far: 5000, color: "#8899aa" }); A `Color` is held by reference, so mutating it animates the fog. +**Height falloff** makes mist pool in low ground instead of hanging at every +altitude equally — the difference between fog reading as weather and as a global +desaturation: + +```js +camera.setFog({ near: 1200, far: 7000, fogHeight: 0, heightFalloff: 0.0015 }); +``` + +`heightFalloff` defaults to **0**, which is uniform fog — not a special case, +the same integral with the dial at zero, so leaving it out changes nothing. +Render space is **Y-down**, so `fogHeight` is the floor and density rises +*below* it; every published form of this formula assumes Y-up and has the +opposite sign. + Fog is measured **radially** from the camera and applied **per fragment**, so it does not slide as the camera turns and does not band across large triangles. It lives on the camera, so a split-screen or minimap view fogs independently — @@ -435,6 +449,8 @@ To branch rather than fail, read `app.renderer.supportsDepthBuffer` after | black canvas under `Camera3d` | Canvas renderer (no depth buffer) — check the `console.warn` | | everything flat and unlit | `lit: true` with no `Light3d` in the world (falls back to fullbright), or a mesh under a 2D camera | | a `floating` HUD draws behind the scenery | a large \|z\| is *far* under `Camera3d` — use a small depth | +| fog hangs in the sky as thickly as in the valley | uniform fog — add `heightFalloff` so it pools low | +| mist sits on the ridges instead of the valley floor | the `fogHeight` sign — Y is DOWN here, density rises below it | | distant geometry pops in against the sky | no fog — `camera.setFog({})` picks up the clip planes and background colour | | fog does not match the sky after a background fade | an explicit `color` was passed; omit it to track `renderer.backgroundColor` | | geometry clips before it has finished fading | fog `far` beyond the clip far — omit the distances and they default to the clip planes | diff --git a/packages/melonjs/src/camera/camera3d.ts b/packages/melonjs/src/camera/camera3d.ts index a2bfb8ad0..fae6f50a6 100644 --- a/packages/melonjs/src/camera/camera3d.ts +++ b/packages/melonjs/src/camera/camera3d.ts @@ -129,6 +129,8 @@ export default class Camera3d extends Camera2d { /** @ignore */ private _fogNear: number | undefined = undefined; /** @ignore */ private _fogFar: number | undefined = undefined; /** @ignore */ private _fogDensity: number | undefined = undefined; + /** @ignore */ private _fogHeight = 0; + /** @ignore */ private _fogHeightFalloff = 0; /** * Owned colour, used only when the caller passed a CSS string or an array. @@ -150,6 +152,9 @@ export default class Camera3d extends Camera2d { invRange: 0, density: 0, color: new Float32Array(3), + heightFalloff: 0, + fogHeight: 0, + cameraY: 0, }; /** @@ -319,6 +324,11 @@ export default class Camera3d extends Camera2d { * * Fog is per camera, so a split-screen or minimap view fogs independently * — and a `Camera2d` never fogs at all. + * + * `heightFalloff` adds a second falloff with altitude, so mist pools in low + * ground instead of hanging at every height equally. It defaults to 0, + * which is uniform fog — not a special case, the same integral with the + * dial at zero. * @param options - fog settings, or `null` to switch fog off * @returns this camera (chainable) * @throws {Error} on an unknown `mode`, a non-finite or negative distance, @@ -349,6 +359,16 @@ export default class Camera3d extends Camera2d { * camera.setFog({ far: 5000, color: murk }); * murk.setColor(60, 90, 55); // thickens over the next frame * @example + * // Mist pooling in a valley: dense along the floor, thinning up the + * // walls so the tree line stays crisp. Render space is Y-down, so + * // `fogHeight` is the floor and density rises BELOW it. + * camera.setFog({ + * near: 1200, + * far: 7000, + * fogHeight: 0, + * heightFalloff: 0.0015, + * }); + * @example * // Everything is optional: with nothing at all, fog spans the camera's * // own clip planes in the backdrop's colour. * camera.setFog({}); @@ -395,12 +415,27 @@ export default class Camera3d extends Camera2d { if (options.density !== undefined && options.density <= 0) { throw new Error("Camera3d.setFog: density must be greater than zero"); } + for (const [name, value] of [ + ["fogHeight", options.fogHeight], + ["heightFalloff", options.heightFalloff], + ] as const) { + if (value !== undefined && !Number.isFinite(value)) { + throw new Error(`Camera3d.setFog: ${name} must be a finite number`); + } + } + if (options.heightFalloff !== undefined && options.heightFalloff < 0) { + throw new Error("Camera3d.setFog: heightFalloff must not be negative"); + } this._fogOptions = options; this._fogMode = mode; this._fogNear = options.near; this._fogFar = options.far; this._fogDensity = options.density; + this._fogHeight = options.fogHeight ?? 0; + // zero is uniform fog — the maths below collapses to the distance-only + // form exactly, so the default changes nothing + this._fogHeightFalloff = options.heightFalloff ?? 0; // A `Color` is referenced so mutating it animates the fog; anything // else is parsed once into a colour this camera owns. if (options.color === undefined || options.color instanceof Color) { @@ -441,6 +476,12 @@ export default class Camera3d extends Camera2d { if (this._fogDensity !== undefined) { out.density = this._fogDensity; } + if (this._fogHeight !== 0) { + out.fogHeight = this._fogHeight; + } + if (this._fogHeightFalloff !== 0) { + out.heightFalloff = this._fogHeightFalloff; + } const colour = this._fogOwnColor ?? this._fogOptions.color; if (colour !== undefined) { out.color = colour; @@ -494,6 +535,11 @@ export default class Camera3d extends Camera2d { (options.color instanceof Color ? options.color : renderer.backgroundColor); + state.heightFalloff = this._fogHeightFalloff; + state.fogHeight = this._fogHeight; + // the height integral runs from the camera to the fragment, so the + // shaders need where the camera is on that axis + state.cameraY = this.pos.y; state.color[0] = color.r / 255; state.color[1] = color.g / 255; state.color[2] = color.b / 255; diff --git a/packages/melonjs/src/camera/fog.ts b/packages/melonjs/src/camera/fog.ts index ccf3ac327..4d7c9bba2 100644 --- a/packages/melonjs/src/camera/fog.ts +++ b/packages/melonjs/src/camera/fog.ts @@ -55,6 +55,26 @@ export interface FogOptions { * parsed into a colour this camera owns. */ color?: Color | string | [number, number, number]; + /** + * World Y at which the fog is at its reference density. Only meaningful + * alongside a non-zero {@link FogOptions.heightFalloff}. + * @default 0 + */ + fogHeight?: number; + /** + * How fast fog density drops with altitude, so mist pools in low ground + * instead of filling the sky as readily as the valley floor. + * + * **Zero — the default — is uniform fog**, which is the fog that shipped + * without this: `exp(0)` is 1, density is the same at every altitude, and + * the maths collapses exactly. Raise it and density falls off above + * `fogHeight`, leaving ridges and sky clear while the hollows stay thick. + * + * Render space is **Y-down**, so density rises as `y` INCREASES — the + * opposite sign to every published height-fog formula, which assume Y-up. + * @default 0 + */ + heightFalloff?: number; } /** @@ -73,4 +93,10 @@ export interface Fog3dState { density: number; /** straight (unpremultiplied) fog colour, 3 components in 0..1 */ color: Float32Array; + /** height falloff; 0 is uniform fog */ + heightFalloff: number; + /** world Y the falloff is measured from */ + fogHeight: number; + /** the camera's own world Y, which the height integral starts from */ + cameraY: number; } diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js index 501aa1c8b..be9beac4f 100644 --- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js @@ -54,6 +54,7 @@ const _EYE_POSITION = new Float32Array(3); // scratches for the per-camera distance fog, unpacked per draw that needs it const _FOG_COLOR = new Float32Array(3); const _FOG_PARAMS = new Float32Array(4); +const _FOG_HEIGHT = new Float32Array(4); /** * A WebGL Batcher for rendering textured triangle meshes. @@ -135,6 +136,9 @@ export default class MeshBatcher extends MaterialBatcher { this.currentFogR = Number.NaN; this.currentFogG = Number.NaN; this.currentFogB = Number.NaN; + this.currentFogFalloff = Number.NaN; + this.currentFogHeight = Number.NaN; + this.currentFogCamY = Number.NaN; // Retained geometry per mesh (model-space buffers uploaded once). A // re-init means a new GL context or a fresh batcher life, so anything @@ -1288,6 +1292,25 @@ export default class MeshBatcher extends MaterialBatcher { this.currentFogInvRange = invRange; this.currentFogDensity = density; } + if (uniforms.uFogHeight != null) { + const falloff = mode !== 0 ? fog.heightFalloff : 0; + const height = mode !== 0 ? fog.fogHeight : 0; + const camY = mode !== 0 ? fog.cameraY : 0; + if ( + falloff !== this.currentFogFalloff || + height !== this.currentFogHeight || + camY !== this.currentFogCamY + ) { + _FOG_HEIGHT[0] = falloff; + _FOG_HEIGHT[1] = height; + _FOG_HEIGHT[2] = camY; + _FOG_HEIGHT[3] = 0; + shader.setUniform("uFogHeight", _FOG_HEIGHT); + this.currentFogFalloff = falloff; + this.currentFogHeight = height; + this.currentFogCamY = camY; + } + } if ( uniforms.uFogColor != null && (r !== this.currentFogR || diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-instanced.vert b/packages/melonjs/src/video/webgl/shaders/mesh-instanced.vert index b80ad6c40..5dd672c37 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-instanced.vert +++ b/packages/melonjs/src/video/webgl/shaders/mesh-instanced.vert @@ -52,6 +52,38 @@ mat4 instanceMatrix() { vec4(aInstanceRow0.w, aInstanceRow1.w, aInstanceRow2.w, 1.0)); } +#ifdef FOG +uniform vec4 uFogHeight; // x = falloff (0 = uniform), y = reference world Y, + // z = the camera's world Y, w unused + +// How much the height falloff scales the fog along this view ray. +// +// Density falls off exponentially with altitude, and an exponential integrates +// analytically along a straight segment, so the whole ray costs one `exp` and +// no marching. The result multiplies the distance, which leaves both fog +// curves exactly as they are. +// +// Render space is Y-DOWN: density rises as `y` INCREASES, the opposite sign to +// every published form of this. The `- uFogHeight.y` below is measured that +// way round, and there is a test that catches it being flipped. +// +// A falloff of 0 gives exactly 1: `kdy` is 0, the series limit is taken, and +// `exp(0)` is 1 — so uniform fog is not a special case, it is this with the +// dial at zero. +float fogHeightFactor(float worldY) { + float k = uFogHeight.x; + float dy = worldY - uFogHeight.z; + float kdy = k * dy; + // (exp(x) - 1) / x is 0/0 at x = 0, and a horizontal view ray — looking + // straight across a valley — is exactly that case. Take the limit rather + // than guarding, or the fog steps as the ray approaches horizontal. + float t = abs(kdy) < 1e-4 ? 1.0 : (exp(kdy) - 1.0) / kdy; + // clamped: a camera far below the reference height would otherwise + // overflow the exponential and whiten the frame + return exp(clamp(k * (uFogHeight.z - uFogHeight.y), -30.0, 30.0)) * t; +} +#endif + void main(void) { mat4 instance = instanceMatrix(); gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * instance @@ -62,7 +94,7 @@ void main(void) { // scene. The clip position above keeps its own product: re-associating it // could shift vertices by an ulp, and a scene without fog must be unchanged. vec4 viewPos = uViewMatrix * uModelMatrix * instance * vec4(aVertex, 1.0); - vFogDepth = length(viewPos.xyz); + vFogDepth = length(viewPos.xyz) * fogHeightFactor((uModelMatrix * instance * vec4(aVertex, 1.0)).y); #endif vec4 tinted = aColor * uTint; diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-lit-instanced.vert b/packages/melonjs/src/video/webgl/shaders/mesh-lit-instanced.vert index b834125c5..2a2f6f702 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-lit-instanced.vert +++ b/packages/melonjs/src/video/webgl/shaders/mesh-lit-instanced.vert @@ -55,6 +55,38 @@ mat4 instanceMatrix() { vec4(aInstanceRow0.w, aInstanceRow1.w, aInstanceRow2.w, 1.0)); } +#ifdef FOG +uniform vec4 uFogHeight; // x = falloff (0 = uniform), y = reference world Y, + // z = the camera's world Y, w unused + +// How much the height falloff scales the fog along this view ray. +// +// Density falls off exponentially with altitude, and an exponential integrates +// analytically along a straight segment, so the whole ray costs one `exp` and +// no marching. The result multiplies the distance, which leaves both fog +// curves exactly as they are. +// +// Render space is Y-DOWN: density rises as `y` INCREASES, the opposite sign to +// every published form of this. The `- uFogHeight.y` below is measured that +// way round, and there is a test that catches it being flipped. +// +// A falloff of 0 gives exactly 1: `kdy` is 0, the series limit is taken, and +// `exp(0)` is 1 — so uniform fog is not a special case, it is this with the +// dial at zero. +float fogHeightFactor(float worldY) { + float k = uFogHeight.x; + float dy = worldY - uFogHeight.z; + float kdy = k * dy; + // (exp(x) - 1) / x is 0/0 at x = 0, and a horizontal view ray — looking + // straight across a valley — is exactly that case. Take the limit rather + // than guarding, or the fog steps as the ray approaches horizontal. + float t = abs(kdy) < 1e-4 ? 1.0 : (exp(kdy) - 1.0) / kdy; + // clamped: a camera far below the reference height would otherwise + // overflow the exponential and whiten the frame + return exp(clamp(k * (uFogHeight.z - uFogHeight.y), -30.0, 30.0)) * t; +} +#endif + void main(void) { mat4 instance = instanceMatrix(); vec4 worldPos = uModelMatrix * instance * vec4(aVertex, 1.0); @@ -65,7 +97,8 @@ void main(void) { // z, so fog holds steady as the camera turns instead of sliding across the // scene. The clip position above keeps its own product: re-associating it // could shift vertices by an ulp, and a scene without fog must be unchanged. - vFogDepth = length((uViewMatrix * worldPos).xyz); + vFogDepth = length((uViewMatrix * worldPos).xyz) + * fogHeightFactor(worldPos.y); #endif vec4 tinted = aColor * uTint; diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert b/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert index ccca33a0d..cdfb982a6 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert +++ b/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert @@ -29,6 +29,38 @@ out vec3 vWorldPos; out float vFogDepth; #endif +#ifdef FOG +uniform vec4 uFogHeight; // x = falloff (0 = uniform), y = reference world Y, + // z = the camera's world Y, w unused + +// How much the height falloff scales the fog along this view ray. +// +// Density falls off exponentially with altitude, and an exponential integrates +// analytically along a straight segment, so the whole ray costs one `exp` and +// no marching. The result multiplies the distance, which leaves both fog +// curves exactly as they are. +// +// Render space is Y-DOWN: density rises as `y` INCREASES, the opposite sign to +// every published form of this. The `- uFogHeight.y` below is measured that +// way round, and there is a test that catches it being flipped. +// +// A falloff of 0 gives exactly 1: `kdy` is 0, the series limit is taken, and +// `exp(0)` is 1 — so uniform fog is not a special case, it is this with the +// dial at zero. +float fogHeightFactor(float worldY) { + float k = uFogHeight.x; + float dy = worldY - uFogHeight.z; + float kdy = k * dy; + // (exp(x) - 1) / x is 0/0 at x = 0, and a horizontal view ray — looking + // straight across a valley — is exactly that case. Take the limit rather + // than guarding, or the fog steps as the ray approaches horizontal. + float t = abs(kdy) < 1e-4 ? 1.0 : (exp(kdy) - 1.0) / kdy; + // clamped: a camera far below the reference height would otherwise + // overflow the exponential and whiten the frame + return exp(clamp(k * (uFogHeight.z - uFogHeight.y), -30.0, 30.0)) * t; +} +#endif + void main(void) { vec4 worldPos = uModelMatrix * vec4(aVertex, 1.0); gl_Position = uProjectionMatrix * uViewMatrix * worldPos; @@ -38,7 +70,8 @@ void main(void) { // z, so fog holds steady as the camera turns instead of sliding across the // scene. The clip position above keeps its own product: re-associating it // could shift vertices by an ulp, and a scene without fog must be unchanged. - vFogDepth = length((uViewMatrix * worldPos).xyz); + vFogDepth = length((uViewMatrix * worldPos).xyz) + * fogHeightFactor(worldPos.y); #endif vec4 tinted = aColor * uTint; vColor = vec4(tinted.rgb * tinted.a, tinted.a); diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-shadow-instanced.vert b/packages/melonjs/src/video/webgl/shaders/mesh-shadow-instanced.vert index 3d400c072..1c1ae459d 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-shadow-instanced.vert +++ b/packages/melonjs/src/video/webgl/shaders/mesh-shadow-instanced.vert @@ -45,6 +45,38 @@ varying vec4 vColor; varying float vFogDepth; #endif +#ifdef FOG +uniform vec4 uFogHeight; // x = falloff (0 = uniform), y = reference world Y, + // z = the camera's world Y, w unused + +// How much the height falloff scales the fog along this view ray. +// +// Density falls off exponentially with altitude, and an exponential integrates +// analytically along a straight segment, so the whole ray costs one `exp` and +// no marching. The result multiplies the distance, which leaves both fog +// curves exactly as they are. +// +// Render space is Y-DOWN: density rises as `y` INCREASES, the opposite sign to +// every published form of this. The `- uFogHeight.y` below is measured that +// way round, and there is a test that catches it being flipped. +// +// A falloff of 0 gives exactly 1: `kdy` is 0, the series limit is taken, and +// `exp(0)` is 1 — so uniform fog is not a special case, it is this with the +// dial at zero. +float fogHeightFactor(float worldY) { + float k = uFogHeight.x; + float dy = worldY - uFogHeight.z; + float kdy = k * dy; + // (exp(x) - 1) / x is 0/0 at x = 0, and a horizontal view ray — looking + // straight across a valley — is exactly that case. Take the limit rather + // than guarding, or the fog steps as the ray approaches horizontal. + float t = abs(kdy) < 1e-4 ? 1.0 : (exp(kdy) - 1.0) / kdy; + // clamped: a camera far below the reference height would otherwise + // overflow the exponential and whiten the frame + return exp(clamp(k * (uFogHeight.z - uFogHeight.y), -30.0, 30.0)) * t; +} +#endif + void main(void) { vec3 instancePos = vec3(aInstanceRow0.w, aInstanceRow1.w, aInstanceRow2.w); // column 0 of the instance basis carries the X axis times its scale; a @@ -65,7 +97,7 @@ void main(void) { // scene. The clip position above keeps its own product: re-associating it // could shift vertices by an ulp, and a scene without fog must be unchanged. vec4 viewPos = uViewMatrix * uModelMatrix * vec4(local, 1.0); - vFogDepth = length(viewPos.xyz); + vFogDepth = length(viewPos.xyz) * fogHeightFactor((uModelMatrix * vec4(local, 1.0)).y); #endif // tint first, then premultiply — matches the fragment shader's expectation diff --git a/packages/melonjs/src/video/webgl/shaders/mesh.vert b/packages/melonjs/src/video/webgl/shaders/mesh.vert index 128797d5e..ba0b90aae 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh.vert +++ b/packages/melonjs/src/video/webgl/shaders/mesh.vert @@ -30,6 +30,38 @@ varying vec4 vColor; varying float vFogDepth; #endif +#ifdef FOG +uniform vec4 uFogHeight; // x = falloff (0 = uniform), y = reference world Y, + // z = the camera's world Y, w unused + +// How much the height falloff scales the fog along this view ray. +// +// Density falls off exponentially with altitude, and an exponential integrates +// analytically along a straight segment, so the whole ray costs one `exp` and +// no marching. The result multiplies the distance, which leaves both fog +// curves exactly as they are. +// +// Render space is Y-DOWN: density rises as `y` INCREASES, the opposite sign to +// every published form of this. The `- uFogHeight.y` below is measured that +// way round, and there is a test that catches it being flipped. +// +// A falloff of 0 gives exactly 1: `kdy` is 0, the series limit is taken, and +// `exp(0)` is 1 — so uniform fog is not a special case, it is this with the +// dial at zero. +float fogHeightFactor(float worldY) { + float k = uFogHeight.x; + float dy = worldY - uFogHeight.z; + float kdy = k * dy; + // (exp(x) - 1) / x is 0/0 at x = 0, and a horizontal view ray — looking + // straight across a valley — is exactly that case. Take the limit rather + // than guarding, or the fog steps as the ray approaches horizontal. + float t = abs(kdy) < 1e-4 ? 1.0 : (exp(kdy) - 1.0) / kdy; + // clamped: a camera far below the reference height would otherwise + // overflow the exponential and whiten the frame + return exp(clamp(k * (uFogHeight.z - uFogHeight.y), -30.0, 30.0)) * t; +} +#endif + void main(void) { gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aVertex, 1.0); @@ -39,7 +71,7 @@ void main(void) { // scene. The clip position above keeps its own product: re-associating it // could shift vertices by an ulp, and a scene without fog must be unchanged. vec4 viewPos = uViewMatrix * uModelMatrix * vec4(aVertex, 1.0); - vFogDepth = length(viewPos.xyz); + vFogDepth = length(viewPos.xyz) * fogHeightFactor((uModelMatrix * vec4(aVertex, 1.0)).y); #endif // tint first, then premultiply — matches the fragment shader's expectation vec4 tinted = aColor * uTint; diff --git a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js index 6a75caeac..93b06999d 100644 --- a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js @@ -22,10 +22,11 @@ import WebGPUBatcher from "./webgpu_batcher.js"; * mat4x4 model (64) + mat4x4 view (64) + vec4 tint (16) + vec4 params * (alphaCutoff, hasAlphaMap, reserved ×2) (16) + vec4 emissive (16) + * vec4 specular (rgb + shininess) (16) + vec4 eye (camera world position; - * w reserved) (16) + vec4 fogColor (16) + vec4 fogParams (16) → 240. + * w reserved) (16) + vec4 fogColor (16) + vec4 fogParams (16) + * + vec4 fogHeight (16) → 256. * @ignore */ -export const MESH_UNIFORM_SIZE = 240; +export const MESH_UNIFORM_SIZE = 256; // Shared identity model matrix for draws whose vertices are already placed // (the 2D-camera path pre-projects them on the CPU). Never mutated. @@ -660,6 +661,12 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { scratch[57] = fog.near; scratch[58] = fog.invRange; scratch[59] = fog.density; + // x = height falloff (0 = uniform), y = reference world Y, + // z = the camera's world Y + scratch[60] = fog.heightFalloff; + scratch[61] = fog.fogHeight; + scratch[62] = fog.cameraY; + scratch[63] = 0; } else { scratch[52] = 0; scratch[53] = 0; @@ -669,6 +676,10 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { scratch[57] = 0; scratch[58] = 0; scratch[59] = 0; + scratch[60] = 0; + scratch[61] = 0; + scratch[62] = 0; + scratch[63] = 0; } const region = renderer.effectUniformArena.alloc( diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh-instanced.js b/packages/melonjs/src/video/webgpu/shaders/mesh-instanced.js index 8b2d72686..5a95407b7 100644 --- a/packages/melonjs/src/video/webgpu/shaders/mesh-instanced.js +++ b/packages/melonjs/src/video/webgpu/shaders/mesh-instanced.js @@ -184,7 +184,8 @@ export const UNLIT_INSTANCED = { "\t// pipeline-overridable constant the base module declares", "\tif (enable_fog) {", "\t\tlet viewPos = uMesh.view * uMesh.model * instance * vec4f(aVertex, 1.0);", - "\t\tout.vFogDepth = length(viewPos.xyz);", + "\t\tout.vFogDepth = length(viewPos.xyz)", + "\t\t\t* fog_height_factor((uMesh.model * instance * vec4f(aVertex, 1.0)).y);", "\t} else {", "\t\tout.vFogDepth = 0.0;", "\t}", @@ -209,7 +210,8 @@ export const LIT_INSTANCED = { "\t// radial view-space distance for distance fog, behind the same", "\t// pipeline-overridable constant the base module declares", "\tif (enable_fog) {", - "\t\tout.vFogDepth = length((uMesh.view * worldPos).xyz);", + "\t\tout.vFogDepth = length((uMesh.view * worldPos).xyz)", + "\t\t\t* fog_height_factor(worldPos.y);", "\t} else {", "\t\tout.vFogDepth = 0.0;", "\t}", diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl b/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl index 3e3cff5f0..182d153be 100644 --- a/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl @@ -40,6 +40,9 @@ struct MeshUniforms { // x = mode (0 off / 1 linear / 2 exp2), y = near, z = 1/(far - near), // w = density fogParams : vec4f, + // x = height falloff (0 = uniform), y = reference world Y, + // z = the camera's world Y, w unused + fogHeight : vec4f, }; // One light, type inferred from sentinels (see std140.ts): @@ -94,6 +97,34 @@ struct VSOut { // enables fog pays for none of the work below. override enable_fog : bool = false; +// How much the height falloff scales the fog along this view ray. Twin of +// `fogHeightFactor` in the GLSL mesh shaders — keep them in step. +// +// Density falls off exponentially with altitude, and an exponential integrates +// analytically along a straight segment, so the ray costs one `exp` and no +// marching. The result multiplies the distance, leaving both curves untouched. +// +// Render space is Y-DOWN: density rises as `y` INCREASES, the opposite sign to +// every published form of this. +// +// A falloff of 0 gives exactly 1, so uniform fog is this with the dial at zero +// rather than a special case. +fn fog_height_factor(worldY : f32) -> f32 { + let k = uMesh.fogHeight.x; + let dy = worldY - uMesh.fogHeight.z; + let kdy = k * dy; + // (exp(x) - 1) / x is 0/0 at x = 0, which is a horizontal view ray — take + // the limit rather than guarding, or the fog steps as the ray flattens + var t = 1.0; + if (abs(kdy) >= 1e-4) { + t = (exp(kdy) - 1.0) / kdy; + } + // clamped: a camera far below the reference height would otherwise + // overflow the exponential and whiten the frame + return exp(clamp(k * (uMesh.fogHeight.z - uMesh.fogHeight.y), -30.0, 30.0)) * t; +} + + // Fold distance fog into a PREMULTIPLIED colour. Twin of `apply_fog` in // mesh.wgsl and `applyFog` in the GLSL mesh shaders — keep them in step. WGSL // has no preprocessor and the build loads shaders as raw text, so the block is @@ -138,7 +169,8 @@ fn vertex_main( out.vRegion = aRegion; out.vWorldPos = worldPos.xyz; if (enable_fog) { - out.vFogDepth = length((uMesh.view * worldPos).xyz); + out.vFogDepth = length((uMesh.view * worldPos).xyz) + * fog_height_factor(worldPos.y); } else { out.vFogDepth = 0.0; } diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh-shadow-instanced.wgsl b/packages/melonjs/src/video/webgpu/shaders/mesh-shadow-instanced.wgsl index 78b48d13b..60eef1fab 100644 --- a/packages/melonjs/src/video/webgpu/shaders/mesh-shadow-instanced.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/mesh-shadow-instanced.wgsl @@ -43,6 +43,9 @@ struct MeshUniforms { // x = mode (0 off / 1 linear / 2 exp2), y = near, z = 1/(far - near), // w = density fogParams : vec4f, + // x = height falloff (0 = uniform), y = reference world Y, + // z = the camera's world Y, w unused + fogHeight : vec4f, }; @group(0) @binding(0) var uFrame : FrameUniforms; @@ -60,6 +63,34 @@ struct MeshUniforms { // enables fog pays for none of the work below. override enable_fog : bool = false; +// How much the height falloff scales the fog along this view ray. Twin of +// `fogHeightFactor` in the GLSL mesh shaders — keep them in step. +// +// Density falls off exponentially with altitude, and an exponential integrates +// analytically along a straight segment, so the ray costs one `exp` and no +// marching. The result multiplies the distance, leaving both curves untouched. +// +// Render space is Y-DOWN: density rises as `y` INCREASES, the opposite sign to +// every published form of this. +// +// A falloff of 0 gives exactly 1, so uniform fog is this with the dial at zero +// rather than a special case. +fn fog_height_factor(worldY : f32) -> f32 { + let k = uMesh.fogHeight.x; + let dy = worldY - uMesh.fogHeight.z; + let kdy = k * dy; + // (exp(x) - 1) / x is 0/0 at x = 0, which is a horizontal view ray — take + // the limit rather than guarding, or the fog steps as the ray flattens + var t = 1.0; + if (abs(kdy) >= 1e-4) { + t = (exp(kdy) - 1.0) / kdy; + } + // clamped: a camera far below the reference height would otherwise + // overflow the exponential and whiten the frame + return exp(clamp(k * (uMesh.fogHeight.z - uMesh.fogHeight.y), -30.0, 30.0)) * t; +} + + struct VSOut { @builtin(position) position : vec4f, @location(0) vRegion : vec2f, @@ -99,7 +130,8 @@ fn vertex_main( // a blob fades with distance like the ground it lies on if (enable_fog) { let viewPos = uMesh.view * uMesh.model * vec4f(local, 1.0); - out.vFogDepth = length(viewPos.xyz); + out.vFogDepth = length(viewPos.xyz) + * fog_height_factor((uMesh.model * vec4f(local, 1.0)).y); } else { out.vFogDepth = 0.0; } diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl b/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl index e02ce84cc..685636a0c 100644 --- a/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl @@ -46,6 +46,9 @@ struct MeshUniforms { // x = mode (0 off / 1 linear / 2 exp2), y = near, z = 1/(far - near), // w = density fogParams : vec4f, + // x = height falloff (0 = uniform), y = reference world Y, + // z = the camera's world Y, w unused + fogHeight : vec4f, }; @group(0) @binding(0) var uFrame : FrameUniforms; @@ -73,6 +76,34 @@ struct VSOut { // enables fog pays for none of the work below. override enable_fog : bool = false; +// How much the height falloff scales the fog along this view ray. Twin of +// `fogHeightFactor` in the GLSL mesh shaders — keep them in step. +// +// Density falls off exponentially with altitude, and an exponential integrates +// analytically along a straight segment, so the ray costs one `exp` and no +// marching. The result multiplies the distance, leaving both curves untouched. +// +// Render space is Y-DOWN: density rises as `y` INCREASES, the opposite sign to +// every published form of this. +// +// A falloff of 0 gives exactly 1, so uniform fog is this with the dial at zero +// rather than a special case. +fn fog_height_factor(worldY : f32) -> f32 { + let k = uMesh.fogHeight.x; + let dy = worldY - uMesh.fogHeight.z; + let kdy = k * dy; + // (exp(x) - 1) / x is 0/0 at x = 0, which is a horizontal view ray — take + // the limit rather than guarding, or the fog steps as the ray flattens + var t = 1.0; + if (abs(kdy) >= 1e-4) { + t = (exp(kdy) - 1.0) / kdy; + } + // clamped: a camera far below the reference height would otherwise + // overflow the exponential and whiten the frame + return exp(clamp(k * (uMesh.fogHeight.z - uMesh.fogHeight.y), -30.0, 30.0)) * t; +} + + // Fold distance fog into a PREMULTIPLIED colour. Twin of `applyFog` in the // GLSL mesh shaders — keep the two in step. They are duplicated rather than // shared because WGSL has no preprocessor and the production build loads each @@ -127,7 +158,8 @@ fn vertex_main( // variable — but the work behind it folds away with the constant if (enable_fog) { let viewPos = uMesh.view * uMesh.model * vec4f(aVertex, 1.0); - out.vFogDepth = length(viewPos.xyz); + out.vFogDepth = length(viewPos.xyz) + * fog_height_factor((uMesh.model * vec4f(aVertex, 1.0)).y); } else { out.vFogDepth = 0.0; } diff --git a/packages/melonjs/tests/webgl_mesh_fog.spec.js b/packages/melonjs/tests/webgl_mesh_fog.spec.js index ffbf5ced1..3337fd0cf 100644 --- a/packages/melonjs/tests/webgl_mesh_fog.spec.js +++ b/packages/melonjs/tests/webgl_mesh_fog.spec.js @@ -136,6 +136,10 @@ describe("mesh distance fog (#1622)", () => { invRange: 1 / 1000, density: 0, color: new Float32Array([1, 1, 1]), + // uniform fog: a zero falloff collapses the height integral to 1 + heightFalloff: 0, + fogHeight: 0, + cameraY: 0, ...over, }; }; @@ -331,6 +335,99 @@ describe("mesh distance fog (#1622)", () => { }); }); + describe("height falloff", () => { + it("is uniform at falloff 0, matching the fog that shipped without it", (ctx) => { + requireWebGL(ctx); + // the whole design rests on this: zero is not a special case, it is + // the integral with the dial at zero, and it must be EXACT + renderer.setFog(fog()); + const plain = drawRed(quad(), 500); + renderer.setFog(fog({ heightFalloff: 0, fogHeight: 400, cameraY: 90 })); + const withZero = drawRed(quad(), 500); + renderer.setFog(null); + expect(Array.from(withZero)).toEqual(Array.from(plain)); + }); + + it("fogs low ground MORE than high ground — Y is down", (ctx) => { + requireWebGL(ctx); + // The sign trap. Render space is Y-down, so a GREATER y is LOWER in + // the world and must fog harder. Every published form of this + // formula assumes Y-up, and flipping it silently inverts the + // effect: mist would sit on the peaks instead of in the valley. + // + // Both quads stay inside the ortho and are read at their own screen + // positions — placing them far apart in Y would put them off-screen + // and read the cleared background instead, which looks like "no fog" + // and proves nothing. + const common = { + near: 0, + invRange: 1 / 1000, + heightFalloff: 0.02, + fogHeight: 0, + cameraY: 0, + }; + const sample = (worldY, glY) => { + setup(); + renderer.setFog(fog(common)); + const mesh = quad(); + mesh.pos.set(0, worldY, 0); + mesh.depth = 500; + mesh.preDraw(renderer); + mesh.draw(renderer); + mesh.postDraw(renderer); + const px = readPixel(SIZE / 2, glY); + renderer.setFog(null); + return px; + }; + // ortho maps world y=-64 to the top of the drawing buffer and + // y=+64 to the bottom; readPixels counts from the bottom + const low = sample(40, 24); // greater y = lower in the world + const high = sample(-40, 104); + + // both actually drew: a background read would be pure black + expect(low[0]).toBeGreaterThan(200); + expect(high[0]).toBeGreaterThan(200); + // more fog = more white mixed into the red + expect(low[1]).toBeGreaterThan(high[1] + 20); + }); + + it("survives a horizontal ray, where the integral is 0/0", (ctx) => { + requireWebGL(ctx); + // looking straight across a valley is exactly dy = 0; a guard that + // returned something else here would step visibly as the ray + // flattened out + renderer.setFog(fog({ heightFalloff: 0.004, fogHeight: 0, cameraY: 0 })); + const level = quad(); + level.pos.set(0, 0, 0); // same Y as the camera + const px = drawRed(level, 500); + renderer.setFog(null); + for (const channel of [0, 1, 2]) { + expect(Number.isFinite(px[channel])).toBe(true); + } + // and it is fogged, not blanked or left untouched + expect(px[1]).toBeGreaterThan(0); + expect(px[1]).toBeLessThan(255); + }); + + it("does not blow up with the camera far below the reference height", (ctx) => { + requireWebGL(ctx); + // The extreme end of the range must produce a usable frame rather + // than NaN. Note this does NOT prove the clamp inside + // `fogHeightFactor`: with or without it the result saturates to + // fully-fogged, because an infinite distance is clamped by the fog + // curve anyway. The clamp is insurance against a driver carrying + // `Inf` into an interpolated varying, which this cannot observe. + renderer.setFog( + fog({ heightFalloff: 0.05, fogHeight: -5000, cameraY: 20000 }), + ); + const px = drawRed(quad(), 500); + renderer.setFog(null); + for (const channel of [0, 1, 2]) { + expect(Number.isNaN(px[channel])).toBe(false); + } + }); + }); + describe("the distance is radial", () => { it("fogs an off-axis mesh more than one dead ahead at the same depth", (ctx) => { requireWebGL(ctx); diff --git a/packages/melonjs/tests/webgpu_mesh_batcher.spec.js b/packages/melonjs/tests/webgpu_mesh_batcher.spec.js index 96273c368..1d417378f 100644 --- a/packages/melonjs/tests/webgpu_mesh_batcher.spec.js +++ b/packages/melonjs/tests/webgpu_mesh_batcher.spec.js @@ -59,10 +59,10 @@ describe("WebGPUMeshBatcher (mock device)", () => { // dedup by module text: a second batcher re-registers the same key const again = new WebGPUMeshBatcher(renderer); expect(again.shaderKey).toBe(batcher.shaderKey); - expect(renderer.pipelineCache.effectLayouts.has("mesh:u240")).toBe(true); - // 176 before #1575, 208 before fog — grown by the specular vec4 and the + expect(renderer.pipelineCache.effectLayouts.has("mesh:u256")).toBe(true); + // 176 before #1575, 208 before fog, 240 before its height falloff — grown by the specular vec4 and the // eye position, then by the fog colour and fog params - expect(MESH_UNIFORM_SIZE).toBe(240); + expect(MESH_UNIFORM_SIZE).toBe(256); }); it("addMesh dedups indexed vertices: 6 indices land as 4 vertices + drawIndexed(6)", () => { diff --git a/packages/melonjs/tests/webgpu_mesh_fog.spec.js b/packages/melonjs/tests/webgpu_mesh_fog.spec.js index 422e8c378..8e47ad045 100644 --- a/packages/melonjs/tests/webgpu_mesh_fog.spec.js +++ b/packages/melonjs/tests/webgpu_mesh_fog.spec.js @@ -83,15 +83,19 @@ describe("WebGPU mesh distance fog (#1622)", () => { invRange: 0.25, density: 0, color: new Float32Array([0.25, 0.5, 0.75]), + // uniform fog: a zero falloff collapses the height integral to 1 + heightFalloff: 0, + fogHeight: 0, + cameraY: 0, ...over, }; }; describe("the uniform block", () => { - it("grew to 240 bytes, and the layout key moved with it", () => { - // 176 before #1575, 208 before fog - expect(MESH_UNIFORM_SIZE).toBe(240); - expect(renderer.pipelineCache.effectLayouts.has("mesh:u240")).toBe(true); + it("grew to 256 bytes, and the layout key moved with it", () => { + // 176 before #1575, 208 before fog, 240 before its height falloff + expect(MESH_UNIFORM_SIZE).toBe(256); + expect(renderer.pipelineCache.effectLayouts.has("mesh:u256")).toBe(true); }); it("writes the fog colour at 52-54 and the params at 56-59", () => { diff --git a/packages/melonjs/tests/webgpu_mtl_material.spec.js b/packages/melonjs/tests/webgpu_mtl_material.spec.js index 6bef05c67..94afdffa1 100644 --- a/packages/melonjs/tests/webgpu_mtl_material.spec.js +++ b/packages/melonjs/tests/webgpu_mtl_material.spec.js @@ -72,8 +72,8 @@ describe("WebGPU MTL specular and alpha maps (#1575)", () => { }; describe("the uniform block", () => { - it("grew to 240 bytes: specular at float 44, eye at 48, fog at 52", () => { - expect(MESH_UNIFORM_SIZE).toBe(240); + it("grew to 256 bytes: specular at float 44, eye at 48, fog at 52", () => { + expect(MESH_UNIFORM_SIZE).toBe(256); const mesh = makeMesh({ specular: new Float32Array([0.25, 0.5, 0.75]), shininess: 64,