Fluid Smudge
Настоящая жидкость на GPU: поле скорости и краска живут в текстурах и пересчитываются каждый кадр парой буферов «читаю из одного, пишу в другой». Кадр идёт по классической схеме несжимаемой жидкости: перенос поля самим собой, толчок под курсором, подкрутка завихрений и проекция — дивергенция, итерации Якоби для давления и вычитание его градиента. Без проекции поток надувает и сдувает объём, и вместо жидкости получается размазня. Краска светится рельефом по собственному наклону, поэтому мазок читается объёмным. Без курсора сцена водит мазок сама.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/fluid-smudge.jsonзависимости: three@^0.185.1 · @types/three@^0.185.0
Что нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef } from "react";
import { createFluidSmudge, type FluidSmudge as Engine, type FluidSmudgeOptions } from "@/lib/fluid-smudge-engine";
export type FluidSmudgeProps = Omit<FluidSmudgeOptions, "animate"> & {
/** Подложка под холст: CSS `background` или `false`, если её задаёт секция. */
backdrop?: string | false;
};
/**
* Fluid Smudge — настоящая жидкость на GPU: поле скорости и краска живут в текстурах
* и пересчитываются каждый кадр. Курсор толкает поток, краска расплывается вихрями.
*
* Сцена — `createFluidSmudge` из соседнего файла; компонент держит холст, прокидывает
* крутилки через `set()` и разбирает всё при размонтировании.
* `prefers-reduced-motion` и `data-shot` рисуют один стоячий кадр.
*/
export function FluidSmudge({ backdrop = "oklch(0.08 0.01 260)", ...options }: FluidSmudgeProps = {}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const engine = useRef<Engine | null>(null);
const first = useRef(options);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const animate = !(
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
engine.current = createFluidSmudge(canvas, { ...first.current, animate });
return () => {
engine.current?.destroy();
engine.current = null;
};
}, []);
const { fade, force, radius, swirl, palette, relief, quality, idle } = options;
useEffect(() => {
engine.current?.set({ fade, force, radius, swirl, palette, relief, quality, idle });
}, [fade, force, radius, swirl, palette, relief, quality, idle]);
return (
<div
style={{
position: "absolute",
inset: 0,
overflow: "hidden",
...(backdrop === false ? null : { background: backdrop }),
}}
>
<canvas ref={canvasRef} aria-hidden="true" style={{ display: "block", width: "100%", height: "100%" }} />
</div>
);
}
import * as THREE from "three";
/**
* Fluid Smudge — движок на голом three.js (без React). Настоящая жидкость на GPU:
* поле скорости и краска живут в текстурах и пересчитываются каждый кадр парой
* буферов «читаю из одного, пишу в другой» (ping-pong).
*
* Шаги кадра — классическая схема несжимаемой жидкости: перенос поля по себе самому
* (advection), добавление силы и краски под курсором, подкрутка завихрений
* (vorticity confinement), затем проекция — дивергенция, несколько итераций Якоби
* для давления и вычитание его градиента. Без проекции скорость «надувает» и «сдувает»
* объём, и вместо жидкости получается размазня.
*
* `createFluidSmudge(canvas, options)` → { set(options), destroy() }.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `animate: false`
* (reduced-motion / `data-shot`) рисует один стоячий кадр.
*/
export interface FluidSmudgeOptions {
/** Как быстро гаснет краска: 0 — почти не гаснет. */
fade?: number;
/** Сила толчка от курсора. */
force?: number;
/** Размер пятна под курсором. */
radius?: number;
/** Завихрённость: сколько подкручивать мелкие вихри. */
swirl?: number;
/** Палитра: `spectrum` · `ink` · `ember`. */
palette?: "spectrum" | "ink" | "ember";
/** Рельеф: подсветка по наклону краски, 0 — плоская заливка. */
relief?: number;
/** Точность проекции: больше итераций — спокойнее поток, дороже кадр. */
quality?: number;
/** Своё движение, когда курсора нет. */
idle?: number;
/** false — один стоячий кадр (reduced-motion, скриншоты). */
animate?: boolean;
}
export interface FluidSmudge {
set(next: Partial<FluidSmudgeOptions>): void;
destroy(): void;
}
const DEFAULTS = {
fade: 0.18,
force: 0.75,
radius: 0.7,
swirl: 0.6,
palette: "spectrum" as "spectrum" | "ink" | "ember",
relief: 0.6,
quality: 0.5,
idle: 0.7,
animate: true,
};
type Config = typeof DEFAULTS;
/** Разрешение симуляции скорости по короткой стороне. Краска считается вдвое подробнее. */
const SIM = 160;
const DYE = 420;
const QUAD_VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
/**
* Перенос поля самим собой: смотрим, ОТКУДА пришла жидкость в эту точку, и берём
* значение там (обратный шаг по времени — так схема остаётся устойчивой при любом dt).
*/
const ADVECT = /* glsl */ `
precision highp float;
uniform sampler2D uSource;
uniform sampler2D uVelocity;
uniform vec2 uTexel;
uniform float uDt;
uniform float uDissipation;
varying vec2 vUv;
void main() {
vec2 from = vUv - uDt * texture2D(uVelocity, vUv).xy * uTexel;
gl_FragColor = texture2D(uSource, from) / (1.0 + uDissipation * uDt);
}
`;
/**
* Мазок. Скорость ПРИБАВЛЯЕТСЯ (толчки складываются), краска ПОДМЕШИВАЕТСЯ: сложение
* краски не имеет потолка, и в местах, где кисть задерживается, все три канала упираются
* в максимум — вместо цвета белое пятно. Подмешивание держит цвет мазка честным всегда.
*/
const SPLAT = /* glsl */ `
precision highp float;
uniform sampler2D uSource;
uniform vec2 uPoint;
uniform vec3 uValue;
uniform float uRadius;
uniform float uAspect;
uniform float uMix;
uniform float uAmount;
varying vec2 vUv;
void main() {
vec2 d = vUv - uPoint;
d.x *= uAspect;
float f = exp(-dot(d, d) / uRadius);
vec3 src = texture2D(uSource, vUv).xyz;
vec3 added = src + uValue * f;
vec3 mixed = mix(src, uValue, clamp(f * uAmount, 0.0, 1.0));
gl_FragColor = vec4(mix(added, mixed, uMix), 1.0);
}
`;
const CURL = /* glsl */ `
precision highp float;
uniform sampler2D uVelocity;
uniform vec2 uTexel;
varying vec2 vUv;
void main() {
float l = texture2D(uVelocity, vUv - vec2(uTexel.x, 0.0)).y;
float r = texture2D(uVelocity, vUv + vec2(uTexel.x, 0.0)).y;
float b = texture2D(uVelocity, vUv - vec2(0.0, uTexel.y)).x;
float t = texture2D(uVelocity, vUv + vec2(0.0, uTexel.y)).x;
gl_FragColor = vec4(0.5 * ((r - l) - (t - b)), 0.0, 0.0, 1.0);
}
`;
/** Подкрутка вихрей: без неё мелкая завитушка гаснет за десяток кадров. */
const VORTICITY = /* glsl */ `
precision highp float;
uniform sampler2D uVelocity;
uniform sampler2D uCurl;
uniform vec2 uTexel;
uniform float uStrength;
uniform float uDt;
varying vec2 vUv;
void main() {
float l = texture2D(uCurl, vUv - vec2(uTexel.x, 0.0)).x;
float r = texture2D(uCurl, vUv + vec2(uTexel.x, 0.0)).x;
float b = texture2D(uCurl, vUv - vec2(0.0, uTexel.y)).x;
float t = texture2D(uCurl, vUv + vec2(0.0, uTexel.y)).x;
float c = texture2D(uCurl, vUv).x;
vec2 force = 0.5 * vec2(abs(t) - abs(b), abs(r) - abs(l));
force /= length(force) + 0.0001;
force *= uStrength * c;
force.y *= -1.0;
vec2 v = texture2D(uVelocity, vUv).xy + force * uDt;
gl_FragColor = vec4(clamp(v, -1000.0, 1000.0), 0.0, 1.0);
}
`;
const DIVERGENCE = /* glsl */ `
precision highp float;
uniform sampler2D uVelocity;
uniform vec2 uTexel;
varying vec2 vUv;
void main() {
float l = texture2D(uVelocity, vUv - vec2(uTexel.x, 0.0)).x;
float r = texture2D(uVelocity, vUv + vec2(uTexel.x, 0.0)).x;
float b = texture2D(uVelocity, vUv - vec2(0.0, uTexel.y)).y;
float t = texture2D(uVelocity, vUv + vec2(0.0, uTexel.y)).y;
gl_FragColor = vec4(0.5 * (r - l + t - b), 0.0, 0.0, 1.0);
}
`;
const PRESSURE = /* glsl */ `
precision highp float;
uniform sampler2D uPressure;
uniform sampler2D uDivergence;
uniform vec2 uTexel;
varying vec2 vUv;
void main() {
float l = texture2D(uPressure, vUv - vec2(uTexel.x, 0.0)).x;
float r = texture2D(uPressure, vUv + vec2(uTexel.x, 0.0)).x;
float b = texture2D(uPressure, vUv - vec2(0.0, uTexel.y)).x;
float t = texture2D(uPressure, vUv + vec2(0.0, uTexel.y)).x;
float d = texture2D(uDivergence, vUv).x;
gl_FragColor = vec4((l + r + b + t - d) * 0.25, 0.0, 0.0, 1.0);
}
`;
const GRADIENT = /* glsl */ `
precision highp float;
uniform sampler2D uPressure;
uniform sampler2D uVelocity;
uniform vec2 uTexel;
uniform float uMaxSpeed;
varying vec2 vUv;
void main() {
float l = texture2D(uPressure, vUv - vec2(uTexel.x, 0.0)).x;
float r = texture2D(uPressure, vUv + vec2(uTexel.x, 0.0)).x;
float b = texture2D(uPressure, vUv - vec2(0.0, uTexel.y)).x;
float t = texture2D(uPressure, vUv + vec2(0.0, uTexel.y)).x;
vec2 v = texture2D(uVelocity, vUv).xy - vec2(r - l, t - b);
// потолок на скорость: даже на крайних значениях крутилок поле не должно
// уносить краску за кадр быстрее, чем глаз успевает её прочитать
float m = length(v);
if (m > uMaxSpeed) v *= uMaxSpeed / m;
gl_FragColor = vec4(v, 0.0, 1.0);
}
`;
/** Показ краски: цвет плюс РЕЛЬЕФ по наклону поля — без него это плоская заливка. */
const DISPLAY = /* glsl */ `
precision highp float;
uniform sampler2D uDye;
uniform vec2 uTexel;
uniform float uRelief;
varying vec2 vUv;
void main() {
vec3 c = texture2D(uDye, vUv).rgb;
float l = length(texture2D(uDye, vUv - vec2(uTexel.x, 0.0)).rgb);
float r = length(texture2D(uDye, vUv + vec2(uTexel.x, 0.0)).rgb);
float b = length(texture2D(uDye, vUv - vec2(0.0, uTexel.y)).rgb);
float t = length(texture2D(uDye, vUv + vec2(0.0, uTexel.y)).rgb);
vec3 n = normalize(vec3(l - r, b - t, 0.55));
float key = max(0.0, dot(n, normalize(vec3(-0.5, 0.65, 0.58))));
// Краска копится без потолка: повторные мазки в одно место уходили в белое пятно,
// и цвет пропадал. Сжимаем экспонентой — насыщенное место темнеет в тон, а не белеет.
c = vec3(1.0) - exp(-c * 1.6);
c *= 1.0 + uRelief * (key - 0.45) * 1.6;
gl_FragColor = vec4(clamp(c, 0.0, 1.0), 1.0);
}
`;
interface Pair {
read: THREE.WebGLRenderTarget;
write: THREE.WebGLRenderTarget;
swap(): void;
}
function makePair(w: number, h: number, type: THREE.TextureDataType): Pair {
const opts = {
type,
format: THREE.RGBAFormat,
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
depthBuffer: false,
stencilBuffer: false,
};
const a = new THREE.WebGLRenderTarget(w, h, opts);
const b = new THREE.WebGLRenderTarget(w, h, opts);
return {
read: a,
write: b,
swap() {
const t = this.read;
this.read = this.write;
this.write = t;
},
};
}
const PALETTES: Record<"spectrum" | "ink" | "ember", (t: number) => [number, number, number]> = {
// спектр: краска меняет тон со временем, поэтому мазки не сливаются в одно пятно
spectrum: (t) => {
const c = new THREE.Color().setHSL((t * 0.22) % 1, 0.85, 0.55);
return [c.r, c.g, c.b];
},
ink: (t) => {
const c = new THREE.Color().setHSL(0.6 + 0.06 * Math.sin(t * 0.4), 0.55, 0.62);
return [c.r, c.g, c.b];
},
ember: (t) => {
const c = new THREE.Color().setHSL(0.06 + 0.04 * Math.sin(t * 0.5), 0.9, 0.56);
return [c.r, c.g, c.b];
},
};
export function createFluidSmudge(canvas: HTMLCanvasElement, options: FluidSmudgeOptions = {}): FluidSmudge {
const cfg: Config = { ...DEFAULTS, ...options };
const animate = cfg.animate;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.autoClear = false;
// Половинная точность — не экономия, а условие: поля скорости и давления обязаны
// быть вещественными, в обычный байтовый буфер они не помещаются вовсе.
const type = THREE.HalfFloatType;
const geo = new THREE.PlaneGeometry(2, 2);
const cam = new THREE.Camera();
const scene = new THREE.Scene();
const mat = (fragmentShader: string, uniforms: Record<string, THREE.IUniform>) =>
new THREE.ShaderMaterial({ vertexShader: QUAD_VERT, fragmentShader, uniforms, depthTest: false, depthWrite: false });
const simTexel = new THREE.Vector2();
const dyeTexel = new THREE.Vector2();
const advectMat = mat(ADVECT, {
uSource: { value: null },
uVelocity: { value: null },
uTexel: { value: simTexel },
uDt: { value: 0.016 },
uDissipation: { value: 0.2 },
});
const splatMat = mat(SPLAT, {
uSource: { value: null },
uPoint: { value: new THREE.Vector2() },
uValue: { value: new THREE.Vector3() },
uRadius: { value: 0.0005 },
uAspect: { value: 1 },
uMix: { value: 0 },
uAmount: { value: 1 },
});
const curlMat = mat(CURL, { uVelocity: { value: null }, uTexel: { value: simTexel } });
const vortMat = mat(VORTICITY, {
uVelocity: { value: null },
uCurl: { value: null },
uTexel: { value: simTexel },
uStrength: { value: 20 },
uDt: { value: 0.016 },
});
const divMat = mat(DIVERGENCE, { uVelocity: { value: null }, uTexel: { value: simTexel } });
const pressMat = mat(PRESSURE, { uPressure: { value: null }, uDivergence: { value: null }, uTexel: { value: simTexel } });
const gradMat = mat(GRADIENT, {
uPressure: { value: null },
uVelocity: { value: null },
uTexel: { value: simTexel },
uMaxSpeed: { value: 46 },
});
const showMat = mat(DISPLAY, { uDye: { value: null }, uTexel: { value: dyeTexel }, uRelief: { value: cfg.relief } });
// Квадрат создаётся УЖЕ с шейдерным материалом: с `MeshBasicMaterial` в конструкторе
// тип меша фиксируется на нём, и присваивание своего материала не проходит по типам
// у потребителя (внутренний tsc это пропускает, ловит только смоук).
const quad = new THREE.Mesh(geo, advectMat);
scene.add(quad);
let velocity!: Pair;
let dye!: Pair;
let pressure!: Pair;
let curlRt!: THREE.WebGLRenderTarget;
let divRt!: THREE.WebGLRenderTarget;
function blit(target: THREE.WebGLRenderTarget | null, material: THREE.ShaderMaterial): void {
quad.material = material;
renderer.setRenderTarget(target);
renderer.clear();
renderer.render(scene, cam);
renderer.setRenderTarget(null);
}
function allocate(): void {
const w = canvas.clientWidth || 1;
const h = canvas.clientHeight || 1;
const aspect = Math.max(0.2, w / h);
const sw = Math.round(SIM * Math.sqrt(aspect));
const sh = Math.round(SIM / Math.sqrt(aspect));
const dw = Math.round(DYE * Math.sqrt(aspect));
const dh = Math.round(DYE / Math.sqrt(aspect));
velocity?.read.dispose();
velocity?.write.dispose();
dye?.read.dispose();
dye?.write.dispose();
pressure?.read.dispose();
pressure?.write.dispose();
curlRt?.dispose();
divRt?.dispose();
velocity = makePair(sw, sh, type);
pressure = makePair(sw, sh, type);
dye = makePair(dw, dh, type);
const opts = { type, format: THREE.RGBAFormat, minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, depthBuffer: false, stencilBuffer: false };
curlRt = new THREE.WebGLRenderTarget(sw, sh, opts);
divRt = new THREE.WebGLRenderTarget(sw, sh, opts);
simTexel.set(1 / sw, 1 / sh);
dyeTexel.set(1 / dw, 1 / dh);
splatMat.uniforms.uAspect!.value = aspect;
}
function resize(): void {
const w = canvas.clientWidth || window.innerWidth;
const h = canvas.clientHeight || window.innerHeight;
renderer.setSize(w, h, false);
allocate();
}
resize();
const pointer = { x: 0.5, y: 0.5, px: 0.5, py: 0.5, down: false, moved: false };
const onPointerMove = (e: PointerEvent) => {
if (!animate) return;
const r = canvas.getBoundingClientRect();
pointer.x = (e.clientX - r.left) / r.width;
pointer.y = 1 - (e.clientY - r.top) / r.height;
pointer.moved = true;
};
canvas.addEventListener("pointermove", onPointerMove);
/**
* Мазок краски и толчок в поле скорости в одной точке. `amount` — доля краски:
* она обязана падать вместе со СКОРОСТЬЮ кисти. Иначе на медленном повороте
* (и под стоящим курсором) краска копится в одном месте, все три канала упираются
* в потолок, и вместо цвета получается белое пятно.
*/
function splat(x: number, y: number, dx: number, dy: number, color: [number, number, number], amount = 1): void {
const radius = 0.00006 + cfg.radius * 0.0013;
splatMat.uniforms.uPoint!.value.set(x, y);
splatMat.uniforms.uRadius!.value = radius;
splatMat.uniforms.uSource!.value = velocity.read.texture;
splatMat.uniforms.uMix!.value = 0;
splatMat.uniforms.uValue!.value.set(dx, dy, 0);
blit(velocity.write, splatMat);
velocity.swap();
splatMat.uniforms.uSource!.value = dye.read.texture;
// скорость кисти правит НЕПРОЗРАЧНОСТЬ мазка, а не яркость краски:
// иначе медленный мазок выходит блёклым вместо тонкого
splatMat.uniforms.uMix!.value = 1;
splatMat.uniforms.uAmount!.value = Math.min(1, Math.max(0, amount));
splatMat.uniforms.uValue!.value.set(color[0], color[1], color[2]);
blit(dye.write, splatMat);
dye.swap();
}
let raf = 0;
let last = 0;
let time = 0;
function simulate(dt: number): void {
const uni = (m: THREE.ShaderMaterial, k: string, v: unknown) => ((m.uniforms[k] as THREE.IUniform).value = v);
// завихрения
uni(curlMat, "uVelocity", velocity.read.texture);
blit(curlRt, curlMat);
uni(vortMat, "uVelocity", velocity.read.texture);
uni(vortMat, "uCurl", curlRt.texture);
// Подкрутка вихрей ДОБАВЛЯЕТ энергию в поле, поэтому её вклад держим скромным:
// вместе с малым трением она и раскручивала сцену.
uni(vortMat, "uStrength", 5 + cfg.swirl * 16);
uni(vortMat, "uDt", dt);
blit(velocity.write, vortMat);
velocity.swap();
// проекция: дивергенция → давление → вычитание градиента
uni(divMat, "uVelocity", velocity.read.texture);
blit(divRt, divMat);
const iters = Math.max(6, Math.round(6 + cfg.quality * 26));
uni(pressMat, "uDivergence", divRt.texture);
for (let i = 0; i < iters; i++) {
uni(pressMat, "uPressure", pressure.read.texture);
blit(pressure.write, pressMat);
pressure.swap();
}
uni(gradMat, "uPressure", pressure.read.texture);
uni(gradMat, "uVelocity", velocity.read.texture);
blit(velocity.write, gradMat);
velocity.swap();
// перенос: сначала скорость собой, потом краска этой скоростью
uni(advectMat, "uTexel", simTexel);
uni(advectMat, "uDt", dt);
uni(advectMat, "uVelocity", velocity.read.texture);
uni(advectMat, "uSource", velocity.read.texture);
// Трение поля скорости. Ключевое число всей записи: сила подаётся КАЖДЫЙ кадр,
// и равновесная скорость равна «подача ÷ трение». При 0.12 постоянная времени
// около восьми секунд, поле копит движение минутами и в итоге сдувает картинку
// ветром (замер: снос 9.5 px/с на третьей секунде против 20.1 на 75-й).
// При 0.85 постоянная времени чуть больше секунды — поток живой, но не разгоняется.
uni(advectMat, "uDissipation", 0.85);
blit(velocity.write, advectMat);
velocity.swap();
uni(advectMat, "uTexel", dyeTexel);
uni(advectMat, "uVelocity", velocity.read.texture);
uni(advectMat, "uSource", dye.read.texture);
uni(advectMat, "uDissipation", 0.06 + cfg.fade * 1.4);
blit(dye.write, advectMat);
dye.swap();
}
function frame(now = 0): void {
const dt = animate && last ? Math.min((now - last) / 1000, 0.033) : 0;
last = now;
time += dt;
const color = PALETTES[cfg.palette](time);
const push = 1500 * cfg.force;
if (dt > 0) {
if (pointer.moved) {
const dx = (pointer.x - pointer.px) * push;
const dy = (pointer.y - pointer.py) * push;
const speed = Math.hypot(pointer.x - pointer.px, pointer.y - pointer.py);
splat(pointer.x, pointer.y, dx, dy, color, speed * 26);
pointer.moved = false;
} else if (cfg.idle > 0.01) {
// Две «руки» вразнобой: одна оставляет запятую в углу кадра, поле выглядит пустым.
// Своя «рука», когда курсора нет: восьмёрка со сменой тона. Без неё запись
// в каталоге выглядит пустым чёрным полем, пока на неё не наведут мышь.
const t = time * (0.3 + cfg.idle * 0.45);
for (const [k, phase] of [[1, 0], [0.78, 2.4]] as [number, number][]) {
const u = t * k + phase;
const x = 0.5 + Math.sin(u) * 0.33;
const y = 0.5 + Math.sin(u * 1.7 + 0.6) * 0.27;
const vx = Math.cos(u) * 0.33;
const vy = Math.cos(u * 1.7 + 0.6) * 1.7 * 0.27;
const c = PALETTES[cfg.palette](time + phase * 1.7);
splat(x, y, vx * push * 0.05 * cfg.idle, vy * push * 0.05 * cfg.idle, c, Math.hypot(vx, vy) * 0.9);
}
}
pointer.px = pointer.x;
pointer.py = pointer.y;
simulate(dt);
}
showMat.uniforms.uDye!.value = dye.read.texture;
showMat.uniforms.uRelief!.value = cfg.relief;
blit(null, showMat);
}
const loop = (now: number) => {
frame(now);
raf = requestAnimationFrame(loop);
};
const start = () => {
if (raf || !animate) return;
last = 0;
raf = requestAnimationFrame(loop);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
/**
* Затравка: несколько мазков и прогон симуляции. Нужна не только стоп-кадру —
* живая сцена без неё первые секунды выглядит пустым чёрным полем, а карточка
* каталога и снимок ловят ровно эти секунды.
*/
function seed(): void {
for (let i = 0; i < 9; i++) {
const t = i * 0.8;
splat(
0.5 + Math.sin(t) * 0.3,
0.5 + Math.sin(t * 1.7 + 0.6) * 0.24,
Math.cos(t) * 460,
Math.cos(t * 1.7 + 0.6) * 380,
PALETTES[cfg.palette](t * 0.9),
0.85,
);
for (let k = 0; k < 4; k++) simulate(0.016);
}
}
const ro = new ResizeObserver(() => {
resize();
if (!animate) {
seed();
frame();
}
});
ro.observe(canvas);
const io = new IntersectionObserver(([e]) => (e!.isIntersecting && !document.hidden ? start() : stop()));
io.observe(canvas);
const onVis = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVis);
const onLost = (e: Event) => {
e.preventDefault();
stop();
};
canvas.addEventListener("webglcontextlost", onLost);
seed();
frame();
start();
return {
set(next) {
// undefined в пропсах React — «не задано», а не «сбросить»
const patch = Object.fromEntries(Object.entries(next).filter(([, v]) => v !== undefined)) as Partial<FluidSmudgeOptions>;
Object.assign(cfg, patch);
if (!animate) {
seed();
frame();
}
},
destroy() {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("webglcontextlost", onLost);
for (const t of [velocity.read, velocity.write, dye.read, dye.write, pressure.read, pressure.write, curlRt, divRt]) t.dispose();
for (const m of [advectMat, splatMat, curlMat, vortMat, divMat, pressMat, gradMat, showMat]) m.dispose();
geo.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}