Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
## [20.4.0] (melonJS 2) - _unreleased_

### Added
- **Soft transparency for the 3D tier** ([#1516](https://github.com/melonjs/melonJS/issues/1516)): a mesh now fades when you fade it. `setOpacity(0.5)` used to write premultiplied colour with blending off, so the mesh came out **darkened toward black** with the background contributing nothing — the same defect as the fully-transparent end of the range, which painted an opaque black silhouette until it was fixed. Draws that resolve to fractional alpha go into a **transparent pass** instead: replayed back-to-front after the opaque one, blending, writing no depth but still depth-tested, so transparent objects composite with each other and stay correctly hidden behind opaque geometry. Blending honours the renderable's existing `blendMode`, so `"additive"` gives glows. `transparent: true` opts in a soft-alpha *texture* (a glTF `alphaMode: "BLEND"` material, a glow sprite) that the automatic check cannot see into — `Sprite3d` drops its `alphaCutoff` default to `1/255` in that case, since a half-opacity cutout would otherwise discard the soft edge before blending saw it; `transparent: false` pins the old opaque behaviour. Sorting is per object, so intersecting transparent meshes remain order-dependent. Ground shadows now ride the same pass as its first client. Needs a GPU backend and a `Camera3d`; a scene with no transparent objects renders identically and never enters the queue
- **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

### Fixed
- Ground shadows: a scene could lose every blob it drew, in two different ways. Shadows are queued and replayed once the opaque meshes are down, and the replay used to be triggered by any batcher switch. That fired in two places it should not have: inside the screen-projection window `Container.draw` opens around a `floating` child, where world-space blobs were fed screen-space clip coordinates and landed off-screen — so a single HUD deleted every ground shadow in the scene — and in the middle of a scene whenever anything non-mesh sorted there (a particle emitter, a sprite), after which the meshes still to come painted straight over the blobs just put down. The queue now drains only where the world draw is actually finished: before a floating child, at the camera, and at end of frame. A **non-floating** 2D renderable drawn part-way through a 3D scene consequently draws under the shadows rather than over them
- Mesh: an alpha-cutout mesh **vanished entirely** once it faded past its own threshold. The cutout compared the *drawn* alpha against `alphaCutoff`, and fading a mesh scales every texel's alpha at once — so a `Sprite3d`, whose threshold defaults to `0.5`, popped out of existence at 49% opacity rather than fading through it, and a mesh at a higher cutoff shed its soft edges as it faded. The threshold now applies to the material's own alpha (the texel, times any opacity map), which is what it describes: a surface's cut-out shape does not change when the object fades
- Mesh: `alpha = 0` painted the mesh **opaque black** instead of hiding it, on both GPU backends. `CanvasRenderer.drawMesh` has always skipped when the global alpha falls below `1/255` — the same guard eight other Canvas draw methods use — but neither GPU renderer had it, and the mesh path disables blending (`MeshBatcher.bind`), so the alpha never reached the blend stage: the shader multiplied the colour by zero and wrote the result opaque. Hiding a mesh with `alpha = 0` left a black silhouette of it, and the same property behaved differently per backend. Both GPU renderers now skip at the same threshold
- Color: `toUint32()` returned a **negative** number for any colour with alpha at or above 0.5. The packing used `|`, which yields a signed int32, so a method named `toUint32` — documented as returning "a Uint32 ARGB representation" — handed back e.g. `-16711936` for green. Every consumer inside the engine writes it into a `Uint32Array` or a shader attribute where the bit pattern is identical, so nothing rendered wrong; what broke was reading the value back, comparing it, or printing it. The four unit tests covering this had the correct expectations commented out and the signed values asserted instead
- Container: a `floating` child in a depth-sorted world was ordered by its **screen** position. `Container.draw` gives a floating child `resetTransform()` and the camera's screen projection, so its `pos.x/y` are canvas pixels — but `_sortDepth` fed those to a world-space distance and subtracted the camera position on top. Two consequences, both visible under a `Camera3d`: a HUD's layering depended on where it sat on the screen (a score in a corner scored `20² + 16²` and floated above the scene, while the same text centred scored `512² + 200²` and sank behind it), and it drifted as the camera travelled, so a HUD that was correct at the start of a level was buried by the end of it. A floating child is now ordered by `|pos.z|` alone — a small depth draws in front of the world, a large one behind it — which is the convention screen-space content already used (a HUD at -150, a sky backdrop at -10000 or 100000), now holding at any camera position and from anywhere on the screen rather than by luck of the numbers
Expand Down
58 changes: 57 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, height fog, heightFalloff, 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, transparent, transparency, alpha, blendMode, fade, castGroundShadow, lit."
license: MIT
---

Expand Down Expand Up @@ -112,6 +112,58 @@ 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.

## Transparency

A mesh fades by setting its opacity — there is nothing else to switch on:

```js
ghost.setOpacity(0.4);
```

Meshes render in two phases. The **opaque pass** writes depth in sort order; the
**transparent pass** replays afterwards, back-to-front, blending and writing no
depth. A draw lands in the second whenever its alpha is fractional.

That default matters because the opaque path writes premultiplied colour with
blending off, so before this a faded mesh came out **darkened toward black**
rather than see-through — the background contributed nothing.

**`transparent: true`** when the transparency is in the TEXTURE rather than the
opacity — a soft-edged glow, smoke, a glTF material with `alphaMode: "BLEND"`.
The automatic check reads the draw's alpha and cannot see into a texture. Watch
`alphaCutoff` here: it discards texels *before* blending sees them, so a soft
edge needs a low cutoff (`Sprite3d` drops its own default to `1/255` when you
set `transparent: true`). The cutoff thresholds the MATERIAL's alpha, not the
drawn alpha, so a fading cutout mesh keeps its shape instead of disappearing at
its own threshold.

The glTF loader does **not** set this for you: one loaded mesh can merge several
materials and the flag routes the whole mesh, so a `"BLEND"` material sharing
geometry with an opaque one would drag the opaque half into the transparent pass.

**`transparent: false`** pins a mesh to the opaque pass however it is faded.

Blending uses the renderable's existing `blendMode`, so a glow is one property.
The advanced modes (`"overlay"`, `"difference"`, and the rest that need a
compositing pass) fall back to `"normal"` here, on both backends:

```js
const glow = new Mesh(0, 0, {
...quad, texture: glowTexture,
transparent: true, blendMode: "additive", alphaCutoff: 0,
});
```

| | |
| --- | --- |
| sorting | **per object**, by distance from the camera |
| intersecting transparent meshes | may pop as the camera moves — split them, or accept it |
| `InstancedMesh` | sorts as **one** object; instances draw in buffer order |
| needs | a GPU backend and a `Camera3d` |

Ground shadows ride the same pass — a blob is a decal, and decals are its first
client rather than a feature of their own.

## Distance fog

Off until you ask for it, and one call on the camera:
Expand Down Expand Up @@ -448,6 +500,10 @@ To branch rather than fail, read `app.renderer.supportsDepthBuffer` after
| distant surfaces z-fight | `near` too small for the scene scale |
| 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 faded mesh goes dark instead of see-through | `transparent: false` on it, or a 2D camera — the transparent pass needs a `Camera3d` |
| a soft-edged glow has hard edges | `alphaCutoff` discarded the soft texels; lower it |
| a glTF `alphaMode: "BLEND"` material draws opaque | the loader does not set `transparent` — one mesh can merge several materials, so set it yourself |
| two transparent objects pop as the camera moves | per-object sorting cannot order intersecting geometry |
| 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 |
Expand Down
10 changes: 9 additions & 1 deletion packages/melonjs/src/application/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
if (physic === "none") {
return { adapter: undefined, physicLabel: "none" };
}
if (physic === undefined || physic === "builtin") {

Check warning on line 96 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, the types have no overlap

Check warning on line 96 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, the types have no overlap

Check warning on line 96 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary conditional, the types have no overlap
return { adapter: undefined, physicLabel: "builtin" };
}
// instance or { adapter } object — extract and pass through. The
Expand All @@ -103,7 +103,7 @@
// predating the `physicLabel` field.
const adapter =
typeof physic === "object" && "adapter" in physic ? physic.adapter : physic;
return { adapter, physicLabel: adapter?.physicLabel ?? "builtin" };

Check warning on line 106 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 106 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 106 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary optional chain on a non-nullish value
}

/**
Expand Down Expand Up @@ -332,7 +332,7 @@

const merged = {
...defaultApplicationSettings,
...(options || {}),

Check warning on line 335 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 335 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 335 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary conditional, value is always truthy
};

const autoScale =
Expand Down Expand Up @@ -377,7 +377,7 @@
this.settings = settings;

// identify parent element and/or the html target for resizing
this.parentElement = device.getElement(settings.parent!);

Check warning on line 380 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 380 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 380 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Forbidden non-null assertion
if (typeof settings.scaleTarget !== "undefined") {
settings.scaleTarget = device.getElement(settings.scaleTarget);
}
Expand Down Expand Up @@ -506,7 +506,7 @@
// a previous init() attempt may have constructed a renderer before
// rejecting (e.g. the WebGPU device negotiation failed) — release
// it before building a new one, so a retry does not leak a backend
this.renderer?.destroy();

Check warning on line 509 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 509 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 509 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary optional chain on a non-nullish value

if (typeof this.settings.renderer === "number") {
switch (this.settings.renderer) {
Expand All @@ -517,7 +517,7 @@
// rejection falls through to the synchronous candidates
// (autoDetectRenderer) instead of failing the application.
let negotiated;
if (typeof globalThis.navigator?.gpu !== "undefined") {

Check warning on line 520 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 520 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 520 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary optional chain on a non-nullish value
const attempt = new WebGPURenderer(this.settings as any);
attempt.parentApplication = this;
try {
Expand Down Expand Up @@ -591,14 +591,14 @@
// negotiation for WebGPU. This await is why `init()` is asynchronous.
// (optional-chained so a duck-typed custom renderer that does not
// extend `Renderer` keeps working without the new lifecycle hook)
await this.renderer.init?.();

Check warning on line 594 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 594 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 594 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary optional chain on a non-nullish value

// destroy() may have run while the backend was negotiating its
// context — finishing the bootstrap now would resurrect a torn-down
// application (re-registered listeners, an appended canvas, a live
// GPU device nothing will ever release, and the `game` global
// pointing at a dead app)
if (this._destroyed) {

Check warning on line 601 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always falsy

Check warning on line 601 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always falsy

Check warning on line 601 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary conditional, value is always falsy
this.renderer.destroy();
throw new Error(
"Application: destroyed while init() was awaiting the renderer — " +
Expand Down Expand Up @@ -691,7 +691,7 @@
if (this.settings.consoleHeader) {
if (this.world.physic === "none") {
console.log("physics: disabled");
} else if (this.world.adapter) {

Check warning on line 694 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 694 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 694 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary conditional, value is always truthy
const a = this.world.adapter as {
constructor: { name: string };
name?: string;
Expand Down Expand Up @@ -722,7 +722,7 @@
// app starting time
this.lastUpdate = globalThis.performance.now();
// only register event listeners once per instance
if (!this.isInitialized) {

Check warning on line 725 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 725 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 725 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary conditional, value is always truthy
/* eslint-disable @typescript-eslint/unbound-method */
on(STATE_CHANGE, this.repaint, this);
on(STATE_RESTART, this.repaint, this);
Expand Down Expand Up @@ -1118,6 +1118,14 @@
// prepare renderer to draw a new frame
this.renderer.clear();

// The screen-space bracket `Container.draw` opens around a
// floating child is not exception-safe: a child that throws
// leaves the depth raised, and every later drain of the
// transparent queue then silently skips — for the rest of the
// session, since `reset()` only runs on a stage change. A frame is
// the natural boundary, and by here the previous one is over.
this.renderer.resetScreenSpace?.();

// 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
Expand All @@ -1135,7 +1143,7 @@
// ground shadows are held back until every opaque mesh is down
// (#1515); a scene that is nothing but meshes never switches away
// from mesh mode, so the pass is closed here
this.renderer.flushGroundShadows();
this.renderer.flushTransparent();

// flush/render our frame
this.renderer.flush();
Expand Down
2 changes: 1 addition & 1 deletion packages/melonjs/src/camera/camera2d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1056,7 +1056,7 @@ export default class Camera2d extends Renderable {
// world is down, and this is where that is true — still inside the
// camera's FBO/post-effect bracket, so they land in the frame the
// camera is about to resolve rather than after it has been composited.
renderer.flushGroundShadows();
renderer.flushTransparent();

// draw the viewport/camera effects
this.drawFX(renderer);
Expand Down
13 changes: 12 additions & 1 deletion packages/melonjs/src/renderable/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -1297,7 +1297,7 @@ export default class Container extends Renderable {
// so they cannot be replayed once it is installed, and this
// is where they belong in the order anyway: over the world,
// under the overlay about to be drawn.
renderer.flushGroundShadows?.();
renderer.flushTransparent?.();
renderer.beginScreenSpace?.();
renderer.save();
renderer.resetTransform();
Expand All @@ -1319,6 +1319,17 @@ export default class Container extends Renderable {
obj.postDraw(renderer);

if (isFloating) {
// Put down anything the overlay itself queued, while its
// SCREEN projection is still installed. A floating child
// shares the render target with the world, so its
// transparent meshes land in the same queue — but not the
// same projection, and replaying them after the restore
// below sends world-space geometry through the camera's
// perspective. Their vertices sit at view-space z = 0,
// which is the camera itself, and the perspective divide
// deletes them: a faded HUD mesh silently disappeared
// while the same mesh drew correctly at full opacity.
renderer.flushTransparentPass?.();
// Restore the projection the camera had installed for
// this draw pass — non-default cameras use a separate
// `worldProjection`; the default camera just uses
Expand Down
Loading