wgpu-kit 1.1.0 → 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.
@@ -1,6 +1,6 @@
1
1
  import { TYPES } from "./layout.js";
2
2
  import { GpuContext } from "./context.js";
3
- import { UsageError } from "./errors.js";
3
+ import { ERR, UsageError } from "./errors.js";
4
4
  const TYPED_CTORS = {
5
5
  Float32Array, Int32Array, Uint32Array,
6
6
  };
@@ -16,6 +16,7 @@ export class Buffer {
16
16
  #ctx;
17
17
  #byteLength;
18
18
  #stride;
19
+ #reading = null;
19
20
  #staging = null;
20
21
  constructor(ctx, kind, length, gpuBuffer) {
21
22
  this.#ctx = ctx;
@@ -27,11 +28,11 @@ export class Buffer {
27
28
  }
28
29
  static async create(kind, length) {
29
30
  if (!Number.isInteger(length) || length <= 0) {
30
- throw new UsageError(`Buffer 长度必须是正整数,收到: ${String(length)}`);
31
+ throw new UsageError(ERR.BUFFER_CREATE, `Buffer length must be a positive integer, got: ${String(length)}`);
31
32
  }
32
33
  const def = TYPES[kind];
33
34
  if (!def)
34
- throw new UsageError(`未知类型 "${String(kind)}",可用: ${Object.keys(TYPES).join(', ')}`);
35
+ throw new UsageError(ERR.BUFFER_CREATE, `Unknown Buffer kind "${String(kind)}". Available: ${Object.keys(TYPES).join(", ")}`);
35
36
  const ctx = await GpuContext.get();
36
37
  const gpuBuffer = ctx.device.createBuffer({
37
38
  size: length * def.stride,
@@ -45,11 +46,11 @@ export class Buffer {
45
46
  const def = TYPES[this.kind];
46
47
  const ctor = TYPED_CTORS[def.typed];
47
48
  if (!(data instanceof ctor)) {
48
- throw new UsageError(`Buffer<${this.kind}>.write 需要 ${def.typed},收到 ${data.constructor?.name ?? typeof data}`);
49
+ throw new UsageError(ERR.BUFFER_WRITE, `Buffer<${this.kind}>.write expects ${def.typed}, got ${data.constructor?.name ?? typeof data}`);
49
50
  }
50
51
  const expected = this.length * def.comps;
51
52
  if (data.length !== expected) {
52
- throw new UsageError(`Buffer<${this.kind}>[${this.length}].write 需要 ${expected} 个分量,收到 ${data.length}`);
53
+ throw new UsageError(ERR.BUFFER_WRITE, `Buffer<${this.kind}>[${this.length}].write expects ${expected} components, got ${data.length}`);
53
54
  }
54
55
  if (def.stride === def.size || def.comps === 1) {
55
56
  this.#ctx.device.queue.writeBuffer(this.gpuBuffer, 0, data);
@@ -67,6 +68,17 @@ export class Buffer {
67
68
  }
68
69
  /** GPU → CPU:内部 staging buffer + mapAsync,mapAsync 的异步陷阱由库承担 */
69
70
  async read() {
71
+ if (this.#reading)
72
+ return this.#reading;
73
+ this.#reading = this.#doRead();
74
+ try {
75
+ return await this.#reading;
76
+ }
77
+ finally {
78
+ this.#reading = null;
79
+ }
80
+ }
81
+ async #doRead() {
70
82
  const def = TYPES[this.kind];
71
83
  if (!this.#staging) {
72
84
  this.#staging = this.#ctx.device.createBuffer({
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Error codes for programmatic identification of wgpu-kit errors.
3
+ * These are stable string constants — safe to switch on in user code.
4
+ */
5
+ export declare const ERR: {
6
+ /** WebGPU is unavailable or the adapter could not be acquired */
7
+ readonly WGPU_UNAVAILABLE: "ERR_WGPU_UNAVAILABLE";
8
+ /** WGSL compilation failed */
9
+ readonly COMPILE: "ERR_COMPILE";
10
+ /** Invalid argument passed by the caller */
11
+ readonly USAGE: "ERR_USAGE";
12
+ /** A required uniform field is missing or has an invalid value */
13
+ readonly UNIFORM_FIELD: "ERR_UNIFORM_FIELD";
14
+ /** Uniform scalar types are not yet supported */
15
+ readonly UNIFORM_UNSUPPORTED: "ERR_UNIFORM_UNSUPPORTED";
16
+ /** workgroupSize is outside the valid range */
17
+ readonly WORKGROUP_SIZE: "ERR_WORKGROUP_SIZE";
18
+ /** A resource (Buffer) is missing from the resources map */
19
+ readonly RESOURCE_MISSING: "ERR_RESOURCE_MISSING";
20
+ /** A resource type does not match the kernel declaration */
21
+ readonly RESOURCE_TYPE: "ERR_RESOURCE_TYPE";
22
+ /** Resource lengths are inconsistent across state/inputs */
23
+ readonly RESOURCE_LENGTH: "ERR_RESOURCE_LENGTH";
24
+ /** Buffer.create received an invalid kind or length */
25
+ readonly BUFFER_CREATE: "ERR_BUFFER_CREATE";
26
+ /** Buffer.write type or component count mismatch */
27
+ readonly BUFFER_WRITE: "ERR_BUFFER_WRITE";
28
+ /** MediaRecorder is unavailable or the recording failed */
29
+ readonly MEDIA: "ERR_MEDIA";
30
+ /** timestamp-query is not supported on this device */
31
+ readonly TIMESTAMP_UNSUPPORTED: "ERR_TIMESTAMP_UNSUPPORTED";
32
+ /** A generic library error that doesn't fit any specific code */
33
+ readonly GENERIC: "ERR_GENERIC";
34
+ };
35
+ export type ErrorCode = (typeof ERR)[keyof typeof ERR];
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Error codes for programmatic identification of wgpu-kit errors.
3
+ * These are stable string constants — safe to switch on in user code.
4
+ */
5
+ export const ERR = {
6
+ /** WebGPU is unavailable or the adapter could not be acquired */
7
+ WGPU_UNAVAILABLE: 'ERR_WGPU_UNAVAILABLE',
8
+ /** WGSL compilation failed */
9
+ COMPILE: 'ERR_COMPILE',
10
+ /** Invalid argument passed by the caller */
11
+ USAGE: 'ERR_USAGE',
12
+ /** A required uniform field is missing or has an invalid value */
13
+ UNIFORM_FIELD: 'ERR_UNIFORM_FIELD',
14
+ /** Uniform scalar types are not yet supported */
15
+ UNIFORM_UNSUPPORTED: 'ERR_UNIFORM_UNSUPPORTED',
16
+ /** workgroupSize is outside the valid range */
17
+ WORKGROUP_SIZE: 'ERR_WORKGROUP_SIZE',
18
+ /** A resource (Buffer) is missing from the resources map */
19
+ RESOURCE_MISSING: 'ERR_RESOURCE_MISSING',
20
+ /** A resource type does not match the kernel declaration */
21
+ RESOURCE_TYPE: 'ERR_RESOURCE_TYPE',
22
+ /** Resource lengths are inconsistent across state/inputs */
23
+ RESOURCE_LENGTH: 'ERR_RESOURCE_LENGTH',
24
+ /** Buffer.create received an invalid kind or length */
25
+ BUFFER_CREATE: 'ERR_BUFFER_CREATE',
26
+ /** Buffer.write type or component count mismatch */
27
+ BUFFER_WRITE: 'ERR_BUFFER_WRITE',
28
+ /** MediaRecorder is unavailable or the recording failed */
29
+ MEDIA: 'ERR_MEDIA',
30
+ /** timestamp-query is not supported on this device */
31
+ TIMESTAMP_UNSUPPORTED: 'ERR_TIMESTAMP_UNSUPPORTED',
32
+ /** A generic library error that doesn't fit any specific code */
33
+ GENERIC: 'ERR_GENERIC',
34
+ };
@@ -26,7 +26,7 @@ export class GpuContext {
26
26
  }
27
27
  static async #create() {
28
28
  if (typeof navigator === 'undefined' || !('gpu' in navigator) || !navigator.gpu) {
29
- throw new WebGPUUnavailableError('navigator.gpu 不存在');
29
+ throw new WebGPUUnavailableError('navigator.gpu is not available');
30
30
  }
31
31
  const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
32
32
  if (!adapter)
@@ -1,20 +1,32 @@
1
- /** 错误体系:所有错误说人话,给出定位与修复建议(章程原则 3)。 */
1
+ import { type ErrorCode } from './codes.ts';
2
+ /**
3
+ * Error hierarchy for wgpu-kit. All errors extend WgpuKitError and carry a
4
+ * stable machine-readable `code` for programmatic handling.
5
+ */
2
6
  export declare class WgpuKitError extends Error {
3
- constructor(message: string);
7
+ /** Stable error code, e.g. 'ERR_WGPU_UNAVAILABLE' — safe to switch on. */
8
+ readonly code: ErrorCode;
9
+ constructor(code: ErrorCode, message: string);
4
10
  }
5
- /** 环境无 WebGPU / 拿不到 adapter */
11
+ /** WebGPU is unavailable or the adapter could not be acquired. */
6
12
  export declare class WebGPUUnavailableError extends WgpuKitError {
7
13
  constructor(reason: string);
8
14
  }
9
- /** WGSL 编译错误,行号已映射回用户代码。 */
15
+ /** WGSL compilation failed. Line numbers are mapped back to user code. */
10
16
  export declare class CompileError extends WgpuKitError {
11
17
  constructor(kernelName: string, messages: readonly {
12
18
  line: number;
13
19
  msg: string;
14
20
  }[], userCodeOffset: number);
15
21
  }
16
- /** 调用方使用不当(缺资源/类型不匹配/长度不一致等) */
22
+ /** Caller passed an invalid argument (missing resource, type mismatch, bad length, etc.). */
17
23
  export declare class UsageError extends WgpuKitError {
24
+ constructor(code: ErrorCode, message: string);
18
25
  }
19
- /** 创建 compute 管线并用 pushErrorScope 捕获异步校验错误(超限等),把"黑屏刷屏"变成显式报错 */
26
+ /** Compute pipeline creation failed validation (e.g. too many storage buffers). */
27
+ export declare class PipelineError extends WgpuKitError {
28
+ constructor(label: string, detail: string);
29
+ }
30
+ export { ERR, type ErrorCode } from './codes.ts';
31
+ /** Create a compute pipeline with pushErrorScope to surface async validation errors. */
20
32
  export declare function createComputePipelineChecked(device: GPUDevice, module: GPUShaderModule, label: string, entryPoint?: string): Promise<GPUComputePipeline>;
@@ -1,42 +1,57 @@
1
- /** 错误体系:所有错误说人话,给出定位与修复建议(章程原则 3)。 */
1
+ import { ERR } from "./codes.js";
2
+ /**
3
+ * Error hierarchy for wgpu-kit. All errors extend WgpuKitError and carry a
4
+ * stable machine-readable `code` for programmatic handling.
5
+ */
2
6
  export class WgpuKitError extends Error {
3
- constructor(message) {
7
+ /** Stable error code, e.g. 'ERR_WGPU_UNAVAILABLE' — safe to switch on. */
8
+ code;
9
+ constructor(code, message) {
4
10
  super(message);
5
11
  this.name = new.target.name;
12
+ this.code = code;
6
13
  }
7
14
  }
8
- /** 环境无 WebGPU / 拿不到 adapter */
15
+ /** WebGPU is unavailable or the adapter could not be acquired. */
9
16
  export class WebGPUUnavailableError extends WgpuKitError {
10
17
  constructor(reason) {
11
- super(`当前环境不可用 WebGPU: ${reason}\n` +
12
- ' 排查:① 浏览器需 Chrome/Edge 113+ Safari 18+;② 无头环境需开启 WebGPU;③ 检查 GPU 驱动与硬件加速设置。\n' +
13
- ' 可用 navigator.gpu 是否存在快速判断。');
18
+ super(ERR.WGPU_UNAVAILABLE, `WebGPU is unavailable: ${reason}\n` +
19
+ ' Check: 1) Use Chrome/Edge 113+ or Safari 18+. 2) Enable WebGPU in headless mode. 3) Verify GPU drivers and hardware acceleration.');
14
20
  }
15
21
  }
16
- /** WGSL 编译错误,行号已映射回用户代码。 */
22
+ /** WGSL compilation failed. Line numbers are mapped back to user code. */
17
23
  export class CompileError extends WgpuKitError {
18
24
  constructor(kernelName, messages, userCodeOffset) {
19
25
  const mapped = messages
20
26
  .map((m) => {
21
27
  const userLine = m.line - userCodeOffset;
22
- const where = userLine > 0 ? `用户代码第 ${userLine} 行` : `生成代码第 ${m.line} (库的问题,欢迎报 issue)`;
28
+ const where = userLine > 0 ? `your code, line ${userLine}` : `generated code, line ${m.line} (library issue — please file an issue)`;
23
29
  return ` ${where}: ${m.msg}`;
24
30
  })
25
31
  .join('\n');
26
- super(`kernel "${kernelName}" WGSL 编译失败:\n${mapped}`);
32
+ super(ERR.COMPILE, `kernel "${kernelName}" WGSL compilation failed:\n${mapped}`);
27
33
  }
28
34
  }
29
- /** 调用方使用不当(缺资源/类型不匹配/长度不一致等) */
35
+ /** Caller passed an invalid argument (missing resource, type mismatch, bad length, etc.). */
30
36
  export class UsageError extends WgpuKitError {
37
+ constructor(code, message) {
38
+ super(code, message);
39
+ }
40
+ }
41
+ /** Compute pipeline creation failed validation (e.g. too many storage buffers). */
42
+ export class PipelineError extends WgpuKitError {
43
+ constructor(label, detail) {
44
+ super(ERR.GENERIC, `compute pipeline "${label}" creation failed: ${detail}`);
45
+ }
31
46
  }
32
- /** 创建 compute 管线并用 pushErrorScope 捕获异步校验错误(超限等),把"黑屏刷屏"变成显式报错 */
47
+ export { ERR } from "./codes.js";
48
+ /** Create a compute pipeline with pushErrorScope to surface async validation errors. */
33
49
  export async function createComputePipelineChecked(device, module, label, entryPoint = 'main') {
34
50
  device.pushErrorScope('validation');
35
51
  const pipeline = device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint } });
36
52
  const err = await device.popErrorScope();
37
53
  if (err) {
38
- throw new WgpuKitError(`compute 管线 "${label}" 创建失败: ${err.message}
39
- 常见原因:storage buffer 数超过每阶段上限(可向本库提 issue 申请 limits 支持)`);
54
+ throw new PipelineError(label, err.message);
40
55
  }
41
56
  return pipeline;
42
57
  }
@@ -1,7 +1,7 @@
1
1
  import { planUniform, packUniformInto, TYPES } from "./layout.js";
2
2
  import { GpuContext } from "./context.js";
3
3
  import { Buffer } from "./buffer.js";
4
- import { CompileError, UsageError } from "./errors.js";
4
+ import { CompileError, ERR, UsageError } from "./errors.js";
5
5
  const RESERVED = new Set(['count']);
6
6
  /** GPUBuffer 的稳定数字身份(WeakMap 分配,用于 bind group 缓存键) */
7
7
  let nextBufferId = 0;
@@ -19,24 +19,24 @@ export function generateElementKernel(spec) {
19
19
  const name = spec.name ?? 'kernel';
20
20
  const workgroupSize = spec.workgroupSize ?? 64;
21
21
  if (!Number.isInteger(workgroupSize) || workgroupSize < 1 || workgroupSize > 512) {
22
- throw new UsageError(`workgroupSize 必须在 1..512,收到: ${String(workgroupSize)}`);
22
+ throw new UsageError(ERR.WORKGROUP_SIZE, `workgroupSize must be in 1..512, got: ${String(workgroupSize)}`);
23
23
  }
24
24
  const state = Object.entries(spec.state ?? {});
25
25
  const inputs = Object.entries(spec.inputs ?? {});
26
26
  const uniforms = Object.entries(spec.uniforms ?? {});
27
27
  if (state.length + inputs.length === 0) {
28
- throw new UsageError(`elementKernel "${name}" 至少需要一个 state inputs 字段`);
28
+ throw new UsageError(ERR.RESOURCE_MISSING, `elementKernel "${name}" requires at least one state or inputs field`);
29
29
  }
30
30
  for (const [uName] of uniforms) {
31
31
  if (RESERVED.has(uName))
32
- throw new UsageError(`uniform "${uName}" 是保留名(count 由库自动注入)`);
32
+ throw new UsageError(ERR.USAGE, `uniform name "${uName}" is reserved (count is auto-injected by the library)`);
33
33
  }
34
34
  const seen = new Set([...state, ...inputs, ...uniforms].map(([n]) => n));
35
35
  if (seen.size !== state.length + inputs.length + uniforms.length) {
36
- throw new UsageError(`elementKernel "${name}" state/inputs/uniforms 存在重名字段`);
36
+ throw new UsageError(ERR.USAGE, `elementKernel "${name}" has duplicate field names across state/inputs/uniforms`);
37
37
  }
38
38
  if (typeof spec.code !== 'string' || spec.code.trim().length === 0) {
39
- throw new UsageError(`elementKernel "${name}" 缺少 code(用户 WGSL 函数)`);
39
+ throw new UsageError(ERR.USAGE, `elementKernel "${name}" is missing code (user WGSL function)`);
40
40
  }
41
41
  const uniformEntries = [...uniforms, ['count', 'u32']];
42
42
  const uniformLayout = planUniform(uniformEntries);
@@ -143,17 +143,17 @@ export function elementKernel(spec) {
143
143
  const f = orderedFields[i];
144
144
  const buf = resources[f.key];
145
145
  if (!buf)
146
- throw new UsageError(`kernel "${normalized.name}".run 缺少资源 "${f.key}"`);
146
+ throw new UsageError(ERR.RESOURCE_MISSING, `kernel "${normalized.name}".run is missing resource "${f.key}"`);
147
147
  const want = expectedKinds.get(f.key);
148
148
  if (buf.kind !== want) {
149
- throw new UsageError(`资源 "${f.key}" 类型不匹配: 需要 ${want},收到 ${buf.kind}`);
149
+ throw new UsageError(ERR.RESOURCE_TYPE, `Resource "${f.key}" type mismatch: expected ${want}, got ${buf.kind}`);
150
150
  }
151
151
  if (count === -1) {
152
152
  count = buf.length;
153
153
  firstKey = f.key;
154
154
  }
155
155
  else if (buf.length !== count) {
156
- throw new UsageError(`资源 "${f.key}" 长度 ${buf.length} "${firstKey}" ${count} 不一致`);
156
+ throw new UsageError(ERR.RESOURCE_LENGTH, `Resource "${f.key}" length ${buf.length} does not match "${firstKey}" length ${count}`);
157
157
  }
158
158
  ordered.push(buf);
159
159
  }
@@ -171,12 +171,15 @@ export function elementKernel(spec) {
171
171
  let cacheKey = 0;
172
172
  for (let i = 0; i < ordered.length; i++)
173
173
  cacheKey = (cacheKey * 31 + bufId(ordered[i].gpuBuffer)) | 0;
174
- let bg = bindGroupCache.get(cacheKey);
174
+ const ids = ordered.map((b) => bufId(b.gpuBuffer));
175
+ const cached = bindGroupCache.get(cacheKey);
176
+ const identityMatch = cached && cached.ids.length === ids.length && cached.ids.every((id, idx) => id === ids[idx]);
177
+ let bg = identityMatch ? cached.bg : undefined;
175
178
  if (!bg) {
176
179
  const entries = [{ binding: 0, resource: { buffer: uniformBuffer } }];
177
180
  ordered.forEach((buffer, i) => entries.push({ binding: i + 1, resource: { buffer: buffer.gpuBuffer } }));
178
181
  bg = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries });
179
- bindGroupCache.set(cacheKey, bg);
182
+ bindGroupCache.set(cacheKey, { bg, ids });
180
183
  }
181
184
  // —— 编码提交 ——
182
185
  const enc = device.createCommandEncoder();
@@ -1,3 +1,4 @@
1
+ import { ERR, UsageError } from "./errors.js";
1
2
  export const TYPES = {
2
3
  f32: { size: 4, stride: 4, align: 4, comps: 1, typed: 'Float32Array', wgsl: 'f32' },
3
4
  i32: { size: 4, stride: 4, align: 4, comps: 1, typed: 'Int32Array', wgsl: 'i32' },
@@ -38,13 +39,13 @@ export function packUniform(layout, values) {
38
39
  for (const f of layout.fields) {
39
40
  const pack = PACKERS[f.kind];
40
41
  if (!pack) {
41
- throw new Error(`uniform 字段 ${f.name} 的类型 ${f.kind} 暂不支持(当前仅支持标量)`);
42
+ throw new UsageError(ERR.UNIFORM_UNSUPPORTED, `Uniform field "${f.name}" has unsupported type "${f.kind}". Only scalars are currently supported.`);
42
43
  }
43
44
  const v = values[f.name];
44
45
  if (v === undefined)
45
- throw new Error(`缺少 uniform 值: ${f.name}`);
46
+ throw new UsageError(ERR.UNIFORM_FIELD, `Missing uniform value for "${f.name}"`);
46
47
  if (typeof v !== 'number' || !Number.isFinite(v)) {
47
- throw new Error(`uniform ${f.name} 必须是有限数字,收到: ${String(v)}`);
48
+ throw new UsageError(ERR.UNIFORM_FIELD, `Uniform value for "${f.name}" must be a finite number, got: ${String(v)}`);
48
49
  }
49
50
  pack(view, f.offset, v);
50
51
  }
@@ -56,13 +57,13 @@ export function packUniformInto(target, layout, values) {
56
57
  for (const f of layout.fields) {
57
58
  const pack = PACKERS[f.kind];
58
59
  if (!pack) {
59
- throw new Error(`uniform 字段 ${f.name} 的类型 ${f.kind} 暂不支持(当前仅支持标量)`);
60
+ throw new UsageError(ERR.UNIFORM_UNSUPPORTED, `Uniform field "${f.name}" has unsupported type "${f.kind}". Only scalars are currently supported.`);
60
61
  }
61
62
  const v = values[f.name];
62
63
  if (v === undefined)
63
- throw new Error(`缺少 uniform 值: ${f.name}`);
64
+ throw new UsageError(ERR.UNIFORM_FIELD, `Missing uniform value for "${f.name}"`);
64
65
  if (typeof v !== 'number' || !Number.isFinite(v)) {
65
- throw new Error(`uniform ${f.name} 必须是有限数字,收到: ${String(v)}`);
66
+ throw new UsageError(ERR.UNIFORM_FIELD, `Uniform value for "${f.name}" must be a finite number, got: ${String(v)}`);
66
67
  }
67
68
  pack(view, f.offset, v);
68
69
  }
@@ -20,7 +20,7 @@ export class PingPong {
20
20
  static async create(kinds, length) {
21
21
  const names = Object.keys(kinds);
22
22
  if (names.length === 0)
23
- throw new Error('PingPong 至少需要一个字段');
23
+ throw new Error('PingPong requires at least one field');
24
24
  const make = async () => {
25
25
  const side = {};
26
26
  for (const name of names)
package/dist/core/raw.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { GpuContext } from "./context.js";
2
- import { CompileError, UsageError } from "./errors.js";
2
+ import { CompileError, ERR, UsageError } from "./errors.js";
3
3
  export function rawKernel(code, entryPoint = 'main', label = 'rawKernel') {
4
4
  if (typeof code !== 'string' || code.trim().length === 0)
5
- throw new UsageError('rawKernel 需要 WGSL 代码');
5
+ throw new UsageError(ERR.USAGE, 'rawKernel requires WGSL code');
6
6
  let pipelinePromise = null;
7
7
  return {
8
8
  async run(entries, workgroups) {
9
9
  if (!Number.isInteger(workgroups) || workgroups < 1) {
10
- throw new UsageError(`rawKernel.run workgroups 必须是正整数,收到 ${String(workgroups)}`);
10
+ throw new UsageError(ERR.USAGE, `rawKernel.run workgroups must be a positive integer, got ${String(workgroups)}`);
11
11
  }
12
12
  const ctx = await GpuContext.get();
13
13
  if (!pipelinePromise) {
package/dist/media.js CHANGED
@@ -24,14 +24,14 @@ export class CanvasRecorder {
24
24
  constructor() {
25
25
  const mime = pickMime();
26
26
  if (!mime)
27
- throw new Error('当前环境不支持 MediaRecorder 录制(无可用编码)');
27
+ throw new Error('MediaRecorder is not supported in this environment');
28
28
  this.#mime = mime;
29
29
  }
30
30
  get mimeType() { return this.#mime; }
31
31
  get recording() { return this.#recorder?.state === 'recording'; }
32
32
  start(canvas, videoBitsPerSecond = 12_000_000) {
33
33
  if (this.#recorder)
34
- throw new Error('已在录制中');
34
+ throw new Error('Recording already in progress');
35
35
  const stream = canvas.captureStream(60);
36
36
  this.#chunks = [];
37
37
  this.#recorder = new MediaRecorder(stream, { mimeType: this.#mime, videoBitsPerSecond });
@@ -51,7 +51,7 @@ export class CanvasRecorder {
51
51
  const blob = new Blob(this.#chunks, { type: this.#mime });
52
52
  this.#recorder = null;
53
53
  if (blob.size === 0) {
54
- err(new Error(`录制产物为空(${this.#mime});编码器可能不可用,换浏览器或网络前重试`));
54
+ err(new Error(`Recording produced 0 bytes (${this.#mime}); encoder may be unavailable`));
55
55
  return;
56
56
  }
57
57
  ok({ blob, mimeType: this.#mime, seconds: (performance.now() - this.#startedAt) / 1000, bytes: blob.size });
package/dist/observe.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * —— 评审指出的"最大产品级缺口":GPGPU 库却看不到"这帧 GPU 花了多少"。
4
4
  */
5
5
  import { GpuContext } from "./core/context.js";
6
- import { UsageError } from "./core/errors.js";
6
+ import { ERR, UsageError } from "./core/errors.js";
7
7
  /** 时间戳查询封装:测量一段 GPU 工作的真实耗时(毫秒)。
8
8
  * Chrome/Edge 支持 timestamp-query;不支持的浏览器 reject。 */
9
9
  export async function timeGpu(fn) {
@@ -11,7 +11,7 @@ export async function timeGpu(fn) {
11
11
  const device = ctx.device;
12
12
  const featureOk = device.features.has('timestamp-query');
13
13
  if (!featureOk)
14
- throw new UsageError('timestamp-query 在当前设备不可用( Chrome/Edge + 支持时间戳的 GPU)');
14
+ throw new UsageError(ERR.TIMESTAMP_UNSUPPORTED, 'timestamp-query is not supported on this device (requires Chrome/Edge + a GPU with timestamp support)');
15
15
  const QUERY_POOL = 2;
16
16
  const querySet = device.createQuerySet({ type: 'timestamp', count: QUERY_POOL });
17
17
  const resolveBuf = device.createBuffer({
@@ -7,9 +7,9 @@ const USIZE = 32;
7
7
  export async function createNeighborGrid(config) {
8
8
  const { count, worldHalf, cellSize, workgroupSize = WG } = config;
9
9
  if (!Number.isInteger(count) || count <= 0)
10
- throw new Error(`count 必须是正整数,收到 ${String(count)}`);
10
+ throw new Error(`count must be a positive integer, got ${String(count)}`);
11
11
  if (!(cellSize > 0))
12
- throw new Error(`cellSize 必须为正,收到 ${String(cellSize)}`);
12
+ throw new Error(`cellSize must be positive, got ${String(cellSize)}`);
13
13
  const gridSize = Math.max(1, Math.ceil((2 * worldHalf) / cellSize));
14
14
  const cells = gridSize * gridSize;
15
15
  const ctx = await GpuContext.get();
@@ -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();
@@ -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
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,12 +40,13 @@ fn main(@builtin(global_invocation_id) gid: vec3u) {
40
40
  `;
41
41
  }
42
42
  export function gridScanWgsl() {
43
- // 两级扫描,三 pass 结构(pass 边界保证跨 workgroup 可见性):
44
- // A main_scan_blocks : workgroup 对自己的 256-cell 块做排他扫描 → cellFill(临时),
45
- // 块总和写入 blockSums[wid]
46
- // B main_scan_bases : workgroup 对 blockSums 做排他扫描 → 各块基址
47
- // C main_scan_apply : start = cellFill + base;fill = start + count;counts 归零
48
- // 支持至 65536 cell(gridSize 256);更大的世界需要多 pass 分块升级(路线图)。
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
+ // 归零给下一帧。
49
50
  return /* wgsl */ `
50
51
  struct Params {
51
52
  count: u32, _pad0: u32,
@@ -56,70 +57,50 @@ struct Params {
56
57
  @group(0) @binding(0) var<uniform> params: Params;
57
58
  @group(0) @binding(1) var<storage, read_write> cellCount: array<atomic<u32>>;
58
59
  @group(0) @binding(2) var<storage, read_write> cellStart: array<u32>;
59
- @group(0) @binding(3) var<storage, read_write> cellFill: array<atomic<u32>>;
60
- @group(0) @binding(4) var<storage, read_write> blockSums: array<atomic<u32>>;
60
+ @group(0) @binding(3) var<storage, read_write> cellFill: array<u32>;
61
61
 
62
62
  var<workgroup> partial: array<u32, ${SCAN_WORKGROUP}>;
63
+ var<workgroup> carry: u32;
63
64
 
64
- // Pass A:块内排他扫描。cellFill[c] = 块内排他前缀(临时);blockSums[wid] = 块总和
65
65
  @compute @workgroup_size(${SCAN_WORKGROUP})
66
- fn main_scan_blocks(@builtin(local_invocation_id) lid: vec3u, @builtin(workgroup_id) wid: vec3u) {
66
+ fn main(@builtin(local_invocation_id) lid: vec3u) {
67
67
  let tid = lid.x;
68
68
  let wg = ${SCAN_WORKGROUP}u;
69
- let base = wid.x * wg;
70
69
  let cells = params.cells;
71
- let v0 = select(0u, atomicLoad(&cellCount[base + tid]), base + tid < cells);
72
- partial[tid] = v0;
70
+ let numChunks = (cells + wg - 1u) / wg;
71
+ if (tid == 0u) { carry = 0u; }
73
72
  workgroupBarrier();
74
- var offset = 1u;
75
- loop {
76
- if (offset >= wg) { break; }
77
- var v = 0u;
78
- if (tid >= offset) { v = partial[tid - offset]; }
79
- workgroupBarrier();
80
- 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;
81
78
  workgroupBarrier();
82
- offset = offset << 1u;
83
- }
84
- // 含前缀 → 排他:excl = incl - own
85
- if (base + tid < cells) {
86
- atomicStore(&cellFill[base + tid], partial[tid] - v0);
87
- }
88
- if (tid == 0u) { atomicStore(&blockSums[wid.x], partial[wg - 1u]); }
89
- }
90
-
91
- // Pass B:单 workgroup 对 blockSums 做排他扫描 → 各块基址
92
- @compute @workgroup_size(${SCAN_WORKGROUP})
93
- fn main_scan_bases(@builtin(local_invocation_id) lid: vec3u) {
94
- let tid = lid.x;
95
- let nBlocks = ceil(f32(params.cells) / ${SCAN_WORKGROUP}.0);
96
- let v0 = select(atomicLoad(&blockSums[tid]), 0u, f32(tid) >= nBlocks);
97
- partial[tid] = v0;
98
- workgroupBarrier();
99
- var offset = 1u;
100
- loop {
101
- if (offset >= ${SCAN_WORKGROUP}u) { break; }
102
- var v = 0u;
103
- if (tid >= offset) { v = partial[tid - offset]; }
79
+ // 块内含前缀(Hillis-Steele)
80
+ var offset = 1u;
81
+ loop {
82
+ if (offset >= wg) { break; }
83
+ var v = 0u;
84
+ if (tid >= offset) { v = partial[tid - offset]; }
85
+ workgroupBarrier();
86
+ if (tid >= offset) { partial[tid] = partial[tid] + v; }
87
+ workgroupBarrier();
88
+ offset = offset << 1u;
89
+ }
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 后才能进入下一块
104
100
  workgroupBarrier();
105
- if (tid >= offset) { partial[tid] = partial[tid] + v; }
101
+ if (tid == wg - 1u) { carry = carry + partial[wg - 1u]; }
106
102
  workgroupBarrier();
107
- offset = offset << 1u;
108
103
  }
109
- atomicStore(&blockSums[tid], partial[tid] - v0);
110
- }
111
-
112
- // Pass C:加块基址 → 最终 start/fill;counts 归零供下一帧
113
- @compute @workgroup_size(${SCAN_WORKGROUP})
114
- fn main_scan_apply(@builtin(global_invocation_id) gid: vec3u) {
115
- let i = gid.x;
116
- if (i >= params.cells) { return; }
117
- let block = i / ${SCAN_WORKGROUP}u;
118
- let base = atomicLoad(&blockSums[block]);
119
- let excl = atomicLoad(&cellFill[i]);
120
- cellStart[i] = excl + base;
121
- atomicExchange(&cellFill[i], excl + base);
122
- atomicStore(&cellCount[i], 0u);
123
104
  }
124
105
  `;
125
106
  }
@@ -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;
@@ -97,14 +97,11 @@ export async function particles(config = {}) {
97
97
  const mScatter = await compile(gridScatterWgsl(), 'grid-scatter');
98
98
  const mForce = await compile(gridForceWgsl(4), 'grid-force');
99
99
  const pCounts = await makePipeline(mCounts, 'main', 'grid-counts');
100
- const pScanBlocks = await makePipeline(mScan, 'main_scan_blocks', 'grid-scan-blocks');
101
- const pScanBases = await makePipeline(mScan, 'main_scan_bases', 'grid-scan-bases');
102
- const pScanApply = await makePipeline(mScan, 'main_scan_apply', 'grid-scan-apply');
100
+ const pScan = await makePipeline(mScan, 'main', 'grid-scan');
103
101
  const pScatter = await makePipeline(mScatter, 'main', 'grid-scatter');
104
102
  const pForceCell = await makePipeline(mForce, 'main_force_cell', 'grid-force-cell');
105
103
  const pForceInt = await makePipeline(mForce, 'main_force_integrate', 'grid-force-integrate');
106
104
  const partial = await Buffer.create('vec2f', cfg.count * 9); // (粒子 × 3×3 格) 部分力
107
- const blockSums = await Buffer.create('u32', Math.ceil(cells / 256)); // 二级扫描块和
108
105
  const sortedPos = await Buffer.create('vec2f', cfg.count); // 按格子序重排的副本(合并访问)
109
106
  const sortedSp = await Buffer.create('u32', cfg.count);
110
107
  const bgCounts = (readPos) => device.createBindGroup({
@@ -115,34 +112,13 @@ export async function particles(config = {}) {
115
112
  { binding: 2, resource: { buffer: count.gpuBuffer } },
116
113
  ],
117
114
  });
118
- const bgScanBlocks = device.createBindGroup({
119
- layout: pScanBlocks.getBindGroupLayout(0),
115
+ const bgScan = device.createBindGroup({
116
+ layout: pScan.getBindGroupLayout(0),
120
117
  entries: [
121
118
  { binding: 0, resource: { buffer: uniform } },
122
119
  { binding: 1, resource: { buffer: count.gpuBuffer } },
123
120
  { binding: 2, resource: { buffer: start.gpuBuffer } },
124
121
  { binding: 3, resource: { buffer: fill.gpuBuffer } },
125
- { binding: 4, resource: { buffer: blockSums.gpuBuffer } },
126
- ],
127
- });
128
- const bgScanBases = device.createBindGroup({
129
- layout: pScanBases.getBindGroupLayout(0),
130
- entries: [
131
- { binding: 0, resource: { buffer: uniform } },
132
- { binding: 1, resource: { buffer: count.gpuBuffer } },
133
- { binding: 2, resource: { buffer: start.gpuBuffer } },
134
- { binding: 3, resource: { buffer: fill.gpuBuffer } },
135
- { binding: 4, resource: { buffer: blockSums.gpuBuffer } },
136
- ],
137
- });
138
- const bgScanApply = device.createBindGroup({
139
- layout: pScanApply.getBindGroupLayout(0),
140
- entries: [
141
- { binding: 0, resource: { buffer: uniform } },
142
- { binding: 1, resource: { buffer: count.gpuBuffer } },
143
- { binding: 2, resource: { buffer: start.gpuBuffer } },
144
- { binding: 3, resource: { buffer: fill.gpuBuffer } },
145
- { binding: 4, resource: { buffer: blockSums.gpuBuffer } },
146
122
  ],
147
123
  });
148
124
  const bgScatter = (readPos) => device.createBindGroup({
@@ -192,11 +168,10 @@ export async function particles(config = {}) {
192
168
  };
193
169
  const state = {
194
170
  size,
195
- cells,
196
- count, start, fill, order, partial, sortedPos, sortedSp, blockSums,
197
- pCounts, pScanBlocks, pScanBases, pScanApply, pScatter, pForceCell, pForceInt,
171
+ count, start, fill, order, partial, sortedPos, sortedSp,
172
+ pCounts, pScan, pScatter, pForceCell, pForceInt,
198
173
  bgCountsA: bgCounts(sideA.pos), bgCountsB: bgCounts(sideB.pos),
199
- bgScanBlocks, bgScanBases, bgScanApply,
174
+ bgScan,
200
175
  bgScatterA: bgScatter(sideA.pos), bgScatterB: bgScatter(sideB.pos),
201
176
  bgForceCellAB: bgForceCell(sideA.pos), bgForceCellBA: bgForceCell(sideB.pos),
202
177
  bgIntegrateAB: bgIntegrate(sideA, sideB), bgIntegrateBA: bgIntegrate(sideB, sideA),
@@ -259,28 +234,39 @@ export async function particles(config = {}) {
259
234
  grid.bgForceCellRebuild(sideA.pos);
260
235
  gridBindGroupsDirty = false;
261
236
  }
237
+ const enc = device.createCommandEncoder();
262
238
  if (grid) {
263
- // 五段各自独立 encoder+submit:WebGPU 同队列按提交序执行,
264
- // pass 边界保证跨 workgroup 可见性( pass 内多 dispatch 曾实测读到旧数据)
265
- const runPass = (pipeline, bg, wgs) => {
266
- const e = device.createCommandEncoder();
267
- const p = e.beginComputePass();
268
- p.setPipeline(pipeline);
269
- p.setBindGroup(0, bg);
270
- p.dispatchWorkgroups(wgs);
271
- p.end();
272
- device.queue.submit([e.finish()]);
273
- };
274
- const nCellWg = Math.ceil(grid.cells / 256);
275
- runPass(grid.pCounts, useAB ? grid.bgCountsA : grid.bgCountsB, Math.ceil(cfg.count / WORKGROUP));
276
- runPass(grid.pScanBlocks, grid.bgScanBlocks, nCellWg);
277
- runPass(grid.pScanBases, grid.bgScanBases, 1);
278
- runPass(grid.pScatter, useAB ? grid.bgScatterA : grid.bgScatterB, Math.ceil(cfg.count / WORKGROUP));
279
- runPass(grid.pForceCell, useAB ? grid.bgForceCellAB : grid.bgForceCellBA, Math.ceil((cfg.count * 9) / WORKGROUP));
280
- runPass(grid.pForceInt, useAB ? grid.bgIntegrateAB : grid.bgIntegrateBA, Math.ceil(cfg.count / WORKGROUP));
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
+ const passB = enc.beginComputePass();
258
+ passB.setPipeline(grid.pForceCell);
259
+ passB.setBindGroup(0, useAB ? grid.bgForceCellAB : grid.bgForceCellBA);
260
+ passB.dispatchWorkgroups(Math.ceil((cfg.count * 9) / WORKGROUP));
261
+ passB.end();
262
+ const passC = enc.beginComputePass();
263
+ passC.setPipeline(grid.pForceInt);
264
+ passC.setBindGroup(0, useAB ? grid.bgIntegrateAB : grid.bgIntegrateBA);
265
+ passC.dispatchWorkgroups(Math.ceil(cfg.count / WORKGROUP));
266
+ passC.end();
267
+ device.queue.submit([enc.finish()]);
281
268
  }
282
269
  else {
283
- const enc = device.createCommandEncoder();
284
270
  const pass = enc.beginComputePass();
285
271
  pass.setPipeline(simPipeline);
286
272
  pass.setBindGroup(0, useAB ? bgAB : bgBA);
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wgpu-kit",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Creative-coding GPU toolkit for the browser. 200k-particle physics at 120fps in 5 lines of code. WebGPU compute without the boilerplate.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -103,5 +103,10 @@
103
103
  "bugs": {
104
104
  "url": "https://github.com/nanfengw0w/wgpu-kit/issues"
105
105
  },
106
- "homepage": "https://github.com/nanfengw0w/wgpu-kit#readme"
106
+ "homepage": "https://github.com/nanfengw0w/wgpu-kit#readme",
107
+ "peerDependenciesMeta": {
108
+ "react": {
109
+ "optional": true
110
+ }
111
+ }
107
112
  }