@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.
Files changed (98) hide show
  1. package/dist/exports/canvas-shaders.d.ts +1 -0
  2. package/dist/exports/canvas-shaders.js +1 -0
  3. package/dist/exports/canvas-shaders.js.map +1 -1
  4. package/dist/exports/index.d.ts +1 -0
  5. package/dist/exports/index.js +1 -0
  6. package/dist/exports/index.js.map +1 -1
  7. package/dist/exports/webgl-shaders.d.ts +1 -0
  8. package/dist/exports/webgl-shaders.js +1 -0
  9. package/dist/exports/webgl-shaders.js.map +1 -1
  10. package/dist/src/core/CoreNode.d.ts +7 -0
  11. package/dist/src/core/CoreNode.js +13 -0
  12. package/dist/src/core/CoreNode.js.map +1 -1
  13. package/dist/src/core/CoreShaderManager.js +16 -2
  14. package/dist/src/core/CoreShaderManager.js.map +1 -1
  15. package/dist/src/core/CoreTextNode.js +3 -2
  16. package/dist/src/core/CoreTextNode.js.map +1 -1
  17. package/dist/src/core/CoreTextureManager.d.ts +39 -3
  18. package/dist/src/core/CoreTextureManager.js +72 -61
  19. package/dist/src/core/CoreTextureManager.js.map +1 -1
  20. package/dist/src/core/Stage.js +6 -4
  21. package/dist/src/core/Stage.js.map +1 -1
  22. package/dist/src/core/TextureMemoryManager.js +1 -1
  23. package/dist/src/core/TextureMemoryManager.js.map +1 -1
  24. package/dist/src/core/lib/WebGlContextWrapper.d.ts +13 -0
  25. package/dist/src/core/lib/WebGlContextWrapper.js +15 -0
  26. package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
  27. package/dist/src/core/renderers/canvas/CanvasRenderer.d.ts +10 -0
  28. package/dist/src/core/renderers/canvas/CanvasRenderer.js +19 -0
  29. package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
  30. package/dist/src/core/renderers/webgl/WebGlCtxTexture.js +5 -3
  31. package/dist/src/core/renderers/webgl/WebGlCtxTexture.js.map +1 -1
  32. package/dist/src/core/renderers/webgl/WebGlRenderer.js +14 -1
  33. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  34. package/dist/src/core/shaders/canvas/EdgeFade.d.ts +10 -0
  35. package/dist/src/core/shaders/canvas/EdgeFade.js +92 -0
  36. package/dist/src/core/shaders/canvas/EdgeFade.js.map +1 -0
  37. package/dist/src/core/shaders/canvas/LinearGradient.js +1 -18
  38. package/dist/src/core/shaders/canvas/LinearGradient.js.map +1 -1
  39. package/dist/src/core/shaders/templates/EdgeFadeTemplate.d.ts +32 -0
  40. package/dist/src/core/shaders/templates/EdgeFadeTemplate.js +9 -0
  41. package/dist/src/core/shaders/templates/EdgeFadeTemplate.js.map +1 -0
  42. package/dist/src/core/shaders/webgl/EdgeFade.d.ts +9 -0
  43. package/dist/src/core/shaders/webgl/EdgeFade.js +74 -0
  44. package/dist/src/core/shaders/webgl/EdgeFade.js.map +1 -0
  45. package/dist/src/core/shaders/webgl/LinearGradient.js +31 -51
  46. package/dist/src/core/shaders/webgl/LinearGradient.js.map +1 -1
  47. package/dist/src/core/shaders/webgl/RadialGradient.js +2 -2
  48. package/dist/src/core/shaders/webgl/RadialProgress.js +7 -0
  49. package/dist/src/core/shaders/webgl/RadialProgress.js.map +1 -1
  50. package/dist/src/core/text-rendering/SdfFontHandler.d.ts +1 -0
  51. package/dist/src/core/text-rendering/SdfFontHandler.js +88 -51
  52. package/dist/src/core/text-rendering/SdfFontHandler.js.map +1 -1
  53. package/dist/src/core/textures/ImageTexture.js +2 -1
  54. package/dist/src/core/textures/ImageTexture.js.map +1 -1
  55. package/dist/src/core/textures/SubTexture.js +1 -1
  56. package/dist/src/core/textures/SubTexture.js.map +1 -1
  57. package/dist/src/core/textures/Texture.d.ts +1 -1
  58. package/dist/src/core/textures/Texture.js +10 -13
  59. package/dist/src/core/textures/Texture.js.map +1 -1
  60. package/dist/src/main-api/Inspector.js +1 -1
  61. package/dist/src/main-api/Inspector.js.map +1 -1
  62. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  63. package/exports/canvas-shaders.ts +1 -0
  64. package/exports/index.ts +1 -0
  65. package/exports/webgl-shaders.ts +1 -0
  66. package/package.json +1 -1
  67. package/src/core/CoreNode.test.ts +165 -0
  68. package/src/core/CoreNode.ts +14 -0
  69. package/src/core/CoreShaderManager.contextLoss.test.ts +72 -0
  70. package/src/core/CoreShaderManager.ts +15 -2
  71. package/src/core/CoreTextNode.ts +3 -2
  72. package/src/core/CoreTextureManager.ts +75 -70
  73. package/src/core/Stage.ts +9 -4
  74. package/src/core/TextureMemoryManager.test.ts +2 -2
  75. package/src/core/TextureMemoryManager.ts +1 -1
  76. package/src/core/lib/WebGlContextWrapper.ts +16 -0
  77. package/src/core/renderers/canvas/CanvasRenderer.ts +20 -0
  78. package/src/core/renderers/webgl/WebGlCtxTexture.ts +5 -4
  79. package/src/core/renderers/webgl/WebGlRenderer.contextLoss.test.ts +49 -0
  80. package/src/core/renderers/webgl/WebGlRenderer.ts +13 -1
  81. package/src/core/shaders/canvas/EdgeFade.ts +105 -0
  82. package/src/core/shaders/canvas/LinearGradient.test.ts +45 -0
  83. package/src/core/shaders/canvas/LinearGradient.ts +1 -18
  84. package/src/core/shaders/templates/EdgeFadeTemplate.test.ts +35 -0
  85. package/src/core/shaders/templates/EdgeFadeTemplate.ts +41 -0
  86. package/src/core/shaders/webgl/EdgeFade.ts +84 -0
  87. package/src/core/shaders/webgl/LinearGradient.test.ts +70 -0
  88. package/src/core/shaders/webgl/LinearGradient.ts +36 -51
  89. package/src/core/shaders/webgl/RadialGradient.ts +2 -2
  90. package/src/core/shaders/webgl/RadialProgress.ts +7 -0
  91. package/src/core/text-rendering/SdfFontHandler.ts +77 -30
  92. package/src/core/text-rendering/tests/SdfFontHandler.test.ts +181 -35
  93. package/src/core/textures/ImageTexture.test.ts +91 -0
  94. package/src/core/textures/ImageTexture.ts +4 -1
  95. package/src/core/textures/SubTexture.ts +1 -1
  96. package/src/core/textures/Texture.test.ts +124 -0
  97. package/src/core/textures/Texture.ts +10 -13
  98. package/src/main-api/Inspector.ts +1 -1
@@ -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
- shProgram = this.stage.renderer.createShaderProgram(shType, props)!;
107
- this.shCache.set(shaderKey, shProgram);
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(
@@ -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
- this.texture.setRenderableOwner(this._id, true);
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 call processSome().
412
+ // tick happens to drain them.
413
413
  if (this.uploadTextureQueue.size > 0) {
414
- this.processSome(Infinity).catch((err) => {
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
- * Process a limited number of uploads.
590
+ * Upload a single queued texture to the GPU.
591
591
  *
592
- * @param maxProcessingTime - The maximum processing time in milliseconds
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 processSome(maxProcessingTime: number): Promise<void> {
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
- // Decode / fetch ("getTextureData") is IO-bound and parallelisable across
603
- // image workers, while GPU upload is effectively serial. Keep a small
604
- // sliding window of in-flight data fetches so the next decode runs while
605
- // we're uploading the current one.
606
- const prefetchLimit = Math.max(1, this.numImageWorkers);
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
- fillPrefetch();
639
+ await this.uploadQueued(texture);
640
+ }
641
+ }
640
642
 
641
- while (
642
- pending.length > 0 &&
643
- platform.getTimeStamp() - startTime < maxProcessingTime
644
- ) {
645
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
646
- const next = pending.shift()!;
647
- // Top up the prefetch window before awaiting — the next decode starts
648
- // now and overlaps with this upload.
649
- fillPrefetch();
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
- if (isDead(next.texture)) {
652
- continue;
655
+ try {
656
+ if (texture.textureData === null) {
657
+ await texture.getTextureData();
653
658
  }
654
-
655
- try {
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
- // Time ran out before we got to these. Put them back so we don't lose
668
- // them their getTextureData() is already in flight and will populate
669
- // `textureData` for the next tick.
670
- for (const { texture } of pending) {
671
- if (!isDead(texture)) {
672
- this.uploadTextureQueue.add(texture);
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
- const timeLimit = hasActiveAnimations
599
- ? this.options.textureProcessingTimeLimit / 2
600
- : this.options.textureProcessingTimeLimit;
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
- this.txManager.processSome(timeLimit).catch((err) => {
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
- (texture.renderableOwners as unknown[]).push(1);
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.length === 0 &&
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
- // Set to a 1x1 transparent texture
165
- glw.texImage2D(0, glw.RGBA, 1, 1, 0, glw.RGBA, glw.UNSIGNED_BYTE, null);
166
- this.setTextureMemUse(TRANSPARENT_TEXTURE_DATA.byteLength);
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
- return new WebGlShaderProgram(this, shaderType, props);
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
+ });