@flighthq/effects-wgpu 0.4.0-next.1488.43bcb37 → 0.4.0-next.1585.bf131c8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flighthq/effects-wgpu",
3
- "version": "0.4.0-next.1488.43bcb37",
3
+ "version": "0.4.0-next.1585.bf131c8",
4
4
  "author": "Joshua Granick and other contributors",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,14 +38,14 @@
38
38
  "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
39
39
  },
40
40
  "dependencies": {
41
- "@flighthq/adjustments": "0.4.0-next.1488.43bcb37",
42
- "@flighthq/color": "0.4.0-next.1488.43bcb37",
43
- "@flighthq/effects": "0.4.0-next.1488.43bcb37",
44
- "@flighthq/geometry": "0.4.0-next.1488.43bcb37",
45
- "@flighthq/log": "0.4.0-next.1488.43bcb37",
46
- "@flighthq/render-wgpu": "0.4.0-next.1488.43bcb37",
47
- "@flighthq/registry": "0.4.0-next.1488.43bcb37",
48
- "@flighthq/types": "0.4.0-next.1488.43bcb37"
41
+ "@flighthq/adjustments": "0.4.0-next.1585.bf131c8",
42
+ "@flighthq/color": "0.4.0-next.1585.bf131c8",
43
+ "@flighthq/effects": "0.4.0-next.1585.bf131c8",
44
+ "@flighthq/geometry": "0.4.0-next.1585.bf131c8",
45
+ "@flighthq/log": "0.4.0-next.1585.bf131c8",
46
+ "@flighthq/render-wgpu": "0.4.0-next.1585.bf131c8",
47
+ "@flighthq/registry": "0.4.0-next.1585.bf131c8",
48
+ "@flighthq/types": "0.4.0-next.1585.bf131c8"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "@webgpu/types": "^0.1.70"
@@ -1,19 +1,227 @@
1
+ import { createWgpuRenderStateForTest, installWgpuMock } from '@flighthq/render-wgpu/contract';
2
+ import type { BevelEffect, WgpuRenderState, WgpuRenderTarget, WgpuRenderTargetPool } from '@flighthq/types/contract';
3
+
4
+ const passMock = vi.hoisted(() => ({
5
+ clearWgpuEffectTarget: vi.fn(),
6
+ createWgpuDualSourceEffectPipeline: vi.fn(() => ({ pipeline: {} })),
7
+ drawWgpuDualSourceEffectPass: vi.fn(
8
+ (
9
+ _state: unknown,
10
+ _field: unknown,
11
+ _source: unknown,
12
+ _dest: unknown,
13
+ _pipeline: unknown,
14
+ setUniforms: (f32: Float32Array) => void,
15
+ ) => {
16
+ const f32 = new Float32Array(12);
17
+ setUniforms(f32);
18
+ recorded.composites.push([...f32]);
19
+ },
20
+ ),
21
+ }));
22
+ const blitMock = vi.hoisted(() => ({
23
+ applyWgpuEffectBlitPass: vi.fn(() => recorded.calls.push('blit')),
24
+ applyWgpuEffectErasePass: vi.fn(() => recorded.calls.push('erase')),
25
+ }));
26
+ const blurMock = vi.hoisted(() => ({
27
+ applyWgpuEffectBoxBlur: vi.fn((_state: unknown, _s: unknown, _d: unknown, _t: unknown, options: unknown) => {
28
+ recorded.blurs.push(options);
29
+ }),
30
+ }));
31
+ const tintMock = vi.hoisted(() => ({
32
+ applyWgpuEffectTintPass: vi.fn(
33
+ (_state: unknown, _source: unknown, _dest: unknown, color: number, alpha: number, strength: number) => {
34
+ recorded.tints.push([color, alpha, strength]);
35
+ },
36
+ ),
37
+ }));
38
+ const targetMock = vi.hoisted(() => ({
39
+ acquireWgpuRenderTarget: vi.fn((_state: unknown, _pool: unknown, descriptor: unknown) => {
40
+ const target = { ...(descriptor as object), id: `scratch-${recorded.acquired.length}`, view: {} };
41
+ recorded.acquired.push(target);
42
+ return target;
43
+ }),
44
+ releaseWgpuRenderTarget: vi.fn((_pool: unknown, target: unknown) => recorded.released.push(target)),
45
+ }));
46
+ const recorded = vi.hoisted(() => ({
47
+ acquired: [] as unknown[],
48
+ blurs: [] as unknown[],
49
+ calls: [] as string[],
50
+ composites: [] as number[][],
51
+ released: [] as unknown[],
52
+ tints: [] as (number | undefined)[][],
53
+ }));
54
+
55
+ vi.mock('./wgpuEffectBlitShader', () => blitMock);
56
+ vi.mock('./wgpuEffectBoxBlur', () => blurMock);
57
+ vi.mock('./wgpuEffectPass', () => passMock);
58
+ vi.mock('./wgpuEffectTintShader', () => tintMock);
59
+
60
+ vi.mock('@flighthq/render-wgpu/contract', async () => {
61
+ const actual = (await vi.importActual('@flighthq/render-wgpu/contract')) as Record<string, unknown>;
62
+ return { ...actual, ...targetMock };
63
+ });
64
+
1
65
  import { applyBevelEffectToWgpu, defaultWgpuBevelEffectRunner, registerWgpuBevelEffect } from './wgpuBevelEffect';
66
+ import { getWgpuRenderEffectRunner } from './wgpuRenderEffectRegistry';
67
+
68
+ beforeAll(() => installWgpuMock());
69
+
70
+ const SOURCE_WIDTH = 100;
71
+ const SOURCE_HEIGHT = 50;
72
+
73
+ function apply(effect: Readonly<Partial<BevelEffect>> = {}): readonly number[] {
74
+ for (const list of [
75
+ recorded.acquired,
76
+ recorded.blurs,
77
+ recorded.calls,
78
+ recorded.composites,
79
+ recorded.released,
80
+ recorded.tints,
81
+ ]) {
82
+ list.length = 0;
83
+ }
84
+ const target = {
85
+ format: 'rgba8unorm',
86
+ height: SOURCE_HEIGHT,
87
+ view: {},
88
+ width: SOURCE_WIDTH,
89
+ } as unknown as WgpuRenderTarget;
90
+ applyBevelEffectToWgpu(
91
+ {} as unknown as WgpuRenderState,
92
+ target,
93
+ target,
94
+ {} as unknown as WgpuRenderTargetPool,
95
+ {
96
+ kind: 'BevelEffect',
97
+ ...effect,
98
+ } as BevelEffect,
99
+ );
100
+ return recorded.composites[0]!;
101
+ }
2
102
 
3
103
  describe('applyBevelEffectToWgpu', () => {
4
- it('is a function', () => {
5
- expect(typeof applyBevelEffectToWgpu).toBe('function');
104
+ // ★ THE DEFECT THIS REPLACES A `typeof` CHECK FOR — branch 1, restorable from git. The highlight and
105
+ // shadow colours used to be read as 24-bit RGB with the `*Alpha` fields standing alone
106
+ // (`effect.shadowColor ?? 0x000000`). They are packed RGBA everywhere else in the SDK, so under the old
107
+ // reading 0x102030c0 shaded the bevel 0x2030c0 at full opacity — a different colour, fully plausible,
108
+ // with the c0 silently becoming part of the blue channel. Unified in 51e364d1d.
109
+ //
110
+ // MEASURED by restoring 51e364d1d^'s exact four lines — 2 of 11 failed:
111
+ // AssertionError: expected 0.3333333432674408 to be close to 0.615686274509804
112
+ // AssertionError: expected 0.125490203499794 to be close to 0.06274509803921569
113
+ it('splits a packed RGBA colour into composite RGB and an alpha multiplied by its Alpha field', () => {
114
+ const uniforms = apply({
115
+ highlightAlpha: 0.5,
116
+ highlightColor: 0x9d55ff80,
117
+ shadowAlpha: 1,
118
+ shadowColor: 0x102030c0,
119
+ });
120
+
121
+ expect(uniforms[0]).toBeCloseTo(0x9d / 255, 6);
122
+ expect(uniforms[1]).toBeCloseTo(0x55 / 255, 6);
123
+ expect(uniforms[2]).toBeCloseTo(0xff / 255, 6);
124
+ expect(uniforms[3]).toBeCloseTo((0x80 / 255) * 0.5, 6);
125
+
126
+ expect(uniforms[4]).toBeCloseTo(0x10 / 255, 6);
127
+ expect(uniforms[5]).toBeCloseTo(0x20 / 255, 6);
128
+ expect(uniforms[6]).toBeCloseTo(0x30 / 255, 6);
129
+ expect(uniforms[7]).toBeCloseTo(0xc0 / 255, 6);
130
+ });
131
+
132
+ it('defaults to opaque white over opaque black', () => {
133
+ const uniforms = apply();
134
+
135
+ expect(uniforms.slice(0, 4)).toEqual([1, 1, 1, 1]);
136
+ expect(uniforms.slice(4, 8)).toEqual([0, 0, 0, 1]);
137
+ });
138
+
139
+ // ★ THE VERTICAL SEAM, the same class that was wrong in six effects tonight: the light direction
140
+ // arrives in screen space (Y down) and only the Y component is negated on the way into the shader.
141
+ // Written symmetrically, every bevel is lit from the wrong side — and still looks like a bevel.
142
+ it('negates only the vertical component of the light offset', () => {
143
+ // 0 degrees points along +X, 90 degrees along +Y in screen space.
144
+ const alongX = apply({ angle: 0, distance: 10 });
145
+ expect(alongX[8]).toBeCloseTo(10 / SOURCE_WIDTH, 6);
146
+ expect(alongX[9]).toBeCloseTo(0, 6);
147
+
148
+ const alongY = apply({ angle: 90, distance: 10 });
149
+ expect(alongY[8]).toBeCloseTo(0, 6);
150
+ expect(alongY[9]).toBeCloseTo(-10 / SOURCE_HEIGHT, 6);
151
+ });
152
+
153
+ // Normalised per axis, and the two source dimensions differ here on purpose: with them equal, dividing
154
+ // by the wrong one is invisible.
155
+ it('normalises each offset component by its own source dimension', () => {
156
+ const uniforms = apply({ angle: 45, distance: 10 });
157
+
158
+ // cos(45)*10 and sin(45)*10 both round to 7.
159
+ expect(uniforms[8]).toBeCloseTo(7 / SOURCE_WIDTH, 6);
160
+ expect(uniforms[9]).toBeCloseTo(-7 / SOURCE_HEIGHT, 6);
161
+ });
162
+
163
+ it('snaps the light offset to whole pixels, matching the surface reference', () => {
164
+ // cos(30)*10 = 8.66 -> 9, sin(30)*10 = 5 -> 5.
165
+ const uniforms = apply({ angle: 30, distance: 10 });
166
+
167
+ expect(uniforms[8] * SOURCE_WIDTH).toBeCloseTo(9, 6);
168
+ expect(uniforms[9] * SOURCE_HEIGHT).toBeCloseTo(-5, 6);
169
+ });
170
+
171
+ it('maps each bevel type to its clip mode', () => {
172
+ expect(apply({ bevelType: 'inner' })[11]).toBe(1);
173
+ expect(apply({ bevelType: 'outer' })[11]).toBe(2);
174
+ expect(apply({ bevelType: 'full' })[11]).toBe(0);
175
+ // The default is inner, which is the one a caller gets without asking.
176
+ expect(apply()[11]).toBe(1);
177
+ });
178
+
179
+ it('draws the source under the bevel for draw mode and erases it for knockout', () => {
180
+ apply({ sourceMode: 'draw' });
181
+ expect(recorded.calls).toEqual(['blit']);
182
+
183
+ apply({ sourceMode: 'knockout' });
184
+ expect(recorded.calls).toEqual(['erase']);
185
+ });
186
+
187
+ // The field is built with a NEUTRAL tint at strength 1 — `strength` is the per-pixel gradient intensity
188
+ // in the composite, not something baked into the blurred silhouette. Baking it there instead would
189
+ // change the bevel's width as well as its contrast.
190
+ it('builds the alpha field with a neutral tint and passes strength to the composite instead', () => {
191
+ const uniforms = apply({ strength: 3 });
192
+
193
+ expect(recorded.tints[0]).toEqual([0xffffffff, 1, 1]);
194
+ expect(uniforms[10]).toBe(3);
195
+ });
196
+
197
+ it('releases every scratch target it acquired', () => {
198
+ apply();
199
+
200
+ expect(recorded.acquired).toHaveLength(3);
201
+ expect(recorded.released).toEqual(recorded.acquired);
6
202
  });
7
203
  });
8
204
 
9
205
  describe('defaultWgpuBevelEffectRunner', () => {
10
- it('is a function', () => {
11
- expect(typeof defaultWgpuBevelEffectRunner).toBe('function');
206
+ it('routes the runner context through to the pass', () => {
207
+ recorded.composites.length = 0;
208
+ const target = { format: 'rgba8unorm', height: 8, view: {}, width: 8 } as unknown as WgpuRenderTarget;
209
+
210
+ defaultWgpuBevelEffectRunner(
211
+ { dest: target, pool: {}, source: target, state: {} } as never,
212
+ { kind: 'BevelEffect', shadowColor: 0x102030ff } as BevelEffect,
213
+ );
214
+
215
+ expect(recorded.composites[0]![4]).toBeCloseTo(0x10 / 255, 6);
12
216
  });
13
217
  });
14
218
 
15
219
  describe('registerWgpuBevelEffect', () => {
16
- it('is a separately importable registration primitive', () => {
17
- expect(registerWgpuBevelEffect).toBeTypeOf('function');
220
+ it('makes the runner resolvable for the BevelEffect kind', async () => {
221
+ const state = await createWgpuRenderStateForTest();
222
+
223
+ expect(getWgpuRenderEffectRunner(state, 'BevelEffect')).toBeNull();
224
+ registerWgpuBevelEffect(state);
225
+ expect(getWgpuRenderEffectRunner(state, 'BevelEffect')).toBe(defaultWgpuBevelEffectRunner);
18
226
  });
19
227
  });
@@ -1,7 +1,252 @@
1
+ import { installWgpuMock } from '@flighthq/render-wgpu/contract';
2
+ import type { ColorLut, WgpuColorLutTextureCache, WgpuRenderState, WgpuRenderTarget } from '@flighthq/types/contract';
3
+
4
+ const passStateMock = vi.hoisted(() => ({
5
+ EFFECT_VERTEX_WGSL: 'VERTEX_WGSL\n',
6
+ getWgpuEffectPassState: vi.fn(() => passState),
7
+ }));
8
+ const passState = vi.hoisted(() => ({
9
+ acquireSlot: vi.fn(() => 256),
10
+ beginPass: vi.fn((_dest: unknown, loadOp: string) => {
11
+ recorded.loadOps.push(loadOp);
12
+ return {
13
+ draw: vi.fn((count: number) => recorded.draws.push(count)),
14
+ end: vi.fn(),
15
+ setBindGroup: vi.fn((index: number, group: unknown) => recorded.bindGroups.push({ group, index })),
16
+ setPipeline: vi.fn((pipeline: unknown) => recorded.pipelines.push(pipeline)),
17
+ };
18
+ }),
19
+ sampler: { id: 'sampler' },
20
+ textureBGLayout: { id: 'textureBGLayout' },
21
+ uniformBG: { id: 'uniformBG' },
22
+ uniformBGLayout: { id: 'uniformBGLayout' },
23
+ writeSlot: vi.fn((_offset: number, write: (f32: Float32Array) => void) => {
24
+ const f32 = new Float32Array(4);
25
+ write(f32);
26
+ recorded.uniforms.push([...f32]);
27
+ }),
28
+ }));
29
+ const recorded = vi.hoisted(() => ({
30
+ bindGroups: [] as { group: unknown; index: number }[],
31
+ draws: [] as number[],
32
+ loadOps: [] as string[],
33
+ pipelines: [] as unknown[],
34
+ shaderCode: [] as string[],
35
+ uniforms: [] as number[][],
36
+ }));
37
+
38
+ vi.mock('./wgpuEffectPass', () => passStateMock);
39
+
1
40
  import { applyColorLutPassToWgpu } from './wgpuColorLutPass';
2
41
 
42
+ // The WebGPU flag enums are type-level only in @webgpu/types, so jsdom has no runtime values for the
43
+ // usage and visibility bits this module ORs together.
44
+ beforeAll(() => installWgpuMock());
45
+
46
+ function createHarness(): {
47
+ createTexture: ReturnType<typeof vi.fn>;
48
+ destroyed: string[];
49
+ state: WgpuRenderState;
50
+ uploads: Uint8Array[];
51
+ } {
52
+ const destroyed: string[] = [];
53
+ const uploads: Uint8Array[] = [];
54
+ let nextTexture = 0;
55
+ const createTexture = vi.fn(() => {
56
+ const id = `lut-${nextTexture++}`;
57
+ return { createView: vi.fn(() => ({ id: `${id}-view` })), destroy: () => destroyed.push(id), id };
58
+ });
59
+ const state = {
60
+ device: {
61
+ createBindGroup: vi.fn((descriptor: unknown) => ({ descriptor })),
62
+ createBindGroupLayout: vi.fn(() => ({ id: 'lutBGLayout' })),
63
+ createPipelineLayout: vi.fn(() => ({ id: 'pipelineLayout' })),
64
+ createRenderPipeline: vi.fn((descriptor: { fragment: { targets: { format: string }[] } }) => ({
65
+ format: descriptor.fragment.targets[0]!.format,
66
+ })),
67
+ createShaderModule: vi.fn((descriptor: { code: string }) => {
68
+ recorded.shaderCode.push(descriptor.code);
69
+ return { id: 'module' };
70
+ }),
71
+ createTexture,
72
+ queue: { writeTexture: vi.fn((_destination: unknown, data: Uint8Array) => uploads.push(new Uint8Array(data))) },
73
+ },
74
+ } as unknown as WgpuRenderState;
75
+ return { createTexture, destroyed, state, uploads };
76
+ }
77
+
78
+ function createLut(size: number, samples: ReadonlyArray<number>): ColorLut {
79
+ return { samples: Float32Array.from(samples), size } as unknown as ColorLut;
80
+ }
81
+
82
+ function createGreyLut(size = 2): ColorLut {
83
+ const count = size * size * size;
84
+ const samples: number[] = [];
85
+ for (let index = 0; index < count; index++)
86
+ samples.push(index / (count - 1), index / (count - 1), index / (count - 1));
87
+ return createLut(size, samples);
88
+ }
89
+
90
+ function emptyCache(): WgpuColorLutTextureCache {
91
+ return { lut: null, size: 0, texture: null } as unknown as WgpuColorLutTextureCache;
92
+ }
93
+
94
+ function apply(
95
+ harness: ReturnType<typeof createHarness>,
96
+ lut: Readonly<ColorLut>,
97
+ cache: WgpuColorLutTextureCache,
98
+ format = 'rgba8unorm',
99
+ ): void {
100
+ const target = { format, view: { id: 'sourceView' } } as unknown as WgpuRenderTarget;
101
+ applyColorLutPassToWgpu(harness.state, target, target, lut, cache);
102
+ }
103
+
104
+ function reset(): void {
105
+ recorded.bindGroups.length = 0;
106
+ recorded.draws.length = 0;
107
+ recorded.loadOps.length = 0;
108
+ recorded.pipelines.length = 0;
109
+ recorded.shaderCode.length = 0;
110
+ recorded.uniforms.length = 0;
111
+ }
112
+
3
113
  describe('applyColorLutPassToWgpu', () => {
4
- it('is a function', () => {
5
- expect(typeof applyColorLutPassToWgpu).toBe('function');
114
+ // ★ WHY THIS FILE IS WORTH MORE THAN ONE EFFECT'S. It is the SINGLE realization for the whole LUT-tier
115
+ // Adjustment family on this backend — curves, levels, gamma, colour grading all bake to one 3D LUT and
116
+ // arrive here — so an error is wrong colour everywhere at once, and a LUT lookup always returns a
117
+ // colour rather than an error.
118
+ //
119
+ // ★ NO HISTORICAL DEFECT EXISTS FOR THIS FILE, so the cases below are CONSTRUCTED, not restored: its
120
+ // history holds the feature commit, a caching perf change, a type move and the lane refactor.
121
+ //
122
+ // ★ SCOPE, STATED SO A PASS IS NOT OVERREAD. The fragment arithmetic — the half-texel cell-centre
123
+ // mapping — is evaluated numerically on the paired Gl file, where a scalar GLSL evaluator exists;
124
+ // effects-wgpu must not depend on effects-gl to borrow it. What this file covers is everything on the
125
+ // CPU side of the seam, which is where this backend carries complexity its sibling does not: an
126
+ // explicit bind-group layout, a pipeline per destination format, and a texture that is recreated rather
127
+ // than merely rewritten when the LUT size changes.
128
+ beforeEach(() => reset());
129
+
130
+ it('uploads the LUT with samples scaled to bytes and alpha forced opaque', () => {
131
+ const harness = createHarness();
132
+
133
+ apply(
134
+ harness,
135
+ createLut(
136
+ 2,
137
+ new Array(24).fill(0).map((_, index) => (index % 3) / 2),
138
+ ),
139
+ emptyCache(),
140
+ );
141
+
142
+ expect([...harness.uploads[0]!.slice(0, 8)]).toEqual([0, 128, 255, 255, 0, 128, 255, 255]);
143
+ });
144
+
145
+ it('clamps a sample outside zero to one rather than wrapping it', () => {
146
+ const harness = createHarness();
147
+ const samples = new Array(24).fill(0);
148
+ samples[0] = -0.5;
149
+ samples[1] = 1.5;
150
+
151
+ apply(harness, createLut(2, samples), emptyCache());
152
+
153
+ expect([...harness.uploads[0]!.slice(0, 2)]).toEqual([0, 255]);
154
+ });
155
+
156
+ // ★ CONSTRUCTED CASE: the write is skipped when handed the SAME LUT reference. A static grade is the
157
+ // common case and a 32³ table is 128 KB — re-uploading it every frame is a defect no output comparison
158
+ // could ever see.
159
+ it('writes once for an unchanged LUT and again when it changes', () => {
160
+ const harness = createHarness();
161
+ const cache = emptyCache();
162
+ const lut = createGreyLut();
163
+
164
+ apply(harness, lut, cache);
165
+ apply(harness, lut, cache);
166
+ expect(harness.uploads).toHaveLength(1);
167
+
168
+ apply(harness, createGreyLut(), cache);
169
+ expect(harness.uploads).toHaveLength(2);
170
+ });
171
+
172
+ // ★ CONSTRUCTED CASE: a 3D texture's dimensions are fixed at creation, so a LUT of a different SIZE
173
+ // needs a new texture — rewriting the old one would either fail validation or grade through a table of
174
+ // the wrong extent. The previous texture is destroyed rather than leaked, since this is a real GPU
175
+ // resource and `destroy` is the verb for one.
176
+ // MEASURED by dropping the `cache.size !== n` arm so the texture is only created once — 1 of 9 failed,
177
+ // the predicted one and only it:
178
+ // AssertionError: expected "vi.fn()" to be called 2 times, but got 1 times
179
+ it('recreates and destroys the texture when the LUT size changes, and reuses it when it does not', () => {
180
+ const harness = createHarness();
181
+ const cache = emptyCache();
182
+
183
+ apply(harness, createGreyLut(2), cache);
184
+ apply(harness, createGreyLut(2), cache);
185
+ expect(harness.createTexture).toHaveBeenCalledTimes(1);
186
+ expect(harness.destroyed).toEqual([]);
187
+
188
+ apply(harness, createGreyLut(4), cache);
189
+ expect(harness.createTexture).toHaveBeenCalledTimes(2);
190
+ expect(harness.destroyed).toEqual(['lut-0']);
191
+ });
192
+
193
+ // ★ CONSTRUCTED CASE: a render pipeline's colour target format must match the attachment it draws into,
194
+ // so an HDR (rgba16float) effect target needs its own pipeline. Cached per state alone, the second
195
+ // format would silently reuse the first's pipeline — a validation error at best and a wrong-format
196
+ // draw at worst, and only on the HDR path.
197
+ // MEASURED by looking the pipeline up without the format key — 1 of 9 failed, the predicted one and
198
+ // only it:
199
+ // AssertionError: expected "vi.fn()" to be called 2 times, but got 1 times
200
+ it('builds one pipeline per destination format and reuses it within a format', () => {
201
+ const harness = createHarness();
202
+ const cache = emptyCache();
203
+
204
+ apply(harness, createGreyLut(), cache, 'rgba8unorm');
205
+ apply(harness, createGreyLut(), cache, 'rgba8unorm');
206
+ expect(harness.state.device.createRenderPipeline).toHaveBeenCalledTimes(1);
207
+
208
+ apply(harness, createGreyLut(), cache, 'rgba16float');
209
+ expect(harness.state.device.createRenderPipeline).toHaveBeenCalledTimes(2);
210
+ expect(recorded.pipelines.at(-1)).toEqual({ format: 'rgba16float' });
211
+ });
212
+
213
+ // The group indices are a contract with the WGSL: 0 uniforms, 1 source texture, 2 LUT. Swapped, the
214
+ // pass would sample the scene as its own grade — every draw wrong, in a way that still produces pixels.
215
+ it('binds uniforms, source and LUT to groups zero, one and two', () => {
216
+ const harness = createHarness();
217
+
218
+ apply(harness, createGreyLut(), emptyCache());
219
+
220
+ expect(recorded.bindGroups.map((entry) => entry.index)).toEqual([0, 1, 2]);
221
+ expect(recorded.draws).toEqual([6]);
222
+ });
223
+
224
+ // `load` rather than `clear`: this is a full-frame grade over what is already in the target.
225
+ it('loads the destination rather than clearing it', () => {
226
+ const harness = createHarness();
227
+
228
+ apply(harness, createGreyLut(), emptyCache());
229
+
230
+ expect(recorded.loadOps).toEqual(['load']);
231
+ });
232
+
233
+ it('passes the LUT size to the shader as its one uniform', () => {
234
+ const harness = createHarness();
235
+
236
+ apply(harness, createGreyLut(4), emptyCache());
237
+
238
+ expect(recorded.uniforms[0]![0]).toBe(4);
239
+ });
240
+
241
+ // A TEXT check, and named as one: it pins the half-texel form against the numeric assertions made on
242
+ // the Gl sibling. It cannot tell whether the arithmetic is right — only that these two shaders still
243
+ // agree about what to compute, which is the cross-backend claim worth locking here.
244
+ it('computes cell centres from the size uniform, matching the Gl recipe', () => {
245
+ const harness = createHarness();
246
+
247
+ apply(harness, createGreyLut(), emptyCache());
248
+
249
+ expect(recorded.shaderCode[0]).toContain('(uni.u_size - 1.0) / uni.u_size');
250
+ expect(recorded.shaderCode[0]).toContain('0.5 / uni.u_size');
6
251
  });
7
252
  });
@@ -1,7 +1,105 @@
1
+ import type { WgpuRenderState, WgpuRenderTarget } from '@flighthq/types/contract';
2
+
3
+ const passMock = vi.hoisted(() => ({
4
+ drawWgpuEffectPass: vi.fn(
5
+ (
6
+ _state: unknown,
7
+ _source: unknown,
8
+ _dest: unknown,
9
+ _pipeline: unknown,
10
+ setUniforms: (f32: Float32Array) => void,
11
+ ) => {
12
+ const f32 = new Float32Array(20);
13
+ setUniforms(f32);
14
+ uniformState.uploads.push([...f32]);
15
+ },
16
+ ),
17
+ }));
18
+ const pipelineMock = vi.hoisted(() => ({
19
+ getWgpuEffectPipeline: vi.fn((_state: unknown, _key: string, _wgsl: string, _blend: string) => ({ pipeline: {} })),
20
+ }));
21
+ const uniformState = vi.hoisted(() => ({ uploads: [] as number[][] }));
22
+
23
+ vi.mock('./wgpuEffectPass', () => passMock);
24
+ vi.mock('./wgpuEffectProgramCache', () => pipelineMock);
25
+
1
26
  import { applyColorMatrixPassToWgpu } from './wgpuColorMatrixPass';
2
27
 
28
+ function packed(matrix: ReadonlyArray<number>): readonly number[] {
29
+ uniformState.uploads.length = 0;
30
+ pipelineMock.getWgpuEffectPipeline.mockClear();
31
+ const target = {} as unknown as WgpuRenderTarget;
32
+ applyColorMatrixPassToWgpu({} as unknown as WgpuRenderState, target, target, matrix);
33
+ return uniformState.uploads[0]!;
34
+ }
35
+
3
36
  describe('applyColorMatrixPassToWgpu', () => {
4
- it('is a function', () => {
5
- expect(typeof applyColorMatrixPassToWgpu).toBe('function');
37
+ // ★ WHY THIS FILE IS WORTH MORE THAN ONE EFFECT'S. It is the SINGLE realization for the whole
38
+ // matrix-tier Adjustment family on this backend — invert, grayscale, sepia, saturation, hue rotate,
39
+ // scale/bias all fuse into one 4×5 matrix and arrive here. A packing error is wrong colour in every one
40
+ // of them, and it produces a plausible colour rather than an error.
41
+ //
42
+ // ★ THIS BACKEND REPACKS AND THE OTHER DOES NOT, WHICH IS THE WHOLE HAZARD. Gl uploads the caller's
43
+ // twenty floats verbatim; WGSL wants four vec4f coefficient ROWS with the bias column moved to a fifth
44
+ // vec4f, so this runner permutes 20 indices by hand. Twenty hand-written assignments is precisely the
45
+ // shape where one transposed pair survives review, and nothing downstream would report it.
46
+ //
47
+ // ★ NO HISTORICAL DEFECT EXISTS FOR THIS FILE, so the cases are CONSTRUCTED, not restored: its history
48
+ // holds the feature commit, an API rename, a type move and the lane refactor. Branch-2 shape.
49
+ //
50
+ // MEASURED by swapping the red row's alpha coefficient with its bias (f32[3] <-> f32[16]) — 2 of 6:
51
+ // AssertionError: expected [ 1, 2, 3, 5, 6, 7, 8, 9, 11, …(11) ] to deeply equal [ 1, 2, 3, 4, 6, 7, 8, 9, 11, …(11) ]
52
+ // AssertionError: expected 5 to be 4 // Object.is equality
53
+ it('packs the four coefficient rows first and the bias column last', () => {
54
+ // Twenty distinct values, so every slot names exactly one source index and a swap cannot hide.
55
+ const distinct = Array.from({ length: 20 }, (_, index) => index + 1);
56
+
57
+ expect(packed(distinct)).toEqual([
58
+ // rows R, G, B, A — the RGBA coefficients of each, with each row's bias skipped
59
+ 1, 2, 3, 4, 6, 7, 8, 9, 11, 12, 13, 14, 16, 17, 18, 19,
60
+ // the bias column, gathered from indices 4, 9, 14, 19
61
+ 5, 10, 15, 20,
62
+ ]);
63
+ });
64
+
65
+ // Stated separately from the packing above because it is the claim a reader actually needs: the
66
+ // caller's 4×5 row-major matrix means the same thing on both backends, and only the transport differs.
67
+ it('keeps each row bias with its own row, not with the row after it', () => {
68
+ const distinct = Array.from({ length: 20 }, (_, index) => index + 1);
69
+ const uploaded = packed(distinct);
70
+
71
+ // Red's bias is the caller's index 4, and red's alpha coefficient is index 3 — the pair a one-off
72
+ // permutation swaps.
73
+ expect(uploaded[3]).toBe(distinct[3]);
74
+ expect(uploaded[16]).toBe(distinct[4]);
75
+ expect(uploaded[19]).toBe(distinct[19]);
76
+ });
77
+
78
+ it('leaves an identity matrix as an identity packing', () => {
79
+ expect(packed(IDENTITY)).toEqual([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0]);
80
+ });
81
+
82
+ // A caller handing over a 4×4 (sixteen entries) is the realistic mistake, and every missing slot must
83
+ // read as zero rather than as `undefined` written into a Float32Array — which would be NaN, and NaN
84
+ // through a colour matrix is a transparent black frame.
85
+ it('zero-fills a matrix shorter than twenty', () => {
86
+ const uploaded = packed([1, 0, 0, 0]);
87
+
88
+ expect(uploaded.every(Number.isFinite)).toBe(true);
89
+ expect(uploaded.slice(4)).toEqual(new Array(16).fill(0));
90
+ });
91
+
92
+ it('ignores coefficients past the twentieth', () => {
93
+ expect(packed([...IDENTITY, 99, 98])).toEqual(packed(IDENTITY));
94
+ });
95
+
96
+ it('compiles one pipeline for every matrix, in replace blend', () => {
97
+ packed(IDENTITY);
98
+
99
+ const call = pipelineMock.getWgpuEffectPipeline.mock.calls[0]!;
100
+ expect(call[1]).toBe('adjustment.colorMatrix');
101
+ expect(call[3]).toBe('replace');
6
102
  });
7
103
  });
104
+
105
+ const IDENTITY = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0];