fancy-ui-react 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/components/fluid-cursor/FluidCursor.js +48 -18
- package/dist/components/fluid-cursor/fluid-shared.d.ts +18 -0
- package/dist/components/fluid-cursor/fluid-shared.js +7 -0
- package/dist/components/fluid-cursor/webgpu-engine.d.ts +6 -0
- package/dist/components/fluid-cursor/webgpu-engine.js +24 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -139,8 +139,14 @@ pnpm install # at the repo root
|
|
|
139
139
|
pnpm --filter fancy-ui-react test # vitest + testing-library
|
|
140
140
|
pnpm --filter fancy-ui-react check # tsc --noEmit
|
|
141
141
|
pnpm --filter fancy-ui-react build # vite lib build + d.ts via tsc, then the dist gates
|
|
142
|
+
pnpm --filter fancy-ui-react-demo dev # the demo site (examples/react-demo) on http://localhost:3200
|
|
142
143
|
```
|
|
143
144
|
|
|
145
|
+
Two consumer apps live under `examples/`: `next-app`, the App Router census
|
|
146
|
+
that CI builds as the SSR/hydration gate, and `react-demo`, a one-page demo
|
|
147
|
+
site built as a static export (`next build` → `examples/react-demo/out/`).
|
|
148
|
+
Both resolve `fancy-ui-react` to `react/dist`, so run `build` first.
|
|
149
|
+
|
|
144
150
|
`build` does more than build. After Vite and `tsc` it runs four scripts in
|
|
145
151
|
`react/scripts/`, each guarding something no other gate can see:
|
|
146
152
|
|
|
@@ -3,7 +3,7 @@ import { jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import { useState, useRef, useEffect } from "react";
|
|
4
4
|
import { useLiveRef } from "../../internals/dom/use-live-ref.js";
|
|
5
5
|
import { cn } from "../../utils.js";
|
|
6
|
-
import { hexToRgb, DEV, wrap, pointerPrototype, scaleRadiusForContainer, HSVtoRGB } from "./fluid-shared.js";
|
|
6
|
+
import { hexToRgb, clickBoost, DEV, wrap, pointerPrototype, scaleRadiusForContainer, HSVtoRGB } from "./fluid-shared.js";
|
|
7
7
|
import { startWebGpuFluid } from "./webgpu-engine.js";
|
|
8
8
|
function FluidCursor({
|
|
9
9
|
simResolution = 128,
|
|
@@ -38,13 +38,19 @@ function FluidCursor({
|
|
|
38
38
|
ditherLevels = 4,
|
|
39
39
|
onReady
|
|
40
40
|
}) {
|
|
41
|
-
const [derived] = useState(() =>
|
|
42
|
-
clampedColorIntensity
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
const [derived] = useState(() => {
|
|
42
|
+
const clampedColorIntensity = Math.max(0, Math.min(1, colorIntensity));
|
|
43
|
+
return {
|
|
44
|
+
clampedColorIntensity,
|
|
45
|
+
// A click's extra brightness, capped so it never compounds
|
|
46
|
+
// colorIntensity into the dither pass's clamp — see clickBoost().
|
|
47
|
+
clickColorBoost: clickBoost(clampedColorIntensity),
|
|
48
|
+
clampedHdrBoost: Math.max(1, Math.min(4, hdrBoost)),
|
|
49
|
+
clampedDitherPixelSize: Math.max(1, ditherPixelSize),
|
|
50
|
+
clampedDitherLevels: Math.max(2, Math.min(16, Math.floor(ditherLevels))),
|
|
51
|
+
resolvedBackColor: typeof backColor === "string" ? hexToRgb(backColor) : backColor
|
|
52
|
+
};
|
|
53
|
+
});
|
|
48
54
|
const canvasRef = useRef(null);
|
|
49
55
|
const activeInstanceRef = useRef(null);
|
|
50
56
|
const mountCounterRef = useRef(0);
|
|
@@ -61,11 +67,11 @@ function FluidCursor({
|
|
|
61
67
|
const onReadyRef = useLiveRef(onReady);
|
|
62
68
|
function getScaledColor(hex) {
|
|
63
69
|
const { r, g, b } = hexToRgb(hex);
|
|
64
|
-
return {
|
|
70
|
+
return Object.freeze({
|
|
65
71
|
r: r * derived.clampedColorIntensity,
|
|
66
72
|
g: g * derived.clampedColorIntensity,
|
|
67
73
|
b: b * derived.clampedColorIntensity
|
|
68
|
-
};
|
|
74
|
+
});
|
|
69
75
|
}
|
|
70
76
|
function getCachedFluidColor(hex) {
|
|
71
77
|
const cache = colorCacheRef.current;
|
|
@@ -186,7 +192,8 @@ function FluidCursor({
|
|
|
186
192
|
autoSplatInterval,
|
|
187
193
|
pauseWhenHidden,
|
|
188
194
|
splatOnMount,
|
|
189
|
-
generateColor
|
|
195
|
+
generateColor,
|
|
196
|
+
clickBoost: derived.clickColorBoost
|
|
190
197
|
}).then((handle) => {
|
|
191
198
|
if (disposed) {
|
|
192
199
|
handle?.cleanup();
|
|
@@ -581,13 +588,19 @@ function FluidCursor({
|
|
|
581
588
|
uniform vec3 color;
|
|
582
589
|
uniform vec2 point;
|
|
583
590
|
uniform float radius;
|
|
591
|
+
uniform float saturate;
|
|
584
592
|
|
|
585
593
|
void main () {
|
|
586
594
|
vec2 p = vUv - point.xy;
|
|
587
595
|
p.x *= aspectRatio;
|
|
588
596
|
vec3 splat = exp(-dot(p, p) / radius) * color;
|
|
589
597
|
vec3 base = texture2D(uTarget, vUv).xyz;
|
|
590
|
-
|
|
598
|
+
// A click splat lifts each texel to its own profile instead of adding
|
|
599
|
+
// to it: what is already there never rises past what one click would
|
|
600
|
+
// leave at that spot, so a burst of clicks paints the same disc once
|
|
601
|
+
// instead of summing towards white and widening with every click.
|
|
602
|
+
vec3 sum = saturate > 0.5 ? max(base, splat) : base + splat;
|
|
603
|
+
gl_FragColor = vec4(sum, 1.0);
|
|
591
604
|
}
|
|
592
605
|
`
|
|
593
606
|
);
|
|
@@ -1211,15 +1224,17 @@ function FluidCursor({
|
|
|
1211
1224
|
splat(pointer.texcoordX, pointer.texcoordY, dx, dy, pointer.color);
|
|
1212
1225
|
}
|
|
1213
1226
|
function clickSplat(pointer) {
|
|
1214
|
-
const
|
|
1215
|
-
color
|
|
1216
|
-
|
|
1217
|
-
|
|
1227
|
+
const base = generateColor();
|
|
1228
|
+
const color = {
|
|
1229
|
+
r: base.r * derived.clickColorBoost,
|
|
1230
|
+
g: base.g * derived.clickColorBoost,
|
|
1231
|
+
b: base.b * derived.clickColorBoost
|
|
1232
|
+
};
|
|
1218
1233
|
const dx = 10 * (Math.random() - 0.5);
|
|
1219
1234
|
const dy = 30 * (Math.random() - 0.5);
|
|
1220
|
-
splat(pointer.texcoordX, pointer.texcoordY, dx, dy, color);
|
|
1235
|
+
splat(pointer.texcoordX, pointer.texcoordY, dx, dy, color, true);
|
|
1221
1236
|
}
|
|
1222
|
-
function splat(x, y, dx, dy, color) {
|
|
1237
|
+
function splat(x, y, dx, dy, color, saturate = false) {
|
|
1223
1238
|
splatProgram.bind();
|
|
1224
1239
|
if (splatProgram.uniforms.uTarget) {
|
|
1225
1240
|
gl.uniform1i(splatProgram.uniforms.uTarget, velocity.read.attach(0));
|
|
@@ -1233,6 +1248,9 @@ function FluidCursor({
|
|
|
1233
1248
|
if (splatProgram.uniforms.color) {
|
|
1234
1249
|
gl.uniform3f(splatProgram.uniforms.color, dx, dy, 0);
|
|
1235
1250
|
}
|
|
1251
|
+
if (splatProgram.uniforms.saturate) {
|
|
1252
|
+
gl.uniform1f(splatProgram.uniforms.saturate, 0);
|
|
1253
|
+
}
|
|
1236
1254
|
if (splatProgram.uniforms.radius) {
|
|
1237
1255
|
let radius = correctRadius(config.SPLAT_RADIUS / 100);
|
|
1238
1256
|
if (containedRef.current) {
|
|
@@ -1248,6 +1266,9 @@ function FluidCursor({
|
|
|
1248
1266
|
if (splatProgram.uniforms.color) {
|
|
1249
1267
|
gl.uniform3f(splatProgram.uniforms.color, color.r, color.g, color.b);
|
|
1250
1268
|
}
|
|
1269
|
+
if (splatProgram.uniforms.saturate) {
|
|
1270
|
+
gl.uniform1f(splatProgram.uniforms.saturate, saturate ? 1 : 0);
|
|
1271
|
+
}
|
|
1251
1272
|
blit(dye.write);
|
|
1252
1273
|
dye.swap();
|
|
1253
1274
|
}
|
|
@@ -1347,6 +1368,9 @@ function FluidCursor({
|
|
|
1347
1368
|
updatePointerDownData(pointer, -1, posX, posY);
|
|
1348
1369
|
clickSplat(pointer);
|
|
1349
1370
|
}
|
|
1371
|
+
function handlePointerUp() {
|
|
1372
|
+
pointers[0].down = false;
|
|
1373
|
+
}
|
|
1350
1374
|
function handleFirstMouseMove(e) {
|
|
1351
1375
|
const pointer = pointers[0];
|
|
1352
1376
|
const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
|
|
@@ -1389,11 +1413,14 @@ function FluidCursor({
|
|
|
1389
1413
|
}
|
|
1390
1414
|
if (interactive) {
|
|
1391
1415
|
window.addEventListener("mousedown", handleMouseDown);
|
|
1416
|
+
window.addEventListener("mouseup", handlePointerUp);
|
|
1392
1417
|
document.body.addEventListener("mousemove", handleFirstMouseMove);
|
|
1393
1418
|
window.addEventListener("mousemove", handleMouseMove);
|
|
1394
1419
|
document.body.addEventListener("touchstart", handleFirstTouchStart);
|
|
1395
1420
|
window.addEventListener("touchstart", handleTouchStart, false);
|
|
1396
1421
|
window.addEventListener("touchmove", handleTouchMove, false);
|
|
1422
|
+
window.addEventListener("touchend", handlePointerUp);
|
|
1423
|
+
window.addEventListener("touchcancel", handlePointerUp);
|
|
1397
1424
|
}
|
|
1398
1425
|
let observer = null;
|
|
1399
1426
|
if (pauseWhenHidden) {
|
|
@@ -1432,11 +1459,14 @@ function FluidCursor({
|
|
|
1432
1459
|
if (autoSplatTimer) clearInterval(autoSplatTimer);
|
|
1433
1460
|
if (interactive) {
|
|
1434
1461
|
window.removeEventListener("mousedown", handleMouseDown);
|
|
1462
|
+
window.removeEventListener("mouseup", handlePointerUp);
|
|
1435
1463
|
document.body.removeEventListener("mousemove", handleFirstMouseMove);
|
|
1436
1464
|
window.removeEventListener("mousemove", handleMouseMove);
|
|
1437
1465
|
document.body.removeEventListener("touchstart", handleFirstTouchStart);
|
|
1438
1466
|
window.removeEventListener("touchstart", handleTouchStart);
|
|
1439
1467
|
window.removeEventListener("touchmove", handleTouchMove);
|
|
1468
|
+
window.removeEventListener("touchend", handlePointerUp);
|
|
1469
|
+
window.removeEventListener("touchcancel", handlePointerUp);
|
|
1440
1470
|
}
|
|
1441
1471
|
if (contained) {
|
|
1442
1472
|
window.removeEventListener("resize", updateCanvasRectCache);
|
|
@@ -36,6 +36,24 @@ export declare function correctRadius(radius: number, width: number, height: num
|
|
|
36
36
|
* length under ~30% of the container height.
|
|
37
37
|
*/
|
|
38
38
|
export declare function scaleRadiusForContainer(radius: number, containerHeight: number, viewportHeight: number): number;
|
|
39
|
+
/**
|
|
40
|
+
* Dye a click deposits at its centre, in multiples of the hue: the engine's
|
|
41
|
+
* default `colorIntensity` (0.15) times the ×10 boost the solver has always
|
|
42
|
+
* given a click — the amount that reads as a bright pop on every renderer.
|
|
43
|
+
*/
|
|
44
|
+
export declare const CLICK_PEAK = 1.5;
|
|
45
|
+
/**
|
|
46
|
+
* The boost a click applies to an already intensity-scaled colour, chosen so
|
|
47
|
+
* the peak lands on CLICK_PEAK whatever `colorIntensity` is.
|
|
48
|
+
*
|
|
49
|
+
* The historical fixed ×10 compounded `colorIntensity`. At the default 0.15
|
|
50
|
+
* that is a 1.5 peak; but the dither pass asks consumers to raise the
|
|
51
|
+
* intensity to ≈0.4+, and at 0.6 the same ×10 stacked 5.4 per click into an
|
|
52
|
+
* unclamped half-float dye buffer that the dither display then clamps to 1
|
|
53
|
+
* and quantises — every click grew a flat, saturated disc that never read as
|
|
54
|
+
* a splat. Capping the boost at 10 keeps intensities ≤ 0.15 byte-identical.
|
|
55
|
+
*/
|
|
56
|
+
export declare function clickBoost(colorIntensity: number): number;
|
|
39
57
|
export declare function correctDeltaX(delta: number, width: number, height: number): number;
|
|
40
58
|
export declare function correctDeltaY(delta: number, width: number, height: number): number;
|
|
41
59
|
/**
|
|
@@ -95,6 +95,11 @@ function scaleRadiusForContainer(radius, containerHeight, viewportHeight) {
|
|
|
95
95
|
if (k <= 1) return radius;
|
|
96
96
|
return Math.max(radius, Math.min(radius * k, 0.09));
|
|
97
97
|
}
|
|
98
|
+
const CLICK_PEAK = 1.5;
|
|
99
|
+
function clickBoost(colorIntensity) {
|
|
100
|
+
if (!(colorIntensity > 0)) return 0;
|
|
101
|
+
return Math.min(10, CLICK_PEAK / colorIntensity);
|
|
102
|
+
}
|
|
98
103
|
function correctDeltaX(delta, width, height) {
|
|
99
104
|
const aspectRatio = width / height;
|
|
100
105
|
if (aspectRatio < 1) return delta * aspectRatio;
|
|
@@ -107,8 +112,10 @@ function correctDeltaY(delta, width, height) {
|
|
|
107
112
|
}
|
|
108
113
|
const DEV = typeof process !== "undefined" && process.env.NODE_ENV !== "production";
|
|
109
114
|
export {
|
|
115
|
+
CLICK_PEAK,
|
|
110
116
|
DEV,
|
|
111
117
|
HSVtoRGB,
|
|
118
|
+
clickBoost,
|
|
112
119
|
correctDeltaX,
|
|
113
120
|
correctDeltaY,
|
|
114
121
|
correctRadius,
|
|
@@ -20,6 +20,12 @@ export interface WebGpuFluidOptions {
|
|
|
20
20
|
pauseWhenHidden: boolean;
|
|
21
21
|
splatOnMount: boolean;
|
|
22
22
|
generateColor: () => ColorRGB;
|
|
23
|
+
/**
|
|
24
|
+
* Multiplier a click applies to the (already intensity-scaled) colour
|
|
25
|
+
* `generateColor()` returns — `clickBoost(colorIntensity)` from
|
|
26
|
+
* fluid-shared, so the peak is the same whatever the intensity.
|
|
27
|
+
*/
|
|
28
|
+
clickBoost: number;
|
|
23
29
|
}
|
|
24
30
|
/**
|
|
25
31
|
* Full WebGPU setup for FluidCursor: engine + pointer wiring + rAF loop +
|
|
@@ -52,7 +52,11 @@ fn fs_splat(in: VSOut) -> @location(0) vec4f {
|
|
|
52
52
|
p.x = p.x * u.aspect;
|
|
53
53
|
let splat = exp(-dot(p, p) / u.radius) * u.color.rgb;
|
|
54
54
|
let base = textureSample(tex0, smpLinear, in.uv).rgb;
|
|
55
|
-
|
|
55
|
+
// color.a is the click flag: a click splat lifts each texel to its own
|
|
56
|
+
// profile instead of adding to it, so a burst of clicks paints the same
|
|
57
|
+
// disc once instead of summing towards white and widening every click.
|
|
58
|
+
let sum = select(base + splat, max(base, splat), u.color.a > 0.5);
|
|
59
|
+
return vec4f(sum, 1.0);
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
@fragment
|
|
@@ -233,7 +237,7 @@ async function createWebGpuFluid(canvas, opts) {
|
|
|
233
237
|
scratch[OFF_COLOR] = v.color[0];
|
|
234
238
|
scratch[OFF_COLOR + 1] = v.color[1];
|
|
235
239
|
scratch[OFF_COLOR + 2] = v.color[2];
|
|
236
|
-
scratch[OFF_COLOR + 3] =
|
|
240
|
+
scratch[OFF_COLOR + 3] = v.color[3] ?? 0;
|
|
237
241
|
}
|
|
238
242
|
scratch[OFF_DT] = v.dt ?? 0;
|
|
239
243
|
scratch[OFF_DISSIPATION] = v.dissipation ?? 0;
|
|
@@ -368,7 +372,7 @@ async function createWebGpuFluid(canvas, opts) {
|
|
|
368
372
|
canvas.height = height;
|
|
369
373
|
initTextures();
|
|
370
374
|
}
|
|
371
|
-
}, splat = function(x, y, dx, dy, color) {
|
|
375
|
+
}, splat = function(x, y, dx, dy, color, saturate = false) {
|
|
372
376
|
if (destroyed || lost || !velocity || !dye) return;
|
|
373
377
|
let radius = correctRadius(opts.splatRadius / 100, canvas.width, canvas.height);
|
|
374
378
|
if (opts.contained) {
|
|
@@ -383,7 +387,7 @@ async function createWebGpuFluid(canvas, opts) {
|
|
|
383
387
|
});
|
|
384
388
|
writeUniforms(uSplatDye, {
|
|
385
389
|
point: [x, y],
|
|
386
|
-
color: [color.r, color.g, color.b],
|
|
390
|
+
color: [color.r, color.g, color.b, saturate ? 1 : 0],
|
|
387
391
|
aspect,
|
|
388
392
|
radius
|
|
389
393
|
});
|
|
@@ -716,13 +720,15 @@ async function startWebGpuFluid(canvas, opts) {
|
|
|
716
720
|
engine.splat(pointer.texcoordX, pointer.texcoordY, dx, dy, pointer.color);
|
|
717
721
|
}
|
|
718
722
|
function clickSplat(pointer) {
|
|
719
|
-
const
|
|
720
|
-
color
|
|
721
|
-
|
|
722
|
-
|
|
723
|
+
const base = opts.generateColor();
|
|
724
|
+
const color = {
|
|
725
|
+
r: base.r * opts.clickBoost,
|
|
726
|
+
g: base.g * opts.clickBoost,
|
|
727
|
+
b: base.b * opts.clickBoost
|
|
728
|
+
};
|
|
723
729
|
const dx = 10 * (Math.random() - 0.5);
|
|
724
730
|
const dy = 30 * (Math.random() - 0.5);
|
|
725
|
-
engine.splat(pointer.texcoordX, pointer.texcoordY, dx, dy, color);
|
|
731
|
+
engine.splat(pointer.texcoordX, pointer.texcoordY, dx, dy, color, true);
|
|
726
732
|
}
|
|
727
733
|
function multipleSplats(steps) {
|
|
728
734
|
const angle = Math.random() * Math.PI * 2;
|
|
@@ -780,6 +786,9 @@ async function startWebGpuFluid(canvas, opts) {
|
|
|
780
786
|
updatePointerDownData(pointer, -1, posX, posY);
|
|
781
787
|
clickSplat(pointer);
|
|
782
788
|
}
|
|
789
|
+
function handlePointerUp() {
|
|
790
|
+
pointers[0].down = false;
|
|
791
|
+
}
|
|
783
792
|
function handleFirstMouseMove(e) {
|
|
784
793
|
const pointer = pointers[0];
|
|
785
794
|
const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
|
|
@@ -826,11 +835,14 @@ async function startWebGpuFluid(canvas, opts) {
|
|
|
826
835
|
}
|
|
827
836
|
if (opts.interactive) {
|
|
828
837
|
window.addEventListener("mousedown", handleMouseDown);
|
|
838
|
+
window.addEventListener("mouseup", handlePointerUp);
|
|
829
839
|
document.body.addEventListener("mousemove", handleFirstMouseMove);
|
|
830
840
|
window.addEventListener("mousemove", handleMouseMove);
|
|
831
841
|
document.body.addEventListener("touchstart", handleFirstTouchStart);
|
|
832
842
|
window.addEventListener("touchstart", handleTouchStart, false);
|
|
833
843
|
window.addEventListener("touchmove", handleTouchMove, false);
|
|
844
|
+
window.addEventListener("touchend", handlePointerUp);
|
|
845
|
+
window.addEventListener("touchcancel", handlePointerUp);
|
|
834
846
|
}
|
|
835
847
|
let observer = null;
|
|
836
848
|
if (opts.pauseWhenHidden) {
|
|
@@ -903,11 +915,14 @@ async function startWebGpuFluid(canvas, opts) {
|
|
|
903
915
|
if (autoSplatTimer) clearInterval(autoSplatTimer);
|
|
904
916
|
if (opts.interactive) {
|
|
905
917
|
window.removeEventListener("mousedown", handleMouseDown);
|
|
918
|
+
window.removeEventListener("mouseup", handlePointerUp);
|
|
906
919
|
document.body.removeEventListener("mousemove", handleFirstMouseMove);
|
|
907
920
|
window.removeEventListener("mousemove", handleMouseMove);
|
|
908
921
|
document.body.removeEventListener("touchstart", handleFirstTouchStart);
|
|
909
922
|
window.removeEventListener("touchstart", handleTouchStart);
|
|
910
923
|
window.removeEventListener("touchmove", handleTouchMove);
|
|
924
|
+
window.removeEventListener("touchend", handlePointerUp);
|
|
925
|
+
window.removeEventListener("touchcancel", handlePointerUp);
|
|
911
926
|
}
|
|
912
927
|
if (opts.contained) {
|
|
913
928
|
window.removeEventListener("resize", updateCanvasRectCache);
|
package/package.json
CHANGED