fancy-ui-svelte 0.6.0 → 0.8.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/README.md +84 -71
- package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte +1132 -1111
- package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte.d.ts +3 -5
- package/dist/fancy-ui/fluid-cursor/FluidCursor.test.js +29 -0
- package/dist/fancy-ui/fluid-cursor/README.md +57 -6
- package/dist/fancy-ui/fluid-cursor/fluid-shared.d.ts +40 -0
- package/dist/fancy-ui/fluid-cursor/fluid-shared.js +125 -0
- package/dist/fancy-ui/fluid-cursor/fluid-shared.test.d.ts +1 -0
- package/dist/fancy-ui/fluid-cursor/fluid-shared.test.js +39 -0
- package/dist/fancy-ui/fluid-cursor/webgpu-engine.d.ts +29 -0
- package/dist/fancy-ui/fluid-cursor/webgpu-engine.js +847 -0
- package/dist/fancy-ui/frosted-glass/FrostedGlass.svelte +204 -0
- package/dist/fancy-ui/frosted-glass/FrostedGlass.svelte.d.ts +20 -0
- package/dist/fancy-ui/frosted-glass/FrostedGlass.test.d.ts +1 -0
- package/dist/fancy-ui/frosted-glass/FrostedGlass.test.js +48 -0
- package/dist/fancy-ui/frosted-glass/README.md +61 -0
- package/dist/fancy-ui/frosted-glass/index.d.ts +1 -0
- package/dist/fancy-ui/frosted-glass/index.js +1 -0
- package/dist/fancy-ui/index.d.ts +2 -0
- package/dist/fancy-ui/index.js +2 -0
- package/dist/fancy-ui/liquid-glass/LiquidGlass.svelte +19 -2
- package/dist/fancy-ui/liquid-glass/LiquidGlass.svelte.d.ts +4 -0
- package/dist/fancy-ui/liquid-glass/LiquidGlass.test.js +14 -0
- package/dist/fancy-ui/liquid-glass/README.md +7 -1
- package/dist/fancy-ui/liquid-text/LiquidText.svelte +1151 -0
- package/dist/fancy-ui/liquid-text/LiquidText.svelte.d.ts +37 -0
- package/dist/fancy-ui/liquid-text/LiquidText.test.d.ts +1 -0
- package/dist/fancy-ui/liquid-text/LiquidText.test.js +219 -0
- package/dist/fancy-ui/liquid-text/README.md +139 -0
- package/dist/fancy-ui/liquid-text/index.d.ts +2 -0
- package/dist/fancy-ui/liquid-text/index.js +1 -0
- package/dist/fancy-ui/registry.js +192 -0
- package/dist/fancy-ui/themes.d.ts +38 -0
- package/dist/fancy-ui/themes.js +356 -0
- package/dist/utils/animation.d.ts +109 -0
- package/dist/utils/animation.js +198 -0
- package/dist/utils/color.d.ts +128 -0
- package/dist/utils/color.js +314 -0
- package/dist/utils/geometry.d.ts +125 -0
- package/dist/utils/geometry.js +250 -0
- package/dist/utils/index.d.ts +8 -0
- package/dist/utils/index.js +39 -0
- package/package.json +13 -4
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
/// <reference types="@webgpu/types" />
|
|
2
|
+
// WebGPU HDR engine for FluidCursor.
|
|
3
|
+
//
|
|
4
|
+
// Port of the WebGL fluid simulation to WGSL, rendering into an
|
|
5
|
+
// `rgba16float` canvas configured with `colorSpace: "display-p3"` and
|
|
6
|
+
// `toneMapping: { mode: "extended" }`. On HDR displays, dye values pushed
|
|
7
|
+
// above 1.0 by the exposure boost render brighter than SDR white; on wide
|
|
8
|
+
// gamut (P3) displays colors are noticeably more saturated. Browsers that
|
|
9
|
+
// ignore the `toneMapping` member simply clamp to SDR — the sim still runs.
|
|
10
|
+
//
|
|
11
|
+
// The math mirrors the GLSL shaders in FluidCursor.svelte pass for pass
|
|
12
|
+
// (splat, curl, vorticity, divergence, clear, pressure Jacobi iterations,
|
|
13
|
+
// gradient subtract, advection, display). Any behavioral change here must be
|
|
14
|
+
// mirrored in the WebGL path and vice versa.
|
|
15
|
+
import { pointerPrototype, correctDeltaX, correctDeltaY, correctRadius, getSimResolution, scaleRadiusForContainer, } from "./fluid-shared.js";
|
|
16
|
+
const WGSL = /* wgsl */ `
|
|
17
|
+
struct Uniforms {
|
|
18
|
+
texelSize: vec2f,
|
|
19
|
+
point: vec2f,
|
|
20
|
+
color: vec4f,
|
|
21
|
+
dt: f32,
|
|
22
|
+
dissipation: f32,
|
|
23
|
+
curlStrength: f32,
|
|
24
|
+
clearValue: f32,
|
|
25
|
+
aspect: f32,
|
|
26
|
+
radius: f32,
|
|
27
|
+
exposure: f32,
|
|
28
|
+
shading: f32,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@group(0) @binding(0) var<uniform> u: Uniforms;
|
|
32
|
+
@group(0) @binding(1) var smpLinear: sampler;
|
|
33
|
+
@group(0) @binding(2) var smpNearest: sampler;
|
|
34
|
+
@group(0) @binding(3) var tex0: texture_2d<f32>;
|
|
35
|
+
@group(0) @binding(4) var tex1: texture_2d<f32>;
|
|
36
|
+
|
|
37
|
+
struct VSOut {
|
|
38
|
+
@builtin(position) pos: vec4f,
|
|
39
|
+
@location(0) uv: vec2f,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@vertex
|
|
43
|
+
fn vs(@builtin(vertex_index) i: u32) -> VSOut {
|
|
44
|
+
// Fullscreen triangle.
|
|
45
|
+
var p = array<vec2f, 3>(vec2f(-1.0, -3.0), vec2f(-1.0, 1.0), vec2f(3.0, 1.0));
|
|
46
|
+
var out: VSOut;
|
|
47
|
+
out.pos = vec4f(p[i], 0.0, 1.0);
|
|
48
|
+
// Negated y so uv row order matches texture row order (WebGPU NDC +Y is
|
|
49
|
+
// up but texture v=0 is the top row) — without it every pass mirrors its
|
|
50
|
+
// input vertically. Pointer texcoords use the same top-origin convention.
|
|
51
|
+
out.uv = vec2f(p[i].x, -p[i].y) * 0.5 + 0.5;
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@fragment
|
|
56
|
+
fn fs_copy(in: VSOut) -> @location(0) vec4f {
|
|
57
|
+
return textureSample(tex0, smpLinear, in.uv);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@fragment
|
|
61
|
+
fn fs_splat(in: VSOut) -> @location(0) vec4f {
|
|
62
|
+
var p = in.uv - u.point;
|
|
63
|
+
p.x = p.x * u.aspect;
|
|
64
|
+
let splat = exp(-dot(p, p) / u.radius) * u.color.rgb;
|
|
65
|
+
let base = textureSample(tex0, smpLinear, in.uv).rgb;
|
|
66
|
+
return vec4f(base + splat, 1.0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@fragment
|
|
70
|
+
fn fs_curl(in: VSOut) -> @location(0) vec4f {
|
|
71
|
+
let vL = in.uv - vec2f(u.texelSize.x, 0.0);
|
|
72
|
+
let vR = in.uv + vec2f(u.texelSize.x, 0.0);
|
|
73
|
+
let vT = in.uv + vec2f(0.0, u.texelSize.y);
|
|
74
|
+
let vB = in.uv - vec2f(0.0, u.texelSize.y);
|
|
75
|
+
let L = textureSample(tex0, smpLinear, vL).y;
|
|
76
|
+
let R = textureSample(tex0, smpLinear, vR).y;
|
|
77
|
+
let T = textureSample(tex0, smpLinear, vT).x;
|
|
78
|
+
let B = textureSample(tex0, smpLinear, vB).x;
|
|
79
|
+
let vorticity = R - L - T + B;
|
|
80
|
+
return vec4f(0.5 * vorticity, 0.0, 0.0, 1.0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@fragment
|
|
84
|
+
fn fs_vorticity(in: VSOut) -> @location(0) vec4f {
|
|
85
|
+
let vL = in.uv - vec2f(u.texelSize.x, 0.0);
|
|
86
|
+
let vR = in.uv + vec2f(u.texelSize.x, 0.0);
|
|
87
|
+
let vT = in.uv + vec2f(0.0, u.texelSize.y);
|
|
88
|
+
let vB = in.uv - vec2f(0.0, u.texelSize.y);
|
|
89
|
+
let L = textureSample(tex1, smpNearest, vL).x;
|
|
90
|
+
let R = textureSample(tex1, smpNearest, vR).x;
|
|
91
|
+
let T = textureSample(tex1, smpNearest, vT).x;
|
|
92
|
+
let B = textureSample(tex1, smpNearest, vB).x;
|
|
93
|
+
let C = textureSample(tex1, smpNearest, in.uv).x;
|
|
94
|
+
|
|
95
|
+
var force = 0.5 * vec2f(abs(T) - abs(B), abs(R) - abs(L));
|
|
96
|
+
force = force / (length(force) + 0.0001);
|
|
97
|
+
force = force * u.curlStrength * C;
|
|
98
|
+
force.y = force.y * -1.0;
|
|
99
|
+
|
|
100
|
+
var velocity = textureSample(tex0, smpLinear, in.uv).xy;
|
|
101
|
+
velocity = velocity + force * u.dt;
|
|
102
|
+
velocity = clamp(velocity, vec2f(-1000.0), vec2f(1000.0));
|
|
103
|
+
return vec4f(velocity, 0.0, 1.0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@fragment
|
|
107
|
+
fn fs_divergence(in: VSOut) -> @location(0) vec4f {
|
|
108
|
+
let vL = in.uv - vec2f(u.texelSize.x, 0.0);
|
|
109
|
+
let vR = in.uv + vec2f(u.texelSize.x, 0.0);
|
|
110
|
+
let vT = in.uv + vec2f(0.0, u.texelSize.y);
|
|
111
|
+
let vB = in.uv - vec2f(0.0, u.texelSize.y);
|
|
112
|
+
var L = textureSample(tex0, smpLinear, vL).x;
|
|
113
|
+
var R = textureSample(tex0, smpLinear, vR).x;
|
|
114
|
+
var T = textureSample(tex0, smpLinear, vT).y;
|
|
115
|
+
var B = textureSample(tex0, smpLinear, vB).y;
|
|
116
|
+
|
|
117
|
+
let C = textureSample(tex0, smpLinear, in.uv).xy;
|
|
118
|
+
if (vL.x < 0.0) { L = -C.x; }
|
|
119
|
+
if (vR.x > 1.0) { R = -C.x; }
|
|
120
|
+
if (vT.y > 1.0) { T = -C.y; }
|
|
121
|
+
if (vB.y < 0.0) { B = -C.y; }
|
|
122
|
+
|
|
123
|
+
let div = 0.5 * (R - L + T - B);
|
|
124
|
+
return vec4f(div, 0.0, 0.0, 1.0);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@fragment
|
|
128
|
+
fn fs_clear(in: VSOut) -> @location(0) vec4f {
|
|
129
|
+
return u.clearValue * textureSample(tex0, smpNearest, in.uv);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@fragment
|
|
133
|
+
fn fs_pressure(in: VSOut) -> @location(0) vec4f {
|
|
134
|
+
let vL = in.uv - vec2f(u.texelSize.x, 0.0);
|
|
135
|
+
let vR = in.uv + vec2f(u.texelSize.x, 0.0);
|
|
136
|
+
let vT = in.uv + vec2f(0.0, u.texelSize.y);
|
|
137
|
+
let vB = in.uv - vec2f(0.0, u.texelSize.y);
|
|
138
|
+
let L = textureSample(tex0, smpNearest, vL).x;
|
|
139
|
+
let R = textureSample(tex0, smpNearest, vR).x;
|
|
140
|
+
let T = textureSample(tex0, smpNearest, vT).x;
|
|
141
|
+
let B = textureSample(tex0, smpNearest, vB).x;
|
|
142
|
+
let divergence = textureSample(tex1, smpNearest, in.uv).x;
|
|
143
|
+
let pressure = (L + R + B + T - divergence) * 0.25;
|
|
144
|
+
return vec4f(pressure, 0.0, 0.0, 1.0);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@fragment
|
|
148
|
+
fn fs_gradient(in: VSOut) -> @location(0) vec4f {
|
|
149
|
+
let vL = in.uv - vec2f(u.texelSize.x, 0.0);
|
|
150
|
+
let vR = in.uv + vec2f(u.texelSize.x, 0.0);
|
|
151
|
+
let vT = in.uv + vec2f(0.0, u.texelSize.y);
|
|
152
|
+
let vB = in.uv - vec2f(0.0, u.texelSize.y);
|
|
153
|
+
let L = textureSample(tex0, smpNearest, vL).x;
|
|
154
|
+
let R = textureSample(tex0, smpNearest, vR).x;
|
|
155
|
+
let T = textureSample(tex0, smpNearest, vT).x;
|
|
156
|
+
let B = textureSample(tex0, smpNearest, vB).x;
|
|
157
|
+
var velocity = textureSample(tex1, smpLinear, in.uv).xy;
|
|
158
|
+
velocity = velocity - vec2f(R - L, T - B);
|
|
159
|
+
return vec4f(velocity, 0.0, 1.0);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@fragment
|
|
163
|
+
fn fs_advection(in: VSOut) -> @location(0) vec4f {
|
|
164
|
+
let coord = in.uv - u.dt * textureSample(tex0, smpLinear, in.uv).xy * u.texelSize;
|
|
165
|
+
let result = textureSample(tex1, smpLinear, coord);
|
|
166
|
+
let decay = 1.0 + u.dissipation * u.dt;
|
|
167
|
+
return result / decay;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
@fragment
|
|
171
|
+
fn fs_display(in: VSOut) -> @location(0) vec4f {
|
|
172
|
+
var c = textureSample(tex0, smpLinear, in.uv).rgb;
|
|
173
|
+
|
|
174
|
+
// Neighbor samples are taken unconditionally and mixed by the shading
|
|
175
|
+
// flag so texture sampling stays in uniform control flow.
|
|
176
|
+
let vL = in.uv - vec2f(u.texelSize.x, 0.0);
|
|
177
|
+
let vR = in.uv + vec2f(u.texelSize.x, 0.0);
|
|
178
|
+
let vT = in.uv + vec2f(0.0, u.texelSize.y);
|
|
179
|
+
let vB = in.uv - vec2f(0.0, u.texelSize.y);
|
|
180
|
+
let lc = textureSample(tex0, smpLinear, vL).rgb;
|
|
181
|
+
let rc = textureSample(tex0, smpLinear, vR).rgb;
|
|
182
|
+
let tc = textureSample(tex0, smpLinear, vT).rgb;
|
|
183
|
+
let bc = textureSample(tex0, smpLinear, vB).rgb;
|
|
184
|
+
let dx = length(rc) - length(lc);
|
|
185
|
+
let dy = length(tc) - length(bc);
|
|
186
|
+
let n = normalize(vec3f(dx, dy, length(u.texelSize)));
|
|
187
|
+
let l = vec3f(0.0, 0.0, 1.0);
|
|
188
|
+
let diffuse = clamp(dot(n, l) + 0.7, 0.7, 1.0);
|
|
189
|
+
c = c * mix(1.0, diffuse, u.shading);
|
|
190
|
+
|
|
191
|
+
// The canvas color space keeps the sRGB transfer function even with a
|
|
192
|
+
// float16 format (it only adds range/precision), so values are written
|
|
193
|
+
// as-is like the WebGL path — no gamma conversion. The exposure boost
|
|
194
|
+
// pushes encoded values above 1.0, which extended tone mapping renders
|
|
195
|
+
// brighter than SDR white. Alpha stays clamped for premultiplied
|
|
196
|
+
// compositing; color intentionally exceeds it for the HDR glow.
|
|
197
|
+
let a = min(max(c.r, max(c.g, c.b)), 1.0);
|
|
198
|
+
let boosted = max(c, vec3f(0.0)) * u.exposure;
|
|
199
|
+
return vec4f(boosted, a);
|
|
200
|
+
}
|
|
201
|
+
`;
|
|
202
|
+
// Float offsets inside the 64-byte Uniforms struct.
|
|
203
|
+
const U_FLOATS = 16;
|
|
204
|
+
const OFF_TEXEL = 0;
|
|
205
|
+
const OFF_POINT = 2;
|
|
206
|
+
const OFF_COLOR = 4;
|
|
207
|
+
const OFF_DT = 8;
|
|
208
|
+
const OFF_DISSIPATION = 9;
|
|
209
|
+
const OFF_CURL = 10;
|
|
210
|
+
const OFF_CLEAR = 11;
|
|
211
|
+
const OFF_ASPECT = 12;
|
|
212
|
+
const OFF_RADIUS = 13;
|
|
213
|
+
const OFF_EXPOSURE = 14;
|
|
214
|
+
const OFF_SHADING = 15;
|
|
215
|
+
function scaleByPixelRatio(input) {
|
|
216
|
+
const pixelRatio = window.devicePixelRatio || 1;
|
|
217
|
+
return Math.floor(input * pixelRatio);
|
|
218
|
+
}
|
|
219
|
+
async function createWebGpuFluid(canvas, opts) {
|
|
220
|
+
try {
|
|
221
|
+
const gpu = navigator.gpu;
|
|
222
|
+
if (!gpu)
|
|
223
|
+
return null;
|
|
224
|
+
const adapter = await gpu.requestAdapter();
|
|
225
|
+
if (!adapter)
|
|
226
|
+
return null;
|
|
227
|
+
const device = await adapter.requestDevice();
|
|
228
|
+
const format = "rgba16float";
|
|
229
|
+
// `toneMapping` and `colorSpace` are ignored by browsers that do not
|
|
230
|
+
// support them yet (WebIDL drops unknown dictionary members), in which
|
|
231
|
+
// case output is clamped to SDR.
|
|
232
|
+
const configuration = {
|
|
233
|
+
device,
|
|
234
|
+
format,
|
|
235
|
+
alphaMode: "premultiplied",
|
|
236
|
+
colorSpace: "display-p3",
|
|
237
|
+
toneMapping: { mode: "extended" },
|
|
238
|
+
};
|
|
239
|
+
// Probe the configuration on a detached canvas first. Once a canvas
|
|
240
|
+
// has vended a "webgpu" context it can never provide a WebGL one, so
|
|
241
|
+
// claiming the visible canvas before knowing configure() succeeds
|
|
242
|
+
// would break the advertised WebGL fallback.
|
|
243
|
+
try {
|
|
244
|
+
const probe = document.createElement("canvas").getContext("webgpu");
|
|
245
|
+
if (!probe) {
|
|
246
|
+
device.destroy();
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
probe.configure(configuration);
|
|
250
|
+
probe.unconfigure();
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
device.destroy();
|
|
254
|
+
if (import.meta.env.DEV) {
|
|
255
|
+
console.warn("[FluidCursor] WebGPU canvas configuration rejected:", error);
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
const maybeContext = canvas.getContext("webgpu");
|
|
260
|
+
if (!maybeContext) {
|
|
261
|
+
device.destroy();
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
const context = maybeContext;
|
|
265
|
+
context.configure(configuration);
|
|
266
|
+
if (import.meta.env.DEV) {
|
|
267
|
+
// Browsers that do not support toneMapping drop the member, which
|
|
268
|
+
// getConfiguration() makes observable — log the real level.
|
|
269
|
+
let extended = false;
|
|
270
|
+
try {
|
|
271
|
+
const applied = context.getConfiguration?.();
|
|
272
|
+
extended = applied?.toneMapping?.mode === "extended";
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// getConfiguration unsupported: assume clamped output.
|
|
276
|
+
}
|
|
277
|
+
console.info(extended
|
|
278
|
+
? "[FluidCursor] HDR level: webgpu-hdr (extended tone mapping active)"
|
|
279
|
+
: "[FluidCursor] HDR level: webgpu-sdr (float16 + P3, tone mapping clamped by browser)");
|
|
280
|
+
}
|
|
281
|
+
let destroyed = false;
|
|
282
|
+
let lost = false;
|
|
283
|
+
device.lost.then((info) => {
|
|
284
|
+
lost = true;
|
|
285
|
+
if (!destroyed && import.meta.env.DEV) {
|
|
286
|
+
console.warn(`[FluidCursor] WebGPU device lost (${info.reason}): ${info.message}`);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
if (import.meta.env.DEV) {
|
|
290
|
+
device.addEventListener("uncapturederror", (event) => {
|
|
291
|
+
console.error("[FluidCursor] WebGPU uncaptured error:", event.error.message);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
const module = device.createShaderModule({ code: WGSL });
|
|
295
|
+
const bindGroupLayout = device.createBindGroupLayout({
|
|
296
|
+
entries: [
|
|
297
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
298
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
|
|
299
|
+
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
|
|
300
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
301
|
+
{ binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
302
|
+
],
|
|
303
|
+
});
|
|
304
|
+
const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] });
|
|
305
|
+
function makePipeline(entryPoint, targetFormat) {
|
|
306
|
+
return device.createRenderPipeline({
|
|
307
|
+
layout: pipelineLayout,
|
|
308
|
+
vertex: { module, entryPoint: "vs" },
|
|
309
|
+
fragment: { module, entryPoint, targets: [{ format: targetFormat }] },
|
|
310
|
+
primitive: { topology: "triangle-list" },
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
const VEL_FORMAT = "rg16float";
|
|
314
|
+
const SCALAR_FORMAT = "r16float";
|
|
315
|
+
const DYE_FORMAT = "rgba16float";
|
|
316
|
+
const pipelines = {
|
|
317
|
+
splatVel: makePipeline("fs_splat", VEL_FORMAT),
|
|
318
|
+
splatDye: makePipeline("fs_splat", DYE_FORMAT),
|
|
319
|
+
curl: makePipeline("fs_curl", SCALAR_FORMAT),
|
|
320
|
+
vorticity: makePipeline("fs_vorticity", VEL_FORMAT),
|
|
321
|
+
divergence: makePipeline("fs_divergence", SCALAR_FORMAT),
|
|
322
|
+
clear: makePipeline("fs_clear", SCALAR_FORMAT),
|
|
323
|
+
pressure: makePipeline("fs_pressure", SCALAR_FORMAT),
|
|
324
|
+
gradient: makePipeline("fs_gradient", VEL_FORMAT),
|
|
325
|
+
advectVel: makePipeline("fs_advection", VEL_FORMAT),
|
|
326
|
+
advectDye: makePipeline("fs_advection", DYE_FORMAT),
|
|
327
|
+
display: makePipeline("fs_display", format),
|
|
328
|
+
copyVel: makePipeline("fs_copy", VEL_FORMAT),
|
|
329
|
+
copyDye: makePipeline("fs_copy", DYE_FORMAT),
|
|
330
|
+
};
|
|
331
|
+
const smpLinear = device.createSampler({
|
|
332
|
+
magFilter: "linear",
|
|
333
|
+
minFilter: "linear",
|
|
334
|
+
addressModeU: "clamp-to-edge",
|
|
335
|
+
addressModeV: "clamp-to-edge",
|
|
336
|
+
});
|
|
337
|
+
const smpNearest = device.createSampler({
|
|
338
|
+
magFilter: "nearest",
|
|
339
|
+
minFilter: "nearest",
|
|
340
|
+
addressModeU: "clamp-to-edge",
|
|
341
|
+
addressModeV: "clamp-to-edge",
|
|
342
|
+
});
|
|
343
|
+
function makeUniformBuffer() {
|
|
344
|
+
return device.createBuffer({
|
|
345
|
+
size: U_FLOATS * 4,
|
|
346
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
const uSplatVel = makeUniformBuffer();
|
|
350
|
+
const uSplatDye = makeUniformBuffer();
|
|
351
|
+
const uCurl = makeUniformBuffer();
|
|
352
|
+
const uVorticity = makeUniformBuffer();
|
|
353
|
+
const uDivergence = makeUniformBuffer();
|
|
354
|
+
const uClear = makeUniformBuffer();
|
|
355
|
+
const uPressure = makeUniformBuffer();
|
|
356
|
+
const uGradient = makeUniformBuffer();
|
|
357
|
+
const uAdvectVel = makeUniformBuffer();
|
|
358
|
+
const uAdvectDye = makeUniformBuffer();
|
|
359
|
+
const uDisplay = makeUniformBuffer();
|
|
360
|
+
const uCopy = makeUniformBuffer();
|
|
361
|
+
const scratch = new Float32Array(U_FLOATS);
|
|
362
|
+
function writeUniforms(buffer, v) {
|
|
363
|
+
scratch.fill(0);
|
|
364
|
+
if (v.texelSize) {
|
|
365
|
+
scratch[OFF_TEXEL] = v.texelSize[0];
|
|
366
|
+
scratch[OFF_TEXEL + 1] = v.texelSize[1];
|
|
367
|
+
}
|
|
368
|
+
if (v.point) {
|
|
369
|
+
scratch[OFF_POINT] = v.point[0];
|
|
370
|
+
scratch[OFF_POINT + 1] = v.point[1];
|
|
371
|
+
}
|
|
372
|
+
if (v.color) {
|
|
373
|
+
scratch[OFF_COLOR] = v.color[0];
|
|
374
|
+
scratch[OFF_COLOR + 1] = v.color[1];
|
|
375
|
+
scratch[OFF_COLOR + 2] = v.color[2];
|
|
376
|
+
scratch[OFF_COLOR + 3] = 1;
|
|
377
|
+
}
|
|
378
|
+
scratch[OFF_DT] = v.dt ?? 0;
|
|
379
|
+
scratch[OFF_DISSIPATION] = v.dissipation ?? 0;
|
|
380
|
+
scratch[OFF_CURL] = v.curlStrength ?? 0;
|
|
381
|
+
scratch[OFF_CLEAR] = v.clearValue ?? 0;
|
|
382
|
+
scratch[OFF_ASPECT] = v.aspect ?? 1;
|
|
383
|
+
scratch[OFF_RADIUS] = v.radius ?? 1;
|
|
384
|
+
scratch[OFF_EXPOSURE] = v.exposure ?? 1;
|
|
385
|
+
scratch[OFF_SHADING] = v.shading ?? 0;
|
|
386
|
+
device.queue.writeBuffer(buffer, 0, scratch);
|
|
387
|
+
}
|
|
388
|
+
function makeTexture(w, h, texFormat) {
|
|
389
|
+
return device.createTexture({
|
|
390
|
+
size: { width: w, height: h },
|
|
391
|
+
format: texFormat,
|
|
392
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
function makeDoubleTex(w, h, texFormat) {
|
|
396
|
+
const a = makeTexture(w, h, texFormat);
|
|
397
|
+
const b = makeTexture(w, h, texFormat);
|
|
398
|
+
const target = {
|
|
399
|
+
read: a,
|
|
400
|
+
write: b,
|
|
401
|
+
readView: a.createView(),
|
|
402
|
+
writeView: b.createView(),
|
|
403
|
+
width: w,
|
|
404
|
+
height: h,
|
|
405
|
+
texelSizeX: 1 / w,
|
|
406
|
+
texelSizeY: 1 / h,
|
|
407
|
+
swap() {
|
|
408
|
+
const t = this.read;
|
|
409
|
+
const tv = this.readView;
|
|
410
|
+
this.read = this.write;
|
|
411
|
+
this.readView = this.writeView;
|
|
412
|
+
this.write = t;
|
|
413
|
+
this.writeView = tv;
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
return target;
|
|
417
|
+
}
|
|
418
|
+
// Bind groups are cached per (uniform buffer, texture views) combination
|
|
419
|
+
// — the ping-pong swaps only cycle through a small finite set, and
|
|
420
|
+
// recreating ~30 bind groups per frame (20 of them for the pressure
|
|
421
|
+
// iterations) is measurable allocation/validation overhead. The cache
|
|
422
|
+
// is cleared whenever textures are recreated (resize).
|
|
423
|
+
const bindGroupCache = new Map();
|
|
424
|
+
const resourceIds = new WeakMap();
|
|
425
|
+
let nextResourceId = 0;
|
|
426
|
+
function idOf(resource) {
|
|
427
|
+
let id = resourceIds.get(resource);
|
|
428
|
+
if (id === undefined) {
|
|
429
|
+
id = nextResourceId++;
|
|
430
|
+
resourceIds.set(resource, id);
|
|
431
|
+
}
|
|
432
|
+
return id;
|
|
433
|
+
}
|
|
434
|
+
function getBindGroup(uniforms, tex0, tex1) {
|
|
435
|
+
const key = `${idOf(uniforms)}:${idOf(tex0)}:${idOf(tex1)}`;
|
|
436
|
+
let bindGroup = bindGroupCache.get(key);
|
|
437
|
+
if (!bindGroup) {
|
|
438
|
+
bindGroup = device.createBindGroup({
|
|
439
|
+
layout: bindGroupLayout,
|
|
440
|
+
entries: [
|
|
441
|
+
{ binding: 0, resource: { buffer: uniforms } },
|
|
442
|
+
{ binding: 1, resource: smpLinear },
|
|
443
|
+
{ binding: 2, resource: smpNearest },
|
|
444
|
+
{ binding: 3, resource: tex0 },
|
|
445
|
+
{ binding: 4, resource: tex1 },
|
|
446
|
+
],
|
|
447
|
+
});
|
|
448
|
+
bindGroupCache.set(key, bindGroup);
|
|
449
|
+
}
|
|
450
|
+
return bindGroup;
|
|
451
|
+
}
|
|
452
|
+
function runPass(encoder, pipeline, uniforms, tex0, tex1, target) {
|
|
453
|
+
const bindGroup = getBindGroup(uniforms, tex0, tex1);
|
|
454
|
+
const pass = encoder.beginRenderPass({
|
|
455
|
+
colorAttachments: [
|
|
456
|
+
{
|
|
457
|
+
view: target,
|
|
458
|
+
loadOp: "clear",
|
|
459
|
+
storeOp: "store",
|
|
460
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
461
|
+
},
|
|
462
|
+
],
|
|
463
|
+
});
|
|
464
|
+
pass.setPipeline(pipeline);
|
|
465
|
+
pass.setBindGroup(0, bindGroup);
|
|
466
|
+
pass.draw(3);
|
|
467
|
+
pass.end();
|
|
468
|
+
}
|
|
469
|
+
// --- Simulation state ---
|
|
470
|
+
let dye = null;
|
|
471
|
+
let velocity = null;
|
|
472
|
+
let pressureTex = null;
|
|
473
|
+
let divergenceTex = null;
|
|
474
|
+
let divergenceView = null;
|
|
475
|
+
let curlTex = null;
|
|
476
|
+
let curlView = null;
|
|
477
|
+
function initTextures() {
|
|
478
|
+
const simRes = getSimResolution(opts.simResolution, canvas.width, canvas.height);
|
|
479
|
+
const dyeRes = getSimResolution(opts.dyeResolution, canvas.width, canvas.height);
|
|
480
|
+
const oldDye = dye;
|
|
481
|
+
const oldVelocity = velocity;
|
|
482
|
+
// Canvas resize that leaves grid sizes unchanged (aspect-preserving):
|
|
483
|
+
// keep the existing textures (mirrors resizeDoubleFBO's early-out).
|
|
484
|
+
if (oldDye &&
|
|
485
|
+
oldVelocity &&
|
|
486
|
+
oldDye.width === dyeRes.width &&
|
|
487
|
+
oldDye.height === dyeRes.height &&
|
|
488
|
+
oldVelocity.width === simRes.width &&
|
|
489
|
+
oldVelocity.height === simRes.height) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const newDye = makeDoubleTex(dyeRes.width, dyeRes.height, DYE_FORMAT);
|
|
493
|
+
const newVelocity = makeDoubleTex(simRes.width, simRes.height, VEL_FORMAT);
|
|
494
|
+
// Preserve existing fluid across resizes by resampling the old
|
|
495
|
+
// read textures into the new ones (mirrors resizeDoubleFBO).
|
|
496
|
+
if (oldDye && oldVelocity) {
|
|
497
|
+
writeUniforms(uCopy, {});
|
|
498
|
+
const encoder = device.createCommandEncoder();
|
|
499
|
+
runPass(encoder, pipelines.copyDye, uCopy, oldDye.readView, oldDye.readView, newDye.readView);
|
|
500
|
+
runPass(encoder, pipelines.copyVel, uCopy, oldVelocity.readView, oldVelocity.readView, newVelocity.readView);
|
|
501
|
+
device.queue.submit([encoder.finish()]);
|
|
502
|
+
oldDye.read.destroy();
|
|
503
|
+
oldDye.write.destroy();
|
|
504
|
+
oldVelocity.read.destroy();
|
|
505
|
+
oldVelocity.write.destroy();
|
|
506
|
+
}
|
|
507
|
+
dye = newDye;
|
|
508
|
+
velocity = newVelocity;
|
|
509
|
+
// Cached bind groups reference the destroyed texture views.
|
|
510
|
+
bindGroupCache.clear();
|
|
511
|
+
pressureTex?.read.destroy();
|
|
512
|
+
pressureTex?.write.destroy();
|
|
513
|
+
divergenceTex?.destroy();
|
|
514
|
+
curlTex?.destroy();
|
|
515
|
+
pressureTex = makeDoubleTex(simRes.width, simRes.height, SCALAR_FORMAT);
|
|
516
|
+
divergenceTex = makeTexture(simRes.width, simRes.height, SCALAR_FORMAT);
|
|
517
|
+
divergenceView = divergenceTex.createView();
|
|
518
|
+
curlTex = makeTexture(simRes.width, simRes.height, SCALAR_FORMAT);
|
|
519
|
+
curlView = curlTex.createView();
|
|
520
|
+
}
|
|
521
|
+
function resizeIfNeeded() {
|
|
522
|
+
if (destroyed || lost)
|
|
523
|
+
return;
|
|
524
|
+
const width = scaleByPixelRatio(canvas.clientWidth);
|
|
525
|
+
const height = scaleByPixelRatio(canvas.clientHeight);
|
|
526
|
+
if (width > 0 && height > 0 && (canvas.width !== width || canvas.height !== height)) {
|
|
527
|
+
canvas.width = width;
|
|
528
|
+
canvas.height = height;
|
|
529
|
+
initTextures();
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function splat(x, y, dx, dy, color) {
|
|
533
|
+
if (destroyed || lost || !velocity || !dye)
|
|
534
|
+
return;
|
|
535
|
+
let radius = correctRadius(opts.splatRadius / 100, canvas.width, canvas.height);
|
|
536
|
+
if (opts.contained) {
|
|
537
|
+
radius = scaleRadiusForContainer(radius, canvas.clientHeight, window.innerHeight);
|
|
538
|
+
}
|
|
539
|
+
const aspect = canvas.width / canvas.height;
|
|
540
|
+
writeUniforms(uSplatVel, {
|
|
541
|
+
point: [x, y],
|
|
542
|
+
color: [dx, dy, 0],
|
|
543
|
+
aspect,
|
|
544
|
+
radius,
|
|
545
|
+
});
|
|
546
|
+
writeUniforms(uSplatDye, {
|
|
547
|
+
point: [x, y],
|
|
548
|
+
color: [color.r, color.g, color.b],
|
|
549
|
+
aspect,
|
|
550
|
+
radius,
|
|
551
|
+
});
|
|
552
|
+
const encoder = device.createCommandEncoder();
|
|
553
|
+
runPass(encoder, pipelines.splatVel, uSplatVel, velocity.readView, velocity.readView, velocity.writeView);
|
|
554
|
+
velocity.swap();
|
|
555
|
+
runPass(encoder, pipelines.splatDye, uSplatDye, dye.readView, dye.readView, dye.writeView);
|
|
556
|
+
dye.swap();
|
|
557
|
+
device.queue.submit([encoder.finish()]);
|
|
558
|
+
}
|
|
559
|
+
function frame(dt) {
|
|
560
|
+
if (destroyed || lost || !velocity || !dye || !pressureTex || !divergenceView || !curlView) {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const velTexel = [velocity.texelSizeX, velocity.texelSizeY];
|
|
564
|
+
writeUniforms(uCurl, { texelSize: velTexel });
|
|
565
|
+
writeUniforms(uVorticity, { texelSize: velTexel, curlStrength: opts.curl, dt });
|
|
566
|
+
writeUniforms(uDivergence, { texelSize: velTexel });
|
|
567
|
+
writeUniforms(uClear, { clearValue: opts.pressure });
|
|
568
|
+
writeUniforms(uPressure, { texelSize: velTexel });
|
|
569
|
+
writeUniforms(uGradient, { texelSize: velTexel });
|
|
570
|
+
writeUniforms(uAdvectVel, {
|
|
571
|
+
texelSize: velTexel,
|
|
572
|
+
dt,
|
|
573
|
+
dissipation: opts.velocityDissipation,
|
|
574
|
+
});
|
|
575
|
+
writeUniforms(uAdvectDye, {
|
|
576
|
+
texelSize: velTexel,
|
|
577
|
+
dt,
|
|
578
|
+
dissipation: opts.densityDissipation,
|
|
579
|
+
});
|
|
580
|
+
writeUniforms(uDisplay, {
|
|
581
|
+
texelSize: [1 / canvas.width, 1 / canvas.height],
|
|
582
|
+
exposure: opts.exposure,
|
|
583
|
+
shading: opts.shading ? 1 : 0,
|
|
584
|
+
});
|
|
585
|
+
const encoder = device.createCommandEncoder();
|
|
586
|
+
runPass(encoder, pipelines.curl, uCurl, velocity.readView, velocity.readView, curlView);
|
|
587
|
+
runPass(encoder, pipelines.vorticity, uVorticity, velocity.readView, curlView, velocity.writeView);
|
|
588
|
+
velocity.swap();
|
|
589
|
+
runPass(encoder, pipelines.divergence, uDivergence, velocity.readView, velocity.readView, divergenceView);
|
|
590
|
+
runPass(encoder, pipelines.clear, uClear, pressureTex.readView, pressureTex.readView, pressureTex.writeView);
|
|
591
|
+
pressureTex.swap();
|
|
592
|
+
for (let i = 0; i < opts.pressureIterations; i++) {
|
|
593
|
+
runPass(encoder, pipelines.pressure, uPressure, pressureTex.readView, divergenceView, pressureTex.writeView);
|
|
594
|
+
pressureTex.swap();
|
|
595
|
+
}
|
|
596
|
+
runPass(encoder, pipelines.gradient, uGradient, pressureTex.readView, velocity.readView, velocity.writeView);
|
|
597
|
+
velocity.swap();
|
|
598
|
+
runPass(encoder, pipelines.advectVel, uAdvectVel, velocity.readView, velocity.readView, velocity.writeView);
|
|
599
|
+
velocity.swap();
|
|
600
|
+
runPass(encoder, pipelines.advectDye, uAdvectDye, velocity.readView, dye.readView, dye.writeView);
|
|
601
|
+
dye.swap();
|
|
602
|
+
runPass(encoder, pipelines.display, uDisplay, dye.readView, dye.readView, context.getCurrentTexture().createView());
|
|
603
|
+
device.queue.submit([encoder.finish()]);
|
|
604
|
+
}
|
|
605
|
+
initTextures();
|
|
606
|
+
return {
|
|
607
|
+
splat,
|
|
608
|
+
resizeIfNeeded,
|
|
609
|
+
frame,
|
|
610
|
+
get lost() {
|
|
611
|
+
return lost;
|
|
612
|
+
},
|
|
613
|
+
destroy() {
|
|
614
|
+
destroyed = true;
|
|
615
|
+
device.destroy();
|
|
616
|
+
},
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
if (import.meta.env.DEV) {
|
|
621
|
+
console.warn("[FluidCursor] WebGPU init failed, falling back to WebGL:", error);
|
|
622
|
+
}
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Full WebGPU setup for FluidCursor: engine + pointer wiring + rAF loop +
|
|
628
|
+
* visibility pause + auto splats. Resolves with a cleanup function, or with
|
|
629
|
+
* `null` when WebGPU is unavailable (caller falls back to the WebGL path).
|
|
630
|
+
*/
|
|
631
|
+
export async function startWebGpuFluid(canvas, opts) {
|
|
632
|
+
const maybeEngine = await createWebGpuFluid(canvas, opts);
|
|
633
|
+
if (!maybeEngine)
|
|
634
|
+
return null;
|
|
635
|
+
const engine = maybeEngine;
|
|
636
|
+
const pointers = [pointerPrototype()];
|
|
637
|
+
let animationFrameId = 0;
|
|
638
|
+
let lastUpdateTime = Date.now();
|
|
639
|
+
let colorUpdateTimer = 0;
|
|
640
|
+
let isVisible = true;
|
|
641
|
+
let stopped = false;
|
|
642
|
+
// --- Pointer position mapping (mirrors the WebGL path) ---
|
|
643
|
+
let canvasRectCache = null;
|
|
644
|
+
function updateCanvasRectCache() {
|
|
645
|
+
if (opts.contained)
|
|
646
|
+
canvasRectCache = canvas.getBoundingClientRect();
|
|
647
|
+
}
|
|
648
|
+
function getCanvasPos(clientX, clientY) {
|
|
649
|
+
if (opts.contained) {
|
|
650
|
+
if (!canvasRectCache)
|
|
651
|
+
updateCanvasRectCache();
|
|
652
|
+
const rect = canvasRectCache;
|
|
653
|
+
return {
|
|
654
|
+
x: scaleByPixelRatio(clientX - rect.left),
|
|
655
|
+
y: scaleByPixelRatio(clientY - rect.top),
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
return { x: scaleByPixelRatio(clientX), y: scaleByPixelRatio(clientY) };
|
|
659
|
+
}
|
|
660
|
+
function updatePointerDownData(pointer, id, posX, posY) {
|
|
661
|
+
pointer.id = id;
|
|
662
|
+
pointer.down = true;
|
|
663
|
+
pointer.moved = false;
|
|
664
|
+
pointer.texcoordX = posX / canvas.width;
|
|
665
|
+
pointer.texcoordY = posY / canvas.height;
|
|
666
|
+
pointer.prevTexcoordX = pointer.texcoordX;
|
|
667
|
+
pointer.prevTexcoordY = pointer.texcoordY;
|
|
668
|
+
pointer.deltaX = 0;
|
|
669
|
+
pointer.deltaY = 0;
|
|
670
|
+
pointer.color = opts.generateColor();
|
|
671
|
+
}
|
|
672
|
+
function updatePointerMoveData(pointer, posX, posY, color) {
|
|
673
|
+
pointer.prevTexcoordX = pointer.texcoordX;
|
|
674
|
+
pointer.prevTexcoordY = pointer.texcoordY;
|
|
675
|
+
pointer.texcoordX = posX / canvas.width;
|
|
676
|
+
pointer.texcoordY = posY / canvas.height;
|
|
677
|
+
pointer.deltaX = correctDeltaX(pointer.texcoordX - pointer.prevTexcoordX, canvas.width, canvas.height);
|
|
678
|
+
pointer.deltaY = correctDeltaY(pointer.texcoordY - pointer.prevTexcoordY, canvas.width, canvas.height);
|
|
679
|
+
pointer.moved = Math.abs(pointer.deltaX) > 0 || Math.abs(pointer.deltaY) > 0;
|
|
680
|
+
pointer.color = color;
|
|
681
|
+
}
|
|
682
|
+
function splatPointer(pointer) {
|
|
683
|
+
const dx = pointer.deltaX * opts.splatForce;
|
|
684
|
+
const dy = pointer.deltaY * opts.splatForce;
|
|
685
|
+
engine.splat(pointer.texcoordX, pointer.texcoordY, dx, dy, pointer.color);
|
|
686
|
+
}
|
|
687
|
+
function clickSplat(pointer) {
|
|
688
|
+
const color = opts.generateColor();
|
|
689
|
+
color.r *= 10;
|
|
690
|
+
color.g *= 10;
|
|
691
|
+
color.b *= 10;
|
|
692
|
+
const dx = 10 * (Math.random() - 0.5);
|
|
693
|
+
const dy = 30 * (Math.random() - 0.5);
|
|
694
|
+
engine.splat(pointer.texcoordX, pointer.texcoordY, dx, dy, color);
|
|
695
|
+
}
|
|
696
|
+
function multipleSplats(steps) {
|
|
697
|
+
// Simulate a cursor sweep along a random arc — each step on its own
|
|
698
|
+
// frame so the velocity field has time to evolve between injections.
|
|
699
|
+
const angle = Math.random() * Math.PI * 2;
|
|
700
|
+
const cx = 0.25 + Math.random() * 0.5;
|
|
701
|
+
const cy = 0.25 + Math.random() * 0.5;
|
|
702
|
+
const arcSpan = Math.PI * (0.8 + Math.random() * 0.8);
|
|
703
|
+
let i = 0;
|
|
704
|
+
function step() {
|
|
705
|
+
if (stopped || i >= steps)
|
|
706
|
+
return;
|
|
707
|
+
const t = i / Math.max(1, steps - 1);
|
|
708
|
+
const a = angle + t * arcSpan;
|
|
709
|
+
const r = 0.15 + 0.05 * Math.sin(t * Math.PI);
|
|
710
|
+
const x = cx + Math.cos(a) * r;
|
|
711
|
+
const y = cy + Math.sin(a) * r;
|
|
712
|
+
const color = opts.generateColor();
|
|
713
|
+
engine.splat(x, y, -Math.sin(a) * opts.splatForce * 0.5, Math.cos(a) * opts.splatForce * 0.5, color);
|
|
714
|
+
i++;
|
|
715
|
+
if (i < steps)
|
|
716
|
+
requestAnimationFrame(step);
|
|
717
|
+
}
|
|
718
|
+
requestAnimationFrame(step);
|
|
719
|
+
}
|
|
720
|
+
// --- Frame loop (same ordering as the WebGL path: resize, colors,
|
|
721
|
+
// inputs, then simulation step + render) ---
|
|
722
|
+
function updateFrame() {
|
|
723
|
+
if (stopped || !isVisible)
|
|
724
|
+
return;
|
|
725
|
+
// Device loss (GPU reset, driver update): stop the loop instead of
|
|
726
|
+
// spinning on a dead device. The effect simply disappears.
|
|
727
|
+
if (engine.lost)
|
|
728
|
+
return;
|
|
729
|
+
const now = Date.now();
|
|
730
|
+
const dt = Math.min((now - lastUpdateTime) / 1000, 0.016666);
|
|
731
|
+
lastUpdateTime = now;
|
|
732
|
+
engine.resizeIfNeeded();
|
|
733
|
+
colorUpdateTimer += dt * opts.colorUpdateSpeed;
|
|
734
|
+
if (colorUpdateTimer >= 1) {
|
|
735
|
+
colorUpdateTimer = colorUpdateTimer % 1;
|
|
736
|
+
pointers.forEach((p) => {
|
|
737
|
+
p.color = opts.generateColor();
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
for (const p of pointers) {
|
|
741
|
+
if (p.moved) {
|
|
742
|
+
p.moved = false;
|
|
743
|
+
splatPointer(p);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
engine.frame(dt);
|
|
747
|
+
animationFrameId = requestAnimationFrame(updateFrame);
|
|
748
|
+
}
|
|
749
|
+
// --- Event listeners ---
|
|
750
|
+
function handleMouseDown(e) {
|
|
751
|
+
const pointer = pointers[0];
|
|
752
|
+
const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
|
|
753
|
+
updatePointerDownData(pointer, -1, posX, posY);
|
|
754
|
+
clickSplat(pointer);
|
|
755
|
+
}
|
|
756
|
+
function handleFirstMouseMove(e) {
|
|
757
|
+
const pointer = pointers[0];
|
|
758
|
+
const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
|
|
759
|
+
updatePointerMoveData(pointer, posX, posY, opts.generateColor());
|
|
760
|
+
document.body.removeEventListener("mousemove", handleFirstMouseMove);
|
|
761
|
+
}
|
|
762
|
+
function handleMouseMove(e) {
|
|
763
|
+
const pointer = pointers[0];
|
|
764
|
+
const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
|
|
765
|
+
updatePointerMoveData(pointer, posX, posY, pointer.color);
|
|
766
|
+
}
|
|
767
|
+
function handleFirstTouchStart(e) {
|
|
768
|
+
const touches = e.targetTouches;
|
|
769
|
+
const pointer = pointers[0];
|
|
770
|
+
for (let i = 0; i < touches.length; i++) {
|
|
771
|
+
const { x: posX, y: posY } = getCanvasPos(touches[i].clientX, touches[i].clientY);
|
|
772
|
+
updatePointerDownData(pointer, touches[i].identifier, posX, posY);
|
|
773
|
+
}
|
|
774
|
+
document.body.removeEventListener("touchstart", handleFirstTouchStart);
|
|
775
|
+
}
|
|
776
|
+
function handleTouchStart(e) {
|
|
777
|
+
const touches = e.targetTouches;
|
|
778
|
+
const pointer = pointers[0];
|
|
779
|
+
for (let i = 0; i < touches.length; i++) {
|
|
780
|
+
const { x: posX, y: posY } = getCanvasPos(touches[i].clientX, touches[i].clientY);
|
|
781
|
+
updatePointerDownData(pointer, touches[i].identifier, posX, posY);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
function handleTouchMove(e) {
|
|
785
|
+
const touches = e.targetTouches;
|
|
786
|
+
const pointer = pointers[0];
|
|
787
|
+
for (let i = 0; i < touches.length; i++) {
|
|
788
|
+
const { x: posX, y: posY } = getCanvasPos(touches[i].clientX, touches[i].clientY);
|
|
789
|
+
updatePointerMoveData(pointer, posX, posY, pointer.color);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (opts.contained) {
|
|
793
|
+
window.addEventListener("resize", updateCanvasRectCache, { passive: true });
|
|
794
|
+
window.addEventListener("scroll", updateCanvasRectCache, { passive: true });
|
|
795
|
+
updateCanvasRectCache();
|
|
796
|
+
}
|
|
797
|
+
if (opts.interactive) {
|
|
798
|
+
window.addEventListener("mousedown", handleMouseDown);
|
|
799
|
+
document.body.addEventListener("mousemove", handleFirstMouseMove);
|
|
800
|
+
window.addEventListener("mousemove", handleMouseMove);
|
|
801
|
+
document.body.addEventListener("touchstart", handleFirstTouchStart);
|
|
802
|
+
window.addEventListener("touchstart", handleTouchStart, false);
|
|
803
|
+
window.addEventListener("touchmove", handleTouchMove, false);
|
|
804
|
+
}
|
|
805
|
+
let observer = null;
|
|
806
|
+
if (opts.pauseWhenHidden) {
|
|
807
|
+
observer = new IntersectionObserver(([entry]) => {
|
|
808
|
+
const wasVisible = isVisible;
|
|
809
|
+
isVisible = entry.isIntersecting;
|
|
810
|
+
if (isVisible && !wasVisible && !stopped) {
|
|
811
|
+
lastUpdateTime = Date.now();
|
|
812
|
+
animationFrameId = requestAnimationFrame(updateFrame);
|
|
813
|
+
}
|
|
814
|
+
}, { threshold: 0 });
|
|
815
|
+
observer.observe(canvas);
|
|
816
|
+
}
|
|
817
|
+
updateFrame();
|
|
818
|
+
if (opts.splatOnMount)
|
|
819
|
+
multipleSplats(Math.floor(Math.random() * 6) + 10);
|
|
820
|
+
let autoSplatTimer = null;
|
|
821
|
+
if (opts.autoSplat) {
|
|
822
|
+
autoSplatTimer = setInterval(() => {
|
|
823
|
+
engine.splat(Math.random(), Math.random(), (Math.random() - 0.5) * 200, (Math.random() - 0.5) * 200, opts.generateColor());
|
|
824
|
+
}, opts.autoSplatInterval);
|
|
825
|
+
}
|
|
826
|
+
return () => {
|
|
827
|
+
stopped = true;
|
|
828
|
+
cancelAnimationFrame(animationFrameId);
|
|
829
|
+
if (observer)
|
|
830
|
+
observer.disconnect();
|
|
831
|
+
if (autoSplatTimer)
|
|
832
|
+
clearInterval(autoSplatTimer);
|
|
833
|
+
if (opts.interactive) {
|
|
834
|
+
window.removeEventListener("mousedown", handleMouseDown);
|
|
835
|
+
document.body.removeEventListener("mousemove", handleFirstMouseMove);
|
|
836
|
+
window.removeEventListener("mousemove", handleMouseMove);
|
|
837
|
+
document.body.removeEventListener("touchstart", handleFirstTouchStart);
|
|
838
|
+
window.removeEventListener("touchstart", handleTouchStart);
|
|
839
|
+
window.removeEventListener("touchmove", handleTouchMove);
|
|
840
|
+
}
|
|
841
|
+
if (opts.contained) {
|
|
842
|
+
window.removeEventListener("resize", updateCanvasRectCache);
|
|
843
|
+
window.removeEventListener("scroll", updateCanvasRectCache);
|
|
844
|
+
}
|
|
845
|
+
engine.destroy();
|
|
846
|
+
};
|
|
847
|
+
}
|