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
2 changes: 1 addition & 1 deletion DOC_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +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**, 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
Expand Down
4 changes: 3 additions & 1 deletion packages/melonjs/skills/melonjs-3d-assets/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
119 changes: 117 additions & 2 deletions 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, 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
---

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

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 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
branch that is skipped at runtime — the code is not there.

## Meshes

```js
Expand All @@ -130,9 +200,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

Expand Down Expand Up @@ -324,6 +435,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)` |
Expand Down
4 changes: 4 additions & 0 deletions packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions packages/melonjs/skills/melonjs-performance/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/melonjs/skills/melonjs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
8 changes: 8 additions & 0 deletions 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();

// 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);

Expand Down
22 changes: 22 additions & 0 deletions packages/melonjs/src/camera/camera2d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.).
Expand Down Expand Up @@ -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
Expand Down
Loading