Skip to content

Shader Effects

Olivier Biot edited this page Aug 5, 2026 · 7 revisions

Shader Effects

melonJS ships a library of post-processing effects that can be applied to any renderable (sprite, image layer, container, camera) without writing shader code. You can also write your own effect by extending ShaderEffect.

Shader effects run on both GPU backends — WebGL (GLSL bodies) and WebGPU (WGSL bodies); a dual-language effect carries one body per language and the renderer compiles the one it speaks. Under Canvas (or when an effect has no body in the active renderer's language) effects are silently inert — the scene keeps rendering un-effected.

Built-in Effects

Each built-in effect is a class exported from melonjs. Construct it with a reference to the renderer and an options object, then attach it via addPostEffect() on a renderable, or viewport.addPostEffect() for a camera-wide effect.

Class What it does Common uses
BlurEffect Gaussian blur Depth-of-field, frosted UI
ChromaticAberrationEffect Per-channel offset CRT/glitch look
ColorMatrixEffect 4×5 matrix transform Custom color grading
DesaturateEffect Reduce saturation Death/pause overlays
DissolveEffect Animated noise dissolve Scene transitions
DropShadowEffect Offset shadow UI panels, sprites
FlashEffect Full-screen color flash Damage feedback
GlowEffect Soft halo outside the sprite Magic items, selection
HologramEffect Scanlines + edge glow Sci-fi projections
InvertEffect Invert RGB Stylistic shock
OutlineEffect Hard 1-pixel outline Highlight on hover
PixelateEffect Snap to coarse pixels Retro look, transitions
RadialGradientEffect Radial color gradient Lighting falloff
ScanlineEffect Horizontal scanlines CRT/arcade overlay
SepiaEffect Brown-tone conversion Flashback / "old film"
ShineEffect Sweeping highlight band Coins, gems, polished surfaces
TintPulseEffect Pulsing color overlay Status effects (poison, freeze)
VignetteEffect Darkened corners Cinematic framing, focus
WaveEffect Sinusoidal distortion Heat haze, water
Screenshot 2026-05-14 at 10 38 18

Applying an effect

import { ShineEffect, timer, event } from "melonjs";

// once the application is running, the renderer is ready
const shine = new ShineEffect(renderer, {
    color: [1.0, 0.95, 0.7],
    speed: 0.8,
    width: 0.18,
    intensity: 0.7,
});

// attach to a single sprite
coinSprite.addPostEffect(shine);

// or to the whole camera (affects every rendered pixel)
app.viewport.addPostEffect(shine);

// time-driven effects need a per-frame update
event.on(event.GAME_UPDATE, () => {
    shine.setTime(timer.getTime() / 1000.0);
});

Most effects accept their tunables both at construction (new XEffect(renderer, options)) and at runtime via setColor(...), setIntensity(...), setTime(...), etc. Check each class's docs for the exact setter list.

Writing a Custom Effect

Extend ShaderEffect and pass the body of an apply(color, uv) function — melonJS handles the rest of the vertex shader, the texture sampler, and the GPU pipeline.

Since 20.0 an effect body is dual-language: a plain string is a GLSL body exactly as before (WebGL only), while { glsl, wgsl } provides one body per shading language and the renderer compiles the one matching its shaderLanguage — GLSL on WebGL, WGSL on WebGPU. Uniform names are shared, so a single setUniform call drives both. When no body matches the active backend (or on the Canvas renderer), the effect warns once and stays disabled (enabled === false) while the scene keeps rendering.

import { ShaderEffect } from "melonjs";

class StripesEffect extends ShaderEffect {
    constructor(renderer, options = {}) {
        super(renderer, {
            glsl: `
            uniform vec3  uColor;
            uniform float uFrequency;
            uniform float uTime;
            vec4 apply(vec4 color, vec2 uv) {
                if (color.a == 0.0) return color;
                float stripe = step(0.5, fract(uv.y * uFrequency + uTime));
                return vec4(mix(color.rgb, uColor, stripe * 0.4), color.a);
            }
            `,
            wgsl: `
            struct StripesUniforms {
                uColor : vec3f,
                uFrequency : f32,
                uTime : f32,
            };
            @group(3) @binding(0) var<uniform> fx : StripesUniforms;

            fn apply(color : vec4f, uv : vec2f) -> vec4f {
                if (color.a == 0.0) {
                    return color;
                }
                let stripe = step(0.5, fract(uv.y * fx.uFrequency + fx.uTime));
                return vec4f(mix(color.rgb, fx.uColor, stripe * 0.4), color.a);
            }
            `,
        });
        this.setUniform("uColor", new Float32Array(options.color ?? [1.0, 0.0, 0.0]));
        this.setUniform("uFrequency", options.frequency ?? 8.0);
        this.setUniform("uTime", 0.0);
    }

    setTime(t) { this.setUniform("uTime", t); }
}

Providing only one language

You never have to provide both bodies — an effect degrades gracefully on any backend it has no body for:

Body passed WebGL WebGPU Canvas
"...glsl..." (plain string) ✅ compiles inert stub inert stub
{ glsl } ✅ compiles inert stub inert stub
{ wgsl } inert stub ✅ compiles inert stub
{ glsl, wgsl } ✅ compiles ✅ compiles inert stub

An inert stub logs a single console warning at construction, reports enabled === false, and turns every method (setUniform, setTime, setTexture, clone, …) into a safe no-op — the renderable and the rest of the scene render normally without the effect, and nothing throws. This is the Canvas renderer's long-standing degradation contract, generalized to "no body for this backend's shading language". Check effect.enabled at runtime if you need to know whether the effect is live.

The apply contract

Every melonJS shader effect declares a fragment-stage function:

vec4 apply(vec4 color, vec2 uv);
  • color — the pre-sampled texture color at uv (RGBA, alpha-premultiplied on output)
  • uv — texture coordinates, [0, 1] over the sprite/region
  • The return value is the final pixel color (RGBA)

Useful builtins exposed by the engine's vertex stage:

Identifier Type Description
uSampler sampler2D The texture being drawn
vColor vec4 Vertex tint (sprite tint × per-vertex alpha)
screen_uv vec2 This fragment's position on screen, [0, 1] (available when referenced — see screen-reading effects)
noise_uv vec2 Frame-local [0, 1] across the drawn object, independent of where its frame sits in a texture atlas (available when referenced)

Anything else you need (time, mouse position, hover state, …) you declare yourself with uniform and update via setUniform().

The WGSL convention

A WGSL body mirrors the GLSL one — declarations plus the apply function, compiled verbatim inside the engine's scaffold:

  • The entry point is fn apply(color : vec4f, uv : vec2f) -> vec4f — same contract as the GLSL apply.
  • Uniforms live in one struct at @group(3) @binding(0), and the struct member names are the setUniform names — keep them identical to the GLSL twin so one call sets both.
  • Extra setTexture() samplers are texture/sampler pairs at explicit consecutive group-3 bindings, starting from 1: @group(3) @binding(1) var uNoise : texture_2d<f32>; @group(3) @binding(2) var uNoiseSampler : sampler; (the "repeat" mode passed to setTexture selects the bound sampler).
  • The source texture is uTexture with uSampler (textureSample(uTexture, uSampler, uv) is the WGSL spelling of GLSL's texture2D(uSampler, uv)), and the tint is vColor.
  • The shader builtins keep their names: screen_uv, noise_uv, and screen_texture — sampled through screen_sampler (clamped) or screen_sampler_repeat (wrapping), replacing the GLSL : screen_texture(repeat) annotation.
  • Porting notes: a texture sampled after a non-uniform return/branch needs textureSampleLevel(uTexture, uSampler, uv, 0.0) (a WGSL uniform-control-flow rule; identical output for sprite textures), and a frame capture from renderer.toFrameTexture() is top-down on WebGPU where the GL capture is bottom-up — GLSL bodies flip with 1.0 - uv.y, their WGSL twins must not.

Setting uniforms

// scalar
shader.setUniform("uIntensity", 0.5);

// vector — wrap in Float32Array
shader.setUniform("uColor", new Float32Array([1.0, 0.5, 0.2]));

// the per-frame time pattern most effects use
event.on(event.GAME_UPDATE, () => {
    shader.setUniform("uTime", timer.getTime() / 1000.0);
});

Time-driven effects: setTime()

Every ShaderEffect has a setTime(seconds) convenience for the common uTime pattern — it writes the shader's uniform float uTime if one is declared, and is a safe no-op otherwise:

event.on(event.GAME_UPDATE, () => {
    myEffect.setTime(timer.getTime() / 1000.0);
});

Extra textures: setTexture()

An effect can sample additional textures beyond the sprite it processes — a noise map, a mask, a color LUT, a flow table. Declare the sampler2D in your fragment and bind any image (or texture asset) to it by name; the engine handles the upload and the texture units:

import { NoiseTexture2d, ShaderEffect } from "melonjs";

const noise = new NoiseTexture2d({
    type: "cellular", width: 512, height: 512, seamless: true,
});

const ripple = new ShaderEffect(renderer, `
    uniform sampler2D uNoise;
    uniform float uTime;
    vec4 apply(vec4 color, vec2 uv) {
        vec2 offset = texture2D(uNoise, uv + uTime * 0.05).rg * 0.01;
        return texture2D(uSampler, uv + offset);
    }
`);
ripple.setTexture("uNoise", noise.getTexture(), "repeat");

The optional third argument sets the wrap mode ("repeat", "repeat-x", "repeat-y", "no-repeat"). NoiseTexture2d pairs naturally with this: seamless procedural noise, bakeable as grayscale, a color ramp, or a normal map — see its class docs.

Screen-reading effects: screen_texture, screen_uv, noise_uv

Some effects need to read what is already on screen behind the object — water refraction, heat haze, frosted glass, shock waves. Three builtin names make this a few lines of GLSL, with no JavaScript plumbing:

  • uniform sampler2D <name> : screen_texture; — annotate any sampler and the engine keeps it filled with a capture of everything drawn so far (refreshed automatically right before the effect draws)
  • screen_uv — this fragment's position in that capture, [0, 1] across the screen
  • noise_uv — a [0, 1] coordinate across the drawn object itself, unaffected by where the sprite's frame sits in its texture atlas (perfect for tiling a seamless noise texture over an atlas-packed sprite)
const water = new ShaderEffect(renderer, `
    uniform sampler2D uNoise;
    uniform sampler2D screenTex : screen_texture;
    uniform float uTime;
    vec4 apply(vec4 color, vec2 uv) {
        vec2 flow = texture2D(uNoise, noise_uv + uTime * 0.25).rg;
        vec4 refracted = texture2D(screenTex, screen_uv + flow * 0.005);
        return refracted * texture2D(uSampler, uv + flow * 0.005);
    }
`);
water.setTexture("uNoise", noiseTexture.getTexture(), "repeat");
pondSprite.addPostEffect(water);

That's a refracting pond: whatever is rendered behind the sprite (trees, sky, characters) shows through it, distorted by the flowing noise. See the Water Overworld example for the full scene.

Notes:

  • The builtins only activate when referenced; existing shaders are unaffected (a shader that declares its own screen_uv/noise_uv is left untouched).
  • Each draw of a screen_texture effect performs one GPU screen copy — cheap, but worth knowing if you attach it to hundreds of objects.
  • For manual control of the capture (region, timing, multiple captures), use renderer.toFrameTexture() with setTexture() — the annotation is the automated version of exactly that.

Preloading shaders as assets

Shaders can be preloaded like any other asset with the "shader" type. The GLSL compiles at load time, so the compile cost lands in the loading screen and a compile error fails the load with the asset's name:

me.loader.preload([
    // an apply() fragment body, from a file or inline via `data`
    { name: "waterRipple", type: "shader", src: "shaders/waterRipple.frag" },
    // or a complete program — a {vertex, fragment} GLSL pair and/or a
    // full WGSL module: one GLShader carrying a realization per GPU
    // backend (a wgsl source that declares its own @vertex entry point
    // is recognized as a complete module rather than an effect body)
    { name: "toonMesh", type: "shader", src: {
        vertex: "shaders/toon.vert",
        fragment: "shaders/toon.frag",
        wgsl: "shaders/toon.wgsl",
    } },
    // or a dual-language body — the renderer fetches and compiles the
    // language it speaks (URLs here; inline strings work via `data`)
    { name: "ripple", type: "shader", src: {
        glsl: "shaders/ripple.frag",
        wgsl: "shaders/ripple.wgsl",
    } },
]);

A GLSL-only asset still preloads successfully on the WebGPU renderer (and vice versa): the resulting effect is an inert stub — disabled, warn-once, safe to unload — so shared asset lists never fail the load.

// a fragment body comes back as a shared ShaderEffect
mySprite.addPostEffect(me.loader.getShader("waterRipple"));

// a complete program comes back as a shared raw GLShader, for the hosted
// paths that take one — assign it to a mesh to replace its built-in shading
myMesh.shader = me.loader.getShader("toonMesh");

getShader() returns the shared, loader-owned instance — the same object on every call, freed only by loader.unload() / loader.unloadAll(). When a renderable needs its own uniform values, take a private copy with clone():

boss.addPostEffect(me.loader.getShader("flash").clone());

Chaining effects

addPostEffect() can be called multiple times — effects run in the order they're added. For example, blur first, then a vignette over the blurred result:

mySprite.addPostEffect(new BlurEffect(renderer, { radius: 6.0 }));
mySprite.addPostEffect(new VignetteEffect(renderer, { intensity: 0.6 }));

Sharing one effect across many renderables

Effects are stateless per-renderable — one ShineEffect instance attached to N sprites runs the same shader for all of them, with one set of uniforms. That's usually what you want for grouped objects (every coin pulses in sync). If you need per-sprite phase offsets, give each sprite its own instance.

When one instance is shared, set effect.shared = true so that removing it from (or destroying) one renderable never auto-destroys the GL program the others still use — you then own its lifecycle and call destroy() yourself. Effects returned by loader.getShader() come pre-flagged as shared.

Best practices

  • Only sample within the sprite quad. uSampler only contains the sprite's texture region — coordinates outside [0, 1] (or the region's UV range) return clamped/wrapped values, not adjacent sprites.
  • Use vColor if you want sprite tint / alpha to flow through. Most effects do return result * vColor; somewhere.
  • Guard with if (color.a == 0.0) return color; for effects that should only act on visible pixels (avoids tinting transparent padding).
  • Animate via setUniform(), not by rebuilding the shader. Recreating a ShaderEffect triggers a WebGL program recompile.

Custom mesh shaders

Post-effects operate on quads (sprites, layers, cameras). To replace a 3D mesh's built-in shading instead, assign a complete shader program to mesh.shader — a GLShader carrying a {vertex, fragment} GLSL pair for WebGL and/or a complete WGSL module for WebGPU (one object serves both backends; a missing realization degrades to the built-in shading):

myMesh.shader = new me.GLShader(app.renderer.gl, {
    vertex: toonVertexGLSL,      // mesh contract: aVertex/aRegion/aColor +
    fragment: toonFragmentGLSL,  // uProjectionMatrix/uViewMatrix/uModelMatrix
    wgsl: toonModuleWGSL,        // vertex_main / fragment_main entry points
});

The exact vertex layouts, bind groups and the WGSL module contract are documented on the GLShader class, and the shader-asset shape above (src: {vertex, fragment, wgsl}) preloads one ready to assign via loader.getShader().

Clone this wiki locally