@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
@@ -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 { 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;
@@ -1268,8 +1290,285 @@ export function recordGeometryDraws(
1268
1290
  *
1269
1291
  * @internal
1270
1292
  */
1271
- function resolveGeometryInstanceBuffer(
1272
- c: _InternalRenderPipelineContext,
1293
+ function uploadInstanceRanges(
1294
+ c: Pick<
1295
+ _InternalRenderPipelineContext,
1296
+ 'runtime' | 'pipelineState' | 'frameState' | 'bindGroupCounts'
1297
+ >,
1298
+ inst: NonNullable<RenderableSnapshot['instances']>,
1299
+ buffer: Buffer,
1300
+ ranges: readonly { readonly start: number; readonly end: number }[],
1301
+ storageBacked: boolean,
1302
+ sourceOffsetInstances = 0,
1303
+ ): InstanceUploadResult {
1304
+ const strideBytes =
1305
+ (storageBacked ? INSTANCE_STORAGE_STRIDE_FLOATS : 16) * Float32Array.BYTES_PER_ELEMENT;
1306
+ const uploadedRanges: { start: number; end: number }[] = [];
1307
+ let uploadedBytes = 0;
1308
+ for (const range of ranges) {
1309
+ const start = Math.max(0, Math.min(inst.instanceCount, range.start));
1310
+ const end = Math.max(start, Math.min(inst.instanceCount, range.end));
1311
+ if (end <= start) continue;
1312
+ const source = inst.transforms.subarray(
1313
+ (sourceOffsetInstances + start) * 16,
1314
+ (sourceOffsetInstances + end) * 16,
1315
+ );
1316
+ const payload = storageBacked ? packInstanceStorageBuffer(source) : source;
1317
+ const written = c.runtime.device.queue.writeBuffer(buffer, start * strideBytes, payload);
1318
+ if (!written.ok) {
1319
+ c.runtime.errorRegistry.fire(written.error);
1320
+ return { ok: false, ranges: uploadedRanges, bytes: uploadedBytes };
1321
+ }
1322
+ uploadedRanges.push({ start, end });
1323
+ uploadedBytes += payload.byteLength;
1324
+ }
1325
+ return { ok: true, ranges: uploadedRanges, bytes: uploadedBytes };
1326
+ }
1327
+
1328
+ export function reportInstanceResidency(
1329
+ c: Pick<
1330
+ _InternalRenderPipelineContext,
1331
+ 'runtime' | 'pipelineState' | 'frameState' | 'bindGroupCounts'
1332
+ >,
1333
+ inst: NonNullable<RenderableSnapshot['instances']>,
1334
+ input: {
1335
+ readonly lane: 'direct-storage' | 'chunked-storage' | 'direct-uniform' | 'chunked-uniform';
1336
+ readonly requestedBytes: number;
1337
+ readonly supportedBytes: number | undefined;
1338
+ readonly uploadRanges: readonly { readonly start: number; readonly end: number }[];
1339
+ readonly uploadedBytes: number;
1340
+ },
1341
+ ): void {
1342
+ if (inst.collectionId === undefined || c.frameState.instanceResidency === undefined) return;
1343
+ recordInstanceResidency(c.frameState.instanceResidency, {
1344
+ collectionId: inst.collectionId,
1345
+ frameNumber: c.frameState.frameNumber,
1346
+ residentGeneration: c.runtime.deviceScope.generation,
1347
+ lane: input.lane,
1348
+ requestedBytes: input.requestedBytes,
1349
+ supportedBytes: input.supportedBytes,
1350
+ uploadRanges: input.uploadRanges,
1351
+ uploadedBytes: input.uploadedBytes,
1352
+ backend: c.runtime.device.caps.backendKind,
1353
+ });
1354
+ }
1355
+
1356
+ function reportInstanceFailure(
1357
+ c: Pick<
1358
+ _InternalRenderPipelineContext,
1359
+ 'runtime' | 'pipelineState' | 'frameState' | 'bindGroupCounts'
1360
+ >,
1361
+ inst: NonNullable<RenderableSnapshot['instances']>,
1362
+ error: {
1363
+ readonly code: InstanceCollectionFailureCode;
1364
+ readonly expected: string;
1365
+ readonly hint: string;
1366
+ },
1367
+ requestedBytes: number,
1368
+ supportedBytes: number | undefined,
1369
+ ): void {
1370
+ if (inst.collectionId === undefined || c.frameState.instanceResidency === undefined) return;
1371
+ recordInstanceFailure(c.frameState.instanceResidency, {
1372
+ collectionId: inst.collectionId,
1373
+ code: error.code,
1374
+ expected: error.expected,
1375
+ hint: error.hint,
1376
+ facts: {
1377
+ requestedBytes,
1378
+ supportedBytes,
1379
+ backend: c.runtime.device.caps.backendKind,
1380
+ owner: 'renderer.instances',
1381
+ cause: error.code,
1382
+ recovery: error.hint,
1383
+ },
1384
+ });
1385
+ }
1386
+
1387
+ function clearInstanceChunksForOwner(
1388
+ c: Pick<
1389
+ _InternalRenderPipelineContext,
1390
+ 'runtime' | 'pipelineState' | 'frameState' | 'bindGroupCounts'
1391
+ >,
1392
+ ownerKey: number,
1393
+ ): void {
1394
+ const chunks = c.frameState.instanceBufferChunks;
1395
+ if (chunks === undefined) return;
1396
+ const prefix = `${ownerKey}:`;
1397
+ for (const [key, entry] of chunks.entries()) {
1398
+ if (!key.startsWith(prefix)) continue;
1399
+ if (!entry.buffer.isDestroyed) {
1400
+ const destroyed = entry.buffer.destroy();
1401
+ if (!destroyed.ok) c.runtime.errorRegistry.fire(destroyed.error);
1402
+ }
1403
+ chunks.delete(key);
1404
+ }
1405
+ }
1406
+
1407
+ export function resolveStorageInstanceChunks(
1408
+ c: Pick<
1409
+ _InternalRenderPipelineContext,
1410
+ 'runtime' | 'pipelineState' | 'frameState' | 'bindGroupCounts'
1411
+ >,
1412
+ entry: _InternalRenderPipelineContext['validatedOrdered'][number],
1413
+ inst: NonNullable<RenderableSnapshot['instances']>,
1414
+ cap: number,
1415
+ probeBuffer: Buffer | undefined,
1416
+ probeOffset: number,
1417
+ ): readonly GeometryInstanceDraw[] | null {
1418
+ const { runtime, frameState } = c;
1419
+ const strideBytes = INSTANCE_STORAGE_STRIDE_FLOATS * Float32Array.BYTES_PER_ELEMENT;
1420
+ const chunkCapacity = Math.floor(cap / strideBytes);
1421
+ if (chunkCapacity < 1) {
1422
+ reportInstanceFailure(
1423
+ c,
1424
+ inst,
1425
+ {
1426
+ code: 'limit-exceeded',
1427
+ expected: `maxStorageBufferBindingSize (${cap}) >= ${strideBytes}`,
1428
+ hint: 'use a backend with storage-buffer bindings large enough for one InstanceData record',
1429
+ },
1430
+ inst.instanceCount * strideBytes,
1431
+ cap,
1432
+ );
1433
+ runtime.errorRegistry.fire(
1434
+ new RhiError({
1435
+ code: 'limit-exceeded',
1436
+ expected: `maxStorageBufferBindingSize (${cap}) >= ${strideBytes}`,
1437
+ hint: 'use a backend with storage-buffer bindings large enough for one InstanceData record',
1438
+ detail: { maxStorageBufferBindingSize: cap, requestedBytes: strideBytes },
1439
+ }),
1440
+ );
1441
+ return null;
1442
+ }
1443
+ const ownerKey = instanceCollectionCacheKey(entry.source.worldId, inst);
1444
+ const chunks = frameState.instanceBufferChunks;
1445
+ const activeKeys = new Set<string>();
1446
+ const draws: GeometryInstanceDraw[] = [];
1447
+ const uploadedRanges: { start: number; end: number }[] = [];
1448
+ let uploadedBytes = 0;
1449
+ for (let start = 0; start < inst.instanceCount; start += chunkCapacity) {
1450
+ const end = Math.min(inst.instanceCount, start + chunkCapacity);
1451
+ const count = end - start;
1452
+ const chunkKey = `${ownerKey}:${start}`;
1453
+ activeKeys.add(chunkKey);
1454
+ // Keep stable-resident checks arithmetic-only; packing is deferred to the
1455
+ // upload path below, where a new or dirty range actually needs payload.
1456
+ const payloadBytes = count * strideBytes;
1457
+ const previous = chunks?.get(chunkKey);
1458
+ let active =
1459
+ previous !== undefined &&
1460
+ previous.uploadedArchVersion === inst.archVersion &&
1461
+ previous.uploadedByteLength === payloadBytes
1462
+ ? previous
1463
+ : undefined;
1464
+ let activeIsNew = false;
1465
+ if (active === undefined) {
1466
+ const created = runtime.device.createBuffer({
1467
+ size: payloadBytes,
1468
+ usage: GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST,
1469
+ mappedAtCreation: false,
1470
+ });
1471
+ if (!created.ok) {
1472
+ runtime.errorRegistry.fire(created.error);
1473
+ reportInstanceFailure(c, inst, created.error, inst.instanceCount * strideBytes, cap);
1474
+ return null;
1475
+ }
1476
+ if (previous !== undefined && !previous.buffer.isDestroyed) {
1477
+ const destroyed = previous.buffer.destroy();
1478
+ if (!destroyed.ok) runtime.errorRegistry.fire(destroyed.error);
1479
+ }
1480
+ active = {
1481
+ buffer: new GpuBuffer(runtime.device, created.value),
1482
+ uploadedArchVersion: inst.archVersion,
1483
+ uploadedByteLength: payloadBytes,
1484
+ };
1485
+ activeIsNew = true;
1486
+ chunks?.set(chunkKey, active);
1487
+ if (chunks === undefined) frameState.transientInstanceBuffers.push(active);
1488
+ }
1489
+ if (active === undefined) return null;
1490
+ const needsUpload =
1491
+ activeIsNew || active.uploadedRevision !== (inst.revision ?? inst.archVersion);
1492
+ const rangesToUpload = [
1493
+ {
1494
+ start: 0,
1495
+ end: count,
1496
+ },
1497
+ ];
1498
+ if (needsUpload && rangesToUpload.length > 0) {
1499
+ const upload = uploadInstanceRanges(
1500
+ c,
1501
+ inst,
1502
+ active.buffer.handle,
1503
+ rangesToUpload,
1504
+ true,
1505
+ start,
1506
+ );
1507
+ for (const range of upload.ranges) {
1508
+ uploadedRanges.push({ start: range.start + start, end: range.end + start });
1509
+ }
1510
+ uploadedBytes += upload.bytes;
1511
+ if (!upload.ok) {
1512
+ reportInstanceFailure(
1513
+ c,
1514
+ inst,
1515
+ {
1516
+ code: 'queue-write-buffer-failed',
1517
+ expected:
1518
+ 'the renderer-owned instance storage buffer accepts the complete instance payload',
1519
+ hint: 'retry after the active device is healthy or recover the renderer',
1520
+ },
1521
+ inst.instanceCount * strideBytes,
1522
+ cap,
1523
+ );
1524
+ return null;
1525
+ }
1526
+ }
1527
+ if (needsUpload) {
1528
+ {
1529
+ const published = { ...active, uploadedRevision: inst.revision ?? inst.archVersion };
1530
+ active = published;
1531
+ chunks?.set(chunkKey, published);
1532
+ }
1533
+ }
1534
+ const activeBuffer = active;
1535
+ draws.push({
1536
+ instanceBuffer: activeBuffer.buffer.handle,
1537
+ instanceBindGroup: resolveGeometryInstancesBindGroup(
1538
+ c,
1539
+ activeBuffer.buffer.handle,
1540
+ probeBuffer,
1541
+ ),
1542
+ probeOffset,
1543
+ instanceCount: count,
1544
+ });
1545
+ }
1546
+ if (chunks !== undefined) {
1547
+ const prefix = `${ownerKey}:`;
1548
+ for (const [key, stale] of chunks.entries()) {
1549
+ if (!key.startsWith(prefix) || activeKeys.has(key)) continue;
1550
+ if (!stale.buffer.isDestroyed) {
1551
+ const destroyed = stale.buffer.destroy();
1552
+ if (!destroyed.ok) runtime.errorRegistry.fire(destroyed.error);
1553
+ }
1554
+ chunks.delete(key);
1555
+ }
1556
+ }
1557
+ reportInstanceResidency(c, inst, {
1558
+ lane: 'chunked-storage',
1559
+ requestedBytes: inst.instanceCount * strideBytes,
1560
+ supportedBytes: cap,
1561
+ uploadRanges: uploadedRanges,
1562
+ uploadedBytes,
1563
+ });
1564
+ return draws;
1565
+ }
1566
+
1567
+ export function resolveGeometryInstanceBuffer(
1568
+ c: Pick<
1569
+ _InternalRenderPipelineContext,
1570
+ 'runtime' | 'pipelineState' | 'frameState' | 'bindGroupCounts'
1571
+ >,
1273
1572
  entry: _InternalRenderPipelineContext['validatedOrdered'][number],
1274
1573
  identityInstanceDraws: readonly GeometryInstanceDraw[],
1275
1574
  useProbeBlend: boolean,
@@ -1304,6 +1603,11 @@ function resolveGeometryInstanceBuffer(
1304
1603
  // A present, empty Instances array means zero draws. Do not fall back to
1305
1604
  // the shared identity buffer, which would silently render one instance.
1306
1605
  if (inst.instanceCount === 0) return [];
1606
+ const entityCacheKey = instanceCollectionCacheKey(entry.source.worldId, inst);
1607
+ const revision = inst.revision ?? inst.archVersion;
1608
+ const sameRevision = (cached: InstanceBufferCacheEntry): boolean =>
1609
+ cached.uploadedRevision === revision;
1610
+ const fullRange = [{ start: 0, end: inst.instanceCount }] as const;
1307
1611
  let instanceBuffer: Buffer = pipelineState.identityInstanceBuffer;
1308
1612
  let instanceCount = 1;
1309
1613
  {
@@ -1315,41 +1619,126 @@ function resolveGeometryInstanceBuffer(
1315
1619
  let instanceBufferUsage = GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST;
1316
1620
 
1317
1621
  if (uniformFallback && inst.instanceCount > MAX_UNIFORM_INSTANCES) {
1622
+ const ownerKey = instanceCollectionCacheKey(entry.source.worldId, inst);
1623
+ const chunks = frameState.instanceBufferChunks;
1624
+ const activeKeys = new Set<string>();
1318
1625
  const draws: GeometryInstanceDraw[] = [];
1626
+ const uniformStrideBytes = 16 * Float32Array.BYTES_PER_ELEMENT;
1627
+ const uploadedRanges: { start: number; end: number }[] = [];
1628
+ let uploadedBytes = 0;
1319
1629
  for (let start = 0; start < inst.instanceCount; start += MAX_UNIFORM_INSTANCES) {
1320
1630
  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;
1631
+ const chunkKey = `${ownerKey}:${start}`;
1632
+ activeKeys.add(chunkKey);
1633
+ const payloadBytes = count * uniformStrideBytes;
1634
+ const previous = chunks?.get(chunkKey);
1635
+ let active =
1636
+ previous !== undefined &&
1637
+ previous.uploadedArchVersion === inst.archVersion &&
1638
+ previous.uploadedByteLength === payloadBytes
1639
+ ? previous
1640
+ : undefined;
1641
+ let activeIsNew = false;
1642
+ if (active === undefined) {
1643
+ const created = runtime.device.createBuffer({
1644
+ size: INSTANCE_UBO_FULL_ARRAY_BYTES,
1645
+ usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
1646
+ mappedAtCreation: false,
1647
+ });
1648
+ if (!created.ok) {
1649
+ runtime.errorRegistry.fire(created.error);
1650
+ reportInstanceFailure(
1651
+ c,
1652
+ inst,
1653
+ created.error,
1654
+ inst.instanceCount * uniformStrideBytes,
1655
+ MAX_UNIFORM_INSTANCES * uniformStrideBytes,
1656
+ );
1657
+ return null;
1658
+ }
1659
+ if (previous !== undefined && !previous.buffer.isDestroyed) {
1660
+ const destroyed = previous.buffer.destroy();
1661
+ if (!destroyed.ok) runtime.errorRegistry.fire(destroyed.error);
1662
+ }
1663
+ active = {
1664
+ buffer: new GpuBuffer(runtime.device, created.value),
1665
+ uploadedArchVersion: inst.archVersion,
1666
+ uploadedByteLength: payloadBytes,
1667
+ };
1668
+ activeIsNew = true;
1669
+ chunks?.set(chunkKey, active);
1670
+ if (chunks === undefined) frameState.transientInstanceBuffers.push(active);
1330
1671
  }
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;
1672
+ const needsUpload = activeIsNew || !sameRevision(active);
1673
+ const rangesToUpload = [
1674
+ {
1675
+ start: 0,
1676
+ end: count,
1677
+ },
1678
+ ];
1679
+ if (needsUpload && rangesToUpload.length > 0) {
1680
+ const upload = uploadInstanceRanges(
1681
+ c,
1682
+ inst,
1683
+ active.buffer.handle,
1684
+ rangesToUpload,
1685
+ false,
1686
+ start,
1687
+ );
1688
+ for (const range of upload.ranges) {
1689
+ uploadedRanges.push({ start: range.start + start, end: range.end + start });
1690
+ }
1691
+ uploadedBytes += upload.bytes;
1692
+ if (!upload.ok) {
1693
+ reportInstanceFailure(
1694
+ c,
1695
+ inst,
1696
+ {
1697
+ code: 'queue-write-buffer-failed',
1698
+ expected:
1699
+ 'the renderer-owned instance uniform buffer accepts the complete instance payload',
1700
+ hint: 'retry after the active device is healthy or recover the renderer',
1701
+ },
1702
+ inst.instanceCount * uniformStrideBytes,
1703
+ MAX_UNIFORM_INSTANCES * uniformStrideBytes,
1704
+ );
1705
+ return null;
1706
+ }
1707
+ }
1708
+ if (needsUpload) {
1709
+ const published = { ...active, uploadedRevision: revision };
1710
+ active = published;
1711
+ chunks?.set(chunkKey, published);
1341
1712
  }
1342
1713
  draws.push({
1343
- instanceBuffer: buffer.handle,
1714
+ instanceBuffer: active.buffer.handle,
1344
1715
  instanceBindGroup: resolveGeometryInstancesBindGroup(
1345
1716
  c,
1346
- buffer.handle,
1717
+ active.buffer.handle,
1347
1718
  probeBinding?.buffer,
1348
1719
  ),
1349
1720
  probeOffset: probeBinding?.offset ?? -1,
1350
1721
  instanceCount: count,
1351
1722
  });
1352
1723
  }
1724
+ if (chunks !== undefined) {
1725
+ const prefix = `${ownerKey}:`;
1726
+ for (const [key, stale] of chunks.entries()) {
1727
+ if (!key.startsWith(prefix) || activeKeys.has(key)) continue;
1728
+ if (!stale.buffer.isDestroyed) {
1729
+ const destroyed = stale.buffer.destroy();
1730
+ if (!destroyed.ok) runtime.errorRegistry.fire(destroyed.error);
1731
+ }
1732
+ chunks.delete(key);
1733
+ }
1734
+ }
1735
+ reportInstanceResidency(c, inst, {
1736
+ lane: 'chunked-uniform',
1737
+ requestedBytes: inst.instanceCount * uniformStrideBytes,
1738
+ supportedBytes: MAX_UNIFORM_INSTANCES * uniformStrideBytes,
1739
+ uploadRanges: uploadedRanges,
1740
+ uploadedBytes,
1741
+ });
1353
1742
  return draws;
1354
1743
  }
1355
1744
  if (uniformFallback) {
@@ -1359,31 +1748,30 @@ function resolveGeometryInstanceBuffer(
1359
1748
  {
1360
1749
  // Cap-gate (LimitExceededDetail single emit point — feat-20260514
1361
1750
  // M3 / w15 anchor): `requestedBytes <= maxStorageBufferBindingSize`.
1362
- const instancePayload = uniformFallback
1363
- ? inst.transforms
1364
- : packInstanceStorageBuffer(inst.transforms);
1365
- const requestedBytes = instancePayload.byteLength;
1751
+ // Derive the size from the authoring count; storage packing is deferred
1752
+ // until uploadInstanceRanges confirms a new or dirty resident.
1753
+ const requestedBytes =
1754
+ inst.instanceCount *
1755
+ (uniformFallback ? 16 : INSTANCE_STORAGE_STRIDE_FLOATS) *
1756
+ Float32Array.BYTES_PER_ELEMENT;
1366
1757
  const cap = runtime.device.limits.maxStorageBufferBindingSize;
1367
1758
  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
- }),
1759
+ const chunked = resolveStorageInstanceChunks(
1760
+ c,
1761
+ entry,
1762
+ inst,
1763
+ cap,
1764
+ probeBinding?.buffer,
1765
+ probeBinding?.offset ?? -1,
1378
1766
  );
1379
- return null;
1767
+ return chunked;
1380
1768
  } else {
1381
1769
  // Look up the cached GPU buffer or create a fresh one when the
1382
1770
  // archetype version bumped or the byte length changed.
1383
- const cached = frameState.instanceBuffers.get(
1384
- worldEntityKey(entry.source.worldId, inst.cacheKey),
1385
- );
1771
+ // entityCacheKey is the worldEntityKey + collectionId namespace.
1772
+ const cached = frameState.instanceBuffers.get(entityCacheKey);
1386
1773
  let active: InstanceBufferCacheEntry | null = null;
1774
+ let activeIsNew = false;
1387
1775
  if (
1388
1776
  cached !== undefined &&
1389
1777
  cached.uploadedArchVersion === inst.archVersion &&
@@ -1398,6 +1786,8 @@ function resolveGeometryInstanceBuffer(
1398
1786
  });
1399
1787
  if (!bufRes.ok) {
1400
1788
  runtime.errorRegistry.fire(bufRes.error);
1789
+ reportInstanceFailure(c, inst, bufRes.error, requestedBytes, cap);
1790
+ return null;
1401
1791
  } else {
1402
1792
  // feat-20260619 M4 / F12: destroy the old cached buffer
1403
1793
  // before replacing it with the new one (D-6).
@@ -1411,24 +1801,67 @@ function resolveGeometryInstanceBuffer(
1411
1801
  uploadedArchVersion: inst.archVersion,
1412
1802
  uploadedByteLength: requestedBytes,
1413
1803
  };
1414
- frameState.instanceBuffers.set(
1415
- worldEntityKey(entry.source.worldId, inst.cacheKey),
1416
- active,
1417
- );
1804
+ activeIsNew = true;
1805
+ if (active !== null) {
1806
+ // Keep the collection identity in the worldEntityKey namespace.
1807
+ frameState.instanceBuffers.set(entityCacheKey, active);
1808
+ }
1418
1809
  }
1419
1810
  }
1420
1811
  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);
1812
+ const needsUpload = activeIsNew || !sameRevision(active);
1813
+ const ranges = [fullRange[0]];
1814
+ let uploadRanges: readonly { readonly start: number; readonly end: number }[] = [];
1815
+ let uploadedBytes = 0;
1816
+ if (needsUpload && ranges.length > 0) {
1817
+ const upload = uploadInstanceRanges(
1818
+ c,
1819
+ inst,
1820
+ active.buffer.handle,
1821
+ ranges,
1822
+ !uniformFallback,
1823
+ );
1824
+ uploadRanges = upload.ranges;
1825
+ uploadedBytes = upload.bytes;
1826
+ if (!upload.ok) {
1827
+ reportInstanceFailure(
1828
+ c,
1829
+ inst,
1830
+ {
1831
+ code: 'queue-write-buffer-failed',
1832
+ expected:
1833
+ 'the renderer-owned instance buffer accepts the complete instance payloads',
1834
+ hint: 'retry after the active device is healthy or recover the renderer',
1835
+ },
1836
+ requestedBytes,
1837
+ uniformFallback
1838
+ ? MAX_UNIFORM_INSTANCES * Float32Array.BYTES_PER_ELEMENT * 16
1839
+ : typeof cap === 'number'
1840
+ ? cap
1841
+ : undefined,
1842
+ );
1843
+ return null;
1844
+ }
1845
+ }
1846
+ if (needsUpload) {
1847
+ const published = { ...active, uploadedRevision: revision };
1848
+ frameState.instanceBuffers.set(entityCacheKey, published);
1849
+ active = published;
1431
1850
  }
1851
+ instanceBuffer = active.buffer.handle;
1852
+ instanceCount = Math.max(1, inst.instanceCount);
1853
+ reportInstanceResidency(c, inst, {
1854
+ lane: uniformFallback ? 'direct-uniform' : 'direct-storage',
1855
+ requestedBytes,
1856
+ supportedBytes: uniformFallback
1857
+ ? MAX_UNIFORM_INSTANCES * Float32Array.BYTES_PER_ELEMENT * 16
1858
+ : typeof cap === 'number'
1859
+ ? cap
1860
+ : undefined,
1861
+ uploadRanges,
1862
+ uploadedBytes,
1863
+ });
1864
+ clearInstanceChunksForOwner(c, entityCacheKey);
1432
1865
  }
1433
1866
  }
1434
1867
  // The cache write above is keyed by worldEntityKey(worldId, cacheKey)
@@ -1446,7 +1879,10 @@ function resolveGeometryInstanceBuffer(
1446
1879
  }
1447
1880
 
1448
1881
  export function resolveProbeBlendBuffer(
1449
- c: _InternalRenderPipelineContext,
1882
+ c: Pick<
1883
+ _InternalRenderPipelineContext,
1884
+ 'runtime' | 'pipelineState' | 'frameState' | 'bindGroupCounts'
1885
+ >,
1450
1886
  record: import('../scene/probe-blend-record').ProbeBlendRecord | undefined,
1451
1887
  entityKey?: number,
1452
1888
  ): { readonly buffer: Buffer; readonly offset: number } {
@@ -1494,7 +1930,10 @@ export function resolveProbeBlendBuffer(
1494
1930
  }
1495
1931
 
1496
1932
  export function resolveGeometryInstancesBindGroup(
1497
- c: _InternalRenderPipelineContext,
1933
+ c: Pick<
1934
+ _InternalRenderPipelineContext,
1935
+ 'runtime' | 'pipelineState' | 'frameState' | 'bindGroupCounts'
1936
+ >,
1498
1937
  instanceBuffer: Buffer,
1499
1938
  probeBuffer?: Buffer,
1500
1939
  ): BindGroup {
@@ -856,7 +856,7 @@ function recordSpriteEntityDraws(
856
856
  new RhiError({
857
857
  code: 'limit-exceeded',
858
858
  expected: `requestedBytes (${requestedBytes}) <= maxStorageBufferBindingSize (${cap})`,
859
- hint: 'reduce instance count to fit within device.limits.maxStorageBufferBindingSize, or split transforms across multiple Instances entries',
859
+ hint: 'reduce the sprite batch to fit within device.limits.maxStorageBufferBindingSize or use a storage-capable backend',
860
860
  detail: {
861
861
  maxStorageBufferBindingSize: cap,
862
862
  requestedBytes,