@solidtv/renderer 1.5.6 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/core/CoreNode.js +2 -2
- package/dist/src/core/CoreNode.js.map +1 -1
- package/dist/src/core/CoreTextureManager.d.ts +31 -0
- package/dist/src/core/CoreTextureManager.js +87 -6
- package/dist/src/core/CoreTextureManager.js.map +1 -1
- package/dist/src/core/Stage.js +11 -2
- package/dist/src/core/Stage.js.map +1 -1
- package/dist/src/core/lib/WebGlContextWrapper.d.ts +17 -1
- package/dist/src/core/lib/WebGlContextWrapper.js +21 -0
- package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
- package/dist/src/core/lib/colorParser.d.ts +4 -1
- package/dist/src/core/lib/colorParser.js +16 -6
- package/dist/src/core/lib/colorParser.js.map +1 -1
- package/dist/src/core/platforms/web/WebPlatform.js +60 -50
- package/dist/src/core/platforms/web/WebPlatform.js.map +1 -1
- package/dist/src/core/renderers/canvas/CanvasRenderer.d.ts +4 -0
- package/dist/src/core/renderers/canvas/CanvasRenderer.js +28 -13
- package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
- package/dist/src/core/renderers/canvas/CanvasShaderNode.js +1 -0
- package/dist/src/core/renderers/canvas/CanvasShaderNode.js.map +1 -1
- package/dist/src/core/renderers/webgl/SdfRenderOp.js +2 -2
- package/dist/src/core/renderers/webgl/SdfRenderOp.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +43 -0
- package/dist/src/core/renderers/webgl/WebGlRenderer.js +109 -10
- package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +21 -13
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
- package/dist/src/core/renderers/webgl/internal/ShaderUtils.d.ts +8 -3
- package/dist/src/core/renderers/webgl/internal/ShaderUtils.js +12 -11
- package/dist/src/core/renderers/webgl/internal/ShaderUtils.js.map +1 -1
- package/dist/src/core/shaders/canvas/Rounded.js +3 -3
- package/dist/src/core/shaders/canvas/Rounded.js.map +1 -1
- package/dist/src/core/shaders/canvas/RoundedWithShadow.js +3 -3
- package/dist/src/core/shaders/canvas/RoundedWithShadow.js.map +1 -1
- package/dist/src/core/shaders/canvas/utils/render.js +3 -3
- package/dist/src/core/shaders/canvas/utils/render.js.map +1 -1
- package/dist/src/core/shaders/webgl/Border.js +12 -11
- package/dist/src/core/shaders/webgl/Border.js.map +1 -1
- package/dist/src/core/shaders/webgl/HolePunch.js +12 -5
- package/dist/src/core/shaders/webgl/HolePunch.js.map +1 -1
- package/dist/src/core/shaders/webgl/LinearGradient.js +3 -19
- package/dist/src/core/shaders/webgl/LinearGradient.js.map +1 -1
- package/dist/src/core/shaders/webgl/RadialGradient.js +3 -20
- package/dist/src/core/shaders/webgl/RadialGradient.js.map +1 -1
- package/dist/src/core/shaders/webgl/RadialProgress.js +15 -23
- package/dist/src/core/shaders/webgl/RadialProgress.js.map +1 -1
- package/dist/src/core/shaders/webgl/RoundedWithBorder.js +35 -32
- package/dist/src/core/shaders/webgl/RoundedWithBorder.js.map +1 -1
- package/dist/src/core/shaders/webgl/RoundedWithBorderAndShadow.js +58 -45
- package/dist/src/core/shaders/webgl/RoundedWithBorderAndShadow.js.map +1 -1
- package/dist/src/core/shaders/webgl/RoundedWithShadow.js +15 -13
- package/dist/src/core/shaders/webgl/RoundedWithShadow.js.map +1 -1
- package/dist/src/core/shaders/webgl/Shadow.js +4 -5
- package/dist/src/core/shaders/webgl/Shadow.js.map +1 -1
- package/dist/src/core/text-rendering/SdfTextRenderer.js +23 -10
- package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
- package/dist/src/core/text-rendering/TextLayoutEngine.js +14 -2
- package/dist/src/core/text-rendering/TextLayoutEngine.js.map +1 -1
- package/dist/src/main-api/Renderer.d.ts +28 -5
- package/dist/src/main-api/Renderer.js +5 -1
- package/dist/src/main-api/Renderer.js.map +1 -1
- package/dist/src/utils.js +1 -1
- package/dist/src/utils.js.map +1 -1
- package/dist/tsconfig.dist.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/core/CoreNode.ts +2 -2
- package/src/core/CoreTextureManager.test.ts +147 -0
- package/src/core/CoreTextureManager.ts +97 -8
- package/src/core/Stage.renderListUpdate.test.ts +73 -0
- package/src/core/Stage.ts +13 -1
- package/src/core/lib/WebGlContextWrapper.test.ts +23 -1
- package/src/core/lib/WebGlContextWrapper.ts +23 -1
- package/src/core/lib/colorParser.test.ts +46 -0
- package/src/core/lib/colorParser.ts +17 -6
- package/src/core/platforms/web/WebPlatform.ts +63 -52
- package/src/core/renderers/canvas/CanvasRenderer.test.ts +230 -0
- package/src/core/renderers/canvas/CanvasRenderer.ts +29 -15
- package/src/core/renderers/canvas/CanvasShaderNode.test.ts +73 -0
- package/src/core/renderers/canvas/CanvasShaderNode.ts +1 -0
- package/src/core/renderers/webgl/SdfRenderOp.ts +2 -2
- package/src/core/renderers/webgl/WebGlRenderer.reuseRenderOp.test.ts +122 -0
- package/src/core/renderers/webgl/WebGlRenderer.sdfBuffer.test.ts +302 -0
- package/src/core/renderers/webgl/WebGlRenderer.ts +151 -11
- package/src/core/renderers/webgl/WebGlShaderProgram.reuseRenderOp.test.ts +150 -0
- package/src/core/renderers/webgl/WebGlShaderProgram.ts +27 -15
- package/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts +4 -2
- package/src/core/renderers/webgl/internal/ShaderUtils.test.ts +124 -0
- package/src/core/renderers/webgl/internal/ShaderUtils.ts +14 -13
- package/src/core/shaders/canvas/Rounded.ts +3 -3
- package/src/core/shaders/canvas/RoundedWithShadow.ts +3 -3
- package/src/core/shaders/canvas/utils/render.ts +3 -3
- package/src/core/shaders/webgl/Border.ts +12 -11
- package/src/core/shaders/webgl/HolePunch.ts +12 -5
- package/src/core/shaders/webgl/LinearGradient.ts +3 -19
- package/src/core/shaders/webgl/RadialGradient.ts +3 -20
- package/src/core/shaders/webgl/RadialProgress.ts +15 -23
- package/src/core/shaders/webgl/RoundedWithBorder.ts +35 -32
- package/src/core/shaders/webgl/RoundedWithBorderAndShadow.ts +58 -45
- package/src/core/shaders/webgl/RoundedWithShadow.ts +15 -13
- package/src/core/shaders/webgl/Shadow.ts +4 -5
- package/src/core/text-rendering/SdfTextRenderer.test.ts +177 -0
- package/src/core/text-rendering/SdfTextRenderer.ts +38 -10
- package/src/core/text-rendering/TextLayoutEngine.ts +15 -2
- package/src/core/text-rendering/tests/TextLayoutEngine.test.ts +99 -0
- package/src/main-api/Renderer.ts +35 -6
- package/src/utils.ts +1 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { Stage } from './Stage.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for the requestRenderListUpdate dedupe.
|
|
6
|
+
*
|
|
7
|
+
* A full Stage needs a live renderer, so the method is exercised on a minimal
|
|
8
|
+
* fake via the prototype. Only the fields requestRenderListUpdate touches are
|
|
9
|
+
* provided: renderer.invalidateQuadBuffer, renderListDirty, requestRender.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type FakeStage = {
|
|
13
|
+
renderer: { invalidateQuadBuffer?: ReturnType<typeof vi.fn> };
|
|
14
|
+
renderListDirty: boolean;
|
|
15
|
+
requestRender: ReturnType<typeof vi.fn>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const makeStage = (hasInvalidate = true): FakeStage => ({
|
|
19
|
+
renderer: hasInvalidate ? { invalidateQuadBuffer: vi.fn() } : {},
|
|
20
|
+
renderListDirty: false,
|
|
21
|
+
requestRender: vi.fn(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const requestRenderListUpdate = (stage: FakeStage) =>
|
|
25
|
+
Stage.prototype.requestRenderListUpdate.call(stage as unknown as Stage);
|
|
26
|
+
|
|
27
|
+
describe('Stage.requestRenderListUpdate dedupe', () => {
|
|
28
|
+
it('invalidates the quad buffer and marks the list dirty on first call', () => {
|
|
29
|
+
const stage = makeStage();
|
|
30
|
+
|
|
31
|
+
requestRenderListUpdate(stage);
|
|
32
|
+
|
|
33
|
+
expect(stage.renderer.invalidateQuadBuffer!.mock.calls.length).toBe(1);
|
|
34
|
+
expect(stage.renderListDirty).toBe(true);
|
|
35
|
+
expect(stage.requestRender.mock.calls.length).toBe(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('skips repeat invalidations while a rebuild is already pending', () => {
|
|
39
|
+
const stage = makeStage();
|
|
40
|
+
|
|
41
|
+
requestRenderListUpdate(stage);
|
|
42
|
+
requestRenderListUpdate(stage);
|
|
43
|
+
requestRenderListUpdate(stage);
|
|
44
|
+
|
|
45
|
+
// One structural invalidation for the whole burst (e.g. a row of cards
|
|
46
|
+
// flipping renderable in the same frame), but a render is requested each
|
|
47
|
+
// time so no frame is ever dropped.
|
|
48
|
+
expect(stage.renderer.invalidateQuadBuffer!.mock.calls.length).toBe(1);
|
|
49
|
+
expect(stage.requestRender.mock.calls.length).toBe(3);
|
|
50
|
+
expect(stage.renderListDirty).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('invalidates again after drawFrame clears the dirty flag', () => {
|
|
54
|
+
const stage = makeStage();
|
|
55
|
+
|
|
56
|
+
requestRenderListUpdate(stage);
|
|
57
|
+
// drawFrame rebuilds the render list and clears the flag
|
|
58
|
+
stage.renderListDirty = false;
|
|
59
|
+
requestRenderListUpdate(stage);
|
|
60
|
+
|
|
61
|
+
expect(stage.renderer.invalidateQuadBuffer!.mock.calls.length).toBe(2);
|
|
62
|
+
expect(stage.renderListDirty).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('handles renderers without invalidateQuadBuffer (canvas backend)', () => {
|
|
66
|
+
const stage = makeStage(false);
|
|
67
|
+
|
|
68
|
+
requestRenderListUpdate(stage);
|
|
69
|
+
|
|
70
|
+
expect(stage.renderListDirty).toBe(true);
|
|
71
|
+
expect(stage.requestRender.mock.calls.length).toBe(1);
|
|
72
|
+
});
|
|
73
|
+
});
|
package/src/core/Stage.ts
CHANGED
|
@@ -200,6 +200,7 @@ export class Stage {
|
|
|
200
200
|
premultiplyAlphaHonored,
|
|
201
201
|
platform,
|
|
202
202
|
maxRetryCount,
|
|
203
|
+
imageDecodeConcurrency,
|
|
203
204
|
} = options;
|
|
204
205
|
|
|
205
206
|
assertTruthy(
|
|
@@ -229,6 +230,7 @@ export class Stage {
|
|
|
229
230
|
// undefined -> true (default: assume honored, no probe)
|
|
230
231
|
premultiplyAlphaHonored: premultiplyAlphaHonored ?? true,
|
|
231
232
|
maxRetryCount,
|
|
233
|
+
imageDecodeConcurrency,
|
|
232
234
|
});
|
|
233
235
|
|
|
234
236
|
// Wait for the Texture Manager to initialize
|
|
@@ -737,6 +739,17 @@ export class Stage {
|
|
|
737
739
|
}
|
|
738
740
|
|
|
739
741
|
requestRenderListUpdate() {
|
|
742
|
+
this.requestRender();
|
|
743
|
+
|
|
744
|
+
// A rebuild is already pending: the invalidation below ran when the flag
|
|
745
|
+
// was first raised, and the render list doesn't change until drawFrame
|
|
746
|
+
// rebuilds it. Skipping the repeat matters when many nodes flip renderable
|
|
747
|
+
// state in one frame (e.g. a whole row scrolling out of the viewport) —
|
|
748
|
+
// invalidateQuadBuffer walks the entire render list on every call.
|
|
749
|
+
if (this.renderListDirty === true) {
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
740
753
|
// Notify the renderer that the render list is structurally changing.
|
|
741
754
|
// For the WebGL renderer this resets per-node buffer slot assignments
|
|
742
755
|
// and schedules a full GPU buffer re-upload on the next frame.
|
|
@@ -744,7 +757,6 @@ export class Stage {
|
|
|
744
757
|
this.renderer.invalidateQuadBuffer();
|
|
745
758
|
}
|
|
746
759
|
this.renderListDirty = true;
|
|
747
|
-
this.requestRender();
|
|
748
760
|
}
|
|
749
761
|
|
|
750
762
|
buildRenderList(node: CoreNode) {
|
|
@@ -19,7 +19,7 @@ function mockWebGl1(hasVaoExtension: boolean): WebGLRenderingContext {
|
|
|
19
19
|
deleteVertexArrayOES: (_vao: unknown) => undefined,
|
|
20
20
|
};
|
|
21
21
|
const target: Record<string, unknown> = {
|
|
22
|
-
canvas: {},
|
|
22
|
+
canvas: { width: 1920, height: 1080 },
|
|
23
23
|
drawingBufferWidth: 100,
|
|
24
24
|
drawingBufferHeight: 100,
|
|
25
25
|
getParameter: () => 8,
|
|
@@ -56,3 +56,25 @@ describe('WebGlContextWrapper VAO support', () => {
|
|
|
56
56
|
expect(glw.isWebGl2).toBe(false);
|
|
57
57
|
});
|
|
58
58
|
});
|
|
59
|
+
|
|
60
|
+
describe('WebGlContextWrapper canvas dimension cache', () => {
|
|
61
|
+
it('seeds canvasW/canvasH from the canvas at construction', () => {
|
|
62
|
+
const glw = new WebGlContextWrapper(mockWebGl1(true));
|
|
63
|
+
expect(glw.canvasW).toBe(1920);
|
|
64
|
+
expect(glw.canvasH).toBe(1080);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('does not track canvas resizes until updateCanvasDimensions is called', () => {
|
|
68
|
+
const glw = new WebGlContextWrapper(mockWebGl1(true));
|
|
69
|
+
const canvas = glw.canvas as { width: number; height: number };
|
|
70
|
+
|
|
71
|
+
canvas.width = 1280;
|
|
72
|
+
canvas.height = 720;
|
|
73
|
+
expect(glw.canvasW).toBe(1920);
|
|
74
|
+
expect(glw.canvasH).toBe(1080);
|
|
75
|
+
|
|
76
|
+
glw.updateCanvasDimensions();
|
|
77
|
+
expect(glw.canvasW).toBe(1280);
|
|
78
|
+
expect(glw.canvasH).toBe(720);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -69,7 +69,18 @@ export class WebGlContextWrapper {
|
|
|
69
69
|
//#endregion Vertex Array Objects
|
|
70
70
|
|
|
71
71
|
//#region Canvas
|
|
72
|
-
public readonly canvas;
|
|
72
|
+
public readonly canvas: HTMLCanvasElement | OffscreenCanvas;
|
|
73
|
+
/**
|
|
74
|
+
* Cached copies of `canvas.width`/`canvas.height`.
|
|
75
|
+
*
|
|
76
|
+
* @remarks
|
|
77
|
+
* On HTMLCanvasElement these are DOM getters that cross into Blink — too
|
|
78
|
+
* slow to read per render op per frame on embedded targets. The canvas is
|
|
79
|
+
* only resized via Renderer.updateAppDimensions, which refreshes this cache
|
|
80
|
+
* through {@link updateCanvasDimensions}.
|
|
81
|
+
*/
|
|
82
|
+
public canvasW: number;
|
|
83
|
+
public canvasH: number;
|
|
73
84
|
//#endregion Canvas
|
|
74
85
|
|
|
75
86
|
//#region WebGL Enums
|
|
@@ -170,6 +181,8 @@ export class WebGlContextWrapper {
|
|
|
170
181
|
this.isWebGl2 = this.gl2 !== null;
|
|
171
182
|
|
|
172
183
|
this.canvas = gl.canvas;
|
|
184
|
+
this.canvasW = gl.canvas.width;
|
|
185
|
+
this.canvasH = gl.canvas.height;
|
|
173
186
|
|
|
174
187
|
// Extract GLenums
|
|
175
188
|
this.MAX_RENDERBUFFER_SIZE = gl.MAX_RENDERBUFFER_SIZE;
|
|
@@ -447,6 +460,15 @@ export class WebGlContextWrapper {
|
|
|
447
460
|
this.gl.viewport(x, y, width, height);
|
|
448
461
|
}
|
|
449
462
|
|
|
463
|
+
/**
|
|
464
|
+
* Refresh {@link canvasW}/{@link canvasH} from the canvas element.
|
|
465
|
+
* Must be called after the canvas is resized.
|
|
466
|
+
*/
|
|
467
|
+
updateCanvasDimensions() {
|
|
468
|
+
this.canvasW = this.canvas.width;
|
|
469
|
+
this.canvasH = this.canvas.height;
|
|
470
|
+
}
|
|
471
|
+
|
|
450
472
|
/**
|
|
451
473
|
* ```
|
|
452
474
|
* gl.clearColor(red, green, blue, alpha);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseColor } from './colorParser.js';
|
|
3
|
+
|
|
4
|
+
describe('parseColor', () => {
|
|
5
|
+
it('returns the white singleton for opaque white', () => {
|
|
6
|
+
const first = parseColor(0xffffffff);
|
|
7
|
+
const second = parseColor(0xffffffff);
|
|
8
|
+
|
|
9
|
+
expect(first).toBe(second);
|
|
10
|
+
expect(first.isWhite).toBe(true);
|
|
11
|
+
expect(first.a).toBe(1);
|
|
12
|
+
expect(first.r).toBe(0xff);
|
|
13
|
+
expect(first.g).toBe(0xff);
|
|
14
|
+
expect(first.b).toBe(0xff);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('extracts abgr components', () => {
|
|
18
|
+
const color = parseColor(0x80102030);
|
|
19
|
+
|
|
20
|
+
expect(color.isWhite).toBe(false);
|
|
21
|
+
expect(color.a).toBe(0x80 / 255);
|
|
22
|
+
expect(color.b).toBe(0x10);
|
|
23
|
+
expect(color.g).toBe(0x20);
|
|
24
|
+
expect(color.r).toBe(0x30);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('reuses one scratch object across calls', () => {
|
|
28
|
+
const first = parseColor(0x80102030);
|
|
29
|
+
const second = parseColor(0xff405060);
|
|
30
|
+
|
|
31
|
+
expect(second).toBe(first);
|
|
32
|
+
expect(second.a).toBe(1);
|
|
33
|
+
expect(second.b).toBe(0x40);
|
|
34
|
+
expect(second.g).toBe(0x50);
|
|
35
|
+
expect(second.r).toBe(0x60);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('never returns the white singleton as the scratch object', () => {
|
|
39
|
+
const white = parseColor(0xffffffff);
|
|
40
|
+
const tinted = parseColor(0x80102030);
|
|
41
|
+
|
|
42
|
+
expect(tinted).not.toBe(white);
|
|
43
|
+
expect(white.isWhite).toBe(true);
|
|
44
|
+
expect(white.r).toBe(0xff);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -14,18 +14,29 @@ const WHITE: IParsedColor = {
|
|
|
14
14
|
b: 0xff,
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
+
const SCRATCH: IParsedColor = {
|
|
18
|
+
isWhite: false,
|
|
19
|
+
a: 1,
|
|
20
|
+
r: 0,
|
|
21
|
+
g: 0,
|
|
22
|
+
b: 0,
|
|
23
|
+
};
|
|
24
|
+
|
|
17
25
|
/**
|
|
18
|
-
* Extract color components
|
|
26
|
+
* Extract color components.
|
|
27
|
+
*
|
|
28
|
+
* Returns a shared scratch object to avoid a per-call allocation in the
|
|
29
|
+
* canvas frame loop — consume it synchronously, never retain it.
|
|
19
30
|
*/
|
|
20
31
|
export function parseColor(abgr: number): IParsedColor {
|
|
21
32
|
if (abgr === 0xffffffff) {
|
|
22
33
|
return WHITE;
|
|
23
34
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return
|
|
35
|
+
SCRATCH.a = ((abgr >>> 24) & 0xff) / 255;
|
|
36
|
+
SCRATCH.b = (abgr >>> 16) & 0xff;
|
|
37
|
+
SCRATCH.g = (abgr >>> 8) & 0xff;
|
|
38
|
+
SCRATCH.r = abgr & 0xff;
|
|
39
|
+
return SCRATCH;
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
export function parseToAbgrString(abgr: number): string {
|
|
@@ -29,9 +29,24 @@ export class WebPlatform extends Platform {
|
|
|
29
29
|
override startLoop(stage: Stage): void {
|
|
30
30
|
let isIdle = false;
|
|
31
31
|
let lastFrameTime = 0;
|
|
32
|
+
// Set by `tick` once it has queued the next frame; read by `runLoop`'s catch
|
|
33
|
+
// so a throw after scheduling never double-schedules (which would compound
|
|
34
|
+
// into runaway frames if it threw every frame). Reset at the top of each frame.
|
|
35
|
+
let scheduled = false;
|
|
32
36
|
const buffer = 4;
|
|
33
37
|
|
|
34
|
-
const
|
|
38
|
+
const requestLoop = () => requestAnimationFrame(runLoop);
|
|
39
|
+
|
|
40
|
+
// All per-frame work lives in `tick`, deliberately split out of `runLoop`.
|
|
41
|
+
// `runLoop` carries the try/catch that keeps the loop alive, and some
|
|
42
|
+
// optimizing compilers — notably Chrome 38's Crankshaft, our language floor —
|
|
43
|
+
// refuse to optimize any function that contains a try/catch. Keeping that
|
|
44
|
+
// guard in a near-empty shell confines the de-opt to the shell: every hot
|
|
45
|
+
// operation (the `stage.*` calls below, run every frame) stays in fully
|
|
46
|
+
// optimizable functions. `tick` is also where all client-supplied code runs
|
|
47
|
+
// (synchronous event subscribers, the events drained by `flushFrameEvents`,
|
|
48
|
+
// and animation steps) — i.e. everything that can actually throw.
|
|
49
|
+
const tick = (currentTime: number) => {
|
|
35
50
|
// The GL context is lost and the engine does not rebuild it in place.
|
|
36
51
|
// Stop the loop entirely (no reschedule) so we issue no GL calls against
|
|
37
52
|
// a dead context. Recovery is via app reload (see the `contextLost` event).
|
|
@@ -55,6 +70,7 @@ export class WebPlatform extends Platform {
|
|
|
55
70
|
} else {
|
|
56
71
|
requestAnimationFrame(runLoop);
|
|
57
72
|
}
|
|
73
|
+
scheduled = true;
|
|
58
74
|
return;
|
|
59
75
|
}
|
|
60
76
|
|
|
@@ -64,65 +80,62 @@ export class WebPlatform extends Platform {
|
|
|
64
80
|
lastFrameTime = currentTime;
|
|
65
81
|
}
|
|
66
82
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// `flushFrameEvents`) and animation steps. A throw in any of these would
|
|
70
|
-
// otherwise propagate out of the rAF callback and permanently stop the
|
|
71
|
-
// render loop — the whole app freezes until reload. Guard the body and
|
|
72
|
-
// always keep the loop alive: hand the error to `handleLoopError` (a
|
|
73
|
-
// no-op by default; the app can override it to log/report) and reschedule.
|
|
74
|
-
// `scheduled` tracks whether the next tick was already queued before the
|
|
75
|
-
// throw so we never double-schedule (which would compound into runaway
|
|
76
|
-
// frames if it threw every frame).
|
|
77
|
-
let scheduled = false;
|
|
78
|
-
try {
|
|
79
|
-
stage.updateFrameTime();
|
|
80
|
-
const hasActiveAnimations = stage.updateAnimations();
|
|
81
|
-
|
|
82
|
-
if (!stage.hasSceneUpdates()) {
|
|
83
|
-
// We still need to calculate the fps else it looks like the app is frozen
|
|
84
|
-
stage.calculateFps();
|
|
83
|
+
stage.updateFrameTime();
|
|
84
|
+
const hasActiveAnimations = stage.updateAnimations();
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
setTimeout(requestLoop, Math.max(targetFrameTime, 15));
|
|
90
|
-
scheduled = true;
|
|
86
|
+
if (!stage.hasSceneUpdates()) {
|
|
87
|
+
// We still need to calculate the fps else it looks like the app is frozen
|
|
88
|
+
stage.calculateFps();
|
|
91
89
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
// every frame. Queues the `outOfMemory` event, flushed below.
|
|
98
|
-
if (stage.renderer.checkForOutOfMemory() === true) {
|
|
99
|
-
stage.txMemManager.handleOutOfMemory();
|
|
100
|
-
}
|
|
101
|
-
stage.shManager.cleanup();
|
|
102
|
-
stage.cleanupTextRenderers();
|
|
103
|
-
stage.eventBus.emit('idle');
|
|
104
|
-
isIdle = true;
|
|
105
|
-
}
|
|
90
|
+
// We use 15ms instead of 16.6ms to provide a safety buffer.
|
|
91
|
+
// This ensures we wake up slightly before the next frame to check for updates,
|
|
92
|
+
// preventing us from missing a frame due to timer variances.
|
|
93
|
+
setTimeout(requestLoop, Math.max(targetFrameTime, 15));
|
|
94
|
+
scheduled = true;
|
|
106
95
|
|
|
107
|
-
|
|
108
|
-
|
|
96
|
+
if (isIdle === false) {
|
|
97
|
+
// The render burst has settled. Probe for a GPU out-of-memory now
|
|
98
|
+
// rather than every frame: GL errors accumulate and persist until
|
|
99
|
+
// drained, so a single check here still catches any OOM raised during
|
|
100
|
+
// the active frames, without paying the getError() CPU/GPU sync on
|
|
101
|
+
// every frame. Queues the `outOfMemory` event, flushed below.
|
|
102
|
+
if (stage.renderer.checkForOutOfMemory() === true) {
|
|
103
|
+
stage.txMemManager.handleOutOfMemory();
|
|
109
104
|
}
|
|
105
|
+
stage.shManager.cleanup();
|
|
106
|
+
stage.cleanupTextRenderers();
|
|
107
|
+
stage.eventBus.emit('idle');
|
|
108
|
+
isIdle = true;
|
|
109
|
+
}
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
if (stage.txMemManager.checkCleanup() === true) {
|
|
112
|
+
stage.txMemManager.cleanup();
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
isIdle = false;
|
|
116
|
-
stage.drawFrame(hasActiveAnimations);
|
|
117
115
|
stage.flushFrameEvents();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
isIdle = false;
|
|
120
|
+
stage.drawFrame(hasActiveAnimations);
|
|
121
|
+
stage.flushFrameEvents();
|
|
122
|
+
|
|
123
|
+
// Schedule next frame
|
|
124
|
+
requestAnimationFrame(runLoop);
|
|
125
|
+
scheduled = true;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const runLoop = (currentTime: number = 0) => {
|
|
129
|
+
// `tick` runs client-supplied code every frame; a throw there would
|
|
130
|
+
// otherwise propagate out of the rAF callback and permanently stop the
|
|
131
|
+
// render loop — the whole app freezes until reload. Guard it and always
|
|
132
|
+
// keep the loop alive: hand the error to `handleLoopError` (a no-op by
|
|
133
|
+
// default; the app can override it to log/report) and reschedule unless
|
|
134
|
+
// `tick` already queued the next frame before throwing.
|
|
135
|
+
scheduled = false;
|
|
136
|
+
try {
|
|
137
|
+
tick(currentTime);
|
|
122
138
|
} catch (error: unknown) {
|
|
123
|
-
// Report the error (default handler is a no-op), then keep the loop
|
|
124
|
-
// alive — a single bad frame must never freeze the app. Skip the
|
|
125
|
-
// reschedule if this frame already queued the next tick before throwing.
|
|
126
139
|
const handleLoopError = stage.options.handleLoopError;
|
|
127
140
|
if (handleLoopError !== undefined) {
|
|
128
141
|
handleLoopError(error);
|
|
@@ -133,8 +146,6 @@ export class WebPlatform extends Platform {
|
|
|
133
146
|
}
|
|
134
147
|
};
|
|
135
148
|
|
|
136
|
-
const requestLoop = () => requestAnimationFrame(runLoop);
|
|
137
|
-
|
|
138
149
|
requestAnimationFrame(runLoop);
|
|
139
150
|
}
|
|
140
151
|
|
|
@@ -33,6 +33,236 @@ describe('CanvasRenderer.renderContext', () => {
|
|
|
33
33
|
});
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
describe('CanvasRenderer.addQuad transform detection', () => {
|
|
37
|
+
const makeRenderer = () => {
|
|
38
|
+
const renderer = Object.create(CanvasRenderer.prototype) as CanvasRenderer;
|
|
39
|
+
const context = {
|
|
40
|
+
save: vi.fn(),
|
|
41
|
+
restore: vi.fn(),
|
|
42
|
+
setTransform: vi.fn(),
|
|
43
|
+
scale: vi.fn(),
|
|
44
|
+
translate: vi.fn(),
|
|
45
|
+
fillRect: vi.fn(),
|
|
46
|
+
clip: vi.fn(),
|
|
47
|
+
fillStyle: '',
|
|
48
|
+
globalAlpha: 1,
|
|
49
|
+
};
|
|
50
|
+
(renderer as any).context = context;
|
|
51
|
+
(renderer as any).pixelRatio = 1;
|
|
52
|
+
(renderer as any).stage = { defaultTexture: null };
|
|
53
|
+
return { renderer, context };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const makeColorNode = (transform: {
|
|
57
|
+
tx: number;
|
|
58
|
+
ty: number;
|
|
59
|
+
ta: number;
|
|
60
|
+
tb: number;
|
|
61
|
+
tc: number;
|
|
62
|
+
td: number;
|
|
63
|
+
}) =>
|
|
64
|
+
({
|
|
65
|
+
globalTransform: transform,
|
|
66
|
+
clippingRect: { valid: false, x: 0, y: 0, w: 0, h: 0 },
|
|
67
|
+
placeholderActive: false,
|
|
68
|
+
premultipliedColorTl: 0xff0000ff,
|
|
69
|
+
premultipliedColorTr: 0xff0000ff,
|
|
70
|
+
premultipliedColorBr: 0xff0000ff,
|
|
71
|
+
worldAlpha: 1,
|
|
72
|
+
props: {
|
|
73
|
+
texture: { type: TextureType.color },
|
|
74
|
+
shader: null,
|
|
75
|
+
w: 100,
|
|
76
|
+
h: 50,
|
|
77
|
+
},
|
|
78
|
+
} as any);
|
|
79
|
+
|
|
80
|
+
it('applies the transform for a scaleY-only transform (td !== 1)', () => {
|
|
81
|
+
const { renderer, context } = makeRenderer();
|
|
82
|
+
const node = makeColorNode({ tx: 5, ty: 10, ta: 1, tb: 0, tc: 0, td: 2 });
|
|
83
|
+
|
|
84
|
+
renderer.addQuad(node);
|
|
85
|
+
|
|
86
|
+
expect(context.setTransform).toHaveBeenCalledWith(1, 0, 0, 2, 5, 10);
|
|
87
|
+
expect(context.save).toHaveBeenCalledTimes(1);
|
|
88
|
+
expect(context.restore).toHaveBeenCalledTimes(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('applies the transform for a rotation-only transform (tb/tc !== 0)', () => {
|
|
92
|
+
const { renderer, context } = makeRenderer();
|
|
93
|
+
const node = makeColorNode({ tx: 0, ty: 0, ta: 1, tb: -1, tc: 1, td: 1 });
|
|
94
|
+
|
|
95
|
+
renderer.addQuad(node);
|
|
96
|
+
|
|
97
|
+
expect(context.setTransform).toHaveBeenCalledWith(1, 1, -1, 1, 0, 0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('skips the transform for an identity transform', () => {
|
|
101
|
+
const { renderer, context } = makeRenderer();
|
|
102
|
+
const node = makeColorNode({ tx: 5, ty: 10, ta: 1, tb: 0, tc: 0, td: 1 });
|
|
103
|
+
|
|
104
|
+
renderer.addQuad(node);
|
|
105
|
+
|
|
106
|
+
expect(context.setTransform).not.toHaveBeenCalled();
|
|
107
|
+
expect(context.save).not.toHaveBeenCalled();
|
|
108
|
+
expect(context.fillRect).toHaveBeenCalledWith(5, 10, 100, 50);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('CanvasRenderer.reset', () => {
|
|
113
|
+
const makeRenderer = (clearColorAlpha: number) => {
|
|
114
|
+
const renderer = Object.create(CanvasRenderer.prototype) as CanvasRenderer;
|
|
115
|
+
const context = {
|
|
116
|
+
setTransform: vi.fn(),
|
|
117
|
+
clearRect: vi.fn(),
|
|
118
|
+
fillRect: vi.fn(),
|
|
119
|
+
scale: vi.fn(),
|
|
120
|
+
fillStyle: '',
|
|
121
|
+
};
|
|
122
|
+
let widthWrites = 0;
|
|
123
|
+
const canvas = {
|
|
124
|
+
height: 100,
|
|
125
|
+
get width() {
|
|
126
|
+
return 200;
|
|
127
|
+
},
|
|
128
|
+
set width(_v: number) {
|
|
129
|
+
widthWrites++;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
(renderer as any).context = context;
|
|
133
|
+
(renderer as any).canvas = canvas;
|
|
134
|
+
(renderer as any).pixelRatio = 2;
|
|
135
|
+
(renderer as any).clearColor = 'rgba(0,0,0,0)';
|
|
136
|
+
(renderer as any).clearColorAlpha = clearColorAlpha;
|
|
137
|
+
return { renderer, context, getWidthWrites: () => widthWrites };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
it('clears via setTransform + clearRect without reallocating the canvas', () => {
|
|
141
|
+
const { renderer, context, getWidthWrites } = makeRenderer(0);
|
|
142
|
+
|
|
143
|
+
renderer.reset();
|
|
144
|
+
|
|
145
|
+
expect(getWidthWrites()).toBe(0);
|
|
146
|
+
expect(context.setTransform).toHaveBeenCalledWith(1, 0, 0, 1, 0, 0);
|
|
147
|
+
expect(context.clearRect).toHaveBeenCalledWith(0, 0, 200, 100);
|
|
148
|
+
expect(context.scale).toHaveBeenCalledWith(2, 2);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('skips the clear-color fill when the clear color is fully transparent', () => {
|
|
152
|
+
const { renderer, context } = makeRenderer(0);
|
|
153
|
+
|
|
154
|
+
renderer.reset();
|
|
155
|
+
|
|
156
|
+
expect(context.fillRect).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('fills with the clear color when its alpha is non-zero', () => {
|
|
160
|
+
const { renderer, context } = makeRenderer(0xff);
|
|
161
|
+
(renderer as any).clearColor = 'rgba(16,32,48,1)';
|
|
162
|
+
|
|
163
|
+
renderer.reset();
|
|
164
|
+
|
|
165
|
+
expect(context.fillRect).toHaveBeenCalledWith(0, 0, 200, 100);
|
|
166
|
+
expect(context.fillStyle).toBe('rgba(16,32,48,1)');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('updateClearColor keeps the alpha skip in sync', () => {
|
|
170
|
+
const { renderer, context } = makeRenderer(0xff);
|
|
171
|
+
|
|
172
|
+
renderer.updateClearColor(0x00000000);
|
|
173
|
+
renderer.reset();
|
|
174
|
+
|
|
175
|
+
expect(context.fillRect).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('CanvasRenderer.addQuad allocation-free paths', () => {
|
|
180
|
+
const makeRenderer = () => {
|
|
181
|
+
const renderer = Object.create(CanvasRenderer.prototype) as CanvasRenderer;
|
|
182
|
+
const context = {
|
|
183
|
+
save: vi.fn(),
|
|
184
|
+
restore: vi.fn(),
|
|
185
|
+
setTransform: vi.fn(),
|
|
186
|
+
scale: vi.fn(),
|
|
187
|
+
translate: vi.fn(),
|
|
188
|
+
fillRect: vi.fn(),
|
|
189
|
+
beginPath: vi.fn(),
|
|
190
|
+
rect: vi.fn(),
|
|
191
|
+
clip: vi.fn(),
|
|
192
|
+
fillStyle: '',
|
|
193
|
+
globalAlpha: 1,
|
|
194
|
+
};
|
|
195
|
+
(renderer as any).context = context;
|
|
196
|
+
(renderer as any).pixelRatio = 1;
|
|
197
|
+
(renderer as any).stage = { defaultTexture: null };
|
|
198
|
+
return { renderer, context };
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const makeColorNode = (shader: unknown = null) =>
|
|
202
|
+
({
|
|
203
|
+
globalTransform: { tx: 5, ty: 10, ta: 1, tb: 0, tc: 0, td: 1 },
|
|
204
|
+
clippingRect: { valid: true, x: 0, y: 0, w: 300, h: 200 },
|
|
205
|
+
placeholderActive: false,
|
|
206
|
+
premultipliedColorTl: 0xff0000ff,
|
|
207
|
+
premultipliedColorTr: 0xff0000ff,
|
|
208
|
+
premultipliedColorBr: 0xff0000ff,
|
|
209
|
+
worldAlpha: 1,
|
|
210
|
+
props: {
|
|
211
|
+
texture: { type: TextureType.color },
|
|
212
|
+
shader,
|
|
213
|
+
w: 100,
|
|
214
|
+
h: 50,
|
|
215
|
+
},
|
|
216
|
+
} as any);
|
|
217
|
+
|
|
218
|
+
it('clips with beginPath/rect/clip instead of a Path2D', () => {
|
|
219
|
+
const { renderer, context } = makeRenderer();
|
|
220
|
+
|
|
221
|
+
renderer.addQuad(makeColorNode());
|
|
222
|
+
|
|
223
|
+
expect(context.beginPath).toHaveBeenCalledTimes(1);
|
|
224
|
+
expect(context.rect).toHaveBeenCalledWith(0, 0, 300, 200);
|
|
225
|
+
expect(context.clip).toHaveBeenCalledWith();
|
|
226
|
+
expect(context.fillRect).toHaveBeenCalledWith(5, 10, 100, 50);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('passes the same preallocated renderContext callback to every shader', () => {
|
|
230
|
+
// Real construction (not Object.create) so class-field initializers run —
|
|
231
|
+
// the preallocated callback is a field arrow function.
|
|
232
|
+
const context = {
|
|
233
|
+
save: vi.fn(),
|
|
234
|
+
restore: vi.fn(),
|
|
235
|
+
fillRect: vi.fn(),
|
|
236
|
+
beginPath: vi.fn(),
|
|
237
|
+
rect: vi.fn(),
|
|
238
|
+
clip: vi.fn(),
|
|
239
|
+
fillStyle: '',
|
|
240
|
+
globalAlpha: 1,
|
|
241
|
+
};
|
|
242
|
+
const renderer = new CanvasRenderer({
|
|
243
|
+
stage: { pixelRatio: 1, clearColor: 0x00000000, defaultTexture: null },
|
|
244
|
+
canvas: { getContext: () => context },
|
|
245
|
+
} as any);
|
|
246
|
+
const seen: Array<() => void> = [];
|
|
247
|
+
const shader = {
|
|
248
|
+
applySNR: false,
|
|
249
|
+
render: vi.fn((_ctx: unknown, _node: unknown, rc: () => void) => {
|
|
250
|
+
seen.push(rc);
|
|
251
|
+
rc();
|
|
252
|
+
}),
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
renderer.addQuad(makeColorNode(shader));
|
|
256
|
+
renderer.addQuad(makeColorNode(shader));
|
|
257
|
+
|
|
258
|
+
expect(seen.length).toBe(2);
|
|
259
|
+
expect(seen[0]).toBe(seen[1]);
|
|
260
|
+
// The callback drew each node's content when invoked
|
|
261
|
+
expect(context.fillRect).toHaveBeenCalledTimes(2);
|
|
262
|
+
expect(context.fillRect).toHaveBeenCalledWith(5, 10, 100, 50);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
36
266
|
describe('CanvasRenderer.getCapabilities', () => {
|
|
37
267
|
it('reports the canvas backend with no WebGL or VAO support', () => {
|
|
38
268
|
const renderer = Object.create(CanvasRenderer.prototype) as CanvasRenderer;
|