@forgeax/engine-render 0.1.31 → 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +68 -1
  2. package/dist/assembly/webgpu-ready.d.ts +2 -1
  3. package/dist/assembly/webgpu-ready.d.ts.map +1 -1
  4. package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
  5. package/dist/{chunk-C3DDSXYK.mjs → chunk-4NNZQCL3.mjs} +110 -35
  6. package/dist/chunk-4NNZQCL3.mjs.map +1 -0
  7. package/dist/{chunk-JRBDO2KB.mjs → chunk-6TFBXLRJ.mjs} +19 -7
  8. package/dist/chunk-6TFBXLRJ.mjs.map +1 -0
  9. package/dist/{chunk-FUUO4UND.mjs → chunk-DRR7D3KX.mjs} +3 -3
  10. package/dist/chunk-DRR7D3KX.mjs.map +1 -0
  11. package/dist/{chunk-S5UE2T5P.mjs → chunk-GWCEMDVR.mjs} +380 -4
  12. package/dist/chunk-GWCEMDVR.mjs.map +1 -0
  13. package/dist/{chunk-MDQJPY3T.mjs → chunk-JCIRYQ4V.mjs} +3 -3
  14. package/dist/{chunk-MDQJPY3T.mjs.map → chunk-JCIRYQ4V.mjs.map} +1 -1
  15. package/dist/{chunk-PSEQ6QPH.mjs → chunk-JRIPCMYC.mjs} +17 -10
  16. package/dist/chunk-JRIPCMYC.mjs.map +1 -0
  17. package/dist/{chunk-7MQ3XBSP.mjs → chunk-LULCA3OF.mjs} +4 -4
  18. package/dist/{chunk-7MQ3XBSP.mjs.map → chunk-LULCA3OF.mjs.map} +1 -1
  19. package/dist/{chunk-NO4WZCAH.mjs → chunk-S665NJNF.mjs} +3 -3
  20. package/dist/{chunk-NO4WZCAH.mjs.map → chunk-S665NJNF.mjs.map} +1 -1
  21. package/dist/components/depth-of-field.d.ts +28 -0
  22. package/dist/components/depth-of-field.d.ts.map +1 -0
  23. package/dist/components/directional-light.d.ts +2 -1
  24. package/dist/components/directional-light.d.ts.map +1 -1
  25. package/dist/components/index.d.ts +1 -0
  26. package/dist/components/index.d.ts.map +1 -1
  27. package/dist/construct-renderer.mjs +898 -328
  28. package/dist/construct-renderer.mjs.map +1 -1
  29. package/dist/extract/camera.d.ts.map +1 -1
  30. package/dist/features/depth-of-field/depth-of-field-assembly.d.ts +19 -0
  31. package/dist/features/depth-of-field/depth-of-field-assembly.d.ts.map +1 -0
  32. package/dist/features/depth-of-field/depth-of-field-feature.d.ts +43 -0
  33. package/dist/features/depth-of-field/depth-of-field-feature.d.ts.map +1 -0
  34. package/dist/features/depth-of-field/depth-of-field-inspection.d.ts +34 -0
  35. package/dist/features/depth-of-field/depth-of-field-inspection.d.ts.map +1 -0
  36. package/dist/features/depth-of-field/depth-of-field-params.d.ts +83 -0
  37. package/dist/features/depth-of-field/depth-of-field-params.d.ts.map +1 -0
  38. package/dist/features/types.d.ts +19 -1
  39. package/dist/features/types.d.ts.map +1 -1
  40. package/dist/index.d.ts +4 -2
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.mjs +6 -5
  43. package/dist/index.mjs.map +1 -1
  44. package/dist/inspection-types.d.ts +35 -0
  45. package/dist/inspection-types.d.ts.map +1 -1
  46. package/dist/instances.d.ts +7 -0
  47. package/dist/instances.d.ts.map +1 -1
  48. package/dist/internal.mjs +6 -6
  49. package/dist/mesh-material-bindings.d.ts +3 -0
  50. package/dist/mesh-material-bindings.d.ts.map +1 -1
  51. package/dist/pipeline/standard-output/graph.d.ts.map +1 -1
  52. package/dist/pipeline/standard-output/types.d.ts +4 -2
  53. package/dist/pipeline/standard-output/types.d.ts.map +1 -1
  54. package/dist/pipeline/standard-post.d.ts.map +1 -1
  55. package/dist/plugin.d.ts.map +1 -1
  56. package/dist/record/frame-snapshot.d.ts +21 -1
  57. package/dist/record/frame-snapshot.d.ts.map +1 -1
  58. package/dist/record/frame.d.ts.map +1 -1
  59. package/dist/record/main-pass.d.ts.map +1 -1
  60. package/dist/record/typed-frame-graph.d.ts +7 -0
  61. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  62. package/dist/recovery/render-system-candidate.d.ts.map +1 -1
  63. package/dist/render-contract.d.ts +8 -1
  64. package/dist/render-contract.d.ts.map +1 -1
  65. package/dist/render-graph-primitives.d.ts +2 -0
  66. package/dist/render-graph-primitives.d.ts.map +1 -1
  67. package/dist/render-pipeline.d.ts +13 -1
  68. package/dist/render-pipeline.d.ts.map +1 -1
  69. package/dist/render-system-extract-tail.d.ts.map +1 -1
  70. package/dist/render-system-extract.d.ts +18 -4
  71. package/dist/render-system-extract.d.ts.map +1 -1
  72. package/dist/render-system.d.ts +2 -1
  73. package/dist/render-system.d.ts.map +1 -1
  74. package/dist/temporal/index.mjs +5 -5
  75. package/dist/typed-render-graph-primitives.d.ts.map +1 -1
  76. package/package.json +21 -21
  77. package/src/__tests__/gpu-driven-baseline.characterization.test.ts +12 -0
  78. package/src/__tests__/gpu-driven-production.integration.test.ts +27 -1
  79. package/src/__tests__/instances-store.unit.test.ts +15 -0
  80. package/src/__tests__/prepared-gpu-driven-pbr.integration.test.ts +28 -0
  81. package/src/assembly/webgpu-ready.ts +5 -5
  82. package/src/assembly/webgpu-renderer.ts +14 -14
  83. package/src/components/depth-of-field.ts +45 -0
  84. package/src/components/directional-light.ts +2 -1
  85. package/src/components/index.ts +1 -0
  86. package/src/extract/camera.ts +22 -2
  87. package/src/features/depth-of-field/__tests__/depth-of-field-params.unit.test.ts +943 -0
  88. package/src/features/depth-of-field/depth-of-field-assembly.ts +100 -0
  89. package/src/features/depth-of-field/depth-of-field-feature.ts +295 -0
  90. package/src/features/depth-of-field/depth-of-field-inspection.ts +237 -0
  91. package/src/features/depth-of-field/depth-of-field-params.ts +556 -0
  92. package/src/features/host.ts +1 -1
  93. package/src/features/types.ts +18 -1
  94. package/src/index.ts +32 -0
  95. package/src/inspection-types.ts +38 -0
  96. package/src/instances.ts +19 -5
  97. package/src/mesh-material-bindings.ts +4 -0
  98. package/src/pipeline/standard-output/graph.ts +3 -0
  99. package/src/pipeline/standard-output/types.ts +4 -1
  100. package/src/pipeline/standard-post.ts +21 -2
  101. package/src/plugin.ts +2 -0
  102. package/src/record/frame-snapshot.ts +26 -1
  103. package/src/record/frame.ts +39 -6
  104. package/src/record/main-pass.ts +1 -5
  105. package/src/record/typed-frame-graph.ts +106 -6
  106. package/src/recovery/render-system-candidate.ts +6 -0
  107. package/src/render-contract.ts +11 -0
  108. package/src/render-graph-primitives.ts +8 -1
  109. package/src/render-pipeline.ts +17 -1
  110. package/src/render-system-extract-tail.ts +87 -13
  111. package/src/render-system-extract.ts +103 -25
  112. package/src/render-system.ts +78 -0
  113. package/src/typed-render-graph-primitives.ts +3 -0
  114. package/dist/chunk-C3DDSXYK.mjs.map +0 -1
  115. package/dist/chunk-FUUO4UND.mjs.map +0 -1
  116. package/dist/chunk-JRBDO2KB.mjs.map +0 -1
  117. package/dist/chunk-PSEQ6QPH.mjs.map +0 -1
  118. package/dist/chunk-S5UE2T5P.mjs.map +0 -1
@@ -0,0 +1,943 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ DepthOfFieldQualityValue,
5
+ DepthOfFieldSideValue,
6
+ } from '../../../components/depth-of-field';
7
+ import { depthOfFieldTopology } from '../depth-of-field-feature';
8
+ import { projectDepthOfFieldInspection } from '../depth-of-field-inspection';
9
+ import {
10
+ DEPTH_OF_FIELD_PARAMS_BYTE_SIZE,
11
+ depthOfFieldQualityCode,
12
+ depthOfFieldRequestFailure,
13
+ depthOfFieldSideCode,
14
+ depthOfFieldTapCount,
15
+ packDepthOfFieldParams,
16
+ resolveDepthOfFieldFrameParams,
17
+ signedDepthOfFieldCoC,
18
+ validateDepthOfFieldFrameParams,
19
+ validateDepthOfFieldParams,
20
+ } from '../depth-of-field-params';
21
+
22
+ const camera = {
23
+ projection: 'perspective' as const,
24
+ fov: (40 * Math.PI) / 180,
25
+ near: 0.1,
26
+ far: 100,
27
+ };
28
+
29
+ function smoothstep(edge0: number, edge1: number, value: number): number {
30
+ const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)));
31
+ return t * t * (3 - 2 * t);
32
+ }
33
+
34
+ function halfResolutionSourceFactor(radiusPixels: number): number {
35
+ return smoothstep(2, 3, radiusPixels);
36
+ }
37
+
38
+ function fullResolutionSourceFactor(radiusPixels: number): number {
39
+ return smoothstep(0.5, 2, radiusPixels);
40
+ }
41
+
42
+ function radiusMix(radiusPixels: number): { readonly small: number; readonly half: number } {
43
+ const half = halfResolutionSourceFactor(radiusPixels);
44
+ return {
45
+ small: fullResolutionSourceFactor(radiusPixels) * (1 - half),
46
+ half,
47
+ };
48
+ }
49
+
50
+ function circleKernelMean(sourceRadius: number, domainRadius: number): number {
51
+ const sampleRadius = Math.max(domainRadius, 1e-5);
52
+ const kernelRadius = Math.max(sourceRadius + 1, 1e-5);
53
+ if (kernelRadius <= sampleRadius) {
54
+ return Math.max((kernelRadius * kernelRadius) / (3 * sampleRadius * sampleRadius), 1e-5);
55
+ }
56
+ return Math.max(1 - (2 * sampleRadius) / (3 * kernelRadius), 1e-5);
57
+ }
58
+
59
+ function sourceKernelMean(sourceRadius: number, domainRadius = 16, tapCount = 64): number {
60
+ const localCount = Math.max(1, Math.floor(tapCount * 0.25));
61
+ const outerCount = Math.max(tapCount - localCount, 1);
62
+ const localMean = circleKernelMean(sourceRadius, Math.min(domainRadius, 3));
63
+ const outerMean = circleKernelMean(sourceRadius, domainRadius);
64
+ return Math.max((localCount * localMean + outerCount * outerMean) / Math.max(tapCount, 1), 1e-5);
65
+ }
66
+
67
+ function diskOffsetRadius(index: number, count: number, radius: number): number {
68
+ return Math.sqrt((index + 0.5) / Math.max(count, 1)) * radius;
69
+ }
70
+
71
+ function sourceDiskOffsetRadius(index: number, count: number, maxRadius: number): number {
72
+ const localCount = Math.max(1, Math.floor(count * 0.25));
73
+ if (index < localCount) {
74
+ return diskOffsetRadius(index, localCount, Math.min(maxRadius, 3));
75
+ }
76
+ return diskOffsetRadius(index - localCount, Math.max(count - localCount, 1), maxRadius);
77
+ }
78
+
79
+ function gatherCoverageAlpha(
80
+ supportWeights: readonly number[],
81
+ sourceRadius: number,
82
+ sourceCoverages: readonly number[] = supportWeights.map(() => 1),
83
+ sampleAlphas: readonly number[] = supportWeights.map(() => 1),
84
+ accepted: readonly boolean[] = supportWeights.map(() => true),
85
+ domainRadius = 16,
86
+ ): number {
87
+ if (
88
+ sourceCoverages.length !== supportWeights.length ||
89
+ sampleAlphas.length !== supportWeights.length ||
90
+ accepted.length !== supportWeights.length
91
+ ) {
92
+ throw new Error('coverage fixtures must have matching lengths');
93
+ }
94
+ const largeFactor = halfResolutionSourceFactor(sourceRadius);
95
+ const kernelMean = sourceKernelMean(sourceRadius, domainRadius, supportWeights.length);
96
+ let capacity = 0;
97
+ let numerator = 0;
98
+ for (let index = 0; index < supportWeights.length; index += 1) {
99
+ const sourceCoverage = sourceCoverages[index] ?? 0;
100
+ capacity += sourceCoverage;
101
+ if (accepted[index] === true) {
102
+ const normalizedSupport = (supportWeights[index] ?? 0) / kernelMean;
103
+ numerator += normalizedSupport * largeFactor * sourceCoverage * (sampleAlphas[index] ?? 0);
104
+ }
105
+ }
106
+ return Math.min(1, numerator / Math.max(capacity, 1e-5));
107
+ }
108
+
109
+ function backgroundConfidence(
110
+ supportWeights: readonly number[],
111
+ sourceRadius: number,
112
+ sourceCoverages: readonly number[],
113
+ nearSource: readonly boolean[],
114
+ backgroundWeights: readonly number[],
115
+ domainRadius = 16,
116
+ ): number {
117
+ if (
118
+ sourceCoverages.length !== supportWeights.length ||
119
+ nearSource.length !== supportWeights.length ||
120
+ backgroundWeights.length !== supportWeights.length
121
+ ) {
122
+ throw new Error('background fixtures must have matching lengths');
123
+ }
124
+ const largeFactor = fullResolutionSourceFactor(sourceRadius);
125
+ const kernelMean = sourceKernelMean(sourceRadius, domainRadius, supportWeights.length);
126
+ let capacity = 0;
127
+ let nearCoverage = 0;
128
+ let backgroundWeight = 0;
129
+ for (let index = 0; index < supportWeights.length; index += 1) {
130
+ const sourceCoverage = sourceCoverages[index] ?? 0;
131
+ capacity += sourceCoverage;
132
+ if (nearSource[index] === true) {
133
+ nearCoverage += ((supportWeights[index] ?? 0) / kernelMean) * largeFactor * sourceCoverage;
134
+ }
135
+ backgroundWeight += backgroundWeights[index] ?? 0;
136
+ }
137
+ const hasBackground = backgroundWeight > 0 && capacity > 0;
138
+ return hasBackground ? Math.min(1, nearCoverage / Math.max(capacity, 1e-5)) : 0;
139
+ }
140
+
141
+ function backgroundResolveColor(
142
+ currentColor: readonly number[],
143
+ backgroundColor: readonly number[],
144
+ nearCoverage: number,
145
+ coverageCapacity: number,
146
+ backgroundWeight: number,
147
+ ): number[] {
148
+ const hasBackground = backgroundWeight > 0 && coverageCapacity > 0;
149
+ const confidence = hasBackground
150
+ ? Math.min(1, nearCoverage / Math.max(coverageCapacity, 1e-5))
151
+ : 0;
152
+ return currentColor.map(
153
+ (value, index) => value * (1 - confidence) + (backgroundColor[index] ?? 0) * confidence,
154
+ );
155
+ }
156
+
157
+ function areaWeightedColor(
158
+ samples: readonly { readonly color: readonly number[]; readonly alpha: number }[],
159
+ ): number[] {
160
+ const totalArea = samples.reduce((sum, sample) => sum + sample.alpha, 0);
161
+ return (samples[0]?.color ?? []).map(
162
+ (_, channel) =>
163
+ samples.reduce((sum, sample) => sum + (sample.color[channel] ?? 0) * sample.alpha, 0) /
164
+ Math.max(totalArea, 1e-5),
165
+ );
166
+ }
167
+
168
+ function prefilterResolveColor(
169
+ currentColor: readonly number[],
170
+ samples: readonly { readonly color: readonly number[]; readonly alpha: number }[],
171
+ ): number[] {
172
+ const totalArea = samples.reduce((sum, sample) => sum + sample.alpha, 0);
173
+ if (totalArea <= 0) return [...currentColor];
174
+ return areaWeightedColor(samples);
175
+ }
176
+
177
+ function compositeNearColor(
178
+ baseColor: readonly number[],
179
+ nearColor: readonly number[],
180
+ coverage: number,
181
+ ): number[] {
182
+ const weight = Math.max(0, Math.min(1, coverage));
183
+ return baseColor.map((value, index) => value * (1 - weight) + (nearColor[index] ?? 0) * weight);
184
+ }
185
+
186
+ function smallKernelAcceptedCount(sourceRadius: number, tapCount = 8, radiusPixels = 2): number {
187
+ return Array.from({ length: tapCount }, (_, index) =>
188
+ diskOffsetRadius(index, tapCount, radiusPixels),
189
+ ).filter((distance) => distance <= sourceRadius + 0.5).length;
190
+ }
191
+
192
+ function smallKernelColor(
193
+ centerColor: readonly number[],
194
+ sourceColor: readonly number[],
195
+ sourceRadius: number,
196
+ tapCount = 8,
197
+ ): number[] {
198
+ const accepted = smallKernelAcceptedCount(sourceRadius, tapCount);
199
+ const weight = 1 + accepted;
200
+ return centerColor.map((value, index) => (value + (sourceColor[index] ?? 0) * accepted) / weight);
201
+ }
202
+
203
+ function sourceCircleSupport(sourceRadius: number, distancePixels: number): number {
204
+ return Math.max(
205
+ 0,
206
+ Math.min(1, (sourceRadius + 1 - distancePixels) / Math.max(sourceRadius + 1, 1)),
207
+ );
208
+ }
209
+
210
+ function smallKernelConfidence(sourceRadius: number, tapCount = 8, radiusPixels = 2): number {
211
+ const accepted = smallKernelAcceptedCount(sourceRadius, tapCount, radiusPixels);
212
+ const smallSourceFactor = 1 - halfResolutionSourceFactor(sourceRadius);
213
+ const sourceWeight = accepted * smallSourceFactor;
214
+ return Math.min(1, Math.max(0, sourceWeight / Math.max(1 + sourceWeight, 1)));
215
+ }
216
+
217
+ function focalNearColor(
218
+ baseColor: readonly number[],
219
+ nearGatherColor: readonly number[],
220
+ smallColor: readonly number[],
221
+ smallConfidence: number,
222
+ nearCoverage: number,
223
+ ): number[] {
224
+ const blendedNearColor = nearGatherColor.map(
225
+ (value, index) => value * (1 - smallConfidence) + (smallColor[index] ?? 0) * smallConfidence,
226
+ );
227
+ return compositeNearColor(baseColor, blendedNearColor, nearCoverage);
228
+ }
229
+
230
+ function singleSampleCoverage(depth: number): number {
231
+ return Number.isFinite(depth) && depth > 0 ? 1 : 0;
232
+ }
233
+
234
+ function nearestLayerCoverage(depths: readonly (number | undefined)[]): {
235
+ readonly depth: number;
236
+ readonly coverage: number;
237
+ } {
238
+ const valid = depths.filter(
239
+ (depth): depth is number => depth !== undefined && Number.isFinite(depth) && depth > 0,
240
+ );
241
+ if (valid.length === 0) return { depth: 0, coverage: 0 };
242
+ const depth = Math.min(...valid);
243
+ const tolerance = Math.max(0.02, depth * 0.04);
244
+ const nearestSamples = valid.filter((sample) => Math.abs(sample - depth) <= tolerance).length;
245
+ return { depth, coverage: nearestSamples / 4 };
246
+ }
247
+
248
+ describe('Depth of Field parameter and shader contract', () => {
249
+ it('derives the thin-lens focal length and signed CoC oracle', () => {
250
+ const resolved = validateDepthOfFieldParams({}, camera);
251
+ expect(resolved.ok).toBe(true);
252
+ if (!resolved.ok) return;
253
+
254
+ const { value } = resolved;
255
+ expect(value.focalLength).toBeCloseTo(0.032969729, 7);
256
+ expect(signedDepthOfFieldCoC(value, 8, 1080)).toBeCloseTo(0, 7);
257
+ expect(signedDepthOfFieldCoC(value, 4, 1080)).toBeCloseTo(-1.0963741, 5);
258
+ expect(signedDepthOfFieldCoC(value, 16, 1080)).toBeCloseTo(0.548187, 5);
259
+
260
+ const clamped = validateDepthOfFieldParams({ maxRadiusPixels: 0.5 }, camera);
261
+ expect(clamped.ok).toBe(true);
262
+ if (clamped.ok) {
263
+ expect(signedDepthOfFieldCoC(clamped.value, 4, 1080)).toBe(-0.5);
264
+ expect(signedDepthOfFieldCoC(clamped.value, 16, 1080)).toBe(0.5);
265
+ }
266
+ });
267
+
268
+ it('keeps wide but representable f32 optics finite with the stable CoC ratio', () => {
269
+ const wideCamera = {
270
+ projection: 'perspective' as const,
271
+ fov: 1,
272
+ near: 1,
273
+ far: 2e30,
274
+ };
275
+ const resolved = validateDepthOfFieldParams(
276
+ { focusDistance: 1e30, sensorHeight: 1e20 },
277
+ wideCamera,
278
+ );
279
+ expect(resolved.ok).toBe(true);
280
+ if (!resolved.ok) return;
281
+ const frame = validateDepthOfFieldFrameParams(resolved.value, {
282
+ outputHeight: 1080,
283
+ near: wideCamera.near,
284
+ far: wideCamera.far,
285
+ useTemporalDepth: false,
286
+ });
287
+ expect(frame.ok).toBe(true);
288
+ const bytes = packDepthOfFieldParams(resolved.value, {
289
+ outputHeight: 1080,
290
+ near: wideCamera.near,
291
+ far: wideCamera.far,
292
+ useTemporalDepth: false,
293
+ });
294
+ expect(Array.from(new Float32Array(bytes.buffer)).every(Number.isFinite)).toBe(true);
295
+ expect(signedDepthOfFieldCoC(resolved.value, 1e30, 1080)).toBeCloseTo(0, 5);
296
+ });
297
+
298
+ it('accepts the authored f32 f-stop boundaries after quantization', () => {
299
+ const minimum = validateDepthOfFieldParams({ fStop: 0.7 }, camera);
300
+ const maximum = validateDepthOfFieldParams({ fStop: 32 }, camera);
301
+ expect(minimum.ok).toBe(true);
302
+ expect(maximum.ok).toBe(true);
303
+ if (minimum.ok) expect(minimum.value.fStop).toBe(Math.fround(0.7));
304
+ if (maximum.ok) expect(maximum.value.fStop).toBe(Math.fround(32));
305
+ });
306
+
307
+ it('rejects values that become zero or infinity at the f32 boundary', () => {
308
+ const cases = [
309
+ [{ focusDistance: Number.MIN_VALUE }, camera, 'focusDistance'],
310
+ [{ sensorHeight: Number.MIN_VALUE }, camera, 'sensorHeight'],
311
+ [{ fStop: Number.MAX_VALUE }, camera, 'fStop'],
312
+ [{ focusDistance: 8 }, { ...camera, near: Number.MIN_VALUE }, 'near/far'],
313
+ [{ focusDistance: 8 }, { ...camera, far: Number.MAX_VALUE }, 'near/far'],
314
+ ] as const;
315
+ for (const [input, inputCamera, field] of cases) {
316
+ const result = validateDepthOfFieldParams(input, inputCamera);
317
+ expect(result.ok, field).toBe(false);
318
+ if (!result.ok && 'field' in result.error.detail) {
319
+ expect(result.error.detail.field).toBe(field);
320
+ }
321
+ }
322
+ });
323
+
324
+ it('rejects a focus distance whose f32 value equals the derived focal length', () => {
325
+ const focal = Math.fround(camera.fov === 0 ? 0 : 0.024 / (2 * Math.tan(camera.fov / 2)));
326
+ const result = validateDepthOfFieldParams({ focusDistance: focal }, { ...camera, near: 0.001 });
327
+ expect(result.ok).toBe(false);
328
+ if (!result.ok && 'field' in result.error.detail) {
329
+ expect(result.error.detail.field).toBe('focusDistance');
330
+ expect(result.error.expected).toContain('focalLength in f32');
331
+ }
332
+ });
333
+
334
+ it('rejects an unrepresentable packed CoC endpoint before creating the UBO', () => {
335
+ const resolved = validateDepthOfFieldParams(
336
+ { focusDistance: 1, sensorHeight: 0.1 },
337
+ { projection: 'perspective', fov: 1, near: 1e-38, far: 10 },
338
+ );
339
+ expect(resolved.ok).toBe(true);
340
+ if (!resolved.ok) return;
341
+ const frame = validateDepthOfFieldFrameParams(resolved.value, {
342
+ outputHeight: 1080,
343
+ near: 1e-38,
344
+ far: 10,
345
+ useTemporalDepth: false,
346
+ });
347
+ expect(frame.ok).toBe(false);
348
+ let thrown: unknown;
349
+ try {
350
+ packDepthOfFieldParams(resolved.value, {
351
+ outputHeight: 1080,
352
+ near: 1e-38,
353
+ far: 10,
354
+ useTemporalDepth: false,
355
+ });
356
+ } catch (error) {
357
+ thrown = error;
358
+ }
359
+ expect(thrown).toBeInstanceOf(Error);
360
+ expect((thrown as { readonly detail?: { readonly field?: string } }).detail?.field).toBe(
361
+ 'coc.near',
362
+ );
363
+ });
364
+
365
+ it('rejects a near/far ratio that collapses to zero at the f32 depth seam', () => {
366
+ const resolved = validateDepthOfFieldParams({}, camera);
367
+ expect(resolved.ok).toBe(true);
368
+ if (!resolved.ok) return;
369
+ const frame = validateDepthOfFieldFrameParams(resolved.value, {
370
+ outputHeight: 1080,
371
+ near: Math.fround(2 ** -149),
372
+ far: Math.fround(3.402823e38),
373
+ useTemporalDepth: false,
374
+ });
375
+ expect(frame.ok).toBe(false);
376
+ if (!frame.ok && 'field' in frame.error.detail) {
377
+ expect(frame.error.detail.field).toBe('near/far ratio');
378
+ }
379
+ });
380
+
381
+ it('preserves CoC under equal world-length scaling', () => {
382
+ const base = validateDepthOfFieldParams({}, camera);
383
+ const scaled = validateDepthOfFieldParams(
384
+ { focusDistance: 80, sensorHeight: 0.24 },
385
+ { ...camera, near: 1, far: 1000 },
386
+ );
387
+ expect(base.ok).toBe(true);
388
+ expect(scaled.ok).toBe(true);
389
+ if (base.ok && scaled.ok) {
390
+ for (const depth of [2, 4, 8, 16, 20]) {
391
+ expect(signedDepthOfFieldCoC(scaled.value, depth * 10, 1080)).toBeCloseTo(
392
+ signedDepthOfFieldCoC(base.value, depth, 1080),
393
+ 5,
394
+ );
395
+ }
396
+ }
397
+ });
398
+
399
+ it('packs the stable 4xvec4 layout consumed by both WGSL variants', () => {
400
+ const resolved = validateDepthOfFieldParams({ quality: DepthOfFieldQualityValue.high }, camera);
401
+ expect(resolved.ok).toBe(true);
402
+ if (!resolved.ok) return;
403
+ const bytes = packDepthOfFieldParams(resolved.value, {
404
+ outputHeight: 1080,
405
+ near: camera.near,
406
+ far: camera.far,
407
+ useTemporalDepth: true,
408
+ });
409
+ expect(bytes.byteLength).toBe(DEPTH_OF_FIELD_PARAMS_BYTE_SIZE);
410
+ const payload = Array.from(new Float32Array(bytes.buffer));
411
+ const expected = [
412
+ 8,
413
+ 2.8,
414
+ 0.024,
415
+ resolved.value.focalLength,
416
+ 1080,
417
+ 16,
418
+ 0,
419
+ 2,
420
+ 0.1,
421
+ 100,
422
+ 1,
423
+ 0,
424
+ 1.0963743,
425
+ 0,
426
+ 0,
427
+ 0,
428
+ ];
429
+ expect(payload).toHaveLength(expected.length);
430
+ payload.forEach((value, index) => {
431
+ expect(value).toBeCloseTo(expected[index] ?? 0, 6);
432
+ });
433
+ // The WGSL consumer reads reserved.x (row3.x, float index 12). Keep the
434
+ // row2.w reserved lane zero so the CPU pack and both shader variants share
435
+ // one 64-byte binding contract.
436
+ expect(payload[11]).toBeCloseTo(0, 6);
437
+ expect(payload[12]).toBeCloseTo(1.0963743, 6);
438
+ expect(signedDepthOfFieldCoC(resolved.value, camera.near, 1080)).toBeLessThan(0);
439
+ expect(signedDepthOfFieldCoC(resolved.value, resolved.value.focusDistance, 1080)).toBeCloseTo(
440
+ 0,
441
+ 6,
442
+ );
443
+ expect(signedDepthOfFieldCoC(resolved.value, camera.far, 1080)).toBeGreaterThan(0);
444
+ expect(depthOfFieldQualityCode('low')).toBe(DepthOfFieldQualityValue.low);
445
+ expect(depthOfFieldSideCode('near')).toBe(DepthOfFieldSideValue.near);
446
+ expect(depthOfFieldTapCount('low')).toBe(16);
447
+ expect(depthOfFieldTapCount('medium')).toBe(32);
448
+ expect(depthOfFieldTapCount('high')).toBe(64);
449
+ });
450
+
451
+ it('keeps topology changes separate from optical values and rejects invalid projection', () => {
452
+ const both = validateDepthOfFieldParams({}, camera);
453
+ const near = validateDepthOfFieldParams({ blurSide: DepthOfFieldSideValue.near }, camera);
454
+ const far = validateDepthOfFieldParams({ blurSide: DepthOfFieldSideValue.far }, camera);
455
+ expect(both.ok && depthOfFieldTopology(both.value)).toEqual({
456
+ blurSide: 'both',
457
+ useNear: true,
458
+ useFar: true,
459
+ });
460
+ expect(near.ok && depthOfFieldTopology(near.value)).toEqual({
461
+ blurSide: 'near',
462
+ useNear: true,
463
+ useFar: false,
464
+ });
465
+ expect(far.ok && depthOfFieldTopology(far.value)).toEqual({
466
+ blurSide: 'far',
467
+ useNear: false,
468
+ useFar: true,
469
+ });
470
+ expect(both.ok).toBe(true);
471
+ if (both.ok) {
472
+ expect(depthOfFieldTopology({ ...both.value, maxRadiusPixels: 0 })).toBeUndefined();
473
+ }
474
+
475
+ const orthographic = validateDepthOfFieldParams({}, { ...camera, projection: 'orthographic' });
476
+ expect(orthographic.ok).toBe(false);
477
+ if (!orthographic.ok)
478
+ expect(orthographic.error.code).toBe('depth-of-field-orthographic-unsupported');
479
+ for (const input of [
480
+ { fStop: 0.69 },
481
+ { sensorHeight: 0 },
482
+ { maxRadiusPixels: 33 },
483
+ { quality: 3 },
484
+ { blurSide: 3 },
485
+ ]) {
486
+ const invalid = validateDepthOfFieldParams(input, camera);
487
+ expect(invalid.ok).toBe(false);
488
+ if (!invalid.ok) expect(invalid.error.code).toBe('depth-of-field-invalid-params');
489
+ }
490
+ });
491
+
492
+ it('uses the same row/field mapping in single-sample and MSAA WGSL', () => {
493
+ const paths = [
494
+ new URL('../../../../../shader/src/depth-of-field.wgsl', import.meta.url),
495
+ new URL('../../../../../shader/src/depth-of-field-msaa.wgsl', import.meta.url),
496
+ ];
497
+ for (const path of paths) {
498
+ const source = readFileSync(path, 'utf8');
499
+ expect(source).toContain('let focus = params.optics.x;');
500
+ expect(source).toContain('let outputHeight = params.image.x;');
501
+ expect(source).toContain('let near = params.camera.x;');
502
+ expect(source).toContain('let far = params.camera.y;');
503
+ expect(source).toContain('let depthRatio = near / far;');
504
+ expect(source).toContain('let denominator = (1.0 - raw) + raw * depthRatio;');
505
+ expect(source).toContain('let cocCoefficient = params.reserved.x;');
506
+ expect(source).toContain('let focusOverDepth = focus / depth;');
507
+ expect(source).toContain('let depthFactor = 1.0 - focusOverDepth;');
508
+ expect(source).toContain('let radius = cocCoefficient * depthFactor;');
509
+ expect(source).not.toContain('let cocScale =');
510
+ expect(source).not.toContain('(f * f) / (aperture * (focus - f))');
511
+ expect(source).not.toContain('(near * far) /');
512
+ }
513
+ });
514
+
515
+ it('uses side-specific entry points instead of rewriting the shared DoF UBO', () => {
516
+ const feature = readFileSync(
517
+ new URL('../depth-of-field-feature.ts', import.meta.url),
518
+ 'utf8',
519
+ ).replace(/\s+/g, ' ');
520
+ expect(feature).toContain(
521
+ "fragmentEntryPoint: side === 'near' ? 'fs_prefilter_near' : 'fs_prefilter_far'",
522
+ );
523
+ expect(feature).toContain(
524
+ "fragmentEntryPoint: side === 'near' ? 'fs_prefilter_metadata_near' : 'fs_prefilter_metadata_far'",
525
+ );
526
+ expect(feature).not.toContain('paramsTransform: sideParamsTransform');
527
+ for (const path of [
528
+ new URL('../../../../../shader/src/depth-of-field.wgsl', import.meta.url),
529
+ new URL('../../../../../shader/src/depth-of-field-msaa.wgsl', import.meta.url),
530
+ ]) {
531
+ const source = readFileSync(path, 'utf8');
532
+ expect(source).toContain('fn fs_prefilter_near');
533
+ expect(source).toContain('fn fs_prefilter_far');
534
+ expect(source).toContain('fn fs_prefilter_metadata_near');
535
+ expect(source).toContain('fn fs_prefilter_metadata_far');
536
+ }
537
+ });
538
+
539
+ it('carries source coverage through the DoF resolve contract', () => {
540
+ const msaaPath = new URL('../../../../../shader/src/depth-of-field-msaa.wgsl', import.meta.url);
541
+ const paths = [
542
+ new URL('../../../../../shader/src/depth-of-field.wgsl', import.meta.url),
543
+ msaaPath,
544
+ ];
545
+ for (const path of paths) {
546
+ const source = readFileSync(path, 'utf8');
547
+ expect(source).toContain('fn depthAndCoverageAt');
548
+ expect(source).toContain('fn cocCoverageAtExtra1');
549
+ expect(source).toContain('depthSample.coverage');
550
+ expect(source).toContain('let largeSourceFactor = smoothstep(2.0, 3.0, sourceRadius);');
551
+ expect(source).toContain('let nearSourceFactor = smoothstep(0.5, 2.0, sourceRadius);');
552
+ expect(source).toContain('fn sourceKernelMean(sourceRadius : f32, tapCount : f32) -> f32');
553
+ expect(source).toContain('supportWeight / sourceKernelMean(sourceRadius, tapLimit())');
554
+ expect(source).toContain(
555
+ 'fn circleKernelMean(sourceRadius : f32, domainRadius : f32) -> f32',
556
+ );
557
+ expect(source).toContain('(localCount * localMean + outerCount * outerMean)');
558
+ expect(source).toContain('fn sourceDiskOffset(index : u32, count : f32) -> vec2<f32>');
559
+ expect(source).toContain('let localCount = max(1.0, floor(count * 0.25));');
560
+ expect(source).toContain('min(params.image.y, 3.0)');
561
+ expect(source).toContain('let sampleAreaWeight = cocCoverage * sample.a;');
562
+ expect(source).toContain('supportWeight * largeSourceFactor * sampleAreaWeight');
563
+ expect(source).toContain('let sourceCoverageFactor = largeSourceFactor;');
564
+ expect(source).toContain('normalizedSupport * sourceCoverageFactor * sampleAreaWeight');
565
+ expect(source).toContain('let rawSampleWeight = select(');
566
+ expect(source).toContain('coverage += coverageWeight;');
567
+ expect(source).toContain(
568
+ 'let resolvedColor = select(fallbackColor, color / positiveWeight, weight > 0.0);',
569
+ );
570
+ expect(source).toContain('return vec4<f32>(resolvedColor, alpha);');
571
+ expect(source).toContain('var coverageCapacity = 0.0;');
572
+ expect(source).toContain('coverageCapacity += sourceCapacity;');
573
+ expect(source).toContain(
574
+ 'let alpha = clamp(coverage / max(coverageCapacity, 1e-5), 0.0, 1.0);',
575
+ );
576
+ expect(source).toContain('let hasBackground = weight > 0.0 && coverageCapacity > 0.0;');
577
+ expect(source).toContain('clamp(nearCoverage / max(coverageCapacity, 1e-5), 0.0, 1.0)');
578
+ expect(source).toContain('return vec4<f32>(color / positiveWeight, confidence);');
579
+ expect(source).toContain('let nearCoverage = clamp(background.a, 0.0, 1.0);');
580
+ expect(source).toContain('let nearWeight = clamp(nearBlur.a, 0.0, 1.0);');
581
+ expect(source).toContain('let focalDestination = absoluteCoc < 0.5;');
582
+ expect(source).toContain('if (hasSmallDestination && coc < -0.001)');
583
+ expect(source).toContain('color = mix(nearBase, small.rgb, smallFactor);');
584
+ expect(source).toContain('color = mix(color, nearBlur.rgb, nearWeight);');
585
+ expect(source.indexOf('if (hasSmallDestination && coc < -0.001)')).toBeLessThan(
586
+ source.indexOf('let nearWeight = clamp(nearBlur.a, 0.0, 1.0);'),
587
+ );
588
+ expect(source).toContain('if (focalDestination && (nearWeight > 0.0 || nearCoverage > 0.0))');
589
+ expect(source).toContain(
590
+ 'fn smallBlur(uv : vec2<f32>, nearSide : bool, radiusPixels : f32) -> vec4<f32>',
591
+ );
592
+ expect(source).toContain(
593
+ 'let smallSourceFactor = 1.0 - smoothstep(2.0, 3.0, abs(sourceCoc));',
594
+ );
595
+ expect(source).toContain(
596
+ 'let sampleWeight = select(0.0, sourceCoverage * smallSourceFactor, accepted);',
597
+ );
598
+ expect(source).toContain(
599
+ 'let fallbackColor = textureSampleLevel(currentColor, linearSampler, in.uv, 0.0).rgb;',
600
+ );
601
+ expect(source).toContain(
602
+ 'let resolvedColor = select(fallbackColor, color / positiveWeight, weight > 0.0);',
603
+ );
604
+ expect(source).toContain(
605
+ 'let confidence = clamp((weight - 1.0) / max(weight, 1.0), 0.0, 1.0);',
606
+ );
607
+ expect(source).toContain('let small = smallBlur(in.uv, true, 2.0);');
608
+ expect(source).toContain('let supports = distancePixels <= abs(sourceCoc) + 0.5;');
609
+ expect(source).toContain('let largeFactor = smoothstep(2.0, 3.0, absoluteCoc);');
610
+ expect(source).toContain('let smallFactor = smoothstep(0.5, 2.0, absoluteCoc)');
611
+ }
612
+ const msaa = readFileSync(msaaPath, 'utf8');
613
+ expect(msaa).toContain('var nearestSamples = 0u;');
614
+ expect(msaa).toContain('abs(sampleDepth - depth) <= layerTolerance');
615
+ expect(msaa).toContain('f32(nearestSamples) / 4.0');
616
+ });
617
+
618
+ it('keeps single-sample coverage binary and MSAA coverage on the nearest layer', () => {
619
+ expect(singleSampleCoverage(2)).toBe(1);
620
+ expect(singleSampleCoverage(0)).toBe(0);
621
+ expect(nearestLayerCoverage([2, 8, 8, 8])).toEqual({ depth: 2, coverage: 0.25 });
622
+ expect(nearestLayerCoverage([8, 8.2, 8.1, undefined]).coverage).toBeCloseTo(0.75, 6);
623
+ expect(nearestLayerCoverage([undefined, 0, Number.NaN, 8])).toEqual({
624
+ depth: 8,
625
+ coverage: 0.25,
626
+ });
627
+ });
628
+
629
+ it('weights gathered RGB by the same prefiltered sample area as coverage', () => {
630
+ const resolved = areaWeightedColor([
631
+ { color: [1, 0, 0], alpha: 0.25 },
632
+ { color: [0, 0, 1], alpha: 1 },
633
+ ]);
634
+ expect(resolved).toEqual([0.2, 0, 0.8]);
635
+ expect(resolved[0]).toBeLessThan(0.25);
636
+ expect(resolved[2]).toBeGreaterThan(0.75);
637
+ });
638
+
639
+ it('allows near gather coverage and color to reach a focal destination', () => {
640
+ const focal = compositeNearColor([1, 1, 1], [5, 1, 0], 0.4);
641
+ expect(focal).toEqual([2.6, 1, 0.6]);
642
+ expect(compositeNearColor([1, 1, 1], [5, 1, 0], 0)).toEqual([1, 1, 1]);
643
+ expect(compositeNearColor([1, 1, 1], [5, 1, 0], 1)).toEqual([5, 1, 0]);
644
+ });
645
+
646
+ it('diffuses a 1.5px red near source into a blue focal destination', () => {
647
+ const sourceRadius = 1.5;
648
+ const sourceCoverage = fullResolutionSourceFactor(sourceRadius);
649
+ const accepted = smallKernelAcceptedCount(sourceRadius);
650
+ const nearColor = smallKernelColor([0, 0, 1], [1, 0, 0], sourceRadius);
651
+ const focal = focalNearColor(
652
+ [0, 0, 1],
653
+ [0, 0, 1],
654
+ nearColor,
655
+ smallKernelConfidence(sourceRadius),
656
+ sourceCoverage,
657
+ );
658
+ expect(halfResolutionSourceFactor(sourceRadius)).toBe(0);
659
+ expect(accepted).toBe(8);
660
+ expect(nearColor).toEqual([8 / 9, 0, 1 / 9]);
661
+ expect(sourceCoverage).toBeCloseTo(0.7407407407, 6);
662
+ expect(smallKernelConfidence(sourceRadius)).toBeCloseTo(8 / 9, 6);
663
+ expect(focal[0]).toBeCloseTo(0.5852766347, 6);
664
+ expect(focal[2]).toBeCloseTo(0.4147233653, 6);
665
+ expect(focal[0]).toBeGreaterThan(0);
666
+ expect(focal[2]).toBeLessThan(1);
667
+ });
668
+
669
+ it('retains a large near gather contribution at a focal destination', () => {
670
+ const sourceRadius = 6;
671
+ const sourceDistance = 3;
672
+ const nearGatherColor = [1, 0, 0];
673
+ const focalBase = [0, 0, 1];
674
+ const smallColor = [0, 0, 1];
675
+ const support = sourceCircleSupport(sourceRadius, sourceDistance);
676
+ const smallConfidence = smallKernelConfidence(sourceRadius);
677
+ const focal = focalNearColor(focalBase, nearGatherColor, smallColor, smallConfidence, support);
678
+ expect(support).toBeCloseTo(4 / 7, 6);
679
+ expect(smallConfidence).toBe(0);
680
+ expect(focal[0]).toBeCloseTo(4 / 7, 6);
681
+ expect(focal[1]).toBe(0);
682
+ expect(focal[2]).toBeCloseTo(3 / 7, 6);
683
+ expect(focal[0]).toBeGreaterThan(0);
684
+ });
685
+
686
+ it('preserves constant color when near coverage has no selected prefilter samples', () => {
687
+ const white = [1, 1, 1];
688
+ const nearGatherColor = prefilterResolveColor(white, []);
689
+ const sourceRadius = 1.5;
690
+ const smallColor = smallKernelColor(white, white, sourceRadius);
691
+ const focal = focalNearColor(
692
+ white,
693
+ nearGatherColor,
694
+ smallColor,
695
+ smallKernelConfidence(sourceRadius),
696
+ fullResolutionSourceFactor(sourceRadius),
697
+ );
698
+ expect(nearGatherColor).toEqual(white);
699
+ expect(focal).toEqual(white);
700
+ });
701
+
702
+ it('keeps small near blur full-resolution through 2px and cross-fades at 2-3px', () => {
703
+ expect(radiusMix(0.49)).toEqual({ small: 0, half: 0 });
704
+ expect(radiusMix(0.5)).toEqual({ small: 0, half: 0 });
705
+ expect(radiusMix(1).small).toBeGreaterThan(0);
706
+ expect(radiusMix(1).half).toBe(0);
707
+ expect(radiusMix(2)).toEqual({ small: 1, half: 0 });
708
+ expect(radiusMix(2.5).small).toBeCloseTo(0.5, 6);
709
+ expect(radiusMix(2.5).half).toBeCloseTo(0.5, 6);
710
+ expect(radiusMix(3)).toEqual({ small: 0, half: 1 });
711
+ });
712
+
713
+ it('keeps fractional source coverage for sparse focal destinations', () => {
714
+ const candidateCount = 16;
715
+ const sourceCoverages = Array.from({ length: candidateCount }, () => 1);
716
+ const sparseSupports = (nearTapCount: number) =>
717
+ Array.from({ length: candidateCount }, (_, index) =>
718
+ index < nearTapCount ? ([0.05, 0.07, 0.09, 0.11][index] ?? 0) : 0,
719
+ );
720
+ const alphaFor = (nearTapCount: number) => {
721
+ const supports = sparseSupports(nearTapCount);
722
+ return gatherCoverageAlpha(
723
+ supports,
724
+ 6,
725
+ sourceCoverages,
726
+ sourceCoverages,
727
+ supports.map((support) => support > 0),
728
+ );
729
+ };
730
+ const oneTap = alphaFor(1);
731
+ const twoTaps = alphaFor(2);
732
+ const fourTaps = alphaFor(4);
733
+ expect(oneTap).toBeGreaterThan(0);
734
+ expect(oneTap).toBeLessThan(1);
735
+ expect(twoTaps).toBeGreaterThan(oneTap);
736
+ expect(twoTaps).toBeLessThan(1);
737
+ expect(fourTaps).toBeGreaterThan(twoTaps);
738
+ expect(fourTaps).toBeLessThan(1);
739
+ });
740
+
741
+ it('keeps focal background confidence fractional for one, two, and four near taps', () => {
742
+ const candidateCount = 16;
743
+ const sourceCoverages = Array.from({ length: candidateCount }, () => 1);
744
+ const backgroundWeights = sourceCoverages.map((_, index) => (index === 15 ? 1 : 0));
745
+ const sparseSupports = (nearTapCount: number) =>
746
+ Array.from({ length: candidateCount }, (_, index) =>
747
+ index < nearTapCount ? ([0.05, 0.07, 0.09, 0.11][index] ?? 0) : 0,
748
+ );
749
+ const confidenceFor = (nearTapCount: number) => {
750
+ const supports = sparseSupports(nearTapCount);
751
+ return backgroundConfidence(
752
+ supports,
753
+ 6,
754
+ sourceCoverages,
755
+ supports.map((support) => support > 0),
756
+ backgroundWeights,
757
+ );
758
+ };
759
+ const oneTap = confidenceFor(1);
760
+ const twoTaps = confidenceFor(2);
761
+ const fourTaps = confidenceFor(4);
762
+ expect(oneTap).toBeGreaterThan(0);
763
+ expect(oneTap).toBeLessThan(1);
764
+ expect(twoTaps).toBeGreaterThan(oneTap);
765
+ expect(twoTaps).toBeLessThan(1);
766
+ expect(fourTaps).toBeGreaterThan(twoTaps);
767
+ expect(fourTaps).toBeLessThan(1);
768
+ const smallNearSupports = [0.05, 0.07, 0.09, 0.11, ...Array.from({ length: 12 }, () => 0)];
769
+ expect(
770
+ backgroundConfidence(
771
+ smallNearSupports,
772
+ 1,
773
+ sourceCoverages,
774
+ smallNearSupports.map((support) => support > 0),
775
+ [0, 0, 0, 0, ...Array.from({ length: 12 }, () => 1)],
776
+ ),
777
+ ).toBeGreaterThan(0);
778
+ });
779
+
780
+ it('reserves local disk taps for small focal circles at every quality', () => {
781
+ for (const tapCount of [16, 32, 64]) {
782
+ const localCount = Math.max(1, Math.floor(tapCount * 0.25));
783
+ const localRadii = Array.from({ length: localCount }, (_, index) =>
784
+ sourceDiskOffsetRadius(index, tapCount, 16),
785
+ );
786
+ const outerRadii = Array.from({ length: tapCount - localCount }, (_, index) =>
787
+ sourceDiskOffsetRadius(index + localCount, tapCount, 16),
788
+ );
789
+ expect(Math.min(...localRadii)).toBeLessThanOrEqual(1.5);
790
+ expect(Math.max(...localRadii)).toBeLessThanOrEqual(3);
791
+ expect(localRadii.some((distance) => distance <= 1.5)).toBe(true);
792
+ expect(Math.max(...outerRadii)).toBeGreaterThan(10);
793
+ }
794
+ });
795
+
796
+ it('normalizes actual mixed-domain support during the half-resolution handoff', () => {
797
+ const maxRadius = 16;
798
+ const expected = new Map([
799
+ [2.25, [0.1389748905, 0.1535840091, 0.1552009122]],
800
+ [2.5, [0.4616459554, 0.4922781345, 0.4965728943]],
801
+ [2.75, [0.7989213548, 0.8295083064, 0.8383427443]],
802
+ ]);
803
+ for (const [sourceRadius, expectedByQuality] of expected) {
804
+ const actual = [16, 32, 64].map((tapCount) => {
805
+ const supports = Array.from({ length: tapCount }, (_, index) =>
806
+ Math.max(
807
+ 0,
808
+ Math.min(
809
+ 1,
810
+ (sourceRadius + 1 - sourceDiskOffsetRadius(index, tapCount, maxRadius)) /
811
+ (sourceRadius + 1),
812
+ ),
813
+ ),
814
+ );
815
+ return gatherCoverageAlpha(
816
+ supports,
817
+ sourceRadius,
818
+ Array.from({ length: tapCount }, () => 1),
819
+ Array.from({ length: tapCount }, () => 1),
820
+ supports.map((support) => support > 0),
821
+ maxRadius,
822
+ );
823
+ });
824
+ actual.forEach((value, index) => {
825
+ expect(value).toBeCloseTo(expectedByQuality[index] ?? 0, 5);
826
+ expect(value).toBeLessThan(1);
827
+ });
828
+ expect(actual[1]).toBeGreaterThan(actual[0] ?? 0);
829
+ expect(actual[2]).toBeGreaterThan(actual[1] ?? 0);
830
+ }
831
+ });
832
+
833
+ it('keeps a uniform plane quality invariant while transitioning radius smoothly', () => {
834
+ const radius = 1.25;
835
+ const lowDensity = Array.from({ length: 16 }, () => sourceKernelMean(radius, 16, 16));
836
+ const highDensity = Array.from({ length: 64 }, () => sourceKernelMean(radius, 16, 64));
837
+ expect(gatherCoverageAlpha(lowDensity, radius)).toBeCloseTo(
838
+ halfResolutionSourceFactor(radius),
839
+ 6,
840
+ );
841
+ expect(gatherCoverageAlpha(lowDensity, radius)).toBeCloseTo(
842
+ gatherCoverageAlpha(highDensity, radius),
843
+ 6,
844
+ );
845
+
846
+ const radii = [0.25, 0.5, 1, 1.5, 2, 2.5, 3];
847
+ const alphas = radii.map((radius) => {
848
+ const mean = sourceKernelMean(radius, 16, 32);
849
+ return gatherCoverageAlpha(
850
+ Array.from({ length: 32 }, () => mean),
851
+ radius,
852
+ );
853
+ });
854
+ expect(alphas[0]).toBe(0);
855
+ expect(alphas[1]).toBe(0);
856
+ expect(alphas[2]).toBe(0);
857
+ expect(alphas[3]).toBe(0);
858
+ expect(alphas[4]).toBe(0);
859
+ expect(alphas[5]).toBeCloseTo(0.5, 6);
860
+ expect(alphas[6]).toBeCloseTo(1, 6);
861
+ });
862
+
863
+ it('preserves a uniform near plane when no valid background exists at any quality', () => {
864
+ const nearColor = [4, 2, 1];
865
+ const blackBackground = [0, 0, 0];
866
+ for (const tapCount of [16, 32, 64]) {
867
+ const capacity = tapCount * 0.75;
868
+ expect(backgroundResolveColor(nearColor, blackBackground, capacity, capacity, 0)).toEqual(
869
+ nearColor,
870
+ );
871
+ }
872
+ });
873
+
874
+ it('keeps off, invalid, and unsupported requests structurally inspectable', () => {
875
+ const off = projectDepthOfFieldInspection({ deviceGeneration: 1 });
876
+ expect(off).toMatchObject({
877
+ enabled: false,
878
+ status: 'off',
879
+ fallbackReason: 'zero-radius',
880
+ passCount: 0,
881
+ lastKnownGood: false,
882
+ });
883
+
884
+ const unsupported = validateDepthOfFieldParams({}, { ...camera, projection: 'orthographic' });
885
+ expect(unsupported.ok).toBe(false);
886
+ if (!unsupported.ok) {
887
+ const inspection = projectDepthOfFieldInspection({
888
+ deviceGeneration: 1,
889
+ error: depthOfFieldRequestFailure(unsupported.error),
890
+ });
891
+ expect(inspection).toMatchObject({
892
+ enabled: true,
893
+ status: 'unsupported',
894
+ fallbackReason: 'orthographic-unsupported',
895
+ lastKnownGood: false,
896
+ error: { code: 'depth-of-field-orthographic-unsupported' },
897
+ });
898
+ }
899
+
900
+ const invalid = validateDepthOfFieldParams({ fStop: 0.69 }, camera);
901
+ expect(invalid.ok).toBe(false);
902
+ if (!invalid.ok) {
903
+ const inspection = projectDepthOfFieldInspection({
904
+ deviceGeneration: 1,
905
+ error: depthOfFieldRequestFailure(invalid.error),
906
+ });
907
+ expect(inspection).toMatchObject({
908
+ enabled: true,
909
+ status: 'invalid',
910
+ fallbackReason: 'invalid-params',
911
+ error: { code: 'depth-of-field-invalid-params' },
912
+ });
913
+ }
914
+ });
915
+
916
+ it('keeps accepted params as the frame fallback for rejected requests', () => {
917
+ const accepted = validateDepthOfFieldParams({}, camera);
918
+ const invalid = validateDepthOfFieldParams({ fStop: 0.69 }, camera);
919
+ expect(accepted.ok).toBe(true);
920
+ expect(invalid.ok).toBe(false);
921
+ if (accepted.ok && !invalid.ok) {
922
+ const failure = depthOfFieldRequestFailure(invalid.error);
923
+ expect(resolveDepthOfFieldFrameParams(undefined, failure, accepted.value)).toBe(
924
+ accepted.value,
925
+ );
926
+ expect(resolveDepthOfFieldFrameParams(undefined, undefined, accepted.value)).toBeUndefined();
927
+ expect(resolveDepthOfFieldFrameParams(accepted.value, failure, undefined)).toBe(
928
+ accepted.value,
929
+ );
930
+ }
931
+ const unsupported = validateDepthOfFieldParams({}, { ...camera, projection: 'orthographic' });
932
+ expect(unsupported.ok).toBe(false);
933
+ if (accepted.ok && !unsupported.ok) {
934
+ expect(
935
+ resolveDepthOfFieldFrameParams(
936
+ undefined,
937
+ depthOfFieldRequestFailure(unsupported.error),
938
+ accepted.value,
939
+ ),
940
+ ).toBeUndefined();
941
+ }
942
+ });
943
+ });