Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## [20.4.0] (melonJS 2) - _unreleased_

### Added
- **Height falloff for distance fog** ([#1633](https://github.com/melonjs/melonJS/issues/1633)): `camera.setFog({ ..., fogHeight, heightFalloff })` makes fog density drop with altitude, so mist pools in low ground instead of hanging as thickly over a ridge as over the valley floor. Uniform fog gives you one dial for two jobs — tune it so a valley has atmosphere and the skyline washes out with it, tune it so the peaks stay crisp and the low ground has no air in it. This separates them. `heightFalloff` defaults to `0`, which is not a special case but the same integral with the dial at zero, so a scene that omits it renders exactly as before. Costs one `exp` per vertex: the density falls off exponentially with height, and an exponential integrates analytically along a straight segment, so there is no ray marching and no volume texture. Render space is **Y-down**, so `fogHeight` is the floor and density rises below it — the opposite sign to the usual published form
- **Distance fog for the 3D tier** ([#1622](https://github.com/melonjs/melonJS/issues/1622)): `camera.setFog({ mode, near, far, density, color })` fades mesh geometry toward a colour with distance — `"linear"` between two distances, or `"exp2"` from a single density, the two parameterisations inherited from fixed-function graphics pipelines. It is the cheapest thing that stops a 3D scene reading as flat cut-outs, and it hides the far plane so props can appear without a visible edge. Every parameter is optional and the omitted ones resolve **live**: the distances track the camera's own clip planes, so fog cannot silently disagree with them after a later `setClipPlanes`, and the colour tracks `renderer.backgroundColor`, so geometry dissolves into the sky you already set — including through a day/night fade. Pass `color` only when the fog should differ from the backdrop. Measured radially and applied per fragment, so it neither slides as the camera turns nor bands across large triangles. Fog belongs to the camera, so split-screen and minimap views fog independently and a `Camera2d` never fogs; a mesh opts out with `fog: false`, for a marker that must stay readable at any distance. **Off by default**, and not merely skipped at runtime: fog is compiled out on both backends — `#define FOG` on WebGL, an `enable_fog` pipeline-overridable constant on WebGPU — so a scene that never calls `setFog` runs the shader it ran before fog existed
- Mesh: `settings.vertexColors` and `setVertexColor(index, color)` give procedural geometry a per-vertex colour, multiplied into `tint`. Both batchers already wrote a per-vertex `aColor` on WebGL and WebGPU, but the array could only ever be built internally from a multi-material OBJ — so a mesh you built yourself had no way to reach it. `tint` is per *object*, so a terrain built as one mesh could only be tinted whole; this is what lets it fade toward the sky with distance, or darken in a crease, without splitting the mesh or writing a shader. Takes packed RGBA8 (`Uint32Array`, the form the batchers read) or one `Color` per vertex; a length that does not match the vertex count throws rather than mis-colouring the tail ([#1624](https://github.com/melonjs/melonJS/issues/1624))
- Mesh: normals are generated from the geometry when a `lit` mesh is built without them. A lit mesh with no normals had nothing for the shader to light with and rendered **fullbright** — asking for lighting and silently getting flat colour — and every hand-built mesh had to write the same accumulate-and-normalize loop first. Flat versus smooth is decided by the geometry rather than a flag: face normals accumulate into their vertices weighted by area, so shared vertices average into smooth shading while a triangle soup (each face owning its three vertices) resolves to the face normal and shades flat. An explicit `settings.normals` still wins, and an unlit mesh gets none
Expand Down
18 changes: 17 additions & 1 deletion packages/melonjs/skills/melonjs-3d/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: melonjs-3d
description: "Use this skill for anything 3D or 2.5D in melonJS — Camera3d, Mesh, InstancedMesh, Sprite3d billboards, Light3d, ground shadows, glTF/GLB scenes, and depth sorting. Covers the Y-down/+Z-forward convention that is the inverse of OpenGL, the cameraClass opt-in, clip planes, and what does not work on the Canvas fallback. Triggers on: Camera3d, Mesh, InstancedMesh, Sprite3d, Light3d, billboard, glTF, glb, 3D, 2.5D, depth, cameraClass, fov, setClipPlanes, setFog, fog, distance fog, castGroundShadow, lit."
description: "Use this skill for anything 3D or 2.5D in melonJS — Camera3d, Mesh, InstancedMesh, Sprite3d billboards, Light3d, ground shadows, glTF/GLB scenes, and depth sorting. Covers the Y-down/+Z-forward convention that is the inverse of OpenGL, the cameraClass opt-in, clip planes, and what does not work on the Canvas fallback. Triggers on: Camera3d, Mesh, InstancedMesh, Sprite3d, Light3d, billboard, glTF, glb, 3D, 2.5D, depth, cameraClass, fov, setClipPlanes, setFog, fog, distance fog, height fog, heightFalloff, castGroundShadow, lit."
license: MIT
---

Expand Down Expand Up @@ -138,6 +138,20 @@ camera.setFog({ far: 5000, color: "#8899aa" });

A `Color` is held by reference, so mutating it animates the fog.

**Height falloff** makes mist pool in low ground instead of hanging at every
altitude equally — the difference between fog reading as weather and as a global
desaturation:

```js
camera.setFog({ near: 1200, far: 7000, fogHeight: 0, heightFalloff: 0.0015 });
```

`heightFalloff` defaults to **0**, which is uniform fog — not a special case,
the same integral with the dial at zero, so leaving it out changes nothing.
Render space is **Y-down**, so `fogHeight` is the floor and density rises
*below* it; every published form of this formula assumes Y-up and has the
opposite sign.

Fog is measured **radially** from the camera and applied **per fragment**, so
it does not slide as the camera turns and does not band across large triangles.
It lives on the camera, so a split-screen or minimap view fogs independently —
Expand Down Expand Up @@ -435,6 +449,8 @@ To branch rather than fail, read `app.renderer.supportsDepthBuffer` after
| black canvas under `Camera3d` | Canvas renderer (no depth buffer) — check the `console.warn` |
| everything flat and unlit | `lit: true` with no `Light3d` in the world (falls back to fullbright), or a mesh under a 2D camera |
| a `floating` HUD draws behind the scenery | a large \|z\| is *far* under `Camera3d` — use a small depth |
| fog hangs in the sky as thickly as in the valley | uniform fog — add `heightFalloff` so it pools low |
| mist sits on the ridges instead of the valley floor | the `fogHeight` sign — Y is DOWN here, density rises below it |
| distant geometry pops in against the sky | no fog — `camera.setFog({})` picks up the clip planes and background colour |
| fog does not match the sky after a background fade | an explicit `color` was passed; omit it to track `renderer.backgroundColor` |
| geometry clips before it has finished fading | fog `far` beyond the clip far — omit the distances and they default to the clip planes |
Expand Down
46 changes: 46 additions & 0 deletions packages/melonjs/src/camera/camera3d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ export default class Camera3d extends Camera2d {
/** @ignore */ private _fogNear: number | undefined = undefined;
/** @ignore */ private _fogFar: number | undefined = undefined;
/** @ignore */ private _fogDensity: number | undefined = undefined;
/** @ignore */ private _fogHeight = 0;
/** @ignore */ private _fogHeightFalloff = 0;

/**
* Owned colour, used only when the caller passed a CSS string or an array.
Expand All @@ -150,6 +152,9 @@ export default class Camera3d extends Camera2d {
invRange: 0,
density: 0,
color: new Float32Array(3),
heightFalloff: 0,
fogHeight: 0,
cameraY: 0,
};

/**
Expand Down Expand Up @@ -319,6 +324,11 @@ export default class Camera3d extends Camera2d {
*
* Fog is per camera, so a split-screen or minimap view fogs independently
* — and a `Camera2d` never fogs at all.
*
* `heightFalloff` adds a second falloff with altitude, so mist pools in low
* ground instead of hanging at every height equally. It defaults to 0,
* which is uniform fog — not a special case, the same integral with the
* dial at zero.
* @param options - fog settings, or `null` to switch fog off
* @returns this camera (chainable)
* @throws {Error} on an unknown `mode`, a non-finite or negative distance,
Expand Down Expand Up @@ -349,6 +359,16 @@ export default class Camera3d extends Camera2d {
* camera.setFog({ far: 5000, color: murk });
* murk.setColor(60, 90, 55); // thickens over the next frame
* @example
* // Mist pooling in a valley: dense along the floor, thinning up the
* // walls so the tree line stays crisp. Render space is Y-down, so
* // `fogHeight` is the floor and density rises BELOW it.
* camera.setFog({
* near: 1200,
* far: 7000,
* fogHeight: 0,
* heightFalloff: 0.0015,
* });
* @example
* // Everything is optional: with nothing at all, fog spans the camera's
* // own clip planes in the backdrop's colour.
* camera.setFog({});
Expand Down Expand Up @@ -395,12 +415,27 @@ export default class Camera3d extends Camera2d {
if (options.density !== undefined && options.density <= 0) {
throw new Error("Camera3d.setFog: density must be greater than zero");
}
for (const [name, value] of [
["fogHeight", options.fogHeight],
["heightFalloff", options.heightFalloff],
] as const) {
if (value !== undefined && !Number.isFinite(value)) {
throw new Error(`Camera3d.setFog: ${name} must be a finite number`);
}
}
if (options.heightFalloff !== undefined && options.heightFalloff < 0) {
throw new Error("Camera3d.setFog: heightFalloff must not be negative");
}

this._fogOptions = options;
this._fogMode = mode;
this._fogNear = options.near;
this._fogFar = options.far;
this._fogDensity = options.density;
this._fogHeight = options.fogHeight ?? 0;
// zero is uniform fog — the maths below collapses to the distance-only
// form exactly, so the default changes nothing
this._fogHeightFalloff = options.heightFalloff ?? 0;
// A `Color` is referenced so mutating it animates the fog; anything
// else is parsed once into a colour this camera owns.
if (options.color === undefined || options.color instanceof Color) {
Expand Down Expand Up @@ -441,6 +476,12 @@ export default class Camera3d extends Camera2d {
if (this._fogDensity !== undefined) {
out.density = this._fogDensity;
}
if (this._fogHeight !== 0) {
out.fogHeight = this._fogHeight;
}
if (this._fogHeightFalloff !== 0) {
out.heightFalloff = this._fogHeightFalloff;
}
const colour = this._fogOwnColor ?? this._fogOptions.color;
if (colour !== undefined) {
out.color = colour;
Expand Down Expand Up @@ -494,6 +535,11 @@ export default class Camera3d extends Camera2d {
(options.color instanceof Color
? options.color
: renderer.backgroundColor);
state.heightFalloff = this._fogHeightFalloff;
state.fogHeight = this._fogHeight;
// the height integral runs from the camera to the fragment, so the
// shaders need where the camera is on that axis
state.cameraY = this.pos.y;
state.color[0] = color.r / 255;
state.color[1] = color.g / 255;
state.color[2] = color.b / 255;
Expand Down
26 changes: 26 additions & 0 deletions packages/melonjs/src/camera/fog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,26 @@ export interface FogOptions {
* parsed into a colour this camera owns.
*/
color?: Color | string | [number, number, number];
/**
* World Y at which the fog is at its reference density. Only meaningful
* alongside a non-zero {@link FogOptions.heightFalloff}.
* @default 0
*/
fogHeight?: number;
/**
* How fast fog density drops with altitude, so mist pools in low ground
* instead of filling the sky as readily as the valley floor.
*
* **Zero — the default — is uniform fog**, which is the fog that shipped
* without this: `exp(0)` is 1, density is the same at every altitude, and
* the maths collapses exactly. Raise it and density falls off above
* `fogHeight`, leaving ridges and sky clear while the hollows stay thick.
*
* Render space is **Y-down**, so density rises as `y` INCREASES — the
* opposite sign to every published height-fog formula, which assume Y-up.
* @default 0
*/
heightFalloff?: number;
}

/**
Expand All @@ -73,4 +93,10 @@ export interface Fog3dState {
density: number;
/** straight (unpremultiplied) fog colour, 3 components in 0..1 */
color: Float32Array;
/** height falloff; 0 is uniform fog */
heightFalloff: number;
/** world Y the falloff is measured from */
fogHeight: number;
/** the camera's own world Y, which the height integral starts from */
cameraY: number;
}
23 changes: 23 additions & 0 deletions packages/melonjs/src/video/webgl/batchers/mesh_batcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const _EYE_POSITION = new Float32Array(3);
// scratches for the per-camera distance fog, unpacked per draw that needs it
const _FOG_COLOR = new Float32Array(3);
const _FOG_PARAMS = new Float32Array(4);
const _FOG_HEIGHT = new Float32Array(4);

/**
* A WebGL Batcher for rendering textured triangle meshes.
Expand Down Expand Up @@ -135,6 +136,9 @@ export default class MeshBatcher extends MaterialBatcher {
this.currentFogR = Number.NaN;
this.currentFogG = Number.NaN;
this.currentFogB = Number.NaN;
this.currentFogFalloff = Number.NaN;
this.currentFogHeight = Number.NaN;
this.currentFogCamY = Number.NaN;

// Retained geometry per mesh (model-space buffers uploaded once). A
// re-init means a new GL context or a fresh batcher life, so anything
Expand Down Expand Up @@ -1288,6 +1292,25 @@ export default class MeshBatcher extends MaterialBatcher {
this.currentFogInvRange = invRange;
this.currentFogDensity = density;
}
if (uniforms.uFogHeight != null) {
const falloff = mode !== 0 ? fog.heightFalloff : 0;
const height = mode !== 0 ? fog.fogHeight : 0;
const camY = mode !== 0 ? fog.cameraY : 0;
if (
falloff !== this.currentFogFalloff ||
height !== this.currentFogHeight ||
camY !== this.currentFogCamY
) {
_FOG_HEIGHT[0] = falloff;
_FOG_HEIGHT[1] = height;
_FOG_HEIGHT[2] = camY;
_FOG_HEIGHT[3] = 0;
shader.setUniform("uFogHeight", _FOG_HEIGHT);
this.currentFogFalloff = falloff;
this.currentFogHeight = height;
this.currentFogCamY = camY;
}
}
if (
uniforms.uFogColor != null &&
(r !== this.currentFogR ||
Expand Down
34 changes: 33 additions & 1 deletion packages/melonjs/src/video/webgl/shaders/mesh-instanced.vert
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,38 @@ mat4 instanceMatrix() {
vec4(aInstanceRow0.w, aInstanceRow1.w, aInstanceRow2.w, 1.0));
}

#ifdef FOG
uniform vec4 uFogHeight; // x = falloff (0 = uniform), y = reference world Y,
// z = the camera's world Y, w unused

// How much the height falloff scales the fog along this view ray.
//
// Density falls off exponentially with altitude, and an exponential integrates
// analytically along a straight segment, so the whole ray costs one `exp` and
// no marching. The result multiplies the distance, which leaves both fog
// curves exactly as they are.
//
// Render space is Y-DOWN: density rises as `y` INCREASES, the opposite sign to
// every published form of this. The `- uFogHeight.y` below is measured that
// way round, and there is a test that catches it being flipped.
//
// A falloff of 0 gives exactly 1: `kdy` is 0, the series limit is taken, and
// `exp(0)` is 1 — so uniform fog is not a special case, it is this with the
// dial at zero.
float fogHeightFactor(float worldY) {
float k = uFogHeight.x;
float dy = worldY - uFogHeight.z;
float kdy = k * dy;
// (exp(x) - 1) / x is 0/0 at x = 0, and a horizontal view ray — looking
// straight across a valley — is exactly that case. Take the limit rather
// than guarding, or the fog steps as the ray approaches horizontal.
float t = abs(kdy) < 1e-4 ? 1.0 : (exp(kdy) - 1.0) / kdy;
// clamped: a camera far below the reference height would otherwise
// overflow the exponential and whiten the frame
return exp(clamp(k * (uFogHeight.z - uFogHeight.y), -30.0, 30.0)) * t;
}
#endif

void main(void) {
mat4 instance = instanceMatrix();
gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * instance
Expand All @@ -62,7 +94,7 @@ void main(void) {
// scene. The clip position above keeps its own product: re-associating it
// could shift vertices by an ulp, and a scene without fog must be unchanged.
vec4 viewPos = uViewMatrix * uModelMatrix * instance * vec4(aVertex, 1.0);
vFogDepth = length(viewPos.xyz);
vFogDepth = length(viewPos.xyz) * fogHeightFactor((uModelMatrix * instance * vec4(aVertex, 1.0)).y);
#endif

vec4 tinted = aColor * uTint;
Expand Down
35 changes: 34 additions & 1 deletion packages/melonjs/src/video/webgl/shaders/mesh-lit-instanced.vert
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,38 @@ mat4 instanceMatrix() {
vec4(aInstanceRow0.w, aInstanceRow1.w, aInstanceRow2.w, 1.0));
}

#ifdef FOG
uniform vec4 uFogHeight; // x = falloff (0 = uniform), y = reference world Y,
// z = the camera's world Y, w unused

// How much the height falloff scales the fog along this view ray.
//
// Density falls off exponentially with altitude, and an exponential integrates
// analytically along a straight segment, so the whole ray costs one `exp` and
// no marching. The result multiplies the distance, which leaves both fog
// curves exactly as they are.
//
// Render space is Y-DOWN: density rises as `y` INCREASES, the opposite sign to
// every published form of this. The `- uFogHeight.y` below is measured that
// way round, and there is a test that catches it being flipped.
//
// A falloff of 0 gives exactly 1: `kdy` is 0, the series limit is taken, and
// `exp(0)` is 1 — so uniform fog is not a special case, it is this with the
// dial at zero.
float fogHeightFactor(float worldY) {
float k = uFogHeight.x;
float dy = worldY - uFogHeight.z;
float kdy = k * dy;
// (exp(x) - 1) / x is 0/0 at x = 0, and a horizontal view ray — looking
// straight across a valley — is exactly that case. Take the limit rather
// than guarding, or the fog steps as the ray approaches horizontal.
float t = abs(kdy) < 1e-4 ? 1.0 : (exp(kdy) - 1.0) / kdy;
// clamped: a camera far below the reference height would otherwise
// overflow the exponential and whiten the frame
return exp(clamp(k * (uFogHeight.z - uFogHeight.y), -30.0, 30.0)) * t;
}
#endif

void main(void) {
mat4 instance = instanceMatrix();
vec4 worldPos = uModelMatrix * instance * vec4(aVertex, 1.0);
Expand All @@ -65,7 +97,8 @@ void main(void) {
// z, so fog holds steady as the camera turns instead of sliding across the
// scene. The clip position above keeps its own product: re-associating it
// could shift vertices by an ulp, and a scene without fog must be unchanged.
vFogDepth = length((uViewMatrix * worldPos).xyz);
vFogDepth = length((uViewMatrix * worldPos).xyz)
* fogHeightFactor(worldPos.y);
#endif

vec4 tinted = aColor * uTint;
Expand Down
Loading
Loading