@forgeax/engine-vfx-render 0.1.4 → 0.1.7

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/engine-vfx-render",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,13 +22,13 @@
22
22
  "LICENSE"
23
23
  ],
24
24
  "dependencies": {
25
- "@forgeax/engine-assets-runtime": "0.1.4",
26
- "@forgeax/engine-ecs": "0.1.4",
27
- "@forgeax/engine-math": "0.1.4",
28
- "@forgeax/engine-render": "0.1.4",
29
- "@forgeax/engine-scene": "0.1.4",
30
- "@forgeax/engine-types": "0.1.4",
31
- "@forgeax/engine-vfx": "0.1.4"
25
+ "@forgeax/engine-assets-runtime": "0.1.7",
26
+ "@forgeax/engine-ecs": "0.1.7",
27
+ "@forgeax/engine-math": "0.1.7",
28
+ "@forgeax/engine-render": "0.1.7",
29
+ "@forgeax/engine-scene": "0.1.7",
30
+ "@forgeax/engine-types": "0.1.7",
31
+ "@forgeax/engine-vfx": "0.1.7"
32
32
  },
33
33
  "devDependencies": {},
34
34
  "forgeax": {
@@ -43,27 +43,4 @@ describe('billboard advanced executable inputs', () => {
43
43
  error: { code: 'vfx-renderer-depth-missing' },
44
44
  });
45
45
  });
46
-
47
- it('changes soft-particle alpha when scene depth changes before Fog mixing', () => {
48
- const nearDepth = resolveBillboardAdvancedState(renderer, {
49
- age: 0.1,
50
- lifetime: 1,
51
- particleDepth: 0.7,
52
- sceneDepth: 0.75,
53
- depthAvailable: true,
54
- });
55
- const farDepth = resolveBillboardAdvancedState(renderer, {
56
- age: 0.1,
57
- lifetime: 1,
58
- particleDepth: 0.7,
59
- sceneDepth: 1.0,
60
- depthAvailable: true,
61
- });
62
-
63
- expect(nearDepth.ok).toBe(true);
64
- expect(farDepth.ok).toBe(true);
65
- if (nearDepth.ok && farDepth.ok) {
66
- expect(farDepth.value.softParticleFade).toBeGreaterThan(nearDepth.value.softParticleFade);
67
- }
68
- });
69
46
  });
@@ -29,7 +29,7 @@ describe('VFX Fog producer contract', () => {
29
29
  }
30
30
  });
31
31
 
32
- it('keeps VFX coverage on the existing reactive graphics lane', () => {
32
+ it('keeps VFX coverage on the current graphics feature lane', () => {
33
33
  const feature = readFileSync(
34
34
  fileURLToPath(new URL('../feature/gpu-particle-feature.ts', import.meta.url)),
35
35
  'utf8',
@@ -38,9 +38,10 @@ describe('VFX Fog producer contract', () => {
38
38
  fileURLToPath(new URL('../feature/camera.ts', import.meta.url)),
39
39
  'utf8',
40
40
  );
41
- expect(feature).toContain("temporalCoverage: 'reactive'");
41
+ expect(feature).toContain('requiredCapabilities');
42
+ expect(feature).toContain('identity: IDENTITY');
42
43
  expect(feature).not.toMatch(/fog(?:History|Texture|Registry)/i);
43
- expect(feature).toContain('worldPosition');
44
+ expect(feature).toContain('worldCenter');
44
45
  expect(camera).toContain('position');
45
46
  expect(camera).toContain('viewProjection');
46
47
  });
@@ -1,15 +1,12 @@
1
- import { createAssetRegistry, createCatalogSource } from '@forgeax/engine-assets-runtime';
2
1
  import { World } from '@forgeax/engine-ecs';
3
- import { RenderFeaturePreparationFailedError } from '@forgeax/engine-render';
4
- import { err, ok } from '@forgeax/engine-types';
2
+ import type { Asset, MaterialAsset } from '@forgeax/engine-types';
5
3
  import {
6
4
  ParticleEffectPlayer,
7
5
  VFX_GPU_RUNTIME_RESOURCE_KEY,
8
6
  type VfxGpuRuntime,
9
7
  type VfxGpuTickIntent,
10
- vfxGpuEffectContribution,
11
8
  } from '@forgeax/engine-vfx';
12
- import { describe, expect, it, vi } from 'vitest';
9
+ import { describe, expect, it } from 'vitest';
13
10
  import { gpuParticleRenderFeature } from '../feature/gpu-particle-feature.js';
14
11
  import {
15
12
  createCameraProvider,
@@ -21,12 +18,10 @@ import {
21
18
  describe('GPU VFX public host', () => {
22
19
  it('owns loader and FixedUpdate attachment without exposing simulation internals', async () => {
23
20
  const world = new World();
24
- const installed: unknown[] = [];
21
+ const registered: unknown[] = [];
25
22
  const assets = {
26
- installDecoder: (kind: unknown, decoder: unknown) => {
27
- installed.push({ kind, decoder });
28
- return { kind: 'particle-effect', dispose: () => {} };
29
- },
23
+ loaders: { registerPackLoader: (loader: unknown) => registered.push(loader) },
24
+ lookup: () => undefined,
30
25
  };
31
26
  const host = createVfxRuntimeHost({
32
27
  camera: {
@@ -41,45 +36,104 @@ describe('GPU VFX public host', () => {
41
36
 
42
37
  const attached = await host.attachWorld({ world, assets: assets as never });
43
38
  expect(attached).toMatchObject({ ok: true, value: { state: 'attached' } });
44
- expect(installed).toHaveLength(1);
39
+ expect(registered).toHaveLength(1);
45
40
  expect(host.feature.requiredMaterialShaders).toEqual(
46
41
  Object.values(PARTICLE_SHADER_IDENTIFIERS),
47
42
  );
48
- expect(host.feature.inspect()).toEqual({
49
- frameNumber: -1,
50
- dispatches: 0,
51
- indirectDraws: 0,
52
- subjectOutputs: 0,
53
- });
54
43
  expect(await host.detachWorld({ world })).toMatchObject({
55
44
  ok: true,
56
45
  value: { state: 'detached' },
57
46
  });
58
47
  });
59
48
 
60
- it('shares an App-selected GPU decoder when the host attaches the same registry', async () => {
49
+ it('preserves the asset registry receiver while planning a material projection', async () => {
61
50
  const world = new World();
62
- const assets = createAssetRegistry({
63
- catalog: createCatalogSource({ entries: [] }),
64
- });
65
- assets.installDecoder(vfxGpuEffectContribution.kind, vfxGpuEffectContribution.decoder);
66
- const host = createVfxRuntimeHost({ camera: { read: () => undefined } });
51
+ const lookups: string[] = [];
52
+ class StatefulAssets {
53
+ readonly material: MaterialAsset = { kind: 'material', values: {} };
54
+ readonly loaders = { registerPackLoader: () => {} };
67
55
 
68
- expect(await host.attachWorld({ world, assets })).toMatchObject({
69
- ok: true,
70
- value: { state: 'attached' },
71
- });
72
- expect(await host.detachWorld({ world })).toMatchObject({
73
- ok: true,
74
- value: { state: 'detached' },
56
+ lookup<T extends Asset = Asset>(guid: string): T | undefined {
57
+ if (this !== assets) throw new Error('asset registry receiver was lost');
58
+ lookups.push(guid);
59
+ return guid === 'material-guid' ? (this.material as T) : undefined;
60
+ }
61
+ }
62
+ const assets = new StatefulAssets();
63
+ const host = createVfxRuntimeHost({
64
+ camera: {
65
+ read: () => ({
66
+ position: new Float32Array(3),
67
+ right: new Float32Array([1, 0, 0]),
68
+ up: new Float32Array([0, 1, 0]),
69
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
70
+ }),
71
+ },
75
72
  });
76
- assets.dispose();
73
+ expect(await host.attachWorld({ world, assets })).toMatchObject({ ok: true });
74
+
75
+ const intent = {
76
+ player: world.spawn().unwrap(),
77
+ fixedDelta: 1 / 60,
78
+ phaseTick: 0,
79
+ seed: 1,
80
+ playCycle: 0,
81
+ spawnCount: 1,
82
+ firstParticleId: 0,
83
+ reset: true,
84
+ channelInputs: [],
85
+ emitter: {
86
+ id: 'receiver-check',
87
+ capacity: 4,
88
+ backend: { required: 'gpu' },
89
+ space: 'world',
90
+ simulationWhenCulled: 'continue',
91
+ schedule: { rate: 0, bursts: [] },
92
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
93
+ wgsl: '// receiver check',
94
+ reflection: { entryPoints: [], bindings: [] },
95
+ renderers: [{ kind: 'billboard', material: 'material-guid' }],
96
+ },
97
+ } as unknown as VfxGpuTickIntent;
98
+ const planned = host.feature.plan(
99
+ {
100
+ worlds: [
101
+ {
102
+ world,
103
+ runtime: {
104
+ isEmitterSessionEnabled: () => true,
105
+ setEmitterCameraVisibility: () => {},
106
+ markEventDispatched: () => {},
107
+ commit: () => {},
108
+ },
109
+ camera: {
110
+ position: new Float32Array(3),
111
+ right: new Float32Array([1, 0, 0]),
112
+ up: new Float32Array([0, 1, 0]),
113
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
114
+ },
115
+ intents: [intent],
116
+ },
117
+ ],
118
+ frameNumber: 1,
119
+ } as never,
120
+ {
121
+ targets: [],
122
+ caps: {},
123
+ frame: { frameNumber: 1 },
124
+ generation: 1,
125
+ } as never,
126
+ );
127
+ expect(planned.ok).toBe(true);
128
+ expect(lookups).toEqual(['material-guid']);
129
+ await host.detachWorld({ world });
77
130
  });
78
131
 
79
132
  it('fences preview controls to one attached host generation', async () => {
80
133
  const world = new World();
81
134
  const assets = {
82
- installDecoder: () => ({ kind: 'particle-effect', dispose: () => {} }),
135
+ loaders: { registerPackLoader: () => {} },
136
+ lookup: () => undefined,
83
137
  };
84
138
  const host = createVfxRuntimeHost({ camera: { read: () => undefined } });
85
139
  expect(host.acquireControl(world)).toMatchObject({
@@ -145,7 +199,7 @@ describe('GPU VFX public host', () => {
145
199
  expect(host.acquireControl(world)).toMatchObject({ ok: true, value: { generation: 2 } });
146
200
  });
147
201
 
148
- it('starts every pending emitter program in one prepare frame', () => {
202
+ it('declares every pending emitter program in one feature plan', () => {
149
203
  const world = new World();
150
204
  const player = world.spawn().unwrap();
151
205
  const intent = (id: string): VfxGpuTickIntent => ({
@@ -206,45 +260,41 @@ describe('GPU VFX public host', () => {
206
260
  lastSequence: -1,
207
261
  },
208
262
  });
209
- const prepareProgram = vi.fn((name: string) =>
210
- err(
211
- new RenderFeaturePreparationFailedError(
212
- 'forgeax.vfx-render.gpu-particles',
213
- -1,
214
- 'prepare-gpu-program',
215
- 'pipeline',
216
- name,
217
- 'rhi-not-available:compile pending',
218
- 'next-frame',
219
- ),
220
- ),
221
- );
222
263
  const feature = gpuParticleRenderFeature({ camera: { read: () => undefined } });
223
-
224
- const prepared = feature.prepare(
264
+ const planned = feature.plan(
225
265
  {
226
266
  worlds: [
227
267
  {
228
268
  world,
229
- runtime: {},
230
- camera: {},
269
+ runtime: {
270
+ isEmitterSessionEnabled: () => true,
271
+ setEmitterCameraVisibility: () => {},
272
+ markEventDispatched: () => {},
273
+ commit: () => {},
274
+ },
275
+ camera: {
276
+ position: new Float32Array(3),
277
+ right: new Float32Array([1, 0, 0]),
278
+ up: new Float32Array([0, 1, 0]),
279
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
280
+ },
231
281
  intents: [intent('first'), intent('second')],
232
282
  },
233
283
  ],
234
284
  frameNumber: 1,
235
285
  } as never,
236
- { gpu: { prepareProgram }, targets: [] } as never,
286
+ { targets: [], caps: {}, frame: { frameNumber: 1 }, generation: 1 } as never,
237
287
  );
238
-
239
- expect(prepared).toMatchObject({ ok: false, error: { detail: { recovery: 'next-frame' } } });
240
- expect(prepareProgram).toHaveBeenCalledTimes(2);
241
- expect(prepareProgram.mock.calls.map(([name]) => name)).toEqual([
242
- expect.stringContaining('.first.'),
243
- expect.stringContaining('.second.'),
244
- ]);
288
+ expect(planned.ok).toBe(true);
289
+ if (!planned.ok) return;
290
+ expect(
291
+ planned.value.resources
292
+ .filter((resource) => resource.kind === 'compute-program')
293
+ .map((resource) => resource.name),
294
+ ).toEqual([expect.stringContaining('.first.'), expect.stringContaining('.second.')]);
245
295
  });
246
296
 
247
- it('fences render consumption and keeps masked bindings warm', () => {
297
+ it('keeps masked bindings warm and sends effect-relative time to WGSL', () => {
248
298
  const world = new World();
249
299
  const player = world.spawn().unwrap();
250
300
  const intent = (tick: number): VfxGpuTickIntent => ({
@@ -306,38 +356,11 @@ describe('GPU VFX public host', () => {
306
356
  },
307
357
  });
308
358
  let sessionEnabled = true;
309
- let renderConsumptionEnabled = true;
310
- let generation = 0;
311
- const liveBindings = new Set<object>();
312
- const prepareProgram = vi.fn(() => ok({ generation: 1 } as never));
313
- const prepareBuffer = vi.fn((_name: string, _descriptor: { readonly data?: Uint8Array }) =>
314
- ok({ generation: 1 } as never),
315
- );
316
- const prepareBindings = vi.fn(() => {
317
- generation += 1;
318
- const reference = { generation: 1, id: generation };
319
- liveBindings.add(reference);
320
- return ok(reference as never);
321
- });
322
- const retainBindings = vi.fn((references: readonly object[]) =>
323
- references.every((reference) => liveBindings.has(reference))
324
- ? ok(undefined)
325
- : err(
326
- new RenderFeaturePreparationFailedError(
327
- 'forgeax.vfx-render.gpu-particles',
328
- -1,
329
- 'retain-gpu-bindings',
330
- 'bindings',
331
- 'persistent-bindings',
332
- 'bindings-unavailable',
333
- 'next-frame',
334
- ),
335
- ),
336
- );
337
359
  const runtime = {
338
- hasPlayer: () => true,
339
360
  isEmitterSessionEnabled: () => sessionEnabled,
340
361
  setEmitterCameraVisibility: () => {},
362
+ markEventDispatched: () => {},
363
+ commit: () => {},
341
364
  };
342
365
  const camera = {
343
366
  position: new Float32Array(3),
@@ -345,45 +368,39 @@ describe('GPU VFX public host', () => {
345
368
  up: new Float32Array([0, 1, 0]),
346
369
  viewProjection: new Float32Array(16),
347
370
  };
348
- const feature = gpuParticleRenderFeature({
349
- camera: { read: () => camera },
350
- playerConsumption: { isEnabled: () => renderConsumptionEnabled },
351
- });
352
- const context = {
353
- gpu: { prepareProgram, prepareBuffer, prepareBindings, retainBindings },
354
- targets: [],
355
- } as never;
371
+ const feature = gpuParticleRenderFeature({ camera: { read: () => camera } });
372
+ const context = { targets: [], caps: {}, frame: { frameNumber: 1 }, generation: 1 } as never;
356
373
  const frame = (intents: readonly VfxGpuTickIntent[], frameNumber: number) =>
357
374
  ({
358
375
  worlds: [{ world, runtime, camera, intents }],
359
376
  frameNumber,
360
377
  }) as never;
361
378
 
362
- expect(feature.prepare(frame([intent(0)], 1), context).ok).toBe(true);
363
- const runtimeWrite = prepareBuffer.mock.calls.find(([name]) => name.includes('.runtime.'))?.[1];
364
- expect(runtimeWrite?.data).toBeInstanceOf(Uint8Array);
365
- expect(new Uint32Array((runtimeWrite?.data as Uint8Array).buffer)[1]).toBe(7);
366
- renderConsumptionEnabled = false;
367
- expect(feature.prepare(frame([], 2), context).ok).toBe(true);
368
- // Despawned players must not fall back to the retained lastIntent,
369
- // otherwise the missing Transform is rendered once at world origin.
370
- expect(retainBindings).toHaveBeenCalledTimes(1);
371
- renderConsumptionEnabled = true;
379
+ const active = feature.plan(frame([intent(0)], 1), context);
380
+ expect(active.ok).toBe(true);
381
+ if (!active.ok) return;
382
+ const runtimeWrite = active.value.resources.find(
383
+ (resource) => resource.kind === 'buffer' && resource.name.endsWith('.runtime'),
384
+ );
385
+ expect(runtimeWrite?.kind === 'buffer' ? runtimeWrite.data : undefined).toBeInstanceOf(
386
+ Uint8Array,
387
+ );
388
+ const runtimeBytes = runtimeWrite?.kind === 'buffer' ? runtimeWrite.data : undefined;
389
+ expect(new Uint32Array((runtimeBytes as Uint8Array).buffer)[1]).toBe(7);
372
390
  sessionEnabled = false;
373
- expect(feature.prepare(frame([], 3), context).ok).toBe(true);
374
- // Session isolation is not a resource lifetime boundary. A paused player
375
- // can be re-enabled without a fresh simulation intent, so the masked frame
376
- // must keep its prepared bindings warm while contributing no work.
377
- expect(retainBindings).toHaveBeenCalledTimes(2);
391
+ const masked = feature.plan(frame([intent(1)], 2), context);
392
+ expect(masked).toMatchObject({ ok: true, value: { resources: [], passes: [] } });
378
393
  sessionEnabled = true;
379
- expect(feature.prepare(frame([], 4), context).ok).toBe(true);
380
- expect(retainBindings).toHaveBeenLastCalledWith([expect.objectContaining({ id: generation })]);
394
+ const resumed = feature.plan(frame([intent(2)], 3), context);
395
+ expect(resumed.ok).toBe(true);
396
+ if (resumed.ok) expect(resumed.value.resources.length).toBeGreaterThan(0);
381
397
  });
382
398
 
383
399
  it('rejects a second host without replacing the first World runtime', async () => {
384
400
  const world = new World();
385
401
  const assets = {
386
- installDecoder: () => ({ kind: 'particle-effect', dispose: () => {} }),
402
+ loaders: { registerPackLoader: () => {} },
403
+ lookup: () => undefined,
387
404
  };
388
405
  const options = {
389
406
  camera: {
@@ -426,7 +443,8 @@ describe('GPU VFX public host', () => {
426
443
  it('returns an empty keyed inspection aggregate before a player is present', async () => {
427
444
  const world = new World();
428
445
  const assets = {
429
- installDecoder: () => ({ kind: 'particle-effect', dispose: () => {} }),
446
+ loaders: { registerPackLoader: () => {} },
447
+ lookup: () => undefined,
430
448
  };
431
449
  const host = createVfxRuntimeHost({ camera: { read: () => undefined } });
432
450
  await host.attachWorld({ world, assets: assets as never });
@@ -4,7 +4,6 @@ import {
4
4
  canonicalMeshVertices,
5
5
  particleMaterialPass,
6
6
  particleMaterialUsesBindings,
7
- particleMaterialUsesSceneDepth,
8
7
  } from '../feature/particle-resources.js';
9
8
 
10
9
  describe('particle mesh resources', () => {
@@ -54,14 +53,6 @@ describe('particle material pass', () => {
54
53
  });
55
54
  });
56
55
 
57
- it('uses the renderer-owned shader contract for scene-depth selection', () => {
58
- expect(particleMaterialUsesSceneDepth('group-0-resource')).toBe(true);
59
- expect(particleMaterialUsesSceneDepth('view-with-resource')).toBe(true);
60
- expect(particleMaterialUsesSceneDepth('group-0')).toBe(false);
61
- expect(particleMaterialUsesSceneDepth('view-only')).toBe(false);
62
- expect(particleMaterialUsesSceneDepth('render-material')).toBe(false);
63
- });
64
-
65
56
  it('does not mistake an ordinary Forward pass for a particle shader', () => {
66
57
  const material: MaterialAsset = {
67
58
  kind: 'material',
@@ -1,6 +1,10 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
+ import type { RenderFeaturePlan } from '@forgeax/engine-render';
4
+ import type { VfxGpuTickIntent } from '@forgeax/engine-vfx';
3
5
  import { describe, expect, it } from 'vitest';
6
+ import { freezeRenderFeaturePlan } from '../../../render/src/features/plan';
7
+ import { gpuParticleRenderFeature } from '../feature/gpu-particle-feature.js';
4
8
 
5
9
  const stagePlanPath = resolve(import.meta.dirname, '../feature/stage-plan.ts');
6
10
 
@@ -13,4 +17,132 @@ describe('VFX managed stage execution seam', () => {
13
17
  expect(source).toContain('forgeax_vfx_stage_');
14
18
  expect(source).not.toContain('authorRawDispatch');
15
19
  });
20
+
21
+ it('projects a VFX stage into the closed render feature plan surface', () => {
22
+ const source = readFileSync(
23
+ resolve(import.meta.dirname, '../feature/gpu-particle-feature.ts'),
24
+ 'utf8',
25
+ );
26
+ const plan: RenderFeaturePlan = { resources: [], passes: [] };
27
+ expect(source).toContain('RenderFeaturePlan');
28
+ expect(source).not.toMatch(/GPUCommandEncoder|queue\.submit|encoder\.finish/);
29
+ expect(plan.passes).toEqual([]);
30
+ });
31
+
32
+ it('declares storage-produced indirect work and dependent raster descriptors', () => {
33
+ const intent = {
34
+ sequence: 1,
35
+ player: 1,
36
+ emitter: {
37
+ id: 'spark',
38
+ capacity: 4,
39
+ space: 'world',
40
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
41
+ wgsl: '// cooked VFX program',
42
+ reflection: {
43
+ entryPoints: ['forgeax_vfx_spawn_main', 'forgeax_vfx_billboard_main'],
44
+ bindings: [
45
+ {
46
+ entries: [
47
+ { binding: 0, visibility: 4, buffer: { type: 'storage' } },
48
+ { binding: 1, visibility: 4, buffer: { type: 'uniform' } },
49
+ { binding: 4, visibility: 4, buffer: { type: 'storage' } },
50
+ { binding: 6, visibility: 4, buffer: { type: 'storage' } },
51
+ ],
52
+ },
53
+ ],
54
+ stages: [],
55
+ dataInterfaces: [],
56
+ },
57
+ renderers: [
58
+ {
59
+ kind: 'billboard',
60
+ material: 'material-guid',
61
+ blend: 'additive',
62
+ sorting: 'none',
63
+ },
64
+ ],
65
+ },
66
+ programFingerprint: 'spark-program',
67
+ reset: true,
68
+ fixedDelta: 1 / 60,
69
+ phaseTick: 0,
70
+ tick: 0,
71
+ seed: 1,
72
+ playCycle: 0,
73
+ spawnCount: 1,
74
+ firstParticleId: 0,
75
+ instanceGeneration: 1,
76
+ channelInputs: [],
77
+ eventCounters: {},
78
+ } as unknown as VfxGpuTickIntent;
79
+ const runtime = {
80
+ isEmitterSessionEnabled: () => true,
81
+ setEmitterCameraVisibility: () => {},
82
+ markEventDispatched: () => {},
83
+ commit: () => {},
84
+ };
85
+ const feature = gpuParticleRenderFeature({ camera: { read: () => undefined } });
86
+ const planned = feature.plan(
87
+ {
88
+ worlds: [
89
+ {
90
+ world: {},
91
+ runtime,
92
+ camera: {
93
+ position: new Float32Array(3),
94
+ right: new Float32Array([1, 0, 0]),
95
+ up: new Float32Array([0, 1, 0]),
96
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
97
+ },
98
+ intents: [intent],
99
+ },
100
+ ],
101
+ frameNumber: 1,
102
+ } as never,
103
+ {
104
+ caps: {} as never,
105
+ frame: { frameNumber: 1 },
106
+ generation: 1,
107
+ targets: [
108
+ { name: 'scene-color', kind: 'color', format: 'rgba16float', sampleCount: 1 },
109
+ { name: 'scene-depth', kind: 'depth', format: 'depth24plus', sampleCount: 1 },
110
+ ],
111
+ },
112
+ );
113
+
114
+ expect(planned.ok).toBe(true);
115
+ if (!planned.ok) return;
116
+ expect(
117
+ freezeRenderFeaturePlan(feature.identity, planned.value, [
118
+ { name: 'scene-color', kind: 'color', format: 'rgba16float', sampleCount: 1 },
119
+ { name: 'scene-depth', kind: 'depth', format: 'depth24plus', sampleCount: 1 },
120
+ ]).ok,
121
+ ).toBe(true);
122
+ expect(planned.value.resources).toEqual(
123
+ expect.arrayContaining([
124
+ expect.objectContaining({
125
+ kind: 'buffer',
126
+ name: expect.stringContaining('.indirect'),
127
+ usage: ['storage', 'indirect'],
128
+ }),
129
+ expect.objectContaining({ kind: 'graphics-program' }),
130
+ expect.objectContaining({ kind: 'graphics-bindings' }),
131
+ expect.objectContaining({ kind: 'vertex-data' }),
132
+ ]),
133
+ );
134
+ expect(planned.value.passes).toEqual(
135
+ expect.arrayContaining([
136
+ expect.objectContaining({ kind: 'compute' }),
137
+ expect.objectContaining({
138
+ kind: 'raster',
139
+ draws: [
140
+ expect.objectContaining({
141
+ draw: expect.objectContaining({ kind: 'draw-indirect' }),
142
+ }),
143
+ ],
144
+ }),
145
+ ]),
146
+ );
147
+ });
16
148
  });