@solidtv/renderer 1.5.4 → 1.5.6

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 (77) 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/CoreShaderManager.js +16 -2
  11. package/dist/src/core/CoreShaderManager.js.map +1 -1
  12. package/dist/src/core/lib/WebGlContextWrapper.d.ts +13 -0
  13. package/dist/src/core/lib/WebGlContextWrapper.js +15 -0
  14. package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
  15. package/dist/src/core/lib/validateImageBitmap.js +7 -0
  16. package/dist/src/core/lib/validateImageBitmap.js.map +1 -1
  17. package/dist/src/core/platforms/web/WebPlatform.js +57 -30
  18. package/dist/src/core/platforms/web/WebPlatform.js.map +1 -1
  19. package/dist/src/core/renderers/canvas/CanvasRenderer.d.ts +10 -0
  20. package/dist/src/core/renderers/canvas/CanvasRenderer.js +19 -0
  21. package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
  22. package/dist/src/core/renderers/webgl/WebGlRenderer.js +14 -1
  23. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  24. package/dist/src/core/shaders/canvas/EdgeFade.d.ts +10 -0
  25. package/dist/src/core/shaders/canvas/EdgeFade.js +92 -0
  26. package/dist/src/core/shaders/canvas/EdgeFade.js.map +1 -0
  27. package/dist/src/core/shaders/canvas/LinearGradient.js +1 -18
  28. package/dist/src/core/shaders/canvas/LinearGradient.js.map +1 -1
  29. package/dist/src/core/shaders/templates/EdgeFadeTemplate.d.ts +32 -0
  30. package/dist/src/core/shaders/templates/EdgeFadeTemplate.js +9 -0
  31. package/dist/src/core/shaders/templates/EdgeFadeTemplate.js.map +1 -0
  32. package/dist/src/core/shaders/webgl/EdgeFade.d.ts +9 -0
  33. package/dist/src/core/shaders/webgl/EdgeFade.js +74 -0
  34. package/dist/src/core/shaders/webgl/EdgeFade.js.map +1 -0
  35. package/dist/src/core/shaders/webgl/LinearGradient.js +31 -51
  36. package/dist/src/core/shaders/webgl/LinearGradient.js.map +1 -1
  37. package/dist/src/core/shaders/webgl/RadialGradient.js +2 -2
  38. package/dist/src/core/shaders/webgl/RadialProgress.js +7 -0
  39. package/dist/src/core/shaders/webgl/RadialProgress.js.map +1 -1
  40. package/dist/src/core/text-rendering/SdfFontHandler.d.ts +1 -0
  41. package/dist/src/core/text-rendering/SdfFontHandler.js +88 -51
  42. package/dist/src/core/text-rendering/SdfFontHandler.js.map +1 -1
  43. package/dist/src/core/textures/ImageTexture.js +2 -1
  44. package/dist/src/core/textures/ImageTexture.js.map +1 -1
  45. package/dist/src/main-api/Renderer.d.ts +20 -0
  46. package/dist/src/main-api/Renderer.js +6 -0
  47. package/dist/src/main-api/Renderer.js.map +1 -1
  48. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  49. package/exports/canvas-shaders.ts +1 -0
  50. package/exports/index.ts +1 -0
  51. package/exports/webgl-shaders.ts +1 -0
  52. package/package.json +1 -1
  53. package/src/core/CoreShaderManager.contextLoss.test.ts +72 -0
  54. package/src/core/CoreShaderManager.ts +15 -2
  55. package/src/core/lib/WebGlContextWrapper.ts +16 -0
  56. package/src/core/lib/validateImageBitmap.test.ts +15 -0
  57. package/src/core/lib/validateImageBitmap.ts +8 -0
  58. package/src/core/platforms/web/WebPlatform.loopError.test.ts +145 -0
  59. package/src/core/platforms/web/WebPlatform.ts +61 -35
  60. package/src/core/renderers/canvas/CanvasRenderer.ts +20 -0
  61. package/src/core/renderers/webgl/WebGlRenderer.contextLoss.test.ts +49 -0
  62. package/src/core/renderers/webgl/WebGlRenderer.ts +13 -1
  63. package/src/core/shaders/canvas/EdgeFade.ts +105 -0
  64. package/src/core/shaders/canvas/LinearGradient.test.ts +45 -0
  65. package/src/core/shaders/canvas/LinearGradient.ts +1 -18
  66. package/src/core/shaders/templates/EdgeFadeTemplate.test.ts +35 -0
  67. package/src/core/shaders/templates/EdgeFadeTemplate.ts +41 -0
  68. package/src/core/shaders/webgl/EdgeFade.ts +84 -0
  69. package/src/core/shaders/webgl/LinearGradient.test.ts +70 -0
  70. package/src/core/shaders/webgl/LinearGradient.ts +36 -51
  71. package/src/core/shaders/webgl/RadialGradient.ts +2 -2
  72. package/src/core/shaders/webgl/RadialProgress.ts +7 -0
  73. package/src/core/text-rendering/SdfFontHandler.ts +77 -30
  74. package/src/core/text-rendering/tests/SdfFontHandler.test.ts +181 -35
  75. package/src/core/textures/ImageTexture.test.ts +91 -0
  76. package/src/core/textures/ImageTexture.ts +4 -1
  77. package/src/main-api/Renderer.ts +28 -0
@@ -64,47 +64,73 @@ export class WebPlatform extends Platform {
64
64
  lastFrameTime = currentTime;
65
65
  }
66
66
 
67
- stage.updateFrameTime();
68
- const hasActiveAnimations = stage.updateAnimations();
69
-
70
- if (!stage.hasSceneUpdates()) {
71
- // We still need to calculate the fps else it looks like the app is frozen
72
- stage.calculateFps();
73
-
74
- // We use 15ms instead of 16.6ms to provide a safety buffer.
75
- // This ensures we wake up slightly before the next frame to check for updates,
76
- // preventing us from missing a frame due to timer variances.
77
- setTimeout(requestLoop, Math.max(targetFrameTime, 15));
78
-
79
- if (isIdle === false) {
80
- // The render burst has settled. Probe for a GPU out-of-memory now
81
- // rather than every frame: GL errors accumulate and persist until
82
- // drained, so a single check here still catches any OOM raised during
83
- // the active frames, without paying the getError() CPU/GPU sync on
84
- // every frame. Queues the `outOfMemory` event, flushed below.
85
- if (stage.renderer.checkForOutOfMemory() === true) {
86
- stage.txMemManager.handleOutOfMemory();
67
+ // From here on the frame runs client-supplied code: synchronous event
68
+ // subscribers (`frameTick`, `idle`, and the queued events drained by
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();
85
+
86
+ // We use 15ms instead of 16.6ms to provide a safety buffer.
87
+ // This ensures we wake up slightly before the next frame to check for updates,
88
+ // preventing us from missing a frame due to timer variances.
89
+ setTimeout(requestLoop, Math.max(targetFrameTime, 15));
90
+ scheduled = true;
91
+
92
+ if (isIdle === false) {
93
+ // The render burst has settled. Probe for a GPU out-of-memory now
94
+ // rather than every frame: GL errors accumulate and persist until
95
+ // drained, so a single check here still catches any OOM raised during
96
+ // the active frames, without paying the getError() CPU/GPU sync on
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
+ }
106
+
107
+ if (stage.txMemManager.checkCleanup() === true) {
108
+ stage.txMemManager.cleanup();
87
109
  }
88
- stage.shManager.cleanup();
89
- stage.cleanupTextRenderers();
90
- stage.eventBus.emit('idle');
91
- isIdle = true;
92
- }
93
110
 
94
- if (stage.txMemManager.checkCleanup() === true) {
95
- stage.txMemManager.cleanup();
111
+ stage.flushFrameEvents();
112
+ return;
96
113
  }
97
114
 
115
+ isIdle = false;
116
+ stage.drawFrame(hasActiveAnimations);
98
117
  stage.flushFrameEvents();
99
- return;
100
- }
101
-
102
- isIdle = false;
103
- stage.drawFrame(hasActiveAnimations);
104
- stage.flushFrameEvents();
105
118
 
106
- // Schedule next frame
107
- requestAnimationFrame(runLoop);
119
+ // Schedule next frame
120
+ requestAnimationFrame(runLoop);
121
+ scheduled = true;
122
+ } 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
+ const handleLoopError = stage.options.handleLoopError;
127
+ if (handleLoopError !== undefined) {
128
+ handleLoopError(error);
129
+ }
130
+ if (scheduled === false) {
131
+ requestAnimationFrame(runLoop);
132
+ }
133
+ }
108
134
  };
109
135
 
110
136
  const requestLoop = () => requestAnimationFrame(runLoop);
@@ -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>,
@@ -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
+ });
@@ -31,24 +31,7 @@ export const LinearGradient: CanvasShaderType<
31
31
  y0: line * Math.sin(angle) + nHeight * 0.5,
32
32
  x1: line * Math.cos(angle + Math.PI) + nWidth * 0.5,
33
33
  y1: line * Math.sin(angle + Math.PI) + nHeight * 0.5,
34
- colors: this.props!.colors.map((value, i, arr) => {
35
- const alpha = (value >>> 24) & 0xff;
36
- if (alpha === 0) {
37
- let nearestRGB = value & 0x00ffffff;
38
- for (let step = 1; step < arr.length; step++) {
39
- if (i - step >= 0 && ((arr[i - step]! >>> 24) & 0xff) > 0) {
40
- nearestRGB = arr[i - step]! & 0x00ffffff;
41
- break;
42
- }
43
- if (i + step < arr.length && ((arr[i + step]! >>> 24) & 0xff) > 0) {
44
- nearestRGB = arr[i + step]! & 0x00ffffff;
45
- break;
46
- }
47
- }
48
- value = (value & 0xff000000) | nearestRGB;
49
- }
50
- return this.toColorString(value);
51
- }),
34
+ colors: this.props!.colors.map((value) => this.toColorString(value)),
52
35
  };
53
36
  },
54
37
  render(ctx, node, renderContext) {
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { EdgeFadeTemplate, type EdgeFadeProps } from './EdgeFadeTemplate.js';
3
+ import { resolveShaderProps } from '../../renderers/CoreShaderNode.js';
4
+
5
+ function resolve(input: Partial<EdgeFadeProps>): EdgeFadeProps {
6
+ const props = { ...input } as Record<string, unknown>;
7
+ resolveShaderProps(props, EdgeFadeTemplate.props as never);
8
+ return props as unknown as EdgeFadeProps;
9
+ }
10
+
11
+ describe('EdgeFadeTemplate', () => {
12
+ it('defaults all edges to 0 (no fade)', () => {
13
+ const props = resolve({});
14
+ expect(props.left).toBe(0);
15
+ expect(props.top).toBe(0);
16
+ expect(props.right).toBe(0);
17
+ expect(props.bottom).toBe(0);
18
+ });
19
+
20
+ it('passes through a single edge, leaving others at 0', () => {
21
+ const props = resolve({ right: 420 });
22
+ expect(props.right).toBe(420);
23
+ expect(props.left).toBe(0);
24
+ expect(props.top).toBe(0);
25
+ expect(props.bottom).toBe(0);
26
+ });
27
+
28
+ it('passes through all edges', () => {
29
+ const props = resolve({ left: 10, top: 20, right: 30, bottom: 40 });
30
+ expect(props.left).toBe(10);
31
+ expect(props.top).toBe(20);
32
+ expect(props.right).toBe(30);
33
+ expect(props.bottom).toBe(40);
34
+ });
35
+ });
@@ -0,0 +1,41 @@
1
+ import type { CoreShaderType } from '../../renderers/CoreShaderNode.js';
2
+
3
+ /**
4
+ * Properties of the {@link EdgeFade} shader
5
+ */
6
+ export interface EdgeFadeProps {
7
+ /**
8
+ * Fade distance in pixels from the left edge. Alpha ramps 0 → 1 over this
9
+ * distance. 0 disables the fade for this edge.
10
+ *
11
+ * @default 0
12
+ */
13
+ left: number;
14
+ /**
15
+ * Fade distance in pixels from the top edge.
16
+ *
17
+ * @default 0
18
+ */
19
+ top: number;
20
+ /**
21
+ * Fade distance in pixels from the right edge.
22
+ *
23
+ * @default 0
24
+ */
25
+ right: number;
26
+ /**
27
+ * Fade distance in pixels from the bottom edge.
28
+ *
29
+ * @default 0
30
+ */
31
+ bottom: number;
32
+ }
33
+
34
+ export const EdgeFadeTemplate: CoreShaderType<EdgeFadeProps> = {
35
+ props: {
36
+ left: 0,
37
+ top: 0,
38
+ right: 0,
39
+ bottom: 0,
40
+ },
41
+ };
@@ -0,0 +1,84 @@
1
+ import type { WebGlShaderType } from '../../renderers/webgl/WebGlShaderNode.js';
2
+ import {
3
+ EdgeFadeTemplate,
4
+ type EdgeFadeProps,
5
+ } from '../templates/EdgeFadeTemplate.js';
6
+
7
+ /**
8
+ * Multiplies the node's alpha by a linear ramp inward from each edge with a
9
+ * non-zero fade distance, revealing whatever is rendered behind the node.
10
+ * Unlike {@link LinearGradient} this masks the texture's own alpha instead of
11
+ * blending a gradient color over its RGB.
12
+ */
13
+ export const EdgeFade: WebGlShaderType<EdgeFadeProps> = {
14
+ props: EdgeFadeTemplate.props,
15
+ update() {
16
+ const props = this.props!;
17
+ // Reciprocals are uploaded so the fragment shader needs no division and
18
+ // no zero-guard branch: 1e6 saturates clamp(px * recip) to 1.0 within a
19
+ // fraction of a pixel, which is exactly "no fade on this edge".
20
+ this.uniform4f(
21
+ 'u_fadeRecip',
22
+ props.left > 0 ? 1 / props.left : 1e6,
23
+ props.top > 0 ? 1 / props.top : 1e6,
24
+ props.right > 0 ? 1 / props.right : 1e6,
25
+ props.bottom > 0 ? 1 / props.bottom : 1e6,
26
+ );
27
+ },
28
+ vertex: `
29
+ # ifdef GL_FRAGMENT_PRECISION_HIGH
30
+ precision highp float;
31
+ # else
32
+ precision mediump float;
33
+ # endif
34
+
35
+ attribute vec2 a_position;
36
+ attribute vec2 a_textureCoords;
37
+ attribute vec4 a_color;
38
+ attribute vec2 a_nodeCoords;
39
+
40
+ uniform vec2 u_resolution;
41
+ uniform float u_pixelRatio;
42
+
43
+ varying vec4 v_color;
44
+ varying vec2 v_textureCoords;
45
+ varying vec2 v_nodeCoords;
46
+
47
+ void main() {
48
+ vec2 normalized = a_position * u_pixelRatio / u_resolution;
49
+ vec2 zero_two = normalized * 2.0;
50
+ vec2 clip_space = zero_two - 1.0;
51
+
52
+ v_color = a_color;
53
+ v_textureCoords = a_textureCoords;
54
+ v_nodeCoords = a_nodeCoords;
55
+
56
+ gl_Position = vec4(clip_space * vec2(1.0, -1.0), 0, 1);
57
+ }
58
+ `,
59
+ fragment: `
60
+ # ifdef GL_FRAGMENT_PRECISION_HIGH
61
+ precision highp float;
62
+ # else
63
+ precision mediump float;
64
+ # endif
65
+
66
+ uniform vec2 u_dimensions;
67
+ uniform sampler2D u_texture;
68
+ uniform vec4 u_fadeRecip;
69
+
70
+ varying vec4 v_color;
71
+ varying vec2 v_textureCoords;
72
+ varying vec2 v_nodeCoords;
73
+
74
+ void main() {
75
+ vec4 color = v_color * texture2D(u_texture, v_textureCoords);
76
+ vec2 px = v_nodeCoords * u_dimensions;
77
+ float fade = clamp(px.x * u_fadeRecip.x, 0.0, 1.0)
78
+ * clamp(px.y * u_fadeRecip.y, 0.0, 1.0)
79
+ * clamp((u_dimensions.x - px.x) * u_fadeRecip.z, 0.0, 1.0)
80
+ * clamp((u_dimensions.y - px.y) * u_fadeRecip.w, 0.0, 1.0);
81
+ gl_FragColor = color * fade;
82
+ }
83
+ `,
84
+ };
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { LinearGradient } from './LinearGradient.js';
3
+
4
+ interface GradUniforms {
5
+ a: [number, number];
6
+ b: number;
7
+ }
8
+
9
+ /**
10
+ * Invoke the WebGL LinearGradient `update()` with a fake shader node that
11
+ * captures the gradient uniforms (`u_grad_a` / `u_grad_b`). The fragment
12
+ * shader computes `dist = dot(v_textureCoords, u_grad_a) + u_grad_b`, so these
13
+ * uniforms fully define the gradient ramp.
14
+ */
15
+ function computeGrad(angle: number, w: number, h: number): GradUniforms {
16
+ const out: GradUniforms = { a: [0, 0], b: 0 };
17
+ const ctx = {
18
+ props: { colors: [0x000000ff, 0xffffffff], stops: [0, 1], angle },
19
+ uniform2f: (name: string, v0: number, v1: number) => {
20
+ if (name === 'u_grad_a') out.a = [v0, v1];
21
+ },
22
+ uniform1f: (name: string, v: number) => {
23
+ if (name === 'u_grad_b') out.b = v;
24
+ },
25
+ uniform1fv: () => undefined,
26
+ uniform4fv: () => undefined,
27
+ };
28
+ LinearGradient.update!.call(ctx as never, { w, h } as never);
29
+ return out;
30
+ }
31
+
32
+ const distAt = (g: GradUniforms, tx: number, ty: number) =>
33
+ tx * g.a[0] + ty * g.a[1] + g.b;
34
+
35
+ describe('webgl LinearGradient gradient uniforms', () => {
36
+ it('angle 0 ramps top -> bottom across the node-local box', () => {
37
+ const g = computeGrad(0, 200, 100);
38
+ expect(g.a[0]).toBeCloseTo(0, 5);
39
+ expect(g.a[1]).toBeCloseTo(1, 5);
40
+ expect(g.b).toBeCloseTo(0, 5);
41
+ // dist runs 0 (top) -> 1 (bottom)
42
+ expect(distAt(g, 0.5, 0)).toBeCloseTo(0, 5);
43
+ expect(distAt(g, 0.5, 1)).toBeCloseTo(1, 5);
44
+ });
45
+
46
+ it('angle 90deg ramps horizontally', () => {
47
+ const g = computeGrad(Math.PI / 2, 200, 100);
48
+ expect(g.a[0]).toBeCloseTo(-1, 5);
49
+ expect(g.a[1]).toBeCloseTo(0, 5);
50
+ expect(distAt(g, 0, 0.5)).toBeCloseTo(1, 5);
51
+ expect(distAt(g, 1, 0.5)).toBeCloseTo(0, 5);
52
+ });
53
+
54
+ it('endpoints stay within [0,1] for an arbitrary angle', () => {
55
+ const g = computeGrad(Math.PI / 4, 640, 360);
56
+ // Box corners must map into the clampable [0,1] ramp range.
57
+ const corners = [
58
+ distAt(g, 0, 0),
59
+ distAt(g, 1, 0),
60
+ distAt(g, 0, 1),
61
+ distAt(g, 1, 1),
62
+ ];
63
+ for (let i = 0; i < corners.length; i++) {
64
+ expect(corners[i]).toBeGreaterThanOrEqual(-1e-6);
65
+ expect(corners[i]).toBeLessThanOrEqual(1 + 1e-6);
66
+ }
67
+ // The gradient axis is symmetric about the box center -> dist = 0.5 there.
68
+ expect(distAt(g, 0.5, 0.5)).toBeCloseTo(0.5, 5);
69
+ });
70
+ });