@flighthq/render-gl 0.2.0 → 0.2.1-next.410.a23f189
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/glCompressedTexture.d.ts +8 -0
- package/dist/glCompressedTexture.d.ts.map +1 -0
- package/dist/glCompressedTexture.js +270 -0
- package/dist/glCompressedTexture.js.map +1 -0
- package/dist/glDraw.d.ts +4 -2
- package/dist/glDraw.d.ts.map +1 -1
- package/dist/glDraw.js +215 -20
- package/dist/glDraw.js.map +1 -1
- package/dist/glFullscreenPass.d.ts.map +1 -1
- package/dist/glFullscreenPass.js +32 -0
- package/dist/glFullscreenPass.js.map +1 -1
- package/dist/glLinearToSrgbPass.d.ts +4 -0
- package/dist/glLinearToSrgbPass.d.ts.map +1 -0
- package/dist/glLinearToSrgbPass.js +42 -0
- package/dist/glLinearToSrgbPass.js.map +1 -0
- package/dist/glPresentRenderTarget.d.ts +3 -0
- package/dist/glPresentRenderTarget.d.ts.map +1 -0
- package/dist/glPresentRenderTarget.js +35 -0
- package/dist/glPresentRenderTarget.js.map +1 -0
- package/dist/glRenderPass.d.ts +5 -0
- package/dist/glRenderPass.d.ts.map +1 -0
- package/dist/glRenderPass.js +130 -0
- package/dist/glRenderPass.js.map +1 -0
- package/dist/glRenderState.d.ts +1 -1
- package/dist/glRenderState.d.ts.map +1 -1
- package/dist/glRenderState.js +1 -0
- package/dist/glRenderState.js.map +1 -1
- package/dist/glRenderTarget.d.ts +2 -15
- package/dist/glRenderTarget.d.ts.map +1 -1
- package/dist/glRenderTarget.js +48 -57
- package/dist/glRenderTarget.js.map +1 -1
- package/dist/glRenderTargetPool.d.ts.map +1 -1
- package/dist/glRenderTargetPool.js +4 -0
- package/dist/glRenderTargetPool.js.map +1 -1
- package/dist/glSkinPaletteTexture.d.ts +5 -0
- package/dist/glSkinPaletteTexture.d.ts.map +1 -0
- package/dist/glSkinPaletteTexture.js +42 -0
- package/dist/glSkinPaletteTexture.js.map +1 -0
- package/dist/glTestHelper.d.ts.map +1 -1
- package/dist/glTestHelper.js +1 -0
- package/dist/glTestHelper.js.map +1 -1
- package/dist/glTextureUpload.d.ts +5 -0
- package/dist/glTextureUpload.d.ts.map +1 -0
- package/dist/glTextureUpload.js +27 -0
- package/dist/glTextureUpload.js.map +1 -0
- package/dist/glTextureVideoUpload.d.ts +3 -0
- package/dist/glTextureVideoUpload.d.ts.map +1 -0
- package/dist/glTextureVideoUpload.js +24 -0
- package/dist/glTextureVideoUpload.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/glCompressedTexture.test.ts +276 -0
- package/src/glDraw.test.ts +319 -36
- package/src/glFullscreenPass.test.ts +22 -1
- package/src/glLinearToSrgbPass.test.ts +75 -0
- package/src/glPresentRenderTarget.test.ts +62 -0
- package/src/glReadback.test.ts +1 -0
- package/src/glRenderPass.test.ts +170 -0
- package/src/glRenderTarget.test.ts +63 -98
- package/src/glRenderTargetPool.test.ts +14 -0
- package/src/glSkinPaletteTexture.test.ts +129 -0
- package/src/glTextureUpload.test.ts +49 -0
- package/src/glTextureVideoUpload.test.ts +53 -0
package/src/glDraw.test.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ImageResource, SamplerLike, VideoTexture } from '@flighthq/types';
|
|
2
|
+
import { AdvancedBlendMode, BlendMode } from '@flighthq/types';
|
|
2
3
|
|
|
4
|
+
import { registerGlCompressedTextureDecoder, registerGlCompressedTextureUpload } from './glCompressedTexture';
|
|
3
5
|
import {
|
|
4
6
|
applyGlBlendMode,
|
|
7
|
+
bindGlImageResourceTexture,
|
|
5
8
|
bindGlTexture,
|
|
9
|
+
bindGlVideoTexture,
|
|
6
10
|
createGlTexture,
|
|
7
11
|
drawGlQuad,
|
|
8
12
|
enableGlBlendModeSupport,
|
|
@@ -17,6 +21,47 @@ import { getGlRenderStateRuntime } from './glRenderState';
|
|
|
17
21
|
import { registerGlBitmapShader } from './glShaderRegistry';
|
|
18
22
|
import { createGlState } from './glTestHelper';
|
|
19
23
|
|
|
24
|
+
// A compressed-only ImageResource (single 4×4 bc3 level, no element/data) for exercising the opt-in
|
|
25
|
+
// compressed upload seam.
|
|
26
|
+
function compressedBc3ImageResource(): ImageResource {
|
|
27
|
+
return {
|
|
28
|
+
source: null,
|
|
29
|
+
data: null,
|
|
30
|
+
compressed: {
|
|
31
|
+
container: {
|
|
32
|
+
format: 'bc3',
|
|
33
|
+
width: 4,
|
|
34
|
+
height: 4,
|
|
35
|
+
depth: 1,
|
|
36
|
+
mipLevels: 1,
|
|
37
|
+
layers: 1,
|
|
38
|
+
faces: 1,
|
|
39
|
+
supercompression: 'None',
|
|
40
|
+
levels: [{ byteOffset: 0, byteLength: 16, width: 4, height: 4 }],
|
|
41
|
+
},
|
|
42
|
+
payload: new Uint8Array(16),
|
|
43
|
+
},
|
|
44
|
+
width: 4,
|
|
45
|
+
height: 4,
|
|
46
|
+
version: 1,
|
|
47
|
+
alphaType: 'straight',
|
|
48
|
+
} as unknown as ImageResource;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A plain SamplerLike with the AAA sampling defaults (clamp/linear/trilinear/mips, anisotropy off),
|
|
52
|
+
// mirroring createSampler in @flighthq/texture without pulling that package into render-gl's tests.
|
|
53
|
+
function makeSampler(overrides?: Partial<SamplerLike>): SamplerLike {
|
|
54
|
+
return {
|
|
55
|
+
anisotropy: 1,
|
|
56
|
+
magFilter: 'linear',
|
|
57
|
+
minFilter: 'linear-mipmap-linear',
|
|
58
|
+
mipmaps: true,
|
|
59
|
+
wrapU: 'clamp-to-edge',
|
|
60
|
+
wrapV: 'clamp-to-edge',
|
|
61
|
+
...overrides,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
20
65
|
describe('applyGlBlendMode', () => {
|
|
21
66
|
it('does not call blendFunc when blend mode has not changed', () => {
|
|
22
67
|
const { state, gl } = createGlState();
|
|
@@ -50,14 +95,6 @@ describe('applyGlBlendMode', () => {
|
|
|
50
95
|
expect(gl.blendFunc).toHaveBeenCalledWith(g.DST_COLOR, g.ONE_MINUS_SRC_ALPHA);
|
|
51
96
|
});
|
|
52
97
|
|
|
53
|
-
it('sets (ONE, ZERO) for BlendMode.None', () => {
|
|
54
|
-
const { state, gl } = createGlState();
|
|
55
|
-
registerDefaultGlBlendModes(state);
|
|
56
|
-
applyGlBlendMode(state, BlendMode.None);
|
|
57
|
-
const g = gl as unknown as { ONE: number; ZERO: number };
|
|
58
|
-
expect(gl.blendFunc).toHaveBeenCalledWith(g.ONE, g.ZERO);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
98
|
it('sets (ONE, ONE_MINUS_SRC_COLOR) for BlendMode.Screen', () => {
|
|
62
99
|
const { state, gl } = createGlState();
|
|
63
100
|
registerDefaultGlBlendModes(state);
|
|
@@ -84,23 +121,6 @@ describe('applyGlBlendMode', () => {
|
|
|
84
121
|
expect(gl.blendFunc).toHaveBeenCalledWith(g.ONE, g.ONE);
|
|
85
122
|
});
|
|
86
123
|
|
|
87
|
-
it('sets the reverse-subtract equation with (ONE, ONE) for BlendMode.Subtract', () => {
|
|
88
|
-
const { state, gl } = createGlState();
|
|
89
|
-
registerDefaultGlBlendModes(state);
|
|
90
|
-
applyGlBlendMode(state, BlendMode.Subtract);
|
|
91
|
-
const g = gl as unknown as { ONE: number; FUNC_REVERSE_SUBTRACT: number };
|
|
92
|
-
expect(gl.blendEquation).toHaveBeenCalledWith(g.FUNC_REVERSE_SUBTRACT);
|
|
93
|
-
expect(gl.blendFunc).toHaveBeenCalledWith(g.ONE, g.ONE);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('sets (ZERO, ONE_MINUS_SRC_ALPHA) for BlendMode.Erase', () => {
|
|
97
|
-
const { state, gl } = createGlState();
|
|
98
|
-
registerDefaultGlBlendModes(state);
|
|
99
|
-
applyGlBlendMode(state, BlendMode.Erase);
|
|
100
|
-
const g = gl as unknown as { ZERO: number; ONE_MINUS_SRC_ALPHA: number };
|
|
101
|
-
expect(gl.blendFunc).toHaveBeenCalledWith(g.ZERO, g.ONE_MINUS_SRC_ALPHA);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
124
|
it('resets the blend equation to FUNC_ADD for a mode that does not carry one', () => {
|
|
105
125
|
const { state, gl } = createGlState();
|
|
106
126
|
registerDefaultGlBlendModes(state);
|
|
@@ -113,7 +133,7 @@ describe('applyGlBlendMode', () => {
|
|
|
113
133
|
it('falls back to normal blend for a mode with no registered realization', () => {
|
|
114
134
|
const { state, gl } = createGlState();
|
|
115
135
|
registerDefaultGlBlendModes(state);
|
|
116
|
-
applyGlBlendMode(state,
|
|
136
|
+
applyGlBlendMode(state, AdvancedBlendMode.Overlay);
|
|
117
137
|
const g = gl as unknown as { ONE: number; ONE_MINUS_SRC_ALPHA: number };
|
|
118
138
|
expect(gl.blendFunc).toHaveBeenCalledWith(g.ONE, g.ONE_MINUS_SRC_ALPHA);
|
|
119
139
|
});
|
|
@@ -141,6 +161,78 @@ describe('applyGlBlendMode', () => {
|
|
|
141
161
|
});
|
|
142
162
|
});
|
|
143
163
|
|
|
164
|
+
describe('bindGlImageResourceTexture', () => {
|
|
165
|
+
function dataResource(size: number, version: number): ImageResource {
|
|
166
|
+
return {
|
|
167
|
+
source: null,
|
|
168
|
+
data: new Uint8ClampedArray(size * size * 4),
|
|
169
|
+
width: size,
|
|
170
|
+
height: size,
|
|
171
|
+
version,
|
|
172
|
+
alphaType: 'straight',
|
|
173
|
+
} as unknown as ImageResource;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
it('uploads a data-only ImageResource (a generated Surface) via the raw-pixel path', () => {
|
|
177
|
+
const { state, gl } = createGlState();
|
|
178
|
+
bindGlImageResourceTexture(state, dataResource(4, 1));
|
|
179
|
+
expect(gl.createTexture).toHaveBeenCalled();
|
|
180
|
+
expect(gl.texImage2D).toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('uploads an element-backed ImageResource via the element path', () => {
|
|
184
|
+
const { state, gl } = createGlState();
|
|
185
|
+
const image = {
|
|
186
|
+
source: document.createElement('img'),
|
|
187
|
+
data: null,
|
|
188
|
+
width: 1,
|
|
189
|
+
height: 1,
|
|
190
|
+
version: 1,
|
|
191
|
+
alphaType: 'straight',
|
|
192
|
+
} as unknown as ImageResource;
|
|
193
|
+
bindGlImageResourceTexture(state, image);
|
|
194
|
+
expect(gl.texImage2D).toHaveBeenCalled();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('caches by resource identity and re-uploads only when the version changes', () => {
|
|
198
|
+
const { state, gl } = createGlState();
|
|
199
|
+
const image = dataResource(1, 1);
|
|
200
|
+
const t1 = bindGlImageResourceTexture(state, image);
|
|
201
|
+
const uploads = (gl.texImage2D as ReturnType<typeof vi.fn>).mock.calls.length;
|
|
202
|
+
const t2 = bindGlImageResourceTexture(state, image);
|
|
203
|
+
expect(t2).toBe(t1);
|
|
204
|
+
expect((gl.texImage2D as ReturnType<typeof vi.fn>).mock.calls.length).toBe(uploads);
|
|
205
|
+
image.version = 2;
|
|
206
|
+
bindGlImageResourceTexture(state, image);
|
|
207
|
+
expect((gl.texImage2D as ReturnType<typeof vi.fn>).mock.calls.length).toBe(uploads + 1);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('routes a compressed-only ImageResource through the registered compressed upload seam (decode fallback)', () => {
|
|
211
|
+
// The device mock exposes no block extension, so the compressed container falls back to the
|
|
212
|
+
// registered RGBA decoder and uploads via texImage2D — proving the real bind/draw path reaches the
|
|
213
|
+
// installed compressed uploader, not just the raw data/element branches.
|
|
214
|
+
const { state, gl } = createGlState();
|
|
215
|
+
const rgba = new Uint8ClampedArray(4 * 4 * 4);
|
|
216
|
+
const decode = vi.fn(() => rgba);
|
|
217
|
+
registerGlCompressedTextureUpload(state);
|
|
218
|
+
registerGlCompressedTextureDecoder(state, decode);
|
|
219
|
+
const image = compressedBc3ImageResource();
|
|
220
|
+
bindGlImageResourceTexture(state, image);
|
|
221
|
+
expect(decode).toHaveBeenCalledWith('bc3', 4, 4, expect.any(Uint8Array));
|
|
222
|
+
expect(gl.texImage2D).toHaveBeenCalled();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('skips a compressed-only ImageResource when no compressed uploader is registered', () => {
|
|
226
|
+
// The compressed path is an opt-in seam: without registerGlCompressedTextureUpload, a compressed
|
|
227
|
+
// resource uploads nothing (the enum table tree-shakes out of a bitmap-only bundle) rather than
|
|
228
|
+
// reaching texImage2D.
|
|
229
|
+
const { state, gl } = createGlState();
|
|
230
|
+
bindGlImageResourceTexture(state, compressedBc3ImageResource());
|
|
231
|
+
expect(gl.texImage2D).not.toHaveBeenCalled();
|
|
232
|
+
expect(gl.compressedTexImage2D).not.toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
144
236
|
describe('bindGlTexture', () => {
|
|
145
237
|
it('creates a new texture for an uncached image source', () => {
|
|
146
238
|
const { state, gl } = createGlState();
|
|
@@ -206,6 +298,198 @@ describe('bindGlTexture', () => {
|
|
|
206
298
|
expect(gl.bindTexture).toHaveBeenCalledWith((gl as unknown as { TEXTURE_2D: number }).TEXTURE_2D, texture);
|
|
207
299
|
expect(runtime.currentTexture).toBe(texture);
|
|
208
300
|
});
|
|
301
|
+
|
|
302
|
+
it('rebinds a cached texture even when it is already current, for multi-unit correctness', () => {
|
|
303
|
+
// The same image reused as two maps (e.g. normal + metallic-roughness) is bound to two active
|
|
304
|
+
// units in a row; currentTexture is unit-blind, so a skip would leave the second unit unbound.
|
|
305
|
+
const { state, gl } = createGlState();
|
|
306
|
+
const img = document.createElement('img');
|
|
307
|
+
const texture = bindGlTexture(state, img);
|
|
308
|
+
const g = gl as unknown as { TEXTURE_2D: number };
|
|
309
|
+
const bindsBefore = (gl.bindTexture as ReturnType<typeof vi.fn>).mock.calls.filter((c) => c[1] === texture).length;
|
|
310
|
+
// currentTexture === texture here; the second bind (a different active unit in practice) must not skip.
|
|
311
|
+
bindGlTexture(state, img);
|
|
312
|
+
const bindsAfter = (gl.bindTexture as ReturnType<typeof vi.fn>).mock.calls.filter((c) => c[1] === texture).length;
|
|
313
|
+
expect(bindsAfter).toBe(bindsBefore + 1);
|
|
314
|
+
expect(gl.bindTexture).toHaveBeenLastCalledWith(g.TEXTURE_2D, texture);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('defaults both wrap modes to clamp-to-edge', () => {
|
|
318
|
+
const { state, gl } = createGlState();
|
|
319
|
+
bindGlTexture(state, document.createElement('img'));
|
|
320
|
+
const g = gl as unknown as {
|
|
321
|
+
TEXTURE_2D: number;
|
|
322
|
+
TEXTURE_WRAP_S: number;
|
|
323
|
+
TEXTURE_WRAP_T: number;
|
|
324
|
+
CLAMP_TO_EDGE: number;
|
|
325
|
+
};
|
|
326
|
+
expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_S, g.CLAMP_TO_EDGE);
|
|
327
|
+
expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_T, g.CLAMP_TO_EDGE);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('applies the sampler wrap mode — repeat → REPEAT — so tiled uvs actually repeat', () => {
|
|
331
|
+
const { state, gl } = createGlState();
|
|
332
|
+
bindGlTexture(state, document.createElement('img'), makeSampler({ wrapU: 'repeat', wrapV: 'repeat' }));
|
|
333
|
+
const g = gl as unknown as { TEXTURE_2D: number; TEXTURE_WRAP_S: number; TEXTURE_WRAP_T: number; REPEAT: number };
|
|
334
|
+
expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_S, g.REPEAT);
|
|
335
|
+
expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_T, g.REPEAT);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('maps mirror-repeat to MIRRORED_REPEAT', () => {
|
|
339
|
+
const { state, gl } = createGlState();
|
|
340
|
+
bindGlTexture(
|
|
341
|
+
state,
|
|
342
|
+
document.createElement('img'),
|
|
343
|
+
makeSampler({ wrapU: 'mirror-repeat', wrapV: 'mirror-repeat' }),
|
|
344
|
+
);
|
|
345
|
+
const g = gl as unknown as {
|
|
346
|
+
TEXTURE_2D: number;
|
|
347
|
+
TEXTURE_WRAP_S: number;
|
|
348
|
+
TEXTURE_WRAP_T: number;
|
|
349
|
+
MIRRORED_REPEAT: number;
|
|
350
|
+
};
|
|
351
|
+
expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_S, g.MIRRORED_REPEAT);
|
|
352
|
+
expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_T, g.MIRRORED_REPEAT);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('re-applies wrap on a cache hit so a shared image follows the current draw sampler', () => {
|
|
356
|
+
// The torus in basic-shading reuses one jpg as both normal and metallic-roughness; if wrap were
|
|
357
|
+
// baked only at upload, the second material would inherit the first's wrap. Re-applying per bind
|
|
358
|
+
// means a repeat draw then a clamp draw each set their own wrap on the shared cached texture.
|
|
359
|
+
const { state, gl } = createGlState();
|
|
360
|
+
const img = document.createElement('img');
|
|
361
|
+
bindGlTexture(state, img, makeSampler({ wrapU: 'repeat', wrapV: 'repeat' }));
|
|
362
|
+
bindGlTexture(state, img, makeSampler({ wrapU: 'clamp-to-edge', wrapV: 'clamp-to-edge' }));
|
|
363
|
+
const g = gl as unknown as {
|
|
364
|
+
TEXTURE_2D: number;
|
|
365
|
+
TEXTURE_WRAP_S: number;
|
|
366
|
+
REPEAT: number;
|
|
367
|
+
CLAMP_TO_EDGE: number;
|
|
368
|
+
};
|
|
369
|
+
expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_S, g.REPEAT);
|
|
370
|
+
expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_S, g.CLAMP_TO_EDGE);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('falls back to the allowSmoothing LINEAR filter and clamp-to-edge when no sampler is given', () => {
|
|
374
|
+
// The 2D bitmap/sprite path passes no sampler and must keep its historical bilinear, no-mip,
|
|
375
|
+
// clamp default — a material sampler never leaks into it.
|
|
376
|
+
const { state, gl } = createGlState({ allowSmoothing: true });
|
|
377
|
+
bindGlTexture(state, document.createElement('img'));
|
|
378
|
+
const g = gl as unknown as {
|
|
379
|
+
TEXTURE_2D: number;
|
|
380
|
+
TEXTURE_MIN_FILTER: number;
|
|
381
|
+
TEXTURE_MAG_FILTER: number;
|
|
382
|
+
TEXTURE_WRAP_S: number;
|
|
383
|
+
LINEAR: number;
|
|
384
|
+
CLAMP_TO_EDGE: number;
|
|
385
|
+
};
|
|
386
|
+
expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_MIN_FILTER, g.LINEAR);
|
|
387
|
+
expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_MAG_FILTER, g.LINEAR);
|
|
388
|
+
expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_WRAP_S, g.CLAMP_TO_EDGE);
|
|
389
|
+
expect(gl.generateMipmap).not.toHaveBeenCalled();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('applies the sampler min/mag filter and generates a mip chain for a trilinear sampler', () => {
|
|
393
|
+
const { state, gl } = createGlState({ allowSmoothing: false });
|
|
394
|
+
bindGlTexture(state, document.createElement('img'), makeSampler());
|
|
395
|
+
const g = gl as unknown as {
|
|
396
|
+
TEXTURE_2D: number;
|
|
397
|
+
TEXTURE_MIN_FILTER: number;
|
|
398
|
+
TEXTURE_MAG_FILTER: number;
|
|
399
|
+
LINEAR: number;
|
|
400
|
+
LINEAR_MIPMAP_LINEAR: number;
|
|
401
|
+
};
|
|
402
|
+
// Sampler filters win over the state's allowSmoothing flag for material textures.
|
|
403
|
+
expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_MIN_FILTER, g.LINEAR_MIPMAP_LINEAR);
|
|
404
|
+
expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_MAG_FILTER, g.LINEAR);
|
|
405
|
+
expect(gl.generateMipmap).toHaveBeenCalledWith(g.TEXTURE_2D);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('collapses a mip-named min filter to LINEAR and skips mip generation when mipmaps is false', () => {
|
|
409
|
+
// minFilter names a mip level but mipmaps is off: selecting an absent chain would render black, so
|
|
410
|
+
// the filter must fall back to the base level and generateMipmap must not run.
|
|
411
|
+
const { state, gl } = createGlState();
|
|
412
|
+
bindGlTexture(state, document.createElement('img'), makeSampler({ mipmaps: false }));
|
|
413
|
+
const g = gl as unknown as { TEXTURE_2D: number; TEXTURE_MIN_FILTER: number; LINEAR: number };
|
|
414
|
+
expect(gl.texParameteri).toHaveBeenCalledWith(g.TEXTURE_2D, g.TEXTURE_MIN_FILTER, g.LINEAR);
|
|
415
|
+
expect(gl.generateMipmap).not.toHaveBeenCalled();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('generates the mip chain only once for a texture bound repeatedly', () => {
|
|
419
|
+
const { state, gl } = createGlState();
|
|
420
|
+
const img = document.createElement('img');
|
|
421
|
+
bindGlTexture(state, img, makeSampler());
|
|
422
|
+
bindGlTexture(state, img, makeSampler());
|
|
423
|
+
expect((gl.generateMipmap as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('applies clamped anisotropy through EXT_texture_filter_anisotropic when supported', () => {
|
|
427
|
+
const { state, gl } = createGlState();
|
|
428
|
+
const ext = { TEXTURE_MAX_ANISOTROPY_EXT: 0x84fe, MAX_TEXTURE_MAX_ANISOTROPY_EXT: 0x84ff };
|
|
429
|
+
(gl.getExtension as ReturnType<typeof vi.fn>).mockImplementation((name: string) =>
|
|
430
|
+
name === 'EXT_texture_filter_anisotropic' ? ext : null,
|
|
431
|
+
);
|
|
432
|
+
(gl.getParameter as ReturnType<typeof vi.fn>).mockImplementation((p: number) =>
|
|
433
|
+
p === ext.MAX_TEXTURE_MAX_ANISOTROPY_EXT ? 8 : undefined,
|
|
434
|
+
);
|
|
435
|
+
bindGlTexture(state, document.createElement('img'), makeSampler({ anisotropy: 16 }));
|
|
436
|
+
const g = gl as unknown as { TEXTURE_2D: number };
|
|
437
|
+
// Requested 16 clamps to the hardware max of 8.
|
|
438
|
+
expect(gl.texParameterf).toHaveBeenCalledWith(g.TEXTURE_2D, ext.TEXTURE_MAX_ANISOTROPY_EXT, 8);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('skips anisotropy setup when the extension is unavailable', () => {
|
|
442
|
+
const { state, gl } = createGlState();
|
|
443
|
+
(gl.getExtension as ReturnType<typeof vi.fn>).mockImplementation(() => null);
|
|
444
|
+
bindGlTexture(state, document.createElement('img'), makeSampler({ anisotropy: 16 }));
|
|
445
|
+
expect(gl.texParameterf).not.toHaveBeenCalled();
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
describe('bindGlVideoTexture', () => {
|
|
450
|
+
function videoTexture(frameId: number, readyState = 4, videoWidth = 320, videoHeight = 240): VideoTexture {
|
|
451
|
+
return {
|
|
452
|
+
frameId,
|
|
453
|
+
sampler: null,
|
|
454
|
+
source: { element: { readyState, videoWidth, videoHeight } as unknown as HTMLVideoElement },
|
|
455
|
+
} as unknown as VideoTexture;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
it('creates a texture, uploads the current frame, and caches by VideoTexture identity', () => {
|
|
459
|
+
const { state, gl } = createGlState();
|
|
460
|
+
const vt = videoTexture(3);
|
|
461
|
+
const t1 = bindGlVideoTexture(state, vt);
|
|
462
|
+
expect(gl.createTexture).toHaveBeenCalled();
|
|
463
|
+
expect(gl.texImage2D).toHaveBeenCalledWith(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, vt.source.element);
|
|
464
|
+
const t2 = bindGlVideoTexture(state, vt);
|
|
465
|
+
expect(t2).toBe(t1);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('re-uploads only when the frameId advances (the dirty-gate)', () => {
|
|
469
|
+
const { state, gl } = createGlState();
|
|
470
|
+
const vt = videoTexture(1);
|
|
471
|
+
bindGlVideoTexture(state, vt);
|
|
472
|
+
const uploads = (gl.texImage2D as ReturnType<typeof vi.fn>).mock.calls.length;
|
|
473
|
+
bindGlVideoTexture(state, vt);
|
|
474
|
+
expect((gl.texImage2D as ReturnType<typeof vi.fn>).mock.calls.length).toBe(uploads);
|
|
475
|
+
vt.frameId = 2;
|
|
476
|
+
bindGlVideoTexture(state, vt);
|
|
477
|
+
expect((gl.texImage2D as ReturnType<typeof vi.fn>).mock.calls.length).toBe(uploads + 1);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('does not upload when the element has no decoded frame yet', () => {
|
|
481
|
+
const { state, gl } = createGlState();
|
|
482
|
+
bindGlVideoTexture(state, videoTexture(1, 1, 0, 0));
|
|
483
|
+
expect(gl.texImage2D).not.toHaveBeenCalled();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('applies the VideoTexture sampler when no explicit sampler is passed', () => {
|
|
487
|
+
const { state, gl } = createGlState();
|
|
488
|
+
const vt = videoTexture(1);
|
|
489
|
+
(vt as { sampler: SamplerLike }).sampler = makeSampler({ wrapU: 'repeat' });
|
|
490
|
+
bindGlVideoTexture(state, vt);
|
|
491
|
+
expect(gl.texParameteri).toHaveBeenCalledWith(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
|
|
492
|
+
});
|
|
209
493
|
});
|
|
210
494
|
|
|
211
495
|
describe('createGlTexture', () => {
|
|
@@ -346,10 +630,10 @@ describe('isBlendModeSupported', () => {
|
|
|
346
630
|
expect(isBlendModeSupported(state, BlendMode.Screen)).toBe(true);
|
|
347
631
|
});
|
|
348
632
|
|
|
349
|
-
it('returns false for
|
|
633
|
+
it('returns false for an advanced mode with no fixed-function realization', () => {
|
|
350
634
|
const { state } = createGlState();
|
|
351
635
|
registerDefaultGlBlendModes(state);
|
|
352
|
-
expect(isBlendModeSupported(state,
|
|
636
|
+
expect(isBlendModeSupported(state, AdvancedBlendMode.Overlay)).toBe(false);
|
|
353
637
|
});
|
|
354
638
|
|
|
355
639
|
it('returns true for a custom registered mode', () => {
|
|
@@ -365,23 +649,20 @@ describe('registerDefaultGlBlendModes', () => {
|
|
|
365
649
|
registerDefaultGlBlendModes(state);
|
|
366
650
|
for (const mode of [
|
|
367
651
|
BlendMode.Normal,
|
|
368
|
-
BlendMode.Layer,
|
|
369
652
|
BlendMode.Add,
|
|
370
653
|
BlendMode.Multiply,
|
|
371
654
|
BlendMode.Screen,
|
|
372
655
|
BlendMode.Darken,
|
|
373
656
|
BlendMode.Lighten,
|
|
374
|
-
BlendMode.Subtract,
|
|
375
|
-
BlendMode.Erase,
|
|
376
657
|
]) {
|
|
377
658
|
expect(isBlendModeSupported(state, mode)).toBe(true);
|
|
378
659
|
}
|
|
379
660
|
});
|
|
380
661
|
|
|
381
|
-
it('does not register the shader-
|
|
662
|
+
it('does not register the shader-composited or unary modes', () => {
|
|
382
663
|
const { state } = createGlState();
|
|
383
664
|
registerDefaultGlBlendModes(state);
|
|
384
|
-
for (const mode of [
|
|
665
|
+
for (const mode of [AdvancedBlendMode.Overlay, AdvancedBlendMode.HardLight, AdvancedBlendMode.Difference]) {
|
|
385
666
|
expect(isBlendModeSupported(state, mode)).toBe(false);
|
|
386
667
|
}
|
|
387
668
|
});
|
|
@@ -449,12 +730,14 @@ describe('updateGlTexture', () => {
|
|
|
449
730
|
expect(runtime.currentTexture).toBe(texture);
|
|
450
731
|
});
|
|
451
732
|
|
|
452
|
-
it('
|
|
733
|
+
it('rebinds even when the texture is already current (currentTexture is unit-blind)', () => {
|
|
453
734
|
const { state, gl } = createGlState();
|
|
454
735
|
const texture = {} as WebGLTexture;
|
|
455
736
|
getGlRenderStateRuntime(state).currentTexture = texture;
|
|
456
737
|
updateGlTexture(state, texture, document.createElement('canvas'));
|
|
457
|
-
|
|
738
|
+
// Never skip on currentTexture: the active unit may have changed since it was set, so uploading
|
|
739
|
+
// without rebinding could target the wrong texture.
|
|
740
|
+
expect(gl.bindTexture).toHaveBeenCalledWith((gl as unknown as { TEXTURE_2D: number }).TEXTURE_2D, texture);
|
|
458
741
|
});
|
|
459
742
|
|
|
460
743
|
it('always calls texImage2D to upload canvas data', () => {
|
|
@@ -19,6 +19,9 @@ function makeTarget(framebuffer: WebGLFramebuffer, width = 32, height = 16): GlR
|
|
|
19
19
|
width,
|
|
20
20
|
height,
|
|
21
21
|
format: 'rgba8',
|
|
22
|
+
colorSpace: 'srgb',
|
|
23
|
+
clearColors: [],
|
|
24
|
+
clearDepth: 1,
|
|
22
25
|
sampleCount: 1,
|
|
23
26
|
framebuffer,
|
|
24
27
|
resolveFramebuffer: null,
|
|
@@ -172,14 +175,32 @@ describe('drawGlFullscreenPass', () => {
|
|
|
172
175
|
expect(drawSpy).toHaveBeenCalled();
|
|
173
176
|
});
|
|
174
177
|
|
|
175
|
-
it('sets premultiplied-alpha blending and
|
|
178
|
+
it('sets premultiplied-alpha blending and caches the normal blend mode', () => {
|
|
176
179
|
const { state, gl } = createGlState();
|
|
177
180
|
const program = compileGlFullscreenProgram(gl, FRAG_SRC);
|
|
178
181
|
const blendSpy = vi.spyOn(gl, 'blendFunc');
|
|
182
|
+
const blendEquationSpy = vi.spyOn(gl, 'blendEquation');
|
|
179
183
|
|
|
180
184
|
drawGlFullscreenPass(state, program, [], null, () => {});
|
|
181
185
|
|
|
182
186
|
expect(blendSpy).toHaveBeenCalledWith(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
|
187
|
+
expect(blendEquationSpy).toHaveBeenCalledWith(gl.FUNC_ADD);
|
|
188
|
+
expect(getGlRenderStateRuntime(state).currentBlendMode).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('restores normal blending when setUniforms overrides the blend function', () => {
|
|
192
|
+
const { state, gl } = createGlState();
|
|
193
|
+
const program = compileGlFullscreenProgram(gl, FRAG_SRC);
|
|
194
|
+
const blendSpy = vi.spyOn(gl, 'blendFunc');
|
|
195
|
+
const blendEquationSpy = vi.spyOn(gl, 'blendEquation');
|
|
196
|
+
|
|
197
|
+
drawGlFullscreenPass(state, program, [], null, (gl) => {
|
|
198
|
+
gl.blendFunc(gl.ZERO, gl.ONE_MINUS_SRC_ALPHA);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(blendSpy).toHaveBeenCalledWith(gl.ZERO, gl.ONE_MINUS_SRC_ALPHA);
|
|
202
|
+
expect(blendSpy).toHaveBeenLastCalledWith(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
|
203
|
+
expect(blendEquationSpy).toHaveBeenLastCalledWith(gl.FUNC_ADD);
|
|
183
204
|
expect(getGlRenderStateRuntime(state).currentBlendMode).toBeNull();
|
|
184
205
|
});
|
|
185
206
|
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { GlRenderTarget } from '@flighthq/types';
|
|
2
|
+
|
|
3
|
+
import { drawGlLinearToSrgbPass, LINEAR_TO_SRGB_FRAGMENT_SRC } from './glLinearToSrgbPass';
|
|
4
|
+
import { createGlState } from './glTestHelper';
|
|
5
|
+
|
|
6
|
+
function makeTarget(framebuffer: WebGLFramebuffer, texture: WebGLTexture, width = 32, height = 16): GlRenderTarget {
|
|
7
|
+
return {
|
|
8
|
+
width,
|
|
9
|
+
height,
|
|
10
|
+
format: 'rgba16f',
|
|
11
|
+
colorSpace: 'linear',
|
|
12
|
+
clearColors: [],
|
|
13
|
+
clearDepth: 1,
|
|
14
|
+
sampleCount: 1,
|
|
15
|
+
framebuffer,
|
|
16
|
+
resolveFramebuffer: null,
|
|
17
|
+
textures: [],
|
|
18
|
+
texture,
|
|
19
|
+
depthTexture: null,
|
|
20
|
+
colorRenderbuffers: [],
|
|
21
|
+
depthStencilRenderbuffer: null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('drawGlLinearToSrgbPass', () => {
|
|
26
|
+
it('binds the source texture and draws to the canvas when dest is null', () => {
|
|
27
|
+
const { state, gl, canvas } = createGlState();
|
|
28
|
+
const source = makeTarget({} as WebGLFramebuffer, { id: 'src' } as unknown as WebGLTexture);
|
|
29
|
+
const bindTexture = vi.spyOn(gl, 'bindTexture');
|
|
30
|
+
const viewport = vi.spyOn(gl, 'viewport');
|
|
31
|
+
const drawElements = vi.spyOn(gl, 'drawElements');
|
|
32
|
+
|
|
33
|
+
drawGlLinearToSrgbPass(state, source, null);
|
|
34
|
+
|
|
35
|
+
expect(bindTexture).toHaveBeenCalledWith(gl.TEXTURE_2D, source.texture);
|
|
36
|
+
// dest === null presents to the canvas, so the viewport covers the full canvas.
|
|
37
|
+
expect(viewport).toHaveBeenCalledWith(0, 0, canvas.width, canvas.height);
|
|
38
|
+
expect(drawElements).toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('presents into dest when a target is given', () => {
|
|
42
|
+
const { state, gl } = createGlState();
|
|
43
|
+
const source = makeTarget({} as WebGLFramebuffer, { id: 'src' } as unknown as WebGLTexture);
|
|
44
|
+
const dest = makeTarget({ id: 'destFb' } as unknown as WebGLFramebuffer, { id: 'dst' } as unknown as WebGLTexture);
|
|
45
|
+
const bindFramebuffer = vi.spyOn(gl, 'bindFramebuffer');
|
|
46
|
+
|
|
47
|
+
drawGlLinearToSrgbPass(state, source, dest);
|
|
48
|
+
|
|
49
|
+
expect(bindFramebuffer).toHaveBeenCalledWith(gl.FRAMEBUFFER, dest.framebuffer);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('compiles its program once per state and reuses it across frames', () => {
|
|
53
|
+
const { state, gl } = createGlState();
|
|
54
|
+
const source = makeTarget({} as WebGLFramebuffer, {} as WebGLTexture);
|
|
55
|
+
const createProgram = vi.spyOn(gl, 'createProgram');
|
|
56
|
+
|
|
57
|
+
drawGlLinearToSrgbPass(state, source, null);
|
|
58
|
+
drawGlLinearToSrgbPass(state, source, null);
|
|
59
|
+
drawGlLinearToSrgbPass(state, source, null);
|
|
60
|
+
|
|
61
|
+
expect(createProgram).toHaveBeenCalledTimes(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('encodes the exact IEC 61966-2-1 sRGB OETF constants (parity with color.linearChannelToSrgb)', () => {
|
|
65
|
+
// Guards against the shader drifting from @flighthq/color's canonical transfer, which cannot be
|
|
66
|
+
// executed here (jsdom does not compile GLSL). The constants are the piecewise sRGB OETF.
|
|
67
|
+
expect(LINEAR_TO_SRGB_FRAGMENT_SRC).toContain('0.0031308');
|
|
68
|
+
expect(LINEAR_TO_SRGB_FRAGMENT_SRC).toContain('12.92');
|
|
69
|
+
expect(LINEAR_TO_SRGB_FRAGMENT_SRC).toContain('1.055');
|
|
70
|
+
expect(LINEAR_TO_SRGB_FRAGMENT_SRC).toContain('1.0 / 2.4');
|
|
71
|
+
expect(LINEAR_TO_SRGB_FRAGMENT_SRC).toContain('0.055');
|
|
72
|
+
// Alpha must pass through unencoded — it is linear coverage, not a color channel.
|
|
73
|
+
expect(LINEAR_TO_SRGB_FRAGMENT_SRC).toContain('linear.a');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { GlRenderTarget } from '@flighthq/types';
|
|
2
|
+
|
|
3
|
+
import { presentGlRenderTarget } from './glPresentRenderTarget';
|
|
4
|
+
import { createGlState } from './glTestHelper';
|
|
5
|
+
|
|
6
|
+
function makeTarget(colorSpace: 'linear' | 'srgb', texture: WebGLTexture): GlRenderTarget {
|
|
7
|
+
return {
|
|
8
|
+
width: 32,
|
|
9
|
+
height: 16,
|
|
10
|
+
format: colorSpace === 'linear' ? 'rgba16f' : 'rgba8',
|
|
11
|
+
colorSpace,
|
|
12
|
+
clearColors: [],
|
|
13
|
+
clearDepth: 1,
|
|
14
|
+
sampleCount: 1,
|
|
15
|
+
framebuffer: {} as WebGLFramebuffer,
|
|
16
|
+
resolveFramebuffer: null,
|
|
17
|
+
textures: [texture],
|
|
18
|
+
texture,
|
|
19
|
+
depthTexture: null,
|
|
20
|
+
colorRenderbuffers: [],
|
|
21
|
+
depthStencilRenderbuffer: null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('presentGlRenderTarget', () => {
|
|
26
|
+
it('encodes a linear target to sRGB and draws to the canvas when dest is null', () => {
|
|
27
|
+
const { state, gl, canvas } = createGlState();
|
|
28
|
+
const source = makeTarget('linear', { id: 'lin' } as unknown as WebGLTexture);
|
|
29
|
+
const bindTexture = vi.spyOn(gl, 'bindTexture');
|
|
30
|
+
const viewport = vi.spyOn(gl, 'viewport');
|
|
31
|
+
const drawElements = vi.spyOn(gl, 'drawElements');
|
|
32
|
+
|
|
33
|
+
presentGlRenderTarget(state, source);
|
|
34
|
+
|
|
35
|
+
expect(bindTexture).toHaveBeenCalledWith(gl.TEXTURE_2D, source.texture);
|
|
36
|
+
expect(viewport).toHaveBeenCalledWith(0, 0, canvas.width, canvas.height);
|
|
37
|
+
expect(drawElements).toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('copies an srgb target straight through (already encoded, no OETF pass)', () => {
|
|
41
|
+
const { state, gl } = createGlState();
|
|
42
|
+
const source = makeTarget('srgb', { id: 'srgb' } as unknown as WebGLTexture);
|
|
43
|
+
const bindTexture = vi.spyOn(gl, 'bindTexture');
|
|
44
|
+
const drawElements = vi.spyOn(gl, 'drawElements');
|
|
45
|
+
|
|
46
|
+
presentGlRenderTarget(state, source);
|
|
47
|
+
|
|
48
|
+
expect(bindTexture).toHaveBeenCalledWith(gl.TEXTURE_2D, source.texture);
|
|
49
|
+
expect(drawElements).toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('presents into dest when a target is given', () => {
|
|
53
|
+
const { state, gl } = createGlState();
|
|
54
|
+
const source = makeTarget('srgb', { id: 'srgb' } as unknown as WebGLTexture);
|
|
55
|
+
const dest = makeTarget('srgb', { id: 'dst' } as unknown as WebGLTexture);
|
|
56
|
+
const bindFramebuffer = vi.spyOn(gl, 'bindFramebuffer');
|
|
57
|
+
|
|
58
|
+
presentGlRenderTarget(state, source, dest);
|
|
59
|
+
|
|
60
|
+
expect(bindFramebuffer).toHaveBeenCalledWith(gl.FRAMEBUFFER, dest.framebuffer);
|
|
61
|
+
});
|
|
62
|
+
});
|
package/src/glReadback.test.ts
CHANGED