@plasius/gpu-worker 0.1.1 → 0.1.2

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.
package/CHANGELOG.md CHANGED
@@ -20,6 +20,32 @@ The format is based on **[Keep a Changelog](https://keepachangelog.com/en/1.1.0/
20
20
  - **Security**
21
21
  - (placeholder)
22
22
 
23
+ ## [0.1.2] - 2026-01-24
24
+
25
+ - **Added**
26
+ - `loadJobWgsl` to register multiple job WGSL modules and receive `job_type` ids.
27
+ - Job-aware `assembleWorkerWgsl` that appends registered jobs and generates a dispatch wrapper.
28
+ - Optional debug mode for WGSL identifier clash detection during assembly.
29
+ - `loadQueueWgsl` helper that can apply queue compatibility renames during load.
30
+ - `createWorkerLoop` helper to drive worker/job compute dispatch at max or throttled rates.
31
+ - Demo denoise job WGSL (`demo/jobs/denoise.wgsl`) with a compute pass + present shader.
32
+ - Temporal denoise history buffer in the demo to stabilize jittered lighting.
33
+
34
+ - **Changed**
35
+ - Demo visuals now render a campfire scene with deferred lighting (G-buffer + fullscreen lighting pass).
36
+ - Demo now builds per-type worklists from queue jobs and uses indirect draws for render jobs alongside physics jobs.
37
+ - `src/worker.wgsl` is now a minimal worker template; demo kernels live in `demo/jobs/*.wgsl`.
38
+ - Demo job shaders are split into `demo/jobs/common.wgsl`, `demo/jobs/physics.job.wgsl`, and `demo/jobs/render.job.wgsl`.
39
+ - Demo lighting sampling now uses screen-space jitter to avoid world-space banding artifacts.
40
+ - `assembleWorkerWgsl` can now consume a registry or explicit job list and emit a dispatching `process_job`.
41
+ - `assembleWorkerWgsl` now applies queue compatibility renames (e.g., `JobMeta` -> `JobDesc`) by default.
42
+
43
+ - **Fixed**
44
+ - Reduced diagonal banding artifacts in the demo lighting pass.
45
+
46
+ - **Security**
47
+ - (placeholder)
48
+
23
49
  ## [0.1.1] - 2026-01-23
24
50
 
25
51
  - **Added**
@@ -88,9 +114,7 @@ The format is based on **[Keep a Changelog](https://keepachangelog.com/en/1.1.0/
88
114
 
89
115
  ---
90
116
 
91
- [Unreleased]: https://github.com/Plasius-LTD/gpu-worker/compare/v0.1.1...HEAD
117
+ [Unreleased]: https://github.com/Plasius-LTD/gpu-worker/compare/v0.1.2...HEAD
92
118
  [0.1.0-beta.1]: https://github.com/Plasius-LTD/gpu-worker/releases/tag/v0.1.0-beta.1
93
119
  [0.1.0]: https://github.com/Plasius-LTD/gpu-worker/releases/tag/v0.1.0
94
- [0.2.0]: https://github.com/Plasius-LTD/gpu-worker/releases/tag/v0.2.0
95
- [0.3.0]: https://github.com/Plasius-LTD/gpu-worker/releases/tag/v0.3.0
96
- [0.1.1]: https://github.com/Plasius-LTD/gpu-worker/releases/tag/v0.1.1
120
+ [0.1.2]: https://github.com/Plasius-LTD/gpu-worker/releases/tag/v0.1.2
package/README.md CHANGED
@@ -15,15 +15,68 @@ npm install @plasius/gpu-worker
15
15
 
16
16
  ## Usage
17
17
  ```js
18
- import { assembleWorkerWgsl, loadWorkerWgsl } from "@plasius/gpu-worker";
18
+ import {
19
+ assembleWorkerWgsl,
20
+ createWorkerLoop,
21
+ loadJobWgsl,
22
+ loadWorkerWgsl,
23
+ } from "@plasius/gpu-worker";
19
24
 
20
25
  const workerWgsl = await loadWorkerWgsl();
21
- const shaderCode = await assembleWorkerWgsl(workerWgsl);
26
+ const jobType = await loadJobWgsl({
27
+ wgsl: `
28
+ fn process_job(job_index: u32, job_type: u32, payload_words: u32) {
29
+ // job logic here
30
+ }
31
+ `,
32
+ label: "physics",
33
+ });
34
+
35
+ const shaderCode = await assembleWorkerWgsl(workerWgsl, { debug: true });
22
36
  // Pass shaderCode to device.createShaderModule({ code: shaderCode })
23
37
  ```
24
38
 
25
- `assembleWorkerWgsl` also accepts an optional second argument to override the queue WGSL source:
26
- `assembleWorkerWgsl(workerWgsl, { queueWgsl, queueUrl, fetcher })`.
39
+ `loadJobWgsl` registers job WGSL and returns the assigned `job_type` index.
40
+ Call `assembleWorkerWgsl` again after registering new jobs to rebuild the
41
+ combined WGSL. Job types are assigned in registration order, so keep the
42
+ registration order stable across rebuilds if you need deterministic ids.
43
+
44
+ `assembleWorkerWgsl` also accepts an optional second argument to override the
45
+ queue WGSL source: `assembleWorkerWgsl(workerWgsl, { queueWgsl, queueUrl, fetcher })`.
46
+ By default it applies queue compatibility renames (for example `JobMeta` -> `JobDesc`);
47
+ set `queueCompat: false` to disable that behavior.
48
+ If you are concatenating WGSL manually, `loadQueueWgsl` provides the same
49
+ compatibility renames by default: `loadQueueWgsl({ url, fetcher, queueCompat: false })`.
50
+
51
+ To bypass the registry, pass jobs directly:
52
+ ```js
53
+ const shaderCode = await assembleWorkerWgsl(workerWgsl, {
54
+ jobs: [{ wgsl: jobA }, { wgsl: jobB, label: "lighting" }],
55
+ debug: true,
56
+ });
57
+ ```
58
+
59
+ When assembling jobs, each job WGSL must define
60
+ `process_job(job_index, job_type, payload_words)`. The assembler rewrites each
61
+ job's `process_job` to a unique name and generates a dispatcher based on
62
+ `job_type`. Set `debug: true` to detect identifier clashes across appended WGSL.
63
+
64
+ To run the worker loop at the highest practical rate (or a target rate), use the
65
+ helper:
66
+ ```js
67
+ const loop = createWorkerLoop({
68
+ device,
69
+ worker: { pipeline: workerPipeline, bindGroups: [queueBindGroup, simBindGroup] },
70
+ jobs: [
71
+ { pipeline: physicsPipeline, bindGroups: [queueBindGroup, simBindGroup], workgroups: physicsWorkgroups },
72
+ { pipeline: renderIndirectPipeline, bindGroups: [queueBindGroup, simBindGroup], workgroups: 1 },
73
+ ],
74
+ workgroupSize: 64,
75
+ maxJobsPerDispatch: queueCapacity,
76
+ // rateHz: 120, // optional throttle; omit for animation-frame cadence
77
+ });
78
+ loop.start();
79
+ ```
27
80
 
28
81
  ## What this is
29
82
  - A minimal GPU worker layer that combines a lock-free queue with user WGSL jobs.
@@ -31,8 +84,9 @@ const shaderCode = await assembleWorkerWgsl(workerWgsl);
31
84
  - A reference job format for fixed-size job dispatch (u32 indices).
32
85
 
33
86
  ## Demo
34
- The demo enqueues ray tracing tile jobs on the GPU and renders a simple scene. Install
35
- dependencies first so the lock-free queue package is available for the browser import map.
87
+ The demo enqueues physics and render jobs on the GPU, builds per-type worklists, runs the
88
+ physics kernel, and uses an indirect draw for the particle pass. Install dependencies first
89
+ so the lock-free queue package is available for the browser import map.
36
90
 
37
91
  ```
38
92
  npm install
@@ -61,9 +115,12 @@ certificate for that name and set `DEMO_HOST`, `DEMO_PORT`, `DEMO_TLS_CERT`, and
61
115
  `npm run build` emits `dist/index.js`, `dist/index.cjs`, and `dist/worker.wgsl`.
62
116
 
63
117
  ## Files
64
- - `demo/index.html`: Loads the ray tracing demo.
65
- - `demo/main.js`: WebGPU setup, enqueue, and ray tracing kernel.
66
- - `src/worker.wgsl`: Worker entry points that dequeue jobs and run a ray tracer.
118
+ - `demo/index.html`: Loads the WebGPU demo.
119
+ - `demo/main.js`: WebGPU setup, queue jobs, physics worklists, and indirect draw.
120
+ - `demo/jobs/common.wgsl`: Shared WGSL definitions for demo jobs.
121
+ - `demo/jobs/physics.job.wgsl`: Physics job kernel (worklist + integration).
122
+ - `demo/jobs/render.job.wgsl`: Render job kernel (worklist + indirect args).
123
+ - `src/worker.wgsl`: Minimal worker entry point template (dequeue + `process_job` hook).
67
124
  - `src/index.js`: Helper functions to load/assemble WGSL.
68
125
 
69
126
  ## Job shape
package/dist/index.cjs CHANGED
@@ -1,6 +1,8 @@
1
+ var __create = Object.create;
1
2
  var __defProp = Object.defineProperty;
2
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __getProtoOf = Object.getPrototypeOf;
4
6
  var __hasOwnProp = Object.prototype.hasOwnProperty;
5
7
  var __export = (target, all) => {
6
8
  for (var name in all)
@@ -14,12 +16,23 @@ var __copyProps = (to, from, except, desc) => {
14
16
  }
15
17
  return to;
16
18
  };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
20
+ // If the importer is in node compatibility mode or this is not an ESM
21
+ // file that has been converted to a CommonJS file using a Babel-
22
+ // compatible transform (i.e. "__esModule" has not been set), then set
23
+ // "default" to the CommonJS "module.exports" for node compatibility.
24
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
25
+ mod
26
+ ));
17
27
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
18
28
 
19
29
  // src/index.js
20
30
  var index_exports = {};
21
31
  __export(index_exports, {
22
32
  assembleWorkerWgsl: () => assembleWorkerWgsl,
33
+ createWorkerLoop: () => createWorkerLoop,
34
+ loadJobWgsl: () => loadJobWgsl,
35
+ loadQueueWgsl: () => loadQueueWgsl,
23
36
  loadWorkerWgsl: () => loadWorkerWgsl,
24
37
  workerWgslUrl: () => workerWgslUrl
25
38
  });
@@ -36,21 +49,492 @@ var workerWgslUrl = (() => {
36
49
  const base = typeof process !== "undefined" && process.cwd ? `file://${process.cwd()}/` : "file:///";
37
50
  return new URL("./worker.wgsl", base);
38
51
  })();
39
- async function loadWorkerWgsl() {
40
- const response = await fetch(workerWgslUrl);
41
- return response.text();
52
+ var jobRegistry = [];
53
+ var nextJobType = 0;
54
+ async function loadWgslSource(options = {}) {
55
+ const { wgsl, url, fetcher = globalThis.fetch, baseUrl } = options ?? {};
56
+ if (typeof wgsl === "string") {
57
+ assertNotHtmlWgsl(wgsl, "inline WGSL");
58
+ return wgsl;
59
+ }
60
+ if (!url) {
61
+ return null;
62
+ }
63
+ const resolved = url instanceof URL ? url : new URL(url, baseUrl);
64
+ if (!fetcher) {
65
+ if (resolved.protocol !== "file:") {
66
+ throw new Error("No fetcher available for non-file WGSL URL.");
67
+ }
68
+ const { readFile } = await import("fs/promises");
69
+ const { fileURLToPath } = await import("url");
70
+ const source2 = await readFile(fileURLToPath(resolved), "utf8");
71
+ assertNotHtmlWgsl(source2, resolved.href);
72
+ return source2;
73
+ }
74
+ const response = await fetcher(resolved);
75
+ if (!response.ok) {
76
+ const status = "status" in response ? response.status : "unknown";
77
+ const statusText = "statusText" in response ? response.statusText : "";
78
+ const detail = statusText ? `${status} ${statusText}` : `${status}`;
79
+ throw new Error(`Failed to load WGSL (${detail})`);
80
+ }
81
+ const source = await response.text();
82
+ assertNotHtmlWgsl(source, resolved.href);
83
+ return source;
84
+ }
85
+ function stripComments(source) {
86
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
87
+ }
88
+ function tokenize(source) {
89
+ return source.match(/[A-Za-z_][A-Za-z0-9_]*|[{}();<>,:=]/g) ?? [];
90
+ }
91
+ function isIdentifier(token) {
92
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(token);
93
+ }
94
+ function readNameAfterType(tokens, startIndex) {
95
+ let i = startIndex;
96
+ if (tokens[i] === "<") {
97
+ let depth = 1;
98
+ i += 1;
99
+ while (i < tokens.length && depth > 0) {
100
+ if (tokens[i] === "<") {
101
+ depth += 1;
102
+ } else if (tokens[i] === ">") {
103
+ depth -= 1;
104
+ }
105
+ i += 1;
106
+ }
107
+ }
108
+ return tokens[i];
109
+ }
110
+ function scanModuleNames(source) {
111
+ const cleaned = stripComments(source);
112
+ const tokens = tokenize(cleaned);
113
+ const names = [];
114
+ let depth = 0;
115
+ for (let i = 0; i < tokens.length; i += 1) {
116
+ const token = tokens[i];
117
+ if (token === "{") {
118
+ depth += 1;
119
+ continue;
120
+ }
121
+ if (token === "}") {
122
+ depth = Math.max(0, depth - 1);
123
+ continue;
124
+ }
125
+ if (depth !== 0) {
126
+ continue;
127
+ }
128
+ if (token === "fn") {
129
+ const name = tokens[i + 1];
130
+ if (isIdentifier(name)) {
131
+ names.push({ kind: "fn", name });
132
+ }
133
+ continue;
134
+ }
135
+ if (token === "struct") {
136
+ const name = tokens[i + 1];
137
+ if (isIdentifier(name)) {
138
+ names.push({ kind: "struct", name });
139
+ }
140
+ continue;
141
+ }
142
+ if (token === "alias") {
143
+ const name = tokens[i + 1];
144
+ if (isIdentifier(name)) {
145
+ names.push({ kind: "alias", name });
146
+ }
147
+ continue;
148
+ }
149
+ if (token === "var" || token === "let" || token === "const" || token === "override") {
150
+ const name = readNameAfterType(tokens, i + 1);
151
+ if (isIdentifier(name)) {
152
+ names.push({ kind: token, name });
153
+ }
154
+ }
155
+ }
156
+ return names;
157
+ }
158
+ function buildNameIndex(modules) {
159
+ const index = /* @__PURE__ */ new Map();
160
+ for (const module2 of modules) {
161
+ for (const item of scanModuleNames(module2.source)) {
162
+ const bucket = index.get(item.name) ?? [];
163
+ bucket.push({ kind: item.kind, module: module2.name });
164
+ index.set(item.name, bucket);
165
+ }
166
+ }
167
+ return index;
168
+ }
169
+ function assertNoNameClashes(modules) {
170
+ const index = buildNameIndex(modules);
171
+ const clashes = [];
172
+ for (const [name, entries] of index.entries()) {
173
+ if (entries.length > 1) {
174
+ clashes.push({ name, entries });
175
+ }
176
+ }
177
+ if (clashes.length === 0) {
178
+ return;
179
+ }
180
+ const lines = ["WGSL debug: identifier clashes detected:"];
181
+ for (const clash of clashes) {
182
+ const locations = clash.entries.map((entry) => `${entry.module} (${entry.kind})`).join(", ");
183
+ lines.push(`- ${clash.name}: ${locations}`);
184
+ }
185
+ throw new Error(lines.join("\n"));
186
+ }
187
+ function assertNotHtmlWgsl(source, context) {
188
+ const sample = source.slice(0, 200).toLowerCase();
189
+ if (sample.includes("<!doctype") || sample.includes("<html") || sample.includes("<meta")) {
190
+ const label = context ? ` for ${context}` : "";
191
+ throw new Error(
192
+ `Expected WGSL${label} but received HTML. Check the URL or server root.`
193
+ );
194
+ }
195
+ }
196
+ function renameProcessJob(source, name) {
197
+ return source.replace(/\bprocess_job\b/g, name);
198
+ }
199
+ function getQueueCompatMap(source) {
200
+ if (!/\bJobMeta\b/.test(source)) {
201
+ return null;
202
+ }
203
+ return [{ from: /\bJobMeta\b/g, to: "JobDesc" }];
204
+ }
205
+ function applyCompatMap(source, map) {
206
+ if (!map || map.length === 0) {
207
+ return source;
208
+ }
209
+ let next = source;
210
+ for (const entry of map) {
211
+ next = next.replace(entry.from, entry.to);
212
+ }
213
+ return next;
214
+ }
215
+ function normalizeJobs(jobs) {
216
+ const normalized = jobs.map((job, index) => {
217
+ if (typeof job === "string") {
218
+ return {
219
+ jobType: index,
220
+ wgsl: job,
221
+ label: `job_${index}`,
222
+ sourceName: `job-${index}`
223
+ };
224
+ }
225
+ if (!job || typeof job.wgsl !== "string") {
226
+ throw new Error("Job entries must provide WGSL source strings.");
227
+ }
228
+ const jobType = job.jobType ?? index;
229
+ const label = job.label ?? `job_${jobType}`;
230
+ return {
231
+ jobType,
232
+ wgsl: job.wgsl,
233
+ label,
234
+ sourceName: job.sourceName ?? job.label ?? `job-${jobType}`
235
+ };
236
+ });
237
+ const seen = /* @__PURE__ */ new Set();
238
+ for (const job of normalized) {
239
+ if (seen.has(job.jobType)) {
240
+ throw new Error(`Duplicate job_type detected: ${job.jobType}`);
241
+ }
242
+ seen.add(job.jobType);
243
+ }
244
+ return normalized;
245
+ }
246
+ function buildProcessJobDispatch(jobs) {
247
+ const lines = [
248
+ "fn process_job(job_index: u32, job_type: u32, payload_words: u32) {"
249
+ ];
250
+ if (jobs.length === 0) {
251
+ lines.push(" return;");
252
+ lines.push("}");
253
+ return lines.join("\n");
254
+ }
255
+ jobs.forEach((job, idx) => {
256
+ const clause = idx === 0 ? "if" : "else if";
257
+ lines.push(` ${clause} (job_type == ${job.jobType}u) {`);
258
+ lines.push(
259
+ ` ${job.entryName}(job_index, job_type, payload_words);`
260
+ );
261
+ lines.push(" }");
262
+ });
263
+ lines.push("}");
264
+ return lines.join("\n");
265
+ }
266
+ async function loadWorkerWgsl(options = {}) {
267
+ const { url = workerWgslUrl, fetcher } = options ?? {};
268
+ const source = await loadWgslSource({
269
+ url,
270
+ fetcher,
271
+ baseUrl: workerWgslUrl
272
+ });
273
+ if (typeof source !== "string") {
274
+ throw new Error("Failed to load worker WGSL source.");
275
+ }
276
+ return source;
277
+ }
278
+ async function loadQueueWgsl(options = {}) {
279
+ const { queueCompat = true, ...rest } = options ?? {};
280
+ const source = await (0, import_gpu_lock_free_queue.loadQueueWgsl)(rest);
281
+ if (typeof source !== "string") {
282
+ throw new Error("Failed to load queue WGSL source.");
283
+ }
284
+ assertNotHtmlWgsl(source, rest?.url ? String(rest.url) : "queue WGSL");
285
+ if (!queueCompat) {
286
+ return source;
287
+ }
288
+ const compatMap = getQueueCompatMap(source);
289
+ return applyCompatMap(source, compatMap);
290
+ }
291
+ async function loadJobWgsl(options = {}) {
292
+ const { wgsl, url, fetcher, label } = options ?? {};
293
+ const source = await loadWgslSource({
294
+ wgsl,
295
+ url,
296
+ fetcher,
297
+ baseUrl: workerWgslUrl
298
+ });
299
+ if (typeof source !== "string") {
300
+ throw new Error("loadJobWgsl requires a WGSL string or URL.");
301
+ }
302
+ const jobType = nextJobType;
303
+ nextJobType += 1;
304
+ jobRegistry.push({
305
+ jobType,
306
+ wgsl: source,
307
+ label: label ?? `job_${jobType}`,
308
+ sourceName: label ?? `job-${jobType}`
309
+ });
310
+ return jobType;
42
311
  }
43
312
  async function assembleWorkerWgsl(workerWgsl, options = {}) {
44
- const { queueWgsl, queueUrl, fetcher } = options ?? {};
45
- const queueSource = queueWgsl ?? await (0, import_gpu_lock_free_queue.loadQueueWgsl)({ url: queueUrl, fetcher });
46
- const body = workerWgsl ?? await loadWorkerWgsl();
313
+ const {
314
+ queueWgsl,
315
+ queueUrl,
316
+ preludeWgsl,
317
+ preludeUrl,
318
+ fetcher,
319
+ jobs,
320
+ debug,
321
+ queueCompat = true
322
+ } = options ?? {};
323
+ const rawQueueSource = queueWgsl ?? await (0, import_gpu_lock_free_queue.loadQueueWgsl)({ url: queueUrl, fetcher });
324
+ const bodyRaw = workerWgsl ?? await loadWorkerWgsl({ fetcher });
325
+ const compatMap = queueCompat ? getQueueCompatMap(rawQueueSource) : null;
326
+ const queueSource = applyCompatMap(rawQueueSource, compatMap);
327
+ const preludeRaw = preludeWgsl ?? (preludeUrl ? await loadWgslSource({ url: preludeUrl, fetcher, baseUrl: workerWgslUrl }) : "");
328
+ if ((preludeWgsl || preludeUrl) && typeof preludeRaw !== "string") {
329
+ throw new Error("Failed to load prelude WGSL source.");
330
+ }
331
+ const preludeSource = typeof preludeRaw === "string" && preludeRaw.length > 0 ? applyCompatMap(preludeRaw, compatMap) : "";
332
+ const body = applyCompatMap(bodyRaw, compatMap);
333
+ const jobList = normalizeJobs(
334
+ typeof jobs === "undefined" ? jobRegistry : jobs
335
+ );
336
+ if (!jobList || jobList.length === 0) {
337
+ return `${queueSource}
338
+
339
+ ${body}`;
340
+ }
341
+ const rewrittenJobs = jobList.map((job) => {
342
+ const source = applyCompatMap(job.wgsl, compatMap);
343
+ const hasProcessJob = /\bfn\s+process_job\b/.test(source);
344
+ if (!hasProcessJob) {
345
+ throw new Error(
346
+ `Job ${job.sourceName} is missing a process_job() entry function.`
347
+ );
348
+ }
349
+ const entryName = `process_job__${job.jobType}`;
350
+ const renamed = renameProcessJob(source, entryName);
351
+ return { ...job, entryName, wgsl: renamed };
352
+ });
353
+ const dispatch = buildProcessJobDispatch(rewrittenJobs);
354
+ const modulesForDebug = debug ? [
355
+ { name: "queue.wgsl", source: queueSource },
356
+ ...preludeSource ? [{ name: "jobs.prelude.wgsl", source: preludeSource }] : [],
357
+ ...rewrittenJobs.map((job) => ({
358
+ name: job.sourceName,
359
+ source: job.wgsl
360
+ })),
361
+ { name: "jobs.dispatch.wgsl", source: dispatch },
362
+ { name: "worker.wgsl", source: body }
363
+ ] : null;
364
+ if (modulesForDebug) {
365
+ assertNoNameClashes(modulesForDebug);
366
+ }
367
+ const jobBlocks = rewrittenJobs.map((job) => `// Job ${job.jobType}: ${job.label}
368
+ ${job.wgsl}`).join("\n\n");
369
+ const preludeBlock = preludeSource ? `${preludeSource}
370
+
371
+ ` : "";
47
372
  return `${queueSource}
48
373
 
374
+ ${preludeBlock}${jobBlocks}
375
+
376
+ ${dispatch}
377
+
49
378
  ${body}`;
50
379
  }
380
+ function normalizeWorkgroups(value, label) {
381
+ if (typeof value === "number") {
382
+ return [value, 1, 1];
383
+ }
384
+ if (Array.isArray(value)) {
385
+ const [x = 0, y = 1, z = 1] = value;
386
+ return [x, y, z];
387
+ }
388
+ throw new Error(`Invalid workgroup count for ${label}.`);
389
+ }
390
+ function resolveWorkgroups(value, label) {
391
+ if (typeof value === "function") {
392
+ return normalizeWorkgroups(value(), label);
393
+ }
394
+ if (value == null) {
395
+ return null;
396
+ }
397
+ return normalizeWorkgroups(value, label);
398
+ }
399
+ function setBindGroups(pass, bindGroups) {
400
+ if (!bindGroups) {
401
+ return;
402
+ }
403
+ bindGroups.forEach((group, index) => {
404
+ if (group) {
405
+ pass.setBindGroup(index, group);
406
+ }
407
+ });
408
+ }
409
+ function computeWorkerWorkgroups(maxJobs, workgroupSize) {
410
+ const jobs = typeof maxJobs === "function" ? Number(maxJobs()) : Number(maxJobs);
411
+ if (!Number.isFinite(jobs) || jobs <= 0) {
412
+ throw new Error("maxJobsPerDispatch must be a positive number.");
413
+ }
414
+ const size = Number(workgroupSize);
415
+ if (!Number.isFinite(size) || size <= 0) {
416
+ throw new Error("workgroupSize must be a positive number.");
417
+ }
418
+ return Math.max(1, Math.ceil(jobs / size));
419
+ }
420
+ function createWorkerLoop(options = {}) {
421
+ const {
422
+ device,
423
+ worker,
424
+ jobs = [],
425
+ workgroupSize = 64,
426
+ maxJobsPerDispatch,
427
+ rateHz,
428
+ label,
429
+ onTick,
430
+ onError
431
+ } = options ?? {};
432
+ if (!device) {
433
+ throw new Error("createWorkerLoop requires a GPUDevice.");
434
+ }
435
+ if (!worker || !worker.pipeline) {
436
+ throw new Error("createWorkerLoop requires a worker pipeline.");
437
+ }
438
+ let running = false;
439
+ let handle = null;
440
+ let usingRaf = false;
441
+ const intervalMs = Number.isFinite(rateHz) && rateHz > 0 ? 1e3 / rateHz : null;
442
+ const tick = () => {
443
+ try {
444
+ const encoder = device.createCommandEncoder();
445
+ const pass = encoder.beginComputePass(
446
+ label ? { label } : void 0
447
+ );
448
+ pass.setPipeline(worker.pipeline);
449
+ setBindGroups(pass, worker.bindGroups);
450
+ const explicitWorkerGroups = resolveWorkgroups(worker.workgroups, "worker") ?? resolveWorkgroups(worker.workgroupCount, "worker") ?? resolveWorkgroups(worker.dispatch, "worker");
451
+ const workerGroups = explicitWorkerGroups ? explicitWorkerGroups : [computeWorkerWorkgroups(maxJobsPerDispatch, workgroupSize), 1, 1];
452
+ if (workerGroups[0] > 0) {
453
+ pass.dispatchWorkgroups(...workerGroups);
454
+ }
455
+ jobs.forEach((job, index) => {
456
+ if (!job || !job.pipeline) {
457
+ throw new Error(`Job pipeline missing at index ${index}.`);
458
+ }
459
+ pass.setPipeline(job.pipeline);
460
+ setBindGroups(pass, job.bindGroups);
461
+ const groups = resolveWorkgroups(
462
+ job.workgroups ?? job.workgroupCount ?? job.dispatch,
463
+ `job ${index}`
464
+ );
465
+ if (!groups) {
466
+ throw new Error(`Job ${index} requires a workgroup count.`);
467
+ }
468
+ if (groups[0] > 0) {
469
+ pass.dispatchWorkgroups(...groups);
470
+ }
471
+ });
472
+ pass.end();
473
+ device.queue.submit([encoder.finish()]);
474
+ if (onTick) {
475
+ onTick();
476
+ }
477
+ } catch (err) {
478
+ if (onError) {
479
+ onError(err);
480
+ return;
481
+ }
482
+ throw err;
483
+ }
484
+ };
485
+ const scheduleNext = () => {
486
+ if (!running) {
487
+ return;
488
+ }
489
+ if (intervalMs != null) {
490
+ tick();
491
+ usingRaf = false;
492
+ handle = setTimeout(scheduleNext, intervalMs);
493
+ return;
494
+ }
495
+ tick();
496
+ if (typeof requestAnimationFrame === "function") {
497
+ usingRaf = true;
498
+ handle = requestAnimationFrame(scheduleNext);
499
+ } else {
500
+ usingRaf = false;
501
+ handle = setTimeout(scheduleNext, 0);
502
+ }
503
+ };
504
+ const start = () => {
505
+ if (running) {
506
+ return;
507
+ }
508
+ running = true;
509
+ scheduleNext();
510
+ };
511
+ const stop = () => {
512
+ running = false;
513
+ if (handle == null) {
514
+ return;
515
+ }
516
+ if (usingRaf && typeof cancelAnimationFrame === "function") {
517
+ cancelAnimationFrame(handle);
518
+ } else {
519
+ clearTimeout(handle);
520
+ }
521
+ handle = null;
522
+ };
523
+ return {
524
+ start,
525
+ stop,
526
+ tick,
527
+ get running() {
528
+ return running;
529
+ }
530
+ };
531
+ }
51
532
  // Annotate the CommonJS export names for ESM import in node:
52
533
  0 && (module.exports = {
53
534
  assembleWorkerWgsl,
535
+ createWorkerLoop,
536
+ loadJobWgsl,
537
+ loadQueueWgsl,
54
538
  loadWorkerWgsl,
55
539
  workerWgslUrl
56
540
  });