@solidtv/renderer 1.5.0-0 → 1.5.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/common/CommonTypes.d.ts +20 -0
- package/dist/src/core/Stage.d.ts +1 -0
- package/dist/src/core/Stage.js +13 -2
- package/dist/src/core/Stage.js.map +1 -1
- package/dist/src/core/TextureMemoryManager.d.ts +23 -0
- package/dist/src/core/TextureMemoryManager.js +37 -3
- package/dist/src/core/TextureMemoryManager.js.map +1 -1
- package/dist/src/core/lib/WebGlContextWrapper.d.ts +1 -1
- package/dist/src/core/lib/WebGlContextWrapper.js +10 -3
- package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
- package/dist/src/core/lib/textureCompression.js +68 -4
- package/dist/src/core/lib/textureCompression.js.map +1 -1
- package/dist/src/core/renderers/CoreRenderer.d.ts +1 -0
- package/dist/src/core/renderers/CoreRenderer.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +8 -0
- package/dist/src/core/renderers/webgl/WebGlRenderer.js +49 -13
- package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.d.ts +2 -0
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +18 -5
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
- package/dist/src/core/renderers/webgl/internal/RendererUtils.js +9 -2
- package/dist/src/core/renderers/webgl/internal/RendererUtils.js.map +1 -1
- package/dist/src/core/text-rendering/SdfTextRenderer.js +16 -3
- package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
- package/dist/src/main-api/Renderer.d.ts +33 -1
- package/dist/src/main-api/Renderer.js +2 -0
- package/dist/src/main-api/Renderer.js.map +1 -1
- package/dist/src/utils.js +23 -7
- package/dist/src/utils.js.map +1 -1
- package/dist/tsconfig.dist.tsbuildinfo +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/common/CommonTypes.ts +20 -0
- package/src/core/Stage.ts +16 -1
- package/src/core/TextureMemoryManager.test.ts +51 -2
- package/src/core/TextureMemoryManager.ts +42 -3
- package/src/core/lib/WebGlContextWrapper.test.ts +58 -0
- package/src/core/lib/WebGlContextWrapper.ts +13 -3
- package/src/core/lib/textureCompression.test.ts +158 -0
- package/src/core/lib/textureCompression.ts +78 -4
- package/src/core/renderers/CoreRenderer.ts +1 -0
- package/src/core/renderers/webgl/WebGlRenderer.ts +58 -15
- package/src/core/renderers/webgl/WebGlShaderProgram.ts +18 -5
- package/src/core/renderers/webgl/internal/RendererUtils.test.ts +73 -0
- package/src/core/renderers/webgl/internal/RendererUtils.ts +9 -2
- package/src/core/text-rendering/SdfTextRenderer.test.ts +11 -10
- package/src/core/text-rendering/SdfTextRenderer.ts +17 -3
- package/src/main-api/Renderer.ts +36 -1
- package/src/utils.test.ts +57 -0
- package/src/utils.ts +24 -8
|
@@ -166,6 +166,44 @@ export class TextureMemoryManager {
|
|
|
166
166
|
return this.criticalThreshold > 0 && this.memUsed > this.criticalThreshold;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Reversibly free a texture's GPU resources under memory pressure.
|
|
171
|
+
*
|
|
172
|
+
* @remarks
|
|
173
|
+
* Unlike {@link destroyTexture}, this keeps the `Texture` object, its event
|
|
174
|
+
* listeners, and its cache entry intact. It only releases the GPU-side
|
|
175
|
+
* resource and transitions the source to the `freed` state. A `CoreNode` that
|
|
176
|
+
* still references this texture will reload it — and be re-notified via its
|
|
177
|
+
* `loaded` listener — when it re-enters the viewport (see
|
|
178
|
+
* `Texture.setRenderableOwner` → `Texture.load`).
|
|
179
|
+
*
|
|
180
|
+
* This is the correct path for LRU/idle cleanup: destroying instead would
|
|
181
|
+
* sever the node's subscription (`removeAllListeners`) and evict the cache
|
|
182
|
+
* entry, leaving the node stuck on a texture that reloads to `loaded` but is
|
|
183
|
+
* never displayed.
|
|
184
|
+
*
|
|
185
|
+
* `texture.free()` reclaims tracked memory via `setTextureMemUse(0)` when a
|
|
186
|
+
* ctxTexture exists; the guard below keeps the accounting correct for any
|
|
187
|
+
* texture that entered `loadedTextures` without one.
|
|
188
|
+
*
|
|
189
|
+
* @param texture - The texture to free
|
|
190
|
+
*/
|
|
191
|
+
freeTexture(texture: Texture) {
|
|
192
|
+
if (this.debugLogging === true) {
|
|
193
|
+
console.log(
|
|
194
|
+
`[TextureMemoryManager] Freeing texture. State: ${texture.state}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
texture.free();
|
|
199
|
+
|
|
200
|
+
if (this.loadedTextures.has(texture) === true) {
|
|
201
|
+
this.loadedTextures.delete(texture);
|
|
202
|
+
this.memUsed -= texture.memUsed;
|
|
203
|
+
texture.memUsed = 0;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
169
207
|
/**
|
|
170
208
|
* Destroy a texture and remove it from the memory manager
|
|
171
209
|
*
|
|
@@ -230,11 +268,12 @@ export class TextureMemoryManager {
|
|
|
230
268
|
|
|
231
269
|
// Immediate cleanup if eligible
|
|
232
270
|
if (isCleanableType && texture.canBeCleanedUp() === true) {
|
|
233
|
-
// Get memory before
|
|
271
|
+
// Get memory before freeing
|
|
234
272
|
const textureMemory = texture.memUsed;
|
|
235
273
|
|
|
236
|
-
//
|
|
237
|
-
|
|
274
|
+
// Reversibly free (keeps listeners + cache) so the texture reloads when
|
|
275
|
+
// its node re-enters the viewport.
|
|
276
|
+
this.freeTexture(texture);
|
|
238
277
|
currentMemUsed -= textureMemory;
|
|
239
278
|
}
|
|
240
279
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { WebGlContextWrapper } from './WebGlContextWrapper.js';
|
|
3
|
+
|
|
4
|
+
// The wrapper reads `self.WebGL2RenderingContext` to detect WebGL2. The unit
|
|
5
|
+
// test environment is plain Node where `self` is undefined, so provide one.
|
|
6
|
+
// With no WebGL2RenderingContext on it, the mock context is treated as WebGL1.
|
|
7
|
+
const g = globalThis as unknown as { self?: unknown };
|
|
8
|
+
if (g.self === undefined) {
|
|
9
|
+
g.self = globalThis;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Minimal WebGL1-like context. A Proxy returns 0 for every GLenum constant the
|
|
13
|
+
// constructor copies; only the handful of methods/props it actually calls are
|
|
14
|
+
// implemented.
|
|
15
|
+
function mockWebGl1(hasVaoExtension: boolean): WebGLRenderingContext {
|
|
16
|
+
const vaoExt = {
|
|
17
|
+
createVertexArrayOES: () => ({}),
|
|
18
|
+
bindVertexArrayOES: (_vao: unknown) => undefined,
|
|
19
|
+
deleteVertexArrayOES: (_vao: unknown) => undefined,
|
|
20
|
+
};
|
|
21
|
+
const target: Record<string, unknown> = {
|
|
22
|
+
canvas: {},
|
|
23
|
+
drawingBufferWidth: 100,
|
|
24
|
+
drawingBufferHeight: 100,
|
|
25
|
+
getParameter: () => 8,
|
|
26
|
+
getExtension: (name: string) =>
|
|
27
|
+
name === 'OES_vertex_array_object' && hasVaoExtension ? vaoExt : null,
|
|
28
|
+
};
|
|
29
|
+
return new Proxy(target, {
|
|
30
|
+
get(t, prop) {
|
|
31
|
+
if (prop in t) {
|
|
32
|
+
return t[prop as string];
|
|
33
|
+
}
|
|
34
|
+
// Every other access is a GLenum constant.
|
|
35
|
+
return 0;
|
|
36
|
+
},
|
|
37
|
+
}) as unknown as WebGLRenderingContext;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('WebGlContextWrapper VAO support', () => {
|
|
41
|
+
it('enables VAOs when the OES extension is present', () => {
|
|
42
|
+
const glw = new WebGlContextWrapper(mockWebGl1(true));
|
|
43
|
+
expect(glw.canUseVertexArrayObject).toBe(true);
|
|
44
|
+
expect(glw.isWebGl2).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('disables VAOs when the OES extension is absent', () => {
|
|
48
|
+
const glw = new WebGlContextWrapper(mockWebGl1(false));
|
|
49
|
+
expect(glw.canUseVertexArrayObject).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('forces VAOs off when disableVertexArrayObject is true, even if supported', () => {
|
|
53
|
+
const glw = new WebGlContextWrapper(mockWebGl1(true), true);
|
|
54
|
+
expect(glw.canUseVertexArrayObject).toBe(false);
|
|
55
|
+
// The flag gates VAO usage only; it does not change the detected context.
|
|
56
|
+
expect(glw.isWebGl2).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -111,7 +111,10 @@ export class WebGlContextWrapper {
|
|
|
111
111
|
public readonly INVALID_OPERATION: number;
|
|
112
112
|
//#endregion WebGL Enums
|
|
113
113
|
|
|
114
|
-
constructor(
|
|
114
|
+
constructor(
|
|
115
|
+
private gl: WebGLRenderingContext | WebGL2RenderingContext,
|
|
116
|
+
disableVertexArrayObject = false,
|
|
117
|
+
) {
|
|
115
118
|
// A freshly created WebGL context is in a fully specified default state.
|
|
116
119
|
// Rather than reading that state back with getParameter/isEnabled — each a
|
|
117
120
|
// synchronous CPU<->GPU round-trip, and previously ~one per texture unit
|
|
@@ -154,9 +157,16 @@ export class WebGlContextWrapper {
|
|
|
154
157
|
self.WebGL2RenderingContext && gl instanceof self.WebGL2RenderingContext
|
|
155
158
|
? (gl as WebGL2RenderingContext)
|
|
156
159
|
: null;
|
|
160
|
+
// `disableVertexArrayObject` forces the per-draw attribute-binding path even
|
|
161
|
+
// when VAOs are available (diagnostics / benchmarking). isWebGl2 still
|
|
162
|
+
// reflects the real context — the flag only gates VAO usage.
|
|
157
163
|
this.vaoExt =
|
|
158
|
-
this.gl2 === null
|
|
159
|
-
|
|
164
|
+
this.gl2 === null && disableVertexArrayObject !== true
|
|
165
|
+
? gl.getExtension('OES_vertex_array_object')
|
|
166
|
+
: null;
|
|
167
|
+
this.canUseVertexArrayObject =
|
|
168
|
+
disableVertexArrayObject !== true &&
|
|
169
|
+
(this.gl2 !== null || this.vaoExt !== null);
|
|
160
170
|
this.isWebGl2 = this.gl2 !== null;
|
|
161
171
|
|
|
162
172
|
this.canvas = gl.canvas;
|
|
@@ -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
|
-
|
|
180
|
-
|
|
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
|
}
|
|
@@ -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(
|
|
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;
|
|
@@ -496,6 +521,14 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
496
521
|
// The GPU upload is deferred to render().
|
|
497
522
|
// During RTT, always write since the buffer is rebuilt from scratch.
|
|
498
523
|
if (!DIRTY_QUAD_BUFFER || isRTT || node.isQuadDirty) {
|
|
524
|
+
// Count main-scene dirty nodes here, while we already have the node in
|
|
525
|
+
// hand, so render() can pick full vs. surgical upload without a second
|
|
526
|
+
// pass over the render list. Slot is guaranteed assigned at this point
|
|
527
|
+
// (i = quadBufferIndex above), so no quadBufferIndex !== -1 guard needed.
|
|
528
|
+
if (DIRTY_QUAD_BUFFER && isRTT === false && node.isQuadDirty === true) {
|
|
529
|
+
this.dirtyQuadCount++;
|
|
530
|
+
}
|
|
531
|
+
|
|
499
532
|
const rc = node.renderCoords!;
|
|
500
533
|
const tc = node.textureCoords || this.defaultTextureCoords;
|
|
501
534
|
|
|
@@ -966,31 +999,41 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
966
999
|
const BYTES = Float32Array.BYTES_PER_ELEMENT;
|
|
967
1000
|
|
|
968
1001
|
if (DIRTY_QUAD_BUFFER) {
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1002
|
+
const renderList = this.stage.renderList;
|
|
1003
|
+
const len = renderList.length;
|
|
1004
|
+
|
|
1005
|
+
// Growth/realloc always forces a full upload (new nodes, structural
|
|
1006
|
+
// reorders, or curBufferIdx grown past the last uploaded size).
|
|
1007
|
+
let fullUpload =
|
|
1008
|
+
this.needsFullUpload || this.curBufferIdx > this.lastUploadedBufferSize;
|
|
1009
|
+
|
|
1010
|
+
// Otherwise decide adaptively: if the number of nodes we would upload
|
|
1011
|
+
// surgically exceeds FULL_UPLOAD_DIRTY_RATIO of the render list, a single
|
|
1012
|
+
// bufferData is cheaper than that many bufferSubData calls. The count was
|
|
1013
|
+
// accumulated for free during the addQuad pass (dirtyQuadCount), so no
|
|
1014
|
+
// separate counting loop is needed here.
|
|
1015
|
+
if (fullUpload === false) {
|
|
1016
|
+
fullUpload = this.dirtyQuadCount > len * FULL_UPLOAD_DIRTY_RATIO;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (fullUpload === true) {
|
|
1020
|
+
// Full GPU re-allocation: covers the growth/realloc cases above and the
|
|
1021
|
+
// "most of the scene changed" case where one bulk upload beats N
|
|
1022
|
+
// per-node uploads. Uses DYNAMIC_DRAW to signal to the driver that the
|
|
1023
|
+
// buffer will be updated frequently going forward.
|
|
979
1024
|
const arr = new Float32Array(quadBuffer, 0, this.curBufferIdx);
|
|
980
1025
|
glw.arrayBufferData(buffer, arr, glw.DYNAMIC_DRAW);
|
|
981
1026
|
this.needsFullUpload = false;
|
|
982
1027
|
this.lastUploadedBufferSize = this.curBufferIdx;
|
|
983
1028
|
|
|
984
1029
|
// Clear dirty flags since we just uploaded everything.
|
|
985
|
-
|
|
986
|
-
for (let i = 0, len = renderList.length; i < len; i++) {
|
|
1030
|
+
for (let i = 0; i < len; i++) {
|
|
987
1031
|
renderList[i]!.isQuadDirty = false;
|
|
988
1032
|
}
|
|
989
1033
|
} else {
|
|
990
1034
|
// Surgical per-node uploads: only write the 20 float32s for nodes
|
|
991
1035
|
// whose quad data changed since the last frame.
|
|
992
|
-
|
|
993
|
-
for (let i = 0, len = renderList.length; i < len; i++) {
|
|
1036
|
+
for (let i = 0; i < len; i++) {
|
|
994
1037
|
const node = renderList[i]!;
|
|
995
1038
|
if (node.isQuadDirty && node.quadBufferIndex !== -1) {
|
|
996
1039
|
const byteOffset = node.quadBufferIndex * BYTES;
|
|
@@ -276,17 +276,25 @@ export class WebGlShaderProgram implements CoreShaderProgram {
|
|
|
276
276
|
if (glw.canUseVertexArrayObject === true) {
|
|
277
277
|
let vao = this.vaos.get(buffer);
|
|
278
278
|
if (vao === undefined) {
|
|
279
|
-
// First draw with this collection:
|
|
280
|
-
// VAO.
|
|
279
|
+
// First draw with this collection: try to capture the attribute layout
|
|
280
|
+
// in a VAO. Cache the result — including a null on allocation failure,
|
|
281
|
+
// so we don't retry createVertexArray every frame.
|
|
281
282
|
vao = this.createVao(buffer);
|
|
282
283
|
this.vaos.set(buffer, vao);
|
|
284
|
+
}
|
|
285
|
+
if (vao !== null) {
|
|
286
|
+
// createVao leaves a freshly built VAO bound; re-binding is cheap and
|
|
287
|
+
// keeps the build and reuse paths identical.
|
|
288
|
+
glw.bindVertexArray(vao);
|
|
283
289
|
return;
|
|
284
290
|
}
|
|
285
|
-
|
|
286
|
-
|
|
291
|
+
// VAO allocation failed (e.g. under GL OOM). Bind the default VAO so the
|
|
292
|
+
// per-draw attribute setup below records into it rather than corrupting
|
|
293
|
+
// another program's cached VAO, then fall through.
|
|
294
|
+
glw.bindVertexArray(null);
|
|
287
295
|
}
|
|
288
296
|
|
|
289
|
-
// No VAO
|
|
297
|
+
// No (usable) VAO: re-point every attribute on each draw.
|
|
290
298
|
this.bindAttributes(buffer);
|
|
291
299
|
}
|
|
292
300
|
|
|
@@ -322,10 +330,15 @@ export class WebGlShaderProgram implements CoreShaderProgram {
|
|
|
322
330
|
/**
|
|
323
331
|
* Create and populate a Vertex Array Object capturing this program's
|
|
324
332
|
* attribute layout for the given buffer collection. The new VAO is left bound.
|
|
333
|
+
* Returns null if the context reports VAO support but allocation fails (e.g.
|
|
334
|
+
* under GL OOM), in which case the caller falls back to per-draw binding.
|
|
325
335
|
*/
|
|
326
336
|
private createVao(buffer: BufferCollection): WebGLVertexArrayObject | null {
|
|
327
337
|
const { glw } = this;
|
|
328
338
|
const vao = glw.createVertexArray();
|
|
339
|
+
if (vao === null) {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
329
342
|
glw.bindVertexArray(vao);
|
|
330
343
|
|
|
331
344
|
this.bindAttributes(buffer);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createIndexBuffer } from './RendererUtils.js';
|
|
3
|
+
import type { WebGlContextWrapper } from '../../../lib/WebGlContextWrapper.js';
|
|
4
|
+
|
|
5
|
+
// Capture the Uint16Array handed to elementArrayBufferData so we can assert on
|
|
6
|
+
// the generated quad indices without a real GL context.
|
|
7
|
+
function mockGlw(): {
|
|
8
|
+
glw: WebGlContextWrapper;
|
|
9
|
+
getIndices: () => Uint16Array;
|
|
10
|
+
} {
|
|
11
|
+
let captured: Uint16Array | null = null;
|
|
12
|
+
const glw = {
|
|
13
|
+
STATIC_DRAW: 0,
|
|
14
|
+
createBuffer: () => ({}),
|
|
15
|
+
elementArrayBufferData: (_buffer: unknown, indices: Uint16Array) => {
|
|
16
|
+
captured = indices;
|
|
17
|
+
},
|
|
18
|
+
} as unknown as WebGlContextWrapper;
|
|
19
|
+
return {
|
|
20
|
+
glw,
|
|
21
|
+
getIndices: () => {
|
|
22
|
+
if (captured === null) {
|
|
23
|
+
throw new Error('elementArrayBufferData was not called');
|
|
24
|
+
}
|
|
25
|
+
return captured;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The expected 6 element indices for quad `q`: two triangles over the quad's
|
|
31
|
+
// four vertices [4q, 4q+1, 4q+2, 4q+3] wound as [0,1,2, 2,1,3].
|
|
32
|
+
const expectedQuad = (q: number): number[] => {
|
|
33
|
+
const j = q * 4;
|
|
34
|
+
return [j, j + 1, j + 2, j + 2, j + 1, j + 3];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('createIndexBuffer', () => {
|
|
38
|
+
it('fills indices for EVERY quad, not just the first 1/6', () => {
|
|
39
|
+
// size / 80 = 800 quads, comfortably under the Uint16 cap.
|
|
40
|
+
const { glw, getIndices } = mockGlw();
|
|
41
|
+
createIndexBuffer(glw, 800 * 80);
|
|
42
|
+
const indices = getIndices();
|
|
43
|
+
const maxQuads = 800;
|
|
44
|
+
|
|
45
|
+
expect(indices.length).toBe(maxQuads * 6);
|
|
46
|
+
|
|
47
|
+
// First, middle and — critically — the LAST quad must be populated. The
|
|
48
|
+
// original bug stopped the loop at `i < maxQuads`, zeroing every quad past
|
|
49
|
+
// ~maxQuads/6 and collapsing it into a degenerate triangle.
|
|
50
|
+
for (const q of [0, 1, maxQuads >> 1, maxQuads - 2, maxQuads - 1]) {
|
|
51
|
+
const slice = Array.from(indices.subarray(q * 6, q * 6 + 6));
|
|
52
|
+
expect(slice).toEqual(expectedQuad(q));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// No trailing zeroed slots (every quad past index 0 references vertices > 0).
|
|
56
|
+
const lastQuad = Array.from(indices.subarray((maxQuads - 1) * 6));
|
|
57
|
+
expect(lastQuad.some((v) => v !== 0)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('caps at 16384 quads so Uint16 vertex ids never overflow', () => {
|
|
61
|
+
// Request far more than Uint16 can address (25000 quads worth of bytes).
|
|
62
|
+
const { glw, getIndices } = mockGlw();
|
|
63
|
+
createIndexBuffer(glw, 25000 * 80);
|
|
64
|
+
const indices = getIndices();
|
|
65
|
+
|
|
66
|
+
expect(indices.length).toBe(16384 * 6);
|
|
67
|
+
// The very last vertex id is 16384*4 - 1 = 65535, the Uint16 maximum.
|
|
68
|
+
expect(indices[indices.length - 1]).toBe(65535);
|
|
69
|
+
expect(Array.from(indices.subarray(16383 * 6, 16383 * 6 + 6))).toEqual(
|
|
70
|
+
expectedQuad(16383),
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|