@solidtv/renderer 1.5.0-1 → 1.5.1

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 (67) hide show
  1. package/dist/src/common/EventEmitter.d.ts +8 -0
  2. package/dist/src/common/EventEmitter.js +20 -0
  3. package/dist/src/common/EventEmitter.js.map +1 -1
  4. package/dist/src/core/Autosizer.js +3 -1
  5. package/dist/src/core/Autosizer.js.map +1 -1
  6. package/dist/src/core/CoreNode.d.ts +39 -0
  7. package/dist/src/core/CoreNode.js +105 -25
  8. package/dist/src/core/CoreNode.js.map +1 -1
  9. package/dist/src/core/CoreTextNode.js +4 -2
  10. package/dist/src/core/CoreTextNode.js.map +1 -1
  11. package/dist/src/core/Stage.js +4 -1
  12. package/dist/src/core/Stage.js.map +1 -1
  13. package/dist/src/core/TextureMemoryManager.d.ts +62 -2
  14. package/dist/src/core/TextureMemoryManager.js +90 -4
  15. package/dist/src/core/TextureMemoryManager.js.map +1 -1
  16. package/dist/src/core/lib/WebGlContextWrapper.d.ts +1 -1
  17. package/dist/src/core/lib/WebGlContextWrapper.js +10 -3
  18. package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
  19. package/dist/src/core/lib/textureCompression.js +68 -4
  20. package/dist/src/core/lib/textureCompression.js.map +1 -1
  21. package/dist/src/core/renderers/CoreRenderer.d.ts +1 -0
  22. package/dist/src/core/renderers/CoreRenderer.js.map +1 -1
  23. package/dist/src/core/renderers/canvas/CanvasRenderer.js +5 -1
  24. package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
  25. package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +8 -0
  26. package/dist/src/core/renderers/webgl/WebGlRenderer.js +54 -14
  27. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  28. package/dist/src/core/renderers/webgl/WebGlShaderProgram.d.ts +28 -0
  29. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +70 -10
  30. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
  31. package/dist/src/core/renderers/webgl/internal/RendererUtils.js +9 -2
  32. package/dist/src/core/renderers/webgl/internal/RendererUtils.js.map +1 -1
  33. package/dist/src/core/text-rendering/SdfTextRenderer.js +16 -3
  34. package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
  35. package/dist/src/main-api/Renderer.d.ts +16 -0
  36. package/dist/src/main-api/Renderer.js +2 -0
  37. package/dist/src/main-api/Renderer.js.map +1 -1
  38. package/dist/src/utils.js +23 -7
  39. package/dist/src/utils.js.map +1 -1
  40. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  41. package/dist/tsconfig.tsbuildinfo +1 -1
  42. package/package.json +1 -1
  43. package/src/common/EventEmitter.ts +21 -0
  44. package/src/core/Autosizer.ts +3 -1
  45. package/src/core/CoreNode.test.ts +283 -0
  46. package/src/core/CoreNode.ts +131 -26
  47. package/src/core/CoreTextNode.test.ts +1 -0
  48. package/src/core/CoreTextNode.ts +6 -2
  49. package/src/core/Stage.ts +4 -0
  50. package/src/core/TextureMemoryManager.test.ts +323 -7
  51. package/src/core/TextureMemoryManager.ts +101 -5
  52. package/src/core/lib/WebGlContextWrapper.test.ts +58 -0
  53. package/src/core/lib/WebGlContextWrapper.ts +13 -3
  54. package/src/core/lib/textureCompression.test.ts +158 -0
  55. package/src/core/lib/textureCompression.ts +78 -4
  56. package/src/core/renderers/CoreRenderer.ts +1 -0
  57. package/src/core/renderers/canvas/CanvasRenderer.ts +7 -1
  58. package/src/core/renderers/webgl/WebGlRenderer.ts +64 -16
  59. package/src/core/renderers/webgl/WebGlShaderProgram.ts +78 -15
  60. package/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts +233 -0
  61. package/src/core/renderers/webgl/internal/RendererUtils.test.ts +73 -0
  62. package/src/core/renderers/webgl/internal/RendererUtils.ts +9 -2
  63. package/src/core/text-rendering/SdfTextRenderer.test.ts +11 -10
  64. package/src/core/text-rendering/SdfTextRenderer.ts +17 -3
  65. package/src/main-api/Renderer.ts +19 -0
  66. package/src/utils.test.ts +57 -0
  67. package/src/utils.ts +24 -8
@@ -0,0 +1,158 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { uploadCompressedTexture } from './textureCompression.js';
3
+ import type { WebGlContextWrapper } from './WebGlContextWrapper.js';
4
+ import type { CompressedData } from '../textures/Texture.js';
5
+
6
+ /**
7
+ * A compressed format enum is only valid in compressedTexImage2D after its
8
+ * owning extension has been enabled via getExtension; otherwise the driver
9
+ * rejects it with GL_INVALID_ENUM (1280). These tests pin that every upload
10
+ * path enables its extension *before* uploading, and fails loudly when the
11
+ * device exposes none of the candidates.
12
+ */
13
+
14
+ interface MockGlw {
15
+ glw: WebGlContextWrapper;
16
+ order: string[];
17
+ getExtension: ReturnType<typeof vi.fn>;
18
+ compressedTexImage2D: ReturnType<typeof vi.fn>;
19
+ }
20
+
21
+ function makeGlw(supported: Set<string>): MockGlw {
22
+ const order: string[] = [];
23
+ const getExtension = vi.fn((name: string) => {
24
+ order.push(`getExtension:${name}`);
25
+ return supported.has(name) === true ? {} : null;
26
+ });
27
+ const compressedTexImage2D = vi.fn(() => {
28
+ order.push('compressedTexImage2D');
29
+ });
30
+ const glw = {
31
+ getExtension,
32
+ compressedTexImage2D,
33
+ bindTexture: vi.fn(),
34
+ texParameteri: vi.fn(),
35
+ TEXTURE_WRAP_S: 0,
36
+ TEXTURE_WRAP_T: 0,
37
+ TEXTURE_MAG_FILTER: 0,
38
+ TEXTURE_MIN_FILTER: 0,
39
+ CLAMP_TO_EDGE: 0,
40
+ LINEAR: 0,
41
+ LINEAR_MIPMAP_LINEAR: 0,
42
+ } as unknown as WebGlContextWrapper;
43
+ return { glw, order, getExtension, compressedTexImage2D };
44
+ }
45
+
46
+ function makeData(
47
+ type: 'ktx' | 'pvr' | 'astc',
48
+ glInternalFormat: number,
49
+ ): CompressedData {
50
+ return {
51
+ type,
52
+ glInternalFormat,
53
+ w: 4,
54
+ h: 4,
55
+ mipmaps: [new ArrayBuffer(16)],
56
+ blockInfo: { width: 4, height: 4, bytes: 16 },
57
+ };
58
+ }
59
+
60
+ const texture = {} as WebGLTexture;
61
+
62
+ // COMPRESSED_RGBA_S3TC_DXT5_EXT
63
+ const S3TC_DXT5 = 0x83f3;
64
+ // COMPRESSED_RGB_PVRTC_4BPPV1_IMG
65
+ const PVRTC_4BPP = 0x8c00;
66
+ // COMPRESSED_RGB_ETC1_WEBGL
67
+ const ETC1 = 0x8d64;
68
+ // COMPRESSED_RGBA_ASTC_4x4_KHR
69
+ const ASTC_4x4 = 0x93b0;
70
+
71
+ describe('compressed texture extension guards', () => {
72
+ it('KTX enables the s3tc extension before uploading', () => {
73
+ const m = makeGlw(new Set(['WEBGL_compressed_texture_s3tc']));
74
+ uploadCompressedTexture.ktx!(m.glw, texture, makeData('ktx', S3TC_DXT5));
75
+
76
+ expect(m.getExtension).toHaveBeenCalledWith(
77
+ 'WEBGL_compressed_texture_s3tc',
78
+ );
79
+ expect(m.compressedTexImage2D).toHaveBeenCalled();
80
+ // getExtension must precede the first compressedTexImage2D call
81
+ expect(m.order.indexOf('getExtension:WEBGL_compressed_texture_s3tc')).toBe(
82
+ 0,
83
+ );
84
+ expect(
85
+ m.order.indexOf('getExtension:WEBGL_compressed_texture_s3tc') <
86
+ m.order.indexOf('compressedTexImage2D'),
87
+ ).toBe(true);
88
+ });
89
+
90
+ it('KTX throws (no silent 1280) when the s3tc extension is unavailable', () => {
91
+ const m = makeGlw(new Set());
92
+ expect(() =>
93
+ uploadCompressedTexture.ktx!(m.glw, texture, makeData('ktx', S3TC_DXT5)),
94
+ ).toThrow(/not supported/);
95
+ expect(m.compressedTexImage2D).not.toHaveBeenCalled();
96
+ });
97
+
98
+ it('KTX enables the etc1 extension for ETC1 formats', () => {
99
+ const m = makeGlw(new Set(['WEBGL_compressed_texture_etc1']));
100
+ uploadCompressedTexture.ktx!(m.glw, texture, makeData('ktx', ETC1));
101
+ expect(m.getExtension).toHaveBeenCalledWith(
102
+ 'WEBGL_compressed_texture_etc1',
103
+ );
104
+ expect(m.compressedTexImage2D).toHaveBeenCalled();
105
+ });
106
+
107
+ it('PVR enables the pvrtc extension before uploading', () => {
108
+ const m = makeGlw(new Set(['WEBGL_compressed_texture_pvrtc']));
109
+ uploadCompressedTexture.pvr!(m.glw, texture, makeData('pvr', PVRTC_4BPP));
110
+ expect(m.getExtension).toHaveBeenCalledWith(
111
+ 'WEBGL_compressed_texture_pvrtc',
112
+ );
113
+ expect(
114
+ m.order.indexOf('getExtension:WEBGL_compressed_texture_pvrtc') <
115
+ m.order.indexOf('compressedTexImage2D'),
116
+ ).toBe(true);
117
+ });
118
+
119
+ it('PVR falls back to the WebKit-prefixed pvrtc extension', () => {
120
+ const m = makeGlw(new Set(['WEBKIT_WEBGL_compressed_texture_pvrtc']));
121
+ uploadCompressedTexture.pvr!(m.glw, texture, makeData('pvr', PVRTC_4BPP));
122
+ expect(m.getExtension).toHaveBeenCalledWith(
123
+ 'WEBGL_compressed_texture_pvrtc',
124
+ );
125
+ expect(m.getExtension).toHaveBeenCalledWith(
126
+ 'WEBKIT_WEBGL_compressed_texture_pvrtc',
127
+ );
128
+ expect(m.compressedTexImage2D).toHaveBeenCalled();
129
+ });
130
+
131
+ it('PVR throws when neither pvrtc extension is available', () => {
132
+ const m = makeGlw(new Set());
133
+ expect(() =>
134
+ uploadCompressedTexture.pvr!(m.glw, texture, makeData('pvr', PVRTC_4BPP)),
135
+ ).toThrow(/not supported/);
136
+ expect(m.compressedTexImage2D).not.toHaveBeenCalled();
137
+ });
138
+
139
+ it('ASTC enables the astc extension before uploading', () => {
140
+ const m = makeGlw(new Set(['WEBGL_compressed_texture_astc']));
141
+ uploadCompressedTexture.astc!(m.glw, texture, makeData('astc', ASTC_4x4));
142
+ expect(m.getExtension).toHaveBeenCalledWith(
143
+ 'WEBGL_compressed_texture_astc',
144
+ );
145
+ expect(
146
+ m.order.indexOf('getExtension:WEBGL_compressed_texture_astc') <
147
+ m.order.indexOf('compressedTexImage2D'),
148
+ ).toBe(true);
149
+ });
150
+
151
+ it('ASTC throws when the astc extension is unavailable', () => {
152
+ const m = makeGlw(new Set());
153
+ expect(() =>
154
+ uploadCompressedTexture.astc!(m.glw, texture, makeData('astc', ASTC_4x4)),
155
+ ).toThrow(/not supported/);
156
+ expect(m.compressedTexImage2D).not.toHaveBeenCalled();
157
+ });
158
+ });
@@ -171,18 +171,90 @@ const loadASTC = async function (view: DataView): Promise<TextureData> {
171
171
  };
172
172
  };
173
173
 
174
+ // Candidate extension name lists, hoisted to module constants so the per-upload
175
+ // resolver returns a shared reference instead of allocating a new array each
176
+ // call (zero GC pressure on the texture-upload path).
177
+ const EXT_ASTC = ['WEBGL_compressed_texture_astc'];
178
+ const EXT_S3TC = ['WEBGL_compressed_texture_s3tc'];
179
+ const EXT_ETC1 = ['WEBGL_compressed_texture_etc1'];
180
+ const EXT_ETC = ['WEBGL_compressed_texture_etc'];
181
+ // WebKit-prefixed name is the legacy fallback.
182
+ const EXT_PVRTC = [
183
+ 'WEBGL_compressed_texture_pvrtc',
184
+ 'WEBKIT_WEBGL_compressed_texture_pvrtc',
185
+ ];
186
+ const EXT_NONE: string[] = [];
187
+
188
+ /**
189
+ * Resolve the WebGL extension(s) that must be enabled before a given compressed
190
+ * GL internal format may be used.
191
+ *
192
+ * @remarks
193
+ * `getExtension` is the call that actually enables a compressed format on a
194
+ * context — until it is called, the format enum is rejected by
195
+ * `compressedTexImage2D` with `GL_INVALID_ENUM` (1280). Listed in priority
196
+ * order; the first name the device exposes is used.
197
+ */
198
+ const requiredExtensionsForFormat = (glInternalFormat: number): string[] => {
199
+ // ASTC (incl. sRGB variants): 0x93b0–0x93d5
200
+ if (glInternalFormat >= 0x93b0 && glInternalFormat <= 0x93d5) {
201
+ return EXT_ASTC;
202
+ }
203
+ // S3TC / DXTn: 0x83f0–0x83f3
204
+ if (glInternalFormat >= 0x83f0 && glInternalFormat <= 0x83f3) {
205
+ return EXT_S3TC;
206
+ }
207
+ // ETC1: 0x8d64
208
+ if (glInternalFormat === 0x8d64) {
209
+ return EXT_ETC1;
210
+ }
211
+ // ETC2 / EAC: 0x9274–0x9279
212
+ if (glInternalFormat >= 0x9274 && glInternalFormat <= 0x9279) {
213
+ return EXT_ETC;
214
+ }
215
+ // PVRTC: 0x8c00–0x8c03
216
+ if (glInternalFormat >= 0x8c00 && glInternalFormat <= 0x8c03) {
217
+ return EXT_PVRTC;
218
+ }
219
+ return EXT_NONE;
220
+ };
221
+
222
+ /**
223
+ * Enable the extension owning `glInternalFormat` so the format enum is valid in
224
+ * `compressedTexImage2D`, throwing a clear error if the device exposes none of
225
+ * the candidate extensions (instead of leaking a silent `GL_INVALID_ENUM`).
226
+ */
227
+ const ensureCompressedFormatEnabled = (
228
+ glw: WebGlContextWrapper,
229
+ glInternalFormat: number,
230
+ ): void => {
231
+ const names = requiredExtensionsForFormat(glInternalFormat);
232
+ const len = names.length;
233
+ if (len === 0) {
234
+ return;
235
+ }
236
+ for (let i = 0; i < len; i++) {
237
+ if (glw.getExtension(names[i]!) !== null) {
238
+ return;
239
+ }
240
+ }
241
+ throw new Error(
242
+ `Compressed texture format 0x${glInternalFormat.toString(
243
+ 16,
244
+ )} is not supported by this device (requires ${names.join(' or ')})`,
245
+ );
246
+ };
247
+
174
248
  const uploadASTC = function (
175
249
  glw: WebGlContextWrapper,
176
250
  texture: WebGLTexture,
177
251
  data: CompressedData,
178
252
  ) {
179
- if (glw.getExtension('WEBGL_compressed_texture_astc') === null) {
180
- throw new Error('ASTC compressed textures not supported by this device');
181
- }
253
+ const { glInternalFormat, mipmaps, w, h } = data;
254
+ ensureCompressedFormatEnabled(glw, glInternalFormat);
182
255
 
183
256
  glw.bindTexture(texture);
184
257
 
185
- const { glInternalFormat, mipmaps, w, h } = data;
186
258
  if (mipmaps === undefined) {
187
259
  return;
188
260
  }
@@ -277,6 +349,7 @@ const uploadKTX = function (
277
349
  data: CompressedData,
278
350
  ) {
279
351
  const { glInternalFormat, mipmaps, w: width, h: height, blockInfo } = data;
352
+ ensureCompressedFormatEnabled(glw, glInternalFormat);
280
353
  if (mipmaps === undefined) {
281
354
  return;
282
355
  }
@@ -415,6 +488,7 @@ const uploadPVR = function (
415
488
  data: CompressedData,
416
489
  ) {
417
490
  const { glInternalFormat, mipmaps, w: width, h: height } = data;
491
+ ensureCompressedFormatEnabled(glw, glInternalFormat);
418
492
  if (mipmaps === undefined) {
419
493
  return;
420
494
  }
@@ -11,6 +11,7 @@ export interface CoreRendererOptions {
11
11
  canvas: HTMLCanvasElement | OffscreenCanvas;
12
12
  contextSpy: ContextSpy | null;
13
13
  forceWebGL2: boolean;
14
+ disableVertexArrayObject: boolean;
14
15
  }
15
16
 
16
17
  export interface BufferInfo {
@@ -52,7 +52,13 @@ export class CanvasRenderer extends CoreRenderer {
52
52
  const ctx = this.context;
53
53
  const { tx, ty, ta, tb, tc, td } = node.globalTransform!;
54
54
  const clippingRect = node.clippingRect;
55
- let texture = (node.props.texture || this.stage.defaultTexture) as Texture;
55
+ // While a placeholder is showing, render the color-rect path (the default
56
+ // ColorTexture) tinted by the node's premultiplied placeholder color.
57
+ let texture = (
58
+ node.placeholderActive === true
59
+ ? this.stage.defaultTexture
60
+ : node.props.texture || this.stage.defaultTexture
61
+ ) as Texture;
56
62
  // The Canvas2D renderer only supports image textures, no textures are used for color blocks
57
63
  if (texture !== null) {
58
64
  const textureType = texture.type;
@@ -48,6 +48,19 @@ const GL_OUT_OF_MEMORY = 0x0505;
48
48
  */
49
49
  const MAX_DRAINED_GL_ERRORS = 8;
50
50
 
51
+ /**
52
+ * Dirty-ratio cutoff that flips the per-frame quad upload from surgical
53
+ * `bufferSubData` (one call per changed node) to a single full `bufferData`.
54
+ *
55
+ * The surgical path wins when few nodes change per frame (typical UI), but
56
+ * degrades to one GL call per node when most of the scene moves at once
57
+ * (full-screen animation, scroll, zoom). At that point a single bulk upload is
58
+ * cheaper than N driver round-trips, so when the number of nodes we would
59
+ * `bufferSubData` exceeds this fraction of the render list we upload everything
60
+ * in one call instead. Range 0..1; ~0.4 balances the two regimes.
61
+ */
62
+ const FULL_UPLOAD_DIRTY_RATIO = 0.4;
63
+
51
64
  export type WebGlRenderOp = CoreNode | SdfRenderOp;
52
65
 
53
66
  export class WebGlRenderer extends CoreRenderer {
@@ -161,6 +174,14 @@ export class WebGlRenderer extends CoreRenderer {
161
174
  * capacity, requiring a full re-upload even when needsFullUpload is false.
162
175
  */
163
176
  lastUploadedBufferSize = 0;
177
+ /**
178
+ * Count of main-scene nodes whose quad data changed this frame and which
179
+ * own a buffer slot. Accumulated for free during the addQuad pass (which
180
+ * already branches on isQuadDirty) and consumed in render() to choose
181
+ * between surgical bufferSubData uploads and a single full bufferData,
182
+ * avoiding a separate counting loop. Reset each frame in reset().
183
+ */
184
+ dirtyQuadCount = 0;
164
185
  /**
165
186
  * Whether the renderer is currently rendering to a texture.
166
187
  */
@@ -187,7 +208,10 @@ export class WebGlRenderer extends CoreRenderer {
187
208
  options.forceWebGL2,
188
209
  options.contextSpy,
189
210
  );
190
- const glw = (this.glw = new WebGlContextWrapper(gl));
211
+ const glw = (this.glw = new WebGlContextWrapper(
212
+ gl,
213
+ options.disableVertexArrayObject,
214
+ ));
191
215
  glw.viewport(0, 0, options.canvas.width, options.canvas.height);
192
216
 
193
217
  this.attachContextLossListeners(options.canvas);
@@ -346,6 +370,7 @@ export class WebGlRenderer extends CoreRenderer {
346
370
  }
347
371
  this.curRenderOp = null;
348
372
  this.curSdfRenderOp = null;
373
+ this.dirtyQuadCount = 0;
349
374
  this.sdfBufferIdx = 0;
350
375
  this.sdfQuadCount = 0;
351
376
  this.renderOps.length = 0;
@@ -446,7 +471,12 @@ export class WebGlRenderer extends CoreRenderer {
446
471
  }
447
472
 
448
473
  const props = node.props;
449
- let tx = props.texture || this.stage.defaultTexture!;
474
+ // While a placeholder is showing, the quad samples the shared 1x1 white
475
+ // texture tinted by the node's premultiplied placeholder color.
476
+ let tx =
477
+ node.placeholderActive === true
478
+ ? this.stage.defaultTexture!
479
+ : props.texture || this.stage.defaultTexture!;
450
480
 
451
481
  if (tx.type === TextureType.subTexture) {
452
482
  tx = (tx as SubTexture).parentTexture;
@@ -496,6 +526,14 @@ export class WebGlRenderer extends CoreRenderer {
496
526
  // The GPU upload is deferred to render().
497
527
  // During RTT, always write since the buffer is rebuilt from scratch.
498
528
  if (!DIRTY_QUAD_BUFFER || isRTT || node.isQuadDirty) {
529
+ // Count main-scene dirty nodes here, while we already have the node in
530
+ // hand, so render() can pick full vs. surgical upload without a second
531
+ // pass over the render list. Slot is guaranteed assigned at this point
532
+ // (i = quadBufferIndex above), so no quadBufferIndex !== -1 guard needed.
533
+ if (DIRTY_QUAD_BUFFER && isRTT === false && node.isQuadDirty === true) {
534
+ this.dirtyQuadCount++;
535
+ }
536
+
499
537
  const rc = node.renderCoords!;
500
538
  const tc = node.textureCoords || this.defaultTextureCoords;
501
539
 
@@ -966,31 +1004,41 @@ export class WebGlRenderer extends CoreRenderer {
966
1004
  const BYTES = Float32Array.BYTES_PER_ELEMENT;
967
1005
 
968
1006
  if (DIRTY_QUAD_BUFFER) {
969
- if (
970
- this.needsFullUpload ||
971
- this.curBufferIdx > this.lastUploadedBufferSize
972
- ) {
973
- // Full GPU re-allocation: covers new nodes and structural reorders.
974
- // Also triggered when curBufferIdx has grown beyond the last uploaded
975
- // size (e.g. after RTT rendering consumed needsFullUpload and then
976
- // the main scene added more quads).
977
- // Uses DYNAMIC_DRAW to signal to the driver that the buffer will be
978
- // updated frequently in smaller pieces going forward.
1007
+ const renderList = this.stage.renderList;
1008
+ const len = renderList.length;
1009
+
1010
+ // Growth/realloc always forces a full upload (new nodes, structural
1011
+ // reorders, or curBufferIdx grown past the last uploaded size).
1012
+ let fullUpload =
1013
+ this.needsFullUpload || this.curBufferIdx > this.lastUploadedBufferSize;
1014
+
1015
+ // Otherwise decide adaptively: if the number of nodes we would upload
1016
+ // surgically exceeds FULL_UPLOAD_DIRTY_RATIO of the render list, a single
1017
+ // bufferData is cheaper than that many bufferSubData calls. The count was
1018
+ // accumulated for free during the addQuad pass (dirtyQuadCount), so no
1019
+ // separate counting loop is needed here.
1020
+ if (fullUpload === false) {
1021
+ fullUpload = this.dirtyQuadCount > len * FULL_UPLOAD_DIRTY_RATIO;
1022
+ }
1023
+
1024
+ if (fullUpload === true) {
1025
+ // Full GPU re-allocation: covers the growth/realloc cases above and the
1026
+ // "most of the scene changed" case where one bulk upload beats N
1027
+ // per-node uploads. Uses DYNAMIC_DRAW to signal to the driver that the
1028
+ // buffer will be updated frequently going forward.
979
1029
  const arr = new Float32Array(quadBuffer, 0, this.curBufferIdx);
980
1030
  glw.arrayBufferData(buffer, arr, glw.DYNAMIC_DRAW);
981
1031
  this.needsFullUpload = false;
982
1032
  this.lastUploadedBufferSize = this.curBufferIdx;
983
1033
 
984
1034
  // Clear dirty flags since we just uploaded everything.
985
- const renderList = this.stage.renderList;
986
- for (let i = 0, len = renderList.length; i < len; i++) {
1035
+ for (let i = 0; i < len; i++) {
987
1036
  renderList[i]!.isQuadDirty = false;
988
1037
  }
989
1038
  } else {
990
1039
  // Surgical per-node uploads: only write the 20 float32s for nodes
991
1040
  // whose quad data changed since the last frame.
992
- const renderList = this.stage.renderList;
993
- for (let i = 0, len = renderList.length; i < len; i++) {
1041
+ for (let i = 0; i < len; i++) {
994
1042
  const node = renderList[i]!;
995
1043
  if (node.isQuadDirty && node.quadBufferIndex !== -1) {
996
1044
  const byteOffset = node.quadBufferIndex * BYTES;
@@ -30,6 +30,35 @@ export class WebGlShaderProgram implements CoreShaderProgram {
30
30
  public isDestroyed = false;
31
31
  supportsIndexedTextures = false;
32
32
 
33
+ /**
34
+ * Shadow copies of this program's GL uniform state, used by
35
+ * {@link bindRenderOp} to skip redundant gl.uniform* calls.
36
+ *
37
+ * @remarks
38
+ * GL uniform values are state on the program object and persist across
39
+ * useProgram switches, and bindRenderOp is the only writer of these
40
+ * uniforms, so the shadows always mirror GL truth — even when ops using
41
+ * other programs are interleaved between two ops of this program.
42
+ *
43
+ * `lastBoundUniforms` tracks the shader node's uniform collection by
44
+ * identity: collections are created once, filled once, and shared by
45
+ * reference across shader nodes with the same value key (see
46
+ * WebGlShaderNode.update), so reference equality implies value equality.
47
+ * The failure direction is safe — a new collection holding identical
48
+ * values just causes a redundant upload, never a wrong skip.
49
+ *
50
+ * Sentinels: -1 never collides with real pixel ratios, resolutions,
51
+ * alphas, dimensions, or time values (all >= 0).
52
+ */
53
+ protected lastBoundUniforms: unknown = null;
54
+ protected lastPixelRatio = -1;
55
+ protected lastResolutionW = -1;
56
+ protected lastResolutionH = -1;
57
+ protected lastAlpha = -1;
58
+ protected lastDimensionsW = -1;
59
+ protected lastDimensionsH = -1;
60
+ protected lastTime = -1;
61
+
33
62
  /**
34
63
  * Cached Vertex Array Objects, keyed by the buffer collection they capture.
35
64
  *
@@ -198,44 +227,78 @@ export class WebGlShaderProgram implements CoreShaderProgram {
198
227
  return;
199
228
  }
200
229
 
201
- // Bind render texture framebuffer dimensions as resolution
202
- // if the parent has a render texture
230
+ // Resolve target pixel ratio / resolution, then compare-and-set against
231
+ // the program's shadow state. Each gl.uniform* call below crosses into
232
+ // the GPU-process command buffer, so skipping value-identical re-uploads
233
+ // is a real per-op CPU saving on embedded targets.
234
+ let pixelRatio: number;
235
+ let resolutionW: number;
236
+ let resolutionH: number;
203
237
  if (USE_RTT && parentHasRenderTexture === true && framebufferDimensions) {
204
- const { w, h } = framebufferDimensions;
205
238
  // Force pixel ratio to 1.0 for render textures since they are always 1:1
206
239
  // the final render texture will be rendered to the screen with the correct pixel ratio
207
- this.glw.uniform1f('u_pixelRatio', 1.0);
208
-
240
+ pixelRatio = 1.0;
209
241
  // Set resolution to the framebuffer dimensions
210
- this.glw.uniform2f('u_resolution', w, h);
242
+ resolutionW = framebufferDimensions.w;
243
+ resolutionH = framebufferDimensions.h;
211
244
  } else {
212
- this.glw.uniform1f('u_pixelRatio', renderOp.stage.pixelRatio);
245
+ pixelRatio = renderOp.stage.pixelRatio;
246
+ resolutionW = this.glw.canvas.width;
247
+ resolutionH = this.glw.canvas.height;
248
+ }
213
249
 
214
- this.glw.uniform2f(
215
- 'u_resolution',
216
- this.glw.canvas.width,
217
- this.glw.canvas.height,
218
- );
250
+ if (pixelRatio !== this.lastPixelRatio) {
251
+ this.glw.uniform1f('u_pixelRatio', pixelRatio);
252
+ this.lastPixelRatio = pixelRatio;
219
253
  }
220
254
 
221
- if (this.useTimeValue === true) {
255
+ if (
256
+ resolutionW !== this.lastResolutionW ||
257
+ resolutionH !== this.lastResolutionH
258
+ ) {
259
+ this.glw.uniform2f('u_resolution', resolutionW, resolutionH);
260
+ this.lastResolutionW = resolutionW;
261
+ this.lastResolutionH = resolutionH;
262
+ }
263
+
264
+ if (this.useTimeValue === true && renderOp.time !== this.lastTime) {
222
265
  this.glw.uniform1f('u_time', renderOp.time);
266
+ this.lastTime = renderOp.time;
223
267
  }
224
268
 
225
- if (this.useSystemAlpha === true) {
269
+ if (
270
+ this.useSystemAlpha === true &&
271
+ renderOp.worldAlpha !== this.lastAlpha
272
+ ) {
226
273
  this.glw.uniform1f('u_alpha', renderOp.worldAlpha);
274
+ this.lastAlpha = renderOp.worldAlpha;
227
275
  }
228
276
 
229
- if (this.useSystemDimensions === true) {
277
+ if (
278
+ this.useSystemDimensions === true &&
279
+ (renderOp.w !== this.lastDimensionsW ||
280
+ renderOp.h !== this.lastDimensionsH)
281
+ ) {
230
282
  this.glw.uniform2f('u_dimensions', renderOp.w, renderOp.h);
283
+ this.lastDimensionsW = renderOp.w;
284
+ this.lastDimensionsH = renderOp.h;
231
285
  }
232
286
 
233
287
  const shader = renderOp.shader as WebGlShaderNode;
234
288
  if (shader.props !== undefined) {
235
289
  /**
236
290
  * loop over all precalculated uniform types
291
+ *
292
+ * Collections are immutable after being filled and shared by reference
293
+ * across shader nodes with equal value keys, so when the same object is
294
+ * already bound the GL program still holds exactly these values and the
295
+ * whole pass (loops + gl calls) can be skipped.
237
296
  */
238
297
  const uniforms = shader.uniforms;
298
+ if ((uniforms as unknown) === this.lastBoundUniforms) {
299
+ return;
300
+ }
301
+ this.lastBoundUniforms = uniforms;
239
302
 
240
303
  for (const key in uniforms.single) {
241
304
  const { method, value } = uniforms.single[key]!;