@forgeax/engine-render 0.1.24 → 0.1.25

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 (135) hide show
  1. package/README.md +100 -1
  2. package/dist/assembly/factory.d.ts +1 -1
  3. package/dist/assembly/factory.d.ts.map +1 -1
  4. package/dist/assembly/material/surface-projection.d.ts +24 -0
  5. package/dist/assembly/material/surface-projection.d.ts.map +1 -0
  6. package/dist/assembly/material-shader-policy.d.ts +20 -0
  7. package/dist/assembly/material-shader-policy.d.ts.map +1 -1
  8. package/dist/assembly/webgpu-ready.d.ts.map +1 -1
  9. package/dist/assembly/webgpu-renderer.d.ts +1 -1
  10. package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
  11. package/dist/assembly/webgpu-vertex-layouts.d.ts +89 -0
  12. package/dist/assembly/webgpu-vertex-layouts.d.ts.map +1 -0
  13. package/dist/assets/asset-decoders.d.ts.map +1 -1
  14. package/dist/authoring.mjs +1 -1
  15. package/dist/{chunk-Q752SHI5.mjs → chunk-ADHHVYLW.mjs} +3 -3
  16. package/dist/chunk-ADHHVYLW.mjs.map +1 -0
  17. package/dist/{chunk-TFH5I7JJ.mjs → chunk-E34VL5VI.mjs} +3 -3
  18. package/dist/{chunk-TFH5I7JJ.mjs.map → chunk-E34VL5VI.mjs.map} +1 -1
  19. package/dist/{chunk-KZFTK6DO.mjs → chunk-HKXTW355.mjs} +7 -4
  20. package/dist/chunk-HKXTW355.mjs.map +1 -0
  21. package/dist/{chunk-YKB5DQV7.mjs → chunk-JSEDTZYS.mjs} +87 -19
  22. package/dist/chunk-JSEDTZYS.mjs.map +1 -0
  23. package/dist/{chunk-UQYS7FRM.mjs → chunk-KM2NOX2I.mjs} +4 -4
  24. package/dist/{chunk-UQYS7FRM.mjs.map → chunk-KM2NOX2I.mjs.map} +1 -1
  25. package/dist/{chunk-Z3YYH2MB.mjs → chunk-N3RT2EUR.mjs} +94 -41
  26. package/dist/chunk-N3RT2EUR.mjs.map +1 -0
  27. package/dist/{chunk-YRUYGA4A.mjs → chunk-TCLDX7SZ.mjs} +559 -138
  28. package/dist/chunk-TCLDX7SZ.mjs.map +1 -0
  29. package/dist/{chunk-RR5L4SRS.mjs → chunk-ZZ4YQ474.mjs} +2 -2
  30. package/dist/{chunk-RR5L4SRS.mjs.map → chunk-ZZ4YQ474.mjs.map} +1 -1
  31. package/dist/components/instances.d.ts +13 -31
  32. package/dist/components/instances.d.ts.map +1 -1
  33. package/dist/construct-renderer.mjs +865 -142
  34. package/dist/construct-renderer.mjs.map +1 -1
  35. package/dist/gpu-driven/production-raster.d.ts +6 -1
  36. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  37. package/dist/gpu-scene.d.ts +4 -0
  38. package/dist/gpu-scene.d.ts.map +1 -1
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.mjs +27 -9
  42. package/dist/index.mjs.map +1 -1
  43. package/dist/instance-buffer-cache.d.ts +7 -1
  44. package/dist/instance-buffer-cache.d.ts.map +1 -1
  45. package/dist/instances.d.ts +101 -0
  46. package/dist/instances.d.ts.map +1 -0
  47. package/dist/internal.mjs +5 -5
  48. package/dist/materials.d.ts +63 -2
  49. package/dist/materials.d.ts.map +1 -1
  50. package/dist/pipeline-builder.d.ts.map +1 -1
  51. package/dist/pipeline-spec.d.ts.map +1 -1
  52. package/dist/record/frame-snapshot.d.ts +13 -2
  53. package/dist/record/frame-snapshot.d.ts.map +1 -1
  54. package/dist/record/frame.d.ts.map +1 -1
  55. package/dist/record/main-pass-geometry.d.ts +22 -3
  56. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  57. package/dist/record/render-context.d.ts +2 -1
  58. package/dist/record/render-context.d.ts.map +1 -1
  59. package/dist/record/shadow-pass.d.ts +11 -1
  60. package/dist/record/shadow-pass.d.ts.map +1 -1
  61. package/dist/recovery/render-system-candidate.d.ts +1 -1
  62. package/dist/recovery/render-system-candidate.d.ts.map +1 -1
  63. package/dist/render-contract.d.ts +4 -0
  64. package/dist/render-contract.d.ts.map +1 -1
  65. package/dist/render-system-extract-tail.d.ts +4 -0
  66. package/dist/render-system-extract-tail.d.ts.map +1 -1
  67. package/dist/render-system-extract.d.ts +25 -1
  68. package/dist/render-system-extract.d.ts.map +1 -1
  69. package/dist/render-system.d.ts +4 -1
  70. package/dist/render-system.d.ts.map +1 -1
  71. package/dist/scene/render-scene-types.d.ts +2 -0
  72. package/dist/scene/render-scene-types.d.ts.map +1 -1
  73. package/dist/scene/render-scene.d.ts +12 -1
  74. package/dist/scene/render-scene.d.ts.map +1 -1
  75. package/dist/systems/skin-palette-allocator.d.ts +6 -0
  76. package/dist/systems/skin-palette-allocator.d.ts.map +1 -1
  77. package/dist/temporal/index.mjs +5 -5
  78. package/package.json +20 -20
  79. package/src/__tests__/gpu-scene.unit.test.ts +38 -2
  80. package/src/__tests__/instances-store.unit.test.ts +46 -0
  81. package/src/__tests__/instances-world-ownership.integration.test.ts +62 -0
  82. package/src/__tests__/material-contract-inventory.unit.test.ts +5 -13
  83. package/src/__tests__/material-pass-policy.unit.test.ts +69 -0
  84. package/src/__tests__/material-snapshot-mutation.integration.test.ts +69 -0
  85. package/src/__tests__/material-surface-authoring.test-d.ts +62 -0
  86. package/src/__tests__/materials-transmission-validation.unit.test.ts +24 -0
  87. package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +0 -4
  88. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +42 -0
  89. package/src/__tests__/skin-motion-regression.unit.test.ts +33 -0
  90. package/src/__tests__/skinned-shadow-caster.test.ts +30 -2
  91. package/src/__tests__/surface-adjacent-regressions.integration.test.ts +60 -0
  92. package/src/__tests__/surface-standard-pipeline.browser.test.ts +20 -0
  93. package/src/__tests__/surface-standard-pipeline.dawn.test.ts +20 -0
  94. package/src/__tests__/surface-standard-pipeline.fixture.ts +132 -0
  95. package/src/__tests__/surface-variant-provenance.integration.test.ts +33 -0
  96. package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +1 -1
  97. package/src/assembly/factory.ts +2 -0
  98. package/src/assembly/material/__tests__/standard-layer-plan-projection.integration.test.ts +18 -0
  99. package/src/assembly/material/__tests__/surface-pass-projection.integration.test.ts +62 -0
  100. package/src/assembly/material/surface-projection.ts +90 -0
  101. package/src/assembly/material-shader-policy.ts +88 -10
  102. package/src/assembly/recovery/renderer-recover.ts +1 -1
  103. package/src/assembly/webgpu-ready.ts +3 -16
  104. package/src/assembly/webgpu-renderer.ts +90 -158
  105. package/src/assembly/webgpu-vertex-layouts.ts +132 -0
  106. package/src/assets/asset-decoders.ts +27 -4
  107. package/src/components/instances.ts +14 -197
  108. package/src/gpu-driven/production-raster.ts +34 -1
  109. package/src/gpu-scene.ts +84 -29
  110. package/src/index.ts +1 -0
  111. package/src/instance-buffer-cache.ts +14 -1
  112. package/src/instances.ts +288 -0
  113. package/src/materials.ts +107 -12
  114. package/src/pipeline-builder.ts +14 -10
  115. package/src/pipeline-spec.ts +6 -14
  116. package/src/record/frame-snapshot.ts +19 -2
  117. package/src/record/frame.ts +31 -2
  118. package/src/record/main-pass-geometry.ts +498 -59
  119. package/src/record/main-pass-sprite-draws.ts +1 -1
  120. package/src/record/render-context.ts +4 -0
  121. package/src/record/shadow-pass.ts +46 -99
  122. package/src/recovery/render-system-candidate.ts +41 -4
  123. package/src/render-contract.ts +4 -0
  124. package/src/render-system-extract-tail.ts +107 -51
  125. package/src/render-system-extract.ts +53 -26
  126. package/src/render-system.ts +40 -5
  127. package/src/scene/render-scene-types.ts +2 -0
  128. package/src/scene/render-scene.ts +579 -36
  129. package/src/systems/skin-palette-allocator.ts +129 -7
  130. package/src/transmission/__tests__/surface-compatibility.integration.test.ts +32 -0
  131. package/dist/chunk-KZFTK6DO.mjs.map +0 -1
  132. package/dist/chunk-Q752SHI5.mjs.map +0 -1
  133. package/dist/chunk-YKB5DQV7.mjs.map +0 -1
  134. package/dist/chunk-YRUYGA4A.mjs.map +0 -1
  135. package/dist/chunk-Z3YYH2MB.mjs.map +0 -1
@@ -26,6 +26,7 @@ import {
26
26
  type MaterialShaderArtifact,
27
27
  } from '@forgeax/engine-shader';
28
28
  import type {
29
+ MaterialAsset,
29
30
  MaterialRenderState,
30
31
  ParamSchemaEntry,
31
32
  PassKind,
@@ -155,6 +156,9 @@ export interface RenderSystemRuntime {
155
156
  readonly errorRegistry: RhiErrorListenerRegistry;
156
157
  readonly debugOverlay?: RenderDebugOverlay | undefined;
157
158
  readonly healthRegistry: HealthListenerRegistry;
159
+ readonly getMaterialProjectionForPayload?: (
160
+ material: MaterialAsset,
161
+ ) => MaterialRenderProjection | undefined;
158
162
  readonly getMaterialProjection?: (materialGuid: string) => MaterialRenderProjection | undefined;
159
163
  readonly getMaterialArtifact?: (specializationKey: string) => MaterialRuntimeArtifact | undefined;
160
164
  /** Producer-owned cooked Standard PBR artifact used by GPU-driven projection. */
@@ -8,14 +8,8 @@ import {
8
8
  type RhiRenderPassEncoder,
9
9
  } from '@forgeax/engine-rhi';
10
10
  import type { MaterialRenderState, PassSelector } from '@forgeax/engine-types';
11
- import { GpuBuffer } from '../gpu-resource';
12
- import {
13
- GPU_BUFFER_USAGE_COPY_DST,
14
- GPU_BUFFER_USAGE_STORAGE,
15
- GPU_BUFFER_USAGE_UNIFORM,
16
- } from '../gpu-usage';
11
+ import type { GpuBuffer } from '../gpu-resource';
17
12
  import { assembleMaterialWithSkylightEntries } from '../ibl/skylight-bind-group';
18
- import type { InstanceBufferCacheEntry } from '../instance-buffer-cache';
19
13
  import {
20
14
  buildPbrMaterialUserRegionEntries,
21
15
  createPbrSkinMeshBindGroupEntries,
@@ -26,15 +20,15 @@ import {
26
20
  import { COOKIE_MATRIX_BYTES } from '../prepare/extended-lighting/resources';
27
21
  import type { DispatchEntry, ExtractedLights } from '../render-system-extract';
28
22
  import { matchPass } from '../systems/pass-selector';
29
- import { worldEntityKey } from './frame-snapshot';
30
- import { resolveGeometryInstancesBindGroup } from './main-pass-geometry';
23
+ import {
24
+ resolveGeometryInstanceBuffer,
25
+ resolveGeometryInstancesBindGroup,
26
+ } from './main-pass-geometry';
31
27
  import {
32
28
  getOrCreateFromChain,
33
- MAX_UNIFORM_INSTANCES,
34
29
  MESH_PER_ENTITY_STRIDE,
35
30
  MESH_SSBO_BYTES,
36
31
  MESH_UBO_FULL_ARRAY_BYTES,
37
- packInstanceStorageBuffer,
38
32
  } from './mesh-ssbo';
39
33
  import type { _InternalRenderPipelineContext } from './render-context';
40
34
  import { STANDARD_PBR_UBO_SIZE } from './render-context';
@@ -253,13 +247,16 @@ function ensureTypedShadowViewBg(
253
247
  }
254
248
 
255
249
  interface ShadowDispatch {
250
+ readonly materialHandle: number;
256
251
  readonly materialShaderId: string;
257
252
  readonly renderState: MaterialRenderState | undefined;
253
+ readonly materialSlot: number | undefined;
254
+ readonly paramSnapshot: DispatchEntry['paramSnapshot'];
258
255
  }
259
256
 
260
257
  type ShadowDispatchMap = ReadonlyMap<number, ReadonlyMap<number, ShadowDispatch>>;
261
258
 
262
- function shadowShaderMap(c: _InternalRenderPipelineContext): ShadowDispatchMap {
259
+ export function shadowShaderMap(c: _InternalRenderPipelineContext): ShadowDispatchMap {
263
260
  const shaders = new Map<number, Map<number, ShadowDispatch>>();
264
261
  for (const entry of c.dispatch) {
265
262
  if (entry.tags.LightMode === 'ShadowCaster' && entry.materialShaderId !== undefined) {
@@ -268,9 +265,20 @@ function shadowShaderMap(c: _InternalRenderPipelineContext): ShadowDispatchMap {
268
265
  byMaterial = new Map<number, ShadowDispatch>();
269
266
  shaders.set(entry.renderableIndex, byMaterial);
270
267
  }
268
+ const candidateSlots =
269
+ c.materialSlotIndices[entry.renderableIndex] ?? c.materialSlotIndices.flat();
270
+ const materialSlot = candidateSlots.find(
271
+ (slot) => c.materialSlots[slot]?.materialHandle === entry.materialHandle,
272
+ );
271
273
  byMaterial.set(entry.materialHandle, {
272
- materialShaderId: entry.materialShaderId,
274
+ materialHandle: entry.materialHandle,
275
+ materialShaderId:
276
+ entry.materialShaderId === 'forgeax::default-standard-pbr'
277
+ ? SHADOW_CASTER_SHADER_ID
278
+ : entry.materialShaderId,
273
279
  renderState: entry.renderState,
280
+ materialSlot,
281
+ paramSnapshot: entry.paramSnapshot,
274
282
  });
275
283
  }
276
284
  }
@@ -640,89 +648,25 @@ function recordShadowCasterDraws(
640
648
  shadowPass.setBindGroup(2, shadowMeshBindGroup, [i * MESH_PER_ENTITY_STRIDE]);
641
649
  }
642
650
 
643
- // C1 + C2 (w12): per-entity instance buffer + instanceCount
644
- let shadowInstanceBuffer: Buffer = pipelineState.identityInstanceBuffer;
645
- let shadowInstanceCount = 1;
646
- const shadowInst = entry.source.instances;
647
- if (shadowInst?.instanceCount === 0) continue;
648
- if (shadowInst !== undefined) {
649
- const uniformFallback = runtime.device.caps.storageBuffer === false;
650
- // Over-cap uniform fallback can't fit the per-instance window — bind
651
- // identity and let the shader collapse (same semantics as the main
652
- // pass). Otherwise build/upload the per-entity instance buffer: storage
653
- // by default, uniform when the device lacks storage buffers.
654
- if (uniformFallback && shadowInst.instanceCount > MAX_UNIFORM_INSTANCES) {
655
- shadowInstanceCount = shadowInst.instanceCount;
656
- shadowInstanceBuffer = pipelineState.identityInstanceBuffer;
657
- } else {
658
- const bufUsage = uniformFallback
659
- ? GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST
660
- : GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST;
661
- const instancePayload = uniformFallback
662
- ? shadowInst.transforms
663
- : packInstanceStorageBuffer(shadowInst.transforms);
664
- const requestedBytes = instancePayload.byteLength;
665
- const cached = c.frameState.instanceBuffers.get(
666
- worldEntityKey(entry.source.worldId, shadowInst.cacheKey),
667
- );
668
- let active: InstanceBufferCacheEntry | null = null;
669
- if (
670
- cached !== undefined &&
671
- cached.uploadedArchVersion === shadowInst.archVersion &&
672
- cached.uploadedByteLength === requestedBytes
673
- ) {
674
- active = cached;
675
- } else if (requestedBytes > 0) {
676
- const bufRes = runtime.device.createBuffer({
677
- size: requestedBytes,
678
- usage: bufUsage,
679
- mappedAtCreation: false,
680
- });
681
- if (!bufRes.ok) {
682
- runtime.errorRegistry.fire(bufRes.error);
683
- } else {
684
- // feat-20260619 M4 / F12: destroy the old cached buffer
685
- // before replacing it with the new one (D-6).
686
- if (cached !== undefined && !cached.buffer.isDestroyed) {
687
- const r = cached.buffer.destroy();
688
- if (!r.ok) runtime.errorRegistry.fire(r.error);
689
- }
690
- const newBuffer = new GpuBuffer(runtime.device, bufRes.value);
691
- active = {
692
- buffer: newBuffer,
693
- uploadedArchVersion: shadowInst.archVersion,
694
- uploadedByteLength: requestedBytes,
695
- };
696
- c.frameState.instanceBuffers.set(
697
- worldEntityKey(entry.source.worldId, shadowInst.cacheKey),
698
- active,
699
- );
700
- }
701
- }
702
- if (active !== null) {
703
- const writeRes = runtime.device.queue.writeBuffer(
704
- active.buffer.handle,
705
- 0,
706
- instancePayload,
707
- );
708
- if (!writeRes.ok) {
709
- runtime.errorRegistry.fire(writeRes.error);
710
- } else {
711
- shadowInstanceBuffer = active.buffer.handle;
712
- shadowInstanceCount = Math.max(1, shadowInst.instanceCount);
713
- }
714
- }
715
- }
716
- }
651
+ // Main and shadow passes share one instance residency owner and upload receipt.
652
+ const shadowInstanceDraws = resolveGeometryInstanceBuffer(
653
+ c,
654
+ entry,
655
+ [
656
+ {
657
+ instanceBuffer: pipelineState.identityInstanceBuffer,
658
+ instanceBindGroup: resolveGeometryInstancesBindGroup(
659
+ c,
660
+ pipelineState.identityInstanceBuffer,
661
+ ),
662
+ instanceCount: 1,
663
+ probeOffset: -1,
664
+ },
665
+ ],
666
+ false,
667
+ );
668
+ if (shadowInstanceDraws === null) continue;
717
669
 
718
- // Bind per-entity instances BG for @group(3) (or fallback identity).
719
- // D-4: write end of the HDRP shadow-instances producer/consumer pair.
720
- // outerKey = worldEntityKey(entry.source.worldId, entry.source.entityKey),
721
- // handle = shadowInstanceBuffer; the HDRP main pass read end (:3820 below)
722
- // must look up the same (compositeKey, instBuffer) leaf or the shadow
723
- // instances silently drop.
724
- const shadowInstancesBg = resolveGeometryInstancesBindGroup(c, shadowInstanceBuffer);
725
- shadowPass.setBindGroup(3, shadowInstancesBg);
726
670
  // feat-20260608 M4 / w16: per-submesh shadow draw loop.
727
671
  // Only draw submeshes whose topology is triangle-list or triangle-strip
728
672
  // (line-list / point-list submeshes cast no shadow and are skipped).
@@ -772,10 +716,13 @@ function recordShadowCasterDraws(
772
716
  shadowPass.setPipeline(entryShadowPipeline);
773
717
  shadowLastPipeline = entryShadowPipeline;
774
718
  }
775
- if (entry.mesh.indexed) {
776
- shadowPass.drawIndexed(sm.indexCount, shadowInstanceCount, sm.indexOffset, 0, 0);
777
- } else {
778
- shadowPass.draw(sm.vertexCount, shadowInstanceCount, 0, 0);
719
+ for (const instanceDraw of shadowInstanceDraws) {
720
+ shadowPass.setBindGroup(3, instanceDraw.instanceBindGroup);
721
+ if (entry.mesh.indexed) {
722
+ shadowPass.drawIndexed(sm.indexCount, instanceDraw.instanceCount, sm.indexOffset, 0, 0);
723
+ } else {
724
+ shadowPass.draw(sm.vertexCount, instanceDraw.instanceCount, 0, 0);
725
+ }
779
726
  }
780
727
  }
781
728
  }
@@ -28,6 +28,7 @@ import {
28
28
  postProcessShaderEntrySignature,
29
29
  } from '../fullscreen-post-process-pass';
30
30
  import { GpuDrivenProduction } from '../gpu-driven/production-raster';
31
+ import { disposeInstanceBufferChunks, disposeInstanceBuffers } from '../instance-buffer-cache';
31
32
  import { inspectStandardLighting } from '../pipeline/standard-lighting/inspection';
32
33
  import type { PipelineBuilderShaderModuleFactory } from '../pipeline-builder';
33
34
  import { inspectPointShadow } from '../point-shadow-inspection';
@@ -35,6 +36,7 @@ import type { PreparedGraphicsResolver } from '../prepare/prepared-graphics-reso
35
36
  import { validateRenderables } from '../record/frame';
36
37
  import { prepareFrameLighting } from '../record/frame-lighting';
37
38
  import type { RenderFrameState, ValidatedRenderable } from '../record/frame-snapshot';
39
+ import { resolveGeometryInstanceBuffer } from '../record/main-pass-geometry';
38
40
  import { computeSplitLdrSprite } from '../record/main-pass-sprite-draws';
39
41
  import {
40
42
  prepareRecoveryPipelineReadiness,
@@ -121,7 +123,7 @@ export interface RenderSystemRecoveryOwner {
121
123
  setLastSuccessfulFrameSeed(seed: RecoveryFrameSeed): void;
122
124
  prepareRecoveryGraphCandidate(
123
125
  runtime: RecoveryGraphCandidateRuntime,
124
- ): RecoveryGraphCandidatePreparation;
126
+ ): Promise<RecoveryGraphCandidatePreparation>;
125
127
  submitCandidateSetup(
126
128
  candidate: RecoveryGraphCandidate,
127
129
  isValid: () => boolean,
@@ -225,6 +227,8 @@ export function createRenderSystemRecovery(
225
227
  currentDirectionalShadowView: null,
226
228
  currentSpotShadowView: null,
227
229
  instanceBuffers: new Map(),
230
+ instanceBufferChunks: new Map(),
231
+ instanceResidency: new Map(),
228
232
  morphBuffers: new Map(),
229
233
  hdrpClusterBinScratch: createClusterBinScratch(),
230
234
  hdrpClusterGridScratch: null,
@@ -313,9 +317,9 @@ export function createRenderSystemRecovery(
313
317
  candidate.release();
314
318
  };
315
319
 
316
- const prepareRecoveryGraphCandidate = (
320
+ const prepareRecoveryGraphCandidate = async (
317
321
  runtime: RecoveryGraphCandidateRuntime,
318
- ): RecoveryGraphCandidatePreparation => {
322
+ ): Promise<RecoveryGraphCandidatePreparation> => {
319
323
  const seed = lastSuccessfulFrameSeed;
320
324
  if (seed === undefined) return { kind: 'no-seed' };
321
325
  const camera = seed.frame.cameras[0];
@@ -382,6 +386,13 @@ export function createRenderSystemRecovery(
382
386
  candidateGpuDrivenScene = undefined;
383
387
  candidatePointsLines?.release();
384
388
  candidatePointsLines = undefined;
389
+ disposeInstanceBuffers(candidateFrameState.instanceBuffers, runtime.internals.errorRegistry);
390
+ if (candidateFrameState.instanceBufferChunks !== undefined) {
391
+ disposeInstanceBufferChunks(
392
+ candidateFrameState.instanceBufferChunks,
393
+ runtime.internals.errorRegistry,
394
+ );
395
+ }
385
396
  candidateGpuStore.destroyAll();
386
397
  };
387
398
  const markPublished = (): void => {
@@ -403,10 +414,17 @@ export function createRenderSystemRecovery(
403
414
  if (!gpuScene.ok) return failed('candidate GPU scene preparation failed', gpuScene.error);
404
415
  candidateGpuDrivenScene = gpuScene.value;
405
416
  if (candidateGpuDrivenScene.state !== undefined) {
406
- candidateGpuDrivenProduction = GpuDrivenProduction.forDevice(
417
+ const production = await GpuDrivenProduction.forRecoveryDevice(
407
418
  runtime.internals.device,
408
419
  gpuDrivenShaderFactory,
420
+ (descriptor) =>
421
+ internals.createShaderModule === undefined
422
+ ? Promise.resolve(gpuDrivenShaderFactory.createShaderModule(descriptor))
423
+ : internals.createShaderModule(runtime.internals.device, descriptor),
409
424
  );
425
+ if (!production.ok)
426
+ return failed('candidate GPU-driven shader preparation failed', production.error);
427
+ candidateGpuDrivenProduction = production.value;
410
428
  const prepared = candidateGpuDrivenProduction.prepare({
411
429
  scene: candidateGpuDrivenScene.state,
412
430
  camera,
@@ -578,6 +596,25 @@ export function createRenderSystemRecovery(
578
596
  if (candidateValidated.length !== seed.frame.renderables.length) {
579
597
  return failed('candidate validation did not retain every LKG-visible renderable');
580
598
  }
599
+ for (const entry of candidateValidated) {
600
+ if (entry.source.instances === undefined) continue;
601
+ const prepared = resolveGeometryInstanceBuffer(
602
+ {
603
+ runtime: runtime.internals,
604
+ pipelineState: runtime.pipelineState,
605
+ frameState: candidateFrameState,
606
+ bindGroupCounts: { createBindGroup: 0, keys: [] },
607
+ },
608
+ entry,
609
+ [],
610
+ false,
611
+ );
612
+ if (prepared === null) return failed('candidate instance preparation failed');
613
+ }
614
+ // These uploads belong to recovery preparation, never to a rendered frame.
615
+ for (const [id, residency] of candidateFrameState.instanceResidency ?? []) {
616
+ candidateFrameState.instanceResidency?.set(id, { ...residency, frameNumber: -1 });
617
+ }
581
618
  const lighting = prepareFrameLighting(
582
619
  runtime.internals,
583
620
  candidateFrameState,
@@ -42,6 +42,7 @@ import type {
42
42
  TemporalTargetInspection,
43
43
  TransmissionInspection,
44
44
  } from './inspection-types';
45
+ import type { InstanceCollectionInspection } from './instances';
45
46
  import type { SsrAdmissionIdentity } from './ssr/identity';
46
47
  import type {
47
48
  RenderTarget,
@@ -294,6 +295,7 @@ export interface SpotLightProjectorFrameContext {
294
295
  * leases, receipts, inspection PODs, and recovery Result values.
295
296
  */
296
297
  export interface Renderer {
298
+ /** Renderer-owned authoring and update path for large instance collections. */
297
299
  attach(world: World): RenderResult<RenderWorldLease, RenderError>;
298
300
  draw(input: RenderFrameInput): RenderResult<FrameReceipt, RenderError>;
299
301
  createRenderTarget(descriptor: RenderTargetDescriptor): RenderResult<RenderTarget, RenderError>;
@@ -509,6 +511,8 @@ export interface RenderInspection {
509
511
  readonly featureDiagnostics: readonly RenderFeatureDiagnostics[];
510
512
  readonly frustumStats: { readonly culled: number; readonly total: number };
511
513
  readonly visibilityStats: { readonly explicitlyHidden: number };
514
+ /** Renderer-owned large-instance residency and upload facts. */
515
+ readonly instanceCollections: readonly InstanceCollectionInspection[];
512
516
  readonly renderScene: RenderSceneInspection;
513
517
  readonly reflectionProbes: ReflectionProbeInspection;
514
518
  /** Renderer-owned SSR dependency seam; consumer facts are projected from live owners. */
@@ -88,7 +88,8 @@ import type {
88
88
  } from './features/types';
89
89
  import { ensureGlyphMeshMaterialSlots } from './glyph-text-layout-system';
90
90
  import type { GpuDrivenGeometryReceipt } from './gpu-driven/prepared-draw';
91
- import { deriveInstancesUnionBounds } from './instances-derived-bounds';
91
+ import type { InstanceProjectionStore } from './instances';
92
+ import { deriveInstancesUnionBounds, fingerprintNumericArray } from './instances-derived-bounds';
92
93
  import {
93
94
  type MeshMaterialBindingDiagnostic,
94
95
  type MeshMaterialBindingSource,
@@ -156,7 +157,7 @@ import {
156
157
  readStablePersistentMaterialSnapshot,
157
158
  resolveMaterialSnapshot,
158
159
  resolveVideoFieldHandle,
159
- runtimeMaterialShaderId,
160
+ runtimeMaterialShaderIdForMaterial,
160
161
  Severity,
161
162
  selectCameraRoles,
162
163
  selectFogFrame,
@@ -175,9 +176,12 @@ export function extractFrames(
175
176
  options: {
176
177
  readonly cull?: 'normal' | 'none';
177
178
  readonly renderables?: 'full' | 'none';
179
+ readonly renderableEntitiesByWorld?: readonly (ReadonlySet<number> | undefined)[];
180
+ readonly retainHidden?: boolean;
178
181
  readonly getMaterialShaderArtifact?: (
179
182
  materialShaderId: string,
180
183
  ) => MaterialShaderArtifact | undefined;
184
+ readonly instanceCollections?: InstanceProjectionStore;
181
185
  } = {},
182
186
  ): ExtractedFrame {
183
187
  // w4: normalize the owner argument. A bare number is the legacy single-owner
@@ -249,9 +253,16 @@ export function extractFrames(
249
253
  }),
250
254
  cull: options.cull === 'none' ? 'none' : isCameraOwner ? 'self' : 'external',
251
255
  renderables: options.renderables ?? 'full',
256
+ ...(options.renderableEntitiesByWorld?.[wi] === undefined
257
+ ? {}
258
+ : { renderableEntities: options.renderableEntitiesByWorld[wi] }),
259
+ retainHidden: options.retainHidden ?? false,
252
260
  ...(options.getMaterialShaderArtifact === undefined
253
261
  ? {}
254
262
  : { getMaterialShaderArtifact: options.getMaterialShaderArtifact }),
263
+ ...(options.instanceCollections === undefined
264
+ ? {}
265
+ : { instanceCollections: options.instanceCollections }),
255
266
  ...(cameraOwnerFrame === undefined ? {} : { cullCameras: cameraOwnerFrame.cameras }),
256
267
  });
257
268
  const frame = extractFrame(world, prepared);
@@ -495,6 +506,30 @@ export function extractFrames(
495
506
  return (f.reflectionProbes ?? []).map((probe) => ({ ...probe, worldId }));
496
507
  });
497
508
 
509
+ if (options.renderables !== 'none' && options.renderableEntitiesByWorld === undefined) {
510
+ options.instanceCollections?.retain(
511
+ new Set(
512
+ renderables.flatMap((row) =>
513
+ row.instances?.collectionId === undefined ? [] : [row.instances.collectionId],
514
+ ),
515
+ ),
516
+ );
517
+ if (
518
+ skinPaletteAllocator !== null &&
519
+ typeof skinPaletteAllocator.retainSliceKeys === 'function'
520
+ ) {
521
+ skinPaletteAllocator.retainSliceKeys(
522
+ new Set(
523
+ renderables.flatMap((row) =>
524
+ row.skin === undefined
525
+ ? []
526
+ : [`${worlds[row.worldId]?.identity ?? row.worldId}:${row.entityKey}`],
527
+ ),
528
+ ),
529
+ );
530
+ }
531
+ }
532
+
498
533
  return {
499
534
  cameras,
500
535
  auxiliaryCameras,
@@ -604,6 +639,9 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
604
639
  cull: cullMode,
605
640
  resourceOwnerFog,
606
641
  getMaterialShaderArtifact,
642
+ instanceCollections,
643
+ renderableEntities,
644
+ retainHidden,
607
645
  } = context;
608
646
  // Environment reads are owned by the retained resource projection and are
609
647
  // established at the frame boundary; record never revisits World state.
@@ -1525,18 +1563,13 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
1525
1563
  // through one World-owned Query. K-2 sniffing scheme B
1526
1564
  // (`row.get(X) !== undefined` edge sniff) replaces the prior
1527
1565
  // `arch.components.some` row-internal back-door. K-3 invariant: the
1528
- // variable-length array reads (`MeshRenderer.materials`,
1529
- // `Instances.transforms`) still flow through `_getArrayView` /
1530
- // `world.get(e, Instances)` -- only the `entity` source switches to
1531
- // `row.entity`.
1566
+ // variable-length array reads (`MeshRenderer.materials`, SpriteInstances
1567
+ // arrays) still flow through `_getArrayView`; explicit Instances bytes also come
1568
+ // from World, before projection into renderer-owned snapshots.
1532
1569
  //
1533
- // archVersion plumbing exception: the `RenderableSnapshot.instances
1534
- // .archVersion` cache key is keyed off the live archetype's mutation
1535
- // counter (record stage `instanceBuffers` cache invalidation). The
1536
- // QueryRow does not surface this number, so a single archetype
1537
- // graph access is retained for instance-bearing rows -- match by entity
1538
- // packed handle. AC-01 grep ≤ current - 1 still holds: the stale
1539
- // archetype-graph traversal commentary at the prior call site is gone.
1570
+ // archVersion remains the content fingerprint for SpriteInstances' managed
1571
+ // arrays; explicit Instances use the collection revision below and never
1572
+ // need an archetype graph access for their matrix bytes.
1540
1573
  // feat-20260521-sprite-atlas-animation M3 / T-16: SpriteRegionOverride
1541
1574
  // column id for the sprite-bucket per-entity region override read
1542
1575
  void SpriteRegionOverride;
@@ -1564,11 +1597,10 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
1564
1597
  })
1565
1598
  .unwrap();
1566
1599
  // QueryRow deliberately exposes component data, not an archetype version.
1567
- // The optimized ECS owner keeps table storage private, so instance-buffer
1568
- // invalidation uses a content fingerprint of the managed arrays instead.
1600
+ // SpriteInstances still uses a content fingerprint for its managed arrays;
1601
+ // explicit Instances use the renderer-owned collection revision below.
1569
1602
  const resolveArchVersion = (entity: EntityHandle): number => {
1570
1603
  const values = [
1571
- worldInternal._getArrayView(entity, Instances, 'transforms'),
1572
1604
  worldInternal._getArrayView(entity, SpriteInstances, 'transforms'),
1573
1605
  worldInternal._getArrayView(entity, SpriteInstances, 'regions'),
1574
1606
  ];
@@ -1591,6 +1623,13 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
1591
1623
  for (const entry of pending) dispatch.push(entry);
1592
1624
  };
1593
1625
  for (const row of meshRendererQuery) {
1626
+ if (
1627
+ context.renderables === 'full' &&
1628
+ renderableEntities !== undefined &&
1629
+ !renderableEntities.has(row.entity)
1630
+ ) {
1631
+ continue;
1632
+ }
1594
1633
  // K-2 archetype-edge sniff (scheme B): a missing optional component
1595
1634
  // surfaces as an absent bundle key, not a row-internal optional chain.
1596
1635
  // Presence-only checks use QueryRow.has so large array-bearing components
@@ -1687,8 +1726,9 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
1687
1726
  {
1688
1727
  // feat-20260608 M2 / w11: read materials array via _getArrayView
1689
1728
  const entity = row.entity;
1690
- if (isRenderable && visibility?.effective(entity) === 'hidden') {
1691
- explicitlyHidden.add(entity);
1729
+ const authorVisible = !(isRenderable && visibility?.effective(entity) === 'hidden');
1730
+ if (!authorVisible) explicitlyHidden.add(entity);
1731
+ if (!authorVisible && !retainHidden) {
1692
1732
  continue;
1693
1733
  }
1694
1734
  const layerVal = fLayerValue ?? 0;
@@ -1886,6 +1926,8 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
1886
1926
  renderables.length,
1887
1927
  layerVal,
1888
1928
  materialSnap.paramSnapshot,
1929
+ 0,
1930
+ materialSnap.materialShaderId,
1889
1931
  );
1890
1932
  }
1891
1933
  } else if (handleRaw === 0 || assets === undefined || assets === null) {
@@ -1913,6 +1955,8 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
1913
1955
  renderables.length,
1914
1956
  layerVal,
1915
1957
  materialSnap.paramSnapshot,
1958
+ 0,
1959
+ materialSnap.materialShaderId,
1916
1960
  );
1917
1961
  }
1918
1962
  } else {
@@ -1976,10 +2020,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
1976
2020
  }
1977
2021
  const resolved = resolvedResult.value;
1978
2022
  const allPasses = resolved.passes;
1979
- const firstPassShader =
1980
- allPasses.length > 0
1981
- ? runtimeMaterialShaderId(allPasses[0]?.program.module, allPasses[0]?.name)
1982
- : undefined;
2023
+ const firstPassShader = runtimeMaterialShaderIdForMaterial(asset, allPasses, assets);
1983
2024
  const pv = materialValuesToLinearRuntime(
1984
2025
  resolved.values,
1985
2026
  materialColorParameterSchema(resolved.parameters ?? [], firstPassShader, assets),
@@ -2357,6 +2398,8 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2357
2398
  renderables.length,
2358
2399
  layerVal,
2359
2400
  paramSnap,
2401
+ 0,
2402
+ firstPassShader,
2360
2403
  );
2361
2404
  }
2362
2405
  }
@@ -2512,7 +2555,18 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2512
2555
  // surface is absent (test fixtures that pass undefined) the
2513
2556
  // hasSkin segment is skipped silently — bind-pose equivalent.
2514
2557
  if (skinPaletteAllocator !== null) {
2515
- const slice = skinPaletteAllocator.allocateSlice(skeleton.jointCount);
2558
+ // Keep the narrow extraction seam compatible with older test or
2559
+ // host-owned allocator doubles while the real renderer uses the
2560
+ // stable-key path. The fallback is frame-local and therefore
2561
+ // cannot affect production allocators that implement the new
2562
+ // method.
2563
+ const slice =
2564
+ typeof skinPaletteAllocator.allocateSliceFor === 'function'
2565
+ ? skinPaletteAllocator.allocateSliceFor(
2566
+ `${world.identity}:${entity}`,
2567
+ skeleton.jointCount,
2568
+ )
2569
+ : skinPaletteAllocator.allocateSlice(skeleton.jointCount);
2516
2570
  skinPaletteAllocator.writeJointPalette(slice, ibms, jointWorlds);
2517
2571
  skinSlice = {
2518
2572
  jointCount: slice.jointCount,
@@ -2584,6 +2638,8 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2584
2638
  renderables.length,
2585
2639
  layerVal,
2586
2640
  materialsArr[mi]?.paramSnapshot,
2641
+ 0,
2642
+ materialsArr[mi]?.materialShaderId,
2587
2643
  );
2588
2644
  }
2589
2645
  }
@@ -2727,39 +2783,35 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2727
2783
  // Instances are snapshotted before the CPU visibility decision so the
2728
2784
  // culling owner can derive the union of every instance's transformed
2729
2785
  // mesh bounds. The same detached snapshot is then carried to record;
2730
- // do not re-read the ECS array after culling (a structural write must
2731
- // not make the cull and draw disagree within one frame).
2786
+ // do not re-read the collection after culling (a mutation must not
2787
+ // make the cull and draw disagree within one frame).
2732
2788
  let instancesSnap: InstancesSnapshot | undefined;
2733
2789
  if (hasInstances) {
2734
- const transforms = worldInternal._getArrayView(entity, Instances, 'transforms');
2735
- if (transforms === undefined) {
2736
- // A present component with an unavailable array is an empty
2737
- // instance contribution, not an absent component. Preserve the
2738
- // zero-draw contract so the record stage cannot synthesize one
2739
- // identity instance from an incomplete row.
2740
- instancesSnap = {
2741
- transforms: new Float32Array(),
2742
- instanceCount: 0,
2743
- cacheKey: entity,
2744
- archVersion,
2745
- };
2746
- } else {
2747
- const actualLength = transforms.length;
2748
- if (actualLength % 16 !== 0) {
2749
- worldInternal._routeError(new InstanceTransformsStrideMismatchError(actualLength), {
2790
+ const transforms =
2791
+ worldInternal._getArrayView(entity, Instances, 'transforms') ?? new Float32Array();
2792
+ if (transforms.length % 16 !== 0) {
2793
+ worldInternal._routeError(
2794
+ new InstanceTransformsStrideMismatchError(transforms.length),
2795
+ {
2750
2796
  severity: Severity.Error,
2751
2797
  systemName: 'RenderSystem.extract (Instances stride)',
2752
- });
2753
- continue;
2754
- }
2755
- const snapshotCopy = new Float32Array(transforms);
2756
- instancesSnap = {
2757
- transforms: snapshotCopy,
2758
- instanceCount: actualLength / 16,
2759
- cacheKey: entity,
2760
- archVersion,
2761
- };
2798
+ },
2799
+ );
2800
+ continue;
2762
2801
  }
2802
+ const projected = instanceCollections?.project(world, entity, transforms);
2803
+ instancesSnap = {
2804
+ transforms: projected?.transforms ?? new Float32Array(transforms),
2805
+ instanceCount: transforms.length / 16,
2806
+ cacheKey: entity,
2807
+ archVersion: projected === undefined ? fingerprintNumericArray(transforms) : 0,
2808
+ ...(projected === undefined
2809
+ ? {}
2810
+ : {
2811
+ collectionId: projected.collectionId,
2812
+ revision: projected.revision,
2813
+ }),
2814
+ };
2763
2815
  }
2764
2816
 
2765
2817
  const baseRenderable: RenderableSnapshot = {
@@ -2774,7 +2826,11 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2774
2826
  materialBindingDiagnostics,
2775
2827
  worldId: 0,
2776
2828
  entityKey: entity,
2829
+ ...(authorVisible ? {} : { authorVisible: false }),
2777
2830
  ...(skinSlice !== undefined ? { skin: skinSlice } : {}),
2831
+ ...(skin?.joints === undefined
2832
+ ? {}
2833
+ : { skinJointEntities: Array.from(skin.joints, (joint) => Number(joint)) }),
2778
2834
  ...(morph !== undefined ? { morph } : {}),
2779
2835
  ...(instancesSnap !== undefined ? { instances: instancesSnap } : {}),
2780
2836
  ...(spriteInstancesSnap !== undefined ? { spriteInstances: spriteInstancesSnap } : {}),