@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
@@ -1,206 +1,23 @@
1
- // @forgeax/engine-render - Instances component (per-entity instanced-draw transforms).
2
- //
3
- // Two sources, one component (feat-20260622-chunk-gpu-instancing-sprite-tilemap
4
- // / AC-09; charter P4 consistent abstraction + charter P1 progressive disclosure).
5
- // The `Instances` ECS component has exactly TWO sources of `transforms` data, and
6
- // AI users should hold the boundary explicitly to avoid misreading the ECS view:
7
- //
8
- // Source 1 (EXPLICIT, the only source that lands in ECS) — AI user opt-in.
9
- // The AI user hand-attaches `Instances { transforms }` onto an entity at
10
- // spawn / set time for high-performance instanced draw. `world.get(e,
11
- // Instances)` returns the AI user's payload verbatim (a `Float32Array`
12
- // snapshot of column-major mat4 instances; stride = 16). This is the sole
13
- // code path through which `Instances` ever appears on an entity.
14
- //
15
- // Source 2 (TRANSPARENT, never written back into ECS) — engine record-stage
16
- // fold. When the AI user spawns N independent sprite / tilemap-cell
17
- // entities sharing the same (Layer.value, posZ, materialHandle) sort
18
- // equivalence class, the engine record stage transparently folds the
19
- // equivalent run into ONE `drawIndexed(indexCount, count)` using an
20
- // INTERNAL transient buffer (see `render-system-record.ts` fold operator
21
- // + `render.instancing.foldedDraws` metric, and the `RhiError
22
- // 'instancing-exceeds-uniform-cap'` fallback). The fold operator does
23
- // NOT auto-inject `Instances` onto those entities — `world.get(e,
24
- // Instances)` on a folded entity returns `Result.err` exactly as before.
25
- // The ECS view is preserved bit-for-bit (per-cell / per-sprite entity
26
- // retained, query / despawn / picking unchanged); the optimisation is
27
- // invisible at the component layer (charter P4: consistent abstraction —
28
- // the high-performance path is opt-in via Source 1, the default spawn
29
- // shape stays untouched).
30
- //
31
- // AI user reading this header: do NOT expect engine-side fold to materialise
32
- // as an `Instances` row on your entity. Read the metric (foldedDraws) or the
33
- // RhiError code to observe fold behaviour; never gate logic on a runtime
34
- // `world.get(e, Instances)` lookup after spawn.
35
- //
36
- // Schema: 1 array<f32> field `transforms` carrying packed column-major mat4
37
- // instance transforms (16 f32 per instance, stride = 16). Stride is enforced
38
- // through a TWO-LAYER contract:
39
- //
40
- // 1. AI user set-site: spawn / `world.set` / `world.push` callers pass a
41
- // `Float32Array` (or numeric array) whose `length` is a non-zero
42
- // multiple of 16. The set site is the AI user's responsibility -- the
43
- // engine cannot prove the column-major mat4 packing without the
44
- // caller's intent.
45
- // 2. RenderSystem extract entry: `render-system-extract.ts` performs a
46
- // defensive `transforms.length % 16 === 0` check on every
47
- // `world.get(e, Instances).transforms` snapshot at frame extract time;
48
- // violations route a structured `InstanceTransformsStrideMismatchError`
49
- // (`code: 'instance-transforms-stride-mismatch'`,
50
- // `detail: { actualLength, expectedStride: 16 }`) through the World
51
- // Layer-3 ErrorHandler and the renderable is skipped (fail-fast: the
52
- // malformed length never reaches the GPU upload path).
53
- //
54
- // feat-20260515-buffer-array-vocab-collapse M3 / w16 (decision §2.3 stride
55
- // responsibility migration): the legacy component-level
56
- // stride-declaration option (`{ transforms: 16 }` keyed on the retired
57
- // per-component stride defineComponent option key) was retired because:
58
- // - The ECS layer no longer carries a per-component stride schema (M2 /
59
- // w9 dropped the option from `DefineComponentOptions` -- the SSOT
60
- // moved to the RenderSystem entry + AI user set site).
61
- // - Centralising the check at the consumer (RenderSystem extract) rather
62
- // than the producer (ECS write paths) lets the GPU upload path stay
63
- // trust-the-snapshot; AI users get one structured error per offending
64
- // frame rather than one per write.
65
- //
66
- // feat-20260514-ecs-children-instances-managed-buffer-array M3 / w14:
67
- // migrated from the legacy `{ buffer: 'ref', count: 'u32' }` pair (which
68
- // cross-coupled with the now-deleted `AssetRegistry.createInstancedBuffer`
69
- // pipeline + the `InstancedBufferAsset` POD) to the ECS-managed array path.
70
- // Resize is a `world.push(e, Instances, 'transforms', value)` away (one f32
71
- // at a time) plus a future `world.grow` if a bulk-resize affordance is
72
- // added.
73
- //
74
- // Storage buffer cap-gate (D-5 OOS-08 follow-up):
75
- // - The runtime RenderSystem consumer (`render-system-record.ts`) checks
76
- // `device.caps.storageBuffer` before binding the per-entity transforms;
77
- // `caps.storageBuffer === false` (backend lacking storage buffer support,
78
- // e.g. rhi-wgpu webgl backend) routes a `RhiError` ('feature-not-enabled').
79
- // A future loop owns the per-draw fallback path.
80
- //
81
- // Group transform chained semantics (charter proposition 5 mental migration):
82
- // - When the same entity carries both `Transform` (entity_world) and
83
- // `Instances` (per-instance local transforms), the vertex shader
84
- // composes per instance:
85
- //
86
- // world_position[i] = entity_world * instances_local[i] * vertex_position
87
- //
88
- // - `Instances.transforms[i*16..i*16+15]` is interpreted as a local-space
89
- // transform under the entity, exactly like a child entity's `Transform`
90
- // under `ChildOf { parent }` in the hierarchy system. AI users reuse
91
- // the `parent x local` intuition without a new concept. Set the
92
- // entity's `Transform` to identity to make `instances_local[i]`
93
- // directly world-space.
94
- //
95
- // 4-segment minimum contract (read this header before reaching for
96
- // the source body):
97
- //
98
- // ===== (a) single-component import + spawn example =====
99
- //
100
- // import {
101
- // createRenderer, HANDLE_CUBE,
102
- // MeshFilter, MeshRenderer, Transform,
103
- // Instances, type InstancesData,
104
- // } from '@forgeax/engine-render';
105
- //
106
- // // 1 entity rendering N instanced cubes (16N packed f32, column-major mat4):
107
- // const transforms = new Float32Array(N * 16);
108
- // // ... fill column-major mat4 columns per instance ...
109
- // world.spawn(
110
- // { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
111
- // { component: MeshRenderer, data: {} },
112
- // { component: Instances, data: { transforms } },
113
- // );
114
- //
115
- // ===== (b) packed mat4 layout =====
116
- //
117
- // const transforms = new Float32Array(N * 16); // column-major mat4 per instance
118
- // // instance i occupies floats [i*16 .. i*16+15]:
119
- // // [m00 m10 m20 m30 <- column 0
120
- // // m01 m11 m21 m31 <- column 1
121
- // // m02 m12 m22 m32 <- column 2
122
- // // m03 m13 m23 m33] <- column 3 (translation in m03/m13/m23)
123
- //
124
- // `transforms.length` MUST be a non-zero multiple of 16. AI users gate at
125
- // the set / push site; the RenderSystem extract entry holds the second
126
- // defensive (AC-06: detail carries `{ expectedStride: 16, actualLength }`).
127
- // The instance count is the live snapshot length / 16.
128
- //
129
- // ===== (c) error code consumption (typed property access, no message regex) =====
130
- //
131
- // // Stride violation surfaces through the engine-ecs Layer-3 ErrorHandler
132
- // // when extract reads a malformed snapshot:
133
- // // on('error', (err) => {
134
- // // if (err.code === 'instance-transforms-stride-mismatch') {
135
- // // // err.detail.expectedStride === 16
136
- // // // err.detail.actualLength === <user-supplied length>
137
- // // }
138
- // // });
139
- // //
140
- // // RenderSystem cap-gate (D-5) -> RhiError 'feature-not-enabled' on the
141
- // // RhiErrorListenerRegistry channel:
142
- // // on('error', (err) => {
143
- // // if (err.code === 'feature-not-enabled') {
144
- // // console.warn(err.expected, err.hint);
145
- // // }
146
- // // });
147
- //
148
- // charter mapping: proposition 1 (single import surface
149
- // `import { Instances, type InstancesData } from '@forgeax/engine-render'`);
150
- // proposition 3 (machine-readable schema > prose:
151
- // `{ transforms: 'array<f32>' }` is the SSOT, stride 16 documented here +
152
- // enforced at the RenderSystem entry);
153
- // proposition 4 (explicit failure: stride violation routes a structured
154
- // EcsError, not a silent half-row); proposition 5 (consistent abstraction:
155
- // Instances mirrors Children -- both ride the array-vocab path).
156
- //
157
- // Anchors: requirements §AC-06 (stride 16 + RenderSystem upload path);
158
- // plan-strategy §2.3 (stride responsibility migration to RenderSystem entry
159
- // defensive + AI user set site) + §3.3 stride error path; plan-decisions
160
- // D-1 / D-5.
161
- // OOS-09 (no addChild Commands API) does not apply here; OOS-01 (no
162
- // dangling-entity sweep) does not apply (Instances stores f32 not entity).
163
-
164
1
  import { defineComponent } from '@forgeax/engine-ecs';
165
2
 
166
3
  /**
167
- * Per-entity instanced-draw transforms (ECS component).
168
- *
169
- * Carries a variable-length `array<f32>` of column-major mat4 instance
170
- * transforms (16 f32 per instance, stride = 16; documented above and
171
- * enforced at the RenderSystem extract entry, NOT at the ECS write paths).
172
- *
173
- * The runtime RenderSystem consumer materialises a fresh `Float32Array`
174
- * snapshot on every `world.get(e, Instances).transforms` access (D-4
175
- * no-cache), reads the live count from the snapshot's `length`, and
176
- * uploads the bytes to a per-entity GPU storage buffer in the record stage
177
- * (`render-system-record.ts`).
4
+ * World-owned local transforms for one explicit instanced renderable.
5
+ * Packed column-major mat4 values compose as entityWorld * instanceLocal.
6
+ * ECS owns copying, mutation and entity lifetime, including large arrays.
7
+ * Renderer extraction derives its own snapshot and GPU residency; neither
8
+ * authoring nor glTF SceneAsset lowering requires a Renderer identity.
178
9
  *
179
- * @example Spawn an entity rendering 10000 instanced cubes:
180
- * const transforms = new Float32Array(10000 * 16);
181
- * // ... fill column-major mat4 columns ...
182
- * world.spawn(
183
- * { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
184
- * { component: MeshRenderer, data: { ... } },
185
- * { component: Instances, data: { transforms } },
186
- * );
10
+ * @example
11
+ * const transforms = new Float32Array(20_000 * 16);
12
+ * for (let i = 0; i < 20_000; i++) {
13
+ * transforms[i * 16] = transforms[i * 16 + 5] =
14
+ * transforms[i * 16 + 10] = transforms[i * 16 + 15] = 1;
15
+ * }
16
+ * const entity = world.spawn({ component: Instances, data: { transforms } }).unwrap();
17
+ * world.set(entity, Instances, { transforms }).unwrap();
187
18
  */
188
19
  export const Instances = defineComponent('Instances', {
189
20
  transforms: { type: 'array<f32>' },
190
21
  });
191
22
 
192
- /**
193
- * Type-level hint for `data` at the `Instances` spawn site.
194
- *
195
- * The runtime ECS column shape for `array<f32>` accepts a `Float32Array`
196
- * payload at spawn / set time (the bytes are copied into the BufferPool
197
- * slot). The `transforms` field is documented as `Float32Array` here so
198
- * AI-user IDE autocomplete picks up the typed-array shape.
199
- *
200
- * @example
201
- * import type { InstancesData } from '@forgeax/engine-render';
202
- * const data: InstancesData = { transforms: new Float32Array(N * 16) };
203
- */
204
- export type InstancesData = {
205
- readonly transforms: Float32Array;
206
- };
23
+ export type InstancesData = { readonly transforms: Float32Array };
@@ -49,7 +49,12 @@ import {
49
49
  projectedHeightForCandidate,
50
50
  type SubmissionPlan,
51
51
  } from './batch-topology';
52
- import { type GpuDrivenLodSelectionInspection, GpuDrivenView, selectGpuLodLane } from './view-gpu';
52
+ import {
53
+ GPU_DRIVEN_VIEW_WGSL,
54
+ type GpuDrivenLodSelectionInspection,
55
+ GpuDrivenView,
56
+ selectGpuLodLane,
57
+ } from './view-gpu';
53
58
 
54
59
  const VERTEX_STAGE = 0x1;
55
60
  const FRAGMENT_STAGE = 0x2;
@@ -1038,6 +1043,34 @@ export class GpuDrivenProduction {
1038
1043
  return new GpuDrivenProduction(device, shaderModuleFactory);
1039
1044
  }
1040
1045
 
1046
+ /** Recovery has no frame retry: await generated programs before preparing the graph. */
1047
+ static async forRecoveryDevice(
1048
+ device: RhiDevice,
1049
+ factory: PipelineBuilderShaderModuleFactory,
1050
+ createShaderModule: (descriptor: {
1051
+ code: string;
1052
+ label?: string;
1053
+ }) => Promise<Result<ShaderModule, RhiError>>,
1054
+ ): Promise<Result<GpuDrivenProduction, RhiError>> {
1055
+ const modules = new Map<string, ShaderModule>();
1056
+ for (const descriptor of [
1057
+ { label: 'gpu-driven-view', code: GPU_DRIVEN_VIEW_WGSL },
1058
+ { label: 'gpu-driven-rigid-unlit', code: GPU_DRIVEN_RIGID_UNLIT_WGSL },
1059
+ ]) {
1060
+ const module = await createShaderModule(descriptor);
1061
+ if (!module.ok) return module;
1062
+ modules.set(descriptor.code, module.value);
1063
+ }
1064
+ return ok(
1065
+ new GpuDrivenProduction(device, {
1066
+ createShaderModule: (descriptor) => {
1067
+ const module = modules.get(descriptor.code);
1068
+ return module === undefined ? factory.createShaderModule(descriptor) : ok(module);
1069
+ },
1070
+ }),
1071
+ );
1072
+ }
1073
+
1041
1074
  createRecoveryRoot(scope: DeviceScope): LifecycleResourceSpec<unknown> {
1042
1075
  return {
1043
1076
  kind: 'scene-table',
package/src/gpu-scene.ts CHANGED
@@ -225,6 +225,10 @@ export class GpuScene {
225
225
  drawTemplate: [],
226
226
  material: [],
227
227
  };
228
+ /** Transform rows whose previous value advances only after submit. */
229
+ private readonly pendingTemporalTransforms = new Set<number>();
230
+ /** Previous rows whose post-submit upload must be retried before the next draw. */
231
+ private readonly pendingTemporalUploads = new Set<number>();
228
232
 
229
233
  private constructor(
230
234
  private readonly device: RhiDevice,
@@ -273,31 +277,47 @@ export class GpuScene {
273
277
  }
274
278
 
275
279
  sync(delta: RenderSceneApplyResult): Result<GpuSceneSyncResult, RhiError> {
280
+ for (const transformIndex of this.pendingTemporalUploads) {
281
+ this.writesByTable.transform.push(transformIndex);
282
+ }
283
+ const pendingWrites = TABLE_NAMES.some((name) => this.writesByTable[name].length > 0);
276
284
  if (
277
285
  delta.createdSlots.length === 0 &&
278
286
  delta.updatedSlots.length === 0 &&
279
287
  delta.recreatedSlots.length === 0 &&
280
- delta.removedSlots.length === 0
288
+ delta.removedSlots.length === 0 &&
289
+ !pendingWrites
281
290
  ) {
282
291
  this.noChangeFrames += 1;
283
292
  return ok({ ranges: 0, bytes: 0, grew: false, cleared: 0 });
284
293
  }
285
294
  const writesByTable = this.writesByTable;
286
- for (const name of TABLE_NAMES) writesByTable[name].length = 0;
287
- if (!this.identityUploaded) writesByTable.transform.push(0);
295
+ if (!this.identityUploaded && !writesByTable.transform.includes(0)) {
296
+ writesByTable.transform.push(0);
297
+ }
288
298
  let highestChangedSlot = -1;
289
- for (const records of [delta.createdSlots, delta.recreatedSlots]) {
299
+ const contentUpdatedSlots = delta.contentUpdatedSlots ?? [];
300
+ const contentUpdatedSlotIds = new Set(contentUpdatedSlots.map((record) => record.slot));
301
+ const allocationResets = new Set<number>();
302
+ // RenderScene may recycle a released CPU slot for a different identity in
303
+ // the same delta. Clear the old GPU allocation before ensuring the new
304
+ // record; otherwise the later removal pass clears the newly allocated
305
+ // record and the next transform-only update sees no allocation.
306
+ for (const record of delta.removedSlots) {
307
+ if (record.slot > highestChangedSlot) highestChangedSlot = record.slot;
308
+ this.clearSlot(record.slot, writesByTable);
309
+ }
310
+ for (const records of [delta.createdSlots, contentUpdatedSlots, delta.recreatedSlots]) {
290
311
  for (const record of records) {
312
+ const previous = this.allocations[record.slot];
291
313
  this.ensureAllocation(record, writesByTable);
314
+ if (previous !== this.allocations[record.slot]) allocationResets.add(record.slot);
292
315
  if (record.slot > highestChangedSlot) highestChangedSlot = record.slot;
293
316
  }
294
317
  }
295
318
  for (const record of delta.updatedSlots) {
296
319
  if (record.slot > highestChangedSlot) highestChangedSlot = record.slot;
297
320
  }
298
- for (const record of delta.removedSlots) {
299
- if (record.slot > highestChangedSlot) highestChangedSlot = record.slot;
300
- }
301
321
  const requiredCapacity = Math.max(
302
322
  highestChangedSlot + 1,
303
323
  this.instances.requiredCapacity(),
@@ -312,11 +332,18 @@ export class GpuScene {
312
332
  }
313
333
 
314
334
  for (const record of delta.createdSlots) this.writeSlot(record, true, writesByTable);
315
- for (const record of delta.updatedSlots) this.writeRootTransform(record, false, writesByTable);
335
+ for (const record of contentUpdatedSlots) {
336
+ this.writeSlot(record, allocationResets.has(record.slot), writesByTable);
337
+ }
338
+ for (const record of delta.updatedSlots) {
339
+ if (contentUpdatedSlotIds.has(record.slot)) continue;
340
+ this.writeRootTransform(record, false, writesByTable);
341
+ }
316
342
  for (const record of delta.recreatedSlots) this.writeSlot(record, true, writesByTable);
317
- for (const record of delta.removedSlots) this.clearSlot(record.slot, writesByTable);
318
343
  const uploaded = this.uploadRows(writesByTable);
319
344
  if (!uploaded.ok) return uploaded;
345
+ this.pendingTemporalUploads.clear();
346
+ for (const name of TABLE_NAMES) writesByTable[name].length = 0;
320
347
  this.identityUploaded = true;
321
348
  this.clearedSlots += delta.removedSlots.length;
322
349
  return ok({
@@ -329,27 +356,47 @@ export class GpuScene {
329
356
 
330
357
  /** Publish current transforms as previous only after a successful submit. */
331
358
  commitTemporalFrame(enabled = true): Result<void, RhiError> {
332
- if (!enabled) return ok(undefined);
333
- const nextTransform = this.tableBytes.transform.slice(0);
334
- const next = new Uint8Array(nextTransform);
335
- const current = this.transformBytes;
359
+ if (!enabled) {
360
+ this.pendingTemporalTransforms.clear();
361
+ this.pendingTemporalUploads.clear();
362
+ return ok(undefined);
363
+ }
364
+ if (this.pendingTemporalTransforms.size === 0) return ok(undefined);
336
365
  const currentOffset = offset(TRANSFORM, 'currentWorld');
337
366
  const previousOffset = offset(TRANSFORM, 'previousWorld');
338
- for (let index = 0; index < this.capacity; index += 1) {
339
- next.set(
340
- current.subarray(
367
+ const ranges = coalesceSlots([...this.pendingTemporalTransforms]);
368
+ for (const range of ranges) {
369
+ for (let index = range.start; index < range.end; index += 1) {
370
+ this.pendingTemporalUploads.add(index);
371
+ }
372
+ }
373
+ for (const range of ranges) {
374
+ for (let index = range.start; index < range.end; index += 1) {
375
+ this.transformBytes.copyWithin(
376
+ index * TRANSFORM.stride + previousOffset,
341
377
  index * TRANSFORM.stride + currentOffset,
342
378
  index * TRANSFORM.stride + currentOffset + 64,
343
- ),
344
- index * TRANSFORM.stride + previousOffset,
379
+ );
380
+ }
381
+ }
382
+ for (const range of ranges) {
383
+ const firstOffset = range.start * TRANSFORM.stride;
384
+ const lastOffset = range.end * TRANSFORM.stride;
385
+ const uploaded = this.device.queue.writeBuffer(
386
+ this.buffers.transform,
387
+ firstOffset,
388
+ this.transformBytes.subarray(firstOffset, lastOffset),
345
389
  );
390
+ if (!uploaded.ok) return uploaded;
346
391
  }
347
- const uploaded = this.device.queue.writeBuffer(this.buffers.transform, 0, next);
348
- if (!uploaded.ok) return uploaded;
349
- this.tableBytes.transform = nextTransform;
350
- this.refreshTransformViews();
351
- this.uploadRanges += 1;
352
- this.uploadBytes += next.byteLength;
392
+ this.pendingTemporalTransforms.clear();
393
+ this.pendingTemporalUploads.clear();
394
+ const bytes = ranges.reduce(
395
+ (total, range) => total + (range.end - range.start) * TRANSFORM.stride,
396
+ 0,
397
+ );
398
+ this.uploadRanges += ranges.length;
399
+ this.uploadBytes += bytes;
353
400
  return ok(undefined);
354
401
  }
355
402
 
@@ -359,6 +406,9 @@ export class GpuScene {
359
406
  this.transforms.reset(1);
360
407
  this.draws.reset();
361
408
  this.materials.reset();
409
+ this.pendingTemporalTransforms.clear();
410
+ this.pendingTemporalUploads.clear();
411
+ for (const name of TABLE_NAMES) this.writesByTable[name].length = 0;
362
412
  const writesByTable: Record<GpuSceneTableName, number[]> = {
363
413
  primitive: [],
364
414
  instance: [],
@@ -520,7 +570,8 @@ export class GpuScene {
520
570
  const localCurrentOffset = localOffset + offset(TRANSFORM, 'currentWorld');
521
571
  const localPreviousOffset = localOffset + offset(TRANSFORM, 'previousWorld');
522
572
  writeMat4(transform, localCurrentOffset, localWorld);
523
- writeMat4(transform, localPreviousOffset, localWorld);
573
+ if (resetPrevious) writeMat4(transform, localPreviousOffset, localWorld);
574
+ else this.pendingTemporalTransforms.add(transformIndex);
524
575
  writes.transform.push(transformIndex);
525
576
  }
526
577
  writes.instance.push(instanceIndex);
@@ -631,14 +682,16 @@ export class GpuScene {
631
682
  const allocation = this.allocations[record.slot];
632
683
  if (allocation === undefined) throw new RangeError('GPU Scene allocation unavailable');
633
684
  const transformOffset = allocation.transformStart * TRANSFORM.stride;
634
- const currentOffset = transformOffset + offset(TRANSFORM, 'currentWorld');
635
685
  const previousOffset = transformOffset + offset(TRANSFORM, 'previousWorld');
636
686
  if (resetPrevious) {
637
687
  writeMat4(this.transformView, previousOffset, record.snapshot.transform.world);
638
- } else {
639
- this.transformBytes.copyWithin(previousOffset, currentOffset, currentOffset + 64);
640
688
  }
641
- writeMat4(this.transformView, currentOffset, record.snapshot.transform.world);
689
+ writeMat4(
690
+ this.transformView,
691
+ transformOffset + offset(TRANSFORM, 'currentWorld'),
692
+ record.snapshot.transform.world,
693
+ );
694
+ if (!resetPrevious) this.pendingTemporalTransforms.add(allocation.transformStart);
642
695
  writes.transform.push(allocation.transformStart);
643
696
  }
644
697
 
@@ -743,6 +796,8 @@ export class GpuScene {
743
796
  TRANSFORM.stride,
744
797
  ).fill(0);
745
798
  writes.transform.push(transformIndex);
799
+ this.pendingTemporalTransforms.delete(transformIndex);
800
+ this.pendingTemporalUploads.delete(transformIndex);
746
801
  }
747
802
  for (let ordinal = 0; ordinal < allocation.drawCount; ordinal += 1) {
748
803
  const drawIndex = allocation.drawStart + ordinal;
package/src/index.ts CHANGED
@@ -196,6 +196,7 @@ export type {
196
196
  ReflectionProbeSelectionInspection,
197
197
  TransmissionInspection,
198
198
  } from './inspection-types';
199
+ export type { InstanceCollectionInspection } from './instances';
199
200
  export {
200
201
  type MaterialColorInput3,
201
202
  type MaterialColorInput4,
@@ -23,12 +23,16 @@ import type { GpuBuffer } from './gpu-resource';
23
23
  * and routes dispose through the RHI shim's destroy bookkeeping SSOT. The
24
24
  * two `uploaded*` fields are the cache invalidation fingerprint -- when the
25
25
  * archetype version bumps or the byte length changes, the record stage
26
- * allocates a fresh GpuBuffer and replaces the entry.
26
+ * allocates a fresh GpuBuffer and replaces the entry. Explicit collection
27
+ * entries are keyed by renderer-owned collection id at the outer map; the
28
+ * legacy entity key remains only for sprite/fold snapshots.
27
29
  */
28
30
  export interface InstanceBufferCacheEntry {
29
31
  readonly buffer: GpuBuffer;
30
32
  readonly uploadedByteLength: number;
31
33
  readonly uploadedArchVersion: number;
34
+ /** Renderer-owned collection revision uploaded into this buffer. */
35
+ readonly uploadedRevision?: number;
32
36
  }
33
37
 
34
38
  function destroyInstanceBufferEntries(
@@ -85,6 +89,15 @@ export function disposeInstanceBuffers(
85
89
  map.clear();
86
90
  }
87
91
 
92
+ /** Destroy the renderer-owned buffers used by internal large-instance chunks. */
93
+ export function disposeInstanceBufferChunks(
94
+ map: Map<string, InstanceBufferCacheEntry>,
95
+ errorRegistry?: InstanceBufferCacheErrorSink,
96
+ ): void {
97
+ destroyInstanceBufferEntries(map.values(), errorRegistry);
98
+ map.clear();
99
+ }
100
+
88
101
  /** Destroy transient uniform-fallback chunks allocated for the previous frame. */
89
102
  export function disposeTransientInstanceBuffers(
90
103
  entries: InstanceBufferCacheEntry[],