DEV Community

hao jia
hao jia

Posted on

My cutout PNG looked fine in canvas 2D and glowed in WebGL

I took one transparent PNG, drew it onto a black background with canvas 2D, and the edges looked right. I drew the same file with WebGL and a bright white band appeared under the dog's belly. The semi-transparent edge pixels averaged 54.9 in luminance with canvas 2D and 108.9 with WebGL. Same file, same browser. The only difference was two WebGL settings that disagreed with each other.

Part of the halo is already in the file

For the cutout I used ImgIng (https://imging.ai/) in its fast mode, ISNet INT8, on two white-background photos from Wikimedia Commons, then composited the exports onto black. The one I use here is a puppy photo by George Hodan, released under CC0. The model runs in the browser and I watched the network panel during export: zero non-GET requests. The refine panel says the default output uses the AI result pixel by pixel without changing alpha or edge colors, so whatever I measured came from the model output and my compositing, nothing in between.

The default export has 113,438 semi-transparent pixels. Their stored color averages 160.1 in luminance, while the opaque subject within 3 px of the edge averages 108.6. So the soft edge still carries some of the original white background. Composited correctly onto black, the edge averages 75.4 against an estimated 64.3 if it only held subject color. That gap of about 11 is baked into the file. Exporting WebP instead of PNG gave identical edge pixels, so the format is not the cause. Your compositing code cannot remove this part. What it can do is double it.

Straight alpha needs exactly one multiply

PNG stores straight alpha: color as is, alpha in its own channel. Compositing has to multiply color by alpha exactly once. Premultiplied data has already been multiplied, so it must not be multiplied again. Canvas 2D handles this for you. WebGL splits the decision across two independent settings: UNPACK_PREMULTIPLY_ALPHA_WEBGL at texture upload, and the source factor you pass to blendFunc.

I cropped a 480×480 px edge region with 43,740 semi-transparent pixels and ran all four combinations in Chromium 149, reading pixels back after drawing onto black. The correct answer for that crop is 54.9.

  • Canvas 2D: 54.9, max difference from the formula 0.
  • Straight upload + SRC_ALPHA: 54.9, mean difference 0.2.
  • Premultiplied upload + ONE: 54.9, mean difference 0.2.
  • Straight upload + ONE: 108.9. Color never gets multiplied, so the edge is twice as bright.
  • Premultiplied upload + SRC_ALPHA: 48.8. Color gets multiplied twice and the edge goes dark.

To make sure multiply count was the whole story, I computed rgb*a, rgb and rgb*a*a on the CPU over the same 43,740 pixels. The means came out 54.9, 108.9 and 48.8, matching what WebGL returned.

Same puppy edge: canvas 2D, straight upload with ONE, premultiplied upload with SRC_ALPHA

Look at the bottom edge of each panel. The left one is canvas 2D and fades cleanly into black. The middle one has a near-white stripe below the belly and every fur tip glows. The right one looks fine at first glance, but the edge is thinner and darker than on the left. That dark variant is the one I would miss in a screenshot review.

Tie both settings to one value

What I do now is never set the two separately:

const BLEND_FOR_UPLOAD = { straight: 'SRC_ALPHA', premultiplied: 'ONE' };

function uploadCutout(webgl, image, mode) {
  webgl.pixelStorei(webgl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, mode === 'premultiplied');
  webgl.texImage2D(webgl.TEXTURE_2D, 0, webgl.RGBA, webgl.RGBA, webgl.UNSIGNED_BYTE, image);
  webgl.blendFunc(webgl[BLEND_FOR_UPLOAD[mode]], webgl.ONE_MINUS_SRC_ALPHA);
}
Enter fullscreen mode Exit fullscreen mode

I ran both modes on the same crop and compared edge luminance against the canvas 2D result on black. The mean difference was 0 in both cases. Anyone who changes the upload mode later changes the blend factor with it.

What is still open

Canvas 2D makes a good reference here: getImageData on a transparent canvas returned the file's RGB with zero error, including pixels with alpha below 32. But I only tested Chromium 149, where WebGL may be running on a software renderer. Firefox and Safari are untested.

The roughly 11 units baked into the file are a separate decision. ImgIng has a "remove background color fringe" toggle, off by default. On the puppy it brought the edge to 76.0 against an estimated 75.6, so the halo is basically gone. The cost is that it rewrites alpha: 76,236 pixels changed and a jagged patch appeared where the front leg meets the belly. I have left it off for now. Before shipping anything that draws cutouts with WebGL, I check that upload mode and blend factor come from the same variable, and compare one frame against canvas 2D.

Top comments (0)