melonjs-camera-and-drawing · git:20261011.ea10090 · 2026-10-11 · sha256 15a73a7f7929dadd
melonjs-camera-and-drawing git:20261011.ea10090A
Immutable. This exact content never changes and is served at /api/v1/blob/15a73a7f7929dadd.
---
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."
license: MIT
---
# Camera and immediate-mode drawing
## Following a target
```js
app.viewport.follow(player, app.viewport.AXIS.BOTH, 0.1);
```
**Pass the renderable, not `renderable.pos`.** `follow()` accepts a `Renderable`,
a `Vector2d` or a `Vector3d` and **throws** `invalid target for me.Camera2d.follow`
for anything else. A renderable's `pos` is an `ObservableVector3d`, which is not
a `Vector3d` subclass, so `follow(player.pos, …)` throws.
The third argument is a damping factor in `[0 .. 1]` — lower is smoother and
laggier, `1` snaps. Omit it for a hard lock (it defaults to `1`). It is
frame-rate independent since 19.7. `AXIS.HORIZONTAL` / `AXIS.VERTICAL` /
`AXIS.BOTH` / `AXIS.NONE`.
Call it from the followed entity's `onActivateEvent`, using `this.parentApp`.
**For anything that is NOT the camera, the same smoothing is `math.damp`.**
`follow()` is frame-rate independent for you; a value you smooth yourself is
not, and `lerp(current, target, alpha)` once per frame moves further on a slow
frame than on a fast one, so the feel changes with the frame rate:
```js
import { math } from "melonjs";
// dt is milliseconds, damp wants seconds
this.speed = math.damp(this.speed, targetSpeed, 5, dt / 1000);
this.turret.angle = math.damp(this.turret.angle, wanted, 8, dt / 1000);
```
`damp(current, target, lambda, dt)`. Higher `lambda` converges faster; it is
the rate, not a per-frame fraction. Use it for camera pans you drive yourself,
turret tracking, zoom, input smoothing and any value that should chase another
one — it is the tool that is easy to miss and easy to hand-write slightly
wrong.
## Bounds are the thing people forget
The camera's bounds default to the **renderer size**, so it cannot scroll until
you widen them to the world:
```js
app.viewport.setBounds(0, 0, mapWidth, mapHeight);
```
Symptom: `follow()` is called, looks right, and the view never moves. Note a
canvas resize resets the default camera's bounds to `(0, 0, w, h)`, so re-apply
them from a `CANVAS_ONRESIZE` handler if your game resizes. Loading a Tiled map
sets them for you from the map size.
## Camera effects
```js
app.viewport.shake(4, 200); // intensity, ms
app.viewport.fadeIn("#000000", 500); // fade TO black
app.viewport.fadeOut("#000000", 500); // start black, fade back to normal
app.viewport.colorMatrix.contrast(1.1).saturate(1.1);
app.viewport.addPostEffect(new VignetteEffect(app.renderer));
```
The fade pair is named from the *colour's* point of view, not the scene's:
`fadeIn` fades the colour in (screen goes to `color`), `fadeOut` fades it out
(screen starts at `color` and clears). Both default to a 1000 ms duration and
take an optional completion callback.
**`fadeIn` does not HOLD the colour.** The effect completes, the camera's own
update destroys it, and the screen snaps back — which is why the engine's
idiom does the work in the completion callback and then calls `fadeOut`. To
keep a filled screen across a state change, own the effect: a completed one
marked `isPersistent` is left alone, and taking it away again is yours to do.
```js
const hide = camera.addCameraEffect(
new FadeEffect(camera, { color: "#000", duration: 400, direction: "in" }),
);
hide.isPersistent = true; // survives completion AND Camera2d#reset()
// ... later, once the new stage is up
camera.removeCameraEffect(hide); // which destroys it for you
```
That is exactly what `state.transition()` is built on, so a hand-rolled
transition behaves the same way. Before 20.9 a persistent effect was destroyed
on completion like any other, which is why a transition used to flash the
scene it had just covered.
`colorMatrix` (`brightness` / `contrast` / `saturate` / `hueRotate` / `sepia` /
`invertColors`, all chainable) is the cheap way to grade a whole camera: the
camera appends it as a final post-effect pass by itself whenever the matrix is
non-identity, and drops it again after the frame. It is **not** shader-free —
it is a `ColorMatrixEffect`, i.e. a `ShaderEffect`, so like every post effect it
is inert on the Canvas renderer.
## Coordinate conversion
```js
app.viewport.worldToLocal(x, y); // world → screen
app.viewport.localToWorld(x, y); // screen → world
```
Both return a `Vector2d`; with no third argument that vector comes from the
shared pool, so in a per-frame path pass your own `v` as the third argument, or
release it with `getPool("vector2d").release(v)`.
Pointer events already carry every frame of reference: `gameWorldX`/`gameWorldY`
(world), `gameScreenX`/`gameScreenY` (screen), `gameLocalX`/`gameLocalY`
(relative to the registered region's container) and `gameX`/`gameY`, which is
whichever of world/screen matches the region — screen for a `floating` region,
world otherwise. Use the screen pair for anything that *moves* the camera, or
the drag feeds back on itself.
### In 3D: `Camera3d#worldToScreen`
`worldToLocal` is a 2D camera's offset subtraction — it knows nothing about a
perspective projection. To pin a label, damage number or marker to a point in a
3D scene, project it:
```js
const p = camera.worldToScreen(x, y, z); // view + projection, divide included
if (p !== null) { // null = at or BEHIND the camera
label.pos.set(p.x, p.y);
}
```
It returns canvas pixels with the origin top-left and **y down**, which is the
engine's own 2D draw space — so the result feeds a `floating` renderable or the
immediate-mode API directly, with no flip.
**Always check for `null`.** A point at or behind the camera (clip `w <= 0`)
has no honest screen position, and projecting it anyway yields a mirrored
coordinate that puts your label on the wrong side of the screen. Skip it.
Do not hand-roll this with `Matrix3d` — the perspective divide and the behind-
camera case are exactly what gets it wrong.
## Pointing a 3D camera
A 2D camera's only rotation is `roll`. A 3D one can be pointed at a target, and
optionally banked at the same time:
```js
camera.lookAt(target); // pitch and yaw; roll untouched
camera.lookAt(target, up); // and bank, where `up` is what should
// appear UP ON SCREEN
camera.setBasis(right, up, forward); // or pose it from a basis outright
```
`up` has no default, so the one-argument form behaves exactly as it always
did. Because this engine renders Y-down, a level horizon is `(0, -1, 0)`.
`setBasis` is the writable counterpart of `getBasis` and takes the camera's
own columns, whose `up` points down the screen: the opposite sense, one
negation apart. See `melonjs-3d` for the full account.
## Secondary cameras
Cameras are first-class — a minimap is a second camera with `autoResize = false`
and an overridden `postDraw`:
```js
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
renderable rules: read `this.pos`, and zero the anchor.
```js
draw(renderer) {
renderer.setColor("#ff8800");
renderer.fillRect(this.pos.x, this.pos.y, this.width, this.height);
renderer.setColor("#ffffff");
renderer.lineWidth = 2; // property, not setLineWidth()
renderer.stroke(new Line(0, 0, [/* … */]));
}
```
Geometry available: `Rect`, `RoundRect`, `Ellipse`, `Polygon`, `Line`, `Point`
(and a `Bounds`). `renderer.fill(shape)` and `renderer.stroke(shape)` take any
of them and throw `Invalid geometry for fill/stroke` for anything else.
`Gradient` gives linear and radial fills, built with
`renderer.createLinearGradient(x0, y0, x1, y1)` /
`createRadialGradient(x0, y0, r0, x1, y1, r1)` and then handed to
`renderer.setColor(gradient)`. `NoiseTexture2d` generates cellular and simplex
noise — including seamless tiling and `asNormalMap` — so you rarely need to ship
noise PNGs.
Two things to get right when one is animated:
- **Drive it yourself.** `update(dt)` advances the time axis and re-bakes; the
engine never calls it. An `animated: true` texture nobody updates is a still
image, with no error to say so.
- **Hand consumers the texture, not its canvas.** Re-baking bumps the
`Texture2d`'s `version`, and that bump is what tells the renderer to
re-upload. `getTexture()` returns the baked canvas, which carries no version
— so a mesh given the canvas shows frame one forever.
Set `seamless: true` for anything tiled by UVs past 1, or every repeat carries a
visible seam.
**And it still will not animate on a `Mesh`.** Driving `update(dt)` genuinely
re-bakes the field — the pixels change, the version increments — but the mesh
path does not pick the new copy up, so a 3D surface textured with an animated
noise renders frozen while every counter reports success. Verify with a
checksum of `getTexture()` before concluding the animation is broken; it is the
upload that is missing, not the bake. For a moving 3D surface, scroll the UVs
or move the geometry instead.
## Clipping and masking
```js
renderer.clipRect(x, y, w, h); // rectangular clip
renderable.mask = new Ellipse(/* … */); // shape mask on an object
```
Note that a `Container` with `clipping` enabled clips **after** its own
`translate(pos)`, in container-local coordinates — `clipRect(0, 0, width,
height)`. It also silently does nothing unless the container is non-root and has
a finite `width`/`height`: a `Container` built with no explicit size is
`Infinity × Infinity`, and `clipping = true` on it is a no-op.
## Bake once, draw many
When you would otherwise issue hundreds of identical draws every frame, render
them once into a `CanvasRenderTarget` and draw the result:
```js
const rt = new CanvasRenderTarget(width, height, { context: "2d" });
const ctx = rt.context; // a CanvasRenderingContext2D — draw into it once…
// then draw the backing canvas, not the target object:
renderer.drawImage(rt.canvas, x, y);
// or wrap it: new Sprite(0, 0, { image: rt.canvas })
```
Pass `rt.canvas`, never `rt` — a `CanvasRenderTarget` is a wrapper, not a
drawable, and the engine's own bakes (the particle emitter's default texture)
hand `.canvas` on for exactly that reason.
This is the sanctioned performance idiom before reaching for a custom shader.
**For a solid fill you do not need a canvas at all.** `Renderer.getWhitePixel()`
is a shared, lazily-created 1x1 white canvas, and stretching it is how you get
a coloured rectangle, a bar, a flat screen overlay, or a blank carrier for an
effect that paints from scratch:
```js
const bar = new Sprite(0, 0, { image: Renderer.getWhitePixel() });
bar.anchorPoint.set(0, 0);
bar.setTint("#39d0ff");
bar.scale(width, height); // a 1x1 texture, so scale IS the size in pixels
```
It is also the right texture under a `ShaderEffect` whose body ignores the
incoming colour, and it keeps the quad batched with everything else using it.
When you DO need a real canvas, `Renderer.createCanvas(width, height)` is the
engine's allocator: it hands back an `OffscreenCanvas` where that is safe and
throws on a zero dimension, instead of `document.createElement("canvas")`.
`renderer.toFrameTexture()` is the related tool for capturing the *current frame*
into a texture for a shader to sample.
## Reading pixels back
To grab what a render target holds, as pixels or as a file:
```js
const pixels = await rt.toImageData(); // ImageData, any backend
const blob = await rt.toBlob(); // Blob, "image/png" by default
const url = await rt.toDataURL(); // data: URL string
const bitmap = await rt.toImageBitmap(); // ImageBitmap
```
All four are promises, and that is not decoration. **WebGPU cannot read a
texture back synchronously** — it has to map a buffer, which only completes on
a later tick. So `toImageData()` is the portable readback and the other three
are built on it.
There is also a synchronous `getImageData()` on `CanvasRenderTarget` and
`WebGLRenderTarget`, because those two genuinely can. It is **not** part of the
`RenderTarget` contract and it **throws on a WebGPU target**. Reach for it only
in code that already knows which backend it is on; anything backend-agnostic
should await `toImageData()`.
```js
// fine in canvas-only or WebGL-only code
const data = rt.getImageData(0, 0, w, h);
// the version that works wherever the game runs
const data = await rt.toImageData(0, 0, w, h);
```
## Symptom → cause
| symptom | cause |
|---|---|
| `follow()` set but the view never moves | viewport bounds still at renderer size |
| `invalid target for me.Camera2d.follow` | passed `player.pos` — pass the renderable, a `Vector2d` or a `Vector3d` |
| camera drag jumps around | using world (`gameWorldX`) instead of screen (`gameScreenX`) coordinates |
| `colorMatrix` does nothing | it is a shader effect — inert on the Canvas renderer |
| custom drawing appears at the origin | `draw()` not reading `this.pos` |
| custom drawing offset by half its size | centred `anchorPoint` not zeroed |
| `setLineWidth` is not a function | it is the `lineWidth` property |
| clipping in the wrong place inside a container | container clips in local coordinates, after its own translate |
| `container.clipping = true` does nothing | container has no explicit size, so `width`/`height` are `Infinity` |
| baked target draws nothing / throws | passed the `CanvasRenderTarget`, not its `.canvas` |
| `getImageData()` throws on one machine but not another | that one is on the WebGPU renderer, which cannot read back synchronously — `await toImageData()` instead |
| frame rate drops with many static draws | bake into a `CanvasRenderTarget` instead |
| a 3D camera cannot be given an arbitrary up | `Camera3d#lookAt(target, up)` or `setBasis(right, up, forward)`; see `melonjs-3d` |
## Related skills
- `melonjs-renderables` — the `draw()` contract and anchors
- `melonjs-3d` — `Camera3d`, which extends `Camera2d` and keeps all of the above