@motion-core/motion-gpu 0.4.2 → 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 (121) hide show
  1. package/README.md +99 -0
  2. package/dist/advanced.js +3 -1
  3. package/dist/core/advanced.js +3 -1
  4. package/dist/core/compute-bindgroup-cache.d.ts +13 -0
  5. package/dist/core/compute-bindgroup-cache.d.ts.map +1 -0
  6. package/dist/core/compute-bindgroup-cache.js +45 -0
  7. package/dist/core/compute-bindgroup-cache.js.map +1 -0
  8. package/dist/core/compute-shader.d.ts +135 -0
  9. package/dist/core/compute-shader.d.ts.map +1 -0
  10. package/dist/core/compute-shader.js +238 -0
  11. package/dist/core/compute-shader.js.map +1 -0
  12. package/dist/core/error-diagnostics.d.ts +8 -1
  13. package/dist/core/error-diagnostics.d.ts.map +1 -1
  14. package/dist/core/error-diagnostics.js +7 -3
  15. package/dist/core/error-diagnostics.js.map +1 -1
  16. package/dist/core/error-report.d.ts +1 -1
  17. package/dist/core/error-report.d.ts.map +1 -1
  18. package/dist/core/error-report.js +82 -1
  19. package/dist/core/error-report.js.map +1 -1
  20. package/dist/core/frame-registry.d.ts.map +1 -1
  21. package/dist/core/frame-registry.js +1 -1
  22. package/dist/core/frame-registry.js.map +1 -1
  23. package/dist/core/index.d.ts +5 -2
  24. package/dist/core/index.d.ts.map +1 -1
  25. package/dist/core/index.js +3 -1
  26. package/dist/core/material-preprocess.d.ts.map +1 -1
  27. package/dist/core/material-preprocess.js +5 -3
  28. package/dist/core/material-preprocess.js.map +1 -1
  29. package/dist/core/material.d.ts +22 -6
  30. package/dist/core/material.d.ts.map +1 -1
  31. package/dist/core/material.js +32 -4
  32. package/dist/core/material.js.map +1 -1
  33. package/dist/core/render-graph.d.ts +7 -3
  34. package/dist/core/render-graph.d.ts.map +1 -1
  35. package/dist/core/render-graph.js +22 -6
  36. package/dist/core/render-graph.js.map +1 -1
  37. package/dist/core/renderer.d.ts.map +1 -1
  38. package/dist/core/renderer.js +489 -29
  39. package/dist/core/renderer.js.map +1 -1
  40. package/dist/core/runtime-loop.d.ts +2 -2
  41. package/dist/core/runtime-loop.d.ts.map +1 -1
  42. package/dist/core/runtime-loop.js +74 -14
  43. package/dist/core/runtime-loop.js.map +1 -1
  44. package/dist/core/shader.d.ts +16 -3
  45. package/dist/core/shader.d.ts.map +1 -1
  46. package/dist/core/shader.js +22 -2
  47. package/dist/core/shader.js.map +1 -1
  48. package/dist/core/storage-buffers.d.ts +37 -0
  49. package/dist/core/storage-buffers.d.ts.map +1 -0
  50. package/dist/core/storage-buffers.js +95 -0
  51. package/dist/core/storage-buffers.js.map +1 -0
  52. package/dist/core/texture-loader.d.ts.map +1 -1
  53. package/dist/core/texture-loader.js +4 -0
  54. package/dist/core/texture-loader.js.map +1 -1
  55. package/dist/core/textures.d.ts +16 -0
  56. package/dist/core/textures.d.ts.map +1 -1
  57. package/dist/core/textures.js +8 -2
  58. package/dist/core/textures.js.map +1 -1
  59. package/dist/core/types.d.ts +146 -4
  60. package/dist/core/types.d.ts.map +1 -1
  61. package/dist/index.js +3 -1
  62. package/dist/passes/ComputePass.d.ts +83 -0
  63. package/dist/passes/ComputePass.d.ts.map +1 -0
  64. package/dist/passes/ComputePass.js +92 -0
  65. package/dist/passes/ComputePass.js.map +1 -0
  66. package/dist/passes/PingPongComputePass.d.ts +104 -0
  67. package/dist/passes/PingPongComputePass.d.ts.map +1 -0
  68. package/dist/passes/PingPongComputePass.js +132 -0
  69. package/dist/passes/PingPongComputePass.js.map +1 -0
  70. package/dist/passes/ShaderPass.d.ts.map +1 -1
  71. package/dist/passes/ShaderPass.js +2 -1
  72. package/dist/passes/ShaderPass.js.map +1 -1
  73. package/dist/passes/index.d.ts +2 -0
  74. package/dist/passes/index.d.ts.map +1 -1
  75. package/dist/passes/index.js +3 -1
  76. package/dist/react/FragCanvas.d.ts +2 -2
  77. package/dist/react/FragCanvas.d.ts.map +1 -1
  78. package/dist/react/FragCanvas.js.map +1 -1
  79. package/dist/react/MotionGPUErrorOverlay.d.ts.map +1 -1
  80. package/dist/react/MotionGPUErrorOverlay.js +123 -20
  81. package/dist/react/MotionGPUErrorOverlay.js.map +1 -1
  82. package/dist/react/advanced.js +3 -1
  83. package/dist/react/index.d.ts +5 -2
  84. package/dist/react/index.d.ts.map +1 -1
  85. package/dist/react/index.js +3 -1
  86. package/dist/svelte/FragCanvas.svelte +2 -2
  87. package/dist/svelte/FragCanvas.svelte.d.ts +2 -2
  88. package/dist/svelte/FragCanvas.svelte.d.ts.map +1 -1
  89. package/dist/svelte/MotionGPUErrorOverlay.svelte +137 -7
  90. package/dist/svelte/MotionGPUErrorOverlay.svelte.d.ts.map +1 -1
  91. package/dist/svelte/advanced.js +3 -1
  92. package/dist/svelte/index.d.ts +5 -2
  93. package/dist/svelte/index.d.ts.map +1 -1
  94. package/dist/svelte/index.js +3 -1
  95. package/package.json +1 -1
  96. package/src/lib/core/compute-bindgroup-cache.ts +73 -0
  97. package/src/lib/core/compute-shader.ts +412 -0
  98. package/src/lib/core/error-diagnostics.ts +29 -4
  99. package/src/lib/core/error-report.ts +155 -1
  100. package/src/lib/core/frame-registry.ts +2 -1
  101. package/src/lib/core/index.ts +18 -1
  102. package/src/lib/core/material-preprocess.ts +17 -6
  103. package/src/lib/core/material.ts +103 -21
  104. package/src/lib/core/render-graph.ts +39 -9
  105. package/src/lib/core/renderer.ts +768 -48
  106. package/src/lib/core/runtime-loop.ts +116 -16
  107. package/src/lib/core/shader.ts +58 -4
  108. package/src/lib/core/storage-buffers.ts +142 -0
  109. package/src/lib/core/texture-loader.ts +6 -0
  110. package/src/lib/core/textures.ts +29 -2
  111. package/src/lib/core/types.ts +165 -4
  112. package/src/lib/passes/ComputePass.ts +136 -0
  113. package/src/lib/passes/PingPongComputePass.ts +180 -0
  114. package/src/lib/passes/ShaderPass.ts +2 -1
  115. package/src/lib/passes/index.ts +6 -0
  116. package/src/lib/react/FragCanvas.tsx +3 -3
  117. package/src/lib/react/MotionGPUErrorOverlay.tsx +137 -5
  118. package/src/lib/react/index.ts +18 -1
  119. package/src/lib/svelte/FragCanvas.svelte +2 -2
  120. package/src/lib/svelte/MotionGPUErrorOverlay.svelte +137 -7
  121. package/src/lib/svelte/index.ts +18 -1
@@ -17,7 +17,16 @@ import {
17
17
  toTextureData
18
18
  } from './textures.js';
19
19
  import { packUniformsInto } from './uniforms.js';
20
+ import {
21
+ buildComputeShaderSourceWithMap,
22
+ buildPingPongComputeShaderSourceWithMap,
23
+ extractWorkgroupSize,
24
+ storageTextureSampleScalarType
25
+ } from './compute-shader.js';
26
+ import { createComputeStorageBindGroupCache } from './compute-bindgroup-cache.js';
27
+ import { normalizeStorageBufferDefinition } from './storage-buffers.js';
20
28
  import type {
29
+ AnyPass,
21
30
  RenderPass,
22
31
  RenderPassInputSlot,
23
32
  RenderPassOutputSlot,
@@ -25,6 +34,8 @@ import type {
25
34
  RenderTarget,
26
35
  Renderer,
27
36
  RendererOptions,
37
+ StorageBufferAccess,
38
+ StorageBufferType,
28
39
  TextureSource,
29
40
  TextureUpdateMode,
30
41
  TextureValue
@@ -52,6 +63,7 @@ interface RuntimeTextureBinding {
52
63
  key: string;
53
64
  samplerBinding: number;
54
65
  textureBinding: number;
66
+ fragmentVisible: boolean;
55
67
  sampler: GPUSampler;
56
68
  fallbackTexture: GPUTexture;
57
69
  fallbackView: GPUTextureView;
@@ -86,11 +98,28 @@ interface RuntimeRenderTarget {
86
98
  format: GPUTextureFormat;
87
99
  }
88
100
 
101
+ /**
102
+ * Runtime ping-pong storage textures for a single logical target key.
103
+ */
104
+ interface PingPongTexturePair {
105
+ target: string;
106
+ format: GPUTextureFormat;
107
+ width: number;
108
+ height: number;
109
+ textureA: GPUTexture;
110
+ viewA: GPUTextureView;
111
+ textureB: GPUTexture;
112
+ viewB: GPUTextureView;
113
+ bindGroupLayout: GPUBindGroupLayout;
114
+ readAWriteBBindGroup: GPUBindGroup | null;
115
+ readBWriteABindGroup: GPUBindGroup | null;
116
+ }
117
+
89
118
  /**
90
119
  * Cached pass properties used to validate render-graph cache correctness.
91
120
  */
92
121
  interface RenderGraphPassSnapshot {
93
- pass: RenderPass;
122
+ pass: AnyPass;
94
123
  enabled: RenderPass['enabled'];
95
124
  needsSwap: RenderPass['needsSwap'];
96
125
  input: RenderPass['input'];
@@ -118,6 +147,19 @@ function getTextureBindings(index: number): {
118
147
  };
119
148
  }
120
149
 
150
+ /**
151
+ * Maps WGSL scalar texture type to WebGPU sampled texture bind-group sample type.
152
+ */
153
+ function toGpuTextureSampleType(type: 'f32' | 'u32' | 'i32'): GPUTextureSampleType {
154
+ if (type === 'u32') {
155
+ return 'uint';
156
+ }
157
+ if (type === 'i32') {
158
+ return 'sint';
159
+ }
160
+ return 'float';
161
+ }
162
+
121
163
  /**
122
164
  * Resizes canvas backing store to match client size and DPR.
123
165
  */
@@ -149,6 +191,7 @@ async function assertCompilation(
149
191
  options?: {
150
192
  lineMap?: ShaderLineMap;
151
193
  fragmentSource?: string;
194
+ computeSource?: string;
152
195
  includeSources?: Record<string, string>;
153
196
  defineBlockSource?: string;
154
197
  materialSource?: {
@@ -159,6 +202,8 @@ async function assertCompilation(
159
202
  functionName?: string;
160
203
  } | null;
161
204
  runtimeContext?: ShaderCompilationRuntimeContext;
205
+ errorPrefix?: string;
206
+ shaderStage?: 'fragment' | 'compute';
162
207
  }
163
208
  ): Promise<void> {
164
209
  const info = await module.getCompilationInfo();
@@ -189,11 +234,14 @@ async function assertCompilation(
189
234
  return `[${contextLabel.join(' | ')}] ${diagnostic.message}`;
190
235
  })
191
236
  .join('\n');
192
- 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}`);
193
239
  throw attachShaderCompilationDiagnostics(error, {
194
240
  kind: 'shader-compilation',
241
+ ...(options?.shaderStage !== undefined ? { shaderStage: options.shaderStage } : {}),
195
242
  diagnostics,
196
243
  fragmentSource: options?.fragmentSource ?? '',
244
+ ...(options?.computeSource !== undefined ? { computeSource: options.computeSource } : {}),
197
245
  includeSources: options?.includeSources ?? {},
198
246
  ...(options?.defineBlockSource !== undefined
199
247
  ? { defineBlockSource: options.defineBlockSource }
@@ -207,8 +255,66 @@ function toSortedUniqueStrings(values: string[]): string[] {
207
255
  return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
208
256
  }
209
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
+
210
316
  function buildPassGraphSnapshot(
211
- passes: RenderPass[] | undefined
317
+ passes: AnyPass[] | undefined
212
318
  ): NonNullable<ShaderCompilationRuntimeContext['passGraph']> {
213
319
  const declaredPasses = passes ?? [];
214
320
  let enabledPassCount = 0;
@@ -221,9 +327,13 @@ function buildPassGraphSnapshot(
221
327
  }
222
328
 
223
329
  enabledPassCount += 1;
224
- const needsSwap = pass.needsSwap ?? true;
225
- const input = pass.input ?? 'source';
226
- const output = pass.output ?? (needsSwap ? 'target' : 'source');
330
+ if ('isCompute' in pass && (pass as { isCompute?: boolean }).isCompute === true) {
331
+ continue;
332
+ }
333
+ const rp = pass as RenderPass;
334
+ const needsSwap = rp.needsSwap ?? true;
335
+ const input = rp.input ?? 'source';
336
+ const output = rp.output ?? (needsSwap ? 'target' : 'source');
227
337
  inputs.push(input);
228
338
  outputs.push(output);
229
339
  }
@@ -625,13 +735,22 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
625
735
  try {
626
736
  const runtimeContext = buildShaderCompilationRuntimeContext(options);
627
737
  const convertLinearToSrgb = shouldConvertLinearToSrgb(options.outputColorSpace, format);
738
+ const fragmentTextureKeys = options.textureKeys.filter(
739
+ (key) => options.textureDefinitions[key]?.fragmentVisible !== false
740
+ );
628
741
  const builtShader = buildShaderSourceWithMap(
629
742
  options.fragmentWgsl,
630
743
  options.uniformLayout,
631
- options.textureKeys,
744
+ fragmentTextureKeys,
632
745
  {
633
746
  convertLinearToSrgb,
634
- fragmentLineMap: options.fragmentLineMap
747
+ fragmentLineMap: options.fragmentLineMap,
748
+ ...(options.storageBufferKeys !== undefined
749
+ ? { storageBufferKeys: options.storageBufferKeys }
750
+ : {}),
751
+ ...(options.storageBufferDefinitions !== undefined
752
+ ? { storageBufferDefinitions: options.storageBufferDefinitions }
753
+ : {})
635
754
  }
636
755
  );
637
756
  const shaderModule = device.createShaderModule({ code: builtShader.code });
@@ -650,13 +769,22 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
650
769
  options.textureDefinitions,
651
770
  options.textureKeys
652
771
  );
653
- const textureBindings = options.textureKeys.map((key, index): RuntimeTextureBinding => {
772
+ const storageBufferKeys = options.storageBufferKeys ?? [];
773
+ const storageBufferDefinitions = options.storageBufferDefinitions ?? {};
774
+ const storageTextureKeys = options.storageTextureKeys ?? [];
775
+ const storageTextureKeySet = new Set(storageTextureKeys);
776
+ const fragmentTextureIndexByKey = new Map(
777
+ fragmentTextureKeys.map((key, index) => [key, index] as const)
778
+ );
779
+ const textureBindings = options.textureKeys.map((key): RuntimeTextureBinding => {
654
780
  const config = normalizedTextureDefinitions[key];
655
781
  if (!config) {
656
782
  throw new Error(`Missing texture definition for "${key}"`);
657
783
  }
658
784
 
659
- const { samplerBinding, textureBinding } = getTextureBindings(index);
785
+ const fragmentTextureIndex = fragmentTextureIndexByKey.get(key);
786
+ const fragmentVisible = fragmentTextureIndex !== undefined;
787
+ const { samplerBinding, textureBinding } = getTextureBindings(fragmentTextureIndex ?? 0);
660
788
  const sampler = device.createSampler({
661
789
  magFilter: config.filter,
662
790
  minFilter: config.filter,
@@ -665,7 +793,11 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
665
793
  addressModeV: config.addressModeV,
666
794
  maxAnisotropy: config.filter === 'linear' ? config.anisotropy : 1
667
795
  });
668
- const fallbackTexture = createFallbackTexture(device, config.format);
796
+ // Storage textures use a safe fallback format — the fallback is never
797
+ // sampled because storage textures are eagerly allocated with their
798
+ // real format/dimensions. Non-storage textures use their own format.
799
+ const fallbackFormat = config.storage ? 'rgba8unorm' : config.format;
800
+ const fallbackTexture = createFallbackTexture(device, fallbackFormat);
669
801
  registerInitializationCleanup(() => {
670
802
  fallbackTexture.destroy();
671
803
  });
@@ -675,6 +807,7 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
675
807
  key,
676
808
  samplerBinding,
677
809
  textureBinding,
810
+ fragmentVisible,
678
811
  sampler,
679
812
  fallbackTexture,
680
813
  fallbackView,
@@ -701,14 +834,86 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
701
834
  runtimeBinding.defaultUpdate = config.update;
702
835
  }
703
836
 
837
+ // Storage textures: eagerly create GPU texture with explicit dimensions
838
+ if (config.storage && config.width && config.height) {
839
+ const storageUsage =
840
+ GPUTextureUsage.TEXTURE_BINDING |
841
+ GPUTextureUsage.STORAGE_BINDING |
842
+ GPUTextureUsage.COPY_DST;
843
+ const storageTexture = device.createTexture({
844
+ size: { width: config.width, height: config.height, depthOrArrayLayers: 1 },
845
+ format: config.format,
846
+ usage: storageUsage
847
+ });
848
+ registerInitializationCleanup(() => {
849
+ storageTexture.destroy();
850
+ });
851
+ runtimeBinding.texture = storageTexture as unknown as GPUTexture;
852
+ runtimeBinding.view = storageTexture.createView();
853
+ runtimeBinding.width = config.width;
854
+ runtimeBinding.height = config.height;
855
+ }
856
+
704
857
  return runtimeBinding;
705
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);
706
899
 
707
900
  const bindGroupLayout = device.createBindGroupLayout({
708
- entries: createBindGroupLayoutEntries(textureBindings)
901
+ entries: createBindGroupLayoutEntries(fragmentTextureBindings)
709
902
  });
903
+ const fragmentStorageBindGroupLayout =
904
+ storageBufferKeys.length > 0
905
+ ? device.createBindGroupLayout({
906
+ entries: storageBufferKeys.map((_, index) => ({
907
+ binding: index,
908
+ visibility: GPUShaderStage.FRAGMENT,
909
+ buffer: { type: 'read-only-storage' as GPUBufferBindingType }
910
+ }))
911
+ })
912
+ : null;
710
913
  const pipelineLayout = device.createPipelineLayout({
711
- bindGroupLayouts: [bindGroupLayout]
914
+ bindGroupLayouts: fragmentStorageBindGroupLayout
915
+ ? [bindGroupLayout, fragmentStorageBindGroupLayout]
916
+ : [bindGroupLayout]
712
917
  });
713
918
 
714
919
  const pipeline = device.createRenderPipeline({
@@ -773,6 +978,372 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
773
978
  });
774
979
  let blitBindGroupByView = new WeakMap<GPUTextureView, GPUBindGroup>();
775
980
 
981
+ // ── Storage buffer allocation ────────────────────────────────────────
982
+ const storageBufferMap = new Map<string, GPUBuffer>();
983
+ const pingPongTexturePairs = new Map<string, PingPongTexturePair>();
984
+
985
+ for (const key of storageBufferKeys) {
986
+ const definition = storageBufferDefinitions[key];
987
+ if (!definition) {
988
+ continue;
989
+ }
990
+ const normalized = normalizeStorageBufferDefinition(definition);
991
+ const buffer = device.createBuffer({
992
+ size: normalized.size,
993
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC
994
+ });
995
+ registerInitializationCleanup(() => {
996
+ buffer.destroy();
997
+ });
998
+ if (definition.initialData) {
999
+ const data = definition.initialData;
1000
+ device.queue.writeBuffer(
1001
+ buffer,
1002
+ 0,
1003
+ data.buffer as ArrayBuffer,
1004
+ data.byteOffset,
1005
+ data.byteLength
1006
+ );
1007
+ }
1008
+ storageBufferMap.set(key, buffer);
1009
+ }
1010
+ const fragmentStorageBindGroup =
1011
+ fragmentStorageBindGroupLayout && storageBufferKeys.length > 0
1012
+ ? device.createBindGroup({
1013
+ layout: fragmentStorageBindGroupLayout,
1014
+ entries: storageBufferKeys.map((key, index) => {
1015
+ const buffer = storageBufferMap.get(key);
1016
+ if (!buffer) {
1017
+ throw new Error(`Storage buffer "${key}" not allocated.`);
1018
+ }
1019
+ return { binding: index, resource: { buffer } };
1020
+ })
1021
+ })
1022
+ : null;
1023
+
1024
+ const ensurePingPongTexturePair = (target: string): PingPongTexturePair => {
1025
+ const existing = pingPongTexturePairs.get(target);
1026
+ if (existing) {
1027
+ return existing;
1028
+ }
1029
+
1030
+ const config = normalizedTextureDefinitions[target];
1031
+ if (!config || !config.storage) {
1032
+ throw new Error(
1033
+ `PingPongComputePass target "${target}" must reference a texture declared with storage:true.`
1034
+ );
1035
+ }
1036
+ if (!config.width || !config.height) {
1037
+ throw new Error(
1038
+ `PingPongComputePass target "${target}" requires explicit texture width and height.`
1039
+ );
1040
+ }
1041
+
1042
+ const usage =
1043
+ GPUTextureUsage.TEXTURE_BINDING |
1044
+ GPUTextureUsage.STORAGE_BINDING |
1045
+ GPUTextureUsage.COPY_DST;
1046
+ const textureA = device.createTexture({
1047
+ size: { width: config.width, height: config.height, depthOrArrayLayers: 1 },
1048
+ format: config.format,
1049
+ usage
1050
+ });
1051
+ const textureB = device.createTexture({
1052
+ size: { width: config.width, height: config.height, depthOrArrayLayers: 1 },
1053
+ format: config.format,
1054
+ usage
1055
+ });
1056
+ registerInitializationCleanup(() => {
1057
+ textureA.destroy();
1058
+ });
1059
+ registerInitializationCleanup(() => {
1060
+ textureB.destroy();
1061
+ });
1062
+
1063
+ const sampleScalarType = storageTextureSampleScalarType(config.format);
1064
+ const sampleType = toGpuTextureSampleType(sampleScalarType);
1065
+ const bindGroupLayout = device.createBindGroupLayout({
1066
+ entries: [
1067
+ {
1068
+ binding: 0,
1069
+ visibility: GPUShaderStage.COMPUTE,
1070
+ texture: {
1071
+ sampleType,
1072
+ viewDimension: '2d',
1073
+ multisampled: false
1074
+ }
1075
+ },
1076
+ {
1077
+ binding: 1,
1078
+ visibility: GPUShaderStage.COMPUTE,
1079
+ storageTexture: {
1080
+ access: 'write-only' as GPUStorageTextureAccess,
1081
+ format: config.format as GPUTextureFormat,
1082
+ viewDimension: '2d'
1083
+ }
1084
+ }
1085
+ ]
1086
+ });
1087
+
1088
+ const pair: PingPongTexturePair = {
1089
+ target,
1090
+ format: config.format as GPUTextureFormat,
1091
+ width: config.width,
1092
+ height: config.height,
1093
+ textureA,
1094
+ viewA: textureA.createView(),
1095
+ textureB,
1096
+ viewB: textureB.createView(),
1097
+ bindGroupLayout,
1098
+ readAWriteBBindGroup: null,
1099
+ readBWriteABindGroup: null
1100
+ };
1101
+ pingPongTexturePairs.set(target, pair);
1102
+ return pair;
1103
+ };
1104
+
1105
+ // ── Compute pipeline setup ──────────────────────────────────────────
1106
+ interface ComputePipelineEntry {
1107
+ pipeline: GPUComputePipeline;
1108
+ bindGroup: GPUBindGroup;
1109
+ workgroupSize: [number, number, number];
1110
+ computeSource: string;
1111
+ }
1112
+ const computePipelineCache = new Map<string, ComputePipelineEntry>();
1113
+
1114
+ const buildComputePipelineEntry = (buildOptions: {
1115
+ computeSource: string;
1116
+ pingPongTarget?: string;
1117
+ pingPongFormat?: GPUTextureFormat;
1118
+ }): ComputePipelineEntry => {
1119
+ const cacheKey =
1120
+ buildOptions.pingPongTarget && buildOptions.pingPongFormat
1121
+ ? `pingpong:${buildOptions.pingPongTarget}:${buildOptions.pingPongFormat}:${buildOptions.computeSource}`
1122
+ : `compute:${buildOptions.computeSource}`;
1123
+ const cached = computePipelineCache.get(cacheKey);
1124
+ if (cached) {
1125
+ return cached;
1126
+ }
1127
+
1128
+ const storageBufferDefs: Record<
1129
+ string,
1130
+ { type: StorageBufferType; access: StorageBufferAccess }
1131
+ > = {};
1132
+ for (const key of storageBufferKeys) {
1133
+ const def = storageBufferDefinitions[key];
1134
+ if (def) {
1135
+ const norm = normalizeStorageBufferDefinition(def);
1136
+ storageBufferDefs[key] = { type: norm.type, access: norm.access };
1137
+ }
1138
+ }
1139
+ const storageTextureDefs: Record<string, { format: GPUTextureFormat }> = {};
1140
+ for (const key of storageTextureKeys) {
1141
+ const texDef = options.textureDefinitions[key];
1142
+ if (texDef?.format) {
1143
+ storageTextureDefs[key] = { format: texDef.format };
1144
+ }
1145
+ }
1146
+
1147
+ const isPingPongPipeline = Boolean(
1148
+ buildOptions.pingPongTarget && buildOptions.pingPongFormat
1149
+ );
1150
+ const builtComputeShader = isPingPongPipeline
1151
+ ? buildPingPongComputeShaderSourceWithMap({
1152
+ compute: buildOptions.computeSource,
1153
+ uniformLayout: options.uniformLayout,
1154
+ storageBufferKeys,
1155
+ storageBufferDefinitions: storageBufferDefs,
1156
+ target: buildOptions.pingPongTarget!,
1157
+ targetFormat: buildOptions.pingPongFormat!
1158
+ })
1159
+ : buildComputeShaderSourceWithMap({
1160
+ compute: buildOptions.computeSource,
1161
+ uniformLayout: options.uniformLayout,
1162
+ storageBufferKeys,
1163
+ storageBufferDefinitions: storageBufferDefs,
1164
+ storageTextureKeys,
1165
+ storageTextureDefinitions: storageTextureDefs
1166
+ });
1167
+
1168
+ const computeShaderModule = device.createShaderModule({ code: builtComputeShader.code });
1169
+ const workgroupSize = extractWorkgroupSize(buildOptions.computeSource);
1170
+
1171
+ // Compute bind group layout: group(0)=uniforms, group(1)=storage buffers, group(2)=storage textures
1172
+ const computeUniformBGL = device.createBindGroupLayout({
1173
+ entries: [
1174
+ {
1175
+ binding: FRAME_BINDING,
1176
+ visibility: GPUShaderStage.COMPUTE,
1177
+ buffer: { type: 'uniform', minBindingSize: 16 }
1178
+ },
1179
+ {
1180
+ binding: UNIFORM_BINDING,
1181
+ visibility: GPUShaderStage.COMPUTE,
1182
+ buffer: { type: 'uniform' }
1183
+ }
1184
+ ]
1185
+ });
1186
+
1187
+ const storageBGL =
1188
+ computeStorageBufferLayoutEntries.length > 0
1189
+ ? device.createBindGroupLayout({ entries: computeStorageBufferLayoutEntries })
1190
+ : null;
1191
+
1192
+ const storageTextureBGLEntries: GPUBindGroupLayoutEntry[] = isPingPongPipeline
1193
+ ? [
1194
+ {
1195
+ binding: 0,
1196
+ visibility: GPUShaderStage.COMPUTE,
1197
+ texture: {
1198
+ sampleType: toGpuTextureSampleType(
1199
+ storageTextureSampleScalarType(buildOptions.pingPongFormat!)
1200
+ ),
1201
+ viewDimension: '2d',
1202
+ multisampled: false
1203
+ }
1204
+ },
1205
+ {
1206
+ binding: 1,
1207
+ visibility: GPUShaderStage.COMPUTE,
1208
+ storageTexture: {
1209
+ access: 'write-only' as GPUStorageTextureAccess,
1210
+ format: buildOptions.pingPongFormat!,
1211
+ viewDimension: '2d'
1212
+ }
1213
+ }
1214
+ ]
1215
+ : computeStorageTextureLayoutEntries;
1216
+ const storageTextureBGL =
1217
+ storageTextureBGLEntries.length > 0
1218
+ ? device.createBindGroupLayout({ entries: storageTextureBGLEntries })
1219
+ : null;
1220
+
1221
+ // Bind group layout indices must match shader @group() indices:
1222
+ // group(0) = uniforms, group(1) = storage buffers, group(2) = storage textures.
1223
+ // When a group is unused, insert an empty placeholder to keep indices aligned.
1224
+ const bindGroupLayouts: GPUBindGroupLayout[] = [computeUniformBGL];
1225
+ if (storageBGL || storageTextureBGL) {
1226
+ bindGroupLayouts.push(storageBGL ?? device.createBindGroupLayout({ entries: [] }));
1227
+ }
1228
+ if (storageTextureBGL) {
1229
+ bindGroupLayouts.push(storageTextureBGL);
1230
+ }
1231
+
1232
+ const computePipelineLayout = device.createPipelineLayout({ bindGroupLayouts });
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
+ }
1250
+
1251
+ // Build uniform bind group for compute (group 0)
1252
+ const computeUniformBindGroup = device.createBindGroup({
1253
+ layout: computeUniformBGL,
1254
+ entries: [
1255
+ { binding: FRAME_BINDING, resource: { buffer: frameBuffer } },
1256
+ { binding: UNIFORM_BINDING, resource: { buffer: uniformBuffer } }
1257
+ ]
1258
+ });
1259
+
1260
+ const entry: ComputePipelineEntry = {
1261
+ pipeline,
1262
+ bindGroup: computeUniformBindGroup,
1263
+ workgroupSize,
1264
+ computeSource: buildOptions.computeSource
1265
+ };
1266
+ computePipelineCache.set(cacheKey, entry);
1267
+ return entry;
1268
+ };
1269
+
1270
+ // Helper to get the storage bind group for dispatch
1271
+ const getComputeStorageBindGroup = (): GPUBindGroup | null => {
1272
+ if (computeStorageBufferLayoutEntries.length === 0) {
1273
+ return null;
1274
+ }
1275
+ const resources: GPUBuffer[] = storageBufferKeys.map((key) => {
1276
+ const buffer = storageBufferMap.get(key);
1277
+ if (!buffer) {
1278
+ throw new Error(`Storage buffer "${key}" not allocated.`);
1279
+ }
1280
+ return buffer;
1281
+ });
1282
+ const storageEntries: GPUBindGroupEntry[] = resources.map((buffer, index) => {
1283
+ return { binding: index, resource: { buffer } };
1284
+ });
1285
+ return computeStorageBufferBindGroupCache.getOrCreate({
1286
+ topologyKey: computeStorageBufferTopologyKey,
1287
+ layoutEntries: computeStorageBufferLayoutEntries,
1288
+ bindGroupEntries: storageEntries,
1289
+ resourceRefs: resources
1290
+ });
1291
+ };
1292
+
1293
+ // Helper to get the storage texture bind group for compute dispatch (group 2)
1294
+ const getComputeStorageTextureBindGroup = (): GPUBindGroup | null => {
1295
+ if (computeStorageTextureLayoutEntries.length === 0) {
1296
+ return null;
1297
+ }
1298
+ const resources: GPUTextureView[] = storageTextureKeys.map((key) => {
1299
+ const binding = textureBindingByKey.get(key);
1300
+ if (!binding || !binding.texture) {
1301
+ throw new Error(`Storage texture "${key}" not allocated.`);
1302
+ }
1303
+ return binding.view;
1304
+ });
1305
+ const bgEntries: GPUBindGroupEntry[] = resources.map((view, index) => {
1306
+ return { binding: index, resource: view };
1307
+ });
1308
+
1309
+ return computeStorageTextureBindGroupCache.getOrCreate({
1310
+ topologyKey: computeStorageTextureTopologyKey,
1311
+ layoutEntries: computeStorageTextureLayoutEntries,
1312
+ bindGroupEntries: bgEntries,
1313
+ resourceRefs: resources
1314
+ });
1315
+ };
1316
+
1317
+ // Helper to get ping-pong storage texture bind group for compute dispatch (group 2)
1318
+ const getPingPongStorageTextureBindGroup = (
1319
+ target: string,
1320
+ readFromA: boolean
1321
+ ): GPUBindGroup => {
1322
+ const pair = ensurePingPongTexturePair(target);
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;
1345
+ };
1346
+
776
1347
  const frameBuffer = device.createBuffer({
777
1348
  size: 16,
778
1349
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
@@ -802,7 +1373,7 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
802
1373
  { binding: UNIFORM_BINDING, resource: { buffer: uniformBuffer } }
803
1374
  ];
804
1375
 
805
- for (const binding of textureBindings) {
1376
+ for (const binding of fragmentTextureBindings) {
806
1377
  entries.push({
807
1378
  binding: binding.samplerBinding,
808
1379
  resource: binding.sampler
@@ -891,14 +1462,18 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
891
1462
  return false;
892
1463
  }
893
1464
 
1465
+ let textureUsage =
1466
+ GPUTextureUsage.TEXTURE_BINDING |
1467
+ GPUTextureUsage.COPY_DST |
1468
+ GPUTextureUsage.RENDER_ATTACHMENT;
1469
+ if (storageTextureKeySet.has(binding.key)) {
1470
+ textureUsage |= GPUTextureUsage.STORAGE_BINDING;
1471
+ }
894
1472
  const texture = device.createTexture({
895
1473
  size: { width, height, depthOrArrayLayers: 1 },
896
1474
  format,
897
1475
  mipLevelCount,
898
- usage:
899
- GPUTextureUsage.TEXTURE_BINDING |
900
- GPUTextureUsage.COPY_DST |
901
- GPUTextureUsage.RENDER_ATTACHMENT
1476
+ usage: textureUsage
902
1477
  });
903
1478
  registerInitializationCleanup(() => {
904
1479
  texture.destroy();
@@ -924,6 +1499,8 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
924
1499
  };
925
1500
 
926
1501
  for (const binding of textureBindings) {
1502
+ // Skip storage textures — they are eagerly allocated and not source-driven
1503
+ if (storageTextureKeySet.has(binding.key)) continue;
927
1504
  const defaultSource = normalizedTextureDefinitions[binding.key]?.source ?? null;
928
1505
  updateTextureBinding(binding, defaultSource, 'always');
929
1506
  }
@@ -942,18 +1519,18 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
942
1519
  let configuredWidth = 0;
943
1520
  let configuredHeight = 0;
944
1521
  const runtimeRenderTargets = new Map<string, RuntimeRenderTarget>();
945
- const activePasses: RenderPass[] = [];
946
- const lifecyclePreviousSet = new Set<RenderPass>();
947
- const lifecycleNextSet = new Set<RenderPass>();
948
- const lifecycleUniquePasses: RenderPass[] = [];
949
- let lifecyclePassesRef: RenderPass[] | null = null;
1522
+ const activePasses: AnyPass[] = [];
1523
+ const lifecyclePreviousSet = new Set<AnyPass>();
1524
+ const lifecycleNextSet = new Set<AnyPass>();
1525
+ const lifecycleUniquePasses: AnyPass[] = [];
1526
+ let lifecyclePassesRef: AnyPass[] | null = null;
950
1527
  let passWidth = 0;
951
1528
  let passHeight = 0;
952
1529
 
953
1530
  /**
954
1531
  * Resolves active render pass list for current frame.
955
1532
  */
956
- const resolvePasses = (): RenderPass[] => {
1533
+ const resolvePasses = (): AnyPass[] => {
957
1534
  return options.getPasses?.() ?? options.passes ?? [];
958
1535
  };
959
1536
 
@@ -968,7 +1545,7 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
968
1545
  * Checks whether cached render-graph plan can be reused for this frame.
969
1546
  */
970
1547
  const isGraphPlanCacheValid = (
971
- passes: RenderPass[],
1548
+ passes: AnyPass[],
972
1549
  clearColor: [number, number, number, number]
973
1550
  ): boolean => {
974
1551
  if (!cachedGraphPlan) {
@@ -994,6 +1571,7 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
994
1571
 
995
1572
  for (let index = 0; index < passes.length; index += 1) {
996
1573
  const pass = passes[index];
1574
+ const rp = pass as Partial<RenderPass>;
997
1575
  const snapshot = cachedGraphPasses[index];
998
1576
  if (!pass || !snapshot || snapshot.pass !== pass) {
999
1577
  return false;
@@ -1001,16 +1579,16 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1001
1579
 
1002
1580
  if (
1003
1581
  snapshot.enabled !== pass.enabled ||
1004
- snapshot.needsSwap !== pass.needsSwap ||
1005
- snapshot.input !== pass.input ||
1006
- snapshot.output !== pass.output ||
1007
- snapshot.clear !== pass.clear ||
1008
- snapshot.preserve !== pass.preserve
1582
+ snapshot.needsSwap !== rp.needsSwap ||
1583
+ snapshot.input !== rp.input ||
1584
+ snapshot.output !== rp.output ||
1585
+ snapshot.clear !== rp.clear ||
1586
+ snapshot.preserve !== rp.preserve
1009
1587
  ) {
1010
1588
  return false;
1011
1589
  }
1012
1590
 
1013
- const passClearColor = pass.clearColor;
1591
+ const passClearColor = rp.clearColor;
1014
1592
  const hasPassClearColor = passClearColor !== undefined;
1015
1593
  if (snapshot.hasClearColor !== hasPassClearColor) {
1016
1594
  return false;
@@ -1035,7 +1613,7 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1035
1613
  * Updates render-graph cache with current pass set.
1036
1614
  */
1037
1615
  const updateGraphPlanCache = (
1038
- passes: RenderPass[],
1616
+ passes: AnyPass[],
1039
1617
  clearColor: [number, number, number, number],
1040
1618
  graphPlan: RenderGraphPlan
1041
1619
  ): void => {
@@ -1049,18 +1627,19 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1049
1627
 
1050
1628
  let index = 0;
1051
1629
  for (const pass of passes) {
1052
- const passClearColor = pass.clearColor;
1630
+ const rp = pass as Partial<RenderPass>;
1631
+ const passClearColor = rp.clearColor;
1053
1632
  const hasPassClearColor = passClearColor !== undefined;
1054
1633
  const snapshot = cachedGraphPasses[index];
1055
1634
  if (!snapshot) {
1056
1635
  cachedGraphPasses[index] = {
1057
1636
  pass,
1058
1637
  enabled: pass.enabled,
1059
- needsSwap: pass.needsSwap,
1060
- input: pass.input,
1061
- output: pass.output,
1062
- clear: pass.clear,
1063
- preserve: pass.preserve,
1638
+ needsSwap: rp.needsSwap,
1639
+ input: rp.input,
1640
+ output: rp.output,
1641
+ clear: rp.clear,
1642
+ preserve: rp.preserve,
1064
1643
  hasClearColor: hasPassClearColor,
1065
1644
  clearColor0: passClearColor?.[0] ?? 0,
1066
1645
  clearColor1: passClearColor?.[1] ?? 0,
@@ -1073,11 +1652,11 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1073
1652
 
1074
1653
  snapshot.pass = pass;
1075
1654
  snapshot.enabled = pass.enabled;
1076
- snapshot.needsSwap = pass.needsSwap;
1077
- snapshot.input = pass.input;
1078
- snapshot.output = pass.output;
1079
- snapshot.clear = pass.clear;
1080
- snapshot.preserve = pass.preserve;
1655
+ snapshot.needsSwap = rp.needsSwap;
1656
+ snapshot.input = rp.input;
1657
+ snapshot.output = rp.output;
1658
+ snapshot.clear = rp.clear;
1659
+ snapshot.preserve = rp.preserve;
1081
1660
  snapshot.hasClearColor = hasPassClearColor;
1082
1661
  snapshot.clearColor0 = passClearColor?.[0] ?? 0;
1083
1662
  snapshot.clearColor1 = passClearColor?.[1] ?? 0;
@@ -1090,7 +1669,7 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1090
1669
  /**
1091
1670
  * Synchronizes pass lifecycle callbacks and resize notifications.
1092
1671
  */
1093
- const syncPassLifecycle = (passes: RenderPass[], width: number, height: number): void => {
1672
+ const syncPassLifecycle = (passes: AnyPass[], width: number, height: number): void => {
1094
1673
  const resized = passWidth !== width || passHeight !== height;
1095
1674
  if (!resized && lifecyclePassesRef === passes && passes.length === activePasses.length) {
1096
1675
  let isSameOrder = true;
@@ -1292,7 +1871,8 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1292
1871
  renderMode,
1293
1872
  uniforms,
1294
1873
  textures,
1295
- canvasSize
1874
+ canvasSize,
1875
+ pendingStorageWrites
1296
1876
  }) => {
1297
1877
  if (deviceLostMessage) {
1298
1878
  throw new Error(deviceLostMessage);
@@ -1361,9 +1941,11 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1361
1941
 
1362
1942
  let bindGroupDirty = false;
1363
1943
  for (const binding of textureBindings) {
1944
+ // Storage textures are managed by compute passes, skip source-driven updates
1945
+ if (storageTextureKeySet.has(binding.key)) continue;
1364
1946
  const nextTexture =
1365
1947
  textures[binding.key] ?? normalizedTextureDefinitions[binding.key]?.source ?? null;
1366
- if (updateTextureBinding(binding, nextTexture, renderMode)) {
1948
+ if (updateTextureBinding(binding, nextTexture, renderMode) && binding.fragmentVisible) {
1367
1949
  bindGroupDirty = true;
1368
1950
  }
1369
1951
  }
@@ -1372,6 +1954,23 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1372
1954
  bindGroup = createBindGroup();
1373
1955
  }
1374
1956
 
1957
+ // Apply pending storage buffer writes
1958
+ if (pendingStorageWrites) {
1959
+ for (const write of pendingStorageWrites) {
1960
+ const buffer = storageBufferMap.get(write.name);
1961
+ if (buffer) {
1962
+ const data = write.data;
1963
+ device.queue.writeBuffer(
1964
+ buffer,
1965
+ write.offset,
1966
+ data.buffer as ArrayBuffer,
1967
+ data.byteOffset,
1968
+ data.byteLength
1969
+ );
1970
+ }
1971
+ }
1972
+ }
1973
+
1375
1974
  const commandEncoder = device.createCommandEncoder();
1376
1975
  const passes = resolvePasses();
1377
1976
  const clearColor = options.getClearColor();
@@ -1402,6 +2001,98 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1402
2001
  : null;
1403
2002
  const sceneOutput = slots ? slots.source : canvasSurface;
1404
2003
 
2004
+ // Dispatch compute passes BEFORE scene render so storage textures
2005
+ // and buffers are up-to-date when the fragment shader samples them.
2006
+ if (slots) {
2007
+ for (const step of graphPlan.steps) {
2008
+ if (step.kind !== 'compute') {
2009
+ continue;
2010
+ }
2011
+ const computePass = step.pass as {
2012
+ isCompute?: boolean;
2013
+ getCompute?: () => string;
2014
+ resolveDispatch?: (ctx: {
2015
+ width: number;
2016
+ height: number;
2017
+ time: number;
2018
+ delta: number;
2019
+ workgroupSize: [number, number, number];
2020
+ }) => [number, number, number];
2021
+ getWorkgroupSize?: () => [number, number, number];
2022
+ isPingPong?: boolean;
2023
+ getTarget?: () => string;
2024
+ getCurrentOutput?: () => string;
2025
+ getIterations?: () => number;
2026
+ advanceFrame?: () => void;
2027
+ };
2028
+ if (
2029
+ computePass.getCompute &&
2030
+ computePass.resolveDispatch &&
2031
+ computePass.getWorkgroupSize
2032
+ ) {
2033
+ const computeSource = computePass.getCompute();
2034
+ const pingPongTarget =
2035
+ computePass.isPingPong && computePass.getTarget ? computePass.getTarget() : undefined;
2036
+ if (computePass.isPingPong && !pingPongTarget) {
2037
+ throw new Error('PingPongComputePass must provide a target texture key.');
2038
+ }
2039
+ const pingPongPair = pingPongTarget ? ensurePingPongTexturePair(pingPongTarget) : null;
2040
+ const pipelineEntry = buildComputePipelineEntry({
2041
+ computeSource,
2042
+ ...(pingPongPair
2043
+ ? {
2044
+ pingPongTarget: pingPongPair.target,
2045
+ pingPongFormat: pingPongPair.format
2046
+ }
2047
+ : {})
2048
+ });
2049
+ const workgroupSize = computePass.getWorkgroupSize();
2050
+ const storageBindGroup = getComputeStorageBindGroup();
2051
+ const storageTextureBindGroup = getComputeStorageTextureBindGroup();
2052
+ const iterations =
2053
+ computePass.isPingPong && computePass.getIterations ? computePass.getIterations() : 1;
2054
+ const currentOutput =
2055
+ computePass.isPingPong && computePass.getCurrentOutput
2056
+ ? computePass.getCurrentOutput()
2057
+ : null;
2058
+ const readFromAAtIterationZero =
2059
+ pingPongPair && currentOutput ? currentOutput !== `${pingPongPair.target}B` : true;
2060
+
2061
+ for (let iter = 0; iter < iterations; iter += 1) {
2062
+ const dispatch = computePass.resolveDispatch({
2063
+ width,
2064
+ height,
2065
+ time,
2066
+ delta,
2067
+ workgroupSize
2068
+ });
2069
+ const cPass = commandEncoder.beginComputePass();
2070
+ cPass.setPipeline(pipelineEntry.pipeline);
2071
+ cPass.setBindGroup(0, pipelineEntry.bindGroup);
2072
+ if (storageBindGroup) {
2073
+ cPass.setBindGroup(1, storageBindGroup);
2074
+ }
2075
+ if (pingPongPair) {
2076
+ const readFromA =
2077
+ iter % 2 === 0 ? readFromAAtIterationZero : !readFromAAtIterationZero;
2078
+ cPass.setBindGroup(
2079
+ 2,
2080
+ getPingPongStorageTextureBindGroup(pingPongPair.target, readFromA)
2081
+ );
2082
+ } else if (storageTextureBindGroup) {
2083
+ cPass.setBindGroup(2, storageTextureBindGroup);
2084
+ }
2085
+ cPass.dispatchWorkgroups(dispatch[0], dispatch[1], dispatch[2]);
2086
+ cPass.end();
2087
+ }
2088
+
2089
+ if (computePass.isPingPong && computePass.advanceFrame) {
2090
+ computePass.advanceFrame();
2091
+ }
2092
+ }
2093
+ }
2094
+ }
2095
+
1405
2096
  const scenePass = commandEncoder.beginRenderPass({
1406
2097
  colorAttachments: [
1407
2098
  {
@@ -1420,6 +2111,9 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1420
2111
 
1421
2112
  scenePass.setPipeline(pipeline);
1422
2113
  scenePass.setBindGroup(0, bindGroup);
2114
+ if (fragmentStorageBindGroup) {
2115
+ scenePass.setBindGroup(1, fragmentStorageBindGroup);
2116
+ }
1423
2117
  scenePass.draw(3);
1424
2118
  scenePass.end();
1425
2119
 
@@ -1448,10 +2142,15 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1448
2142
  };
1449
2143
 
1450
2144
  for (const step of graphPlan.steps) {
2145
+ // Compute passes already dispatched above
2146
+ if (step.kind === 'compute') {
2147
+ continue;
2148
+ }
2149
+
1451
2150
  const input = resolveStepSurface(step.input);
1452
2151
  const output = resolveStepSurface(step.output);
1453
2152
 
1454
- step.pass.render({
2153
+ (step.pass as RenderPass).render({
1455
2154
  device,
1456
2155
  commandEncoder,
1457
2156
  source: slots.source,
@@ -1467,7 +2166,12 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1467
2166
  clear: step.clear,
1468
2167
  clearColor: step.clearColor,
1469
2168
  preserve: step.preserve,
1470
- beginRenderPass: (passOptions) => {
2169
+ beginRenderPass: (passOptions?: {
2170
+ clear?: boolean;
2171
+ clearColor?: [number, number, number, number];
2172
+ preserve?: boolean;
2173
+ view?: GPUTextureView;
2174
+ }) => {
1471
2175
  const clear = passOptions?.clear ?? step.clear;
1472
2176
  const clearColor = passOptions?.clearColor ?? step.clearColor;
1473
2177
  const preserve = passOptions?.preserve ?? step.preserve;
@@ -1510,11 +2214,27 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1510
2214
  initializationCleanups.length = 0;
1511
2215
  return {
1512
2216
  render,
2217
+ getStorageBuffer: (name: string): GPUBuffer | undefined => {
2218
+ return storageBufferMap.get(name);
2219
+ },
2220
+ getDevice: (): GPUDevice => {
2221
+ return device;
2222
+ },
1513
2223
  destroy: () => {
1514
2224
  isDestroyed = true;
1515
2225
  device.removeEventListener('uncapturederror', handleUncapturedError);
1516
2226
  frameBuffer.destroy();
1517
2227
  uniformBuffer.destroy();
2228
+ for (const buffer of storageBufferMap.values()) {
2229
+ buffer.destroy();
2230
+ }
2231
+ storageBufferMap.clear();
2232
+ for (const pair of pingPongTexturePairs.values()) {
2233
+ pair.textureA.destroy();
2234
+ pair.textureB.destroy();
2235
+ }
2236
+ pingPongTexturePairs.clear();
2237
+ computePipelineCache.clear();
1518
2238
  destroyRenderTexture(sourceSlotTarget);
1519
2239
  destroyRenderTexture(targetSlotTarget);
1520
2240
  for (const target of runtimeRenderTargets.values()) {