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
Binary file added packages/examples/public/assets/gltf/forest.glb
Binary file not shown.
254 changes: 254 additions & 0 deletions packages/examples/src/examples/forest/ExampleForest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
/**
* melonJS — mesh instancing example (#1508).
*
* A forest of 100 000 trees drawn from ONE copy of the geometry, in a single
* draw call. Nothing here builds the instances: the `.glb` carries them as
* `EXT_mesh_gpu_instancing` per-instance transforms, so `level.load()`
* produces an `InstancedMesh` on its own — the same one call a Tiled map
* takes.
*
* The `visibleInstanceCount` slider is the cheap level-of-detail knob:
* moving it changes how many trees are drawn without re-uploading anything.
*
* Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License.
* See `packages/examples/LICENSE.md` for full license + asset credits.
*/
import { DebugPanelPlugin } from "@melonjs/debug-plugin";
import {
Application,
Camera3d as Camera3dClass,
type CanvasRenderer,
type InstancedMesh,
input,
level,
loader,
type Pointer,
plugin,
Renderable,
state,
video,
type WebGLRenderer,
} from "melonjs";
import { createExampleComponent } from "../utils";

const base = `${import.meta.env.BASE_URL}assets/gltf/`;

// pixels per glTF unit
const SCALE = 26;

/** A dusk sky, drawn screen-fixed behind the scene. */
function bakeSky() {
const c = document.createElement("canvas");
c.width = 1;
c.height = 512;
const ctx = c.getContext("2d");
if (ctx) {
const g = ctx.createLinearGradient(0, 0, 0, 512);
g.addColorStop(0, "#1b3a63");
g.addColorStop(0.55, "#6f8fb5");
g.addColorStop(1, "#e5c9a3");
ctx.fillStyle = g;
ctx.fillRect(0, 0, 1, 512);
}
return c;
}

class SkyBackdrop extends Renderable {
private sky = bakeSky();

constructor() {
super(0, 0, 1, 1);
this.floating = true; // screen-space — exempt from the perspective camera
this.anchorPoint.set(0, 0);
}

override draw(renderer: CanvasRenderer | WebGLRenderer) {
renderer.drawImage(
this.sky,
0,
0,
1,
512,
0,
0,
renderer.width,
renderer.height,
);
}
}

const createGame = async () => {
let app: Application;
try {
app = new Application(1024, 768, {
parent: "screen",
renderer: video.AUTO,
scale: "auto",
cameraClass: Camera3dClass,
antiAlias: true,
});
await app.init();
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
globalThis.alert(
"This example couldn't start: no GPU renderer is available.\n\n" +
`Details: ${reason}`,
);
throw err;
}

plugin.register(DebugPanelPlugin, "debugPanel");

let torndown = false;
let pointerCleanup: (() => void) | null = null;
let domCleanup: (() => void) | null = null;

const setupScene = () => {
if (torndown) {
return;
}
// The sky is a screen-space backdrop, so it has to be drawn BEFORE
// the meshes. Under `Camera3d` the world sorts on depth, and this
// camera looks along +Z — so "behind everything" is the largest
// depth, not the smallest. A negative z here reads as *nearest* and
// paints the whole forest over, which looks exactly like nothing
// rendering at all.
app.world.addChild(new SkyBackdrop(), 100000);

// the loader turned the instanced node into an InstancedMesh — find it
// so the slider can drive `visibleInstanceCount`
const trees = app.world.getChildByName("Trees")[0] as
| InstancedMesh
| undefined;

const camera = app.viewport as InstanceType<typeof Camera3dClass>;
// the ground runs to ±12 000 px at this scale, so the far plane has
// to reach past the diagonal or the tree line is clipped mid-scene
camera.setClipPlanes(SCALE, 70000);

// Framed from INSIDE the forest rather than above it. The trees are
// only ~3 units tall against a scatter 300 units across, so a camera
// placed by "distance from the centre" ends up tens of tree-heights
// up and the whole thing reads as a lawn seen from a plane. Eye
// height and horizontal distance are therefore separate: the eye sits
// just under the canopy and looks level, which is what makes it a
// forest view.
let yaw = -0.54;
let eyeHeight = 420; // px — just above the canopy, looking out over it
let distance = 9000; // px out from the centre, horizontally
const clamp = (v: number, lo: number, hi: number) =>
Math.max(lo, Math.min(hi, v));

const updateCam = () => {
distance = clamp(distance, 900, 18000);
eyeHeight = clamp(eyeHeight, 60, 4000);
// up is -Y in render space
camera.pos.set(Math.sin(yaw) * -distance, -eyeHeight);
camera.depth = -Math.cos(yaw) * distance;
// look level, at the far tree line rather than down at the floor
camera.lookAt(0, -eyeHeight * 0.92, 0);
};
updateCam();

// drag to orbit — screen coordinates, not world ones: orbiting moves
// the camera every frame, so a world-projected pixel would map
// somewhere new on each move and the drag would jump
const ORBIT_SENSITIVITY = 0.0022;
let dragging = false;
let lastX = 0;
let lastY = 0;
input.registerPointerEvent("pointerdown", camera, (ev: Pointer) => {
dragging = true;
lastX = ev.gameScreenX;
lastY = ev.gameScreenY;
});
input.registerPointerEvent("pointerup", camera, () => {
dragging = false;
});
input.registerPointerEvent("pointermove", camera, (ev: Pointer) => {
if (!dragging) {
return;
}
yaw += (ev.gameScreenX - lastX) * ORBIT_SENSITIVITY;
// vertical drag rises above the canopy instead of tilting, so the
// view stays a forest view at every height
eyeHeight += (ev.gameScreenY - lastY) * 6;
lastX = ev.gameScreenX;
lastY = ev.gameScreenY;
updateCam();
});
pointerCleanup = () => {
input.releasePointerEvent("pointerdown", camera);
input.releasePointerEvent("pointerup", camera);
input.releasePointerEvent("pointermove", camera);
};

// the LOD knob: how many of the instances to draw
const total = trees?.instanceCount ?? 0;
const panel = document.createElement("div");
panel.style.cssText =
"position:absolute;top:60px;left:16px;z-index:1000;" +
"font-family:sans-serif;font-size:12px;color:#f0e6d8;" +
"text-shadow:0 1px 2px rgba(0,0,0,0.7);";
const readout = document.createElement("div");
const slider = document.createElement("input");
slider.type = "range";
slider.min = "0";
slider.max = String(total);
slider.value = String(total);
slider.style.cssText = "width:220px;display:block;margin-top:6px;";
const render = () => {
readout.textContent =
`${slider.value} / ${total} trees · 1 geometry · 1 draw call` +
(trees ? "" : " (instanced node not found)");
};
slider.addEventListener("input", () => {
if (trees) {
// no re-upload: the records stay put, the draw is just shorter
trees.visibleInstanceCount = Number(slider.value);
}
render();
});
render();
panel.appendChild(readout);
panel.appendChild(slider);

const hint = document.createElement("div");
hint.textContent = "drag to orbit";
hint.style.cssText = "margin-top:8px;opacity:0.75;";
panel.appendChild(hint);

const parent = app.renderer.getCanvas().parentElement;
if (parent) {
parent.style.position = "relative";
parent.appendChild(panel);
}
domCleanup = () => {
panel.remove();
};
};

loader.preload(
[{ name: "forest", type: "glb", src: `${base}forest.glb` }],
() => {
state.change(state.DEFAULT, true);
// one call — the instanced node becomes an InstancedMesh, the
// ground stays an ordinary Mesh, and the authored sun lights both
level.load("forest", { scale: SCALE, onLoaded: setupScene });
},
);

return () => {
// Deliberately NOT app.destroy(): the example host re-parents the
// existing canvas on a same-example remount and assumes the engine is
// still alive, so destroying here leaves a dead canvas and a blank
// screen on the second visit. `torndown` covers the other order — an
// unmount while the 3 MB glb is still loading, after which setupScene
// must not register handlers nobody will ever remove.
torndown = true;
pointerCleanup?.();
domCleanup?.();
};
};

export const ExampleForest = createExampleComponent(createGame);
13 changes: 13 additions & 0 deletions packages/examples/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ const ExampleGltf = lazy(() =>
default: m.ExampleGltf,
})),
);
const ExampleForest = lazy(() =>
import("./examples/forest/ExampleForest").then((m) => ({
default: m.ExampleForest,
})),
);
const ExampleGltfCharacter = lazy(() =>
import("./examples/gltf/ExampleGltfCharacter").then((m) => ({
default: m.ExampleGltfCharacter,
Expand Down Expand Up @@ -440,6 +445,14 @@ const examples: {
description:
"A rigged blocky character (Kenney, CC0) loaded from GLB, using node-TRS animation over a rigid hierarchy.",
},
{
component: <ExampleForest />,
label: "Instanced Forest",
path: "forest",
sourceDir: "forest",
description:
"100 000 trees drawn from one geometry in a single call, scattered by the glTF asset itself.",
},
{
component: <ExampleNightCity />,
label: "Night City Flythrough",
Expand Down
2 changes: 2 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
- **Backend-neutral vertex formats and draw topologies** ([#1551](https://github.com/melonjs/melonJS/issues/1551)) — a vertex attribute can now be declared with a single `format` token (`"float32x3"`, `"unorm8x4"`) instead of a `size` + `type` + `normalized` triple, and a draw mode with a topology name (`"triangle-list"`, `"line-list"`). `Batcher.addAttribute` accepts three forms — a descriptor object, `(name, format, offset)`, and the existing `(name, size, glType, normalized, offset)` — and `Batcher.mode` accepts either vocabulary while still reading back as the GL enum. `Batcher.topology` is the new portable spelling. **The GL-enum form is supported indefinitely**, so custom batchers need no changes. Groundwork for [#1184](https://github.com/melonjs/melonJS/issues/1184): a format-declared layout needs no live rendering context, and describes itself to any backend. `VertexFormat` / `Topology` types and the `isVertexFormat` / `isTopology` / `resolveVertexFormat` / `PORTABLE_TOPOLOGIES` helpers are exported
- **A `"none"` blend mode on both GPU backends** — `setBlendMode("none")` disables blending outright (the source replaces the destination, alpha included). It was born as a WebGPU pipeline blend state; the WebGL renderer now honors it identically instead of silently falling back to `"normal"`. The related `setBlendEnabled`, `enableScissor` and `clearRenderTarget` renderer methods — WebGL-only before — are implemented on the WebGPU renderer as well, along with custom batcher overrides (`settings.batcher`/`settings.compositor`), the `settings.blendMode` startup value, `GPUVendor` (from the adapter info), and `failIfMajorPerformanceCaveat` (rejects a software fallback adapter, falling through to WebGL under AUTO)
- **Gradient and Text textures stopped power-of-two rounding** ([#1554](https://github.com/melonjs/melonJS/issues/1554)) — two allocation-stability schemes replace it. Gradients now rasterize into a **fixed 256×256 shared bake target** regardless of on-screen size and are stretched by the destination quad (visually equivalent: linear stop interpolation × linear texture filtering — verified pixel-identical on all three backends): the shared canvas is allocated once and never resized, every re-bake is a same-size texture update, and gradient memory is capped at 256 KB instead of growing with the largest gradient drawn. Text canvases now round to **32-pixel buckets** (grow-only, as before) instead of the next power of two: a ticking counter still re-bakes into identical dimensions (the cheap same-size upload path on every backend), while worst-case memory waste drops from up to 2× per axis to at most 31 px per axis
- **Mesh instancing** ([#1508](https://github.com/melonjs/melonJS/issues/1508)) — the new `InstancedMesh` draws one geometry many times in a **single call**, so cost scales with the number of instances rather than with `instances × vertices`. A forest of 100 000 trees is one 52-vertex geometry on the GPU plus a compact per-instance record each, instead of 100 000 copies of identical geometry — see the new **Instanced Forest** example, which renders exactly that at 60 fps on both GPU backends. `InstancedMesh` extends `Mesh`, so every existing setting works unchanged (`model` + `material` from an OBJ, raw geometry, `lit`, `cullBackFaces`, `rightHanded`, `tint`, `textureRepeat`, a custom `shader`); what it adds is the instance buffer. A record always carries a transform — packed as a **3×4 affine** rather than a full `mat4`, since the bottom row of an affine matrix is always `(0,0,0,1)` — plus two **opt-in** slots: `instanceColors` gives each instance a colour multiplied into the mesh tint, and `instanceData` gives it an opaque `vec4` that the built-in shading reads as emissive and a custom mesh shader may read as anything at all (a wind phase, an atlas offset, a random seed). Nobody pays for a slot they did not declare: the shader variants are compiled per declared combination, on first use. Placement is uniform-driven exactly as it is for a retained mesh, so **moving the whole group re-uploads nothing** and moving one instance re-uploads only that record; `visibleInstanceCount` draws the first N without touching the buffer at all, which is a distance-LOD knob costing one integer. `getBounds3d()` covers every instance so the group frustum-culls as one object. Requires a GPU backend (`renderer.supportsInstancing`, the new capability flag); the Canvas renderer falls back to drawing each instance individually — correct, and as slow as the scene it replaces
- **glTF `EXT_mesh_gpu_instancing`** ([#1508](https://github.com/melonjs/melonJS/issues/1508)) — authored instancing loads with no user code. A glTF node may carry per-instance `TRANSLATION` / `ROTATION` / `SCALE` accessors instead of being duplicated N times, which is what exporters write for linked duplicates; `level.load()` now turns such a node into an `InstancedMesh` while ordinary nodes stay ordinary meshes. `ROTATION` is accepted as float or as normalized `BYTE`/`SHORT` (the encoding exporters use to shrink large scatters), and any of the three attributes may be absent, taking its glTF default
- **`Mesh.needsUpdate`** ([#1507](https://github.com/melonjs/melonJS/issues/1507)) — signal that a mesh's geometry was edited in place (`originalVertices`, `uvs`, `indices`, normals or per-vertex colours), so the GPU copy is refreshed on the next draw. Moving, rotating, scaling, re-tinting or fading a mesh needs no signal — those are applied when drawing, not stored in the geometry
- **`antiAlias: true` now survives post effects, on both GPU backends** ([#1556](https://github.com/melonjs/melonJS/issues/1556)) — adding any post effect (a camera vignette, a chained blur, a mask around an effect) used to silently switch MSAA off: the scene rasterized into a single-sampled offscreen capture target, and the antialiased default framebuffer only ever received already-aliased pixels. Post-effect **capture** targets are now multisampled themselves — up to 4× (`min(4, MAX_SAMPLES)`) color + depth-stencil renderbuffers resolved through `blitFramebuffer` on WebGL, a per-target 4× texture resolved by the render pass on WebGPU — so rotated sprites, shape edges and 3D geometry keep their smoothed edges under an effect chain. Ping-pong intermediates deliberately stay single-sampled: effect blits are screen-aligned quads with no geometric edges to antialias. Verified pixel-equivalent to the no-effect MSAA output on both backends (an edge probe finds the identical intermediate-coverage signature — 3 distinct levels, the 4× quantization — with and without an active effect). Frame captures keep working mid-bracket: `toFrameTexture()` resolves the multisampled target before copying (reading from a multisampled framebuffer is a GL error), then restores it

Expand Down
2 changes: 2 additions & 0 deletions packages/melonjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { DropTarget } from "./renderable/dragndrop.js";
import Entity from "./renderable/entity/entity.js";
import FrameAnimation from "./renderable/frameAnimation.js";
import ImageLayer from "./renderable/imagelayer.js";
import InstancedMesh from "./renderable/instanced_mesh.js";
import Mesh from "./renderable/mesh.js";
import NineSliceSprite from "./renderable/nineslicesprite.js";
import Renderable from "./renderable/renderable.js";
Expand Down Expand Up @@ -202,6 +203,7 @@ export {
Gradient,
HologramEffect,
ImageLayer,
InstancedMesh,
InvertEffect,
isPortableTopology,
isTopology,
Expand Down
11 changes: 10 additions & 1 deletion packages/melonjs/src/level/gltf/GLTFModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
} from "../../loader/parsers/gltf.js";
import { parseAnimationOptions } from "../../renderable/animation.ts";
import Container from "../../renderable/container.js";
import InstancedMesh from "../../renderable/instanced_mesh.js";
import Mesh from "../../renderable/mesh.js";
import { fillInstances } from "./GLTFScene.js";
import { sampleChannel } from "./gltf_sampler.js";

/**
Expand Down Expand Up @@ -122,7 +124,11 @@ export default class GLTFModel extends Container {
this._world[idx] = new Array(16);

for (const prim of node.primitives) {
const mesh = new Mesh(0, 0, {
// an instanced node inside an ANIMATED asset still instances:
// the node's own TRS is animated, the records place the copies
// within it
const MeshClass = prim.instances ? InstancedMesh : Mesh;
const mesh = new MeshClass(0, 0, {
vertices: prim.vertices,
uvs: prim.uvs,
indices: prim.indices,
Expand All @@ -147,6 +153,9 @@ export default class GLTFModel extends Container {
// thin/flat double-sided parts must not be back-face culled
cullBackFaces: prim.doubleSided !== true,
});
if (prim.instances) {
fillInstances(mesh, prim.instances);
}
const f = prim.baseColorFactor;
if (f) {
mesh.tint.setColor(
Expand Down
Loading
Loading