Crystal Refract
Гранёный кристалл преломляет снимок за собой по-настоящему: луч ломается дважды — на входной грани и на выходной, — а каналы расходятся дисперсией, как в стекле. Чтобы знать выходную грань, кристалл рисуется двумя проходами: сначала задние грани в свой буфер (там нормаль и глубина выходной поверхности), потом передние, которые этот буфер читают. Оттуда же берётся толщина по лучу: по ней считаются и величина смещения, и поглощение цвета, поэтому тонкая кромка светлая, а глубина камня густая. Курсор поворачивает камень; форма переключается: самоцвет, призма, осколок.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/crystal-refract.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 { createCrystalRefract, type CrystalRefract as Engine, type CrystalRefractOptions } from "@/lib/crystal-refract-engine";
export type CrystalRefractProps = Omit<CrystalRefractOptions, "animate"> & {
/** Подложка под холст: CSS `background` или `false`, если её задаёт секция. */
backdrop?: string | false;
};
/**
* Crystal Refract — гранёный кристалл преломляет снимок за собой: луч ломается
* дважды (на входной и выходной грани), каналы расходятся дисперсией, толща
* поглощает цвет. Курсор поворачивает камень.
*
* Сцена — `createCrystalRefract` из соседнего файла; компонент держит холст, прокидывает
* крутилки через `set()` и разбирает всё при размонтировании.
* `prefers-reduced-motion` и `data-shot` рисуют один стоячий кадр.
*/
export function CrystalRefract({ backdrop = "oklch(0.1 0.015 260)", ...options }: CrystalRefractProps = {}) {
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 = createCrystalRefract(canvas, { ...first.current, animate });
return () => {
engine.current?.destroy();
engine.current = null;
};
}, []);
const { shape, ior, dispersion, size, absorb, spin, tilt } = options;
useEffect(() => {
engine.current?.set({ shape, ior, dispersion, size, absorb, spin, tilt });
}, [shape, ior, dispersion, size, absorb, spin, tilt]);
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";
/**
* Crystal Refract — движок на голом three.js (без React). Гранёный кристалл
* ПРЕЛОМЛЯЕТ кадр за собой по-настоящему: луч ломается дважды — на входной грани
* и на выходной, — а каналы расходятся дисперсией, как в стекле.
*
* Чтобы знать выходную грань, кристалл рисуется двумя проходами: сначала ЗАДНИЕ
* грани в свой буфер (там лежит нормаль и глубина задней поверхности), потом
* передние — они и читают этот буфер. Отсюда же берётся толщина по лучу: по ней
* считается и величина смещения, и поглощение цвета (закон Бугера), поэтому
* тонкая кромка светлая, а глубина камня — густая.
*
* `createCrystalRefract(canvas, options)` → { set(options), destroy() }.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `animate: false`
* (reduced-motion / `data-shot`) рисует один стоячий кадр.
*/
export interface CrystalRefractOptions {
/** Снимок под кристаллом (любой URL). */
image?: string;
/** Форма: `gem` · `prism` · `shard`. */
shape?: "gem" | "prism" | "shard";
/** Показатель преломления: 1 — воздух, 1.5 — стекло, 2.4 — алмаз. */
ior?: number;
/** Дисперсия: расхождение каналов. */
dispersion?: number;
/** Размер кристалла в долях кадра. */
size?: number;
/** Густота цвета в толще. */
absorb?: number;
/** Скорость собственного вращения. */
spin?: number;
/** Отклик на курсор. */
tilt?: number;
/** false — один стоячий кадр (reduced-motion, скриншоты). */
animate?: boolean;
}
export interface CrystalRefract {
set(next: Partial<CrystalRefractOptions>): void;
destroy(): void;
}
const DEFAULTS = {
image: "https://motiva.pages.dev/img/1069-1200x1200.jpg",
shape: "gem" as "gem" | "prism" | "shard",
ior: 1.5,
dispersion: 0.5,
size: 0.62,
absorb: 0.35,
spin: 0.35,
tilt: 0.6,
animate: true,
};
type Config = typeof DEFAULTS;
const BACK_VERT = /* glsl */ `
varying vec3 vNormal;
varying float vDepth;
void main() {
vNormal = normalize(mat3(modelMatrix) * normal);
vec4 mv = modelViewMatrix * vec4(position, 1.0);
vDepth = -mv.z;
gl_Position = projectionMatrix * mv;
}
`;
/** Задний проход: в буфер уходит нормаль выходной грани и её глубина. */
const BACK_FRAG = /* glsl */ `
precision highp float;
varying vec3 vNormal;
varying float vDepth;
void main() {
gl_FragColor = vec4(normalize(vNormal), vDepth);
}
`;
const FRONT_VERT = /* glsl */ `
varying vec3 vNormal;
varying vec3 vWorld;
varying float vDepth;
void main() {
vNormal = normalize(mat3(modelMatrix) * normal);
vec4 world = modelMatrix * vec4(position, 1.0);
vWorld = world.xyz;
vec4 mv = viewMatrix * world;
vDepth = -mv.z;
gl_Position = projectionMatrix * mv;
}
`;
const FRONT_FRAG = /* glsl */ `
precision highp float;
uniform sampler2D uPhoto;
uniform sampler2D uBack;
uniform vec2 uRes;
uniform vec2 uCover;
uniform float uIor;
uniform float uDispersion;
uniform float uAbsorb;
varying vec3 vNormal;
varying vec3 vWorld;
varying float vDepth;
vec2 photoUv(vec2 uv) {
// снимок повторяется ЗЕРКАЛЬНО (см. wrapS/wrapT движка): при обрезке по краю
// грань, чей луч ушёл за кадр, тянулась полосами от крайнего пикселя
return (uv - 0.5) * uCover + 0.5;
}
/** Процедурное окружение: тёмный низ, светлый софтбокс сверху, тёплый отскок. */
vec3 env(vec3 dir) {
float up = dir.y * 0.5 + 0.5;
vec3 sky = mix(vec3(0.05, 0.06, 0.09), vec3(0.85, 0.9, 1.0), smoothstep(0.55, 0.98, up));
vec3 warm = vec3(0.3, 0.22, 0.16) * smoothstep(0.45, 0.0, up);
return sky + warm + pow(max(0.0, dir.y), 24.0) * 0.8;
}
/** Одна ломающаяся траектория: вход, выход и точка на снимке. */
vec2 bend(vec3 v, vec3 n, vec3 bn, float ior, float thick) {
vec3 r1 = refract(v, n, 1.0 / ior);
vec3 r2 = refract(r1, -bn, ior);
// полное внутреннее отражение: выхода нет, берём ход внутри камня
if (dot(r2, r2) < 0.0001) r2 = r1;
// Множитель большой намеренно: у выпуклого камня входная и выходная грани почти
// параллельны, чистое отклонение луча мало, и без усиления «стекло» выглядит плёнкой.
// Длина смещения ограничена: у грани, стоящей почти вдоль взгляда, толщина
// обращается в километры, и снимок внутри размазывает в полосы.
// Толщина входит КОРНЕМ, а не прямо: на длинной грани (призма, осколок) она
// растёт быстрее, чем успевает меняться нормаль, и снимок внутри вытягивается
// в полосы вдоль грани. Корень сжимает этот разбег, длина смещения ещё и ограничена.
vec2 o = r2.xy * sqrt(thick) * 0.3;
float m = length(o);
return m > 0.22 ? o * (0.22 / m) : o;
}
void main() {
vec2 suv = gl_FragCoord.xy / uRes;
vec3 n = normalize(vNormal);
vec3 v = normalize(vWorld - cameraPosition);
vec4 back = texture2D(uBack, suv);
vec3 bn = length(back.xyz) > 0.1 ? normalize(back.xyz) : -n;
float thick = max(0.04, back.w - vDepth);
// дисперсия: у каждого канала свой показатель преломления
float d = uDispersion * 0.06;
vec2 or_ = bend(v, n, bn, uIor - d, thick);
vec2 og = bend(v, n, bn, uIor, thick);
vec2 ob = bend(v, n, bn, uIor + d, thick);
vec3 col = vec3(
texture2D(uPhoto, photoUv(suv + or_)).r,
texture2D(uPhoto, photoUv(suv + og)).g,
texture2D(uPhoto, photoUv(suv + ob)).b
);
// поглощение в толще: тонкая кромка светлая, глубина густая
col *= exp(-uAbsorb * thick * vec3(0.55, 0.42, 0.3));
// френель: на скользящем угле грань становится зеркалом
float f = pow(1.0 - max(0.0, dot(-v, n)), 5.0);
vec3 mirror = env(reflect(v, n));
col = mix(col, mirror, clamp(0.04 + f * 0.55, 0.0, 1.0));
// острый блик по грани: он и делает камень гранёным, а не стеклянной плёнкой
vec3 key = normalize(vec3(-0.45, 0.8, 0.6));
float spec = pow(max(0.0, dot(reflect(v, n), key)), 90.0);
col += vec3(1.0, 0.98, 0.94) * spec * 0.9;
// блик по ребру: без него грани не читаются на пёстром снимке
float edge = pow(1.0 - abs(dot(-v, n)), 3.0);
col += vec3(edge * 0.18);
gl_FragColor = vec4(col, 1.0);
}
`;
const PHOTO_VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
const PHOTO_FRAG = /* glsl */ `
precision highp float;
uniform sampler2D uPhoto;
uniform vec2 uCover;
varying vec2 vUv;
void main() {
vec3 c = texture2D(uPhoto, (vUv - 0.5) * uCover + 0.5).rgb;
gl_FragColor = vec4(c * 0.92, 1.0);
}
`;
/** Гранёная геометрия: нормали ПО ГРАНЯМ, иначе камень выглядит мыльным шаром. */
function faceted(shape: "gem" | "prism" | "shard"): THREE.BufferGeometry {
let geo: THREE.BufferGeometry;
if (shape === "gem") geo = new THREE.IcosahedronGeometry(1, 0);
else if (shape === "prism") geo = new THREE.CylinderGeometry(0.78, 0.78, 1.7, 6, 1);
else geo = new THREE.OctahedronGeometry(1.15, 0);
// многогранники three приходят УЖЕ неиндексированными: повторный вызов только
// печатает предупреждение в консоль
const flat = geo.index ? geo.toNonIndexed() : geo;
if (flat !== geo) geo.dispose();
if (shape === "shard") flat.scale(0.72, 1.5, 0.72);
flat.computeVertexNormals();
return flat;
}
export function createCrystalRefract(canvas: HTMLCanvasElement, options: CrystalRefractOptions = {}): CrystalRefract {
const cfg: Config = { ...DEFAULTS, ...options };
const animate = cfg.animate;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: "high-performance" });
const dpr = Math.min(window.devicePixelRatio || 1, 2);
renderer.setPixelRatio(dpr);
renderer.setClearColor(0x07080d, 1);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 30);
camera.position.set(0, 0, 5);
const loader = new THREE.TextureLoader();
loader.crossOrigin = "anonymous";
const photo = loader.load(cfg.image, () => resize());
photo.colorSpace = THREE.SRGBColorSpace;
photo.wrapS = photo.wrapT = THREE.MirroredRepeatWrapping;
photo.minFilter = THREE.LinearFilter;
const cover = new THREE.Vector2(1, 1);
/* --------------------------------------------------------- задний проход */
const backTarget = new THREE.WebGLRenderTarget(1, 1, {
type: THREE.HalfFloatType,
format: THREE.RGBAFormat,
minFilter: THREE.NearestFilter,
magFilter: THREE.NearestFilter,
depthBuffer: true,
});
const backMat = new THREE.ShaderMaterial({ vertexShader: BACK_VERT, fragmentShader: BACK_FRAG, side: THREE.BackSide });
const backScene = new THREE.Scene();
/* ------------------------------------------------------------ кристалл */
const frontMat = new THREE.ShaderMaterial({
vertexShader: FRONT_VERT,
fragmentShader: FRONT_FRAG,
uniforms: {
uPhoto: { value: photo },
uBack: { value: backTarget.texture },
uRes: { value: new THREE.Vector2(1, 1) },
uCover: { value: cover },
uIor: { value: cfg.ior },
uDispersion: { value: cfg.dispersion },
uAbsorb: { value: cfg.absorb },
},
side: THREE.FrontSide,
});
let geo = faceted(cfg.shape);
const crystal = new THREE.Mesh(geo, frontMat);
const backCrystal = new THREE.Mesh(geo, backMat);
scene.add(crystal);
backScene.add(backCrystal);
const photoQuad = new THREE.Mesh(
new THREE.PlaneGeometry(2, 2),
new THREE.ShaderMaterial({
vertexShader: PHOTO_VERT,
fragmentShader: PHOTO_FRAG,
uniforms: { uPhoto: { value: photo }, uCover: { value: cover } },
depthTest: false,
depthWrite: false,
}),
);
photoQuad.renderOrder = -1;
photoQuad.frustumCulled = false;
scene.add(photoQuad);
function applyShape(): void {
const next = faceted(cfg.shape);
crystal.geometry = next;
backCrystal.geometry = next;
geo.dispose();
geo = next;
}
function resize(): void {
const w = canvas.clientWidth || window.innerWidth;
const h = canvas.clientHeight || window.innerHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
const bw = Math.max(2, Math.round(w * dpr));
const bh = Math.max(2, Math.round(h * dpr));
backTarget.setSize(bw, bh);
frontMat.uniforms.uRes!.value.set(bw, bh);
// снимок вписывается «по покрытию»: иначе на широком кадре он растянут
const img = photo.image as { width?: number; height?: number } | undefined;
const ia = img?.width && img?.height ? img.width / img.height : 1;
const ca = w / h;
if (ca > ia) cover.set(1, ia / ca);
else cover.set(ca / ia, 1);
}
resize();
const pointer = { x: 0, y: 0, tx: 0, ty: 0 };
const onPointerMove = (e: PointerEvent) => {
if (!animate) return;
const r = canvas.getBoundingClientRect();
pointer.tx = (e.clientX - r.left) / r.width - 0.5;
pointer.ty = 0.5 - (e.clientY - r.top) / r.height;
};
const onLeave = () => {
pointer.tx = 0;
pointer.ty = 0;
};
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerleave", onLeave);
let raf = 0;
let last = 0;
let time = animate ? 0 : 2.6;
let shape = cfg.shape;
let loaded = false;
function frame(now = 0): void {
const dt = animate && last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
time += dt;
if (!loaded && (photo.image as HTMLImageElement | undefined)?.width) {
loaded = true;
resize();
}
if (shape !== cfg.shape) {
shape = cfg.shape;
applyShape();
}
pointer.x += (pointer.tx - pointer.x) * 0.06;
pointer.y += (pointer.ty - pointer.y) * 0.06;
crystal.scale.setScalar(2.2 * cfg.size);
crystal.rotation.set(
time * cfg.spin * 0.35 + pointer.y * cfg.tilt,
time * cfg.spin * 0.55 + pointer.x * cfg.tilt * 1.4,
Math.sin(time * cfg.spin * 0.3) * 0.15,
);
// Задняя копия обязана повторять преобразование ТОЧНО: буфер выходных граней
// читается по экранным координатам, и любое расхождение даёт рваную кромку.
backCrystal.scale.copy(crystal.scale);
backCrystal.rotation.copy(crystal.rotation);
backCrystal.position.copy(crystal.position);
frontMat.uniforms.uIor!.value = Math.max(1.01, cfg.ior);
frontMat.uniforms.uDispersion!.value = cfg.dispersion;
frontMat.uniforms.uAbsorb!.value = cfg.absorb;
renderer.setRenderTarget(backTarget);
renderer.clear();
renderer.render(backScene, camera);
renderer.setRenderTarget(null);
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<CrystalRefractOptions>;
Object.assign(cfg, patch);
if (!animate) frame();
},
destroy() {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerleave", onLeave);
canvas.removeEventListener("webglcontextlost", onLost);
geo.dispose();
frontMat.dispose();
backMat.dispose();
photoQuad.geometry.dispose();
(photoQuad.material as THREE.Material).dispose();
backTarget.dispose();
photo.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}