zhimo-ui 0.1.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.
package/src/ink.js ADDED
@@ -0,0 +1,951 @@
1
+ /* ZhiMo UI 特效组件:zhimo-ink-paper —— 墨滴入水的背景显影
2
+ 用法:<zhimo-ink-paper image="bg.jpg"></zhimo-ink-paper>
3
+
4
+ 背景图垫在页面最底层,纸面(--zhimo-bg)盖在其上。
5
+ 内部跑一个 Stable Fluids 流体模拟(Jos Stam 算法):
6
+ 鼠标移动把墨和动量一起注入流场,墨被水流推着卷出涡旋须,
7
+ 按下鼠标则滴一滴墨(径向外冲的速度场,像墨滴砸进水里炸开),
8
+ 墨蚀开纸面露出背景图,随时间稀释后纸面重新合拢。
9
+
10
+ 默认走 GPU(WebGL2 + 半精度浮点纹理):full 档保持全窗口分辨率,
11
+ 驱动或性能撑不住时自动降到较低分辨率档位,每个模拟步骤一个 fragment shader。
12
+ 不支持 WebGL2 浮点渲染的环境自动回退到低分辨率 CPU canvas 版本。 */
13
+
14
+ /* ---------- 手感参数 ---------- */
15
+ const DYE_DISSIPATION = 0.988; // 墨每帧的稀释率(≈2 秒明显变淡,3 秒基本复原)
16
+ const VEL_DISSIPATION = 0.96; // 水流每帧的衰减率(粘滞:大尺度流快速耗散,只留涡旋)
17
+ const VEL_FLOOR = 0.008; // 速度线性衰减地板(texel/帧):指数衰减永远到不了零,
18
+ // 而涡度增强会不断喂养残留噪声形成"幽灵水流",
19
+ // 每帧直接减去这个量,低于它的微流彻底归零,水才会真正静下来
20
+ const VORTICITY = 0.15; // 涡度增强强度:墨水须状卷曲的来源。
21
+ // 它每帧注入与涡度成正比的能量,必须小到能被
22
+ // VEL_DISSIPATION 的耗散压住,否则流场会自激发散
23
+ const PRESSURE_ITER = 28; // 压力投影的 Jacobi 迭代次数(GPU)
24
+ const REF_SIM_H = 180; // 速度量纲的参考网格高度:速度单位是 texel/帧,
25
+ // 网格密度改变时所有速度相关常量按 simH/REF_SIM_H 缩放,手感不变
26
+ const INK_CURVE = 2.4; // 浓度→透明度的映射陡度
27
+ const GPU_QUALITY_TIERS = [
28
+ { name: 'full', scale: 1, pressureIter: PRESSURE_ITER },
29
+ { name: 'balanced', scale: 0.5, pressureIter: 22 },
30
+ { name: 'compat', scale: 0.33, pressureIter: 16 },
31
+ ];
32
+ const GPU_SLOW_FRAME_MS = 26;
33
+ const GPU_SLOW_FRAME_LIMIT = 10;
34
+
35
+ const clamp = (x, lo, hi) => (x < lo ? lo : x > hi ? hi : x);
36
+ const DROP_DYE_RADIUS = 0.6;
37
+ const DROP_LOBES = [
38
+ { x: 0, y: 0, radius: 0.62, amount: 0.28 },
39
+ { x: 1.22, y: -0.24, radius: 0.55, amount: 0.25 },
40
+ { x: -0.94, y: 0.78, radius: 0.42, amount: 0.22 },
41
+ { x: 0.16, y: 1.28, radius: 0.32, amount: 0.16 },
42
+ { x: -1.28, y: -0.46, radius: 0.36, amount: 0.18 },
43
+ { x: 0.78, y: 0.76, radius: 0.28, amount: 0.14 },
44
+ { x: 1.68, y: 0.1, radius: 0.22, amount: 0.12 },
45
+ { x: -0.34, y: -1.06, radius: 0.26, amount: 0.12 },
46
+ ];
47
+
48
+ /* ============================================================
49
+ GPU 实现:每个模拟步骤一个 fragment shader
50
+ ============================================================ */
51
+
52
+ const VERT = `
53
+ attribute vec2 aPos;
54
+ varying vec2 vUv;
55
+ void main() {
56
+ vUv = aPos * 0.5 + 0.5;
57
+ gl_Position = vec4(aPos, 0.0, 1.0);
58
+ }
59
+ `;
60
+
61
+ /* 半拉格朗日平流:顺着速度场倒推采样,无条件稳定。
62
+ uFloor 只对速度场使用(染料场传 0):每帧线性减去固定量,微流归零 */
63
+ const FRAG_ADVECT = `
64
+ precision highp float;
65
+ varying vec2 vUv;
66
+ uniform sampler2D uVelocity;
67
+ uniform sampler2D uSource;
68
+ uniform vec2 uSimTexel;
69
+ uniform float uDissipation;
70
+ uniform float uFloor;
71
+ uniform float uMaxVel;
72
+ void main() {
73
+ vec2 vel = texture2D(uVelocity, vUv).xy;
74
+ vec2 coord = vUv - vel * uSimTexel;
75
+ vec4 val = uDissipation * texture2D(uSource, coord);
76
+ float m = length(val.xy);
77
+ val.xy *= max(0.0, m - uFloor) / max(m, 1e-6);
78
+ val.xy = clamp(val.xy, -uMaxVel, uMaxVel);
79
+ gl_FragColor = val;
80
+ }
81
+ `;
82
+
83
+ const FRAG_DIVERGENCE = `
84
+ precision highp float;
85
+ varying vec2 vUv;
86
+ uniform sampler2D uVelocity;
87
+ uniform vec2 uTexel;
88
+ void main() {
89
+ float L = texture2D(uVelocity, vUv - vec2(uTexel.x, 0.0)).x;
90
+ float R = texture2D(uVelocity, vUv + vec2(uTexel.x, 0.0)).x;
91
+ float B = texture2D(uVelocity, vUv - vec2(0.0, uTexel.y)).y;
92
+ float T = texture2D(uVelocity, vUv + vec2(0.0, uTexel.y)).y;
93
+ gl_FragColor = vec4(0.5 * (R - L + T - B), 0.0, 0.0, 1.0);
94
+ }
95
+ `;
96
+
97
+ /* 压力 Jacobi 迭代:沿用上一帧压力作初值(热启动),收敛更快 */
98
+ const FRAG_PRESSURE = `
99
+ precision highp float;
100
+ varying vec2 vUv;
101
+ uniform sampler2D uPressure;
102
+ uniform sampler2D uDivergence;
103
+ uniform vec2 uTexel;
104
+ void main() {
105
+ float L = texture2D(uPressure, vUv - vec2(uTexel.x, 0.0)).x;
106
+ float R = texture2D(uPressure, vUv + vec2(uTexel.x, 0.0)).x;
107
+ float B = texture2D(uPressure, vUv - vec2(0.0, uTexel.y)).x;
108
+ float T = texture2D(uPressure, vUv + vec2(0.0, uTexel.y)).x;
109
+ float div = texture2D(uDivergence, vUv).x;
110
+ gl_FragColor = vec4((L + R + B + T - div) * 0.25, 0.0, 0.0, 1.0);
111
+ }
112
+ `;
113
+
114
+ /* 减压力梯度:让流场无散度(水不可压缩),墨才会打转而不是堆积 */
115
+ const FRAG_GRADIENT = `
116
+ precision highp float;
117
+ varying vec2 vUv;
118
+ uniform sampler2D uPressure;
119
+ uniform sampler2D uVelocity;
120
+ uniform vec2 uTexel;
121
+ void main() {
122
+ float L = texture2D(uPressure, vUv - vec2(uTexel.x, 0.0)).x;
123
+ float R = texture2D(uPressure, vUv + vec2(uTexel.x, 0.0)).x;
124
+ float B = texture2D(uPressure, vUv - vec2(0.0, uTexel.y)).x;
125
+ float T = texture2D(uPressure, vUv + vec2(0.0, uTexel.y)).x;
126
+ vec2 vel = texture2D(uVelocity, vUv).xy - 0.5 * vec2(R - L, T - B);
127
+ gl_FragColor = vec4(vel, 0.0, 1.0);
128
+ }
129
+ `;
130
+
131
+ const FRAG_CURL = `
132
+ precision highp float;
133
+ varying vec2 vUv;
134
+ uniform sampler2D uVelocity;
135
+ uniform vec2 uTexel;
136
+ void main() {
137
+ float L = texture2D(uVelocity, vUv - vec2(uTexel.x, 0.0)).y;
138
+ float R = texture2D(uVelocity, vUv + vec2(uTexel.x, 0.0)).y;
139
+ float B = texture2D(uVelocity, vUv - vec2(0.0, uTexel.y)).x;
140
+ float T = texture2D(uVelocity, vUv + vec2(0.0, uTexel.y)).x;
141
+ gl_FragColor = vec4(0.5 * ((R - L) - (T - B)), 0.0, 0.0, 1.0);
142
+ }
143
+ `;
144
+
145
+ /* 涡度增强:把数值耗散掉的小涡旋补回来,墨须就是它卷出来的 */
146
+ const FRAG_VORTICITY = `
147
+ precision highp float;
148
+ varying vec2 vUv;
149
+ uniform sampler2D uVelocity;
150
+ uniform sampler2D uCurl;
151
+ uniform vec2 uTexel;
152
+ uniform float uVorticity;
153
+ void main() {
154
+ float L = abs(texture2D(uCurl, vUv - vec2(uTexel.x, 0.0)).x);
155
+ float R = abs(texture2D(uCurl, vUv + vec2(uTexel.x, 0.0)).x);
156
+ float B = abs(texture2D(uCurl, vUv - vec2(0.0, uTexel.y)).x);
157
+ float T = abs(texture2D(uCurl, vUv + vec2(0.0, uTexel.y)).x);
158
+ float C = texture2D(uCurl, vUv).x;
159
+ vec2 grad = 0.5 * vec2(R - L, T - B);
160
+ vec2 N = grad / (length(grad) + 1e-5);
161
+ vec2 vel = texture2D(uVelocity, vUv).xy + uVorticity * C * vec2(N.y, -N.x);
162
+ gl_FragColor = vec4(vel, 0.0, 1.0);
163
+ }
164
+ `;
165
+
166
+ /* 注墨/注动量:高斯团叠加到目标场上 */
167
+ const FRAG_SPLAT = `
168
+ precision highp float;
169
+ varying vec2 vUv;
170
+ uniform sampler2D uTarget;
171
+ uniform vec2 uPoint;
172
+ uniform vec3 uValue;
173
+ uniform float uRadius;
174
+ uniform float uAspect;
175
+ void main() {
176
+ vec2 p = vUv - uPoint;
177
+ p.x *= uAspect;
178
+ float s = exp(-dot(p, p) / uRadius);
179
+ gl_FragColor = vec4(texture2D(uTarget, vUv).xyz + uValue * s, 1.0);
180
+ }
181
+ `;
182
+
183
+ /* 滴墨:径向外冲的速度环,sqrt(q) 让中心平静、外圈推力大 */
184
+ const FRAG_DROP = `
185
+ precision highp float;
186
+ varying vec2 vUv;
187
+ uniform sampler2D uTarget;
188
+ uniform vec2 uPoint;
189
+ uniform float uRadius;
190
+ uniform float uStrength;
191
+ uniform float uAspect;
192
+ void main() {
193
+ vec2 p = vUv - uPoint;
194
+ p.x *= uAspect;
195
+ float q = dot(p, p) / uRadius;
196
+ vec2 dir = p / (length(p) + 1e-5);
197
+ vec2 vel = texture2D(uTarget, vUv).xy + dir * uStrength * exp(-4.0 * q) * sqrt(q);
198
+ gl_FragColor = vec4(vel, 0.0, 1.0);
199
+ }
200
+ `;
201
+
202
+ /* 显影合成:输出纸色,墨浓处透明度降低、露出背景图(预乘 alpha) */
203
+ const FRAG_DISPLAY = `
204
+ precision highp float;
205
+ varying vec2 vUv;
206
+ uniform sampler2D uDye;
207
+ uniform vec3 uPaper;
208
+ uniform float uCurve;
209
+ void main() {
210
+ float d = texture2D(uDye, vUv).x;
211
+ float ink = 0.98 * (1.0 - exp(-uCurve * d));
212
+ float a = 1.0 - ink;
213
+ gl_FragColor = vec4(uPaper * a, a);
214
+ }
215
+ `;
216
+
217
+ function compileShader(gl, type, src) {
218
+ const sh = gl.createShader(type);
219
+ gl.shaderSource(sh, src);
220
+ gl.compileShader(sh);
221
+ if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
222
+ throw new Error(gl.getShaderInfoLog(sh));
223
+ }
224
+ return sh;
225
+ }
226
+
227
+ function createProgram(gl, fragSrc) {
228
+ const prog = gl.createProgram();
229
+ gl.attachShader(prog, compileShader(gl, gl.VERTEX_SHADER, VERT));
230
+ gl.attachShader(prog, compileShader(gl, gl.FRAGMENT_SHADER, fragSrc));
231
+ gl.bindAttribLocation(prog, 0, 'aPos');
232
+ gl.linkProgram(prog);
233
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
234
+ throw new Error(gl.getProgramInfoLog(prog));
235
+ }
236
+ const uniforms = {};
237
+ const n = gl.getProgramParameter(prog, gl.ACTIVE_UNIFORMS);
238
+ for (let i = 0; i < n; i++) {
239
+ const name = gl.getActiveUniform(prog, i).name;
240
+ uniforms[name] = gl.getUniformLocation(prog, name);
241
+ }
242
+ return { prog, uniforms };
243
+ }
244
+
245
+ class InkGL {
246
+ static supported() {
247
+ try {
248
+ const gl = document.createElement('canvas').getContext('webgl2');
249
+ return !!(gl && gl.getExtension('EXT_color_buffer_float'));
250
+ } catch {
251
+ return false;
252
+ }
253
+ }
254
+
255
+ constructor(canvas, tierIndex = 0) {
256
+ const gl = canvas.getContext('webgl2', {
257
+ alpha: true, depth: false, stencil: false, antialias: false,
258
+ });
259
+ if (!gl || !gl.getExtension('EXT_color_buffer_float')) {
260
+ throw new Error('WebGL2 float rendering unsupported');
261
+ }
262
+ this.gl = gl;
263
+ this.tierIndex = tierIndex;
264
+ this.tier = GPU_QUALITY_TIERS[tierIndex];
265
+
266
+ const buf = gl.createBuffer();
267
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
268
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
269
+ gl.enableVertexAttribArray(0);
270
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
271
+ gl.disable(gl.BLEND);
272
+
273
+ this.pAdvect = createProgram(gl, FRAG_ADVECT);
274
+ this.pDivergence = createProgram(gl, FRAG_DIVERGENCE);
275
+ this.pPressure = createProgram(gl, FRAG_PRESSURE);
276
+ this.pGradient = createProgram(gl, FRAG_GRADIENT);
277
+ this.pCurl = createProgram(gl, FRAG_CURL);
278
+ this.pVorticity = createProgram(gl, FRAG_VORTICITY);
279
+ this.pSplat = createProgram(gl, FRAG_SPLAT);
280
+ this.pDrop = createProgram(gl, FRAG_DROP);
281
+ this.pDisplay = createProgram(gl, FRAG_DISPLAY);
282
+ this._targets = [];
283
+ }
284
+
285
+ resize(w, h) {
286
+ const gl = this.gl;
287
+ this.w = w;
288
+ this.h = h;
289
+ const scale = this.tier.scale;
290
+ this.simW = Math.max(16, Math.round(w * scale));
291
+ this.simH = Math.max(16, Math.round(h * scale));
292
+ this.velScale = this.simH / REF_SIM_H;
293
+ for (const t of this._targets) {
294
+ gl.deleteTexture(t.tex);
295
+ gl.deleteFramebuffer(t.fbo);
296
+ }
297
+ this._targets = [];
298
+ this.velocity = this._double(this.simW, this.simH, gl.RG16F, gl.RG, gl.LINEAR);
299
+ this.pressure = this._double(this.simW, this.simH, gl.R16F, gl.RED, gl.NEAREST);
300
+ this.divergence = this._fbo(this.simW, this.simH, gl.R16F, gl.RED, gl.NEAREST);
301
+ this.curl = this._fbo(this.simW, this.simH, gl.R16F, gl.RED, gl.NEAREST);
302
+ this.dye = this._double(this.simW, this.simH, gl.R16F, gl.RED, gl.LINEAR);
303
+ }
304
+
305
+ _fbo(w, h, internal, format, filter) {
306
+ const gl = this.gl;
307
+ const tex = gl.createTexture();
308
+ gl.activeTexture(gl.TEXTURE0);
309
+ gl.bindTexture(gl.TEXTURE_2D, tex);
310
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
311
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
312
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
313
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
314
+ gl.texImage2D(gl.TEXTURE_2D, 0, internal, w, h, 0, format, gl.HALF_FLOAT, null);
315
+ const fbo = gl.createFramebuffer();
316
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
317
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
318
+ const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
319
+ if (status !== gl.FRAMEBUFFER_COMPLETE) {
320
+ gl.deleteTexture(tex);
321
+ gl.deleteFramebuffer(fbo);
322
+ throw new Error(`Framebuffer incomplete: ${status}`);
323
+ }
324
+ gl.viewport(0, 0, w, h);
325
+ gl.clearColor(0, 0, 0, 0);
326
+ gl.clear(gl.COLOR_BUFFER_BIT);
327
+ const target = { tex, fbo, w, h };
328
+ this._targets.push(target);
329
+ return target;
330
+ }
331
+
332
+ _double(w, h, internal, format, filter) {
333
+ const pair = {
334
+ read: this._fbo(w, h, internal, format, filter),
335
+ write: this._fbo(w, h, internal, format, filter),
336
+ swap() { [this.read, this.write] = [this.write, this.read]; },
337
+ };
338
+ return pair;
339
+ }
340
+
341
+ _tex(t, unit) {
342
+ const gl = this.gl;
343
+ gl.activeTexture(gl.TEXTURE0 + unit);
344
+ gl.bindTexture(gl.TEXTURE_2D, t.tex);
345
+ return unit;
346
+ }
347
+
348
+ _blit(target) {
349
+ const gl = this.gl;
350
+ gl.bindFramebuffer(gl.FRAMEBUFFER, target ? target.fbo : null);
351
+ gl.viewport(0, 0, target ? target.w : this.w, target ? target.h : this.h);
352
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
353
+ }
354
+
355
+ /* x/y 为 UV 坐标(左下原点),dx/dy 为速度(模拟网格 texel/帧) */
356
+ splat(x, y, dx, dy, amount, rVel, rDye) {
357
+ const gl = this.gl;
358
+ const P = this.pSplat;
359
+ gl.useProgram(P.prog);
360
+ gl.uniform1f(P.uniforms.uAspect, this.w / this.h);
361
+ gl.uniform2f(P.uniforms.uPoint, x, y);
362
+ gl.uniform1i(P.uniforms.uTarget, this._tex(this.velocity.read, 0));
363
+ gl.uniform1f(P.uniforms.uRadius, rVel);
364
+ gl.uniform3f(P.uniforms.uValue, dx, dy, 0);
365
+ this._blit(this.velocity.write);
366
+ this.velocity.swap();
367
+ gl.uniform1i(P.uniforms.uTarget, this._tex(this.dye.read, 0));
368
+ gl.uniform1f(P.uniforms.uRadius, rDye);
369
+ gl.uniform3f(P.uniforms.uValue, amount, 0, 0);
370
+ this._blit(this.dye.write);
371
+ this.dye.swap();
372
+ }
373
+
374
+ drop(x, y, strength, radius, amount) {
375
+ const gl = this.gl;
376
+ const P = this.pDrop;
377
+ gl.useProgram(P.prog);
378
+ gl.uniform1f(P.uniforms.uAspect, this.w / this.h);
379
+ gl.uniform2f(P.uniforms.uPoint, x, y);
380
+ gl.uniform1f(P.uniforms.uRadius, radius);
381
+ gl.uniform1f(P.uniforms.uStrength, strength);
382
+ gl.uniform1i(P.uniforms.uTarget, this._tex(this.velocity.read, 0));
383
+ this._blit(this.velocity.write);
384
+ this.velocity.swap();
385
+ const S = this.pSplat;
386
+ const aspect = this.w / this.h;
387
+ const dyeRadius = radius * DROP_DYE_RADIUS;
388
+ const dyeSpread = Math.sqrt(dyeRadius);
389
+ gl.useProgram(S.prog);
390
+ gl.uniform1f(S.uniforms.uAspect, aspect);
391
+ for (const lobe of DROP_LOBES) {
392
+ gl.uniform2f(
393
+ S.uniforms.uPoint,
394
+ x + (lobe.x * dyeSpread) / aspect,
395
+ y + lobe.y * dyeSpread,
396
+ );
397
+ gl.uniform1i(S.uniforms.uTarget, this._tex(this.dye.read, 0));
398
+ gl.uniform1f(S.uniforms.uRadius, dyeRadius * lobe.radius * lobe.radius);
399
+ gl.uniform3f(S.uniforms.uValue, amount * lobe.amount, 0, 0);
400
+ this._blit(this.dye.write);
401
+ this.dye.swap();
402
+ }
403
+ }
404
+
405
+ step(paper) {
406
+ const gl = this.gl;
407
+ const tx = 1 / this.simW;
408
+ const ty = 1 / this.simH;
409
+
410
+ let P = this.pCurl;
411
+ gl.useProgram(P.prog);
412
+ gl.uniform2f(P.uniforms.uTexel, tx, ty);
413
+ gl.uniform1i(P.uniforms.uVelocity, this._tex(this.velocity.read, 0));
414
+ this._blit(this.curl);
415
+
416
+ P = this.pVorticity;
417
+ gl.useProgram(P.prog);
418
+ gl.uniform2f(P.uniforms.uTexel, tx, ty);
419
+ // 注意:涡度强度不随 velScale 缩放——它的稳定性条件是逐格噪声层面的,
420
+ // 与网格密度无关;乘上去会直接进入自激发散区间
421
+ gl.uniform1f(P.uniforms.uVorticity, VORTICITY);
422
+ gl.uniform1i(P.uniforms.uVelocity, this._tex(this.velocity.read, 0));
423
+ gl.uniform1i(P.uniforms.uCurl, this._tex(this.curl, 1));
424
+ this._blit(this.velocity.write);
425
+ this.velocity.swap();
426
+
427
+ P = this.pDivergence;
428
+ gl.useProgram(P.prog);
429
+ gl.uniform2f(P.uniforms.uTexel, tx, ty);
430
+ gl.uniform1i(P.uniforms.uVelocity, this._tex(this.velocity.read, 0));
431
+ this._blit(this.divergence);
432
+
433
+ P = this.pPressure;
434
+ gl.useProgram(P.prog);
435
+ gl.uniform2f(P.uniforms.uTexel, tx, ty);
436
+ gl.uniform1i(P.uniforms.uDivergence, this._tex(this.divergence, 1));
437
+ for (let i = 0; i < this.tier.pressureIter; i++) {
438
+ gl.uniform1i(P.uniforms.uPressure, this._tex(this.pressure.read, 0));
439
+ this._blit(this.pressure.write);
440
+ this.pressure.swap();
441
+ }
442
+
443
+ P = this.pGradient;
444
+ gl.useProgram(P.prog);
445
+ gl.uniform2f(P.uniforms.uTexel, tx, ty);
446
+ gl.uniform1i(P.uniforms.uPressure, this._tex(this.pressure.read, 0));
447
+ gl.uniform1i(P.uniforms.uVelocity, this._tex(this.velocity.read, 1));
448
+ this._blit(this.velocity.write);
449
+ this.velocity.swap();
450
+
451
+ P = this.pAdvect;
452
+ gl.useProgram(P.prog);
453
+ gl.uniform2f(P.uniforms.uSimTexel, tx, ty);
454
+ gl.uniform1f(P.uniforms.uDissipation, VEL_DISSIPATION);
455
+ gl.uniform1f(P.uniforms.uFloor, VEL_FLOOR * this.velScale);
456
+ gl.uniform1f(P.uniforms.uMaxVel, 8 * this.velScale);
457
+ gl.uniform1i(P.uniforms.uVelocity, this._tex(this.velocity.read, 0));
458
+ gl.uniform1i(P.uniforms.uSource, this._tex(this.velocity.read, 0));
459
+ this._blit(this.velocity.write);
460
+ this.velocity.swap();
461
+
462
+ gl.uniform1f(P.uniforms.uDissipation, DYE_DISSIPATION);
463
+ gl.uniform1f(P.uniforms.uFloor, 0);
464
+ gl.uniform1i(P.uniforms.uVelocity, this._tex(this.velocity.read, 0));
465
+ gl.uniform1i(P.uniforms.uSource, this._tex(this.dye.read, 1));
466
+ this._blit(this.dye.write);
467
+ this.dye.swap();
468
+
469
+ P = this.pDisplay;
470
+ gl.useProgram(P.prog);
471
+ gl.uniform1f(P.uniforms.uCurve, INK_CURVE);
472
+ gl.uniform3f(P.uniforms.uPaper, paper[0], paper[1], paper[2]);
473
+ gl.uniform1i(P.uniforms.uDye, this._tex(this.dye.read, 0));
474
+ this._blit(null);
475
+ if (gl.isContextLost()) {
476
+ throw new Error('WebGL context lost');
477
+ }
478
+ }
479
+ }
480
+
481
+ /* ============================================================
482
+ CPU 回退实现:低分辨率网格上的同一套算法
483
+ ============================================================ */
484
+
485
+ const CPU_MAX_CELLS = 26000;
486
+ const CPU_PRESSURE_ITER = 14;
487
+
488
+ class FluidCPU {
489
+ constructor(w, h) {
490
+ this.w = w;
491
+ this.h = h;
492
+ const n = w * h;
493
+ this.u = new Float32Array(n);
494
+ this.v = new Float32Array(n);
495
+ this.u2 = new Float32Array(n);
496
+ this.v2 = new Float32Array(n);
497
+ this.d = new Float32Array(n);
498
+ this.d2 = new Float32Array(n);
499
+ this.p = new Float32Array(n);
500
+ this.div = new Float32Array(n);
501
+ this.curl = new Float32Array(n);
502
+ }
503
+
504
+ splat(x, y, dx, dy, amount, radius) {
505
+ const { w, h, u, v, d } = this;
506
+ const r2 = radius * radius;
507
+ const x0 = Math.max(1, Math.floor(x - radius));
508
+ const x1 = Math.min(w - 2, Math.ceil(x + radius));
509
+ const y0 = Math.max(1, Math.floor(y - radius));
510
+ const y1 = Math.min(h - 2, Math.ceil(y + radius));
511
+ for (let j = y0; j <= y1; j++) {
512
+ for (let i = x0; i <= x1; i++) {
513
+ const q = ((i - x) * (i - x) + (j - y) * (j - y)) / r2;
514
+ if (q >= 1) continue;
515
+ const f = Math.exp(-4 * q);
516
+ const k = i + j * w;
517
+ d[k] += amount * f;
518
+ u[k] += dx * f;
519
+ v[k] += dy * f;
520
+ }
521
+ }
522
+ }
523
+
524
+ drop(x, y, strength, radius, amount) {
525
+ const { w, h, u, v, d } = this;
526
+ const r2 = radius * radius;
527
+ const x0 = Math.max(1, Math.floor(x - radius));
528
+ const x1 = Math.min(w - 2, Math.ceil(x + radius));
529
+ const y0 = Math.max(1, Math.floor(y - radius));
530
+ const y1 = Math.min(h - 2, Math.ceil(y + radius));
531
+ for (let j = y0; j <= y1; j++) {
532
+ for (let i = x0; i <= x1; i++) {
533
+ const ox = i - x;
534
+ const oy = j - y;
535
+ const q = (ox * ox + oy * oy) / r2;
536
+ if (q >= 1) continue;
537
+ const k = i + j * w;
538
+ const len = Math.sqrt(ox * ox + oy * oy) + 1e-5;
539
+ const push = strength * Math.exp(-4 * q) * Math.sqrt(q);
540
+ u[k] += (ox / len) * push;
541
+ v[k] += (oy / len) * push;
542
+ }
543
+ }
544
+ for (const lobe of DROP_LOBES) {
545
+ this._addDropDyeLobe(
546
+ x + lobe.x * radius,
547
+ y + lobe.y * radius,
548
+ radius * lobe.radius,
549
+ amount * lobe.amount,
550
+ );
551
+ }
552
+ }
553
+
554
+ _addDropDyeLobe(x, y, radius, amount) {
555
+ const { w, h, d } = this;
556
+ const r2 = radius * radius;
557
+ const x0 = Math.max(1, Math.floor(x - radius));
558
+ const x1 = Math.min(w - 2, Math.ceil(x + radius));
559
+ const y0 = Math.max(1, Math.floor(y - radius));
560
+ const y1 = Math.min(h - 2, Math.ceil(y + radius));
561
+ for (let j = y0; j <= y1; j++) {
562
+ for (let i = x0; i <= x1; i++) {
563
+ const q = ((i - x) * (i - x) + (j - y) * (j - y)) / r2;
564
+ if (q >= 1) continue;
565
+ d[i + j * w] += amount * Math.exp(-5 * q);
566
+ }
567
+ }
568
+ }
569
+
570
+ step() {
571
+ this._vorticity();
572
+ this._advectVelocity();
573
+ this._project();
574
+ this._advectDye();
575
+ }
576
+
577
+ _vorticity() {
578
+ const { w, h, u, v, curl } = this;
579
+ for (let j = 1; j < h - 1; j++) {
580
+ for (let i = 1; i < w - 1; i++) {
581
+ const k = i + j * w;
582
+ curl[k] = (v[k + 1] - v[k - 1] - u[k + w] + u[k - w]) * 0.5;
583
+ }
584
+ }
585
+ for (let j = 2; j < h - 2; j++) {
586
+ for (let i = 2; i < w - 2; i++) {
587
+ const k = i + j * w;
588
+ let gx = Math.abs(curl[k + 1]) - Math.abs(curl[k - 1]);
589
+ let gy = Math.abs(curl[k + w]) - Math.abs(curl[k - w]);
590
+ const len = Math.sqrt(gx * gx + gy * gy) + 1e-5;
591
+ gx /= len;
592
+ gy /= len;
593
+ u[k] += VORTICITY * gy * curl[k];
594
+ v[k] -= VORTICITY * gx * curl[k];
595
+ }
596
+ }
597
+ }
598
+
599
+ _project() {
600
+ const { w, h, u, v, p, div } = this;
601
+ for (let j = 1; j < h - 1; j++) {
602
+ for (let i = 1; i < w - 1; i++) {
603
+ const k = i + j * w;
604
+ div[k] = -0.5 * (u[k + 1] - u[k - 1] + v[k + w] - v[k - w]);
605
+ p[k] = 0;
606
+ }
607
+ }
608
+ for (let it = 0; it < CPU_PRESSURE_ITER; it++) {
609
+ for (let j = 1; j < h - 1; j++) {
610
+ for (let i = 1; i < w - 1; i++) {
611
+ const k = i + j * w;
612
+ p[k] = (div[k] + p[k - 1] + p[k + 1] + p[k - w] + p[k + w]) * 0.25;
613
+ }
614
+ }
615
+ }
616
+ for (let j = 1; j < h - 1; j++) {
617
+ for (let i = 1; i < w - 1; i++) {
618
+ const k = i + j * w;
619
+ u[k] -= 0.5 * (p[k + 1] - p[k - 1]);
620
+ v[k] -= 0.5 * (p[k + w] - p[k - w]);
621
+ }
622
+ }
623
+ }
624
+
625
+ _advectVelocity() {
626
+ const { w, h, u, v, u2, v2 } = this;
627
+ for (let j = 1; j < h - 1; j++) {
628
+ for (let i = 1; i < w - 1; i++) {
629
+ const k = i + j * w;
630
+ const x = clamp(i - u[k], 0.5, w - 1.5);
631
+ const y = clamp(j - v[k], 0.5, h - 1.5);
632
+ let nu = this._sample(u, x, y) * VEL_DISSIPATION;
633
+ let nv = this._sample(v, x, y) * VEL_DISSIPATION;
634
+ // 线性衰减地板:低于阈值的微流彻底归零
635
+ const m = Math.sqrt(nu * nu + nv * nv);
636
+ if (m < VEL_FLOOR) {
637
+ nu = 0;
638
+ nv = 0;
639
+ } else {
640
+ const f = (m - VEL_FLOOR) / m;
641
+ nu *= f;
642
+ nv *= f;
643
+ }
644
+ u2[k] = nu;
645
+ v2[k] = nv;
646
+ }
647
+ }
648
+ [this.u, this.u2] = [this.u2, this.u];
649
+ [this.v, this.v2] = [this.v2, this.v];
650
+ }
651
+
652
+ _advectDye() {
653
+ const { w, h, u, v, d, d2 } = this;
654
+ for (let j = 1; j < h - 1; j++) {
655
+ for (let i = 1; i < w - 1; i++) {
656
+ const k = i + j * w;
657
+ const x = clamp(i - u[k], 0.5, w - 1.5);
658
+ const y = clamp(j - v[k], 0.5, h - 1.5);
659
+ d2[k] = this._sample(d, x, y) * DYE_DISSIPATION;
660
+ }
661
+ }
662
+ [this.d, this.d2] = [this.d2, this.d];
663
+ }
664
+
665
+ _sample(f, x, y) {
666
+ const { w } = this;
667
+ const i0 = x | 0;
668
+ const j0 = y | 0;
669
+ const sx = x - i0;
670
+ const sy = y - j0;
671
+ const k = i0 + j0 * w;
672
+ const a = f[k] * (1 - sx) + f[k + 1] * sx;
673
+ const b = f[k + w] * (1 - sx) + f[k + w + 1] * sx;
674
+ return a * (1 - sy) + b * sy;
675
+ }
676
+ }
677
+
678
+ /* ============================================================
679
+ 组件
680
+ ============================================================ */
681
+
682
+ class ZhimoInkPaper extends HTMLElement {
683
+ static observedAttributes = ['image'];
684
+
685
+ constructor() {
686
+ super();
687
+ this.attachShadow({ mode: 'open' });
688
+ this.shadowRoot.innerHTML = `
689
+ <style>
690
+ :host {
691
+ position: fixed;
692
+ inset: 0;
693
+ z-index: -1;
694
+ pointer-events: none;
695
+ }
696
+ .bg, canvas {
697
+ position: absolute;
698
+ inset: 0;
699
+ width: 100%;
700
+ height: 100%;
701
+ }
702
+ .bg { background-size: cover; background-position: center; }
703
+ </style>
704
+ <div class="bg" part="image"></div>
705
+ <canvas></canvas>
706
+ `;
707
+ this._bg = this.shadowRoot.querySelector('.bg');
708
+ this._canvas = this.shadowRoot.querySelector('canvas');
709
+ this._last = null;
710
+ }
711
+
712
+ attributeChangedCallback(name, _old, val) {
713
+ if (name === 'image') {
714
+ this._bg.style.backgroundImage = val ? `url("${val}")` : 'none';
715
+ }
716
+ }
717
+
718
+ connectedCallback() {
719
+ this._still = matchMedia('(prefers-reduced-motion: reduce)').matches;
720
+ this._gl = null;
721
+ this._usingCPU = false;
722
+ this._gpuAvailable = InkGL.supported();
723
+ this._gpuTierIndex = 0;
724
+ this._slowFrames = 0;
725
+
726
+ this._readPaper();
727
+ this._themeObserver = new MutationObserver(() => this._readPaper());
728
+ this._themeObserver.observe(document.documentElement, {
729
+ attributes: true, attributeFilter: ['data-theme'],
730
+ });
731
+
732
+ this._onResize = () => this._resize();
733
+ this._onMove = (e) => this._pointerMove(e.clientX, e.clientY);
734
+ this._onDown = (e) => this._pointerDown(e.clientX, e.clientY);
735
+ window.addEventListener('resize', this._onResize);
736
+ window.addEventListener('pointermove', this._onMove);
737
+ window.addEventListener('pointerdown', this._onDown);
738
+
739
+ this._resize();
740
+ this._raf = requestAnimationFrame(this._tick);
741
+ }
742
+
743
+ disconnectedCallback() {
744
+ cancelAnimationFrame(this._raf);
745
+ this._themeObserver.disconnect();
746
+ window.removeEventListener('resize', this._onResize);
747
+ window.removeEventListener('pointermove', this._onMove);
748
+ window.removeEventListener('pointerdown', this._onDown);
749
+ }
750
+
751
+ _readPaper() {
752
+ const str = getComputedStyle(document.documentElement)
753
+ .getPropertyValue('--zhimo-bg').trim() || '#faf7f0';
754
+ this._paper = str;
755
+ this._paperRGB = this._parseColor(str);
756
+ }
757
+
758
+ _parseColor(str) {
759
+ if (str[0] === '#') {
760
+ let hex = str.slice(1);
761
+ if (hex.length === 3) hex = hex.replace(/./g, (c) => c + c);
762
+ const n = parseInt(hex, 16);
763
+ return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
764
+ }
765
+ const m = str.match(/[\d.]+/g);
766
+ if (m && m.length >= 3) return [m[0] / 255, m[1] / 255, m[2] / 255];
767
+ return [0.98, 0.968, 0.941];
768
+ }
769
+
770
+ _resize() {
771
+ const w = window.innerWidth;
772
+ const h = window.innerHeight;
773
+ if (this._gpuAvailable && !this._usingCPU && this._resizeGpu(w, h, this._gpuTierIndex)) {
774
+ this._last = null;
775
+ return;
776
+ }
777
+ this._switchToCPU();
778
+ this._resizeCPU(w, h);
779
+ this._last = null;
780
+ }
781
+
782
+ _replaceCanvas() {
783
+ const canvas = document.createElement('canvas');
784
+ this._canvas.replaceWith(canvas);
785
+ this._canvas = canvas;
786
+ }
787
+
788
+ _resizeGpu(w, h, startTier) {
789
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
790
+ const k = Math.min(1, 2048 / (Math.max(w, h) * dpr));
791
+ const cw = Math.round(w * dpr * k);
792
+ const ch = Math.round(h * dpr * k);
793
+ for (let tier = startTier; tier < GPU_QUALITY_TIERS.length; tier++) {
794
+ try {
795
+ if (!this._gl || this._gl.tierIndex !== tier) {
796
+ if (this._gl || tier !== startTier) this._replaceCanvas();
797
+ this._gl = new InkGL(this._canvas, tier);
798
+ }
799
+ this._canvas.width = cw;
800
+ this._canvas.height = ch;
801
+ this._gl.resize(cw, ch);
802
+ this._gpuTierIndex = tier;
803
+ this._slowFrames = 0;
804
+ return true;
805
+ } catch {
806
+ this._gl = null;
807
+ }
808
+ }
809
+ this._gpuAvailable = false;
810
+ return false;
811
+ }
812
+
813
+ _switchToCPU() {
814
+ if (!this._usingCPU) {
815
+ this._replaceCanvas();
816
+ this._ctx = this._canvas.getContext('2d');
817
+ this._low = document.createElement('canvas');
818
+ this._lctx = this._low.getContext('2d');
819
+ this._usingCPU = true;
820
+ this._gl = null;
821
+ }
822
+ }
823
+
824
+ _resizeCPU(w, h) {
825
+ this._canvas.width = w;
826
+ this._canvas.height = h;
827
+ this._cpuScale = Math.max(4, Math.ceil(Math.sqrt((w * h) / CPU_MAX_CELLS)));
828
+ const gw = Math.max(16, Math.round(w / this._cpuScale));
829
+ const gh = Math.max(16, Math.round(h / this._cpuScale));
830
+ this._fluid = new FluidCPU(gw, gh);
831
+ this._low.width = gw;
832
+ this._low.height = gh;
833
+ this._image = this._lctx.createImageData(gw, gh);
834
+ }
835
+
836
+ _downgradeGpu() {
837
+ const nextTier = (this._gl ? this._gl.tierIndex : this._gpuTierIndex) + 1;
838
+ this._gl = null;
839
+ this._gpuTierIndex = nextTier;
840
+ this._slowFrames = 0;
841
+ if (nextTier < GPU_QUALITY_TIERS.length) {
842
+ this._replaceCanvas();
843
+ if (this._resizeGpu(window.innerWidth, window.innerHeight, nextTier)) return;
844
+ }
845
+ this._gpuAvailable = false;
846
+ this._switchToCPU();
847
+ this._resizeCPU(window.innerWidth, window.innerHeight);
848
+ }
849
+
850
+ _pointerMove(x, y) {
851
+ if (this._still) return;
852
+ if (!this._last) {
853
+ this._last = { x, y };
854
+ return;
855
+ }
856
+ const dx = x - this._last.x;
857
+ const dy = y - this._last.y;
858
+ const dist = Math.sqrt(dx * dx + dy * dy);
859
+ const w = window.innerWidth;
860
+ const h = window.innerHeight;
861
+
862
+ if (this._gl) {
863
+ // 鼠标速度 → 模拟网格 texel/帧
864
+ // 墨沿轨迹铺,但动量只在指针当前位置注入一次:
865
+ // 否则相邻墨团区域重叠、速度成倍叠加,会变成把墨吹飞的喷流
866
+ const vs = this._gl.velScale;
867
+ const vx = clamp(dx * (this._gl.simW / w) * 0.7, -6 * vs, 6 * vs);
868
+ const vy = clamp(-dy * (this._gl.simH / h) * 0.7, -6 * vs, 6 * vs);
869
+ const steps = Math.max(1, Math.ceil(dist / 8));
870
+ for (let i = 1; i <= steps; i++) {
871
+ const t = i / steps;
872
+ const ux = (this._last.x + dx * t) / w;
873
+ const uy = 1 - (this._last.y + dy * t) / h;
874
+ const isLast = i === steps;
875
+ this._gl.splat(ux, uy, isLast ? vx : 0, isLast ? vy : 0,
876
+ 0.45 / steps + 0.08, 4e-4, 1.2e-4);
877
+ }
878
+ } else {
879
+ const s = this._cpuScale;
880
+ const vx = clamp((dx / s) * 0.8, -3, 3);
881
+ const vy = clamp((dy / s) * 0.8, -3, 3);
882
+ const gdist = dist / s;
883
+ const steps = Math.max(1, Math.ceil(gdist / 1.5));
884
+ for (let i = 1; i <= steps; i++) {
885
+ const t = i / steps;
886
+ const isLast = i === steps;
887
+ this._fluid.splat(
888
+ (this._last.x + dx * t) / s,
889
+ (this._last.y + dy * t) / s,
890
+ isLast ? vx : 0, isLast ? vy : 0,
891
+ 0.55 / steps + 0.15,
892
+ 2.6,
893
+ );
894
+ }
895
+ }
896
+ this._last = { x, y };
897
+ }
898
+
899
+ _pointerDown(x, y) {
900
+ if (this._still) return;
901
+ if (this._gl) {
902
+ const ux = x / window.innerWidth;
903
+ const uy = 1 - y / window.innerHeight;
904
+ this._gl.drop(ux, uy, 6 * this._gl.velScale, 2.5e-3, 1.5);
905
+ } else {
906
+ const s = this._cpuScale;
907
+ this._fluid.drop(x / s, y / s, 3.6, 7, 1.8);
908
+ }
909
+ }
910
+
911
+ _tick = () => {
912
+ this._raf = requestAnimationFrame(this._tick);
913
+ if (this._gl) {
914
+ const t0 = performance.now();
915
+ try {
916
+ this._gl.step(this._paperRGB);
917
+ } catch {
918
+ this._downgradeGpu();
919
+ return;
920
+ }
921
+ const elapsed = performance.now() - t0;
922
+ if (elapsed > GPU_SLOW_FRAME_MS && this._gl.tierIndex < GPU_QUALITY_TIERS.length - 1) {
923
+ this._slowFrames++;
924
+ if (this._slowFrames >= GPU_SLOW_FRAME_LIMIT) this._downgradeGpu();
925
+ } else {
926
+ this._slowFrames = 0;
927
+ }
928
+ return;
929
+ }
930
+
931
+ /* CPU 回退路径 */
932
+ const fluid = this._fluid;
933
+ fluid.step();
934
+ const { d } = fluid;
935
+ const px = this._image.data;
936
+ for (let i = 0, n = d.length; i < n; i++) {
937
+ px[i * 4 + 3] = 250 * (1 - Math.exp(-INK_CURVE * d[i]));
938
+ }
939
+ this._lctx.putImageData(this._image, 0, 0);
940
+ const c = this._ctx;
941
+ const cv = this._canvas;
942
+ c.globalCompositeOperation = 'source-over';
943
+ c.fillStyle = this._paper;
944
+ c.fillRect(0, 0, cv.width, cv.height);
945
+ c.globalCompositeOperation = 'destination-out';
946
+ c.imageSmoothingEnabled = true;
947
+ c.drawImage(this._low, 0, 0, cv.width, cv.height);
948
+ };
949
+ }
950
+
951
+ customElements.define('zhimo-ink-paper', ZhimoInkPaper);