@motion-core/motion-gpu 0.4.2 → 0.5.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 (111) 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-shader.d.ts +87 -0
  5. package/dist/core/compute-shader.d.ts.map +1 -0
  6. package/dist/core/compute-shader.js +205 -0
  7. package/dist/core/compute-shader.js.map +1 -0
  8. package/dist/core/error-report.d.ts +1 -1
  9. package/dist/core/error-report.d.ts.map +1 -1
  10. package/dist/core/error-report.js +63 -0
  11. package/dist/core/error-report.js.map +1 -1
  12. package/dist/core/frame-registry.d.ts.map +1 -1
  13. package/dist/core/frame-registry.js +1 -1
  14. package/dist/core/frame-registry.js.map +1 -1
  15. package/dist/core/index.d.ts +5 -2
  16. package/dist/core/index.d.ts.map +1 -1
  17. package/dist/core/index.js +3 -1
  18. package/dist/core/material-preprocess.d.ts.map +1 -1
  19. package/dist/core/material-preprocess.js +5 -3
  20. package/dist/core/material-preprocess.js.map +1 -1
  21. package/dist/core/material.d.ts +22 -6
  22. package/dist/core/material.d.ts.map +1 -1
  23. package/dist/core/material.js +30 -3
  24. package/dist/core/material.js.map +1 -1
  25. package/dist/core/render-graph.d.ts +7 -3
  26. package/dist/core/render-graph.d.ts.map +1 -1
  27. package/dist/core/render-graph.js +22 -6
  28. package/dist/core/render-graph.js.map +1 -1
  29. package/dist/core/renderer.d.ts.map +1 -1
  30. package/dist/core/renderer.js +418 -23
  31. package/dist/core/renderer.js.map +1 -1
  32. package/dist/core/runtime-loop.d.ts +2 -2
  33. package/dist/core/runtime-loop.d.ts.map +1 -1
  34. package/dist/core/runtime-loop.js +49 -1
  35. package/dist/core/runtime-loop.js.map +1 -1
  36. package/dist/core/shader.d.ts +9 -1
  37. package/dist/core/shader.d.ts.map +1 -1
  38. package/dist/core/shader.js +21 -2
  39. package/dist/core/shader.js.map +1 -1
  40. package/dist/core/storage-buffers.d.ts +37 -0
  41. package/dist/core/storage-buffers.d.ts.map +1 -0
  42. package/dist/core/storage-buffers.js +95 -0
  43. package/dist/core/storage-buffers.js.map +1 -0
  44. package/dist/core/texture-loader.d.ts.map +1 -1
  45. package/dist/core/texture-loader.js +4 -0
  46. package/dist/core/texture-loader.js.map +1 -1
  47. package/dist/core/textures.d.ts +12 -0
  48. package/dist/core/textures.d.ts.map +1 -1
  49. package/dist/core/textures.js +7 -2
  50. package/dist/core/textures.js.map +1 -1
  51. package/dist/core/types.d.ts +146 -4
  52. package/dist/core/types.d.ts.map +1 -1
  53. package/dist/index.js +3 -1
  54. package/dist/passes/ComputePass.d.ts +83 -0
  55. package/dist/passes/ComputePass.d.ts.map +1 -0
  56. package/dist/passes/ComputePass.js +92 -0
  57. package/dist/passes/ComputePass.js.map +1 -0
  58. package/dist/passes/PingPongComputePass.d.ts +104 -0
  59. package/dist/passes/PingPongComputePass.d.ts.map +1 -0
  60. package/dist/passes/PingPongComputePass.js +132 -0
  61. package/dist/passes/PingPongComputePass.js.map +1 -0
  62. package/dist/passes/ShaderPass.d.ts.map +1 -1
  63. package/dist/passes/ShaderPass.js +2 -1
  64. package/dist/passes/ShaderPass.js.map +1 -1
  65. package/dist/passes/index.d.ts +2 -0
  66. package/dist/passes/index.d.ts.map +1 -1
  67. package/dist/passes/index.js +3 -1
  68. package/dist/react/FragCanvas.d.ts +2 -2
  69. package/dist/react/FragCanvas.d.ts.map +1 -1
  70. package/dist/react/FragCanvas.js.map +1 -1
  71. package/dist/react/MotionGPUErrorOverlay.d.ts.map +1 -1
  72. package/dist/react/MotionGPUErrorOverlay.js +123 -20
  73. package/dist/react/MotionGPUErrorOverlay.js.map +1 -1
  74. package/dist/react/advanced.js +3 -1
  75. package/dist/react/index.d.ts +5 -2
  76. package/dist/react/index.d.ts.map +1 -1
  77. package/dist/react/index.js +3 -1
  78. package/dist/svelte/FragCanvas.svelte +2 -2
  79. package/dist/svelte/FragCanvas.svelte.d.ts +2 -2
  80. package/dist/svelte/FragCanvas.svelte.d.ts.map +1 -1
  81. package/dist/svelte/MotionGPUErrorOverlay.svelte +137 -7
  82. package/dist/svelte/MotionGPUErrorOverlay.svelte.d.ts.map +1 -1
  83. package/dist/svelte/advanced.js +3 -1
  84. package/dist/svelte/index.d.ts +5 -2
  85. package/dist/svelte/index.d.ts.map +1 -1
  86. package/dist/svelte/index.js +3 -1
  87. package/package.json +1 -1
  88. package/src/lib/core/compute-shader.ts +326 -0
  89. package/src/lib/core/error-report.ts +129 -0
  90. package/src/lib/core/frame-registry.ts +2 -1
  91. package/src/lib/core/index.ts +18 -1
  92. package/src/lib/core/material-preprocess.ts +17 -6
  93. package/src/lib/core/material.ts +101 -20
  94. package/src/lib/core/render-graph.ts +39 -9
  95. package/src/lib/core/renderer.ts +655 -41
  96. package/src/lib/core/runtime-loop.ts +82 -3
  97. package/src/lib/core/shader.ts +45 -2
  98. package/src/lib/core/storage-buffers.ts +142 -0
  99. package/src/lib/core/texture-loader.ts +6 -0
  100. package/src/lib/core/textures.ts +24 -2
  101. package/src/lib/core/types.ts +165 -4
  102. package/src/lib/passes/ComputePass.ts +136 -0
  103. package/src/lib/passes/PingPongComputePass.ts +180 -0
  104. package/src/lib/passes/ShaderPass.ts +2 -1
  105. package/src/lib/passes/index.ts +6 -0
  106. package/src/lib/react/FragCanvas.tsx +3 -3
  107. package/src/lib/react/MotionGPUErrorOverlay.tsx +137 -5
  108. package/src/lib/react/index.ts +18 -1
  109. package/src/lib/svelte/FragCanvas.svelte +2 -2
  110. package/src/lib/svelte/MotionGPUErrorOverlay.svelte +137 -7
  111. package/src/lib/svelte/index.ts +18 -1
@@ -17,7 +17,15 @@ import {
17
17
  toTextureData
18
18
  } from './textures.js';
19
19
  import { packUniformsInto } from './uniforms.js';
20
+ import {
21
+ buildComputeShaderSource,
22
+ buildPingPongComputeShaderSource,
23
+ extractWorkgroupSize,
24
+ storageTextureSampleScalarType
25
+ } from './compute-shader.js';
26
+ import { normalizeStorageBufferDefinition } from './storage-buffers.js';
20
27
  import type {
28
+ AnyPass,
21
29
  RenderPass,
22
30
  RenderPassInputSlot,
23
31
  RenderPassOutputSlot,
@@ -25,6 +33,8 @@ import type {
25
33
  RenderTarget,
26
34
  Renderer,
27
35
  RendererOptions,
36
+ StorageBufferAccess,
37
+ StorageBufferType,
28
38
  TextureSource,
29
39
  TextureUpdateMode,
30
40
  TextureValue
@@ -86,11 +96,26 @@ interface RuntimeRenderTarget {
86
96
  format: GPUTextureFormat;
87
97
  }
88
98
 
99
+ /**
100
+ * Runtime ping-pong storage textures for a single logical target key.
101
+ */
102
+ interface PingPongTexturePair {
103
+ target: string;
104
+ format: GPUTextureFormat;
105
+ width: number;
106
+ height: number;
107
+ textureA: GPUTexture;
108
+ viewA: GPUTextureView;
109
+ textureB: GPUTexture;
110
+ viewB: GPUTextureView;
111
+ bindGroupLayout: GPUBindGroupLayout;
112
+ }
113
+
89
114
  /**
90
115
  * Cached pass properties used to validate render-graph cache correctness.
91
116
  */
92
117
  interface RenderGraphPassSnapshot {
93
- pass: RenderPass;
118
+ pass: AnyPass;
94
119
  enabled: RenderPass['enabled'];
95
120
  needsSwap: RenderPass['needsSwap'];
96
121
  input: RenderPass['input'];
@@ -118,6 +143,19 @@ function getTextureBindings(index: number): {
118
143
  };
119
144
  }
120
145
 
146
+ /**
147
+ * Maps WGSL scalar texture type to WebGPU sampled texture bind-group sample type.
148
+ */
149
+ function toGpuTextureSampleType(type: 'f32' | 'u32' | 'i32'): GPUTextureSampleType {
150
+ if (type === 'u32') {
151
+ return 'uint';
152
+ }
153
+ if (type === 'i32') {
154
+ return 'sint';
155
+ }
156
+ return 'float';
157
+ }
158
+
121
159
  /**
122
160
  * Resizes canvas backing store to match client size and DPR.
123
161
  */
@@ -208,7 +246,7 @@ function toSortedUniqueStrings(values: string[]): string[] {
208
246
  }
209
247
 
210
248
  function buildPassGraphSnapshot(
211
- passes: RenderPass[] | undefined
249
+ passes: AnyPass[] | undefined
212
250
  ): NonNullable<ShaderCompilationRuntimeContext['passGraph']> {
213
251
  const declaredPasses = passes ?? [];
214
252
  let enabledPassCount = 0;
@@ -221,9 +259,13 @@ function buildPassGraphSnapshot(
221
259
  }
222
260
 
223
261
  enabledPassCount += 1;
224
- const needsSwap = pass.needsSwap ?? true;
225
- const input = pass.input ?? 'source';
226
- const output = pass.output ?? (needsSwap ? 'target' : 'source');
262
+ if ('isCompute' in pass && (pass as { isCompute?: boolean }).isCompute === true) {
263
+ continue;
264
+ }
265
+ const rp = pass as RenderPass;
266
+ const needsSwap = rp.needsSwap ?? true;
267
+ const input = rp.input ?? 'source';
268
+ const output = rp.output ?? (needsSwap ? 'target' : 'source');
227
269
  inputs.push(input);
228
270
  outputs.push(output);
229
271
  }
@@ -631,7 +673,13 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
631
673
  options.textureKeys,
632
674
  {
633
675
  convertLinearToSrgb,
634
- fragmentLineMap: options.fragmentLineMap
676
+ fragmentLineMap: options.fragmentLineMap,
677
+ ...(options.storageBufferKeys !== undefined
678
+ ? { storageBufferKeys: options.storageBufferKeys }
679
+ : {}),
680
+ ...(options.storageBufferDefinitions !== undefined
681
+ ? { storageBufferDefinitions: options.storageBufferDefinitions }
682
+ : {})
635
683
  }
636
684
  );
637
685
  const shaderModule = device.createShaderModule({ code: builtShader.code });
@@ -650,6 +698,10 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
650
698
  options.textureDefinitions,
651
699
  options.textureKeys
652
700
  );
701
+ const storageBufferKeys = options.storageBufferKeys ?? [];
702
+ const storageBufferDefinitions = options.storageBufferDefinitions ?? {};
703
+ const storageTextureKeys = options.storageTextureKeys ?? [];
704
+ const storageTextureKeySet = new Set(storageTextureKeys);
653
705
  const textureBindings = options.textureKeys.map((key, index): RuntimeTextureBinding => {
654
706
  const config = normalizedTextureDefinitions[key];
655
707
  if (!config) {
@@ -665,7 +717,11 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
665
717
  addressModeV: config.addressModeV,
666
718
  maxAnisotropy: config.filter === 'linear' ? config.anisotropy : 1
667
719
  });
668
- const fallbackTexture = createFallbackTexture(device, config.format);
720
+ // Storage textures use a safe fallback format — the fallback is never
721
+ // sampled because storage textures are eagerly allocated with their
722
+ // real format/dimensions. Non-storage textures use their own format.
723
+ const fallbackFormat = config.storage ? 'rgba8unorm' : config.format;
724
+ const fallbackTexture = createFallbackTexture(device, fallbackFormat);
669
725
  registerInitializationCleanup(() => {
670
726
  fallbackTexture.destroy();
671
727
  });
@@ -701,14 +757,46 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
701
757
  runtimeBinding.defaultUpdate = config.update;
702
758
  }
703
759
 
760
+ // Storage textures: eagerly create GPU texture with explicit dimensions
761
+ if (config.storage && config.width && config.height) {
762
+ const storageUsage =
763
+ GPUTextureUsage.TEXTURE_BINDING |
764
+ GPUTextureUsage.STORAGE_BINDING |
765
+ GPUTextureUsage.COPY_DST;
766
+ const storageTexture = device.createTexture({
767
+ size: { width: config.width, height: config.height, depthOrArrayLayers: 1 },
768
+ format: config.format,
769
+ usage: storageUsage
770
+ });
771
+ registerInitializationCleanup(() => {
772
+ storageTexture.destroy();
773
+ });
774
+ runtimeBinding.texture = storageTexture as unknown as GPUTexture;
775
+ runtimeBinding.view = storageTexture.createView();
776
+ runtimeBinding.width = config.width;
777
+ runtimeBinding.height = config.height;
778
+ }
779
+
704
780
  return runtimeBinding;
705
781
  });
706
782
 
707
783
  const bindGroupLayout = device.createBindGroupLayout({
708
784
  entries: createBindGroupLayoutEntries(textureBindings)
709
785
  });
786
+ const fragmentStorageBindGroupLayout =
787
+ storageBufferKeys.length > 0
788
+ ? device.createBindGroupLayout({
789
+ entries: storageBufferKeys.map((_, index) => ({
790
+ binding: index,
791
+ visibility: GPUShaderStage.FRAGMENT,
792
+ buffer: { type: 'read-only-storage' as GPUBufferBindingType }
793
+ }))
794
+ })
795
+ : null;
710
796
  const pipelineLayout = device.createPipelineLayout({
711
- bindGroupLayouts: [bindGroupLayout]
797
+ bindGroupLayouts: fragmentStorageBindGroupLayout
798
+ ? [bindGroupLayout, fragmentStorageBindGroupLayout]
799
+ : [bindGroupLayout]
712
800
  });
713
801
 
714
802
  const pipeline = device.createRenderPipeline({
@@ -773,6 +861,383 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
773
861
  });
774
862
  let blitBindGroupByView = new WeakMap<GPUTextureView, GPUBindGroup>();
775
863
 
864
+ // ── Storage buffer allocation ────────────────────────────────────────
865
+ const storageBufferMap = new Map<string, GPUBuffer>();
866
+ const pingPongTexturePairs = new Map<string, PingPongTexturePair>();
867
+
868
+ for (const key of storageBufferKeys) {
869
+ const definition = storageBufferDefinitions[key];
870
+ if (!definition) {
871
+ continue;
872
+ }
873
+ const normalized = normalizeStorageBufferDefinition(definition);
874
+ const buffer = device.createBuffer({
875
+ size: normalized.size,
876
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC
877
+ });
878
+ registerInitializationCleanup(() => {
879
+ buffer.destroy();
880
+ });
881
+ if (definition.initialData) {
882
+ const data = definition.initialData;
883
+ device.queue.writeBuffer(
884
+ buffer,
885
+ 0,
886
+ data.buffer as ArrayBuffer,
887
+ data.byteOffset,
888
+ data.byteLength
889
+ );
890
+ }
891
+ storageBufferMap.set(key, buffer);
892
+ }
893
+ const fragmentStorageBindGroup =
894
+ fragmentStorageBindGroupLayout && storageBufferKeys.length > 0
895
+ ? device.createBindGroup({
896
+ layout: fragmentStorageBindGroupLayout,
897
+ entries: storageBufferKeys.map((key, index) => {
898
+ const buffer = storageBufferMap.get(key);
899
+ if (!buffer) {
900
+ throw new Error(`Storage buffer "${key}" not allocated.`);
901
+ }
902
+ return { binding: index, resource: { buffer } };
903
+ })
904
+ })
905
+ : null;
906
+
907
+ const ensurePingPongTexturePair = (target: string): PingPongTexturePair => {
908
+ const existing = pingPongTexturePairs.get(target);
909
+ if (existing) {
910
+ return existing;
911
+ }
912
+
913
+ const config = normalizedTextureDefinitions[target];
914
+ if (!config || !config.storage) {
915
+ throw new Error(
916
+ `PingPongComputePass target "${target}" must reference a texture declared with storage:true.`
917
+ );
918
+ }
919
+ if (!config.width || !config.height) {
920
+ throw new Error(
921
+ `PingPongComputePass target "${target}" requires explicit texture width and height.`
922
+ );
923
+ }
924
+
925
+ const usage =
926
+ GPUTextureUsage.TEXTURE_BINDING |
927
+ GPUTextureUsage.STORAGE_BINDING |
928
+ GPUTextureUsage.COPY_DST;
929
+ const textureA = device.createTexture({
930
+ size: { width: config.width, height: config.height, depthOrArrayLayers: 1 },
931
+ format: config.format,
932
+ usage
933
+ });
934
+ const textureB = device.createTexture({
935
+ size: { width: config.width, height: config.height, depthOrArrayLayers: 1 },
936
+ format: config.format,
937
+ usage
938
+ });
939
+ registerInitializationCleanup(() => {
940
+ textureA.destroy();
941
+ });
942
+ registerInitializationCleanup(() => {
943
+ textureB.destroy();
944
+ });
945
+
946
+ const sampleScalarType = storageTextureSampleScalarType(config.format);
947
+ const sampleType = toGpuTextureSampleType(sampleScalarType);
948
+ const bindGroupLayout = device.createBindGroupLayout({
949
+ entries: [
950
+ {
951
+ binding: 0,
952
+ visibility: GPUShaderStage.COMPUTE,
953
+ texture: {
954
+ sampleType,
955
+ viewDimension: '2d',
956
+ multisampled: false
957
+ }
958
+ },
959
+ {
960
+ binding: 1,
961
+ visibility: GPUShaderStage.COMPUTE,
962
+ storageTexture: {
963
+ access: 'write-only' as GPUStorageTextureAccess,
964
+ format: config.format as GPUTextureFormat,
965
+ viewDimension: '2d'
966
+ }
967
+ }
968
+ ]
969
+ });
970
+
971
+ const pair: PingPongTexturePair = {
972
+ target,
973
+ format: config.format as GPUTextureFormat,
974
+ width: config.width,
975
+ height: config.height,
976
+ textureA,
977
+ viewA: textureA.createView(),
978
+ textureB,
979
+ viewB: textureB.createView(),
980
+ bindGroupLayout
981
+ };
982
+ pingPongTexturePairs.set(target, pair);
983
+ return pair;
984
+ };
985
+
986
+ // ── Compute pipeline setup ──────────────────────────────────────────
987
+ interface ComputePipelineEntry {
988
+ pipeline: GPUComputePipeline;
989
+ bindGroup: GPUBindGroup;
990
+ workgroupSize: [number, number, number];
991
+ computeSource: string;
992
+ }
993
+ const computePipelineCache = new Map<string, ComputePipelineEntry>();
994
+
995
+ const buildComputePipelineEntry = (buildOptions: {
996
+ computeSource: string;
997
+ pingPongTarget?: string;
998
+ pingPongFormat?: GPUTextureFormat;
999
+ }): ComputePipelineEntry => {
1000
+ const cacheKey =
1001
+ buildOptions.pingPongTarget && buildOptions.pingPongFormat
1002
+ ? `pingpong:${buildOptions.pingPongTarget}:${buildOptions.pingPongFormat}:${buildOptions.computeSource}`
1003
+ : `compute:${buildOptions.computeSource}`;
1004
+ const cached = computePipelineCache.get(cacheKey);
1005
+ if (cached) {
1006
+ return cached;
1007
+ }
1008
+
1009
+ const storageBufferDefs: Record<
1010
+ string,
1011
+ { type: StorageBufferType; access: StorageBufferAccess }
1012
+ > = {};
1013
+ for (const key of storageBufferKeys) {
1014
+ const def = storageBufferDefinitions[key];
1015
+ if (def) {
1016
+ const norm = normalizeStorageBufferDefinition(def);
1017
+ storageBufferDefs[key] = { type: norm.type, access: norm.access };
1018
+ }
1019
+ }
1020
+ const storageTextureDefs: Record<string, { format: GPUTextureFormat }> = {};
1021
+ for (const key of storageTextureKeys) {
1022
+ const texDef = options.textureDefinitions[key];
1023
+ if (texDef?.format) {
1024
+ storageTextureDefs[key] = { format: texDef.format };
1025
+ }
1026
+ }
1027
+
1028
+ const isPingPongPipeline = Boolean(
1029
+ buildOptions.pingPongTarget && buildOptions.pingPongFormat
1030
+ );
1031
+ const shaderCode = isPingPongPipeline
1032
+ ? buildPingPongComputeShaderSource({
1033
+ compute: buildOptions.computeSource,
1034
+ uniformLayout: options.uniformLayout,
1035
+ storageBufferKeys,
1036
+ storageBufferDefinitions: storageBufferDefs,
1037
+ target: buildOptions.pingPongTarget!,
1038
+ targetFormat: buildOptions.pingPongFormat!
1039
+ })
1040
+ : buildComputeShaderSource({
1041
+ compute: buildOptions.computeSource,
1042
+ uniformLayout: options.uniformLayout,
1043
+ storageBufferKeys,
1044
+ storageBufferDefinitions: storageBufferDefs,
1045
+ storageTextureKeys,
1046
+ storageTextureDefinitions: storageTextureDefs
1047
+ });
1048
+
1049
+ const computeShaderModule = device.createShaderModule({ code: shaderCode });
1050
+ const workgroupSize = extractWorkgroupSize(buildOptions.computeSource);
1051
+
1052
+ // Compute bind group layout: group(0)=uniforms, group(1)=storage buffers, group(2)=storage textures
1053
+ const computeUniformBGL = device.createBindGroupLayout({
1054
+ entries: [
1055
+ {
1056
+ binding: FRAME_BINDING,
1057
+ visibility: GPUShaderStage.COMPUTE,
1058
+ buffer: { type: 'uniform', minBindingSize: 16 }
1059
+ },
1060
+ {
1061
+ binding: UNIFORM_BINDING,
1062
+ visibility: GPUShaderStage.COMPUTE,
1063
+ buffer: { type: 'uniform' }
1064
+ }
1065
+ ]
1066
+ });
1067
+
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
+ const storageBGL =
1080
+ storageBGLEntries.length > 0
1081
+ ? device.createBindGroupLayout({ entries: storageBGLEntries })
1082
+ : null;
1083
+
1084
+ const storageTextureBGLEntries: GPUBindGroupLayoutEntry[] = isPingPongPipeline
1085
+ ? [
1086
+ {
1087
+ binding: 0,
1088
+ visibility: GPUShaderStage.COMPUTE,
1089
+ texture: {
1090
+ sampleType: toGpuTextureSampleType(
1091
+ storageTextureSampleScalarType(buildOptions.pingPongFormat!)
1092
+ ),
1093
+ viewDimension: '2d',
1094
+ multisampled: false
1095
+ }
1096
+ },
1097
+ {
1098
+ binding: 1,
1099
+ visibility: GPUShaderStage.COMPUTE,
1100
+ storageTexture: {
1101
+ access: 'write-only' as GPUStorageTextureAccess,
1102
+ format: buildOptions.pingPongFormat!,
1103
+ viewDimension: '2d'
1104
+ }
1105
+ }
1106
+ ]
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
+ });
1119
+ const storageTextureBGL =
1120
+ storageTextureBGLEntries.length > 0
1121
+ ? device.createBindGroupLayout({ entries: storageTextureBGLEntries })
1122
+ : null;
1123
+
1124
+ // Bind group layout indices must match shader @group() indices:
1125
+ // group(0) = uniforms, group(1) = storage buffers, group(2) = storage textures.
1126
+ // When a group is unused, insert an empty placeholder to keep indices aligned.
1127
+ const bindGroupLayouts: GPUBindGroupLayout[] = [computeUniformBGL];
1128
+ if (storageBGL || storageTextureBGL) {
1129
+ bindGroupLayouts.push(storageBGL ?? device.createBindGroupLayout({ entries: [] }));
1130
+ }
1131
+ if (storageTextureBGL) {
1132
+ bindGroupLayouts.push(storageTextureBGL);
1133
+ }
1134
+
1135
+ const computePipelineLayout = device.createPipelineLayout({ bindGroupLayouts });
1136
+ const pipeline = device.createComputePipeline({
1137
+ layout: computePipelineLayout,
1138
+ compute: {
1139
+ module: computeShaderModule,
1140
+ entryPoint: 'compute'
1141
+ }
1142
+ });
1143
+
1144
+ // Build uniform bind group for compute (group 0)
1145
+ const computeUniformBindGroup = device.createBindGroup({
1146
+ layout: computeUniformBGL,
1147
+ entries: [
1148
+ { binding: FRAME_BINDING, resource: { buffer: frameBuffer } },
1149
+ { binding: UNIFORM_BINDING, resource: { buffer: uniformBuffer } }
1150
+ ]
1151
+ });
1152
+
1153
+ const entry: ComputePipelineEntry = {
1154
+ pipeline,
1155
+ bindGroup: computeUniformBindGroup,
1156
+ workgroupSize,
1157
+ computeSource: buildOptions.computeSource
1158
+ };
1159
+ computePipelineCache.set(cacheKey, entry);
1160
+ return entry;
1161
+ };
1162
+
1163
+ // Helper to get the storage bind group for dispatch
1164
+ const getComputeStorageBindGroup = (): GPUBindGroup | null => {
1165
+ if (storageBufferKeys.length === 0) {
1166
+ return null;
1167
+ }
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) => {
1182
+ const buffer = storageBufferMap.get(key);
1183
+ if (!buffer) {
1184
+ throw new Error(`Storage buffer "${key}" not allocated.`);
1185
+ }
1186
+ return { binding: index, resource: { buffer } };
1187
+ });
1188
+ return device.createBindGroup({
1189
+ layout: storageBGL,
1190
+ entries: storageEntries
1191
+ });
1192
+ };
1193
+
1194
+ // Helper to get the storage texture bind group for compute dispatch (group 2)
1195
+ const getComputeStorageTextureBindGroup = (): GPUBindGroup | null => {
1196
+ if (storageTextureKeys.length === 0) {
1197
+ return null;
1198
+ }
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);
1215
+ if (!binding || !binding.texture) {
1216
+ throw new Error(`Storage texture "${key}" not allocated.`);
1217
+ }
1218
+ return { binding: index, resource: binding.view };
1219
+ });
1220
+
1221
+ return device.createBindGroup({ layout: bgl, entries: bgEntries });
1222
+ };
1223
+
1224
+ // Helper to get ping-pong storage texture bind group for compute dispatch (group 2)
1225
+ const getPingPongStorageTextureBindGroup = (
1226
+ target: string,
1227
+ readFromA: boolean
1228
+ ): GPUBindGroup => {
1229
+ 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
+ });
1239
+ };
1240
+
776
1241
  const frameBuffer = device.createBuffer({
777
1242
  size: 16,
778
1243
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
@@ -891,14 +1356,18 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
891
1356
  return false;
892
1357
  }
893
1358
 
1359
+ let textureUsage =
1360
+ GPUTextureUsage.TEXTURE_BINDING |
1361
+ GPUTextureUsage.COPY_DST |
1362
+ GPUTextureUsage.RENDER_ATTACHMENT;
1363
+ if (storageTextureKeySet.has(binding.key)) {
1364
+ textureUsage |= GPUTextureUsage.STORAGE_BINDING;
1365
+ }
894
1366
  const texture = device.createTexture({
895
1367
  size: { width, height, depthOrArrayLayers: 1 },
896
1368
  format,
897
1369
  mipLevelCount,
898
- usage:
899
- GPUTextureUsage.TEXTURE_BINDING |
900
- GPUTextureUsage.COPY_DST |
901
- GPUTextureUsage.RENDER_ATTACHMENT
1370
+ usage: textureUsage
902
1371
  });
903
1372
  registerInitializationCleanup(() => {
904
1373
  texture.destroy();
@@ -924,6 +1393,8 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
924
1393
  };
925
1394
 
926
1395
  for (const binding of textureBindings) {
1396
+ // Skip storage textures — they are eagerly allocated and not source-driven
1397
+ if (storageTextureKeySet.has(binding.key)) continue;
927
1398
  const defaultSource = normalizedTextureDefinitions[binding.key]?.source ?? null;
928
1399
  updateTextureBinding(binding, defaultSource, 'always');
929
1400
  }
@@ -942,18 +1413,18 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
942
1413
  let configuredWidth = 0;
943
1414
  let configuredHeight = 0;
944
1415
  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;
1416
+ const activePasses: AnyPass[] = [];
1417
+ const lifecyclePreviousSet = new Set<AnyPass>();
1418
+ const lifecycleNextSet = new Set<AnyPass>();
1419
+ const lifecycleUniquePasses: AnyPass[] = [];
1420
+ let lifecyclePassesRef: AnyPass[] | null = null;
950
1421
  let passWidth = 0;
951
1422
  let passHeight = 0;
952
1423
 
953
1424
  /**
954
1425
  * Resolves active render pass list for current frame.
955
1426
  */
956
- const resolvePasses = (): RenderPass[] => {
1427
+ const resolvePasses = (): AnyPass[] => {
957
1428
  return options.getPasses?.() ?? options.passes ?? [];
958
1429
  };
959
1430
 
@@ -968,7 +1439,7 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
968
1439
  * Checks whether cached render-graph plan can be reused for this frame.
969
1440
  */
970
1441
  const isGraphPlanCacheValid = (
971
- passes: RenderPass[],
1442
+ passes: AnyPass[],
972
1443
  clearColor: [number, number, number, number]
973
1444
  ): boolean => {
974
1445
  if (!cachedGraphPlan) {
@@ -994,6 +1465,7 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
994
1465
 
995
1466
  for (let index = 0; index < passes.length; index += 1) {
996
1467
  const pass = passes[index];
1468
+ const rp = pass as Partial<RenderPass>;
997
1469
  const snapshot = cachedGraphPasses[index];
998
1470
  if (!pass || !snapshot || snapshot.pass !== pass) {
999
1471
  return false;
@@ -1001,16 +1473,16 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1001
1473
 
1002
1474
  if (
1003
1475
  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
1476
+ snapshot.needsSwap !== rp.needsSwap ||
1477
+ snapshot.input !== rp.input ||
1478
+ snapshot.output !== rp.output ||
1479
+ snapshot.clear !== rp.clear ||
1480
+ snapshot.preserve !== rp.preserve
1009
1481
  ) {
1010
1482
  return false;
1011
1483
  }
1012
1484
 
1013
- const passClearColor = pass.clearColor;
1485
+ const passClearColor = rp.clearColor;
1014
1486
  const hasPassClearColor = passClearColor !== undefined;
1015
1487
  if (snapshot.hasClearColor !== hasPassClearColor) {
1016
1488
  return false;
@@ -1035,7 +1507,7 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1035
1507
  * Updates render-graph cache with current pass set.
1036
1508
  */
1037
1509
  const updateGraphPlanCache = (
1038
- passes: RenderPass[],
1510
+ passes: AnyPass[],
1039
1511
  clearColor: [number, number, number, number],
1040
1512
  graphPlan: RenderGraphPlan
1041
1513
  ): void => {
@@ -1049,18 +1521,19 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1049
1521
 
1050
1522
  let index = 0;
1051
1523
  for (const pass of passes) {
1052
- const passClearColor = pass.clearColor;
1524
+ const rp = pass as Partial<RenderPass>;
1525
+ const passClearColor = rp.clearColor;
1053
1526
  const hasPassClearColor = passClearColor !== undefined;
1054
1527
  const snapshot = cachedGraphPasses[index];
1055
1528
  if (!snapshot) {
1056
1529
  cachedGraphPasses[index] = {
1057
1530
  pass,
1058
1531
  enabled: pass.enabled,
1059
- needsSwap: pass.needsSwap,
1060
- input: pass.input,
1061
- output: pass.output,
1062
- clear: pass.clear,
1063
- preserve: pass.preserve,
1532
+ needsSwap: rp.needsSwap,
1533
+ input: rp.input,
1534
+ output: rp.output,
1535
+ clear: rp.clear,
1536
+ preserve: rp.preserve,
1064
1537
  hasClearColor: hasPassClearColor,
1065
1538
  clearColor0: passClearColor?.[0] ?? 0,
1066
1539
  clearColor1: passClearColor?.[1] ?? 0,
@@ -1073,11 +1546,11 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1073
1546
 
1074
1547
  snapshot.pass = pass;
1075
1548
  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;
1549
+ snapshot.needsSwap = rp.needsSwap;
1550
+ snapshot.input = rp.input;
1551
+ snapshot.output = rp.output;
1552
+ snapshot.clear = rp.clear;
1553
+ snapshot.preserve = rp.preserve;
1081
1554
  snapshot.hasClearColor = hasPassClearColor;
1082
1555
  snapshot.clearColor0 = passClearColor?.[0] ?? 0;
1083
1556
  snapshot.clearColor1 = passClearColor?.[1] ?? 0;
@@ -1090,7 +1563,7 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1090
1563
  /**
1091
1564
  * Synchronizes pass lifecycle callbacks and resize notifications.
1092
1565
  */
1093
- const syncPassLifecycle = (passes: RenderPass[], width: number, height: number): void => {
1566
+ const syncPassLifecycle = (passes: AnyPass[], width: number, height: number): void => {
1094
1567
  const resized = passWidth !== width || passHeight !== height;
1095
1568
  if (!resized && lifecyclePassesRef === passes && passes.length === activePasses.length) {
1096
1569
  let isSameOrder = true;
@@ -1292,7 +1765,8 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1292
1765
  renderMode,
1293
1766
  uniforms,
1294
1767
  textures,
1295
- canvasSize
1768
+ canvasSize,
1769
+ pendingStorageWrites
1296
1770
  }) => {
1297
1771
  if (deviceLostMessage) {
1298
1772
  throw new Error(deviceLostMessage);
@@ -1361,6 +1835,8 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1361
1835
 
1362
1836
  let bindGroupDirty = false;
1363
1837
  for (const binding of textureBindings) {
1838
+ // Storage textures are managed by compute passes, skip source-driven updates
1839
+ if (storageTextureKeySet.has(binding.key)) continue;
1364
1840
  const nextTexture =
1365
1841
  textures[binding.key] ?? normalizedTextureDefinitions[binding.key]?.source ?? null;
1366
1842
  if (updateTextureBinding(binding, nextTexture, renderMode)) {
@@ -1372,6 +1848,23 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1372
1848
  bindGroup = createBindGroup();
1373
1849
  }
1374
1850
 
1851
+ // Apply pending storage buffer writes
1852
+ if (pendingStorageWrites) {
1853
+ for (const write of pendingStorageWrites) {
1854
+ const buffer = storageBufferMap.get(write.name);
1855
+ if (buffer) {
1856
+ const data = write.data;
1857
+ device.queue.writeBuffer(
1858
+ buffer,
1859
+ write.offset,
1860
+ data.buffer as ArrayBuffer,
1861
+ data.byteOffset,
1862
+ data.byteLength
1863
+ );
1864
+ }
1865
+ }
1866
+ }
1867
+
1375
1868
  const commandEncoder = device.createCommandEncoder();
1376
1869
  const passes = resolvePasses();
1377
1870
  const clearColor = options.getClearColor();
@@ -1402,6 +1895,98 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1402
1895
  : null;
1403
1896
  const sceneOutput = slots ? slots.source : canvasSurface;
1404
1897
 
1898
+ // Dispatch compute passes BEFORE scene render so storage textures
1899
+ // and buffers are up-to-date when the fragment shader samples them.
1900
+ if (slots) {
1901
+ for (const step of graphPlan.steps) {
1902
+ if (step.kind !== 'compute') {
1903
+ continue;
1904
+ }
1905
+ const computePass = step.pass as {
1906
+ isCompute?: boolean;
1907
+ getCompute?: () => string;
1908
+ resolveDispatch?: (ctx: {
1909
+ width: number;
1910
+ height: number;
1911
+ time: number;
1912
+ delta: number;
1913
+ workgroupSize: [number, number, number];
1914
+ }) => [number, number, number];
1915
+ getWorkgroupSize?: () => [number, number, number];
1916
+ isPingPong?: boolean;
1917
+ getTarget?: () => string;
1918
+ getCurrentOutput?: () => string;
1919
+ getIterations?: () => number;
1920
+ advanceFrame?: () => void;
1921
+ };
1922
+ if (
1923
+ computePass.getCompute &&
1924
+ computePass.resolveDispatch &&
1925
+ computePass.getWorkgroupSize
1926
+ ) {
1927
+ const computeSource = computePass.getCompute();
1928
+ const pingPongTarget =
1929
+ computePass.isPingPong && computePass.getTarget ? computePass.getTarget() : undefined;
1930
+ if (computePass.isPingPong && !pingPongTarget) {
1931
+ throw new Error('PingPongComputePass must provide a target texture key.');
1932
+ }
1933
+ const pingPongPair = pingPongTarget ? ensurePingPongTexturePair(pingPongTarget) : null;
1934
+ const pipelineEntry = buildComputePipelineEntry({
1935
+ computeSource,
1936
+ ...(pingPongPair
1937
+ ? {
1938
+ pingPongTarget: pingPongPair.target,
1939
+ pingPongFormat: pingPongPair.format
1940
+ }
1941
+ : {})
1942
+ });
1943
+ const workgroupSize = computePass.getWorkgroupSize();
1944
+ const storageBindGroup = getComputeStorageBindGroup();
1945
+ const storageTextureBindGroup = getComputeStorageTextureBindGroup();
1946
+ const iterations =
1947
+ computePass.isPingPong && computePass.getIterations ? computePass.getIterations() : 1;
1948
+ const currentOutput =
1949
+ computePass.isPingPong && computePass.getCurrentOutput
1950
+ ? computePass.getCurrentOutput()
1951
+ : null;
1952
+ const readFromAAtIterationZero =
1953
+ pingPongPair && currentOutput ? currentOutput !== `${pingPongPair.target}B` : true;
1954
+
1955
+ for (let iter = 0; iter < iterations; iter += 1) {
1956
+ const dispatch = computePass.resolveDispatch({
1957
+ width,
1958
+ height,
1959
+ time,
1960
+ delta,
1961
+ workgroupSize
1962
+ });
1963
+ const cPass = commandEncoder.beginComputePass();
1964
+ cPass.setPipeline(pipelineEntry.pipeline);
1965
+ cPass.setBindGroup(0, pipelineEntry.bindGroup);
1966
+ if (storageBindGroup) {
1967
+ cPass.setBindGroup(1, storageBindGroup);
1968
+ }
1969
+ if (pingPongPair) {
1970
+ const readFromA =
1971
+ iter % 2 === 0 ? readFromAAtIterationZero : !readFromAAtIterationZero;
1972
+ cPass.setBindGroup(
1973
+ 2,
1974
+ getPingPongStorageTextureBindGroup(pingPongPair.target, readFromA)
1975
+ );
1976
+ } else if (storageTextureBindGroup) {
1977
+ cPass.setBindGroup(2, storageTextureBindGroup);
1978
+ }
1979
+ cPass.dispatchWorkgroups(dispatch[0], dispatch[1], dispatch[2]);
1980
+ cPass.end();
1981
+ }
1982
+
1983
+ if (computePass.isPingPong && computePass.advanceFrame) {
1984
+ computePass.advanceFrame();
1985
+ }
1986
+ }
1987
+ }
1988
+ }
1989
+
1405
1990
  const scenePass = commandEncoder.beginRenderPass({
1406
1991
  colorAttachments: [
1407
1992
  {
@@ -1420,6 +2005,9 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1420
2005
 
1421
2006
  scenePass.setPipeline(pipeline);
1422
2007
  scenePass.setBindGroup(0, bindGroup);
2008
+ if (fragmentStorageBindGroup) {
2009
+ scenePass.setBindGroup(1, fragmentStorageBindGroup);
2010
+ }
1423
2011
  scenePass.draw(3);
1424
2012
  scenePass.end();
1425
2013
 
@@ -1448,10 +2036,15 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1448
2036
  };
1449
2037
 
1450
2038
  for (const step of graphPlan.steps) {
2039
+ // Compute passes already dispatched above
2040
+ if (step.kind === 'compute') {
2041
+ continue;
2042
+ }
2043
+
1451
2044
  const input = resolveStepSurface(step.input);
1452
2045
  const output = resolveStepSurface(step.output);
1453
2046
 
1454
- step.pass.render({
2047
+ (step.pass as RenderPass).render({
1455
2048
  device,
1456
2049
  commandEncoder,
1457
2050
  source: slots.source,
@@ -1467,7 +2060,12 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1467
2060
  clear: step.clear,
1468
2061
  clearColor: step.clearColor,
1469
2062
  preserve: step.preserve,
1470
- beginRenderPass: (passOptions) => {
2063
+ beginRenderPass: (passOptions?: {
2064
+ clear?: boolean;
2065
+ clearColor?: [number, number, number, number];
2066
+ preserve?: boolean;
2067
+ view?: GPUTextureView;
2068
+ }) => {
1471
2069
  const clear = passOptions?.clear ?? step.clear;
1472
2070
  const clearColor = passOptions?.clearColor ?? step.clearColor;
1473
2071
  const preserve = passOptions?.preserve ?? step.preserve;
@@ -1510,11 +2108,27 @@ export async function createRenderer(options: RendererOptions): Promise<Renderer
1510
2108
  initializationCleanups.length = 0;
1511
2109
  return {
1512
2110
  render,
2111
+ getStorageBuffer: (name: string): GPUBuffer | undefined => {
2112
+ return storageBufferMap.get(name);
2113
+ },
2114
+ getDevice: (): GPUDevice => {
2115
+ return device;
2116
+ },
1513
2117
  destroy: () => {
1514
2118
  isDestroyed = true;
1515
2119
  device.removeEventListener('uncapturederror', handleUncapturedError);
1516
2120
  frameBuffer.destroy();
1517
2121
  uniformBuffer.destroy();
2122
+ for (const buffer of storageBufferMap.values()) {
2123
+ buffer.destroy();
2124
+ }
2125
+ storageBufferMap.clear();
2126
+ for (const pair of pingPongTexturePairs.values()) {
2127
+ pair.textureA.destroy();
2128
+ pair.textureB.destroy();
2129
+ }
2130
+ pingPongTexturePairs.clear();
2131
+ computePipelineCache.clear();
1518
2132
  destroyRenderTexture(sourceSlotTarget);
1519
2133
  destroyRenderTexture(targetSlotTarget);
1520
2134
  for (const target of runtimeRenderTargets.values()) {