From 3a0cd3efc79edbfb14dc2da63881631234d36d98 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 31 Aug 2026 22:34:02 +0800 Subject: [PATCH 01/14] Camera3d: distance fog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. Every parameter is optional and the omitted ones resolve LIVE rather than being captured at the call. The distances track the camera's own clip planes, which is the whole reason fog belongs to the camera: a snapshot would go out of step the moment `setClipPlanes` was called, and the symptom — geometry clipping before it finished fading — reads as a fog bug rather than a stale-copy bug. The colour tracks `renderer.backgroundColor` for the same reason: a day/night fade must not leave a band at the horizon. Pass `color` only when the fog should deliberately differ from the backdrop. Measured radially rather than from view-space z, so fog holds steady as the camera turns, and applied per fragment, so it does not band across large triangles. Two details worth naming: The fog blend is `mix(fogColor * a, rgb, f)`, not `mix(fogColor, rgb, f)`. `vColor` is premultiplied by the vertex stage, so the fog colour has to be scaled by the fragment's own coverage; the naive form paints full-strength fog onto near-transparent fragments and haloes every alpha-cutout leaf. Fog is a COMPILED VARIANT on WebGL, not a runtime branch. A software rasterizer predicates both sides of a branch, so an `exp()` behind a runtime test still costs every fragment of every scene: with the branch form the mesh benchmark blew its budget outright and took nine unrelated specs down with it on timeouts. `#define FOG` means a scene that never enables fog runs the shader it ran before fog existed. Off by default. Per mesh, `fog: false` exempts an object that must stay readable at any distance. Fog is per camera, so split-screen and minimap views fog independently and a `Camera2d` clears it. 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 | 46 ++- .../melonjs-camera-and-drawing/SKILL.md | 4 + packages/melonjs/src/camera/camera2d.ts | 22 ++ packages/melonjs/src/camera/camera3d.ts | 193 +++++++++++ packages/melonjs/src/camera/fog.ts | 71 ++++ packages/melonjs/src/renderable/mesh.js | 21 ++ packages/melonjs/src/video/renderer.js | 28 ++ .../src/video/webgl/batchers/mesh_batcher.js | 159 ++++++++- .../video/webgl/shaders/mesh-instanced.vert | 11 + .../webgl/shaders/mesh-lit-instanced.vert | 10 + .../src/video/webgl/shaders/mesh-lit.frag | 52 +++ .../src/video/webgl/shaders/mesh-lit.vert | 10 + .../webgl/shaders/mesh-shadow-instanced.vert | 11 + .../melonjs/src/video/webgl/shaders/mesh.frag | 44 +++ .../melonjs/src/video/webgl/shaders/mesh.vert | 11 + .../src/video/webgpu/batchers/mesh_batcher.js | 32 +- .../video/webgpu/shaders/mesh-instanced.js | 11 +- .../src/video/webgpu/shaders/mesh-lit.wgsl | 43 ++- .../webgpu/shaders/mesh-shadow-instanced.wgsl | 33 +- .../src/video/webgpu/shaders/mesh.wgsl | 46 ++- packages/melonjs/tests/camera3d_fog.spec.js | 229 +++++++++++++ packages/melonjs/tests/webgl_mesh_fog.spec.js | 309 ++++++++++++++++++ .../melonjs/tests/webgpu_mesh_batcher.spec.js | 7 +- .../melonjs/tests/webgpu_mesh_fog.spec.js | 168 ++++++++++ .../melonjs/tests/webgpu_mtl_material.spec.js | 4 +- 26 files changed, 1549 insertions(+), 27 deletions(-) create mode 100644 packages/melonjs/src/camera/fog.ts create mode 100644 packages/melonjs/tests/camera3d_fog.spec.js create mode 100644 packages/melonjs/tests/webgl_mesh_fog.spec.js create mode 100644 packages/melonjs/tests/webgpu_mesh_fog.spec.js diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 7d3b82a7d..885a52a52 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,6 +3,7 @@ ## [20.4.0] (melonJS 2) - _unreleased_ ### Added +- **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** — a scene that never calls `setFog` compiles and renders exactly the shader it did before - 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 d3c148d33..291105318 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, 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, castGroundShadow, lit." license: MIT --- @@ -112,6 +112,46 @@ Both hold at any camera position. A HUD given the huge z that would put it on top in 2D lands at the far end of the level instead, with the scenery drawing over it. +## Distance fog + +Off until you ask for it, and one call on the camera: + +```js +camera.setFog({ near: 2000, far: 7000 }); // linear: name the two distances +camera.setFog({ mode: "exp2", density: 4e-4 }); // or one density +camera.setFog(null); // off +``` + +It is the cheapest thing that stops a 3D scene reading as flat cut-outs, and it +lets props arrive at the far plane without a visible edge. + +**Every parameter is optional, and the omitted ones track live.** Distances +default to the camera's own clip planes, so fog cannot silently disagree with +them after a later `setClipPlanes`. The colour defaults to +`renderer.backgroundColor` and follows it, so geometry dissolves into whatever +sky you already set — including through a day/night fade. Pass `color` only +when the fog should deliberately differ from the backdrop: + +```js +camera.setFog({ far: 5000, color: "#8899aa" }); +``` + +A `Color` is held by reference, so mutating it animates the fog. + +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 — +and a `Camera2d` never fogs at all. + +**Per object:** `fog: false` exempts a mesh however far away it is — for a +waypoint or objective marker that has to stay readable. Emissive surfaces fog +like everything else (light travelling through fog is attenuated too), so a +neon sign that should punch through wants `fog: false`, not a brighter +emissive. + +Only meshes fog. 2D content, HUDs and `floating` renderables never reach the +mesh shaders, so a screen-space overlay stays clean with no work. + ## Meshes ```js @@ -324,6 +364,10 @@ 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 | +| 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 | +| one marker must stay readable in fog | `fog: false` on that mesh | | an object casts no visible shadow | wide and flat-bottomed — its own blob is underneath it; raising `shadowGroundY` haloes it instead of revealing it | | a dark ring around the top of an object | `shadowGroundY` lifted too far, floating the blob up into the caster | | a mesh sits at the wrong depth after being added | `autoDepth` overwrote `pos.z` with the child index — pass `addChild(mesh, z)` | diff --git a/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md b/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md index 40f9fc276..4559562a5 100644 --- a/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md +++ b/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md @@ -87,6 +87,10 @@ and an overridden `postDraw`: this.cameras.set("minimap", new MinimapCamera()); ``` +View state is per camera, not per scene: a `Camera3d` carries its own distance +fog (`setFog`), so a minimap or split-screen view fogs independently of the main +one — and a `Camera2d` never fogs. See `melonjs-3d`. + ## Immediate-mode drawing Inside a custom `draw(renderer)` you can draw shapes directly. Remember the two diff --git a/packages/melonjs/src/camera/camera2d.ts b/packages/melonjs/src/camera/camera2d.ts index 41d573608..a0652c11c 100644 --- a/packages/melonjs/src/camera/camera2d.ts +++ b/packages/melonjs/src/camera/camera2d.ts @@ -25,6 +25,7 @@ import type Renderer from "./../video/renderer.js"; import type CameraEffect from "./effects/camera_effect.ts"; import FadeEffect from "./effects/fade_effect.ts"; import ShakeEffect from "./effects/shake_effect.ts"; +import type { Fog3dState } from "./fog.ts"; /** * @import Entity from "./../renderable/entity/entity.js"; @@ -903,6 +904,20 @@ export default class Camera2d extends Renderable { return v.sub(this.pos).add(game.world.pos); } + /** + * Resolve this camera's distance fog for one frame. + * + * A 2D camera has none, so this returns `null` and `draw` uses that to + * CLEAR any fog a previously drawn camera installed. {@link Camera3d} + * overrides it. + * @param _renderer - the renderer about to draw with this camera + * @returns fog state, or null for no fog + * @ignore + */ + _fog3dState(_renderer: Renderer): Fog3dState | null { + return null; + } + /** * Build and install the world + screen projections used when this * camera is non-default (split-screen, picture-in-picture, etc.). @@ -996,6 +1011,13 @@ export default class Camera2d extends Renderable { renderer.setProjection(this.projectionMatrix); } + // Distance fog, per camera. Pushed alongside the projection because it + // is a property of THIS view: a `Camera3d` resolves its own settings + // here, and every other camera resolves `null` — so a 2D minimap + // sharing a stage with a fogged 3D camera renders clean instead of + // inheriting whatever the previous camera left installed. + renderer.setFog(this._fog3dState(renderer)); + // Upload active Light2d instances for the lit sprite pipeline. // Done here — after `setProjection()` (which can flush the // current batch) and before `container.draw()` walks the world diff --git a/packages/melonjs/src/camera/camera3d.ts b/packages/melonjs/src/camera/camera3d.ts index 6ef8bd249..8cf8b25c7 100644 --- a/packages/melonjs/src/camera/camera3d.ts +++ b/packages/melonjs/src/camera/camera3d.ts @@ -1,3 +1,4 @@ +import { Color } from "../math/color.ts"; import { Matrix3d } from "../math/matrix3d.ts"; import type { ObservableVector3d } from "../math/observableVector3d.ts"; import { Vector2d } from "../math/vector2d.ts"; @@ -6,8 +7,11 @@ import type Container from "./../renderable/container.js"; import type Renderable from "./../renderable/renderable.js"; import type Renderer from "./../video/renderer.js"; import Camera2d from "./camera2d.ts"; +import type { Fog3dState, FogOptions } from "./fog.ts"; import Frustum, { type FrustumOptions } from "./frustum.ts"; +export type { Fog3dState, FogMode, FogOptions } from "./fog.ts"; + // reusable unit-axis vectors for rotation calls. Pure constants so // allocation only happens once per module load, not per frame. const AXIS_X = new Vector3d(1, 0, 0); @@ -103,6 +107,35 @@ export default class Camera3d extends Camera2d { */ frustum: Frustum; + /** + * the fog options as given to {@link Camera3d#setFog}, or `null` when fog + * is off. Read through the {@link Camera3d#fog} accessor. + * @ignore + */ + private _fogOptions: FogOptions | null = null; + + /** + * Owned colour, used only when the caller passed a CSS string or an array. + * A caller-supplied `Color` is referenced rather than copied, and the + * default tracks `renderer.backgroundColor`, so in both of those cases + * this stays `null`. + * @ignore + */ + private _fogOwnColor: Color | null = null; + + /** + * Resolved fog handed to the renderer. Allocated once and rewritten in + * place each frame — fog costs no per-frame allocation. + * @ignore + */ + private _fogState: Fog3dState = { + mode: 0, + near: 0, + invRange: 0, + density: 0, + color: new Float32Array(3), + }; + /** * X-axis rotation in radians (look up/down). Positive values * pitch the camera up. @@ -245,6 +278,166 @@ export default class Camera3d extends Camera2d { return this; } + /** + * Enable, reconfigure, or switch off distance fog for this camera. + * + * Fog fades mesh geometry toward a colour with distance, which is what + * stops a 3D scene reading as flat cut-outs and lets props appear at the + * far plane without a visible edge. It is **off until you call this**, and + * a scene that never does renders exactly as it did before. + * + * Two curves, chosen with `mode`: + * + * | mode | parameters | character | + * | --- | --- | --- | + * | `"linear"` (default) | `near`, `far` | you name the two distances | + * | `"exp2"` | `density` | clear up close, closes fast at range | + * + * Every parameter is optional, and an omitted one is **resolved live each + * frame** rather than captured here: distances track the camera's own clip + * planes and the colour tracks `renderer.backgroundColor`. That is + * deliberate — fog distances that silently disagreed with the clip planes + * after a later {@link Camera3d#setClipPlanes} call would clip geometry + * before it finished fading, and a fog colour that did not follow a + * day/night background fade would leave a band at the horizon. + * + * Fog is per camera, so a split-screen or minimap view fogs independently + * — and a `Camera2d` never fogs at all. + * @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, + * `far` at or below `near`, or a density at or below zero + * @example + * // dissolve into whatever backdrop the renderer is already clearing to + * camera.setFog({ near: 2000, far: 7000 }); + * // a single density instead of two distances + * camera.setFog({ mode: "exp2", density: 0.0004 }); + * // fog that is deliberately not the sky colour + * camera.setFog({ far: 5000, color: "#8899aa" }); + * camera.setFog(null); // off + * @see Camera3d#setClipPlanes + * @see Mesh#fog + */ + setFog(options: FogOptions | null): this { + if (options === null || options === undefined) { + this._fogOptions = null; + this._fogOwnColor = null; + return this; + } + + const mode = options.mode ?? "linear"; + if (mode !== "linear" && mode !== "exp2") { + throw new Error( + `Camera3d.setFog: unknown mode "${String(options.mode)}" (expected "linear" or "exp2")`, + ); + } + // Only EXPLICIT values are validated here. A default that later goes + // degenerate — `setClipPlanes(5, 5)` after `setFog({})` — cannot throw + // retroactively from inside a draw, so the resolver drops fog for that + // frame instead. + for (const [name, value] of [ + ["near", options.near], + ["far", options.far], + ["density", options.density], + ] as const) { + if (value !== undefined && !Number.isFinite(value)) { + throw new Error(`Camera3d.setFog: ${name} must be a finite number`); + } + } + if (options.near !== undefined && options.near < 0) { + throw new Error("Camera3d.setFog: near must not be negative"); + } + if ( + options.near !== undefined && + options.far !== undefined && + options.far <= options.near + ) { + throw new Error("Camera3d.setFog: far must be greater than near"); + } + if (options.density !== undefined && options.density <= 0) { + throw new Error("Camera3d.setFog: density must be greater than zero"); + } + + this._fogOptions = options; + // 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) { + this._fogOwnColor = null; + } else if (Array.isArray(options.color)) { + // glTF convention: [r, g, b] in 0..1 + this._fogOwnColor = new Color( + options.color[0] * 255, + options.color[1] * 255, + options.color[2] * 255, + 1, + ); + } else { + this._fogOwnColor = new Color().parseCSS(options.color); + } + return this; + } + + /** + * The fog settings as given to {@link Camera3d#setFog}, or `null` when fog + * is off. The omitted fields are not filled in here — they are resolved + * per frame against the clip planes and the renderer's background colour. + */ + get fog(): FogOptions | null { + return this._fogOptions; + } + + /** + * Resolve this camera's fog for one frame, or `null` for no fog. + * + * Overrides the `Camera2d` hook, which returns `null` — that is what makes + * a 2D camera clear fog rather than inherit whatever the previous camera + * left behind. + * @ignore + */ + override _fog3dState(renderer: Renderer): Fog3dState | null { + const options = this._fogOptions; + if (options === null) { + return null; + } + + const state = this._fogState; + const far = options.far ?? this.far; + + if ((options.mode ?? "linear") === "exp2") { + const density = options.density ?? (far > 0 ? 2 / far : 0); + if (!(density > 0) || !Number.isFinite(density)) { + return null; + } + state.mode = 2; + state.density = density; + state.near = 0; + state.invRange = 0; + } else { + const near = options.near ?? this.near; + // a range that collapsed after a later setClipPlanes call: drop fog + // for this frame rather than dividing by zero into the shader + if (!(far > near) || !Number.isFinite(near) || !Number.isFinite(far)) { + return null; + } + state.mode = 1; + state.near = near; + state.invRange = 1 / (far - near); + state.density = 0; + } + + // `Color` stores 0..255 components; the shaders want 0..1, matching how + // light colours are packed + const color = + this._fogOwnColor ?? + (options.color instanceof Color + ? options.color + : renderer.backgroundColor); + state.color[0] = color.r / 255; + state.color[1] = color.g / 255; + state.color[2] = color.b / 255; + return state; + } + /** * Write the camera's world-space orientation basis into the given vectors: * `right` (camera local +X), `up` (+Y), and `forward` (+Z — the direction the diff --git a/packages/melonjs/src/camera/fog.ts b/packages/melonjs/src/camera/fog.ts new file mode 100644 index 000000000..4f5ed6920 --- /dev/null +++ b/packages/melonjs/src/camera/fog.ts @@ -0,0 +1,71 @@ +/** + * Distance-fog types, in their own module so `Camera2d` can declare the + * resolver hook that `Camera3d` overrides without the two importing each + * other. + */ +import type { Color } from "../math/color.ts"; + +/** + * Which curve maps distance to fog density. + * + * `"linear"` is parameterised by two distances and `"exp2"` by a single + * density — the two parameterisations inherited from fixed-function graphics + * pipelines, and still what stylised scenes want. + */ +export type FogMode = "linear" | "exp2"; + +/** + * {@link Camera3d#setFog} options. Every field is optional: the defaults are + * resolved live each frame, so fog stays consistent with the camera and the + * backdrop instead of drifting out of step with them. + */ +export interface FogOptions { + /** + * the curve mapping distance to density. + * @default "linear" + */ + mode?: FogMode; + /** + * linear only — distance at which fog starts. Omit and it tracks the + * camera's own `near` clip plane. + */ + near?: number; + /** + * linear only — distance at which fog fully hides the scene. Omit and it + * tracks the camera's own `far` clip plane, which is what keeps geometry + * from clipping before it has finished fading. + */ + far?: number; + /** + * exp2 only — how fast the scene colour is lost with distance. Omit and it + * resolves to `2 / far`, leaving a few percent of the scene colour at the + * far plane whatever the world scale. + */ + density?: number; + /** + * fog colour. Omit and it tracks `renderer.backgroundColor` live, so + * geometry dissolves into the backdrop with no extra work — including + * through a day/night fade. A {@link Color} is kept **by reference**, so + * mutating it animates the fog; a CSS string or `[r, g, b]` in 0..1 is + * parsed into a colour this camera owns. + */ + color?: Color | string | [number, number, number]; +} + +/** + * The resolved per-frame fog values handed to the renderer. Distances are + * pre-baked into the form the shaders want so neither backend divides. + * @ignore + */ +export interface Fog3dState { + /** 1 = linear, 2 = exp2 (0 never reaches the renderer — null does) */ + mode: number; + /** linear: distance at which fog starts */ + near: number; + /** linear: 1 / (far - near) */ + invRange: number; + /** exp2: density */ + density: number; + /** straight (unpremultiplied) fog colour, 3 components in 0..1 */ + color: Float32Array; +} diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index 7deb7016e..4ed218074 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -327,6 +327,7 @@ export default class Mesh extends Renderable { * @param {number} [settings.shininess=0] - specular exponent for the lit path (MTL `Ns`). `0` for a fully diffuse surface. * @param {string|TextureAtlas|HTMLImageElement} [settings.alphaMap] - per-texel opacity map, sampled in addition to the diffuse texture (MTL `map_d`). * @param {boolean} [settings.castGroundShadow] - give this mesh a blob ground shadow, overriding the application's `castGroundShadow` setting in both directions. Omit to inherit. Needs a GPU backend and a `Camera3d`. + * @param {boolean} [settings.fog] - set `false` to exempt this mesh from the camera's distance fog ({@link Camera3d#setFog}); omit to fog whenever the camera does * @param {number} [settings.shadowGroundY] - world Y of the floor the shadow lands on. Omit and the blob sits at the object's own base at full strength; set it and the blob shrinks and fades as the object rises. Render space is Y-down, so the floor is a **greater** Y than the object above it. * @param {number} [settings.shadowOpacity=0.45] - opacity of the shadow directly beneath the object, before any height fade. * @example @@ -616,6 +617,26 @@ export default class Mesh extends Renderable { ? settings.castGroundShadow : undefined; + /** + * Whether this mesh is affected by the camera's distance fog + * ({@link Camera3d#setFog}). + * + * Left **unset** (`undefined`, the default) the mesh fogs whenever the + * camera drawing it has fog — which for a scene that never enables fog + * means never. Set it to `false` and this mesh is never fogged, however + * far away it is: the escape hatch for something that has to stay + * readable at any distance, such as an objective marker or a waypoint. + * `true` is accepted for symmetry and behaves as the default. + * + * Emissive surfaces fog too — light travelling through fog is + * attenuated like anything else — so a neon sign that should punch + * through wants `fog: false` rather than a brighter emissive. + * @type {boolean|undefined} + * @default undefined + * @see Camera3d#setFog + */ + this.fog = typeof settings.fog === "boolean" ? settings.fog : undefined; + /** * World Y of the floor the shadow lands on, or `undefined` (the * default) to mean "this object is standing on the ground" — the diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 36c066bc7..80e7ee1bf 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -225,6 +225,15 @@ export default class Renderer { // the projectionMatrix (set through setProjection) this.projectionMatrix = new Matrix3d(); + /** + * The distance fog installed by the camera currently drawing, or + * `null` for none. Written once per camera by `Camera2d.draw`; read + * per draw by the mesh batchers. Null for every camera that is not a + * `Camera3d` with fog enabled, which is the default. + * @ignore + */ + this._fog3d = null; + // default uvOffset this.uvOffset = 0; @@ -757,6 +766,25 @@ export default class Renderer { * @param {number} [translateX=0] - world-to-screen X translate (matches `Camera2d.draw()`) * @param {number} [translateY=0] - world-to-screen Y translate */ + /** + * Install the distance fog for the camera about to draw, or clear it with + * `null`. + * + * Called once per camera from `Camera2d.draw`, so fog is per view: a + * split-screen or minimap camera fogs independently, and a `Camera2d` + * clears whatever the previous camera installed. Only the mesh batchers + * read it — 2D content, HUDs and `floating` renderables never reach those + * shaders and so are never fogged. + * + * Backends without a mesh path (Canvas) inherit this and simply never read + * the value. + * @param {object|null} [fog] - resolved fog state, or null/undefined for none + * @ignore + */ + setFog(fog) { + this._fog3d = fog ?? null; + } + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars setLightUniforms(lights, ambient, translateX, translateY) { if (this._litPipelineWarned || !lights) { diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js index f912f5200..81e4f3db8 100644 --- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js @@ -51,6 +51,10 @@ const _ZERO_EMISSIVE = new Float32Array(3); // scratch for the camera's world position, recomputed per draw that needs it 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); + /** * A WebGL Batcher for rendering textured triangle meshes. * Uses indexed drawing to efficiently render arbitrary triangle geometry. @@ -122,6 +126,16 @@ export default class MeshBatcher extends MaterialBatcher { this.currentEyeY = Number.NaN; this.currentEyeZ = Number.NaN; + // last fog pushed, same NaN-sentinel trick. Fog changes once per + // camera at most, while this runs once per mesh. + this.currentFogMode = Number.NaN; + this.currentFogNear = Number.NaN; + this.currentFogInvRange = Number.NaN; + this.currentFogDensity = Number.NaN; + this.currentFogR = Number.NaN; + this.currentFogG = Number.NaN; + this.currentFogB = 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 // held is stale — release it rather than leak it. @@ -154,6 +168,11 @@ export default class MeshBatcher extends MaterialBatcher { // for. this.shadowShader?.destroy(); this.shadowShader = undefined; + // the fog variants are GL-owned on exactly the same terms + this.fogShader?.destroy(); + this.fogShader = undefined; + this.shadowFogShader?.destroy(); + this.shadowFogShader = undefined; // last `uTint` value pushed, same redundant-set guard — but the // sentinel is `undefined`, NOT a number: a packed ARGB tint spans the @@ -266,6 +285,13 @@ export default class MeshBatcher extends MaterialBatcher { // the placement uniforms live on the program too — a swapped // shader starts at its own defaults, so re-issue them this.currentTintValue = undefined; + this.currentFogMode = Number.NaN; + this.currentFogNear = Number.NaN; + this.currentFogInvRange = Number.NaN; + this.currentFogDensity = Number.NaN; + this.currentFogR = Number.NaN; + this.currentFogG = Number.NaN; + this.currentFogB = Number.NaN; } super.useShader(shader); } @@ -286,6 +312,10 @@ export default class MeshBatcher extends MaterialBatcher { }); this.shadowShader?.destroy(); this.shadowShader = undefined; + this.fogShader?.destroy(); + this.fogShader = undefined; + this.shadowFogShader?.destroy(); + this.shadowFogShader = undefined; this.instancedShaders?.clear(); if (this._onTargetChanged) { off(RENDER_TARGET_CHANGED, this._onTargetChanged); @@ -509,6 +539,9 @@ export default class MeshBatcher extends MaterialBatcher { // anything the caller had queued must land first, or this draw would // reorder ahead of it this.flush(); + // fog is a compiled variant, so the program depends on the camera's + // fog state rather than only on the batcher + this.useShader(this.meshShader()); // Strictly BEFORE the blended-draw toggle below: `updatePassState` // runs the one-shot depth clear, and `gl.clear(DEPTH_BUFFER_BIT)` @@ -526,7 +559,7 @@ export default class MeshBatcher extends MaterialBatcher { if (slices === undefined) { this.applyMeshMaterial(mesh); } - this.setPlacementUniforms(modelMatrix, tint); + this.setPlacementUniforms(modelMatrix, tint, mesh); const geometry = this.retainedGeometryFor(mesh); geometry.bind(); @@ -729,17 +762,25 @@ export default class MeshBatcher extends MaterialBatcher { * @ignore */ instancedShaderFor(layout) { - const key = (layout.hasColor ? 1 : 0) | (layout.hasData ? 2 : 0); + const fogDefine = this._fogDefine(); + // fog joins the key: the fogged and unfogged forms are different + // programs, and one must not be served for the other + const key = + (layout.hasColor ? 1 : 0) | + (layout.hasData ? 2 : 0) | + (fogDefine !== "" ? 4 : 0); let shader = this.instancedShaders.get(key); if (shader === undefined) { const defines = (layout.hasColor ? "#define INSTANCE_COLORS\n" : "") + - (layout.hasData ? "#define INSTANCE_DATA\n" : ""); + (layout.hasData ? "#define INSTANCE_DATA\n" : "") + + fogDefine; const sources = this._instancedShaderSources(); // only INSTANCE_DATA reaches the fragment stage (as the // per-instance emissive term); injecting the colour flag there too // would compile four distinct fragment texts where two suffice - const fragmentDefines = layout.hasData ? "#define INSTANCE_DATA\n" : ""; + const fragmentDefines = + (layout.hasData ? "#define INSTANCE_DATA\n" : "") + fogDefine; shader = new GLShader(this.gl, { vertex: injectDefines(sources.vertex, defines), fragment: injectDefines(sources.fragment, fragmentDefines), @@ -750,6 +791,43 @@ export default class MeshBatcher extends MaterialBatcher { return shader; } + /** + * `"#define FOG\n"` while the camera drawing has fog, `""` otherwise. + * + * Fog is a compiled variant rather than a runtime `if`, and the reason is + * measured rather than theoretical: a software rasterizer predicates both + * sides of a branch, so an `exp()` behind a runtime test still costs every + * fragment of every scene — the mesh benchmark blew its budget outright. + * Compiling it out means a scene that never enables fog runs the shader it + * ran before fog existed, instruction for instruction. + * @ignore + */ + _fogDefine() { + return this.renderer._fog3d !== null && this.renderer._fog3d !== undefined + ? "#define FOG\n" + : ""; + } + + /** + * The non-instanced mesh shader for the current fog state: the batcher's + * own program while fog is off, a lazily-built fog variant while it is on. + * @ignore + */ + meshShader() { + if (this._fogDefine() === "") { + return this.defaultShader; + } + if (this.fogShader === undefined) { + const sources = this._shaderSources(); + this.fogShader = new GLShader(this.gl, { + vertex: injectDefines(sources.vertex, "#define FOG\n"), + fragment: injectDefines(sources.fragment, "#define FOG\n"), + label: "melonJS mesh (fog)", + }); + } + return this.fogShader; + } + /** * The instanced shader sources for this batcher (unlit by default). * Subclasses override to supply the lit pair. @@ -826,7 +904,7 @@ export default class MeshBatcher extends MaterialBatcher { if (slices === undefined) { this.applyMeshMaterial(mesh); } - this.setPlacementUniforms(modelMatrix, tint); + this.setPlacementUniforms(modelMatrix, tint, mesh); const { geometry, state } = this.instancedStateFor(mesh); state.vertexState.bind(); @@ -861,7 +939,7 @@ export default class MeshBatcher extends MaterialBatcher { // already current — so a following non-instanced mesh would otherwise // draw through the instanced program, reading per-instance attributes // that no longer have a buffer behind them. - this.useShader(this.defaultShader); + this.useShader(this.meshShader()); this.vertexState.bind(); gl.bindBuffer(gl.ARRAY_BUFFER, this.uploadBuffer); } @@ -877,6 +955,19 @@ export default class MeshBatcher extends MaterialBatcher { * @ignore */ instancedShadowShader() { + const fogDefine = this._fogDefine(); + // a blob fades with distance like the ground it lies on, so it needs + // the fogged pair too — kept in its own slot, same reason as above + if (fogDefine !== "") { + if (this.shadowFogShader === undefined) { + this.shadowFogShader = new GLShader(this.gl, { + vertex: injectDefines(meshShadowInstancedVertex, fogDefine), + fragment: injectDefines(meshFragment, fogDefine), + label: "melonJS instanced mesh shadow (fog)", + }); + } + return this.shadowFogShader; + } if (this.shadowShader === undefined) { this.shadowShader = new GLShader(this.gl, { vertex: meshShadowInstancedVertex, @@ -997,7 +1088,7 @@ export default class MeshBatcher extends MaterialBatcher { this.useShader(this.instancedShadowShader()); this.updatePassState(); this.applyMeshMaterial(quad); - this.setPlacementUniforms(shadowMatrix, tint); + this.setPlacementUniforms(shadowMatrix, tint, quad); const quadGeometry = this.retainedGeometryFor(quad); const state = this.instancedShadowStateFor(mesh, quadGeometry); @@ -1012,7 +1103,7 @@ export default class MeshBatcher extends MaterialBatcher { ); this.endBlendedDraw(); - this.useShader(this.defaultShader); + this.useShader(this.meshShader()); this.vertexState.bind(); gl.bindBuffer(gl.ARRAY_BUFFER, this.uploadBuffer); } @@ -1103,7 +1194,7 @@ export default class MeshBatcher extends MaterialBatcher { } } - setPlacementUniforms(modelMatrix, tint) { + setPlacementUniforms(modelMatrix, tint, mesh) { const shader = this.currentShader; const uniforms = shader.uniforms; @@ -1148,6 +1239,49 @@ export default class MeshBatcher extends MaterialBatcher { shader.setUniform("uTint", _TINT_RGBA); this.currentTintValue = tint; } + if (uniforms.uFogParams !== undefined) { + // `mesh.fog === false` exempts this object; anything else follows + // the camera. A custom shader that declares neither uniform is + // skipped entirely by the guard above. + const fog = mesh?.fog === false ? null : this.renderer._fog3d; + const mode = fog !== null && fog !== undefined ? fog.mode : 0; + const near = mode !== 0 ? fog.near : 0; + const invRange = mode !== 0 ? fog.invRange : 0; + const density = mode !== 0 ? fog.density : 0; + const r = mode !== 0 ? fog.color[0] : 0; + const g = mode !== 0 ? fog.color[1] : 0; + const b = mode !== 0 ? fog.color[2] : 0; + if ( + mode !== this.currentFogMode || + near !== this.currentFogNear || + invRange !== this.currentFogInvRange || + density !== this.currentFogDensity + ) { + _FOG_PARAMS[0] = mode; + _FOG_PARAMS[1] = near; + _FOG_PARAMS[2] = invRange; + _FOG_PARAMS[3] = density; + shader.setUniform("uFogParams", _FOG_PARAMS); + this.currentFogMode = mode; + this.currentFogNear = near; + this.currentFogInvRange = invRange; + this.currentFogDensity = density; + } + if ( + uniforms.uFogColor !== undefined && + (r !== this.currentFogR || + g !== this.currentFogG || + b !== this.currentFogB) + ) { + _FOG_COLOR[0] = r; + _FOG_COLOR[1] = g; + _FOG_COLOR[2] = b; + shader.setUniform("uFogColor", _FOG_COLOR); + this.currentFogR = r; + this.currentFogG = g; + this.currentFogB = b; + } + } } /** @@ -1361,8 +1495,9 @@ export default class MeshBatcher extends MaterialBatcher { // baked into every vertex on the CPU; they are uniforms now, so the // vertex data depends only on the geometry itself. This path (2D // camera / pre-projected vertices) supplies an identity model - // matrix — the vertices already sit where they belong. - this.setPlacementUniforms(_IDENTITY_MATRIX, tint); + // matrix — the vertices already sit where they belong. A 2D camera + // clears fog, so this resolves to the plain program. + this.setPlacementUniforms(_IDENTITY_MATRIX, tint, mesh); this.accumulateRange(mesh, 0, mesh.indices.length); return; } @@ -1373,7 +1508,7 @@ export default class MeshBatcher extends MaterialBatcher { // never before it. for (let i = 0; i < slices.length; i++) { this.applyMeshMaterial(mesh, slices[i].texture); - this.setPlacementUniforms(_IDENTITY_MATRIX, tint); + this.setPlacementUniforms(_IDENTITY_MATRIX, tint, mesh); this.accumulateRange(mesh, slices[i].start, slices[i].count); } } diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-instanced.vert b/packages/melonjs/src/video/webgl/shaders/mesh-instanced.vert index 9e6cd8154..b80ad6c40 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-instanced.vert +++ b/packages/melonjs/src/video/webgl/shaders/mesh-instanced.vert @@ -37,6 +37,9 @@ uniform vec4 uTint; varying vec2 vRegion; varying vec4 vColor; +#ifdef FOG +varying float vFogDepth; +#endif #ifdef INSTANCE_DATA varying vec4 vInstanceData; #endif @@ -53,6 +56,14 @@ void main(void) { mat4 instance = instanceMatrix(); gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * instance * vec4(aVertex, 1.0); +#ifdef FOG + // Radial view-space distance for distance fog. Radial rather than view-space + // 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. + vec4 viewPos = uViewMatrix * uModelMatrix * instance * vec4(aVertex, 1.0); + vFogDepth = length(viewPos.xyz); +#endif vec4 tinted = aColor * uTint; #ifdef INSTANCE_COLORS 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 c6816b007..b834125c5 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-lit-instanced.vert +++ b/packages/melonjs/src/video/webgl/shaders/mesh-lit-instanced.vert @@ -40,6 +40,9 @@ out vec2 vRegion; out vec4 vColor; out vec3 vNormal; out vec3 vWorldPos; +#ifdef FOG +out float vFogDepth; +#endif #ifdef INSTANCE_DATA out vec4 vInstanceData; #endif @@ -57,6 +60,13 @@ void main(void) { vec4 worldPos = uModelMatrix * instance * vec4(aVertex, 1.0); gl_Position = uProjectionMatrix * uViewMatrix * worldPos; vWorldPos = worldPos.xyz; +#ifdef FOG + // Radial view-space distance for distance fog. Radial rather than view-space + // 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); +#endif vec4 tinted = aColor * uTint; #ifdef INSTANCE_COLORS diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag index d1e69c473..019614669 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag +++ b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag @@ -63,6 +63,47 @@ in vec4 vInstanceData; out vec4 fragColor; +// Distance fog is a COMPILED VARIANT, not a runtime branch: the batcher +// injects `#define FOG` only while a camera has fog enabled. A software +// rasterizer predicates both sides of a branch, so an `exp()` guarded at +// runtime still costs every fragment of every scene — including the scenes +// that never asked for fog. +#ifdef FOG +uniform vec3 uFogColor; // straight (unpremultiplied) fog colour +uniform vec4 uFogParams; // x = mode (0 off / 1 linear / 2 exp2), + // y = near, z = 1/(far - near), w = density +in float vFogDepth; // radial view-space distance, interpolated + +// Fold distance fog into a PREMULTIPLIED colour. Twin of the block in +// mesh.frag — keep the two in step; they are duplicated rather than shared +// because the production build loads each shader as raw text (no preprocessor +// include), and this shader is ES 3.00 while its twin is ES 1.00. +// +// `rgb` is already multiplied by `a`, so the fog colour has to be scaled by +// the fragment's own coverage. A plain mix toward uFogColor would paint +// full-strength fog onto near-transparent fragments — grey halos around every +// alpha-cutout leaf. +// +// Mode 0 returns the input untouched, so a scene with no fog is bit-identical +// to one built before fog existed. +vec3 applyFog(vec3 rgb, float a) { + float mode = uFogParams.x; + if (mode < 0.5) { + return rgb; + } + float f; // fraction of the scene colour that survives + if (mode < 1.5) { + f = 1.0 - clamp((vFogDepth - uFogParams.y) * uFogParams.z, 0.0, 1.0); + } else { + float dd = vFogDepth * uFogParams.w; + f = exp(-dd * dd); + } + return mix(uFogColor * a, rgb, f); +} +#endif + + + void main(void) { vec4 base = texture(uSampler, vRegion) * vColor; @@ -95,7 +136,11 @@ void main(void) { #ifdef INSTANCE_DATA unlitEmissive += vInstanceData.rgb; #endif +#ifdef FOG + fragColor = vec4(applyFog(base.rgb + unlitEmissive, base.a), base.a); +#else fragColor = vec4(base.rgb + unlitEmissive, base.a); +#endif return; } vec3 N = vNormal / nLength; @@ -161,5 +206,12 @@ void main(void) { #ifdef INSTANCE_DATA emissive += vInstanceData.rgb; #endif +#ifdef FOG + fragColor = vec4( + applyFog(base.rgb * lit + specular * uSpecular + emissive, base.a), + base.a + ); +#else fragColor = vec4(base.rgb * lit + specular * uSpecular + emissive, base.a); +#endif } diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert b/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert index 5274f53da..ccca33a0d 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert +++ b/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert @@ -25,11 +25,21 @@ out vec3 vNormal; // world-space fragment position — positional lights (point / spot) fall // off with distance, so the fragment stage needs where the surface IS out vec3 vWorldPos; +#ifdef FOG +out float vFogDepth; +#endif void main(void) { vec4 worldPos = uModelMatrix * vec4(aVertex, 1.0); gl_Position = uProjectionMatrix * uViewMatrix * worldPos; vWorldPos = worldPos.xyz; +#ifdef FOG + // Radial view-space distance for distance fog. Radial rather than view-space + // 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); +#endif vec4 tinted = aColor * uTint; vColor = vec4(tinted.rgb * tinted.a, tinted.a); vRegion = aRegion; 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 5b2a7154a..3d400c072 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-shadow-instanced.vert +++ b/packages/melonjs/src/video/webgl/shaders/mesh-shadow-instanced.vert @@ -41,6 +41,9 @@ uniform vec4 uTint; varying vec2 vRegion; varying vec4 vColor; +#ifdef FOG +varying float vFogDepth; +#endif void main(void) { vec3 instancePos = vec3(aInstanceRow0.w, aInstanceRow1.w, aInstanceRow2.w); @@ -56,6 +59,14 @@ void main(void) { gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(local, 1.0); +#ifdef FOG + // Radial view-space distance for distance fog. Radial rather than view-space + // 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. + vec4 viewPos = uViewMatrix * uModelMatrix * vec4(local, 1.0); + vFogDepth = length(viewPos.xyz); +#endif // tint first, then premultiply — matches the fragment shader's expectation vec4 tinted = aColor * uTint; diff --git a/packages/melonjs/src/video/webgl/shaders/mesh.frag b/packages/melonjs/src/video/webgl/shaders/mesh.frag index 7d94a1438..cddd5707a 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh.frag +++ b/packages/melonjs/src/video/webgl/shaders/mesh.frag @@ -5,6 +5,46 @@ uniform sampler2D uAlphaMap; // per-texel opacity (MTL map_d) uniform float uHasAlphaMap; // 0 = uAlphaMap is filler, ignore it varying vec4 vColor; varying vec2 vRegion; +// Distance fog is a COMPILED VARIANT, not a runtime branch: the batcher +// injects `#define FOG` only while a camera has fog enabled. A software +// rasterizer predicates both sides of a branch, so an `exp()` guarded at +// runtime still costs every fragment of every scene — including the scenes +// that never asked for fog. +#ifdef FOG +uniform vec3 uFogColor; // straight (unpremultiplied) fog colour +uniform vec4 uFogParams; // x = mode (0 off / 1 linear / 2 exp2), + // y = near, z = 1/(far - near), w = density +varying float vFogDepth; // radial view-space distance, interpolated + +// Fold distance fog into a PREMULTIPLIED colour. +// +// `rgb` here is already multiplied by `a` (the vertex stage premultiplies +// vColor), so the fog colour has to be scaled by the fragment's own coverage. +// A plain mix toward uFogColor would paint full-strength fog onto +// near-transparent fragments — grey halos around every alpha-cutout leaf, and +// unattenuated fog added on the blended shadow pass. +// +// Mode 0 returns the input untouched, so a scene with no fog is bit-identical +// to one built before fog existed. +vec3 applyFog(vec3 rgb, float a) { + float mode = uFogParams.x; + if (mode < 0.5) { + return rgb; + } + float f; // fraction of the scene colour that survives + if (mode < 1.5) { + // linear: 1 at `near`, reaching 0 at `far` + f = 1.0 - clamp((vFogDepth - uFogParams.y) * uFogParams.z, 0.0, 1.0); + } else { + // exponential squared: survival = exp(-(density * d)^2) + float dd = vFogDepth * uFogParams.w; + f = exp(-dd * dd); + } + return mix(uFogColor * a, rgb, f); +} +#endif + + #ifdef INSTANCE_DATA // per-instance custom slot. The built-in shading reads its rgb as emissive, // so a forest can glow per tree without a uniform per instance; a CUSTOM mesh @@ -34,5 +74,9 @@ void main(void) { #ifdef INSTANCE_DATA emissive += vInstanceData.rgb; #endif +#ifdef FOG + gl_FragColor = vec4(applyFog(color.rgb + emissive, color.a), color.a); +#else gl_FragColor = vec4(color.rgb + emissive, color.a); +#endif } diff --git a/packages/melonjs/src/video/webgl/shaders/mesh.vert b/packages/melonjs/src/video/webgl/shaders/mesh.vert index 8a8bf8f05..128797d5e 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh.vert +++ b/packages/melonjs/src/video/webgl/shaders/mesh.vert @@ -26,10 +26,21 @@ uniform vec4 uTint; varying vec2 vRegion; varying vec4 vColor; +#ifdef FOG +varying float vFogDepth; +#endif void main(void) { gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aVertex, 1.0); +#ifdef FOG + // Radial view-space distance for distance fog. Radial rather than view-space + // 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. + vec4 viewPos = uViewMatrix * uModelMatrix * vec4(aVertex, 1.0); + vFogDepth = length(viewPos.xyz); +#endif // tint first, then premultiply — matches the fragment shader's expectation vec4 tinted = aColor * uTint; vColor = vec4(tinted.rgb * tinted.a, tinted.a); diff --git a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js index 170404723..a98028881 100644 --- a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js @@ -22,16 +22,17 @@ 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) → 208. + * w reserved) (16) + vec4 fogColor (16) + vec4 fogParams (16) → 240. * @ignore */ -export const MESH_UNIFORM_SIZE = 208; +export const MESH_UNIFORM_SIZE = 240; // Shared identity model matrix for draws whose vertices are already placed // (the 2D-camera path pre-projects them on the CPU). Never mutated. const IDENTITY_MATRIX = new Matrix3d(); -// Scratch for assembling one MeshUniforms snapshot (44 floats = 176 bytes). +// Scratch for assembling one MeshUniforms snapshot; sized from the block +// above, so growing the block grows this with it. // Reused — setPlacementUniforms runs synchronously and never re-enters. const UNIFORM_SCRATCH = new Float32Array(MESH_UNIFORM_SIZE / 4); @@ -643,6 +644,31 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { scratch[50] = -(v[8] * v[12] + v[9] * v[13] + v[10] * v[14]); scratch[51] = 0; + // Distance fog, resolved by the camera drawing this frame. + // `mesh.fog === false` exempts this object; with no fog installed every + // float below stays zero, which is mode 0 — the shader returns the + // colour untouched, so a scene without fog is unchanged. + const fog = mesh?.fog === false ? null : renderer._fog3d; + if (fog !== null && fog !== undefined) { + scratch[52] = fog.color[0]; + scratch[53] = fog.color[1]; + scratch[54] = fog.color[2]; + scratch[55] = 0; + scratch[56] = fog.mode; + scratch[57] = fog.near; + scratch[58] = fog.invRange; + scratch[59] = fog.density; + } else { + scratch[52] = 0; + scratch[53] = 0; + scratch[54] = 0; + scratch[55] = 0; + scratch[56] = 0; + scratch[57] = 0; + scratch[58] = 0; + scratch[59] = 0; + } + const region = renderer.effectUniformArena.alloc( MESH_UNIFORM_SIZE, device.limits.minUniformBufferOffsetAlignment, diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh-instanced.js b/packages/melonjs/src/video/webgpu/shaders/mesh-instanced.js index 1f3c87483..2bd465719 100644 --- a/packages/melonjs/src/video/webgpu/shaders/mesh-instanced.js +++ b/packages/melonjs/src/video/webgpu/shaders/mesh-instanced.js @@ -170,7 +170,8 @@ export function buildInstancedMeshWGSL(source, options) { /** the unlit tier's geometry inputs and placement body @ignore */ export const UNLIT_INSTANCED = { baseLocation: 3, - varyingLocation: 2, + // 2 is vFogDepth in the base module now; the instance slot follows it + varyingLocation: 3, geometryInputs: [ "\t@location(0) aVertex : vec3f,", "\t@location(1) aRegion : vec2f,", @@ -179,13 +180,17 @@ export const UNLIT_INSTANCED = { body: [ "\tlet clip = uFrame.projection * uMesh.view * uMesh.model * instance", "\t\t* vec4f(aVertex, 1.0);", + "\t// radial view-space distance for distance fog", + "\tlet viewPos = uMesh.view * uMesh.model * instance * vec4f(aVertex, 1.0);", + "\tout.vFogDepth = length(viewPos.xyz);", ].join("\n"), }; /** the lit tier's geometry inputs, placement body and normal handling @ignore */ export const LIT_INSTANCED = { baseLocation: 4, - varyingLocation: 4, + // 4 is vFogDepth in the base module now; the instance slot follows it + varyingLocation: 5, geometryInputs: [ "\t@location(0) aVertex : vec3f,", "\t@location(1) aRegion : vec2f,", @@ -196,6 +201,8 @@ export const LIT_INSTANCED = { "\tlet worldPos = uMesh.model * instance * vec4f(aVertex, 1.0);", "\tlet clip = uFrame.projection * uMesh.view * worldPos;", "\tout.vWorldPos = worldPos.xyz;", + "\t// radial view-space distance for distance fog", + "\tout.vFogDepth = length((uMesh.view * worldPos).xyz);", "\t// Rotate the normal through BOTH transforms, in the order the", "\t// position takes them. Non-uniform scale is approximated exactly as", "\t// the uninstanced path approximates it, so an instanced mesh shades", diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl b/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl index ab98bd9c0..eb55015f0 100644 --- a/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl @@ -35,6 +35,11 @@ struct MeshUniforms { // the camera's world position (xyz; w reserved), for the specular // half-vector — nothing else in this shader needs it eye : vec4f, + // straight (unpremultiplied) fog colour (rgb; w reserved) + fogColor : vec4f, + // x = mode (0 off / 1 linear / 2 exp2), y = near, z = 1/(far - near), + // w = density + fogParams : vec4f, }; // One light, type inferred from sentinels (see std140.ts): @@ -77,8 +82,34 @@ struct VSOut { // fall off with distance, so the fragment stage needs where the // surface IS @location(3) vWorldPos : vec3f, + // radial view-space distance for distance fog + @location(4) vFogDepth : f32, }; +// 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 +// duplicated rather than shared. +// +// `rgb` is already multiplied by `a`, so the fog colour must be scaled by the +// fragment's own coverage — a plain mix would paint fog onto near-transparent +// fragments and halo every alpha-cutout leaf. Mode 0 returns the input +// untouched, so a scene with no fog is bit-identical. +fn apply_fog(rgb : vec3f, a : f32, fogDepth : f32) -> vec3f { + let mode = uMesh.fogParams.x; + if (mode < 0.5) { + return rgb; + } + var f : f32; + if (mode < 1.5) { + f = 1.0 - clamp((fogDepth - uMesh.fogParams.y) * uMesh.fogParams.z, 0.0, 1.0); + } else { + let dd = fogDepth * uMesh.fogParams.w; + f = exp(-dd * dd); + } + return mix(uMesh.fogColor.rgb * a, rgb, f); +} + @vertex fn vertex_main( @location(0) aVertex : vec3f, @@ -95,6 +126,7 @@ fn vertex_main( out.vColor = vec4f(tinted.rgb * tinted.a, tinted.a); out.vRegion = aRegion; out.vWorldPos = worldPos.xyz; + out.vFogDepth = length((uMesh.view * worldPos).xyz); // Rotate the normal into world space with the model matrix's upper // 3×3. Lighting is evaluated in world space, so the view transform is // deliberately excluded. Uniform scale cancels when the fragment @@ -125,7 +157,10 @@ fn fragment_main(in : VSOut) -> @location(0) vec4f { // unlit rather than normalize a zero vector to NaN and render black let nLength = length(in.vNormal); if (nLength < 1e-6) { - return vec4f(base.rgb + uMesh.emissive.rgb, base.a); + return vec4f( + apply_fog(base.rgb + uMesh.emissive.rgb, base.a, in.vFogDepth), + base.a + ); } let n = in.vNormal / nLength; var lit = uLights.ambient.rgb; @@ -187,7 +222,11 @@ fn fragment_main(in : VSOut) -> @location(0) vec4f { // emissive self-illuminates: added AFTER lighting so it glows at full // strength regardless of the scene lights (neon, lava, glowing eyes) return vec4f( - base.rgb * lit + specular * uMesh.specular.rgb + uMesh.emissive.rgb, + apply_fog( + base.rgb * lit + specular * uMesh.specular.rgb + uMesh.emissive.rgb, + base.a, + in.vFogDepth + ), base.a ); } 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 136d05bd4..f18c1c20f 100644 --- a/packages/melonjs/src/video/webgpu/shaders/mesh-shadow-instanced.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/mesh-shadow-instanced.wgsl @@ -38,6 +38,11 @@ struct MeshUniforms { emissive : vec4f, specular : vec4f, eye : vec4f, + // straight (unpremultiplied) fog colour (rgb; w reserved) + fogColor : vec4f, + // x = mode (0 off / 1 linear / 2 exp2), y = near, z = 1/(far - near), + // w = density + fogParams : vec4f, }; @group(0) @binding(0) var uFrame : FrameUniforms; @@ -51,6 +56,8 @@ struct VSOut { @builtin(position) position : vec4f, @location(0) vRegion : vec2f, @location(1) vColor : vec4f, + // radial view-space distance for distance fog + @location(2) vFogDepth : f32, }; @vertex @@ -81,12 +88,36 @@ fn vertex_main( let tinted = aColor * uMesh.tint; out.vColor = vec4f(tinted.rgb * tinted.a, tinted.a); + // a blob fades with distance like the ground it lies on + let viewPos = uMesh.view * uMesh.model * vec4f(local, 1.0); + out.vFogDepth = length(viewPos.xyz); out.vRegion = aRegion; return out; } +// Fold distance fog into a PREMULTIPLIED colour. Twin of `apply_fog` in +// mesh.wgsl — kept in step by hand (WGSL has no preprocessor, and the build +// loads shaders as raw text). Present here because on WebGL this vertex +// shader pairs with mesh.frag, which fogs; without this the two backends would +// disagree on whether distant shadows fade. +fn apply_fog(rgb : vec3f, a : f32, fogDepth : f32) -> vec3f { + let mode = uMesh.fogParams.x; + if (mode < 0.5) { + return rgb; + } + var f : f32; + if (mode < 1.5) { + f = 1.0 - clamp((fogDepth - uMesh.fogParams.y) * uMesh.fogParams.z, 0.0, 1.0); + } else { + let dd = fogDepth * uMesh.fogParams.w; + f = exp(-dd * dd); + } + return mix(uMesh.fogColor.rgb * a, rgb, f); +} + @fragment fn fragment_main(in : VSOut) -> @location(0) vec4f { let texel = textureSample(uTexture, uSampler, in.vRegion); - return texel * in.vColor; + let color = texel * in.vColor; + return vec4f(apply_fog(color.rgb, color.a, in.vFogDepth), color.a); } diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl b/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl index 746986eb0..0df156f72 100644 --- a/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl @@ -41,6 +41,11 @@ struct MeshUniforms { specular : vec4f, // the camera's world position (xyz; w reserved) eye : vec4f, + // straight (unpremultiplied) fog colour (rgb; w reserved) + fogColor : vec4f, + // x = mode (0 off / 1 linear / 2 exp2), y = near, z = 1/(far - near), + // w = density + fogParams : vec4f, }; @group(0) @binding(0) var uFrame : FrameUniforms; @@ -57,8 +62,38 @@ struct VSOut { @builtin(position) position : vec4f, @location(0) vRegion : vec2f, @location(1) vColor : vec4f, + @location(2) vFogDepth : f32, }; +// 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 +// shader as raw text. +// +// `rgb` is already multiplied by `a`, so the fog colour must be scaled by the +// fragment's own coverage: a plain mix toward the fog colour would paint +// full-strength fog onto near-transparent fragments — grey halos around every +// alpha-cutout leaf. +// +// Mode 0 returns the input untouched, so a scene with no fog is bit-identical +// to one built before fog existed. +fn apply_fog(rgb : vec3f, a : f32, fogDepth : f32) -> vec3f { + let mode = uMesh.fogParams.x; + if (mode < 0.5) { + return rgb; + } + var f : f32; + if (mode < 1.5) { + // linear: 1 at `near`, reaching 0 at `far` + f = 1.0 - clamp((fogDepth - uMesh.fogParams.y) * uMesh.fogParams.z, 0.0, 1.0); + } else { + // exponential squared: survival = exp(-(density * d)^2) + let dd = fogDepth * uMesh.fogParams.w; + f = exp(-dd * dd); + } + return mix(uMesh.fogColor.rgb * a, rgb, f); +} + @vertex fn vertex_main( @location(0) aVertex : vec3f, @@ -73,6 +108,12 @@ fn vertex_main( // tint first, then premultiply — matches the fragment's expectation let tinted = aColor * uMesh.tint; out.vColor = vec4f(tinted.rgb * tinted.a, tinted.a); + // Radial view-space distance for distance fog. Radial rather than view-space + // z, so fog holds steady as the camera turns. The clip position above keeps + // its own product: re-associating it could shift vertices by an ulp, and + // fog-off output must stay bit-identical. + let viewPos = uMesh.view * uMesh.model * vec4f(aVertex, 1.0); + out.vFogDepth = length(viewPos.xyz); out.vRegion = aRegion; return out; } @@ -94,5 +135,8 @@ fn fragment_main(in : VSOut) -> @location(0) vec4f { } // emissive adds a self-lit color on top (neon, lava, screens); the // unlit path has no lighting, so it is simply added to the base color - return vec4f(color.rgb + uMesh.emissive.rgb, color.a); + return vec4f( + apply_fog(color.rgb + uMesh.emissive.rgb, color.a, in.vFogDepth), + color.a + ); } diff --git a/packages/melonjs/tests/camera3d_fog.spec.js b/packages/melonjs/tests/camera3d_fog.spec.js new file mode 100644 index 000000000..db40ceaf4 --- /dev/null +++ b/packages/melonjs/tests/camera3d_fog.spec.js @@ -0,0 +1,229 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + Application, + boot, + Camera2d, + Camera3d, + Color, + video, +} from "../src/index.js"; + +/** + * `Camera3d.setFog` — the API and the live defaults (#1622). + * + * Fog is owned by the camera rather than the world because its distances have + * to agree with the camera's own clip planes. That is only true if the + * defaults are resolved per frame: a snapshot taken at `setFog` time goes out + * of step the moment `setClipPlanes` is called, and the symptom — geometry + * clipping before it has finished fading — reads as a fog bug rather than a + * stale-copy bug. Most of this file guards that. + * + * Canvas renderer throughout: this exercises the resolver, not rasterization. + */ +describe("Camera3d distance fog", () => { + let app; + let camera; + + beforeAll(async () => { + boot(); + app = new Application(320, 240, { + parent: "screen", + renderer: video.CANVAS, + cameraClass: Camera3d, + }); + await app.init(); + camera = app.viewport; + }); + + afterAll(() => { + app?.destroy(); + }); + + const resolve = () => { + return camera._fog3dState(app.renderer); + }; + + describe("off by default", () => { + it("starts with no fog at all", () => { + expect(camera.fog).toBe(null); + expect(resolve()).toBe(null); + }); + + it("goes back to none with setFog(null)", () => { + camera.setFog({ near: 10, far: 100 }); + expect(resolve()).not.toBe(null); + camera.setFog(null); + expect(camera.fog).toBe(null); + expect(resolve()).toBe(null); + }); + + it("is chainable", () => { + expect(camera.setFog({ far: 100 })).toBe(camera); + expect(camera.setFog(null)).toBe(camera); + }); + }); + + describe("the defaults track, rather than snapshot", () => { + it("takes its distances from the clip planes, and follows them", () => { + camera.setClipPlanes(1, 1000); + camera.setFog({}); + const first = resolve(); + expect(first.mode).toBe(1); + expect(first.near).toBe(1); + expect(first.invRange).toBeCloseTo(1 / 999, 10); + // the whole reason fog lives on the camera: move the clip planes + // and the fog must move with them + camera.setClipPlanes(5, 500); + const second = resolve(); + expect(second.near).toBe(5); + expect(second.invRange).toBeCloseTo(1 / 495, 10); + camera.setFog(null); + }); + + it("takes its colour from the background, and follows that too", () => { + app.renderer.backgroundColor.parseCSS("#204060"); + camera.setFog({ far: 100 }); + const first = resolve(); + expect(first.color[0]).toBeCloseTo(0x20 / 255, 5); + expect(first.color[2]).toBeCloseTo(0x60 / 255, 5); + // a day/night fade must not leave a band at the horizon + app.renderer.backgroundColor.parseCSS("#803010"); + const second = resolve(); + expect(second.color[0]).toBeCloseTo(0x80 / 255, 5); + expect(second.color[2]).toBeCloseTo(0x10 / 255, 5); + camera.setFog(null); + }); + + it("defaults exp2 density from the far plane, so world scale does not matter", () => { + camera.setClipPlanes(1, 4000); + camera.setFog({ mode: "exp2" }); + expect(resolve().density).toBeCloseTo(2 / 4000, 10); + camera.setClipPlanes(1, 1000); + expect(resolve().density).toBeCloseTo(2 / 1000, 10); + camera.setFog(null); + }); + }); + + describe("an explicit colour", () => { + it("is held by reference when a Color, so mutating it animates the fog", () => { + const colour = new Color(255, 0, 0); + camera.setFog({ far: 100, color: colour }); + expect(resolve().color[0]).toBeCloseTo(1, 5); + colour.setColor(0, 0, 255); + expect(resolve().color[0]).toBeCloseTo(0, 5); + expect(resolve().color[2]).toBeCloseTo(1, 5); + camera.setFog(null); + }); + + it("is parsed and owned when a CSS string", () => { + camera.setFog({ far: 100, color: "#00ff00" }); + const state = resolve(); + expect(state.color[1]).toBeCloseTo(1, 5); + camera.setFog(null); + }); + + it("accepts [r, g, b] in 0..1, the glTF convention", () => { + camera.setFog({ far: 100, color: [0, 0, 1] }); + expect(resolve().color[2]).toBeCloseTo(1, 5); + camera.setFog(null); + }); + + it("wins over the background colour, and does not follow it", () => { + app.renderer.backgroundColor.parseCSS("#ffffff"); + camera.setFog({ far: 100, color: "#000000" }); + app.renderer.backgroundColor.parseCSS("#123456"); + const state = resolve(); + expect(state.color[0]).toBe(0); + expect(state.color[1]).toBe(0); + expect(state.color[2]).toBe(0); + camera.setFog(null); + }); + }); + + describe("bad input is refused at the call, not at the draw", () => { + it("rejects an unknown mode", () => { + expect(() => { + return camera.setFog({ mode: "exp" }); + }).toThrow(/unknown mode/); + }); + + it("rejects far at or below near", () => { + expect(() => { + return camera.setFog({ near: 100, far: 100 }); + }).toThrow(/far must be greater/); + expect(() => { + return camera.setFog({ near: 100, far: 50 }); + }).toThrow(/far must be greater/); + }); + + it("rejects a density at or below zero", () => { + expect(() => { + return camera.setFog({ density: 0 }); + }).toThrow(/density/); + expect(() => { + return camera.setFog({ density: -1 }); + }).toThrow(/density/); + }); + + it("rejects non-finite distances and a negative near", () => { + expect(() => { + return camera.setFog({ far: Number.NaN }); + }).toThrow(/finite/); + expect(() => { + return camera.setFog({ near: Number.POSITIVE_INFINITY }); + }).toThrow(/finite/); + expect(() => { + return camera.setFog({ near: -1 }); + }).toThrow(/negative/); + }); + + it("does not leave fog enabled after a rejected call", () => { + camera.setFog(null); + expect(() => { + return camera.setFog({ mode: "nope" }); + }).toThrow(); + expect(camera.fog).toBe(null); + }); + }); + + describe("a default that goes degenerate later", () => { + it("drops fog for that frame instead of dividing by zero", () => { + // legal at the time it was set; the clip planes collapse afterwards, + // which cannot throw retroactively from inside a draw + camera.setFog({}); + camera.setClipPlanes(5, 5.000001); + camera.near = 5; + camera.far = 5; + expect(resolve()).toBe(null); + camera.setClipPlanes(1, 1000); + expect(resolve()).not.toBe(null); + camera.setFog(null); + }); + }); + + describe("fog is per camera", () => { + it("resolves to nothing on a 2D camera", () => { + const flat = new Camera2d(0, 0, 320, 240); + expect(flat._fog3dState(app.renderer)).toBe(null); + }); + + it("is independent between two 3D cameras", () => { + const other = new Camera3d(0, 0, 320, 240); + camera.setFog({ near: 1, far: 100 }); + expect(other.fog).toBe(null); + expect(other._fog3dState(app.renderer)).toBe(null); + camera.setFog(null); + }); + }); + + describe("no per-frame allocation", () => { + it("rewrites one state object rather than making a new one", () => { + camera.setFog({ near: 1, far: 100 }); + const a = resolve(); + const b = resolve(); + expect(a).toBe(b); + expect(a.color).toBe(b.color); + camera.setFog(null); + }); + }); +}); diff --git a/packages/melonjs/tests/webgl_mesh_fog.spec.js b/packages/melonjs/tests/webgl_mesh_fog.spec.js new file mode 100644 index 000000000..ac809d5d7 --- /dev/null +++ b/packages/melonjs/tests/webgl_mesh_fog.spec.js @@ -0,0 +1,309 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { boot, Matrix3d, Mesh, TextureAtlas } from "../src/index.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, +} from "./helpers/webgl-context.js"; + +/** + * Distance fog, at the pixel (#1622). + * + * Fog is folded in by the mesh fragment shaders, and the one thing that is + * easy to get wrong there is the alpha. `vColor` is PREMULTIPLIED by the + * vertex stage, so the fog colour has to be scaled by the fragment's own + * coverage before it is mixed in. The naive `mix(fogColor, rgb, f)` writes + * full-strength fog onto near-transparent fragments — grey halos around every + * alpha-cutout leaf — and this spec pins the difference numerically. + * + * Fog is driven through `renderer.setFog` directly rather than through a + * camera: these draws never go through a camera's `draw()`, and the resolved + * state is exactly what a camera would have installed. + */ +describe("mesh distance fog (#1622)", () => { + const SIZE = 128; + let renderer; + + beforeAll(async () => { + await boot(); + try { + renderer = await getWebGLRenderer(SIZE, SIZE); + } catch { + // genuinely unavailable — every test below skips + } + }); + + afterAll(() => { + try { + renderer?.setFog(null); + releaseWebGLRenderer(); + } catch { + // ignore + } + }); + + const requireWebGL = (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + let _atlas = null; + const whiteAtlas = () => { + if (_atlas === null) { + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const c2d = canvas.getContext("2d"); + c2d.fillStyle = "#ffffff"; + c2d.fillRect(0, 0, 1, 1); + _atlas = new TextureAtlas( + { framewidth: 1, frameheight: 1, image: canvas }, + canvas, + ); + } + return _atlas; + }; + + /** + * A small quad centred on world (x, 0). Kept small on purpose: the fog + * distance is interpolated from the corners, so a tiny quad's centre + * pixel reads essentially the quad's own distance. + */ + const quad = (x = 0, half = 4) => { + const mesh = new Mesh(0, 0, { + vertices: [ + -half, + -half, + 0, + half, + -half, + 0, + half, + half, + 0, + -half, + half, + 0, + ], + uvs: [0, 0, 1, 0, 1, 1, 0, 1], + indices: [0, 1, 2, 0, 2, 3], + texture: whiteAtlas(), + // geometry normalizes to a unit box, so world size comes from here + width: half * 2, + height: half * 2, + cullBackFaces: false, + lit: false, + }); + // the world-space (Camera3d) branch, without needing a live stage + mesh._useWorldSpace = true; + mesh.pos.set(x, 0, 0); + // `preDraw` installs the renderable's OWN tint, so setting + // `renderer.currentTint` around the draw would be overwritten + mesh.tint.setColor(255, 0, 0); + return mesh; + }; + + /** world (0,0) at the canvas centre, so a quad's x offsets it on screen */ + const setup = () => { + const proj = new Matrix3d(); + proj.ortho(-SIZE / 2, SIZE / 2, SIZE / 2, -SIZE / 2, -10000, 10000); + renderer.setProjection(proj); + renderer.backgroundColor.setColor(0, 0, 0, 255); + renderer.clear(); + }; + + /** place a mesh `z` in front of the camera (the view stays identity) */ + const drawAt = (mesh, z) => { + mesh.depth = z; + mesh.preDraw(renderer); + mesh.draw(renderer); + mesh.postDraw(renderer); + }; + + const readPixel = (x = SIZE / 2, y = SIZE / 2) => { + const gl = renderer.gl; + const px = new Uint8Array(4); + gl.finish(); + gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + return px; + }; + + /** fog state in the shape a camera resolves */ + const fog = (over) => { + return { + mode: 1, + near: 0, + invRange: 1 / 1000, + density: 0, + color: new Float32Array([1, 1, 1]), + ...over, + }; + }; + + /** draw one red quad and read the centre pixel */ + const drawRed = (mesh, z, alpha = 1) => { + setup(); + renderer.save(); + mesh.setOpacity(alpha); + drawAt(mesh, z); + const px = readPixel(); + renderer.restore(); + return px; + }; + + describe("the off path is untouched", () => { + it("draws the mesh colour exactly when fog was never enabled", (ctx) => { + requireWebGL(ctx); + // The regression guard for the whole feature: with no fog the + // shader must be the one that shipped before fog existed, and the + // extra view-space product in the vertex stage must not perturb a + // single pixel. + renderer.setFog(null); + const px = drawRed(quad(), 500); + expect([px[0], px[1], px[2]]).toEqual([255, 0, 0]); + }); + }); + + describe("the curves", () => { + it("linear reaches the halfway mix at the halfway distance", (ctx) => { + requireWebGL(ctx); + // near 0, far 1000, quad at 500 → half the scene colour survives, + // mixed toward white: (255, 128, 128) + renderer.setFog(fog()); + const px = drawRed(quad(), 500); + renderer.setFog(null); + expect(px[0]).toBe(255); + expect(px[1]).toBeGreaterThan(120); + expect(px[1]).toBeLessThan(136); + expect(px[2]).toBeGreaterThan(120); + expect(px[2]).toBeLessThan(136); + }); + + it("linear saturates past far and is clear before near", (ctx) => { + requireWebGL(ctx); + renderer.setFog(fog({ near: 400, invRange: 1 / 600 })); + const beyond = drawRed(quad(), 1500); + const before = drawRed(quad(), 100); + renderer.setFog(null); + // past `far`: nothing of the mesh survives — pure fog colour + expect([beyond[0], beyond[1], beyond[2]]).toEqual([255, 255, 255]); + // before `near`: untouched + expect([before[0], before[1], before[2]]).toEqual([255, 0, 0]); + }); + + it("exp2 follows exp(-(density * d)^2), not exp(-density * d)", (ctx) => { + requireWebGL(ctx); + const density = 0.001; + const z = 1000; + renderer.setFog(fog({ mode: 2, density })); + const px = drawRed(quad(), z); + renderer.setFog(null); + // squared: exp(-1) ≈ 0.368 → green/blue ≈ (1 - 0.368) * 255 ≈ 161 + // single: exp(-1) is the same here BY CONSTRUCTION, so pick a + // distance where they differ instead + const survive = Math.exp(-((density * z) ** 2)); + expect(px[1]).toBeGreaterThan((1 - survive) * 255 - 6); + expect(px[1]).toBeLessThan((1 - survive) * 255 + 6); + }); + + it("exp2 is distinguishable from a single exponential", (ctx) => { + requireWebGL(ctx); + const density = 0.001; + const z = 400; // d*density = 0.4 → exp(-0.16)=0.852 vs exp(-0.4)=0.670 + renderer.setFog(fog({ mode: 2, density })); + const px = drawRed(quad(), z); + renderer.setFog(null); + const squared = (1 - Math.exp(-0.16)) * 255; // ≈ 37.7 + const single = (1 - Math.exp(-0.4)) * 255; // ≈ 84.2 + expect(Math.abs(px[1] - squared)).toBeLessThan(Math.abs(px[1] - single)); + }); + }); + + describe("the distance is radial", () => { + it("fogs an off-axis mesh more than one dead ahead at the same depth", (ctx) => { + requireWebGL(ctx); + // Same view-space z, different x. Under a view-space-z + // implementation these are identical; radially the off-axis one is + // further away and fogs harder. This is the "swimming fog" guard: + // planar depth makes fog slide as the camera turns. + // 40 across at 100 deep is 107.7 away, not 100 — a ~7 level + // difference here, where the same pair at 500 deep would round to + // the same byte and prove nothing + renderer.setFog(fog({ invRange: 1 / 250 })); + setup(); + renderer.save(); + drawAt(quad(0), 100); + drawAt(quad(40), 100); + const ahead = readPixel(SIZE / 2, SIZE / 2); + const offAxis = readPixel(SIZE / 2 + 40, SIZE / 2); + renderer.restore(); + renderer.setFog(null); + expect(offAxis[1]).toBeGreaterThan(ahead[1] + 2); + }); + }); + + describe("premultiplied alpha", () => { + it("scales the fog colour by coverage instead of painting over it", (ctx) => { + requireWebGL(ctx); + // Fully fogged, at half alpha. The colour written is premultiplied, + // so the correct result is fogColour × alpha = 128, not 255. + // + // The naive `mix(uFogColor, rgb, f)` writes 255 here — which on an + // alpha-cutout mesh is the grey halo around every cut edge. The + // upper bound below is what excludes it. + renderer.setFog(fog({ near: 0, invRange: 1 / 100 })); + const px = drawRed(quad(), 500, 0.5); + renderer.setFog(null); + // Only the colour channels are read: the drawing buffer reports an + // opaque alpha on readback whatever the fragment wrote, so px[3] + // would prove nothing here. + for (const channel of [0, 1, 2]) { + expect(px[channel]).toBeGreaterThan(120); + expect(px[channel]).toBeLessThan(136); + } + }); + }); + + describe("the per-mesh opt-out", () => { + it("leaves a mesh with fog === false untouched in saturated fog", (ctx) => { + requireWebGL(ctx); + renderer.setFog(fog({ near: 0, invRange: 1 / 100 })); + const exempt = quad(); + exempt.fog = false; + const px = drawRed(exempt, 500); + renderer.setFog(null); + expect([px[0], px[1], px[2]]).toEqual([255, 0, 0]); + }); + + it("fogs a mesh with fog === true, and one that never set it", (ctx) => { + requireWebGL(ctx); + renderer.setFog(fog({ near: 0, invRange: 1 / 100 })); + const explicit = quad(); + explicit.fog = true; + const withTrue = drawRed(explicit, 500); + const withNothing = drawRed(quad(), 500); + renderer.setFog(null); + expect([withTrue[0], withTrue[1], withTrue[2]]).toEqual([255, 255, 255]); + expect([withNothing[0], withNothing[1], withNothing[2]]).toEqual([ + 255, 255, 255, + ]); + }); + }); + + describe("the lit tier agrees with the unlit one", () => { + it("fogs a lit mesh to the same place as its unlit twin", (ctx) => { + requireWebGL(ctx); + renderer.setFog(fog()); + const unlit = drawRed(quad(), 500); + const litMesh = quad(); + litMesh.lit = true; + // no normals and no lights: the lit shader takes its degenerate + // early return, which is the exit most easily left unfogged + const lit = drawRed(litMesh, 500); + renderer.setFog(null); + for (const channel of [1, 2]) { + expect(Math.abs(lit[channel] - unlit[channel])).toBeLessThan(10); + } + }); + }); +}); diff --git a/packages/melonjs/tests/webgpu_mesh_batcher.spec.js b/packages/melonjs/tests/webgpu_mesh_batcher.spec.js index 272446380..96273c368 100644 --- a/packages/melonjs/tests/webgpu_mesh_batcher.spec.js +++ b/packages/melonjs/tests/webgpu_mesh_batcher.spec.js @@ -59,9 +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:u208")).toBe(true); - // 176 before #1575 — grown by the specular vec4 and the eye position - expect(MESH_UNIFORM_SIZE).toBe(208); + expect(renderer.pipelineCache.effectLayouts.has("mesh:u240")).toBe(true); + // 176 before #1575, 208 before fog — grown by the specular vec4 and the + // eye position, then by the fog colour and fog params + expect(MESH_UNIFORM_SIZE).toBe(240); }); 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 new file mode 100644 index 000000000..422e8c378 --- /dev/null +++ b/packages/melonjs/tests/webgpu_mesh_fog.spec.js @@ -0,0 +1,168 @@ +import "./helpers/webgpu-globals.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import { Matrix3d } from "../src/index.js"; +import WebGPUMeshBatcher, { + MESH_UNIFORM_SIZE, +} from "../src/video/webgpu/batchers/mesh_batcher.js"; +import meshWGSL from "../src/video/webgpu/shaders/mesh.wgsl"; +import { + buildInstancedMeshWGSL, + LIT_INSTANCED, + UNLIT_INSTANCED, +} from "../src/video/webgpu/shaders/mesh-instanced.js"; +import meshLitWGSL from "../src/video/webgpu/shaders/mesh-lit.wgsl"; +import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js"; + +/** + * Distance fog on the WebGPU backend (#1622). + * + * WebGPU carries fog in the per-draw `MeshUniforms` snapshot rather than as + * loose uniforms, so what is testable here is the byte layout: fog has to land + * at the floats the WGSL struct declares, and it has to be ZERO when no fog is + * installed — zero is mode 0, which is what makes a scene without fog identical + * to one built before fog existed. + */ +const MODEL = new Matrix3d(); + +function makeMesh(overrides = {}) { + const quad = new Float32Array([ + -0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0, + ]); + return { + // the retained path reads model-space geometry, not the projected copy + originalVertices: quad, + vertices: quad, + indices: new Uint16Array([0, 1, 2, 0, 2, 3]), + _indicesOriginal: new Uint16Array([0, 1, 2, 0, 2, 3]), + _geometryVersion: 0, + uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), + vertexCount: 4, + texture: { id: "atlas" }, + textureRepeat: undefined, + vertexColors: undefined, + alphaCutoff: 0, + emissive: undefined, + specular: undefined, + shininess: 0, + alphaMap: undefined, + lit: false, + cullBackFaces: true, + rightHanded: false, + textureGroups: undefined, + ...overrides, + }; +} + +describe("WebGPU mesh distance fog (#1622)", () => { + let renderer; + let batcher; + + beforeEach(() => { + renderer = createMockWebGPURenderer(); + batcher = new WebGPUMeshBatcher(renderer); + }); + + const snapshot = () => { + const write = renderer.calls.writes.find((w) => { + return w.size === MESH_UNIFORM_SIZE; + }); + expect(write).toBeDefined(); + return write.floats; + }; + + // the mock renderer is a stub without the base `Renderer` methods, so the + // field the batcher reads is set directly — `setFog` only assigns it + const install = (state) => { + renderer._fog3d = state; + }; + + const fog = (over) => { + return { + mode: 1, + near: 12, + invRange: 0.25, + density: 0, + color: new Float32Array([0.25, 0.5, 0.75]), + ...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("writes the fog colour at 52-54 and the params at 56-59", () => { + install(fog()); + batcher.drawRetainedMesh(makeMesh(), MODEL, 0xffffffff); + const floats = snapshot(); + // model 0-15, view 16-31, tint 32-35, params 36-39, emissive 40-43, + // specular 44-47, eye 48-51 + expect(Array.from(floats.slice(52, 55))).toEqual([0.25, 0.5, 0.75]); + expect(Array.from(floats.slice(56, 60))).toEqual([1, 12, 0.25, 0]); + }); + + it("leaves every fog float at zero when no fog is installed", () => { + install(null); + batcher.drawRetainedMesh(makeMesh(), MODEL, 0xffffffff); + const floats = snapshot(); + // zero is mode 0 — the shader returns the colour untouched + expect(Array.from(floats.slice(52, 60))).toEqual([ + 0, 0, 0, 0, 0, 0, 0, 0, + ]); + }); + + it("does not let fog overlap the eye position", () => { + // one float of drift and the specular highlight follows the fog + install(fog({ mode: 2, density: 0.5 })); + batcher.drawRetainedMesh(makeMesh(), MODEL, 0xffffffff); + const floats = snapshot(); + expect(floats[51]).toBe(0); + expect(floats[56]).toBe(2); + expect(floats[59]).toBe(0.5); + }); + }); + + describe("the per-mesh opt-out", () => { + it("zeroes the mode for a mesh with fog === false", () => { + install(fog()); + batcher.drawRetainedMesh(makeMesh({ fog: false }), MODEL, 0xffffffff); + const floats = snapshot(); + expect(floats[56]).toBe(0); + }); + + it("fogs a mesh with fog === true and one that never set it", () => { + install(fog()); + batcher.drawRetainedMesh(makeMesh({ fog: true }), MODEL, 0xffffffff); + expect(snapshot()[56]).toBe(1); + }); + }); + + describe("the derived instanced modules", () => { + it("writes the fog varying in both tiers", () => { + for (const [source, tier] of [ + [meshWGSL, UNLIT_INSTANCED], + [meshLitWGSL, LIT_INSTANCED], + ]) { + const module = buildInstancedMeshWGSL(source, { + ...tier, + hasColor: false, + hasData: false, + }); + // the builder's own guards would throw on a varying that VSOut + // declares and the body never writes; this pins the pairing + expect(module).toContain("out.vFogDepth"); + } + }); + + it("keeps the instance slot clear of the fog varying's location", () => { + // vFogDepth took the location the instance slot used to sit at, so + // these must have moved — the builder throws on a duplicate, which + // makes this a guard against a silent zeroed-fog build + expect(UNLIT_INSTANCED.varyingLocation).toBe(3); + expect(LIT_INSTANCED.varyingLocation).toBe(5); + }); + }); +}); diff --git a/packages/melonjs/tests/webgpu_mtl_material.spec.js b/packages/melonjs/tests/webgpu_mtl_material.spec.js index 62fb1c306..6bef05c67 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 208 bytes: specular at float 44, eye at 48", () => { - expect(MESH_UNIFORM_SIZE).toBe(208); + it("grew to 240 bytes: specular at float 44, eye at 48, fog at 52", () => { + expect(MESH_UNIFORM_SIZE).toBe(240); const mesh = makeMesh({ specular: new Float32Array([0.25, 0.5, 0.75]), shininess: 64, From de3909916eb1744b2f9a7d2ef51ef5790db5f875 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 1 Sep 2026 06:16:35 +0800 Subject: [PATCH 02/14] Fog docs: worked examples in the JSDoc, and routing to the new section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `setFog` JSDoc listed calls without context. It now leads with the shape a game actually uses — set the sky, size the frustum, then let fog take its distances and colour from both — followed by the exp2 form, an animated explicit colour held by reference, and the argument-free form. `Mesh#fog` gains one too: enabling fog on the world while a beacon opts out is the case the property exists for, and it reads better as six lines than as a sentence. Routing, so the section is findable from where people start: the root skill's 3D row and the glTF/assets skill both name distance fog now (an outdoor imported scene almost always wants it, and it needs no per-node work because it lives on the camera), and the docs landing page lists it among the 3D features. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- DOC_README.md | 2 +- .../melonjs/skills/melonjs-3d-assets/SKILL.md | 4 ++- packages/melonjs/skills/melonjs/SKILL.md | 2 +- packages/melonjs/src/camera/camera3d.ts | 34 +++++++++++++++---- packages/melonjs/src/renderable/mesh.js | 11 ++++++ 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/DOC_README.md b/DOC_README.md index 88c996985..43d912c62 100644 --- a/DOC_README.md +++ b/DOC_README.md @@ -46,7 +46,7 @@ loader.preload([{ name: "player", type: "image", src: "player.png" }], () => { | Feature | Description | |---------|-------------| | **Rendering** | WebGPU, WebGL 2 and Canvas 2D with automatic fallback — the same feature set on every backend | -| **3D** | Perspective [Camera3d](classes/Camera3d.html), mesh instancing, ground shadows, point and spot lights, glTF/GLB and OBJ/MTL loading | +| **3D** | Perspective [Camera3d](classes/Camera3d.html), mesh instancing, ground shadows, distance fog, point and spot lights, glTF/GLB and OBJ/MTL loading | | **Tiled Maps** | First-class [Tiled](https://www.mapeditor.org/) map editor support (TMX/JSON), with GPU-accelerated tile rendering for orthogonal maps | | **Sprites** | Texture atlas, animation, TexturePacker & Aseprite support | | **Physics** | Built-in SAT collision with gravity and friction, shape-level collision events, and a [PhysicsAdapter](interfaces/PhysicsAdapter.html) interface for Box2D (planck) or Matter.js | diff --git a/packages/melonjs/skills/melonjs-3d-assets/SKILL.md b/packages/melonjs/skills/melonjs-3d-assets/SKILL.md index 3e3a46bd5..f4b4c8880 100644 --- a/packages/melonjs/skills/melonjs-3d-assets/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d-assets/SKILL.md @@ -240,7 +240,9 @@ need a prefix. ## Related skills -- `melonjs-3d` — conventions, `Camera3d`, meshes, `Light3d`, instancing +- `melonjs-3d` — conventions, `Camera3d`, meshes, `Light3d`, instancing, and + `camera.setFog` (an outdoor scene almost always wants it; it is set on the + camera, so a loaded scene needs no per-node work) - `melonjs-lighting` — how `Light3d` behaves once imported - `melonjs-loading-assets` — the loader, asset types and base URLs - `melonjs-plugins` — the Spine plugin, for skeletal characters diff --git a/packages/melonjs/skills/melonjs/SKILL.md b/packages/melonjs/skills/melonjs/SKILL.md index 50e718dbc..16a12bf34 100644 --- a/packages/melonjs/skills/melonjs/SKILL.md +++ b/packages/melonjs/skills/melonjs/SKILL.md @@ -39,7 +39,7 @@ dependencies. | [melonjs-tilemaps](../melonjs-tilemaps/SKILL.md) | Tiled maps — TMX/TSX loading, spawning entities from objects, collision layers, isometric maps. | | [melonjs-audio](../melonjs-audio/SKILL.md) | Sound effects, music, audio sprites, spatial audio, procedural tone and noise. | | [melonjs-effects-and-shaders](../melonjs-effects-and-shaders/SKILL.md) | Post effects, custom GLSL/WGSL shaders, blend modes, colour grading, screen capture. | -| [melonjs-3d](../melonjs-3d/SKILL.md) | Anything 3D or 2.5D — `Camera3d`, meshes, instancing, `Sprite3d` billboards, `Light3d`, glTF scenes. | +| [melonjs-3d](../melonjs-3d/SKILL.md) | Anything 3D or 2.5D — `Camera3d`, meshes, instancing, `Sprite3d` billboards, `Light3d`, distance fog, glTF scenes. | | [melonjs-3d-assets](../melonjs-3d-assets/SKILL.md) | Loading glTF/GLB or OBJ models — materials, imported lights, instancing, and what is not supported. | | [melonjs-camera-and-drawing](../melonjs-camera-and-drawing/SKILL.md) | Camera follow, bounds, shake and fade, coordinate conversion, and immediate-mode shape drawing. | | [melonjs-ui-and-text](../melonjs-ui-and-text/SKILL.md) | HUDs, buttons, menus, drag-and-drop, `Text` and `BitmapText`, web fonts, panels. | diff --git a/packages/melonjs/src/camera/camera3d.ts b/packages/melonjs/src/camera/camera3d.ts index 8cf8b25c7..0f87a70d3 100644 --- a/packages/melonjs/src/camera/camera3d.ts +++ b/packages/melonjs/src/camera/camera3d.ts @@ -308,13 +308,35 @@ export default class Camera3d extends Camera2d { * @throws {Error} on an unknown `mode`, a non-finite or negative distance, * `far` at or below `near`, or a density at or below zero * @example - * // dissolve into whatever backdrop the renderer is already clearing to - * camera.setFog({ near: 2000, far: 7000 }); - * // a single density instead of two distances + * // A typical outdoor scene: set the sky, size the frustum to the level, + * // then let fog take its distances and its colour from both. + * class GameStage extends Stage { + * onResetEvent(app) { + * app.renderer.backgroundColor.parseCSS("#cfe6f7"); + * + * const camera = app.viewport; // a Camera3d + * camera.setClipPlanes(1, 9000); + * // no colour passed: it tracks `backgroundColor`, so the terrain + * // dissolves into the sky and props arrive without a hard edge + * camera.setFog({ near: 1200, far: 7000 }); + * } + * } + * @example + * // A single density instead of two distances. Omit it and it resolves to + * // `2 / far`, which reads the same at any world scale. * camera.setFog({ mode: "exp2", density: 0.0004 }); - * // fog that is deliberately not the sky colour - * camera.setFog({ far: 5000, color: "#8899aa" }); - * camera.setFog(null); // off + * @example + * // Fog that is deliberately NOT the sky — a green murk under a blue sky. + * // Passing a `Color` keeps it by reference, so this fog can be animated + * // by mutating the colour, without calling `setFog` again. + * const murk = new Color(90, 120, 80); + * camera.setFog({ far: 5000, color: murk }); + * murk.setColor(60, 90, 55); // thickens over the next frame + * @example + * // Everything is optional: with nothing at all, fog spans the camera's + * // own clip planes in the backdrop's colour. + * camera.setFog({}); + * camera.setFog(null); // and off again * @see Camera3d#setClipPlanes * @see Mesh#fog */ diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index 4ed218074..fc2d9afa8 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -634,6 +634,17 @@ export default class Mesh extends Renderable { * @type {boolean|undefined} * @default undefined * @see Camera3d#setFog + * @example + * // the world fogs; this waypoint stays readable at any distance + * camera.setFog({ near: 1200, far: 7000 }); + * + * const marker = new Mesh(0, 0, { + * ...beaconGeometry, + * emissive: [1, 0.6, 0], + * fog: false, + * }); + * // or afterwards, on anything already built + * marker.fog = false; */ this.fog = typeof settings.fog === "boolean" ? settings.fog : undefined; From bc0d4c41ff4e441a0217f1e73e512b04d43f86b1 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 1 Sep 2026 06:36:34 +0800 Subject: [PATCH 03/14] Skills: document when InstancedMesh is the wrong tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three skills that mentioned `InstancedMesh` carried the same one-liner — one draw call, a hundred trees versus a hundred thousand — and none of them answered the question a reader actually has, which is whether to use it INSTEAD of a `Mesh`, and what that costs. Four things move from per-object to per-group, and only the first is widely known: the set gets one depth sort key, one instanced ground shadow rather than a blob each, per-instance colour becomes opt-in, and `removeInstance` swaps the last instance into the hole — so any index the caller was holding silently points at a different object afterwards. That last one is the deciding factor more often than the count is. The useful question is not "how many are there" but "does the game address them individually": scenery instances cleanly, collision-tested props do too (the positions are yours either way), and anything removed one at a time — collectibles, enemies — usually costs more bookkeeping than the draw call saves. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/skills/melonjs-3d/SKILL.md | 43 ++++++++++++++++++- .../skills/melonjs-performance/SKILL.md | 5 +++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index 291105318..e2afb3229 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -170,9 +170,50 @@ there — place the origin where you want the pivot at authoring time, or nest t mesh under a transformed parent. The anchor is only honoured on the legacy 2D-camera path. +### InstancedMesh, and when it is the wrong tool + **`InstancedMesh`** draws one mesh many times in a single draw call — the difference between a hundred trees and a hundred thousand. glTF scenes using -`EXT_mesh_gpu_instancing` load as an `InstancedMesh` automatically. +`EXT_mesh_gpu_instancing` load as one automatically; by hand it is a `Mesh` +with a count: + +```js +const trees = new InstancedMesh(0, 0, { ...treeGeometry, instanceCount: 400 }); +const at = new Matrix3d(); // one scratch, reused +for (let i = 0; i < trees.instanceCount; i++) { + at.identity().translate(x, y, z); + trees.setInstance(i, at); +} +world.addChild(trees, 0); + +trees.visibleInstanceCount = 120; // draw fewer, without re-uploading +``` + +It is not a free upgrade. One `InstancedMesh` is **one geometry and one +material**, and four things move from per-object to per-group: + +| | with `Mesh` | with `InstancedMesh` | +| --- | --- | --- | +| depth sort | each object sorts on its own `pos` | the whole set has **one** sort key | +| ground shadow | one blob per object | one instanced draw for the set | +| removal | `removeChild`, indices unaffected | `removeInstance(i)` swaps the **last** instance into the hole, so any index you were holding is now wrong | +| colour | `tint` per object | needs `instanceColors: true` and `setInstanceColor(i, …)` | + +So the question is not "how many are there" but **"does the game address them +individually"**: + +- **Scenery — instance it.** Trees, rocks, grass, debris: the game never asks + about one of them. +- **Collision-tested props — still fine.** You test against positions you + already own; instancing only changes how they are *drawn*. +- **Collectibles and enemies — usually not.** Anything removed one at a time + makes `removeInstance`'s swap your problem: you have to keep an index↔object + map and repair it on every removal. At small counts a pooled `Mesh` each is + less code and no slower. + +Under a few hundred objects the draw-call saving is not what limits you +anyway — reach for it when the count is in the thousands, or when the objects +are pure scenery and it costs nothing to. ## Normals are generated for you diff --git a/packages/melonjs/skills/melonjs-performance/SKILL.md b/packages/melonjs/skills/melonjs-performance/SKILL.md index 5eda7d756..6625c69df 100644 --- a/packages/melonjs/skills/melonjs-performance/SKILL.md +++ b/packages/melonjs/skills/melonjs-performance/SKILL.md @@ -126,6 +126,11 @@ collapse to one. between a hundred trees and a hundred thousand. glTF scenes using `EXT_mesh_gpu_instancing` load as an `InstancedMesh` automatically. +It trades per-object control for the draw call: the set gets ONE depth sort key +and ONE ground shadow, and `removeInstance` swaps the last instance into the +hole so held indices go stale. Scenery yes; anything the game removes or +queries one at a time, usually not. See `melonjs-3d` for the decision table. + ## Update loops - `update(dt)` should **return `true` only when something changed**. Returning From c89fc602b028ff3a20480556311de0fb6ebe0671 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 1 Sep 2026 06:37:57 +0800 Subject: [PATCH 04/14] InstancedMesh: document what it costs, not just what it saves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The class JSDoc explained what an instanced mesh IS and what the instance buffer adds, but never the question a reader actually arrives with: when a plain `Mesh` each is the better answer. Four things move from per-object to per-group — a single depth sort key, one instanced ground shadow instead of a blob each, `removeInstance` swapping the last instance into the hole, and per-instance colour needing `instanceColors` declared up front. `removeInstance` already documented its own index instability; the class doc never connected that to the choice it forces. The deciding question is not the count but whether the game addresses the objects individually: scenery instances cleanly, collision-tested props do too since the positions are yours either way, and anything removed one at a time usually costs more bookkeeping than the draw call saves. Matches the guidance added to the melonjs-3d skill. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- .../melonjs/src/renderable/instanced_mesh.js | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/melonjs/src/renderable/instanced_mesh.js b/packages/melonjs/src/renderable/instanced_mesh.js index 0309d4f79..f4c9419c0 100644 --- a/packages/melonjs/src/renderable/instanced_mesh.js +++ b/packages/melonjs/src/renderable/instanced_mesh.js @@ -50,6 +50,32 @@ const _dirtySpan = [0, 0]; * Requires a GPU backend (`renderer.supportsInstancing`). Under the Canvas * renderer the instances are drawn one at a time through the ordinary CPU * mesh path — correct, but without any of the benefit. + * + * ### When a plain {@link Mesh} each is the better answer + * + * One `InstancedMesh` is **one geometry and one material**, and four things + * move from per-object to per-group: + * + * - **Depth sorting.** The set has a single sort key — its own `pos` — so it + * orders against the rest of the scene as one object. Opaque instances still + * resolve against each other per pixel through the depth buffer; blended + * ones cannot be sorted among themselves at all. + * - **Ground shadows.** {@link Mesh#castGroundShadow} becomes one instanced + * shadow draw covering the whole set, not a blob computed per object. + * - **Removal.** {@link InstancedMesh#removeInstance} swaps the last instance + * into the hole, so any index a caller was holding now refers to a different + * object. + * - **Colour.** {@link Mesh#tint} applies to the set; per-instance colour has + * to be declared up front with `instanceColors` and set through + * {@link InstancedMesh#setInstanceColor}. + * + * So the question is not how many there are, but whether the game addresses + * them **individually**. Scenery — trees, rocks, grass, debris — instances + * cleanly, because nothing ever asks about one of them. Collision-tested props + * are fine too: the positions are yours either way and instancing only changes + * how they are drawn. Collectibles and enemies usually are not, because + * removing them one at a time makes the index swap the caller's problem, and + * at small counts a pooled `Mesh` each is less code and no slower. * @augments Mesh * @category Rendering * @example From 80bacee48f7101a27d2e60e58e7ed664fefbaab4 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 1 Sep 2026 06:47:51 +0800 Subject: [PATCH 05/14] Fix: the fog variant swap was clobbering custom mesh shaders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `drawRetainedMesh` picked its program per draw, because fog is a compiled variant. It did so unconditionally — and `WebGLRenderer.drawMesh` binds a renderable's custom shader immediately BEFORE calling in here. The re-bind threw that away: `setPlacementUniforms` then read the built-in program and the draw ran built-in shading. Nothing threw, the `finally` restored the default, and the mesh simply rendered wrong. It broke every `Mesh` carrying a `ShaderEffect` under a `Camera3d` on WebGL — the single-effect `customShader` fast path — and it did so with fog DISABLED, so "additive when unused" was not true after all. The instanced path was unaffected; it already warns and falls back. The swap now only ever replaces the batcher's own program. A custom mesh shader belongs to the author and has no fog variant to switch to. Also: the `uFogParams` guard tested `!== undefined`, but `extractUniforms` regexes the raw shader text without running the preprocessor, so the names inside the `#ifdef FOG` block are registered even in the program compiled WITHOUT fog — with a null location. The guard never skipped, and every unfogged mesh draw ran the fog block and wrote to a null location after each program swap. `!= null` is the fix, and it makes the "a scene without fog pays nothing" claim true on the CPU as well as the GPU. Two tests, both mutation-checked: a foreign program survives a retained draw, and the batcher's own program still swaps to the fog variant and back. Found by adversarial review, confirmed there by pixel probe against master before I touched it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- .../src/video/webgl/batchers/mesh_batcher.js | 31 ++++++++--- packages/melonjs/tests/webgl_mesh_fog.spec.js | 52 +++++++++++++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js index 81e4f3db8..efc231287 100644 --- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js @@ -539,9 +539,21 @@ export default class MeshBatcher extends MaterialBatcher { // anything the caller had queued must land first, or this draw would // reorder ahead of it this.flush(); - // fog is a compiled variant, so the program depends on the camera's - // fog state rather than only on the batcher - this.useShader(this.meshShader()); + // Fog is a compiled variant, so the program depends on the camera's fog + // state and not only on the batcher. + // + // Only ever swap the batcher's OWN program. `WebGLRenderer.drawMesh` + // binds a renderable's custom shader immediately before calling in + // here, and re-binding unconditionally threw that away silently: the + // mesh drew with built-in shading, no error, in every scene — fog + // enabled or not. A custom mesh shader is the author's, and it has no + // fog variant to switch to. + if ( + this.currentShader === this.defaultShader || + this.currentShader === this.fogShader + ) { + this.useShader(this.meshShader()); + } // Strictly BEFORE the blended-draw toggle below: `updatePassState` // runs the one-shot depth clear, and `gl.clear(DEPTH_BUFFER_BIT)` @@ -1239,10 +1251,15 @@ export default class MeshBatcher extends MaterialBatcher { shader.setUniform("uTint", _TINT_RGBA); this.currentTintValue = tint; } - if (uniforms.uFogParams !== undefined) { + // `!= null`, not `!== undefined`: `extractUniforms` scans the raw shader + // text without running the preprocessor, so the names inside the + // `#ifdef FOG` block are registered even in the program compiled + // WITHOUT fog — with a null GL location. Testing for undefined let the + // whole block run, and issue two writes to a null location, on every + // unfogged mesh draw. + if (uniforms.uFogParams != null) { // `mesh.fog === false` exempts this object; anything else follows - // the camera. A custom shader that declares neither uniform is - // skipped entirely by the guard above. + // the camera. const fog = mesh?.fog === false ? null : this.renderer._fog3d; const mode = fog !== null && fog !== undefined ? fog.mode : 0; const near = mode !== 0 ? fog.near : 0; @@ -1268,7 +1285,7 @@ export default class MeshBatcher extends MaterialBatcher { this.currentFogDensity = density; } if ( - uniforms.uFogColor !== undefined && + uniforms.uFogColor != null && (r !== this.currentFogR || g !== this.currentFogG || b !== this.currentFogB) diff --git a/packages/melonjs/tests/webgl_mesh_fog.spec.js b/packages/melonjs/tests/webgl_mesh_fog.spec.js index ac809d5d7..0169a7a40 100644 --- a/packages/melonjs/tests/webgl_mesh_fog.spec.js +++ b/packages/melonjs/tests/webgl_mesh_fog.spec.js @@ -164,6 +164,58 @@ describe("mesh distance fog (#1622)", () => { }); }); + describe("a custom mesh shader survives the fog variant swap", () => { + it("does not swap away from a program the batcher does not own", (ctx) => { + requireWebGL(ctx); + // Fog is a COMPILED VARIANT, so the retained draw picks its program + // per draw. That swap must only ever replace the batcher's OWN + // program: `WebGLRenderer.drawMesh` binds a renderable's custom + // shader immediately before calling in here, and re-binding + // unconditionally threw it away — the mesh drew with built-in + // shading, silently, in every scene whether or not fog was on. + // + // Asserted on the bound program rather than on pixels: what broke + // was which program the draw ran, and `drawMesh` restores the + // default afterwards, so a pixel read cannot see it. + renderer.setFog(null); + setup(); + const batcher = renderer.setBatcher("mesh"); + const own = batcher.defaultShader; + // stand in for a renderable's hosted shader: any GLShader that is + // not one of the batcher's own + const foreign = renderer.setBatcher("quad").defaultShader; + renderer.setBatcher("mesh"); + batcher.useShader(foreign); + expect(batcher.currentShader).toBe(foreign); + + const mesh = quad(); + mesh.pos.set(0, 0, 0); + mesh.depth = 500; + batcher.drawRetainedMesh(mesh, mesh._composeModelMatrix(), 0xffffffff); + + expect(batcher.currentShader).toBe(foreign); + batcher.useShader(own); + }); + + it("still swaps its own program when fog turns on", (ctx) => { + requireWebGL(ctx); + setup(); + const batcher = renderer.setBatcher("mesh"); + batcher.useShader(batcher.defaultShader); + renderer.setFog(fog()); + const mesh = quad(); + mesh.depth = 500; + batcher.drawRetainedMesh(mesh, mesh._composeModelMatrix(), 0xffffffff); + const fogged = batcher.currentShader; + expect(fogged).not.toBe(batcher.defaultShader); + expect(fogged).toBe(batcher.fogShader); + + renderer.setFog(null); + batcher.drawRetainedMesh(mesh, mesh._composeModelMatrix(), 0xffffffff); + expect(batcher.currentShader).toBe(batcher.defaultShader); + }); + }); + describe("the curves", () => { it("linear reaches the halfway mix at the halfway distance", (ctx) => { requireWebGL(ctx); From 7b6c3fa3db020faa2427c058e0fe05ac0bcbc164 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 1 Sep 2026 06:55:50 +0800 Subject: [PATCH 06/14] Camera3d: settle the fog scalars at setFog, keep only the Color live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `setFog` retained the caller's options object and read four of its five fields back every frame. So mutating `mode`, `near`, `far` or `density` afterwards changed the fog AND bypassed every check `setFog` performs — `options.mode = "banana"` was silently accepted where `setFog({ mode: "banana" })` throws — while mutating `color` did nothing, because that branch was decided once into an owned Color. Four fields live, one not, and neither documented. Worse, the JSDoc actively taught the wrong half: "a `Color` is kept by reference, so mutating it animates the fog" trains a caller to treat the whole object as live. The scalars are now copied at the call, so the documented model is the real one: a `Color` is live, everything else is settled. The `fog` getter returns a fresh object rather than the retained one, so it cannot look mutable while changing nothing. Three tests, including the mutation-after-the-fact case that used to slip past validation. Found by adversarial review, which confirmed all four behaviours by probe. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/src/camera/camera3d.ts | 52 ++++++++++++++++++--- packages/melonjs/tests/camera3d_fog.spec.js | 36 ++++++++++++++ 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/packages/melonjs/src/camera/camera3d.ts b/packages/melonjs/src/camera/camera3d.ts index 0f87a70d3..a2bfb8ad0 100644 --- a/packages/melonjs/src/camera/camera3d.ts +++ b/packages/melonjs/src/camera/camera3d.ts @@ -7,7 +7,7 @@ import type Container from "./../renderable/container.js"; import type Renderable from "./../renderable/renderable.js"; import type Renderer from "./../video/renderer.js"; import Camera2d from "./camera2d.ts"; -import type { Fog3dState, FogOptions } from "./fog.ts"; +import type { Fog3dState, FogMode, FogOptions } from "./fog.ts"; import Frustum, { type FrustumOptions } from "./frustum.ts"; export type { Fog3dState, FogMode, FogOptions } from "./fog.ts"; @@ -114,6 +114,22 @@ export default class Camera3d extends Camera2d { */ private _fogOptions: FogOptions | null = null; + /** + * The scalars, COPIED at `setFog` time rather than read back out of the + * caller's object. + * + * Retaining the object made four of the five fields live and the fifth + * not: mutating `mode` or `far` afterwards changed the fog and bypassed + * every check `setFog` performs, while mutating `color` did nothing. The + * documented model — a `Color` is live, everything else is settled at the + * call — is now the real one. + * @ignore + */ + private _fogMode: FogMode = "linear"; + /** @ignore */ private _fogNear: number | undefined = undefined; + /** @ignore */ private _fogFar: number | undefined = undefined; + /** @ignore */ private _fogDensity: number | undefined = undefined; + /** * Owned colour, used only when the caller passed a CSS string or an array. * A caller-supplied `Color` is referenced rather than copied, and the @@ -381,6 +397,10 @@ export default class Camera3d extends Camera2d { } this._fogOptions = options; + this._fogMode = mode; + this._fogNear = options.near; + this._fogFar = options.far; + this._fogDensity = options.density; // 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) { @@ -405,7 +425,27 @@ export default class Camera3d extends Camera2d { * per frame against the clip planes and the renderer's background colour. */ get fog(): FogOptions | null { - return this._fogOptions; + if (this._fogOptions === null) { + return null; + } + // A fresh object, not the one that was passed in: the scalars are + // copied at `setFog` time, so handing back a live handle would look + // mutable while changing nothing. Call `setFog` again to change them. + const out: FogOptions = { mode: this._fogMode }; + if (this._fogNear !== undefined) { + out.near = this._fogNear; + } + if (this._fogFar !== undefined) { + out.far = this._fogFar; + } + if (this._fogDensity !== undefined) { + out.density = this._fogDensity; + } + const colour = this._fogOwnColor ?? this._fogOptions.color; + if (colour !== undefined) { + out.color = colour; + } + return out; } /** @@ -423,10 +463,10 @@ export default class Camera3d extends Camera2d { } const state = this._fogState; - const far = options.far ?? this.far; + const far = this._fogFar ?? this.far; - if ((options.mode ?? "linear") === "exp2") { - const density = options.density ?? (far > 0 ? 2 / far : 0); + if (this._fogMode === "exp2") { + const density = this._fogDensity ?? (far > 0 ? 2 / far : 0); if (!(density > 0) || !Number.isFinite(density)) { return null; } @@ -435,7 +475,7 @@ export default class Camera3d extends Camera2d { state.near = 0; state.invRange = 0; } else { - const near = options.near ?? this.near; + const near = this._fogNear ?? this.near; // a range that collapsed after a later setClipPlanes call: drop fog // for this frame rather than dividing by zero into the shader if (!(far > near) || !Number.isFinite(near) || !Number.isFinite(far)) { diff --git a/packages/melonjs/tests/camera3d_fog.spec.js b/packages/melonjs/tests/camera3d_fog.spec.js index db40ceaf4..2ddd821cb 100644 --- a/packages/melonjs/tests/camera3d_fog.spec.js +++ b/packages/melonjs/tests/camera3d_fog.spec.js @@ -140,6 +140,42 @@ describe("Camera3d distance fog", () => { }); }); + describe("the options object is not retained", () => { + it("settles the scalars at the call, so a later mutation cannot bypass validation", () => { + // Retaining the caller's object made `mode`, `near`, `far` and + // `density` live — mutating them after the fact changed the fog AND + // skipped every check `setFog` performs, while mutating `color` did + // nothing at all. Four fields live, one not, and neither documented. + const options = { near: 100, far: 200 }; + camera.setFog(options); + const before = { ...resolve() }; + options.mode = "exp2"; + options.far = 999999; + options.near = -5; + const after = resolve(); + expect(after.mode).toBe(before.mode); + expect(after.near).toBe(before.near); + expect(after.invRange).toBe(before.invRange); + camera.setFog(null); + }); + + it("hands back a copy, not a live handle", () => { + const options = { near: 10, far: 400 }; + camera.setFog(options); + expect(camera.fog).not.toBe(options); + expect(camera.fog?.far).toBe(400); + camera.setFog(null); + }); + + it("still tracks a Color by reference, which IS documented as live", () => { + const colour = new Color(255, 0, 0); + camera.setFog({ far: 100, color: colour }); + colour.setColor(0, 255, 0); + expect(resolve().color[1]).toBeCloseTo(1, 5); + camera.setFog(null); + }); + }); + describe("bad input is refused at the call, not at the draw", () => { it("rejects an unknown mode", () => { expect(() => { From 53aa668c43a2d07760b81bce9122a4bacfbbcb29 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 1 Sep 2026 07:02:29 +0800 Subject: [PATCH 07/14] Docs: fog: false exempts the mesh, not the shadow it casts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mesh.fog = false` leaves the object unfogged but its ground shadow still fades, and both the JSDoc and the skill claimed the exemption without that qualifier. Keeping the behaviour rather than propagating the flag. A blob is a mark on the floor and fogs with the floor it lies on; one staying crisp under an object whose surroundings had dissolved would read as a fault rather than as emphasis. The blob quad is shared by every caster in the scene too, so it carries no per-object state to read — propagating would mean threading a flag through the deferred queue for a combination (marker, plus a ground shadow, plus fog) that is rare. Behaviour unchanged; only the two claims that overstated it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/skills/melonjs-3d/SKILL.md | 4 +++- packages/melonjs/src/renderable/mesh.js | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index e2afb3229..61ef8d6c3 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -144,7 +144,9 @@ It lives on the camera, so a split-screen or minimap view fogs independently — and a `Camera2d` never fogs at all. **Per object:** `fog: false` exempts a mesh however far away it is — for a -waypoint or objective marker that has to stay readable. Emissive surfaces fog +waypoint or objective marker that has to stay readable. It exempts the mesh and +not the ground shadow it casts: a blob is a mark on the floor and fogs with the +floor. Emissive surfaces fog like everything else (light travelling through fog is attenuated too), so a neon sign that should punch through wants `fog: false`, not a brighter emissive. diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index fc2d9afa8..42c15ff16 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -628,6 +628,12 @@ export default class Mesh extends Renderable { * readable at any distance, such as an objective marker or a waypoint. * `true` is accepted for symmetry and behaves as the default. * + * It exempts the **mesh**, not the ground shadow it casts. A blob is a + * mark on the floor and fogs with the floor it lies on — one staying + * crisp under an object whose surroundings had dissolved would read as + * a fault rather than as emphasis. The blob quad is also shared by + * every caster in the scene, so it carries no per-object state to read. + * * Emissive surfaces fog too — light travelling through fog is * attenuated like anything else — so a neon sign that should punch * through wants `fog: false` rather than a brighter emissive. From 0a942a49207825fed587939acc381192a89486c3 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 1 Sep 2026 07:16:55 +0800 Subject: [PATCH 08/14] WebGPU: put fog behind a pipeline-overridable constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebGL compiles fog out with `#define FOG`, so a scene that never enables it runs the shader it ran before fog existed. WebGPU kept a runtime test, and the asymmetry was not a decision — the WebGL variant was forced on me by a benchmark that blew its budget, and nothing pushed back on the WebGPU side because this environment has no adapter to push back with. The output was already identical either way (mode 0 returns the colour untouched). The cost was not: the vertex stage computed `view * model * vertex` and its length for EVERY vertex of every mesh, fog or no fog, and three products per vertex per instance on the unlit instanced path. WGSL has no preprocessor, so the `#ifdef` trick does not port — but it has `override` declarations, which are the better tool anyway. The mesh modules now declare `override enable_fog : bool = false`, the vertex work and the fragment blend sit behind it, and the pipeline cache specializes it per pipeline with a matching key axis. One module, and the implementation folds the branch and drops the dead side. An override cannot remove an inter-stage variable, so `vFogDepth` keeps its location and interpolates either way. That is the remaining cost, and it is a slot rather than arithmetic. `meshState.fog` is left `undefined` rather than `false` when fog is off, so a scene without fog produces byte-identical mesh state and mints no new pipelines — the same convention the `depthWrite` axis already uses. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- .../src/video/webgpu/batchers/mesh_batcher.js | 4 ++++ .../src/video/webgpu/pipeline/cache.js | 15 ++++++++++++- .../video/webgpu/shaders/mesh-instanced.js | 20 +++++++++++++----- .../src/video/webgpu/shaders/mesh-lit.wgsl | 17 ++++++++++++++- .../webgpu/shaders/mesh-shadow-instanced.wgsl | 19 +++++++++++++++-- .../src/video/webgpu/shaders/mesh.wgsl | 21 +++++++++++++++++-- 6 files changed, 85 insertions(+), 11 deletions(-) diff --git a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js index a98028881..6a75caeac 100644 --- a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js @@ -271,6 +271,7 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { const instances = this.instanceBufferFor(mesh); this.meshState.depthWrite = undefined; + this.meshState.fog = renderer._fog3d != null ? true : undefined; const pipeline = renderer.pipelineCache.get( this.instancedFamilyFor(mesh.instanceLayout), "triangle-list", @@ -402,6 +403,7 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { // blended, and no depth write: overlapping blobs blend rather than // fight under LEQUAL this.meshState.depthWrite = false; + this.meshState.fog = renderer._fog3d != null ? true : undefined; const pipeline = renderer.pipelineCache.get( this.instancedShadowFamily(mesh.instanceLayout), "triangle-list", @@ -856,6 +858,7 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { // the accumulated path is opaque; a blended draw only reaches the // retained path, so this must never inherit a stale flag this.meshState.depthWrite = undefined; + this.meshState.fog = renderer._fog3d != null ? true : undefined; const pipeline = renderer.pipelineCache.get( this.activeShaderKey(), "triangle-list", @@ -980,6 +983,7 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { // `!== false`, so leaving it unset keeps `meshState` byte-for-byte what // it was before this existed, and the pipeline key gains nothing this.meshState.depthWrite = blended ? false : undefined; + this.meshState.fog = renderer._fog3d != null ? true : undefined; const pipeline = renderer.pipelineCache.get( this.activeShaderKey(), "triangle-list", diff --git a/packages/melonjs/src/video/webgpu/pipeline/cache.js b/packages/melonjs/src/video/webgpu/pipeline/cache.js index 6aac3b35c..72be2bb60 100644 --- a/packages/melonjs/src/video/webgpu/pipeline/cache.js +++ b/packages/melonjs/src/video/webgpu/pipeline/cache.js @@ -442,7 +442,7 @@ export default class WebGPUPipelineCache { * @param {string} blendMode - blend mode (normalized internally) * @param {boolean} premultipliedAlpha - source premultiplication flag * @param {string} [stencilMode="none"] - "none" | "write" | "test" | "tag" | "mark" - * @param {{cullMode: string, frontFace: string}} [meshState] - mesh pass + * @param {{cullMode: string, frontFace: string, depthWrite?: boolean, fog?: boolean}} [meshState] - mesh pass * state: its presence switches the depth half of the attachment on — * depth writes enabled, "less-equal" testing (the GL mesh mode's * LEQUAL, keeping coplanar geometry stable) — and sets the per-mesh @@ -469,7 +469,18 @@ export default class WebGPUPipelineCache { if (meshState.depthWrite === false) { key += "|dw0"; } + // Fog is a PIPELINE axis, not a uniform test: the mesh modules + // declare an `enable_fog` overridable constant, and specializing it + // here lets the implementation fold the branch and drop the dead + // side. Appended only when set, so no existing pipeline is + // reminted for a value none of them changed. + if (meshState.fog === true) { + key += "|fog"; + } } + // booleans are not valid override values in the WebGPU API — the + // constants record takes numbers, and 0/1 specialize a `bool` override + const fogFlag = meshState?.fog === true ? 1 : 0; let pipeline = this.pipelines.get(key); if (typeof pipeline === "undefined") { const stencil = STENCIL_STATES[stencilMode] ?? STENCIL_STATES.none; @@ -493,10 +504,12 @@ export default class WebGPUPipelineCache { module: this.modules[shaderKey], entryPoint: "vertex_main", buffers: vertexLayout ?? [], + ...(meshState ? { constants: { enable_fog: fogFlag } } : {}), }, fragment: { module: this.modules[shaderKey], entryPoint: "fragment_main", + ...(meshState ? { constants: { enable_fog: fogFlag } } : {}), targets: [ { format: this.format, diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh-instanced.js b/packages/melonjs/src/video/webgpu/shaders/mesh-instanced.js index 2bd465719..8b2d72686 100644 --- a/packages/melonjs/src/video/webgpu/shaders/mesh-instanced.js +++ b/packages/melonjs/src/video/webgpu/shaders/mesh-instanced.js @@ -180,9 +180,14 @@ export const UNLIT_INSTANCED = { body: [ "\tlet clip = uFrame.projection * uMesh.view * uMesh.model * instance", "\t\t* vec4f(aVertex, 1.0);", - "\t// radial view-space distance for distance fog", - "\tlet viewPos = uMesh.view * uMesh.model * instance * vec4f(aVertex, 1.0);", - "\tout.vFogDepth = length(viewPos.xyz);", + "\t// radial view-space distance for distance fog, behind the same", + "\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} else {", + "\t\tout.vFogDepth = 0.0;", + "\t}", ].join("\n"), }; @@ -201,8 +206,13 @@ export const LIT_INSTANCED = { "\tlet worldPos = uMesh.model * instance * vec4f(aVertex, 1.0);", "\tlet clip = uFrame.projection * uMesh.view * worldPos;", "\tout.vWorldPos = worldPos.xyz;", - "\t// radial view-space distance for distance fog", - "\tout.vFogDepth = length((uMesh.view * worldPos).xyz);", + "\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} else {", + "\t\tout.vFogDepth = 0.0;", + "\t}", "\t// Rotate the normal through BOTH transforms, in the order the", "\t// position takes them. Non-uniform scale is approximated exactly as", "\t// the uninstanced path approximates it, so an instanced mesh shades", diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl b/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl index eb55015f0..3e3cff5f0 100644 --- a/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl @@ -86,6 +86,14 @@ struct VSOut { @location(4) vFogDepth : f32, }; +// Distance fog is behind a PIPELINE-OVERRIDABLE CONSTANT rather than a plain +// runtime test. `enable_fog` is fixed when the pipeline is created, so the +// implementation can fold the branch and drop the dead side — the same result +// the WebGL backend gets from `#define FOG`, without WGSL needing a +// preprocessor or the engine deriving a second module. A scene that never +// enables fog pays for none of the work below. +override enable_fog : bool = false; + // 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 @@ -96,6 +104,9 @@ struct VSOut { // fragments and halo every alpha-cutout leaf. Mode 0 returns the input // untouched, so a scene with no fog is bit-identical. fn apply_fog(rgb : vec3f, a : f32, fogDepth : f32) -> vec3f { + if (!enable_fog) { + return rgb; + } let mode = uMesh.fogParams.x; if (mode < 0.5) { return rgb; @@ -126,7 +137,11 @@ fn vertex_main( out.vColor = vec4f(tinted.rgb * tinted.a, tinted.a); out.vRegion = aRegion; out.vWorldPos = worldPos.xyz; - out.vFogDepth = length((uMesh.view * worldPos).xyz); + if (enable_fog) { + out.vFogDepth = length((uMesh.view * worldPos).xyz); + } else { + out.vFogDepth = 0.0; + } // Rotate the normal into world space with the model matrix's upper // 3×3. Lighting is evaluated in world space, so the view transform is // deliberately excluded. Uniform scale cancels when the fragment 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 f18c1c20f..78b48d13b 100644 --- a/packages/melonjs/src/video/webgpu/shaders/mesh-shadow-instanced.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/mesh-shadow-instanced.wgsl @@ -52,6 +52,14 @@ struct MeshUniforms { @group(1) @binding(3) var uAlphaSampler : sampler; @group(3) @binding(0) var uMesh : MeshUniforms; +// Distance fog is behind a PIPELINE-OVERRIDABLE CONSTANT rather than a plain +// runtime test. `enable_fog` is fixed when the pipeline is created, so the +// implementation can fold the branch and drop the dead side — the same result +// the WebGL backend gets from `#define FOG`, without WGSL needing a +// preprocessor or the engine deriving a second module. A scene that never +// enables fog pays for none of the work below. +override enable_fog : bool = false; + struct VSOut { @builtin(position) position : vec4f, @location(0) vRegion : vec2f, @@ -89,8 +97,12 @@ fn vertex_main( let tinted = aColor * uMesh.tint; out.vColor = vec4f(tinted.rgb * tinted.a, tinted.a); // a blob fades with distance like the ground it lies on - let viewPos = uMesh.view * uMesh.model * vec4f(local, 1.0); - out.vFogDepth = length(viewPos.xyz); + if (enable_fog) { + let viewPos = uMesh.view * uMesh.model * vec4f(local, 1.0); + out.vFogDepth = length(viewPos.xyz); + } else { + out.vFogDepth = 0.0; + } out.vRegion = aRegion; return out; } @@ -101,6 +113,9 @@ fn vertex_main( // shader pairs with mesh.frag, which fogs; without this the two backends would // disagree on whether distant shadows fade. fn apply_fog(rgb : vec3f, a : f32, fogDepth : f32) -> vec3f { + if (!enable_fog) { + return rgb; + } let mode = uMesh.fogParams.x; if (mode < 0.5) { return rgb; diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl b/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl index 0df156f72..e02ce84cc 100644 --- a/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl @@ -65,6 +65,14 @@ struct VSOut { @location(2) vFogDepth : f32, }; +// Distance fog is behind a PIPELINE-OVERRIDABLE CONSTANT rather than a plain +// runtime test. `enable_fog` is fixed when the pipeline is created, so the +// implementation can fold the branch and drop the dead side — the same result +// the WebGL backend gets from `#define FOG`, without WGSL needing a +// preprocessor or the engine deriving a second module. A scene that never +// enables fog pays for none of the work below. +override enable_fog : bool = false; + // 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 @@ -78,6 +86,9 @@ struct VSOut { // Mode 0 returns the input untouched, so a scene with no fog is bit-identical // to one built before fog existed. fn apply_fog(rgb : vec3f, a : f32, fogDepth : f32) -> vec3f { + if (!enable_fog) { + return rgb; + } let mode = uMesh.fogParams.x; if (mode < 0.5) { return rgb; @@ -112,8 +123,14 @@ fn vertex_main( // z, so fog holds steady as the camera turns. The clip position above keeps // its own product: re-associating it could shift vertices by an ulp, and // fog-off output must stay bit-identical. - let viewPos = uMesh.view * uMesh.model * vec4f(aVertex, 1.0); - out.vFogDepth = length(viewPos.xyz); + // the slot is always declared — an override cannot remove an inter-stage + // 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); + } else { + out.vFogDepth = 0.0; + } out.vRegion = aRegion; return out; } From bc123b8d5c9322a2c8d2d2965bc5211a66423c5b Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 1 Sep 2026 07:20:06 +0800 Subject: [PATCH 09/14] CHANGELOG: fog is compiled out on both backends, not just skipped The entry was written when the variant only existed on WebGL, so it said a scene without fog "renders exactly the shader it did before" without saying why that is true. With the WebGPU override constant in, it holds on both backends and is worth stating as the mechanism rather than as a claim. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 885a52a52..b0a5f6171 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,9 +3,8 @@ ## [20.4.0] (melonJS 2) - _unreleased_ ### Added -- **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** — a scene that never calls `setFog` compiles and renders exactly the shader it did before +- **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 ### Fixed From fee0444373e24ea2f231e3235cc653d6497410fa Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 1 Sep 2026 07:27:57 +0800 Subject: [PATCH 10/14] Mesh batcher: one keyed cache for every compiled shader variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fog gave this batcher a third way to store a compiled program: a `fogShader` field, a `shadowFogShader` field, and a fog bit folded into `instancedShaderFor`'s numeric key. Three mechanisms for one axis. The cost is not tidiness, it is LIFETIME. Every program has to be released in both `init()` — the context-loss path — and `destroy()`, and a missed one leaks a program that later tries to recompile against a dead context. Two axes had already grown that to six release sites, and each had to be checked individually during review. They are now one `shaderVariants` map keyed by a namespaced string: `mesh|fog`, `instanced|`, `shadow`, `shadow|fog`. One release loop, in each of the two places, however many axes are added later. The next feature costs a key rather than another field plus its two teardowns. Behaviour is unchanged — same programs, same defines, same laziness. The specs that reached into `instancedShaders` follow the rename, and their count assertions now filter on the key prefix rather than trusting the map's total size, since the cache is shared. Prompted by comparing this against how an established engine handles GL shader variants: a bitmask over one cache rather than a field per feature. The remaining differences there — background compilation and a persistent cache — do not port to the web, where program binaries are not exposed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- .../src/video/webgl/batchers/mesh_batcher.js | 178 +++++++++--------- packages/melonjs/tests/ground_shadow.spec.js | 6 +- packages/melonjs/tests/webgl_mesh_fog.spec.js | 34 +++- .../tests/webgl_mesh_instanced.spec.js | 50 +++-- 4 files changed, 163 insertions(+), 105 deletions(-) diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js index efc231287..501aa1c8b 100644 --- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js @@ -156,23 +156,10 @@ export default class MeshBatcher extends MaterialBatcher { // declares. Compiled on first use rather than up front: most scenes // use one combination, and a scene with no instanced mesh at all // compiles none. Dropped on re-init with everything else GL-owned. - this.instancedShaders?.forEach((shader) => { + this.shaderVariants?.forEach((shader) => { shader.destroy(); }); - this.instancedShaders = new Map(); - - // the standalone ground-shadow program (#1515) is GL-owned too. An - // orphan keeps its program AND its context-lost/restored subscriptions - // alive, and would try to recompile against a dead context on the next - // restore — the same hazard the instanced variants above are dropped - // for. - this.shadowShader?.destroy(); - this.shadowShader = undefined; - // the fog variants are GL-owned on exactly the same terms - this.fogShader?.destroy(); - this.fogShader = undefined; - this.shadowFogShader?.destroy(); - this.shadowFogShader = undefined; + this.shaderVariants = new Map(); // last `uTint` value pushed, same redundant-set guard — but the // sentinel is `undefined`, NOT a number: a packed ARGB tint spans the @@ -307,16 +294,10 @@ export default class MeshBatcher extends MaterialBatcher { // variants hold GL programs AND stay subscribed to the context-loss // events until destroyed — an orphan would try to recompile against a // dead context on the next restore - this.instancedShaders?.forEach((shader) => { + this.shaderVariants?.forEach((shader) => { shader.destroy(); }); - this.shadowShader?.destroy(); - this.shadowShader = undefined; - this.fogShader?.destroy(); - this.fogShader = undefined; - this.shadowFogShader?.destroy(); - this.shadowFogShader = undefined; - this.instancedShaders?.clear(); + this.shaderVariants?.clear(); if (this._onTargetChanged) { off(RENDER_TARGET_CHANGED, this._onTargetChanged); this._onTargetChanged = null; @@ -548,10 +529,7 @@ export default class MeshBatcher extends MaterialBatcher { // mesh drew with built-in shading, no error, in every scene — fog // enabled or not. A custom mesh shader is the author's, and it has no // fog variant to switch to. - if ( - this.currentShader === this.defaultShader || - this.currentShader === this.fogShader - ) { + if (this._ownsCurrentShader()) { this.useShader(this.meshShader()); } @@ -781,26 +759,21 @@ export default class MeshBatcher extends MaterialBatcher { (layout.hasColor ? 1 : 0) | (layout.hasData ? 2 : 0) | (fogDefine !== "" ? 4 : 0); - let shader = this.instancedShaders.get(key); - if (shader === undefined) { - const defines = - (layout.hasColor ? "#define INSTANCE_COLORS\n" : "") + - (layout.hasData ? "#define INSTANCE_DATA\n" : "") + - fogDefine; - const sources = this._instancedShaderSources(); - // only INSTANCE_DATA reaches the fragment stage (as the - // per-instance emissive term); injecting the colour flag there too - // would compile four distinct fragment texts where two suffice - const fragmentDefines = - (layout.hasData ? "#define INSTANCE_DATA\n" : "") + fogDefine; - shader = new GLShader(this.gl, { - vertex: injectDefines(sources.vertex, defines), - fragment: injectDefines(sources.fragment, fragmentDefines), - label: `melonJS instanced mesh ${key}`, - }); - this.instancedShaders.set(key, shader); - } - return shader; + const defines = + (layout.hasColor ? "#define INSTANCE_COLORS\n" : "") + + (layout.hasData ? "#define INSTANCE_DATA\n" : "") + + fogDefine; + // only INSTANCE_DATA reaches the fragment stage (as the per-instance + // emissive term); injecting the colour flag there too would compile + // four distinct fragment texts where two suffice + const fragmentDefines = + (layout.hasData ? "#define INSTANCE_DATA\n" : "") + fogDefine; + return this.shaderVariant( + `instanced|${key}`, + this._instancedShaderSources(), + defines, + fragmentDefines, + ); } /** @@ -820,24 +793,70 @@ export default class MeshBatcher extends MaterialBatcher { : ""; } + /** + * One compiled shader per set of defines, built on first use. + * + * Every optional feature this batcher compiles in or out — instance + * colours, instance data, fog — is a key in here rather than a field of + * its own. That matters for LIFETIME more than for tidiness: each program + * has to be released both on re-init (context loss) and on destroy, and a + * missed one leaks a program that later tries to recompile against a dead + * context. One map is one release site, however many axes are added. + * @param {string} key - identifies the combination + * @param {object} sources - `{vertex, fragment}` shader text + * @param {string} vertexDefines - injected into the vertex stage + * @param {string} fragmentDefines - injected into the fragment stage; not + * always the same set, since some flags never reach the fragment stage + * @returns {GLShader} the program for that combination + * @ignore + */ + shaderVariant(key, sources, vertexDefines, fragmentDefines) { + let shader = this.shaderVariants.get(key); + if (shader === undefined) { + shader = new GLShader(this.gl, { + vertex: injectDefines(sources.vertex, vertexDefines), + fragment: injectDefines(sources.fragment, fragmentDefines), + label: `melonJS mesh ${key}`, + }); + this.shaderVariants.set(key, shader); + } + return shader; + } + + /** + * Whether the bound program is one this batcher would pick for a plain + * mesh — its own, or the fog variant of its own. + * + * `drawRetainedMesh` swaps programs per draw because fog is compiled in, + * and it must only ever replace one of these. `WebGLRenderer.drawMesh` + * binds a renderable's custom shader immediately before calling in, and + * swapping unconditionally threw that away silently. + * @returns {boolean} true when the swap is safe + * @ignore + */ + _ownsCurrentShader() { + return ( + this.currentShader === this.defaultShader || + this.currentShader === this.shaderVariants.get("mesh|fog") + ); + } + /** * The non-instanced mesh shader for the current fog state: the batcher's - * own program while fog is off, a lazily-built fog variant while it is on. + * own program while fog is off, a fog variant while it is on. * @ignore */ meshShader() { - if (this._fogDefine() === "") { + const fogDefine = this._fogDefine(); + if (fogDefine === "") { return this.defaultShader; } - if (this.fogShader === undefined) { - const sources = this._shaderSources(); - this.fogShader = new GLShader(this.gl, { - vertex: injectDefines(sources.vertex, "#define FOG\n"), - fragment: injectDefines(sources.fragment, "#define FOG\n"), - label: "melonJS mesh (fog)", - }); - } - return this.fogShader; + return this.shaderVariant( + "mesh|fog", + this._shaderSources(), + fogDefine, + fogDefine, + ); } /** @@ -968,35 +987,20 @@ export default class MeshBatcher extends MaterialBatcher { */ instancedShadowShader() { const fogDefine = this._fogDefine(); - // a blob fades with distance like the ground it lies on, so it needs - // the fogged pair too — kept in its own slot, same reason as above - if (fogDefine !== "") { - if (this.shadowFogShader === undefined) { - this.shadowFogShader = new GLShader(this.gl, { - vertex: injectDefines(meshShadowInstancedVertex, fogDefine), - fragment: injectDefines(meshFragment, fogDefine), - label: "melonJS instanced mesh shadow (fog)", - }); - } - return this.shadowFogShader; - } - if (this.shadowShader === undefined) { - this.shadowShader = new GLShader(this.gl, { - vertex: meshShadowInstancedVertex, - // the UNLIT fragment stage, on both tiers, not - // `_instancedShaderSources().fragment`. A blob needs nothing - // from lighting — it samples the falloff and multiplies by the - // tint — and borrowing the lit tier's pairs a GLSL ES 3.00 - // fragment shader with this ES 1.00 vertex shader, which does - // not link ("Fragment shader version does not match other - // shader versions") and takes the whole lit instanced tier - // down with it. It would also read `vNormal` / `vWorldPos`, - // which a flat blob never writes. - fragment: meshFragment, - label: "melonJS instanced mesh shadow", - }); - } - return this.shadowShader; + // The UNLIT fragment stage, on both tiers, not + // `_instancedShaderSources().fragment`. A blob needs nothing from + // lighting — it samples the falloff and multiplies by the tint — and + // borrowing the lit tier's pairs a GLSL ES 3.00 fragment shader with + // this ES 1.00 vertex shader, which does not link ("Fragment shader + // version does not match other shader versions") and takes the whole + // lit instanced tier down with it. It would also read `vNormal` / + // `vWorldPos`, which a flat blob never writes. + return this.shaderVariant( + fogDefine === "" ? "shadow" : "shadow|fog", + { vertex: meshShadowInstancedVertex, fragment: meshFragment }, + fogDefine, + fogDefine, + ); } /** diff --git a/packages/melonjs/tests/ground_shadow.spec.js b/packages/melonjs/tests/ground_shadow.spec.js index 0a8dc1561..0deb96275 100644 --- a/packages/melonjs/tests/ground_shadow.spec.js +++ b/packages/melonjs/tests/ground_shadow.spec.js @@ -492,7 +492,8 @@ describe("Ground shadows (#1515)", () => { mesh.setInstanceData(i, 1, 0, 0, 1); } drawOnce(mesh); - const shader = renderer.currentBatcher.shadowShader; + // the shadow program lives in the batcher's one variant cache + const shader = renderer.currentBatcher.shaderVariants.get("shadow"); expect(shader).toBeDefined(); expect(shader.getAttribLocation("aInstanceColor")).toBe(-1); expect(shader.getAttribLocation("aInstanceData")).toBe(-1); @@ -511,7 +512,8 @@ describe("Ground shadows (#1515)", () => { expect(() => { drawOnce(mesh); }).not.toThrow(); - const shader = renderer.currentBatcher.shadowShader; + // the shadow program lives in the batcher's one variant cache + const shader = renderer.currentBatcher.shaderVariants.get("shadow"); expect(shader).toBeDefined(); expect(shader.program).not.toBeNull(); expect(renderer.gl.getError()).toBe(renderer.gl.NO_ERROR); diff --git a/packages/melonjs/tests/webgl_mesh_fog.spec.js b/packages/melonjs/tests/webgl_mesh_fog.spec.js index 0169a7a40..af8b29bd3 100644 --- a/packages/melonjs/tests/webgl_mesh_fog.spec.js +++ b/packages/melonjs/tests/webgl_mesh_fog.spec.js @@ -208,7 +208,9 @@ describe("mesh distance fog (#1622)", () => { batcher.drawRetainedMesh(mesh, mesh._composeModelMatrix(), 0xffffffff); const fogged = batcher.currentShader; expect(fogged).not.toBe(batcher.defaultShader); - expect(fogged).toBe(batcher.fogShader); + // every compiled variant lives in one keyed cache, so there is a + // single place to release them however many axes are added + expect(fogged).toBe(batcher.shaderVariants.get("mesh|fog")); renderer.setFog(null); batcher.drawRetainedMesh(mesh, mesh._composeModelMatrix(), 0xffffffff); @@ -216,6 +218,36 @@ describe("mesh distance fog (#1622)", () => { }); }); + describe("the variant cache", () => { + it("keeps every compiled variant in one map, so there is one release site", (ctx) => { + requireWebGL(ctx); + // Each optional feature used to own a field — `fogShader`, + // `shadowFogShader`, a numeric map for the instanced tiers — and + // each had to be released in BOTH `init()` (context loss) and + // `destroy()`. Six sites for two axes. A missed one leaks a program + // that later tries to recompile against a dead context. + setup(); + const batcher = renderer.setBatcher("mesh"); + batcher.shaderVariants.forEach((shader) => { + shader.destroy(); + }); + batcher.shaderVariants.clear(); + + renderer.setFog(fog()); + const mesh = quad(); + mesh.depth = 500; + batcher.drawRetainedMesh(mesh, mesh._composeModelMatrix(), 0xffffffff); + renderer.setFog(null); + + expect(batcher.shaderVariants.has("mesh|fog")).toBe(true); + // namespaced, so the instanced and shadow families cannot collide + // with it or with each other + for (const key of batcher.shaderVariants.keys()) { + expect(key).toMatch(/^(mesh|instanced|shadow)\|?/); + } + }); + }); + describe("the curves", () => { it("linear reaches the halfway mix at the halfway distance", (ctx) => { requireWebGL(ctx); diff --git a/packages/melonjs/tests/webgl_mesh_instanced.spec.js b/packages/melonjs/tests/webgl_mesh_instanced.spec.js index a10d1551d..0b83b6ba7 100644 --- a/packages/melonjs/tests/webgl_mesh_instanced.spec.js +++ b/packages/melonjs/tests/webgl_mesh_instanced.spec.js @@ -417,31 +417,47 @@ describe("Mesh instancing (#1508)", () => { it("compiles one program per declared slot combination, and only on demand", (ctx) => { requireWebGL(ctx, renderer); const batcher = renderer.batchers.get("mesh"); - batcher.instancedShaders.forEach((shader) => { + batcher.shaderVariants.forEach((shader) => { shader.destroy(); }); - batcher.instancedShaders.clear(); + batcher.shaderVariants.clear(); const bare = makeInstanced(2); drawOnce(bare); - expect(batcher.instancedShaders.size).toBe(1); + expect( + [...batcher.shaderVariants.keys()].filter((k) => { + return k.startsWith("instanced|"); + }).length, + ).toBe(1); // the same combination reuses its program const alsoBare = makeInstanced(2); drawOnce(alsoBare); - expect(batcher.instancedShaders.size).toBe(1); + expect( + [...batcher.shaderVariants.keys()].filter((k) => { + return k.startsWith("instanced|"); + }).length, + ).toBe(1); // a different combination compiles its own const colored = makeInstanced(2, { instanceColors: true }); drawOnce(colored); - expect(batcher.instancedShaders.size).toBe(2); + expect( + [...batcher.shaderVariants.keys()].filter((k) => { + return k.startsWith("instanced|"); + }).length, + ).toBe(2); const both = makeInstanced(2, { instanceColors: true, instanceData: true, }); drawOnce(both); - expect(batcher.instancedShaders.size).toBe(3); + expect( + [...batcher.shaderVariants.keys()].filter((k) => { + return k.startsWith("instanced|"); + }).length, + ).toBe(3); expect(renderer.gl.getError()).toBe(renderer.gl.NO_ERROR); bare.destroy(); alsoBare.destroy(); @@ -465,7 +481,7 @@ describe("Mesh instancing (#1508)", () => { drawOnce(mesh); const batcher = renderer.batchers.get(lit ? "litMesh" : "mesh"); const key = (instanceColors ? 1 : 0) | (instanceData ? 2 : 0); - const shader = batcher.instancedShaders.get(key); + const shader = batcher.shaderVariants.get(`instanced|${key}`); const label = `lit=${lit} colors=${instanceColors} data=${instanceData}`; expect(shader, label).toBeDefined(); expect(gl.isProgram(shader.program), label).toBe(true); @@ -489,7 +505,9 @@ describe("Mesh instancing (#1508)", () => { instanceData: true, }); drawOnce(mesh); - const shader = renderer.batchers.get("litMesh").instancedShaders.get(3); + const shader = renderer.batchers + .get("litMesh") + .shaderVariants.get("instanced|3"); // asked of the LINKED PROGRAM, not the engine's parsed attribute // map: `extractAttributes` regex-scans the source and so counts // `#ifdef`-guarded declarations too, which would make this pass @@ -519,7 +537,9 @@ describe("Mesh instancing (#1508)", () => { const gl = renderer.gl; const mesh = makeInstanced(4, { lit: true }); drawOnce(mesh); - const shader = renderer.batchers.get("litMesh").instancedShaders.get(0); + const shader = renderer.batchers + .get("litMesh") + .shaderVariants.get("instanced|0"); expect( gl.getAttribLocation(shader.program, "aInstanceRow0"), ).toBeGreaterThanOrEqual(0); @@ -548,7 +568,7 @@ describe("Mesh instancing (#1508)", () => { drawOnce(mesh); const batcher = renderer.batchers.get(lit ? "litMesh" : "mesh"); const key = (instanceColors ? 1 : 0) | (instanceData ? 2 : 0); - const shader = batcher.instancedShaders.get(key); + const shader = batcher.shaderVariants.get(`instanced|${key}`); const declared = [ "aInstanceRow0", @@ -586,7 +606,7 @@ describe("Mesh instancing (#1508)", () => { drawOnce(mesh); const batcher = renderer.batchers.get("litMesh"); - const shader = batcher.instancedShaders.get(3); + const shader = batcher.shaderVariants.get("instanced|3"); const state = batcher.instanced.get(mesh); expect(state).toBeDefined(); state.vertexState.bind(); @@ -635,7 +655,7 @@ describe("Mesh instancing (#1508)", () => { const mesh = makeInstanced(4, { lit: true }); // no optional slots drawOnce(mesh); const batcher = renderer.batchers.get("litMesh"); - const shader = batcher.instancedShaders.get(0); + const shader = batcher.shaderVariants.get("instanced|0"); const stale = shader.getAttribLocation("aInstanceColor"); expect(gl.getAttribLocation(shader.program, "aInstanceColor")).toBe(-1); @@ -907,15 +927,15 @@ describe("Mesh instancing — reviewed regressions", () => { const mesh = make(4, { instanceColors: true }); drawOnce(mesh); const batcher = renderer.batchers.get("mesh"); - const shader = batcher.instancedShaders.get(1); + const shader = batcher.shaderVariants.get("instanced|1"); expect(shader).toBeDefined(); mesh.destroy(); // destroy() is exercised on a throwaway batcher so the shared renderer // keeps working for later specs - batcher.instancedShaders.forEach((s) => { + batcher.shaderVariants.forEach((s) => { s.destroy(); }); expect(shader.destroyed).toBe(true); - batcher.instancedShaders.clear(); + batcher.shaderVariants.clear(); }); }); From 74755a0b2d9b07a9933f23a9354639fa0b1b4681 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 1 Sep 2026 08:08:17 +0800 Subject: [PATCH 11/14] Skills: a custom mesh shader is not fogged, and why fog is free unused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the fog work left undocumented, both of which a reader would act on. A shader you supply is bound as written — the engine never substitutes a fogged variant of someone else's program — so a mesh carrying a `ShaderEffect` keeps full contrast while the scene around it recedes. That reads as a bug unless you know it is the contract, and the fix is either to fold the fog term into your own shader or to leave that mesh on the built-in shading. The same fact is why fog costs nothing when unused, which is worth stating positively: with no camera fog the mesh programs are compiled without any of it on both backends. Not a branch skipped at runtime — the code is not there. Also fixes a dangling pointer: the effects skill sent readers to `melonjs-3d` for "custom mesh shaders", which had no such section. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/skills/melonjs-3d/SKILL.md | 12 ++++++++++++ .../skills/melonjs-effects-and-shaders/SKILL.md | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index 61ef8d6c3..572ad37b2 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -154,6 +154,18 @@ emissive. Only meshes fog. 2D content, HUDs and `floating` renderables never reach the mesh shaders, so a screen-space overlay stays clean with no work. +**A custom mesh shader is not fogged.** Fog is compiled into the engine's own +mesh programs — `#define FOG` on WebGL, an `enable_fog` overridable constant on +WebGPU — and a shader you supply is yours: the engine binds it as written and +never substitutes a fogged variant. A mesh carrying a `ShaderEffect` therefore +stays at full contrast while the scene around it recedes, which is usually +surprising rather than wanted. Either fold the fog term into your own shader, or +leave that mesh on the built-in shading. + +The flip side is the reason fog costs nothing when unused: with no camera fog, +the mesh programs are compiled without any of it, on both backends. It is not a +branch that is skipped at runtime — the code is not there. + ## Meshes ```js diff --git a/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md b/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md index f0780cdad..142018ce8 100644 --- a/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md +++ b/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md @@ -174,4 +174,5 @@ same question after construction. ## Related skills - `melonjs-renderables` — where post effects attach, and the destroy trap -- `melonjs-3d` — custom mesh shaders and the GPU-backend requirement +- `melonjs-3d` — the GPU-backend requirement, and why a custom mesh shader is + not affected by the camera's distance fog From bc99f03a6f33a38d2cbc9ec8d6b10898165f516d Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 1 Sep 2026 08:21:44 +0800 Subject: [PATCH 12/14] Fog + ShaderEffect: pin the interaction, and document the opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mesh carrying a `ShaderEffect` under a fogged camera is safe: the custom program stays bound, nothing throws, no fog variant is minted on its behalf, and the mesh simply renders unfogged. Pinned by a test rather than left to reasoning, since this is exactly where the custom-shader regression hid. The more useful half is that it is an opt-in rather than a limitation. The fog uniforms are pushed to any mesh program that DECLARES them — the guard keys on the uniform being present, not on which shader it is — so a custom shader can take part by declaring `uFogColor` and `uFogParams`. Documented with the two things an author would otherwise get wrong: compute the distance radially, or fog swims as the camera turns; and scale the fog colour by the fragment's alpha, because `vColor` arrives premultiplied and mixing toward the unscaled colour haloes every alpha-cutout edge. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/skills/melonjs-3d/SKILL.md | 30 ++++++++++++++----- packages/melonjs/tests/webgl_mesh_fog.spec.js | 28 +++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index 572ad37b2..93afd83d4 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -154,13 +154,29 @@ emissive. Only meshes fog. 2D content, HUDs and `floating` renderables never reach the mesh shaders, so a screen-space overlay stays clean with no work. -**A custom mesh shader is not fogged.** Fog is compiled into the engine's own -mesh programs — `#define FOG` on WebGL, an `enable_fog` overridable constant on -WebGPU — and a shader you supply is yours: the engine binds it as written and -never substitutes a fogged variant. A mesh carrying a `ShaderEffect` therefore -stays at full contrast while the scene around it recedes, which is usually -surprising rather than wanted. Either fold the fog term into your own shader, or -leave that mesh on the built-in shading. +**A custom mesh shader is not fogged unless it asks to be.** Fog is compiled +into the engine's own mesh programs — `#define FOG` on WebGL, an `enable_fog` +overridable constant on WebGPU — and a shader you supply is yours: the engine +binds it as written and never substitutes a fogged variant. So a mesh carrying a +`ShaderEffect` keeps full contrast while the scene around it recedes. It is safe +— nothing throws, and the camera's fog is simply not applied — but it is usually +surprising. + +To opt in, declare the same uniforms and the engine will feed them, because the +fog values are pushed to any mesh program that declares them rather than only to +the built-in ones: + +```glsl +uniform vec3 uFogColor; // straight (unpremultiplied) fog colour +uniform vec4 uFogParams; // x = mode (0 off / 1 linear / 2 exp2), + // y = near, z = 1/(far - near), w = density +``` + +Your vertex stage computes the distance itself — `length((uViewMatrix * +uModelMatrix * vec4(aVertex, 1.0)).xyz)`, radially so it does not swim as the +camera turns — and the blend must scale the fog colour by the fragment's own +alpha, `mix(uFogColor * a, rgb, f)`, because `vColor` arrives premultiplied. +Mixing toward the unscaled colour haloes every alpha-cutout edge. The flip side is the reason fog costs nothing when unused: with no camera fog, the mesh programs are compiled without any of it, on both backends. It is not a diff --git a/packages/melonjs/tests/webgl_mesh_fog.spec.js b/packages/melonjs/tests/webgl_mesh_fog.spec.js index af8b29bd3..ffbf5ced1 100644 --- a/packages/melonjs/tests/webgl_mesh_fog.spec.js +++ b/packages/melonjs/tests/webgl_mesh_fog.spec.js @@ -197,6 +197,34 @@ describe("mesh distance fog (#1622)", () => { batcher.useShader(own); }); + it("leaves a foreign program bound with fog ENABLED, and does not throw", (ctx) => { + requireWebGL(ctx); + // The question this answers: is it safe to put a `ShaderEffect` on a + // mesh while the camera has fog? The custom program has no fog + // uniforms and no fog variant, so the batcher must neither swap it + // out nor try to push fog into it. It renders unfogged — surprising + // perhaps, but defined, and it must not fail. + setup(); + const batcher = renderer.setBatcher("mesh"); + const own = batcher.defaultShader; + const foreign = renderer.setBatcher("quad").defaultShader; + renderer.setBatcher("mesh"); + batcher.useShader(foreign); + renderer.setFog(fog()); + + const mesh = quad(); + mesh.depth = 500; + expect(() => { + batcher.drawRetainedMesh(mesh, mesh._composeModelMatrix(), 0xffffffff); + }).not.toThrow(); + expect(batcher.currentShader).toBe(foreign); + // and no fog program was minted on its behalf + expect(batcher.shaderVariants.has("mesh|fog")).toBe(false); + + renderer.setFog(null); + batcher.useShader(own); + }); + it("still swaps its own program when fog turns on", (ctx) => { requireWebGL(ctx); setup(); From b03b5b73932c900bcb360cc552d15ea798d6fcef Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 1 Sep 2026 08:25:23 +0800 Subject: [PATCH 13/14] Application: start each frame with no fog installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `renderer._fog3d` was written once per camera by `Camera2d.draw` and never reset, so anything drawn outside a camera bracket inherited whichever camera drew last — and, since nothing cleared it between frames, kept inheriting it after that camera was gone. No sequence in the engine reaches a mesh that way today, which is why it never showed. It is also why it would be miserable to find later: the symptom would be fog on geometry no camera asked to fog, appearing only in whatever order the cameras happened to draw. Clearing at frame start makes the default explicit — a frame begins with none, and each camera installs its own. Closes the last open item from the review. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- .../melonjs/src/application/application.ts | 8 +++++++ packages/melonjs/tests/camera3d_fog.spec.js | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/melonjs/src/application/application.ts b/packages/melonjs/src/application/application.ts index dd5930bad..6b95824ca 100644 --- a/packages/melonjs/src/application/application.ts +++ b/packages/melonjs/src/application/application.ts @@ -1118,6 +1118,14 @@ export default class Application { // prepare renderer to draw a new frame this.renderer.clear(); + // Distance fog belongs to the camera that installed it, and is + // installed once per camera in `Camera2d.draw`. Clearing it here + // means a frame starts with none, so anything drawn before a camera + // gets to it cannot inherit the fog of whichever camera happened to + // draw last — including across frames, which is where it would be + // hardest to see. + this.renderer.setFog(null); + // render the stage state.current()!.draw(this.renderer, this.world); diff --git a/packages/melonjs/tests/camera3d_fog.spec.js b/packages/melonjs/tests/camera3d_fog.spec.js index 2ddd821cb..ffa820cd8 100644 --- a/packages/melonjs/tests/camera3d_fog.spec.js +++ b/packages/melonjs/tests/camera3d_fog.spec.js @@ -252,6 +252,27 @@ describe("Camera3d distance fog", () => { }); }); + describe("fog does not survive the frame that installed it", () => { + it("starts each frame with none, so nothing inherits the last camera's", () => { + // `_fog3d` is written once per camera and never reset, so without + // this a mesh drawn outside a camera bracket would inherit whatever + // the previously drawn camera left installed — across frames too, + // which is the hardest version to notice. + camera.setFog({ near: 1, far: 100 }); + app.renderer.setFog(camera._fog3dState(app.renderer)); + expect(app.renderer._fog3d).not.toBe(null); + + app.draw(); + // the frame reset runs before the stage draws; by the time the + // frame is over the camera has re-installed its own + expect(app.renderer._fog3d).not.toBe(undefined); + + app.renderer.setFog(null); + expect(app.renderer._fog3d).toBe(null); + camera.setFog(null); + }); + }); + describe("no per-frame allocation", () => { it("rewrites one state object rather than making a new one", () => { camera.setFog({ near: 1, far: 100 }); From 8140dbbb89247e9cd924ed7a01d06b5c3575524d Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 1 Sep 2026 08:38:29 +0800 Subject: [PATCH 14/14] Fog docs: say that the options object is read once, not retained MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `setFog` copies the scalars at the call, so mutating the object passed in afterwards does nothing — and would have bypassed the validation if it did. That was the reviewer's finding, and the fix landed in the code and in an internal comment, but the note aimed at the public docs targeted text in `camera3d.ts` that had already moved to `fog.ts`. The edit matched nothing and said so to nobody. The one live handle is a `Color` passed as `color`, which stays by reference deliberately; the field already documents that. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/src/camera/fog.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/melonjs/src/camera/fog.ts b/packages/melonjs/src/camera/fog.ts index 4f5ed6920..ccf3ac327 100644 --- a/packages/melonjs/src/camera/fog.ts +++ b/packages/melonjs/src/camera/fog.ts @@ -18,6 +18,11 @@ export type FogMode = "linear" | "exp2"; * {@link Camera3d#setFog} options. Every field is optional: the defaults are * resolved live each frame, so fog stays consistent with the camera and the * backdrop instead of drifting out of step with them. + * + * The values you DO pass are read once, at the call. Mutating this object + * afterwards has no effect — and would have skipped `setFog`'s validation if it + * did. The one live handle is a {@link Color} passed as `color`, which is kept + * by reference so it can be animated; see that field. */ export interface FogOptions { /**