@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.
- 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/CoreShaderManager.js +16 -2
- package/dist/src/core/CoreShaderManager.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/lib/validateImageBitmap.js +7 -0
- package/dist/src/core/lib/validateImageBitmap.js.map +1 -1
- package/dist/src/core/platforms/web/WebPlatform.js +57 -30
- package/dist/src/core/platforms/web/WebPlatform.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/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/main-api/Renderer.d.ts +20 -0
- package/dist/src/main-api/Renderer.js +6 -0
- package/dist/src/main-api/Renderer.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/CoreShaderManager.contextLoss.test.ts +72 -0
- package/src/core/CoreShaderManager.ts +15 -2
- package/src/core/lib/WebGlContextWrapper.ts +16 -0
- package/src/core/lib/validateImageBitmap.test.ts +15 -0
- package/src/core/lib/validateImageBitmap.ts +8 -0
- package/src/core/platforms/web/WebPlatform.loopError.test.ts +145 -0
- package/src/core/platforms/web/WebPlatform.ts +61 -35
- package/src/core/renderers/canvas/CanvasRenderer.ts +20 -0
- 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/main-api/Renderer.ts +28 -0
|
@@ -64,47 +64,73 @@ export class WebPlatform extends Platform {
|
|
|
64
64
|
lastFrameTime = currentTime;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// the
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|