@plasius/gpu-renderer 0.2.55 → 0.2.57

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.55",
3
+ "version": "0.2.57",
4
4
  "description": "Framework-agnostic WebGPU renderer runtime for Plasius projects.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -84,14 +84,6 @@ function textureSample(binding, textures, label, extra = {}) {
84
84
 
85
85
  function cloneMaterialExtensions(material, textures) {
86
86
  const extensions = { ...(material.extensions ?? {}) };
87
- if (material.workflow === "unlit") {
88
- const unlitKey = "KHR_materials_unlit";
89
- const unlitValue = extensions[unlitKey] ?? extensions.unlit ?? {};
90
- if (typeof unlitValue !== "object" || unlitValue === null || Array.isArray(unlitValue)) {
91
- throw new TypeError(`Canonical material extension ${unlitKey} must be an object.`);
92
- }
93
- extensions[unlitKey] = { ...unlitValue, enabled: true };
94
- }
95
87
  for (const [bindingName, extensionName, textureKey] of EXTENSION_TEXTURE_BINDINGS) {
96
88
  const binding = material.textures?.[bindingName];
97
89
  if (!binding) {
@@ -118,7 +110,12 @@ function mapMaterial(material, materialRefId, textures) {
118
110
  if (material.alphaMode === "mask") {
119
111
  throw new Error("Canonical alphaMode mask is unsupported by WavefrontMeshInput.");
120
112
  }
121
- if (material.workflow !== "metallic-roughness" && material.workflow !== "unlit") {
113
+ if (material.workflow === "unlit"
114
+ || Object.hasOwn(material.extensions ?? {}, "KHR_materials_unlit")
115
+ || Object.hasOwn(material.extensions ?? {}, "unlit")) {
116
+ throw new Error("Canonical unlit materials are unsupported by WavefrontMeshInput; retain an unlit-capable rendering path.");
117
+ }
118
+ if (material.workflow !== "metallic-roughness") {
122
119
  throw new Error(`Unsupported canonical material workflow: ${material.workflow}.`);
123
120
  }
124
121
 
@@ -160,6 +157,8 @@ function mapMaterial(material, materialRefId, textures) {
160
157
  /**
161
158
  * Maps decoded canonical mesh primitives into the renderer's existing Wavefront
162
159
  * input contract. Geometry and image decoding remain upstream responsibilities.
160
+ * Referenced unlit workflows or extension markers are rejected until an explicit
161
+ * unlit shading path exists; callers must retain an unlit-capable rendering path.
163
162
  */
164
163
  export function createCanonicalWavefrontMeshInputs({ document, geometry, textures = new Map() } = {}) {
165
164
  if (!document || typeof document !== "object") {
@@ -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,8 @@ export interface WavefrontPathTracingMemoryEstimate {
1045
1050
  readonly pathVertexBytes: number;
1046
1051
  readonly sceneObjectBytes: number;
1047
1052
  readonly triangleBytes: number;
1053
+ /** Instance lookup texture bytes; separate from totalHotBufferBytes. */
1054
+ readonly instanceBytes: number;
1048
1055
  readonly materialTableBytes: number;
1049
1056
  readonly bvhNodeBytes: number;
1050
1057
  readonly bvhCombinedBytes: number;
@@ -1102,6 +1109,21 @@ export interface WavefrontGpuParallelismDiagnostics {
1102
1109
  readonly coreUtilizationStatus: "not-exposed-by-webgpu";
1103
1110
  }
1104
1111
 
1112
+ /** Static column-major affine transform referencing a unique mesh. */
1113
+ export interface WavefrontMeshInstanceInput {
1114
+ readonly id: number;
1115
+ readonly meshId: number;
1116
+ readonly transform: readonly number[] | Float32Array;
1117
+ }
1118
+ export interface WavefrontGpuAllocationSnapshot {
1119
+ /** Requested resource storage; excludes driver overhead and externally owned resources. */
1120
+ readonly accounting: "requested-resource-bytes";
1121
+ readonly budgetBytes: number | null;
1122
+ readonly allocatedBytes: number;
1123
+ readonly peakAllocatedBytes: number;
1124
+ readonly resources: readonly Readonly<{kind: string; label: string; bytes: number}>[];
1125
+ }
1126
+
1105
1127
  export interface CreateWavefrontPathTracingComputeRendererOptions {
1106
1128
  /** Accumulate linear radiance between frames until camera/scene/reset changes. Default false. */
1107
1129
  readonly progressive?: boolean;
@@ -1127,6 +1149,10 @@ export interface CreateWavefrontPathTracingComputeRendererOptions {
1127
1149
  readonly mediums?: readonly WavefrontMediumInput[];
1128
1150
  readonly mesh?: WavefrontMeshInput;
1129
1151
  readonly meshes?: readonly WavefrontMeshInput[];
1152
+ /** Opt-in shared BLAS/TLAS instances. Every mesh must be referenced. Static, non-emissive meshes; CPU BLAS construction. */
1153
+ readonly instances?: readonly WavefrontMeshInstanceInput[];
1154
+ /** Positive renderer-owned resource byte budget. Device limits apply independently. */
1155
+ readonly memoryBudgetBytes?: number;
1130
1156
  readonly triangleCapacity?: number;
1131
1157
  readonly bvhNodeCapacity?: number;
1132
1158
  readonly bvhLeafSortCapacity?: number;
@@ -1203,6 +1229,8 @@ export interface WavefrontPathTracingComputeRenderer {
1203
1229
  maxFramePassesPerSubmission: number;
1204
1230
  sceneObjectCount: number;
1205
1231
  triangleCount: number;
1232
+ instanceCount: number;
1233
+ geometryCount: number;
1206
1234
  emissiveTriangleCount: number;
1207
1235
  environmentPortalCount: number;
1208
1236
  environmentPortalMode: 0 | 1 | 2;
@@ -1221,6 +1249,7 @@ export interface WavefrontPathTracingComputeRenderer {
1221
1249
  frameConfigSlots: number;
1222
1250
  gpuParallelism: WavefrontGpuParallelismDiagnostics;
1223
1251
  memory: WavefrontPathTracingMemoryEstimate;
1252
+ allocations: WavefrontGpuAllocationSnapshot;
1224
1253
  }>;
1225
1254
  destroy(): void;
1226
1255
  }
@@ -1557,6 +1586,7 @@ export function renderWavefrontPathTracingComputeFrame(
1557
1586
  }
1558
1587
  ): Promise<WavefrontPathTracingComputeFrameStats>;
1559
1588
  export function createWavefrontPathTracingComputeShaderSource(options?: {
1589
+ readonly instanced?: boolean;
1560
1590
  workgroupSize?: number;
1561
1591
  outputTextureFormat?: GPUTextureFormat | "rgba8unorm";
1562
1592
  }): 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,5 @@
1
+ import { uploadWavefrontBuffer } from './wavefront-upload.js';
2
+ import { createGpuAllocationTracker, openGpuAllocationErrorScopes } from './gpu-allocation-budget.js';
1
3
  import { createWavefrontProgressiveResources } from './wavefront-progressive.js';
2
4
  import {
3
5
  createGpuParallelismCounters,
@@ -190,11 +192,16 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
190
192
  throw new Error("Unable to acquire a WebGPU adapter for wavefront path tracing.");
191
193
  }
192
194
 
193
- const device = await adapter.requestDevice(
195
+ const rawDevice = await adapter.requestDevice(
194
196
  createWavefrontDeviceDescriptor(adapter, options, initialConfig.memory)
195
197
  );
198
+ const allocationTracker = createGpuAllocationTracker(rawDevice, {budgetBytes:options.memoryBudgetBytes,storageUsage:constants.buffer.STORAGE});
199
+ const device = allocationTracker.device;
200
+ let context, finishAllocationScopes;
201
+ try {
202
+ finishAllocationScopes = openGpuAllocationErrorScopes(rawDevice);
196
203
  const gpuAdapterParallelism = createGpuAdapterParallelismDiagnostics(adapter, device);
197
- const context = canvas.getContext("webgpu");
204
+ context = canvas.getContext("webgpu");
198
205
  if (!context || typeof context.configure !== "function") {
199
206
  throw new Error("Canvas WebGPU context does not support configure().");
200
207
  }
@@ -226,13 +233,23 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
226
233
  canvas.width = config.width;
227
234
  canvas.height = config.height;
228
235
  context.configure({
229
- device,
236
+ device: rawDevice,
230
237
  format,
231
238
  alphaMode: options.alpha === true ? "premultiplied" : "opaque",
232
239
  });
233
240
 
234
241
  const bufferUsage = constants.buffer.STORAGE | constants.buffer.COPY_DST;
235
242
  const memory = config.memory;
243
+ let instanceTexture = null, instanceView = null;
244
+ if(config.instancing) {
245
+ const count=config.instancing.instanceCount;
246
+ instanceTexture=device.createTexture({label:"plasius.wavefront.instances",size:{width:9,height:count},
247
+ format:"rgba32uint",usage:constants.texture.TEXTURE_BINDING|constants.texture.COPY_DST});
248
+ const upload=new Uint8Array(count*256),source=new Uint8Array(config.instancing.buffer);
249
+ for(let i=0;i<count;i++) upload.set(source.subarray(i*144,(i+1)*144),i*256);
250
+ device.queue.writeTexture({texture:instanceTexture},upload,{bytesPerRow:256,rowsPerImage:count},{width:9,height:count,depthOrArrayLayers:1});
251
+ instanceView=instanceTexture.createView();
252
+ }
236
253
  const activeQueue = createBuffer(device, bufferUsage, memory.queueBytes, "plasius.wavefront.activeQueue");
237
254
  const nextQueue = createBuffer(device, bufferUsage, memory.queueBytes, "plasius.wavefront.nextQueue");
238
255
  const hitBuffer = createBuffer(device, bufferUsage, memory.hitBytes, "plasius.wavefront.hitBuffer");
@@ -354,11 +371,11 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
354
371
  packedBvhNodeFloats[nodeOffset + 2] = totalEmissiveWeight;
355
372
  packedBvhNodeUints[nodeOffset + 8] = triangleIndex;
356
373
  });
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);
374
+ await uploadWavefrontBuffer(device, triangleBuffer, packedTriangles.buffer);
375
+ await uploadWavefrontBuffer(device, bvhNodeBuffer, packedBvhNodes.buffer);
376
+ await uploadWavefrontBuffer(device, meshVertexBuffer, config.gpuMeshSource.vertices.buffer);
377
+ await uploadWavefrontBuffer(device, meshIndexBuffer, config.gpuMeshSource.indices.buffer);
378
+ await uploadWavefrontBuffer(device, meshRangeBuffer, config.gpuMeshSource.meshes.buffer);
362
379
  const packedEnvironmentPortals = packEnvironmentPortals(
363
380
  config.environmentPortals,
364
381
  Math.max(1, config.environmentPortalCapacity)
@@ -501,7 +518,7 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
501
518
  },
502
519
  computePipelines: pipelines,
503
520
  presentPipeline,
504
- } = await createWavefrontPipelineResources({ device, constants, format });
521
+ } = await createWavefrontPipelineResources({ device, constants, format, instanced:!!config.instancing });
505
522
 
506
523
  function createTraceBindGroups() {
507
524
  return createWavefrontTraceBindGroups({
@@ -516,6 +533,7 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
516
533
  counterBuffer,
517
534
  outputView,
518
535
  triangleBuffer,
536
+ instanceView,
519
537
  bvhNodeBuffer,
520
538
  radianceView,
521
539
  environmentPortalBuffer,
@@ -1058,6 +1076,8 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
1058
1076
  maxFramePassesPerSubmission: config.maxFramePassesPerSubmission,
1059
1077
  sceneObjectCount: config.sceneObjectCount,
1060
1078
  triangleCount: config.triangleCount,
1079
+ instanceCount: config.instancing?.instanceCount ?? 0,
1080
+ geometryCount: config.instancing?.geometryCount ?? config.gpuMeshSource.meshes.count,
1061
1081
  emissiveTriangleCount: config.emissiveTriangleCount,
1062
1082
  environmentPortalCount: config.environmentPortalCount,
1063
1083
  environmentPortalMode: config.environmentPortalMode,
@@ -1076,6 +1096,7 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
1076
1096
  frameConfigSlots: frameConfigSlotCount,
1077
1097
  gpuParallelism: lastGpuParallelism,
1078
1098
  memory: config.memory,
1099
+ allocations: allocationTracker.snapshot(),
1079
1100
  });
1080
1101
  }
1081
1102
 
@@ -1089,6 +1110,7 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
1089
1110
  pathVertexBuffer.destroy?.();
1090
1111
  sceneObjectBuffer.destroy?.();
1091
1112
  triangleBuffer.destroy?.();
1113
+ instanceTexture?.destroy?.();
1092
1114
  bvhNodeBuffer.destroy?.();
1093
1115
  meshVertexBuffer.destroy?.();
1094
1116
  meshIndexBuffer.destroy?.();
@@ -1133,12 +1155,14 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
1133
1155
  }
1134
1156
  });
1135
1157
  context.unconfigure?.();
1158
+ allocationTracker.destroy();
1136
1159
  }
1137
1160
 
1161
+ await finishAllocationScopes();
1138
1162
  return Object.freeze({
1139
1163
  canvas,
1140
1164
  context,
1141
- device,
1165
+ device: rawDevice,
1142
1166
  format,
1143
1167
  config,
1144
1168
  renderOnce,
@@ -1150,6 +1174,13 @@ export async function createWavefrontPathTracingComputeRenderer(options = {}) {
1150
1174
  getSnapshot,
1151
1175
  destroy,
1152
1176
  });
1177
+ } catch(error) {
1178
+ try { await finishAllocationScopes?.(); } catch { /* Preserve the initiating failure. */ }
1179
+ context?.unconfigure?.();
1180
+ allocationTracker.destroy();
1181
+ rawDevice.destroy?.();
1182
+ throw error;
1183
+ }
1153
1184
  }
1154
1185
 
1155
1186
  export async function renderWavefrontPathTracingComputeFrame(options = {}) {
@@ -1,3 +1,4 @@
1
+ import { prepareWavefrontInstances, buildWavefrontInstances } from './wavefront-instances.js';
1
2
  import {
2
3
  ACCUMULATION_RECORD_BYTES,
3
4
  BVH_LEAF_REF_RECORD_BYTES,
@@ -357,6 +358,7 @@ export function estimateWavefrontPathTracingMemory(options = {}) {
357
358
  const progressiveBytes = options.progressive === true ? width * height * 16 + 16 : 0;
358
359
  const pathVertexBytes = tilePixelCapacity * (maxDepth + 1) * PATH_VERTEX_RECORD_BYTES;
359
360
  const sceneObjectBytes = sceneObjectCapacity * SCENE_OBJECT_RECORD_BYTES;
361
+ const instanceBytes = readNonNegativeInteger("instanceBytes", options.instanceBytes, 0);
360
362
  const triangleBytes = allocatedRecordBytes(triangleCapacity, TRIANGLE_RECORD_BYTES);
361
363
  const materialTableBytes = 0;
362
364
  const bvhNodeBytes = bvhNodeCapacity * BVH_NODE_RECORD_BYTES;
@@ -431,6 +433,7 @@ export function estimateWavefrontPathTracingMemory(options = {}) {
431
433
  pathVertexBytes,
432
434
  sceneObjectBytes,
433
435
  triangleBytes,
436
+ instanceBytes,
434
437
  materialTableBytes,
435
438
  bvhNodeBytes,
436
439
  bvhCombinedBytes,
@@ -461,6 +464,7 @@ export function estimateWavefrontPathTracingMemoryForConfig(config, limits = {})
461
464
  maxDepth: config.maxDepth,
462
465
  sceneObjectCapacity: config.sceneObjectCapacity,
463
466
  triangleCapacity: config.triangleCapacity,
467
+ instanceBytes: config.instancing?.instanceBytes ?? 0,
464
468
  bvhNodeCapacity: config.bvhNodeCapacity,
465
469
  bvhLeafSortCapacity: config.bvhLeafSortCapacity,
466
470
  emissiveTriangleCapacity: config.emissiveTriangleCapacity,
@@ -482,6 +486,7 @@ export function estimateWavefrontPathTracingMemoryForConfig(config, limits = {})
482
486
  export function createWavefrontPathTracingComputeConfig(options = {}) {
483
487
  assertAnalyticDisplayQualityPolicy(options);
484
488
  const accelerationBuildMode = resolveAccelerationBuildMode(options);
489
+ if(options.instances !== undefined && accelerationBuildMode === "gpu") throw new Error("Instance BLAS construction currently requires cpu-upload acceleration.");
485
490
  const canvas = options.canvas;
486
491
  const width = readPositiveInteger("width", options.width, getCanvasDimension(canvas, "width", DEFAULT_WIDTH));
487
492
  const height = readPositiveInteger("height", options.height, getCanvasDimension(canvas, "height", DEFAULT_HEIGHT));
@@ -512,6 +517,12 @@ export function createWavefrontPathTracingComputeConfig(options = {}) {
512
517
  );
513
518
  const meshes = normalizeMeshes(options);
514
519
  const meshSourceShape = estimateMeshSourceShape(meshes);
520
+ if(options.memoryBudgetBytes !== undefined) {
521
+ if(!Number.isSafeInteger(options.memoryBudgetBytes)||options.memoryBudgetBytes<=0) throw new Error("GPU memory budget must be a positive safe integer.");
522
+ const geometryMinimumBytes = meshSourceShape.triangleCount * TRIANGLE_RECORD_BYTES + (options.instances?.length ?? 0) * 144;
523
+ if(!Number.isSafeInteger(geometryMinimumBytes)||geometryMinimumBytes>options.memoryBudgetBytes) throw new Error("Unique geometry exceeds the GPU memory budget before acceleration construction.");
524
+ }
525
+ const instancePlan = options.instances === undefined ? null : prepareWavefrontInstances(meshes, options.instances);
515
526
  const gpuMaterialSource =
516
527
  meshes.length > 0
517
528
  ? createWavefrontGpuMaterialSource(meshes)
@@ -520,10 +531,11 @@ export function createWavefrontPathTracingComputeConfig(options = {}) {
520
531
  meshes.length > 0
521
532
  ? createWavefrontGpuMeshSource(meshes, gpuMaterialSource)
522
533
  : createWavefrontGpuMeshSource([]);
523
- const meshAcceleration =
534
+ const instancing = instancePlan ? buildWavefrontInstances(meshes, instancePlan, gpuMaterialSource) : null;
535
+ const meshAcceleration = instancing?.acceleration ?? (
524
536
  accelerationBuildMode === "cpu-upload"
525
537
  ? createWavefrontMeshAcceleration(meshes, gpuMaterialSource)
526
- : Object.freeze({ nodes: Object.freeze([]), triangles: Object.freeze([]) });
538
+ : Object.freeze({ nodes: Object.freeze([]), triangles: Object.freeze([]) }));
527
539
  const emissiveTriangleIndices = createWavefrontEmissiveTriangleIndexSource(
528
540
  meshes,
529
541
  options.emissiveTriangleCapacity
@@ -625,6 +637,7 @@ export function createWavefrontPathTracingComputeConfig(options = {}) {
625
637
  gpuAccelerationBuildRequired: accelerationBuildMode === "gpu" && triangleCount > 0,
626
638
  gpuMeshSource,
627
639
  gpuMaterialSource,
640
+ instancing,
628
641
  meshAcceleration,
629
642
  emissiveTriangleIndices,
630
643
  emissiveTriangleCount: emissiveTriangleIndices.count,
@@ -663,6 +676,7 @@ export function createWavefrontPathTracingComputeConfig(options = {}) {
663
676
  tilePixelCapacity,
664
677
  maxDepth,
665
678
  sceneObjectCapacity,
679
+ instanceBytes: instancing?.instanceBytes ?? 0,
666
680
  triangleCapacity,
667
681
  bvhNodeCapacity,
668
682
  bvhLeafSortCapacity,
@@ -0,0 +1,86 @@
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 y = i32(index);
13
+ let metadata = textureLoad(meshInstances, vec2<i32>(8, y), 0);
14
+ return MeshInstance(
15
+ mat4x4<f32>(bitcast<vec4<f32>>(textureLoad(meshInstances, vec2<i32>(0, y), 0)),
16
+ bitcast<vec4<f32>>(textureLoad(meshInstances, vec2<i32>(1, y), 0)),
17
+ bitcast<vec4<f32>>(textureLoad(meshInstances, vec2<i32>(2, y), 0)),
18
+ bitcast<vec4<f32>>(textureLoad(meshInstances, vec2<i32>(3, y), 0))),
19
+ mat4x4<f32>(bitcast<vec4<f32>>(textureLoad(meshInstances, vec2<i32>(4, y), 0)),
20
+ bitcast<vec4<f32>>(textureLoad(meshInstances, vec2<i32>(5, y), 0)),
21
+ bitcast<vec4<f32>>(textureLoad(meshInstances, vec2<i32>(6, y), 0)),
22
+ bitcast<vec4<f32>>(textureLoad(meshInstances, vec2<i32>(7, y), 0))),
23
+ metadata.x, metadata.y, bitcast<f32>(metadata.z), 0u);
24
+ }
25
+
26
+ fn instance_normal(instance: MeshInstance, normal: vec3<f32>) -> vec3<f32> {
27
+ let normalMatrix = transpose(mat3x3<f32>(instance.inverse[0].xyz, instance.inverse[1].xyz, instance.inverse[2].xyz));
28
+ return safe_normalize(normalMatrix * normal, vec3<f32>(0.0, 1.0, 0.0));
29
+ }
30
+
31
+ fn triangle_for_candidate(candidate: Candidate) -> TriangleRecord {
32
+ var triangle = triangles[candidate.triangleIndex];
33
+ let instance = read_mesh_instance(candidate.instanceIndex);
34
+ triangle.v0 = instance.world * vec4<f32>(triangle.v0.xyz, 1.0);
35
+ triangle.v1 = instance.world * vec4<f32>(triangle.v1.xyz, 1.0);
36
+ triangle.v2 = instance.world * vec4<f32>(triangle.v2.xyz, 1.0);
37
+ triangle.meshId = instance.objectId;
38
+ return triangle;
39
+ }
40
+
41
+ fn intersect_bvh(ray: RayRecord, initialNearest: f32) -> Candidate {
42
+ var nearest = initialNearest;
43
+ var best = no_candidate();
44
+ var stack = array<u32, 64>();
45
+ var stackSize = 1u;
46
+ stack[0] = 0u;
47
+ loop {
48
+ if (stackSize == 0u) { break; }
49
+ stackSize -= 1u;
50
+ let node = bvhNodes[stack[stackSize]];
51
+ if (!intersect_bounds(ray, node.boundsMin.xyz, node.boundsMax.xyz, nearest)) { continue; }
52
+ if (node.triangleCount == 0u) {
53
+ stack[stackSize] = node.childOrFirst;
54
+ stack[stackSize + 1u] = node.rightChild;
55
+ stackSize += 2u;
56
+ } else {
57
+ let instanceIndex = node.childOrFirst;
58
+ let instance = read_mesh_instance(instanceIndex);
59
+ var localRay = ray;
60
+ localRay.origin = instance.inverse * vec4<f32>(ray.origin.xyz, 1.0);
61
+ // Keep this direction unnormalized so t remains the world-space ray parameter.
62
+ localRay.direction = instance.inverse * vec4<f32>(ray.direction.xyz, 0.0);
63
+ var candidate = intersect_geometry_bvh(localRay, nearest, instance.root);
64
+ if (candidate.hit == 1u && candidate.distance < nearest) {
65
+ candidate.geometricNormal = instance_normal(instance, candidate.geometricNormal);
66
+ candidate.shadingNormal = repair_shading_normal(candidate.geometricNormal,
67
+ instance_normal(instance, candidate.shadingNormal));
68
+ if (instance.handedness < 0.0) { candidate.frontFace = 1u - candidate.frontFace; }
69
+ candidate.instanceIndex = instanceIndex;
70
+ nearest = candidate.distance;
71
+ best = candidate;
72
+ }
73
+ }
74
+ }
75
+ return best;
76
+ }
77
+ `;
78
+
79
+ export const WAVEFRONT_UNINSTANCED_SHADER_WGSL = `
80
+ fn triangle_for_candidate(candidate: Candidate) -> TriangleRecord {
81
+ return triangles[candidate.triangleIndex];
82
+ }
83
+ fn intersect_bvh(ray: RayRecord, initialNearest: f32) -> Candidate {
84
+ return intersect_geometry_bvh(ray, initialNearest, 0u);
85
+ }
86
+ `;
@@ -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
+ }
@@ -159,7 +159,7 @@ function chooseSplitAxis(triangles) {
159
159
  return extent[1] >= extent[2] ? 1 : 2;
160
160
  }
161
161
 
162
- function buildBvh(triangles, maxLeafTriangles = 4) {
162
+ export function buildWavefrontBoundsBvh(triangles, maxLeafTriangles = 4) {
163
163
  if (triangles.length === 0) {
164
164
  return Object.freeze({ nodes: Object.freeze([]), triangles: Object.freeze([]) });
165
165
  }
@@ -217,7 +217,7 @@ export function createWavefrontMeshAcceleration(meshes = [], gpuMaterialSource =
217
217
  const source = Array.isArray(meshes) ? meshes : [meshes];
218
218
  const resolvedMaterialSource = gpuMaterialSource ?? createWavefrontGpuMaterialSource(source);
219
219
  const triangles = createMeshTriangleRecords(source, resolvedMaterialSource);
220
- return buildBvh(triangles);
220
+ return buildWavefrontBoundsBvh(triangles);
221
221
  }
222
222
 
223
223
  export function estimateMeshSourceShape(meshes) {