diff --git a/examples/tests/premultiply-alpha.ts b/examples/tests/premultiply-alpha.ts new file mode 100644 index 0000000..b0249aa --- /dev/null +++ b/examples/tests/premultiply-alpha.ts @@ -0,0 +1,115 @@ +import type { INode, NodeLoadedEventHandler } from '@lightningjs/renderer'; +import type { ExampleSettings } from '../common/ExampleSettings.js'; +import rocko from '../assets/rocko.png'; + +/** + * Premultiply-alpha ghosting test. + * + * A transparent PNG with antialiased edges is rendered with + * `premultiplyAlpha: true` (correct) and `premultiplyAlpha: false` (the state + * older Safari/WebKit silently lands in because it ignores the + * createImageBitmap `premultiplyAlpha: 'premultiply'` option). + * + * Each is shown over a light and a dark background so the edge halos produced + * by straight (non-premultiplied) alpha are visible: the `false` column should + * show fringing/ghosting around the silhouette, the `true` column should have + * clean edges. + */ +export async function automation(settings: ExampleSettings) { + await test(settings); + await settings.snapshot(); +} + +export default async function test({ renderer, testRoot }: ExampleSettings) { + const PADDING = 80; + const CELL = 220; + + testRoot.color = 0x808080ff; // neutral grey so both halo colors show + + renderer.createTextNode({ + text: 'Premultiply Alpha: left column = premultiply:true (correct), right = false (Safari bug)', + fontFamily: 'Ubuntu', + fontSize: 30, + color: 0xffffffff, + x: PADDING, + y: PADDING, + parent: testRoot, + }); + + // Surface what the startup probe detected on this device, if reachable. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const honored = (renderer as any).stage?.txManager?.imageBitmapSupported + ?.premultiplyHonored; + renderer.createTextNode({ + text: `Device probe: createImageBitmap premultiplyAlpha honored: ${String( + honored, + )}`, + fontFamily: 'Ubuntu', + fontSize: 26, + color: 0xffff00ff, + x: PADDING, + y: PADDING + 40, + parent: testRoot, + }); + + const sizeToTexture: NodeLoadedEventHandler = (target, payload) => { + const { w, h } = payload.dimensions; + target.w = w; + target.h = h; + }; + + const rows: { label: string; bg: number }[] = [ + { label: 'over white', bg: 0xffffffff }, + { label: 'over black', bg: 0x000000ff }, + ]; + + const cols: { label: string; premultiplyAlpha: boolean }[] = [ + { label: 'premultiply: true', premultiplyAlpha: true }, + { label: 'premultiply: false', premultiplyAlpha: false }, + ]; + + const gridTop = PADDING + 90; + + for (let r = 0; r < rows.length; r++) { + const row = rows[r]!; + const y = gridTop + r * (CELL + PADDING + 30); + + for (let c = 0; c < cols.length; c++) { + const col = cols[c]!; + const x = PADDING + c * (CELL + PADDING); + + // Contrasting background panel + renderer.createNode({ + x, + y, + w: CELL, + h: CELL, + color: row.bg, + parent: testRoot, + }); + + // Transparent image on top, with the mode under test + renderer + .createNode({ + x, + y, + parent: testRoot, + texture: renderer.createTexture('ImageTexture', { + src: rocko, + premultiplyAlpha: col.premultiplyAlpha, + }), + }) + .once('loaded', sizeToTexture); + + renderer.createTextNode({ + text: `${col.label}, ${row.label}`, + fontFamily: 'Ubuntu', + fontSize: 22, + color: 0xffffffff, + x, + y: y + CELL + 4, + parent: testRoot, + }); + } + } +} diff --git a/src/core/CoreTextureManager.ts b/src/core/CoreTextureManager.ts index ef70e88..d508511 100644 --- a/src/core/CoreTextureManager.ts +++ b/src/core/CoreTextureManager.ts @@ -10,6 +10,7 @@ import { EventEmitter } from '../common/EventEmitter.js'; import type { Stage } from './Stage.js'; import { validateCreateImageBitmap, + detectPremultiplyAlphaHonored, type CreateImageBitmapSupport, } from './lib/validateImageBitmap.js'; import type { Platform } from './platforms/Platform.js'; @@ -46,6 +47,9 @@ export interface TextureManagerDebugInfo { export interface TextureManagerSettings { numImageWorkers: number; createImageBitmapSupport: 'auto' | 'basic' | 'options' | 'full'; + // Override for whether createImageBitmap honors premultiplyAlpha:'premultiply'. + // 'auto' = detect via probe; boolean = force the value and skip the probe. + premultiplyAlphaHonored: boolean | 'auto'; maxRetryCount: number; } @@ -255,6 +259,7 @@ export class CoreTextureManager extends EventEmitter { basic: false, options: false, full: false, + premultiplyHonored: null as boolean | null, }; hasWorker = !!self.Worker; @@ -281,8 +286,12 @@ export class CoreTextureManager extends EventEmitter { constructor(stage: Stage, settings: TextureManagerSettings) { super(); - const { numImageWorkers, createImageBitmapSupport, maxRetryCount } = - settings; + const { + numImageWorkers, + createImageBitmapSupport, + premultiplyAlphaHonored, + maxRetryCount, + } = settings; this.stage = stage; this.platform = stage.platform; @@ -292,7 +301,7 @@ export class CoreTextureManager extends EventEmitter { if (createImageBitmapSupport === 'auto') { validateCreateImageBitmap(this.platform) .then((result) => { - this.initialize(result); + this.resolvePremultiplyAndInit(result, premultiplyAlphaHonored); }) .catch(() => { console.warn( @@ -304,11 +313,15 @@ export class CoreTextureManager extends EventEmitter { this.emit('initialized'); }); } else { - this.initialize({ - basic: createImageBitmapSupport === 'basic', - options: createImageBitmapSupport === 'options', - full: createImageBitmapSupport === 'full', - }); + this.resolvePremultiplyAndInit( + { + basic: createImageBitmapSupport === 'basic', + options: createImageBitmapSupport === 'options', + full: createImageBitmapSupport === 'full', + premultiplyHonored: null, + }, + premultiplyAlphaHonored, + ); } this.registerTextureType('ImageTexture', ImageTexture); @@ -325,11 +338,51 @@ export class CoreTextureManager extends EventEmitter { this.txConstructors[textureType] = textureClass; } + /** + * Resolve `premultiplyHonored` on the support object, then initialize. + * + * - boolean override -> use it directly, skip the probe + * - 'auto' -> run the detection probe (only meaningful when the options/full + * API exists, since that's the only path that passes the premultiply option) + */ + private resolvePremultiplyAndInit( + support: CreateImageBitmapSupport, + premultiplyAlphaHonored: boolean | 'auto', + ): void { + if (premultiplyAlphaHonored !== 'auto') { + support.premultiplyHonored = premultiplyAlphaHonored; + this.initialize(support); + return; + } + + if (support.options === false && support.full === false) { + support.premultiplyHonored = null; + this.initialize(support); + return; + } + + detectPremultiplyAlphaHonored(this.platform) + .then((honored) => { + support.premultiplyHonored = honored; + this.initialize(support); + }) + .catch(() => { + support.premultiplyHonored = null; + this.initialize(support); + }); + } + private initialize(support: CreateImageBitmapSupport) { this.hasCreateImageBitmap = support.basic || support.options || support.full; this.imageBitmapSupported = support; + if (support.premultiplyHonored === false) { + console.warn( + '[Lightning] createImageBitmap premultiplyAlpha:"premultiply" is not honored on this device — images may show alpha ghosting. GL-side premultiply fallback recommended.', + ); + } + if (this.hasCreateImageBitmap === false) { console.warn( '[Lightning] createImageBitmap is not supported on this browser. ImageTexture will be slower.', diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 7f495e1..016eee7 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -179,6 +179,7 @@ export class Stage { renderEngine, fontEngines, createImageBitmapSupport, + premultiplyAlphaHonored, platform, maxRetryCount, } = options; @@ -206,6 +207,8 @@ export class Stage { this.txManager = new CoreTextureManager(this, { numImageWorkers, createImageBitmapSupport, + // undefined -> true (default: assume honored, no probe) + premultiplyAlphaHonored: premultiplyAlphaHonored ?? true, maxRetryCount, }); diff --git a/src/core/lib/ImageWorker.ts b/src/core/lib/ImageWorker.ts index de15edc..fc38764 100644 --- a/src/core/lib/ImageWorker.ts +++ b/src/core/lib/ImageWorker.ts @@ -43,12 +43,14 @@ function createImageWorker() { options: { supportsOptionsCreateImageBitmap: boolean; supportsFullCreateImageBitmap: boolean; + premultiplyAlphaHonored: boolean; }, ): Promise { return new Promise(function (resolve, reject) { var supportsOptionsCreateImageBitmap = options.supportsOptionsCreateImageBitmap; var supportsFullCreateImageBitmap = options.supportsFullCreateImageBitmap; + var premultiplyAlphaHonored = options.premultiplyAlphaHonored; var xhr = new XMLHttpRequest(); xhr.open('GET', src, true); xhr.responseType = 'blob'; @@ -71,6 +73,17 @@ function createImageWorker() { ? premultiplyAlpha : hasAlphaChannel(blob.type); + // When the device ignores the createImageBitmap premultiply option, + // create a straight ('none') bitmap and let WebGL premultiply on + // upload. `premultiplyAlpha` in the resolved value means "WebGL should + // premultiply this source on upload". + var useGlPremultiply = + withAlphaChannel === true && premultiplyAlphaHonored === false; + var bitmapMode: 'premultiply' | 'none' = + withAlphaChannel === true && useGlPremultiply === false + ? 'premultiply' + : 'none'; + // createImageBitmap with crop and options if ( supportsFullCreateImageBitmap === true && @@ -78,12 +91,12 @@ function createImageWorker() { height !== null ) { createImageBitmap(blob, x || 0, y || 0, width, height, { - premultiplyAlpha: withAlphaChannel ? 'premultiply' : 'none', + premultiplyAlpha: bitmapMode, colorSpaceConversion: 'none', imageOrientation: 'none', }) .then(function (data) { - resolve({ data: data, premultiplyAlpha: withAlphaChannel }); + resolve({ data: data, premultiplyAlpha: useGlPremultiply }); }) .catch(function (error) { reject(error); @@ -94,22 +107,23 @@ function createImageWorker() { supportsFullCreateImageBitmap === false ) { // Fallback for browsers that do not support createImageBitmap with options - // this is supported for Chrome v50 to v52/54 that doesn't support options + // this is supported for Chrome v50 to v52/54 that doesn't support options. + // The browser default premultiplies, so WebGL must not premultiply again. createImageBitmap(blob) .then(function (data) { - resolve({ data: data, premultiplyAlpha: withAlphaChannel }); + resolve({ data: data, premultiplyAlpha: false }); }) .catch(function (error) { reject(error); }); } else { createImageBitmap(blob, { - premultiplyAlpha: withAlphaChannel ? 'premultiply' : 'none', + premultiplyAlpha: bitmapMode, colorSpaceConversion: 'none', imageOrientation: 'none', }) .then(function (data) { - resolve({ data: data, premultiplyAlpha: withAlphaChannel }); + resolve({ data: data, premultiplyAlpha: useGlPremultiply }); }) .catch(function (error) { reject(error); @@ -139,10 +153,13 @@ function createImageWorker() { // these will be set to true if the browser supports the createImageBitmap options or full var supportsOptionsCreateImageBitmap = false; var supportsFullCreateImageBitmap = false; + // set to false when the device is known to ignore the premultiply option + var premultiplyAlphaHonored = true; getImage(src, premultiplyAlpha, x, y, width, height, { supportsOptionsCreateImageBitmap, supportsFullCreateImageBitmap, + premultiplyAlphaHonored, }) .then(function (data) { // @ts-ignore ts has wrong postMessage signature @@ -240,6 +257,13 @@ export class ImageWorkerManager { ); } + if (createImageBitmapSupport.premultiplyHonored === false) { + workerCode = workerCode.replace( + 'var premultiplyAlphaHonored = true;', + 'var premultiplyAlphaHonored = false;', + ); + } + workerCode = workerCode.replace('"use strict";', ''); const blob: Blob = new Blob([workerCode], { type: 'application/javascript', diff --git a/src/core/lib/textureSvg.ts b/src/core/lib/textureSvg.ts index 25d236d..ce2868a 100644 --- a/src/core/lib/textureSvg.ts +++ b/src/core/lib/textureSvg.ts @@ -91,8 +91,11 @@ export const loadSvg = async ( } } + // getImageData returns straight (un-premultiplied) pixels, so WebGL must + // premultiply this source on upload (unlike the ImageBitmap path above, + // where the canvas is already premultiplied by createImageBitmap's default). return { data: ctx.getImageData(0, 0, physW, physH), - premultiplyAlpha: false, + premultiplyAlpha: true, }; }; diff --git a/src/core/lib/validateImageBitmap.test.ts b/src/core/lib/validateImageBitmap.test.ts new file mode 100644 index 0000000..bee51ba --- /dev/null +++ b/src/core/lib/validateImageBitmap.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { detectPremultiplyAlphaHonored } from './validateImageBitmap.js'; +import type { Platform } from '../platforms/Platform.js'; + +/** + * Minimal stand-in for the WebGL constants/methods the probe touches. The probe + * uploads a known straight pixel, reads it back, and infers whether + * createImageBitmap premultiplied it. `readbackRed` is what readPixels returns + * for the red channel: ~128 = premultiplied (honored), ~255 = straight (ignored). + */ +function createFakeGl(readbackRed: number, framebufferComplete = true) { + return { + TEXTURE_2D: 0x0de1, + UNPACK_PREMULTIPLY_ALPHA_WEBGL: 0x9241, + UNPACK_FLIP_Y_WEBGL: 0x9240, + RGBA: 0x1908, + UNSIGNED_BYTE: 0x1401, + FRAMEBUFFER: 0x8d40, + COLOR_ATTACHMENT0: 0x8ce0, + FRAMEBUFFER_COMPLETE: 0x8cd5, + createTexture: vi.fn(() => ({})), + bindTexture: vi.fn(), + pixelStorei: vi.fn(), + texImage2D: vi.fn(), + createFramebuffer: vi.fn(() => ({})), + bindFramebuffer: vi.fn(), + framebufferTexture2D: vi.fn(), + checkFramebufferStatus: vi.fn(() => (framebufferComplete ? 0x8cd5 : 0)), + readPixels: vi.fn( + ( + _x: number, + _y: number, + _w: number, + _h: number, + _format: number, + _type: number, + px: Uint8Array, + ) => { + px[0] = readbackRed; + px[1] = 0; + px[2] = 0; + px[3] = 128; + }, + ), + deleteFramebuffer: vi.fn(), + deleteTexture: vi.fn(), + }; +} + +function createPlatform(gl: object | null): Platform { + const close = vi.fn(); + return { + createImageBitmap: vi.fn(() => Promise.resolve({ close })), + createCanvas: vi.fn(() => ({ + width: 0, + height: 0, + getContext: vi.fn((type: string) => (type === 'webgl' ? gl : null)), + })), + } as unknown as Platform; +} + +describe('detectPremultiplyAlphaHonored', () => { + beforeEach(() => { + // node test env has no ImageData global; the probe constructs one. + (globalThis as unknown as { ImageData: unknown }).ImageData = class { + data: Uint8ClampedArray; + width: number; + height: number; + constructor(data: Uint8ClampedArray, width: number, height: number) { + this.data = data; + this.width = width; + this.height = height; + } + }; + }); + + afterEach(() => { + delete (globalThis as unknown as { ImageData?: unknown }).ImageData; + }); + + it('returns true when the bitmap reads back premultiplied (~128)', async () => { + const platform = createPlatform(createFakeGl(128)); + expect(await detectPremultiplyAlphaHonored(platform)).toBe(true); + }); + + it('returns false when the bitmap reads back straight (~255)', async () => { + const platform = createPlatform(createFakeGl(255)); + expect(await detectPremultiplyAlphaHonored(platform)).toBe(false); + }); + + it('returns null when createImageBitmap throws', async () => { + const platform = { + createImageBitmap: vi.fn(() => Promise.reject(new Error('unsupported'))), + createCanvas: vi.fn(), + } as unknown as Platform; + expect(await detectPremultiplyAlphaHonored(platform)).toBe(null); + }); + + it('returns null when no WebGL context is available', async () => { + const platform = createPlatform(null); + expect(await detectPremultiplyAlphaHonored(platform)).toBe(null); + }); + + it('returns null when the framebuffer is incomplete', async () => { + const platform = createPlatform(createFakeGl(128, false)); + expect(await detectPremultiplyAlphaHonored(platform)).toBe(null); + }); + + it('disables GL-side premultiply on the probe upload', async () => { + const gl = createFakeGl(128); + await detectPremultiplyAlphaHonored(createPlatform(gl)); + // The probe must observe the bitmap's own alpha state, so GL premultiply + // has to be off during the readback upload. + expect(gl.pixelStorei).toHaveBeenCalledWith( + gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, + false, + ); + }); +}); diff --git a/src/core/lib/validateImageBitmap.ts b/src/core/lib/validateImageBitmap.ts index 70f7d4f..8c4abcf 100644 --- a/src/core/lib/validateImageBitmap.ts +++ b/src/core/lib/validateImageBitmap.ts @@ -4,6 +4,11 @@ export interface CreateImageBitmapSupport { basic: boolean; // Supports createImageBitmap(image) options: boolean; // Supports createImageBitmap(image, options) full: boolean; // Supports createImageBitmap(image, sx, sy, sw, sh, options) + // Whether `premultiplyAlpha: 'premultiply'` is actually HONORED (not just + // accepted without throwing). null = could not determine. Older Safari/WebKit + // accepts the option but ignores it, returning straight alpha — the source of + // the edge-ghosting bug on those devices. + premultiplyHonored: boolean | null; } export async function validateCreateImageBitmap( @@ -47,6 +52,7 @@ export async function validateCreateImageBitmap( basic: false, options: false, full: false, + premultiplyHonored: null, }; // Test basic createImageBitmap support @@ -83,5 +89,88 @@ export async function validateCreateImageBitmap( /* ignore */ } + // premultiplyHonored is resolved separately by the caller (it may be a + // forced override or an explicit opt-in to the probe), so it is left as its + // default (null) here. return support; } + +/** + * Determine whether `createImageBitmap(..., { premultiplyAlpha: 'premultiply' })` + * is actually honored by this browser. + * + * Strategy: feed a known straight-alpha pixel (255, 0, 0, 128) through + * createImageBitmap with 'premultiply', upload it to a WebGL texture with + * GL-side premultiply DISABLED (so we observe the bitmap's own state), then + * read the raw texel back via a framebuffer. + * + * - honored -> red comes back premultiplied (~128) + * - ignored -> red comes back straight (~255) [older Safari/WebKit] + * + * @returns true if honored, false if ignored, null if it couldn't be measured + * (no WebGL, createImageBitmap from ImageData unsupported, framebuffer + * incomplete, etc.) — caller should treat null as "unknown". + */ +export async function detectPremultiplyAlphaHonored( + platform: Platform, +): Promise { + let bitmap: ImageBitmap; + try { + // Straight (un-premultiplied) RGBA. ImageData is straight-alpha by spec. + const imageData = new ImageData( + new Uint8ClampedArray([255, 0, 0, 128]), + 1, + 1, + ); + bitmap = await platform.createImageBitmap(imageData, { + premultiplyAlpha: 'premultiply', + colorSpaceConversion: 'none', + imageOrientation: 'none', + }); + } catch (e) { + return null; + } + + const canvas = platform.createCanvas(); + canvas.width = 1; + canvas.height = 1; + const gl = (canvas.getContext('webgl') || + canvas.getContext('experimental-webgl')) as WebGLRenderingContext | null; + if (gl === null) { + bitmap.close?.(); + return null; + } + + const tex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, tex); + // Critical: do NOT let GL premultiply. We want to observe whatever state the + // bitmap itself is in. + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap); + + const fb = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fb); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + tex, + 0, + ); + + let result: boolean | null = null; + if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE) { + const px = new Uint8Array(4); + gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + // Straight red reads ~255; premultiplied red reads ~128. Split at the + // midpoint to tolerate rounding/colorspace drift. + result = px[0]! < 192; + } + + gl.deleteFramebuffer(fb); + gl.deleteTexture(tex); + bitmap.close?.(); + + return result; +} diff --git a/src/core/renderers/webgl/WebGlCtxTexture.ts b/src/core/renderers/webgl/WebGlCtxTexture.ts index de85d18..0fcf37d 100644 --- a/src/core/renderers/webgl/WebGlCtxTexture.ts +++ b/src/core/renderers/webgl/WebGlCtxTexture.ts @@ -188,9 +188,13 @@ export class WebGlCtxTexture extends CoreContextTexture { w = tdata.width; h = tdata.height; glw.bindTexture(this._nativeCtxTexture); + // `premultiplyAlpha` carries the source's GL-upload intent: true when the + // source pixels are straight and WebGL must premultiply (e.g. a straight + // bitmap produced when the device ignores the createImageBitmap + // premultiply option), false when the source is already premultiplied. glw.pixelStorei( glw.UNPACK_PREMULTIPLY_ALPHA_WEBGL, - isImageBitmap ? false : !!textureData.premultiplyAlpha, + !!textureData.premultiplyAlpha, ); glw.texImage2D(0, format, format, glw.UNSIGNED_BYTE, tdata); diff --git a/src/core/textures/ImageTexture.ts b/src/core/textures/ImageTexture.ts index 44904ab..8890a03 100644 --- a/src/core/textures/ImageTexture.ts +++ b/src/core/textures/ImageTexture.ts @@ -192,6 +192,18 @@ export class ImageTexture extends Texture { const hasAlphaChannel = premultiplyAlpha ?? blob.type.includes('image/png'); const imageBitmapSupported = this.txManager.imageBitmapSupported; + // When the device does NOT honor the createImageBitmap premultiply option + // (e.g. older Safari), request a straight ('none') bitmap and let WebGL + // premultiply on upload instead. `premultiplyAlpha` in the returned + // TextureData means "WebGL should premultiply this source on upload". + const useGlPremultiply = + hasAlphaChannel === true && + imageBitmapSupported.premultiplyHonored === false; + const bitmapMode: 'premultiply' | 'none' = + hasAlphaChannel === true && useGlPremultiply === false + ? 'premultiply' + : 'none'; + if (imageBitmapSupported.full === true && sw !== null && sh !== null) { // createImageBitmap with crop const bitmap = await this.platform.createImageBitmap( @@ -201,28 +213,29 @@ export class ImageTexture extends Texture { sw, sh, { - premultiplyAlpha: hasAlphaChannel ? 'premultiply' : 'none', + premultiplyAlpha: bitmapMode, colorSpaceConversion: 'none', imageOrientation: 'none', }, ); - return { data: bitmap, premultiplyAlpha: hasAlphaChannel }; + return { data: bitmap, premultiplyAlpha: useGlPremultiply }; } else if (imageBitmapSupported.basic === true) { // basic createImageBitmap without options or crop - // this is supported for Chrome v50 to v52/54 that doesn't support options + // this is supported for Chrome v50 to v52/54 that doesn't support options. + // The browser default premultiplies, so WebGL must not premultiply again. return { data: await this.platform.createImageBitmap(blob), - premultiplyAlpha: hasAlphaChannel, + premultiplyAlpha: false, }; } // default createImageBitmap without crop but with options const bitmap = await this.platform.createImageBitmap(blob, { - premultiplyAlpha: hasAlphaChannel ? 'premultiply' : 'none', + premultiplyAlpha: bitmapMode, colorSpaceConversion: 'none', imageOrientation: 'none', }); - return { data: bitmap, premultiplyAlpha: hasAlphaChannel }; + return { data: bitmap, premultiplyAlpha: useGlPremultiply }; } async loadImage(src: string) { @@ -274,9 +287,14 @@ export class ImageTexture extends Texture { }; } + // The loader computes whether WebGL should premultiply this source on + // upload (it depends on the source type and on whether createImageBitmap + // honored the premultiply option). Preserve it; fall back to the prop only + // when the loader didn't decide. return { data: resp.data, - premultiplyAlpha: this.props.premultiplyAlpha ?? true, + premultiplyAlpha: + resp.premultiplyAlpha ?? this.props.premultiplyAlpha ?? true, }; } diff --git a/src/main-api/Renderer.ts b/src/main-api/Renderer.ts index 5c978de..940ebcc 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -458,6 +458,25 @@ export type RendererMainSettings = RendererRuntimeSettings & { */ createImageBitmapSupport: 'auto' | 'basic' | 'options' | 'full'; + /** + * Override for whether `createImageBitmap(..., { premultiplyAlpha: 'premultiply' })` + * is actually honored by the target device. + * + * @remarks + * Some older browsers (notably older Safari/WebKit) accept the + * `premultiplyAlpha: 'premultiply'` option without throwing but silently + * ignore it, returning straight (non-premultiplied) alpha. This causes edge + * "ghosting" on images with transparency. + * + * Set to `'auto'` to detect via a cheap startup probe (one 1×1 texture + * upload + framebuffer readback). Set to a boolean to force the value. Leave + * unset to assume the option is honored — the default, which preserves + * existing behavior with no probe overhead. + * + * @defaultValue `true` (assume honored; no probe) + */ + premultiplyAlphaHonored?: boolean | 'auto'; + /** * Provide an alternative platform abstraction layer * @@ -586,6 +605,12 @@ export class RendererMain extends EventEmitter { textureProcessingTimeLimit: settings.textureProcessingTimeLimit || 10, canvas: settings.canvas, createImageBitmapSupport: settings.createImageBitmapSupport || 'full', + // undefined -> true (assume honored, no probe); 'auto' -> probe; + // explicit boolean -> force the value. + premultiplyAlphaHonored: + settings.premultiplyAlphaHonored === undefined + ? true + : settings.premultiplyAlphaHonored, platform: settings.platform || null, maxRetryCount: settings.maxRetryCount ?? 5, }; @@ -646,6 +671,7 @@ export class RendererMain extends EventEmitter { targetFPS: settings.targetFPS!, textureProcessingTimeLimit: settings.textureProcessingTimeLimit!, createImageBitmapSupport: settings.createImageBitmapSupport!, + premultiplyAlphaHonored: settings.premultiplyAlphaHonored, platform, maxRetryCount: settings.maxRetryCount ?? 5, }); diff --git a/visual-regression/certified-snapshots/chromium-ci/premultiply-alpha-1.png b/visual-regression/certified-snapshots/chromium-ci/premultiply-alpha-1.png new file mode 100644 index 0000000..f539d4f Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/premultiply-alpha-1.png differ