@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,556 @@
1
+ import { err, ok, type Result } from '@forgeax/engine-types';
2
+ import {
3
+ type DepthOfFieldData,
4
+ type DepthOfFieldQuality,
5
+ DepthOfFieldQualityValue,
6
+ type DepthOfFieldSide,
7
+ DepthOfFieldSideValue,
8
+ depthOfFieldQualityFromF32,
9
+ depthOfFieldSideFromF32,
10
+ } from '../../components/depth-of-field';
11
+
12
+ export const DEPTH_OF_FIELD_PARAMS_BYTE_SIZE = 64;
13
+
14
+ // Camera component values cross the UBO boundary as f32. Keep bounds in that
15
+ // same representation so the authored decimal boundary 0.7 remains valid
16
+ // after quantization.
17
+ const MIN_F_STOP = Math.fround(0.7);
18
+ const MAX_F_STOP = Math.fround(32);
19
+ const MAX_LINEAR_DEPTH_RAW = Math.fround(1 - 2 ** -24);
20
+
21
+ export interface DepthOfFieldParams {
22
+ readonly focusDistance: number;
23
+ readonly fStop: number;
24
+ readonly sensorHeight: number;
25
+ readonly maxRadiusPixels: number;
26
+ readonly quality: DepthOfFieldQuality;
27
+ readonly blurSide: DepthOfFieldSide;
28
+ readonly focalLength: number;
29
+ }
30
+
31
+ export const DEFAULT_DEPTH_OF_FIELD_PARAMS: DepthOfFieldParams = Object.freeze({
32
+ focusDistance: 8,
33
+ fStop: 2.8,
34
+ sensorHeight: 0.024,
35
+ maxRadiusPixels: 16,
36
+ quality: 'medium',
37
+ blurSide: 'both',
38
+ focalLength: 0,
39
+ });
40
+
41
+ function quantizeF32(value: number): number {
42
+ return Math.fround(value);
43
+ }
44
+
45
+ export type DepthOfFieldErrorCode =
46
+ | 'depth-of-field-invalid-params'
47
+ | 'depth-of-field-orthographic-unsupported';
48
+
49
+ export interface DepthOfFieldInvalidParamsDetail {
50
+ readonly field: string;
51
+ readonly value: unknown;
52
+ readonly expected: string;
53
+ readonly bound?: readonly [number, number] | undefined;
54
+ }
55
+
56
+ export interface DepthOfFieldOrthographicDetail {
57
+ readonly projection: 'orthographic';
58
+ }
59
+
60
+ export type DepthOfFieldErrorDetail =
61
+ | DepthOfFieldInvalidParamsDetail
62
+ | DepthOfFieldOrthographicDetail;
63
+
64
+ /** Detached validation facts carried from extract to renderer inspection. */
65
+ export interface DepthOfFieldRequestFailure {
66
+ readonly code: DepthOfFieldErrorCode;
67
+ readonly detail: DepthOfFieldErrorDetail;
68
+ readonly expected: string;
69
+ readonly hint: string;
70
+ }
71
+
72
+ export class DepthOfFieldValidationError extends Error {
73
+ readonly code: DepthOfFieldErrorCode;
74
+ readonly expected: string;
75
+ readonly hint: string;
76
+ readonly detail: DepthOfFieldErrorDetail;
77
+
78
+ constructor(
79
+ code: DepthOfFieldErrorCode,
80
+ detail: DepthOfFieldErrorDetail,
81
+ expected: string,
82
+ hint: string,
83
+ ) {
84
+ super(`${code}: ${expected}`);
85
+ this.name = 'DepthOfFieldValidationError';
86
+ this.code = code;
87
+ this.expected = expected;
88
+ this.hint = hint;
89
+ this.detail = detail;
90
+ }
91
+ }
92
+
93
+ export function depthOfFieldRequestFailure(
94
+ error: DepthOfFieldValidationError,
95
+ ): DepthOfFieldRequestFailure {
96
+ return Object.freeze({
97
+ code: error.code,
98
+ detail: error.detail,
99
+ expected: error.expected,
100
+ hint: error.hint,
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Resolve the params used by a frame when authoring validation failed.
106
+ * Invalid/unsupported requests stay visible as errors, but an already
107
+ * accepted DoF graph must keep receiving its last-known-good UBO until the
108
+ * author submits a valid replacement or explicitly removes the component.
109
+ */
110
+ export function resolveDepthOfFieldFrameParams(
111
+ requested: DepthOfFieldParams | undefined,
112
+ requestFailure: DepthOfFieldRequestFailure | undefined,
113
+ accepted: DepthOfFieldParams | undefined,
114
+ ): DepthOfFieldParams | undefined {
115
+ if (requested !== undefined) return requested;
116
+ if (
117
+ requestFailure === undefined ||
118
+ requestFailure.code === 'depth-of-field-orthographic-unsupported'
119
+ ) {
120
+ return undefined;
121
+ }
122
+ return accepted;
123
+ }
124
+
125
+ export interface DepthOfFieldCameraInput {
126
+ readonly projection: 'perspective' | 'orthographic';
127
+ readonly fov: number;
128
+ readonly near: number;
129
+ readonly far: number;
130
+ }
131
+
132
+ function invalid(
133
+ field: string,
134
+ value: unknown,
135
+ expected: string,
136
+ bound?: readonly [number, number],
137
+ ): DepthOfFieldValidationError {
138
+ return new DepthOfFieldValidationError(
139
+ 'depth-of-field-invalid-params',
140
+ { field, value, expected, ...(bound === undefined ? {} : { bound }) },
141
+ expected,
142
+ `set ${field} to ${expected}`,
143
+ );
144
+ }
145
+
146
+ function focalLength(sensorHeight: number, fov: number): number {
147
+ return sensorHeight / (2 * Math.tan(fov / 2));
148
+ }
149
+
150
+ function opticalCocScale(
151
+ params: Pick<DepthOfFieldParams, 'focalLength' | 'sensorHeight' | 'fStop' | 'focusDistance'>,
152
+ ): number {
153
+ // This is evaluated on the CPU once per frame. Keep the ratios separate so
154
+ // the final f32 coefficient does not inherit an avoidable focalLength^2
155
+ // overflow.
156
+ return (
157
+ (params.focalLength / params.sensorHeight / (2 * params.fStop)) *
158
+ (params.focalLength / (params.focusDistance - params.focalLength))
159
+ );
160
+ }
161
+
162
+ function frameCocCoefficient(
163
+ params: Pick<DepthOfFieldParams, 'focalLength' | 'sensorHeight' | 'fStop' | 'focusDistance'>,
164
+ outputHeight: number,
165
+ ): number {
166
+ return quantizeF32(quantizeF32(outputHeight) * opticalCocScale(params));
167
+ }
168
+
169
+ interface DepthOfFieldFrameValues {
170
+ readonly focusDistance: number;
171
+ readonly fStop: number;
172
+ readonly sensorHeight: number;
173
+ readonly focalLength: number;
174
+ readonly maxRadiusPixels: number;
175
+ readonly outputHeight: number;
176
+ readonly near: number;
177
+ readonly far: number;
178
+ readonly cocCoefficient: number;
179
+ }
180
+
181
+ function resolveDepthOfFieldFrameValues(
182
+ params: DepthOfFieldParams,
183
+ input: {
184
+ readonly outputHeight: number;
185
+ readonly near: number;
186
+ readonly far: number;
187
+ readonly useTemporalDepth: boolean;
188
+ },
189
+ ): Result<DepthOfFieldFrameValues, DepthOfFieldValidationError> {
190
+ const focusDistance = quantizeF32(params.focusDistance);
191
+ const fStop = quantizeF32(params.fStop);
192
+ const sensorHeight = quantizeF32(params.sensorHeight);
193
+ const focal = quantizeF32(params.focalLength);
194
+ const maxRadiusPixels = quantizeF32(params.maxRadiusPixels);
195
+ const outputHeight = quantizeF32(input.outputHeight);
196
+ const near = quantizeF32(input.near);
197
+ const far = quantizeF32(input.far);
198
+ const finitePositiveFields: readonly [string, number, number][] = [
199
+ ['focusDistance', focusDistance, params.focusDistance],
200
+ ['sensorHeight', sensorHeight, params.sensorHeight],
201
+ ['focalLength', focal, params.focalLength],
202
+ ['near', near, input.near],
203
+ ['far', far, input.far],
204
+ ];
205
+ for (const [field, value, raw] of finitePositiveFields) {
206
+ if (!Number.isFinite(value) || value <= 0) {
207
+ return err(invalid(field, raw, 'a finite positive f32 value'));
208
+ }
209
+ }
210
+ if (!Number.isFinite(fStop) || fStop < MIN_F_STOP || fStop > MAX_F_STOP) {
211
+ return err(
212
+ invalid('fStop', params.fStop, 'a finite f32 value in [0.7, 32]', [MIN_F_STOP, MAX_F_STOP]),
213
+ );
214
+ }
215
+ if (!Number.isFinite(maxRadiusPixels) || maxRadiusPixels < 0 || maxRadiusPixels > 32) {
216
+ return err(
217
+ invalid('maxRadiusPixels', params.maxRadiusPixels, 'a finite f32 value in [0, 32]', [0, 32]),
218
+ );
219
+ }
220
+ if (!Number.isFinite(outputHeight) || outputHeight <= 0) {
221
+ return err(invalid('outputHeight', input.outputHeight, 'a finite positive f32 value'));
222
+ }
223
+ if (far <= near) {
224
+ return err(invalid('near/far', [input.near, input.far], 'finite f32 near > 0 and far > near'));
225
+ }
226
+ if (focusDistance <= focal) {
227
+ return err(invalid('focusDistance', params.focusDistance, 'greater than focalLength in f32'));
228
+ }
229
+
230
+ // linearDepth() uses this ratio and denominator order in both WGSL
231
+ // variants. Reject a ratio that disappears at f32 precision, then check the
232
+ // near, mid and largest-valid raw-depth samples for finite denominators and
233
+ // reconstructed distances. The denominator is strictly positive for valid
234
+ // raw depth, so no world-unit epsilon belongs in the shader.
235
+ const depthRatio = quantizeF32(near / far);
236
+ if (!Number.isFinite(depthRatio) || depthRatio <= 0) {
237
+ return err(invalid('near/far ratio', depthRatio, 'a finite positive f32 value'));
238
+ }
239
+ for (const raw of [quantizeF32(2 ** -149), 0.5, MAX_LINEAR_DEPTH_RAW]) {
240
+ const oneMinusRaw = quantizeF32(1 - raw);
241
+ const weightedRatio = quantizeF32(raw * depthRatio);
242
+ const denominator = quantizeF32(oneMinusRaw + weightedRatio);
243
+ const distance = quantizeF32(near / denominator);
244
+ if (
245
+ !Number.isFinite(oneMinusRaw) ||
246
+ oneMinusRaw <= 0 ||
247
+ !Number.isFinite(weightedRatio) ||
248
+ !Number.isFinite(denominator) ||
249
+ denominator <= 0 ||
250
+ !Number.isFinite(distance) ||
251
+ distance <= 0
252
+ ) {
253
+ return err(invalid('linearDepth', distance, 'finite positive f32 reconstruction'));
254
+ }
255
+ }
256
+
257
+ const cocCoefficient = frameCocCoefficient(
258
+ {
259
+ focusDistance,
260
+ fStop,
261
+ sensorHeight,
262
+ focalLength: focal,
263
+ },
264
+ outputHeight,
265
+ );
266
+ if (!Number.isFinite(cocCoefficient) || cocCoefficient <= 0) {
267
+ return err(
268
+ invalid('cocCoefficient', cocCoefficient, 'a finite positive value representable as f32'),
269
+ );
270
+ }
271
+ // The shader evaluates C * (1 - focus / depth). Check each f32 operation at
272
+ // the projection endpoints before the UBO is written.
273
+ for (const [field, depth] of [
274
+ ['near', near],
275
+ ['far', far],
276
+ ] as const) {
277
+ const focusOverDepth = quantizeF32(focusDistance / depth);
278
+ const depthFactor = quantizeF32(1 - focusOverDepth);
279
+ const radius = quantizeF32(cocCoefficient * depthFactor);
280
+ if (
281
+ !Number.isFinite(focusOverDepth) ||
282
+ !Number.isFinite(depthFactor) ||
283
+ !Number.isFinite(radius)
284
+ ) {
285
+ return err(invalid(`coc.${field}`, radius, 'a finite value representable as f32'));
286
+ }
287
+ }
288
+
289
+ return ok({
290
+ focusDistance,
291
+ fStop,
292
+ sensorHeight,
293
+ focalLength: focal,
294
+ maxRadiusPixels,
295
+ outputHeight,
296
+ near,
297
+ far,
298
+ cocCoefficient,
299
+ });
300
+ }
301
+
302
+ /**
303
+ * Validate the values that cross the f32 camera UBO boundary for one frame.
304
+ * Camera and DoF schemas already use f32 columns, but direct callers and
305
+ * synthetic frame tests can still supply wider JS numbers. Quantize first so
306
+ * admission and the WGSL consumer make the same decision.
307
+ */
308
+ export function validateDepthOfFieldFrameParams(
309
+ params: DepthOfFieldParams,
310
+ input: {
311
+ readonly outputHeight: number;
312
+ readonly near: number;
313
+ readonly far: number;
314
+ readonly useTemporalDepth: boolean;
315
+ },
316
+ ): Result<void, DepthOfFieldValidationError> {
317
+ const resolved = resolveDepthOfFieldFrameValues(params, input);
318
+ return resolved.ok ? ok(undefined) : resolved;
319
+ }
320
+
321
+ /**
322
+ * Validate and derive one camera DoF declaration at the frame admission seam.
323
+ * The returned focal length is a derived fact; callers never author it.
324
+ */
325
+ export function validateDepthOfFieldParams(
326
+ input: Partial<DepthOfFieldData> | undefined,
327
+ camera?: DepthOfFieldCameraInput,
328
+ ): Result<DepthOfFieldParams, DepthOfFieldValidationError> {
329
+ if (camera?.projection === 'orthographic') {
330
+ return err(
331
+ new DepthOfFieldValidationError(
332
+ 'depth-of-field-orthographic-unsupported',
333
+ { projection: 'orthographic' },
334
+ 'DepthOfField requires a perspective Camera',
335
+ 'use a perspective Camera or remove the DepthOfField component',
336
+ ),
337
+ );
338
+ }
339
+ const value = input ?? {};
340
+ const focusDistance = quantizeF32(
341
+ value.focusDistance ?? DEFAULT_DEPTH_OF_FIELD_PARAMS.focusDistance,
342
+ );
343
+ const fStop = quantizeF32(value.fStop ?? DEFAULT_DEPTH_OF_FIELD_PARAMS.fStop);
344
+ const sensorHeight = quantizeF32(
345
+ value.sensorHeight ?? DEFAULT_DEPTH_OF_FIELD_PARAMS.sensorHeight,
346
+ );
347
+ const maxRadiusPixels = quantizeF32(
348
+ value.maxRadiusPixels ?? DEFAULT_DEPTH_OF_FIELD_PARAMS.maxRadiusPixels,
349
+ );
350
+ const qualityValue = quantizeF32(value.quality ?? DepthOfFieldQualityValue.medium);
351
+ const blurSideValue = quantizeF32(value.blurSide ?? DepthOfFieldSideValue.both);
352
+ const quality = depthOfFieldQualityFromF32(qualityValue);
353
+ const blurSide = depthOfFieldSideFromF32(blurSideValue);
354
+ if (!Number.isFinite(focusDistance) || focusDistance <= 0) {
355
+ return err(
356
+ invalid('focusDistance', value.focusDistance ?? focusDistance, 'a finite positive f32 value'),
357
+ );
358
+ }
359
+ if (!Number.isFinite(fStop) || fStop < MIN_F_STOP || fStop > MAX_F_STOP) {
360
+ return err(
361
+ invalid('fStop', value.fStop ?? fStop, 'a finite f32 value in [0.7, 32]', [
362
+ MIN_F_STOP,
363
+ MAX_F_STOP,
364
+ ]),
365
+ );
366
+ }
367
+ if (!Number.isFinite(sensorHeight) || sensorHeight <= 0) {
368
+ return err(
369
+ invalid('sensorHeight', value.sensorHeight ?? sensorHeight, 'a finite positive f32 value'),
370
+ );
371
+ }
372
+ if (!Number.isFinite(maxRadiusPixels) || maxRadiusPixels < 0 || maxRadiusPixels > 32) {
373
+ return err(
374
+ invalid(
375
+ 'maxRadiusPixels',
376
+ value.maxRadiusPixels ?? maxRadiusPixels,
377
+ 'a finite f32 value in [0, 32]',
378
+ [0, 32],
379
+ ),
380
+ );
381
+ }
382
+ if (quality === undefined) {
383
+ return err(invalid('quality', qualityValue, 'DepthOfFieldQualityValue.low, medium, or high'));
384
+ }
385
+ if (blurSide === undefined) {
386
+ return err(invalid('blurSide', blurSideValue, 'DepthOfFieldSideValue.both, near, or far'));
387
+ }
388
+
389
+ let focal = DEFAULT_DEPTH_OF_FIELD_PARAMS.focalLength;
390
+ if (camera !== undefined) {
391
+ const fov = quantizeF32(camera.fov);
392
+ const near = quantizeF32(camera.near);
393
+ const far = quantizeF32(camera.far);
394
+ if (!Number.isFinite(fov) || fov <= 0 || fov >= Math.PI) {
395
+ return err(invalid('fov', camera.fov, 'a finite f32 value in (0, PI)'));
396
+ }
397
+ if (!Number.isFinite(near) || !Number.isFinite(far) || near <= 0 || far <= near) {
398
+ return err(
399
+ invalid('near/far', [camera.near, camera.far], 'finite f32 near > 0 and far > near'),
400
+ );
401
+ }
402
+ focal = quantizeF32(focalLength(sensorHeight, fov));
403
+ if (!Number.isFinite(focal) || focal <= 0) {
404
+ return err(invalid('focalLength', focal, 'a finite positive f32 value'));
405
+ }
406
+ if (focusDistance < near || focusDistance > far) {
407
+ return err(
408
+ invalid('focusDistance', focusDistance, `a value in [${near}, ${far}]`, [near, far]),
409
+ );
410
+ }
411
+ if (focusDistance <= focal) {
412
+ return err(
413
+ invalid('focusDistance', focusDistance, `greater than focalLength in f32 (${focal})`),
414
+ );
415
+ }
416
+ }
417
+ return ok(
418
+ Object.freeze({
419
+ focusDistance,
420
+ fStop,
421
+ sensorHeight,
422
+ maxRadiusPixels,
423
+ quality,
424
+ blurSide,
425
+ focalLength: focal,
426
+ }),
427
+ );
428
+ }
429
+
430
+ export function resolveDepthOfFieldParams(
431
+ input: Partial<DepthOfFieldData> | undefined,
432
+ camera?: DepthOfFieldCameraInput,
433
+ ): Result<DepthOfFieldParams | undefined, DepthOfFieldValidationError> {
434
+ if (input === undefined) return ok(undefined);
435
+ return validateDepthOfFieldParams(input, camera);
436
+ }
437
+
438
+ export function depthOfFieldTapCount(quality: DepthOfFieldQuality): 16 | 32 | 64 {
439
+ switch (quality) {
440
+ case 'low':
441
+ return 16;
442
+ case 'medium':
443
+ return 32;
444
+ case 'high':
445
+ return 64;
446
+ }
447
+ }
448
+
449
+ export function depthOfFieldSideCode(side: DepthOfFieldSide): 0 | 1 | 2 {
450
+ switch (side) {
451
+ case 'both':
452
+ return DepthOfFieldSideValue.both;
453
+ case 'near':
454
+ return DepthOfFieldSideValue.near;
455
+ case 'far':
456
+ return DepthOfFieldSideValue.far;
457
+ }
458
+ }
459
+
460
+ export function depthOfFieldQualityCode(quality: DepthOfFieldQuality): 0 | 1 | 2 {
461
+ switch (quality) {
462
+ case 'low':
463
+ return DepthOfFieldQualityValue.low;
464
+ case 'medium':
465
+ return DepthOfFieldQualityValue.medium;
466
+ case 'high':
467
+ return DepthOfFieldQualityValue.high;
468
+ }
469
+ }
470
+
471
+ /** Signed thin-lens circle-of-confusion radius in final output pixels. */
472
+ export function signedDepthOfFieldCoC(
473
+ params: Pick<DepthOfFieldParams, 'focalLength' | 'sensorHeight' | 'fStop' | 'focusDistance'> &
474
+ Partial<Pick<DepthOfFieldParams, 'maxRadiusPixels'>>,
475
+ viewDepth: number,
476
+ outputHeight: number,
477
+ ): number {
478
+ if (
479
+ !Number.isFinite(viewDepth) ||
480
+ viewDepth <= 0 ||
481
+ !Number.isFinite(outputHeight) ||
482
+ outputHeight <= 0 ||
483
+ !Number.isFinite(params.focalLength) ||
484
+ params.focalLength <= 0 ||
485
+ !Number.isFinite(params.sensorHeight) ||
486
+ params.sensorHeight <= 0 ||
487
+ !Number.isFinite(params.fStop) ||
488
+ params.fStop <= 0 ||
489
+ !Number.isFinite(params.focusDistance) ||
490
+ params.focusDistance <= params.focalLength
491
+ ) {
492
+ return 0;
493
+ }
494
+ const focusDistance = quantizeF32(params.focusDistance);
495
+ const depth = quantizeF32(viewDepth);
496
+ const focalLength = quantizeF32(params.focalLength);
497
+ const sensorHeight = quantizeF32(params.sensorHeight);
498
+ const fStop = quantizeF32(params.fStop);
499
+ const coefficient = frameCocCoefficient(
500
+ { focusDistance, focalLength, sensorHeight, fStop },
501
+ outputHeight,
502
+ );
503
+ const depthFactor = quantizeF32(1 - quantizeF32(focusDistance / depth));
504
+ const radius = quantizeF32(coefficient * depthFactor);
505
+ const limit = Number.isFinite(params.maxRadiusPixels)
506
+ ? quantizeF32(params.maxRadiusPixels as number)
507
+ : Number.POSITIVE_INFINITY;
508
+ return Math.max(-limit, Math.min(limit, Number.isFinite(radius) ? radius : 0));
509
+ }
510
+
511
+ /** Pack the one renderer-owned per-frame DoF UBO (4 x vec4, 64 bytes). */
512
+ export function packDepthOfFieldParams(
513
+ params: DepthOfFieldParams,
514
+ input: {
515
+ readonly outputHeight: number;
516
+ readonly near: number;
517
+ readonly far: number;
518
+ readonly useTemporalDepth: boolean;
519
+ },
520
+ ): Uint8Array {
521
+ const frameValues = resolveDepthOfFieldFrameValues(params, input);
522
+ if (!frameValues.ok) throw frameValues.error;
523
+ const {
524
+ focusDistance,
525
+ fStop,
526
+ sensorHeight,
527
+ focalLength: focal,
528
+ outputHeight,
529
+ maxRadiusPixels,
530
+ near,
531
+ far,
532
+ cocCoefficient,
533
+ } = frameValues.value;
534
+ const payload = new Float32Array(DEPTH_OF_FIELD_PARAMS_BYTE_SIZE / 4);
535
+ // row3.x is the frame CoC coefficient; the remaining reserved lanes stay
536
+ // zero so the existing 64-byte binding contract remains unchanged.
537
+ payload.set([
538
+ focusDistance,
539
+ fStop,
540
+ sensorHeight,
541
+ focal,
542
+ outputHeight,
543
+ maxRadiusPixels,
544
+ depthOfFieldSideCode(params.blurSide),
545
+ depthOfFieldQualityCode(params.quality),
546
+ near,
547
+ far,
548
+ input.useTemporalDepth ? 1 : 0,
549
+ 0, // row2.w reserved
550
+ cocCoefficient, // row3.x / WGSL reserved.x
551
+ 0,
552
+ 0,
553
+ 0,
554
+ ]);
555
+ return new Uint8Array(payload.buffer);
556
+ }
@@ -1545,7 +1545,7 @@ export function runRenderFeatureFrame(
1545
1545
  if (feature.onFrameSubmitted !== undefined) {
1546
1546
  submissionSensitivePlans.add(plannedFrame);
1547
1547
  submissionAcknowledgments.set(plannedFrame, () =>
1548
- feature.onFrameSubmitted?.(extracted.value),
1548
+ feature.onFrameSubmitted?.(extracted.value, projected.value),
1549
1549
  );
1550
1550
  }
1551
1551
  plans.push(plannedFrame);
@@ -79,6 +79,23 @@ export interface RenderFeatureExtractContext {
79
79
  readonly reportHiddenEntity?: (report: RenderFeatureHiddenEntityReport) => void;
80
80
  }
81
81
 
82
+ /**
83
+ * Renderer-owned projection that reached graph admission for one feature.
84
+ * Producers may use this bounded receipt to distinguish declared plan work
85
+ * from passes omitted during prepared-resource resolution.
86
+ */
87
+ export interface RenderFeatureSubmission {
88
+ readonly passes: readonly {
89
+ readonly shadowCaster?: true;
90
+ readonly graphics?: {
91
+ readonly draws: readonly { readonly kind: string }[];
92
+ };
93
+ readonly gpuCompute?: {
94
+ readonly dispatches: readonly unknown[];
95
+ };
96
+ }[];
97
+ }
98
+
82
99
  /**
83
100
  * Producer-owned extension seam for one render frame.
84
101
  *
@@ -115,7 +132,7 @@ export interface RenderFeature<FrameData> {
115
132
  * queue submission. The host invokes this callback at most once for the
116
133
  * extracted frame and never during declarative planning.
117
134
  */
118
- onFrameSubmitted?(data: FrameData): void;
135
+ onFrameSubmitted?(data: FrameData, submission?: RenderFeatureSubmission): void;
119
136
  /**
120
137
  * Discard frame-owned producer state when graph admission or submission is
121
138
  * rejected. This is the symmetric recovery path for onFrameSubmitted.
package/src/index.ts CHANGED
@@ -74,6 +74,16 @@ export {
74
74
  cubeCameraUpdateIntentFromF32,
75
75
  cubeCameraUpdateIntentToF32,
76
76
  } from './components/cube-camera.js';
77
+ export {
78
+ DepthOfField,
79
+ type DepthOfFieldData,
80
+ type DepthOfFieldQuality,
81
+ DepthOfFieldQualityValue,
82
+ type DepthOfFieldSide,
83
+ DepthOfFieldSideValue,
84
+ depthOfFieldQualityFromF32,
85
+ depthOfFieldSideFromF32,
86
+ } from './components/depth-of-field.js';
77
87
  export * from './components/directional-light.js';
78
88
  export {
79
89
  DynamicResolution,
@@ -196,6 +206,25 @@ export {
196
206
  type VisibilityResolution,
197
207
  type VisibilitySnapshot,
198
208
  } from './extract/visibility.js';
209
+ export {
210
+ DEFAULT_DEPTH_OF_FIELD_PARAMS,
211
+ DEPTH_OF_FIELD_PARAMS_BYTE_SIZE,
212
+ type DepthOfFieldCameraInput,
213
+ type DepthOfFieldErrorCode,
214
+ type DepthOfFieldErrorDetail,
215
+ type DepthOfFieldParams,
216
+ type DepthOfFieldRequestFailure,
217
+ DepthOfFieldValidationError,
218
+ depthOfFieldQualityCode,
219
+ depthOfFieldRequestFailure,
220
+ depthOfFieldSideCode,
221
+ depthOfFieldTapCount,
222
+ packDepthOfFieldParams,
223
+ resolveDepthOfFieldParams,
224
+ signedDepthOfFieldCoC,
225
+ validateDepthOfFieldFrameParams,
226
+ validateDepthOfFieldParams,
227
+ } from './features/depth-of-field/depth-of-field-params.js';
199
228
  export type {
200
229
  RenderFeatureLogicalTarget,
201
230
  RenderFeatureMaterialShaderBindingContract,
@@ -216,6 +245,7 @@ export type {
216
245
  RenderFeatureHiddenEntityReport,
217
246
  RenderFeatureShaderModuleMode,
218
247
  RenderFeatureStatus,
248
+ RenderFeatureSubmission,
219
249
  RenderFeatureWorldVisibilitySnapshot,
220
250
  } from './features/types.js';
221
251
  export {
@@ -232,6 +262,8 @@ export {
232
262
  } from './ibl/kernel-cache.js';
233
263
  export type {
234
264
  BloomInspection,
265
+ DepthOfFieldInspection,
266
+ DepthOfFieldInspectionStatus,
235
267
  GpuDrivenLane,
236
268
  GpuDrivenLaneReason,
237
269
  GpuDrivenLaneSummary,
@@ -9,6 +9,7 @@ import type {
9
9
  GpuDrivenPreparationErrorCode,
10
10
  GpuDrivenPreparationErrorDetail,
11
11
  } from './errors/gpu-driven';
12
+ import type { DepthOfFieldRequestFailure } from './features/depth-of-field/depth-of-field-params';
12
13
  import type { PointsLinesInspection } from './points-lines/inspection';
13
14
  import type { LodOcclusionInspection } from './scene/visibility/inspection';
14
15
  import type { SsrAdmissionIdentity } from './ssr/identity';
@@ -556,6 +557,43 @@ export interface TemporalTargetInspection {
556
557
  /** Detached, bounded Motion Blur facts; no graph, device, target, or history handle. */
557
558
  export type MotionBlurInspectionStatus = 'off' | 'active' | 'reset' | 'invalid';
558
559
 
560
+ /** Bounded renderer-owned Depth of Field facts; no graph or GPU handles. */
561
+ export type DepthOfFieldInspectionStatus = 'off' | 'active' | 'reset' | 'invalid' | 'unsupported';
562
+
563
+ export interface DepthOfFieldInspection {
564
+ readonly enabled: boolean;
565
+ readonly status: DepthOfFieldInspectionStatus;
566
+ readonly requested: {
567
+ readonly focusDistance: number;
568
+ readonly fStop: number;
569
+ readonly sensorHeight: number;
570
+ readonly maxRadiusPixels: number;
571
+ readonly quality: 'low' | 'medium' | 'high';
572
+ readonly blurSide: 'both' | 'near' | 'far';
573
+ };
574
+ readonly effective?: DepthOfFieldInspection['requested'];
575
+ readonly focalLength: number;
576
+ readonly outputExtent: { readonly width: number; readonly height: number };
577
+ readonly workExtent: { readonly width: number; readonly height: number };
578
+ readonly tapCount: 0 | 16 | 32 | 64;
579
+ readonly passCount: number;
580
+ readonly textureBytes: number;
581
+ readonly transparentPolicy: 'opaque-depth-approximation';
582
+ readonly graphGeneration: number;
583
+ readonly deviceGeneration: number;
584
+ readonly lastKnownGood: boolean;
585
+ /** Structured authoring validation facts, when admission rejected the request. */
586
+ readonly error?: DepthOfFieldRequestFailure;
587
+ readonly fallbackReason?:
588
+ | 'zero-radius'
589
+ | 'orthographic-unsupported'
590
+ | 'invalid-params'
591
+ | 'shader-not-ready'
592
+ | 'candidate-failed'
593
+ | 'capability-unavailable'
594
+ | undefined;
595
+ }
596
+
559
597
  export interface MotionBlurInspection {
560
598
  readonly enabled: boolean;
561
599
  readonly status: MotionBlurInspectionStatus;