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 .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "melonjs",
"version": "20.10.1",
"version": "20.11.0",
"description": "Build games with melonJS — 24 guides to the 2D, 2.5D and 3D HTML5 game engine, its conventions and idioms, so generated code runs the first time.",
"author": {
"name": "melonJS",
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ A modern & fully featured HTML5 game engine
-------------------------------------------------------------------------------
![melonJS](https://melonjs.org/img/alex4-github.png)

[melonJS](https://melonjs.org/) is an open-source 2.5D game engine designed for indie developers — perspective and orthogonal cameras, GPU-accelerated tilemap rendering, post-processing effects, custom shaders, 3D mesh support, polygon-accurate physics, modern Tiled workflows, and high performance. Runs on WebGPU, WebGL 2 or Canvas2D with automatic fallback. The whole engine is ~250 KB minzipped of vanilla JS/TS, with no dependencies and no toolchain lock-in. Built with modern class syntax and bundled with [esbuild](https://esbuild.github.io).
[melonJS](https://melonjs.org/) is an open-source 2D & 3D game engine designed for indie developers — perspective and orthogonal cameras, GPU-accelerated tilemap rendering, post-processing effects, custom shaders, 3D mesh support, polygon-accurate physics, modern Tiled workflows, and high performance. Runs on WebGPU, WebGL 2 or Canvas2D with automatic fallback. The whole engine is ~250 KB minzipped of vanilla JS/TS, with no dependencies and no toolchain lock-in. Built with modern class syntax and bundled with [esbuild](https://esbuild.github.io).

[melonJS](https://melonjs.org/) is licensed under the [MIT License](LICENSE.md) and actively maintained by the team at AltByte in Singapore.

Expand Down
9 changes: 3 additions & 6 deletions packages/examples/src/examples/waterOverworld/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,12 +229,9 @@ export class Male extends me.Sprite {
super(x, y, settings);
addStripAnimations(this, built.animations, "idle");

// hit box authored in frame-local (top-left) space; the sprite is
// center-anchored, so shift it by half the frame like the original
this.body = new me.Body(
this,
new me.Rect(35 - this.width * 0.5, 21 - this.height * 0.5, 9, 43),
);
// hit box in frame-local (top-left) space: shapes are placed in the
// frame the sprite draws in, whatever its anchor
this.body = new me.Body(this, new me.Rect(35, 21, 9, 43));
this.body.collisionType = me.collision.types.PLAYER_OBJECT;
this.body.setMaxVelocity(2, 15);
this.body.setFriction(0.5, 0);
Expand Down
41 changes: 23 additions & 18 deletions packages/melonjs/CHANGELOG.md

Large diffs are not rendered by default.

40 changes: 21 additions & 19 deletions packages/melonjs/package.json
Original file line number Diff line number Diff line change
@@ -1,31 +1,33 @@
{
"name": "melonjs",
"version": "20.10.1",
"description": "melonJS Game Engine",
"homepage": "http://www.melonjs.org/",
"version": "20.11.0",
"description": "A modern & fully featured HTML5 game engine (2D & 3D) for JavaScript/TypeScript, with WebGL/WebGPU rendering, built-in physics, Tiled map & glTF support and WebAudio.",
"homepage": "https://www.melonjs.org/",
"type": "module",
"sideEffects": [
"./src/polyfill/**"
],
"keywords": [
"2D",
"HTML5",
"javascript",
"TypeScript",
"es6",
"Canvas",
"WebGL",
"WebGL2",
"WebAudio",
"game-engine",
"html5-game-engine",
"javascript-game-engine",
"gamedev",
"game",
"engine",
"2d",
"3d",
"html5",
"javascript",
"typescript",
"webgpu",
"webgl",
"webgl2",
"canvas",
"webaudio",
"physics",
"tiled",
"tileset",
"mapeditor",
"browser",
"electron",
"mobile",
"cordova"
"tilemap",
"gltf",
"glb"
],
"repository": {
"type": "git",
Expand Down
36 changes: 35 additions & 1 deletion packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: melonjs-camera-and-drawing
description: "Use this skill for camera control and immediate-mode drawing in melonJS — following a target, viewport bounds, shake and fade, secondary cameras, and drawing shapes, lines and gradients inside a custom draw(). Covers world versus screen coordinate conversion, clipping and masking, baking with CanvasRenderTarget, and reading pixels back. Triggers on: Camera2d, viewport, follow, setBounds, shake, fadeIn, fadeOut, worldToLocal, localToWorld, colorMatrix, renderer.fill, renderer.stroke, Rect, Ellipse, Polygon, Line, Gradient, clipRect, mask, CanvasRenderTarget, NoiseTexture2d, lookAt, setBasis, toImageData, getImageData, toBlob, toDataURL, screenshot, read pixels."
description: "Use this skill for camera control and immediate-mode drawing in melonJS — following a target, viewport bounds, shake and fade, secondary cameras and what each one draws, and drawing shapes, lines and gradients inside a custom draw(). Covers world versus screen coordinate conversion, clipping and masking, baking with CanvasRenderTarget, and reading pixels back. Triggers on: Camera2d, viewport, follow, setBounds, minimap, second camera, split screen, cullMask, visibilityLayer, hide from a camera, shake, fadeIn, fadeOut, worldToLocal, localToWorld, colorMatrix, renderer.fill, renderer.stroke, Rect, Ellipse, Polygon, Line, Gradient, clipRect, mask, CanvasRenderTarget, NoiseTexture2d, lookAt, setBasis, toImageData, getImageData, toBlob, toDataURL, screenshot, read pixels."
license: MIT
---

Expand Down Expand Up @@ -191,6 +191,37 @@ 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`.

### What each camera draws: `visibilityLayer` and `cullMask`

Every camera draws the whole world. To keep something off one camera only, put
it on a layer that camera leaves out. A renderable's `visibilityLayer` is the
layers it is on, as bits (1, 2, 4...); a camera's `cullMask` is the layers it
draws. A camera draws a renderable only if the two share a bit:

```js
const PARTICLES = 2;
emitter.visibilityLayer = PARTICLES; // the particles go on layer 2
minimap.cullMask = ~PARTICLES; // the minimap draws every layer but 2
```

- By default every renderable is on layer 1 and every camera draws every layer
(`cullMask` is `-1`), so nothing changes until a game sets one.
- A container left out is left out with everything inside it, however deep:
set it on an emitter and none of its particles are drawn. The world itself
obeys it too, and so does what it contains: a child is drawn only if it and
every container above it share a bit with the camera.
- A renderable on layer `0` is drawn by no camera; a `cullMask` of `0` draws
nothing.
- It decides drawing only: what a camera leaves out still updates, collides and
receives pointer events.
- A `Light2d` a camera leaves out, itself or a container it is in, neither
glows, nor cuts its hole in the darkness, nor lights normal-mapped sprites in
that camera. A `Light3d` lights the meshes of every camera.
- It adds to the floating rule: a floating renderable is drawn by a secondary
camera only with `visibleInAllCameras`, and only if the layers match as well.
- On a Tiled object, set it as an `int` custom property named
`visibilityLayer`.

## Immediate-mode drawing

Inside a custom `draw(renderer)` you can draw shapes directly. Remember the two
Expand Down Expand Up @@ -349,6 +380,9 @@ const data = await rt.toImageData(0, 0, w, h);
| GPU memory grows with every level or restart | a `CanvasRenderTarget` created each time: make one and reuse it |
| a repainted `CanvasRenderTarget` still shows the old pixels | call `rt.invalidate(renderer)` after painting |
| a 3D camera cannot be given an arbitrary up | `Camera3d#lookAt(target, up)` or `setBasis(right, up, forward)`; see `melonjs-3d` |
| particles or effects clutter a minimap | every camera draws everything: put them on their own `visibilityLayer` and leave it out of the minimap's `cullMask` |
| something is missing from one camera only | its `visibilityLayer`, or a container's above it, shares no bit with that camera's `cullMask` |
| a renderable shows in no camera at all | its `visibilityLayer` is `0`, or the world's own layer is left out |

## Related skills

Expand Down
100 changes: 98 additions & 2 deletions packages/melonjs/skills/melonjs-display-and-scaling/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: melonjs-display-and-scaling
description: "Use this skill when a melonJS game looks blurry, soft, shimmering or cropped, or when choosing how the canvas fills its page. Covers the design resolution, scale and scaleMethod, scaleTarget, scaleRounding for pixel-perfect integer scaling, antiAlias, textureFilter, subPixel, devicePixelRatio, and which setting does which job. Triggers on: blurry, soft, fuzzy, shimmering, pixel art, pixel perfect, crisp, nearest, linear, scale, scaleMethod, scaleTarget, scaleRounding, integer scaling, antiAlias, textureFilter, subPixel, devicePixelRatio, retina, hidpi, design resolution, canvas size, canvas cropped, letterbox, image-rendering, upscale, resolution."
description: "Use this skill when a melonJS game looks blurry, soft, shimmering or cropped, or when choosing how the canvas fills its page. Covers the design resolution, scale and scaleMethod, scaleTarget, scaleRounding for pixel-perfect integer scaling, antiAlias, textureFilter and linear-mipmap for what is drawn small, subPixel, syncToDisplay for steady motion, devicePixelRatio, and which setting does which job. Triggers on: blurry, soft, fuzzy, shimmering, pixel art, pixel perfect, crisp, nearest, linear, scale, scaleMethod, scaleTarget, scaleRounding, integer scaling, antiAlias, textureFilter, linear-mipmap, mipmaps, minimap shimmer, zoomed out, subPixel, syncToDisplay, stutter, judder, uneven scrolling, devicePixelRatio, retina, hidpi, design resolution, canvas size, canvas cropped, letterbox, image-rendering, upscale, resolution."
license: MIT
---

Expand All @@ -23,7 +23,9 @@ separate decisions. Knowing which setting owns which is most of the work.
| how a texture is sampled | `antiAlias`, `textureFilter` | nearest or linear, and the CSS upscale filter |

`subPixel` is a fifth, orthogonal to all of them: it decides whether a
renderable may sit on a fractional position WITHIN the frame.
renderable may sit on a fractional position WITHIN the frame. `syncToDisplay`
is about the frames in a row rather than one frame: whether motion advances
by the same amount on each.

## Design resolution

Expand Down Expand Up @@ -155,6 +157,42 @@ can have smooth model edges and nearest-sampled sprites. The presentation half
is not separable: there is no way to get MSAA silhouettes and a hard-edged
upscale at the same time.

## Drawn small: `textureFilter: "linear-mipmap"`

A sprite drawn much smaller than its texture (a zoomed-out camera, a minimap,
a scaled-down sprite) shimmers as it moves, even with linear sampling: each
screen pixel covers many texels and linear sampling blends only four of them.
`"linear-mipmap"` samples the texture's mipmaps, smaller copies averaged in
advance, wherever something is drawn below its size:

```js
const app = new Application(1280, 720, { textureFilter: "linear-mipmap" });
```

At its size or larger a texture draws as it does under `"linear"`, so a game
whose main view is at its size and whose minimap is zoomed out gets a smooth
minimap and an unchanged view. It covers sprites, text and tile layers, not a
video nor a texture the engine renders into, and a `nearest` texture keeps to
its full size. GPU backends only; on WebGPU each texture it applies to takes
about a third more memory.

A small mipmap level's texels span many of the art's pixels, so a sample near
the edge of a frame would reach into the next frame of the atlas. Under
`"linear-mipmap"` every sprite and tile keeps its samples half a texel of the
level it reads inside its own frame, so the next frame no longer streaks along
its edges. Two limits:

- a level's texel is an average of a square of the art, 2, 4, 8 pixels a side
and so on. Where a frame's edge falls inside such a square, its outermost
texel already holds some of the neighbour: drawn very small, a frame of an
atlas packed edge to edge can show a faint line of it. Frames on a grid of
a power of two (a 32 or 64 px sheet) are free of it, and a padding of a few
pixels between frames removes it elsewhere;
- a sprite drawn through a `ShaderEffect` samples the texture in the effect's
own program, which is not kept to the frame.

A repeating pattern is left to wrap.

## `subPixel` is about position, not scale

`subPixel: false` (the default) truncates a renderable's translation to whole
Expand All @@ -168,6 +206,58 @@ snaps positions and is then scaled by 2.667 has thrown that work away at the
last step, which is the contradiction `scaleRounding: "integer"` exists to
resolve.

`subPixel: true` alone does not make motion smooth: with nearest sampling a
sprite between two pixels is still drawn on one of them, so a scene moving
4.5 pixels a frame jumps by 4 and 5 in turn. Rendered art (not pixel art)
moves smoothly with `textureFilter: "linear"` as well, which blends it across
the two, while the upscale stays crisp (`antiAlias: true` would also smooth the
whole canvas as it is scaled up, which is what makes it look blurry). A still
scene is only sharp when the camera rests on a whole pixel, so give it a
target on whole pixels:

```js
const app = new Application(1280, 720, {
scale: "auto",
subPixel: true,
textureFilter: "linear",
syncToDisplay: true,
});
// the camera follows a point on whole pixels, kept on the hero: it glides
// between pixels while following, and comes to rest on one
const focus = new Vector2d();
app.viewport.follow(focus, app.viewport.AXIS.BOTH, 0.12);

// in the hero's update(dt)
focus.set(Math.round(this.pos.x), Math.round(this.pos.y));
```

## `syncToDisplay` is about steady motion

The game updates in fixed steps, 60 a second by default, as many as the time
since the last frame holds. Browsers report that time with up to a millisecond
of jitter, so on a 60 Hz display a frame now and then gets no update and the
next one gets two: the world holds still for a frame, then moves twice as far.
Scrolling, and anything else moving steadily, stutters while the frame rate
reads a solid 60.

```js
const app = new Application(1280, 720, { scale: "auto", syncToDisplay: true });
```

With it on, frame times are counted in whole display frames and what is
rounded off is carried to the next frame: one update every frame at 60 Hz, one
every other frame at 120 Hz, and the game keeps its speed. A display within 2%
of `timer.maxfps` (60 by default), or of 2, 3 or 4 times it, or a half, a third
or a quarter of it, is counted at exactly that rate, so a 59.94 Hz screen runs
the game 0.1% slower than the wall clock; leave it off when the updates must
keep to the wall clock exactly. The display's rate is learned as the game runs,
and again when it changes (the window moved to another screen). At a refresh
that is no multiple of the update rate (144 Hz, 165 Hz) the steps cannot fall
evenly however the time is counted.

It is off by default. It sets `timer.syncToDisplay` when the application is
initialized, which a game can also switch at any time.

## Soft text next to crisp sprites

If everything is crisp EXCEPT the text, the scale factor is not the cause: a
Expand Down Expand Up @@ -266,6 +356,12 @@ const app = new Application(1024, 576, {
| the canvas is cropped or overflows its box | automatic scaling measured the wrong element; name it with `scaleTarget` |
| the canvas grows on every resize | the measured ancestor is auto-sized around the canvas itself |
| things shimmer as they move | fractional positions; check `subPixel`, and that the scale factor is whole |
| scrolling stutters while the frame rate reads 60 | a frame now and then runs no update and the next two; set `syncToDisplay: true` |
| moving rendered art jumps by whole pixels with `subPixel: true` | nearest sampling still draws it on one pixel; add `textureFilter: "linear"` |
| the whole game looks blurry with `antiAlias: true` | it also smooths the canvas as it is scaled up; use `textureFilter: "linear"` instead |
| a minimap or a zoomed-out view shimmers as it moves | linear sampling blends 4 texels where a pixel covers many; use `textureFilter: "linear-mipmap"` |
| faint lines along sprites drawn small, from the next frame of the atlas | under `"linear-mipmap"` the built-in sprite shaders keep to the frame, but not a `ShaderEffect`'s own sample, and the smallest levels still mix a frame not on a power-of-two grid with its neighbour: pad the atlas |
| a still scene looks a little soft with linear sampling | the camera rests between two pixels; follow a target rounded to whole pixels |
| 3D model edges are jagged | `antiAlias: false` also disables MSAA; turn it on and use `textureFilter` for textures |
| textures are smoothed when they should be blocky | `antiAlias: true` makes the default texture filter linear; set `textureFilter: "nearest"` |
| `scaleRounding: "integer"` changes nothing | it applies to `scaleMethod: "fit"` only; the others resize the renderer |
Expand Down
33 changes: 33 additions & 0 deletions packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,38 @@ loader.preload([{ name: "ramp", type: "shader", data: myFragmentBody }]);
label.addPostEffect(loader.getShader("ramp"));
```

### Sprites sharing one effect share one draw

Sprites drawn one after the other through the **same** effect instance are one
draw call, as plain sprites are, even when they come from different frames of
an atlas: a hundred enemies outlined by one shared `OutlineEffect` draw once.
Each still draws with the values it was given: a uniform set to a **new**
value draws the sprites queued before it first (setting the value it already
has changes nothing). What splits the batch:

- a uniform taking a new value between two sprites: give a few sprites the
same value one after the other, rather than a different one each;
- a different texture (the effect path samples one texture per draw);
- a different effect instance, or a sprite without the effect in between;
- a `noise_uv` effect on sprites of different frames or sizes (its rect is
per sprite);
- a `screen_texture` effect, which draws each sprite on its own, as each one
samples a capture of everything drawn before it.

So share one instance (the loader's `"shader"` type hands one back, see
above) rather than constructing an effect per sprite: *n* instances are *n*
draws as well as *n* programs. One you construct yourself and share needs
`effect.shared = true`, or the first renderable destroyed destroys it for the
others (the loader's are marked shared already):

```js
const outline = new OutlineEffect(renderer, { color: [1, 1, 1] });
outline.shared = true; // you destroy it, when no enemy is left
for (const enemy of enemies) {
enemy.addPostEffect(outline);
}
```

### Declare a level's effects with that level's assets

An effect the loader knows about can be prepared before anything draws it; an
Expand Down Expand Up @@ -689,6 +721,7 @@ same question after construction.
| ported shader renders upside down | a hand-bound `toFrameTexture()` capture — GL is bottom-up, WebGPU top-down |
| shadow/smear offset flips on some draws | vertical UV offset not multiplied by `uUVYDir` |
| frame rate collapses with many blended sprites | an *advanced* mode (overlay, darken, …) — each draw is a capture plus a composite |
| many sprites with the same effect still cost a draw each | an effect instance per sprite, or a uniform set to a different value per sprite — share one instance and group sprites by value |
| animated shader never moves | `setTime` fed milliseconds, or the shader declares no `uTime` |
| squash and stretch only bulges, the height never changes, under Matter | the adapter rewrites `currentTransform` every step; set the scale from the sprite's `update()` |
| `SquishEffect` squishes the whole screen instead of the sprite | it is chained with another effect, or on the camera; give the sprite it alone |
Expand Down
Loading
Loading