Letter Morph
Буквы летят по выбранному маршруту сквозь кадр и на ходу переливаются одна в другую: через кадр слева направо, из глубины прямо на зрителя или дугой из верхнего угла. Рождаются и уходят они ЗА кромкой кадра, поэтому видно полёт, а не появление. Буква здесь не текст и не геометрия шрифта, а контур: силуэт обводится по кромке маски и приводится к одинаковому у всех букв числу точек — после этого морф становится честной интерполяцией точка-в-точку, а не кроссфейдом. Обводка рисуется металлической лентой с отражением процедурного студийного окружения; проёмы букв живут вторым кольцом.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/letter-morph.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 { createLetterMorph, type LetterMorph as Engine, type LetterMorphOptions } from "@/lib/letter-morph-engine";
export type LetterMorphProps = Omit<LetterMorphOptions, "animate"> & {
/** Подложка под холст: CSS `background` или `false`, если её задаёт секция. */
backdrop?: string | false;
};
// Подложка намеренно НЕ чёрная: светлая металлическая лента на почти чёрном фоне
// даёт максимальный перепад яркости на кромке, и любое движение начинает рябить
// (мера мерцания, RECIPES 4ц). Приподнятый фон снижает перепад, не убирая металл.
const STUDIO =
"radial-gradient(120% 110% at 50% 8%, oklch(0.34 0.035 268) 0%, oklch(0.22 0.025 264) 48%, oklch(0.15 0.02 260) 100%)";
/**
* Letter Morph — буквы как контуры: силуэт каждой обводится по пикселям и приводится
* к одинаковому числу точек, поэтому буква морфится в букву точка-в-точку, пока летит
* по кривой сквозь кадр металлической обводкой.
*
* Сцена — `createLetterMorph` из соседнего файла; компонент держит холст, прокидывает
* крутилки через `set()` и разбирает всё при размонтировании.
* `prefers-reduced-motion` и `data-shot` рисуют один стоячий кадр посреди пролёта.
*/
export function LetterMorph({ backdrop = STUDIO, ...options }: LetterMorphProps = {}) {
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 = createLetterMorph(canvas, { ...first.current, animate });
return () => {
engine.current?.destroy();
engine.current = null;
};
}, []);
const { text, slots, speed, weight, twist, sway, tint, tilt, font } = options;
useEffect(() => {
engine.current?.set({ text, slots, speed, weight, twist, sway, tint, tilt, font });
}, [text, slots, speed, weight, twist, sway, tint, tilt, font]);
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";
/**
* Letter Morph — движок на голом three.js (без React). Буквы здесь не текст и не
* геометрия шрифта, а КОНТУРЫ: каждая буква рисуется в закадровый canvas, её силуэт
* обводится по пикселям, обводка пересчитывается в РОВНО ТО ЖЕ число точек у всех
* букв — и тогда буква морфится в букву простой интерполяцией точка-в-точку, пока
* летит по кривой сквозь кадр.
*
* `createLetterMorph(canvas, options)` → { set(options), destroy() }.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `animate: false`
* (reduced-motion / `data-shot`) рисует один стоячий кадр.
*/
export interface LetterMorphOptions {
/** Слово: буквы морфятся друг в друга по кругу в этом порядке. */
text?: string;
/** Сколько букв летит по кривой одновременно. */
slots?: number;
/** Скорость полёта; 0 — стоп. */
speed?: number;
/** Толщина металлической обводки. */
weight?: number;
/** Скрутка вокруг траектории, градусов на единицу пути. */
twist?: number;
/** Размах кривой в стороны. */
sway?: number;
/**
* Маршрут букв:
* `across` — через кадр слева направо с выходом на зрителя,
* `toward` — из глубины прямо на зрителя,
* `arc` — дугой из верхнего левого угла вниз-вправо.
* Во всех трёх буква рождается и умирает ЗА кромкой кадра, не всплывая в центре.
*/
route?: "across" | "toward" | "arc";
/** Тон металла, 0–1 по кругу. */
tint?: number;
/** Отклик на курсор: доворот всей сцены. */
tilt?: number;
/** Начертание: CSS-стек шрифта. */
font?: string;
/** false — один стоячий кадр (reduced-motion, скриншоты). */
animate?: boolean;
}
export interface LetterMorph {
set(next: Partial<LetterMorphOptions>): void;
destroy(): void;
}
const DEFAULTS: Required<LetterMorphOptions> = {
text: "МОТИВА",
slots: 4,
speed: 0.07,
weight: 1.0,
twist: 12,
sway: 0.75,
route: "across",
tint: 0.58,
tilt: 0.5,
font: '800 {size}px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
animate: true,
};
/**
* Точек на кольцо. Одинаково у всех букв — иначе морфить нечем.
* Мало точек = гранёные дуги: у «О» при 96 точках кромка читалась многоугольником.
*/
const RING = 168;
/** Колец на букву: силуэт и самая крупная внутренняя дырка (О, В, А). */
const RINGS = 2;
const VERT = /* glsl */ `
attribute float aSide;
varying vec3 vNormal;
varying vec3 vView;
varying float vSide;
void main() {
vec4 mv = modelViewMatrix * vec4(position, 1.0);
vNormal = normalize(normalMatrix * normal);
vView = -mv.xyz;
vSide = aSide;
gl_Position = projectionMatrix * mv;
}
`;
const FRAG = /* glsl */ `
precision highp float;
uniform float uTint;
varying vec3 vNormal;
varying vec3 vView;
varying float vSide;
/** Студийное окружение как функция направления: контраст, а не блеск, делает металл. */
vec3 env(vec3 r) {
vec3 floorC = vec3(0.03, 0.035, 0.06);
vec3 ceilC = vec3(0.62, 0.78, 0.98);
vec3 warm = vec3(1.00, 0.84, 0.62);
float hue = uTint * 6.28318;
ceilC = mix(ceilC, vec3(0.98, 0.93, 0.78), 0.5 + 0.5 * sin(hue));
warm = mix(warm, vec3(0.62, 0.50, 0.95), 0.5 + 0.5 * cos(hue));
float y = r.y;
vec3 sky = mix(floorC, ceilC, smoothstep(-0.1, 0.3, y));
sky = mix(sky, warm, smoothstep(0.0, -0.7, y) * 0.6);
sky += vec3(1.0, 0.99, 0.96) * smoothstep(0.6, 1.0, y) * 0.4;
// горизонт намеренно РАЗМЫТ: жёсткая тёмная линия проезжает по узкой ленте
// и даёт рябь — мера мерцания ловила её в p99 (0.61 у первой версии)
sky *= 1.0 - 0.3 * exp(-abs(y) * 8.0);
float lamp = pow(max(dot(r, normalize(vec3(-0.4, 0.78, 0.48))), 0.0), 24.0);
// лампа приглушена: пиковая яркость ленты — это тот же перепад на кромке
return sky + vec3(1.0, 0.97, 0.92) * lamp * 0.9;
}
void main() {
vec3 n = normalize(vNormal);
vec3 v = normalize(vView);
// кромка обводки чуть заваливается: лента читается объёмной, а не плоской
n = normalize(n + vec3(0.0, 0.0, vSide * 0.8));
vec3 col = env(reflect(-v, n));
float fres = pow(1.0 - max(dot(n, v), 0.0), 2.5);
col += fres * vec3(0.7, 0.82, 1.0) * 0.22;
col = vec3(1.0) - exp(-col * 1.05);
gl_FragColor = vec4(col, 1.0);
}
`;
/* ------------------------------------------------------- контуры букв */
/**
* Обводит силуэт буквы по маске и отдаёт `RINGS` кольцевых контуров по `RING` точек.
*
* Зачем именно так: у шрифтовой геометрии число точек у каждой буквы своё, и морфить
* «О» в «Т» напрямую нечем. Здесь буква сводится к фиксированному числу точек, и морф
* становится линейной интерполяцией точка-в-точку — тем же приёмом, каким морфят формы
* в моушн-дизайне. Буквы без внутренней дырки получают второе кольцо схлопнутым
* в центр, и оно просто не рисуется: так число колец у всех одинаковое.
*/
function glyphRings(ch: string, font: string): { pts: Float32Array; present: number[] } {
const S = 288;
const cv = document.createElement("canvas");
cv.width = cv.height = S;
const ctx = cv.getContext("2d", { willReadFrequently: true })!;
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, S, S);
ctx.fillStyle = "#fff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = font.replace("{size}", String(Math.round(S * 0.74)));
ctx.fillText(ch, S / 2, S / 2 + S * 0.02);
const data = ctx.getImageData(0, 0, S, S).data;
const inside = (x: number, y: number) => x >= 0 && y >= 0 && x < S && y < S && data[(y * S + x) * 4]! > 127;
// помечаем связные области фона: дырка буквы — это фон, НЕ касающийся кромки кадра
const bg = new Int8Array(S * S); // 0 — не размечено, 1 — внешний фон, 2 — дырка
const stack: number[] = [];
for (let i = 0; i < S; i++) {
for (const [x, y] of [
[i, 0],
[i, S - 1],
[0, i],
[S - 1, i],
] as [number, number][]) {
if (!inside(x, y) && !bg[y * S + x]) {
bg[y * S + x] = 1;
stack.push(x, y);
}
}
}
while (stack.length) {
const y = stack.pop()!;
const x = stack.pop()!;
for (const [dx, dy] of [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
] as [number, number][]) {
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= S || ny >= S) continue;
if (inside(nx, ny) || bg[ny * S + nx]) continue;
bg[ny * S + nx] = 1;
stack.push(nx, ny);
}
}
/**
* Обводка границы области ПО ПИКСЕЛЯМ (обход Мура), затем пересэмплирование
* по длине пути в RING точек.
*
* Почему не проще: первая версия шла угловой развёрткой от центра — для каждого
* направления бралась самая далёкая точка области. Так можно описать только
* звёздчато-выпуклую фигуру, а буквы такими не бывают: у «М», «В», «Т» глубокие
* вырезы, и развёртка отдавала многоугольник вместо буквы. Обход границы держит
* вогнутости, потому что идёт ПО КРОМКЕ, а не из центра.
*/
function ringOf(test: (x: number, y: number) => boolean): [number, number][] | null {
// самая крупная связная область: у «Ё» точки отдельные, брать надо основную
const seen = new Uint8Array(S * S);
let best: number[] | null = null;
for (let y0 = 0; y0 < S; y0++) {
for (let x0 = 0; x0 < S; x0++) {
if (seen[y0 * S + x0] || !test(x0, y0)) continue;
const comp: number[] = [];
const st = [x0, y0];
seen[y0 * S + x0] = 1;
while (st.length) {
const y = st.pop()!;
const x = st.pop()!;
comp.push(x, y);
for (const [dx, dy] of [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
] as [number, number][]) {
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= S || ny >= S) continue;
if (seen[ny * S + nx] || !test(nx, ny)) continue;
seen[ny * S + nx] = 1;
st.push(nx, ny);
}
}
if (!best || comp.length > best.length) best = comp;
}
}
if (!best || best.length < 60) return null;
const inComp = new Uint8Array(S * S);
for (let i = 0; i < best.length; i += 2) inComp[best[i + 1]! * S + best[i]!] = 1;
const has = (x: number, y: number) => x >= 0 && y >= 0 && x < S && y < S && inComp[y * S + x] === 1;
// Идти можно ТОЛЬКО по пикселям кромки. Без этого ограничения обход
// проваливается внутрь штриха и замыкается за девять шагов: у «О» так
// и вышло — контур не строился вовсе при 3000 пикселей в области.
const isEdge = (x: number, y: number) =>
has(x, y) && (!has(x + 1, y) || !has(x - 1, y) || !has(x, y + 1) || !has(x, y - 1));
// старт обхода: самый верхний-левый пиксель кромки
let sx = -1;
let sy = -1;
for (let y = 0; y < S && sy < 0; y++) {
for (let x = 0; x < S; x++) {
if (isEdge(x, y)) {
sx = x;
sy = y;
break;
}
}
}
if (sx < 0) return null;
// обход кромки: восьмёрка соседей по часовой, от направления «назад»,
// и никогда не наступаем на уже пройденный пиксель
const N8: [number, number][] = [
[1, 0],
[1, 1],
[0, 1],
[-1, 1],
[-1, 0],
[-1, -1],
[0, -1],
[1, -1],
];
const walked = new Uint8Array(S * S);
const path: [number, number][] = [];
let cx = sx;
let cy = sy;
let dir = 2;
walked[sy * S + sx] = 1;
for (let guard = 0; guard < S * S; guard++) {
path.push([cx, cy]);
let moved = false;
for (let k = 0; k < 8; k++) {
const d = (dir + 5 + k) % 8;
const [dx, dy] = N8[d]!;
const nx = cx + dx;
const ny = cy + dy;
if (!isEdge(nx, ny) || walked[ny * S + nx]) continue;
walked[ny * S + nx] = 1;
cx = nx;
cy = ny;
dir = d;
moved = true;
break;
}
if (!moved) break; // кромка замкнулась: свободных соседей больше нет
}
if (path.length < 12) return null;
// Сглаживание ступеньки: обход идёт ПО ПИКСЕЛЯМ, поэтому путь всегда лесенка.
// Без этого прохода лесенка доезжает до геометрии и лента выглядит мятой.
const blur = (src: [number, number][], passes: number, w: number) => {
let cur = src;
for (let k = 0; k < passes; k++) {
const next: [number, number][] = [];
const n = cur.length;
for (let i = 0; i < n; i++) {
let sx2 = 0;
let sy2 = 0;
let wt = 0;
for (let d = -w; d <= w; d++) {
const q = cur[(i + d + n) % n]!;
const k2 = 1 - Math.abs(d) / (w + 1);
sx2 += q[0] * k2;
sy2 += q[1] * k2;
wt += k2;
}
next.push([sx2 / wt, sy2 / wt]);
}
cur = next;
}
return cur;
};
const smoothPath = blur(path, 2, 3);
// пересэмплирование по длине пути: точки ложатся равномерно, а не по пикселям
let total = 0;
const seg: number[] = [0];
for (let i = 1; i <= smoothPath.length; i++) {
const a = smoothPath[i - 1]!;
const b = smoothPath[i % smoothPath.length]!;
total += Math.hypot(b[0] - a[0], b[1] - a[1]);
seg.push(total);
}
const out: [number, number][] = [];
for (let i = 0; i < RING; i++) {
const want = (i / RING) * total;
let j = 1;
while (j < seg.length && seg[j]! < want) j++;
const a = smoothPath[(j - 1) % smoothPath.length]!;
const b = smoothPath[j % smoothPath.length]!;
const span = seg[j]! - seg[j - 1]! || 1;
const f = (want - seg[j - 1]!) / span;
out.push([a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f]);
}
// ещё один лёгкий проход уже по равномерным точкам: снимает остаточную волну
const even = blur(out, 2, 2);
// выравнивание начала: нулевая точка у всех букв — самая верхняя. Без этого
// контуры стартуют в разных местах и при морфе буква проворачивается
let top = 0;
for (let i = 1; i < even.length; i++) if (even[i]![1] < even[top]![1]) top = i;
return even.slice(top).concat(even.slice(0, top));
}
const outer = ringOf((x, y) => inside(x, y));
const hole = ringOf((x, y) => !inside(x, y) && bg[y * S + x] === 0);
const pts = new Float32Array(RINGS * RING * 2);
const present: number[] = [];
[outer, hole].forEach((ring, k) => {
present.push(ring ? 1 : 0);
for (let i = 0; i < RING; i++) {
const p: [number, number] = ring ? ring[i]! : [S / 2, S / 2];
// в координаты сцены: центр в нуле, кадр буквы примерно 1×1
pts[(k * RING + i) * 2] = (p[0] / S - 0.5) * 1.9;
pts[(k * RING + i) * 2 + 1] = -(p[1] / S - 0.5) * 1.9;
}
});
return { pts, present };
}
/* --------------------------------------------------------------- сцена */
interface Slot {
mesh: THREE.Mesh;
geo: THREE.BufferGeometry;
pos: THREE.BufferAttribute;
nrm: THREE.BufferAttribute;
offset: number;
}
export function createLetterMorph(canvas: HTMLCanvasElement, options: LetterMorphOptions = {}): LetterMorph {
const cfg: Required<LetterMorphOptions> = { ...DEFAULTS, ...options };
const animate = cfg.animate;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(0, 0);
const scene = new THREE.Scene();
const rig = new THREE.Group();
scene.add(rig);
const camera = new THREE.PerspectiveCamera(48, 1, 0.1, 60);
camera.position.set(0, 0, 5.2);
const uniforms = { uTint: { value: cfg.tint } };
const material = new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, uniforms, side: THREE.DoubleSide });
let glyphs: { pts: Float32Array; present: number[] }[] = [];
let slots: Slot[] = [];
function buildGlyphs(): void {
const chars = [...cfg.text].filter((c) => c.trim().length > 0);
glyphs = (chars.length ? chars : ["М"]).map((c) => glyphRings(c, cfg.font));
}
/** Лента обводки: на каждое кольцо 2·RING вершин (внутренний и внешний край). */
function buildSlots(): void {
for (const s of slots) {
rig.remove(s.mesh);
s.geo.dispose();
}
slots = [];
const count = Math.max(1, Math.round(cfg.slots));
for (let k = 0; k < count; k++) {
const verts = RINGS * RING * 2;
const geo = new THREE.BufferGeometry();
const pos = new THREE.BufferAttribute(new Float32Array(verts * 3), 3);
const nrm = new THREE.BufferAttribute(new Float32Array(verts * 3), 3);
const side = new THREE.BufferAttribute(new Float32Array(verts), 1);
const idx: number[] = [];
for (let r = 0; r < RINGS; r++) {
const base = r * RING * 2;
for (let i = 0; i < RING; i++) {
const j = (i + 1) % RING;
const a = base + i * 2;
const b = base + i * 2 + 1;
const c = base + j * 2;
const d = base + j * 2 + 1;
idx.push(a, b, c, b, d, c);
side.setX(a, -1);
side.setX(b, 1);
}
}
geo.setAttribute("position", pos);
geo.setAttribute("normal", nrm);
geo.setAttribute("aSide", side);
geo.setIndex(idx);
const mesh = new THREE.Mesh(geo, material);
mesh.frustumCulled = false;
rig.add(mesh);
slots.push({ mesh, geo, pos, nrm, offset: k / count });
}
}
/* ---- траектория: буквы летят на зрителя, покачиваясь в стороны */
/**
* Половина видимой ширины/высоты кадра на заданной глубине. Нужна, чтобы буква
* СТАРТОВАЛА ЗА КРОМКОЙ: иначе она всплывает посреди кадра из ничего, и вместо
* полёта видно «появление» (жалоба владельца 13.09).
*/
const halfAt = (z: number) => {
const dist = Math.max(0.2, camera.position.z - z);
const hh = Math.tan((camera.fov * Math.PI) / 360) * dist;
return { w: hh * camera.aspect, h: hh };
};
/**
* Три маршрута, у каждого своя драматургия: `across` ведёт цепочку через кадр
* (морф виден сбоку, как строка), `toward` гонит буквы из глубины на зрителя
* (морф виден в лоб, буква растёт), `arc` роняет их дугой из верхнего угла.
*/
const pathAt = (t: number, out: THREE.Vector3, phase = 0) => {
if (cfg.route === "toward") {
// Каждая буква летит по СВОЕЙ спирали: без разной фазы все четыре идут по одной
// оси и сваливаются в узел посреди кадра. Радиус растёт к зрителю, поэтому
// буквы расходятся и проходят мимо камеры по сторонам.
const z = -17 + t * 20;
const ang = phase + t * 0.7;
const r = cfg.sway * (0.35 + 2.4 * t * t);
out.set(Math.cos(ang) * r * 1.5, Math.sin(ang) * r, z);
return out;
}
if (cfg.route === "arc") {
const z = -9 + t * 8;
const half = halfAt(z);
// стартует выше-левее кромки, уходит ниже-правее: дуга, а не прямая
const x = (-1.25 + t * 2.5) * (half.w + 1.6);
const y = (1.2 - t * 2.4) * (half.h + 0.8) + Math.sin(t * Math.PI) * cfg.sway;
out.set(x, y, z);
return out;
}
const z = -5.5 + t * 6.5;
const half = halfAt(z);
// запас в полторы буквы за кромкой: рождение и уход не попадают в кадр
const x = (t - 0.5) * 2 * (half.w + 1.6);
const y = Math.sin(t * Math.PI) * cfg.sway - 0.25;
out.set(x, y, z);
return out;
};
const P = new THREE.Vector3();
const tmp = new THREE.Vector3();
const tmpC = new THREE.Vector3();
/** Точка кольца буквы: интерполяция между двумя буквами, потом укладка на кривую. */
function ringPoint(gi: number, gj: number, mix: number, ring: number, i: number, out: THREE.Vector2): void {
const a = glyphs[gi]!.pts;
const b = glyphs[gj]!.pts;
const k = (ring * RING + i) * 2;
out.set(a[k]! + (b[k]! - a[k]!) * mix, a[k + 1]! + (b[k + 1]! - a[k + 1]!) * mix);
}
const pa = new THREE.Vector2();
const pb = new THREE.Vector2();
const pc = new THREE.Vector2();
let travel = 0;
function updateSlot(s: Slot): void {
const n = glyphs.length;
const raw = (travel + s.offset) % 1;
const t = raw < 0 ? raw + 1 : raw;
// какая буква во что морфится: целая часть — буква, дробная — доля перехода
const f = t * n;
const gi = Math.floor(f) % n;
const gj = (gi + 1) % n;
// переход занимает не весь пролёт, а его середину: букву успевает быть видно
const mix = Math.min(1, Math.max(0, (f - Math.floor(f) - 0.25) / 0.5));
const smooth = mix * mix * (3 - 2 * mix);
pathAt(t, P, s.offset * Math.PI * 2);
// Плоскость буквы РАЗВЁРНУТА К КАМЕРЕ, а не поперёк траектории. Первая версия
// строила базис Френе, и когда путь пошёл вбок, буквы встали к зрителю ребром
// и читались сплющенными. Траектория теперь только ставит букву на место,
// а к зрителю её доворачивает небольшой рыскание по X — от него и глубина.
const yaw = -P.x * 0.055;
const cy2 = Math.cos(yaw);
const sy2 = Math.sin(yaw);
// скрутка намеренно небольшая: выше ~20° буква перестаёт читаться буквой
const ang = ((cfg.twist * Math.PI) / 180) * (t * 2 - 1);
const ca = Math.cos(ang);
const sa = Math.sin(ang);
// масштаб: далеко буква мелкая, у камеры крупная — полёт читается без тумана
// «на зрителя» буква обязана расти сильно: это и есть приближение
const scale = cfg.route === "toward" ? 0.35 + t * 1.5 : 1.0 + t * 0.5;
// буква гаснет толщиной обводки на входе и выходе: путь замкнут, и без этого
// на стыке был бы заметный перескок
// гашение оставлено узким: путь и так начинается и кончается за кадром,
// и длинный «фейд» читался бы как появление из воздуха
const life = Math.min(1, t / 0.04) * Math.min(1, (1 - t) / 0.05);
const w = cfg.weight * 0.12 * (0.7 + t * 0.5) * life;
for (let r = 0; r < RINGS; r++) {
const presentA = glyphs[gi]!.present[r]!;
const presentB = glyphs[gj]!.present[r]!;
// кольцо, которого нет у одной из букв, просто не имеет толщины: у «Т» нет
// дырки, и её кольцо схлопнуто в центр — без этого на месте дырки был бы мусор
const ringW = w * (presentA + (presentB - presentA) * smooth);
for (let i = 0; i < RING; i++) {
ringPoint(gi, gj, smooth, r, i, pa);
ringPoint(gi, gj, smooth, r, (i + 1) % RING, pb);
ringPoint(gi, gj, smooth, r, (i + RING - 1) % RING, pc);
// Нормаль контура как БИССЕКТРИСА двух соседних рёбер, с миттером.
// Простой перпендикуляр к хорде сужает ленту на изломах (у «М», «И» углы
// острые), из-за чего обводка выглядела мятой. Миттер компенсирует сужение,
// но ограничен: на совсем остром угле длинный ус выглядел бы шипом.
const e1x = pa.x - pc.x;
const e1y = pa.y - pc.y;
const e2x = pb.x - pa.x;
const e2y = pb.y - pa.y;
const l1 = Math.hypot(e1x, e1y) || 1;
const l2 = Math.hypot(e2x, e2y) || 1;
const n1x = -e1y / l1;
const n1y = e1x / l1;
const n2x = -e2y / l2;
const n2y = e2x / l2;
let bx = n1x + n2x;
let by = n1y + n2y;
const bl = Math.hypot(bx, by) || 1;
bx /= bl;
by /= bl;
const cosHalf = Math.max(0.42, bx * n1x + by * n1y);
const miter = 1 / cosHalf;
// На резком повороте внутренняя кромка ленты перехлёстывает саму себя
// (радиус поворота меньше полуширины) — там лента сужается, как перо.
const turn = Math.acos(Math.min(1, Math.max(-1, n1x * n2x + n1y * n2y)));
const narrow = Math.min(1, Math.max(0.4, 1 - turn * 0.75));
const nx = bx * miter * narrow;
const ny = by * miter * narrow;
for (let e = 0; e < 2; e++) {
const sgn = e === 0 ? -1 : 1;
const lx = (pa.x + nx * sgn * (ringW / scale)) * scale;
const ly = (pa.y + ny * sgn * (ringW / scale)) * scale;
// укладка на кривую: плоскость буквы — это N и B, скрутка вращает её
const rx = lx * ca - ly * sa;
const ry = lx * sa + ly * ca;
tmp.set(P.x + rx * cy2, P.y + ry, P.z - rx * sy2);
const vi = (r * RING + i) * 2 + e;
s.pos.setXYZ(vi, tmp.x, tmp.y, tmp.z);
// нормаль вершины: направление контура, вынесенное в 3D
const mx = nx * ca - ny * sa;
const my = nx * sa + ny * ca;
tmpC.set(mx * cy2, my, -mx * sy2).normalize();
s.nrm.setXYZ(vi, tmpC.x, tmpC.y, tmpC.z);
}
}
}
s.pos.needsUpdate = true;
s.nrm.needsUpdate = true;
}
/* ---- курсор */
const pointer = { x: 0, y: 0 };
const onPointerMove = (e: PointerEvent) => {
if (!animate) return;
const r = canvas.getBoundingClientRect();
pointer.x = (e.clientX - r.left) / r.width - 0.5;
pointer.y = (e.clientY - r.top) / r.height - 0.5;
};
canvas.addEventListener("pointermove", onPointerMove);
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();
}
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;
travel = (travel + dt * cfg.speed) % 1;
uniforms.uTint.value = cfg.tint;
rig.rotation.y += (pointer.x * cfg.tilt * 0.5 - rig.rotation.y) * 0.05;
rig.rotation.x += (-pointer.y * cfg.tilt * 0.3 - rig.rotation.x) * 0.05;
for (const s of slots) updateSlot(s);
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;
};
buildGlyphs();
buildSlots();
resize();
// стоячий кадр ставим не в начало пути, а в середину: видно и букву, и полёт
travel = animate ? 0 : 0.42;
frame();
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);
start();
return {
set(next) {
// undefined в пропсах React — «не задано», а не «сбросить»
const patch = Object.fromEntries(Object.entries(next).filter(([, v]) => v !== undefined)) as Partial<LetterMorphOptions>;
const reglyph =
(patch.text !== undefined && patch.text !== cfg.text) || (patch.font !== undefined && patch.font !== cfg.font);
const reslot = patch.slots !== undefined && Math.round(patch.slots) !== Math.round(cfg.slots);
const reroute = patch.route !== undefined && patch.route !== cfg.route;
Object.assign(cfg, patch);
if (reglyph) buildGlyphs();
if (reslot) buildSlots();
if (!animate || reglyph || reslot || reroute) frame();
},
destroy() {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("webglcontextlost", onLost);
for (const s of slots) s.geo.dispose();
material.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}