跳到正文
cccskills
免费在 GitHub 公开

melonjs-effects-and-shaders

Apply built-in visual effects such as blur, glow, and bloom to sprites or cameras, and create custom effects with GLSL and WGSL shader code. The guide also explains blend modes, color grading, and why shader effects do not run on the Canvas renderer.

中文简介正在准备中。当前显示英语简介。

原文查看原文说明

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)

实测评分 未实测

查看安装方法

适用场景

  • Add glow, blur, or other effects to a sprite or camera
  • Create a custom effect for WebGL or WebGPU
  • Adjust a game's colors with a color matrix
  • Find out why an effect does not appear on Canvas

包含的文件(1)

  • SKILL.md37.4 KB

SKILL.md(原文)

安装前可以查看交给 AI 代理的指令内容。

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

免费

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/melonJS64292026年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/melonJS64292026年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/melonJS64292026年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/melonJS64292026年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/melonJS64292026年10月29日 更新

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.

中文简介正在准备中。当前显示英语简介。

  • 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
melonjs/melonJS64292026年10月29日 更新

查看 melonjs 的全部技能

报告此技能的问题