@forgeax/engine-render 0.1.35 → 0.1.37

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 (101) hide show
  1. package/README.md +28 -0
  2. package/dist/assembly/host-contract.d.ts +2 -2
  3. package/dist/assembly/host-contract.d.ts.map +1 -1
  4. package/dist/assembly/webgpu-vertex-layouts.d.ts.map +1 -1
  5. package/dist/{chunk-MZ4C7EHF.mjs → chunk-3F5XYSN3.mjs} +5 -5
  6. package/dist/{chunk-MZ4C7EHF.mjs.map → chunk-3F5XYSN3.mjs.map} +1 -1
  7. package/dist/{chunk-G6HDNGY7.mjs → chunk-3O2BBKEL.mjs} +24 -230
  8. package/dist/chunk-3O2BBKEL.mjs.map +1 -0
  9. package/dist/{chunk-MXTWKZO4.mjs → chunk-ARLAEJ45.mjs} +225 -4
  10. package/dist/chunk-ARLAEJ45.mjs.map +1 -0
  11. package/dist/{chunk-3XUBWRY2.mjs → chunk-E4NJYUJ2.mjs} +4 -4
  12. package/dist/{chunk-3XUBWRY2.mjs.map → chunk-E4NJYUJ2.mjs.map} +1 -1
  13. package/dist/{chunk-LM5IE7GF.mjs → chunk-N7NVXQWI.mjs} +3 -3
  14. package/dist/{chunk-LM5IE7GF.mjs.map → chunk-N7NVXQWI.mjs.map} +1 -1
  15. package/dist/{chunk-SGPUJW3F.mjs → chunk-NMSOOXBB.mjs} +3 -3
  16. package/dist/{chunk-SGPUJW3F.mjs.map → chunk-NMSOOXBB.mjs.map} +1 -1
  17. package/dist/{chunk-LNIUIKQ7.mjs → chunk-SJIPVWNB.mjs} +79 -21
  18. package/dist/chunk-SJIPVWNB.mjs.map +1 -0
  19. package/dist/construct-renderer.mjs +141 -74
  20. package/dist/construct-renderer.mjs.map +1 -1
  21. package/dist/device/device-scope.d.ts.map +1 -1
  22. package/dist/features/particle-mesh-layout.d.ts +19 -0
  23. package/dist/features/particle-mesh-layout.d.ts.map +1 -0
  24. package/dist/features/types.d.ts +5 -1
  25. package/dist/features/types.d.ts.map +1 -1
  26. package/dist/gpu-driven/production-raster.d.ts +1 -1
  27. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  28. package/dist/gpu-driven/shadow-views.d.ts +1 -1
  29. package/dist/gpu-driven/shadow-views.d.ts.map +1 -1
  30. package/dist/gpu-driven/view-gpu.d.ts +1 -1
  31. package/dist/gpu-driven/view-gpu.d.ts.map +1 -1
  32. package/dist/ibl/IblPipelineCache.d.ts.map +1 -1
  33. package/dist/index.mjs +48 -36
  34. package/dist/index.mjs.map +1 -1
  35. package/dist/internal.mjs +5 -5
  36. package/dist/pbr-pipeline.d.ts.map +1 -1
  37. package/dist/publication/publisher.d.ts +1 -0
  38. package/dist/publication/publisher.d.ts.map +1 -1
  39. package/dist/record/frame.d.ts.map +1 -1
  40. package/dist/record/prepared-material-bindings.d.ts +1 -2
  41. package/dist/record/prepared-material-bindings.d.ts.map +1 -1
  42. package/dist/record/render-context.d.ts +2 -0
  43. package/dist/record/render-context.d.ts.map +1 -1
  44. package/dist/record/shadow-pass.d.ts +2 -2
  45. package/dist/record/shadow-pass.d.ts.map +1 -1
  46. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  47. package/dist/recovery/render-system-candidate.d.ts +1 -8
  48. package/dist/recovery/render-system-candidate.d.ts.map +1 -1
  49. package/dist/render-contract.d.ts +2 -2
  50. package/dist/render-contract.d.ts.map +1 -1
  51. package/dist/render-system-extract-tail.d.ts.map +1 -1
  52. package/dist/render-system-extract.d.ts.map +1 -1
  53. package/dist/render-system.d.ts +2 -1
  54. package/dist/render-system.d.ts.map +1 -1
  55. package/dist/scene/render-scene.d.ts +2 -1
  56. package/dist/scene/render-scene.d.ts.map +1 -1
  57. package/dist/temporal/index.mjs +4 -4
  58. package/dist/typed-shadow-passes.d.ts.map +1 -1
  59. package/package.json +21 -21
  60. package/src/__tests__/device-scope-lifecycle.unit.test.ts +26 -0
  61. package/src/__tests__/gpu-driven-production.integration.test.ts +41 -1
  62. package/src/__tests__/gpu-driven-shadow-views.unit.test.ts +23 -0
  63. package/src/__tests__/ibl/precompute-submission.unit.test.ts +123 -0
  64. package/src/__tests__/ibl-residency-publication.unit.test.ts +0 -16
  65. package/src/__tests__/material-vertex-texture.browser.test.ts +7 -0
  66. package/src/__tests__/material-vertex-texture.dawn.test.ts +7 -0
  67. package/src/__tests__/material-vertex-texture.fixture.ts +31 -0
  68. package/src/__tests__/recovery-candidate-prepare.contract.test.ts +5 -0
  69. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +106 -4
  70. package/src/__tests__/runtime-material-program.integration.test.ts +79 -0
  71. package/src/__tests__/vertex-layouts-ssot.unit.test.ts +23 -3
  72. package/src/assembly/host-contract.ts +2 -2
  73. package/src/assembly/webgpu-renderer.ts +12 -9
  74. package/src/assembly/webgpu-vertex-layouts.ts +2 -9
  75. package/src/device/device-scope.ts +3 -0
  76. package/src/features/particle-mesh-layout.ts +25 -0
  77. package/src/features/types.ts +5 -1
  78. package/src/gpu-driven/production-raster.ts +23 -6
  79. package/src/gpu-driven/shadow-views.ts +27 -2
  80. package/src/gpu-driven/view-gpu.ts +5 -0
  81. package/src/ibl/IblPipelineCache.ts +4 -3
  82. package/src/pbr-pipeline.ts +4 -1
  83. package/src/publication/__tests__/features.integration.test.ts +51 -0
  84. package/src/publication/__tests__/publication.integration.test.ts +59 -0
  85. package/src/publication/publisher.ts +56 -29
  86. package/src/record/__tests__/prepared-runtime-material.integration.test.ts +134 -0
  87. package/src/record/frame.ts +45 -6
  88. package/src/record/prepared-material-bindings.ts +11 -37
  89. package/src/record/render-context.ts +2 -0
  90. package/src/record/shadow-pass.ts +65 -4
  91. package/src/record/typed-frame-graph.ts +5 -1
  92. package/src/recovery/render-system-candidate.ts +1 -9
  93. package/src/render-contract.ts +2 -2
  94. package/src/render-system-extract-tail.ts +15 -6
  95. package/src/render-system-extract.ts +9 -2
  96. package/src/render-system.ts +2 -3
  97. package/src/scene/render-scene.ts +4 -2
  98. package/src/typed-shadow-passes.ts +36 -5
  99. package/dist/chunk-G6HDNGY7.mjs.map +0 -1
  100. package/dist/chunk-LNIUIKQ7.mjs.map +0 -1
  101. package/dist/chunk-MXTWKZO4.mjs.map +0 -1
@@ -71,6 +71,20 @@ function fixture() {
71
71
  }
72
72
 
73
73
  describe('native render publication', () => {
74
+ it('queries detached bounds by publication identity and rejects another epoch', () => {
75
+ const f = fixture();
76
+ const identity = { source: 'publication-test', epoch: 1 };
77
+ expect(f.scene.bounds(identity, f.first)).toBeUndefined();
78
+ f.publish();
79
+ const bounds = f.scene.bounds(identity, f.first);
80
+ expect(bounds).toBeDefined();
81
+ expect(f.scene.bounds({ ...identity, epoch: 2 }, f.first)).toBeUndefined();
82
+ expect(f.scene.bounds({ source: 'other', epoch: 1 }, f.first)).toBeUndefined();
83
+ if (bounds !== undefined) (bounds.min as number[])[0] = -999;
84
+ expect(f.scene.bounds(identity, f.first)?.min[0]).not.toBe(-999);
85
+ f.publisher.dispose();
86
+ });
87
+
74
88
  it('publishes a numeric baseline, observes no static delta and retains the same CPU slots', () => {
75
89
  const f = fixture();
76
90
  const initial = f.publish();
@@ -368,3 +382,48 @@ it('publishes detached skin poses and follows joint changes across skipped sourc
368
382
  ).toBeUndefined();
369
383
  f.publisher.dispose();
370
384
  });
385
+
386
+ it('renders a one-frame identity before its sealed removal and reuses two buffer slots', () => {
387
+ const f = fixture();
388
+ const seal = () => {
389
+ const candidate = f.publisher.prepare(0).unwrap();
390
+ const packet = structuredClone(candidate.packet, {
391
+ transfer: renderPublicationTransfers(candidate.packet),
392
+ });
393
+ candidate.accept();
394
+ return packet;
395
+ };
396
+ try {
397
+ const first = seal();
398
+ f.world.despawn(f.first).unwrap();
399
+ const replacement = f.spawn();
400
+ f.world.update(0).unwrap();
401
+ const second = seal();
402
+ // More source mutations cannot edit either already-transferred publication.
403
+ f.world.despawn(replacement).unwrap();
404
+ f.world.update(0).unwrap();
405
+ const firstFrame = f.scene.consumePublication(f.receiver.accept(first).unwrap());
406
+ expect(firstFrame.renderables.map((row) => row.entityKey)).toContain(f.first);
407
+ f.publisher.recycle(first.revision, renderPublicationTransfers(first)).unwrap();
408
+ const secondFrame = f.scene.consumePublication(f.receiver.accept(second).unwrap());
409
+ expect(secondFrame.renderables.map((row) => row.entityKey)).toContain(replacement);
410
+ expect(secondFrame.renderables.map((row) => row.entityKey)).not.toContain(f.first);
411
+ f.publisher.recycle(second.revision, renderPublicationTransfers(second)).unwrap();
412
+ // Warm both slots for equivalent changed-transform frames, then drain in pairs.
413
+ let warmAllocations: number | undefined;
414
+ for (let index = 0; index < 4; index++) {
415
+ const packets = [1, 2].map((x) => {
416
+ f.world.set(f.second, Transform, { pos: [x, index, 0] }).unwrap();
417
+ f.world.update(0).unwrap();
418
+ return seal();
419
+ });
420
+ const allocations = f.publisher.inspect().allocations;
421
+ for (const packet of packets)
422
+ f.publisher.recycle(packet.revision, renderPublicationTransfers(packet)).unwrap();
423
+ warmAllocations ??= allocations;
424
+ expect(allocations).toBe(warmAllocations);
425
+ }
426
+ } finally {
427
+ f.publisher.dispose();
428
+ }
429
+ });
@@ -68,9 +68,15 @@ export function createRenderPublisher(
68
68
  const consumers = new Map<Handle<string, 'shared'>, Set<number>>();
69
69
  const pending = new Set<number>();
70
70
  let videoConsumers = new Map<number, readonly number[]>();
71
- let pendingFeatures: RenderPublication['features'] = [];
72
- let pendingFeatureOwners: readonly RenderFeature<unknown>[] = [];
73
- const acknowledgedFeatures = new Set<string>();
71
+ // Two slots: the consuming frame and one sealed successor.
72
+ const flights = new Map<
73
+ number,
74
+ {
75
+ features: RenderPublication['features'];
76
+ owners: readonly RenderFeature<unknown>[];
77
+ submitted: boolean;
78
+ }
79
+ >();
74
80
  const jointsByEntity = new Map<number, readonly number[]>();
75
81
  const skinConsumers = new Map<number, Set<number>>();
76
82
  let frameDependencies = new Set<Handle<string, 'shared'>>();
@@ -79,9 +85,9 @@ export function createRenderPublisher(
79
85
  disposed = false,
80
86
  active = false;
81
87
  let lastScannedRows = 0,
82
- inFlight = false,
83
88
  allocations = 0;
84
89
  let storage = [new ArrayBuffer(0), new ArrayBuffer(0), new ArrayBuffer(0), new ArrayBuffer(0)];
90
+ const spareStorage: ArrayBuffer[][] = [];
85
91
  const buffer = (index: number, bytes: number): ArrayBuffer => {
86
92
  let value = storage[index] as ArrayBuffer;
87
93
  if (value.byteLength < bytes) {
@@ -104,7 +110,8 @@ export function createRenderPublisher(
104
110
  members: members.size,
105
111
  assetCount: consumers.size,
106
112
  scannedRows: lastScannedRows,
107
- inFlight,
113
+ inFlight: flights.size > 0,
114
+ inFlightCount: flights.size,
108
115
  allocations,
109
116
  }),
110
117
  prepare(
@@ -112,7 +119,8 @@ export function createRenderPublisher(
112
119
  temporalReset = false,
113
120
  ): Result<RenderPublicationCandidate, RenderPublicationError> {
114
121
  if (disposed) return fail('disposed', 'publisher');
115
- if (active || inFlight) return fail('in-flight', 'publication storage has not been returned');
122
+ if (active || flights.size === 2)
123
+ return fail('in-flight', 'both publication slots are occupied');
116
124
  const baseline = revision === 0;
117
125
  const featureSources = [...features];
118
126
  const batch = source.projection.read();
@@ -330,7 +338,18 @@ export function createRenderPublisher(
330
338
  if (seenGuids.has(key)) continue;
331
339
  seenGuids.add(key);
332
340
  const asset = assets.lookup(key);
333
- const handle = internSharedRefFromGuid(world, assets, key, 'RenderFeatureAsset');
341
+ // Dynamic asset content is keyed by the canonical World handle. Feature
342
+ // dependencies must share the same identity as ordinary scene consumers.
343
+ const handle = internSharedRefFromGuid(
344
+ world,
345
+ assets,
346
+ key,
347
+ asset?.kind === 'material'
348
+ ? 'MaterialAsset'
349
+ : asset?.kind === 'mesh'
350
+ ? 'MeshAsset'
351
+ : 'RenderFeatureAsset',
352
+ );
334
353
  if (asset === undefined || handle === undefined)
335
354
  throw new Error(`Missing feature asset ${guid}`);
336
355
  nextFrameDependencies.add(handle);
@@ -455,7 +474,10 @@ export function createRenderPublisher(
455
474
  transformEntities,
456
475
  transforms: matrices,
457
476
  assets: [...assetRows].map(([handle, value]) => {
458
- const guid = assets.guidOf(value);
477
+ // Runtime content is a projection, so GUID identity stays with the
478
+ // underlying catalogued payload rather than the projected object.
479
+ const source = world.sharedRefs.resolve<string, Asset>(handle);
480
+ const guid = assets.guidOf(source.ok ? source.value : value);
459
481
  return { handle, value, ...(guid === undefined ? {} : { guid }) };
460
482
  }),
461
483
  retiredAssets: [...retired],
@@ -474,13 +496,20 @@ export function createRenderPublisher(
474
496
  for (const key of programs.keys()) publishedPrograms.add(key);
475
497
  closed = true;
476
498
  active = false;
477
- inFlight = true;
478
499
  revision++;
500
+ flights.set(revision, {
501
+ features: featureRows,
502
+ owners: featureSources,
503
+ submitted: false,
504
+ });
505
+ storage = spareStorage.pop() ?? [
506
+ new ArrayBuffer(0),
507
+ new ArrayBuffer(0),
508
+ new ArrayBuffer(0),
509
+ new ArrayBuffer(0),
510
+ ];
479
511
  for (const row of videoFrames) row.frame.close();
480
512
  videoConsumers = nextVideoConsumers;
481
- pendingFeatures = featureRows;
482
- pendingFeatureOwners = featureSources;
483
- acknowledgedFeatures.clear();
484
513
  frameDependencies = nextFrameDependencies;
485
514
  catalogEpoch = assets.catalogEpoch;
486
515
  for (const entity of removedConsumers) {
@@ -560,26 +589,23 @@ export function createRenderPublisher(
560
589
  receipts: readonly { readonly identity: string; readonly feedback: unknown }[],
561
590
  ): Result<void, RenderPublicationError> {
562
591
  if (disposed) return fail('disposed', 'publisher');
563
- if (!inFlight || revision !== acceptedRevision)
592
+ const flight = flights.get(acceptedRevision);
593
+ if (flight === undefined || flight.submitted)
564
594
  return fail('revision', 'feature acknowledgment');
565
595
  const identities = new Set<string>();
566
596
  for (const receipt of receipts) {
567
- if (identities.has(receipt.identity) || acknowledgedFeatures.has(receipt.identity))
597
+ if (identities.has(receipt.identity))
568
598
  return fail('revision', 'duplicate feature acknowledgment');
569
- const row = pendingFeatures.find((row) => row.identity === receipt.identity);
570
- const feature = pendingFeatureOwners.find(
571
- (feature) => feature.identity === receipt.identity,
572
- );
599
+ const row = flight.features.find((row) => row.identity === receipt.identity);
600
+ const feature = flight.owners.find((feature) => feature.identity === receipt.identity);
573
601
  if (row === undefined || feature === undefined)
574
602
  return fail('shape', 'unknown feature acknowledgment');
575
603
  identities.add(receipt.identity);
576
604
  }
605
+ flight.submitted = true;
577
606
  for (const receipt of receipts) {
578
- acknowledgedFeatures.add(receipt.identity);
579
- const row = pendingFeatures.find((row) => row.identity === receipt.identity);
580
- const feature = pendingFeatureOwners.find(
581
- (feature) => feature.identity === receipt.identity,
582
- );
607
+ const row = flight.features.find((row) => row.identity === receipt.identity);
608
+ const feature = flight.owners.find((feature) => feature.identity === receipt.identity);
583
609
  feature?.onSourceFrameSubmitted?.(row?.data, receipt.feedback);
584
610
  }
585
611
  return ok(undefined);
@@ -589,12 +615,14 @@ export function createRenderPublisher(
589
615
  buffers: readonly ArrayBuffer[],
590
616
  ): Result<void, RenderPublicationError> {
591
617
  if (disposed) return fail('disposed', 'publisher');
592
- if (!inFlight || acceptedRevision !== revision)
618
+ if (flights.keys().next().value !== acceptedRevision)
593
619
  return fail('revision', 'returned storage revision');
594
620
  if (buffers.length !== 4 || buffers.some((value) => !(value instanceof ArrayBuffer)))
595
621
  return fail('shape', 'returned storage');
596
- storage = [...buffers];
597
- inFlight = false;
622
+ if (active || storage.some((buffer) => buffer.byteLength > 0))
623
+ spareStorage.push([...buffers]);
624
+ else storage = [...buffers];
625
+ flights.delete(acceptedRevision);
598
626
  return ok(undefined);
599
627
  },
600
628
  dispose() {
@@ -607,9 +635,8 @@ export function createRenderPublisher(
607
635
  pending.clear();
608
636
  pendingMeshContent.clear();
609
637
  videoConsumers.clear();
610
- pendingFeatures = [];
611
- pendingFeatureOwners = [];
612
- acknowledgedFeatures.clear();
638
+ flights.clear();
639
+ spareStorage.length = 0;
613
640
  frameDependencies.clear();
614
641
  jointsByEntity.clear();
615
642
  skinConsumers.clear();
@@ -0,0 +1,134 @@
1
+ import { AssetRegistry, RuntimeMaterialValue } from '@forgeax/engine-assets-runtime';
2
+ import { World } from '@forgeax/engine-ecs';
3
+ import type { BindGroupLayout } from '@forgeax/engine-rhi';
4
+ import { ShaderRegistry } from '@forgeax/engine-shader';
5
+ import { type MaterialAsset, ok } from '@forgeax/engine-types';
6
+ import { expect, it } from 'vitest';
7
+ import { renderPublicationTransfers } from '../../publication/contract';
8
+ import { createRenderPublisher } from '../../publication/publisher';
9
+ import { RenderPublicationReceiver } from '../../publication/receiver';
10
+ import { preparedMaterialBindings } from '../prepared-material-bindings';
11
+ import type { RenderSystemInternals } from '../render-context';
12
+
13
+ it('uploads accepted values on World and published-reader paths without mutating the base', () => {
14
+ const world = new World();
15
+ const assets = new AssetRegistry(new ShaderRegistry({ manifestUrl: undefined }));
16
+ const guid = '12345678-1234-1234-1234-123456789abc';
17
+ const base: MaterialAsset = { kind: 'material', values: { roughness: 0.8 } };
18
+ assets.catalog(guid, base);
19
+ const handle = world.internSharedRef('MaterialAsset', base);
20
+ const content = world
21
+ .spawn({
22
+ component: RuntimeMaterialValue,
23
+ data: { asset: handle, parameter: 'roughness', kind: 0, value: [0.25] },
24
+ })
25
+ .unwrap();
26
+ const uploads: number[] = [];
27
+ let destroys = 0;
28
+ const runtime = {
29
+ assets,
30
+ getParamSchema: () => [{ name: 'roughness', type: 'f32' }],
31
+ getPipelineState: () => ({
32
+ defaultSampler: {},
33
+ skylightFallback: {
34
+ irradianceView: {},
35
+ prefilterView: {},
36
+ brdfLutView: {},
37
+ sampler: {},
38
+ intensityBuffer: {},
39
+ },
40
+ }),
41
+ device: {
42
+ caps: { storageBuffer: false },
43
+ limits: { maxSampledTexturesPerShaderStage: 16 },
44
+ createBuffer: () => ok({}),
45
+ destroyBuffer: () => {
46
+ destroys++;
47
+ return ok(undefined);
48
+ },
49
+ createBindGroup: () => ok({}),
50
+ queue: {
51
+ writeBuffer: (_buffer: unknown, _offset: number, bytes: Uint8Array) => {
52
+ uploads.push(new Float32Array(bytes.buffer, bytes.byteOffset, 1)[0] ?? NaN);
53
+ return ok(undefined);
54
+ },
55
+ },
56
+ },
57
+ } as unknown as RenderSystemInternals;
58
+ const identity = { source: 'material-values', epoch: 1 };
59
+ const publisher = createRenderPublisher(world, assets, identity, undefined, [
60
+ {
61
+ identity: 'material-consumer',
62
+ extract: () => ok({}),
63
+ assetDependencies: () => [guid],
64
+ plan: () => ok({ resources: [], passes: [] }),
65
+ },
66
+ ]);
67
+ const receiver = new RenderPublicationReceiver(identity);
68
+ const publish = () => {
69
+ const candidate = publisher.prepare(0).unwrap();
70
+ const packet = structuredClone(candidate.packet);
71
+ candidate.accept();
72
+ const resources = receiver.accept(packet).unwrap().resources;
73
+ publisher.recycle(packet.revision, renderPublicationTransfers(packet)).unwrap();
74
+ return resources;
75
+ };
76
+ const reader = publish();
77
+ for (const source of [world, reader]) {
78
+ const bindings = preparedMaterialBindings(
79
+ runtime,
80
+ [source],
81
+ 'shader',
82
+ 0,
83
+ guid,
84
+ {} as BindGroupLayout,
85
+ ).unwrap();
86
+ if ('release' in bindings) bindings.release?.();
87
+ }
88
+ expect(uploads).toEqual([0.25, 0.25]);
89
+ expect(base.values?.roughness).toBe(0.8);
90
+ const other = new World();
91
+ const otherHandle = other.internSharedRef('MaterialAsset', base);
92
+ const otherContent = other
93
+ .spawn({
94
+ component: RuntimeMaterialValue,
95
+ data: {
96
+ asset: otherHandle,
97
+ parameter: 'roughness',
98
+ kind: 0,
99
+ value: [0.6],
100
+ },
101
+ })
102
+ .unwrap();
103
+ for (const [owner, expected] of [
104
+ [1, 0.6],
105
+ [0, 0.25],
106
+ ] as const) {
107
+ const bindings = preparedMaterialBindings(
108
+ runtime,
109
+ [world, other],
110
+ 'shader',
111
+ owner,
112
+ guid,
113
+ {} as BindGroupLayout,
114
+ ).unwrap();
115
+ expect(uploads.at(-1)).toBeCloseTo(expected);
116
+ if ('release' in bindings) bindings.release?.();
117
+ }
118
+ other.despawn(otherContent).unwrap();
119
+ world.despawn(content).unwrap();
120
+ for (const source of [world, publish()]) {
121
+ const restored = preparedMaterialBindings(
122
+ runtime,
123
+ [source],
124
+ 'shader',
125
+ 0,
126
+ guid,
127
+ {} as BindGroupLayout,
128
+ ).unwrap();
129
+ expect(uploads.at(-1)).toBeCloseTo(0.8);
130
+ if ('release' in restored) restored.release?.();
131
+ }
132
+ expect(destroys).toBe(6);
133
+ publisher.dispose();
134
+ });
@@ -58,7 +58,7 @@ import {
58
58
  resolveDepthOfFieldFrameParams,
59
59
  } from '../features/depth-of-field/depth-of-field-params';
60
60
  import type { GpuDrivenProduction, PreparedGpuDrivenFrame } from '../gpu-driven/production-raster';
61
- import type { ShadowViewIdentity } from '../gpu-driven/shadow-views';
61
+ import { type ShadowViewIdentity, shadowViewIdentityKey } from '../gpu-driven/shadow-views';
62
62
  import type { GpuDrivenLodSubmitIdentity } from '../gpu-driven/view-gpu';
63
63
  import { GPU_TEXTURE_USAGE_COPY_SRC } from '../gpu-texture-usage';
64
64
  import { GPU_BUFFER_USAGE_COPY_DST, GPU_BUFFER_USAGE_MAP_READ } from '../gpu-usage';
@@ -484,8 +484,42 @@ function shadowPlanes(matrix: Float32Array): Float32Array {
484
484
  return frustum.fromViewProjection(frustum.create(), matrix as never);
485
485
  }
486
486
 
487
- function shadowViewToken(matrix: Float32Array): string {
488
- return Array.from(matrix).join(',');
487
+ function shadowViewPlanesByView(lights: ExtractedLights): ReadonlyMap<string, Float32Array> {
488
+ const views = new Map<string, Float32Array>();
489
+ for (const [index, matrix] of (lights.lightViewProj ?? []).entries()) {
490
+ views.set(shadowViewIdentityKey({ kind: 'directional', index }), shadowPlanes(matrix));
491
+ }
492
+ for (const [index, point] of lights.pointShadow.entries()) {
493
+ for (let face = 0; face < 6; face += 1) {
494
+ views.set(
495
+ shadowViewIdentityKey({ kind: 'point', index, face }),
496
+ shadowPlanes(point.shadowMatrices.subarray(face * 16, face * 16 + 16)),
497
+ );
498
+ }
499
+ }
500
+ let spotIndex = 0;
501
+ for (const spot of lights.spot) {
502
+ if (spot.lightViewProj === undefined) continue;
503
+ views.set(
504
+ shadowViewIdentityKey({ kind: 'spot', index: spotIndex }),
505
+ shadowPlanes(spot.lightViewProj),
506
+ );
507
+ spotIndex += 1;
508
+ }
509
+ return views;
510
+ }
511
+
512
+ function shadowViewToken(
513
+ matrix: Float32Array,
514
+ targetSize: number | undefined,
515
+ graphGeneration: number,
516
+ ): string {
517
+ // A newly accepted graph imports a new resource projection and must refresh
518
+ // the shadow compute inputs once. The graph-generation component is a
519
+ // one-shot resource fence; it is deliberately excluded from the GPU
520
+ // producer's topology signature so that this refresh cannot rebuild the
521
+ // graph indefinitely.
522
+ return `${Array.from(matrix).join(',')}|target:${targetSize}|graph:${graphGeneration}`;
489
523
  }
490
524
 
491
525
  function halfFloatToNumber(bits: number): number {
@@ -1948,7 +1982,7 @@ export function recordFrame(
1948
1982
  shadowViews.push({
1949
1983
  identity: { kind: 'directional', index },
1950
1984
  planes: shadowPlanes(matrix),
1951
- cacheToken: `${shadowViewToken(matrix)}|target:${graphShadowMapSize}:${frameState.graphGeneration}`,
1985
+ cacheToken: shadowViewToken(matrix, graphShadowMapSize, frameState.graphGeneration),
1952
1986
  });
1953
1987
  }
1954
1988
  for (const [index, point] of lights.pointShadow.entries()) {
@@ -1957,7 +1991,7 @@ export function recordFrame(
1957
1991
  shadowViews.push({
1958
1992
  identity: { kind: 'point', index, face },
1959
1993
  planes: shadowPlanes(matrix),
1960
- cacheToken: `${shadowViewToken(matrix)}|target:${graphShadowMapSize}:${frameState.graphGeneration}`,
1994
+ cacheToken: shadowViewToken(matrix, graphShadowMapSize, frameState.graphGeneration),
1961
1995
  });
1962
1996
  }
1963
1997
  }
@@ -1967,7 +2001,11 @@ export function recordFrame(
1967
2001
  shadowViews.push({
1968
2002
  identity: { kind: 'spot', index: shadowSpotIndex },
1969
2003
  planes: shadowPlanes(spot.lightViewProj),
1970
- cacheToken: `${shadowViewToken(spot.lightViewProj)}|target:${graphShadowMapSize}:${frameState.graphGeneration}`,
2004
+ cacheToken: shadowViewToken(
2005
+ spot.lightViewProj,
2006
+ graphShadowMapSize,
2007
+ frameState.graphGeneration,
2008
+ ),
1971
2009
  });
1972
2010
  shadowSpotIndex += 1;
1973
2011
  }
@@ -2727,6 +2765,7 @@ export function recordFrame(
2727
2765
  gpuDrivenShadowDrawKeysByView: rasterGpuDriven.shadowDrawKeysByView,
2728
2766
  }),
2729
2767
  }),
2768
+ shadowViewPlanesByView: shadowViewPlanesByView(lights),
2730
2769
  ...(frameGpuDriven?.scene === undefined
2731
2770
  ? {}
2732
2771
  : { gpuDrivenSceneMaterialBuffer: frameGpuDriven.scene.scene.materialBuffer }),
@@ -14,7 +14,7 @@ import { GPU_BUFFER_USAGE_COPY_DST, GPU_BUFFER_USAGE_UNIFORM } from '../gpu-usag
14
14
  import type { SkylightBindGroupResources } from '../ibl/skylight-bind-group';
15
15
  import { assembleMaterialWithSkylightEntries } from '../ibl/skylight-bind-group';
16
16
  import type { RenderResourceScope } from '../publication/resource-scope';
17
- import { renderAssetByGuid } from '../publication/resource-scope';
17
+ import { internSharedRefFromGuid } from '../render-system-extract';
18
18
  import {
19
19
  applyParamSchemaDefaultsToUbo,
20
20
  applyParamSnapshotToUbo,
@@ -29,7 +29,6 @@ export type PreparedResolverCaches = {
29
29
  readonly preparedGroup0Pipelines: WeakSet<object>;
30
30
  readonly preparedViewOnlyPipelines: WeakSet<object>;
31
31
  readonly preparedRenderMaterialPipelines: WeakSet<object>;
32
- readonly preparedAssetHandles: WeakMap<RenderResourceScope, Map<string, number>>;
33
32
  };
34
33
 
35
34
  export function createPreparedResolverCaches(): PreparedResolverCaches {
@@ -39,37 +38,22 @@ export function createPreparedResolverCaches(): PreparedResolverCaches {
39
38
  preparedGroup0Pipelines: new WeakSet(),
40
39
  preparedViewOnlyPipelines: new WeakSet(),
41
40
  preparedRenderMaterialPipelines: new WeakSet(),
42
- preparedAssetHandles: new WeakMap(),
43
41
  };
44
42
  }
45
43
 
46
44
  function preparedHandle<Brand extends string>(
47
45
  runtime: RenderSystemInternals,
48
- caches: PreparedResolverCaches,
49
46
  world: RenderResourceScope,
50
47
  guid: string,
51
48
  brand: Brand,
52
49
  ): Handle<Brand, 'shared'> | undefined {
53
- let handles = caches.preparedAssetHandles.get(world);
54
- if (handles === undefined) {
55
- handles = new Map();
56
- caches.preparedAssetHandles.set(world, handles);
57
- }
58
- const key = `${brand}:${guid.toLowerCase()}`;
59
- const cached = handles.get(key);
60
- if (cached !== undefined) return cached as Handle<Brand, 'shared'>;
61
50
  if ('resolveAsset' in world)
62
51
  return world.handleForGuid(guid) as Handle<Brand, 'shared'> | undefined;
63
- const asset = runtime.assets.lookup(guid);
64
- if (asset === undefined) return undefined;
65
- const handle = world.allocSharedRef(brand, asset);
66
- handles.set(key, handle as number);
67
- return handle;
52
+ return internSharedRefFromGuid(world, runtime.assets, guid, brand);
68
53
  }
69
54
 
70
55
  export function preparedMaterialBindings(
71
56
  runtime: RenderSystemInternals,
72
- caches: PreparedResolverCaches,
73
57
  worlds: readonly RenderResourceScope[],
74
58
  materialShaderId: string,
75
59
  worldIndex: number,
@@ -78,14 +62,16 @@ export function preparedMaterialBindings(
78
62
  skylightResources?: SkylightBindGroupResources,
79
63
  ) {
80
64
  const world = worlds[worldIndex];
81
- const material =
82
- world === undefined
83
- ? undefined
84
- : renderAssetByGuid<MaterialAsset>(world, runtime.assets, materialGuid);
65
+ const handle =
66
+ world === undefined ? undefined : preparedHandle(runtime, world, materialGuid, 'MaterialAsset');
85
67
  const pipelineState = runtime.getPipelineState();
86
- if (world === undefined || material?.kind !== 'material' || pipelineState === null) {
68
+ if (world === undefined || handle === undefined || pipelineState === null) {
87
69
  return err(new Error('prepared material asset is unavailable'));
88
70
  }
71
+ const resolved = resolveAssetHandle<MaterialAsset>(world, handle);
72
+ if (!resolved.ok) return resolved;
73
+ const material = resolved.value;
74
+ if (material.kind !== 'material') return err(new Error('prepared material asset is unavailable'));
89
75
  const schema = runtime.getParamSchema?.(materialShaderId) ?? [];
90
76
  // Feature materials share the ordinary linear parameter domain and semantic texture defaults.
91
77
  const values = materialValuesToLinearRuntime(
@@ -132,13 +118,7 @@ export function preparedMaterialBindings(
132
118
  : undefined;
133
119
  let sampler = pipelineState.defaultSampler;
134
120
  if (textureValue?.sampler !== undefined) {
135
- const handle = preparedHandle(
136
- runtime,
137
- caches,
138
- world,
139
- String(textureValue.sampler),
140
- 'SamplerAsset',
141
- );
121
+ const handle = preparedHandle(runtime, world, String(textureValue.sampler), 'SamplerAsset');
142
122
  if (handle !== undefined) {
143
123
  const asset = resolveAssetHandle<SamplerAsset>(world, handle);
144
124
  if (asset.ok) {
@@ -149,13 +129,7 @@ export function preparedMaterialBindings(
149
129
  }
150
130
  let view = defaultViewForUserRegionField(field, pipelineState, schema);
151
131
  if (textureValue !== undefined) {
152
- const handle = preparedHandle(
153
- runtime,
154
- caches,
155
- world,
156
- String(textureValue.texture),
157
- 'TextureAsset',
158
- );
132
+ const handle = preparedHandle(runtime, world, String(textureValue.texture), 'TextureAsset');
159
133
  if (handle !== undefined) {
160
134
  view = residentTextureView(world, runtime.gpuStore, runtime, handle) ?? view;
161
135
  }
@@ -620,6 +620,8 @@ export interface _StandardForwardSceneView {
620
620
  readonly gpuDrivenShadowDrawKeys?: ReadonlySet<string>;
621
621
  /** View-scoped ShadowCaster claims for exact CPU residual routing. */
622
622
  readonly gpuDrivenShadowDrawKeysByView?: ReadonlyMap<string, ReadonlySet<string>>;
623
+ /** Per-shadow-view frusta shared by GPU compaction and CPU residual routing. */
624
+ readonly shadowViewPlanesByView?: ReadonlyMap<string, Float32Array>;
623
625
  /** Extracted structured ShadowCaster ownership facts. */
624
626
  readonly shadowCasterMembership?: readonly ShadowCasterMembership[];
625
627
  /** Complete retained ShadowCaster rows, including camera-culled casters. */