Anamorph Box
Приём анаморфных билбордов, тех самых «naked-eye 3D» экранов на углу дома: в кадре стоит ящик, его переднее отверстие точно совпадает с рамкой экрана, а радужные шары внутри выходят наружу — и тот, что вышел за переднюю плоскость, рисуется поверх рамки. Вся иллюзия держится на проекции: камера ходит за курсором, но матрица пересчитывается так, что отверстие остаётся приколоченным к одному прямоугольнику кадра (несимметричная перспектива, та же математика, что у оконных дисплеев). Обычная перспектива этого не даёт — при сдвиге камеры отверстие уезжает вместе со сценой. Перекрытие не нарисовано, а посчитано буфером глубины. Плёнка на шарах — настоящая интерференция, поэтому радуга поворачивается вместе с поверхностью, а не налеплена текстурой.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/anamorph-box.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 { createAnamorphBox, type AnamorphBox as Engine, type AnamorphBoxOptions } from "@/lib/anamorph-box-engine";
export type AnamorphBoxProps = Omit<AnamorphBoxOptions, "animate"> & {
/** Подложка под холст: CSS `background` или `false`, если её задаёт секция. */
backdrop?: string | false;
};
const ROOM =
"radial-gradient(120% 90% at 50% 18%, oklch(0.24 0.02 265) 0%, oklch(0.14 0.015 262) 55%, oklch(0.08 0.01 258) 100%)";
/**
* Anamorph Box — приём анаморфных билбордов: в кадре стоит ящик, его отверстие
* совпадает с рамкой экрана, а существо внутри вылезает наружу и рисуется поверх
* рамки. Курсор двигает проекцию, а рамка стоит на месте — отсюда глубина.
*
* Сцена — `createAnamorphBox` из соседнего файла; компонент держит холст, прокидывает
* крутилки через `set()` и разбирает всё при размонтировании.
* `prefers-reduced-motion` и `data-shot` рисуют один стоячий кадр.
*/
export function AnamorphBox({ backdrop = ROOM, ...options }: AnamorphBoxProps = {}) {
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 = createAnamorphBox(canvas, { ...first.current, animate });
return () => {
engine.current?.destroy();
engine.current = null;
};
}, []);
const { frame, depth, thrust, speed, size, iridescence, glow, parallax } = options;
useEffect(() => {
engine.current?.set({ frame, depth, thrust, speed, size, iridescence, glow, parallax });
}, [frame, depth, thrust, speed, size, iridescence, glow, parallax]);
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";
/**
* Anamorph Box — движок на голом three.js (без React). Приём анаморфных билбордов
* («naked-eye 3D»): в кадре стоит ЯЩИК, его переднее отверстие точно совпадает
* с рамкой экрана, а существо внутри вылезает наружу — и та часть, что вышла
* за переднюю плоскость, рисуется ПОВЕРХ рамки.
*
* Вся иллюзия держится на проекции. Камера ходит за курсором, но матрица проекции
* пересчитывается так, что отверстие ящика остаётся приколоченным к одному и тому же
* прямоугольнику кадра (несимметричная перспектива, та же математика, что у оконных
* и CAVE-дисплеев). Поэтому глубина внутри ящика ведёт себя как настоящая: стенки
* расходятся при движении, а рамка стоит на месте. Обычная симметричная перспектива
* этого не даёт — при сдвиге камеры отверстие уезжает вместе со сценой, и «экран»
* перестаёт быть экраном.
*
* Перекрытие не нарисовано, а посчитано буфером глубины: рамка лежит в плоскости
* отверстия, поэтому тело существа за ней прячется, а голова перед ней — нет.
*
* `createAnamorphBox(canvas, options)` → { set(options), destroy() }.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `animate: false`
* (reduced-motion / `data-shot`) рисует один стоячий кадр.
*/
export interface AnamorphBoxOptions {
/** Высота отверстия в долях кадра. */
frame?: number;
/** Глубина ящика. */
depth?: number;
/** Насколько далеко существо высовывается за рамку. */
thrust?: number;
/** Скорость цикла. */
speed?: number;
/** Размер шара. */
size?: number;
/** Насыщенность радуги на чешуе. */
iridescence?: number;
/** Свечение вокруг вышедшей части. */
glow?: number;
/** Отклик проекции на курсор — сама суть приёма. */
parallax?: number;
/** false — один стоячий кадр (reduced-motion, скриншоты). */
animate?: boolean;
}
export interface AnamorphBox {
set(next: Partial<AnamorphBoxOptions>): void;
destroy(): void;
}
const DEFAULTS = {
frame: 0.68,
depth: 0.6,
thrust: 0.7,
speed: 0.5,
size: 0.62,
iridescence: 0.8,
glow: 0.7,
parallax: 0.7,
animate: true,
};
type Config = typeof DEFAULTS;
/** Отверстие: половина ширины и высоты в мире (пропорция билборда). */
const OPEN_H = 1.1;
const OPEN_W = OPEN_H * 1.45;
/** Расстояние от зрителя до плоскости отверстия. */
const VIEW_D = 6;
const WALL_VERT = /* glsl */ `
varying vec3 vLocal;
varying vec3 vNormal;
void main() {
vLocal = position;
vNormal = normalize(normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
/** Стенки: свет падает из отверстия, поэтому глубина ящика читается затемнением. */
const WALL_FRAG = /* glsl */ `
precision highp float;
uniform float uDepth;
uniform vec3 uTint;
varying vec3 vLocal;
varying vec3 vNormal;
void main() {
// 0 у отверстия, 1 у задней стенки
float k = clamp(-vLocal.z / max(0.001, uDepth), 0.0, 1.0);
float fall = mix(0.42, 0.04, pow(k, 0.6));
// задняя стенка держит сетку панели: без неё дно ящика читается пустым пятном
float grid = 0.0;
if (abs(vNormal.z) > 0.5) {
vec2 g = abs(fract(vLocal.xy * 3.0) - 0.5);
grid = smoothstep(0.46, 0.5, max(g.x, g.y)) * 0.22;
}
// боковые стенки чуть светлее у кромки — так виден сам излом ящика
float edge = abs(vNormal.z) > 0.5 ? 0.0 : pow(1.0 - k, 8.0) * 0.18;
vec3 col = uTint * (0.02 + fall + grid * fall + edge);
gl_FragColor = vec4(col, 1.0);
}
`;
const BEZEL_VERT = /* glsl */ `
varying vec2 vLocal;
void main() {
vLocal = position.xy;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
/** Рамка экрана: яркая линия по кромке отверстия и мягкий сход в страницу. */
const BEZEL_FRAG = /* glsl */ `
precision highp float;
uniform vec2 uOpen;
uniform float uWidth;
uniform vec3 uTint;
varying vec2 vLocal;
void main() {
// расстояние до кромки отверстия по прямоугольной метрике
vec2 d = abs(vLocal) - uOpen;
float out_ = max(d.x, d.y);
float t = clamp(out_ / max(0.001, uWidth), 0.0, 1.0);
float line = exp(-t * 14.0);
float body = pow(1.0 - t, 1.6);
vec3 col = uTint * (0.06 + line * 0.9);
float a = clamp(body * 0.7 + line * 0.9, 0.0, 1.0);
// цвет уже умножен на альфу: премультиплированный материал
gl_FragColor = vec4(col * a, a);
}
`;
const ORB_VERT = /* glsl */ `
varying vec3 vNormal;
varying vec3 vView;
varying vec3 vWorld;
varying vec3 vLocal;
void main() {
vLocal = normalize(position);
vec4 world = modelMatrix * vec4(position, 1.0);
vWorld = world.xyz;
vNormal = normalize(mat3(modelMatrix) * normal);
vView = normalize(cameraPosition - world.xyz);
gl_Position = projectionMatrix * viewMatrix * world;
}
`;
/**
* Оболочка сферы: настоящая интерференция в тонкой плёнке (та же физика, что
* у записи `iridescence`). Толщина хода луча растёт к скользящему углу — делим
* на косинус, — а фаза для каждого канала считается по своей длине волны. Поэтому
* радуга ПОВОРАЧИВАЕТСЯ вместе с шаром, а не налеплена текстурой.
*/
const ORB_FRAG = /* glsl */ `
precision highp float;
uniform vec3 uLight;
uniform float uIrid;
uniform float uOpenZ;
uniform float uSeed;
varying vec3 vNormal;
varying vec3 vView;
varying vec3 vWorld;
varying vec3 vLocal;
void main() {
vec3 n = normalize(vNormal);
vec3 v = normalize(vView);
// Косинус ограничен снизу щедро: полный 1/cos у силуэта даёт десятки кольцевых
// полос, и шар читается ракушкой, а не плёнкой.
float facing = max(0.34, dot(n, v));
// толщина плёнки гуляет крупными разводами по самой сфере
float swirl = 0.72 + 0.28 * sin(vLocal.x * 2.4 + uSeed) * sin(vLocal.y * 2.1 - uSeed * 1.3);
float thick = swirl / facing;
vec3 lambda = vec3(0.612, 0.549, 0.464);
vec3 fringe = 0.5 + 0.5 * cos(vec3(6.28318) * thick * (0.8 + uIrid * 1.5) / lambda);
// У самого силуэта полосы сходятся в частокол, и кромка шара рябит муаром.
// Там плёнка отдаётся перламутру: контраст полос гасится к краю.
fringe = mix(vec3(0.66, 0.68, 0.76), fringe, smoothstep(0.34, 0.62, facing));
float key = max(0.0, dot(n, normalize(uLight)));
float spec = pow(max(0.0, dot(reflect(-normalize(uLight), n), v)), 64.0);
float rim = pow(1.0 - facing, 3.0);
vec3 base = mix(vec3(0.16, 0.19, 0.26), fringe, 0.4 + uIrid * 0.55);
vec3 col = base * (0.18 + key * 0.9) + fringe * rim * 0.7 + vec3(spec) * 1.1;
// вышедшая за рамку часть светлее: она «на свету страницы», а не в ящике
col *= 1.0 + smoothstep(-0.1, 0.7, vWorld.z - uOpenZ) * 0.35;
gl_FragColor = vec4(col, 1.0);
}
`;
/** Свечение: та же сфера, раздутая; горит только то, что вышло за рамку. */
const HALO_FRAG = /* glsl */ `
precision highp float;
uniform float uGlow;
uniform float uOpenZ;
varying vec3 vNormal;
varying vec3 vView;
varying vec3 vWorld;
void main() {
float face = max(0.0, dot(normalize(vNormal), normalize(vView)));
float outside = smoothstep(-0.05, 0.55, vWorld.z - uOpenZ);
float a = pow(face, 2.6) * uGlow * outside;
gl_FragColor = vec4(vec3(0.6, 0.78, 1.0) * a, a);
}
`;
/** Шар в ящике: у каждого своя орбита, фаза и размер. */
interface Orb {
mesh: THREE.Mesh;
halo: THREE.Mesh;
size: number;
phase: number;
lead: boolean;
}
export function createAnamorphBox(canvas: HTMLCanvasElement, options: AnamorphBoxOptions = {}): AnamorphBox {
const cfg: Config = { ...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(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
// проекцию считаем сами, поэтому обычные fov/aspect камеры не участвуют
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 60);
camera.position.set(0, 0, VIEW_D);
/* ------------------------------------------------------------- ящик */
const boxDepth = () => 0.9 + cfg.depth * 3.4;
const wallGeo = new THREE.BoxGeometry(OPEN_W * 2, OPEN_H * 2, 1);
const wallMat = new THREE.ShaderMaterial({
vertexShader: WALL_VERT,
fragmentShader: WALL_FRAG,
uniforms: { uDepth: { value: boxDepth() }, uTint: { value: new THREE.Vector3(0.3, 0.36, 0.58) } },
side: THREE.BackSide,
});
const walls = new THREE.Mesh(wallGeo, wallMat);
scene.add(walls);
/* ------------------------------------------------------------ рамка */
const bezelWidth = 0.3;
const shape = new THREE.Shape();
shape.moveTo(-OPEN_W - bezelWidth, -OPEN_H - bezelWidth);
shape.lineTo(OPEN_W + bezelWidth, -OPEN_H - bezelWidth);
shape.lineTo(OPEN_W + bezelWidth, OPEN_H + bezelWidth);
shape.lineTo(-OPEN_W - bezelWidth, OPEN_H + bezelWidth);
shape.closePath();
const hole = new THREE.Path();
hole.moveTo(-OPEN_W, -OPEN_H);
hole.lineTo(-OPEN_W, OPEN_H);
hole.lineTo(OPEN_W, OPEN_H);
hole.lineTo(OPEN_W, -OPEN_H);
hole.closePath();
shape.holes.push(hole);
const bezelGeo = new THREE.ShapeGeometry(shape, 1);
const bezelMat = new THREE.ShaderMaterial({
vertexShader: BEZEL_VERT,
fragmentShader: BEZEL_FRAG,
uniforms: {
uOpen: { value: new THREE.Vector2(OPEN_W, OPEN_H) },
uWidth: { value: bezelWidth },
uTint: { value: new THREE.Vector3(0.5, 0.6, 0.9) },
},
transparent: true,
premultipliedAlpha: true,
});
const bezel = new THREE.Mesh(bezelGeo, bezelMat);
scene.add(bezel);
/* ------------------------------------------------------------ шары */
const orbGeo = new THREE.SphereGeometry(1, 64, 48);
const orbShared = {
uLight: { value: new THREE.Vector3(-0.4, 0.75, 0.6).normalize() },
uOpenZ: { value: 0 },
};
const orbs: Orb[] = [];
// ведущий шар выходит за рамку, остальные живут в глубине и держат объём
const plan: { size: number; phase: number; lead: boolean }[] = [
{ size: 1, phase: 0, lead: true },
{ size: 0.42, phase: 1.7, lead: false },
{ size: 0.3, phase: 3.1, lead: false },
{ size: 0.22, phase: 4.6, lead: false },
{ size: 0.16, phase: 5.9, lead: false },
];
for (const p of plan) {
const mat = new THREE.ShaderMaterial({
vertexShader: ORB_VERT,
fragmentShader: ORB_FRAG,
uniforms: { ...orbShared, uIrid: { value: cfg.iridescence }, uSeed: { value: p.phase * 2.3 } },
});
const halo = new THREE.ShaderMaterial({
vertexShader: ORB_VERT,
fragmentShader: HALO_FRAG,
uniforms: { ...orbShared, uGlow: { value: cfg.glow } },
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
premultipliedAlpha: true,
});
const mesh = new THREE.Mesh(orbGeo, mat);
const hmesh = new THREE.Mesh(orbGeo, halo);
hmesh.renderOrder = 2;
scene.add(mesh, hmesh);
orbs.push({ mesh, halo: hmesh, size: p.size, phase: p.phase, lead: p.lead });
}
/**
* Раскладка шаров. Ведущий по циклу выходит ВПЕРЁД И К КРОМКЕ: иллюзия работает,
* когда предмет перелезает через рамку, а не выходит ровно по центру.
*/
function place(t: number): void {
const d = boxDepth();
for (const o of orbs) {
const r = (0.24 + cfg.size * 0.55) * o.size;
const u = t + o.phase;
if (o.lead) {
const cycle = (t * 0.16) % 1;
const out = Math.max(0, Math.sin(Math.PI * Math.min(1, Math.max(0, (cycle - 0.1) / 0.45))));
const z = -d * 0.55 + (d * 0.55 + cfg.thrust * 2.2 + r) * out;
// Уход к кромке считан по проекции: на пике центр шара должен встать
// ровно на кромку отверстия, тогда половина его ложится на рамку. Больше —
// и шар просто улетает из кадра, иллюзия исчезает вместе с ним.
const spread = 1 + out * 0.45;
o.mesh.position.set(
Math.sin(u * 0.5) * OPEN_W * 0.42 * spread,
Math.cos(u * 0.37) * OPEN_H * 0.34 * spread,
z,
);
} else {
o.mesh.position.set(
Math.sin(u * 0.43) * OPEN_W * 0.62,
Math.cos(u * 0.51) * OPEN_H * 0.55,
-d * (0.25 + 0.6 * (0.5 + 0.5 * Math.sin(u * 0.29))),
);
}
o.mesh.scale.setScalar(r);
o.halo.position.copy(o.mesh.position);
o.halo.scale.setScalar(r * 1.5);
(o.mesh.material as THREE.ShaderMaterial).uniforms.uIrid!.value = cfg.iridescence;
(o.halo.material as THREE.ShaderMaterial).uniforms.uGlow!.value = cfg.glow * 0.85;
}
}
/* ------------------------------------------------------------- ввод */
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 aspect = 1;
function resize(): void {
const w = canvas.clientWidth || window.innerWidth;
const h = canvas.clientHeight || window.innerHeight;
renderer.setSize(w, h, false);
aspect = w / h;
}
resize();
/**
* Несимметричная перспектива, привязанная к рамке. Полный кадр — это прямоугольник
* в плоскости отверстия, в котором само отверстие занимает долю `frame` по высоте.
* Отсюда главное свойство: камера ходит, а рамка стоит.
*/
function project(): void {
const near = 0.1;
const far = 60;
const fullH = OPEN_H / Math.max(0.2, cfg.frame);
const fullW = fullH * aspect;
const cx = camera.position.x;
const cy = camera.position.y;
const s = near / VIEW_D;
camera.projectionMatrix.makePerspective(
(-fullW - cx) * s,
(fullW - cx) * s,
(fullH - cy) * s,
(-fullH - cy) * s,
near,
far,
);
camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
}
let raf = 0;
let last = 0;
let time = animate ? 0 : 1.9;
function frame(now = 0): void {
const dt = animate && last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
time += dt * cfg.speed;
pointer.x += (pointer.tx - pointer.x) * 0.06;
pointer.y += (pointer.ty - pointer.y) * 0.06;
camera.position.set(pointer.x * cfg.parallax * 2.2, pointer.y * cfg.parallax * 1.4, VIEW_D);
camera.updateMatrixWorld();
project();
const d = boxDepth();
walls.scale.z = d;
walls.position.z = -d / 2;
wallMat.uniforms.uDepth!.value = d;
place(time);
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<AnamorphBoxOptions>;
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);
wallGeo.dispose();
wallMat.dispose();
bezelGeo.dispose();
bezelMat.dispose();
orbGeo.dispose();
for (const o of orbs) {
(o.mesh.material as THREE.Material).dispose();
(o.halo.material as THREE.Material).dispose();
}
renderer.dispose();
renderer.forceContextLoss();
},
};
}