---
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.

**For a DIRECTION rather than a scalar, the tool is `slerp`**, on `Vector2d`
and `Vector3d` alike. Two reasons, and the second is the one that bites:

```js
// a turret, a ship, a camera: a facing that chases another facing
facing.slerp(toTarget, 1 - Math.exp((-6 * dt) / 1000));
```

1. Damping a facing component by component drags it through the inside of the
   circle, so it slows at the ends, rushes the middle and comes out shorter
   than it went in.
2. **Damping a scalar heading goes the wrong way round the wrap.** From 170
   degrees to -170 degrees is 20 degrees apart, and the numbers are 340 apart,
   so the turret sweeps all the way round the back. `slerp` works on the
   directions, takes the shorter arc by construction, and has no wrap to cross.

See `melonjs-3d` for the rest, including what happens between two exactly
opposite directions. 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.

**Create it once and keep it.** On a GPU backend, once drawn, its texture stays
on the GPU for as long as the renderer lives: a stage change does not release
it, and no public call does. A target made for every level, every restart or
every spawned object leaks one texture each time, so make it once (at module
level, say) and clear it for reuse. If you paint into it again after it has
been drawn, call `rt.invalidate(renderer)` before the next draw so the GPU copy
is refreshed.

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 |
| 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` |

## Related skills

- `melonjs-renderables` — the `draw()` contract and anchors
- `melonjs-3d` — `Camera3d`, which extends `Camera2d` and keeps all of the above
