Camera and immediate-mode drawing
Following a target
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.
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:
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
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.
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
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:
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.
Secondary cameras
Cameras are first-class — a minimap is a second camera with autoResize = false
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
renderable rules: read this.pos, and zero the anchor.
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. Ananimated: truetexture nobody updates is a still image, with no error to say so. - Hand consumers the texture, not its canvas. Re-baking bumps the
Texture2d'sversion, 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
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:
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.
renderer.toFrameTexture() is the related tool for capturing the current frame
into a texture for a shader to sample.
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 |
| frame rate drops with many static draws | bake into a CanvasRenderTarget instead |
Related skills
melonjs-renderables— thedraw()contract and anchorsmelonjs-3d—Camera3d, which extendsCamera2dand keeps all of the above