본문으로 건너뛰기
cccskills
무료GitHub에 공개

melonjs-camera-and-drawing

Use this skill to control melonJS cameras and draw shapes, lines, and gradients. It covers target following, camera bounds and effects, coordinate conversion, clipping, rendering into a canvas target, and reading rendered pixels for images or data.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

원문원문 설명 보기

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, offset, look ahead, 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.

melonjs/melonJS스타 6,429(GitHub 6,429 + cccskills 0)

실행 점수 미검증

설치 방법 보기

이럴 때 유용합니다

  • Making a camera follow a player within world bounds
  • Converting pointer or 3D positions to screen coordinates
  • Drawing shapes or gradients in a custom draw method
  • Baking repeated artwork and reading pixels from a render target

포함된 파일(1)

  • SKILL.md19.0 KB

SKILL.md(원문)

설치하기 전에 에이전트에게 전달되는 지시 내용을 확인할 수 있습니다.

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.

To frame the target off centre, set offset; do not follow a helper object. It shifts the view without moving the camera, in world units, and is applied after following and the bounds, so the view can show past them:

app.viewport.follow(player, app.viewport.AXIS.BOTH, 0.1);
app.viewport.offset.set(0, -70);   // look a little above the feet

// or ahead of where the player faces, eased (see math.damp below)
const ahead = facingLeft ? -80 : 80;
app.viewport.offset.x = math.damp(app.viewport.offset.x, ahead, 4, dt / 1000);

Culling, tile layers, parallax ImageLayers, lights and the pointer (localToWorld, worldToLocal) all count it. shake() adds its jitter on top and gives the value back when it ends, so setting it before or during a shake is fine. reset() clears it.

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:

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:

// 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:

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.

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.

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

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.

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:

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:

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.

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:

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

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.

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:

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:

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

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

symptomcause
follow() set but the view never movesviewport bounds still at renderer size
invalid target for me.Camera2d.followpassed player.pos — pass the renderable, a Vector2d or a Vector3d
camera drag jumps aroundusing world (gameWorldX) instead of screen (gameScreenX) coordinates
colorMatrix does nothingit is a shader effect — inert on the Canvas renderer
custom drawing appears at the origindraw() not reading this.pos
custom drawing offset by half its sizecentred anchorPoint not zeroed
setLineWidth is not a functionit is the lineWidth property
clipping in the wrong place inside a containercontainer clips in local coordinates, after its own translate
container.clipping = true does nothingcontainer has no explicit size, so width/height are Infinity
baked target draws nothing / throwspassed the CanvasRenderTarget, not its .canvas
getImageData() throws on one machine but not anotherthat one is on the WebGPU renderer, which cannot read back synchronously — await toImageData() instead
frame rate drops with many static drawsbake into a CanvasRenderTarget instead
GPU memory grows with every level or restarta CanvasRenderTarget created each time: make one and reuse it
a repainted CanvasRenderTarget still shows the old pixelscall rt.invalidate(renderer) after painting
a 3D camera cannot be given an arbitrary upCamera3d#lookAt(target, up) or setBasis(right, up, forward); see melonjs-3d
particles or effects clutter a minimapevery camera draws everything: put them on their own visibilityLayer and leave it out of the minimap's cullMask
something is missing from one camera onlyits visibilityLayer, or a container's above it, shares no bit with that camera's cullMask
a renderable shows in no camera at allits visibilityLayer is 0, or the world's own layer is left out

Related skills

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

리뷰

아직 리뷰가 없습니다. 사용해 본 소감을 남겨 주세요.

댓글

로그인 없이 쓸 수 있습니다. 사용 후기, 질문, 작동하지 않은 환경 등을 편하게 남겨 주세요.

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

보내면 바로 공개됩니다. 이메일 주소나 전화번호 같은 개인 정보는 쓰지 마세요. URL은 2개까지입니다.

0 / 1,000자

이 양식은 reCAPTCHA로 보호되며 Google 개인정보처리방침 및 서비스 약관이 적용됩니다.

같은 저장소의 스킬

melonjs

무료

Use this entry point to find the melonJS guide for a game development task, from application setup and scenes to sprites, input, physics, audio, and 3D. It also points to companion packages and the API index when the guides do not cover a need.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

  • Choose a guide for a melonJS game task
  • Find the right setup steps for a new game
  • Locate guidance for a 3D scene or imported model
melonjs/melonJS6.4천2026년 10월 29일 업데이트

Update older melonJS code for version 20.x. Review the asynchronous Application setup, renderer changes, post-effect API, and deprecated exports so you can identify outdated patterns when migrating or debugging expected APIs.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

  • Migrating a game from melonJS 19.x
  • Checking why an older API is missing
  • Replacing deprecated shader assignments
melonjs/melonJS6.4천2026년 10월 29일 업데이트

Build 3D or 2.5D scenes in melonJS with cameras, meshes, billboards, lights, shadows, and depth sorting. This guide explains the engine’s Y-down and +Z-forward coordinates, camera setup, transparency, and fog, and identifies features that require a GPU renderer instead of the Canvas fallback.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

  • Setting up a Camera3d for a game stage
  • Placing meshes, billboards, or repeated instances
  • Fixing depth order or transparency in a 3D scene
melonjs/melonJS6.4천2026년 10월 29일 업데이트

Load glTF, GLB, or OBJ/MTL assets into melonJS and understand how their materials, lights, animations, shadows, and instances are handled. The guide explains loader options and common format limitations so you can configure scenes and diagnose missing or incorrectly rendered asset features.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

  • Loading a glTF or GLB scene into a melonJS level
  • Adding a static OBJ model with its MTL material
  • Checking why imported materials or lights look different
melonjs/melonJS6.4천2026년 10월 29일 업데이트

Manage sound in melonJS games: load audio, play effects and looping background tracks, control volume, and place sounds in the game world. It also covers generated tones and noise, whole-mix effects, and common causes of silent playback.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

  • Loading sound files and choosing supported audio formats
  • Playing sound effects and looping background music
  • Positioning sounds around a player or game object
melonjs/melonJS6.4천2026년 10월 29일 업데이트

Prepare a melonJS game for web builds, native app packaging, or mini-game platforms. The guide covers project scaffolding, browser syntax support, transpiling, asset-loading constraints, and practical bundle-size considerations.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

  • Scaffolding a new melonJS project
  • Preparing a game for iOS or Android packaging
  • Checking support for older browsers
melonjs/melonJS6.4천2026년 10월 29일 업데이트

melonjs의 스킬 모두 보기

이 스킬의 문제 신고