Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions examples/tests/premultiply-alpha.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
}
69 changes: 61 additions & 8 deletions src/core/CoreTextureManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -255,6 +259,7 @@ export class CoreTextureManager extends EventEmitter {
basic: false,
options: false,
full: false,
premultiplyHonored: null as boolean | null,
};

hasWorker = !!self.Worker;
Expand All @@ -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;
Expand All @@ -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(
Expand All @@ -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);
Expand All @@ -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.',
Expand Down
3 changes: 3 additions & 0 deletions src/core/Stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export class Stage {
renderEngine,
fontEngines,
createImageBitmapSupport,
premultiplyAlphaHonored,
platform,
maxRetryCount,
} = options;
Expand Down Expand Up @@ -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,
});

Expand Down
36 changes: 30 additions & 6 deletions src/core/lib/ImageWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,14 @@ function createImageWorker() {
options: {
supportsOptionsCreateImageBitmap: boolean;
supportsFullCreateImageBitmap: boolean;
premultiplyAlphaHonored: boolean;
},
): Promise<getImageReturn> {
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';
Expand All @@ -71,19 +73,30 @@ 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 &&
width !== null &&
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);
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
5 changes: 4 additions & 1 deletion src/core/lib/textureSvg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
};
Loading
Loading