Skip to content

Sprite sheet export writes premultiplied RGB, so semi-transparent glows come out dark #4

Open

Description

@sanqiPanax

Hi — thanks for the library, the effects are genuinely nice. I ran the full check.mjs suite (244/244 pass) and then hit something on the export path that looks like a real bug.

What happens

tools/lib/fxlib.mjs → spriteSheet() creates the sheet on a fully transparent canvas (canvas(cols*w, rows*h, [0,0,0,0])) and pastes each frame with canvas.paste():

const a = src[s + 3] / 255;
for (let c = 0; c < 3; c++) data[d + c] = src[s + c] * a + data[d + c] * (1 - a);
data[d + 3] = Math.max(data[d + 3], src[s + 3]);

That is an alpha-over blend, but the result is stored without dividing by the output alpha. With a transparent destination (dst = 0), the RGB written to the PNG is src.rgb * src.a — i.e. premultiplied — while the alpha channel is correct.

A consumer reading the PNG as straight (non-premultiplied) RGBA — Aseprite, Godot, Unity, Phaser's default — multiplies by alpha a second time when compositing, so soft edges come out darker than the source.

Repro

node tools/render.mjs fire/torch --sheet

Then crop frame 0 out of records/fire/torch/torch.png using the rects in torch.json, and compare it with the same frame rendered standalone (renderFrames(...).frames[0].clear).

Measured (fire/torch, frame 0, 72×120)

check result
alpha channel identical to source 8,640 / 8,640 pixels
semi-transparent pixels equal to round(rgb * a / 255) 6,740 / 6,740 (≤1 per channel, 0 exceptions)

So it is a clean premultiply, not compression noise. Visually: the torch's glow is gone in the sheet frame and the smoke gets darker. In this effect 6,740 of 8,640 pixels (78%) are semi-transparent, so it affects most of the fire / lightning / holy / shadow categories.

Suggested fix

Normalize by the output alpha inside paste(). It leaves opaque destinations (the contact sheet, fill = [12,11,16,255]) exactly as they are today:

const a = src[s + 3] / 255, da = data[d + 3] / 255, oa = a + da * (1 - a);
if (oa > 0) {
  for (let c = 0; c < 3; c++) data[d + c] = (src[s + c] * a + data[d + c] * da * (1 - a)) / oa;
  data[d + 3] = oa * 255;
}

A narrower fix is to copy RGB straight when data[d + 3] === 0, but the version above also stays correct if anything is ever pasted onto a partly transparent canvas.

Environment

Windows 11, node v24.14.0, pixelfx 0.3.0 (main, 2026-11-03).

Happy to attach the side-by-side PNG (source frame vs. cropped sheet frame) if that helps.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions