@flighthq/effects-gl 0.3.0 → 0.3.1-next.1041.35b950c
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/enableGlRenderEffectGuards.d.ts.map +1 -1
- package/dist/enableGlRenderEffectGuards.js +35 -1
- package/dist/enableGlRenderEffectGuards.js.map +1 -1
- package/dist/glBevelEffect.d.ts.map +1 -1
- package/dist/glBevelEffect.js +11 -5
- package/dist/glBevelEffect.js.map +1 -1
- package/dist/glConvolutionEffect.d.ts.map +1 -1
- package/dist/glConvolutionEffect.js +5 -1
- package/dist/glConvolutionEffect.js.map +1 -1
- package/dist/glCrtEffect.js +3 -1
- package/dist/glCrtEffect.js.map +1 -1
- package/dist/glCustomShaderEffect.d.ts +3 -1
- package/dist/glCustomShaderEffect.d.ts.map +1 -1
- package/dist/glCustomShaderEffect.js +34 -12
- package/dist/glCustomShaderEffect.js.map +1 -1
- package/dist/glDisplacementEffect.js +8 -2
- package/dist/glDisplacementEffect.js.map +1 -1
- package/dist/glDropShadowEffect.d.ts.map +1 -1
- package/dist/glDropShadowEffect.js +3 -1
- package/dist/glDropShadowEffect.js.map +1 -1
- package/dist/glEffectTintShader.d.ts.map +1 -1
- package/dist/glEffectTintShader.js +9 -4
- package/dist/glEffectTintShader.js.map +1 -1
- package/dist/glGlitchEffect.js +5 -1
- package/dist/glGlitchEffect.js.map +1 -1
- package/dist/glGradientBevelEffect.js +1 -1
- package/dist/glGradientBevelEffect.js.map +1 -1
- package/dist/glGradientGlowEffect.js +1 -1
- package/dist/glGradientGlowEffect.js.map +1 -1
- package/dist/glInnerGlowEffect.d.ts.map +1 -1
- package/dist/glInnerGlowEffect.js +8 -8
- package/dist/glInnerGlowEffect.js.map +1 -1
- package/dist/glInnerShadowEffect.d.ts.map +1 -1
- package/dist/glInnerShadowEffect.js +8 -8
- package/dist/glInnerShadowEffect.js.map +1 -1
- package/dist/glOuterGlowEffect.js +1 -1
- package/dist/glOuterGlowEffect.js.map +1 -1
- package/dist/glRenderEffectPipeline.d.ts +2 -1
- package/dist/glRenderEffectPipeline.d.ts.map +1 -1
- package/dist/glRenderEffectPipeline.js +16 -1
- package/dist/glRenderEffectPipeline.js.map +1 -1
- package/dist/glRenderEffectRegistry.d.ts +3 -2
- package/dist/glRenderEffectRegistry.d.ts.map +1 -1
- package/dist/glRenderEffectRegistry.js +25 -4
- package/dist/glRenderEffectRegistry.js.map +1 -1
- package/dist/glRenderTextureEffect.d.ts.map +1 -1
- package/dist/glRenderTextureEffect.js +21 -6
- package/dist/glRenderTextureEffect.js.map +1 -1
- package/dist/glScanlinesEffect.js +3 -1
- package/dist/glScanlinesEffect.js.map +1 -1
- package/dist/glScreenSpaceFogEffect.js +6 -3
- package/dist/glScreenSpaceFogEffect.js.map +1 -1
- package/dist/glTiltShiftEffect.js +4 -1
- package/dist/glTiltShiftEffect.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +9 -7
- package/src/enableGlRenderEffectGuards.test.ts +71 -0
- package/src/glBevelEffect.test.ts +98 -0
- package/src/glConvolutionEffect.test.ts +60 -0
- package/src/glCustomShaderEffect.test.ts +109 -3
- package/src/glDropShadowEffect.test.ts +19 -0
- package/src/glEffectTintShader.test.ts +66 -0
- package/src/glInnerGlowEffect.test.ts +1 -1
- package/src/glInnerShadowEffect.test.ts +1 -1
- package/src/glRenderEffectPipeline.test.ts +25 -0
- package/src/glRenderEffectRegistry.test.ts +40 -2
- package/src/glRenderTextureEffect.test.ts +57 -0
|
@@ -12,6 +12,12 @@ import {
|
|
|
12
12
|
disableGlRenderEffectGuards,
|
|
13
13
|
enableGlRenderEffectGuards,
|
|
14
14
|
} from './enableGlRenderEffectGuards';
|
|
15
|
+
import { registerGlCustomShaderSource } from './glCustomShaderEffect';
|
|
16
|
+
import {
|
|
17
|
+
beginGlRenderEffectPipeline,
|
|
18
|
+
createGlRenderEffectPipeline,
|
|
19
|
+
endGlRenderEffectPipeline,
|
|
20
|
+
} from './glRenderEffectPipeline';
|
|
15
21
|
import { registerGlRenderEffect } from './glRenderEffectRegistry';
|
|
16
22
|
import { applyGlRenderEffectsToRenderTexture } from './glRenderTextureEffect';
|
|
17
23
|
|
|
@@ -93,6 +99,71 @@ describe('enableGlRenderEffectGuards', () => {
|
|
|
93
99
|
expect(messageOf(entries[0])).toContain('handle the false return');
|
|
94
100
|
});
|
|
95
101
|
|
|
102
|
+
it('WARNS that re-registered shader source will NOT run, because the program is cached by key', () => {
|
|
103
|
+
const state = createState();
|
|
104
|
+
enableGlRenderEffectGuards(state);
|
|
105
|
+
|
|
106
|
+
const entries = captureLog(() => {
|
|
107
|
+
registerGlCustomShaderSource(state, 'test.reregistered-a', 'void main() { o_color = vec4(1.0); }');
|
|
108
|
+
// Nothing fails and nothing throws — the edit simply never reaches the GPU.
|
|
109
|
+
registerGlCustomShaderSource(state, 'test.reregistered-a', 'void main() { o_color = vec4(0.0); }');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(entries.length).toBe(1);
|
|
113
|
+
expect(messageOf(entries[0])).toContain('will NOT run');
|
|
114
|
+
expect(messageOf(entries[0])).toContain('test.reregistered-a');
|
|
115
|
+
|
|
116
|
+
// Negative control for the guard itself: with the guard OFF the same sequence says nothing, so the
|
|
117
|
+
// assertion above is measuring the guard rather than some other source of log traffic.
|
|
118
|
+
disableGlRenderEffectGuards(state);
|
|
119
|
+
const afterDisable = captureLog(() => {
|
|
120
|
+
registerGlCustomShaderSource(state, 'test.reregistered-b', 'void main() { o_color = vec4(1.0); }');
|
|
121
|
+
registerGlCustomShaderSource(state, 'test.reregistered-b', 'void main() { o_color = vec4(0.0); }');
|
|
122
|
+
});
|
|
123
|
+
expect(afterDisable.length).toBe(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('WARNS that an unresolvable effect COPIED THROUGH rather than being dropped', () => {
|
|
127
|
+
const state = createState();
|
|
128
|
+
registerGlRenderEffect(state, 'test.unresolved-a', noopRunner, () => false);
|
|
129
|
+
enableGlRenderEffectGuards(state);
|
|
130
|
+
|
|
131
|
+
const entries = captureLog(() => {
|
|
132
|
+
// It returns TRUE and writes dest — the pass ran, it just did nothing. That is the whole hazard.
|
|
133
|
+
expect(applyChain(state, ['test.unresolved-a'])).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(entries.length).toBe(1);
|
|
137
|
+
// The wrong picture is passthrough, not drop; saying "dropped" here would send the reader to the
|
|
138
|
+
// registration call, which is already correct.
|
|
139
|
+
expect(messageOf(entries[0])).toContain('COPIED THE INPUT THROUGH UNCHANGED');
|
|
140
|
+
expect(messageOf(entries[0])).not.toContain('SKIPPED');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('WARNS that a pipeline pass DROPPED an effect kind with no runner, once per kind', () => {
|
|
144
|
+
const state = createState();
|
|
145
|
+
enableGlRenderEffectGuards(state);
|
|
146
|
+
const pipeline = createGlRenderEffectPipeline(state);
|
|
147
|
+
|
|
148
|
+
const entries = captureLog(() => {
|
|
149
|
+
beginGlRenderEffectPipeline(state, pipeline);
|
|
150
|
+
endGlRenderEffectPipeline(state, pipeline, [{ kind: 'test.pipeline-dropped-kind' } as RenderEffect]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(entries).toHaveLength(1);
|
|
154
|
+
expect(messageOf(entries[0])).toContain('test.pipeline-dropped-kind');
|
|
155
|
+
expect(messageOf(entries[0])).toContain('SKIPPED');
|
|
156
|
+
|
|
157
|
+
// Once per KIND, not once per frame: a chain missing the same effect every frame is one
|
|
158
|
+
// observation, and a warning that repeated per frame would be its own defect.
|
|
159
|
+
const again = captureLog(() => {
|
|
160
|
+
beginGlRenderEffectPipeline(state, pipeline);
|
|
161
|
+
endGlRenderEffectPipeline(state, pipeline, [{ kind: 'test.pipeline-dropped-kind' } as RenderEffect]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(again).toHaveLength(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
96
167
|
it('stays SILENT for an empty chain, which is a no-op the caller asked for rather than a miss', () => {
|
|
97
168
|
const state = createState();
|
|
98
169
|
enableGlRenderEffectGuards(state);
|
|
@@ -1,9 +1,107 @@
|
|
|
1
|
+
// The highlight/shadow colors are packed RGBA and their alpha multiplies the matching *Alpha field, the
|
|
2
|
+
// same arithmetic BitmapBevelOptions has always used offscreen. The composite shader still takes RGB plus
|
|
3
|
+
// one alpha, so the split happens in the runner — which makes the uniform the only place the migration is
|
|
4
|
+
// observable, and the reason this file mocks the draw seam rather than asserting on pixels.
|
|
5
|
+
const glMock = vi.hoisted(() => ({
|
|
6
|
+
uniform1f: vi.fn(),
|
|
7
|
+
uniform2f: vi.fn(),
|
|
8
|
+
uniform4f: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.hoisted(() => {
|
|
12
|
+
vi.resetModules();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
vi.mock('@flighthq/render-gl/contract', () => {
|
|
16
|
+
let nextTargetId = 0;
|
|
17
|
+
return {
|
|
18
|
+
acquireGlRenderTarget: vi.fn((_state, _pool, descriptor) => ({
|
|
19
|
+
...descriptor,
|
|
20
|
+
id: `scratch-${nextTargetId++}`,
|
|
21
|
+
texture: {},
|
|
22
|
+
})),
|
|
23
|
+
clearGlRenderTarget: vi.fn(),
|
|
24
|
+
compileGlFullscreenProgram: vi.fn(() => ({
|
|
25
|
+
locHighlight: 'u_highlight',
|
|
26
|
+
locShadow: 'u_shadow',
|
|
27
|
+
program: {},
|
|
28
|
+
})),
|
|
29
|
+
drawGlFullscreenPass: vi.fn((_state, _loc, _textures, _dest, setUniforms) => {
|
|
30
|
+
setUniforms(glMock as never, {} as never);
|
|
31
|
+
}),
|
|
32
|
+
releaseGlRenderTarget: vi.fn(),
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
vi.mock('./glEffectBlitShader', () => ({
|
|
37
|
+
applyGlEffectBlitOffsetPass: vi.fn(),
|
|
38
|
+
applyGlEffectBlitPass: vi.fn(),
|
|
39
|
+
applyGlEffectErasePass: vi.fn(),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
vi.mock('./glEffectBoxBlur', () => ({
|
|
43
|
+
applyGlEffectBoxBlur: vi.fn(),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
vi.mock('./glEffectTintShader', () => ({
|
|
47
|
+
applyGlEffectTintPass: vi.fn(),
|
|
48
|
+
}));
|
|
49
|
+
|
|
1
50
|
import { applyBevelEffectToGl, defaultGlBevelEffectRunner, registerGlBevelEffect } from './glBevelEffect';
|
|
2
51
|
|
|
52
|
+
// The runner caches its compiled locations per `state.gl`, and looks the uniform names up itself, so the
|
|
53
|
+
// fake context must answer getUniformLocation with the name — which is also what makes the assertions
|
|
54
|
+
// below readable as uniform names rather than opaque handles.
|
|
55
|
+
const state = { gl: { getUniformLocation: (_program: unknown, name: string) => name } } as never;
|
|
56
|
+
const target = { height: 4, texture: {}, width: 4 } as never;
|
|
57
|
+
const pool = { free: [], inUse: [] } as never;
|
|
58
|
+
|
|
59
|
+
function uniformFor(name: string): readonly number[] {
|
|
60
|
+
const call = glMock.uniform4f.mock.calls.find((entry) => entry[0] === name);
|
|
61
|
+
if (call === undefined) throw new Error(`no uniform4f call for ${name}`);
|
|
62
|
+
return call.slice(1) as readonly number[];
|
|
63
|
+
}
|
|
64
|
+
|
|
3
65
|
describe('applyBevelEffectToGl', () => {
|
|
4
66
|
it('is a function', () => {
|
|
5
67
|
expect(typeof applyBevelEffectToGl).toBe('function');
|
|
6
68
|
});
|
|
69
|
+
|
|
70
|
+
it('splits each packed RGBA color into composite RGB and an alpha multiplied by its *Alpha field', () => {
|
|
71
|
+
glMock.uniform4f.mockClear();
|
|
72
|
+
|
|
73
|
+
applyBevelEffectToGl(state, target, target, pool, {
|
|
74
|
+
highlightAlpha: 0.5,
|
|
75
|
+
highlightColor: 0x9d55ff80,
|
|
76
|
+
kind: 'BevelEffect',
|
|
77
|
+
shadowAlpha: 1,
|
|
78
|
+
shadowColor: 0x102030c0,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Under the 24-bit reading these same values would light the bevel 0x55ff80 and shade it 0x2030c0 —
|
|
82
|
+
// both different colors, both plausible, at full opacity. That is the 0x44ffee failure shape built
|
|
83
|
+
// into the API surface by construction rather than by a typo, which is what this unification removes.
|
|
84
|
+
const [highlightRed, highlightGreen, highlightBlue, highlightAlpha] = uniformFor('u_highlight');
|
|
85
|
+
expect(highlightRed).toBeCloseTo(0x9d / 255, 5);
|
|
86
|
+
expect(highlightGreen).toBeCloseTo(0x55 / 255, 5);
|
|
87
|
+
expect(highlightBlue).toBeCloseTo(1, 5);
|
|
88
|
+
expect(highlightAlpha).toBeCloseTo((0x80 / 255) * 0.5, 5);
|
|
89
|
+
|
|
90
|
+
const [shadowRed, shadowGreen, shadowBlue, shadowAlpha] = uniformFor('u_shadow');
|
|
91
|
+
expect(shadowRed).toBeCloseTo(0x10 / 255, 5);
|
|
92
|
+
expect(shadowGreen).toBeCloseTo(0x20 / 255, 5);
|
|
93
|
+
expect(shadowBlue).toBeCloseTo(0x30 / 255, 5);
|
|
94
|
+
expect(shadowAlpha).toBeCloseTo(0xc0 / 255, 5);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('defaults to opaque white over opaque black, the pair the pre-migration defaults produced', () => {
|
|
98
|
+
glMock.uniform4f.mockClear();
|
|
99
|
+
|
|
100
|
+
applyBevelEffectToGl(state, target, target, pool, { kind: 'BevelEffect' });
|
|
101
|
+
|
|
102
|
+
expect(uniformFor('u_highlight')).toEqual([1, 1, 1, 1]);
|
|
103
|
+
expect(uniformFor('u_shadow')).toEqual([0, 0, 0, 1]);
|
|
104
|
+
});
|
|
7
105
|
});
|
|
8
106
|
|
|
9
107
|
describe('defaultGlBevelEffectRunner', () => {
|
|
@@ -1,13 +1,73 @@
|
|
|
1
|
+
// The edge color is the one part of this runner with a channel ORDER to get wrong, and it carried a
|
|
2
|
+
// packing — 0xAARRGGBB, alpha in the high byte — that no other color in the SDK used. The uniform
|
|
3
|
+
// assertion below is what makes the order a fact rather than a reading of four shift expressions.
|
|
4
|
+
const glMock = vi.hoisted(() => ({
|
|
5
|
+
getUniformLocation: vi.fn((_program: unknown, name: string) => name),
|
|
6
|
+
uniform1f: vi.fn(),
|
|
7
|
+
uniform1fv: vi.fn(),
|
|
8
|
+
uniform1i: vi.fn(),
|
|
9
|
+
uniform2f: vi.fn(),
|
|
10
|
+
uniform4f: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.hoisted(() => {
|
|
14
|
+
vi.resetModules();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
vi.mock('@flighthq/render-gl/contract', () => ({
|
|
18
|
+
drawGlFullscreenPass: vi.fn((_state, _program, _textures, _dest, setUniforms) => {
|
|
19
|
+
setUniforms(glMock as never, { program: {} } as never);
|
|
20
|
+
}),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock('./glEffectProgramCache', () => ({
|
|
24
|
+
getGlEffectProgram: vi.fn(() => ({ program: {} })),
|
|
25
|
+
}));
|
|
26
|
+
|
|
1
27
|
import {
|
|
2
28
|
applyConvolutionEffectToGl,
|
|
3
29
|
defaultGlConvolutionEffectRunner,
|
|
4
30
|
registerGlConvolutionEffect,
|
|
5
31
|
} from './glConvolutionEffect';
|
|
6
32
|
|
|
33
|
+
const target = { height: 4, texture: {}, width: 4 } as never;
|
|
34
|
+
|
|
7
35
|
describe('applyConvolutionEffectToGl', () => {
|
|
8
36
|
it('is a function', () => {
|
|
9
37
|
expect(typeof applyConvolutionEffectToGl).toBe('function');
|
|
10
38
|
});
|
|
39
|
+
|
|
40
|
+
it('binds the edge color as packed RGBA, not as the ARGB it once carried', () => {
|
|
41
|
+
glMock.uniform4f.mockClear();
|
|
42
|
+
applyConvolutionEffectToGl({} as never, target, target, {
|
|
43
|
+
color: 0x44ffee80,
|
|
44
|
+
kind: 'ConvolutionEffect',
|
|
45
|
+
matrix: [1],
|
|
46
|
+
matrixX: 1,
|
|
47
|
+
matrixY: 1,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// 0x44 0xff 0xee 0x80 read in that order. The previous packing produced (1, 0.933, 0.502, 0.267)
|
|
51
|
+
// from this same value — every channel shifted one place, and still a valid-looking color.
|
|
52
|
+
const [, red, green, blue, alpha] = glMock.uniform4f.mock.calls.find((call) => call[0] === 'u_edgeColor')!;
|
|
53
|
+
expect(red).toBeCloseTo(0x44 / 255, 5);
|
|
54
|
+
expect(green).toBeCloseTo(1, 5);
|
|
55
|
+
expect(blue).toBeCloseTo(0xee / 255, 5);
|
|
56
|
+
expect(alpha).toBeCloseTo(0x80 / 255, 5);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('defaults the edge color to transparent black when the effect omits it', () => {
|
|
60
|
+
glMock.uniform4f.mockClear();
|
|
61
|
+
applyConvolutionEffectToGl({} as never, target, target, {
|
|
62
|
+
kind: 'ConvolutionEffect',
|
|
63
|
+
matrix: [1],
|
|
64
|
+
matrixX: 1,
|
|
65
|
+
matrixY: 1,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const call = glMock.uniform4f.mock.calls.find((entry) => entry[0] === 'u_edgeColor')!;
|
|
69
|
+
expect(call.slice(1)).toEqual([0, 0, 0, 0]);
|
|
70
|
+
});
|
|
11
71
|
});
|
|
12
72
|
|
|
13
73
|
describe('defaultGlConvolutionEffectRunner', () => {
|
|
@@ -1,17 +1,24 @@
|
|
|
1
|
+
import { getRegistryTableEntry } from '@flighthq/registry/contract';
|
|
2
|
+
import {
|
|
3
|
+
copyGlRenderStateRegistrations,
|
|
4
|
+
createGlRenderStateRuntime,
|
|
5
|
+
getGlRenderStateRuntime,
|
|
6
|
+
} from '@flighthq/render-gl/contract';
|
|
1
7
|
import type { GlRenderState } from '@flighthq/types/contract';
|
|
8
|
+
import { EntityRuntimeKey } from '@flighthq/types/contract';
|
|
2
9
|
|
|
3
10
|
import {
|
|
4
11
|
applyCustomShaderEffectToGl,
|
|
5
12
|
defaultGlCustomShaderEffectRunner,
|
|
6
13
|
getGlCustomShaderSource,
|
|
14
|
+
isGlCustomShaderEffectResolvable,
|
|
7
15
|
registerGlCustomShaderSource,
|
|
8
16
|
registerGlCustomShaderEffect,
|
|
17
|
+
setGlCustomShaderSourceGuard,
|
|
9
18
|
} from './glCustomShaderEffect';
|
|
10
19
|
|
|
11
|
-
// The source registry is a plain WeakMap keyed by the render state, with no GL calls, so a bare
|
|
12
|
-
// object stands in for a state. The compile/draw path is exercised by the functional render suite.
|
|
13
20
|
function makeState(): GlRenderState {
|
|
14
|
-
return {} as GlRenderState;
|
|
21
|
+
return { [EntityRuntimeKey]: createGlRenderStateRuntime() } as unknown as GlRenderState;
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
const FRAGMENT_SRC = `#version 300 es
|
|
@@ -52,6 +59,32 @@ describe('getGlCustomShaderSource', () => {
|
|
|
52
59
|
});
|
|
53
60
|
});
|
|
54
61
|
|
|
62
|
+
describe('isGlCustomShaderEffectResolvable', () => {
|
|
63
|
+
it('is false for a shaderKey with no registered source, which is the identity-passthrough case', () => {
|
|
64
|
+
const state = makeState();
|
|
65
|
+
expect(isGlCustomShaderEffectResolvable(state, { kind: 'CustomShaderEffect', shaderKey: 'ripple' } as never)).toBe(
|
|
66
|
+
false,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('is true once the key names registered source', () => {
|
|
71
|
+
const state = makeState();
|
|
72
|
+
registerGlCustomShaderSource(state, 'ripple', FRAGMENT_SRC);
|
|
73
|
+
expect(isGlCustomShaderEffectResolvable(state, { kind: 'CustomShaderEffect', shaderKey: 'ripple' } as never)).toBe(
|
|
74
|
+
true,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('answers per effect instance, so two effects of this kind can disagree', () => {
|
|
79
|
+
const state = makeState();
|
|
80
|
+
registerGlCustomShaderSource(state, 'ripple', FRAGMENT_SRC);
|
|
81
|
+
const registered = { kind: 'CustomShaderEffect', shaderKey: 'ripple' } as never;
|
|
82
|
+
const missing = { kind: 'CustomShaderEffect', shaderKey: 'absent' } as never;
|
|
83
|
+
expect(isGlCustomShaderEffectResolvable(state, registered)).toBe(true);
|
|
84
|
+
expect(isGlCustomShaderEffectResolvable(state, missing)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
55
88
|
describe('registerGlCustomShaderEffect', () => {
|
|
56
89
|
it('is a separately importable registration primitive', () => {
|
|
57
90
|
expect(registerGlCustomShaderEffect).toBeTypeOf('function');
|
|
@@ -72,4 +105,77 @@ describe('registerGlCustomShaderSource', () => {
|
|
|
72
105
|
registerGlCustomShaderSource(state, 'ripple', replacement);
|
|
73
106
|
expect(getGlCustomShaderSource(state, 'ripple')).toBe(replacement);
|
|
74
107
|
});
|
|
108
|
+
|
|
109
|
+
it('replaces the persistent table while an explicitly copied state retains its snapshot', () => {
|
|
110
|
+
const screen = makeState();
|
|
111
|
+
const derived = makeState();
|
|
112
|
+
const replacement = FRAGMENT_SRC.replace('texture(u_texture0, v_texCoord)', 'vec4(1.0)');
|
|
113
|
+
registerGlCustomShaderSource(screen, 'ripple', FRAGMENT_SRC);
|
|
114
|
+
const snapshot = getGlRenderStateRuntime(screen).registries.customEffectShaders;
|
|
115
|
+
|
|
116
|
+
copyGlRenderStateRegistrations(derived, screen);
|
|
117
|
+
registerGlCustomShaderSource(screen, 'ripple', replacement);
|
|
118
|
+
|
|
119
|
+
expect(getGlRenderStateRuntime(derived).registries.customEffectShaders).toBe(snapshot);
|
|
120
|
+
expect(getGlRenderStateRuntime(screen).registries.customEffectShaders).not.toBe(snapshot);
|
|
121
|
+
expect(getRegistryTableEntry(snapshot, 'ripple')).toBe(FRAGMENT_SRC);
|
|
122
|
+
expect(getGlCustomShaderSource(derived, 'ripple')).toBe(FRAGMENT_SRC);
|
|
123
|
+
expect(getGlCustomShaderSource(screen, 'ripple')).toBe(replacement);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('setGlCustomShaderSourceGuard', () => {
|
|
128
|
+
it('fires with both sources when a key is overwritten with DIFFERENT source', () => {
|
|
129
|
+
const state = makeState();
|
|
130
|
+
const replacement = FRAGMENT_SRC.replace('texture(u_texture0, v_texCoord)', 'vec4(1.0)');
|
|
131
|
+
const calls: { key: string; previous: string; next: string }[] = [];
|
|
132
|
+
setGlCustomShaderSourceGuard(state, (_state, key, previous, next) => calls.push({ key, next, previous }));
|
|
133
|
+
|
|
134
|
+
registerGlCustomShaderSource(state, 'ripple', FRAGMENT_SRC);
|
|
135
|
+
registerGlCustomShaderSource(state, 'ripple', replacement);
|
|
136
|
+
|
|
137
|
+
// Both sources, because the whole point is that the NEXT one never reaches the GPU.
|
|
138
|
+
expect(calls).toEqual([{ key: 'ripple', next: replacement, previous: FRAGMENT_SRC }]);
|
|
139
|
+
// Non-vacuous: the registry still took the write, so the guard reports rather than blocks.
|
|
140
|
+
expect(getGlCustomShaderSource(state, 'ripple')).toBe(replacement);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('stays SILENT on the first registration of a key, which compiles the source it names', () => {
|
|
144
|
+
const state = makeState();
|
|
145
|
+
let calls = 0;
|
|
146
|
+
setGlCustomShaderSourceGuard(state, () => calls++);
|
|
147
|
+
|
|
148
|
+
registerGlCustomShaderSource(state, 'ripple', FRAGMENT_SRC);
|
|
149
|
+
|
|
150
|
+
expect(calls).toBe(0);
|
|
151
|
+
expect(getGlCustomShaderSource(state, 'ripple')).toBe(FRAGMENT_SRC);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('stays SILENT when the SAME source is registered again, which changes nothing', () => {
|
|
155
|
+
const state = makeState();
|
|
156
|
+
let calls = 0;
|
|
157
|
+
setGlCustomShaderSourceGuard(state, () => calls++);
|
|
158
|
+
|
|
159
|
+
registerGlCustomShaderSource(state, 'ripple', FRAGMENT_SRC);
|
|
160
|
+
registerGlCustomShaderSource(state, 'ripple', FRAGMENT_SRC);
|
|
161
|
+
|
|
162
|
+
// The cached program IS this source, so warning here would train readers to ignore the crumb.
|
|
163
|
+
expect(calls).toBe(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('stops firing once cleared, and never fires for a state that never installed one', () => {
|
|
167
|
+
const state = makeState();
|
|
168
|
+
const other = makeState();
|
|
169
|
+
const replacement = FRAGMENT_SRC.replace('texture(u_texture0, v_texCoord)', 'vec4(1.0)');
|
|
170
|
+
let calls = 0;
|
|
171
|
+
setGlCustomShaderSourceGuard(state, () => calls++);
|
|
172
|
+
setGlCustomShaderSourceGuard(state, null);
|
|
173
|
+
|
|
174
|
+
registerGlCustomShaderSource(state, 'ripple', FRAGMENT_SRC);
|
|
175
|
+
registerGlCustomShaderSource(state, 'ripple', replacement);
|
|
176
|
+
registerGlCustomShaderSource(other, 'ripple', FRAGMENT_SRC);
|
|
177
|
+
registerGlCustomShaderSource(other, 'ripple', replacement);
|
|
178
|
+
|
|
179
|
+
expect(calls).toBe(0);
|
|
180
|
+
});
|
|
75
181
|
});
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
registerGlDropShadowEffect,
|
|
36
36
|
} from './glDropShadowEffect';
|
|
37
37
|
import { applyGlEffectBlitPass, applyGlEffectErasePass } from './glEffectBlitShader';
|
|
38
|
+
import { applyGlEffectTintPass } from './glEffectTintShader';
|
|
38
39
|
|
|
39
40
|
describe('applyDropShadowEffectToGl', () => {
|
|
40
41
|
it('is a function', () => {
|
|
@@ -51,6 +52,24 @@ describe('applyDropShadowEffectToGl', () => {
|
|
|
51
52
|
expect(applyGlEffectErasePass).not.toHaveBeenCalled();
|
|
52
53
|
});
|
|
53
54
|
|
|
55
|
+
// Piece 3 landed this as a split at the call site, because the tint pass still spoke 24-bit RGB and was
|
|
56
|
+
// shared with effects that had not migrated. Now that every one of them carries packed RGBA, the pass
|
|
57
|
+
// itself folds the color's alpha and the call site hands the value over untouched — one decode point
|
|
58
|
+
// instead of five, and this assertion is what pins which of the two contracts is live.
|
|
59
|
+
it('hands the packed color and the effect alpha to the tint pass without splitting either', () => {
|
|
60
|
+
vi.mocked(applyGlEffectTintPass).mockClear();
|
|
61
|
+
|
|
62
|
+
applyDropShadowEffectToGl(createState(), createTarget('source'), createTarget('dest'), createPool(), {
|
|
63
|
+
alpha: 0.5,
|
|
64
|
+
color: 0x9d55ff80,
|
|
65
|
+
kind: 'DropShadowEffect',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const call = vi.mocked(applyGlEffectTintPass).mock.calls[0]!;
|
|
69
|
+
expect(call[3]).toBe(0x9d55ff80);
|
|
70
|
+
expect(call[4]).toBeCloseTo(0.5, 5);
|
|
71
|
+
});
|
|
72
|
+
|
|
54
73
|
it('hides the source when sourceMode is hide', () => {
|
|
55
74
|
const source = createTarget('source');
|
|
56
75
|
const dest = createTarget('dest');
|
|
@@ -1,13 +1,79 @@
|
|
|
1
|
+
// This pass is the single decode point for every tinted effect on this backend — drop shadow, outer glow,
|
|
2
|
+
// and the neutral white field the bevels lay down. That is what the piece-4 unification bought: the
|
|
3
|
+
// channel order and the alpha fold are asserted once, here, instead of at each of five call sites.
|
|
4
|
+
const glMock = vi.hoisted(() => ({
|
|
5
|
+
ONE: 1,
|
|
6
|
+
ZERO: 0,
|
|
7
|
+
blendFunc: vi.fn(),
|
|
8
|
+
uniform1f: vi.fn(),
|
|
9
|
+
uniform3f: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.hoisted(() => {
|
|
13
|
+
vi.resetModules();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
vi.mock('@flighthq/render-gl/contract', () => ({
|
|
17
|
+
compileGlFullscreenProgram: vi.fn(() => ({
|
|
18
|
+
locAlpha: 'u_alpha',
|
|
19
|
+
locColor: 'u_color',
|
|
20
|
+
locStrength: 'u_strength',
|
|
21
|
+
program: {},
|
|
22
|
+
})),
|
|
23
|
+
drawGlFullscreenPass: vi.fn((_state, _loc, _textures, _dest, setUniforms) => {
|
|
24
|
+
setUniforms(glMock as never, {} as never);
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
|
|
1
28
|
import { applyGlEffectInvertTintPass, applyGlEffectTintPass } from './glEffectTintShader';
|
|
2
29
|
|
|
30
|
+
const state = { gl: { getUniformLocation: (_program: unknown, name: string) => name } } as never;
|
|
31
|
+
const target = { height: 4, texture: {}, width: 4 } as never;
|
|
32
|
+
|
|
3
33
|
describe('applyGlEffectInvertTintPass', () => {
|
|
4
34
|
it('is a function', () => {
|
|
5
35
|
expect(typeof applyGlEffectInvertTintPass).toBe('function');
|
|
6
36
|
});
|
|
37
|
+
|
|
38
|
+
it('reads the packed color and folds its alpha, the same contract as the non-inverted pass', () => {
|
|
39
|
+
glMock.uniform3f.mockClear();
|
|
40
|
+
glMock.uniform1f.mockClear();
|
|
41
|
+
|
|
42
|
+
applyGlEffectInvertTintPass(state, target, target, 0x9d55ff80, 0.5, 1);
|
|
43
|
+
|
|
44
|
+
const [, red, green, blue] = glMock.uniform3f.mock.calls.find((call) => call[0] === 'u_color')!;
|
|
45
|
+
expect(red).toBeCloseTo(0x9d / 255, 5);
|
|
46
|
+
expect(green).toBeCloseTo(0x55 / 255, 5);
|
|
47
|
+
expect(blue).toBeCloseTo(1, 5);
|
|
48
|
+
expect(glMock.uniform1f.mock.calls.find((call) => call[0] === 'u_alpha')![1]).toBeCloseTo((0x80 / 255) * 0.5, 5);
|
|
49
|
+
});
|
|
7
50
|
});
|
|
8
51
|
|
|
9
52
|
describe('applyGlEffectTintPass', () => {
|
|
10
53
|
it('is a function', () => {
|
|
11
54
|
expect(typeof applyGlEffectTintPass).toBe('function');
|
|
12
55
|
});
|
|
56
|
+
|
|
57
|
+
it('splits a packed RGBA color into the shader RGB and an alpha multiplied by the caller alpha', () => {
|
|
58
|
+
glMock.uniform3f.mockClear();
|
|
59
|
+
glMock.uniform1f.mockClear();
|
|
60
|
+
|
|
61
|
+
applyGlEffectTintPass(state, target, target, 0x9d55ff80, 0.5, 1);
|
|
62
|
+
|
|
63
|
+
// Under the 24-bit reading this pass carried before piece 4, the same value tinted 0x55ff80 at a
|
|
64
|
+
// full 0.5 — a different color at twice the opacity, and nothing in the frame to say which was meant.
|
|
65
|
+
const [, red, green, blue] = glMock.uniform3f.mock.calls.find((call) => call[0] === 'u_color')!;
|
|
66
|
+
expect(red).toBeCloseTo(0x9d / 255, 5);
|
|
67
|
+
expect(green).toBeCloseTo(0x55 / 255, 5);
|
|
68
|
+
expect(blue).toBeCloseTo(1, 5);
|
|
69
|
+
expect(glMock.uniform1f.mock.calls.find((call) => call[0] === 'u_alpha')![1]).toBeCloseTo((0x80 / 255) * 0.5, 5);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('passes an opaque tint through at exactly the caller alpha', () => {
|
|
73
|
+
glMock.uniform1f.mockClear();
|
|
74
|
+
|
|
75
|
+
applyGlEffectTintPass(state, target, target, 0xffffffff, 0.25, 1);
|
|
76
|
+
|
|
77
|
+
expect(glMock.uniform1f.mock.calls.find((call) => call[0] === 'u_alpha')![1]).toBeCloseTo(0.25, 5);
|
|
78
|
+
});
|
|
13
79
|
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createGlRenderState, endGlRenderPass } from '@flighthq/render-gl/contract';
|
|
2
|
+
import type { RenderEffect } from '@flighthq/types/contract';
|
|
2
3
|
|
|
3
4
|
import {
|
|
4
5
|
beginGlRenderEffectPipeline,
|
|
5
6
|
createGlRenderEffectPipeline,
|
|
6
7
|
destroyGlRenderEffectPipeline,
|
|
7
8
|
endGlRenderEffectPipeline,
|
|
9
|
+
setGlRenderEffectPipelineSkipGuard,
|
|
8
10
|
setGlRenderEffectVelocityTexture,
|
|
9
11
|
} from './glRenderEffectPipeline';
|
|
10
12
|
|
|
@@ -45,6 +47,29 @@ describe('endGlRenderEffectPipeline', () => {
|
|
|
45
47
|
});
|
|
46
48
|
});
|
|
47
49
|
|
|
50
|
+
describe('setGlRenderEffectPipelineSkipGuard', () => {
|
|
51
|
+
it('reports every effect kind the pass drops, and goes silent again when cleared', () => {
|
|
52
|
+
const state = createGlRenderState(document.createElement('canvas'));
|
|
53
|
+
const pipeline = createGlRenderEffectPipeline(state);
|
|
54
|
+
const dropped: string[] = [];
|
|
55
|
+
const chain = [{ kind: 'test.pipeline-skip-seam' } as RenderEffect];
|
|
56
|
+
|
|
57
|
+
setGlRenderEffectPipelineSkipGuard(state, (_state, kind) => dropped.push(kind));
|
|
58
|
+
beginGlRenderEffectPipeline(state, pipeline);
|
|
59
|
+
endGlRenderEffectPipeline(state, pipeline, chain);
|
|
60
|
+
|
|
61
|
+
expect(dropped).toEqual(['test.pipeline-skip-seam']);
|
|
62
|
+
|
|
63
|
+
// Clearing must restore the original silence exactly: the seam is the ONLY path by which a dropped
|
|
64
|
+
// effect is observable, so a stale guard would be the difference between a diagnostic and a leak.
|
|
65
|
+
setGlRenderEffectPipelineSkipGuard(state, null);
|
|
66
|
+
beginGlRenderEffectPipeline(state, pipeline);
|
|
67
|
+
endGlRenderEffectPipeline(state, pipeline, chain);
|
|
68
|
+
|
|
69
|
+
expect(dropped).toEqual(['test.pipeline-skip-seam']);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
48
73
|
describe('setGlRenderEffectVelocityTexture', () => {
|
|
49
74
|
it('is a function', () => {
|
|
50
75
|
expect(typeof setGlRenderEffectVelocityTexture).toBe('function');
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { createGlRenderState } from '@flighthq/render-gl/contract';
|
|
2
|
-
import
|
|
2
|
+
import { getGlRenderStateRuntime } from '@flighthq/render-gl/contract';
|
|
3
|
+
import type { GlRenderState, RenderEffect } from '@flighthq/types/contract';
|
|
3
4
|
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getGlRenderEffectRunner,
|
|
7
|
+
hasGlRenderEffectRunner,
|
|
8
|
+
isGlRenderEffectResolvable,
|
|
9
|
+
registerGlRenderEffect,
|
|
10
|
+
} from './glRenderEffectRegistry';
|
|
5
11
|
|
|
6
12
|
describe('getGlRenderEffectRunner', () => {
|
|
7
13
|
it('is a function', () => {
|
|
@@ -32,6 +38,26 @@ describe('hasGlRenderEffectRunner', () => {
|
|
|
32
38
|
});
|
|
33
39
|
});
|
|
34
40
|
|
|
41
|
+
describe('isGlRenderEffectResolvable', () => {
|
|
42
|
+
it('treats a kind registered without a resolver as always resolvable', () => {
|
|
43
|
+
const state = createState();
|
|
44
|
+
registerGlRenderEffect(state, 'ResolvableTestEffect', vi.fn());
|
|
45
|
+
expect(isGlRenderEffectResolvable(state, effect('ResolvableTestEffect'))).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('asks the registered resolver, per effect instance', () => {
|
|
49
|
+
const state = createState();
|
|
50
|
+
registerGlRenderEffect(state, 'ResolverTestEffect', vi.fn(), (_state, candidate) => 'key' in candidate);
|
|
51
|
+
expect(isGlRenderEffectResolvable(state, effect('ResolverTestEffect'))).toBe(false);
|
|
52
|
+
expect(isGlRenderEffectResolvable(state, effect('ResolverTestEffect', { key: 'k' }))).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('reports an unregistered kind as unresolvable, since there is nothing to resolve it with', () => {
|
|
56
|
+
const state = createState();
|
|
57
|
+
expect(isGlRenderEffectResolvable(state, effect('NeverRegisteredEffect'))).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
35
61
|
describe('registerGlRenderEffect', () => {
|
|
36
62
|
it('is a function', () => {
|
|
37
63
|
expect(typeof registerGlRenderEffect).toBe('function');
|
|
@@ -40,8 +66,11 @@ describe('registerGlRenderEffect', () => {
|
|
|
40
66
|
it('registers and retrieves a runner', () => {
|
|
41
67
|
const state = createState();
|
|
42
68
|
const runner = vi.fn();
|
|
69
|
+
const before = getGlRenderStateRuntime(state).registries.renderEffects;
|
|
43
70
|
registerGlRenderEffect(state, 'TestEffect', runner);
|
|
44
71
|
expect(getGlRenderEffectRunner(state, 'TestEffect')).toBe(runner);
|
|
72
|
+
expect(getGlRenderStateRuntime(state).registries.renderEffects).not.toBe(before);
|
|
73
|
+
expect(before.entries.size).toBe(0);
|
|
45
74
|
});
|
|
46
75
|
|
|
47
76
|
it('overwrites an existing runner under the same kind', () => {
|
|
@@ -49,8 +78,13 @@ describe('registerGlRenderEffect', () => {
|
|
|
49
78
|
const runnerA = vi.fn();
|
|
50
79
|
const runnerB = vi.fn();
|
|
51
80
|
registerGlRenderEffect(state, 'TestEffect2', runnerA);
|
|
81
|
+
const before = getGlRenderStateRuntime(state).registries.renderEffects;
|
|
52
82
|
registerGlRenderEffect(state, 'TestEffect2', runnerB);
|
|
53
83
|
expect(getGlRenderEffectRunner(state, 'TestEffect2')).toBe(runnerB);
|
|
84
|
+
expect(before.entries.get('TestEffect2')).toEqual({
|
|
85
|
+
state: 'bound',
|
|
86
|
+
value: { isResolvable: undefined, runner: runnerA },
|
|
87
|
+
});
|
|
54
88
|
});
|
|
55
89
|
});
|
|
56
90
|
|
|
@@ -60,3 +94,7 @@ function createState(): GlRenderState {
|
|
|
60
94
|
canvas.height = 16;
|
|
61
95
|
return createGlRenderState(canvas);
|
|
62
96
|
}
|
|
97
|
+
|
|
98
|
+
function effect(kind: string, extra: Readonly<Record<string, unknown>> = {}): Readonly<RenderEffect> {
|
|
99
|
+
return { kind, ...extra } as unknown as Readonly<RenderEffect>;
|
|
100
|
+
}
|