@motion-core/motion-gpu 0.5.0 → 0.6.0

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 (45) hide show
  1. package/dist/core/compute-bindgroup-cache.d.ts +13 -0
  2. package/dist/core/compute-bindgroup-cache.d.ts.map +1 -0
  3. package/dist/core/compute-bindgroup-cache.js +45 -0
  4. package/dist/core/compute-bindgroup-cache.js.map +1 -0
  5. package/dist/core/compute-shader.d.ts +48 -0
  6. package/dist/core/compute-shader.d.ts.map +1 -1
  7. package/dist/core/compute-shader.js +34 -1
  8. package/dist/core/compute-shader.js.map +1 -1
  9. package/dist/core/error-diagnostics.d.ts +8 -1
  10. package/dist/core/error-diagnostics.d.ts.map +1 -1
  11. package/dist/core/error-diagnostics.js +7 -3
  12. package/dist/core/error-diagnostics.js.map +1 -1
  13. package/dist/core/error-report.d.ts.map +1 -1
  14. package/dist/core/error-report.js +19 -1
  15. package/dist/core/error-report.js.map +1 -1
  16. package/dist/core/material.d.ts.map +1 -1
  17. package/dist/core/material.js +2 -1
  18. package/dist/core/material.js.map +1 -1
  19. package/dist/core/renderer.d.ts.map +1 -1
  20. package/dist/core/renderer.js +150 -85
  21. package/dist/core/renderer.js.map +1 -1
  22. package/dist/core/runtime-loop.d.ts.map +1 -1
  23. package/dist/core/runtime-loop.js +26 -14
  24. package/dist/core/runtime-loop.js.map +1 -1
  25. package/dist/core/shader.d.ts +7 -2
  26. package/dist/core/shader.d.ts.map +1 -1
  27. package/dist/core/shader.js +1 -0
  28. package/dist/core/shader.js.map +1 -1
  29. package/dist/core/textures.d.ts +4 -0
  30. package/dist/core/textures.d.ts.map +1 -1
  31. package/dist/core/textures.js +2 -1
  32. package/dist/core/textures.js.map +1 -1
  33. package/dist/core/types.d.ts +1 -1
  34. package/dist/core/types.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/src/lib/core/compute-bindgroup-cache.ts +73 -0
  37. package/src/lib/core/compute-shader.ts +86 -0
  38. package/src/lib/core/error-diagnostics.ts +29 -4
  39. package/src/lib/core/error-report.ts +26 -1
  40. package/src/lib/core/material.ts +2 -1
  41. package/src/lib/core/renderer.ts +198 -92
  42. package/src/lib/core/runtime-loop.ts +37 -16
  43. package/src/lib/core/shader.ts +13 -2
  44. package/src/lib/core/textures.ts +6 -1
  45. package/src/lib/core/types.ts +1 -1
@@ -211,6 +211,19 @@ function buildSourceFromDiagnostics(error: unknown): MotionGPUErrorSource | null
211
211
  };
212
212
  }
213
213
 
214
+ if (location.kind === 'compute') {
215
+ const computeSource = diagnostics.computeSource ?? diagnostics.fragmentSource;
216
+ const component = 'Compute shader';
217
+ const locationLabel = formatShaderSourceLocation(location) ?? `compute line ${location.line}`;
218
+ return {
219
+ component,
220
+ location: `${component} (${locationLabel})`,
221
+ line: location.line,
222
+ ...(column !== undefined ? { column } : {}),
223
+ snippet: toSnippet(computeSource, location.line)
224
+ };
225
+ }
226
+
214
227
  const defineName = location.define ?? 'unknown';
215
228
  const defineLine = Math.max(1, location.line);
216
229
  const component = `#define ${defineName}`;
@@ -516,7 +529,19 @@ export function toMotionGPUErrorReport(
516
529
  error instanceof Error && error.stack
517
530
  ? splitLines(error.stack).filter((line) => line !== message)
518
531
  : [];
519
- const classification = classifyErrorMessage(rawMessage);
532
+ let classification = classifyErrorMessage(rawMessage);
533
+ if (
534
+ shaderDiagnostics?.shaderStage === 'compute' &&
535
+ classification.code === 'WGSL_COMPILATION_FAILED'
536
+ ) {
537
+ classification = {
538
+ code: 'COMPUTE_COMPILATION_FAILED',
539
+ severity: 'error',
540
+ recoverable: true,
541
+ title: 'Compute shader compilation failed',
542
+ hint: 'Check WGSL compute shader sources below and verify storage bindings.'
543
+ };
544
+ }
520
545
 
521
546
  return {
522
547
  code: classification.code,
@@ -542,7 +542,8 @@ function buildTextureConfigSignature<TTextureKey extends string>(
542
542
  normalized.anisotropy,
543
543
  normalized.filter,
544
544
  normalized.addressModeU,
545
- normalized.addressModeV
545
+ normalized.addressModeV,
546
+ normalized.fragmentVisible ? '1' : '0'
546
547
  ].join(':');
547
548
  }
548
549
 
@@ -18,11 +18,12 @@ import {
18
18
  } from './textures.js';
19
19
  import { packUniformsInto } from './uniforms.js';
20
20
  import {
21
- buildComputeShaderSource,
22
- buildPingPongComputeShaderSource,
21
+ buildComputeShaderSourceWithMap,
22
+ buildPingPongComputeShaderSourceWithMap,
23
23
  extractWorkgroupSize,
24
24
  storageTextureSampleScalarType
25
25
  } from './compute-shader.js';
26
+ import { createComputeStorageBindGroupCache } from './compute-bindgroup-cache.js';
26
27
  import { normalizeStorageBufferDefinition } from './storage-buffers.js';
27
28
  import type {
28
29
  AnyPass,
@@ -62,6 +63,7 @@ interface RuntimeTextureBinding {
62
63
  key: string;
63
64
  samplerBinding: number;
64
65
  textureBinding: number;
66
+ fragmentVisible: boolean;
65
67
  sampler: GPUSampler;
66
68
  fallbackTexture: GPUTexture;
67
69
  fallbackView: GPUTextureView;
@@ -109,6 +111,8 @@ interface PingPongTexturePair {
109
111
  textureB: GPUTexture;
110
112
  viewB: GPUTextureView;
111
113
  bindGroupLayout: GPUBindGroupLayout;
114
+ readAWriteBBindGroup: GPUBindGroup | null;
115
+ readBWriteABindGroup: GPUBindGroup | null;
112
116
  }
113
117
 
114
118
  /**
@@ -187,6 +191,7 @@ async function assertCompilation(
187
191
  options?: {
188
192
  lineMap?: ShaderLineMap;
189
193
  fragmentSource?: string;
194
+ computeSource?: string;
190
195
  includeSources?: Record<string, string>;
191
196
  defineBlockSource?: string;
192
197
  materialSource?: {
@@ -197,6 +202,8 @@ async function assertCompilation(
197
202
  functionName?: string;
198
203
  } | null;
199
204
  runtimeContext?: ShaderCompilationRuntimeContext;
205
+ errorPrefix?: string;
206
+ shaderStage?: 'fragment' | 'compute';
200
207
  }
201
208
  ): Promise<void> {
202
209
  const info = await module.getCompilationInfo();
@@ -227,11 +234,14 @@ async function assertCompilation(
227
234
  return `[${contextLabel.join(' | ')}] ${diagnostic.message}`;
228
235
  })
229
236
  .join('\n');
230
- const error = new Error(`WGSL compilation failed:\n${summary}`);
237
+ const prefix = options?.errorPrefix ?? 'WGSL compilation failed';
238
+ const error = new Error(`${prefix}:\n${summary}`);
231
239
  throw attachShaderCompilationDiagnostics(error, {
232
240
  kind: 'shader-compilation',
241
+ ...(options?.shaderStage !== undefined ? { shaderStage: options.shaderStage } : {}),
233
242
  diagnostics,
234
243
  fragmentSource: options?.fragmentSource ?? '',
244
+ ...(options?.computeSource !== undefined ? { computeSource: options.computeSource } : {}),
235
245
  includeSources: options?.includeSources ?? {},
236
246
  ...(options?.defineBlockSource !== undefined
237
247
  ? { defineBlockSource: options.defineBlockSource }
@@ -245,6 +255,64 @@ function toSortedUniqueStrings(values: string[]): string[] {
245
255
  return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
246
256
  }
247
257
 
258
+ function extractGeneratedLineFromComputeError(message: string): number | null {
259
+ const lineMatch = message.match(/\bline\s+(\d+)\b/i);
260
+ if (lineMatch) {
261
+ const parsed = Number.parseInt(lineMatch[1] ?? '', 10);
262
+ if (Number.isFinite(parsed) && parsed > 0) {
263
+ return parsed;
264
+ }
265
+ }
266
+
267
+ const colonMatch = message.match(/:(\d+):\d+/);
268
+ if (colonMatch) {
269
+ const parsed = Number.parseInt(colonMatch[1] ?? '', 10);
270
+ if (Number.isFinite(parsed) && parsed > 0) {
271
+ return parsed;
272
+ }
273
+ }
274
+
275
+ return null;
276
+ }
277
+
278
+ function toComputeCompilationError(input: {
279
+ error: unknown;
280
+ lineMap: ShaderLineMap;
281
+ computeSource: string;
282
+ runtimeContext: ShaderCompilationRuntimeContext;
283
+ }): Error {
284
+ const baseError =
285
+ input.error instanceof Error ? input.error : new Error(String(input.error ?? 'Unknown error'));
286
+ const generatedLine = extractGeneratedLineFromComputeError(baseError.message) ?? 0;
287
+ const sourceLocation = generatedLine > 0 ? (input.lineMap[generatedLine] ?? null) : null;
288
+ const diagnostics = [
289
+ {
290
+ generatedLine,
291
+ message: baseError.message,
292
+ sourceLocation
293
+ }
294
+ ];
295
+ const sourceLabel = formatShaderSourceLocation(sourceLocation);
296
+ const generatedLineLabel = generatedLine > 0 ? `generated WGSL line ${generatedLine}` : null;
297
+ const contextLabel = [sourceLabel, generatedLineLabel].filter((value) => Boolean(value));
298
+ const summary =
299
+ contextLabel.length > 0
300
+ ? `[${contextLabel.join(' | ')}] ${baseError.message}`
301
+ : baseError.message;
302
+ const wrapped = new Error(`Compute shader compilation failed:\n${summary}`);
303
+
304
+ return attachShaderCompilationDiagnostics(wrapped, {
305
+ kind: 'shader-compilation',
306
+ shaderStage: 'compute',
307
+ diagnostics,
308
+ fragmentSource: '',
309
+ computeSource: input.computeSource,
310
+ includeSources: {},
311
+ materialSource: null,
312
+ runtimeContext: input.runtimeContext
313
+ });
314
+ }
315
+
248
316
  function buildPassGraphSnapshot(
249
317
  passes: AnyPass[] | undefined
250
318
  ): NonNullable<ShaderCompilationRuntimeContext['passGraph']> {
@@ -667,10 +735,13 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
667
735
  try {
668
736
  const runtimeContext = buildShaderCompilationRuntimeContext(options);
669
737
  const convertLinearToSrgb = shouldConvertLinearToSrgb(options.outputColorSpace, format);
738
+ const fragmentTextureKeys = options.textureKeys.filter(
739
+ (key) => options.textureDefinitions[key]?.fragmentVisible !== false
740
+ );
670
741
  const builtShader = buildShaderSourceWithMap(
671
742
  options.fragmentWgsl,
672
743
  options.uniformLayout,
673
- options.textureKeys,
744
+ fragmentTextureKeys,
674
745
  {
675
746
  convertLinearToSrgb,
676
747
  fragmentLineMap: options.fragmentLineMap,
@@ -702,13 +773,18 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
702
773
  const storageBufferDefinitions = options.storageBufferDefinitions ?? {};
703
774
  const storageTextureKeys = options.storageTextureKeys ?? [];
704
775
  const storageTextureKeySet = new Set(storageTextureKeys);
705
- const textureBindings = options.textureKeys.map((key, index): RuntimeTextureBinding => {
776
+ const fragmentTextureIndexByKey = new Map(
777
+ fragmentTextureKeys.map((key, index) => [key, index] as const)
778
+ );
779
+ const textureBindings = options.textureKeys.map((key): RuntimeTextureBinding => {
706
780
  const config = normalizedTextureDefinitions[key];
707
781
  if (!config) {
708
782
  throw new Error(`Missing texture definition for "${key}"`);
709
783
  }
710
784
 
711
- const { samplerBinding, textureBinding } = getTextureBindings(index);
785
+ const fragmentTextureIndex = fragmentTextureIndexByKey.get(key);
786
+ const fragmentVisible = fragmentTextureIndex !== undefined;
787
+ const { samplerBinding, textureBinding } = getTextureBindings(fragmentTextureIndex ?? 0);
712
788
  const sampler = device.createSampler({
713
789
  magFilter: config.filter,
714
790
  minFilter: config.filter,
@@ -731,6 +807,7 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
731
807
  key,
732
808
  samplerBinding,
733
809
  textureBinding,
810
+ fragmentVisible,
734
811
  sampler,
735
812
  fallbackTexture,
736
813
  fallbackView,
@@ -779,9 +856,49 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
779
856
 
780
857
  return runtimeBinding;
781
858
  });
859
+ const textureBindingByKey = new Map(textureBindings.map((binding) => [binding.key, binding]));
860
+ const fragmentTextureBindings = textureBindings.filter((binding) => binding.fragmentVisible);
861
+
862
+ const computeStorageBufferLayoutEntries: GPUBindGroupLayoutEntry[] = storageBufferKeys.map(
863
+ (key, index) => {
864
+ const def = storageBufferDefinitions[key];
865
+ const access = def?.access ?? 'read-write';
866
+ const bufferType: GPUBufferBindingType =
867
+ access === 'read' ? 'read-only-storage' : 'storage';
868
+ return {
869
+ binding: index,
870
+ visibility: GPUShaderStage.COMPUTE,
871
+ buffer: { type: bufferType }
872
+ };
873
+ }
874
+ );
875
+ const computeStorageBufferTopologyKey = storageBufferKeys
876
+ .map((key) => `${key}:${storageBufferDefinitions[key]?.access ?? 'read-write'}`)
877
+ .join('|');
878
+
879
+ const computeStorageTextureLayoutEntries: GPUBindGroupLayoutEntry[] = storageTextureKeys.map(
880
+ (key, index) => {
881
+ const config = normalizedTextureDefinitions[key];
882
+ return {
883
+ binding: index,
884
+ visibility: GPUShaderStage.COMPUTE,
885
+ storageTexture: {
886
+ access: 'write-only' as GPUStorageTextureAccess,
887
+ format: (config?.format ?? 'rgba8unorm') as GPUTextureFormat,
888
+ viewDimension: '2d'
889
+ }
890
+ };
891
+ }
892
+ );
893
+ const computeStorageTextureTopologyKey = storageTextureKeys
894
+ .map((key) => `${key}:${normalizedTextureDefinitions[key]?.format ?? 'rgba8unorm'}`)
895
+ .join('|');
896
+
897
+ const computeStorageBufferBindGroupCache = createComputeStorageBindGroupCache(device);
898
+ const computeStorageTextureBindGroupCache = createComputeStorageBindGroupCache(device);
782
899
 
783
900
  const bindGroupLayout = device.createBindGroupLayout({
784
- entries: createBindGroupLayoutEntries(textureBindings)
901
+ entries: createBindGroupLayoutEntries(fragmentTextureBindings)
785
902
  });
786
903
  const fragmentStorageBindGroupLayout =
787
904
  storageBufferKeys.length > 0
@@ -977,7 +1094,9 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
977
1094
  viewA: textureA.createView(),
978
1095
  textureB,
979
1096
  viewB: textureB.createView(),
980
- bindGroupLayout
1097
+ bindGroupLayout,
1098
+ readAWriteBBindGroup: null,
1099
+ readBWriteABindGroup: null
981
1100
  };
982
1101
  pingPongTexturePairs.set(target, pair);
983
1102
  return pair;
@@ -1028,8 +1147,8 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1028
1147
  const isPingPongPipeline = Boolean(
1029
1148
  buildOptions.pingPongTarget && buildOptions.pingPongFormat
1030
1149
  );
1031
- const shaderCode = isPingPongPipeline
1032
- ? buildPingPongComputeShaderSource({
1150
+ const builtComputeShader = isPingPongPipeline
1151
+ ? buildPingPongComputeShaderSourceWithMap({
1033
1152
  compute: buildOptions.computeSource,
1034
1153
  uniformLayout: options.uniformLayout,
1035
1154
  storageBufferKeys,
@@ -1037,7 +1156,7 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1037
1156
  target: buildOptions.pingPongTarget!,
1038
1157
  targetFormat: buildOptions.pingPongFormat!
1039
1158
  })
1040
- : buildComputeShaderSource({
1159
+ : buildComputeShaderSourceWithMap({
1041
1160
  compute: buildOptions.computeSource,
1042
1161
  uniformLayout: options.uniformLayout,
1043
1162
  storageBufferKeys,
@@ -1046,7 +1165,7 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1046
1165
  storageTextureDefinitions: storageTextureDefs
1047
1166
  });
1048
1167
 
1049
- const computeShaderModule = device.createShaderModule({ code: shaderCode });
1168
+ const computeShaderModule = device.createShaderModule({ code: builtComputeShader.code });
1050
1169
  const workgroupSize = extractWorkgroupSize(buildOptions.computeSource);
1051
1170
 
1052
1171
  // Compute bind group layout: group(0)=uniforms, group(1)=storage buffers, group(2)=storage textures
@@ -1065,20 +1184,9 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1065
1184
  ]
1066
1185
  });
1067
1186
 
1068
- const storageBGLEntries: GPUBindGroupLayoutEntry[] = storageBufferKeys.map((key, index) => {
1069
- const def = storageBufferDefinitions[key];
1070
- const access = def?.access ?? 'read-write';
1071
- const bufferType: GPUBufferBindingType =
1072
- access === 'read' ? 'read-only-storage' : 'storage';
1073
- return {
1074
- binding: index,
1075
- visibility: GPUShaderStage.COMPUTE,
1076
- buffer: { type: bufferType }
1077
- };
1078
- });
1079
1187
  const storageBGL =
1080
- storageBGLEntries.length > 0
1081
- ? device.createBindGroupLayout({ entries: storageBGLEntries })
1188
+ computeStorageBufferLayoutEntries.length > 0
1189
+ ? device.createBindGroupLayout({ entries: computeStorageBufferLayoutEntries })
1082
1190
  : null;
1083
1191
 
1084
1192
  const storageTextureBGLEntries: GPUBindGroupLayoutEntry[] = isPingPongPipeline
@@ -1104,18 +1212,7 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1104
1212
  }
1105
1213
  }
1106
1214
  ]
1107
- : storageTextureKeys.map((key, index) => {
1108
- const texDef = options.textureDefinitions[key];
1109
- return {
1110
- binding: index,
1111
- visibility: GPUShaderStage.COMPUTE,
1112
- storageTexture: {
1113
- access: 'write-only' as GPUStorageTextureAccess,
1114
- format: (texDef?.format ?? 'rgba8unorm') as GPUTextureFormat,
1115
- viewDimension: '2d'
1116
- }
1117
- };
1118
- });
1215
+ : computeStorageTextureLayoutEntries;
1119
1216
  const storageTextureBGL =
1120
1217
  storageTextureBGLEntries.length > 0
1121
1218
  ? device.createBindGroupLayout({ entries: storageTextureBGLEntries })
@@ -1133,13 +1230,23 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1133
1230
  }
1134
1231
 
1135
1232
  const computePipelineLayout = device.createPipelineLayout({ bindGroupLayouts });
1136
- const pipeline = device.createComputePipeline({
1137
- layout: computePipelineLayout,
1138
- compute: {
1139
- module: computeShaderModule,
1140
- entryPoint: 'compute'
1141
- }
1142
- });
1233
+ let pipeline: GPUComputePipeline;
1234
+ try {
1235
+ pipeline = device.createComputePipeline({
1236
+ layout: computePipelineLayout,
1237
+ compute: {
1238
+ module: computeShaderModule,
1239
+ entryPoint: 'compute'
1240
+ }
1241
+ });
1242
+ } catch (error) {
1243
+ throw toComputeCompilationError({
1244
+ error,
1245
+ lineMap: builtComputeShader.lineMap,
1246
+ computeSource: buildOptions.computeSource,
1247
+ runtimeContext
1248
+ });
1249
+ }
1143
1250
 
1144
1251
  // Build uniform bind group for compute (group 0)
1145
1252
  const computeUniformBindGroup = device.createBindGroup({
@@ -1162,63 +1269,49 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1162
1269
 
1163
1270
  // Helper to get the storage bind group for dispatch
1164
1271
  const getComputeStorageBindGroup = (): GPUBindGroup | null => {
1165
- if (storageBufferKeys.length === 0) {
1272
+ if (computeStorageBufferLayoutEntries.length === 0) {
1166
1273
  return null;
1167
1274
  }
1168
- // Rebuild bind group with current storage buffers
1169
- const storageBGLEntries: GPUBindGroupLayoutEntry[] = storageBufferKeys.map((key, index) => {
1170
- const def = storageBufferDefinitions[key];
1171
- const access = def?.access ?? 'read-write';
1172
- const bufferType: GPUBufferBindingType =
1173
- access === 'read' ? 'read-only-storage' : 'storage';
1174
- return {
1175
- binding: index,
1176
- visibility: GPUShaderStage.COMPUTE,
1177
- buffer: { type: bufferType }
1178
- };
1179
- });
1180
- const storageBGL = device.createBindGroupLayout({ entries: storageBGLEntries });
1181
- const storageEntries: GPUBindGroupEntry[] = storageBufferKeys.map((key, index) => {
1275
+ const resources: GPUBuffer[] = storageBufferKeys.map((key) => {
1182
1276
  const buffer = storageBufferMap.get(key);
1183
1277
  if (!buffer) {
1184
1278
  throw new Error(`Storage buffer "${key}" not allocated.`);
1185
1279
  }
1280
+ return buffer;
1281
+ });
1282
+ const storageEntries: GPUBindGroupEntry[] = resources.map((buffer, index) => {
1186
1283
  return { binding: index, resource: { buffer } };
1187
1284
  });
1188
- return device.createBindGroup({
1189
- layout: storageBGL,
1190
- entries: storageEntries
1285
+ return computeStorageBufferBindGroupCache.getOrCreate({
1286
+ topologyKey: computeStorageBufferTopologyKey,
1287
+ layoutEntries: computeStorageBufferLayoutEntries,
1288
+ bindGroupEntries: storageEntries,
1289
+ resourceRefs: resources
1191
1290
  });
1192
1291
  };
1193
1292
 
1194
1293
  // Helper to get the storage texture bind group for compute dispatch (group 2)
1195
1294
  const getComputeStorageTextureBindGroup = (): GPUBindGroup | null => {
1196
- if (storageTextureKeys.length === 0) {
1295
+ if (computeStorageTextureLayoutEntries.length === 0) {
1197
1296
  return null;
1198
1297
  }
1199
- const entries: GPUBindGroupLayoutEntry[] = storageTextureKeys.map((key, index) => {
1200
- const texDef = options.textureDefinitions[key];
1201
- return {
1202
- binding: index,
1203
- visibility: GPUShaderStage.COMPUTE,
1204
- storageTexture: {
1205
- access: 'write-only' as GPUStorageTextureAccess,
1206
- format: (texDef?.format ?? 'rgba8unorm') as GPUTextureFormat,
1207
- viewDimension: '2d' as GPUTextureViewDimension
1208
- }
1209
- };
1210
- });
1211
- const bgl = device.createBindGroupLayout({ entries });
1212
-
1213
- const bgEntries: GPUBindGroupEntry[] = storageTextureKeys.map((key, index) => {
1214
- const binding = textureBindings.find((b) => b.key === key);
1298
+ const resources: GPUTextureView[] = storageTextureKeys.map((key) => {
1299
+ const binding = textureBindingByKey.get(key);
1215
1300
  if (!binding || !binding.texture) {
1216
1301
  throw new Error(`Storage texture "${key}" not allocated.`);
1217
1302
  }
1218
- return { binding: index, resource: binding.view };
1303
+ return binding.view;
1304
+ });
1305
+ const bgEntries: GPUBindGroupEntry[] = resources.map((view, index) => {
1306
+ return { binding: index, resource: view };
1219
1307
  });
1220
1308
 
1221
- return device.createBindGroup({ layout: bgl, entries: bgEntries });
1309
+ return computeStorageTextureBindGroupCache.getOrCreate({
1310
+ topologyKey: computeStorageTextureTopologyKey,
1311
+ layoutEntries: computeStorageTextureLayoutEntries,
1312
+ bindGroupEntries: bgEntries,
1313
+ resourceRefs: resources
1314
+ });
1222
1315
  };
1223
1316
 
1224
1317
  // Helper to get ping-pong storage texture bind group for compute dispatch (group 2)
@@ -1227,15 +1320,28 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1227
1320
  readFromA: boolean
1228
1321
  ): GPUBindGroup => {
1229
1322
  const pair = ensurePingPongTexturePair(target);
1230
- const readView = readFromA ? pair.viewA : pair.viewB;
1231
- const writeView = readFromA ? pair.viewB : pair.viewA;
1232
- return device.createBindGroup({
1233
- layout: pair.bindGroupLayout,
1234
- entries: [
1235
- { binding: 0, resource: readView },
1236
- { binding: 1, resource: writeView }
1237
- ]
1238
- });
1323
+ if (readFromA) {
1324
+ if (!pair.readAWriteBBindGroup) {
1325
+ pair.readAWriteBBindGroup = device.createBindGroup({
1326
+ layout: pair.bindGroupLayout,
1327
+ entries: [
1328
+ { binding: 0, resource: pair.viewA },
1329
+ { binding: 1, resource: pair.viewB }
1330
+ ]
1331
+ });
1332
+ }
1333
+ return pair.readAWriteBBindGroup;
1334
+ }
1335
+ if (!pair.readBWriteABindGroup) {
1336
+ pair.readBWriteABindGroup = device.createBindGroup({
1337
+ layout: pair.bindGroupLayout,
1338
+ entries: [
1339
+ { binding: 0, resource: pair.viewB },
1340
+ { binding: 1, resource: pair.viewA }
1341
+ ]
1342
+ });
1343
+ }
1344
+ return pair.readBWriteABindGroup;
1239
1345
  };
1240
1346
 
1241
1347
  const frameBuffer = device.createBuffer({
@@ -1267,7 +1373,7 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1267
1373
  { binding: UNIFORM_BINDING, resource: { buffer: uniformBuffer } }
1268
1374
  ];
1269
1375
 
1270
- for (const binding of textureBindings) {
1376
+ for (const binding of fragmentTextureBindings) {
1271
1377
  entries.push({
1272
1378
  binding: binding.samplerBinding,
1273
1379
  resource: binding.sampler
@@ -1839,7 +1945,7 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1839
1945
  if (storageTextureKeySet.has(binding.key)) continue;
1840
1946
  const nextTexture =
1841
1947
  textures[binding.key] ?? normalizedTextureDefinitions[binding.key]?.source ?? null;
1842
- if (updateTextureBinding(binding, nextTexture, renderMode)) {
1948
+ if (updateTextureBinding(binding, nextTexture, renderMode) && binding.fragmentVisible) {
1843
1949
  bindGroupDirty = true;
1844
1950
  }
1845
1951
  }
@@ -54,6 +54,8 @@ function getRendererRetryDelayMs(attempt: number): number {
54
54
  return Math.min(8000, 250 * 2 ** Math.max(0, attempt - 1));
55
55
  }
56
56
 
57
+ const ERROR_CLEAR_GRACE_MS = 750;
58
+
57
59
  export function createMotionGPURuntimeLoop(
58
60
  options: MotionGPURuntimeLoopOptions
59
61
  ): MotionGPURuntimeLoop {
@@ -90,6 +92,16 @@ export function createMotionGPURuntimeLoop(
90
92
  let shouldContinueAfterFrame = false;
91
93
  let activeErrorKey: string | null = null;
92
94
  let errorHistory: MotionGPUErrorReport[] = [];
95
+ let errorClearReadyAtMs = 0;
96
+ let lastFrameTimestampMs = performance.now();
97
+
98
+ const resolveNowMs = (nowMs?: number): number => {
99
+ if (typeof nowMs === 'number' && Number.isFinite(nowMs)) {
100
+ return nowMs;
101
+ }
102
+
103
+ return lastFrameTimestampMs;
104
+ };
93
105
 
94
106
  const getHistoryLimit = (): number => {
95
107
  const value = options.getErrorHistoryLimit?.() ?? 0;
@@ -129,12 +141,13 @@ export function createMotionGPURuntimeLoop(
129
141
  return;
130
142
  }
131
143
 
132
- errorHistory = errorHistory.slice(errorHistory.length - limit);
144
+ errorHistory.splice(0, errorHistory.length - limit);
133
145
  publishErrorHistory();
134
146
  };
135
147
 
136
- const setError = (error: unknown, phase: MotionGPUErrorPhase): void => {
148
+ const setError = (error: unknown, phase: MotionGPUErrorPhase, nowMs?: number): void => {
137
149
  const report = toMotionGPUErrorReport(error, phase);
150
+ errorClearReadyAtMs = resolveNowMs(nowMs) + ERROR_CLEAR_GRACE_MS;
138
151
  const reportKey = JSON.stringify({
139
152
  phase: report.phase,
140
153
  title: report.title,
@@ -147,9 +160,9 @@ export function createMotionGPURuntimeLoop(
147
160
  activeErrorKey = reportKey;
148
161
  const historyLimit = getHistoryLimit();
149
162
  if (historyLimit > 0) {
150
- errorHistory = [...errorHistory, report];
163
+ errorHistory.push(report);
151
164
  if (errorHistory.length > historyLimit) {
152
- errorHistory = errorHistory.slice(errorHistory.length - historyLimit);
165
+ errorHistory.splice(0, errorHistory.length - historyLimit);
153
166
  }
154
167
  publishErrorHistory();
155
168
  }
@@ -166,12 +179,16 @@ export function createMotionGPURuntimeLoop(
166
179
  }
167
180
  };
168
181
 
169
- const clearError = (): void => {
182
+ const maybeClearError = (nowMs?: number): void => {
170
183
  if (activeErrorKey === null) {
171
184
  return;
172
185
  }
186
+ if (resolveNowMs(nowMs) < errorClearReadyAtMs) {
187
+ return;
188
+ }
173
189
 
174
190
  activeErrorKey = null;
191
+ errorClearReadyAtMs = 0;
175
192
  options.reportError(null);
176
193
  };
177
194
 
@@ -200,13 +217,13 @@ export function createMotionGPURuntimeLoop(
200
217
  const resetRuntimeMaps = (): void => {
201
218
  for (const key of Object.keys(runtimeUniforms)) {
202
219
  if (!uniformKeySet.has(key)) {
203
- Reflect.deleteProperty(runtimeUniforms, key);
220
+ delete runtimeUniforms[key];
204
221
  }
205
222
  }
206
223
 
207
224
  for (const key of Object.keys(runtimeTextures)) {
208
225
  if (!textureKeySet.has(key)) {
209
- Reflect.deleteProperty(runtimeTextures, key);
226
+ delete runtimeTextures[key];
210
227
  }
211
228
  }
212
229
  };
@@ -214,13 +231,13 @@ export function createMotionGPURuntimeLoop(
214
231
  const resetRenderPayloadMaps = (): void => {
215
232
  for (const key of Object.keys(renderUniforms)) {
216
233
  if (!uniformKeySet.has(key)) {
217
- Reflect.deleteProperty(renderUniforms, key);
234
+ delete renderUniforms[key];
218
235
  }
219
236
  }
220
237
 
221
238
  for (const key of Object.keys(renderTextures)) {
222
239
  if (!textureKeySet.has(key)) {
223
- Reflect.deleteProperty(renderTextures, key);
240
+ delete renderTextures[key];
224
241
  }
225
242
  }
226
243
  };
@@ -345,13 +362,14 @@ export function createMotionGPURuntimeLoop(
345
362
  if (isDisposed) {
346
363
  return;
347
364
  }
365
+ lastFrameTimestampMs = timestamp;
348
366
  syncErrorHistory();
349
367
 
350
368
  let materialState: ResolvedMaterial;
351
369
  try {
352
370
  materialState = resolveActiveMaterial();
353
371
  } catch (error) {
354
- setError(error, 'initialization');
372
+ setError(error, 'initialization', timestamp);
355
373
  scheduleFrame();
356
374
  return;
357
375
  }
@@ -418,7 +436,7 @@ export function createMotionGPURuntimeLoop(
418
436
  failedRendererSignature = null;
419
437
  failedRendererAttempts = 0;
420
438
  nextRendererRetryAt = 0;
421
- clearError();
439
+ maybeClearError(performance.now());
422
440
  } catch (error) {
423
441
  failedRendererSignature = rendererSignature;
424
442
  failedRendererAttempts += 1;
@@ -490,15 +508,18 @@ export function createMotionGPURuntimeLoop(
490
508
  uniforms: renderUniforms,
491
509
  textures: renderTextures,
492
510
  canvasSize,
493
- ...(pendingStorageWrites.length > 0
494
- ? { pendingStorageWrites: pendingStorageWrites.splice(0) }
495
- : {})
511
+ pendingStorageWrites: pendingStorageWrites.length > 0 ? pendingStorageWrites : undefined
496
512
  });
513
+ // Clear in-place after synchronous render() completes — avoids
514
+ // the splice(0) copy and eliminates the conditional spread object.
515
+ if (pendingStorageWrites.length > 0) {
516
+ pendingStorageWrites.length = 0;
517
+ }
497
518
  }
498
519
 
499
- clearError();
520
+ maybeClearError(timestamp);
500
521
  } catch (error) {
501
- setError(error, 'render');
522
+ setError(error, 'render', timestamp);
502
523
  } finally {
503
524
  registry.endFrame();
504
525
  }