@solidtv/renderer 1.5.6 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/core/CoreNode.js +2 -2
- package/dist/src/core/CoreNode.js.map +1 -1
- package/dist/src/core/Stage.js +9 -1
- package/dist/src/core/Stage.js.map +1 -1
- package/dist/src/core/lib/WebGlContextWrapper.d.ts +17 -1
- package/dist/src/core/lib/WebGlContextWrapper.js +21 -0
- package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
- package/dist/src/core/lib/colorParser.d.ts +4 -1
- package/dist/src/core/lib/colorParser.js +16 -6
- package/dist/src/core/lib/colorParser.js.map +1 -1
- package/dist/src/core/platforms/web/WebPlatform.js +60 -50
- package/dist/src/core/platforms/web/WebPlatform.js.map +1 -1
- package/dist/src/core/renderers/canvas/CanvasRenderer.d.ts +4 -0
- package/dist/src/core/renderers/canvas/CanvasRenderer.js +28 -13
- package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
- package/dist/src/core/renderers/canvas/CanvasShaderNode.js +1 -0
- package/dist/src/core/renderers/canvas/CanvasShaderNode.js.map +1 -1
- package/dist/src/core/renderers/webgl/SdfRenderOp.js +2 -2
- package/dist/src/core/renderers/webgl/SdfRenderOp.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +43 -0
- package/dist/src/core/renderers/webgl/WebGlRenderer.js +109 -10
- package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +21 -13
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
- package/dist/src/core/renderers/webgl/internal/ShaderUtils.d.ts +8 -3
- package/dist/src/core/renderers/webgl/internal/ShaderUtils.js +12 -11
- package/dist/src/core/renderers/webgl/internal/ShaderUtils.js.map +1 -1
- package/dist/src/core/shaders/canvas/Rounded.js +3 -3
- package/dist/src/core/shaders/canvas/Rounded.js.map +1 -1
- package/dist/src/core/shaders/canvas/RoundedWithShadow.js +3 -3
- package/dist/src/core/shaders/canvas/RoundedWithShadow.js.map +1 -1
- package/dist/src/core/shaders/canvas/utils/render.js +3 -3
- package/dist/src/core/shaders/canvas/utils/render.js.map +1 -1
- package/dist/src/core/shaders/webgl/Border.js +12 -11
- package/dist/src/core/shaders/webgl/Border.js.map +1 -1
- package/dist/src/core/shaders/webgl/HolePunch.js +12 -5
- package/dist/src/core/shaders/webgl/HolePunch.js.map +1 -1
- package/dist/src/core/shaders/webgl/LinearGradient.js +3 -19
- package/dist/src/core/shaders/webgl/LinearGradient.js.map +1 -1
- package/dist/src/core/shaders/webgl/RadialGradient.js +3 -20
- package/dist/src/core/shaders/webgl/RadialGradient.js.map +1 -1
- package/dist/src/core/shaders/webgl/RadialProgress.js +15 -23
- package/dist/src/core/shaders/webgl/RadialProgress.js.map +1 -1
- package/dist/src/core/shaders/webgl/RoundedWithBorder.js +35 -32
- package/dist/src/core/shaders/webgl/RoundedWithBorder.js.map +1 -1
- package/dist/src/core/shaders/webgl/RoundedWithBorderAndShadow.js +58 -45
- package/dist/src/core/shaders/webgl/RoundedWithBorderAndShadow.js.map +1 -1
- package/dist/src/core/shaders/webgl/RoundedWithShadow.js +15 -13
- package/dist/src/core/shaders/webgl/RoundedWithShadow.js.map +1 -1
- package/dist/src/core/shaders/webgl/Shadow.js +4 -5
- package/dist/src/core/shaders/webgl/Shadow.js.map +1 -1
- package/dist/src/core/text-rendering/SdfTextRenderer.js +23 -10
- package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
- package/dist/src/core/text-rendering/TextLayoutEngine.js +14 -2
- package/dist/src/core/text-rendering/TextLayoutEngine.js.map +1 -1
- package/dist/src/utils.js +1 -1
- package/dist/src/utils.js.map +1 -1
- package/dist/tsconfig.dist.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/core/CoreNode.ts +2 -2
- package/src/core/Stage.renderListUpdate.test.ts +73 -0
- package/src/core/Stage.ts +11 -1
- package/src/core/lib/WebGlContextWrapper.test.ts +23 -1
- package/src/core/lib/WebGlContextWrapper.ts +23 -1
- package/src/core/lib/colorParser.test.ts +46 -0
- package/src/core/lib/colorParser.ts +17 -6
- package/src/core/platforms/web/WebPlatform.ts +63 -52
- package/src/core/renderers/canvas/CanvasRenderer.test.ts +230 -0
- package/src/core/renderers/canvas/CanvasRenderer.ts +29 -15
- package/src/core/renderers/canvas/CanvasShaderNode.test.ts +73 -0
- package/src/core/renderers/canvas/CanvasShaderNode.ts +1 -0
- package/src/core/renderers/webgl/SdfRenderOp.ts +2 -2
- package/src/core/renderers/webgl/WebGlRenderer.reuseRenderOp.test.ts +122 -0
- package/src/core/renderers/webgl/WebGlRenderer.sdfBuffer.test.ts +302 -0
- package/src/core/renderers/webgl/WebGlRenderer.ts +151 -11
- package/src/core/renderers/webgl/WebGlShaderProgram.reuseRenderOp.test.ts +150 -0
- package/src/core/renderers/webgl/WebGlShaderProgram.ts +27 -15
- package/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts +4 -2
- package/src/core/renderers/webgl/internal/ShaderUtils.test.ts +124 -0
- package/src/core/renderers/webgl/internal/ShaderUtils.ts +14 -13
- package/src/core/shaders/canvas/Rounded.ts +3 -3
- package/src/core/shaders/canvas/RoundedWithShadow.ts +3 -3
- package/src/core/shaders/canvas/utils/render.ts +3 -3
- package/src/core/shaders/webgl/Border.ts +12 -11
- package/src/core/shaders/webgl/HolePunch.ts +12 -5
- package/src/core/shaders/webgl/LinearGradient.ts +3 -19
- package/src/core/shaders/webgl/RadialGradient.ts +3 -20
- package/src/core/shaders/webgl/RadialProgress.ts +15 -23
- package/src/core/shaders/webgl/RoundedWithBorder.ts +35 -32
- package/src/core/shaders/webgl/RoundedWithBorderAndShadow.ts +58 -45
- package/src/core/shaders/webgl/RoundedWithShadow.ts +15 -13
- package/src/core/shaders/webgl/Shadow.ts +4 -5
- package/src/core/text-rendering/SdfTextRenderer.test.ts +177 -0
- package/src/core/text-rendering/SdfTextRenderer.ts +38 -10
- package/src/core/text-rendering/TextLayoutEngine.ts +15 -2
- package/src/core/text-rendering/tests/TextLayoutEngine.test.ts +99 -0
- package/src/utils.ts +1 -1
|
@@ -127,6 +127,23 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
127
127
|
sdfQuadCount = 0;
|
|
128
128
|
sdfQuadBufferCollection: BufferCollection;
|
|
129
129
|
curSdfRenderOp: SdfRenderOp | null = null;
|
|
130
|
+
/**
|
|
131
|
+
* Whether the shared SDF buffer's bytes may differ from what the GPU
|
|
132
|
+
* currently holds. Set by every write path that produces fresh bytes
|
|
133
|
+
* (cache-miss recompute, translated copy), by anything that can shift
|
|
134
|
+
* offsets or resize the buffer (render-list rebuild, RTT partial upload,
|
|
135
|
+
* backing-store growth), and consumed by {@link uploadSdfBuffer}. The
|
|
136
|
+
* exact cache-hit mem-copy path deliberately does NOT set it — it writes
|
|
137
|
+
* byte-identical data at identical offsets. Conservative direction: when
|
|
138
|
+
* in doubt, set it — a redundant upload is correct, a wrong skip is a
|
|
139
|
+
* glitch.
|
|
140
|
+
*/
|
|
141
|
+
sdfBufferChanged = true;
|
|
142
|
+
/**
|
|
143
|
+
* Float32 length of the last main-pass SDF upload — the size half of the
|
|
144
|
+
* skip test in {@link uploadSdfBuffer}.
|
|
145
|
+
*/
|
|
146
|
+
lastUploadedSdfSize = 0;
|
|
130
147
|
|
|
131
148
|
/**
|
|
132
149
|
* When true, the entire quad buffer is re-uploaded to the GPU via bufferData
|
|
@@ -212,7 +229,7 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
212
229
|
gl,
|
|
213
230
|
options.disableVertexArrayObject,
|
|
214
231
|
));
|
|
215
|
-
glw.viewport(0, 0,
|
|
232
|
+
glw.viewport(0, 0, glw.canvasW, glw.canvasH);
|
|
216
233
|
|
|
217
234
|
this.attachContextLossListeners(options.canvas);
|
|
218
235
|
|
|
@@ -640,9 +657,21 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
640
657
|
return true;
|
|
641
658
|
}
|
|
642
659
|
|
|
643
|
-
//
|
|
660
|
+
// Distinct shader nodes can still batch when they resolve to the same
|
|
661
|
+
// program and the value-key cache handed both the same uniform collection:
|
|
662
|
+
// collections are immutable after fill and shared by reference across
|
|
663
|
+
// equal value keys, so reference equality implies value equality. This is
|
|
664
|
+
// the common TV-rail case — many same-size cards, each app-created with
|
|
665
|
+
// its own shader node but equal props. Program identity is required
|
|
666
|
+
// because the value key does not include the shader type, so equal
|
|
667
|
+
// collections from different programs must not merge.
|
|
644
668
|
if (curShader !== shader) {
|
|
645
|
-
|
|
669
|
+
if (
|
|
670
|
+
curShader.program !== shader.program ||
|
|
671
|
+
curShader.uniforms !== shader.uniforms
|
|
672
|
+
) {
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
646
675
|
}
|
|
647
676
|
|
|
648
677
|
if (
|
|
@@ -729,6 +758,9 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
729
758
|
return;
|
|
730
759
|
}
|
|
731
760
|
|
|
761
|
+
// Full recompute writes fresh bytes — the GPU copy is now stale.
|
|
762
|
+
this.sdfBufferChanged = true;
|
|
763
|
+
|
|
732
764
|
let idx = this.sdfBufferIdx;
|
|
733
765
|
this.ensureSdfBufferCapacity(idx + glyphCount * 24);
|
|
734
766
|
|
|
@@ -891,6 +923,76 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
891
923
|
);
|
|
892
924
|
}
|
|
893
925
|
|
|
926
|
+
/**
|
|
927
|
+
* Append cached SDF vertices translated by (dx, dy) to the shared buffer.
|
|
928
|
+
*
|
|
929
|
+
* @remarks
|
|
930
|
+
* The scroll fast path: a text node whose transform changed by pure
|
|
931
|
+
* translation reuses its world-space vertex cache — one mem-copy plus two
|
|
932
|
+
* adds per vertex instead of full per-glyph matrix math, and the cache
|
|
933
|
+
* keeps its original base so nothing is re-snapshotted per frame.
|
|
934
|
+
*
|
|
935
|
+
* The copy MUST stay a typed-array `set` (bit-exact memcpy): packed ABGR
|
|
936
|
+
* colors live in the same Float32Array and some bit patterns are float32
|
|
937
|
+
* NaNs, which element-wise float reads/writes may canonicalize and corrupt.
|
|
938
|
+
* Only the two position floats of each vertex are touched after the copy.
|
|
939
|
+
*/
|
|
940
|
+
addSdfTranslatedQuads(
|
|
941
|
+
cachedVertices: Float32Array,
|
|
942
|
+
numGlyphs: number,
|
|
943
|
+
dx: number,
|
|
944
|
+
dy: number,
|
|
945
|
+
atlasTexture: WebGlCtxTexture,
|
|
946
|
+
clippingRect: import('../../lib/utils.js').RectWithValid,
|
|
947
|
+
worldAlpha: number,
|
|
948
|
+
width: number,
|
|
949
|
+
height: number,
|
|
950
|
+
parentHasRenderTexture: boolean,
|
|
951
|
+
framebufferDimensions:
|
|
952
|
+
| import('../../../common/CommonTypes.js').Dimensions
|
|
953
|
+
| null,
|
|
954
|
+
sdfShader: WebGlShaderNode,
|
|
955
|
+
): void {
|
|
956
|
+
if (numGlyphs === 0) {
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Translated positions are fresh bytes — the GPU copy is now stale.
|
|
961
|
+
this.sdfBufferChanged = true;
|
|
962
|
+
|
|
963
|
+
const startQuad = this.sdfQuadCount;
|
|
964
|
+
const idx = this.sdfBufferIdx;
|
|
965
|
+
|
|
966
|
+
this.ensureSdfBufferCapacity(idx + cachedVertices.length);
|
|
967
|
+
|
|
968
|
+
// Read the buffer reference only after ensureSdfBufferCapacity — growth
|
|
969
|
+
// swaps the backing store.
|
|
970
|
+
const f = this.fSdfBuffer;
|
|
971
|
+
f.set(cachedVertices, idx);
|
|
972
|
+
|
|
973
|
+
const end = idx + cachedVertices.length;
|
|
974
|
+
for (let i = idx; i < end; i += 6) {
|
|
975
|
+
f[i] = f[i]! + dx;
|
|
976
|
+
f[i + 1] = f[i + 1]! + dy;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
this.sdfBufferIdx = end;
|
|
980
|
+
this.sdfQuadCount += numGlyphs;
|
|
981
|
+
|
|
982
|
+
this.finalizeSdfBatch(
|
|
983
|
+
startQuad,
|
|
984
|
+
numGlyphs,
|
|
985
|
+
atlasTexture,
|
|
986
|
+
clippingRect,
|
|
987
|
+
worldAlpha,
|
|
988
|
+
width,
|
|
989
|
+
height,
|
|
990
|
+
parentHasRenderTexture,
|
|
991
|
+
framebufferDimensions,
|
|
992
|
+
sdfShader,
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
|
|
894
996
|
/**
|
|
895
997
|
* Shared batching logic for SDF render ops.
|
|
896
998
|
* Called by both `addSdfQuads` (full compute) and `addSdfCachedQuads` (fast copy).
|
|
@@ -992,6 +1094,9 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
992
1094
|
this.sdfBuffer = newBuffer;
|
|
993
1095
|
this.fSdfBuffer = newFSdfBuffer;
|
|
994
1096
|
this.uiSdfBuffer = newUiSdfBuffer;
|
|
1097
|
+
|
|
1098
|
+
// New backing store — never skip the next upload.
|
|
1099
|
+
this.sdfBufferChanged = true;
|
|
995
1100
|
}
|
|
996
1101
|
|
|
997
1102
|
/**
|
|
@@ -1068,12 +1173,7 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
1068
1173
|
}
|
|
1069
1174
|
|
|
1070
1175
|
// Upload the shared SDF buffer if any SDF glyphs were written this frame.
|
|
1071
|
-
|
|
1072
|
-
const sdfBuf =
|
|
1073
|
-
this.sdfQuadBufferCollection.getBuffer('a_position') || null;
|
|
1074
|
-
const sdfArr = new Float32Array(this.sdfBuffer, 0, this.sdfBufferIdx);
|
|
1075
|
-
glw.arrayBufferData(sdfBuf, sdfArr, glw.DYNAMIC_DRAW);
|
|
1076
|
-
}
|
|
1176
|
+
this.uploadSdfBuffer();
|
|
1077
1177
|
|
|
1078
1178
|
for (let i = 0, length = this.renderOps.length; i < length; i++) {
|
|
1079
1179
|
this.renderOps[i]!.draw(this);
|
|
@@ -1087,6 +1187,34 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
1087
1187
|
this.numQuadsRendered = this.quadBufferUsage / QUAD_SIZE_IN_BYTES;
|
|
1088
1188
|
}
|
|
1089
1189
|
|
|
1190
|
+
/**
|
|
1191
|
+
* Upload the shared SDF buffer for the main pass, skipping the driver-side
|
|
1192
|
+
* `bufferData` copy when the bytes provably match what the GPU already
|
|
1193
|
+
* holds: every write this frame was an exact cache-hit mem-copy
|
|
1194
|
+
* (`sdfBufferChanged` false) and the total size matches the previous
|
|
1195
|
+
* upload. Exact hits write byte-identical data, and identical offsets are
|
|
1196
|
+
* guaranteed because every source of reorder or resize — cache-miss
|
|
1197
|
+
* recompute, translated copy, render-list rebuild, RTT partial upload,
|
|
1198
|
+
* backing-store growth — sets `sdfBufferChanged`.
|
|
1199
|
+
*/
|
|
1200
|
+
private uploadSdfBuffer(): void {
|
|
1201
|
+
if (this.sdfBufferIdx === 0) {
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
if (
|
|
1205
|
+
this.sdfBufferChanged === false &&
|
|
1206
|
+
this.sdfBufferIdx === this.lastUploadedSdfSize
|
|
1207
|
+
) {
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
const glw = this.glw;
|
|
1211
|
+
const sdfBuf = this.sdfQuadBufferCollection.getBuffer('a_position') || null;
|
|
1212
|
+
const sdfArr = new Float32Array(this.sdfBuffer, 0, this.sdfBufferIdx);
|
|
1213
|
+
glw.arrayBufferData(sdfBuf, sdfArr, glw.DYNAMIC_DRAW);
|
|
1214
|
+
this.lastUploadedSdfSize = this.sdfBufferIdx;
|
|
1215
|
+
this.sdfBufferChanged = false;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1090
1218
|
getQuadCount(): number {
|
|
1091
1219
|
return this.numQuadsRendered;
|
|
1092
1220
|
}
|
|
@@ -1264,7 +1392,7 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
1264
1392
|
// Bind the default framebuffer
|
|
1265
1393
|
glw.bindFramebuffer(null);
|
|
1266
1394
|
|
|
1267
|
-
glw.viewport(0, 0,
|
|
1395
|
+
glw.viewport(0, 0, glw.canvasW, glw.canvasH);
|
|
1268
1396
|
this.renderToTextureActive = false;
|
|
1269
1397
|
}
|
|
1270
1398
|
|
|
@@ -1323,6 +1451,9 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
1323
1451
|
this.sdfQuadBufferCollection.getBuffer('a_position') || null;
|
|
1324
1452
|
const sdfArr = new Float32Array(this.sdfBuffer, 0, this.sdfBufferIdx);
|
|
1325
1453
|
glw.arrayBufferData(sdfBuf, sdfArr, glw.DYNAMIC_DRAW);
|
|
1454
|
+
// This partial upload replaced the GL buffer's contents and size — the
|
|
1455
|
+
// main pass upload can no longer be skipped this frame.
|
|
1456
|
+
this.sdfBufferChanged = true;
|
|
1326
1457
|
}
|
|
1327
1458
|
|
|
1328
1459
|
for (let i = 0, length = this.renderOps.length; i < length; i++) {
|
|
@@ -1331,7 +1462,10 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
1331
1462
|
}
|
|
1332
1463
|
|
|
1333
1464
|
updateViewport(): void {
|
|
1334
|
-
|
|
1465
|
+
// Called after the canvas is resized (Renderer.updateAppDimensions) —
|
|
1466
|
+
// refresh the wrapper's cached dimensions before they're used again.
|
|
1467
|
+
this.glw.updateCanvasDimensions();
|
|
1468
|
+
this.glw.viewport(0, 0, this.glw.canvasW, this.glw.canvasH);
|
|
1335
1469
|
}
|
|
1336
1470
|
|
|
1337
1471
|
removeRTTNode(node: CoreNode) {
|
|
@@ -1486,6 +1620,12 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
1486
1620
|
* next addQuad() pass will reassign compact, contiguous slots starting from 0.
|
|
1487
1621
|
*/
|
|
1488
1622
|
override invalidateQuadBuffer(): void {
|
|
1623
|
+
// A render-list rebuild can reorder text nodes without touching their
|
|
1624
|
+
// SDF vertex caches; byte-identical glyph data would then land at
|
|
1625
|
+
// different offsets, so the upload skip must not fire on the rebuild
|
|
1626
|
+
// frame.
|
|
1627
|
+
this.sdfBufferChanged = true;
|
|
1628
|
+
|
|
1489
1629
|
if (!DIRTY_QUAD_BUFFER) {
|
|
1490
1630
|
return;
|
|
1491
1631
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { WebGlShaderProgram } from './WebGlShaderProgram.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for the batch test in WebGlShaderProgram.reuseRenderOp.
|
|
6
|
+
*
|
|
7
|
+
* The program instance is created without running the constructor (which
|
|
8
|
+
* compiles GLSL against a live GL context); only the fields reuseRenderOp
|
|
9
|
+
* touches are populated manually.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type ProgramFlags = {
|
|
13
|
+
useSystemAlpha?: boolean;
|
|
14
|
+
useSystemDimensions?: boolean;
|
|
15
|
+
useTimeValue?: boolean;
|
|
16
|
+
canBatch?: (node: unknown, op: unknown) => boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const makeProgram = (flags: ProgramFlags = {}): WebGlShaderProgram => {
|
|
20
|
+
const program = Object.create(
|
|
21
|
+
WebGlShaderProgram.prototype,
|
|
22
|
+
) as WebGlShaderProgram;
|
|
23
|
+
const p = program as unknown as Record<string, unknown>;
|
|
24
|
+
p['useSystemAlpha'] = flags.useSystemAlpha === true;
|
|
25
|
+
p['useSystemDimensions'] = flags.useSystemDimensions === true;
|
|
26
|
+
p['useTimeValue'] = flags.useTimeValue === true;
|
|
27
|
+
p['lifecycle'] = { canBatch: flags.canBatch, update: undefined };
|
|
28
|
+
return program;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type FakeShaderNode = {
|
|
32
|
+
uniforms: object;
|
|
33
|
+
resolvedProps: Record<string, unknown> | undefined;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const makeShaderNode = (
|
|
37
|
+
uniforms: object,
|
|
38
|
+
resolvedProps?: Record<string, unknown>,
|
|
39
|
+
): FakeShaderNode => ({ uniforms, resolvedProps });
|
|
40
|
+
|
|
41
|
+
const makeNode = (
|
|
42
|
+
shader: FakeShaderNode,
|
|
43
|
+
overrides?: Record<string, unknown>,
|
|
44
|
+
) => ({
|
|
45
|
+
worldAlpha: 1,
|
|
46
|
+
w: 200,
|
|
47
|
+
h: 100,
|
|
48
|
+
props: { shader },
|
|
49
|
+
...overrides,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const makeOp = (shader: FakeShaderNode, overrides?: Record<string, unknown>) =>
|
|
53
|
+
({
|
|
54
|
+
time: 0,
|
|
55
|
+
worldAlpha: 1,
|
|
56
|
+
w: 200,
|
|
57
|
+
h: 100,
|
|
58
|
+
shader,
|
|
59
|
+
...overrides,
|
|
60
|
+
} as never);
|
|
61
|
+
|
|
62
|
+
describe('WebGlShaderProgram.reuseRenderOp', () => {
|
|
63
|
+
it('batches the same shader node without comparing props', () => {
|
|
64
|
+
const program = makeProgram();
|
|
65
|
+
const shader = makeShaderNode({}, { radius: 16 });
|
|
66
|
+
|
|
67
|
+
expect(
|
|
68
|
+
program.reuseRenderOp(makeNode(shader) as never, makeOp(shader)),
|
|
69
|
+
).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('batches distinct shader nodes that share a uniform collection', () => {
|
|
73
|
+
const program = makeProgram();
|
|
74
|
+
const uniforms = {};
|
|
75
|
+
// Distinct resolvedProps objects — reference-inequal props must not matter
|
|
76
|
+
// when the value-key cache handed both nodes the same collection.
|
|
77
|
+
const shaderA = makeShaderNode(uniforms, { radius: [16, 16, 16, 16] });
|
|
78
|
+
const shaderB = makeShaderNode(uniforms, { radius: [16, 16, 16, 16] });
|
|
79
|
+
|
|
80
|
+
expect(
|
|
81
|
+
program.reuseRenderOp(makeNode(shaderA) as never, makeOp(shaderB)),
|
|
82
|
+
).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('falls back to a prop-value compare for distinct collections', () => {
|
|
86
|
+
const program = makeProgram();
|
|
87
|
+
const shaderA = makeShaderNode({}, { radius: 16, gap: 2 });
|
|
88
|
+
const shaderB = makeShaderNode({}, { radius: 16, gap: 2 });
|
|
89
|
+
const shaderC = makeShaderNode({}, { radius: 16, gap: 4 });
|
|
90
|
+
|
|
91
|
+
expect(
|
|
92
|
+
program.reuseRenderOp(makeNode(shaderA) as never, makeOp(shaderB)),
|
|
93
|
+
).toBe(true);
|
|
94
|
+
expect(
|
|
95
|
+
program.reuseRenderOp(makeNode(shaderA) as never, makeOp(shaderC)),
|
|
96
|
+
).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('rejects on system alpha mismatch before any shader compare', () => {
|
|
100
|
+
const program = makeProgram({ useSystemAlpha: true });
|
|
101
|
+
const shader = makeShaderNode({});
|
|
102
|
+
|
|
103
|
+
expect(
|
|
104
|
+
program.reuseRenderOp(
|
|
105
|
+
makeNode(shader, { worldAlpha: 0.5 }) as never,
|
|
106
|
+
makeOp(shader),
|
|
107
|
+
),
|
|
108
|
+
).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('rejects on dimension mismatch when the program uses u_dimensions', () => {
|
|
112
|
+
const program = makeProgram({ useSystemDimensions: true });
|
|
113
|
+
const shader = makeShaderNode({});
|
|
114
|
+
|
|
115
|
+
expect(
|
|
116
|
+
program.reuseRenderOp(
|
|
117
|
+
makeNode(shader, { w: 300 }) as never,
|
|
118
|
+
makeOp(shader),
|
|
119
|
+
),
|
|
120
|
+
).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('does not read node.time when the program has no time uniform', () => {
|
|
124
|
+
const program = makeProgram();
|
|
125
|
+
const shader = makeShaderNode({});
|
|
126
|
+
const node = makeNode(shader);
|
|
127
|
+
let timeReads = 0;
|
|
128
|
+
Object.defineProperty(node, 'time', {
|
|
129
|
+
get: () => {
|
|
130
|
+
timeReads++;
|
|
131
|
+
return 0;
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
program.reuseRenderOp(node as never, makeOp(shader));
|
|
136
|
+
|
|
137
|
+
expect(timeReads).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('delegates to the shader type canBatch hook when defined', () => {
|
|
141
|
+
const canBatch = vi.fn(() => false);
|
|
142
|
+
const program = makeProgram({ canBatch });
|
|
143
|
+
const shader = makeShaderNode({});
|
|
144
|
+
|
|
145
|
+
expect(
|
|
146
|
+
program.reuseRenderOp(makeNode(shader) as never, makeOp(shader)),
|
|
147
|
+
).toBe(false);
|
|
148
|
+
expect(canBatch.mock.calls.length).toBe(1);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -158,40 +158,52 @@ export class WebGlShaderProgram implements CoreShaderProgram {
|
|
|
158
158
|
return this.lifecycle.canBatch(node, currentRenderOp);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
// Read node getters only for the system uniforms this program actually
|
|
162
|
+
// uses — node.time in particular is a getter that runs per call.
|
|
163
163
|
if (this.useTimeValue === true) {
|
|
164
|
-
if (time !== currentRenderOp.time) {
|
|
164
|
+
if (node.time !== currentRenderOp.time) {
|
|
165
165
|
return false;
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
if (this.useSystemAlpha === true) {
|
|
170
|
-
if (worldAlpha !== currentRenderOp.worldAlpha) {
|
|
170
|
+
if (node.worldAlpha !== currentRenderOp.worldAlpha) {
|
|
171
171
|
return false;
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
if (this.useSystemDimensions === true) {
|
|
176
|
-
if (w !== currentRenderOp.w || h !== currentRenderOp.h) {
|
|
176
|
+
if (node.w !== currentRenderOp.w || node.h !== currentRenderOp.h) {
|
|
177
177
|
return false;
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
|
|
182
|
-
|
|
181
|
+
const shader = node.props.shader as WebGlShaderNode | null;
|
|
182
|
+
const opShader = currentRenderOp.shader as WebGlShaderNode | null;
|
|
183
183
|
|
|
184
|
-
|
|
184
|
+
// Same shader node — same resolved props by definition.
|
|
185
|
+
if (shader === opShader) {
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
185
188
|
|
|
186
|
-
if (shader
|
|
187
|
-
|
|
189
|
+
if (shader === null || opShader === null) {
|
|
190
|
+
return false;
|
|
188
191
|
}
|
|
189
192
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
+
// Shader nodes with equal value keys share their uniform collection by
|
|
194
|
+
// reference (see WebGlShaderNode.update); reference equality implies the
|
|
195
|
+
// resolved prop values match without a key-by-key compare.
|
|
196
|
+
if (shader.uniforms === opShader.uniforms) {
|
|
197
|
+
return true;
|
|
193
198
|
}
|
|
194
199
|
|
|
200
|
+
const shaderPropsA = shader.resolvedProps as
|
|
201
|
+
| Record<string, unknown>
|
|
202
|
+
| undefined;
|
|
203
|
+
const shaderPropsB = opShader.resolvedProps as
|
|
204
|
+
| Record<string, unknown>
|
|
205
|
+
| undefined;
|
|
206
|
+
|
|
195
207
|
if (
|
|
196
208
|
(shaderPropsA === undefined && shaderPropsB !== undefined) ||
|
|
197
209
|
(shaderPropsA !== undefined && shaderPropsB === undefined)
|
|
@@ -243,8 +255,8 @@ export class WebGlShaderProgram implements CoreShaderProgram {
|
|
|
243
255
|
resolutionH = framebufferDimensions.h;
|
|
244
256
|
} else {
|
|
245
257
|
pixelRatio = renderOp.stage.pixelRatio;
|
|
246
|
-
resolutionW = this.glw.
|
|
247
|
-
resolutionH = this.glw.
|
|
258
|
+
resolutionW = this.glw.canvasW;
|
|
259
|
+
resolutionH = this.glw.canvasH;
|
|
248
260
|
}
|
|
249
261
|
|
|
250
262
|
if (pixelRatio !== this.lastPixelRatio) {
|
|
@@ -13,7 +13,8 @@ type FakeGlw = {
|
|
|
13
13
|
uniform1f: ReturnType<typeof vi.fn>;
|
|
14
14
|
uniform2f: ReturnType<typeof vi.fn>;
|
|
15
15
|
uniform4f: ReturnType<typeof vi.fn>;
|
|
16
|
-
|
|
16
|
+
canvasW: number;
|
|
17
|
+
canvasH: number;
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
const makeProgram = (): { program: WebGlShaderProgram; glw: FakeGlw } => {
|
|
@@ -21,7 +22,8 @@ const makeProgram = (): { program: WebGlShaderProgram; glw: FakeGlw } => {
|
|
|
21
22
|
uniform1f: vi.fn(),
|
|
22
23
|
uniform2f: vi.fn(),
|
|
23
24
|
uniform4f: vi.fn(),
|
|
24
|
-
|
|
25
|
+
canvasW: 1920,
|
|
26
|
+
canvasH: 1080,
|
|
25
27
|
};
|
|
26
28
|
|
|
27
29
|
const program = Object.create(
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { genGradientColors } from './ShaderUtils.js';
|
|
3
|
+
|
|
4
|
+
type Vec4 = [number, number, number, number];
|
|
5
|
+
|
|
6
|
+
const smoothstep = (e0: number, e1: number, x: number): number => {
|
|
7
|
+
const t = Math.min(Math.max((x - e0) / (e1 - e0), 0), 1);
|
|
8
|
+
return t * t * (3 - 2 * t);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const mix = (a: Vec4, b: Vec4, t: number): Vec4 => [
|
|
12
|
+
a[0] + (b[0] - a[0]) * t,
|
|
13
|
+
a[1] + (b[1] - a[1]) * t,
|
|
14
|
+
a[2] + (b[2] - a[2]) * t,
|
|
15
|
+
a[3] + (b[3] - a[3]) * t,
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Evaluate the generated GLSL statements as JS. The generated code only uses
|
|
20
|
+
* `mix`, `smoothstep`, `u_colors[i]`, `u_stops[i]` and `dist`, so shimming
|
|
21
|
+
* those makes the string directly executable.
|
|
22
|
+
*/
|
|
23
|
+
function evalGradient(stops: number[], colors: Vec4[], dist: number): Vec4 {
|
|
24
|
+
const src = genGradientColors(stops.length).replace(
|
|
25
|
+
'vec4 colorOut =',
|
|
26
|
+
'let colorOut =',
|
|
27
|
+
);
|
|
28
|
+
const fn = new Function(
|
|
29
|
+
'mix',
|
|
30
|
+
'smoothstep',
|
|
31
|
+
'u_stops',
|
|
32
|
+
'u_colors',
|
|
33
|
+
'dist',
|
|
34
|
+
`${src}; return colorOut;`,
|
|
35
|
+
);
|
|
36
|
+
return fn(mix, smoothstep, stops, colors, dist) as Vec4;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Reference implementation: the original branchy segment select
|
|
41
|
+
* (below first stop / above last stop / smoothstep within the segment).
|
|
42
|
+
*/
|
|
43
|
+
function referenceGradient(
|
|
44
|
+
stops: number[],
|
|
45
|
+
colors: Vec4[],
|
|
46
|
+
dist: number,
|
|
47
|
+
): Vec4 {
|
|
48
|
+
dist = Math.min(Math.max(dist, 0), 1);
|
|
49
|
+
if (dist <= stops[0]!) return colors[0]!;
|
|
50
|
+
const last = stops.length - 1;
|
|
51
|
+
if (dist >= stops[last]!) return colors[last]!;
|
|
52
|
+
for (let i = 0; i < last; i++) {
|
|
53
|
+
if (dist >= stops[i]! && dist <= stops[i + 1]!) {
|
|
54
|
+
return mix(
|
|
55
|
+
colors[i]!,
|
|
56
|
+
colors[i + 1]!,
|
|
57
|
+
smoothstep(stops[i]!, stops[i + 1]!, dist),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return colors[last]!;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const RED: Vec4 = [1, 0, 0, 1];
|
|
65
|
+
const GREEN: Vec4 = [0, 1, 0, 1];
|
|
66
|
+
const BLUE: Vec4 = [0, 0, 1, 0.5];
|
|
67
|
+
const WHITE: Vec4 = [1, 1, 1, 1];
|
|
68
|
+
|
|
69
|
+
describe('genGradientColors', () => {
|
|
70
|
+
it('emits no branches (no if / ternary / return)', () => {
|
|
71
|
+
const src = genGradientColors(4);
|
|
72
|
+
expect(src.includes('if')).toBe(false);
|
|
73
|
+
expect(src.includes('?')).toBe(false);
|
|
74
|
+
expect(src.includes('return')).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('with a single stop resolves to the first color', () => {
|
|
78
|
+
const out = evalGradient([0], [RED], 0.7);
|
|
79
|
+
expect(out).toEqual(RED);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('clamps to the first color below the first stop', () => {
|
|
83
|
+
const out = evalGradient([0.25, 0.75], [RED, GREEN], 0.1);
|
|
84
|
+
expect(out).toEqual(RED);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('clamps to the last color above the last stop', () => {
|
|
88
|
+
const out = evalGradient([0.25, 0.75], [RED, GREEN], 0.9);
|
|
89
|
+
expect(out).toEqual(GREEN);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('interpolates with smoothstep inside a segment', () => {
|
|
93
|
+
const dist = 0.5;
|
|
94
|
+
const out = evalGradient([0.25, 0.75], [RED, GREEN], dist);
|
|
95
|
+
const expected = mix(RED, GREEN, smoothstep(0.25, 0.75, dist));
|
|
96
|
+
for (let i = 0; i < 4; i++) {
|
|
97
|
+
expect(out[i]).toBeCloseTo(expected[i]!, 6);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('matches the reference segment select across a multi-stop ramp', () => {
|
|
102
|
+
const stops = [0, 0.3, 0.6, 1];
|
|
103
|
+
const colors = [RED, GREEN, BLUE, WHITE];
|
|
104
|
+
for (let d = 0; d <= 100; d++) {
|
|
105
|
+
const dist = d / 100;
|
|
106
|
+
const out = evalGradient(stops, colors, dist);
|
|
107
|
+
const ref = referenceGradient(stops, colors, dist);
|
|
108
|
+
for (let i = 0; i < 4; i++) {
|
|
109
|
+
expect(out[i]).toBeCloseTo(ref[i]!, 6);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('is exact at stop boundaries', () => {
|
|
115
|
+
const stops = [0, 0.5, 1];
|
|
116
|
+
const colors = [RED, GREEN, BLUE];
|
|
117
|
+
expect(evalGradient(stops, colors, 0)).toEqual(RED);
|
|
118
|
+
const mid = evalGradient(stops, colors, 0.5);
|
|
119
|
+
for (let i = 0; i < 4; i++) {
|
|
120
|
+
expect(mid[i]).toBeCloseTo(GREEN[i]!, 6);
|
|
121
|
+
}
|
|
122
|
+
expect(evalGradient(stops, colors, 1)).toEqual(BLUE);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -243,21 +243,22 @@ export const DefaultVertexSource = `
|
|
|
243
243
|
`;
|
|
244
244
|
|
|
245
245
|
/**
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
246
|
+
* Generates branchless gradient-stop evaluation statements for a fragment
|
|
247
|
+
* shader. Expects a `float dist` in scope and `u_stops`/`u_colors` uniform
|
|
248
|
+
* arrays of length `stops`; leaves the result in `vec4 colorOut`.
|
|
249
|
+
*
|
|
250
|
+
* The accumulated `mix()` chain is exactly equivalent to selecting the
|
|
251
|
+
* segment containing `dist` (for ascending stops): below a segment the
|
|
252
|
+
* smoothstep is 0 (no-op), above it is 1 (fully replaced by the next color).
|
|
253
|
+
* No `if`/ternary — Mali 400-class fragment pipelines serialize any branch.
|
|
249
254
|
*/
|
|
250
255
|
export function genGradientColors(stops: number): string {
|
|
251
|
-
let result = `
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
result += `colorOut = mix(colorOut, u_colors[${i}], clamp((dist - u_stops[${
|
|
258
|
-
i - 1
|
|
259
|
-
}]) / (u_stops[${i}] - u_stops[${i - 1}]), 0.0, 1.0));`;
|
|
260
|
-
}
|
|
256
|
+
let result = `vec4 colorOut = u_colors[0];`;
|
|
257
|
+
for (let i = 1; i < stops; i++) {
|
|
258
|
+
result += `
|
|
259
|
+
colorOut = mix(colorOut, u_colors[${i}], smoothstep(u_stops[${
|
|
260
|
+
i - 1
|
|
261
|
+
}], u_stops[${i}], dist));`;
|
|
261
262
|
}
|
|
262
263
|
return result;
|
|
263
264
|
}
|
|
@@ -22,16 +22,16 @@ export const Rounded: CanvasShaderType<RoundedProps, ComputedRoundedValues> = {
|
|
|
22
22
|
);
|
|
23
23
|
},
|
|
24
24
|
render(ctx, node, renderContext) {
|
|
25
|
-
|
|
25
|
+
ctx.beginPath();
|
|
26
26
|
roundRect(
|
|
27
|
-
|
|
27
|
+
ctx,
|
|
28
28
|
node.globalTransform!.tx,
|
|
29
29
|
node.globalTransform!.ty,
|
|
30
30
|
node.props.w,
|
|
31
31
|
node.props.h,
|
|
32
32
|
this.computed.radius!,
|
|
33
33
|
);
|
|
34
|
-
ctx.clip(
|
|
34
|
+
ctx.clip();
|
|
35
35
|
|
|
36
36
|
renderContext();
|
|
37
37
|
},
|
|
@@ -49,9 +49,9 @@ export const RoundedWithShadow: CanvasShaderType<
|
|
|
49
49
|
);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
render.roundRect(
|
|
54
|
-
ctx.clip(
|
|
52
|
+
ctx.beginPath();
|
|
53
|
+
render.roundRect(ctx, tx, ty, w, h, computed.radius);
|
|
54
|
+
ctx.clip();
|
|
55
55
|
renderContext();
|
|
56
56
|
},
|
|
57
57
|
};
|