melonJSを使ったゲーム制作で、目的に合う専門ガイドを選ぶための入口です。初期化から描画、入力、物理演算、音声、2D・3D機能まで案内します。
- melonJSで作る内容に合うガイドを探したいとき
- 新しいゲームの初期化手順を知りたいとき
- 3Dやモデル読み込みの参照先を探したいとき
melonJSのカメラ追従や画面効果、座標変換、図形描画を実装する際の要点を確認できます。描画のクリッピングやCanvasへの焼き込み、ピクセルの読み出しも扱います。
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のカメラ操作と即時描画について、実装時に押さえたい手順や注意点を確認できます。カメラに対象を追従させる設定、スクロール範囲、揺れやフェードなどの効果、ワールド座標と画面座標の変換を扱います。独自のdraw()内で図形・線・グラデーションを描く方法に加え、クリッピングやマスク、CanvasRenderTargetへの描画、レンダー結果からのピクセル取得も説明します。
カメラを設定しても画面が動かない場合や、ポインター操作がカメラ移動に引きずられる場合の原因確認に使えます。ゲーム画面に図形やグラデーションを描きたいとき、同じ描画を繰り返す負荷を抑えるため一度だけCanvasへ描きたいときにも役立ちます。3D位置を画面上のラベル位置へ変換する場面も対象です。
toImageData()などの読み出し処理は非同期です。同期のgetImageData()はWebGPUのレンダーターゲットでは使えません。
この紹介文は、公開されている SKILL.md をもとに AI(OpenAI Codex)が作成しました。正確な仕様は下の原文を確認してください。
インストールする前に、エージェントに与えられる指示の中身を確認できます。
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));
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.
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.
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.
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.
Camera3d#worldToScreenworldToLocal 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.
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.
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.
visibilityLayer and cullMaskEvery 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
cullMask is -1), so nothing changes until a game sets one.0 is drawn by no camera; a cullMask of 0 draws
nothing.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.visibleInAllCameras, and only if the layers match as well.int custom property named
visibilityLayer.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:
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.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.
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.
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.
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 |
|---|---|
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 |
| 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 |
melonjs-renderables — the draw() contract and anchorsmelonjs-3d — Camera3d, which extends Camera2d and keeps all of the aboveまだレビューはありません。使ってみた感想をお寄せください。
ログインなしで書き込めます。使ってみた感想、質問、動かなかった環境などを気軽にどうぞ。
まだコメントはありません。最初のコメントを書いてみませんか。
melonJSを使ったゲーム制作で、目的に合う専門ガイドを選ぶための入口です。初期化から描画、入力、物理演算、音声、2D・3D機能まで案内します。
melonJS 19.x以前の知識を20.x向けに更新し、初期化や描画効果、廃止・非推奨APIの変更点を確認して移行時の不具合を調べられます。
melonJSの3D・2.5Dゲーム向けに、カメラ、メッシュ、ライト、奥行き順、透明表現やフォグの設定と座標系の注意点を確認できます。
melonJSでglTF・GLBやOBJ・MTLの3Dモデルを読み込み、マテリアルやライト、アニメーション、影、インスタンスの扱い方を確認できます。
melonJSで効果音やBGMを読み込み、再生・停止・音量調整するほか、位置に応じた音や音声合成、ミックス全体へのエフェクト設定を扱うスキルです。
melonJSゲームのWeb向けビルドやネイティブアプリ化、ミニゲーム配信に向けた準備を進め、ブラウザー対応や容量の注意点も確認できます。