@plasius/gpu-renderer 0.2.56 → 0.2.58

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plasius/gpu-renderer",
3
- "version": "0.2.56",
3
+ "version": "0.2.58",
4
4
  "description": "Framework-agnostic WebGPU renderer runtime for Plasius projects.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -0,0 +1,92 @@
1
+ // Accounts for requested WebGPU resource storage, not driver overhead or free VRAM.
2
+ const formats = Object.freeze({
3
+ r8unorm:1, r8snorm:1, r8uint:1, r8sint:1,
4
+ rg8unorm:2, rg8snorm:2, rg8uint:2, rg8sint:2, r16float:2, r16uint:2, r16sint:2,
5
+ rgba8unorm:4, 'rgba8unorm-srgb':4, bgra8unorm:4, 'bgra8unorm-srgb':4,
6
+ rgba8snorm:4, rgba8uint:4, rgba8sint:4, rg16float:4, rg16uint:4, rg16sint:4,
7
+ r32float:4, r32uint:4, r32sint:4, rgb10a2unorm:4, rg11b10ufloat:4,
8
+ rgba16float:8, rgba16uint:8, rgba16sint:8, rg32float:8, rg32uint:8, rg32sint:8,
9
+ rgba32float:16, rgba32uint:16, rgba32sint:16,
10
+ });
11
+ function integer(value, name, minimum=1) {
12
+ if(!Number.isSafeInteger(value)||value<minimum) throw new Error(`${name} must be a safe integer >= ${minimum}.`);
13
+ return value;
14
+ }
15
+ function within(value,limit,name) {
16
+ if(Number.isFinite(limit)&&value>limit) throw new Error(`GPU ${name} limit exceeded: requested ${value}, available ${limit}.`);
17
+ }
18
+ function textureBytes(descriptor,limits) {
19
+ const size=descriptor.size;
20
+ const array=Array.isArray(size)||ArrayBuffer.isView(size);
21
+ const width=integer(array?size[0]:size?.width,'Texture width');
22
+ const height=integer((array?size[1]:size?.height)??1,'Texture height');
23
+ const layers=integer((array?size[2]:size?.depthOrArrayLayers)??1,'Texture depth/layers');
24
+ const dimension=descriptor.dimension??'2d';
25
+ if(!['1d','2d','3d'].includes(dimension)) throw new Error('Unsupported GPU texture dimension.');
26
+ const limit=limits[dimension==='3d'?'maxTextureDimension3D':dimension==='1d'?'maxTextureDimension1D':'maxTextureDimension2D'];
27
+ within(Math.max(width,height,dimension==='3d'?layers:1),limit,'texture dimension');
28
+ if(dimension!=='3d') within(layers,limits.maxTextureArrayLayers,'texture layers');
29
+ const bpp=formats[descriptor.format];
30
+ if(!bpp) throw new Error(`GPU memory accounting does not support texture format ${descriptor.format}.`);
31
+ const levels=integer(descriptor.mipLevelCount??1,'Mip count');
32
+ const samples=integer(descriptor.sampleCount??1,'Sample count');
33
+ if(levels>Math.floor(Math.log2(Math.max(width,height,dimension==='3d'?layers:1)))+1) throw new Error('Invalid texture mip count.');
34
+ let bytes=0;
35
+ for(let level=0;level<levels;level++) bytes+=Math.max(1,Math.floor(width/2**level))*Math.max(1,Math.floor(height/2**level))*
36
+ (dimension==='3d'?Math.max(1,Math.floor(layers/2**level)):layers)*bpp*samples;
37
+ return integer(bytes,'Texture allocation');
38
+ }
39
+
40
+ export function createGpuAllocationTracker(rawDevice,{budgetBytes,storageUsage=globalThis.GPUBufferUsage?.STORAGE??128}={}) {
41
+ if(budgetBytes!==undefined) integer(budgetBytes,'GPU memory budget');
42
+ const records=new Map();let allocatedBytes=0,peakAllocatedBytes=0;
43
+ function allocate(kind,descriptor,bytes,create) {
44
+ integer(bytes,'GPU allocation',0);
45
+ const total=integer(allocatedBytes+bytes,'GPU allocation total',0);
46
+ if(budgetBytes!==undefined&&total>budgetBytes) throw new Error(`GPU memory budget exceeded: ${total} requested bytes, budget ${budgetBytes}. Reduce quality or scene resources.`);
47
+ const resource=create(descriptor),originalDestroy=resource.destroy.bind(resource);
48
+ const release=()=>{if(!records.has(resource))return;originalDestroy();records.delete(resource);allocatedBytes-=bytes;};
49
+ try{Object.defineProperty(resource,'destroy',{value:release,configurable:true});}
50
+ catch(error){originalDestroy();throw error;}
51
+ records.set(resource,{kind,label:descriptor.label??'',bytes,release});allocatedBytes=total;peakAllocatedBytes=Math.max(total,peakAllocatedBytes);
52
+ return resource;
53
+ }
54
+ const destroy=()=>{for(const record of [...records.values()])record.release();};
55
+ const methods={
56
+ createBuffer(descriptor){
57
+ const bytes=integer(descriptor.size,'Buffer size',0);
58
+ within(bytes,rawDevice.limits?.maxBufferSize,'buffer size');
59
+ if((descriptor.usage??0)&storageUsage) within(bytes,rawDevice.limits?.maxStorageBufferBindingSize,'storage binding size');
60
+ return allocate('buffer',descriptor,bytes,d=>rawDevice.createBuffer(d));
61
+ },
62
+ createTexture(descriptor){return allocate('texture',descriptor,textureBytes(descriptor,rawDevice.limits??{}),d=>rawDevice.createTexture(d));},
63
+ createQuerySet(descriptor){return allocate('query-set',descriptor,integer(descriptor.count,'Query count')*8,d=>rawDevice.createQuerySet(d));},
64
+ destroy(){destroy();rawDevice.destroy();},
65
+ };
66
+ const device=new Proxy(rawDevice,{get(target,key){
67
+ if(Object.hasOwn(methods,key))return methods[key];
68
+ const value=Reflect.get(target,key,target);return typeof value==='function'?value.bind(target):value;
69
+ }});
70
+ return Object.freeze({device,destroy,snapshot:()=>Object.freeze({
71
+ accounting:'requested-resource-bytes',budgetBytes:budgetBytes??null,allocatedBytes,peakAllocatedBytes,
72
+ resources:Object.freeze([...records.values()].map(({kind,label,bytes})=>Object.freeze({kind,label,bytes}))),
73
+ })});
74
+ }
75
+
76
+ /** Pop both scopes before awaiting either, so rejection cannot leave a scope open. */
77
+ export function openGpuAllocationErrorScopes(device) {
78
+ if(typeof device.pushErrorScope!=='function'||typeof device.popErrorScope!=='function') return async()=>{};
79
+ let count=0;
80
+ try{device.pushErrorScope('out-of-memory');count++;device.pushErrorScope('validation');count++;}
81
+ catch(error){while(count-->0)Promise.resolve(device.popErrorScope()).catch(()=>{});throw error;}
82
+ return async()=>{
83
+ const completions=[];
84
+ while(count>0){count--;try{completions.push(Promise.resolve(device.popErrorScope()));}catch(error){completions.push(Promise.reject(error));}}
85
+ if(!completions.length)return;
86
+ let timer;
87
+ try{
88
+ const errors=await Promise.race([Promise.all(completions),new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('GPU allocation validation timed out.')),10000);})]);
89
+ const error=errors.find(Boolean);if(error)throw new Error(`GPU allocation failed: ${error.message??String(error)}`);
90
+ }finally{clearTimeout(timer);}
91
+ };
92
+ }
package/src/index.d.ts CHANGED
@@ -899,6 +899,11 @@ export interface WavefrontPathTracingComputeConfig {
899
899
  readonly gpuAccelerationBuildRequired: boolean;
900
900
  readonly gpuMeshSource: WavefrontGpuMeshSource;
901
901
  readonly gpuMaterialSource: WavefrontGpuMaterialSource;
902
+ readonly instancing: Readonly<{
903
+ instanceCount: number; geometryCount: number; instanceBytes: number;
904
+ tlasNodeCount: number; blasNodeCount: number; buffer: ArrayBuffer;
905
+ acceleration: WavefrontMeshAcceleration;
906
+ }> | null;
902
907
  readonly meshAcceleration: WavefrontMeshAcceleration;
903
908
  readonly emissiveTriangleIndices: WavefrontEmissiveTriangleIndexSource;
904
909
  readonly emissiveTriangleCount: number;
@@ -1045,6 +1050,10 @@ export interface WavefrontPathTracingMemoryEstimate {
1045
1050
  readonly pathVertexBytes: number;
1046
1051
  readonly sceneObjectBytes: number;
1047
1052
  readonly triangleBytes: number;
1053
+ /** Logical 144-byte records, separate from totalHotBufferBytes and before texture row padding. */
1054
+ readonly instanceBytes: number;
1055
+ /** Requested instance texture storage including row padding. */
1056
+ readonly instanceTextureBytes: number;
1048
1057
  readonly materialTableBytes: number;
1049
1058
  readonly bvhNodeBytes: number;
1050
1059
  readonly bvhCombinedBytes: number;
@@ -1102,6 +1111,21 @@ export interface WavefrontGpuParallelismDiagnostics {
1102
1111
  readonly coreUtilizationStatus: "not-exposed-by-webgpu";
1103
1112
  }
1104
1113
 
1114
+ /** Static column-major affine transform referencing a unique mesh. */
1115
+ export interface WavefrontMeshInstanceInput {
1116
+ readonly id: number;
1117
+ readonly meshId: number;
1118
+ readonly transform: readonly number[] | Float32Array;
1119
+ }
1120
+ export interface WavefrontGpuAllocationSnapshot {
1121
+ /** Requested resource storage; excludes driver overhead and externally owned resources. */
1122
+ readonly accounting: "requested-resource-bytes";
1123
+ readonly budgetBytes: number | null;
1124
+ readonly allocatedBytes: number;
1125
+ readonly peakAllocatedBytes: number;
1126
+ readonly resources: readonly Readonly<{kind: string; label: string; bytes: number}>[];
1127
+ }
1128
+
1105
1129
  export interface CreateWavefrontPathTracingComputeRendererOptions {
1106
1130
  /** Accumulate linear radiance between frames until camera/scene/reset changes. Default false. */
1107
1131
  readonly progressive?: boolean;
@@ -1127,6 +1151,10 @@ export interface CreateWavefrontPathTracingComputeRendererOptions {
1127
1151
  readonly mediums?: readonly WavefrontMediumInput[];
1128
1152
  readonly mesh?: WavefrontMeshInput;
1129
1153
  readonly meshes?: readonly WavefrontMeshInput[];
1154
+ /** Opt-in shared BLAS/TLAS instances. Every mesh must be referenced. Static, non-emissive meshes; CPU BLAS construction. */
1155
+ readonly instances?: readonly WavefrontMeshInstanceInput[];
1156
+ /** Positive renderer-owned resource byte budget. Device limits apply independently. */
1157
+ readonly memoryBudgetBytes?: number;
1130
1158
  readonly triangleCapacity?: number;
1131
1159
  readonly bvhNodeCapacity?: number;
1132
1160
  readonly bvhLeafSortCapacity?: number;
@@ -1203,6 +1231,8 @@ export interface WavefrontPathTracingComputeRenderer {
1203
1231
  maxFramePassesPerSubmission: number;
1204
1232
  sceneObjectCount: number;
1205
1233
  triangleCount: number;
1234
+ instanceCount: number;
1235
+ geometryCount: number;
1206
1236
  emissiveTriangleCount: number;
1207
1237
  environmentPortalCount: number;
1208
1238
  environmentPortalMode: 0 | 1 | 2;
@@ -1221,6 +1251,7 @@ export interface WavefrontPathTracingComputeRenderer {
1221
1251
  frameConfigSlots: number;
1222
1252
  gpuParallelism: WavefrontGpuParallelismDiagnostics;
1223
1253
  memory: WavefrontPathTracingMemoryEstimate;
1254
+ allocations: WavefrontGpuAllocationSnapshot;
1224
1255
  }>;
1225
1256
  destroy(): void;
1226
1257
  }
@@ -1388,6 +1419,8 @@ export function estimateWavefrontPathTracingMemory(options?: {
1388
1419
  maxDepth?: number;
1389
1420
  sceneObjectCapacity?: number;
1390
1421
  triangleCapacity?: number;
1422
+ instanceBytes?: number;
1423
+ instanceTextureBytes?: number;
1391
1424
  materialCapacity?: number;
1392
1425
  bvhNodeCapacity?: number;
1393
1426
  bvhLeafSortCapacity?: number;
@@ -1557,6 +1590,7 @@ export function renderWavefrontPathTracingComputeFrame(
1557
1590
  }
1558
1591
  ): Promise<WavefrontPathTracingComputeFrameStats>;
1559
1592
  export function createWavefrontPathTracingComputeShaderSource(options?: {
1593
+ readonly instanced?: boolean;
1560
1594
  workgroupSize?: number;
1561
1595
  outputTextureFormat?: GPUTextureFormat | "rgba8unorm";
1562
1596
  }): string;
@@ -12,6 +12,7 @@ export function createWavefrontTraceBindGroup({
12
12
  counterBuffer,
13
13
  outputView,
14
14
  triangleBuffer,
15
+ instanceView,
15
16
  bvhNodeBuffer,
16
17
  radianceView,
17
18
  environmentPortalBuffer,
@@ -70,6 +71,7 @@ export function createWavefrontTraceBindGroup({
70
71
  { binding: 42, resource: extensionAtlasResources.iridescence.view },
71
72
  { binding: 43, resource: extensionAtlasResources.iridescenceThickness.view },
72
73
  { binding: 44, resource: extensionAtlasResources.anisotropy.view },
74
+ ...(instanceView ? [{binding:47, resource:instanceView}] : []),
73
75
  ],
74
76
  });
75
77
  }
@@ -1,3 +1,6 @@
1
+ import { uploadWavefrontBuffer } from './wavefront-upload.js';
2
+ import { planInstanceTexture, uploadInstanceTexture } from './wavefront-instance-texture.js';
3
+ import { createGpuAllocationTracker, openGpuAllocationErrorScopes } from './gpu-allocation-budget.js';
1
4
  import { createWavefrontProgressiveResources } from './wavefront-progressive.js';
2
5
  import {
3
6
  createGpuParallelismCounters,
@@ -190,11 +193,16 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
190
193
  throw new Error("Unable to acquire a WebGPU adapter for wavefront path tracing.");
191
194
  }
192
195
 
193
- const device = await adapter.requestDevice(
196
+ const rawDevice = await adapter.requestDevice(
194
197
  createWavefrontDeviceDescriptor(adapter, options, initialConfig.memory)
195
198
  );
199
+ const allocationTracker = createGpuAllocationTracker(rawDevice, {budgetBytes:options.memoryBudgetBytes,storageUsage:constants.buffer.STORAGE});
200
+ const device = allocationTracker.device;
201
+ let context, finishAllocationScopes;
202
+ try {
203
+ finishAllocationScopes = openGpuAllocationErrorScopes(rawDevice);
196
204
  const gpuAdapterParallelism = createGpuAdapterParallelismDiagnostics(adapter, device);
197
- const context = canvas.getContext("webgpu");
205
+ context = canvas.getContext("webgpu");
198
206
  if (!context || typeof context.configure !== "function") {
199
207
  throw new Error("Canvas WebGPU context does not support configure().");
200
208
  }
@@ -226,13 +234,21 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
226
234
  canvas.width = config.width;
227
235
  canvas.height = config.height;
228
236
  context.configure({
229
- device,
237
+ device: rawDevice,
230
238
  format,
231
239
  alphaMode: options.alpha === true ? "premultiplied" : "opaque",
232
240
  });
233
241
 
234
242
  const bufferUsage = constants.buffer.STORAGE | constants.buffer.COPY_DST;
235
243
  const memory = config.memory;
244
+ let instanceTexture = null, instanceView = null;
245
+ if(config.instancing) {
246
+ const layout=planInstanceTexture(config.instancing.instanceCount,device.limits?.maxTextureDimension2D);
247
+ instanceTexture=device.createTexture({label:"plasius.wavefront.instances",size:{width:layout.width,height:layout.height},
248
+ format:"rgba32uint",usage:constants.texture.TEXTURE_BINDING|constants.texture.COPY_DST});
249
+ await uploadInstanceTexture(device,instanceTexture,config.instancing.buffer,layout);
250
+ instanceView=instanceTexture.createView();
251
+ }
236
252
  const activeQueue = createBuffer(device, bufferUsage, memory.queueBytes, "plasius.wavefront.activeQueue");
237
253
  const nextQueue = createBuffer(device, bufferUsage, memory.queueBytes, "plasius.wavefront.nextQueue");
238
254
  const hitBuffer = createBuffer(device, bufferUsage, memory.hitBytes, "plasius.wavefront.hitBuffer");
@@ -354,11 +370,11 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
354
370
  packedBvhNodeFloats[nodeOffset + 2] = totalEmissiveWeight;
355
371
  packedBvhNodeUints[nodeOffset + 8] = triangleIndex;
356
372
  });
357
- device.queue.writeBuffer(triangleBuffer, 0, packedTriangles.buffer);
358
- device.queue.writeBuffer(bvhNodeBuffer, 0, packedBvhNodes.buffer);
359
- device.queue.writeBuffer(meshVertexBuffer, 0, config.gpuMeshSource.vertices.buffer);
360
- device.queue.writeBuffer(meshIndexBuffer, 0, config.gpuMeshSource.indices.buffer);
361
- device.queue.writeBuffer(meshRangeBuffer, 0, config.gpuMeshSource.meshes.buffer);
373
+ await uploadWavefrontBuffer(device, triangleBuffer, packedTriangles.buffer);
374
+ await uploadWavefrontBuffer(device, bvhNodeBuffer, packedBvhNodes.buffer);
375
+ await uploadWavefrontBuffer(device, meshVertexBuffer, config.gpuMeshSource.vertices.buffer);
376
+ await uploadWavefrontBuffer(device, meshIndexBuffer, config.gpuMeshSource.indices.buffer);
377
+ await uploadWavefrontBuffer(device, meshRangeBuffer, config.gpuMeshSource.meshes.buffer);
362
378
  const packedEnvironmentPortals = packEnvironmentPortals(
363
379
  config.environmentPortals,
364
380
  Math.max(1, config.environmentPortalCapacity)
@@ -501,7 +517,7 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
501
517
  },
502
518
  computePipelines: pipelines,
503
519
  presentPipeline,
504
- } = await createWavefrontPipelineResources({ device, constants, format });
520
+ } = await createWavefrontPipelineResources({ device, constants, format, instanced:!!config.instancing });
505
521
 
506
522
  function createTraceBindGroups() {
507
523
  return createWavefrontTraceBindGroups({
@@ -516,6 +532,7 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
516
532
  counterBuffer,
517
533
  outputView,
518
534
  triangleBuffer,
535
+ instanceView,
519
536
  bvhNodeBuffer,
520
537
  radianceView,
521
538
  environmentPortalBuffer,
@@ -1058,6 +1075,8 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
1058
1075
  maxFramePassesPerSubmission: config.maxFramePassesPerSubmission,
1059
1076
  sceneObjectCount: config.sceneObjectCount,
1060
1077
  triangleCount: config.triangleCount,
1078
+ instanceCount: config.instancing?.instanceCount ?? 0,
1079
+ geometryCount: config.instancing?.geometryCount ?? config.gpuMeshSource.meshes.count,
1061
1080
  emissiveTriangleCount: config.emissiveTriangleCount,
1062
1081
  environmentPortalCount: config.environmentPortalCount,
1063
1082
  environmentPortalMode: config.environmentPortalMode,
@@ -1076,6 +1095,7 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
1076
1095
  frameConfigSlots: frameConfigSlotCount,
1077
1096
  gpuParallelism: lastGpuParallelism,
1078
1097
  memory: config.memory,
1098
+ allocations: allocationTracker.snapshot(),
1079
1099
  });
1080
1100
  }
1081
1101
 
@@ -1089,6 +1109,7 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
1089
1109
  pathVertexBuffer.destroy?.();
1090
1110
  sceneObjectBuffer.destroy?.();
1091
1111
  triangleBuffer.destroy?.();
1112
+ instanceTexture?.destroy?.();
1092
1113
  bvhNodeBuffer.destroy?.();
1093
1114
  meshVertexBuffer.destroy?.();
1094
1115
  meshIndexBuffer.destroy?.();
@@ -1133,12 +1154,14 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
1133
1154
  }
1134
1155
  });
1135
1156
  context.unconfigure?.();
1157
+ allocationTracker.destroy();
1136
1158
  }
1137
1159
 
1160
+ await finishAllocationScopes();
1138
1161
  return Object.freeze({
1139
1162
  canvas,
1140
1163
  context,
1141
- device,
1164
+ device: rawDevice,
1142
1165
  format,
1143
1166
  config,
1144
1167
  renderOnce,
@@ -1150,6 +1173,13 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
1150
1173
  getSnapshot,
1151
1174
  destroy,
1152
1175
  });
1176
+ } catch(error) {
1177
+ try { await finishAllocationScopes?.(); } catch { /* Preserve the initiating failure. */ }
1178
+ context?.unconfigure?.();
1179
+ allocationTracker.destroy();
1180
+ rawDevice.destroy?.();
1181
+ throw error;
1182
+ }
1153
1183
  }
1154
1184
 
1155
1185
  export async function renderWavefrontPathTracingComputeFrame(options = {}) {
@@ -1,3 +1,5 @@
1
+ import { planInstanceTexture } from './wavefront-instance-texture.js';
2
+ import { WAVEFRONT_INSTANCE_BYTES, prepareWavefrontInstances, buildWavefrontInstances } from './wavefront-instances.js';
1
3
  import {
2
4
  ACCUMULATION_RECORD_BYTES,
3
5
  BVH_LEAF_REF_RECORD_BYTES,
@@ -357,6 +359,8 @@ export function estimateWavefrontPathTracingMemory(options = {}) {
357
359
  const progressiveBytes = options.progressive === true ? width * height * 16 + 16 : 0;
358
360
  const pathVertexBytes = tilePixelCapacity * (maxDepth + 1) * PATH_VERTEX_RECORD_BYTES;
359
361
  const sceneObjectBytes = sceneObjectCapacity * SCENE_OBJECT_RECORD_BYTES;
362
+ const instanceBytes = readNonNegativeInteger("instanceBytes", options.instanceBytes, 0);
363
+ const instanceTextureBytes = readNonNegativeInteger("instanceTextureBytes", options.instanceTextureBytes, instanceBytes);
360
364
  const triangleBytes = allocatedRecordBytes(triangleCapacity, TRIANGLE_RECORD_BYTES);
361
365
  const materialTableBytes = 0;
362
366
  const bvhNodeBytes = bvhNodeCapacity * BVH_NODE_RECORD_BYTES;
@@ -431,6 +435,8 @@ export function estimateWavefrontPathTracingMemory(options = {}) {
431
435
  pathVertexBytes,
432
436
  sceneObjectBytes,
433
437
  triangleBytes,
438
+ instanceBytes,
439
+ instanceTextureBytes,
434
440
  materialTableBytes,
435
441
  bvhNodeBytes,
436
442
  bvhCombinedBytes,
@@ -461,6 +467,8 @@ export function estimateWavefrontPathTracingMemoryForConfig(config, limits = {})
461
467
  maxDepth: config.maxDepth,
462
468
  sceneObjectCapacity: config.sceneObjectCapacity,
463
469
  triangleCapacity: config.triangleCapacity,
470
+ instanceBytes: config.instancing?.instanceBytes ?? 0,
471
+ instanceTextureBytes: config.instancing ? planInstanceTexture(config.instancing.instanceCount, limits.maxTextureDimension2D).bytes : 0,
464
472
  bvhNodeCapacity: config.bvhNodeCapacity,
465
473
  bvhLeafSortCapacity: config.bvhLeafSortCapacity,
466
474
  emissiveTriangleCapacity: config.emissiveTriangleCapacity,
@@ -482,6 +490,7 @@ export function estimateWavefrontPathTracingMemoryForConfig(config, limits = {})
482
490
  export function createWavefrontPathTracingComputeConfig(options = {}) {
483
491
  assertAnalyticDisplayQualityPolicy(options);
484
492
  const accelerationBuildMode = resolveAccelerationBuildMode(options);
493
+ if(options.instances !== undefined && accelerationBuildMode === "gpu") throw new Error("Instance BLAS construction currently requires cpu-upload acceleration.");
485
494
  const canvas = options.canvas;
486
495
  const width = readPositiveInteger("width", options.width, getCanvasDimension(canvas, "width", DEFAULT_WIDTH));
487
496
  const height = readPositiveInteger("height", options.height, getCanvasDimension(canvas, "height", DEFAULT_HEIGHT));
@@ -512,6 +521,14 @@ export function createWavefrontPathTracingComputeConfig(options = {}) {
512
521
  );
513
522
  const meshes = normalizeMeshes(options);
514
523
  const meshSourceShape = estimateMeshSourceShape(meshes);
524
+ if(options.memoryBudgetBytes !== undefined) {
525
+ if(!Number.isSafeInteger(options.memoryBudgetBytes)||options.memoryBudgetBytes<=0) throw new Error("GPU memory budget must be a positive safe integer.");
526
+ const count = options.instances?.length ?? 0;
527
+ const instanceMinimumBytes = count * WAVEFRONT_INSTANCE_BYTES + Math.max(0, count * 2 - 1) * BVH_NODE_RECORD_BYTES;
528
+ const geometryMinimumBytes = meshSourceShape.triangleCount * TRIANGLE_RECORD_BYTES + instanceMinimumBytes;
529
+ if(!Number.isSafeInteger(geometryMinimumBytes)||geometryMinimumBytes>options.memoryBudgetBytes) throw new Error("Unique geometry exceeds the GPU memory budget before acceleration construction.");
530
+ }
531
+ const instancePlan = options.instances === undefined ? null : prepareWavefrontInstances(meshes, options.instances);
515
532
  const gpuMaterialSource =
516
533
  meshes.length > 0
517
534
  ? createWavefrontGpuMaterialSource(meshes)
@@ -520,10 +537,11 @@ export function createWavefrontPathTracingComputeConfig(options = {}) {
520
537
  meshes.length > 0
521
538
  ? createWavefrontGpuMeshSource(meshes, gpuMaterialSource)
522
539
  : createWavefrontGpuMeshSource([]);
523
- const meshAcceleration =
540
+ const instancing = instancePlan ? buildWavefrontInstances(meshes, instancePlan, gpuMaterialSource) : null;
541
+ const meshAcceleration = instancing?.acceleration ?? (
524
542
  accelerationBuildMode === "cpu-upload"
525
543
  ? createWavefrontMeshAcceleration(meshes, gpuMaterialSource)
526
- : Object.freeze({ nodes: Object.freeze([]), triangles: Object.freeze([]) });
544
+ : Object.freeze({ nodes: Object.freeze([]), triangles: Object.freeze([]) }));
527
545
  const emissiveTriangleIndices = createWavefrontEmissiveTriangleIndexSource(
528
546
  meshes,
529
547
  options.emissiveTriangleCapacity
@@ -625,6 +643,7 @@ export function createWavefrontPathTracingComputeConfig(options = {}) {
625
643
  gpuAccelerationBuildRequired: accelerationBuildMode === "gpu" && triangleCount > 0,
626
644
  gpuMeshSource,
627
645
  gpuMaterialSource,
646
+ instancing,
628
647
  meshAcceleration,
629
648
  emissiveTriangleIndices,
630
649
  emissiveTriangleCount: emissiveTriangleIndices.count,
@@ -663,6 +682,7 @@ export function createWavefrontPathTracingComputeConfig(options = {}) {
663
682
  tilePixelCapacity,
664
683
  maxDepth,
665
684
  sceneObjectCapacity,
685
+ instanceBytes: instancing?.instanceBytes ?? 0,
666
686
  triangleCapacity,
667
687
  bvhNodeCapacity,
668
688
  bvhLeafSortCapacity,
@@ -0,0 +1,88 @@
1
+ export const WAVEFRONT_INSTANCE_SHADER_WGSL = `
2
+ struct MeshInstance {
3
+ world: mat4x4<f32>,
4
+ inverse: mat4x4<f32>,
5
+ objectId: u32,
6
+ root: u32,
7
+ handedness: f32,
8
+ _pad: u32,
9
+ };
10
+ @group(0) @binding(47) var meshInstances: texture_2d<u32>;
11
+ fn read_mesh_instance(index: u32) -> MeshInstance {
12
+ let recordsPerRow = textureDimensions(meshInstances).x / 9u;
13
+ let x = i32((index % recordsPerRow) * 9u);
14
+ let y = i32(index / recordsPerRow);
15
+ let metadata = textureLoad(meshInstances, vec2<i32>(x + 8, y), 0);
16
+ return MeshInstance(
17
+ mat4x4<f32>(bitcast<vec4<f32>>(textureLoad(meshInstances, vec2<i32>(x, y), 0)),
18
+ bitcast<vec4<f32>>(textureLoad(meshInstances, vec2<i32>(x + 1, y), 0)),
19
+ bitcast<vec4<f32>>(textureLoad(meshInstances, vec2<i32>(x + 2, y), 0)),
20
+ bitcast<vec4<f32>>(textureLoad(meshInstances, vec2<i32>(x + 3, y), 0))),
21
+ mat4x4<f32>(bitcast<vec4<f32>>(textureLoad(meshInstances, vec2<i32>(x + 4, y), 0)),
22
+ bitcast<vec4<f32>>(textureLoad(meshInstances, vec2<i32>(x + 5, y), 0)),
23
+ bitcast<vec4<f32>>(textureLoad(meshInstances, vec2<i32>(x + 6, y), 0)),
24
+ bitcast<vec4<f32>>(textureLoad(meshInstances, vec2<i32>(x + 7, y), 0))),
25
+ metadata.x, metadata.y, bitcast<f32>(metadata.z), 0u);
26
+ }
27
+
28
+ fn instance_normal(instance: MeshInstance, normal: vec3<f32>) -> vec3<f32> {
29
+ let normalMatrix = transpose(mat3x3<f32>(instance.inverse[0].xyz, instance.inverse[1].xyz, instance.inverse[2].xyz));
30
+ return safe_normalize(normalMatrix * normal, vec3<f32>(0.0, 1.0, 0.0));
31
+ }
32
+
33
+ fn triangle_for_candidate(candidate: Candidate) -> TriangleRecord {
34
+ var triangle = triangles[candidate.triangleIndex];
35
+ let instance = read_mesh_instance(candidate.instanceIndex);
36
+ triangle.v0 = instance.world * vec4<f32>(triangle.v0.xyz, 1.0);
37
+ triangle.v1 = instance.world * vec4<f32>(triangle.v1.xyz, 1.0);
38
+ triangle.v2 = instance.world * vec4<f32>(triangle.v2.xyz, 1.0);
39
+ triangle.meshId = instance.objectId;
40
+ return triangle;
41
+ }
42
+
43
+ fn intersect_bvh(ray: RayRecord, initialNearest: f32) -> Candidate {
44
+ var nearest = initialNearest;
45
+ var best = no_candidate();
46
+ var stack = array<u32, 64>();
47
+ var stackSize = 1u;
48
+ stack[0] = 0u;
49
+ loop {
50
+ if (stackSize == 0u) { break; }
51
+ stackSize -= 1u;
52
+ let node = bvhNodes[stack[stackSize]];
53
+ if (!intersect_bounds(ray, node.boundsMin.xyz, node.boundsMax.xyz, nearest)) { continue; }
54
+ if (node.triangleCount == 0u) {
55
+ stack[stackSize] = node.childOrFirst;
56
+ stack[stackSize + 1u] = node.rightChild;
57
+ stackSize += 2u;
58
+ } else {
59
+ let instanceIndex = node.childOrFirst;
60
+ let instance = read_mesh_instance(instanceIndex);
61
+ var localRay = ray;
62
+ localRay.origin = instance.inverse * vec4<f32>(ray.origin.xyz, 1.0);
63
+ // Keep this direction unnormalized so t remains the world-space ray parameter.
64
+ localRay.direction = instance.inverse * vec4<f32>(ray.direction.xyz, 0.0);
65
+ var candidate = intersect_geometry_bvh(localRay, nearest, instance.root);
66
+ if (candidate.hit == 1u && candidate.distance < nearest) {
67
+ candidate.geometricNormal = instance_normal(instance, candidate.geometricNormal);
68
+ candidate.shadingNormal = repair_shading_normal(candidate.geometricNormal,
69
+ instance_normal(instance, candidate.shadingNormal));
70
+ if (instance.handedness < 0.0) { candidate.frontFace = 1u - candidate.frontFace; }
71
+ candidate.instanceIndex = instanceIndex;
72
+ nearest = candidate.distance;
73
+ best = candidate;
74
+ }
75
+ }
76
+ }
77
+ return best;
78
+ }
79
+ `;
80
+
81
+ export const WAVEFRONT_UNINSTANCED_SHADER_WGSL = `
82
+ fn triangle_for_candidate(candidate: Candidate) -> TriangleRecord {
83
+ return triangles[candidate.triangleIndex];
84
+ }
85
+ fn intersect_bvh(ray: RayRecord, initialNearest: f32) -> Candidate {
86
+ return intersect_geometry_bvh(ray, initialNearest, 0u);
87
+ }
88
+ `;
@@ -0,0 +1,43 @@
1
+ import { waitForSubmittedGpuWork } from './wavefront-gpu-synchronization.js';
2
+ import { WAVEFRONT_INSTANCE_BYTES } from './wavefront-instances.js';
3
+
4
+ const TEXELS_PER_RECORD = WAVEFRONT_INSTANCE_BYTES / 16;
5
+
6
+ /** Whole records never cross rows; limits come from the granted GPU device. */
7
+ export function planInstanceTexture(count, maxDimension = 8192) {
8
+ if (!Number.isSafeInteger(count) || count < 1 || count >= 0xffffffff) {
9
+ throw new Error('Instance count must fit unsigned shader addressing.');
10
+ }
11
+ if (!Number.isSafeInteger(maxDimension) || maxDimension < TEXELS_PER_RECORD) {
12
+ throw new Error('GPU instance texture dimension cannot hold one record.');
13
+ }
14
+ const recordsPerRow = Math.min(count, Math.floor(maxDimension / TEXELS_PER_RECORD));
15
+ const width = recordsPerRow * TEXELS_PER_RECORD, height = Math.ceil(count / recordsPerRow);
16
+ if (height > maxDimension) throw new Error('GPU instance texture dimension limit exceeded.');
17
+ const bytes = width * height * 16;
18
+ if (!Number.isSafeInteger(bytes)) throw new Error('Instance texture byte size is out of range.');
19
+ return Object.freeze({ count, recordsPerRow, width, height, bytes });
20
+ }
21
+
22
+ /** Queue writes have no 256-byte row-alignment requirement. Bound staging by rows. */
23
+ export async function uploadInstanceTexture(device, texture, buffer, layout, { chunkBytes = 8 * 1024 * 1024 } = {}) {
24
+ const bytesPerRow = layout.width * 16;
25
+ if (!Number.isSafeInteger(chunkBytes) || chunkBytes < bytesPerRow) {
26
+ throw new Error('Instance upload chunk must hold at least one texture row.');
27
+ }
28
+ if (buffer.byteLength !== layout.count * WAVEFRONT_INSTANCE_BYTES) {
29
+ throw new Error('Instance upload does not match its record count.');
30
+ }
31
+ const source = new Uint8Array(buffer), rowsPerChunk = Math.floor(chunkBytes / bytesPerRow);
32
+ for (let y = 0; y < layout.height; y += rowsPerChunk) {
33
+ const rows = Math.min(rowsPerChunk, layout.height - y), offset = y * bytesPerRow;
34
+ const length = rows * bytesPerRow;
35
+ let chunk = source.subarray(offset, offset + length);
36
+ if (chunk.byteLength < length) {
37
+ const padded = new Uint8Array(length); padded.set(chunk); chunk = padded;
38
+ }
39
+ device.queue.writeTexture({ texture, origin: { x: 0, y, z: 0 } }, chunk,
40
+ { bytesPerRow, rowsPerImage: rows }, { width: layout.width, height: rows, depthOrArrayLayers: 1 });
41
+ await waitForSubmittedGpuWork(device, { timeoutMs: 10000, maxWaitMs: 10000, allowTimeout: false });
42
+ }
43
+ }
@@ -0,0 +1,107 @@
1
+ import { buildWavefrontBoundsBvh, createWavefrontMeshAcceleration } from './wavefront-mesh-sources.js';
2
+ import { emissionPower, MATERIAL_EMISSIVE } from './wavefront-core.js';
3
+
4
+ export const WAVEFRONT_INSTANCE_BYTES = 144;
5
+ const freeze = Object.freeze;
6
+ const uint = value => Number.isInteger(value) && value >= 0 && value < 0xffffffff;
7
+
8
+ function affineTransform(value) {
9
+ if ((!Array.isArray(value) && !ArrayBuffer.isView(value)) || value.length !== 16 ||
10
+ !Array.from(value).every(n => Number.isFinite(n) && Math.abs(n) <= 1e12) ||
11
+ value[3] !== 0 || value[7] !== 0 || value[11] !== 0 || value[15] !== 1) {
12
+ throw new Error('Instance transform must be a finite affine column-major matrix.');
13
+ }
14
+ // Round to the actual shader representation before inversion and bounds construction.
15
+ const m = Array.from(value, Math.fround);
16
+ const [a,b,c,,d,e,f,,g,h,i] = m;
17
+ const determinant = a*(e*i-f*h)-d*(b*i-c*h)+g*(b*f-c*e);
18
+ if (!Number.isFinite(determinant) || Math.abs(determinant) < 1e-12) {
19
+ throw new Error('Instance transform must be invertible.');
20
+ }
21
+ const inv = [(e*i-f*h)/determinant,(c*h-b*i)/determinant,(b*f-c*e)/determinant,0,
22
+ (f*g-d*i)/determinant,(a*i-c*g)/determinant,(c*d-a*f)/determinant,0,
23
+ (d*h-e*g)/determinant,(b*g-a*h)/determinant,(a*e-b*d)/determinant,0,0,0,0,1];
24
+ for (let r=0;r<3;r++) inv[12+r]=-(inv[r]*m[12]+inv[4+r]*m[13]+inv[8+r]*m[14]);
25
+ if (!inv.every(n=>Number.isFinite(n) && Math.abs(n)<=1e12)) throw new Error('Instance inverse transform is out of range.');
26
+ return {transform:freeze(m),inverse:freeze(inv.map(n=>n===0?0:n)),handedness:Math.sign(determinant)};
27
+ }
28
+
29
+ export function transformInstancePoint(m, p) {
30
+ return [0,1,2].map(r=>m[r]*p[0]+m[4+r]*p[1]+m[8+r]*p[2]+m[12+r]);
31
+ }
32
+
33
+ function meshBounds(mesh) {
34
+ const localMin=[Infinity,Infinity,Infinity],localMax=[-Infinity,-Infinity,-Infinity];
35
+ for(let i=0;i<mesh.positions.length;i+=3) for(let a=0;a<3;a++) {
36
+ localMin[a]=Math.min(localMin[a],Math.fround(mesh.positions[i+a]));localMax[a]=Math.max(localMax[a],Math.fround(mesh.positions[i+a]));
37
+ }
38
+ return {min:localMin,max:localMax};
39
+ }
40
+
41
+ function boundsOf(local, transform) {
42
+ const localMin=local.min,localMax=local.max;
43
+ const min=[Infinity,Infinity,Infinity],max=[-Infinity,-Infinity,-Infinity];
44
+ for(let corner=0;corner<8;corner++) {
45
+ const p=transformInstancePoint(transform,[0,1,2].map(a=>(corner&(1<<a))?localMax[a]:localMin[a]));
46
+ for(let a=0;a<3;a++){min[a]=Math.min(min[a],p[a]);max[a]=Math.max(max[a],p[a]);}
47
+ }
48
+ if(![...min,...max].every(Number.isFinite)) throw new Error('Instance mesh has invalid bounds.');
49
+ return freeze({min:freeze(min),max:freeze(max)});
50
+ }
51
+
52
+ export function prepareWavefrontInstances(meshes, instances) {
53
+ if(!Array.isArray(instances)||instances.length===0) throw new Error('Instances must be a nonempty array.');
54
+ const byId=new Map(),localBounds=new Map();
55
+ for(const mesh of meshes) {
56
+ if(!uint(mesh.id)||byId.has(mesh.id)) throw new Error('Instanced mesh IDs must be unique unsigned integers.');
57
+ if(mesh.materialKind===MATERIAL_EMISSIVE||emissionPower(mesh.emission??[0,0,0,1])>0.0001) {
58
+ throw new Error('Instanced emissive meshes require per-instance light sampling, which is not supported yet.');
59
+ }
60
+ byId.set(mesh.id,mesh);localBounds.set(mesh.id,meshBounds(mesh));
61
+ }
62
+ const ids=new Set(),used=new Set();
63
+ const records=instances.map(instance=>{
64
+ if(!instance||!uint(instance.id)||ids.has(instance.id)) throw new Error('Instance IDs must be unique unsigned integers.');
65
+ const mesh=byId.get(instance.meshId);
66
+ if(!mesh) throw new Error('Instance references an unknown mesh.');
67
+ ids.add(instance.id);used.add(mesh.id);
68
+ const affine=affineTransform(instance.transform);
69
+ const bounds=boundsOf(localBounds.get(mesh.id),affine.transform);
70
+ return freeze({...affine,id:instance.id,meshId:mesh.id,bounds,
71
+ centroid:freeze(bounds.min.map((v,a)=>(v+bounds.max[a])/2))});
72
+ });
73
+ if(used.size!==meshes.length) throw new Error('Every instanced mesh must have at least one instance.');
74
+ return freeze({records:freeze(records),geometryCount:meshes.length});
75
+ }
76
+
77
+ export function buildWavefrontInstances(meshes, plan, materialSource) {
78
+ const tlas=buildWavefrontBoundsBvh(plan.records,1);
79
+ // Outward padding covers f32 matrix multiplication and packed-bound rounding.
80
+ const nodes=tlas.nodes.map(node=>{
81
+ const pad=node.bounds.min.map((v,a)=>Math.max(1,Math.abs(v),Math.abs(node.bounds.max[a]))*1e-6);
82
+ return freeze({...node,bounds:freeze({min:freeze(node.bounds.min.map((v,a)=>v-pad[a])),
83
+ max:freeze(node.bounds.max.map((v,a)=>v+pad[a]))})});
84
+ }),triangles=[],roots=new Map();
85
+ for(const [meshIndex,mesh] of meshes.entries()) {
86
+ const blas=createWavefrontMeshAcceleration([mesh],materialSource);
87
+ const nodeOffset=nodes.length,triangleOffset=triangles.length;
88
+ roots.set(mesh.id,nodeOffset);
89
+ for(const node of blas.nodes) nodes.push(freeze({...node,
90
+ firstTriangle:node.firstTriangle+(node.triangleCount>0?triangleOffset:nodeOffset),
91
+ leftChild:node.leftChild+nodeOffset,rightChild:node.rightChild+nodeOffset}));
92
+ for(const triangle of blas.triangles) triangles.push(freeze({...triangle,
93
+ triangleId:triangle.triangleId+triangleOffset,materialSlot:meshIndex}));
94
+ }
95
+ const records=tlas.triangles.map(record=>freeze({...record,root:roots.get(record.meshId)}));
96
+ const buffer=new ArrayBuffer(records.length*WAVEFRONT_INSTANCE_BYTES);
97
+ const floats=new Float32Array(buffer),uints=new Uint32Array(buffer);
98
+ records.forEach((record,index)=>{
99
+ const offset=index*36;
100
+ floats.set(record.transform,offset);floats.set(record.inverse,offset+16);
101
+ uints[offset+32]=record.id;uints[offset+33]=record.root;floats[offset+34]=record.handedness;
102
+ });
103
+ return freeze({records:freeze(records),buffer,instanceCount:records.length,
104
+ geometryCount:meshes.length,instanceBytes:buffer.byteLength,tlasNodeCount:tlas.nodes.length,
105
+ blasNodeCount:nodes.length-tlas.nodes.length,
106
+ acceleration:freeze({nodes:freeze(nodes),triangles:freeze(triangles)})});
107
+ }