@forgeax/engine-render 0.1.24 → 0.1.26

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 (167) hide show
  1. package/README.md +118 -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/assembly.d.ts +4 -17
  5. package/dist/assembly/material/assembly.d.ts.map +1 -1
  6. package/dist/assembly/material/standard-layer-projection.d.ts.map +1 -1
  7. package/dist/assembly/material/surface-projection.d.ts +24 -0
  8. package/dist/assembly/material/surface-projection.d.ts.map +1 -0
  9. package/dist/assembly/material-shader-policy.d.ts +20 -0
  10. package/dist/assembly/material-shader-policy.d.ts.map +1 -1
  11. package/dist/assembly/webgpu-ready.d.ts.map +1 -1
  12. package/dist/assembly/webgpu-renderer.d.ts +1 -2
  13. package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
  14. package/dist/assembly/webgpu-vertex-layouts.d.ts +89 -0
  15. package/dist/assembly/webgpu-vertex-layouts.d.ts.map +1 -0
  16. package/dist/assets/asset-decoders.d.ts.map +1 -1
  17. package/dist/authoring.mjs +1 -1
  18. package/dist/{chunk-Q752SHI5.mjs → chunk-ADHHVYLW.mjs} +3 -3
  19. package/dist/chunk-ADHHVYLW.mjs.map +1 -0
  20. package/dist/{chunk-TFH5I7JJ.mjs → chunk-E34VL5VI.mjs} +3 -3
  21. package/dist/{chunk-TFH5I7JJ.mjs.map → chunk-E34VL5VI.mjs.map} +1 -1
  22. package/dist/{chunk-YKB5DQV7.mjs → chunk-GF523LHF.mjs} +89 -21
  23. package/dist/chunk-GF523LHF.mjs.map +1 -0
  24. package/dist/{chunk-KZFTK6DO.mjs → chunk-HKXTW355.mjs} +7 -4
  25. package/dist/chunk-HKXTW355.mjs.map +1 -0
  26. package/dist/{chunk-UQYS7FRM.mjs → chunk-KM2NOX2I.mjs} +4 -4
  27. package/dist/{chunk-UQYS7FRM.mjs.map → chunk-KM2NOX2I.mjs.map} +1 -1
  28. package/dist/{chunk-YRUYGA4A.mjs → chunk-TMIASV2N.mjs} +944 -577
  29. package/dist/chunk-TMIASV2N.mjs.map +1 -0
  30. package/dist/{chunk-Z3YYH2MB.mjs → chunk-YUQXSEHG.mjs} +160 -73
  31. package/dist/chunk-YUQXSEHG.mjs.map +1 -0
  32. package/dist/{chunk-RR5L4SRS.mjs → chunk-ZZ4YQ474.mjs} +2 -2
  33. package/dist/{chunk-RR5L4SRS.mjs.map → chunk-ZZ4YQ474.mjs.map} +1 -1
  34. package/dist/components/instances.d.ts +13 -31
  35. package/dist/components/instances.d.ts.map +1 -1
  36. package/dist/construct-renderer.mjs +1012 -149
  37. package/dist/construct-renderer.mjs.map +1 -1
  38. package/dist/extract/contracts.d.ts +6 -6
  39. package/dist/extract/contracts.d.ts.map +1 -1
  40. package/dist/gpu-driven/production-raster.d.ts +6 -1
  41. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  42. package/dist/gpu-scene.d.ts +4 -0
  43. package/dist/gpu-scene.d.ts.map +1 -1
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.mjs +27 -9
  47. package/dist/index.mjs.map +1 -1
  48. package/dist/instance-buffer-cache.d.ts +7 -1
  49. package/dist/instance-buffer-cache.d.ts.map +1 -1
  50. package/dist/instances.d.ts +101 -0
  51. package/dist/instances.d.ts.map +1 -0
  52. package/dist/internal.mjs +5 -5
  53. package/dist/materials.d.ts +63 -2
  54. package/dist/materials.d.ts.map +1 -1
  55. package/dist/pipeline-builder.d.ts.map +1 -1
  56. package/dist/pipeline-spec-types.d.ts +2 -0
  57. package/dist/pipeline-spec-types.d.ts.map +1 -1
  58. package/dist/pipeline-spec.d.ts.map +1 -1
  59. package/dist/record/frame-snapshot.d.ts +13 -2
  60. package/dist/record/frame-snapshot.d.ts.map +1 -1
  61. package/dist/record/frame.d.ts.map +1 -1
  62. package/dist/record/main-pass-geometry.d.ts +23 -4
  63. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  64. package/dist/record/main-pass-material.d.ts +20 -2
  65. package/dist/record/main-pass-material.d.ts.map +1 -1
  66. package/dist/record/main-pass-sprite-draws.d.ts +3 -3
  67. package/dist/record/main-pass-sprite-draws.d.ts.map +1 -1
  68. package/dist/record/main-pass.d.ts.map +1 -1
  69. package/dist/record/material-uniforms.d.ts +4 -0
  70. package/dist/record/material-uniforms.d.ts.map +1 -0
  71. package/dist/record/render-context.d.ts +4 -3
  72. package/dist/record/render-context.d.ts.map +1 -1
  73. package/dist/record/shadow-pass.d.ts +10 -1
  74. package/dist/record/shadow-pass.d.ts.map +1 -1
  75. package/dist/recovery/render-system-candidate.d.ts +1 -1
  76. package/dist/recovery/render-system-candidate.d.ts.map +1 -1
  77. package/dist/render-contract.d.ts +4 -0
  78. package/dist/render-contract.d.ts.map +1 -1
  79. package/dist/render-system-extract-tail.d.ts +6 -0
  80. package/dist/render-system-extract-tail.d.ts.map +1 -1
  81. package/dist/render-system-extract.d.ts +44 -9
  82. package/dist/render-system-extract.d.ts.map +1 -1
  83. package/dist/render-system.d.ts +4 -1
  84. package/dist/render-system.d.ts.map +1 -1
  85. package/dist/scene/render-scene-types.d.ts +2 -0
  86. package/dist/scene/render-scene-types.d.ts.map +1 -1
  87. package/dist/scene/render-scene.d.ts +12 -1
  88. package/dist/scene/render-scene.d.ts.map +1 -1
  89. package/dist/systems/skin-palette-allocator.d.ts +6 -0
  90. package/dist/systems/skin-palette-allocator.d.ts.map +1 -1
  91. package/dist/temporal/index.mjs +5 -5
  92. package/package.json +20 -20
  93. package/src/__tests__/fixtures/ai-weapon-spirit/README.md +20 -0
  94. package/src/__tests__/fixtures/ai-weapon-spirit/low-poly-toon.wgsl +178 -0
  95. package/src/__tests__/fixtures/ai-weapon-spirit/material.json +97 -0
  96. package/src/__tests__/fixtures/ai-weapon-spirit/painterly-surface.wgsl +55 -0
  97. package/src/__tests__/gpu-driven-view-gpu-evidence.ts +3 -3
  98. package/src/__tests__/gpu-driven-view.browser.test.ts +11 -3
  99. package/src/__tests__/gpu-scene.unit.test.ts +38 -2
  100. package/src/__tests__/instances-store.unit.test.ts +46 -0
  101. package/src/__tests__/instances-world-ownership.integration.test.ts +62 -0
  102. package/src/__tests__/material-contract-inventory.unit.test.ts +48 -13
  103. package/src/__tests__/{material-static-dynamic-route.unit.test.ts → material-layer-projection.unit.test.ts} +30 -47
  104. package/src/__tests__/material-pass-policy.unit.test.ts +69 -0
  105. package/src/__tests__/material-program-dispatch.unit.test.ts +48 -0
  106. package/src/__tests__/material-snapshot-mutation.integration.test.ts +69 -0
  107. package/src/__tests__/material-surface-authoring.test-d.ts +62 -0
  108. package/src/__tests__/materials-transmission-validation.unit.test.ts +24 -0
  109. package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +0 -4
  110. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +44 -2
  111. package/src/__tests__/skin-motion-regression.unit.test.ts +33 -0
  112. package/src/__tests__/skinned-shadow-caster.test.ts +30 -2
  113. package/src/__tests__/surface-adjacent-regressions.integration.test.ts +60 -0
  114. package/src/__tests__/surface-standard-pipeline.browser.test.ts +20 -0
  115. package/src/__tests__/surface-standard-pipeline.dawn.test.ts +20 -0
  116. package/src/__tests__/surface-standard-pipeline.fixture.ts +132 -0
  117. package/src/__tests__/surface-variant-provenance.integration.test.ts +33 -0
  118. package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +1 -1
  119. package/src/assembly/factory.ts +2 -3
  120. package/src/assembly/material/__tests__/standard-layer-plan-projection.integration.test.ts +18 -0
  121. package/src/assembly/material/__tests__/surface-pass-projection.integration.test.ts +62 -0
  122. package/src/assembly/material/assembly.ts +8 -40
  123. package/src/assembly/material/standard-layer-projection.ts +3 -1
  124. package/src/assembly/material/surface-projection.ts +91 -0
  125. package/src/assembly/material-shader-policy.ts +88 -10
  126. package/src/assembly/recovery/renderer-recover.ts +1 -1
  127. package/src/assembly/webgpu-ready.ts +3 -16
  128. package/src/assembly/webgpu-renderer.ts +106 -166
  129. package/src/assembly/webgpu-vertex-layouts.ts +132 -0
  130. package/src/assets/asset-decoders.ts +27 -4
  131. package/src/components/instances.ts +14 -197
  132. package/src/extract/contracts.ts +6 -6
  133. package/src/gpu-driven/production-raster.ts +34 -1
  134. package/src/gpu-scene.ts +84 -29
  135. package/src/index.ts +1 -0
  136. package/src/instance-buffer-cache.ts +14 -1
  137. package/src/instances.ts +288 -0
  138. package/src/materials.ts +109 -14
  139. package/src/pipeline-builder.ts +14 -10
  140. package/src/pipeline-spec-types.ts +2 -0
  141. package/src/pipeline-spec.ts +13 -17
  142. package/src/record/frame-snapshot.ts +19 -2
  143. package/src/record/frame.ts +38 -3
  144. package/src/record/main-pass-geometry.ts +837 -337
  145. package/src/record/main-pass-material.ts +87 -2
  146. package/src/record/main-pass-sprite-draws.ts +66 -169
  147. package/src/record/main-pass.ts +24 -246
  148. package/src/record/material-uniforms.ts +186 -0
  149. package/src/record/render-context.ts +8 -0
  150. package/src/record/shadow-pass.ts +120 -115
  151. package/src/recovery/render-system-candidate.ts +46 -5
  152. package/src/render-contract.ts +4 -0
  153. package/src/render-system-extract-tail.ts +153 -57
  154. package/src/render-system-extract.ts +110 -73
  155. package/src/render-system.ts +59 -5
  156. package/src/scene/render-scene-types.ts +2 -0
  157. package/src/scene/render-scene.ts +579 -36
  158. package/src/systems/skin-palette-allocator.ts +129 -7
  159. package/src/transmission/__tests__/surface-compatibility.integration.test.ts +32 -0
  160. package/dist/assembly/material/pipeline-projection.d.ts +0 -30
  161. package/dist/assembly/material/pipeline-projection.d.ts.map +0 -1
  162. package/dist/chunk-KZFTK6DO.mjs.map +0 -1
  163. package/dist/chunk-Q752SHI5.mjs.map +0 -1
  164. package/dist/chunk-YKB5DQV7.mjs.map +0 -1
  165. package/dist/chunk-YRUYGA4A.mjs.map +0 -1
  166. package/dist/chunk-Z3YYH2MB.mjs.map +0 -1
  167. package/src/assembly/material/pipeline-projection.ts +0 -63
@@ -19,6 +19,11 @@ import {
19
19
  } from '../gpu-usage';
20
20
  import { createHdrpSkinUnifiedBindGroup, getOrCreateHdrpBuffers } from '../hdrp-buffers';
21
21
  import type { InstanceBufferCacheEntry } from '../instance-buffer-cache';
22
+ import {
23
+ type InstanceCollectionFailureCode,
24
+ recordInstanceFailure,
25
+ recordInstanceResidency,
26
+ } from '../instances';
22
27
  import { SPRITE_PREMULTIPLIED_ALPHA_BLEND } from '../materials';
23
28
  import {
24
29
  createPbrSkinMeshBindGroupEntries,
@@ -39,13 +44,17 @@ import {
39
44
  import type { PointsLinesRecordPlan } from '../points-lines/record';
40
45
  import { POINTS_LINES_MATERIAL_SHADER_ID } from '../points-lines/record';
41
46
  import type { RenderRecordPhase } from '../render-contract';
42
- import type { MaterialSnapshot } from '../render-system-extract';
47
+ import type { DispatchEntry, MaterialSnapshot, RenderableSnapshot } from '../render-system-extract';
43
48
  import {
44
49
  PROBE_BLEND_RECORD_BYTE_SIZE,
45
50
  PROBE_BLEND_RECORD_STRIDE,
46
51
  probeBlendRecordOffset,
47
52
  } from '../scene/probe-blend-record';
48
- import { getOpaqueResourceIdentity, worldEntityKey } from './frame-snapshot';
53
+ import {
54
+ getOpaqueResourceIdentity,
55
+ instanceCollectionCacheKey,
56
+ worldEntityKey,
57
+ } from './frame-snapshot';
49
58
  import {
50
59
  geometryRenderStateForTopology,
51
60
  isEntityFullyTransparent,
@@ -54,6 +63,7 @@ import {
54
63
  } from './main-pass-material';
55
64
  import {
56
65
  getOrCreateFromChain,
66
+ INSTANCE_STORAGE_STRIDE_FLOATS,
57
67
  INSTANCE_UBO_FULL_ARRAY_BYTES,
58
68
  MAX_UNIFORM_INSTANCES,
59
69
  MESH_PER_ENTITY_STRIDE,
@@ -73,6 +83,18 @@ type GeometryInstanceDraw = {
73
83
  readonly instanceCount: number;
74
84
  };
75
85
 
86
+ type InstanceUploadResult =
87
+ | {
88
+ readonly ok: true;
89
+ readonly ranges: readonly { readonly start: number; readonly end: number }[];
90
+ readonly bytes: number;
91
+ }
92
+ | {
93
+ readonly ok: false;
94
+ readonly ranges: readonly { readonly start: number; readonly end: number }[];
95
+ readonly bytes: number;
96
+ };
97
+
76
98
  type GeometryBindingState = {
77
99
  pipeline: RenderPipeline | null;
78
100
  instancesBuffer: Buffer | null;
@@ -98,6 +120,8 @@ type MaterialPipelineLookup = {
98
120
  readonly topology: PrimitiveTopology;
99
121
  readonly indexFormat: 'uint16' | 'uint32';
100
122
  readonly variantSet: string | undefined;
123
+ readonly vertexEntry: string | undefined;
124
+ readonly fragmentEntry: string | undefined;
101
125
  readonly passKind: PassKind;
102
126
  readonly layoutProjection: import('@forgeax/engine-geometry').VertexLayoutProjection;
103
127
  readonly sampleCount: number;
@@ -343,8 +367,10 @@ export function recordGeometryDraws(
343
367
  submeshMaterial: MaterialSnapshot,
344
368
  entityKey: number,
345
369
  materialWorld: World,
370
+ materialShaderId?: string,
346
371
  ) => BindGroup,
347
372
  passKind: PassKind = 'forward',
373
+ selectedDispatch?: readonly DispatchEntry[],
348
374
  ): void {
349
375
  const {
350
376
  runtime,
@@ -359,6 +385,17 @@ export function recordGeometryDraws(
359
385
  viewBindGroup,
360
386
  viewBindGroupDynamicOffset = 0,
361
387
  } = c;
388
+ const materialPasses = new Map<number, Map<number, DispatchEntry[]>>();
389
+ for (const dispatch of selectedDispatch ?? []) {
390
+ let byMaterial = materialPasses.get(dispatch.renderableIndex);
391
+ if (byMaterial === undefined) {
392
+ byMaterial = new Map();
393
+ materialPasses.set(dispatch.renderableIndex, byMaterial);
394
+ }
395
+ const passes = byMaterial.get(dispatch.materialHandle);
396
+ if (passes === undefined) byMaterial.set(dispatch.materialHandle, [dispatch]);
397
+ else passes.push(dispatch);
398
+ }
362
399
  const diagnosticsEnabled = materialDiagnosticsEnabled();
363
400
  const colorFormatOverride = c.transparentColorFormat as GPUTextureFormat | undefined;
364
401
  // The prepared Standard topology is the frame authority. A bind-group
@@ -416,6 +453,8 @@ export function recordGeometryDraws(
416
453
  variantSet: string | undefined,
417
454
  colorFormat: GPUTextureFormat | undefined,
418
455
  layoutProjection: import('@forgeax/engine-geometry').VertexLayoutProjection,
456
+ vertexEntry?: string,
457
+ fragmentEntry?: string,
419
458
  ): MaterialShaderPipelineEntry | null => {
420
459
  // The fallback MRT is an extension of the built-in Standard PBR output
421
460
  // contract only. The render pass may carry a second attachment whenever
@@ -441,6 +480,8 @@ export function recordGeometryDraws(
441
480
  cached.topology === topology &&
442
481
  cached.indexFormat === indexFormat &&
443
482
  cached.variantSet === variantSet &&
483
+ cached.vertexEntry === vertexEntry &&
484
+ cached.fragmentEntry === fragmentEntry &&
444
485
  cached.passKind === passKind &&
445
486
  cached.layoutProjection.digest === layoutProjection.digest &&
446
487
  cached.sampleCount === sampleCount &&
@@ -452,10 +493,12 @@ export function recordGeometryDraws(
452
493
  }
453
494
  }
454
495
  const resolvedVariantSetResult =
455
- variantSet === undefined &&
456
- (materialShaderId === 'forgeax::sprite' || materialShaderId === 'forgeax::sprite-lit')
496
+ runtime.getMaterialArtifact?.(materialShaderId) !== undefined
457
497
  ? ({ ok: true, value: undefined } as const)
458
- : variantSetFromVertexLayoutProjection(layoutProjection, variantSet);
498
+ : variantSet === undefined &&
499
+ (materialShaderId === 'forgeax::sprite' || materialShaderId === 'forgeax::sprite-lit')
500
+ ? ({ ok: true, value: undefined } as const)
501
+ : variantSetFromVertexLayoutProjection(layoutProjection, variantSet);
459
502
  if (!resolvedVariantSetResult.ok) {
460
503
  runtime.errorRegistry.fire(resolvedVariantSetResult.error);
461
504
  return null;
@@ -486,6 +529,8 @@ export function recordGeometryDraws(
486
529
  undefined,
487
530
  undefined,
488
531
  reflectionFallbackFormats,
532
+ vertexEntry,
533
+ fragmentEntry,
489
534
  ) ?? null;
490
535
  const handle =
491
536
  profilePhase === undefined
@@ -506,6 +551,8 @@ export function recordGeometryDraws(
506
551
  topology,
507
552
  indexFormat,
508
553
  variantSet,
554
+ vertexEntry,
555
+ fragmentEntry,
509
556
  passKind,
510
557
  layoutProjection,
511
558
  sampleCount,
@@ -569,7 +616,8 @@ export function recordGeometryDraws(
569
616
  // decal submesh) is NOT skipped at the entity level; its opaque submeshes
570
617
  // draw here and the per-submesh loop below skips the transparent ones
571
618
  // (they are drawn in the blend sub-pass instead).
572
- if (splitLdrSprite && isEntityFullyTransparent(entry.source)) continue;
619
+ if (selectedDispatch === undefined && splitLdrSprite && isEntityFullyTransparent(entry.source))
620
+ continue;
573
621
 
574
622
  const pipelineTag: 'unlit' = 'unlit';
575
623
 
@@ -953,295 +1001,315 @@ export function recordGeometryDraws(
953
1001
  const materialHandle = submeshMaterial.materialHandle ?? 0;
954
1002
  if (materialHandles === undefined || !materialHandles.has(materialHandle)) continue;
955
1003
  }
956
- // feat-city-glb Bug 5 (per-submesh transparency): in the LDR split, a
957
- // transparent submesh is drawn in the blend sub-pass (non-sRGB view),
958
- // NOT here in the sRGB geometry pass. Skip it. Opaque submeshes of the
959
- // same (mixed) mesh still draw here. Single-material / fully-opaque
960
- // meshes are unaffected (their submesh materials are not transparent).
961
- if (splitLdrSprite && submeshMaterial.transparent === true) {
962
- continue;
963
- }
964
- // bug-20260610 layer 7d: per-submesh BG construction. Texture
965
- // views resolve from `matsForRebind[smIdx]` so the j-th submesh
966
- // sees its own materials[j] textures (baseColor / MR / normal /
967
- // emissive / occlusion). Pick slot j when materials.length covers
968
- // smIdx; otherwise fall back to slot 0 (count-mismatch already
969
- // filtered by extract; this guard handles the materials.length=1
970
- // single-material path mapped over multi-submesh meshes safely).
971
- // This BG drops entityKey: identical-texture-set submeshes
972
- // (whether on the same entity or different ones) share one BG via
973
- // the shaderId-outer `materialBgShared` cache. The 14 handle
974
- // objects form the WeakMap chain and fully discriminate the
975
- // binding state since sampler/textureView/buffer handle identities
976
- // are stable across frames.
977
- //
978
- // feat-20260625-refactor-sprite-as-transparent-mesh M3 / w13:
979
- // sprite materials now use the same per-submesh BG construction
980
- // (the sprite-specific BG branch above is deleted; sprite per-
981
- // submesh single-slot is still enforced via materialSlotIndices
982
- // and the sprite-shaped paramSnapshot fills the PBR-shaped BGL
983
- // bindings via fallback textures for the 4 unused slots).
984
- // feat-city-glb Bug 5: per-submesh material BG assembly extracted to
985
- // the shared `buildPerSubmeshMaterialBg` closure (also called by the
986
- // LDR blend sub-pass). Resolves the shader's user-region textures
987
- // (baseColor/MR/normal + a custom Nth texture), emissive/occlusion
988
- // injection, and Skylight merge; deduped cross-entity via the
989
- // shaderId-outer `materialBgShared` cache.
990
- const materialSlot = materialSlotIndices[i]?.[matSlotIdx] ?? materialSlotIndices[i]?.[0] ?? 0;
991
- const processValue = (
992
- globalThis as {
993
- process?: { env?: Record<string, string | undefined> };
1004
+ const draws: readonly (DispatchEntry | undefined)[] =
1005
+ selectedDispatch === undefined
1006
+ ? [undefined]
1007
+ : (materialPasses.get(entry.renderableIndex)?.get(submeshMaterial.materialHandle ?? 0) ??
1008
+ []);
1009
+ for (const selectedPass of draws) {
1010
+ const selectedShaderId =
1011
+ selectedPass === undefined
1012
+ ? submeshMaterial.materialShaderId
1013
+ : selectedPass.materialShaderId;
1014
+ const selectedRenderState =
1015
+ selectedPass === undefined ? submeshMaterial.renderState : selectedPass.renderState;
1016
+ const selectedStencil =
1017
+ (selectedPass === undefined ? entry.stencilReference : selectedPass.stencilReference) ??
1018
+ 0;
1019
+ if (selectedStencil !== lastStencilReference) {
1020
+ pass.setStencilReference(selectedStencil);
1021
+ lastStencilReference = selectedStencil;
994
1022
  }
995
- ).process;
996
- if (
997
- processValue?.env?.FORGEAX_MATERIAL_PIPELINE_DIAGNOSTICS === '1' &&
998
- (submeshMaterial.materialShaderId === 'forgeax::default-standard-pbr' ||
999
- submeshMaterial.materialShaderId === 'forgeax::pbr-skin')
1000
- ) {
1001
- console.error(
1002
- `[render-material] draw receipt: ${JSON.stringify({
1003
- entityIndex: entry.renderableIndex,
1004
- materialSlot,
1005
- materialHandle: submeshMaterial.materialHandle,
1006
- clearcoat: submeshMaterial.paramSnapshot?.clearcoat,
1007
- shader: submeshMaterial.materialShaderId,
1008
- })}`,
1009
- );
1010
- }
1011
- perSubmeshBg =
1012
- profilePhase === undefined
1013
- ? resolveMaterialBindGroup(
1023
+ // feat-city-glb Bug 5 (per-submesh transparency): in the LDR split, a
1024
+ // transparent submesh is drawn in the blend sub-pass (non-sRGB view),
1025
+ // NOT here in the sRGB geometry pass. Skip it. Opaque submeshes of the
1026
+ // same (mixed) mesh still draw here. Single-material / fully-opaque
1027
+ // meshes are unaffected (their submesh materials are not transparent).
1028
+ if (
1029
+ splitLdrSprite &&
1030
+ (selectedPass === undefined
1031
+ ? submeshMaterial.transparent === true
1032
+ : selectedRenderState?.blend !== undefined)
1033
+ ) {
1034
+ continue;
1035
+ }
1036
+ // bug-20260610 layer 7d: per-submesh BG construction. Texture
1037
+ // views resolve from `matsForRebind[smIdx]` so the j-th submesh
1038
+ // sees its own materials[j] textures (baseColor / MR / normal /
1039
+ // emissive / occlusion). Pick slot j when materials.length covers
1040
+ // smIdx; otherwise fall back to slot 0 (count-mismatch already
1041
+ // filtered by extract; this guard handles the materials.length=1
1042
+ // single-material path mapped over multi-submesh meshes safely).
1043
+ // This BG drops entityKey: identical-texture-set submeshes
1044
+ // (whether on the same entity or different ones) share one BG via
1045
+ // the shaderId-outer `materialBgShared` cache. The 14 handle
1046
+ // objects form the WeakMap chain and fully discriminate the
1047
+ // binding state since sampler/textureView/buffer handle identities
1048
+ // are stable across frames.
1049
+ //
1050
+ // feat-20260625-refactor-sprite-as-transparent-mesh M3 / w13:
1051
+ // sprite materials now use the same per-submesh BG construction
1052
+ // (the sprite-specific BG branch above is deleted; sprite per-
1053
+ // submesh single-slot is still enforced via materialSlotIndices
1054
+ // and the sprite-shaped paramSnapshot fills the PBR-shaped BGL
1055
+ // bindings via fallback textures for the 4 unused slots).
1056
+ // feat-city-glb Bug 5: per-submesh material BG assembly extracted to
1057
+ // the shared `buildPerSubmeshMaterialBg` closure (also called by the
1058
+ // LDR blend sub-pass). Resolves the shader's user-region textures
1059
+ // (baseColor/MR/normal + a custom Nth texture), emissive/occlusion
1060
+ // injection, and Skylight merge; deduped cross-entity via the
1061
+ // shaderId-outer `materialBgShared` cache.
1062
+ const materialSlot =
1063
+ materialSlotIndices[i]?.[matSlotIdx] ?? materialSlotIndices[i]?.[0] ?? 0;
1064
+ const processValue = (
1065
+ globalThis as {
1066
+ process?: { env?: Record<string, string | undefined> };
1067
+ }
1068
+ ).process;
1069
+ if (
1070
+ processValue?.env?.FORGEAX_MATERIAL_PIPELINE_DIAGNOSTICS === '1' &&
1071
+ (submeshMaterial.materialShaderId === 'forgeax::default-standard-pbr' ||
1072
+ submeshMaterial.materialShaderId === 'forgeax::pbr-skin')
1073
+ ) {
1074
+ console.error(
1075
+ `[render-material] draw receipt: ${JSON.stringify({
1076
+ entityIndex: entry.renderableIndex,
1014
1077
  materialSlot,
1015
- submeshMaterial,
1016
- entry.source.entityKey,
1017
- entry.world ?? c.world,
1018
- )
1019
- : profileGeometrySegment(c, passKind, 'material-bind-groups', () =>
1020
- resolveMaterialBindGroup(
1078
+ materialHandle: submeshMaterial.materialHandle,
1079
+ clearcoat: submeshMaterial.paramSnapshot?.clearcoat,
1080
+ shader: submeshMaterial.materialShaderId,
1081
+ })}`,
1082
+ );
1083
+ }
1084
+ perSubmeshBg =
1085
+ profilePhase === undefined
1086
+ ? resolveMaterialBindGroup(
1021
1087
  materialSlot,
1022
1088
  submeshMaterial,
1023
1089
  entry.source.entityKey,
1024
1090
  entry.world ?? c.world,
1025
- ),
1091
+ selectedShaderId,
1092
+ )
1093
+ : profileGeometrySegment(c, passKind, 'material-bind-groups', () =>
1094
+ resolveMaterialBindGroup(
1095
+ materialSlot,
1096
+ submeshMaterial,
1097
+ entry.source.entityKey,
1098
+ entry.world ?? c.world,
1099
+ selectedShaderId,
1100
+ ),
1101
+ );
1102
+ materialGroup1DynamicOffsets[0] = materialSlot * MATERIAL_PER_ENTITY_STRIDE;
1103
+ pass.setBindGroup(1, perSubmeshBg, materialGroup1DynamicOffsets, 0, 1);
1104
+ const smTopology = sm.topology;
1105
+ const smMaterialShaderId =
1106
+ selectedPass !== undefined
1107
+ ? selectedShaderId
1108
+ : entry.source.skin !== undefined
1109
+ ? isStandardPbrSkinMaterialShader(submeshMaterial.materialShaderId)
1110
+ ? submeshMaterial.materialShaderId
1111
+ : SKIN_MATERIAL_SHADER_ID
1112
+ : submeshMaterial.materialShaderId;
1113
+ const probeBlendAvailable =
1114
+ probeBlendRecordAvailable && isStandardPbrMaterialShader(smMaterialShaderId);
1115
+ const isSpriteShader =
1116
+ smMaterialShaderId === 'forgeax::sprite' || smMaterialShaderId === 'forgeax::sprite-lit';
1117
+ // Preserve the dedicated sprite-pass state when the linear-LDR graph
1118
+ // routes a sprite through this generic geometry path. Negative scale
1119
+ // flips winding, so back-face culling would erase the quad.
1120
+ const basePipelineRenderState = isSpriteShader
1121
+ ? {
1122
+ ...selectedRenderState,
1123
+ depthWriteEnabled: false,
1124
+ depthCompare: 'less-equal' as const,
1125
+ cullMode: 'none' as const,
1126
+ blend: selectedRenderState?.blend ?? SPRITE_PREMULTIPLIED_ALPHA_BLEND,
1127
+ }
1128
+ : geometryRenderStateForTopology(smTopology, selectedRenderState);
1129
+ const pipelineRenderState = geometryRenderStateForPass(basePipelineRenderState, passKind);
1130
+ let smPipelineHandle: typeof pipelineState.unlitPipeline;
1131
+ let materialGroup2Contract: 'mesh' | 'skin' | 'cluster' | 'skin-cluster' | undefined;
1132
+ let materialPipelineEntry: MaterialShaderPipelineEntry | null = null;
1133
+ if (smMaterialShaderId === undefined || smMaterialShaderId === 'forgeax::default-unlit') {
1134
+ const unlitShaderId = smMaterialShaderId ?? 'forgeax::default-unlit';
1135
+ const unlitProjectionVariantResult = variantSetFromVertexLayoutProjection(
1136
+ entry.mesh.layoutProjection,
1137
+ undefined,
1138
+ );
1139
+ if (!unlitProjectionVariantResult.ok) {
1140
+ runtime.errorRegistry.fire(unlitProjectionVariantResult.error);
1141
+ continue;
1142
+ }
1143
+ const unlitHasColor =
1144
+ unlitProjectionVariantResult.value === 'VERTEX_COLOR_AVAILABLE=true';
1145
+ const unlitVariantSet = standardStorageVariantSet(
1146
+ runtime.device.caps.storageBuffer,
1147
+ unlitHasColor,
1148
+ );
1149
+ const unlitRsp = resolveMaterialPipeline(
1150
+ unlitShaderId,
1151
+ pipelineRenderState,
1152
+ smTopology,
1153
+ entry.mesh.indexFormat,
1154
+ unlitVariantSet,
1155
+ colorFormatOverride,
1156
+ entry.mesh.layoutProjection,
1157
+ selectedPass?.vertexEntry,
1158
+ selectedPass?.fragmentEntry,
1159
+ );
1160
+ materialPipelineEntry = unlitRsp;
1161
+ smPipelineHandle =
1162
+ unlitRsp?.pipeline ??
1163
+ (colorFormatOverride === undefined
1164
+ ? selectGeometryPipeline(pipelineState, isHdrTarget, msaaActive)
1165
+ : null);
1166
+ } else if (smMaterialShaderId !== undefined) {
1167
+ // feat-20260609 M4.5 / w38 (D-11): the variantSet handed to
1168
+ // getMaterialShaderPipeline MUST mirror the boot-time
1169
+ // `definesKey` rule at createRenderer.ts:2483-2485 (sortedEntries
1170
+ // .every(v=>v===true) ? '' : 'A=v+...'). manifest variant.definesKey
1171
+ // is `''` for the all-true variant, so HDRP (both axes true) must
1172
+ // pass the canonical empty key to hit that variant via
1173
+ // findVariantByKey. Passing the expanded form would produce a
1174
+ // miss and silently fall back to the registered default WGSL,
1175
+ // creating a layout/binding mismatch under HDRP.
1176
+ //
1177
+ // URP path passes the explicit expanded form because the URP
1178
+ // variant's manifest definesKey IS that exact non-empty string
1179
+ // (CLUSTER_FORWARD_AVAILABLE=false+STORAGE_BUFFER_AVAILABLE=true)
1180
+ // -- the canonical-empty rule only applies to the all-true case.
1181
+ const hasVertexColor = entry.mesh.layoutProjection.attributes.some(
1182
+ (attribute) => attribute.key === 'color',
1183
+ );
1184
+ // The all-true HDRP variant uses the canonical empty key only when
1185
+ // every variant axis is true. A plain mesh adds the geometry-owned
1186
+ // VERTEX_COLOR_AVAILABLE=false axis, so retain the two capability
1187
+ // axes explicitly or the selector would silently choose the URP
1188
+ // group(2) layout for an HDRP cluster bind group.
1189
+ const capabilityVariantSet = standardTopologyVariantSet(
1190
+ c.standardLighting,
1191
+ runtime.device.caps.storageBuffer,
1192
+ hasVertexColor,
1193
+ probeBlendAvailable,
1194
+ );
1195
+ const transmissionAvailable = requestsStandardTransmissionVariant(submeshMaterial);
1196
+ const materialCapabilityVariantSet =
1197
+ transmissionAvailable && !capabilityVariantSet.includes('TRANSMISSION_AVAILABLE=')
1198
+ ? `${capabilityVariantSet === '' ? '' : `${capabilityVariantSet}+`}TRANSMISSION_AVAILABLE=true`
1199
+ : capabilityVariantSet;
1200
+ // Sprite's base request must retain the boot-selected artifact. Only
1201
+ // SpriteInstances uses the explicit empty key for the
1202
+ // PER_INSTANCE_REGION=true variant; synthesizing the generic PBR
1203
+ // capability axes for a base sprite would invent a lazy module label
1204
+ // that has no boot seed and skip the first HDR draw.
1205
+ const variantSet = isSpriteShader
1206
+ ? smMaterialShaderId === 'forgeax::sprite' && entry.source.spriteInstances !== undefined
1207
+ ? ''
1208
+ : undefined
1209
+ : entry.variantSet === undefined
1210
+ ? materialCapabilityVariantSet
1211
+ : capabilityVariantSet === ''
1212
+ ? entry.variantSet
1213
+ : `${capabilityVariantSet}+${entry.variantSet}`;
1214
+ const cachedPipeline = resolveMaterialPipeline(
1215
+ smMaterialShaderId,
1216
+ pipelineRenderState,
1217
+ smTopology,
1218
+ entry.mesh.indexFormat,
1219
+ variantSet,
1220
+ colorFormatOverride,
1221
+ entry.mesh.layoutProjection,
1222
+ selectedPass?.vertexEntry,
1223
+ selectedPass?.fragmentEntry,
1224
+ );
1225
+ // feat-20260615-pipeline-spec-ssot M6-T1: cache miss resolves to
1226
+ // null uniformly across URP / HDRP / skin shaders. Charter P3
1227
+ // explicit failure: the pre-M6 URP-path silent fallback to the
1228
+ // boot-time `pipelineState.standardPipeline*` (M4.5-followup w43)
1229
+ // masked real PipelineSpecError build failures behind a
1230
+ // layout-compatible-but-wrong PSO. The per-submesh
1231
+ // `if (smPipelineHandle === null) continue` skip-draw (which
1232
+ // already covered HDRP-active and skin miss paths) is now the
1233
+ // single uniform recovery shape -- one frame of skip-draw on
1234
+ // first-touch, then the cached PSO flows in once the async
1235
+ // build resolves. The pre-loop skin-PSO probe still skips the
1236
+ // entire entry on first-submesh probe miss; this site only
1237
+ // fires on per-submesh topology variance miss.
1238
+ materialPipelineEntry = cachedPipeline;
1239
+ smPipelineHandle = cachedPipeline?.pipeline ?? null;
1240
+ } else {
1241
+ smPipelineHandle = selectGeometryPipeline(pipelineState, isHdrTarget, msaaActive);
1242
+ }
1243
+
1244
+ if (smPipelineHandle === null) {
1245
+ if (diagnosticsEnabled) {
1246
+ console.error(
1247
+ `[render-material] draw skipped: ${JSON.stringify({
1248
+ entityKey: entry.source.entityKey,
1249
+ materialHandle: submeshMaterial.materialHandle,
1250
+ shader: smMaterialShaderId,
1251
+ reason: 'pipeline-unavailable',
1252
+ })}`,
1026
1253
  );
1027
- materialGroup1DynamicOffsets[0] = materialSlot * MATERIAL_PER_ENTITY_STRIDE;
1028
- pass.setBindGroup(1, perSubmeshBg, materialGroup1DynamicOffsets, 0, 1);
1029
- const smTopology = sm.topology;
1030
- const smMaterialShaderId =
1031
- entry.source.skin !== undefined
1032
- ? isStandardPbrSkinMaterialShader(submeshMaterial.materialShaderId)
1033
- ? submeshMaterial.materialShaderId
1034
- : SKIN_MATERIAL_SHADER_ID
1035
- : submeshMaterial.materialShaderId;
1036
- const probeBlendAvailable =
1037
- probeBlendRecordAvailable && isStandardPbrMaterialShader(smMaterialShaderId);
1038
- const isSpriteShader =
1039
- smMaterialShaderId === 'forgeax::sprite' || smMaterialShaderId === 'forgeax::sprite-lit';
1040
- // Preserve the dedicated sprite-pass state when the linear-LDR graph
1041
- // routes a sprite through this generic geometry path. Negative scale
1042
- // flips winding, so back-face culling would erase the quad.
1043
- const basePipelineRenderState = isSpriteShader
1044
- ? {
1045
- ...submeshMaterial.renderState,
1046
- depthWriteEnabled: false,
1047
- depthCompare: 'less-equal' as const,
1048
- cullMode: 'none' as const,
1049
- blend: submeshMaterial.renderState?.blend ?? SPRITE_PREMULTIPLIED_ALPHA_BLEND,
1050
1254
  }
1051
- : geometryRenderStateForTopology(smTopology, submeshMaterial.renderState);
1052
- const pipelineRenderState = geometryRenderStateForPass(basePipelineRenderState, passKind);
1053
- let smPipelineHandle: typeof pipelineState.unlitPipeline;
1054
- let materialGroup2Contract: 'mesh' | 'skin' | 'cluster' | 'skin-cluster' | undefined;
1055
- let materialPipelineEntry: MaterialShaderPipelineEntry | null = null;
1056
- if (smMaterialShaderId === undefined || smMaterialShaderId === 'forgeax::default-unlit') {
1057
- const unlitShaderId = smMaterialShaderId ?? 'forgeax::default-unlit';
1058
- const unlitProjectionVariantResult = variantSetFromVertexLayoutProjection(
1059
- entry.mesh.layoutProjection,
1060
- undefined,
1061
- );
1062
- if (!unlitProjectionVariantResult.ok) {
1063
- runtime.errorRegistry.fire(unlitProjectionVariantResult.error);
1064
1255
  continue;
1065
1256
  }
1066
- const unlitHasColor = unlitProjectionVariantResult.value === 'VERTEX_COLOR_AVAILABLE=true';
1067
- const unlitVariantSet = standardStorageVariantSet(
1068
- runtime.device.caps.storageBuffer,
1069
- unlitHasColor,
1070
- );
1071
- const unlitRsp = resolveMaterialPipeline(
1072
- unlitShaderId,
1073
- pipelineRenderState,
1074
- smTopology,
1075
- entry.mesh.indexFormat,
1076
- unlitVariantSet,
1077
- colorFormatOverride,
1078
- entry.mesh.layoutProjection,
1079
- );
1080
- materialPipelineEntry = unlitRsp;
1081
- smPipelineHandle =
1082
- unlitRsp?.pipeline ??
1083
- (colorFormatOverride === undefined
1084
- ? selectGeometryPipeline(pipelineState, isHdrTarget, msaaActive)
1085
- : null);
1086
- } else if (smMaterialShaderId !== undefined) {
1087
- // feat-20260609 M4.5 / w38 (D-11): the variantSet handed to
1088
- // getMaterialShaderPipeline MUST mirror the boot-time
1089
- // `definesKey` rule at createRenderer.ts:2483-2485 (sortedEntries
1090
- // .every(v=>v===true) ? '' : 'A=v+...'). manifest variant.definesKey
1091
- // is `''` for the all-true variant, so HDRP (both axes true) must
1092
- // pass the canonical empty key to hit that variant via
1093
- // findVariantByKey. Passing the expanded form would produce a
1094
- // miss and silently fall back to the registered default WGSL,
1095
- // creating a layout/binding mismatch under HDRP.
1096
- //
1097
- // URP path passes the explicit expanded form because the URP
1098
- // variant's manifest definesKey IS that exact non-empty string
1099
- // (CLUSTER_FORWARD_AVAILABLE=false+STORAGE_BUFFER_AVAILABLE=true)
1100
- // -- the canonical-empty rule only applies to the all-true case.
1101
- const hasVertexColor = entry.mesh.layoutProjection.attributes.some(
1102
- (attribute) => attribute.key === 'color',
1103
- );
1104
- // The all-true HDRP variant uses the canonical empty key only when
1105
- // every variant axis is true. A plain mesh adds the geometry-owned
1106
- // VERTEX_COLOR_AVAILABLE=false axis, so retain the two capability
1107
- // axes explicitly or the selector would silently choose the URP
1108
- // group(2) layout for an HDRP cluster bind group.
1109
- const capabilityVariantSet = standardTopologyVariantSet(
1110
- c.standardLighting,
1111
- runtime.device.caps.storageBuffer,
1112
- hasVertexColor,
1257
+ // The probe record is an ABI extension of the built-in Standard PBR
1258
+ // shaders only. RenderScene may retain a record on every projected
1259
+ // renderable, but unlit/custom shaders still use the one-binding
1260
+ // instances BGL and must never receive the probe bind group. Resolve
1261
+ // this per submesh so mixed-material meshes cannot cross the layouts.
1262
+ const instanceDraws = resolveGeometryInstanceBuffer(
1263
+ c,
1264
+ entry,
1265
+ identityInstanceDraws,
1113
1266
  probeBlendAvailable,
1114
1267
  );
1115
- const transmissionAvailable = requestsStandardTransmissionVariant(submeshMaterial);
1116
- const materialCapabilityVariantSet =
1117
- transmissionAvailable && !capabilityVariantSet.includes('TRANSMISSION_AVAILABLE=')
1118
- ? `${capabilityVariantSet === '' ? '' : `${capabilityVariantSet}+`}TRANSMISSION_AVAILABLE=true`
1119
- : capabilityVariantSet;
1120
- // Sprite's base request must retain the boot-selected artifact. Only
1121
- // SpriteInstances uses the explicit empty key for the
1122
- // PER_INSTANCE_REGION=true variant; synthesizing the generic PBR
1123
- // capability axes for a base sprite would invent a lazy module label
1124
- // that has no boot seed and skip the first HDR draw.
1125
- const variantSet = isSpriteShader
1126
- ? smMaterialShaderId === 'forgeax::sprite' && entry.source.spriteInstances !== undefined
1127
- ? ''
1128
- : undefined
1129
- : entry.variantSet === undefined
1130
- ? materialCapabilityVariantSet
1131
- : capabilityVariantSet === ''
1132
- ? entry.variantSet
1133
- : `${capabilityVariantSet}+${entry.variantSet}`;
1134
- const cachedPipeline = resolveMaterialPipeline(
1135
- smMaterialShaderId,
1136
- pipelineRenderState,
1137
- smTopology,
1138
- entry.mesh.indexFormat,
1139
- variantSet,
1140
- colorFormatOverride,
1141
- entry.mesh.layoutProjection,
1142
- );
1143
- // feat-20260615-pipeline-spec-ssot M6-T1: cache miss resolves to
1144
- // null uniformly across URP / HDRP / skin shaders. Charter P3
1145
- // explicit failure: the pre-M6 URP-path silent fallback to the
1146
- // boot-time `pipelineState.standardPipeline*` (M4.5-followup w43)
1147
- // masked real PipelineSpecError build failures behind a
1148
- // layout-compatible-but-wrong PSO. The per-submesh
1149
- // `if (smPipelineHandle === null) continue` skip-draw (which
1150
- // already covered HDRP-active and skin miss paths) is now the
1151
- // single uniform recovery shape -- one frame of skip-draw on
1152
- // first-touch, then the cached PSO flows in once the async
1153
- // build resolves. The pre-loop skin-PSO probe still skips the
1154
- // entire entry on first-submesh probe miss; this site only
1155
- // fires on per-submesh topology variance miss.
1156
- materialPipelineEntry = cachedPipeline;
1157
- smPipelineHandle = cachedPipeline?.pipeline ?? null;
1158
- } else {
1159
- smPipelineHandle = selectGeometryPipeline(pipelineState, isHdrTarget, msaaActive);
1160
- }
1161
-
1162
- if (smPipelineHandle === null) {
1163
- if (diagnosticsEnabled) {
1268
+ if (instanceDraws === null) continue;
1269
+ // Standard clustered PBR variants declare the unified cluster/SSAO
1270
+ // group(2) layout. Unlit and other URP-layout materials must bind the
1271
+ // ordinary mesh group instead; choosing one group for the whole pass
1272
+ // makes Dawn reject an otherwise valid unlit pipeline and invalidates
1273
+ // the command buffer before it reaches the surface.
1274
+ if (!isSkinEntry) {
1275
+ materialGroup2Contract = materialPipelineEntry?.group2Contract;
1276
+ if (materialGroup2Contract === undefined) continue;
1277
+ const selectedGroup = selectMaterialGroup2(
1278
+ meshGroup2,
1279
+ meshBindGroup,
1280
+ materialGroup2Contract,
1281
+ );
1282
+ if (selectedGroup === null) continue;
1283
+ group2BindGroup = selectedGroup;
1284
+ }
1285
+ if (diagnosticsEnabled && submeshMaterial.textureHandles !== undefined) {
1164
1286
  console.error(
1165
- `[render-material] draw skipped: ${JSON.stringify({
1287
+ `[render-material] draw submitted: ${JSON.stringify({
1166
1288
  entityKey: entry.source.entityKey,
1167
1289
  materialHandle: submeshMaterial.materialHandle,
1168
1290
  shader: smMaterialShaderId,
1169
- reason: 'pipeline-unavailable',
1291
+ textureHandles: [...submeshMaterial.textureHandles.entries()].map(
1292
+ ([field, handle]) => ({
1293
+ field,
1294
+ handle,
1295
+ }),
1296
+ ),
1297
+ skin: isSkinEntry
1298
+ ? {
1299
+ resourcesReady: skinResources !== null,
1300
+ psoReady: skinPsoProbe !== null,
1301
+ paletteBufferReady: skinSlice?.buffer !== undefined,
1302
+ paletteByteOffset: skinSlice?.byteOffset,
1303
+ dynamicOffsets: [...group2DynamicOffsets],
1304
+ }
1305
+ : undefined,
1306
+ pipelineReady: true,
1170
1307
  })}`,
1171
1308
  );
1172
1309
  }
1173
- continue;
1174
- }
1175
- // The probe record is an ABI extension of the built-in Standard PBR
1176
- // shaders only. RenderScene may retain a record on every projected
1177
- // renderable, but unlit/custom shaders still use the one-binding
1178
- // instances BGL and must never receive the probe bind group. Resolve
1179
- // this per submesh so mixed-material meshes cannot cross the layouts.
1180
- const instanceDraws = resolveGeometryInstanceBuffer(
1181
- c,
1182
- entry,
1183
- identityInstanceDraws,
1184
- probeBlendAvailable,
1185
- );
1186
- if (instanceDraws === null) continue;
1187
- // Standard clustered PBR variants declare the unified cluster/SSAO
1188
- // group(2) layout. Unlit and other URP-layout materials must bind the
1189
- // ordinary mesh group instead; choosing one group for the whole pass
1190
- // makes Dawn reject an otherwise valid unlit pipeline and invalidates
1191
- // the command buffer before it reaches the surface.
1192
- if (!isSkinEntry) {
1193
- materialGroup2Contract = materialPipelineEntry?.group2Contract;
1194
- if (materialGroup2Contract === undefined) continue;
1195
- const selectedGroup = selectMaterialGroup2(
1196
- meshGroup2,
1197
- meshBindGroup,
1198
- materialGroup2Contract,
1199
- );
1200
- if (selectedGroup === null) continue;
1201
- group2BindGroup = selectedGroup;
1202
- }
1203
- if (diagnosticsEnabled && submeshMaterial.textureHandles !== undefined) {
1204
- console.error(
1205
- `[render-material] draw submitted: ${JSON.stringify({
1206
- entityKey: entry.source.entityKey,
1207
- materialHandle: submeshMaterial.materialHandle,
1208
- shader: smMaterialShaderId,
1209
- textureHandles: [...submeshMaterial.textureHandles.entries()].map(
1210
- ([field, handle]) => ({
1211
- field,
1212
- handle,
1213
- }),
1214
- ),
1215
- skin: isSkinEntry
1216
- ? {
1217
- resourcesReady: skinResources !== null,
1218
- psoReady: skinPsoProbe !== null,
1219
- paletteBufferReady: skinSlice?.buffer !== undefined,
1220
- paletteByteOffset: skinSlice?.byteOffset,
1221
- dynamicOffsets: [...group2DynamicOffsets],
1222
- }
1223
- : undefined,
1224
- pipelineReady: true,
1225
- })}`,
1226
- );
1227
- }
1228
- pass.setBindGroup(2, group2BindGroup, group2DynamicOffsets);
1310
+ pass.setBindGroup(2, group2BindGroup, group2DynamicOffsets);
1229
1311
 
1230
- if (profilePhase === undefined) {
1231
- recordOcclusionCandidate(() =>
1232
- submitSubmeshDraws(
1233
- pass,
1234
- bindingState,
1235
- smPipelineHandle,
1236
- instanceDraws,
1237
- entry.mesh.indexed,
1238
- sm.indexCount,
1239
- sm.vertexCount,
1240
- sm.indexOffset,
1241
- ),
1242
- );
1243
- } else {
1244
- profileGeometrySegment(c, passKind, 'draw-submit', () =>
1312
+ if (profilePhase === undefined) {
1245
1313
  recordOcclusionCandidate(() =>
1246
1314
  submitSubmeshDraws(
1247
1315
  pass,
@@ -1253,11 +1321,26 @@ export function recordGeometryDraws(
1253
1321
  sm.vertexCount,
1254
1322
  sm.indexOffset,
1255
1323
  ),
1256
- ),
1257
- );
1258
- }
1259
- if (diagnosticsEnabled && perSubmeshBg !== null) {
1260
- recordIblPipelineBinding(c, submeshMaterial, smPipelineHandle, perSubmeshBg);
1324
+ );
1325
+ } else {
1326
+ profileGeometrySegment(c, passKind, 'draw-submit', () =>
1327
+ recordOcclusionCandidate(() =>
1328
+ submitSubmeshDraws(
1329
+ pass,
1330
+ bindingState,
1331
+ smPipelineHandle,
1332
+ instanceDraws,
1333
+ entry.mesh.indexed,
1334
+ sm.indexCount,
1335
+ sm.vertexCount,
1336
+ sm.indexOffset,
1337
+ ),
1338
+ ),
1339
+ );
1340
+ }
1341
+ if (diagnosticsEnabled && perSubmeshBg !== null) {
1342
+ recordIblPipelineBinding(c, submeshMaterial, smPipelineHandle, perSubmeshBg);
1343
+ }
1261
1344
  }
1262
1345
  }
1263
1346
  }
@@ -1268,8 +1351,285 @@ export function recordGeometryDraws(
1268
1351
  *
1269
1352
  * @internal
1270
1353
  */
1271
- function resolveGeometryInstanceBuffer(
1272
- c: _InternalRenderPipelineContext,
1354
+ function uploadInstanceRanges(
1355
+ c: Pick<
1356
+ _InternalRenderPipelineContext,
1357
+ 'runtime' | 'pipelineState' | 'frameState' | 'bindGroupCounts'
1358
+ >,
1359
+ inst: NonNullable<RenderableSnapshot['instances']>,
1360
+ buffer: Buffer,
1361
+ ranges: readonly { readonly start: number; readonly end: number }[],
1362
+ storageBacked: boolean,
1363
+ sourceOffsetInstances = 0,
1364
+ ): InstanceUploadResult {
1365
+ const strideBytes =
1366
+ (storageBacked ? INSTANCE_STORAGE_STRIDE_FLOATS : 16) * Float32Array.BYTES_PER_ELEMENT;
1367
+ const uploadedRanges: { start: number; end: number }[] = [];
1368
+ let uploadedBytes = 0;
1369
+ for (const range of ranges) {
1370
+ const start = Math.max(0, Math.min(inst.instanceCount, range.start));
1371
+ const end = Math.max(start, Math.min(inst.instanceCount, range.end));
1372
+ if (end <= start) continue;
1373
+ const source = inst.transforms.subarray(
1374
+ (sourceOffsetInstances + start) * 16,
1375
+ (sourceOffsetInstances + end) * 16,
1376
+ );
1377
+ const payload = storageBacked ? packInstanceStorageBuffer(source) : source;
1378
+ const written = c.runtime.device.queue.writeBuffer(buffer, start * strideBytes, payload);
1379
+ if (!written.ok) {
1380
+ c.runtime.errorRegistry.fire(written.error);
1381
+ return { ok: false, ranges: uploadedRanges, bytes: uploadedBytes };
1382
+ }
1383
+ uploadedRanges.push({ start, end });
1384
+ uploadedBytes += payload.byteLength;
1385
+ }
1386
+ return { ok: true, ranges: uploadedRanges, bytes: uploadedBytes };
1387
+ }
1388
+
1389
+ export function reportInstanceResidency(
1390
+ c: Pick<
1391
+ _InternalRenderPipelineContext,
1392
+ 'runtime' | 'pipelineState' | 'frameState' | 'bindGroupCounts'
1393
+ >,
1394
+ inst: NonNullable<RenderableSnapshot['instances']>,
1395
+ input: {
1396
+ readonly lane: 'direct-storage' | 'chunked-storage' | 'direct-uniform' | 'chunked-uniform';
1397
+ readonly requestedBytes: number;
1398
+ readonly supportedBytes: number | undefined;
1399
+ readonly uploadRanges: readonly { readonly start: number; readonly end: number }[];
1400
+ readonly uploadedBytes: number;
1401
+ },
1402
+ ): void {
1403
+ if (inst.collectionId === undefined || c.frameState.instanceResidency === undefined) return;
1404
+ recordInstanceResidency(c.frameState.instanceResidency, {
1405
+ collectionId: inst.collectionId,
1406
+ frameNumber: c.frameState.frameNumber,
1407
+ residentGeneration: c.runtime.deviceScope.generation,
1408
+ lane: input.lane,
1409
+ requestedBytes: input.requestedBytes,
1410
+ supportedBytes: input.supportedBytes,
1411
+ uploadRanges: input.uploadRanges,
1412
+ uploadedBytes: input.uploadedBytes,
1413
+ backend: c.runtime.device.caps.backendKind,
1414
+ });
1415
+ }
1416
+
1417
+ function reportInstanceFailure(
1418
+ c: Pick<
1419
+ _InternalRenderPipelineContext,
1420
+ 'runtime' | 'pipelineState' | 'frameState' | 'bindGroupCounts'
1421
+ >,
1422
+ inst: NonNullable<RenderableSnapshot['instances']>,
1423
+ error: {
1424
+ readonly code: InstanceCollectionFailureCode;
1425
+ readonly expected: string;
1426
+ readonly hint: string;
1427
+ },
1428
+ requestedBytes: number,
1429
+ supportedBytes: number | undefined,
1430
+ ): void {
1431
+ if (inst.collectionId === undefined || c.frameState.instanceResidency === undefined) return;
1432
+ recordInstanceFailure(c.frameState.instanceResidency, {
1433
+ collectionId: inst.collectionId,
1434
+ code: error.code,
1435
+ expected: error.expected,
1436
+ hint: error.hint,
1437
+ facts: {
1438
+ requestedBytes,
1439
+ supportedBytes,
1440
+ backend: c.runtime.device.caps.backendKind,
1441
+ owner: 'renderer.instances',
1442
+ cause: error.code,
1443
+ recovery: error.hint,
1444
+ },
1445
+ });
1446
+ }
1447
+
1448
+ function clearInstanceChunksForOwner(
1449
+ c: Pick<
1450
+ _InternalRenderPipelineContext,
1451
+ 'runtime' | 'pipelineState' | 'frameState' | 'bindGroupCounts'
1452
+ >,
1453
+ ownerKey: number,
1454
+ ): void {
1455
+ const chunks = c.frameState.instanceBufferChunks;
1456
+ if (chunks === undefined) return;
1457
+ const prefix = `${ownerKey}:`;
1458
+ for (const [key, entry] of chunks.entries()) {
1459
+ if (!key.startsWith(prefix)) continue;
1460
+ if (!entry.buffer.isDestroyed) {
1461
+ const destroyed = entry.buffer.destroy();
1462
+ if (!destroyed.ok) c.runtime.errorRegistry.fire(destroyed.error);
1463
+ }
1464
+ chunks.delete(key);
1465
+ }
1466
+ }
1467
+
1468
+ export function resolveStorageInstanceChunks(
1469
+ c: Pick<
1470
+ _InternalRenderPipelineContext,
1471
+ 'runtime' | 'pipelineState' | 'frameState' | 'bindGroupCounts'
1472
+ >,
1473
+ entry: _InternalRenderPipelineContext['validatedOrdered'][number],
1474
+ inst: NonNullable<RenderableSnapshot['instances']>,
1475
+ cap: number,
1476
+ probeBuffer: Buffer | undefined,
1477
+ probeOffset: number,
1478
+ ): readonly GeometryInstanceDraw[] | null {
1479
+ const { runtime, frameState } = c;
1480
+ const strideBytes = INSTANCE_STORAGE_STRIDE_FLOATS * Float32Array.BYTES_PER_ELEMENT;
1481
+ const chunkCapacity = Math.floor(cap / strideBytes);
1482
+ if (chunkCapacity < 1) {
1483
+ reportInstanceFailure(
1484
+ c,
1485
+ inst,
1486
+ {
1487
+ code: 'limit-exceeded',
1488
+ expected: `maxStorageBufferBindingSize (${cap}) >= ${strideBytes}`,
1489
+ hint: 'use a backend with storage-buffer bindings large enough for one InstanceData record',
1490
+ },
1491
+ inst.instanceCount * strideBytes,
1492
+ cap,
1493
+ );
1494
+ runtime.errorRegistry.fire(
1495
+ new RhiError({
1496
+ code: 'limit-exceeded',
1497
+ expected: `maxStorageBufferBindingSize (${cap}) >= ${strideBytes}`,
1498
+ hint: 'use a backend with storage-buffer bindings large enough for one InstanceData record',
1499
+ detail: { maxStorageBufferBindingSize: cap, requestedBytes: strideBytes },
1500
+ }),
1501
+ );
1502
+ return null;
1503
+ }
1504
+ const ownerKey = instanceCollectionCacheKey(entry.source.worldId, inst);
1505
+ const chunks = frameState.instanceBufferChunks;
1506
+ const activeKeys = new Set<string>();
1507
+ const draws: GeometryInstanceDraw[] = [];
1508
+ const uploadedRanges: { start: number; end: number }[] = [];
1509
+ let uploadedBytes = 0;
1510
+ for (let start = 0; start < inst.instanceCount; start += chunkCapacity) {
1511
+ const end = Math.min(inst.instanceCount, start + chunkCapacity);
1512
+ const count = end - start;
1513
+ const chunkKey = `${ownerKey}:${start}`;
1514
+ activeKeys.add(chunkKey);
1515
+ // Keep stable-resident checks arithmetic-only; packing is deferred to the
1516
+ // upload path below, where a new or dirty range actually needs payload.
1517
+ const payloadBytes = count * strideBytes;
1518
+ const previous = chunks?.get(chunkKey);
1519
+ let active =
1520
+ previous !== undefined &&
1521
+ previous.uploadedArchVersion === inst.archVersion &&
1522
+ previous.uploadedByteLength === payloadBytes
1523
+ ? previous
1524
+ : undefined;
1525
+ let activeIsNew = false;
1526
+ if (active === undefined) {
1527
+ const created = runtime.device.createBuffer({
1528
+ size: payloadBytes,
1529
+ usage: GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST,
1530
+ mappedAtCreation: false,
1531
+ });
1532
+ if (!created.ok) {
1533
+ runtime.errorRegistry.fire(created.error);
1534
+ reportInstanceFailure(c, inst, created.error, inst.instanceCount * strideBytes, cap);
1535
+ return null;
1536
+ }
1537
+ if (previous !== undefined && !previous.buffer.isDestroyed) {
1538
+ const destroyed = previous.buffer.destroy();
1539
+ if (!destroyed.ok) runtime.errorRegistry.fire(destroyed.error);
1540
+ }
1541
+ active = {
1542
+ buffer: new GpuBuffer(runtime.device, created.value),
1543
+ uploadedArchVersion: inst.archVersion,
1544
+ uploadedByteLength: payloadBytes,
1545
+ };
1546
+ activeIsNew = true;
1547
+ chunks?.set(chunkKey, active);
1548
+ if (chunks === undefined) frameState.transientInstanceBuffers.push(active);
1549
+ }
1550
+ if (active === undefined) return null;
1551
+ const needsUpload =
1552
+ activeIsNew || active.uploadedRevision !== (inst.revision ?? inst.archVersion);
1553
+ const rangesToUpload = [
1554
+ {
1555
+ start: 0,
1556
+ end: count,
1557
+ },
1558
+ ];
1559
+ if (needsUpload && rangesToUpload.length > 0) {
1560
+ const upload = uploadInstanceRanges(
1561
+ c,
1562
+ inst,
1563
+ active.buffer.handle,
1564
+ rangesToUpload,
1565
+ true,
1566
+ start,
1567
+ );
1568
+ for (const range of upload.ranges) {
1569
+ uploadedRanges.push({ start: range.start + start, end: range.end + start });
1570
+ }
1571
+ uploadedBytes += upload.bytes;
1572
+ if (!upload.ok) {
1573
+ reportInstanceFailure(
1574
+ c,
1575
+ inst,
1576
+ {
1577
+ code: 'queue-write-buffer-failed',
1578
+ expected:
1579
+ 'the renderer-owned instance storage buffer accepts the complete instance payload',
1580
+ hint: 'retry after the active device is healthy or recover the renderer',
1581
+ },
1582
+ inst.instanceCount * strideBytes,
1583
+ cap,
1584
+ );
1585
+ return null;
1586
+ }
1587
+ }
1588
+ if (needsUpload) {
1589
+ {
1590
+ const published = { ...active, uploadedRevision: inst.revision ?? inst.archVersion };
1591
+ active = published;
1592
+ chunks?.set(chunkKey, published);
1593
+ }
1594
+ }
1595
+ const activeBuffer = active;
1596
+ draws.push({
1597
+ instanceBuffer: activeBuffer.buffer.handle,
1598
+ instanceBindGroup: resolveGeometryInstancesBindGroup(
1599
+ c,
1600
+ activeBuffer.buffer.handle,
1601
+ probeBuffer,
1602
+ ),
1603
+ probeOffset,
1604
+ instanceCount: count,
1605
+ });
1606
+ }
1607
+ if (chunks !== undefined) {
1608
+ const prefix = `${ownerKey}:`;
1609
+ for (const [key, stale] of chunks.entries()) {
1610
+ if (!key.startsWith(prefix) || activeKeys.has(key)) continue;
1611
+ if (!stale.buffer.isDestroyed) {
1612
+ const destroyed = stale.buffer.destroy();
1613
+ if (!destroyed.ok) runtime.errorRegistry.fire(destroyed.error);
1614
+ }
1615
+ chunks.delete(key);
1616
+ }
1617
+ }
1618
+ reportInstanceResidency(c, inst, {
1619
+ lane: 'chunked-storage',
1620
+ requestedBytes: inst.instanceCount * strideBytes,
1621
+ supportedBytes: cap,
1622
+ uploadRanges: uploadedRanges,
1623
+ uploadedBytes,
1624
+ });
1625
+ return draws;
1626
+ }
1627
+
1628
+ export function resolveGeometryInstanceBuffer(
1629
+ c: Pick<
1630
+ _InternalRenderPipelineContext,
1631
+ 'runtime' | 'pipelineState' | 'frameState' | 'bindGroupCounts'
1632
+ >,
1273
1633
  entry: _InternalRenderPipelineContext['validatedOrdered'][number],
1274
1634
  identityInstanceDraws: readonly GeometryInstanceDraw[],
1275
1635
  useProbeBlend: boolean,
@@ -1304,6 +1664,11 @@ function resolveGeometryInstanceBuffer(
1304
1664
  // A present, empty Instances array means zero draws. Do not fall back to
1305
1665
  // the shared identity buffer, which would silently render one instance.
1306
1666
  if (inst.instanceCount === 0) return [];
1667
+ const entityCacheKey = instanceCollectionCacheKey(entry.source.worldId, inst);
1668
+ const revision = inst.revision ?? inst.archVersion;
1669
+ const sameRevision = (cached: InstanceBufferCacheEntry): boolean =>
1670
+ cached.uploadedRevision === revision;
1671
+ const fullRange = [{ start: 0, end: inst.instanceCount }] as const;
1307
1672
  let instanceBuffer: Buffer = pipelineState.identityInstanceBuffer;
1308
1673
  let instanceCount = 1;
1309
1674
  {
@@ -1315,41 +1680,126 @@ function resolveGeometryInstanceBuffer(
1315
1680
  let instanceBufferUsage = GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST;
1316
1681
 
1317
1682
  if (uniformFallback && inst.instanceCount > MAX_UNIFORM_INSTANCES) {
1683
+ const ownerKey = instanceCollectionCacheKey(entry.source.worldId, inst);
1684
+ const chunks = frameState.instanceBufferChunks;
1685
+ const activeKeys = new Set<string>();
1318
1686
  const draws: GeometryInstanceDraw[] = [];
1687
+ const uniformStrideBytes = 16 * Float32Array.BYTES_PER_ELEMENT;
1688
+ const uploadedRanges: { start: number; end: number }[] = [];
1689
+ let uploadedBytes = 0;
1319
1690
  for (let start = 0; start < inst.instanceCount; start += MAX_UNIFORM_INSTANCES) {
1320
1691
  const count = Math.min(MAX_UNIFORM_INSTANCES, inst.instanceCount - start);
1321
- const chunk = inst.transforms.subarray(start * 16, (start + count) * 16);
1322
- const bufRes = runtime.device.createBuffer({
1323
- size: INSTANCE_UBO_FULL_ARRAY_BYTES,
1324
- usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
1325
- mappedAtCreation: false,
1326
- });
1327
- if (!bufRes.ok) {
1328
- runtime.errorRegistry.fire(bufRes.error);
1329
- return null;
1692
+ const chunkKey = `${ownerKey}:${start}`;
1693
+ activeKeys.add(chunkKey);
1694
+ const payloadBytes = count * uniformStrideBytes;
1695
+ const previous = chunks?.get(chunkKey);
1696
+ let active =
1697
+ previous !== undefined &&
1698
+ previous.uploadedArchVersion === inst.archVersion &&
1699
+ previous.uploadedByteLength === payloadBytes
1700
+ ? previous
1701
+ : undefined;
1702
+ let activeIsNew = false;
1703
+ if (active === undefined) {
1704
+ const created = runtime.device.createBuffer({
1705
+ size: INSTANCE_UBO_FULL_ARRAY_BYTES,
1706
+ usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
1707
+ mappedAtCreation: false,
1708
+ });
1709
+ if (!created.ok) {
1710
+ runtime.errorRegistry.fire(created.error);
1711
+ reportInstanceFailure(
1712
+ c,
1713
+ inst,
1714
+ created.error,
1715
+ inst.instanceCount * uniformStrideBytes,
1716
+ MAX_UNIFORM_INSTANCES * uniformStrideBytes,
1717
+ );
1718
+ return null;
1719
+ }
1720
+ if (previous !== undefined && !previous.buffer.isDestroyed) {
1721
+ const destroyed = previous.buffer.destroy();
1722
+ if (!destroyed.ok) runtime.errorRegistry.fire(destroyed.error);
1723
+ }
1724
+ active = {
1725
+ buffer: new GpuBuffer(runtime.device, created.value),
1726
+ uploadedArchVersion: inst.archVersion,
1727
+ uploadedByteLength: payloadBytes,
1728
+ };
1729
+ activeIsNew = true;
1730
+ chunks?.set(chunkKey, active);
1731
+ if (chunks === undefined) frameState.transientInstanceBuffers.push(active);
1330
1732
  }
1331
- const buffer = new GpuBuffer(runtime.device, bufRes.value);
1332
- frameState.transientInstanceBuffers.push({
1333
- buffer,
1334
- uploadedArchVersion: inst.archVersion,
1335
- uploadedByteLength: chunk.byteLength,
1336
- });
1337
- const writeRes = runtime.device.queue.writeBuffer(buffer.handle, 0, chunk);
1338
- if (!writeRes.ok) {
1339
- runtime.errorRegistry.fire(writeRes.error);
1340
- return null;
1733
+ const needsUpload = activeIsNew || !sameRevision(active);
1734
+ const rangesToUpload = [
1735
+ {
1736
+ start: 0,
1737
+ end: count,
1738
+ },
1739
+ ];
1740
+ if (needsUpload && rangesToUpload.length > 0) {
1741
+ const upload = uploadInstanceRanges(
1742
+ c,
1743
+ inst,
1744
+ active.buffer.handle,
1745
+ rangesToUpload,
1746
+ false,
1747
+ start,
1748
+ );
1749
+ for (const range of upload.ranges) {
1750
+ uploadedRanges.push({ start: range.start + start, end: range.end + start });
1751
+ }
1752
+ uploadedBytes += upload.bytes;
1753
+ if (!upload.ok) {
1754
+ reportInstanceFailure(
1755
+ c,
1756
+ inst,
1757
+ {
1758
+ code: 'queue-write-buffer-failed',
1759
+ expected:
1760
+ 'the renderer-owned instance uniform buffer accepts the complete instance payload',
1761
+ hint: 'retry after the active device is healthy or recover the renderer',
1762
+ },
1763
+ inst.instanceCount * uniformStrideBytes,
1764
+ MAX_UNIFORM_INSTANCES * uniformStrideBytes,
1765
+ );
1766
+ return null;
1767
+ }
1768
+ }
1769
+ if (needsUpload) {
1770
+ const published = { ...active, uploadedRevision: revision };
1771
+ active = published;
1772
+ chunks?.set(chunkKey, published);
1341
1773
  }
1342
1774
  draws.push({
1343
- instanceBuffer: buffer.handle,
1775
+ instanceBuffer: active.buffer.handle,
1344
1776
  instanceBindGroup: resolveGeometryInstancesBindGroup(
1345
1777
  c,
1346
- buffer.handle,
1778
+ active.buffer.handle,
1347
1779
  probeBinding?.buffer,
1348
1780
  ),
1349
1781
  probeOffset: probeBinding?.offset ?? -1,
1350
1782
  instanceCount: count,
1351
1783
  });
1352
1784
  }
1785
+ if (chunks !== undefined) {
1786
+ const prefix = `${ownerKey}:`;
1787
+ for (const [key, stale] of chunks.entries()) {
1788
+ if (!key.startsWith(prefix) || activeKeys.has(key)) continue;
1789
+ if (!stale.buffer.isDestroyed) {
1790
+ const destroyed = stale.buffer.destroy();
1791
+ if (!destroyed.ok) runtime.errorRegistry.fire(destroyed.error);
1792
+ }
1793
+ chunks.delete(key);
1794
+ }
1795
+ }
1796
+ reportInstanceResidency(c, inst, {
1797
+ lane: 'chunked-uniform',
1798
+ requestedBytes: inst.instanceCount * uniformStrideBytes,
1799
+ supportedBytes: MAX_UNIFORM_INSTANCES * uniformStrideBytes,
1800
+ uploadRanges: uploadedRanges,
1801
+ uploadedBytes,
1802
+ });
1353
1803
  return draws;
1354
1804
  }
1355
1805
  if (uniformFallback) {
@@ -1359,31 +1809,30 @@ function resolveGeometryInstanceBuffer(
1359
1809
  {
1360
1810
  // Cap-gate (LimitExceededDetail single emit point — feat-20260514
1361
1811
  // M3 / w15 anchor): `requestedBytes <= maxStorageBufferBindingSize`.
1362
- const instancePayload = uniformFallback
1363
- ? inst.transforms
1364
- : packInstanceStorageBuffer(inst.transforms);
1365
- const requestedBytes = instancePayload.byteLength;
1812
+ // Derive the size from the authoring count; storage packing is deferred
1813
+ // until uploadInstanceRanges confirms a new or dirty resident.
1814
+ const requestedBytes =
1815
+ inst.instanceCount *
1816
+ (uniformFallback ? 16 : INSTANCE_STORAGE_STRIDE_FLOATS) *
1817
+ Float32Array.BYTES_PER_ELEMENT;
1366
1818
  const cap = runtime.device.limits.maxStorageBufferBindingSize;
1367
1819
  if (!uniformFallback && typeof cap === 'number' && cap > 0 && requestedBytes > cap) {
1368
- runtime.errorRegistry.fire(
1369
- new RhiError({
1370
- code: 'limit-exceeded',
1371
- expected: `requestedBytes (${requestedBytes}) <= maxStorageBufferBindingSize (${cap})`,
1372
- hint: 'reduce instance count to fit within device.limits.maxStorageBufferBindingSize, or split transforms across multiple Instances entries',
1373
- detail: {
1374
- maxStorageBufferBindingSize: cap,
1375
- requestedBytes,
1376
- },
1377
- }),
1820
+ const chunked = resolveStorageInstanceChunks(
1821
+ c,
1822
+ entry,
1823
+ inst,
1824
+ cap,
1825
+ probeBinding?.buffer,
1826
+ probeBinding?.offset ?? -1,
1378
1827
  );
1379
- return null;
1828
+ return chunked;
1380
1829
  } else {
1381
1830
  // Look up the cached GPU buffer or create a fresh one when the
1382
1831
  // archetype version bumped or the byte length changed.
1383
- const cached = frameState.instanceBuffers.get(
1384
- worldEntityKey(entry.source.worldId, inst.cacheKey),
1385
- );
1832
+ // entityCacheKey is the worldEntityKey + collectionId namespace.
1833
+ const cached = frameState.instanceBuffers.get(entityCacheKey);
1386
1834
  let active: InstanceBufferCacheEntry | null = null;
1835
+ let activeIsNew = false;
1387
1836
  if (
1388
1837
  cached !== undefined &&
1389
1838
  cached.uploadedArchVersion === inst.archVersion &&
@@ -1398,6 +1847,8 @@ function resolveGeometryInstanceBuffer(
1398
1847
  });
1399
1848
  if (!bufRes.ok) {
1400
1849
  runtime.errorRegistry.fire(bufRes.error);
1850
+ reportInstanceFailure(c, inst, bufRes.error, requestedBytes, cap);
1851
+ return null;
1401
1852
  } else {
1402
1853
  // feat-20260619 M4 / F12: destroy the old cached buffer
1403
1854
  // before replacing it with the new one (D-6).
@@ -1411,24 +1862,67 @@ function resolveGeometryInstanceBuffer(
1411
1862
  uploadedArchVersion: inst.archVersion,
1412
1863
  uploadedByteLength: requestedBytes,
1413
1864
  };
1414
- frameState.instanceBuffers.set(
1415
- worldEntityKey(entry.source.worldId, inst.cacheKey),
1416
- active,
1417
- );
1865
+ activeIsNew = true;
1866
+ if (active !== null) {
1867
+ // Keep the collection identity in the worldEntityKey namespace.
1868
+ frameState.instanceBuffers.set(entityCacheKey, active);
1869
+ }
1418
1870
  }
1419
1871
  }
1420
1872
  if (active !== null) {
1421
- const writeRes = runtime.device.queue.writeBuffer(
1422
- active.buffer.handle,
1423
- 0,
1424
- instancePayload,
1425
- );
1426
- if (!writeRes.ok) {
1427
- runtime.errorRegistry.fire(writeRes.error);
1428
- } else {
1429
- instanceBuffer = active.buffer.handle;
1430
- instanceCount = Math.max(1, inst.instanceCount);
1873
+ const needsUpload = activeIsNew || !sameRevision(active);
1874
+ const ranges = [fullRange[0]];
1875
+ let uploadRanges: readonly { readonly start: number; readonly end: number }[] = [];
1876
+ let uploadedBytes = 0;
1877
+ if (needsUpload && ranges.length > 0) {
1878
+ const upload = uploadInstanceRanges(
1879
+ c,
1880
+ inst,
1881
+ active.buffer.handle,
1882
+ ranges,
1883
+ !uniformFallback,
1884
+ );
1885
+ uploadRanges = upload.ranges;
1886
+ uploadedBytes = upload.bytes;
1887
+ if (!upload.ok) {
1888
+ reportInstanceFailure(
1889
+ c,
1890
+ inst,
1891
+ {
1892
+ code: 'queue-write-buffer-failed',
1893
+ expected:
1894
+ 'the renderer-owned instance buffer accepts the complete instance payloads',
1895
+ hint: 'retry after the active device is healthy or recover the renderer',
1896
+ },
1897
+ requestedBytes,
1898
+ uniformFallback
1899
+ ? MAX_UNIFORM_INSTANCES * Float32Array.BYTES_PER_ELEMENT * 16
1900
+ : typeof cap === 'number'
1901
+ ? cap
1902
+ : undefined,
1903
+ );
1904
+ return null;
1905
+ }
1906
+ }
1907
+ if (needsUpload) {
1908
+ const published = { ...active, uploadedRevision: revision };
1909
+ frameState.instanceBuffers.set(entityCacheKey, published);
1910
+ active = published;
1431
1911
  }
1912
+ instanceBuffer = active.buffer.handle;
1913
+ instanceCount = Math.max(1, inst.instanceCount);
1914
+ reportInstanceResidency(c, inst, {
1915
+ lane: uniformFallback ? 'direct-uniform' : 'direct-storage',
1916
+ requestedBytes,
1917
+ supportedBytes: uniformFallback
1918
+ ? MAX_UNIFORM_INSTANCES * Float32Array.BYTES_PER_ELEMENT * 16
1919
+ : typeof cap === 'number'
1920
+ ? cap
1921
+ : undefined,
1922
+ uploadRanges,
1923
+ uploadedBytes,
1924
+ });
1925
+ clearInstanceChunksForOwner(c, entityCacheKey);
1432
1926
  }
1433
1927
  }
1434
1928
  // The cache write above is keyed by worldEntityKey(worldId, cacheKey)
@@ -1446,7 +1940,10 @@ function resolveGeometryInstanceBuffer(
1446
1940
  }
1447
1941
 
1448
1942
  export function resolveProbeBlendBuffer(
1449
- c: _InternalRenderPipelineContext,
1943
+ c: Pick<
1944
+ _InternalRenderPipelineContext,
1945
+ 'runtime' | 'pipelineState' | 'frameState' | 'bindGroupCounts'
1946
+ >,
1450
1947
  record: import('../scene/probe-blend-record').ProbeBlendRecord | undefined,
1451
1948
  entityKey?: number,
1452
1949
  ): { readonly buffer: Buffer; readonly offset: number } {
@@ -1494,7 +1991,10 @@ export function resolveProbeBlendBuffer(
1494
1991
  }
1495
1992
 
1496
1993
  export function resolveGeometryInstancesBindGroup(
1497
- c: _InternalRenderPipelineContext,
1994
+ c: Pick<
1995
+ _InternalRenderPipelineContext,
1996
+ 'runtime' | 'pipelineState' | 'frameState' | 'bindGroupCounts'
1997
+ >,
1498
1998
  instanceBuffer: Buffer,
1499
1999
  probeBuffer?: Buffer,
1500
2000
  ): BindGroup {