@forgeax/engine-runtime 0.1.27 → 0.1.29

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 (114) hide show
  1. package/README.md +98 -0
  2. package/dist/collect-scene-asset.d.ts.map +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.mjs +480 -15
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/scene-utils/mount-override-fold.d.ts +3 -1
  8. package/dist/scene-utils/mount-override-fold.d.ts.map +1 -1
  9. package/package.json +37 -30
  10. package/src/__tests__/animation-graph-collect-roundtrip.test.ts +4 -2
  11. package/src/__tests__/asset-registry-aabb.cases.unit.test.ts +5 -4
  12. package/src/__tests__/asset-registry-guid-reverse.test.ts +22 -27
  13. package/src/__tests__/asset-registry-mounts.test.ts +174 -734
  14. package/src/__tests__/asset-registry-scene.cases.unit.test.ts +9 -18
  15. package/src/__tests__/asset-registry.recursive.spec.ts +23 -29
  16. package/src/__tests__/camera-ortho.cases.unit.test.ts +12 -3
  17. package/src/__tests__/collect-scene-asset.test.ts +96 -113
  18. package/src/__tests__/collect-scene-mount-collapse.test.ts +64 -66
  19. package/src/__tests__/collect-transient-children-absent.test.ts +11 -17
  20. package/src/__tests__/collect-transient-roundtrip.test.ts +12 -13
  21. package/src/__tests__/collect-transient-skip.test.ts +20 -27
  22. package/src/__tests__/components.test-d.ts +17 -1
  23. package/src/__tests__/components.unit.test.ts +75 -27
  24. package/src/__tests__/composite-skybox-cross-world.browser.test.ts +4 -2
  25. package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +8 -4
  26. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +4 -1
  27. package/src/__tests__/dawn/instances-uniform-fallback.dawn.test.ts +66 -92
  28. package/src/__tests__/dawn/material-cooked-fixture.dawn.test.ts +200 -4
  29. package/src/__tests__/errors.unit.test.ts +13 -0
  30. package/src/__tests__/feature-depth-material.dawn.test.ts +23 -0
  31. package/src/__tests__/feature-depth-material.fixture.ts +473 -0
  32. package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +14 -14
  33. package/src/__tests__/geometry.unit.test.ts +42 -43
  34. package/src/__tests__/gpu-resource-release.cases.unit.test.ts +16 -9
  35. package/src/__tests__/handle-migration.test.ts +0 -1
  36. package/src/__tests__/instances-renderer-lifecycle.integration.test.ts +20 -14
  37. package/src/__tests__/instances.test-d.ts +13 -52
  38. package/src/__tests__/instantiate-mount-deferred-wiring.test.ts +42 -80
  39. package/src/__tests__/lazy-catalog.cases.unit.test.ts +17 -3
  40. package/src/__tests__/loader-registry.cases.unit.test.ts +4 -4
  41. package/src/__tests__/material-publication.browser.test.ts +29 -0
  42. package/src/__tests__/material-publication.commands.ts +23 -0
  43. package/src/__tests__/material-publication.dawn.test.ts +24 -0
  44. package/src/__tests__/material-publication.fixture.ts +349 -0
  45. package/src/__tests__/material-publication.server.ts +109 -0
  46. package/src/__tests__/parse-scene-payload-refs.cases.unit.test.ts +73 -96
  47. package/src/__tests__/pbr-pipeline.unit.test.ts +25 -11
  48. package/src/__tests__/point-light-shadow.browser.test.ts +8 -2
  49. package/src/__tests__/point-light-shadow.unit.test.ts +37 -8
  50. package/src/__tests__/record-all-topology.cases.unit.test.ts +52 -0
  51. package/src/__tests__/record-draw-branch.cases.unit.test.ts +1 -0
  52. package/src/__tests__/record-worldid-isolation.test.ts +47 -28
  53. package/src/__tests__/render-error-exhaustive.test-d.ts +24 -0
  54. package/src/__tests__/render-system-extract-dispatch-ordering.test.ts +15 -4
  55. package/src/__tests__/render-system-extract.test.ts +61 -0
  56. package/src/__tests__/render-system-extract.unit.test.ts +21 -7
  57. package/src/__tests__/render-system-fold-bucket.unit.test.ts +33 -1
  58. package/src/__tests__/render-system-fold-material.unit.test.ts +32 -6
  59. package/src/__tests__/render-system-fold-modegate.unit.test.ts +26 -4
  60. package/src/__tests__/render-system-fold.dawn.test.ts +4 -1
  61. package/src/__tests__/render-system-mega.test.ts +50 -62
  62. package/src/__tests__/renderer-device-loss-provider-feasibility.dawn.test.ts +0 -1
  63. package/src/__tests__/renderer-device-loss-recovery.dawn.test.ts +0 -3
  64. package/src/__tests__/renderer-draw-world.cases.unit.test.ts +16 -513
  65. package/src/__tests__/resolve-scene-guids.cases.unit.test.ts +48 -43
  66. package/src/__tests__/rhi-null-command-flow.unit.test.ts +9 -7
  67. package/src/__tests__/roots-to-scene-asset.test.ts +27 -28
  68. package/src/__tests__/roundtrip-primitive.test.ts +24 -23
  69. package/src/__tests__/scene-equirect-roundtrip.test.ts +5 -16
  70. package/src/__tests__/scene-instantiate-cycle-runtime.test.ts +13 -29
  71. package/src/__tests__/scene-mount-fixed-point.test.ts +104 -227
  72. package/src/__tests__/scene-mount-roundtrip.test.ts +95 -961
  73. package/src/__tests__/scene-mount-wrapper-roundtrip.test.ts +36 -182
  74. package/src/__tests__/scene-sugar-instantiate.test.ts +20 -15
  75. package/src/__tests__/serialize-mounts-roundtrip.test.ts +108 -492
  76. package/src/__tests__/serialize-scene-asset.test.ts +36 -50
  77. package/src/__tests__/shadow-caster-empty-schema.test.ts +3 -1
  78. package/src/__tests__/shadow-fields-observable.dawn.test.ts +34 -19
  79. package/src/__tests__/shadow-mesh-ssbo-order.test.ts +5 -12
  80. package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +6 -4
  81. package/src/__tests__/skin-m2-extract-happy.cases.unit.test.ts +50 -2
  82. package/src/__tests__/skin-m2-extract-joint-error.cases.unit.test.ts +50 -2
  83. package/src/__tests__/skin-m2-extract-null-assets.cases.unit.test.ts +50 -2
  84. package/src/__tests__/skin-m2-extract-skeleton-error.cases.unit.test.ts +50 -2
  85. package/src/__tests__/skin-palette-extract.cases.unit.test.ts +2 -0
  86. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +93 -48
  87. package/src/__tests__/skylight-pipeline-layout.cases.unit.test.ts +6 -2
  88. package/src/__tests__/sparse-tag-scene-roundtrip.test.ts +1 -1
  89. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -3
  90. package/src/__tests__/template-game-default-api-contract.test.ts +21 -21
  91. package/src/__tests__/tonemap-hdr-target.cases.unit.test.ts +8 -5
  92. package/src/__tests__/tonemap-pipeline-split.cases.unit.test.ts +3 -0
  93. package/src/__tests__/transient-fields-collect.test.ts +4 -4
  94. package/src/__tests__/transient-runtime-behavior.test.ts +9 -9
  95. package/src/__tests__/vfx-depth-material.dawn.test.ts +42 -0
  96. package/src/__tests__/vfx-mesh-lighting.browser.test.ts +16 -0
  97. package/src/__tests__/vfx-mesh-lighting.commands.ts +14 -0
  98. package/src/__tests__/vfx-mesh-lighting.dawn.test.ts +16 -0
  99. package/src/__tests__/vfx-mesh-lighting.fixture.ts +883 -0
  100. package/src/__tests__/wave1-dynamic-geometry.browser.test.ts +326 -0
  101. package/src/__tests__/wave1-rendering-materials.browser.test.ts +617 -0
  102. package/src/__tests__/wave1-rendering-p0.browser.test.ts +751 -0
  103. package/src/__tests__/wave1-rendering-recovery.browser.test.ts +666 -0
  104. package/src/__tests__/wave1-shadow-diagnostic.browser.test.ts +582 -0
  105. package/src/__tests__/world-scene-despawn.test.ts +8 -8
  106. package/src/__tests__/world-scene-instantiate.test.ts +23 -29
  107. package/src/__tests__/world-scene-nested.test.ts +90 -163
  108. package/src/__tests__/world-scene-override.test.ts +17 -16
  109. package/src/__tests__/zero-compression-zero-load.integration.test.ts +1 -0
  110. package/src/collect-scene-asset.ts +637 -19
  111. package/src/components/__tests__/sprite-animation.test-d.ts +2 -2
  112. package/src/index.ts +1 -0
  113. package/src/scene-utils/mount-override-fold.ts +5 -14
  114. package/src/__tests__/node-shims.d.ts +0 -31
@@ -0,0 +1,883 @@
1
+ import { World } from '@forgeax/engine-ecs';
2
+ import { createBoxGeometry, createPlaneGeometry } from '@forgeax/engine-geometry';
3
+ import { AssetGuid } from '@forgeax/engine-pack';
4
+ import {
5
+ Camera,
6
+ DirectionalLight,
7
+ MeshFilter,
8
+ MeshRenderer,
9
+ PointLight,
10
+ PointLightShadow,
11
+ Skylight,
12
+ SpotLight,
13
+ } from '@forgeax/engine-render';
14
+ import { attachRecorder, buildFrameModel, decodeTape, openReplay } from '@forgeax/engine-rhi-debug';
15
+ import { Transform } from '@forgeax/engine-scene';
16
+ import { createBuiltinMaterialAsset } from '@forgeax/engine-shader';
17
+ import { ok, type ParticleEffectAsset, type Result } from '@forgeax/engine-types';
18
+ import { ParticleEffectPlayer } from '@forgeax/engine-vfx';
19
+ import type { ParticleCodeModuleSet } from '@forgeax/engine-vfx-compiler';
20
+ import { createVfxRuntimeHost } from '@forgeax/engine-vfx-render';
21
+ import { expect } from 'vitest';
22
+ import { loadBackendPack } from '../backend-selection';
23
+ import { constructRuntimeRendererHost } from '../renderer-host';
24
+
25
+ export type CookMeshLightingFixture = (
26
+ source: unknown,
27
+ modules: Readonly<Record<string, ParticleCodeModuleSet>>,
28
+ ) => Promise<Result<{ readonly asset: ParticleEffectAsset }, unknown>>;
29
+
30
+ function inspect(value: unknown): string {
31
+ return JSON.stringify(value, (_key, item) =>
32
+ item instanceof Error ? { ...item, message: item.message } : item,
33
+ );
34
+ }
35
+
36
+ /** One real-device acceptance path shared by native Dawn and browser WebGPU. */
37
+ export async function verifyVfxMeshLighting(options: {
38
+ readonly shaderManifestUrl: string;
39
+ readonly cook: CookMeshLightingFixture;
40
+ readonly tapePaths?: Readonly<Record<string, string | undefined>>;
41
+ readonly saveTape?: (path: string, bytes: Uint8Array) => void;
42
+ }): Promise<void> {
43
+ const env = options.tapePaths ?? {};
44
+ const cookParticleCodeEffect = options.cook;
45
+ const world = new World();
46
+ const focal = 1 / Math.tan(Math.PI / 8);
47
+ const viewProjection = new Float32Array([
48
+ focal,
49
+ 0,
50
+ 0,
51
+ 0,
52
+ 0,
53
+ focal,
54
+ 0,
55
+ 0,
56
+ 0,
57
+ 0,
58
+ -10 / 9.9,
59
+ -1,
60
+ 0,
61
+ 0,
62
+ 29 / 9.9,
63
+ 3,
64
+ ]);
65
+ const vfx = createVfxRuntimeHost({
66
+ camera: {
67
+ read: () => ({
68
+ position: new Float32Array([0, 0, 3]),
69
+ right: new Float32Array([1, 0, 0]),
70
+ up: new Float32Array([0, 1, 0]),
71
+ viewProjection,
72
+ }),
73
+ },
74
+ });
75
+ let device: GPUDevice | undefined;
76
+ let target: GPUTexture | undefined;
77
+ const errors: unknown[] = [];
78
+ const pack = await loadBackendPack({});
79
+ if (!pack.ok) throw pack.error;
80
+ if (pack.value.createShaderModule === undefined) throw new Error('No shader factory');
81
+ const recording = attachRecorder({
82
+ ...pack.value,
83
+ createShaderModule: pack.value.createShaderModule,
84
+ });
85
+ if (!recording.ok) throw recording.error;
86
+ const recorder = recording.value;
87
+ const capturedWork: unknown[] = [];
88
+ const canvas = {
89
+ width: 64,
90
+ height: 64,
91
+ getContext: () => ({
92
+ configure(descriptor: GPUCanvasConfiguration) {
93
+ device = descriptor.device;
94
+ device.addEventListener('uncapturederror', (event) => errors.push(event.error.message));
95
+ target = device.createTexture({
96
+ size: [64, 64],
97
+ format: descriptor.format,
98
+ viewFormats: [descriptor.format === 'rgba8unorm' ? 'rgba8unorm-srgb' : 'bgra8unorm-srgb'],
99
+ usage: 0x10 | 0x01,
100
+ });
101
+ },
102
+ unconfigure() {},
103
+ getCurrentTexture: () => target,
104
+ }),
105
+ addEventListener() {},
106
+ removeEventListener() {},
107
+ };
108
+ const constructed = await constructRuntimeRendererHost(
109
+ canvas,
110
+ {
111
+ features: [vfx.feature],
112
+ rhi: recorder.backend.rhi,
113
+ rhiInstrumentation: {
114
+ resolveSurfaceDevice(device) {
115
+ const unwrapped = recorder.backend.unwrapDeviceForSurface(device);
116
+ if (!unwrapped.ok) throw unwrapped.error;
117
+ return ok(unwrapped.value);
118
+ },
119
+ },
120
+ },
121
+ {
122
+ shaderManifestUrl: options.shaderManifestUrl,
123
+ },
124
+ );
125
+ if (!constructed.ok) throw constructed.error;
126
+ const { renderer, assets } = constructed.value;
127
+ renderer.subscribe((event) => {
128
+ if (event.kind === 'error') errors.push(event.error);
129
+ });
130
+ let vfxAttached = false;
131
+ try {
132
+ world
133
+ .spawn(
134
+ { component: Transform, data: { pos: [0, 0, 3], quat: [0, 0, 0, 1], scale: [1, 1, 1] } },
135
+ { component: Camera, data: { fov: Math.PI / 4, aspect: 1, near: 0.1, far: 10 } },
136
+ )
137
+ .unwrap();
138
+ const light = world
139
+ .spawn({
140
+ component: DirectionalLight,
141
+ data: {
142
+ direction: [-1, 0, -1],
143
+ color: [1, 1, 1],
144
+ intensity: 3,
145
+ castShadow: true,
146
+ cascadeCount: 1,
147
+ mapSize: 512,
148
+ shadowDistance: 6,
149
+ depthBias: 0.001,
150
+ normalBias: 0.001,
151
+ },
152
+ })
153
+ .unwrap();
154
+ const material = createBuiltinMaterialAsset('standard');
155
+ const mesh = createBoxGeometry(0.5, 0.5, 0.5).unwrap();
156
+ const materialGuid = AssetGuid.random();
157
+ const meshGuid = AssetGuid.random();
158
+ const materialAdded = assets.catalog(materialGuid, {
159
+ ...material,
160
+ values: { ...material.values, baseColor: [0.3, 0.3, 0.3, 1], metallic: 0, roughness: 1 },
161
+ });
162
+ if (!materialAdded.ok) throw materialAdded.error;
163
+ const meshAdded = assets.catalog(meshGuid, mesh);
164
+ if (!meshAdded.ok) throw meshAdded.error;
165
+ const attached = renderer.attach(world);
166
+ if (!attached.ok) throw attached.error;
167
+ const lease = attached.value;
168
+ const attachedVfx = await vfx.attachWorld({ world, assets });
169
+ if (!attachedVfx.ok) throw attachedVfx.error;
170
+ vfxAttached = true;
171
+ const frame = { leases: [lease], camera: { lease }, environment: { lease } };
172
+ const pixel = async () => {
173
+ if (device === undefined || target === undefined) throw new Error('No GPU surface');
174
+ const buffer = device.createBuffer({ size: 256, usage: 0x01 | 0x08 });
175
+ try {
176
+ const encoder = device.createCommandEncoder();
177
+ encoder.copyTextureToBuffer(
178
+ { texture: target, origin: [32, 32] },
179
+ { buffer, bytesPerRow: 256 },
180
+ [1, 1],
181
+ );
182
+ device.queue.submit([encoder.finish()]);
183
+ await buffer.mapAsync(1);
184
+ const result = [...new Uint8Array(buffer.getMappedRange()).slice(0, 4)];
185
+ buffer.unmap();
186
+ return result;
187
+ } finally {
188
+ buffer.destroy();
189
+ }
190
+ };
191
+ let pointShadowCaptureStage = 0;
192
+ const sample = async (
193
+ lighting: 'standard' | 'unlit',
194
+ receiveShadows: boolean,
195
+ intensity: number,
196
+ caster?: { readonly enabled: boolean; readonly position: readonly [number, number, number] },
197
+ surface?: {
198
+ readonly ordinary?: boolean;
199
+ readonly roughness: number;
200
+ readonly metallic: number;
201
+ },
202
+ ) => {
203
+ world.set(light, DirectionalLight, { intensity }).unwrap();
204
+ const surfaceMaterial = {
205
+ ...material,
206
+ values: {
207
+ ...material.values,
208
+ baseColor: [0.3, 0.3, 0.3, 1],
209
+ metallic: surface?.metallic ?? 0,
210
+ roughness: surface?.roughness ?? 1,
211
+ },
212
+ };
213
+ const surfaceGuid = surface === undefined ? materialGuid : AssetGuid.random();
214
+ if (surface !== undefined) {
215
+ const added = assets.catalog(surfaceGuid, surfaceMaterial);
216
+ if (!added.ok) throw added.error;
217
+ }
218
+ const cooked = await cookParticleCodeEffect(
219
+ {
220
+ schemaVersion: 3,
221
+ emitters: [
222
+ // Its ordinary raster precedes the off-camera caster in authored
223
+ // order. It must not delay the independent caster's GPU projection.
224
+ ...(caster?.enabled
225
+ ? [
226
+ {
227
+ id: 'visible-prefix',
228
+ capacity: 1,
229
+ backend: { required: 'gpu' as const },
230
+ space: 'world' as const,
231
+ bounds: { kind: 'sphere' as const, center: [0.7, 0, 0], radius: 0.5 },
232
+ schedule: { rate: 0, bursts: [{ time: 0, count: 1 }] },
233
+ program: { module: 'visible.wgsl' },
234
+ renderers: [
235
+ {
236
+ kind: 'mesh' as const,
237
+ mesh: AssetGuid.format(meshGuid),
238
+ material: AssetGuid.format(materialGuid),
239
+ lighting: 'unlit' as const,
240
+ castShadows: false,
241
+ },
242
+ ],
243
+ },
244
+ ]
245
+ : []),
246
+ {
247
+ id: 'mesh',
248
+ capacity: 1,
249
+ backend: { required: 'gpu' },
250
+ space: 'world',
251
+ bounds: {
252
+ kind: 'sphere',
253
+ center: caster?.position ?? [0, 0, 0],
254
+ radius: caster === undefined ? 4 : 0.5,
255
+ },
256
+ schedule: { rate: 0, bursts: [{ time: 0, count: 1 }] },
257
+ program: { module: 'mesh.wgsl' },
258
+ renderers: [
259
+ {
260
+ kind: 'mesh',
261
+ mesh: AssetGuid.format(meshGuid),
262
+ material: AssetGuid.format(surfaceGuid),
263
+ lighting,
264
+ receiveShadows,
265
+ castShadows: caster?.enabled ?? false,
266
+ },
267
+ ],
268
+ },
269
+ ],
270
+ },
271
+ {
272
+ 'visible.wgsl': {
273
+ entry: `
274
+ #import forgeax_vfx::prelude::{VfxParticle, VfxSpawnContext, VfxUpdateContext}
275
+ fn vfx_spawn(ctx: VfxSpawnContext, particle: ptr<function, VfxParticle>) {
276
+ (*particle).position = vec3<f32>(0.7, 0.0, 0.0);
277
+ (*particle).color = vec4<f32>(1.0);
278
+ (*particle).mesh_orientation = vec4<f32>(0.0, 0.0, 0.0, 1.0);
279
+ (*particle).mesh_scale = vec3<f32>(0.25);
280
+ (*particle).lifetime = 100.0;
281
+ }
282
+ fn vfx_update(ctx: VfxUpdateContext, particle: ptr<function, VfxParticle>) {}`,
283
+ },
284
+ 'mesh.wgsl': {
285
+ entry: `
286
+ #import forgeax_vfx::prelude::{VfxParticle, VfxSpawnContext, VfxUpdateContext}
287
+ fn vfx_spawn(ctx: VfxSpawnContext, particle: ptr<function, VfxParticle>) {
288
+ (*particle).position = vec3<f32>(${(caster?.position ?? [0, 0, 0]).map((value) => `${value.toFixed(2)}`).join(', ')});
289
+ (*particle).color = vec4<f32>(1.0);
290
+ (*particle).mesh_orientation = vec4<f32>(0.0, 0.0, 0.0, 1.0);
291
+ (*particle).mesh_scale = vec3<f32>(1.0);
292
+ (*particle).lifetime = 100.0;
293
+ }
294
+ fn vfx_update(ctx: VfxUpdateContext, particle: ptr<function, VfxParticle>) {}`,
295
+ },
296
+ },
297
+ );
298
+ if (!cooked.ok) throw cooked.error;
299
+ const player =
300
+ surface?.ordinary === true
301
+ ? world
302
+ .spawn(
303
+ {
304
+ component: Transform,
305
+ data: { pos: [0, 0, 0], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
306
+ },
307
+ {
308
+ component: MeshFilter,
309
+ data: { assetHandle: world.allocSharedRef('MeshAsset', mesh) },
310
+ },
311
+ {
312
+ component: MeshRenderer,
313
+ data: { materials: [world.allocSharedRef('MaterialAsset', surfaceMaterial)] },
314
+ },
315
+ )
316
+ .unwrap()
317
+ : world
318
+ .spawn({
319
+ component: ParticleEffectPlayer,
320
+ data: {
321
+ effect: world.allocSharedRef('ParticleEffectAsset', cooked.value.asset),
322
+ playing: true,
323
+ seed: 1,
324
+ timeScale: 1,
325
+ },
326
+ })
327
+ .unwrap();
328
+ try {
329
+ for (let index = 0; index < 8; index++) {
330
+ world.update(1 / 60).unwrap();
331
+ const submitted = renderer.draw(frame);
332
+ if (!submitted.ok) throw new Error(inspect({ error: submitted.error, events: errors }));
333
+ const observed = await renderer.observe(submitted.value, { include: ['draws'] });
334
+ if (!observed.ok) throw observed.error;
335
+ }
336
+ expect(errors).toEqual([]);
337
+ if (caster?.enabled) {
338
+ expect(
339
+ vfx
340
+ .inspect(world)
341
+ ?.players.flatMap((player) => player.emitters)
342
+ .map((emitter) => emitter.cameraVisible),
343
+ ).toEqual([true, false]);
344
+ const names = renderer.inspect().perFramePassNames;
345
+ const projectIndices = names.flatMap((name, index) =>
346
+ name.endsWith('.project') ? [index] : [],
347
+ );
348
+ expect(projectIndices).toHaveLength(2);
349
+ for (const projectIndex of projectIndices)
350
+ expect(projectIndex, names.join('\n')).toBeLessThan(names.indexOf('shadowCascade0'));
351
+ }
352
+ if (
353
+ surface?.ordinary === true ||
354
+ (env.FORGEAX_POINT_SHADOW_TAPE !== undefined && pointShadowCaptureStage === 2) ||
355
+ (caster?.enabled &&
356
+ intensity === 0 &&
357
+ (env.FORGEAX_VFX_CAST_TAPE !== undefined ||
358
+ env.FORGEAX_POINT_SHADOW_TAPE !== undefined)) ||
359
+ (lighting === 'standard' &&
360
+ receiveShadows &&
361
+ intensity === 3 &&
362
+ capturedWork.length === 0)
363
+ ) {
364
+ const capture = recorder.captureFrame();
365
+ const before = await recorder.frameBoundary();
366
+ if (!before.ok) throw before.error;
367
+ world.update(1 / 60).unwrap();
368
+ const draw = renderer.draw(frame);
369
+ if (!draw.ok) throw draw.error;
370
+ const observed = await renderer.observe(draw.value, { include: ['draws'] });
371
+ if (!observed.ok) throw observed.error;
372
+ const after = await recorder.frameBoundary();
373
+ if (!after.ok) throw after.error;
374
+ const encoded = await capture;
375
+ if (!encoded.ok) throw encoded.error;
376
+ if (env.FORGEAX_VFX_LIGHTING_TAPE !== undefined) {
377
+ options.saveTape?.(env.FORGEAX_VFX_LIGHTING_TAPE, encoded.value.bytes);
378
+ }
379
+ if (env.FORGEAX_POINT_SHADOW_TAPE !== undefined && pointShadowCaptureStage === 2) {
380
+ options.saveTape?.(env.FORGEAX_POINT_SHADOW_TAPE, encoded.value.bytes);
381
+ }
382
+ if (
383
+ env.FORGEAX_POINT_SHADOW_TAPE !== undefined &&
384
+ caster?.enabled === true &&
385
+ lighting === 'standard' &&
386
+ receiveShadows &&
387
+ intensity === 0 &&
388
+ surface === undefined
389
+ ) {
390
+ options.saveTape?.(env.FORGEAX_POINT_SHADOW_TAPE, encoded.value.bytes);
391
+ }
392
+ if (caster?.enabled && env.FORGEAX_VFX_CAST_TAPE !== undefined) {
393
+ options.saveTape?.(env.FORGEAX_VFX_CAST_TAPE, encoded.value.bytes);
394
+ }
395
+ const decoded = decodeTape(encoded.value.bytes);
396
+ if (!decoded.ok) throw decoded.error;
397
+ capturedWork.push(
398
+ ...buildFrameModel(decoded.value).works.map((work) => ({
399
+ index: work.workIndex,
400
+ kind: work.kind,
401
+ pipeline: work.pipeline?.shaders?.map((shader) => shader.entryPoint),
402
+ bindings: work.bindings.map((binding) => [binding.groupIndex, binding.binding]),
403
+ })),
404
+ );
405
+ }
406
+ const sampled = await pixel();
407
+ if (surface?.ordinary && sampled[3] === 0)
408
+ throw new Error(inspect({ work: capturedWork, renderer: renderer.inspect() }));
409
+ return sampled;
410
+ } finally {
411
+ world.despawn(player).unwrap();
412
+ }
413
+ };
414
+ const lit = await sample('standard', true, 3);
415
+ const dark = await sample('standard', true, 0);
416
+ const unlit = await sample('unlit', true, 0);
417
+ const unlitWithLight = await sample('unlit', false, 3);
418
+ expect(
419
+ lit[0],
420
+ JSON.stringify({ lit, dark, unlit, unlitWithLight, capturedWork }),
421
+ ).toBeGreaterThan((dark[0] ?? 0) + 30);
422
+ expect(unlit[0]).toBeGreaterThan((dark[0] ?? 0) + 30);
423
+ expect(unlitWithLight).toEqual(unlit);
424
+ const spawnMeshCaster = (position: readonly [number, number, number], geometry = mesh) =>
425
+ world
426
+ .spawn(
427
+ { component: Transform, data: { pos: position, quat: [0, 0, 0, 1], scale: [1, 1, 1] } },
428
+ {
429
+ component: MeshFilter,
430
+ data: { assetHandle: world.allocSharedRef('MeshAsset', geometry) },
431
+ },
432
+ {
433
+ component: MeshRenderer,
434
+ data: {
435
+ materials: [
436
+ world.allocSharedRef('MaterialAsset', {
437
+ ...material,
438
+ passes: [
439
+ ...(material.passes ?? []),
440
+ {
441
+ name: 'ShadowCaster',
442
+ program: { module: 'forgeax::default-shadow-caster' },
443
+ renderState: {
444
+ tags: { LightMode: 'ShadowCaster' },
445
+ passKind: 'shadow-caster',
446
+ },
447
+ },
448
+ ],
449
+ }),
450
+ ],
451
+ },
452
+ },
453
+ )
454
+ .unwrap();
455
+ const occluder = spawnMeshCaster([1, 0, 1.25]);
456
+ const shadowed = await sample('standard', true, 3);
457
+ const noReceive = await sample('standard', false, 3);
458
+ expect(noReceive[0], JSON.stringify({ lit, shadowed, noReceive })).toBeGreaterThan(
459
+ (shadowed[0] ?? 0) + 30,
460
+ );
461
+ expect(noReceive).toEqual(lit);
462
+ world.despawn(occluder).unwrap();
463
+ expect(await sample('standard', true, 3)).toEqual(lit);
464
+ // Reverse the caster witness: a GPU Mesh must receive the shared point
465
+ // shadow, respect receiveShadows, and consume live authored bias values.
466
+ const receivingPoint = world
467
+ .spawn(
468
+ {
469
+ component: Transform,
470
+ data: { pos: [2.4, 0, 2.65], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
471
+ },
472
+ { component: PointLight, data: { color: [1, 1, 1], intensity: 40, range: 10 } },
473
+ {
474
+ component: PointLightShadow,
475
+ data: {
476
+ mapSize: 512,
477
+ nearPlane: 0.1,
478
+ farPlane: 10,
479
+ depthBias: 0.001,
480
+ normalBias: 0.001,
481
+ },
482
+ },
483
+ )
484
+ .unwrap();
485
+ pointShadowCaptureStage = 1;
486
+ const pointLit = await sample('standard', true, 0);
487
+ const receivingOccluder = spawnMeshCaster([1.2, 0, 1.45]);
488
+ pointShadowCaptureStage = 2;
489
+ const pointShadow = await sample('standard', true, 0);
490
+ const pointNoReceive = await sample('standard', false, 0);
491
+ expect(pointLit[0], JSON.stringify({ pointLit, pointShadow, pointNoReceive })).toBeGreaterThan(
492
+ (pointShadow[0] ?? 0) + 30,
493
+ );
494
+ expect(pointNoReceive).toEqual(pointLit);
495
+ world.set(receivingPoint, PointLightShadow, { depthBias: 1000, normalBias: 0 }).unwrap();
496
+ expect(
497
+ await sample('standard', true, 0),
498
+ 'Authored point depth bias must reach the GPU receiver',
499
+ ).toEqual(pointLit);
500
+ world.set(receivingPoint, PointLightShadow, { depthBias: 0.001, normalBias: 1 }).unwrap();
501
+ expect(
502
+ await sample('standard', true, 0),
503
+ 'Point slope bias must not be swapped with the depth floor',
504
+ ).toEqual(pointLit);
505
+ world.set(receivingPoint, PointLightShadow, { depthBias: 0.001, normalBias: 0.001 }).unwrap();
506
+ expect(await sample('standard', true, 0)).toEqual(pointShadow);
507
+ world.despawn(receivingOccluder).unwrap();
508
+ expect(await sample('standard', true, 0)).toEqual(pointLit);
509
+ world.set(receivingPoint, PointLight, { intensity: 0 }).unwrap();
510
+ expect(await sample('standard', true, 0)).toEqual(dark);
511
+ world.despawn(receivingPoint).unwrap();
512
+ // A directional HDR panorama falsifies the constant-white fallback and
513
+ // exercises the renderer-owned diffuse, specular mip, and BRDF LUT path.
514
+ const environmentBytes = new Uint8Array(16 * 8 * 8);
515
+ const environmentView = new DataView(environmentBytes.buffer);
516
+ for (let y = 0; y < 8; y++)
517
+ for (let x = 0; x < 16; x++) {
518
+ const offset = (y * 16 + x) * 8;
519
+ environmentView.setUint16(offset, x < 8 ? 0x4000 : 0x2800, true);
520
+ environmentView.setUint16(offset + 2, 0x2800, true);
521
+ environmentView.setUint16(offset + 4, x < 8 ? 0x2800 : 0x4000, true);
522
+ environmentView.setUint16(offset + 6, 0x3c00, true);
523
+ }
524
+ const sky = world
525
+ .spawn({
526
+ component: Skylight,
527
+ data: {
528
+ equirect: world.allocSharedRef('EquirectAsset', {
529
+ kind: 'equirect',
530
+ width: 16,
531
+ height: 8,
532
+ format: 'rgba16float',
533
+ colorSpace: 'linear',
534
+ data: environmentBytes,
535
+ }),
536
+ intensity: 0.5,
537
+ },
538
+ })
539
+ .unwrap();
540
+ const environmentSamples: number[][] = [];
541
+ for (const metallic of [0, 1])
542
+ for (const roughness of [0.05, 1]) {
543
+ const surface = { metallic, roughness };
544
+ const ordinary = await sample('standard', true, 0, undefined, {
545
+ ...surface,
546
+ ordinary: true,
547
+ });
548
+ const particle = await sample('standard', true, 0, undefined, surface);
549
+ for (let channel = 0; channel < 3; channel++)
550
+ expect(
551
+ Math.abs((particle[channel] ?? 0) - (ordinary[channel] ?? 0)),
552
+ JSON.stringify({ surface, particle, ordinary }),
553
+ ).toBeLessThanOrEqual(2);
554
+ expect(Math.max(...particle.slice(0, 3))).toBeGreaterThan(30);
555
+ environmentSamples.push(particle);
556
+ }
557
+ expect(environmentSamples[2]).not.toEqual(environmentSamples[3]);
558
+ const environmentLit = await sample('standard', true, 0);
559
+ world.set(sky, Skylight, { rotation: [0, 1, 0, 0] }).unwrap();
560
+ const environmentRotated = await sample('standard', true, 0);
561
+ expect(
562
+ Math.max(
563
+ ...environmentLit
564
+ .slice(0, 3)
565
+ .map((value, index) => Math.abs(value - (environmentRotated[index] ?? 0))),
566
+ ),
567
+ ).toBeGreaterThan(30);
568
+ expect(await sample('standard', false, 0)).toEqual(environmentRotated);
569
+ expect(await sample('unlit', true, 0)).toEqual(unlit);
570
+ world.set(sky, Skylight, { intensity: 0 }).unwrap();
571
+ expect(await sample('standard', true, 0)).toEqual(dark);
572
+ world.despawn(sky).unwrap();
573
+ expect(await sample('standard', true, 0)).toEqual(dark);
574
+ const receiver = world
575
+ .spawn(
576
+ { component: Transform, data: { pos: [0, 0, 0], quat: [0, 0, 0, 1], scale: [1, 1, 1] } },
577
+ { component: MeshFilter, data: { assetHandle: world.allocSharedRef('MeshAsset', mesh) } },
578
+ {
579
+ component: MeshRenderer,
580
+ data: {
581
+ materials: [
582
+ world.allocSharedRef('MaterialAsset', {
583
+ ...material,
584
+ passes: [
585
+ {
586
+ name: 'Forward',
587
+ program: { module: 'forgeax::default-standard-pbr' },
588
+ renderState: { tags: { LightMode: 'Forward' } },
589
+ },
590
+ ],
591
+ }),
592
+ ],
593
+ },
594
+ },
595
+ )
596
+ .unwrap();
597
+ const casterPosition = [1.2, 0, 1.45] as const;
598
+ const clearReceiver = await sample('unlit', false, 3, {
599
+ enabled: false,
600
+ position: casterPosition,
601
+ });
602
+ const particleShadow = await sample('unlit', false, 3, {
603
+ enabled: true,
604
+ position: casterPosition,
605
+ });
606
+ expect(clearReceiver[0], JSON.stringify({ clearReceiver, particleShadow })).toBeGreaterThan(
607
+ (particleShadow[0] ?? 0) + 30,
608
+ );
609
+ expect(await sample('unlit', false, 3, { enabled: false, position: casterPosition })).toEqual(
610
+ clearReceiver,
611
+ );
612
+ for (const [kind, sideX, sideY] of [
613
+ ['point', 1, 0],
614
+ ['point', -1, 0],
615
+ ['point', 0, 1],
616
+ ['point', 0, -1],
617
+ ['spot', 1, 0],
618
+ ] as const) {
619
+ const localCaster = [sideX * 1.2, sideY * 1.2, 1.45] as const;
620
+ const transform = {
621
+ component: Transform,
622
+ data: {
623
+ pos: [sideX * 2.4, sideY * 2.4, 2.65] as const,
624
+ quat: [0, 0, 0, 1] as const,
625
+ scale: [1, 1, 1] as const,
626
+ },
627
+ };
628
+ const localLight =
629
+ kind === 'point'
630
+ ? world
631
+ .spawn(
632
+ transform,
633
+ { component: PointLight, data: { color: [1, 1, 1], intensity: 40, range: 10 } },
634
+ {
635
+ component: PointLightShadow,
636
+ data: {
637
+ mapSize: 512,
638
+ nearPlane: 0.1,
639
+ farPlane: 10,
640
+ depthBias: 0.001,
641
+ normalBias: 0.001,
642
+ },
643
+ },
644
+ )
645
+ .unwrap()
646
+ : world
647
+ .spawn(transform, {
648
+ component: SpotLight,
649
+ data: {
650
+ color: [1, 1, 1],
651
+ intensity: 40,
652
+ range: 10,
653
+ direction: [-sideX, -sideY, -1],
654
+ innerConeDeg: 20,
655
+ outerConeDeg: 35,
656
+ castShadow: true,
657
+ mapSize: 512,
658
+ nearPlane: 0.1,
659
+ farPlane: 10,
660
+ depthBias: 0.001,
661
+ normalBias: 0.001,
662
+ },
663
+ })
664
+ .unwrap();
665
+ const clear = await sample('unlit', false, 0, { enabled: false, position: localCaster });
666
+ const shadow = await sample('unlit', false, 0, { enabled: true, position: localCaster });
667
+ expect(clear[0], JSON.stringify({ kind, sideX, sideY, clear, shadow })).toBeGreaterThan(
668
+ (shadow[0] ?? 0) + 30,
669
+ );
670
+ expect(await sample('unlit', false, 0, { enabled: false, position: localCaster })).toEqual(
671
+ clear,
672
+ );
673
+ const meshCaster = spawnMeshCaster(localCaster, createPlaneGeometry(0.5, 0.5).unwrap());
674
+ const ordinaryShadow = await sample('unlit', false, 0, {
675
+ enabled: false,
676
+ position: localCaster,
677
+ });
678
+ expect(
679
+ clear[0],
680
+ JSON.stringify({ kind, sideX, sideY, clear, ordinaryShadow }),
681
+ ).toBeGreaterThan((ordinaryShadow[0] ?? 0) + 30);
682
+ world.set(meshCaster, Transform, { quat: [0, 1, 0, 0] }).unwrap();
683
+ expect(await sample('unlit', false, 0, { enabled: false, position: localCaster })).toEqual(
684
+ clear,
685
+ );
686
+ world.despawn(meshCaster).unwrap();
687
+ expect(await sample('unlit', false, 0, { enabled: false, position: localCaster })).toEqual(
688
+ clear,
689
+ );
690
+ world.despawn(localLight).unwrap();
691
+ }
692
+ // Current scene depth is produced after shadows. This simulation alternates
693
+ // its caster each FixedUpdate; the light view must consume the last submitted
694
+ // projection, not rerun a tick or pretend current depth exists before opaque.
695
+ world.set(light, DirectionalLight, { intensity: 3 }).unwrap();
696
+ world.update(1 / 60).unwrap();
697
+ const present = async () => {
698
+ const submitted = renderer.draw(frame);
699
+ if (!submitted.ok) throw submitted.error;
700
+ const observed = await renderer.observe(submitted.value, { include: ['draws'] });
701
+ if (!observed.ok) throw observed.error;
702
+ return pixel();
703
+ };
704
+ const depthClear = await present();
705
+ const depthCook = await cookParticleCodeEffect(
706
+ {
707
+ schemaVersion: 3,
708
+ emitters: [
709
+ {
710
+ id: 'depth-latency',
711
+ capacity: 1,
712
+ backend: { required: 'gpu' },
713
+ space: 'world',
714
+ bounds: { kind: 'sphere', center: [1.2, 1, 1.45], radius: 1.5 },
715
+ schedule: { rate: 0, bursts: [{ time: 0, count: 1 }] },
716
+ program: { module: 'depth-latency.wgsl' },
717
+ renderers: [
718
+ {
719
+ kind: 'mesh',
720
+ mesh: AssetGuid.format(meshGuid),
721
+ material: AssetGuid.format(materialGuid),
722
+ lighting: 'unlit',
723
+ castShadows: true,
724
+ },
725
+ ],
726
+ },
727
+ ],
728
+ },
729
+ {
730
+ 'depth-latency.wgsl': {
731
+ entry: `
732
+ #import forgeax_vfx::prelude::{VfxParticle, VfxSpawnContext, VfxUpdateContext}
733
+ #import forgeax_vfx::data::scene_depth
734
+ fn vfx_spawn(ctx: VfxSpawnContext, particle: ptr<function, VfxParticle>) {
735
+ (*particle).position = vec3<f32>(1.2, 2.0, 1.45);
736
+ (*particle).color = vec4<f32>(1.0);
737
+ (*particle).mesh_orientation = vec4<f32>(0.0, 0.0, 0.0, 1.0);
738
+ (*particle).mesh_scale = vec3<f32>(1.0);
739
+ (*particle).lifetime = 100.0;
740
+ }
741
+ fn vfx_update(ctx: VfxUpdateContext, particle: ptr<function, VfxParticle>) {
742
+ let depth = textureLoad(forgeax_vfx_scene_depth, vec2<i32>(32, 32), 0);
743
+ let casts = depth < 0.99 && (ctx.tick % 2u) == 0u;
744
+ (*particle).position = vec3<f32>(1.2, select(2.0, 0.0, casts), 1.45);
745
+ }`,
746
+ },
747
+ },
748
+ );
749
+ if (!depthCook.ok) throw depthCook.error;
750
+ const depthPlayer = world
751
+ .spawn({
752
+ component: ParticleEffectPlayer,
753
+ data: {
754
+ effect: world.allocSharedRef('ParticleEffectAsset', depthCook.value.asset),
755
+ playing: true,
756
+ seed: 1,
757
+ timeScale: 1,
758
+ },
759
+ })
760
+ .unwrap();
761
+ const phase = () =>
762
+ vfx.inspect(world)?.players.find((entry) => entry.player === depthPlayer)?.lastCommitted
763
+ ?.phaseTick;
764
+ world.update(1 / 60).unwrap();
765
+ let firstDepthFrame: number[] = [];
766
+ for (let attempt = 0; attempt < 32; attempt++) {
767
+ firstDepthFrame = await present();
768
+ if (phase() === 0) break;
769
+ }
770
+ expect(phase()).toBe(0);
771
+ expect(firstDepthFrame).toEqual(depthClear);
772
+ const depthShadow = await present();
773
+ expect(depthClear[0], JSON.stringify({ depthClear, depthShadow })).toBeGreaterThan(
774
+ (depthShadow[0] ?? 0) + 30,
775
+ );
776
+ expect(phase()).toBe(0);
777
+ expect(await present()).toEqual(depthShadow);
778
+ expect(phase()).toBe(0);
779
+
780
+ const capture = recorder.captureFrame();
781
+ const before = await recorder.frameBoundary();
782
+ if (!before.ok) throw before.error;
783
+ world.update(1 / 60).unwrap();
784
+ const changedDepthFrame = await present();
785
+ const after = await recorder.frameBoundary();
786
+ if (!after.ok) throw after.error;
787
+ const captured = await capture;
788
+ if (!captured.ok) throw captured.error;
789
+ if (env.FORGEAX_VFX_DEPTH_LATENCY_TAPE !== undefined)
790
+ options.saveTape?.(env.FORGEAX_VFX_DEPTH_LATENCY_TAPE, captured.value.bytes);
791
+ const decoded = decodeTape(captured.value.bytes);
792
+ if (!decoded.ok) throw decoded.error;
793
+ const depthModel = buildFrameModel(decoded.value);
794
+ expect(
795
+ changedDepthFrame,
796
+ JSON.stringify({ phase: phase(), passes: renderer.inspect().perFramePassNames }),
797
+ ).toEqual(depthShadow);
798
+ expect(phase()).toBe(1);
799
+ const depthUpdate = depthModel.works.find((work) =>
800
+ work.pipeline?.shaders.some((shader) => shader.entryPoint === 'forgeax_vfx_update_main'),
801
+ );
802
+ const shadowWork = depthModel.works.find((work) => work.kind === 'drawIndexedIndirect');
803
+ expect(depthUpdate).toBeDefined();
804
+ expect(shadowWork).toBeDefined();
805
+ expect(depthUpdate?.workIndex).toBeGreaterThan(shadowWork?.workIndex ?? Infinity);
806
+ expect(
807
+ depthUpdate?.bindings.some((binding) => binding.groupIndex === 0 && binding.binding === 13),
808
+ ).toBe(true);
809
+ expect(await present()).toEqual(depthClear);
810
+ expect(phase()).toBe(1);
811
+ world.update(1 / 60).unwrap();
812
+ expect(await present()).toEqual(depthClear);
813
+ expect(phase()).toBe(2);
814
+ expect(await present()).toEqual(depthShadow);
815
+ // Catch-up may submit several ordered ticks together. Early shadows still
816
+ // use the previous projection; the following draw sees only the final tick.
817
+ world.update(1 / 60).unwrap();
818
+ world.update(1 / 60).unwrap();
819
+ world.update(1 / 60).unwrap();
820
+ expect(await present()).toEqual(depthShadow);
821
+ expect(phase()).toBe(5);
822
+ expect(await present()).toEqual(depthClear);
823
+ expect(phase()).toBe(5);
824
+ world.despawn(depthPlayer).unwrap();
825
+ world.update(1 / 60).unwrap();
826
+ expect(await present()).toEqual(depthClear);
827
+ world.despawn(receiver).unwrap();
828
+ const replayAdapter = await pack.value.rhi.requestAdapter();
829
+ if (!replayAdapter.ok) throw replayAdapter.error;
830
+ const replaySampledTextureLimit = replayAdapter.value.limits.maxSampledTexturesPerShaderStage;
831
+ const replayDevice = await replayAdapter.value.requestDevice(
832
+ Number.isFinite(replaySampledTextureLimit) && replaySampledTextureLimit > 0
833
+ ? {
834
+ requiredLimits: {
835
+ // Keep the replay admission request within the actual adapter
836
+ // capability. The lighting/depth assertions below still run on
837
+ // the fresh device; this only avoids rejecting low-limit
838
+ // WebGPU adapters before replay can begin.
839
+ maxSampledTexturesPerShaderStage: Math.min(20, replaySampledTextureLimit),
840
+ },
841
+ }
842
+ : undefined,
843
+ );
844
+ if (!replayDevice.ok) throw replayDevice.error;
845
+ // Native lifetime cleanup stays in this Dawn fixture, outside Render/VFX.
846
+ const replayRaw = pack.value._internal_getRawDevice?.(replayDevice.value) as
847
+ | GPUDevice
848
+ | undefined;
849
+ if (replayRaw === undefined) throw new Error('Missing native replay device');
850
+ try {
851
+ const replay = await openReplay(decoded.value, {
852
+ device: replayDevice.value,
853
+ createShaderModule: pack.value.createShaderModule,
854
+ });
855
+ if (!replay.ok) throw replay.error;
856
+ let disposalError: unknown;
857
+ try {
858
+ const lastWork = depthModel.works.at(-1);
859
+ if (lastWork === undefined) throw new Error('Missing captured output work');
860
+ const inspection = await replay.value.inspectWork(lastWork.workIndex, ['pixels']);
861
+ if (!inspection.ok) throw inspection.error;
862
+ const attachment = inspection.value.attachment;
863
+ if (attachment === undefined) throw new Error('Missing replay pixels');
864
+ expect(attachment.width).toBe(64);
865
+ expect(attachment.height).toBe(64);
866
+ const offset = (32 * 64 + 32) * 4;
867
+ expect([...attachment.bytes.slice(offset, offset + 4)]).toEqual(changedDepthFrame);
868
+ } finally {
869
+ const disposed = await replay.value.dispose();
870
+ if (!disposed.ok) disposalError = disposed.error;
871
+ }
872
+ if (disposalError !== undefined) throw disposalError;
873
+ } finally {
874
+ replayRaw.destroy();
875
+ }
876
+ expect(errors).toEqual([]);
877
+ } finally {
878
+ if (vfxAttached) await vfx.detachWorld({ world });
879
+ renderer.dispose();
880
+ target?.destroy();
881
+ await recorder.dispose();
882
+ }
883
+ }