Caustics Pool
Свет сквозь воду: по кадру идёт волновое поле, а на дне бассейна светятся жилы каустики. Рисунок не нарисован узором, а посчитан как фокусировка преломлённых лучей: где поверхность выпукла, лучи сходятся и дно светлее, где вогнута — расходятся и темнее. Отсюда тонкие ветвящиеся жилы, которые сами меняют рисунок вместе с волной. Поверх — блики солнца по гребням, дно плиточное со швами, курсор роняет расходящиеся круги.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/caustics-pool.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 { createCausticsPool, type CausticsPool as Engine, type CausticsPoolOptions } from "@/lib/caustics-pool-engine";
export type CausticsPoolProps = Omit<CausticsPoolOptions, "animate">;
/**
* Caustics Pool — свет сквозь воду: по кадру идёт волновое поле, а на дне светятся
* жилы каустики, посчитанные как фокусировка преломлённых лучей. Курсор роняет круги.
*
* Сцена — `createCausticsPool` из соседнего файла; компонент держит холст, прокидывает
* крутилки через `set()` и разбирает всё при размонтировании.
* `prefers-reduced-motion` и `data-shot` рисуют один стоячий кадр.
*/
export function CausticsPool(options: CausticsPoolProps = {}) {
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 = createCausticsPool(canvas, { ...first.current, animate });
return () => {
engine.current?.destroy();
engine.current = null;
};
}, []);
const { waves, scale, speed, caustics, depth, tint, glints, ripple, tile } = options;
useEffect(() => {
engine.current?.set({ waves, scale, speed, caustics, depth, tint, glints, ripple, tile });
}, [waves, scale, speed, caustics, depth, tint, glints, ripple, tile]);
return (
<div style={{ position: "absolute", inset: 0, overflow: "hidden", background: "#03131c" }}>
<canvas ref={canvasRef} aria-hidden="true" style={{ display: "block", width: "100%", height: "100%" }} />
</div>
);
}
import * as THREE from "three";
/**
* Caustics Pool — движок на голом three.js (без React). Свет сквозь воду: по кадру
* идёт волновое поле, а на дне рисуется КАУСТИКА — сетка светящихся жил, которая
* считается не как узор, а как ФОКУСИРОВКА преломлённых лучей: там, где поверхность
* выпукла, лучи сходятся и дно светлее, где вогнута — расходятся и темнее.
*
* `createCausticsPool(canvas, options)` → { set(options), destroy() }.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `animate: false`
* (reduced-motion / `data-shot`) рисует один стоячий кадр.
*/
export interface CausticsPoolOptions {
/** Высота волн: от ряби до крупной зыби. */
waves?: number;
/** Крупность волн: меньше — длинные валы, больше — мелкая рябь. */
scale?: number;
/** Скорость волн; 0 — стоячая вода. */
speed?: number;
/** Сила каустики: насколько ярко сходятся жилы света. */
caustics?: number;
/** Глубина: чем больше, тем шире расходятся лучи и мягче рисунок. */
depth?: number;
/** Тон воды, 0–1 по кругу: 0.52 — бирюза бассейна. */
tint?: number;
/** Блики на поверхности: солнце по гребням. */
glints?: number;
/** Сила кругов от курсора. */
ripple?: number;
/** Размер плитки на дне; 0 — гладкое дно без плитки. */
tile?: number;
/** false — один стоячий кадр (reduced-motion, скриншоты). */
animate?: boolean;
}
export interface CausticsPool {
set(next: Partial<CausticsPoolOptions>): void;
destroy(): void;
}
const DEFAULTS: Required<CausticsPoolOptions> = {
waves: 0.55,
scale: 3.2,
speed: 0.15,
caustics: 1,
depth: 0.5,
tint: 0.52,
glints: 0.5,
ripple: 0.6,
tile: 4,
animate: true,
};
const VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
/**
* Приём целиком здесь, и он честно физический по смыслу.
*
* 1. `height(p)` — поле высоты воды: сумма нескольких валов с разными направлениями
* и периодами плюс круги от курсора. Направления не кратны друг другу, иначе
* получается регулярная клетка вместо живой воды.
* 2. Дно освещается не «узором», а расходимостью лучей. Преломлённый луч смещает
* точку на дне на величину, пропорциональную наклону поверхности. Если посчитать,
* как МЕНЯЕТСЯ это смещение вокруг точки (вторые производные поля), получится
* множитель площади: где площадь сжимается, свет сгущается — это и есть каустика.
* Яркость берётся как 1/|детерминант|, поэтому жилы света тонкие и ветвятся сами,
* без единого текстурного узора.
* 3. Дно — процедурная плитка со швами: без неё каустика висит в пустоте и читается
* фильтром, а не светом НА чём-то.
* 4. Поверх — блики солнца по гребням (по той же нормали) и тон воды с глубиной.
*/
const FRAG = /* glsl */ `
precision highp float;
uniform vec2 uRes;
uniform vec2 uMouse;
uniform float uTime;
uniform float uRippleT;
uniform float uWaves;
uniform float uScale;
uniform float uCaustics;
uniform float uDepth;
uniform float uTint;
uniform float uGlints;
uniform float uRipple;
uniform float uTile;
varying vec2 vUv;
/** Поле высоты воды в точке (координаты — доли высоты кадра). */
float height(vec2 p) {
float t = uTime;
float s = uScale;
float h = 0.0;
// валы: направления намеренно не кратны, периоды тоже — иначе выходит клетка
h += sin(dot(p, vec2(1.00, 0.13)) * s * 1.00 + t * 1.00) * 0.30;
h += sin(dot(p, vec2(-0.31, 0.95)) * s * 1.37 - t * 0.83) * 0.24;
h += sin(dot(p, vec2(0.72, -0.69)) * s * 2.11 + t * 1.29) * 0.20;
h += sin(dot(p, vec2(-0.86, -0.51)) * s * 3.07 - t * 1.61) * 0.16;
h += sin(dot(p, vec2(0.19, 0.98)) * s * 4.41 + t * 2.03) * 0.12;
// короткая рябь по диагонали: от неё и рождаются тонкие жилы каустики
h += sin(dot(p, vec2(0.66, 0.75)) * s * 6.20 - t * 2.47) * 0.08;
// круги от курсора: волна бежит от точки и гаснет по расстоянию и по времени
vec2 d = p - uMouse;
float r = length(d);
float age = uRippleT;
h += uRipple * 0.5 * sin(r * s * 6.0 - age * 6.5) * exp(-r * 2.6) * exp(-age * 0.9);
return h * uWaves;
}
void main() {
float aspect = uRes.x / uRes.y;
vec2 p = vec2(vUv.x * aspect, vUv.y);
// Шаг производных должен быть ДОЛЕЙ самой короткой волны в поле (здесь это
// гармоника scale*4.41), иначе вторые производные её просто не видят —
// с крупным шагом каустика не появляется вообще.
float e = 0.1 / max(uScale, 0.5);
float h = height(p);
float hx = height(p + vec2(e, 0.0));
float hX = height(p - vec2(e, 0.0));
float hy = height(p + vec2(0.0, e));
float hY = height(p - vec2(0.0, e));
float hxy = height(p + vec2(e, e));
float hXY = height(p - vec2(e, e));
float hxY = height(p + vec2(e, -e));
float hXy = height(p + vec2(-e, e));
vec2 grad = vec2(hx - hX, hy - hY) / (2.0 * e);
float hxx = (hx - 2.0 * h + hX) / (e * e);
float hyy = (hy - 2.0 * h + hY) / (e * e);
float hxyM = (hxy - hxY - hXy + hXY) / (4.0 * e * e);
// Множитель площади преломлённого пучка: детерминант матрицы Якоби смещения.
// Коэффициент подобран так, чтобы c * вторая производная была порядка единицы —
// именно тогда детерминант где-то сжимается к нулю и появляются жилы. На порядок
// меньше — детерминант всегда около единицы, и каустики нет вовсе (проверено).
float c = uDepth * 0.8;
float jac = (1.0 + c * hxx) * (1.0 + c * hyy) - (c * hxyM) * (c * hxyM);
// 1/|det| — сгущение света; ограничиваем, иначе в особых точках уходит в бесконечность
float focus = clamp(1.0 / max(abs(jac), 0.06), 0.0, 6.0);
// единица — нейтральная площадь пучка; светится только СГУЩЕНИЕ выше неё,
// поэтому жилы тонкие и между ними настоящая тень, а не серый фон
// Два слагаемых с разными порогами: узкое ядро жилы и широкий ореол вокруг неё.
// Одно только ядро даёт линии в один пиксель, которые при движении РЯБЯТ:
// мера мерцания (RECIPES 4ц) показывала 0.078 против 0.028 у самой насыщенной
// записи каталога. Ореол снимает рябь, не убирая рисунок.
float core = pow(max(focus - 1.12, 0.0), 0.7);
float halo = pow(max(focus - 1.02, 0.0), 0.4);
float caustic = (core + halo * 0.85) * uCaustics * 0.17;
// дно: процедурная плитка со швами
vec2 floorUv = p + grad * uDepth * 0.05; // дно видно сквозь воду со сдвигом
float tiles = 1.0;
if (uTile > 0.5) {
vec2 g = floorUv * uTile;
vec2 f = abs(fract(g) - 0.5);
float seam = smoothstep(0.47, 0.5, max(f.x, f.y));
// лёгкая разница тона между плитками, чтобы дно не было пластиковым
float shade = fract(sin(dot(floor(g), vec2(12.9898, 78.233))) * 43758.5453) * 0.08;
tiles = (1.0 - seam * 0.55) * (0.94 + shade);
}
// тон воды: у кромки кадра глубже и холоднее
float hue = uTint * 6.28318;
vec3 shallow = vec3(0.36, 0.78, 0.82);
vec3 deepC = vec3(0.05, 0.22, 0.38);
shallow = mix(shallow, vec3(0.42, 0.72, 0.95), 0.5 + 0.5 * sin(hue));
deepC = mix(deepC, vec3(0.06, 0.16, 0.34), 0.5 + 0.5 * cos(hue));
float deepness = smoothstep(0.15, 1.05, length(vUv - vec2(0.5, 0.62)) * 1.6);
vec3 water = mix(shallow, deepC, deepness);
vec3 col = water * (0.52 + 0.3 * tiles);
col += water * caustic * 0.5; // каустика окрашена водой
col += vec3(0.92, 0.98, 1.0) * caustic * 0.34; // и добавляет свой белый свет
// слабая цветная кайма по жилам: у воды дисперсия есть и в каустике
col.r += caustic * caustic * 0.05;
col.b += caustic * 0.06;
// блики солнца по гребням: нормаль из градиента поля высоты
vec3 n = normalize(vec3(-grad, 1.0));
vec3 sun = normalize(vec3(-0.35, 0.62, 0.7));
// блик намеренно ШИРОКИЙ: узкие искры размером в пиксель вспыхивают и гаснут
// между кадрами, и это тоже мерцание — мера ловит их в p99
float spec = pow(max(dot(reflect(-sun, n), vec3(0.0, 0.0, 1.0)), 0.0), 38.0);
col += vec3(1.0, 0.99, 0.94) * spec * uGlints * 1.1;
col = vec3(1.0) - exp(-col * 1.5); // тональная компрессия вместо clamp
gl_FragColor = vec4(col, 1.0);
}
`;
export function createCausticsPool(canvas: HTMLCanvasElement, options: CausticsPoolOptions = {}): CausticsPool {
const cfg: Required<CausticsPoolOptions> = { ...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));
const uniforms = {
uRes: { value: new THREE.Vector2(1, 1) },
uMouse: { value: new THREE.Vector2(0.8, 0.5) },
uTime: { value: 0 },
uRippleT: { value: 6 },
uWaves: { value: cfg.waves },
uScale: { value: cfg.scale },
uCaustics: { value: cfg.caustics },
uDepth: { value: cfg.depth },
uTint: { value: cfg.tint },
uGlints: { value: cfg.glints },
uRipple: { value: cfg.ripple },
uTile: { value: cfg.tile },
};
const scene = new THREE.Scene();
const camera = new THREE.Camera();
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, uniforms });
scene.add(new THREE.Mesh(geometry, material));
function resize(): void {
const w = canvas.clientWidth || window.innerWidth;
const h = canvas.clientHeight || window.innerHeight;
renderer.setSize(w, h, false);
uniforms.uRes.value.set(w, h);
}
resize();
// круги роняются под курсором: каждое заметное движение начинает новую волну,
// но не чаще, чем прежняя успевает разойтись — иначе вода кипит под мышью
let rippleAge = 6;
const target = new THREE.Vector2(0.8, 0.5);
const onPointerMove = (e: PointerEvent) => {
if (!animate) return;
const r = canvas.getBoundingClientRect();
const aspect = r.width / r.height;
const x = ((e.clientX - r.left) / r.width) * aspect;
const y = 1 - (e.clientY - r.top) / r.height;
const moved = Math.hypot(x - target.x, y - target.y);
target.set(x, y);
if (moved > 0.06 && rippleAge > 0.7) {
uniforms.uMouse.value.copy(target);
rippleAge = 0;
}
};
canvas.addEventListener("pointermove", onPointerMove);
let raf = 0;
let last = 0;
function frame(now = 0): void {
const dt = animate && last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
uniforms.uTime.value += dt * cfg.speed;
rippleAge += dt;
uniforms.uRippleT.value = rippleAge;
uniforms.uWaves.value = cfg.waves;
uniforms.uScale.value = cfg.scale;
uniforms.uCaustics.value = cfg.caustics;
uniforms.uDepth.value = cfg.depth;
uniforms.uTint.value = cfg.tint;
uniforms.uGlints.value = cfg.glints;
uniforms.uRipple.value = cfg.ripple;
uniforms.uTile.value = cfg.tile;
renderer.render(scene, camera);
}
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;
};
const ro = new ResizeObserver(() => {
resize();
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);
frame();
start();
return {
set(next) {
// undefined в пропсах React — «не задано», а не «сбросить»
const patch = Object.fromEntries(Object.entries(next).filter(([, v]) => v !== undefined)) as Partial<CausticsPoolOptions>;
Object.assign(cfg, patch);
if (!animate) frame();
},
destroy() {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("webglcontextlost", onLost);
geometry.dispose();
material.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}