Skip to content

Light2d: WebGL-native rendering via custom shader (replace offscreen canvas) #1430

@obiot

Description

@obiot

Summary

Replace Light2d's current "build a radial gradient on an offscreen canvas, upload as a GL texture, drawImage every frame" pipeline with a single textured quad whose fragment shader computes the radial falloff procedurally.

Current state

Light2d (packages/melonjs/src/renderable/light2d.js) builds a CanvasRenderTarget at construction time, uses canvas's createRadialGradient to paint a soft circle, then each frame calls renderer.drawImage(this.texture.canvas, ...) with the "lighter" blend mode.

This works but has drawbacks:

  • Per-light texture memory(radiusX × 2) × (radiusY × 2) × 4 bytes per light, sitting in GPU memory whether the light is on screen or not
  • Stale texture on radius change — the texture is baked once at construction; calling light.resize(...) (or changing radiusX/Y after construction) leaves the old gradient texture in place
  • Pixelated under zoom — bitmap, so heavy camera zoom turns the gradient blocky
  • Doesn't batch with sprites under heavy texture pressure — each light has a unique texture, so beyond the 16-unit multi-texture batcher limit they cause flushes

Proposed solution

A small custom ShaderEffect-style shader that renders the light as a single textured quad with a procedural radial falloff. No texture upload, no offscreen canvas.

uniform vec3 uLightColor;
uniform float uIntensity;

vec4 apply(vec4 color, vec2 uv) {
    // uv in [0,1] across the light's bounding rect; recenter to [-1,1]
    vec2 c = uv * 2.0 - 1.0;
    float d = length(c);
    float falloff = clamp(1.0 - d, 0.0, 1.0);
    // smooth the edge so it doesn't look like a hard circle
    falloff = falloff * falloff;
    return vec4(uLightColor, falloff * uIntensity);
}

Light2d.draw() becomes:

draw(renderer) {
    // ensure the shader is set; emit a single quad over the light's bounding box
    renderer.drawWithShader(this._lightShader, x, y, w, h);
}

Benefits

  • Zero per-light texture memory
  • Reactive to radius changes — no rebake
  • Crisp at any camera zoom — procedural
  • Batches — all light quads share the same shader, single flush
  • Natural Canvas fallback — keep the existing canvas-texture path for Canvas mode (it already works there)

Considerations

  • Custom blend mode — current default is "lighter" (additive). The shader returns vec4(color, alpha) with the alpha encoding falloff; combined with additive blending, the colour accumulates correctly across overlapping lights.
  • Elliptical lights (different radiusX vs radiusY) — the quad's aspect ratio handles this naturally; the UV-space distance computation stays in [-1,1] per axis so the falloff stays elliptical.
  • Color stops beyond two — current Light2d only supports center→edge with a single intensity. If we want richer falloff curves later, the shader can take additional uniforms or a small 1D ramp texture. Out of scope for the first cut.

Pairs with

Files (estimated)

  • packages/melonjs/src/renderable/light2d.js — replace createGradient + texture with shader emission on WebGL; keep canvas fallback
  • packages/melonjs/src/video/webgl/shaders/light2d.frag (new) — the falloff shader
  • tests/light2d.spec.js (new) — unit tests for the rendering path selection

Risks

  • Visual parity with the existing gradient bake under various intensity / radius / blend mode combinations needs a side-by-side comparison
  • The existing createGradient produces a slightly soft edge from canvas's gradient interpolation; the shader's falloff curve has to match perceptually

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions