wgpu-kit 1.0.3 → 1.1.1

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.
@@ -0,0 +1,187 @@
1
+ import { GpuContext } from "../../core/context.js";
2
+ import { Buffer } from "../../core/buffer.js";
3
+ import { CompileError } from "../../core/errors.js";
4
+ const WG = 64;
5
+ const SCAN = 256;
6
+ const USIZE = 32;
7
+ export async function createNeighborGrid(config) {
8
+ const { count, worldHalf, cellSize, workgroupSize = WG } = config;
9
+ if (!Number.isInteger(count) || count <= 0)
10
+ throw new Error(`count must be a positive integer, got ${String(count)}`);
11
+ if (!(cellSize > 0))
12
+ throw new Error(`cellSize must be positive, got ${String(cellSize)}`);
13
+ const gridSize = Math.max(1, Math.ceil((2 * worldHalf) / cellSize));
14
+ const cells = gridSize * gridSize;
15
+ const ctx = await GpuContext.get();
16
+ const device = ctx.device;
17
+ const uniform = device.createBuffer({ size: USIZE, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, label: 'ngrid-params' });
18
+ const writeUniform = () => {
19
+ const b = new ArrayBuffer(USIZE);
20
+ const v = new DataView(b);
21
+ v.setUint32(0, count, true);
22
+ v.setUint32(4, 0, true);
23
+ v.setFloat32(8, worldHalf, true);
24
+ v.setUint32(12, gridSize, true);
25
+ v.setUint32(16, cells, true);
26
+ v.setUint32(20, 0, true);
27
+ v.setUint32(24, 0, true);
28
+ v.setUint32(28, 0, true);
29
+ device.queue.writeBuffer(uniform, 0, b);
30
+ };
31
+ writeUniform();
32
+ const cellCount = await Buffer.create('u32', cells);
33
+ const cellStart = await Buffer.create('u32', cells);
34
+ const cellFill = await Buffer.create('u32', cells);
35
+ const order = await Buffer.create('u32', count);
36
+ cellCount.write(new Uint32Array(cells));
37
+ const module = device.createShaderModule({ code: gridWgsl(), label: 'ngrid' });
38
+ const info = await module.getCompilationInfo();
39
+ const errors = info.messages.filter((m) => m.type === 'error');
40
+ if (errors.length > 0)
41
+ throw new CompileError('ngrid', errors.map((m) => ({ line: m.lineNum, msg: m.message })), 0);
42
+ const pCounts = device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint: 'main_counts' } });
43
+ const pScan = device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint: 'main_scan' } });
44
+ const pScatter = device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint: 'main_scatter' } });
45
+ const bgCounts = (pos) => device.createBindGroup({
46
+ layout: pCounts.getBindGroupLayout(0),
47
+ entries: [
48
+ { binding: 0, resource: { buffer: uniform } },
49
+ { binding: 1, resource: { buffer: pos.gpuBuffer } },
50
+ { binding: 2, resource: { buffer: cellCount.gpuBuffer } },
51
+ ],
52
+ });
53
+ const bgScan = device.createBindGroup({
54
+ layout: pScan.getBindGroupLayout(0),
55
+ entries: [
56
+ { binding: 0, resource: { buffer: uniform } },
57
+ { binding: 1, resource: { buffer: cellCount.gpuBuffer } },
58
+ { binding: 2, resource: { buffer: cellStart.gpuBuffer } },
59
+ { binding: 3, resource: { buffer: cellFill.gpuBuffer } },
60
+ ],
61
+ });
62
+ const bgScatter = (pos) => device.createBindGroup({
63
+ layout: pScatter.getBindGroupLayout(0),
64
+ entries: [
65
+ { binding: 0, resource: { buffer: uniform } },
66
+ { binding: 1, resource: { buffer: pos.gpuBuffer } },
67
+ { binding: 2, resource: { buffer: cellFill.gpuBuffer } },
68
+ { binding: 3, resource: { buffer: order.gpuBuffer } },
69
+ ],
70
+ });
71
+ return {
72
+ gridSize,
73
+ cells,
74
+ cellStart,
75
+ cellFill,
76
+ order,
77
+ update(pos) {
78
+ writeUniform();
79
+ const enc = device.createCommandEncoder();
80
+ const pass = enc.beginComputePass();
81
+ // 独立 pass:实测同 pass 连续 dispatch 存在旧数据可见性问题(Dawn/Windows)
82
+ pass.setPipeline(pCounts);
83
+ pass.setBindGroup(0, bgCounts(pos));
84
+ pass.dispatchWorkgroups(Math.ceil(count / WG));
85
+ pass.end();
86
+ const pass2 = enc.beginComputePass();
87
+ pass2.setPipeline(pScan);
88
+ pass2.setBindGroup(0, bgScan);
89
+ pass2.dispatchWorkgroups(1);
90
+ pass2.end();
91
+ const pass3 = enc.beginComputePass();
92
+ pass3.setPipeline(pScatter);
93
+ pass3.setBindGroup(0, bgScatter(pos));
94
+ pass3.dispatchWorkgroups(Math.ceil(count / WG));
95
+ pass3.end();
96
+ device.queue.submit([enc.finish()]);
97
+ },
98
+ destroy() {
99
+ cellCount.destroy();
100
+ cellStart.destroy();
101
+ cellFill.destroy();
102
+ order.destroy();
103
+ uniform.destroy();
104
+ },
105
+ };
106
+ }
107
+ function gridWgsl() {
108
+ return /* wgsl */ `
109
+ struct Params {
110
+ count: u32, _pad0: u32,
111
+ worldHalf: f32, gridSize: u32, cells: u32,
112
+ _p0: u32, _p1: u32, _p2: u32,
113
+ };
114
+ @group(0) @binding(0) var<uniform> params: Params;
115
+ @group(0) @binding(1) var<storage, read> posIn: array<vec2f>;
116
+ @group(0) @binding(2) var<storage, read_write> cellCount: array<atomic<u32>>;
117
+ @group(0) @binding(3) var<storage, read_write> cellStart: array<u32>;
118
+ @group(0) @binding(4) var<storage, read_write> cellFill: array<atomic<u32>>;
119
+ @group(0) @binding(5) var<storage, read_write> order: array<u32>;
120
+
121
+ fn cellOf(p: vec2f) -> u32 {
122
+ let g = i32(params.gridSize);
123
+ let span = params.worldHalf * 2.0;
124
+ let cx = clamp(i32(floor((p.x + params.worldHalf) / span * f32(g))), 0, g - 1);
125
+ let cy = clamp(i32(floor((p.y + params.worldHalf) / span * f32(g))), 0, g - 1);
126
+ return u32(cy) * u32(g) + u32(cx);
127
+ }
128
+
129
+ @compute @workgroup_size(${WG})
130
+ fn main_counts(@builtin(global_invocation_id) gid: vec3u) {
131
+ let i = gid.x;
132
+ if (i >= params.count) { return; }
133
+ atomicAdd(&cellCount[cellOf(posIn[i])], 1u);
134
+ }
135
+
136
+ var<workgroup> partial: array<u32, ${SCAN}>;
137
+ @compute @workgroup_size(${SCAN})
138
+ fn main_scan(@builtin(local_invocation_id) lid: vec3u, @builtin(workgroup_id) wid: vec3u) {
139
+ let tid = lid.x;
140
+ let cells = params.cells;
141
+ let wg = ${SCAN}u;
142
+ let chunks = (cells + wg - 1u) / wg;
143
+
144
+ // ① 本 workgroup 负责的 chunk 局部和
145
+ var local = 0u;
146
+ for (var c = 0u; c < chunks; c++) {
147
+ let idx = c * wg + tid;
148
+ if (idx < cells) { local = local + atomicLoad(&cellCount[idx]); }
149
+ }
150
+ partial[tid] = local;
151
+ workgroupBarrier();
152
+
153
+ // ② 局部和的含前缀扫描(Hillis-Steele)
154
+ var offset = 1u;
155
+ loop {
156
+ if (offset >= wg) { break; }
157
+ var v = 0u;
158
+ if (tid >= offset) { v = partial[tid - offset]; }
159
+ workgroupBarrier();
160
+ if (tid >= offset) { partial[tid] = partial[tid] + v; }
161
+ workgroupBarrier();
162
+ offset = offset << 1u;
163
+ }
164
+
165
+ // ③ chunk 基址 → start/fill,顺带把 count 归零给下一帧
166
+ var run = 0u;
167
+ if (tid > 0u) { run = partial[tid - 1u]; }
168
+ for (var c = 0u; c < chunks; c++) {
169
+ let idx = c * wg + tid;
170
+ if (idx < cells) {
171
+ cellStart[idx] = run;
172
+ atomicStore(&cellFill[idx], run);
173
+ run = run + atomicLoad(&cellCount[idx]);
174
+ atomicStore(&cellCount[idx], 0u);
175
+ }
176
+ }
177
+ }
178
+
179
+ @compute @workgroup_size(${WG})
180
+ fn main_scatter(@builtin(global_invocation_id) gid: vec3u) {
181
+ let i = gid.x;
182
+ if (i >= params.count) { return; }
183
+ let slot = atomicAdd(&cellFill[cellOf(posIn[i])], 1u);
184
+ order[slot] = i;
185
+ }
186
+ `;
187
+ }
@@ -4,7 +4,7 @@ const OPS = ['grayscale', 'invert', 'edge', 'blur', 'sharpen', 'brightness', 'co
4
4
  const OP_IDS = { grayscale: 0, invert: 1, edge: 2, blur: 3, sharpen: 4, brightness: 5, contrast: 6 };
5
5
  export async function applyImage(source, target, ops) {
6
6
  if (ops.length === 0)
7
- throw new Error('applyImage 需要至少一个算子');
7
+ throw new Error('applyImage requires at least one operator');
8
8
  const width = 'naturalWidth' in source ? source.naturalWidth : source.width;
9
9
  const height = 'naturalHeight' in source ? source.naturalHeight : source.height;
10
10
  const ctx = await GpuContext.get();
@@ -2,10 +2,11 @@ import { GpuContext } from "../../core/context.js";
2
2
  import { Buffer } from "../../core/buffer.js";
3
3
  import { PingPong } from "../../core/pingpong.js";
4
4
  import { CompileError } from "../../core/errors.js";
5
+ import { createNeighborGrid } from "../grid/index.js";
5
6
  import { mulberry32 } from "../particles/presets.js";
6
7
  const WG = 64;
7
8
  export async function boids(config = {}) {
8
- const { count: N = 3000, perception = 0.05, maxSpeed = 0.012, wSep = 1.6, wAli = 1.0, wCoh = 0.8, size = 0.009, seed = 'boids', } = config;
9
+ const { count: N = 1200, perception = 0.05, maxSpeed = 0.012, wSep = 1.6, wAli = 1.0, wCoh = 0.8, size = 0.009, seed = 'boids', } = config;
9
10
  const seedHash = typeof seed === 'string' ? hashStr(seed) : (seed ?? 3);
10
11
  const gridSize = Math.max(4, Math.ceil(2 / perception));
11
12
  const ctx = await GpuContext.get();
@@ -46,20 +47,12 @@ export async function boids(config = {}) {
46
47
  device.queue.writeBuffer(uniform, 0, b);
47
48
  };
48
49
  writeUniform();
49
- const cells = gridSize * gridSize;
50
- const cellCount = await Buffer.create('u32', cells);
51
- const cellStart = await Buffer.create('u32', cells);
52
- const cellFill = await Buffer.create('u32', cells);
53
- const order = await Buffer.create('u32', N);
54
- cellCount.write(new Uint32Array(cells));
55
50
  const module = device.createShaderModule({ code: boidsWgsl(size), label: 'boids' });
56
51
  const info = await module.getCompilationInfo();
57
52
  const errors = info.messages.filter((m) => m.type === 'error');
58
53
  if (errors.length > 0)
59
54
  throw new CompileError('boids', errors.map((m) => ({ line: m.lineNum, msg: m.message })), 0);
60
- const pCounts = device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint: 'main_counts' } });
61
- const pScan = device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint: 'main_scan' } });
62
- const pScatter = device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint: 'main_scatter' } });
55
+ const neighborGrid = await createNeighborGrid({ count: N, worldHalf: 1.0, cellSize: perception });
63
56
  const pForce = device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint: 'main_force' } });
64
57
  const pRender = device.createRenderPipeline({
65
58
  layout: 'auto',
@@ -67,32 +60,6 @@ export async function boids(config = {}) {
67
60
  fragment: { module, entryPoint: 'fs', targets: [{ format: navigator.gpu.getPreferredCanvasFormat() }] },
68
61
  primitive: { topology: 'triangle-list' },
69
62
  });
70
- const bgCounts = (read) => device.createBindGroup({
71
- layout: pCounts.getBindGroupLayout(0),
72
- entries: [
73
- { binding: 0, resource: { buffer: uniform } },
74
- { binding: 1, resource: { buffer: read.pos.gpuBuffer } },
75
- { binding: 2, resource: { buffer: cellCount.gpuBuffer } },
76
- ],
77
- });
78
- const bgScan = device.createBindGroup({
79
- layout: pScan.getBindGroupLayout(0),
80
- entries: [
81
- { binding: 0, resource: { buffer: uniform } },
82
- { binding: 2, resource: { buffer: cellCount.gpuBuffer } },
83
- { binding: 3, resource: { buffer: cellStart.gpuBuffer } },
84
- { binding: 4, resource: { buffer: cellFill.gpuBuffer } },
85
- ],
86
- });
87
- const bgScatter = (read) => device.createBindGroup({
88
- layout: pScatter.getBindGroupLayout(0),
89
- entries: [
90
- { binding: 0, resource: { buffer: uniform } },
91
- { binding: 1, resource: { buffer: read.pos.gpuBuffer } },
92
- { binding: 4, resource: { buffer: cellFill.gpuBuffer } },
93
- { binding: 5, resource: { buffer: order.gpuBuffer } },
94
- ],
95
- });
96
63
  const bgForce = (read, write) => device.createBindGroup({
97
64
  layout: pForce.getBindGroupLayout(0),
98
65
  entries: [
@@ -101,9 +68,9 @@ export async function boids(config = {}) {
101
68
  { binding: 6, resource: { buffer: read.vel.gpuBuffer } },
102
69
  { binding: 7, resource: { buffer: write.pos.gpuBuffer } },
103
70
  { binding: 8, resource: { buffer: write.vel.gpuBuffer } },
104
- { binding: 3, resource: { buffer: cellStart.gpuBuffer } },
105
- { binding: 4, resource: { buffer: cellFill.gpuBuffer } },
106
- { binding: 5, resource: { buffer: order.gpuBuffer } },
71
+ { binding: 3, resource: { buffer: neighborGrid.cellStart.gpuBuffer } },
72
+ { binding: 4, resource: { buffer: neighborGrid.cellFill.gpuBuffer } },
73
+ { binding: 5, resource: { buffer: neighborGrid.order.gpuBuffer } },
107
74
  ],
108
75
  });
109
76
  const bgRender = (read) => device.createBindGroup({
@@ -137,15 +104,6 @@ export async function boids(config = {}) {
137
104
  const write = useAB ? sideB : sideA;
138
105
  const enc = device.createCommandEncoder();
139
106
  const pass = enc.beginComputePass();
140
- pass.setPipeline(pCounts);
141
- pass.setBindGroup(0, bgCounts(read));
142
- pass.dispatchWorkgroups(Math.ceil(N / WG));
143
- pass.setPipeline(pScan);
144
- pass.setBindGroup(0, bgScan);
145
- pass.dispatchWorkgroups(1);
146
- pass.setPipeline(pScatter);
147
- pass.setBindGroup(0, bgScatter(read));
148
- pass.dispatchWorkgroups(Math.ceil(N / WG));
149
107
  pass.setPipeline(pForce);
150
108
  pass.setBindGroup(0, bgForce(read, write));
151
109
  pass.dispatchWorkgroups(Math.ceil(N / WG));
@@ -172,10 +130,7 @@ export async function boids(config = {}) {
172
130
  buffers() { return { pos: pp.current.pos, vel: pp.current.vel }; },
173
131
  destroy() {
174
132
  pp.destroy();
175
- cellCount.destroy();
176
- cellStart.destroy();
177
- cellFill.destroy();
178
- order.destroy();
133
+ neighborGrid.destroy();
179
134
  uniform.destroy();
180
135
  },
181
136
  };
@@ -223,57 +178,6 @@ fn cellOf(p: vec2f) -> u32 {
223
178
  return u32(cy) * u32(g) + u32(cx);
224
179
  }
225
180
 
226
- @compute @workgroup_size(${WG})
227
- fn main_counts(@builtin(global_invocation_id) gid: vec3u) {
228
- let i = gid.x;
229
- if (i >= params.count) { return; }
230
- atomicAdd(&cellCount[cellOf(posIn[i])], 1u);
231
- }
232
-
233
- var<workgroup> partial: array<u32, 256>;
234
- @compute @workgroup_size(256)
235
- fn main_scan(@builtin(local_invocation_id) lid: vec3u) {
236
- let tid = lid.x;
237
- let cells = params.gridSize * params.gridSize;
238
- let chunks = (cells + 255u) / 256u;
239
- var local = 0u;
240
- for (var c = 0u; c < chunks; c++) {
241
- let idx = c * 256u + tid;
242
- if (idx < cells) { local = local + atomicLoad(&cellCount[idx]); }
243
- }
244
- partial[tid] = local;
245
- workgroupBarrier();
246
- var offset = 1u;
247
- loop {
248
- if (offset >= 256u) { break; }
249
- var v = 0u;
250
- if (tid >= offset) { v = partial[tid - offset]; }
251
- workgroupBarrier();
252
- if (tid >= offset) { partial[tid] = partial[tid] + v; }
253
- workgroupBarrier();
254
- offset = offset << 1u;
255
- }
256
- var run = 0u;
257
- if (tid > 0u) { run = partial[tid - 1u]; }
258
- for (var c = 0u; c < chunks; c++) {
259
- let idx = c * 256u + tid;
260
- if (idx < cells) {
261
- cellStart[idx] = run;
262
- atomicStore(&cellFill[idx], run);
263
- run = run + atomicLoad(&cellCount[idx]);
264
- atomicStore(&cellCount[idx], 0u);
265
- }
266
- }
267
- }
268
-
269
- @compute @workgroup_size(${WG})
270
- fn main_scatter(@builtin(global_invocation_id) gid: vec3u) {
271
- let i = gid.x;
272
- if (i >= params.count) { return; }
273
- let slot = atomicAdd(&cellFill[cellOf(posIn[i])], 1u);
274
- order[slot] = i;
275
- }
276
-
277
181
  @compute @workgroup_size(${WG})
278
182
  fn main_force(@builtin(global_invocation_id) gid: vec3u) {
279
183
  let i = gid.x;
@@ -22,7 +22,8 @@ export interface ParticlesConfig {
22
22
  dt?: number;
23
23
  /** 点大小(canvas 像素单位的比例,默认 0.004;大规模下自动缩小) */
24
24
  pointSize?: number;
25
- /** 每粒子邻域候选上限(grid 模式;仅作极端抱团的保险丝,默认 32768 在支持密度内不触发——按格子顺序截断会引入方向偏差伪影) */
25
+ /** 每粒子邻域候选总上限(grid 模式,均摊到 3×3=9 )。默认 8100:正常密度永不触发,
26
+ * 极端抱团时以轻微方向偏差换取帧率稳定(实测 30ms → 7.5ms @ 200k 抱团态)。设 Infinity 可禁用。 */
26
27
  maxNeighbors?: number;
27
28
  }
28
29
  export interface ResolvedConfig {
@@ -1,17 +1,17 @@
1
1
  import { resolveMatrix, hashSeed } from "./presets.js";
2
- import { UsageError } from "../../core/errors.js";
2
+ import { ERR, UsageError } from "../../core/errors.js";
3
3
  const MODES = ['n2', 'tiled', 'grid'];
4
4
  export function resolveConfig(config = {}) {
5
5
  const { count = 8192, forces = 'cells', mode = 'grid', // 基准数据驱动:v0.4 起 grid 全面优于 tiled(0.54ms vs 3.62ms @16k),见 benchmarks.md
6
- color = 'species', bounds = 'wrap', seed = 'wgpu-kit', rMax = 0.12, beta = 0.3, forceFactor = 10, frictionHalfLife = 0.04, dt = 0.02, pointSize = 0.004, maxNeighbors = 32768, } = config;
6
+ color = 'species', bounds = 'wrap', seed = 'wgpu-kit', rMax = 0.12, beta = 0.3, forceFactor = 10, frictionHalfLife = 0.04, dt = 0.02, pointSize = 0.004, maxNeighbors = 8100, } = config;
7
7
  if (!Number.isInteger(count) || count <= 0 || count > 1_000_000) {
8
- throw new UsageError(`count 必须是 1..1_000_000 的整数,收到: ${String(count)}`);
8
+ throw new UsageError(ERR.USAGE, `count must be an integer in 1..1_000_000, got: ${String(count)}`);
9
9
  }
10
10
  if (!MODES.includes(mode)) {
11
- throw new UsageError(`mode 必须是 ${MODES.join(' | ')},收到: "${String(mode)}"`);
11
+ throw new UsageError(ERR.USAGE, `mode must be one of ${MODES.join(" | ")}, got: "${String(mode)}"`);
12
12
  }
13
13
  if (mode === 'n2' && count > 32_000) {
14
- throw new UsageError(`mode='n2' 建议 count 20000(当前 ${count});大规模请用 mode='tiled' 'grid'`);
14
+ throw new UsageError(ERR.USAGE, `mode='n2' is recommended for count <= 20000 (got ${count}); use 'tiled' or 'grid' for larger counts`);
15
15
  }
16
16
  const seedStr = String(seed);
17
17
  return {
@@ -40,9 +40,13 @@ fn main(@builtin(global_invocation_id) gid: vec3u) {
40
40
  `;
41
41
  }
42
42
  export function gridScanWgsl() {
43
- // 两级扫描:① workgroup 扫自己的 256-cell 块,块总和写入 blockSums;
44
- // ② 单 workgroup blockSums(最多 SCAN_WORKGROUP 个块 = 65536 cell);
45
- // workgroup 加上本块基址。256×256=65536 cell O(1) 轮次,不再随规模线性退化。
43
+ // workgroup 分块扫描:256-cell 块循环推进,块内 Hillis-Steele,块间用
44
+ // workgroup carry 串接。此前版本按多 workgroup 分两级写,但 dispatch(1)
45
+ // 只有 block 0 在跑 —— 256 格之外 cellStart 永远是 0、cellCount 永不清零,
46
+ // 对应粒子受力恒零被摩擦冻住(屏幕上出现水平"冻结带")。改成单 workgroup
47
+ // 循环后 barrier 是合法同步,正确性不依赖任何跨 workgroup 时序;
48
+ // cell 上限 65536 = 256 块,每块一轮微秒级,性能无虞。顺带把 cellCount
49
+ // 归零给下一帧。
46
50
  return /* wgsl */ `
47
51
  struct Params {
48
52
  count: u32, _pad0: u32,
@@ -54,63 +58,48 @@ struct Params {
54
58
  @group(0) @binding(1) var<storage, read_write> cellCount: array<atomic<u32>>;
55
59
  @group(0) @binding(2) var<storage, read_write> cellStart: array<u32>;
56
60
  @group(0) @binding(3) var<storage, read_write> cellFill: array<u32>;
57
- @group(0) @binding(4) var<storage, read_write> blockSums: array<u32>;
58
61
 
59
62
  var<workgroup> partial: array<u32, ${SCAN_WORKGROUP}>;
63
+ var<workgroup> carry: u32;
60
64
 
61
65
  @compute @workgroup_size(${SCAN_WORKGROUP})
62
- fn main(@builtin(local_invocation_id) lid: vec3u, @builtin(workgroup_id) wid: vec3u) {
66
+ fn main(@builtin(local_invocation_id) lid: vec3u) {
63
67
  let tid = lid.x;
64
68
  let wg = ${SCAN_WORKGROUP}u;
65
- let base = wid.x * wg;
66
69
  let cells = params.cells;
67
-
68
- // 块内 Hillis-Steele(块不足时以 0 填充)
69
- let v0 = select(0u, atomicLoad(&cellCount[base + tid]), base + tid < cells);
70
- partial[tid] = v0;
70
+ let numChunks = (cells + wg - 1u) / wg;
71
+ if (tid == 0u) { carry = 0u; }
71
72
  workgroupBarrier();
72
- var offset = 1u;
73
- loop {
74
- if (offset >= wg) { break; }
75
- var v = 0u;
76
- if (tid >= offset) { v = partial[tid - offset]; }
77
- workgroupBarrier();
78
- if (tid >= offset) { partial[tid] = partial[tid] + v; }
73
+ for (var ch = 0u; ch < numChunks; ch++) {
74
+ let idx = ch * wg + tid;
75
+ let inRange = idx < cells;
76
+ let v0 = select(0u, atomicLoad(&cellCount[idx]), inRange);
77
+ partial[tid] = v0;
79
78
  workgroupBarrier();
80
- offset = offset << 1u;
81
- }
82
- // 含前缀 → 排他:start = 块内前缀(不含自身),fill = start + count
83
- let myCount = v0;
84
- let myPrefix = select(partial[tid - 1u], 0u, tid == 0u);
85
- if (base + tid < cells) {
86
- cellStart[base + tid] = myPrefix;
87
- cellFill[base + tid] = myPrefix + myCount;
88
- }
89
- // 块总和 → blockSums(含)
90
- if (tid == 0u) { blockSums[wid.x] = partial[wg - 1u]; }
91
- workgroupBarrier();
92
-
93
- // ② 块间扫描(单 workgroup;块数 = ceil(cells/wg) ≤ SCAN_WORKGROUP)
94
- if (wid.x == 0u) {
95
- var off = 1u;
79
+ // 块内含前缀(Hillis-Steele)
80
+ var offset = 1u;
96
81
  loop {
97
- if (off >= wg) { break; }
82
+ if (offset >= wg) { break; }
98
83
  var v = 0u;
99
- if (tid >= off) { v = blockSums[tid - off]; }
84
+ if (tid >= offset) { v = partial[tid - offset]; }
100
85
  workgroupBarrier();
101
- if (tid >= off) { blockSums[tid] = blockSums[tid] + v; }
86
+ if (tid >= offset) { partial[tid] = partial[tid] + v; }
102
87
  workgroupBarrier();
103
- off = off << 1u;
88
+ offset = offset << 1u;
104
89
  }
105
- }
106
- workgroupBarrier();
107
-
108
- // 加块基址;cellCount 归零供下一帧
109
- let blockBase = select(0u, blockSums[wid.x - 1u], wid.x > 0u);
110
- if (base + tid < cells) {
111
- cellStart[base + tid] = cellStart[base + tid] + blockBase;
112
- cellFill[base + tid] = cellFill[base + tid] + blockBase;
113
- atomicStore(&cellCount[base + tid], 0u);
90
+ // 排他:start = carry + 块内前缀(不含自身);fill 是 scatter 的原子填充
91
+ // 游标,初始化为段起点(与通用包 NeighborGrid 同语义)——scatter 填完一格
92
+ // 后 fill 恰好 = start + count,力核读 [start, fill) 才不会多扫下一格的粒子。
93
+ if (inRange) {
94
+ let excl = partial[tid] - v0;
95
+ cellStart[idx] = carry + excl;
96
+ cellFill[idx] = carry + excl;
97
+ atomicStore(&cellCount[idx], 0u);
98
+ }
99
+ // 所有线程读完 partial/写完 carry 后才能进入下一块
100
+ workgroupBarrier();
101
+ if (tid == wg - 1u) { carry = carry + partial[wg - 1u]; }
102
+ workgroupBarrier();
114
103
  }
115
104
  }
116
105
  `;
@@ -37,8 +37,9 @@ export async function particles(config = {}) {
37
37
  }
38
38
  const phys = { rMax: cfg.rMax, beta: cfg.beta, forceFactor: cfg.forceFactor, frictionHalfLife: cfg.frictionHalfLife, dt: cfg.dt };
39
39
  const uniform = device.createBuffer({ size: USIZE, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, label: 'particles-params' });
40
- // grid 尺寸由 rMax 决定(格宽 rMax → 邻域恰好 3×3 格)
41
- const gridSizeOf = (rMax, half = worldHalf) => Math.max(4, Math.ceil((2 * half) / Math.max(rMax, 1e-3)));
40
+ // grid 尺寸由 rMax 决定(floor 确保格宽 rMax → 3×3 邻域完整覆盖交互半径;
41
+ // ceil 会导致格宽 < rMax,3×3 不够覆盖,边界粒子漏配邻居)
42
+ const gridSizeOf = (rMax, half = worldHalf) => Math.max(4, Math.floor((2 * half) / Math.max(rMax, 1e-3)));
42
43
  let gridSize = gridSizeOf(phys.rMax, worldHalf);
43
44
  const writeUniform = (dt) => {
44
45
  const buf = new ArrayBuffer(USIZE);
@@ -83,7 +84,6 @@ export async function particles(config = {}) {
83
84
  g.partial.destroy();
84
85
  g.sortedPos.destroy();
85
86
  g.sortedSp.destroy();
86
- g.blockSums.destroy();
87
87
  };
88
88
  const buildGrid = async (size) => {
89
89
  const cells = size * size;
@@ -102,7 +102,6 @@ export async function particles(config = {}) {
102
102
  const pForceCell = await makePipeline(mForce, 'main_force_cell', 'grid-force-cell');
103
103
  const pForceInt = await makePipeline(mForce, 'main_force_integrate', 'grid-force-integrate');
104
104
  const partial = await Buffer.create('vec2f', cfg.count * 9); // (粒子 × 3×3 格) 部分力
105
- const blockSums = await Buffer.create('u32', Math.ceil(cells / 256)); // 二级扫描块和
106
105
  const sortedPos = await Buffer.create('vec2f', cfg.count); // 按格子序重排的副本(合并访问)
107
106
  const sortedSp = await Buffer.create('u32', cfg.count);
108
107
  const bgCounts = (readPos) => device.createBindGroup({
@@ -120,7 +119,6 @@ export async function particles(config = {}) {
120
119
  { binding: 1, resource: { buffer: count.gpuBuffer } },
121
120
  { binding: 2, resource: { buffer: start.gpuBuffer } },
122
121
  { binding: 3, resource: { buffer: fill.gpuBuffer } },
123
- { binding: 4, resource: { buffer: blockSums.gpuBuffer } },
124
122
  ],
125
123
  });
126
124
  const bgScatter = (readPos) => device.createBindGroup({
@@ -170,7 +168,7 @@ export async function particles(config = {}) {
170
168
  };
171
169
  const state = {
172
170
  size,
173
- count, start, fill, order, partial, sortedPos, sortedSp, blockSums,
171
+ count, start, fill, order, partial, sortedPos, sortedSp,
174
172
  pCounts, pScan, pScatter, pForceCell, pForceInt,
175
173
  bgCountsA: bgCounts(sideA.pos), bgCountsB: bgCounts(sideB.pos),
176
174
  bgScan,
@@ -237,23 +235,25 @@ export async function particles(config = {}) {
237
235
  gridBindGroupsDirty = false;
238
236
  }
239
237
  const enc = device.createCommandEncoder();
240
- const pass = enc.beginComputePass();
241
238
  if (grid) {
242
- // 实测:同一 compute pass 内连续 dispatch 之间,后续 kernel 读到的可能
243
- // 是前一 kernel 的旧数据(Dawn/Windows,partial 竞态)——各段独立 pass
244
- // 提交以保证可见性。
245
- // encoder、五个独立 pass:同一 encoder 内 pass 天然按序执行且可见,
246
- // 免掉每帧 2 次额外 enc.finish()/submit(评审:提交间隔调度空隙)
247
- pass.setPipeline(grid.pCounts);
248
- pass.setBindGroup(0, useAB ? grid.bgCountsA : grid.bgCountsB);
249
- pass.dispatchWorkgroups(Math.ceil(cfg.count / WORKGROUP));
250
- pass.setPipeline(grid.pScan);
251
- pass.setBindGroup(0, grid.bgScan);
252
- pass.dispatchWorkgroups(1);
253
- pass.setPipeline(grid.pScatter);
254
- pass.setBindGroup(0, useAB ? grid.bgScatterA : grid.bgScatterB);
255
- pass.dispatchWorkgroups(Math.ceil(cfg.count / WORKGROUP));
256
- pass.end();
239
+ // 每个 build 阶段独立 pass:同 pass 内跨 dispatch 的存储可见性规范不保证
240
+ // (Dawn/Windows 实测出现过旧数据),counts→scan→scatter 是严格串行依赖。
241
+ // 单 encoder 内 pass 按序执行且天然可见,不增加 submit 次数。
242
+ const passCounts = enc.beginComputePass();
243
+ passCounts.setPipeline(grid.pCounts);
244
+ passCounts.setBindGroup(0, useAB ? grid.bgCountsA : grid.bgCountsB);
245
+ passCounts.dispatchWorkgroups(Math.ceil(cfg.count / WORKGROUP));
246
+ passCounts.end();
247
+ const passScan = enc.beginComputePass();
248
+ passScan.setPipeline(grid.pScan);
249
+ passScan.setBindGroup(0, grid.bgScan);
250
+ passScan.dispatchWorkgroups(1);
251
+ passScan.end();
252
+ const passScatter = enc.beginComputePass();
253
+ passScatter.setPipeline(grid.pScatter);
254
+ passScatter.setBindGroup(0, useAB ? grid.bgScatterA : grid.bgScatterB);
255
+ passScatter.dispatchWorkgroups(Math.ceil(cfg.count / WORKGROUP));
256
+ passScatter.end();
257
257
  const passB = enc.beginComputePass();
258
258
  passB.setPipeline(grid.pForceCell);
259
259
  passB.setBindGroup(0, useAB ? grid.bgForceCellAB : grid.bgForceCellBA);
@@ -267,6 +267,7 @@ export async function particles(config = {}) {
267
267
  device.queue.submit([enc.finish()]);
268
268
  }
269
269
  else {
270
+ const pass = enc.beginComputePass();
270
271
  pass.setPipeline(simPipeline);
271
272
  pass.setBindGroup(0, useAB ? bgAB : bgBA);
272
273
  pass.dispatchWorkgroups(Math.ceil(cfg.count / WORKGROUP));
@@ -67,7 +67,7 @@ export function resolveMatrix(forces, seed) {
67
67
  if (typeof forces === 'string') {
68
68
  const preset = FORCE_PRESETS[forces];
69
69
  if (!preset) {
70
- throw new Error(`未知力矩阵预设 "${forces}",可用: ${Object.keys(FORCE_PRESETS).join(', ')}, random`);
70
+ throw new Error(`Unknown force preset "${forces}". Available: ${Object.keys(FORCE_PRESETS).join(", ")}, random`);
71
71
  }
72
72
  return preset;
73
73
  }