@forgeax/engine-render 0.1.19 → 0.1.20

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 (174) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/assembly/factory.d.ts +16 -5
  3. package/dist/assembly/factory.d.ts.map +1 -1
  4. package/dist/{chunk-RJRUS55L.mjs → chunk-4XINDLAY.mjs} +267 -186
  5. package/dist/chunk-4XINDLAY.mjs.map +1 -0
  6. package/dist/{chunk-YTQ5C7YQ.mjs → chunk-CDTWTHZR.mjs} +176 -8
  7. package/dist/chunk-CDTWTHZR.mjs.map +1 -0
  8. package/dist/{chunk-IPXCABEP.mjs → chunk-M2MP4ZOU.mjs} +2 -2
  9. package/dist/chunk-M2MP4ZOU.mjs.map +1 -0
  10. package/dist/{chunk-LOSO4CTV.mjs → chunk-TBQJOK2L.mjs} +67 -3
  11. package/dist/chunk-TBQJOK2L.mjs.map +1 -0
  12. package/dist/components/light-helpers.d.ts.map +1 -1
  13. package/dist/components/spot-light.d.ts +12 -0
  14. package/dist/components/spot-light.d.ts.map +1 -1
  15. package/dist/construct-renderer.mjs +2281 -164
  16. package/dist/construct-renderer.mjs.map +1 -1
  17. package/dist/device/gpu-residency.d.ts +19 -2
  18. package/dist/device/gpu-residency.d.ts.map +1 -1
  19. package/dist/errors/render.d.ts +69 -1
  20. package/dist/errors/render.d.ts.map +1 -1
  21. package/dist/gpu-usage.d.ts +2 -0
  22. package/dist/gpu-usage.d.ts.map +1 -1
  23. package/dist/index.d.ts +7 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.mjs +3 -3
  26. package/dist/internal.mjs +1 -1
  27. package/dist/light-buffer-layout.d.ts +20 -12
  28. package/dist/light-buffer-layout.d.ts.map +1 -1
  29. package/dist/pbr-pipeline.d.ts +6 -3
  30. package/dist/pbr-pipeline.d.ts.map +1 -1
  31. package/dist/pipeline/standard-forward-lane.d.ts.map +1 -1
  32. package/dist/pipeline/standard-pipeline.d.ts.map +1 -1
  33. package/dist/pipeline/standard-profile.d.ts +13 -0
  34. package/dist/pipeline/standard-profile.d.ts.map +1 -1
  35. package/dist/record/frame-lighting.d.ts +2 -0
  36. package/dist/record/frame-lighting.d.ts.map +1 -1
  37. package/dist/record/frame-snapshot.d.ts +37 -1
  38. package/dist/record/frame-snapshot.d.ts.map +1 -1
  39. package/dist/record/frame-targets.d.ts.map +1 -1
  40. package/dist/record/frame.d.ts +3 -2
  41. package/dist/record/frame.d.ts.map +1 -1
  42. package/dist/record/gpu-timing.d.ts +32 -0
  43. package/dist/record/gpu-timing.d.ts.map +1 -0
  44. package/dist/record/main-pass-material.d.ts +1 -1
  45. package/dist/record/main-pass-material.d.ts.map +1 -1
  46. package/dist/record/render-context.d.ts +5 -1
  47. package/dist/record/render-context.d.ts.map +1 -1
  48. package/dist/record/shadow-pass.d.ts.map +1 -1
  49. package/dist/record/typed-frame-graph.d.ts +11 -3
  50. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  51. package/dist/render-contract.d.ts +66 -1
  52. package/dist/render-contract.d.ts.map +1 -1
  53. package/dist/render-data.d.ts +10 -6
  54. package/dist/render-data.d.ts.map +1 -1
  55. package/dist/render-pipeline.d.ts +36 -0
  56. package/dist/render-pipeline.d.ts.map +1 -1
  57. package/dist/render-system-extract.d.ts +55 -3
  58. package/dist/render-system-extract.d.ts.map +1 -1
  59. package/dist/render-system.d.ts +7 -1
  60. package/dist/render-system.d.ts.map +1 -1
  61. package/dist/renderer-inspect.d.ts +29 -0
  62. package/dist/renderer-inspect.d.ts.map +1 -0
  63. package/dist/scene/render-scene.d.ts.map +1 -1
  64. package/dist/temporal/frame.d.ts +44 -0
  65. package/dist/temporal/frame.d.ts.map +1 -0
  66. package/dist/temporal/index.mjs +3 -3
  67. package/dist/typed-render-graph-primitives.d.ts +6 -1
  68. package/dist/typed-render-graph-primitives.d.ts.map +1 -1
  69. package/dist/typed-shadow-atlas.d.ts +10 -0
  70. package/dist/typed-shadow-atlas.d.ts.map +1 -0
  71. package/dist/typed-shadow-passes.d.ts.map +1 -1
  72. package/dist/volume/capability.d.ts +57 -0
  73. package/dist/volume/capability.d.ts.map +1 -0
  74. package/dist/volume/component.d.ts +61 -0
  75. package/dist/volume/component.d.ts.map +1 -0
  76. package/dist/volume/extract.d.ts +11 -0
  77. package/dist/volume/extract.d.ts.map +1 -0
  78. package/dist/volume/inspection.d.ts +38 -0
  79. package/dist/volume/inspection.d.ts.map +1 -0
  80. package/dist/volume/passes.d.ts +38 -0
  81. package/dist/volume/passes.d.ts.map +1 -0
  82. package/dist/volume/recovery.d.ts +38 -0
  83. package/dist/volume/recovery.d.ts.map +1 -0
  84. package/dist/volume/resources.d.ts +57 -0
  85. package/dist/volume/resources.d.ts.map +1 -0
  86. package/dist/volume/temporal.d.ts +35 -0
  87. package/dist/volume/temporal.d.ts.map +1 -0
  88. package/package.json +20 -20
  89. package/src/__tests__/ktx2-basis-gpu-consumer.dawn.test.ts +14 -14
  90. package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +1 -1
  91. package/src/__tests__/render-error-code-owner.test-d.ts +5 -0
  92. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +28 -0
  93. package/src/__tests__/renderer-frame-transaction.integration.test.ts +1 -1
  94. package/src/__tests__/skinned-shadow-caster.test.ts +21 -0
  95. package/src/__tests__/standard-profile-oracle.unit.test.ts +7 -5
  96. package/src/__tests__/volume-optics-oracle.unit.test.ts +85 -0
  97. package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +655 -0
  98. package/src/__tests__/volumetric-fog-component.unit.test.ts +71 -0
  99. package/src/__tests__/volumetric-fog-composite-stage-readback.dawn.test.ts +376 -0
  100. package/src/__tests__/volumetric-fog-errors.unit.test.ts +51 -0
  101. package/src/__tests__/volumetric-fog-inspection.unit.test.ts +77 -0
  102. package/src/__tests__/volumetric-fog-light-capability.unit.test.ts +19 -0
  103. package/src/__tests__/volumetric-fog-light-consumers.integration.test.ts +18 -0
  104. package/src/__tests__/volumetric-fog-light-errors.unit.test.ts +17 -0
  105. package/src/__tests__/volumetric-fog-light-topology.unit.test.ts +21 -0
  106. package/src/__tests__/volumetric-fog-light-transaction.integration.test.ts +53 -0
  107. package/src/__tests__/volumetric-fog-m9-watcher.integration.test.ts +27 -0
  108. package/src/__tests__/volumetric-fog-point-light-owner.integration.test.ts +44 -0
  109. package/src/__tests__/volumetric-fog-point-spot-accumulation.integration.test.ts +49 -0
  110. package/src/__tests__/volumetric-fog-point-spot-contract.unit.test.ts +28 -0
  111. package/src/__tests__/volumetric-fog-point-spot-lkg.integration.test.ts +65 -0
  112. package/src/__tests__/volumetric-fog-point-spot-selection.integration.test.ts +49 -0
  113. package/src/__tests__/volumetric-fog-point-spot-temporal.unit.test.ts +55 -0
  114. package/src/__tests__/volumetric-fog-projector-contract.unit.test.ts +22 -0
  115. package/src/__tests__/volumetric-fog-projector-inspection.unit.test.ts +12 -0
  116. package/src/__tests__/volumetric-fog-projector-surface-volume.integration.test.ts +11 -0
  117. package/src/__tests__/volumetric-fog-projector-transaction.integration.test.ts +26 -0
  118. package/src/__tests__/volumetric-fog-recovery.integration.test.ts +47 -0
  119. package/src/__tests__/volumetric-fog-selected-light-resolution.integration.test.ts +63 -0
  120. package/src/__tests__/volumetric-fog-spot-closure.integration.test.ts +26 -0
  121. package/src/__tests__/volumetric-fog-spot-contract.test-d.ts +21 -0
  122. package/src/__tests__/volumetric-fog-spot-contract.unit.test.ts +27 -0
  123. package/src/__tests__/volumetric-fog-spot-frame-optics.integration.test.ts +14 -0
  124. package/src/__tests__/volumetric-fog-spot-optics.integration.test.ts +29 -0
  125. package/src/__tests__/volumetric-fog-stage-readback.dawn.test.ts +550 -0
  126. package/src/__tests__/volumetric-fog-temporal-selected-light.unit.test.ts +33 -0
  127. package/src/__tests__/volumetric-fog-temporal.unit.test.ts +45 -0
  128. package/src/__tests__/volumetric-fog-topology.unit.test.ts +237 -0
  129. package/src/__tests__/volumetric-fog-transparent.integration.test.ts +27 -0
  130. package/src/assembly/factory.ts +271 -15
  131. package/src/components/light-helpers.ts +4 -0
  132. package/src/components/spot-light.ts +22 -3
  133. package/src/device/gpu-residency.ts +168 -58
  134. package/src/errors/render.ts +119 -1
  135. package/src/gpu-usage.ts +2 -0
  136. package/src/index.ts +38 -1
  137. package/src/light-buffer-layout.ts +59 -26
  138. package/src/pbr-pipeline.ts +33 -10
  139. package/src/pipeline/standard-forward-lane.ts +20 -0
  140. package/src/pipeline/standard-pipeline.ts +20 -0
  141. package/src/pipeline/standard-profile.ts +23 -0
  142. package/src/record/frame-lighting.ts +103 -89
  143. package/src/record/frame-snapshot.ts +41 -1
  144. package/src/record/frame-targets.ts +6 -2
  145. package/src/record/frame.ts +908 -5
  146. package/src/record/gpu-timing.ts +224 -0
  147. package/src/record/main-pass-material.ts +6 -2
  148. package/src/record/render-context.ts +5 -0
  149. package/src/record/shadow-pass.ts +87 -38
  150. package/src/record/typed-frame-graph.ts +143 -8
  151. package/src/render-contract.ts +72 -0
  152. package/src/render-data.ts +50 -22
  153. package/src/render-pipeline.ts +44 -0
  154. package/src/render-system-extract.ts +329 -3
  155. package/src/render-system.ts +79 -0
  156. package/src/renderer-inspect.ts +66 -0
  157. package/src/scene/render-scene.ts +2 -0
  158. package/src/temporal/frame.ts +128 -0
  159. package/src/transmission/__tests__/standard-transmission.dawn.test.ts +2 -3
  160. package/src/typed-render-graph-primitives.ts +32 -2
  161. package/src/typed-shadow-atlas.ts +17 -0
  162. package/src/typed-shadow-passes.ts +6 -5
  163. package/src/volume/capability.ts +173 -0
  164. package/src/volume/component.ts +110 -0
  165. package/src/volume/extract.ts +21 -0
  166. package/src/volume/inspection.ts +78 -0
  167. package/src/volume/passes.ts +680 -0
  168. package/src/volume/recovery.ts +86 -0
  169. package/src/volume/resources.ts +236 -0
  170. package/src/volume/temporal.ts +89 -0
  171. package/dist/chunk-IPXCABEP.mjs.map +0 -1
  172. package/dist/chunk-LOSO4CTV.mjs.map +0 -1
  173. package/dist/chunk-RJRUS55L.mjs.map +0 -1
  174. package/dist/chunk-YTQ5C7YQ.mjs.map +0 -1
@@ -0,0 +1,29 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const volumeInject = readFileSync(
5
+ new URL('../../../shader/src/volume/volume-inject.wgsl', import.meta.url),
6
+ 'utf8',
7
+ );
8
+ const volumeIntegrate = readFileSync(
9
+ new URL('../../../shader/src/volume/volume-integrate.wgsl', import.meta.url),
10
+ 'utf8',
11
+ );
12
+ const punctual = readFileSync(
13
+ new URL('../../../shader/src/lighting-punctual.wgsl', import.meta.url),
14
+ 'utf8',
15
+ );
16
+
17
+ describe('Spot volume optics integration', () => {
18
+ it('uses the same distance and cone helper as surface lighting', () => {
19
+ expect(volumeIntegrate).toContain('fn evalSpotAttenuation');
20
+ expect(volumeIntegrate).toContain('evalSpotAttenuation');
21
+ expect(punctual).toContain('evalSpotAttenuation');
22
+ });
23
+
24
+ it('does not derive volume radiance from luminaire geometry', () => {
25
+ expect(volumeInject).not.toContain('emitter');
26
+ expect(volumeInject).not.toContain('mesh');
27
+ expect(volumeInject).not.toContain('luminaire');
28
+ });
29
+ });
@@ -0,0 +1,550 @@
1
+ import type { RhiDevice, Texture, TextureView } from '@forgeax/engine-rhi';
2
+ import { createShaderModule, rhi } from '@forgeax/engine-rhi-webgpu';
3
+ import { buildEngineShaderManifest } from '@forgeax/engine-vite-plugin-shader';
4
+ import { describe, expect, it } from 'vitest';
5
+ import {
6
+ LIGHT_ARRAY_HEADER_BYTES,
7
+ LIGHT_ARRAY_MAX_SLOTS,
8
+ POINT_LIGHT_STD430_BYTES,
9
+ SPOT_LIGHT_STD430_BYTES,
10
+ } from '../light-buffer-layout';
11
+
12
+ const MAP_READ = 0x0001;
13
+ const BUFFER_COPY_DST = 0x0008;
14
+ const TEXTURE_COPY_SRC = 0x0001;
15
+ const TEXTURE_COPY_DST = 0x0002;
16
+ const STORAGE_BINDING = 0x0008;
17
+ const TEXTURE_BINDING = 0x0004;
18
+ const RENDER_ATTACHMENT = 0x0010;
19
+ const STORAGE_BUFFER = 0x0080;
20
+ const SIZE = 8;
21
+ const PACKED_LAYERS = Math.ceil(SIZE / 4);
22
+ const BYTES_PER_ROW = 256;
23
+ const BYTES_PER_PIXEL = 8;
24
+ const engineManifest = await buildEngineShaderManifest();
25
+
26
+ interface StageValues {
27
+ readonly red: number[];
28
+ readonly green: number[];
29
+ readonly alpha: number[];
30
+ }
31
+
32
+ interface VolumeStageReadbacks {
33
+ readonly inject: StageValues;
34
+ readonly temporal: StageValues;
35
+ readonly integrate: StageValues;
36
+ }
37
+
38
+ function halfToFloat(bits: number): number {
39
+ const sign = (bits & 0x8000) === 0 ? 1 : -1;
40
+ const exponent = (bits >>> 10) & 0x1f;
41
+ const fraction = bits & 0x3ff;
42
+ if (exponent === 0) return sign * 2 ** -14 * (fraction / 1024);
43
+ if (exponent === 0x1f) return fraction === 0 ? sign * Infinity : Number.NaN;
44
+ return sign * 2 ** (exponent - 15) * (1 + fraction / 1024);
45
+ }
46
+
47
+ function manifestShader(marker: string): string {
48
+ const entry = engineManifest.entries.find(({ wgsl }) => wgsl.includes(marker));
49
+ if (entry === undefined) throw new Error(`missing cooked shader entry: ${marker}`);
50
+ return entry.wgsl;
51
+ }
52
+
53
+ function identityMatrix(target: Float32Array, offset: number): void {
54
+ target[offset] = 1;
55
+ target[offset + 5] = 1;
56
+ target[offset + 10] = 1;
57
+ target[offset + 15] = 1;
58
+ }
59
+
60
+ function createView(
61
+ device: RhiDevice,
62
+ texture: Texture,
63
+ dimension?: '2d' | '2d-array' | '3d',
64
+ ): TextureView {
65
+ const view = device.createTextureView(texture, dimension === undefined ? {} : { dimension });
66
+ if (!view.ok) throw view.error;
67
+ return view.value;
68
+ }
69
+
70
+ async function requestDevice(): Promise<RhiDevice> {
71
+ const adapter = await rhi.requestAdapter();
72
+ if (!adapter.ok) throw new Error(`Dawn volume stage readback unavailable: ${adapter.error.code}`);
73
+ const device = await adapter.value.requestDevice();
74
+ if (!device.ok) throw new Error(`Dawn volume stage readback unavailable: ${device.error.code}`);
75
+ return device.value;
76
+ }
77
+
78
+ async function runInjectReadback(sceneDepthClear = 1): Promise<VolumeStageReadbacks> {
79
+ const device = await requestDevice();
80
+ const moduleResult = await createShaderModule(device, {
81
+ code: manifestShader('fn volume_inject'),
82
+ label: 'stage-readback.volume-inject',
83
+ });
84
+ if (!moduleResult.ok) throw moduleResult.error;
85
+ const temporalModule = await createShaderModule(device, {
86
+ code: manifestShader('fn volume_temporal'),
87
+ label: 'stage-readback.volume-temporal',
88
+ });
89
+ const integrateModule = await createShaderModule(device, {
90
+ code: manifestShader('fn volume_integrate'),
91
+ label: 'stage-readback.volume-integrate',
92
+ });
93
+ if (!temporalModule.ok) throw temporalModule.error;
94
+ if (!integrateModule.ok) throw integrateModule.error;
95
+
96
+ const sceneDepthTexture = device.createTexture({
97
+ label: 'stage-readback.scene-depth',
98
+ size: { width: SIZE, height: SIZE, depthOrArrayLayers: 1 },
99
+ format: 'depth32float',
100
+ usage: RENDER_ATTACHMENT | TEXTURE_BINDING,
101
+ textureBindingViewDimension: undefined,
102
+ });
103
+ const shadowTexture = device.createTexture({
104
+ label: 'stage-readback.shadow-depth',
105
+ size: { width: SIZE, height: SIZE, depthOrArrayLayers: 1 },
106
+ format: 'depth32float',
107
+ usage: RENDER_ATTACHMENT | TEXTURE_BINDING,
108
+ textureBindingViewDimension: undefined,
109
+ });
110
+ const densityTexture = device.createTexture({
111
+ label: 'stage-readback.density',
112
+ size: { width: SIZE, height: SIZE, depthOrArrayLayers: SIZE },
113
+ dimension: '3d',
114
+ format: 'rgba8unorm',
115
+ usage: TEXTURE_BINDING | TEXTURE_COPY_DST,
116
+ textureBindingViewDimension: undefined,
117
+ });
118
+ const outputTexture = device.createTexture({
119
+ label: 'stage-readback.froxel',
120
+ size: { width: SIZE, height: SIZE, depthOrArrayLayers: PACKED_LAYERS },
121
+ dimension: '2d',
122
+ format: 'rgba8unorm',
123
+ usage: STORAGE_BINDING | TEXTURE_COPY_SRC | TEXTURE_BINDING,
124
+ textureBindingViewDimension: undefined,
125
+ });
126
+ const historyTexture = device.createTexture({
127
+ label: 'stage-readback.history',
128
+ size: { width: SIZE, height: SIZE, depthOrArrayLayers: 1 },
129
+ dimension: '2d',
130
+ format: 'rgba16float',
131
+ usage: STORAGE_BINDING | TEXTURE_COPY_SRC | TEXTURE_BINDING,
132
+ textureBindingViewDimension: undefined,
133
+ });
134
+ const temporalTexture = device.createTexture({
135
+ label: 'stage-readback.temporal',
136
+ size: { width: SIZE, height: SIZE, depthOrArrayLayers: 1 },
137
+ dimension: '2d',
138
+ format: 'rgba16float',
139
+ usage: STORAGE_BINDING | TEXTURE_COPY_SRC | TEXTURE_BINDING,
140
+ textureBindingViewDimension: undefined,
141
+ });
142
+ const resolvedTexture = device.createTexture({
143
+ label: 'stage-readback.resolved',
144
+ size: { width: SIZE, height: SIZE, depthOrArrayLayers: 1 },
145
+ dimension: '2d',
146
+ format: 'rgba16float',
147
+ usage: STORAGE_BINDING | TEXTURE_COPY_SRC | TEXTURE_BINDING,
148
+ textureBindingViewDimension: undefined,
149
+ });
150
+ // The production integrator always declares the shared projector pair. A
151
+ // white 1x1 fallback keeps this no-projector fixture on the same BGL shape
152
+ // while preserving the pre-projector radiance when a light is later added.
153
+ const projectorTexture = device.createTexture({
154
+ label: 'stage-readback.projector',
155
+ size: { width: 1, height: 1, depthOrArrayLayers: 1 },
156
+ dimension: '2d',
157
+ format: 'rgba8unorm',
158
+ usage: TEXTURE_BINDING | TEXTURE_COPY_DST,
159
+ textureBindingViewDimension: undefined,
160
+ });
161
+ if (
162
+ !sceneDepthTexture.ok ||
163
+ !shadowTexture.ok ||
164
+ !densityTexture.ok ||
165
+ !outputTexture.ok ||
166
+ !historyTexture.ok ||
167
+ !temporalTexture.ok ||
168
+ !resolvedTexture.ok ||
169
+ !projectorTexture.ok
170
+ ) {
171
+ throw new Error('Dawn volume stage readback unavailable: texture allocation failed');
172
+ }
173
+ const sceneDepthView = device.createTextureView(sceneDepthTexture.value, { dimension: '2d' });
174
+ const shadowView = device.createTextureView(shadowTexture.value, { dimension: '2d' });
175
+ if (!sceneDepthView.ok || !shadowView.ok)
176
+ throw new Error('Dawn volume stage readback unavailable: depth view allocation failed');
177
+ const densityView = createView(device, densityTexture.value, '3d');
178
+ const outputView = createView(device, outputTexture.value, '2d-array');
179
+ const historyView = createView(device, historyTexture.value, '2d');
180
+ const temporalView = createView(device, temporalTexture.value, '2d');
181
+ const resolvedView = createView(device, resolvedTexture.value, '2d');
182
+ const projectorView = createView(device, projectorTexture.value, '2d');
183
+ const viewBuffer = device.createBuffer({
184
+ label: 'stage-readback.view',
185
+ size: 960,
186
+ usage: 0x0040 | BUFFER_COPY_DST,
187
+ });
188
+ const paramsBuffer = device.createBuffer({
189
+ label: 'stage-readback.params',
190
+ size: 128,
191
+ usage: 0x0040 | BUFFER_COPY_DST,
192
+ });
193
+ const spotLightsBuffer = device.createBuffer({
194
+ label: 'stage-readback.spot-lights',
195
+ size: LIGHT_ARRAY_HEADER_BYTES + LIGHT_ARRAY_MAX_SLOTS * SPOT_LIGHT_STD430_BYTES,
196
+ usage: STORAGE_BUFFER | BUFFER_COPY_DST,
197
+ });
198
+ const pointLightsBuffer = device.createBuffer({
199
+ label: 'stage-readback.point-lights',
200
+ size: LIGHT_ARRAY_HEADER_BYTES + LIGHT_ARRAY_MAX_SLOTS * POINT_LIGHT_STD430_BYTES,
201
+ usage: STORAGE_BUFFER | BUFFER_COPY_DST,
202
+ });
203
+ const injectReadback = device.createBuffer({
204
+ label: 'stage-readback.inject',
205
+ size: BYTES_PER_ROW * SIZE * PACKED_LAYERS,
206
+ usage: MAP_READ | BUFFER_COPY_DST,
207
+ });
208
+ const temporalReadback = device.createBuffer({
209
+ label: 'stage-readback.temporal',
210
+ size: BYTES_PER_ROW * SIZE * SIZE,
211
+ usage: MAP_READ | BUFFER_COPY_DST,
212
+ });
213
+ const integrateReadback = device.createBuffer({
214
+ label: 'stage-readback.integrate',
215
+ size: BYTES_PER_ROW * SIZE * SIZE,
216
+ usage: MAP_READ | BUFFER_COPY_DST,
217
+ });
218
+ if (
219
+ !viewBuffer.ok ||
220
+ !paramsBuffer.ok ||
221
+ !spotLightsBuffer.ok ||
222
+ !pointLightsBuffer.ok ||
223
+ !injectReadback.ok ||
224
+ !temporalReadback.ok ||
225
+ !integrateReadback.ok
226
+ )
227
+ throw new Error('Dawn volume stage readback unavailable: readback allocation failed');
228
+
229
+ const viewData = new Float32Array(240);
230
+ for (const offset of [0, 28, 44, 60, 76, 92, 196, 212]) identityMatrix(viewData, offset);
231
+ viewData[24] = 0;
232
+ viewData[25] = 0;
233
+ viewData[26] = -1;
234
+ viewData[124] = 0;
235
+ viewData[228] = 0.1;
236
+ viewData[229] = 10;
237
+ viewData[230] = 0;
238
+ viewData[235] = 1;
239
+ const paramsData = new Float32Array(32);
240
+ paramsData.set([-1, -1, 0.1, 0], 0);
241
+ paramsData.set([1, 1, 0.9, 0], 4);
242
+ paramsData.set([1, 1, 1, 0], 8);
243
+ paramsData.set([1, 1, 1, 0], 12);
244
+ paramsData.set([0, 0, 0, 0], 16);
245
+ paramsData.set([0, -1, 0, 0], 20);
246
+ paramsData.set([1, 1, 1, 0], 24);
247
+ paramsData.set([2, 0, 0, 0], 28);
248
+ const viewWrite = device.queue.writeBuffer(viewBuffer.value, 0, viewData);
249
+ const paramsWrite = device.queue.writeBuffer(paramsBuffer.value, 0, paramsData);
250
+ const spotLightsWrite = device.queue.writeBuffer(
251
+ spotLightsBuffer.value,
252
+ 0,
253
+ new Uint32Array([0, 0, 0, 0]),
254
+ );
255
+ const pointLightsWrite = device.queue.writeBuffer(
256
+ pointLightsBuffer.value,
257
+ 0,
258
+ new Uint32Array([0, 0, 0, 0]),
259
+ );
260
+ if (!viewWrite.ok || !paramsWrite.ok || !spotLightsWrite.ok || !pointLightsWrite.ok)
261
+ throw new Error('Dawn volume stage readback unavailable: uniform upload failed');
262
+ const densityData = new Uint8Array((BYTES_PER_PIXEL / 2) * SIZE * SIZE * SIZE);
263
+ densityData.fill(255);
264
+ const densityWrite = device.queue.writeTexture(
265
+ { texture: densityTexture.value },
266
+ densityData,
267
+ { offset: 0, bytesPerRow: SIZE * 4, rowsPerImage: SIZE },
268
+ { width: SIZE, height: SIZE, depthOrArrayLayers: SIZE },
269
+ );
270
+ if (!densityWrite.ok)
271
+ throw new Error('Dawn volume stage readback unavailable: density upload failed');
272
+ const projectorWrite = device.queue.writeTexture(
273
+ { texture: projectorTexture.value },
274
+ new Uint8Array([255, 255, 255, 255]),
275
+ { bytesPerRow: 4, rowsPerImage: 1 },
276
+ { width: 1, height: 1, depthOrArrayLayers: 1 },
277
+ );
278
+ if (!projectorWrite.ok)
279
+ throw new Error('Dawn volume stage readback unavailable: projector upload failed');
280
+
281
+ const layout = device.createBindGroupLayout({
282
+ label: 'stage-readback.volume-inject.layout',
283
+ entries: [
284
+ { binding: 0, visibility: 4, buffer: { type: 'uniform' } },
285
+ { binding: 3, visibility: 4, texture: { sampleType: 'depth', viewDimension: '2d' } },
286
+ { binding: 4, visibility: 4, sampler: { type: 'comparison' } },
287
+ { binding: 5, visibility: 4, buffer: { type: 'uniform' } },
288
+ {
289
+ binding: 6,
290
+ visibility: 4,
291
+ storageTexture: { access: 'write-only', format: 'rgba8unorm', viewDimension: '2d-array' },
292
+ },
293
+ { binding: 7, visibility: 4, buffer: { type: 'read-only-storage' } },
294
+ ],
295
+ });
296
+ if (!layout.ok) throw layout.error;
297
+ const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [layout.value] });
298
+ if (!pipelineLayout.ok) throw pipelineLayout.error;
299
+ const pipeline = device.createComputePipeline({
300
+ label: 'stage-readback.volume-inject',
301
+ layout: pipelineLayout.value,
302
+ compute: { module: moduleResult.value, entryPoint: 'volume_inject' },
303
+ });
304
+ if (!pipeline.ok) throw pipeline.error;
305
+ const temporalLayout = device.createBindGroupLayout({
306
+ label: 'stage-readback.volume-temporal.layout',
307
+ entries: [
308
+ {
309
+ binding: 0,
310
+ visibility: 4,
311
+ texture: { sampleType: 'float', viewDimension: '2d' },
312
+ },
313
+ {
314
+ binding: 1,
315
+ visibility: 4,
316
+ texture: { sampleType: 'float', viewDimension: '2d' },
317
+ },
318
+ {
319
+ binding: 2,
320
+ visibility: 4,
321
+ storageTexture: { access: 'write-only', format: 'rgba16float', viewDimension: '2d' },
322
+ },
323
+ { binding: 3, visibility: 4, buffer: { type: 'uniform' } },
324
+ { binding: 4, visibility: 4, buffer: { type: 'uniform' } },
325
+ ],
326
+ });
327
+ const integrateLayout = device.createBindGroupLayout({
328
+ label: 'stage-readback.volume-integrate.layout',
329
+ entries: [
330
+ { binding: 0, visibility: 4, texture: { sampleType: 'float', viewDimension: '2d-array' } },
331
+ {
332
+ binding: 1,
333
+ visibility: 4,
334
+ texture: { sampleType: 'depth', viewDimension: '2d' },
335
+ },
336
+ {
337
+ binding: 2,
338
+ visibility: 4,
339
+ storageTexture: { access: 'write-only', format: 'rgba16float', viewDimension: '2d' },
340
+ },
341
+ { binding: 3, visibility: 4, buffer: { type: 'uniform' } },
342
+ { binding: 4, visibility: 4, buffer: { type: 'uniform' } },
343
+ { binding: 5, visibility: 4, sampler: { type: 'filtering' } },
344
+ { binding: 6, visibility: 4, texture: { sampleType: 'float', viewDimension: '3d' } },
345
+ { binding: 7, visibility: 4, sampler: { type: 'filtering' } },
346
+ {
347
+ binding: 8,
348
+ visibility: 4,
349
+ storageTexture: { access: 'write-only', format: 'rgba16float', viewDimension: '2d' },
350
+ },
351
+ {
352
+ binding: 9,
353
+ visibility: 4,
354
+ storageTexture: { access: 'write-only', format: 'rgba16float', viewDimension: '2d' },
355
+ },
356
+ { binding: 10, visibility: 4, buffer: { type: 'read-only-storage' } },
357
+ { binding: 11, visibility: 4, buffer: { type: 'read-only-storage' } },
358
+ { binding: 12, visibility: 4, texture: { sampleType: 'float', viewDimension: '2d' } },
359
+ { binding: 13, visibility: 4, sampler: { type: 'filtering' } },
360
+ ],
361
+ });
362
+ if (!temporalLayout.ok) throw temporalLayout.error;
363
+ if (!integrateLayout.ok) throw integrateLayout.error;
364
+ const temporalPipelineLayout = device.createPipelineLayout({
365
+ bindGroupLayouts: [temporalLayout.value],
366
+ });
367
+ const integratePipelineLayout = device.createPipelineLayout({
368
+ bindGroupLayouts: [integrateLayout.value],
369
+ });
370
+ if (!temporalPipelineLayout.ok) throw temporalPipelineLayout.error;
371
+ if (!integratePipelineLayout.ok) throw integratePipelineLayout.error;
372
+ const temporalPipeline = device.createComputePipeline({
373
+ label: 'stage-readback.volume-temporal',
374
+ layout: temporalPipelineLayout.value,
375
+ compute: { module: temporalModule.value, entryPoint: 'volume_temporal' },
376
+ });
377
+ const integratePipeline = device.createComputePipeline({
378
+ label: 'stage-readback.volume-integrate',
379
+ layout: integratePipelineLayout.value,
380
+ compute: { module: integrateModule.value, entryPoint: 'volume_integrate' },
381
+ });
382
+ if (!temporalPipeline.ok) throw temporalPipeline.error;
383
+ if (!integratePipeline.ok) throw integratePipeline.error;
384
+ const comparisonSampler = device.createSampler({ compare: 'less' });
385
+ const densitySampler = device.createSampler({ minFilter: 'linear', magFilter: 'linear' });
386
+ if (!comparisonSampler.ok || !densitySampler.ok)
387
+ throw new Error('Dawn volume stage readback unavailable: sampler allocation failed');
388
+ const bindings = device.createBindGroup({
389
+ layout: layout.value,
390
+ entries: [
391
+ { binding: 0, resource: { kind: 'buffer', value: { buffer: viewBuffer.value } } },
392
+ { binding: 3, resource: { kind: 'textureView', value: shadowView.value } },
393
+ { binding: 4, resource: { kind: 'sampler', value: comparisonSampler.value } },
394
+ { binding: 5, resource: { kind: 'buffer', value: { buffer: paramsBuffer.value } } },
395
+ { binding: 6, resource: { kind: 'textureView', value: outputView } },
396
+ { binding: 7, resource: { kind: 'buffer', value: { buffer: spotLightsBuffer.value } } },
397
+ ],
398
+ });
399
+ if (!bindings.ok) throw bindings.error;
400
+ const temporalBindings = device.createBindGroup({
401
+ layout: temporalLayout.value,
402
+ entries: [
403
+ { binding: 0, resource: { kind: 'textureView', value: resolvedView } },
404
+ { binding: 1, resource: { kind: 'textureView', value: historyView } },
405
+ { binding: 2, resource: { kind: 'textureView', value: temporalView } },
406
+ { binding: 3, resource: { kind: 'buffer', value: { buffer: paramsBuffer.value } } },
407
+ { binding: 4, resource: { kind: 'buffer', value: { buffer: viewBuffer.value } } },
408
+ ],
409
+ });
410
+ const integrateBindings = device.createBindGroup({
411
+ layout: integrateLayout.value,
412
+ entries: [
413
+ { binding: 0, resource: { kind: 'textureView', value: outputView } },
414
+ { binding: 1, resource: { kind: 'textureView', value: sceneDepthView.value } },
415
+ { binding: 2, resource: { kind: 'textureView', value: resolvedView } },
416
+ { binding: 3, resource: { kind: 'buffer', value: { buffer: paramsBuffer.value } } },
417
+ { binding: 4, resource: { kind: 'buffer', value: { buffer: viewBuffer.value } } },
418
+ { binding: 5, resource: { kind: 'sampler', value: densitySampler.value } },
419
+ { binding: 6, resource: { kind: 'textureView', value: densityView } },
420
+ { binding: 7, resource: { kind: 'sampler', value: densitySampler.value } },
421
+ { binding: 8, resource: { kind: 'textureView', value: historyView } },
422
+ { binding: 9, resource: { kind: 'textureView', value: temporalView } },
423
+ { binding: 10, resource: { kind: 'buffer', value: { buffer: spotLightsBuffer.value } } },
424
+ { binding: 11, resource: { kind: 'buffer', value: { buffer: pointLightsBuffer.value } } },
425
+ { binding: 12, resource: { kind: 'textureView', value: projectorView } },
426
+ { binding: 13, resource: { kind: 'sampler', value: densitySampler.value } },
427
+ ],
428
+ });
429
+ if (!temporalBindings.ok) throw temporalBindings.error;
430
+ if (!integrateBindings.ok) throw integrateBindings.error;
431
+ const encoder = device.createCommandEncoder({ label: 'stage-readback.volume-inject' });
432
+ if (!encoder.ok) throw encoder.error;
433
+ for (const [index, view] of [sceneDepthView.value, shadowView.value].entries()) {
434
+ const pass = encoder.value.beginRenderPass({
435
+ colorAttachments: [],
436
+ depthStencilAttachment: {
437
+ view,
438
+ depthClearValue: index === 0 ? sceneDepthClear : 1,
439
+ depthLoadOp: 'clear',
440
+ depthStoreOp: 'store',
441
+ },
442
+ } as never);
443
+ pass.end();
444
+ }
445
+ const compute = encoder.value.beginComputePass();
446
+ compute.setPipeline(pipeline.value);
447
+ compute.setBindGroup(0, bindings.value);
448
+ compute.dispatchWorkgroups(1, 1, PACKED_LAYERS);
449
+ compute.end();
450
+ encoder.value.copyTextureToBuffer(
451
+ { texture: outputTexture.value as never },
452
+ { buffer: injectReadback.value as never, bytesPerRow: BYTES_PER_ROW, rowsPerImage: SIZE },
453
+ { width: SIZE, height: SIZE, depthOrArrayLayers: PACKED_LAYERS },
454
+ );
455
+ const integrateCompute = encoder.value.beginComputePass();
456
+ integrateCompute.setPipeline(integratePipeline.value);
457
+ integrateCompute.setBindGroup(0, integrateBindings.value);
458
+ integrateCompute.dispatchWorkgroups(1, 1, 1);
459
+ integrateCompute.end();
460
+ encoder.value.copyTextureToBuffer(
461
+ { texture: resolvedTexture.value as never },
462
+ { buffer: integrateReadback.value as never, bytesPerRow: BYTES_PER_ROW, rowsPerImage: SIZE },
463
+ { width: SIZE, height: SIZE, depthOrArrayLayers: 1 },
464
+ );
465
+ const temporalCompute = encoder.value.beginComputePass();
466
+ temporalCompute.setPipeline(temporalPipeline.value);
467
+ temporalCompute.setBindGroup(0, temporalBindings.value);
468
+ temporalCompute.dispatchWorkgroups(1, 1, 1);
469
+ temporalCompute.end();
470
+ encoder.value.copyTextureToBuffer(
471
+ { texture: temporalTexture.value as never },
472
+ { buffer: temporalReadback.value as never, bytesPerRow: BYTES_PER_ROW, rowsPerImage: SIZE },
473
+ { width: SIZE, height: SIZE, depthOrArrayLayers: 1 },
474
+ );
475
+ const command = encoder.value.finish();
476
+ if (!command.ok) throw command.error;
477
+ const submitted = device.queue.submit([command.value]);
478
+ if (!submitted.ok) throw submitted.error;
479
+ await device.queue.onSubmittedWorkDone();
480
+ const decode = async (
481
+ buffer: typeof integrateReadback.value,
482
+ layers: number,
483
+ bytesPerPixel: number,
484
+ halfFloat: boolean,
485
+ ): Promise<StageValues> => {
486
+ const mapped = await buffer.mapAsync(MAP_READ);
487
+ if (!mapped.ok) throw mapped.error;
488
+ const range = mapped.value.getMappedRange();
489
+ if (!range.ok) throw range.error;
490
+ const bytes = new Uint8Array(range.value);
491
+ const red: number[] = [];
492
+ const green: number[] = [];
493
+ const alpha: number[] = [];
494
+ for (let z = 0; z < layers; z += 1) {
495
+ for (let y = 0; y < SIZE; y += 1) {
496
+ for (let x = 0; x < SIZE; x += 1) {
497
+ const offset = z * BYTES_PER_ROW * SIZE + y * BYTES_PER_ROW + x * bytesPerPixel;
498
+ const read = (channel: number): number => {
499
+ if (!halfFloat) return (bytes[offset + channel] ?? 0) / 255;
500
+ const halfOffset = offset + channel * 2;
501
+ return halfToFloat((bytes[halfOffset] ?? 0) | ((bytes[halfOffset + 1] ?? 0) << 8));
502
+ };
503
+ red.push(read(0));
504
+ green.push(read(halfFloat ? 1 : 1));
505
+ alpha.push(read(halfFloat ? 3 : 3));
506
+ }
507
+ }
508
+ }
509
+ mapped.value.unmap();
510
+ return { red, green, alpha };
511
+ };
512
+ return {
513
+ inject: await decode(injectReadback.value, SIZE, 4, false),
514
+ temporal: await decode(temporalReadback.value, 1, BYTES_PER_PIXEL, true),
515
+ integrate: await decode(integrateReadback.value, 1, BYTES_PER_PIXEL, true),
516
+ };
517
+ }
518
+
519
+ describe('volumetric fog production stage readback on Dawn', () => {
520
+ it('injects raw density then resolves a 2d integrated volume', async () => {
521
+ const values = await runInjectReadback();
522
+ // biome-ignore lint/suspicious/noConsole: stage readback is machine evidence
523
+ console.log(
524
+ JSON.stringify({
525
+ inject: [Math.min(...values.inject.red), Math.max(...values.inject.red)],
526
+ temporal: [Math.min(...values.temporal.red), Math.max(...values.temporal.red)],
527
+ integrate: [Math.min(...values.integrate.red), Math.max(...values.integrate.red)],
528
+ }),
529
+ );
530
+ expect(values.inject.red.filter((value) => value > 0.01).length).toBeGreaterThan(0);
531
+ expect(values.inject.green.some((value) => value > 0.9)).toBe(true);
532
+ expect(values.inject.green.every((value) => value >= 0 && value <= 1)).toBe(true);
533
+ expect(values.temporal.red.some((value) => value > 0)).toBe(true);
534
+ expect(values.temporal.alpha.every((value) => value <= 1)).toBe(true);
535
+ expect(values.integrate.red.some((value) => value > 0)).toBe(true);
536
+ expect(values.integrate.alpha.every((value) => value >= 0 && value <= 1)).toBe(true);
537
+ });
538
+
539
+ it('clips the final ray step to the scene-depth segment', async () => {
540
+ const values = await runInjectReadback(0.57);
541
+ const transmittance = values.integrate.alpha[0];
542
+ // The fixture uploads an all-white source texture. The authored Three
543
+ // smoke expression therefore evaluates to 2 * 1.5^3 - 1 = 5.75 before
544
+ // Beer-Lambert consumes it; only the final 0.47-unit segment is allowed
545
+ // to contribute after the scene-depth clip.
546
+ const sourceDensity = 2 * 1.5 ** 3 - 1;
547
+ expect(transmittance).toBeCloseTo(Math.exp(-0.47 * sourceDensity), 2);
548
+ expect(values.integrate.red[0]).toBeGreaterThan(0);
549
+ });
550
+ });
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveVolumeTemporalReset, type VolumeTemporalSignature } from '../volume/temporal';
3
+
4
+ const base: VolumeTemporalSignature = {
5
+ cameraRevision: 1,
6
+ fogRevision: 2,
7
+ lightRevision: 3,
8
+ densityGeneration: 4,
9
+ width: 64,
10
+ height: 64,
11
+ lightKind: 'directional',
12
+ lightEntity: 7,
13
+ lightShadowRevision: 5,
14
+ };
15
+
16
+ describe('volumetric fog selected-light temporal identity', () => {
17
+ it('resets for selected identity, kind, and shadow matrix revisions', () => {
18
+ for (const change of [
19
+ { lightEntity: 8 },
20
+ { lightKind: 'spot' as const },
21
+ { lightShadowRevision: 6 },
22
+ ]) {
23
+ expect(resolveVolumeTemporalReset(base, { ...base, ...change })).toEqual({
24
+ reset: true,
25
+ reason: 'light-revision',
26
+ });
27
+ }
28
+ });
29
+
30
+ it('does not reset when unrelated light revisions are unchanged', () => {
31
+ expect(resolveVolumeTemporalReset(base, { ...base })).toEqual({ reset: false });
32
+ });
33
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveVolumeTemporalReset, type VolumeTemporalSignature } from '../volume/temporal';
3
+
4
+ const signature: VolumeTemporalSignature = {
5
+ cameraRevision: 4,
6
+ fogRevision: 2,
7
+ lightRevision: 7,
8
+ densityGeneration: 3,
9
+ width: 1280,
10
+ height: 720,
11
+ };
12
+
13
+ describe('volumetric fog temporal contract', () => {
14
+ it('keeps history for a stable signature and TAA-independent volume temporal', () => {
15
+ expect(resolveVolumeTemporalReset(signature, signature)).toEqual({ reset: false });
16
+ expect(resolveVolumeTemporalReset(signature, signature, { taaEnabled: false })).toEqual({
17
+ reset: false,
18
+ });
19
+ });
20
+
21
+ it.each([
22
+ ['camera-cut', { ...signature, cameraRevision: 5 }],
23
+ ['fog-revision', { ...signature, fogRevision: 3 }],
24
+ ['light-revision', { ...signature, lightRevision: 8 }],
25
+ ['density-generation', { ...signature, densityGeneration: 4 }],
26
+ ['resize', { ...signature, width: 640, height: 360 }],
27
+ ] as const)('resets history for %s', (reason, next) => {
28
+ expect(resolveVolumeTemporalReset(signature, next)).toEqual({ reset: true, reason });
29
+ });
30
+
31
+ it('rejects out-of-screen reprojection and depth discontinuity without NaN', () => {
32
+ expect(
33
+ resolveVolumeTemporalReset(signature, signature, { reprojection: 'out-of-screen' }),
34
+ ).toEqual({
35
+ reset: true,
36
+ reason: 'reprojection-out-of-screen',
37
+ });
38
+ expect(
39
+ resolveVolumeTemporalReset(signature, signature, { reprojection: 'depth-discontinuity' }),
40
+ ).toEqual({
41
+ reset: true,
42
+ reason: 'reprojection-depth-discontinuity',
43
+ });
44
+ });
45
+ });