本文へ移動
cccskills
無料GitHub で公開日本語紹介

melonjs-effects-and-shaders

melonJSでビネットやぼかしなどの画面効果を適用し、GLSLとWGSLのカスタムシェーダーや色調補正を扱う方法を案内します。GPUとCanvasでの違いも確認できます。

原文原文の説明を見る

Use this skill for post-processing effects, custom shaders, blend modes and colour grading in melonJS. Covers the built-in ShaderEffect presets, addPostEffect on renderables and cameras, writing a custom dual-language GLSL/WGSL effect, the screen_texture builtins, and why effects silently do nothing on the Canvas fallback. Triggers on: Tween, easing, animate an effect, ShaderEffect, addPostEffect, removePostEffect, getPostEffect, postEffectNeedsCapture, VignetteEffect, GlowEffect, BlurEffect, PixelateEffect, ScanlineEffect, shader, GLSL, WGSL, uniform, setUniform, setTexture, setTime, blendMode, colorMatrix, screen_texture, toFrameTexture, post effect, filter.

melonjs/melonJSスター 6,429(GitHub 6,429 + cccskills 0)

実行スコア 未検証

インストール方法を見る

こんなときに便利

  • スプライトに発光やぼかしを加えたいとき
  • 画面全体の色味を調整したいとき
  • 独自のシェーダー効果を作りたいとき
  • Canvasで効果が出ない原因を調べたいとき

日本語での紹介

できること

スプライトやカメラに、ビネット、グロー、ぼかし、ピクセル化などの組み込みエフェクトを追加する方法を扱います。画面全体のブルームや色調補正、ブレンドモードの使い分けも確認できます。独自のエフェクトを作る場合は、GLSLとWGSLの両方でシェーダー本体を書き、共通のユニフォームやテクスチャを設定する手順を参照できます。エフェクトの重ね方や有効・無効の切り替え、描画対象によって必要になるキャプチャ設定も説明します。

こんなときに便利

キャラクターに発光や揺れを加えたいとき、カメラ全体の色味を整えたいとき、独自の画面効果を実装したいときに役立ちます。GPU描画では効果が出るのにCanvasでは何も起きない場合や、HUDまで画面効果がかかる理由を調べる際にも使えます。

使い方の例

  • 「プレイヤーのスプライトにグローを追加してください」
  • 「画面全体にブルームと色調補正を適用してください」
  • 「WebGLとWebGPUの両方で動くシェーダーを作ってください」

注意点

シェーダー系の効果にはGPUバックエンドが必要で、Canvasでは動作しません。カスタム効果はGLSLとWGSLの両方を用意する必要があります。

この紹介文は、公開されている SKILL.md をもとに AI(OpenAI Codex)が作成しました。正確な仕様は下の原文を確認してください。

含まれるファイル(1)

  • SKILL.md37.4 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

Post effects and custom shaders

melonJS ships a catalogue of post effects and a dual-language shader system. The recurring trap is that every shader path needs a GPU backend and degrades to nothing on Canvas rather than erroring. Blend modes are the exception — those work on all three renderers.

Applying an effect

Effects attach to a renderable or to a camera (for full-screen grading):

import { VignetteEffect, GlowEffect } from "melonjs";

sprite.addPostEffect(new GlowEffect(app.renderer));
app.viewport.addPostEffect(new VignetteEffect(app.renderer));

renderable.shader = … is deprecated since 19.2.0, and its setter destroys whatever it replaces. Use addPostEffect / getPostEffect / removePostEffect.

A camera effect covers the HUD too

A camera's post-effect brackets the entire world draw — floating children included. So the obvious way to write a full-screen pass also washes over every HUD label in that world, which is rarely what you want.

To land a pass after the world but before the HUD, host the effect on a screen-filling floating renderable ordered below the HUD's z instead:

const quad = new Sprite(0, 0, { image: anyImage });
quad.anchorPoint.set(0, 0);
quad.scale(viewW / anyImage.width, viewH / anyImage.height);
quad.floating = true;                 // its own uv is now screen space
quad.blendMode = "additive";
quad.addPostEffect(myEffect);
world.addChild(quad, HUD_Z - 10);     // 2D: below the labels. See the note.

Two things follow from the quad filling the frame: its own uv is screen space (so a pass like this needs none of the screen_uv / screen_texture builtins), and the incoming color is the quad's own texture, which a body that paints from scratch can ignore entirely.

Depth here is easy to get backwards, and the rule FLIPS between the two sort modes. Under the default sortOn = "z" a higher z draws later, i.e. on top, so HUD_Z - 10 puts the pass under the labels as written above. Under sortOn = "depth", which every Camera3d scene sets, a floating child's sort key is pos.z * pos.z and the walk paints far to near, so the SMALLER magnitude is nearer and draws on top — HUD_Z - 10 there puts the pass OVER the labels, the opposite of the comment.

So in a 3D scene give the overlay a larger magnitude than the HUD, and give the HUD a small one (melonjs-3d and melonjs-ui-and-text both say a HUD wants a small depth, not the huge z that would put it on top in 2D).

Either way, verify it rather than reasoning about it: return a flat colour from the body for one frame and see what it tints.

A renderable that draws with PRIMITIVES has to ask to be captured

One effect is applied the cheap way: the renderable is drawn with the effect's own program instead of being captured offscreen and post-processed, which costs no render target. That is equivalent only when everything the renderable draws is a textured quad. fillRect, strokeRect and renderer.fill(shape) go through a batcher that never reads that shader, so one effect on such a renderable would silently do nothing, while two would work, because a chain always captures.

So a renderable of your own that draws with primitives says so:

class Bar extends Renderable {
    constructor(x, y, w, h) {
        super(x, y, w, h);
        this.postEffectNeedsCapture = true;   // ← or one effect is a no-op
    }
    draw(renderer) {
        renderer.fillRect(this.pos.x, this.pos.y, this.width, this.height);
    }
}

ProgressBar and Trail set it for you, and a sprite needs nothing: the flag defaults to false and only ever changes the single-effect case.

Toggle with enabled, do not remove

removePostEffect() destroys the effect — it calls effect.destroy() and frees GPU resources, so the instance cannot be re-added. Same for clearPostEffects() and for reassigning the deprecated .shader.

// ✗ destroys it; re-adding later fails or forces a recompile
sprite.removePostEffect(effect);

// ✓
effect.enabled = false;

An effect with effect.shared === true opts out of auto-destroy because it is reused across renderables.

Built-in effects

Twenty-three presets, every one constructed as new XEffect(renderer, options) and every one shipping both a GLSL and a WGSL body, so they all run on either GPU backend: VignetteEffect, ScanlineEffect (optional CRT curvature), BloomEffect, GlowEffect, BlurEffect, PixelateEffect, ChromaticAberrationEffect, DropShadowEffect, FlashEffect, OutlineEffect, ColorMatrixEffect, DesaturateEffect, DissolveEffect, TintPulseEffect, WaveEffect, SwayEffect, InvertEffect, SepiaEffect, HologramEffect, ShineEffect, RadialGradientEffect, SquishEffect, ToneMappingEffect.

Colors take an alpha. Every color option and setColor takes [r, g, b] or [r, g, b, a], each 0.0 to 1.0, and the alpha is the color's own opacity: a half-transparent outline, a glow at half strength, a flash that only half covers. [r, g, b] is opaque, as it always was. Any other length throws. RadialGradientEffect takes a Color instead, and its alpha scales the spot.

sprite.addPostEffect(new OutlineEffect(app.renderer, { color: [1, 1, 1, 0.5] }));

On a sprite cut from an atlas or a tileset, the effects that read around a pixel keep to the sprite's own frame. GlowEffect, OutlineEffect, BlurEffect, DropShadowEffect, ChromaticAberrationEffect, WaveEffect, SwayEffect, PixelateEffect and BloomEffect read the neighbouring frames as transparent, as if the sprite were drawn on its own, so they never show along its edges and the atlas needs no padding for them. Past the texture's own edge (a standalone image, or the outside of an atlas) nothing changes.

The effects that place a pattern place it on the renderable they draw. SwayEffect's planted row, WaveEffect's anchor, the centre of VignetteEffect, RadialGradientEffect and ScanlineEffect's curvature all land on the sprite itself: cut from an atlas, flipped, rotated, or with other effects after it, the pattern sits where it sits on a sprite with a texture of its own and that one effect. What is measured in the texture stays measured in it: ShineEffect's sweep, bands and width, WaveEffect's amplitude and frequency, DissolveEffect's grain, the lines of HologramEffect and ScanlineEffect (on an atlas sprite, fractions of the atlas, so a single shine crosses an atlas sprite only part of the time). With other effects after them they all stay on the sprite. SwayEffect measures everything across the sprite.

Their widths, offsets and sizes are in pixels of the texture they draw, with nothing to pass. Each of those effects takes the size of what it draws: the sprite's whole texture (the atlas image, not the frame), the screen for a camera's effect, a mesh's texture. Do not pass textureSize to get this: a textureSize (or setTextureSize()) fixes the size instead, for a width meant in some other texture's pixels.

// 2 pixels of the sprite's art, whatever the size of its atlas
sprite.addPostEffect(new OutlineEffect(app.renderer, { width: 2 }));

SwayEffect leans a sprite or a layer from side to side, as if in a breeze: the top sways most and everything from its anchor row down stays put, so a tree's trunk or a reed's root stays planted. It suits anything planted at its bottom; something hanging from its top, like a banner, needs its own shader. It is a WaveEffect, which takes the same anchor, so it adds no shader. Drive it with setTime(seconds) every frame, like any time-based effect; it wraps the phase itself, so a raw clock never costs precision on a phone's medium-precision floats.

const sway = new SwayEffect(app.renderer, { strength: 0.02, anchor: 0.9 });
tree.addPostEffect(sway);
// each frame
sway.setTime(timer.getTime() / 1000);

Its strength (a fraction of the sprite's width), its anchor and how it bends are all measured across the sprite, so a sprite cut from an atlas sways exactly like the same art with a texture of its own. WaveEffect's anchor is a row of the sprite too, but its amplitude and frequency are measured across the texture, so on an atlas sprite they are fractions of the atlas.

RadialGradientEffect is a procedural soft spot: solid at the centre of the renderable it draws, fading linearly to transparent at the edge, and elliptical for free on a non-square one. It is the one to reach for instead of baking a disc into a canvas, for a pickup highlight, a trigger marker or a damage flash, on a standalone image, Renderer.getWhitePixel() or a sprite cut from an atlas alike.

SquishEffect bulges a sprite's middle out (setBulge(0.3)) or pinches it in (setBulge(-0.3)), its top and bottom rows staying put. Bulging out widens the sprite past its own frame, and the effect draws on a wider quad to make room, so the art needs no transparent margin. On its own it is a jelly wobble; paired with a change of height it is squash and stretch that keeps the volume: scale the sprite to scaleY of its height about its feet and set the bulge to 1 - scaleY, so a stretched sprite pinches in and a squashed one bulges out. Ease scaleY back to 1 with a short Tween.Easing.Back.Out; an elastic ease keeps it trembling long after the landing.

const squish = new SquishEffect(app.renderer);
player.addPostEffect(squish);
// every frame, from the player's update()
const feet = player.height * (1 - player.anchorPoint.y);
player.currentTransform
    .identity()
    .translate(0, feet)
    .scale(1, scaleY)
    .translate(0, -feet);
squish.setBulge(1 - scaleY);

Apply that from the sprite's own update(), not from a tween's onUpdate. A physics adapter that mirrors a body's angle (Matter does) rewrites currentTransform on every step, before the renderables update, so a scale set anywhere else is gone by the time the sprite is drawn. Let the tween animate a plain { scaleY } object and read it in update().

It is made for a sprite carrying it as its only effect: chained with another, or on the camera, the frame it measures against is the whole capture, so it squishes the whole frame. The bulge is outside the sprite's bounds, so culling and pointer events ignore it.

BloomEffect and GlowEffect are not the same thing, and reaching for the wrong one is the usual disappointment here. GlowEffect draws a halo OUTSIDE a sprite's silhouette: its body returns the colour untouched wherever alpha is already non-zero, so on an opaque frame it returns early on every fragment and a camera-wide glow does nothing at all. BloomEffect is the screen bloom — bright pixels bleed into their neighbours — and it is the one to put on the camera:

app.viewport.addPostEffect(new BloomEffect(app.renderer));
// only the brightest highlights, with a wider halo
app.viewport.addPostEffect(
    new BloomEffect(app.renderer, { threshold: 0.85, radius: 4 }),
);

Its radius is in texels of what it filters, the screen on a camera, and it follows a resize by itself.

ToneMappingEffect goes after it, and goes last. Bloom spreads the bright parts; the tone map is what stops the sum of the two clipping flat at white, which is the difference between an effect that reads as light and one that reads as a white sticker. exposure lifts the image into the curve, and the curve (aces, reinhard or exponential) is baked at construction:

app.viewport.addPostEffect(new BloomEffect(app.renderer));
app.viewport.addPostEffect(
    new ToneMappingEffect(app.renderer, { exposure: 1.6 }),
);

It is a grade, not a colour pipeline. By default melonJS renders into RGBA8 everywhere, so the frame reaching it is already clamped and exposure past 1 is what gives the curve range to compress.

hdr: true in the application settings changes that: render targets become half-float, so the chain carries values above 1 instead of clamping at every write, and the bloom threshold starts discriminating genuinely over-bright pixels rather than merely bright ones. Off by default: it costs GPU memory on the camera's two render targets and depends on the driver.

On its own it is not HDR output: the frame is still presented in SDR, so anything above 1 clamps on the final blit. What it buys is that nothing clamped BEFORE then. Removing that last clamp is a second, separate opt-in — hdrOutput, below — and it is WebGPU-only.

const app = new Application(1024, 576, { hdr: true, … });
// …and check what you actually got, since a driver can refuse it
if (app.renderer.supportsHDR) { /* the chain now carries values above 1 */ }

Branch the GRADE on that flag, not just the feature. An exposure under 1 and a bloom threshold at 1 make sense only where values above 1 exist; applied to a chain that clamps, the same numbers tone map an already display-referred frame and switch the bloom off, which reads as a dark, flat picture rather than as a missing feature. Where there is no headroom, squeeze each emitter into range in its own shader instead and put the bloom threshold back under 1.

Nothing is added to your chain. hdr is additive: the same frame, plus additive content that no longer saturates per write and a threshold that means something. Anything still above 1 at the end clamps on the blit, as it always did.

A tone map is therefore a decision, not a consequence, and it is a bigger one than it looks. Every curve maps 1 to less than 1, so a 2D look built ON the clamp — clipped white specular, saturated neon — goes matte the moment you add one, at any exposure. Add one when your art is authored in linear light and you want the highlights rolled off; skip it when the clipping IS the look.

It applies to the CAMERA chain only. A sprite's chain keeps 8-bit targets on purpose: its final blit composites with ONE_MINUS_SRC_ALPHA, and additive blending accumulates alpha past 1 in a float target, so that factor would go negative and subtract the backdrop.

Once it is on: putting values above 1 into the frame

hdr: true on its own changes nothing visible. It removes a ceiling; something still has to reach for it. No single draw exceeds 1 on its own, so a game that turns the setting on and changes nothing else renders very nearly the frame it did before and looks like the feature is broken. The exception is worth knowing because it is free: additive content that already overlapped was being clipped at every write, and stops being.

What produces over-bright pixels:

// a built-in colour matrix, scaled past 1 — no custom shader needed
sprite.addPostEffect(new ColorMatrixEffect(app.renderer).brightness(4));

// in 3D, both of these were always unbounded; the 8-bit target was the only
// thing destroying the range. `emissive` is a 3-component Float32Array, set
// from the mesh settings or replaced wholesale — it is not a Color
const mesh = new Mesh(x, y, { emissive: [2.5, 1.8, 0.4], /* … */ });
light.intensity = 3;

// and additive blending, which accumulates past 1 once the target can hold it
sprite.blendMode = "additive";

One effect, not two. A renderable carrying exactly ONE post effect takes the renderer's customShader fast path: no offscreen target, and it draws straight into the camera's half-float target with its range intact. A chain of two or more is routed through a pooled intermediate that stays 8 bits per channel on purpose, so the energy is clamped there, before the camera ever sees it. Adding a second effect to an emitter is enough to silently undo its headroom.

Everything inside the camera is graded, including your UI

The tone curve runs on the camera's whole frame. A floating HUD is still inside it, so a label authored at display white arrives at the curve as 1 and leaves at 1 - exp(-exposure) — at exposure 0.45 that is 0.36, and the HUD comes out at roughly a third of its brightness for no reason the author can see.

UI in an HDR chain has to be authored for that chain, exactly as the scene is:

// drive the readout so the curve lands it back at display white. `shared`
// keeps one instance across every element: it opts the effect out of the
// per-renderable auto-destroy, so the first element torn down does not take
// the program the others are still using
const energy = new ColorMatrixEffect(app.renderer).brightness(2);
energy.shared = true;
for (const element of hudElements) element.addPostEffect(energy);

Put it on each ELEMENT, not on their container: beginPostEffect clears customShader for any renderable with no effects of its own, so a container's shader is thrown away by its first child. The alternative is to keep the UI out of the camera pass entirely, by overriding Stage.draw and drawing it after super.draw(...) — at the cost of the bloom that makes additive UI glow.

The quad trick under A camera effect covers the HUD too does not help here. It works by hosting a pass BELOW the HUD's z, and a tone map cannot live there: it has to be the last thing before the blit to the 8-bit canvas, which is the camera. Anything the camera draws goes through it.

Pick the multiplier by measuring against the no-headroom build, not by inverting the curve. Inverting it over-estimates, because additive UI is inside the BLOOM too and driving it harder also makes it spill wider into the same pixels.

hdrOutput: actually presenting in HDR, and what it costs you

hdr gives the chain headroom and then throws it away at the very last step, because the surface the browser presents into is 8 bits per channel. Values above 1 clamp there, exactly as they did before the setting existed. hdrOutput changes that surface, so they reach the compositor instead:

const app = new Application(1024, 576, { hdr: true, hdrOutput: true, … });
if (app.renderer.supportsHDROutput) { /* the display is getting values above 1 */ }

app.renderer.setHDROutput(false);   // and at runtime, for a settings screen

It is WebGPU-only, and that is not a melonJS limitation. The WebGPU specification defines a tone mapping mode on the canvas configuration, and two engines ship it. The WebGL2 equivalent needs a drawing-buffer tone mapping mode that is still an unapproved specification change with no implementation in any browser; the colour-space half of it exists in Chrome behind --enable-experimental-web-platform-features, which is not something you can ask a player to enable. So the WebGL2 backend reports false, warns once if you asked, and presents in SDR as it always has.

That makes this the first setting where the same machine answers differently depending on which backend video.AUTO picked. Branch on renderer.supportsHDROutput, never on the setting.

It requires hdr — the last pass blits from the camera's target, so if that target is 8 bits there is nothing above 1 left to present and the extra cost would buy an identical picture. Turning hdr off turns this off with it.

It changes how your game looks, on an HDR display. Everything else here is careful not to: hdr is additive, the tone curve defaults to none. This one removes the final clamp, so anything you drove above 1 — an additive stack, an emitter, a brightness() above 1 — gets genuinely brighter rather than saturating at white. That is the point of it, and it is why it is a separate opt-in rather than part of hdr. Two consequences worth planning for:

  • a look built on clipped whites will not survive it unchanged, because the clipping is what you were art-directing against
  • a tone curve and hdrOutput pull in opposite directions. A curve exists to fit values into [0, 1]; if you have removed the clamp, either drop the curve or give it a white point high enough to leave the top of the range alone
  • "aces" cannot produce HDR output. Its expression clamps to [0, 1] internally, so everything past the shoulder collapses to one value before the white point scales it — different bright things come out equally bright. Under hdrOutput use "reinhard" or "exponential", which approach 1 asymptotically without clamping and so keep their ordering when the white point lifts them past it, or use no curve at all

What it is not. It does not make an SDR display brighter, and there is no way to ask melonJS how bright the panel is — matchMedia("(dynamic-range: high)") is the closest the platform gets. Calibrating in nits, the way a console settings screen does, is not reachable from the web platform today.

Exposure, and the runtime toggle

Under hdr the useful exposure is often below 1, which surprises people who have only used the effect as an LDR grade. The curve has to work where it separates: at exposure 1 the ACES shoulder maps 2, 4 and 8 to 0.86, 0.96 and 0.99, so genuinely different energies all arrive at white and the headroom buys nothing you can see. Lower exposure spreads them out. The right value is a property of how bright your scene is authored, so measure it; there is no default that is right for two different games.

A tone curve is a setting rather than an effect you wire up:

renderer.setToneMapping("aces", { exposure: 0.4, white: 6 });
renderer.setToneMapping("none");                       // and off again

"none" by default. The renderer keeps the curve LAST on the camera, so whatever you put in front of it is mapped along with everything else. Changing the mode rebuilds one shader (the curve is baked); exposure and white are uniform writes, so a settings screen can drive them live. white is the value that comes out as display white — the knob a "brightness" or "peak" slider wants, since exposure alone drags the midtones up with the highlights.

renderer.setHDR(enable) moves the whole pipeline at runtime, the way setAntiAlias does, for a settings toggle. It empties the render-target pool when the format actually changes, so it is not something to drive per frame. The boot header reports what happened either way:

resolution: requested 1024x576 (HDR), got 1024x576 (SDR)   ← the driver refused

For grading without writing a shader yourself, the camera has a colour matrix:

app.viewport.colorMatrix.contrast(1.1).saturate(1.1);

It is still implemented as one: a non-identity matrix makes the camera append an internal ColorMatrixEffect to its post-effect chain for that frame, so it needs a GPU backend like everything else here.

Custom effects are dual-language

A custom ShaderEffect supplies a fragment body, not a whole program. To run on both GPU backends it needs GLSL and WGSL — a GLSL-only effect silently does nothing when video.AUTO lands on WebGPU.

const effect = new ShaderEffect(app.renderer, { glsl, wgsl });
effect.setUniform("uStrength", 0.5);
effect.setTexture("uScene", tex);
effect.setTime(timer.getTime() / 1000);   // setTime takes SECONDS; getTime is ms

Uniform names are shared across the two bodies — in WGSL they are the members of one @group(3) @binding(0) var<uniform> struct — so a single setUniform call feeds whichever backend is live. setTime is a convenience for a uTime uniform and silently does nothing if the shader does not declare one.

Never put a backtick in a shader body, including in its comments. An inline body is a JavaScript template literal, so a backtick ENDS it — and what follows is parsed as code, which fails somewhere further down the file with an error that says nothing about shaders:

const glsl = `
// x * x, not `pow(x, 2.0)`: the base goes negative here   <-- ends the literal
vec4 apply(vec4 color, vec2 uv) { ... }
`;

Markdown-quoting an identifier is a reflex when writing a comment, and this is the one place it breaks the file. Write pow(x, 2.0) bare. A body loaded from a .glsl / .wgsl file has no such problem, which is one more reason to move a shader out once it stops being a sketch.

One effect is one program — prefer preloading, and share

A ShaderEffect compiles and links in its constructor, and the link is checked with getProgramParameter(LINK_STATUS) straight after linkProgram — a blocking call. So the cost is paid on whatever frame the effect is constructed, and n identical effects cost it n times. clone() does not avoid it: it constructs a new effect and links a new program.

Preload it instead. Loader type "shader" takes a src or inline source via data, compiles at load time (inside the loading screen), and hands back a shared instance:

loader.preload([{ name: "ramp", type: "shader", data: myFragmentBody }]);
// …then, for every label that wants it:
label.addPostEffect(loader.getShader("ramp"));

Sprites sharing one effect share one draw

Sprites drawn one after the other through the same effect instance are one draw call, as plain sprites are, even when they come from different frames of an atlas: a hundred enemies outlined by one shared OutlineEffect draw once. Each still draws with the values it was given: a uniform set to a new value draws the sprites queued before it first (setting the value it already has changes nothing). What splits the batch:

  • a uniform taking a new value between two sprites: give a few sprites the same value one after the other, rather than a different one each;
  • a different texture (the effect path samples one texture per draw);
  • a different effect instance, or a sprite without the effect in between;
  • a noise_uv effect on sprites of different frames or sizes (its rect is per sprite);
  • one of the effects that place a pattern on the sprite (SwayEffect, WaveEffect, ShineEffect, VignetteEffect, RadialGradientEffect, ScanlineEffect, DissolveEffect, HologramEffect): each sprite is told where it is, so each draws on its own;
  • a screen_texture effect, which draws each sprite on its own, as each one samples a capture of everything drawn before it.

So share one instance (the loader's "shader" type hands one back, see above) rather than constructing an effect per sprite: n instances are n draws as well as n programs. One you construct yourself and share needs effect.shared = true, or the first renderable destroyed destroys it for the others (the loader's are marked shared already):

const outline = new OutlineEffect(renderer, { color: [1, 1, 1] });
outline.shared = true; // you destroy it, when no enemy is left
for (const enemy of enemies) {
    enemy.addPostEffect(outline);
}

Declare a level's effects with that level's assets

An effect the loader knows about can be prepared before anything draws it; an effect built inline cannot, because it does not exist until the code that constructs it runs — which is the stage or level you were hoping to speed up. That is the practical reason to prefer the asset form even when the source is a string in your own module:

// with the level's other assets, not in the stage that uses them.
// `preload()` rather than `load()` — preload is what warms the shaders it
// brought in, and it can be called again per level, not just at boot
await loader.preload([
    { name: "ripples", type: "shader", src: {
        glsl: "assets/level2/ripples.glsl",
        wgsl: "assets/level2/ripples.wgsl",
    }},
    // …that level's textures, maps and audio alongside it
], undefined, false);   // `false`: stay on this stage, no loading screen

src takes the dual-language pair, so one asset carries both realizations and the two backends cannot drift apart. data takes inline source the same way.

With prewarm on (the default since 20.6), loader.preload() warms on its way through — automatically, on every call — and compiles the GPU-side objects for every shader it holds — on WebGPU that is the WGSL module, which is otherwise built inside the draw path the first time the effect is used.

Since 20.10 the rest is warmed when the effect is attached, inline effects included: addPostEffect (or assigning shader) compiles the pipelines that effect will draw with on that renderable — on WebGPU the costly part, keyed by the renderable's blend mode and target as well as the shader — and on WebGL links the spliced program when the host is a mesh. It finishes off the frame, so attach when you build (a pool, a stage starting), not when you show: an effect attached and drawn in the same frame still compiles on that frame.

One caveat if you are moving an inline effect to a file: a template literal can interpolate constants into the source (vec2(${ASPECT}, 1.0)), and a static file cannot. Promote those to uniforms rather than duplicating the numbers across the GLSL and WGSL copies — duplicated constants are exactly how the two backends drift.

Sharing one instance across renderables is safe because the loader sets effect.shared. Without that flag a renderable's teardown destroys its post effects, which would free the program out from under everything else still using it. Uniforms live on the effect, so a shared instance means one set of uniform values — fine when the look is uniform, and the reason to clone (and pay a link) when it is not.

Note this covers your effects only. The engine's own mesh/quad shader variants are covered by the loading-screen warm-up and by the per-renderable one — see melonjs-loading-assets.

Screen-reading builtins

For refraction, distortion and anything that samples what is already drawn:

  • screen_texture — a sampler kept filled with everything drawn so far (GLSL: annotate the sampler uniform sampler2D tex : screen_texture;)
  • screen_uv — this fragment's 0..1 position in that capture
  • noise_uv — 0..1 across the sprite regardless of its atlas frame, and across the renderable when it has several effects (it does not slide with the camera)

In WGSL the capture is sampled through screen_sampler (clamped) or screen_sampler_repeat (wrapping) instead of GLSL's : screen_texture annotation. screen_uv is y-up in GLSL and y-down in WGSL, which exactly matches each backend's own capture orientation — so a body that samples screen_texture at screen_uv needs no flip in either language.

Two places where a straight port really does differ:

  • A capture you bind yourself. renderer.toFrameTexture() grabs the frame into a Texture2d you hand to setTexture — the supported replacement for readPixels tricks. The GL capture is bottom-up (GLSL bodies sample it with 1.0 - uv.y) and opaque RGB; the WebGPU capture is top-down (the WGSL twin must not flip) and preserves alpha.
  • Vertical UV offsets — a drop shadow, a directional smear. The WebGL pooled multi-effect path composites through bottom-up FBOs, so declare a uUVYDir uniform (initialise it to 1.0) and multiply vertical offsets by it; the renderer feeds -1 on that path and +1 everywhere else.

A ShaderEffect on a mesh is a colour hook, and drops some builtins

mesh.addPostEffect(fx) shades a Mesh on both backends since 20.5. The body is spliced into the engine's own mesh shader as a colour hook, so the mesh keeps its placement, alpha cutout, lighting and fog, and setUniform / setTime drive it exactly as they do over a sprite. Uniform-only bodies are the supported case and they are most of them.

Five things the quad tier gives an effect that the mesh tier does not, each refused rather than half-working:

refusedwhy
its own setTexture samplersresolved by the quad tier's per-effect device state; refused on both backends
screen_texture, screen_uvneed the screen capture only the quad tier takes
noise_uvderived from a quad's image and object size
vColor, on WGSL onlythe WGSL mesh module has no module-scope equivalent; the GLSL one declares it, so GLSL is fine
InstancedMeshthe instanced WGSL builder drops everything between the two stages

Hitting one warns once and falls back to the built-in shading. Nothing throws, so the symptom is a mesh drawing with its plain texture, which reads as a broken shader, a bad binding or a texture that failed to load. Check the console for the warning before you start eliminating alpha, bindings and transparency: it names which of the five it was.

2D renderables never hit this. Sprite, Text and the rest go through the quad path, which has the full feature set, so a gradient fill for text or a refraction over a sprite is fine.

If you need one of the five on a mesh, supply a full dual-language GLShader rather than a fragment body, and declare the fog uniforms yourself (see melonjs-3d).

Extra textures bind in setTexture order

setTexture("uFoo", …) assigns bindings 1 and 2 to the first extra texture, 3 and 4 to the second, and so on. The WGSL must match:

@group(3) @binding(1) var uFoo : texture_2d<f32>;
@group(3) @binding(2) var uFooSampler : sampler;

Copying a two-texture effect and deleting one leaves the survivor declared at 3/4 while it is bound at 1/2 — the program fails to build and the effect is silently dropped.

Blend modes

Fourteen modes; thirteen of them are honoured by all three renderers:

sprite.blendMode = "additive";   // or "multiply", "screen", "overlay", …

Six ride fixed-function blend state and cost nothing extra: normal, additive (spelled add or lighter too), multiply, screen, exclusion, and none (blending off — the source replaces the destination, alpha included). none is the one mode the Canvas backend does not implement; it reports "normal" instead.

The other eight cannot be expressed as a multiply-add on the destination, so the GPU backends capture the destination and composite through BlendEffect — one capture plus one composite per draw: difference, overlay, hard-light, color-dodge, color-burn, soft-light, darken, lighten. Right for accents; expensive for hundreds of objects. The Canvas renderer reaches all eight through globalCompositeOperation instead, at no extra cost.

Anything else collapses to "normal", and so do 3D meshes and Gradient fills whatever you ask for — the latter with a one-time console warning. renderer.setBlendMode(mode) returns the mode it actually applied, so comparing that against your request detects the fallback.

The Canvas fallback

ShaderEffect on a Canvas renderer logs a warning, leaves enabled = false, and turns every method into a no-op. Nothing throws. The same happens on a GPU backend when no body matches its language — a GLSL-only effect under WebGPU.

That is fine for decoration. It is not fine when the shader draws your content — the carrier renderable then shows through raw. A 1×1 white texture stretched to 280×156 renders as a white box where the effect should be.

Guard when the effect is load-bearing:

if (app.renderer.shaderLanguage === null) {
    // no programmable pipeline — skip the carrier draw entirely
}

shaderLanguage ("glsl", "wgsl", or null) is the flag to test here rather than a backend name or supportsDepthBuffer: it answers "can this renderer compile what I am about to hand it". Per-effect, effect.enabled answers the same question after construction.

Symptom → cause

symptomcause
effect does nothing, warning in consoleCanvas fallback — no programmable pipeline
effect does nothing on some machines onlyGLSL-only shader, video.AUTO chose WebGPU
a white or solid box where the effect should becarrier renderable drawn while the effect is disabled
ONE effect does nothing but two of them workthe renderable draws with primitives — set postEffectNeedsCapture
effect cannot be re-enabledremovePostEffect() destroyed it — use enabled
ported shader renders upside downa hand-bound toFrameTexture() capture — GL is bottom-up, WebGPU top-down
shadow/smear offset flips on some drawsvertical UV offset not multiplied by uUVYDir
frame rate collapses with many blended spritesan advanced mode (overlay, darken, …) — each draw is a capture plus a composite
many sprites with the same effect still cost a draw eachan effect instance per sprite, or a uniform set to a different value per sprite — share one instance and group sprites by value
animated shader never movessetTime fed milliseconds, or the shader declares no uTime
squash and stretch only bulges, the height never changes, under Matterthe adapter rewrites currentTransform every step; set the scale from the sprite's update()
SquishEffect squishes the whole screen instead of the spriteit is chained with another effect, or on the camera; give the sprite it alone
syntax error in a file whose shader you just edited, pointing at a line of GLSLa backtick in the shader body ended the template literal — most often one wrapped around an identifier in a comment

Related skills

  • melonjs-scenes-and-state — Tween and the easing families, for animating any of this

  • melonjs-renderables — where post effects attach, and the destroy trap

  • melonjs-3d — the GPU-backend requirement, and why a custom mesh shader is not affected by the camera's distance fog

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

コメント

ログインなしで書き込めます。使ってみた感想、質問、動かなかった環境などを気軽にどうぞ。

まだコメントはありません。最初のコメントを書いてみませんか。

送信するとすぐに公開されます。メールアドレスや電話番号などの個人情報は書かないでください。URL は 2 つまでです。

0 / 1000 文字

このフォームは reCAPTCHA で保護されており、Google のプライバシーポリシーと利用規約が適用されます。

同じリポジトリのスキル

melonjs

無料概要あり

melonJSを使ったゲーム制作で、目的に合う専門ガイドを選ぶための入口です。初期化から描画、入力、物理演算、音声、2D・3D機能まで案内します。

  • melonJSで作る内容に合うガイドを探したいとき
  • 新しいゲームの初期化手順を知りたいとき
  • 3Dやモデル読み込みの参照先を探したいとき
melonjs/melonJS6,4292026年10月29日 更新

melonjs-20-migration

無料概要あり

melonJS 19.x以前の知識を20.x向けに更新し、初期化や描画効果、廃止・非推奨APIの変更点を確認して移行時の不具合を調べられます。

  • 19.x以前のゲームを20.xへ移行したいとき
  • 古いAPIが見つからない原因を調べたいとき
  • 非推奨の描画効果を置き換えたいとき
melonjs/melonJS6,4292026年10月29日 更新

melonjs-3d

無料概要あり

melonJSの3D・2.5Dゲーム向けに、カメラ、メッシュ、ライト、奥行き順、透明表現やフォグの設定と座標系の注意点を確認できます。

  • 3Dカメラをステージに設定したいとき
  • メッシュやビルボードを配置したいとき
  • 奥行き順や透明表現を調整したいとき
melonjs/melonJS6,4292026年10月29日 更新

melonjs-3d-assets

無料概要あり

melonJSでglTF・GLBやOBJ・MTLの3Dモデルを読み込み、マテリアルやライト、アニメーション、影、インスタンスの扱い方を確認できます。

  • glTF・GLBのシーンを読み込みたいとき
  • OBJモデルとMTL素材を使いたいとき
  • モデルの向きや大きさを調整したいとき
melonjs/melonJS6,4292026年10月29日 更新

melonjs-audio

無料概要あり

melonJSで効果音やBGMを読み込み、再生・停止・音量調整するほか、位置に応じた音や音声合成、ミックス全体へのエフェクト設定を扱うスキルです。

  • 効果音とBGMを読み込みたいとき
  • ゲーム内の位置に応じて音を鳴らしたいとき
  • 音声ファイルなしで短い音を作りたいとき
melonjs/melonJS6,4292026年10月29日 更新

melonjs-camera-and-drawing

無料概要あり

melonJSのカメラ追従や画面効果、座標変換、図形描画を実装する際の要点を確認できます。描画のクリッピングやCanvasへの焼き込み、ピクセルの読み出しも扱います。

  • プレイヤーを追うカメラの範囲を設定したいとき
  • ワールド座標と画面座標を変換したいとき
  • 独自描画に図形やグラデーションを使いたいとき
melonjs/melonJS6,4292026年10月29日 更新

melonjs のスキルをすべて見る

このスキルの問題を報告する