@solidtv/renderer 1.5.3 → 1.5.5
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/exports/canvas-shaders.d.ts +1 -0
- package/dist/exports/canvas-shaders.js +1 -0
- package/dist/exports/canvas-shaders.js.map +1 -1
- package/dist/exports/index.d.ts +1 -0
- package/dist/exports/index.js +1 -0
- package/dist/exports/index.js.map +1 -1
- package/dist/exports/webgl-shaders.d.ts +1 -0
- package/dist/exports/webgl-shaders.js +1 -0
- package/dist/exports/webgl-shaders.js.map +1 -1
- package/dist/src/core/CoreNode.d.ts +7 -0
- package/dist/src/core/CoreNode.js +13 -0
- package/dist/src/core/CoreNode.js.map +1 -1
- package/dist/src/core/CoreShaderManager.js +16 -2
- package/dist/src/core/CoreShaderManager.js.map +1 -1
- package/dist/src/core/CoreTextNode.js +3 -2
- package/dist/src/core/CoreTextNode.js.map +1 -1
- package/dist/src/core/CoreTextureManager.d.ts +39 -3
- package/dist/src/core/CoreTextureManager.js +72 -61
- package/dist/src/core/CoreTextureManager.js.map +1 -1
- package/dist/src/core/Stage.js +6 -4
- package/dist/src/core/Stage.js.map +1 -1
- package/dist/src/core/TextureMemoryManager.js +1 -1
- package/dist/src/core/TextureMemoryManager.js.map +1 -1
- package/dist/src/core/lib/WebGlContextWrapper.d.ts +13 -0
- package/dist/src/core/lib/WebGlContextWrapper.js +15 -0
- package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
- package/dist/src/core/renderers/canvas/CanvasRenderer.d.ts +10 -0
- package/dist/src/core/renderers/canvas/CanvasRenderer.js +19 -0
- package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlCtxTexture.js +5 -3
- package/dist/src/core/renderers/webgl/WebGlCtxTexture.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlRenderer.js +14 -1
- package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
- package/dist/src/core/shaders/canvas/EdgeFade.d.ts +10 -0
- package/dist/src/core/shaders/canvas/EdgeFade.js +92 -0
- package/dist/src/core/shaders/canvas/EdgeFade.js.map +1 -0
- package/dist/src/core/shaders/canvas/LinearGradient.js +1 -18
- package/dist/src/core/shaders/canvas/LinearGradient.js.map +1 -1
- package/dist/src/core/shaders/templates/EdgeFadeTemplate.d.ts +32 -0
- package/dist/src/core/shaders/templates/EdgeFadeTemplate.js +9 -0
- package/dist/src/core/shaders/templates/EdgeFadeTemplate.js.map +1 -0
- package/dist/src/core/shaders/webgl/EdgeFade.d.ts +9 -0
- package/dist/src/core/shaders/webgl/EdgeFade.js +74 -0
- package/dist/src/core/shaders/webgl/EdgeFade.js.map +1 -0
- package/dist/src/core/shaders/webgl/LinearGradient.js +31 -51
- package/dist/src/core/shaders/webgl/LinearGradient.js.map +1 -1
- package/dist/src/core/shaders/webgl/RadialGradient.js +2 -2
- package/dist/src/core/shaders/webgl/RadialProgress.js +7 -0
- package/dist/src/core/shaders/webgl/RadialProgress.js.map +1 -1
- package/dist/src/core/text-rendering/SdfFontHandler.d.ts +1 -0
- package/dist/src/core/text-rendering/SdfFontHandler.js +88 -51
- package/dist/src/core/text-rendering/SdfFontHandler.js.map +1 -1
- package/dist/src/core/textures/ImageTexture.js +2 -1
- package/dist/src/core/textures/ImageTexture.js.map +1 -1
- package/dist/src/core/textures/SubTexture.js +1 -1
- package/dist/src/core/textures/SubTexture.js.map +1 -1
- package/dist/src/core/textures/Texture.d.ts +1 -1
- package/dist/src/core/textures/Texture.js +10 -13
- package/dist/src/core/textures/Texture.js.map +1 -1
- package/dist/src/main-api/Inspector.js +1 -1
- package/dist/src/main-api/Inspector.js.map +1 -1
- package/dist/tsconfig.dist.tsbuildinfo +1 -1
- package/exports/canvas-shaders.ts +1 -0
- package/exports/index.ts +1 -0
- package/exports/webgl-shaders.ts +1 -0
- package/package.json +1 -1
- package/src/core/CoreNode.test.ts +165 -0
- package/src/core/CoreNode.ts +14 -0
- package/src/core/CoreShaderManager.contextLoss.test.ts +72 -0
- package/src/core/CoreShaderManager.ts +15 -2
- package/src/core/CoreTextNode.ts +3 -2
- package/src/core/CoreTextureManager.ts +75 -70
- package/src/core/Stage.ts +9 -4
- package/src/core/TextureMemoryManager.test.ts +2 -2
- package/src/core/TextureMemoryManager.ts +1 -1
- package/src/core/lib/WebGlContextWrapper.ts +16 -0
- package/src/core/renderers/canvas/CanvasRenderer.ts +20 -0
- package/src/core/renderers/webgl/WebGlCtxTexture.ts +5 -4
- package/src/core/renderers/webgl/WebGlRenderer.contextLoss.test.ts +49 -0
- package/src/core/renderers/webgl/WebGlRenderer.ts +13 -1
- package/src/core/shaders/canvas/EdgeFade.ts +105 -0
- package/src/core/shaders/canvas/LinearGradient.test.ts +45 -0
- package/src/core/shaders/canvas/LinearGradient.ts +1 -18
- package/src/core/shaders/templates/EdgeFadeTemplate.test.ts +35 -0
- package/src/core/shaders/templates/EdgeFadeTemplate.ts +41 -0
- package/src/core/shaders/webgl/EdgeFade.ts +84 -0
- package/src/core/shaders/webgl/LinearGradient.test.ts +70 -0
- package/src/core/shaders/webgl/LinearGradient.ts +36 -51
- package/src/core/shaders/webgl/RadialGradient.ts +2 -2
- package/src/core/shaders/webgl/RadialProgress.ts +7 -0
- package/src/core/text-rendering/SdfFontHandler.ts +77 -30
- package/src/core/text-rendering/tests/SdfFontHandler.test.ts +181 -35
- package/src/core/textures/ImageTexture.test.ts +91 -0
- package/src/core/textures/ImageTexture.ts +4 -1
- package/src/core/textures/SubTexture.ts +1 -1
- package/src/core/textures/Texture.test.ts +124 -0
- package/src/core/textures/Texture.ts +10 -13
- package/src/main-api/Inspector.ts +1 -1
package/src/core/CoreNode.ts
CHANGED
|
@@ -829,6 +829,14 @@ export class CoreNode extends EventEmitter {
|
|
|
829
829
|
private hasColorProps = false;
|
|
830
830
|
public textureLoaded = false;
|
|
831
831
|
|
|
832
|
+
/**
|
|
833
|
+
* Last ownership value sent to the current texture via
|
|
834
|
+
* {@link updateTextureOwnership}. Per (node, texture) pair — must reset to
|
|
835
|
+
* `false` whenever the texture is swapped or released, or a stale `true`
|
|
836
|
+
* would skip the re-registration that triggers `Texture.load()`.
|
|
837
|
+
*/
|
|
838
|
+
private textureOwnership = false;
|
|
839
|
+
|
|
832
840
|
/**
|
|
833
841
|
* True while this node should render its `placeholderColor` instead of its
|
|
834
842
|
* texture: `placeholderColor` is non-zero, a texture is set, and that
|
|
@@ -1085,6 +1093,7 @@ export class CoreNode extends EventEmitter {
|
|
|
1085
1093
|
texture.off('failed', this.onTextureFailed);
|
|
1086
1094
|
texture.off('freed', this.onTextureFreed);
|
|
1087
1095
|
texture.setRenderableOwner(this._id, false);
|
|
1096
|
+
this.textureOwnership = false;
|
|
1088
1097
|
}
|
|
1089
1098
|
|
|
1090
1099
|
protected onTextureLoaded: TextureLoadedEventHandler = (_, dimensions) => {
|
|
@@ -1918,6 +1927,10 @@ export class CoreNode extends EventEmitter {
|
|
|
1918
1927
|
* Changes the renderable state of the node.
|
|
1919
1928
|
*/
|
|
1920
1929
|
updateTextureOwnership(isRenderable: boolean) {
|
|
1930
|
+
if (this.textureOwnership === isRenderable) {
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
this.textureOwnership = isRenderable;
|
|
1921
1934
|
this.texture?.setRenderableOwner(this._id, isRenderable);
|
|
1922
1935
|
}
|
|
1923
1936
|
|
|
@@ -3100,6 +3113,7 @@ export class CoreNode extends EventEmitter {
|
|
|
3100
3113
|
this.autosizer.setMode(AutosizeMode.Texture); // Set to texture size mode
|
|
3101
3114
|
}
|
|
3102
3115
|
value.setRenderableOwner(this._id, this.isRenderable);
|
|
3116
|
+
this.textureOwnership = this.isRenderable;
|
|
3103
3117
|
this.loadTexture();
|
|
3104
3118
|
}
|
|
3105
3119
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for CoreShaderManager's behavior when shader creation fails.
|
|
3
|
+
*
|
|
4
|
+
* A lost GL context makes shader creation throw synchronously (see
|
|
5
|
+
* WebGlRenderer.createShaderProgram, which trips `stage.isContextLost`). On a
|
|
6
|
+
* lost context we must fail soft — returning the default shader node so the
|
|
7
|
+
* throw does not propagate through the consumer's reactive layer (recovery is
|
|
8
|
+
* an app reload via the `contextLost` event). A genuine compile error (context
|
|
9
|
+
* not lost) must still surface loudly.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { CoreShaderManager } from './CoreShaderManager.js';
|
|
13
|
+
import type { Stage } from './Stage.js';
|
|
14
|
+
|
|
15
|
+
function makeManager(opts: { isContextLost: boolean; throwOnCreate: Error }) {
|
|
16
|
+
const defShaderNode = { __default: true };
|
|
17
|
+
const createShaderProgram = vi.fn(() => {
|
|
18
|
+
throw opts.throwOnCreate;
|
|
19
|
+
});
|
|
20
|
+
const stage = {
|
|
21
|
+
isContextLost: opts.isContextLost,
|
|
22
|
+
defShaderNode,
|
|
23
|
+
renderer: {
|
|
24
|
+
mode: 'webgl',
|
|
25
|
+
createShaderProgram,
|
|
26
|
+
},
|
|
27
|
+
} as unknown as Stage;
|
|
28
|
+
|
|
29
|
+
const mgr = Object.create(CoreShaderManager.prototype) as CoreShaderManager;
|
|
30
|
+
(mgr as unknown as { stage: Stage }).stage = stage;
|
|
31
|
+
(mgr as unknown as { shTypes: Record<string, unknown> }).shTypes = {
|
|
32
|
+
TestShader: { props: undefined },
|
|
33
|
+
};
|
|
34
|
+
(mgr as unknown as { shCache: Map<string, unknown> }).shCache = new Map();
|
|
35
|
+
|
|
36
|
+
return { mgr, defShaderNode, createShaderProgram };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('CoreShaderManager.createShader — context loss', () => {
|
|
40
|
+
it('returns the default shader node when the context is lost', () => {
|
|
41
|
+
const { mgr, defShaderNode } = makeManager({
|
|
42
|
+
isContextLost: true,
|
|
43
|
+
throwOnCreate: new Error(
|
|
44
|
+
'Unable to create the shader: VERTEX_SHADER. WebGlContext Error: 37442',
|
|
45
|
+
),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const result = mgr.createShader('TestShader' as never);
|
|
49
|
+
|
|
50
|
+
expect(result).toBe(defShaderNode);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('rethrows when the failure is not a lost context (genuine compile error)', () => {
|
|
54
|
+
const err = new Error('Vertex shader creation failed');
|
|
55
|
+
const { mgr } = makeManager({ isContextLost: false, throwOnCreate: err });
|
|
56
|
+
|
|
57
|
+
expect(() => mgr.createShader('TestShader' as never)).toThrow(err);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('does not cache a failed program', () => {
|
|
61
|
+
const { mgr, createShaderProgram } = makeManager({
|
|
62
|
+
isContextLost: true,
|
|
63
|
+
throwOnCreate: new Error('boom'),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
mgr.createShader('TestShader' as never);
|
|
67
|
+
mgr.createShader('TestShader' as never);
|
|
68
|
+
|
|
69
|
+
// Cache miss both times — the failure was never stored.
|
|
70
|
+
expect(createShaderProgram).toHaveBeenCalledTimes(2);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -103,8 +103,21 @@ export class CoreShaderManager {
|
|
|
103
103
|
* if shaderProgram was not found create a new one
|
|
104
104
|
*/
|
|
105
105
|
if (shProgram === undefined) {
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
try {
|
|
107
|
+
shProgram = this.stage.renderer.createShaderProgram(shType, props)!;
|
|
108
|
+
this.shCache.set(shaderKey, shProgram);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
// The renderer trips `isContextLost` when shader creation fails due to
|
|
111
|
+
// a lost GL context. That is unrecoverable in-place (the consumer
|
|
112
|
+
// reloads on the `contextLost` event), so fall back to the default
|
|
113
|
+
// shader node instead of letting the throw propagate through the app's
|
|
114
|
+
// reactive layer. A genuine GLSL compile error (context not lost) is a
|
|
115
|
+
// real bug and is rethrown so it surfaces loudly.
|
|
116
|
+
if (this.stage.isContextLost === true) {
|
|
117
|
+
return this.stage.defShaderNode;
|
|
118
|
+
}
|
|
119
|
+
throw e;
|
|
120
|
+
}
|
|
108
121
|
}
|
|
109
122
|
|
|
110
123
|
return this.stage.renderer.createShaderNode(
|
package/src/core/CoreTextNode.ts
CHANGED
|
@@ -266,8 +266,9 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
|
|
|
266
266
|
this.setRenderable(false);
|
|
267
267
|
|
|
268
268
|
if (this.renderState > CoreNodeRenderState.OutOfBounds) {
|
|
269
|
-
// We do want the texture to load immediately
|
|
270
|
-
|
|
269
|
+
// We do want the texture to load immediately. Routed through
|
|
270
|
+
// updateTextureOwnership so the ownership cache stays in sync.
|
|
271
|
+
this.updateTextureOwnership(true);
|
|
271
272
|
}
|
|
272
273
|
}
|
|
273
274
|
}
|
|
@@ -409,9 +409,9 @@ export class CoreTextureManager extends EventEmitter {
|
|
|
409
409
|
|
|
410
410
|
// Anything that arrived before initialization completed is now safe to
|
|
411
411
|
// process. Without this, queued textures would sit until the next frame
|
|
412
|
-
// tick happens to
|
|
412
|
+
// tick happens to drain them.
|
|
413
413
|
if (this.uploadTextureQueue.size > 0) {
|
|
414
|
-
this.
|
|
414
|
+
this.processUntil(Infinity).catch((err) => {
|
|
415
415
|
console.error('Failed to drain pre-init texture queue:', err);
|
|
416
416
|
});
|
|
417
417
|
}
|
|
@@ -587,11 +587,41 @@ export class CoreTextureManager extends EventEmitter {
|
|
|
587
587
|
}
|
|
588
588
|
|
|
589
589
|
/**
|
|
590
|
-
*
|
|
590
|
+
* Upload a single queued texture to the GPU.
|
|
591
591
|
*
|
|
592
|
-
* @
|
|
592
|
+
* @remarks
|
|
593
|
+
* Used while animations are running so uploads don't steal time from the
|
|
594
|
+
* animation. If the dequeued texture already died (failed/freed), nothing is
|
|
595
|
+
* uploaded this frame and the next call handles the following one.
|
|
596
|
+
*/
|
|
597
|
+
async processOne(): Promise<void> {
|
|
598
|
+
if (this.initialized === false) {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const texture = this.uploadTextureQueue.shift();
|
|
603
|
+
if (texture === undefined) {
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
await this.uploadQueued(texture);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Upload queued textures to the GPU until the per-frame time budget runs out.
|
|
612
|
+
*
|
|
613
|
+
* @remarks
|
|
614
|
+
* Called once per frame when idle. Textures are uploaded one-by-one; after
|
|
615
|
+
* each, the elapsed time is rechecked and processing stops once it exceeds
|
|
616
|
+
* `maxProcessingTime`, leaving the rest queued for the next frame.
|
|
617
|
+
*
|
|
618
|
+
* In normal operation a queued texture's data is already decoded
|
|
619
|
+
* (`loadTexture` awaits `getTextureData` before enqueuing), so this budgets
|
|
620
|
+
* GPU upload time. Pass `Infinity` to drain the whole queue.
|
|
621
|
+
*
|
|
622
|
+
* @param maxProcessingTime - The time budget for this frame, in milliseconds
|
|
593
623
|
*/
|
|
594
|
-
async
|
|
624
|
+
async processUntil(maxProcessingTime: number): Promise<void> {
|
|
595
625
|
if (this.initialized === false) {
|
|
596
626
|
return;
|
|
597
627
|
}
|
|
@@ -599,79 +629,54 @@ export class CoreTextureManager extends EventEmitter {
|
|
|
599
629
|
const platform = this.platform;
|
|
600
630
|
const startTime = platform.getTimeStamp();
|
|
601
631
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
const pending: Array<{ texture: Texture; data: Promise<unknown> }> = [];
|
|
608
|
-
|
|
609
|
-
// Helper avoids TS narrowing `texture.state` permanently after the first
|
|
610
|
-
// discriminated check — the property is mutable and can transition across
|
|
611
|
-
// awaits, so we need to re-read it freshly each time.
|
|
612
|
-
const isDead = (texture: Texture): boolean =>
|
|
613
|
-
texture.state === 'failed' || texture.state === 'freed';
|
|
614
|
-
|
|
615
|
-
const fillPrefetch = () => {
|
|
616
|
-
while (pending.length < prefetchLimit) {
|
|
617
|
-
const texture = this.uploadTextureQueue.shift();
|
|
618
|
-
if (texture === undefined) break;
|
|
619
|
-
|
|
620
|
-
if (isDead(texture)) {
|
|
621
|
-
continue;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
// Swallow the rejection here so an early failure doesn't surface as
|
|
625
|
-
// an unhandled promise rejection while it sits in the prefetch
|
|
626
|
-
// window; we re-check state after awaiting.
|
|
627
|
-
const data =
|
|
628
|
-
texture.textureData === null
|
|
629
|
-
? texture.getTextureData().catch((err) => {
|
|
630
|
-
console.error('Failed to fetch texture data:', err);
|
|
631
|
-
return null;
|
|
632
|
-
})
|
|
633
|
-
: Promise.resolve(texture.textureData);
|
|
634
|
-
|
|
635
|
-
pending.push({ texture, data });
|
|
632
|
+
while (platform.getTimeStamp() - startTime < maxProcessingTime) {
|
|
633
|
+
const texture = this.uploadTextureQueue.shift();
|
|
634
|
+
if (texture === undefined) {
|
|
635
|
+
// Queue drained.
|
|
636
|
+
break;
|
|
636
637
|
}
|
|
637
|
-
};
|
|
638
638
|
|
|
639
|
-
|
|
639
|
+
await this.uploadQueued(texture);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
640
642
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
643
|
+
/**
|
|
644
|
+
* Decode (if needed) and upload a single already-dequeued texture.
|
|
645
|
+
*
|
|
646
|
+
* @remarks
|
|
647
|
+
* Shared by {@link processOne} and {@link processUntil}. Dead (failed/freed)
|
|
648
|
+
* textures and upload failures are skipped without throwing.
|
|
649
|
+
*/
|
|
650
|
+
private async uploadQueued(texture: Texture): Promise<void> {
|
|
651
|
+
if (this.isTextureDead(texture)) {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
650
654
|
|
|
651
|
-
|
|
652
|
-
|
|
655
|
+
try {
|
|
656
|
+
if (texture.textureData === null) {
|
|
657
|
+
await texture.getTextureData();
|
|
653
658
|
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
await next.data;
|
|
657
|
-
if (isDead(next.texture)) {
|
|
658
|
-
continue;
|
|
659
|
-
}
|
|
660
|
-
await this.uploadTexture(next.texture);
|
|
661
|
-
} catch (error) {
|
|
662
|
-
console.error('Failed to upload texture:', error);
|
|
663
|
-
// Continue with next texture instead of stopping entire queue
|
|
659
|
+
if (this.isTextureDead(texture)) {
|
|
660
|
+
return;
|
|
664
661
|
}
|
|
662
|
+
await this.uploadTexture(texture);
|
|
663
|
+
} catch (error) {
|
|
664
|
+
console.error('Failed to upload texture:', error);
|
|
665
|
+
// Skip this texture instead of stalling the queue.
|
|
665
666
|
}
|
|
667
|
+
}
|
|
666
668
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
669
|
+
/**
|
|
670
|
+
* A texture is "dead" once it has failed or been freed — both terminal for
|
|
671
|
+
* the upload pipeline.
|
|
672
|
+
*
|
|
673
|
+
* @remarks
|
|
674
|
+
* Kept as a method rather than an inline check so TypeScript doesn't
|
|
675
|
+
* permanently narrow `state` after the first comparison: the property is
|
|
676
|
+
* mutable and can transition across the awaits in {@link uploadQueued}.
|
|
677
|
+
*/
|
|
678
|
+
private isTextureDead(texture: Texture): boolean {
|
|
679
|
+
return texture.state === 'failed' || texture.state === 'freed';
|
|
675
680
|
}
|
|
676
681
|
|
|
677
682
|
public hasUpdates(): boolean {
|
package/src/core/Stage.ts
CHANGED
|
@@ -595,11 +595,16 @@ export class Stage {
|
|
|
595
595
|
// Process some textures asynchronously but don't block the frame
|
|
596
596
|
// Use a background task to prevent frame drops
|
|
597
597
|
if (this.txManager.hasUpdates() === true) {
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
598
|
+
// While animating, upload at most one texture per frame so uploads don't
|
|
599
|
+
// steal time from the animation; otherwise fill the per-frame time budget.
|
|
600
|
+
const processing =
|
|
601
|
+
hasActiveAnimations === true
|
|
602
|
+
? this.txManager.processOne()
|
|
603
|
+
: this.txManager.processUntil(
|
|
604
|
+
this.options.textureProcessingTimeLimit,
|
|
605
|
+
);
|
|
601
606
|
|
|
602
|
-
|
|
607
|
+
processing.catch((err) => {
|
|
603
608
|
console.error('Error processing textures:', err);
|
|
604
609
|
});
|
|
605
610
|
}
|
|
@@ -175,7 +175,7 @@ function freedCachedTexture(cacheKey: string): Texture & {
|
|
|
175
175
|
state: 'freed',
|
|
176
176
|
type: TextureType.image,
|
|
177
177
|
preventCleanup: false,
|
|
178
|
-
renderableOwners:
|
|
178
|
+
renderableOwners: new Set<string | number>(),
|
|
179
179
|
cacheKey,
|
|
180
180
|
free: vi.fn(),
|
|
181
181
|
destroy: vi.fn(),
|
|
@@ -206,7 +206,7 @@ describe('TextureMemoryManager — orphaned freed texture eviction', () => {
|
|
|
206
206
|
it('keeps a freed texture that still has renderable owners', () => {
|
|
207
207
|
const { mgr, keyCache } = makeManager();
|
|
208
208
|
const texture = freedCachedTexture('img:poster.png');
|
|
209
|
-
|
|
209
|
+
texture.renderableOwners.add(1);
|
|
210
210
|
keyCache.set('img:poster.png', texture);
|
|
211
211
|
|
|
212
212
|
mgr.cleanup();
|
|
@@ -349,7 +349,7 @@ export class TextureMemoryManager {
|
|
|
349
349
|
if (
|
|
350
350
|
evictable === true &&
|
|
351
351
|
texture.preventCleanup === false &&
|
|
352
|
-
texture.renderableOwners.
|
|
352
|
+
texture.renderableOwners.size === 0 &&
|
|
353
353
|
texture.hasListeners() === false
|
|
354
354
|
) {
|
|
355
355
|
this.destroyTexture(texture);
|
|
@@ -1190,6 +1190,22 @@ export class WebGlContextWrapper {
|
|
|
1190
1190
|
return this.gl.getError();
|
|
1191
1191
|
}
|
|
1192
1192
|
|
|
1193
|
+
/**
|
|
1194
|
+
* ```
|
|
1195
|
+
* gl.isContextLost();
|
|
1196
|
+
* ```
|
|
1197
|
+
*
|
|
1198
|
+
* @remarks
|
|
1199
|
+
* Reports the current lost state directly and is not consumed like
|
|
1200
|
+
* `getError()` (whose `CONTEXT_LOST_WEBGL` flag is cleared on first read).
|
|
1201
|
+
* Use this to detect a lost context after a GL call has already failed.
|
|
1202
|
+
*
|
|
1203
|
+
* @returns
|
|
1204
|
+
*/
|
|
1205
|
+
isContextLost() {
|
|
1206
|
+
return this.gl.isContextLost();
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1193
1209
|
/**
|
|
1194
1210
|
* ```
|
|
1195
1211
|
* gl.getAttribLocation(program, name);
|
|
@@ -260,6 +260,26 @@ export class CanvasRenderer extends CoreRenderer {
|
|
|
260
260
|
}
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Renders a node's content (texture or color rect) into an arbitrary 2D
|
|
265
|
+
* context instead of the main canvas. Used by shaders that need to
|
|
266
|
+
* composite the node's content offscreen (e.g. EdgeFade alpha masks).
|
|
267
|
+
*
|
|
268
|
+
* The content is drawn at the node's global position — the caller is
|
|
269
|
+
* responsible for setting a transform on `target` that maps it to the
|
|
270
|
+
* desired location.
|
|
271
|
+
*/
|
|
272
|
+
renderNodeContent(node: CoreNode, target: CanvasRenderingContext2D): void {
|
|
273
|
+
let texture = node.props.texture;
|
|
274
|
+
if (node.placeholderActive === true || texture === null) {
|
|
275
|
+
texture = this.stage.defaultTexture as Texture;
|
|
276
|
+
}
|
|
277
|
+
const prev = this.context;
|
|
278
|
+
this.context = target;
|
|
279
|
+
this.renderContext(node, texture);
|
|
280
|
+
this.context = prev;
|
|
281
|
+
}
|
|
282
|
+
|
|
263
283
|
createShaderNode(
|
|
264
284
|
shaderKey: string,
|
|
265
285
|
shaderType: Readonly<CanvasShaderType>,
|
|
@@ -161,10 +161,11 @@ export class WebGlCtxTexture extends CoreContextTexture {
|
|
|
161
161
|
);
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
164
|
+
// No placeholder upload here: every branch below fully (re)uploads the
|
|
165
|
+
// texture before this method returns, and there is no `await` in between,
|
|
166
|
+
// so no frame can render an intermediate 1x1. The empty-texture case
|
|
167
|
+
// (`tdata === null`) does its own 1x1 upload. Uploading a throwaway 1x1
|
|
168
|
+
// first would be a wasted texImage2D + setTextureMemUse per texture.
|
|
168
169
|
let w = 0;
|
|
169
170
|
let h = 0;
|
|
170
171
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for WebGlRenderer.createShaderProgram's context-loss handling.
|
|
3
|
+
*
|
|
4
|
+
* When the GL context is lost, gl.createShader() returns null and getError()
|
|
5
|
+
* reports CONTEXT_LOST_WEBGL (0x9242 / 37442). That can happen on the app's
|
|
6
|
+
* reactive stack before the async `webglcontextlost` event fires, so the
|
|
7
|
+
* renderer trips `stage.setContextLost()` itself when it detects a lost
|
|
8
|
+
* context — but only then; genuine compile errors are rethrown untouched.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { WebGlRenderer } from './WebGlRenderer.js';
|
|
12
|
+
import type { WebGlShaderType } from './WebGlShaderNode.js';
|
|
13
|
+
|
|
14
|
+
function makeRenderer(isContextLost: boolean) {
|
|
15
|
+
const setContextLost = vi.fn();
|
|
16
|
+
// Minimal glw that drives the WebGlShaderProgram constructor straight to the
|
|
17
|
+
// "Unable to create the shader" throw (createShader returns null).
|
|
18
|
+
const glw = {
|
|
19
|
+
VERTEX_SHADER: 0x8b31,
|
|
20
|
+
getExtension: () => ({}),
|
|
21
|
+
createShader: () => null,
|
|
22
|
+
getError: () => 37442,
|
|
23
|
+
isContextLost: () => isContextLost,
|
|
24
|
+
};
|
|
25
|
+
const renderer = Object.create(WebGlRenderer.prototype) as WebGlRenderer;
|
|
26
|
+
(renderer as unknown as { glw: unknown }).glw = glw;
|
|
27
|
+
(renderer as unknown as { stage: unknown }).stage = { setContextLost };
|
|
28
|
+
|
|
29
|
+
const config = { vertex: 'v', fragment: 'f' } as unknown as WebGlShaderType;
|
|
30
|
+
return { renderer, config, setContextLost };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('WebGlRenderer.createShaderProgram — context loss', () => {
|
|
34
|
+
it('trips setContextLost and rethrows when the context is lost', () => {
|
|
35
|
+
const { renderer, config, setContextLost } = makeRenderer(true);
|
|
36
|
+
|
|
37
|
+
expect(() => renderer.createShaderProgram(config, {})).toThrow(
|
|
38
|
+
/Unable to create the shader/,
|
|
39
|
+
);
|
|
40
|
+
expect(setContextLost).toHaveBeenCalledTimes(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('rethrows without tripping setContextLost when the context is healthy', () => {
|
|
44
|
+
const { renderer, config, setContextLost } = makeRenderer(false);
|
|
45
|
+
|
|
46
|
+
expect(() => renderer.createShaderProgram(config, {})).toThrow();
|
|
47
|
+
expect(setContextLost).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -385,7 +385,19 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
385
385
|
shaderType: WebGlShaderType,
|
|
386
386
|
props: Record<string, unknown>,
|
|
387
387
|
): WebGlShaderProgram {
|
|
388
|
-
|
|
388
|
+
try {
|
|
389
|
+
return new WebGlShaderProgram(this, shaderType, props);
|
|
390
|
+
} catch (e) {
|
|
391
|
+
// A lost GL context makes shader creation/compilation fail synchronously
|
|
392
|
+
// (gl.createShader returns null -> CONTEXT_LOST_WEBGL). This can run on
|
|
393
|
+
// the app's reactive stack before the async `webglcontextlost` event is
|
|
394
|
+
// processed, so trip the flag here too. setContextLost() is idempotent
|
|
395
|
+
// and emits `contextLost` for consumers (recovery is an app reload).
|
|
396
|
+
if (this.glw.isContextLost() === true) {
|
|
397
|
+
this.stage.setContextLost();
|
|
398
|
+
}
|
|
399
|
+
throw e;
|
|
400
|
+
}
|
|
389
401
|
}
|
|
390
402
|
|
|
391
403
|
createShaderNode(
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { CanvasShaderType } from '../../renderers/canvas/CanvasShaderNode.js';
|
|
2
|
+
import type { CanvasRenderer } from '../../renderers/canvas/CanvasRenderer.js';
|
|
3
|
+
import {
|
|
4
|
+
EdgeFadeTemplate,
|
|
5
|
+
type EdgeFadeProps,
|
|
6
|
+
} from '../templates/EdgeFadeTemplate.js';
|
|
7
|
+
|
|
8
|
+
// Shared scratch canvas, grown on demand and reused across all EdgeFade nodes.
|
|
9
|
+
// Rendering is single-threaded and non-reentrant so one scratch is enough.
|
|
10
|
+
let scratchCanvas: HTMLCanvasElement | null = null;
|
|
11
|
+
let scratchCtx: CanvasRenderingContext2D | null = null;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Canvas2D implementation of {@link EdgeFade}: the node's content is drawn
|
|
15
|
+
* into an offscreen canvas, the edge ramps are erased with destination-out
|
|
16
|
+
* gradients, and the result is composited onto the main canvas. This keeps
|
|
17
|
+
* the background behind the node intact, which a destination-out pass on the
|
|
18
|
+
* main canvas could not.
|
|
19
|
+
*/
|
|
20
|
+
export const EdgeFade: CanvasShaderType<EdgeFadeProps> = {
|
|
21
|
+
props: EdgeFadeTemplate.props,
|
|
22
|
+
render(ctx, node, renderContext) {
|
|
23
|
+
const props = this.props!;
|
|
24
|
+
const left = props.left;
|
|
25
|
+
const top = props.top;
|
|
26
|
+
const right = props.right;
|
|
27
|
+
const bottom = props.bottom;
|
|
28
|
+
|
|
29
|
+
if (left <= 0 && top <= 0 && right <= 0 && bottom <= 0) {
|
|
30
|
+
renderContext();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const w = node.props.w;
|
|
35
|
+
const h = node.props.h;
|
|
36
|
+
if (w <= 0 || h <= 0) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const pr = this.stage.pixelRatio;
|
|
41
|
+
const sw = w * pr;
|
|
42
|
+
const sh = h * pr;
|
|
43
|
+
|
|
44
|
+
if (scratchCanvas === null) {
|
|
45
|
+
scratchCanvas = document.createElement('canvas');
|
|
46
|
+
scratchCtx = scratchCanvas.getContext('2d') as CanvasRenderingContext2D;
|
|
47
|
+
}
|
|
48
|
+
const sctx = scratchCtx!;
|
|
49
|
+
|
|
50
|
+
if (scratchCanvas.width < sw || scratchCanvas.height < sh) {
|
|
51
|
+
// Growing the canvas implicitly clears it
|
|
52
|
+
scratchCanvas.width = Math.ceil(sw);
|
|
53
|
+
scratchCanvas.height = Math.ceil(sh);
|
|
54
|
+
} else {
|
|
55
|
+
sctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
56
|
+
sctx.clearRect(0, 0, sw, sh);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Draw the node's content at the scratch origin
|
|
60
|
+
const tx = node.globalTransform!.tx;
|
|
61
|
+
const ty = node.globalTransform!.ty;
|
|
62
|
+
sctx.setTransform(pr, 0, 0, pr, -tx * pr, -ty * pr);
|
|
63
|
+
(this.stage.renderer as CanvasRenderer).renderNodeContent(node, sctx);
|
|
64
|
+
sctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
65
|
+
|
|
66
|
+
// Erase each edge with a linear ramp. Sequential destination-out passes
|
|
67
|
+
// multiply: dst *= (1 - g), matching the WebGL ramp product.
|
|
68
|
+
sctx.globalCompositeOperation = 'destination-out';
|
|
69
|
+
if (left > 0) {
|
|
70
|
+
const d = left * pr;
|
|
71
|
+
const g = sctx.createLinearGradient(0, 0, d, 0);
|
|
72
|
+
g.addColorStop(0, 'rgba(0,0,0,1)');
|
|
73
|
+
g.addColorStop(1, 'rgba(0,0,0,0)');
|
|
74
|
+
sctx.fillStyle = g;
|
|
75
|
+
sctx.fillRect(0, 0, d, sh);
|
|
76
|
+
}
|
|
77
|
+
if (top > 0) {
|
|
78
|
+
const d = top * pr;
|
|
79
|
+
const g = sctx.createLinearGradient(0, 0, 0, d);
|
|
80
|
+
g.addColorStop(0, 'rgba(0,0,0,1)');
|
|
81
|
+
g.addColorStop(1, 'rgba(0,0,0,0)');
|
|
82
|
+
sctx.fillStyle = g;
|
|
83
|
+
sctx.fillRect(0, 0, sw, d);
|
|
84
|
+
}
|
|
85
|
+
if (right > 0) {
|
|
86
|
+
const d = right * pr;
|
|
87
|
+
const g = sctx.createLinearGradient(sw - d, 0, sw, 0);
|
|
88
|
+
g.addColorStop(0, 'rgba(0,0,0,0)');
|
|
89
|
+
g.addColorStop(1, 'rgba(0,0,0,1)');
|
|
90
|
+
sctx.fillStyle = g;
|
|
91
|
+
sctx.fillRect(sw - d, 0, d, sh);
|
|
92
|
+
}
|
|
93
|
+
if (bottom > 0) {
|
|
94
|
+
const d = bottom * pr;
|
|
95
|
+
const g = sctx.createLinearGradient(0, sh - d, 0, sh);
|
|
96
|
+
g.addColorStop(0, 'rgba(0,0,0,0)');
|
|
97
|
+
g.addColorStop(1, 'rgba(0,0,0,1)');
|
|
98
|
+
sctx.fillStyle = g;
|
|
99
|
+
sctx.fillRect(0, sh - d, sw, d);
|
|
100
|
+
}
|
|
101
|
+
sctx.globalCompositeOperation = 'source-over';
|
|
102
|
+
|
|
103
|
+
ctx.drawImage(scratchCanvas, 0, 0, sw, sh, tx, ty, w, h);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { LinearGradient } from './LinearGradient.js';
|
|
3
|
+
import { normalizeCanvasColor } from '../../lib/colorCache.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Invoke the canvas LinearGradient `update()` with a minimal fake shader node
|
|
7
|
+
* and return the computed CSS color strings.
|
|
8
|
+
*/
|
|
9
|
+
function computeColors(colors: number[]): string[] {
|
|
10
|
+
const ctx = {
|
|
11
|
+
props: { colors, stops: [0, 1], angle: 0 },
|
|
12
|
+
computed: undefined as unknown,
|
|
13
|
+
toColorString: (value: number) => normalizeCanvasColor(value, true),
|
|
14
|
+
};
|
|
15
|
+
// Colors are RGBA encoded (alpha in the low byte) — see parseToRgbaString.
|
|
16
|
+
LinearGradient.update!.call(ctx as never, { w: 100, h: 100 } as never);
|
|
17
|
+
return (ctx.computed as { colors: string[] }).colors;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('canvas LinearGradient color mapping', () => {
|
|
21
|
+
it('preserves per-stop alpha (no opaque fallback)', () => {
|
|
22
|
+
// 0x00ff0080 is a half-transparent green stop. The previous
|
|
23
|
+
// "nearest opaque RGB" workaround mis-read the high byte as alpha
|
|
24
|
+
// (it is actually red in RGBA) and overwrote the low byte, turning
|
|
25
|
+
// transparent stops solid. The faithful mapping must keep alpha ~0.5.
|
|
26
|
+
const [opaqueRed, halfGreen] = computeColors([0xff0000ff, 0x00ff0080]);
|
|
27
|
+
|
|
28
|
+
expect(opaqueRed).toBe('rgba(255,0,0,1)');
|
|
29
|
+
expect(halfGreen).toBe(`rgba(0,255,0,${0x80 / 255})`);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('keeps fully transparent stops transparent', () => {
|
|
33
|
+
const [, transparent] = computeColors([0x000000ff, 0x00000000]);
|
|
34
|
+
expect(transparent).toBe('rgba(0,0,0,0)');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('maps every stop straight through toColorString', () => {
|
|
38
|
+
const colors = [0xff0000ff, 0x00ff00ff, 0x0000ffff];
|
|
39
|
+
expect(computeColors(colors)).toEqual([
|
|
40
|
+
'rgba(255,0,0,1)',
|
|
41
|
+
'rgba(0,255,0,1)',
|
|
42
|
+
'rgba(0,0,255,1)',
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
});
|