@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,655 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+ import { World } from '@forgeax/engine-ecs';
5
+ import { RenderGraphBuilder } from '@forgeax/engine-render-graph';
6
+ import type {
7
+ BindGroupLayoutDescriptor,
8
+ RenderPipelineDescriptor,
9
+ RhiAdapter,
10
+ RhiDevice,
11
+ RhiError,
12
+ SamplerDescriptor,
13
+ } from '@forgeax/engine-rhi';
14
+ import {
15
+ rhi as nullRhi,
16
+ RhiNullCommandEncoder,
17
+ RhiNullDevice,
18
+ RhiNullQueue,
19
+ } from '@forgeax/engine-rhi-null';
20
+ import { Transform } from '@forgeax/engine-scene';
21
+ import { ok, type Result, type TextureAsset } from '@forgeax/engine-types';
22
+ import { describe, expect, it } from 'vitest';
23
+ import { Camera, perspective, TONEMAP_ACES_FILMIC } from '../components/camera';
24
+ import { DirectionalLight } from '../components/directional-light';
25
+ import { constructRendererHost } from '../construct-renderer';
26
+ import { resolveVolumetricFogProfile } from '../pipeline/standard-profile';
27
+ import type { RenderPipelineFrame } from '../render-pipeline';
28
+ import { VolumetricFog } from '../volume/component';
29
+ import { inspectVolumetricFog } from '../volume/inspection';
30
+ import { recoverVolumetricFog, stageVolumetricFog } from '../volume/recovery';
31
+ import {
32
+ createVolumetricFogResources,
33
+ deriveVolumetricFogExtent,
34
+ deriveVolumetricFogResolvedExtent,
35
+ inspectVolumetricFogResources,
36
+ } from '../volume/resources';
37
+ import { resolveVolumeTemporalReset } from '../volume/temporal';
38
+
39
+ const MAX_MEMORY_BYTES = 48 * 1024 * 1024;
40
+
41
+ const VOLUME_MANIFEST = `data:application/json,${encodeURIComponent(
42
+ JSON.stringify({
43
+ entries: [
44
+ { hash: 'pbr', wgsl: 'f_schlick', glsl: '', bindings: '' },
45
+ { hash: 'unlit', wgsl: 'unlit', glsl: '', bindings: '' },
46
+ { hash: 'tonemap', wgsl: 'struct TonemapParams {}', glsl: '', bindings: '' },
47
+ { hash: 'volume-inject', wgsl: 'fn volume_inject() {}', glsl: '', bindings: '' },
48
+ { hash: 'volume-temporal', wgsl: 'fn volume_temporal() {}', glsl: '', bindings: '' },
49
+ { hash: 'volume-integrate', wgsl: 'fn volume_integrate() {}', glsl: '', bindings: '' },
50
+ { hash: 'volume-composite', wgsl: 'fn volume_fs() {}', glsl: '', bindings: '' },
51
+ ],
52
+ }),
53
+ )}`;
54
+
55
+ type LostInfo = { readonly reason: 'destroyed' | 'unknown'; readonly message: string };
56
+
57
+ /** A null-device that can drive the real Renderer lost -> recover lifecycle. */
58
+ class RecoverableNullDevice extends RhiNullDevice {
59
+ readonly renderPipelineDescriptors: RenderPipelineDescriptor[] = [];
60
+ readonly bindGroupLayoutDescriptors: BindGroupLayoutDescriptor[] = [];
61
+ readonly samplerDescriptors: SamplerDescriptor[] = [];
62
+ private readonly resolveLost: (info: LostInfo) => void;
63
+ private readonly lostPromise: Promise<LostInfo>;
64
+
65
+ constructor() {
66
+ let resolveLost!: (info: LostInfo) => void;
67
+ const lostPromise = new Promise<LostInfo>((resolve) => {
68
+ resolveLost = resolve;
69
+ });
70
+ super(
71
+ new RhiNullQueue(),
72
+ (bookkeeper, device) => new RhiNullCommandEncoder(bookkeeper, device),
73
+ );
74
+ this.resolveLost = resolveLost;
75
+ this.lostPromise = lostPromise;
76
+ }
77
+
78
+ override get lost(): Promise<LostInfo> {
79
+ return this.lostPromise;
80
+ }
81
+
82
+ override createRenderPipeline(descriptor: RenderPipelineDescriptor) {
83
+ this.renderPipelineDescriptors.push(descriptor);
84
+ return super.createRenderPipeline(descriptor);
85
+ }
86
+
87
+ override createBindGroupLayout(descriptor: BindGroupLayoutDescriptor) {
88
+ this.bindGroupLayoutDescriptors.push(descriptor);
89
+ return super.createBindGroupLayout(descriptor);
90
+ }
91
+
92
+ override createSampler(descriptor?: SamplerDescriptor) {
93
+ this.samplerDescriptors.push(descriptor ?? {});
94
+ return super.createSampler(descriptor);
95
+ }
96
+
97
+ lose(): void {
98
+ this.resolveLost({ reason: 'unknown', message: 'AC-22 lifecycle recovery probe' });
99
+ }
100
+ }
101
+
102
+ class RecoverableNullAdapter implements RhiAdapter {
103
+ readonly features: ReadonlySet<GPUFeatureName> = new Set();
104
+ readonly limits: Readonly<Record<string, number>> = {};
105
+ readonly devices: RecoverableNullDevice[] = [];
106
+
107
+ requestDevice(): Promise<Result<RhiDevice, RhiError>> {
108
+ const device = new RecoverableNullDevice();
109
+ this.devices.push(device);
110
+ return Promise.resolve(ok(device));
111
+ }
112
+ }
113
+
114
+ function surfaceCanvas(width = 1920, height = 1080): HTMLCanvasElement {
115
+ return { width, height, getContext: () => null } as unknown as HTMLCanvasElement;
116
+ }
117
+
118
+ function densityAsset(): TextureAsset {
119
+ return {
120
+ kind: 'texture',
121
+ shape: { viewDimension: '3d', extent: { width: 64, height: 64, depth: 64 } },
122
+ format: 'r8unorm',
123
+ colorSpace: 'linear',
124
+ mips: { kind: 'none' },
125
+ data: new Uint8Array(64 * 64 * 64).fill(32),
126
+ };
127
+ }
128
+
129
+ interface LedgerSnapshot {
130
+ readonly liveTextures: number;
131
+ readonly liveBuffers: number;
132
+ readonly liveHandles: number;
133
+ }
134
+
135
+ function snapshotLedger(device: RecoverableNullDevice): LedgerSnapshot {
136
+ const live = device.bookkeeper.allRecords().filter((record) => !record.destroyed);
137
+ return {
138
+ liveTextures: live.filter((record) => record.kind === 'Texture').length,
139
+ liveBuffers: live.filter((record) => record.kind === 'Buffer').length,
140
+ liveHandles: live.length,
141
+ };
142
+ }
143
+
144
+ async function persistAc22Receipt(receipt: unknown): Promise<void> {
145
+ const target = process.env.FORGEAX_AC22_RECEIPT;
146
+ if (target === undefined) return;
147
+ await mkdir(dirname(target), { recursive: true });
148
+ await writeFile(target, `${JSON.stringify(receipt, null, 2)}\n`);
149
+ }
150
+
151
+ describe('volumetric fog budget and recovery contract', () => {
152
+ it('keeps one active generation and bounded resources through 20 resizes', () => {
153
+ let signature = {
154
+ cameraRevision: 1,
155
+ fogRevision: 1,
156
+ lightRevision: 1,
157
+ densityGeneration: 4,
158
+ width: 320,
159
+ height: 180,
160
+ };
161
+ let resets = 0;
162
+ for (let index = 0; index < 20; index += 1) {
163
+ const next = { ...signature, width: 320 + index + 1, height: 180 + index + 1 };
164
+ const reset = resolveVolumeTemporalReset(signature, next);
165
+ expect(reset).toEqual({ reset: true, reason: 'resize' });
166
+ signature = next;
167
+ resets += 1;
168
+ }
169
+ const inspection = inspectVolumetricFog({
170
+ authored: true,
171
+ capability: 'available',
172
+ recovery: {
173
+ guid: '019f0000-0000-7000-8000-0000000003f1',
174
+ generation: signature.densityGeneration,
175
+ deviceEpoch: 0,
176
+ status: 'accepted',
177
+ },
178
+ acceptedDigest: 'sha256:volume',
179
+ format: 'r8unorm',
180
+ passCount: 1,
181
+ sampleCount: 1,
182
+ memoryBytes: 320 * 180 * 4,
183
+ });
184
+ expect(resets).toBe(20);
185
+ expect(inspection.generation).toBe(4);
186
+ expect(inspection.memoryBytes).toBeLessThanOrEqual(MAX_MEMORY_BYTES);
187
+ expect(inspection.passCount).toBe(1);
188
+ expect(inspection.sampleCount).toBe(1);
189
+ });
190
+
191
+ it('records three recovery transitions without zeroing unavailable timing evidence', () => {
192
+ const initial = {
193
+ guid: '019f0000-0000-7000-8000-0000000003f1',
194
+ generation: 4,
195
+ deviceEpoch: 0,
196
+ status: 'accepted' as const,
197
+ };
198
+ const candidate = stageVolumetricFog(initial, { generation: 5, digest: 'sha256:candidate' });
199
+ const lost = recoverVolumetricFog(candidate, { kind: 'device-lost', deviceEpoch: 1 });
200
+ const degraded = recoverVolumetricFog(lost, { kind: 'submit-failed' });
201
+ const stale = recoverVolumetricFog(degraded, { kind: 'accepted', generation: 5 });
202
+ const inspection = inspectVolumetricFog({
203
+ authored: true,
204
+ capability: 'available',
205
+ degraded: true,
206
+ recovery: stale,
207
+ acceptedDigest: 'sha256:accepted',
208
+ passCount: 1,
209
+ sampleCount: 1,
210
+ memoryBytes: 1024,
211
+ });
212
+ expect(stale.candidateFailure).toBe('stale-generation');
213
+ expect(inspection.resourceStage).toBe('lkg');
214
+ expect(inspection.memoryBytes).toBeGreaterThan(0);
215
+ expect(Number.isFinite(inspection.memoryBytes)).toBe(true);
216
+ });
217
+
218
+ it('derives high-profile bytes and samples from compiled descriptors', () => {
219
+ expect(
220
+ resolveVolumetricFogProfile({ volumetricFog: { quality: 'low', depth: 48, tileSize: 16 } }),
221
+ ).toEqual({
222
+ quality: 'low',
223
+ depth: 48,
224
+ tileSize: 16,
225
+ });
226
+ expect(deriveVolumetricFogExtent({ width: 1920, height: 1080 }, 48, 16)).toEqual({
227
+ width: 240,
228
+ height: 135,
229
+ depth: 48,
230
+ });
231
+ expect(deriveVolumetricFogExtent({ width: 1920, height: 1080 })).toEqual({
232
+ width: 960,
233
+ height: 540,
234
+ depth: 64,
235
+ });
236
+ expect(deriveVolumetricFogResolvedExtent({ width: 1920, height: 1080 }, 4)).toEqual({
237
+ width: 480,
238
+ height: 270,
239
+ depth: 1,
240
+ });
241
+ expect(deriveVolumetricFogResolvedExtent({ width: 1920, height: 1080 }, 16)).toEqual({
242
+ width: 240,
243
+ height: 135,
244
+ depth: 1,
245
+ });
246
+ const extent = { width: 960, height: 540, depthOrArrayLayers: 16 };
247
+ const graph = {
248
+ generation: 17,
249
+ passes: [],
250
+ resources: [
251
+ {
252
+ label: 'volume-froxel',
253
+ kind: 'texture',
254
+ origin: 'created',
255
+ descriptor: {
256
+ kind: 'texture',
257
+ format: 'rgba8unorm',
258
+ size: extent,
259
+ width: extent.width,
260
+ height: extent.height,
261
+ depthOrArrayLayers: extent.depthOrArrayLayers,
262
+ mipLevelCount: 1,
263
+ sampleCount: 1,
264
+ },
265
+ firstUse: 0,
266
+ lastUse: 1,
267
+ derivedUsage: 8,
268
+ format: 'rgba8unorm',
269
+ extent,
270
+ byteSize: 960 * 540 * 16 * 4,
271
+ },
272
+ {
273
+ label: 'volume-resolved-current',
274
+ kind: 'texture',
275
+ origin: 'created',
276
+ descriptor: {
277
+ kind: 'texture',
278
+ format: 'rgba16float',
279
+ size: { width: 960, height: 540, depthOrArrayLayers: 1 },
280
+ width: 960,
281
+ height: 540,
282
+ depthOrArrayLayers: 1,
283
+ mipLevelCount: 1,
284
+ sampleCount: 1,
285
+ },
286
+ firstUse: 1,
287
+ lastUse: 2,
288
+ derivedUsage: 8,
289
+ format: 'rgba16float',
290
+ extent: { width: 960, height: 540, depthOrArrayLayers: 1 },
291
+ byteSize: 960 * 540 * 8,
292
+ },
293
+ {
294
+ label: 'volume-history',
295
+ kind: 'texture',
296
+ origin: 'created',
297
+ descriptor: {
298
+ kind: 'texture',
299
+ format: 'rgba16float',
300
+ size: { width: 960, height: 540, depthOrArrayLayers: 1 },
301
+ width: 960,
302
+ height: 540,
303
+ depthOrArrayLayers: 1,
304
+ mipLevelCount: 1,
305
+ sampleCount: 1,
306
+ },
307
+ firstUse: 1,
308
+ lastUse: 3,
309
+ derivedUsage: 8,
310
+ format: 'rgba16float',
311
+ extent: { width: 960, height: 540, depthOrArrayLayers: 1 },
312
+ byteSize: 960 * 540 * 8,
313
+ },
314
+ {
315
+ label: 'volume-temporal',
316
+ kind: 'texture',
317
+ origin: 'created',
318
+ descriptor: {
319
+ kind: 'texture',
320
+ format: 'rgba16float',
321
+ size: { width: 960, height: 540, depthOrArrayLayers: 1 },
322
+ width: 960,
323
+ height: 540,
324
+ depthOrArrayLayers: 1,
325
+ mipLevelCount: 1,
326
+ sampleCount: 1,
327
+ },
328
+ firstUse: 1,
329
+ lastUse: 3,
330
+ derivedUsage: 8,
331
+ format: 'rgba16float',
332
+ extent: { width: 960, height: 540, depthOrArrayLayers: 1 },
333
+ byteSize: 960 * 540 * 8,
334
+ },
335
+ {
336
+ label: 'volume-params',
337
+ kind: 'buffer',
338
+ origin: 'imported',
339
+ descriptor: { kind: 'buffer', size: 128 },
340
+ firstUse: 0,
341
+ lastUse: 3,
342
+ derivedUsage: 64,
343
+ byteSize: 128,
344
+ },
345
+ ],
346
+ } as const;
347
+ const facts = inspectVolumetricFogResources(graph);
348
+ expect(facts.generation).toBe(17);
349
+ expect(facts.sampleCount).toBe(960 * 540 * 64);
350
+ expect(facts.resolvedPixelCount).toBe(960 * 540);
351
+ expect(facts.currentBytes).toBe(960 * 540 * 8);
352
+ expect(facts.bufferBytes).toBe(128);
353
+ expect(facts.liveResourceCount).toBe(5);
354
+ expect(facts.historyBytes).toBe(960 * 540 * 8 * 2);
355
+ expect(facts.physicalResourceCount).toBe(5);
356
+ expect(facts.totalBytes).toBe(960 * 540 * 16 * 4 + 960 * 540 * 8 * 3 + 128);
357
+ expect(facts.totalBytes).toBe(
358
+ facts.currentBytes + facts.historyBytes + facts.scratchBytes + facts.bufferBytes,
359
+ );
360
+ expect(facts.totalBytes).toBeLessThanOrEqual(MAX_MEMORY_BYTES);
361
+
362
+ const doubleBuffered = inspectVolumetricFogResources(graph, { parameterBufferCount: 2 });
363
+ expect(doubleBuffered.bufferBytes).toBe(256);
364
+ expect(doubleBuffered.liveResourceCount).toBe(6);
365
+ expect(doubleBuffered.physicalResourceCount).toBe(6);
366
+ expect(doubleBuffered.totalBytes).toBe(960 * 540 * 16 * 4 + 960 * 540 * 8 * 3 + 256);
367
+ expect(doubleBuffered.totalBytes).toBe(
368
+ doubleBuffered.currentBytes +
369
+ doubleBuffered.historyBytes +
370
+ doubleBuffered.scratchBytes +
371
+ doubleBuffered.bufferBytes,
372
+ );
373
+ });
374
+
375
+ it('derives a same-XY resolve descriptor when the packed raw extent is supplied alone', async () => {
376
+ const adapter = await nullRhi.requestAdapter();
377
+ expect(adapter.ok).toBe(true);
378
+ if (!adapter.ok) return;
379
+ const createdDevice = await adapter.value.requestDevice();
380
+ expect(createdDevice.ok).toBe(true);
381
+ if (!createdDevice.ok) return;
382
+ const graph = new RenderGraphBuilder<RenderPipelineFrame>();
383
+ const resources = createVolumetricFogResources(graph, { width: 960, height: 540, depth: 64 });
384
+ expect(resources.ok).toBe(true);
385
+ if (!resources.ok) return;
386
+ const compiled = graph.compile({
387
+ device: createdDevice.value,
388
+ surfaceSize: { width: 1920, height: 1080 },
389
+ });
390
+ expect(compiled.ok).toBe(true);
391
+ if (!compiled.ok) return;
392
+ const resolved = compiled.value
393
+ .inspect()
394
+ .resources.find((resource) => resource.label === 'volume-resolved-current');
395
+ expect(resolved?.extent).toEqual({ width: 960, height: 540, depthOrArrayLayers: 1 });
396
+ });
397
+
398
+ it('keeps one real Renderer volume generation through 20 resizes, 3 recoveries, and 300 frames', async () => {
399
+ const adapter = new RecoverableNullAdapter();
400
+ const backend = {
401
+ ...nullRhi,
402
+ requestAdapter: async () => ok(adapter),
403
+ };
404
+ const canvas = surfaceCanvas();
405
+ const created = await constructRendererHost(
406
+ canvas,
407
+ { rhi: backend },
408
+ { shaderManifestUrl: VOLUME_MANIFEST },
409
+ );
410
+ expect(created.ok).toBe(true);
411
+ if (!created.ok) return;
412
+ const { renderer } = created.value;
413
+ const world = new World();
414
+ const density = world.allocSharedRef('TextureAsset', densityAsset());
415
+ world
416
+ .spawn(
417
+ { component: Transform, data: { pos: [0, 0, 3] } },
418
+ {
419
+ component: Camera,
420
+ data: {
421
+ ...perspective({ fov: Math.PI / 4, aspect: 16 / 9 }),
422
+ tonemap: TONEMAP_ACES_FILMIC,
423
+ },
424
+ },
425
+ )
426
+ .unwrap();
427
+ const directionalLight = world
428
+ .spawn({
429
+ component: DirectionalLight,
430
+ data: { direction: [-0.4, -0.8, -0.3], castShadow: true },
431
+ })
432
+ .unwrap();
433
+ world
434
+ .spawn({
435
+ component: VolumetricFog,
436
+ data: {
437
+ light: directionalLight,
438
+ density,
439
+ boundsMin: [-1, -1, -1],
440
+ boundsMax: [1, 1, 1],
441
+ extinction: [0.2, 0.2, 0.2],
442
+ albedo: [0.8, 0.8, 0.8],
443
+ emission: [0, 0, 0],
444
+ anisotropy: 0,
445
+ maxDistance: 50,
446
+ },
447
+ })
448
+ .unwrap();
449
+ expect(world.update(1 / 60).ok).toBe(true);
450
+
451
+ const lease = renderer.attach(world);
452
+ expect(lease.ok).toBe(true);
453
+ if (!lease.ok) return;
454
+ const request = {
455
+ leases: [lease.value],
456
+ camera: { lease: lease.value },
457
+ environment: { lease: lease.value },
458
+ } as const;
459
+ const draw = async () => {
460
+ const frame = renderer.draw(request);
461
+ expect(frame.ok).toBe(true);
462
+ if (!frame.ok) return undefined;
463
+ expect((await frame.value.completed).ok).toBe(true);
464
+ return renderer.inspect();
465
+ };
466
+
467
+ const initial = await draw();
468
+ expect(initial?.volumetricFog).toMatchObject({
469
+ status: 'available',
470
+ resourceStage: 'accepted',
471
+ passCount: 4,
472
+ });
473
+ const initialFacts = initial?.volumetricFog?.resourceFacts;
474
+ expect(initialFacts).toBeDefined();
475
+ if (initialFacts === undefined) return;
476
+ const warmup = await draw();
477
+ expect(warmup?.volumetricFog).toMatchObject({ status: 'available', passCount: 4 });
478
+ const warmedFacts = warmup?.volumetricFog?.resourceFacts;
479
+ expect(warmedFacts).toBeDefined();
480
+ if (warmedFacts === undefined) return;
481
+ const compositeDescriptors = adapter.devices[0]?.renderPipelineDescriptors.filter(
482
+ (descriptor) => descriptor.label === 'volume_composite',
483
+ );
484
+ expect(compositeDescriptors?.length).toBeGreaterThan(0);
485
+ expect(
486
+ compositeDescriptors?.every(
487
+ (descriptor) => descriptor.fragment?.targets[0]?.format === 'rgba16float',
488
+ ),
489
+ ).toBe(true);
490
+ expect(
491
+ adapter.devices[0]?.bindGroupLayoutDescriptors.find(
492
+ (descriptor) => descriptor.label === 'volume_composite.bind-group-layout',
493
+ )?.entries,
494
+ ).toEqual([
495
+ {
496
+ binding: 0,
497
+ visibility: 3,
498
+ texture: { sampleType: 'float', viewDimension: '2d' },
499
+ },
500
+ { binding: 1, visibility: 3, sampler: { type: 'filtering' } },
501
+ { binding: 2, visibility: 3, texture: { sampleType: 'depth', viewDimension: '2d' } },
502
+ { binding: 3, visibility: 3, buffer: { type: 'uniform' } },
503
+ ]);
504
+ expect(
505
+ adapter.devices[0]?.samplerDescriptors.find(
506
+ (descriptor) => descriptor.label === 'volume_composite.sampler',
507
+ ),
508
+ ).toMatchObject({ minFilter: 'linear', magFilter: 'linear' });
509
+ expect(
510
+ adapter.devices[0]?.samplerDescriptors.find(
511
+ (descriptor) => descriptor.label === 'volume_inject.density-sampler',
512
+ ),
513
+ ).toBeUndefined();
514
+ const initialLedger = snapshotLedger(adapter.devices[0] as RecoverableNullDevice);
515
+ expect(initialFacts.totalBytes).toBeLessThanOrEqual(MAX_MEMORY_BYTES);
516
+ expect(initialFacts.liveResourceCount).toBe(5);
517
+ expect(initialFacts.physicalResourceCount).toBe(5);
518
+ expect(warmedFacts.liveResourceCount).toBe(6);
519
+ expect(warmedFacts.physicalResourceCount).toBe(6);
520
+ expect(snapshotLedger(adapter.devices[0] as RecoverableNullDevice).liveTextures).toBe(
521
+ initialLedger.liveTextures,
522
+ );
523
+ expect(snapshotLedger(adapter.devices[0] as RecoverableNullDevice).liveBuffers).toBe(
524
+ initialLedger.liveBuffers,
525
+ );
526
+ const resizeFacts: Array<{
527
+ readonly deviceGeneration: number;
528
+ readonly volumeGeneration: number | undefined;
529
+ readonly graphGeneration: number;
530
+ readonly liveResourceCount: number;
531
+ readonly totalBytes: number;
532
+ readonly physicalResourceCount: number;
533
+ }> = [];
534
+ for (let index = 0; index < 20; index += 1) {
535
+ canvas.width = 1921 + index;
536
+ canvas.height = 1081 + index;
537
+ const inspection = await draw();
538
+ const facts = inspection?.volumetricFog?.resourceFacts;
539
+ expect(facts).toBeDefined();
540
+ if (facts === undefined || inspection === undefined) return;
541
+ resizeFacts.push({
542
+ deviceGeneration: inspection.frame.deviceGeneration,
543
+ volumeGeneration: inspection.volumetricFog?.generation,
544
+ graphGeneration: facts.generation,
545
+ liveResourceCount: facts.liveResourceCount,
546
+ totalBytes: facts.totalBytes,
547
+ physicalResourceCount: facts.physicalResourceCount,
548
+ });
549
+ expect(inspection.volumetricFog).toMatchObject({ status: 'available', passCount: 4 });
550
+ expect(facts.liveResourceCount).toBe(6);
551
+ expect(facts.physicalResourceCount).toBe(6);
552
+ expect(facts.totalBytes).toBeLessThanOrEqual(MAX_MEMORY_BYTES);
553
+ const ledger = snapshotLedger(adapter.devices[0] as RecoverableNullDevice);
554
+ expect(ledger.liveTextures).toBe(initialLedger.liveTextures);
555
+ expect(ledger.liveBuffers).toBe(initialLedger.liveBuffers);
556
+ }
557
+
558
+ const recoveryGenerations: number[] = [];
559
+ const recoveryLedgers: LedgerSnapshot[] = [];
560
+ for (let index = 0; index < 3; index += 1) {
561
+ const device = adapter.devices.at(-1);
562
+ expect(device).toBeDefined();
563
+ if (device === undefined) return;
564
+ device.lose();
565
+ await new Promise((resolve) => setTimeout(resolve, 0));
566
+ expect(renderer.state()).toBe('device-lost');
567
+ expect(renderer.draw(request).ok).toBe(false);
568
+ const recovered = await renderer.recover();
569
+ expect(recovered.ok).toBe(true);
570
+ expect(renderer.state()).toBe('alive');
571
+ const firstAfterRecovery = await draw();
572
+ expect(firstAfterRecovery?.volumetricFog).toMatchObject({
573
+ status: 'available',
574
+ passCount: 4,
575
+ });
576
+ const firstFacts = firstAfterRecovery?.volumetricFog?.resourceFacts;
577
+ expect(firstFacts).toBeDefined();
578
+ if (firstFacts === undefined || firstAfterRecovery === undefined) return;
579
+ expect(firstFacts.liveResourceCount).toBe(5);
580
+ expect(firstFacts.physicalResourceCount).toBe(5);
581
+ expect(firstFacts.totalBytes).toBeLessThanOrEqual(MAX_MEMORY_BYTES);
582
+ const inspection = await draw();
583
+ expect(inspection?.volumetricFog).toMatchObject({ status: 'available', passCount: 4 });
584
+ const facts = inspection?.volumetricFog?.resourceFacts;
585
+ expect(facts).toBeDefined();
586
+ if (facts === undefined || inspection === undefined) return;
587
+ recoveryGenerations.push(inspection.frame.deviceGeneration);
588
+ expect(facts.liveResourceCount).toBe(6);
589
+ expect(facts.physicalResourceCount).toBe(6);
590
+ expect(facts.totalBytes).toBeLessThanOrEqual(MAX_MEMORY_BYTES);
591
+ recoveryLedgers.push(snapshotLedger(adapter.devices.at(-1) as RecoverableNullDevice));
592
+ }
593
+ expect(adapter.devices).toHaveLength(4);
594
+ expect(new Set(recoveryGenerations).size).toBe(3);
595
+ expect(recoveryGenerations[2]).toBeGreaterThan(recoveryGenerations[0] ?? -1);
596
+
597
+ const steadyFacts: Array<{
598
+ readonly deviceGeneration: number;
599
+ readonly volumeGeneration: number | undefined;
600
+ readonly graphGeneration: number;
601
+ readonly liveResourceCount: number;
602
+ readonly totalBytes: number;
603
+ readonly physicalResourceCount: number;
604
+ }> = [];
605
+ for (let frame = 0; frame < 300; frame += 1) {
606
+ const inspection = await draw();
607
+ const facts = inspection?.volumetricFog?.resourceFacts;
608
+ expect(facts).toBeDefined();
609
+ if (facts === undefined || inspection === undefined) return;
610
+ steadyFacts.push({
611
+ deviceGeneration: inspection.frame.deviceGeneration,
612
+ volumeGeneration: inspection.volumetricFog?.generation,
613
+ graphGeneration: facts.generation,
614
+ liveResourceCount: facts.liveResourceCount,
615
+ totalBytes: facts.totalBytes,
616
+ physicalResourceCount: facts.physicalResourceCount,
617
+ });
618
+ }
619
+ const steadyBaseline = steadyFacts[0];
620
+ expect(steadyBaseline).toBeDefined();
621
+ if (steadyBaseline === undefined) return;
622
+ for (const facts of steadyFacts) expect(facts).toEqual(steadyBaseline);
623
+ expect(new Set(resizeFacts.map((facts) => facts.liveResourceCount)).size).toBe(1);
624
+ expect(new Set(resizeFacts.map((facts) => facts.physicalResourceCount)).size).toBe(1);
625
+ expect(new Set(resizeFacts.map((facts) => facts.deviceGeneration)).size).toBe(1);
626
+ expect(new Set(resizeFacts.map((facts) => facts.volumeGeneration)).size).toBe(1);
627
+ expect(new Set(resizeFacts.map((facts) => facts.graphGeneration)).size).toBe(20);
628
+ expect(steadyBaseline.liveResourceCount).toBe(6);
629
+ expect(steadyBaseline.physicalResourceCount).toBe(6);
630
+ expect(steadyBaseline.totalBytes).toBeLessThanOrEqual(MAX_MEMORY_BYTES);
631
+ const steadyLedger = snapshotLedger(adapter.devices.at(-1) as RecoverableNullDevice);
632
+ expect(steadyLedger.liveTextures).toBeGreaterThan(0);
633
+ expect(steadyLedger.liveBuffers).toBeGreaterThan(0);
634
+ for (const ledger of recoveryLedgers) {
635
+ expect(ledger.liveTextures).toBe(steadyLedger.liveTextures);
636
+ expect(ledger.liveBuffers).toBe(steadyLedger.liveBuffers);
637
+ }
638
+ await persistAc22Receipt({
639
+ schemaVersion: '1',
640
+ head: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(),
641
+ runnerClass: 'null-structural',
642
+ source: 'renderer.inspect+RhiNull.bookkeeper',
643
+ resizeCount: resizeFacts.length,
644
+ resizeFacts,
645
+ recoveryCount: recoveryGenerations.length,
646
+ recoveryGenerations,
647
+ steadyFrameCount: steadyFacts.length,
648
+ steadyFirst: steadyFacts[0],
649
+ steadyLast: steadyFacts.at(-1),
650
+ resourceBudgetBytes: MAX_MEMORY_BYTES,
651
+ activeLedger: steadyLedger,
652
+ });
653
+ await renderer.dispose();
654
+ });
655
+ });