Skip to content

Commit 993698c

Browse files
authored
DumpTools: Add bitmaprenderer fallback when ThinEngine is unavailable (#17015)
This PR updates DumpTools to fall back to a BitmapRenderingContext from a canvas when ThinEngine isn’t available, instead of relying solely on ThinEngine. It also fixes issues that were first resolved in #13251 but regressed after #13803. To make DumpData Native-friendly, we need to avoid ThinEngine. While it might be possible to swap ThinEngine with [insert platform compatible engine], in practice it’s much simpler— at least for Native —to extend the existing 2D Canvas polyfill with bitmaprenderer support, rather than extending its graphics infrastructure to handle rendering and reading from a separate context or canvas. We can safely remove the engine dependency entirely if/when Webkit and Gecko respect the `premultiplyAlpha` flag when reading ImageBitmap. Or we can remove it now, if we're OK with breaking those (quite edge) cases. Thoughts? <details> <summary>More details</summary> For reference, below is a table showing observed results for whether unassociated alpha is preserved when reading pixel data, across different rendering contexts and browsers. Platform | WebGL + Canvas | WebGL + OffscreenCanvas | Bitmap + Canvas | Bitmap + OffscreenCanvas -- | -- | -- | -- | -- Blink (Chromium) | ✅| ❌| ✅| ✅ WebKit | ✅| ❌| ❌| ❌ Gecko | ✅| ✅| ❌| ❌ </details>
1 parent 1e362ef commit 993698c

File tree

1 file changed

+151
-136
lines changed

1 file changed

+151
-136
lines changed

packages/dev/core/src/Misc/dumpTools.ts

Lines changed: 151 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -5,92 +5,84 @@ import type { ThinEngine } from "../Engines/thinEngine";
55
import { Constants } from "../Engines/constants";
66
import { EffectRenderer, EffectWrapper } from "../Materials/effectRenderer";
77
import { Tools } from "./tools";
8-
import type { Nullable } from "../types";
98
import { Clamp } from "../Maths/math.scalar.functions";
109
import type { AbstractEngine } from "../Engines/abstractEngine";
1110
import { EngineStore } from "../Engines/engineStore";
11+
import { Logger } from "./logger";
1212

13-
type DumpToolsEngine = {
13+
type DumpResources = {
1414
canvas: HTMLCanvasElement | OffscreenCanvas;
15-
engine: ThinEngine;
16-
renderer: EffectRenderer;
17-
wrapper: EffectWrapper;
15+
dumpEngine?: {
16+
engine: ThinEngine;
17+
renderer: EffectRenderer;
18+
wrapper: EffectWrapper;
19+
};
1820
};
1921

20-
let DumpToolsEngine: Nullable<DumpToolsEngine>;
22+
let ResourcesPromise: Promise<DumpResources> | null = null;
2123

22-
let EnginePromise: Promise<DumpToolsEngine> | null = null;
24+
async function _CreateDumpResourcesAsync(): Promise<DumpResources> {
25+
// Create a compatible canvas. Prefer an HTMLCanvasElement if possible to avoid alpha issues with OffscreenCanvas + WebGL in many browsers.
26+
const canvas = (EngineStore.LastCreatedEngine?.createCanvas(100, 100) ?? new OffscreenCanvas(100, 100)) as HTMLCanvasElement | OffscreenCanvas; // will be resized later
27+
if (canvas instanceof OffscreenCanvas) {
28+
Logger.Warn("DumpData: OffscreenCanvas will be used for dumping data. This may result in lossy alpha values.");
29+
}
2330

24-
async function _CreateDumpRendererAsync(): Promise<DumpToolsEngine> {
25-
if (!EnginePromise) {
26-
EnginePromise = new Promise((resolve, reject) => {
27-
let canvas: HTMLCanvasElement | OffscreenCanvas;
28-
let engine: Nullable<ThinEngine> = null;
29-
const options = {
30-
preserveDrawingBuffer: true,
31-
depth: false,
32-
stencil: false,
33-
alpha: true,
34-
premultipliedAlpha: false,
35-
antialias: false,
36-
failIfMajorPerformanceCaveat: false,
37-
};
38-
import("../Engines/thinEngine")
39-
// eslint-disable-next-line github/no-then
40-
.then(({ ThinEngine: thinEngineClass }) => {
41-
const engineInstanceCount = EngineStore.Instances.length;
42-
try {
43-
canvas = new OffscreenCanvas(100, 100); // will be resized later
44-
engine = new thinEngineClass(canvas, false, options);
45-
} catch (e) {
46-
if (engineInstanceCount < EngineStore.Instances.length) {
47-
// The engine was created by another instance, let's use it
48-
EngineStore.Instances.pop()?.dispose();
49-
}
50-
// The browser either does not support OffscreenCanvas or WebGL context in OffscreenCanvas, fallback on a regular canvas
51-
canvas = document.createElement("canvas");
52-
engine = new thinEngineClass(canvas, false, options);
53-
}
54-
// remove this engine from the list of instances to avoid using it for other purposes
55-
EngineStore.Instances.pop();
56-
// However, make sure to dispose it when no other engines are left
57-
EngineStore.OnEnginesDisposedObservable.add((e) => {
58-
// guaranteed to run when no other instances are left
59-
// only dispose if it's not the current engine
60-
if (engine && e !== engine && !engine.isDisposed && EngineStore.Instances.length === 0) {
61-
// Dump the engine and the associated resources
62-
Dispose();
63-
}
64-
});
65-
engine.getCaps().parallelShaderCompile = undefined;
66-
const renderer = new EffectRenderer(engine);
67-
// eslint-disable-next-line @typescript-eslint/no-floating-promises, github/no-then
68-
import("../Shaders/pass.fragment").then(({ passPixelShader }) => {
69-
if (!engine) {
70-
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
71-
reject("Engine is not defined");
72-
return;
73-
}
74-
const wrapper = new EffectWrapper({
75-
engine,
76-
name: passPixelShader.name,
77-
fragmentShader: passPixelShader.shader,
78-
samplerNames: ["textureSampler"],
79-
});
80-
DumpToolsEngine = {
81-
canvas,
82-
engine,
83-
renderer,
84-
wrapper,
85-
};
86-
resolve(DumpToolsEngine);
87-
});
88-
})
89-
// eslint-disable-next-line github/no-then
90-
.catch(reject);
91-
});
31+
// If WebGL via ThinEngine is not available (e.g. Native), use the BitmapRenderer.
32+
// If https://github.com/whatwg/html/issues/10142 is resolved, we can migrate to just BitmapRenderer and avoid an engine dependency altogether.
33+
const { ThinEngine: thinEngineClass } = await import("../Engines/thinEngine");
34+
if (!thinEngineClass.IsSupported) {
35+
if (!canvas.getContext("bitmaprenderer")) {
36+
throw new Error("DumpData: No WebGL or bitmap rendering context available. Cannot dump data.");
37+
}
38+
return { canvas };
39+
}
40+
41+
const options = {
42+
preserveDrawingBuffer: true,
43+
depth: false,
44+
stencil: false,
45+
alpha: true,
46+
premultipliedAlpha: false,
47+
antialias: false,
48+
failIfMajorPerformanceCaveat: false,
49+
};
50+
const engine = new thinEngineClass(canvas, false, options);
51+
52+
// remove this engine from the list of instances to avoid using it for other purposes
53+
EngineStore.Instances.pop();
54+
// However, make sure to dispose it when no other engines are left
55+
EngineStore.OnEnginesDisposedObservable.add((e) => {
56+
// guaranteed to run when no other instances are left
57+
// only dispose if it's not the current engine
58+
if (engine && e !== engine && !engine.isDisposed && EngineStore.Instances.length === 0) {
59+
// Dump the engine and the associated resources
60+
Dispose();
61+
}
62+
});
63+
64+
engine.getCaps().parallelShaderCompile = undefined;
65+
66+
const renderer = new EffectRenderer(engine);
67+
const { passPixelShader } = await import("../Shaders/pass.fragment");
68+
const wrapper = new EffectWrapper({
69+
engine,
70+
name: passPixelShader.name,
71+
fragmentShader: passPixelShader.shader,
72+
samplerNames: ["textureSampler"],
73+
});
74+
75+
return {
76+
canvas: canvas,
77+
dumpEngine: { engine, renderer, wrapper },
78+
};
79+
}
80+
81+
async function _GetDumpResourcesAsync() {
82+
if (!ResourcesPromise) {
83+
ResourcesPromise = _CreateDumpResourcesAsync();
9284
}
93-
return await EnginePromise;
85+
return await ResourcesPromise;
9486
}
9587

9688
/**
@@ -165,8 +157,65 @@ export async function DumpDataAsync(
165157
toArrayBuffer = false,
166158
quality?: number
167159
): Promise<string | ArrayBuffer> {
168-
return await new Promise((resolve) => {
169-
DumpData(width, height, data, (result) => resolve(result), mimeType, fileName, invertY, toArrayBuffer, quality);
160+
// Convert if data are float32
161+
if (data instanceof Float32Array) {
162+
const data2 = new Uint8Array(data.length);
163+
let n = data.length;
164+
while (n--) {
165+
const v = data[n];
166+
data2[n] = Math.round(Clamp(v) * 255);
167+
}
168+
data = data2;
169+
}
170+
171+
const resources = await _GetDumpResourcesAsync();
172+
173+
// Keep the async render + read from the shared canvas atomic
174+
// eslint-disable-next-line no-async-promise-executor
175+
return await new Promise<string | ArrayBuffer>(async (resolve) => {
176+
if (resources.dumpEngine) {
177+
const dumpEngine = resources.dumpEngine;
178+
dumpEngine.engine.setSize(width, height, true);
179+
180+
// Create the image
181+
const texture = dumpEngine.engine.createRawTexture(data, width, height, Constants.TEXTUREFORMAT_RGBA, false, !invertY, Constants.TEXTURE_NEAREST_NEAREST);
182+
183+
dumpEngine.renderer.setViewport();
184+
dumpEngine.renderer.applyEffectWrapper(dumpEngine.wrapper);
185+
dumpEngine.wrapper.effect._bindTexture("textureSampler", texture);
186+
dumpEngine.renderer.draw();
187+
188+
texture.dispose();
189+
} else {
190+
const ctx = resources.canvas.getContext("bitmaprenderer") as ImageBitmapRenderingContext;
191+
resources.canvas.width = width;
192+
resources.canvas.height = height;
193+
194+
const imageData = new ImageData(width, height); // ImageData(data, sw, sh) ctor not yet widely implemented
195+
imageData.data.set(data as Uint8ClampedArray);
196+
const imageBitmap = await createImageBitmap(imageData, { premultiplyAlpha: "none", imageOrientation: invertY ? "flipY" : "from-image" });
197+
198+
ctx.transferFromImageBitmap(imageBitmap);
199+
}
200+
201+
// Download the result
202+
if (toArrayBuffer) {
203+
Tools.ToBlob(
204+
resources.canvas,
205+
(blob) => {
206+
const fileReader = new FileReader();
207+
fileReader.onload = (event: any) => {
208+
const arrayBuffer = event.target!.result as ArrayBuffer;
209+
resolve(arrayBuffer);
210+
};
211+
fileReader.readAsArrayBuffer(blob!);
212+
},
213+
mimeType,
214+
quality
215+
);
216+
} else {
217+
Tools.EncodeScreenshotCanvasData(resources.canvas, resolve, mimeType, fileName, quality);
218+
}
170219
});
171220
}
172221

@@ -193,72 +242,38 @@ export function DumpData(
193242
toArrayBuffer = false,
194243
quality?: number
195244
): void {
196-
// eslint-disable-next-line @typescript-eslint/no-floating-promises, github/no-then
197-
_CreateDumpRendererAsync().then((renderer) => {
198-
renderer.engine.setSize(width, height, true);
199-
200-
// Convert if data are float32
201-
if (data instanceof Float32Array) {
202-
const data2 = new Uint8Array(data.length);
203-
let n = data.length;
204-
while (n--) {
205-
const v = data[n];
206-
data2[n] = Math.round(Clamp(v) * 255);
245+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
246+
DumpDataAsync(width, height, data, mimeType, fileName, invertY, toArrayBuffer, quality)
247+
// eslint-disable-next-line github/no-then
248+
.then((result) => {
249+
if (successCallback) {
250+
successCallback(result);
207251
}
208-
data = data2;
209-
}
210-
211-
// Create the image
212-
const texture = renderer.engine.createRawTexture(data, width, height, Constants.TEXTUREFORMAT_RGBA, false, !invertY, Constants.TEXTURE_NEAREST_NEAREST);
213-
214-
renderer.renderer.setViewport();
215-
renderer.renderer.applyEffectWrapper(renderer.wrapper);
216-
renderer.wrapper.effect._bindTexture("textureSampler", texture);
217-
renderer.renderer.draw();
218-
219-
if (toArrayBuffer) {
220-
Tools.ToBlob(
221-
renderer.canvas,
222-
(blob) => {
223-
const fileReader = new FileReader();
224-
fileReader.onload = (event: any) => {
225-
const arrayBuffer = event.target!.result as ArrayBuffer;
226-
if (successCallback) {
227-
successCallback(arrayBuffer);
228-
}
229-
};
230-
fileReader.readAsArrayBuffer(blob!);
231-
},
232-
mimeType,
233-
quality
234-
);
235-
} else {
236-
Tools.EncodeScreenshotCanvasData(renderer.canvas, successCallback, mimeType, fileName, quality);
237-
}
238-
239-
texture.dispose();
240-
});
252+
});
241253
}
242254

243255
/**
244256
* Dispose the dump tools associated resources
245257
*/
246258
export function Dispose() {
247-
if (DumpToolsEngine) {
248-
DumpToolsEngine.wrapper.dispose();
249-
DumpToolsEngine.renderer.dispose();
250-
DumpToolsEngine.engine.dispose();
251-
} else {
252-
// in cases where the engine is not yet created, we need to wait for it to dispose it
253-
// eslint-disable-next-line @typescript-eslint/no-floating-promises, github/no-then
254-
EnginePromise?.then((dumpToolsEngine) => {
255-
dumpToolsEngine.wrapper.dispose();
256-
dumpToolsEngine.renderer.dispose();
257-
dumpToolsEngine.engine.dispose();
258-
});
259+
if (!ResourcesPromise) {
260+
return;
259261
}
260-
EnginePromise = null;
261-
DumpToolsEngine = null;
262+
263+
// in cases where the engine is not yet created, we need to wait for it to dispose it
264+
// eslint-disable-next-line @typescript-eslint/no-floating-promises, github/no-then
265+
ResourcesPromise?.then((resources) => {
266+
if (resources.canvas instanceof HTMLCanvasElement) {
267+
resources.canvas.remove();
268+
}
269+
if (resources.dumpEngine) {
270+
resources.dumpEngine.engine.dispose();
271+
resources.dumpEngine.renderer.dispose();
272+
resources.dumpEngine.wrapper.dispose();
273+
}
274+
});
275+
276+
ResourcesPromise = null;
262277
}
263278

264279
/**

0 commit comments

Comments
 (0)