wgpu-kit 0.9.11 → 1.0.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 (41) hide show
  1. package/README.md +7 -0
  2. package/dist/core/buffer.d.ts +1 -1
  3. package/dist/core/buffer.js +109 -0
  4. package/dist/core/context.d.ts +2 -0
  5. package/dist/core/context.js +62 -0
  6. package/dist/core/errors.js +42 -0
  7. package/dist/core/kernel.js +197 -0
  8. package/dist/core/layout.d.ts +5 -1
  9. package/dist/core/layout.js +69 -0
  10. package/dist/core/pingpong.js +61 -0
  11. package/dist/core/raw.js +38 -0
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.js +11 -1510
  14. package/dist/interop/three.js +29 -0
  15. package/dist/media.js +70 -0
  16. package/dist/observe.d.ts +19 -0
  17. package/dist/observe.js +76 -0
  18. package/dist/packs/fields/index.js +230 -0
  19. package/dist/packs/image/index.js +250 -0
  20. package/dist/packs/life/boids.js +367 -0
  21. package/dist/packs/life/index.js +8 -0
  22. package/dist/packs/life/map.js +110 -0
  23. package/dist/packs/life/physarum.js +228 -0
  24. package/dist/packs/life/tentacles.js +238 -0
  25. package/dist/packs/life/turing.js +203 -0
  26. package/dist/packs/particles/config.d.ts +1 -0
  27. package/dist/packs/particles/config.js +36 -0
  28. package/dist/packs/particles/grid.js +248 -0
  29. package/dist/packs/particles/index.js +361 -0
  30. package/dist/packs/particles/presets.js +75 -0
  31. package/dist/packs/particles/render.js +116 -0
  32. package/dist/packs/particles/wgsl.js +175 -0
  33. package/dist/react/index.js +36 -0
  34. package/dist/vite.js +27 -28
  35. package/package.json +1 -1
  36. package/dist/fields.js +0 -585
  37. package/dist/image.js +0 -318
  38. package/dist/life.js +0 -1407
  39. package/dist/particles.js +0 -1245
  40. package/dist/react.js +0 -1289
  41. package/dist/three.js +0 -33
@@ -0,0 +1,29 @@
1
+ export function threePoints(sim, THREE, opts = {}) {
2
+ const { pos } = sim.buffers();
3
+ const count = pos.length;
4
+ const positions = new Float32Array(count * 3); // three 需要 vec3,z=0
5
+ const geometry = new THREE.BufferGeometry();
6
+ const attr = new THREE.BufferAttribute(positions, 3);
7
+ geometry.setAttribute('position', attr);
8
+ const material = new THREE.PointsMaterial({
9
+ size: opts.size ?? 0.015,
10
+ color: opts.color ?? 0x8fb4ff,
11
+ sizeAttenuation: true,
12
+ });
13
+ const points = new THREE.Points(geometry, material);
14
+ return {
15
+ points,
16
+ async update() {
17
+ const data = (await pos.read());
18
+ for (let i = 0; i < count; i++) {
19
+ positions[i * 3] = data[i * 2] ?? 0;
20
+ positions[i * 3 + 1] = data[i * 2 + 1] ?? 0;
21
+ positions[i * 3 + 2] = 0;
22
+ }
23
+ attr.needsUpdate = true;
24
+ },
25
+ dispose() {
26
+ sim.destroy();
27
+ },
28
+ };
29
+ }
package/dist/media.js ADDED
@@ -0,0 +1,70 @@
1
+ const CANDIDATES = [
2
+ // webm 在无头/软件编码环境下最可靠;mp4 的 isTypeSupported 可能"说行但编不动"
3
+ // (实测 headless Edge:mp4 协商成功但产物 0 字节),故排后
4
+ 'video/webm;codecs=vp9',
5
+ 'video/webm;codecs=vp8',
6
+ 'video/webm',
7
+ 'video/mp4;codecs=avc1.42E01E',
8
+ 'video/mp4',
9
+ ];
10
+ export function pickMime() {
11
+ if (typeof MediaRecorder === 'undefined')
12
+ return null;
13
+ for (const m of CANDIDATES) {
14
+ if (MediaRecorder.isTypeSupported(m))
15
+ return m;
16
+ }
17
+ return null;
18
+ }
19
+ export class CanvasRecorder {
20
+ #recorder = null;
21
+ #chunks = [];
22
+ #startedAt = 0;
23
+ #mime;
24
+ constructor() {
25
+ const mime = pickMime();
26
+ if (!mime)
27
+ throw new Error('当前环境不支持 MediaRecorder 录制(无可用编码)');
28
+ this.#mime = mime;
29
+ }
30
+ get mimeType() { return this.#mime; }
31
+ get recording() { return this.#recorder?.state === 'recording'; }
32
+ start(canvas, videoBitsPerSecond = 12_000_000) {
33
+ if (this.#recorder)
34
+ throw new Error('已在录制中');
35
+ const stream = canvas.captureStream(60);
36
+ this.#chunks = [];
37
+ this.#recorder = new MediaRecorder(stream, { mimeType: this.#mime, videoBitsPerSecond });
38
+ this.#recorder.ondataavailable = (e) => { if (e.data.size > 0)
39
+ this.#chunks.push(e.data); };
40
+ this.#startedAt = performance.now();
41
+ this.#recorder.start(250);
42
+ }
43
+ stop() {
44
+ return new Promise((ok, err) => {
45
+ const r = this.#recorder;
46
+ if (!r || r.state !== 'recording') {
47
+ err(new Error('没有进行中的录制'));
48
+ return;
49
+ }
50
+ r.onstop = () => {
51
+ const blob = new Blob(this.#chunks, { type: this.#mime });
52
+ this.#recorder = null;
53
+ if (blob.size === 0) {
54
+ err(new Error(`录制产物为空(${this.#mime});编码器可能不可用,换浏览器或网络前重试`));
55
+ return;
56
+ }
57
+ ok({ blob, mimeType: this.#mime, seconds: (performance.now() - this.#startedAt) / 1000, bytes: blob.size });
58
+ };
59
+ r.stop();
60
+ });
61
+ }
62
+ }
63
+ /** 触发浏览器下载(录完即得的最后一公里) */
64
+ export function downloadBlob(blob, filename) {
65
+ const a = document.createElement('a');
66
+ a.href = URL.createObjectURL(blob);
67
+ a.download = filename;
68
+ a.click();
69
+ setTimeout(() => URL.revokeObjectURL(a.href), 5000);
70
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * 可观测性与健壮性助手(批次 2)。
3
+ * —— 评审指出的"最大产品级缺口":GPGPU 库却看不到"这帧 GPU 花了多少"。
4
+ */
5
+ import { GpuContext } from './core/context.ts';
6
+ /** 时间戳查询封装:测量一段 GPU 工作的真实耗时(毫秒)。
7
+ * Chrome/Edge 支持 timestamp-query;不支持的浏览器 reject。 */
8
+ export declare function timeGpu(fn: (ctx: GpuContext) => void | Promise<void>): Promise<number>;
9
+ /** 设备诊断:注册错误/丢失回调;设备丢失时自动重建上下文并调用 onRebuild。
10
+ * 长跑页面(展览/大屏)的必需品。 */
11
+ export declare function watchDevice(opts: {
12
+ onError?: (message: string, recoverable: boolean) => void;
13
+ onRebuild?: (ctx: GpuContext) => void;
14
+ }): void;
15
+ /** 推荐的画布 canvas 格式(一行助手的语义化封装)。 */
16
+ export declare function preferredCanvasFormat(): GPUTextureFormat;
17
+ /** 画布 resize(DPR 上限策略):宽高物理像素 = clientSize × min(dpr, cap)。
18
+ * 返回是否实际改变了尺寸(没变则无需重建)。 */
19
+ export declare function resizeCanvas(canvas: HTMLCanvasElement, dprCap?: number): boolean;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 可观测性与健壮性助手(批次 2)。
3
+ * —— 评审指出的"最大产品级缺口":GPGPU 库却看不到"这帧 GPU 花了多少"。
4
+ */
5
+ import { GpuContext } from "./core/context.js";
6
+ import { UsageError } from "./core/errors.js";
7
+ /** 时间戳查询封装:测量一段 GPU 工作的真实耗时(毫秒)。
8
+ * Chrome/Edge 支持 timestamp-query;不支持的浏览器 reject。 */
9
+ export async function timeGpu(fn) {
10
+ const ctx = await GpuContext.get();
11
+ const device = ctx.device;
12
+ const featureOk = device.features.has('timestamp-query');
13
+ if (!featureOk)
14
+ throw new UsageError('timestamp-query 在当前设备不可用(需 Chrome/Edge + 支持时间戳的 GPU)');
15
+ const QUERY_POOL = 2;
16
+ const querySet = device.createQuerySet({ type: 'timestamp', count: QUERY_POOL });
17
+ const resolveBuf = device.createBuffer({
18
+ size: QUERY_POOL * 8,
19
+ usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
20
+ });
21
+ const readBuf = device.createBuffer({ size: QUERY_POOL * 8, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
22
+ // 新版 WebGPU 规范:时间戳通过 pass 的 timestampWrites 写入(无 encoder.writeTimestamp)
23
+ const enc = device.createCommandEncoder();
24
+ {
25
+ const p0 = enc.beginComputePass({ timestampWrites: { querySet, beginningOfPassWriteIndex: 0 } });
26
+ p0.end();
27
+ }
28
+ await fn(ctx);
29
+ {
30
+ const p1 = enc.beginComputePass({ timestampWrites: { querySet, beginningOfPassWriteIndex: 1 } });
31
+ p1.end();
32
+ }
33
+ enc.resolveQuerySet(querySet, 0, QUERY_POOL, resolveBuf, 0);
34
+ enc.copyBufferToBuffer(resolveBuf, 0, readBuf, 0, QUERY_POOL * 8);
35
+ device.queue.submit([enc.finish()]);
36
+ await readBuf.mapAsync(GPUMapMode.READ);
37
+ const times = new BigInt64Array(readBuf.getMappedRange().slice(0));
38
+ readBuf.unmap();
39
+ querySet.destroy();
40
+ resolveBuf.destroy();
41
+ readBuf.destroy();
42
+ // period = 1ns(按规范),换算毫秒
43
+ const deltaNs = Number(times[1] - times[0]);
44
+ return deltaNs / 1e6;
45
+ }
46
+ /** 设备诊断:注册错误/丢失回调;设备丢失时自动重建上下文并调用 onRebuild。
47
+ * 长跑页面(展览/大屏)的必需品。 */
48
+ export function watchDevice(opts) {
49
+ void GpuContext.get().then((ctx) => {
50
+ ctx.device.addEventListener?.('uncapturederror', (e) => {
51
+ opts.onError?.(String(e.error?.message ?? e), true);
52
+ });
53
+ void ctx.lost.then((info) => {
54
+ opts.onError?.(`GPU device lost: ${info.reason}`, false);
55
+ // 丢失后重置单例,下一次 GpuContext.get() 走全新设备
56
+ GpuContext.resetForTests?.();
57
+ void GpuContext.get().then((fresh) => opts.onRebuild?.(fresh)).catch(() => undefined);
58
+ });
59
+ });
60
+ }
61
+ /** 推荐的画布 canvas 格式(一行助手的语义化封装)。 */
62
+ export function preferredCanvasFormat() {
63
+ return navigator.gpu.getPreferredCanvasFormat();
64
+ }
65
+ /** 画布 resize(DPR 上限策略):宽高物理像素 = clientSize × min(dpr, cap)。
66
+ * 返回是否实际改变了尺寸(没变则无需重建)。 */
67
+ export function resizeCanvas(canvas, dprCap = 2) {
68
+ const dpr = Math.min(window.devicePixelRatio || 1, dprCap);
69
+ const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
70
+ const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
71
+ if (canvas.width === w && canvas.height === h)
72
+ return false;
73
+ canvas.width = w;
74
+ canvas.height = h;
75
+ return true;
76
+ }
@@ -0,0 +1,230 @@
1
+ import { GpuContext } from "../../core/context.js";
2
+ import { Buffer } from "../../core/buffer.js";
3
+ import { PingPong } from "../../core/pingpong.js";
4
+ import { CompileError } from "../../core/errors.js";
5
+ import { MapRenderer } from "../life/map.js";
6
+ import { mulberry32 } from "../particles/presets.js";
7
+ const FIELD_FNS = {
8
+ // 绕心漩涡:切向速度,离心得越远越慢
9
+ vortex: `
10
+ fn fieldAt(p: vec2f, t: f32) -> vec2f {
11
+ let r = length(p) + 0.12;
12
+ return vec2f(-p.y, p.x) / r * 1.4;
13
+ }`,
14
+ // curl noise:值噪声的旋度,无散度,像真实的湍流
15
+ curl: `
16
+ fn hash(p: vec2f) -> f32 {
17
+ return fract(sin(dot(p, vec2f(127.1, 311.7))) * 43758.5453);
18
+ }
19
+ fn vnoise(p: vec2f) -> f32 {
20
+ let i = floor(p);
21
+ let f = fract(p);
22
+ let u = f * f * (3.0 - 2.0 * f);
23
+ return mix(mix(hash(i), hash(i + vec2f(1.0, 0.0)), u.x), mix(hash(i + vec2f(0.0, 1.0)), hash(i + vec2f(1.0, 1.0)), u.x), u.y);
24
+ }
25
+ fn fieldAt(p: vec2f, t: f32) -> vec2f {
26
+ let s = 2.2;
27
+ let e = 0.01;
28
+ let n1 = vnoise(p * s + vec2f(0.0, t * 0.15));
29
+ let n2 = vnoise(p * s + vec2f(17.3, t * 0.15));
30
+ let dx = vnoise(p * s + vec2f(e, 0.0) + vec2f(0.0, t * 0.15)) - n1;
31
+ let dy = vnoise(p * s + vec2f(0.0, e) + vec2f(17.3, t * 0.15)) - n2;
32
+ return normalize(vec2f(dy, -dx) / e + vec2f(1e-5));
33
+ }`,
34
+ // 双涡:左右反向旋转,中间有剪切层
35
+ twin: `
36
+ fn fieldAt(p: vec2f, t: f32) -> vec2f {
37
+ let s = select(-1.0, 1.0, p.x > 0.0);
38
+ let c = vec2f(0.55 * s, 0.0);
39
+ let r = length(p - c) + 0.1;
40
+ let swirl = vec2f(-(p - c).y, (p - c).x) / r;
41
+ return swirl * s * 1.3 + vec2f(0.0, sin(t * 0.4) * 0.2);
42
+ }`,
43
+ };
44
+ const AWG = 32;
45
+ export async function flow(config = {}) {
46
+ const { count: N = 131_072, mapSize = 1024, field = 'curl', speed = 0.004, decay = 0.045, deposit = 1.0, seed = 'flow', colormap = 'ice', } = config;
47
+ const seedHash = typeof seed === 'string' ? hashStr(seed) : (seed ?? 11);
48
+ const ctx = await GpuContext.get();
49
+ const device = ctx.device;
50
+ const posBuf = await Buffer.create('vec2f', N);
51
+ {
52
+ const rand = mulberry32(seedHash);
53
+ const p = new Float32Array(N * 2);
54
+ for (let i = 0; i < N; i++) {
55
+ p[i * 2] = rand() * 1.8 - 0.9;
56
+ p[i * 2 + 1] = rand() * 1.8 - 0.9;
57
+ }
58
+ posBuf.write(p);
59
+ }
60
+ const trail = await PingPong.create({ t: 'f32' }, mapSize * mapSize);
61
+ const trailA = trail.current.t;
62
+ const trailB = trail.other.t;
63
+ const uniform = device.createBuffer({ size: AWG, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
64
+ // 持久参数对象:每次全量写入(首版只写部分字段,未写字段被清零 → deposit=0 的教训)
65
+ const u = { count: N, pad: 0, speed, worldHalf: 1.0, deposit, time: 0, p0: 0, p1: 0 };
66
+ const writeUniform = () => {
67
+ const b = new ArrayBuffer(AWG);
68
+ const v = new DataView(b);
69
+ v.setUint32(0, u.count, true);
70
+ v.setUint32(4, u.pad, true);
71
+ v.setFloat32(8, u.speed, true);
72
+ v.setFloat32(12, u.worldHalf, true);
73
+ v.setFloat32(16, u.deposit, true);
74
+ v.setFloat32(20, u.time, true);
75
+ device.queue.writeBuffer(uniform, 0, b);
76
+ };
77
+ writeUniform();
78
+ const diffuseUniform = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
79
+ device.queue.writeBuffer(diffuseUniform, 0, new Uint32Array([mapSize, mapSize]));
80
+ device.queue.writeBuffer(diffuseUniform, 8, new Float32Array([1 - decay, 0]));
81
+ const compile = async (code, label) => {
82
+ const m = device.createShaderModule({ code, label });
83
+ const info = await m.getCompilationInfo();
84
+ const errors = info.messages.filter((x) => x.type === 'error');
85
+ if (errors.length > 0)
86
+ throw new CompileError(label, errors.map((x) => ({ line: x.lineNum, msg: x.message })), 0);
87
+ return m;
88
+ };
89
+ const mAdvect = await compile(advectWgsl(FIELD_FNS[field], mapSize), `fields-advect(${field})`);
90
+ const mDiffuse = await compile(diffuseWgsl(), 'fields-diffuse');
91
+ const pAdvect = device.createComputePipeline({ layout: 'auto', compute: { module: mAdvect, entryPoint: 'main' } });
92
+ const pDiffuse = device.createComputePipeline({ layout: 'auto', compute: { module: mDiffuse, entryPoint: 'main' } });
93
+ const bgAdvect = (t) => device.createBindGroup({
94
+ layout: pAdvect.getBindGroupLayout(0),
95
+ entries: [
96
+ { binding: 0, resource: { buffer: uniform } },
97
+ { binding: 1, resource: { buffer: posBuf.gpuBuffer } },
98
+ { binding: 2, resource: { buffer: t.gpuBuffer } },
99
+ ],
100
+ });
101
+ const bgDiffuse = (read, write) => device.createBindGroup({
102
+ layout: pDiffuse.getBindGroupLayout(0),
103
+ entries: [
104
+ { binding: 0, resource: { buffer: diffuseUniform } },
105
+ { binding: 1, resource: { buffer: read.gpuBuffer } },
106
+ { binding: 2, resource: { buffer: write.gpuBuffer } },
107
+ ],
108
+ });
109
+ let renderer = null;
110
+ let frame = 0;
111
+ let time = 0;
112
+ let lastFps = 0;
113
+ let fFrames = 0;
114
+ let fAcc = 0;
115
+ let fLast = performance.now();
116
+ return {
117
+ async attach(canvas) {
118
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
119
+ canvas.width = Math.max(1, Math.floor(canvas.clientWidth * dpr));
120
+ canvas.height = Math.max(1, Math.floor(canvas.clientHeight * dpr));
121
+ renderer = await MapRenderer.create(canvas, { width: mapSize, height: mapSize, maxV: 5.0, gamma: 0.65, colormap });
122
+ },
123
+ tick() {
124
+ time += 1 / 60;
125
+ u.time = time;
126
+ writeUniform();
127
+ const readT = trail.current.t;
128
+ const writeT = trail.other.t;
129
+ const enc = device.createCommandEncoder();
130
+ const pass = enc.beginComputePass();
131
+ pass.setPipeline(pAdvect);
132
+ pass.setBindGroup(0, bgAdvect(readT));
133
+ pass.dispatchWorkgroups(Math.ceil(N / 64));
134
+ pass.setPipeline(pDiffuse);
135
+ pass.setBindGroup(0, bgDiffuse(readT, writeT));
136
+ pass.dispatchWorkgroups(Math.ceil((mapSize * mapSize) / 64));
137
+ pass.end();
138
+ device.queue.submit([enc.finish()]);
139
+ renderer?.render(writeT);
140
+ trail.swap();
141
+ frame++;
142
+ fFrames++;
143
+ const now = performance.now();
144
+ fAcc += now - fLast;
145
+ fLast = now;
146
+ if (fAcc >= 500) {
147
+ lastFps = fFrames / (fAcc / 1000);
148
+ fFrames = 0;
149
+ fAcc = 0;
150
+ }
151
+ },
152
+ stats() { return { fps: lastFps }; },
153
+ async sampleTrail() { return (await trail.current.t.read()); },
154
+ destroy() {
155
+ posBuf.destroy();
156
+ trail.destroy();
157
+ uniform.destroy();
158
+ diffuseUniform.destroy();
159
+ },
160
+ };
161
+ }
162
+ function advectWgsl(fieldFn, mapSize) {
163
+ return /* wgsl */ `
164
+ struct Params {
165
+ count: u32, _pad: u32,
166
+ speed: f32, worldHalf: f32, deposit: f32, time: f32,
167
+ _p0: f32, _p1: f32,
168
+ };
169
+ @group(0) @binding(0) var<uniform> params: Params;
170
+ @group(0) @binding(1) var<storage, read_write> pos: array<vec2f>;
171
+ @group(0) @binding(2) var<storage, read_write> trail: array<f32>;
172
+
173
+ const TRAIL_W: u32 = ${mapSize}u;
174
+ ${fieldFn}
175
+
176
+ @compute @workgroup_size(64)
177
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
178
+ let i = gid.x;
179
+ if (i >= params.count) { return; }
180
+ let p = pos[i];
181
+ let v = fieldAt(p, params.time) * params.speed;
182
+ var np = p + v;
183
+ let h = params.worldHalf * 0.98;
184
+ if (abs(np.x) > h || abs(np.y) > h) {
185
+ // 出界重生:随机撒回(确定性 hash,免额外随机源)
186
+ let r1 = fract(sin(f32(i) * 12.9898 + params.time * 78.233) * 43758.5453);
187
+ let r2 = fract(sin(f32(i) * 78.233 + params.time * 12.9898) * 24634.6345);
188
+ np = vec2f(r1, r2) * 1.8 - 0.9;
189
+ }
190
+ pos[i] = np;
191
+ let w = TRAIL_W;
192
+ let tx = min(u32((np.x * 0.5 + 0.5) * f32(w)), w - 1u);
193
+ let ty = min(u32((np.y * 0.5 + 0.5) * f32(w)), w - 1u);
194
+ let t = ty * w + tx;
195
+ trail[t] = trail[t] + params.deposit;
196
+ }
197
+ `;
198
+ }
199
+ function diffuseWgsl() {
200
+ return /* wgsl */ `
201
+ @group(0) @binding(0) var<uniform> vp: vec4f; // w, h, keep, pad
202
+ @group(0) @binding(1) var<storage, read> src: array<f32>;
203
+ @group(0) @binding(2) var<storage, read_write> dst: array<f32>;
204
+
205
+ @compute @workgroup_size(64)
206
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
207
+ let i = gid.x;
208
+ let w = u32(vp.x);
209
+ let h = u32(vp.y);
210
+ if (i >= w * h) { return; }
211
+ let x = i32(i % w);
212
+ let y = i32(i / w);
213
+ let c = src[i] * 2.0;
214
+ var s = c;
215
+ s = s + src[u32(clamp(y - 1, 0, i32(h) - 1)) * w + u32(clamp(x, 0, i32(w) - 1))];
216
+ s = s + src[u32(clamp(y + 1, 0, i32(h) - 1)) * w + u32(clamp(x, 0, i32(w) - 1))];
217
+ s = s + src[u32(clamp(y, 0, i32(h) - 1)) * w + u32(clamp(x - 1, 0, i32(w) - 1))];
218
+ s = s + src[u32(clamp(y, 0, i32(h) - 1)) * w + u32(clamp(x + 1, 0, i32(w) - 1))];
219
+ dst[i] = (s / 6.0 * 0.5 + src[i] * 0.5) * vp.z;
220
+ }
221
+ `;
222
+ }
223
+ function hashStr(s) {
224
+ let h = 2166136261;
225
+ for (let i = 0; i < s.length; i++) {
226
+ h ^= s.charCodeAt(i);
227
+ h = Math.imul(h, 16777619);
228
+ }
229
+ return h >>> 0;
230
+ }
@@ -0,0 +1,250 @@
1
+ import { GpuContext } from "../../core/context.js";
2
+ import { CompileError } from "../../core/errors.js";
3
+ const OPS = ['grayscale', 'invert', 'edge', 'blur', 'sharpen', 'brightness', 'contrast'];
4
+ const OP_IDS = { grayscale: 0, invert: 1, edge: 2, blur: 3, sharpen: 4, brightness: 5, contrast: 6 };
5
+ export async function applyImage(source, target, ops) {
6
+ if (ops.length === 0)
7
+ throw new Error('applyImage 需要至少一个算子');
8
+ const width = 'naturalWidth' in source ? source.naturalWidth : source.width;
9
+ const height = 'naturalHeight' in source ? source.naturalHeight : source.height;
10
+ const ctx = await GpuContext.get();
11
+ const device = ctx.device;
12
+ // 源纹理
13
+ const srcTex = device.createTexture({
14
+ size: [width, height],
15
+ format: 'rgba8unorm',
16
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
17
+ });
18
+ // 上传走 2D getImageData + writeTexture(纯 CPU 路径):
19
+ // 实测 headless 里 copyExternalImageToTexture 从 canvas 上传拿不到内容(合成器限制)
20
+ const c2d = document.createElement('canvas');
21
+ c2d.width = width;
22
+ c2d.height = height;
23
+ const sctx = c2d.getContext('2d', { willReadFrequently: true });
24
+ sctx.drawImage(source, 0, 0);
25
+ const imageData = sctx.getImageData(0, 0, width, height);
26
+ device.queue.writeTexture({ texture: srcTex }, imageData.data, { bytesPerRow: width * 4, rowsPerImage: height }, [width, height]);
27
+ // 中间纹理池
28
+ const pool = [];
29
+ const temp = () => {
30
+ const t = pool.pop() ?? device.createTexture({
31
+ size: [width, height],
32
+ format: 'rgba8unorm',
33
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
34
+ });
35
+ return t;
36
+ };
37
+ const module = device.createShaderModule({ code: shader(), label: 'image-filters' });
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('image-filters', errors.map((m) => ({ line: m.lineNum, msg: m.message })), 0);
42
+ const pipeline = device.createRenderPipeline({
43
+ layout: 'auto',
44
+ vertex: { module, entryPoint: 'vs' },
45
+ fragment: { module, entryPoint: 'fs', targets: [{ format: 'rgba8unorm' }] },
46
+ primitive: { topology: 'triangle-list' },
47
+ });
48
+ const makePass = (input, output, op) => {
49
+ const p = op;
50
+ const uniform = device.createBuffer({ size: 32, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
51
+ const ub = new ArrayBuffer(32);
52
+ const uv = new DataView(ub);
53
+ uv.setUint32(0, OP_IDS[op.op] ?? 0, true);
54
+ uv.setUint32(4, Math.max(1, Math.min(4, Math.round(p.radius ?? 1))), true);
55
+ uv.setFloat32(8, width, true);
56
+ uv.setFloat32(12, height, true);
57
+ uv.setFloat32(16, p.amount ?? 1, true);
58
+ uv.setFloat32(20, p.value ?? 0, true);
59
+ device.queue.writeBuffer(uniform, 0, ub);
60
+ const sampler = device.createSampler({ magFilter: 'linear', minFilter: 'linear' });
61
+ const bg = device.createBindGroup({
62
+ layout: pipeline.getBindGroupLayout(0),
63
+ entries: [
64
+ { binding: 0, resource: { buffer: uniform } },
65
+ { binding: 1, resource: sampler },
66
+ { binding: 2, resource: input.createView() },
67
+ ],
68
+ });
69
+ const enc = device.createCommandEncoder();
70
+ const pass = enc.beginRenderPass({
71
+ colorAttachments: output
72
+ ? [{ view: output.createView(), clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' }]
73
+ : [],
74
+ });
75
+ pass.setPipeline(pipeline);
76
+ pass.setBindGroup(0, bg);
77
+ pass.draw(3);
78
+ pass.end();
79
+ return enc.finish();
80
+ };
81
+ let cur = srcTex;
82
+ let passes = 0;
83
+ for (const op of ops) {
84
+ const out = temp();
85
+ device.queue.submit([makePass(cur, out, op)]);
86
+ if (cur !== srcTex)
87
+ pool.push(cur);
88
+ cur = out;
89
+ passes++;
90
+ }
91
+ // 最终结果上屏到目标 canvas
92
+ const gpuCtx = target.getContext('webgpu');
93
+ if (!gpuCtx)
94
+ throw new Error('目标 canvas.getContext("webgpu") 返回空');
95
+ const format = navigator.gpu.getPreferredCanvasFormat();
96
+ gpuCtx.configure({ device, format, alphaMode: 'opaque' });
97
+ const blit = device.createShaderModule({
98
+ code: `
99
+ @group(0) @binding(0) var samp: sampler;
100
+ @group(0) @binding(1) var tex: texture_2d<f32>;
101
+ struct VsOut { @builtin(position) pos: vec4f, @location(0) uv: vec2f };
102
+ @vertex fn vs(@builtin(vertex_index) v: u32) -> VsOut {
103
+ var p = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
104
+ var o: VsOut;
105
+ o.pos = vec4f(p[v], 0.0, 1.0);
106
+ o.uv = (p[v] + vec2f(1.0)) * 0.5;
107
+ o.uv.y = 1.0 - o.uv.y;
108
+ return o;
109
+ }
110
+ @fragment fn fs(i: VsOut) -> @location(0) vec4f {
111
+ let c = textureSample(tex, samp, i.uv);
112
+ return vec4f(c.rgb, 1.0);
113
+ }
114
+ `,
115
+ label: 'image-blit',
116
+ });
117
+ const blitPipeline = device.createRenderPipeline({
118
+ layout: 'auto',
119
+ vertex: { module: blit, entryPoint: 'vs' },
120
+ fragment: { module: blit, entryPoint: 'fs', targets: [{ format }] },
121
+ primitive: { topology: 'triangle-list' },
122
+ });
123
+ const enc = device.createCommandEncoder();
124
+ const pass = enc.beginRenderPass({
125
+ colorAttachments: [{ view: gpuCtx.getCurrentTexture().createView(), clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' }],
126
+ });
127
+ pass.setPipeline(blitPipeline);
128
+ pass.setBindGroup(0, device.createBindGroup({
129
+ layout: blitPipeline.getBindGroupLayout(0),
130
+ entries: [
131
+ { binding: 0, resource: device.createSampler({ magFilter: 'linear', minFilter: 'linear' }) },
132
+ { binding: 1, resource: cur.createView() },
133
+ ],
134
+ }));
135
+ pass.draw(3);
136
+ pass.end();
137
+ device.queue.submit([enc.finish()]);
138
+ srcTex.destroy();
139
+ for (const t of pool)
140
+ t.destroy();
141
+ const bytesPerRow = Math.ceil((width * 4) / 256) * 256;
142
+ const staging = device.createBuffer({ size: bytesPerRow * height, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
143
+ return {
144
+ width,
145
+ height,
146
+ passes,
147
+ async readback() {
148
+ // cur = 最后一个输出纹理(不在销毁池里);按行 256B 对齐拷回
149
+ const enc = device.createCommandEncoder();
150
+ enc.copyTextureToBuffer({ texture: cur }, { buffer: staging, bytesPerRow, rowsPerImage: height }, [width, height]);
151
+ device.queue.submit([enc.finish()]);
152
+ await staging.mapAsync(GPUMapMode.READ);
153
+ const ab = staging.getMappedRange().slice(0);
154
+ staging.unmap();
155
+ staging.destroy();
156
+ return new Uint8Array(ab);
157
+ },
158
+ };
159
+ }
160
+ function shader() {
161
+ return /* wgsl */ `
162
+ struct U {
163
+ op: u32, radius: u32,
164
+ w: f32, h: f32, amount: f32, value: f32,
165
+ _p0: u32, _p1: u32,
166
+ };
167
+ @group(0) @binding(0) var<uniform> u: U;
168
+ @group(0) @binding(1) var samp: sampler;
169
+ @group(0) @binding(2) var tex: texture_2d<f32>;
170
+
171
+ struct VsOut { @builtin(position) pos: vec4f, @location(0) uv: vec2f };
172
+
173
+ @vertex
174
+ fn vs(@builtin(vertex_index) v: u32) -> VsOut {
175
+ var p = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
176
+ var o: VsOut;
177
+ o.pos = vec4f(p[v], 0.0, 1.0);
178
+ o.uv = (p[v] + vec2f(1.0)) * 0.5;
179
+ o.uv.y = 1.0 - o.uv.y;
180
+ return o;
181
+ }
182
+
183
+ fn lum(c: vec3f) -> f32 {
184
+ return dot(c, vec3f(0.2126, 0.7152, 0.0722));
185
+ }
186
+
187
+ @fragment
188
+ fn fs(i: VsOut) -> @location(0) vec4f {
189
+ let c = textureSample(tex, samp, i.uv).rgb;
190
+ let op = u.op;
191
+
192
+ if (op == 0u) { // grayscale
193
+ let g = vec3f(lum(c));
194
+ return vec4f(g, 1.0);
195
+ }
196
+ if (op == 1u) { // invert
197
+ return vec4f(1.0 - c, 1.0);
198
+ }
199
+ if (op == 2u) { // edge (sobel 幅值)
200
+ let e = 1.0 / vec2f(u.w, u.h);
201
+ let t00 = textureSample(tex, samp, i.uv + vec2f(-e.x, -e.y)).rgb;
202
+ let t10 = textureSample(tex, samp, i.uv + vec2f(0.0, -e.y)).rgb;
203
+ let t20 = textureSample(tex, samp, i.uv + vec2f(e.x, -e.y)).rgb;
204
+ let t01 = textureSample(tex, samp, i.uv + vec2f(-e.x, 0.0)).rgb;
205
+ let t21 = textureSample(tex, samp, i.uv + vec2f(e.x, 0.0)).rgb;
206
+ let t02 = textureSample(tex, samp, i.uv + vec2f(-e.x, e.y)).rgb;
207
+ let t12 = textureSample(tex, samp, i.uv + vec2f(0.0, e.y)).rgb;
208
+ let t22 = textureSample(tex, samp, i.uv + vec2f(e.x, e.y)).rgb;
209
+ let sx = (t22 + 2.0 * t21 + t02) - (t00 + 2.0 * t01 + t20);
210
+ let sy = (t02 + 2.0 * t12 + t22) - (t00 + 2.0 * t10 + t20);
211
+ let g = sqrt(vec3f(dot(sx, sx) / 3.0 + dot(sy, sy) / 3.0));
212
+ return vec4f(clamp(g * u.amount, vec3f(0.0), vec3f(1.0)), 1.0);
213
+ }
214
+ if (op == 3u) { // box blur,radius 折叠成步长采样
215
+ let r = f32(u.radius);
216
+ let e = vec2f(1.0) / vec2f(u.w, u.h) * r;
217
+ var s = vec3f(0.0);
218
+ var n = 0.0;
219
+ for (var dy = -2; dy <= 2; dy++) {
220
+ for (var dx = -2; dx <= 2; dx++) {
221
+ s = s + textureSample(tex, samp, i.uv + vec2f(f32(dx), f32(dy)) * e * 0.6).rgb;
222
+ n = n + 1.0;
223
+ }
224
+ }
225
+ return vec4f(s / n, 1.0);
226
+ }
227
+ if (op == 4u) { // sharpen(3x3 卷积)
228
+ let e = vec2f(1.0) / vec2f(u.w, u.h);
229
+ let c0 = textureSample(tex, samp, i.uv).rgb;
230
+ let t00 = textureSample(tex, samp, i.uv + vec2f(-e.x, -e.y)).rgb;
231
+ let t10 = textureSample(tex, samp, i.uv + vec2f(0.0, -e.y)).rgb;
232
+ let t20 = textureSample(tex, samp, i.uv + vec2f(e.x, -e.y)).rgb;
233
+ let t01 = textureSample(tex, samp, i.uv + vec2f(-e.x, 0.0)).rgb;
234
+ let t21 = textureSample(tex, samp, i.uv + vec2f(e.x, 0.0)).rgb;
235
+ let t02 = textureSample(tex, samp, i.uv + vec2f(-e.x, e.y)).rgb;
236
+ let t12 = textureSample(tex, samp, i.uv + vec2f(0.0, e.y)).rgb;
237
+ let t22 = textureSample(tex, samp, i.uv + vec2f(e.x, e.y)).rgb;
238
+ let k = u.amount;
239
+ let acc = t00 + t10 + t20 + t01 + t21 + t02 + t12 + t22;
240
+ return vec4f(clamp(c0 * (1.0 + 8.0 * k) - acc * k, vec3f(0.0), vec3f(1.0)), 1.0);
241
+ }
242
+ if (op == 5u) { // brightness
243
+ return vec4f(clamp(c + vec3f(u.value), vec3f(0.0), vec3f(1.0)), 1.0);
244
+ }
245
+ // contrast
246
+ let g = vec3f(0.5);
247
+ return vec4f(clamp(g + (c - g) * u.value, vec3f(0.0), vec3f(1.0)), 1.0);
248
+ }
249
+ `;
250
+ }