Studio Floor
Предмет стоит на полированном полу студии: под ним живое отражение, контактная тень и свет софтбокса, курсор поворачивает предмет. Отражение не постобработка и не картинка: под полом рисуется вторая, зеркальная копия предмета, и она гаснет с расстоянием от точки касания — так ведёт себя настоящий полированный пол. Пол лежит поверх отражения полупрозрачным стеклом, поэтому кромка кадра темнеет и появляется горизонт. Форма предмета переключается: узел, гранёный камень или капсула.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/studio-floor.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 { createStudioFloor, type StudioFloor as Engine, type StudioFloorOptions } from "@/lib/studio-floor-engine";
export type StudioFloorProps = Omit<StudioFloorOptions, "animate"> & {
/** Подложка под холст: CSS `background` или `false`, если её задаёт секция. */
backdrop?: string | false;
};
const STUDIO =
"radial-gradient(110% 90% at 50% 8%, oklch(0.28 0.02 265) 0%, oklch(0.16 0.015 262) 52%, oklch(0.09 0.01 258) 100%)";
/**
* Studio Floor — предмет на полированном полу студии: под ним живое отражение,
* контактная тень и студийный свет, курсор поворачивает предмет.
*
* Сцена — `createStudioFloor` из соседнего файла; компонент держит холст, прокидывает
* крутилки через `set()` и разбирает всё при размонтировании.
* `prefers-reduced-motion` и `data-shot` рисуют один стоячий кадр.
*/
export function StudioFloor({ backdrop = STUDIO, ...options }: StudioFloorProps = {}) {
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 = createStudioFloor(canvas, { ...first.current, animate });
return () => {
engine.current?.destroy();
engine.current = null;
};
}, []);
const { shape, tint, gloss, reflection, falloff, shadow, spin, tilt } = options;
useEffect(() => {
engine.current?.set({ shape, tint, gloss, reflection, falloff, shadow, spin, tilt });
}, [shape, tint, gloss, reflection, falloff, shadow, 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";
/**
* Studio Floor — движок на голом three.js (без React). Предмет стоит на полированном
* полу студии: под ним живое ОТРАЖЕНИЕ и контактная тень, свет студийный, курсор
* поворачивает предмет. Отражение здесь не постобработка и не картинка: рисуется
* ВТОРАЯ, зеркальная копия предмета под полом, и она гаснет с расстоянием — так
* работает полированный пол в продуктовом рендере.
*
* `createStudioFloor(canvas, options)` → { set(options), destroy() }.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `animate: false`
* (reduced-motion / `data-shot`) рисует один стоячий кадр.
*/
export interface StudioFloorOptions {
/** Предмет на подиуме. */
shape?: "knot" | "gem" | "capsule";
/** Тон металла, 0–1 по кругу. */
tint?: number;
/** Полировка: выше — резче блики. */
gloss?: number;
/** Сила отражения в полу. */
reflection?: number;
/** Как быстро отражение гаснет с расстоянием. */
falloff?: number;
/** Плотность контактной тени. */
shadow?: number;
/** Скорость собственного вращения; 0 — стоит. */
spin?: number;
/** Отклик на курсор. */
tilt?: number;
/** false — один стоячий кадр (reduced-motion, скриншоты). */
animate?: boolean;
}
export interface StudioFloor {
set(next: Partial<StudioFloorOptions>): void;
destroy(): void;
}
const DEFAULTS: Required<StudioFloorOptions> = {
shape: "knot",
tint: 0.6,
gloss: 1,
reflection: 0.68,
falloff: 0.45,
shadow: 0.7,
spin: 0.35,
tilt: 0.5,
animate: true,
};
const OBJ_VERT = /* glsl */ `
varying vec3 vNormal;
varying vec3 vView;
varying float vDrop;
uniform float uMirror;
void main() {
vec4 world = modelMatrix * vec4(position, 1.0);
// глубина под полом нужна отражению: по ней оно и гаснет
vDrop = uMirror > 0.5 ? max(0.0, -world.y) : 0.0;
vec4 mv = viewMatrix * world;
vNormal = normalize(normalMatrix * normal);
vView = -mv.xyz;
gl_Position = projectionMatrix * mv;
}
`;
/** Студийное окружение как функция направления: тёмный пол, светлый софтбокс сверху. */
const OBJ_FRAG = /* glsl */ `
precision highp float;
uniform float uTint;
uniform float uGloss;
uniform float uMirror;
uniform float uReflection;
uniform float uFalloff;
varying vec3 vNormal;
varying vec3 vView;
varying float vDrop;
vec3 env(vec3 r) {
vec3 floorC = vec3(0.04, 0.045, 0.06);
vec3 ceilC = vec3(0.72, 0.82, 0.98);
vec3 warm = vec3(1.0, 0.86, 0.66);
float hue = uTint * 6.28318;
ceilC = mix(ceilC, vec3(0.98, 0.94, 0.82), 0.5 + 0.5 * sin(hue));
warm = mix(warm, vec3(0.66, 0.56, 0.96), 0.5 + 0.5 * cos(hue));
float y = r.y;
vec3 sky = mix(floorC, ceilC, smoothstep(-0.12, 0.35, y));
sky = mix(sky, warm, smoothstep(0.0, -0.75, y) * 0.55);
// софтбокс: широкая светлая полоса сверху, от неё и главный блик
sky += vec3(1.0, 0.99, 0.96) * smoothstep(0.55, 0.95, y) * 0.9;
sky *= 1.0 - 0.4 * exp(-abs(y) * 12.0);
float lamp = pow(max(dot(r, normalize(vec3(-0.35, 0.85, 0.4))), 0.0), 20.0 * uGloss);
return sky + vec3(1.0, 0.97, 0.92) * lamp * 1.2;
}
void main() {
vec3 n = normalize(vNormal);
vec3 v = normalize(vView);
vec3 col = env(reflect(-v, n));
float fres = pow(1.0 - max(dot(n, v), 0.0), 2.6);
col += fres * vec3(0.72, 0.84, 1.0) * 0.4;
col = vec3(1.0) - exp(-col * 1.25);
float alpha = 1.0;
if (uMirror > 0.5) {
// отражение гаснет с глубиной: чем дальше от точки касания, тем слабее
alpha = uReflection * exp(-vDrop * (1.2 + uFalloff * 4.0));
col *= 0.85;
}
gl_FragColor = vec4(col, alpha);
}
`;
const FLOOR_VERT = /* glsl */ `
varying vec2 vLocal;
void main() {
vLocal = position.xy;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
/** Пол: тонированное стекло поверх отражения плюс мягкая контактная тень. */
const FLOOR_FRAG = /* glsl */ `
precision highp float;
uniform float uShadow;
uniform float uTint;
varying vec2 vLocal;
void main() {
float d = length(vLocal * vec2(1.0, 1.6));
// контактная тень: плотная у точки касания и быстро тающая
float shade = exp(-d * d * 1.6) * uShadow;
// сам пол слегка тонирован и темнеет к краям — иначе горизонта не видно
float hue = uTint * 6.28318;
vec3 floorC = mix(vec3(0.05, 0.055, 0.075), vec3(0.06, 0.05, 0.08), 0.5 + 0.5 * sin(hue));
float vign = smoothstep(4.5, 1.0, d);
vec3 col = floorC * (0.5 + 0.5 * vign);
float alpha = clamp(0.42 + shade * 0.75 + (1.0 - vign) * 0.35, 0.0, 1.0);
gl_FragColor = vec4(col * (1.0 - shade * 0.6), alpha);
}
`;
function shapeGeometry(shape: string): THREE.BufferGeometry {
if (shape === "gem") return new THREE.IcosahedronGeometry(1.05, 0);
if (shape === "capsule") return new THREE.CapsuleGeometry(0.6, 1.0, 18, 32);
return new THREE.TorusKnotGeometry(0.78, 0.26, 190, 26);
}
export function createStudioFloor(canvas: HTMLCanvasElement, options: StudioFloorOptions = {}): StudioFloor {
const cfg: Required<StudioFloorOptions> = { ...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 camera = new THREE.PerspectiveCamera(40, 1, 0.1, 60);
// Камера отодвинута и смотрит выше, чем кажется нужным: предмет теперь САДИТСЯ
// на пол, поэтому его верх поднялся на прежний зазор и упирался в кромку кадра.
camera.position.set(0, 1.5, 7.1);
camera.lookAt(0, 0.78, 0);
const shared = {
uTint: { value: cfg.tint },
uGloss: { value: cfg.gloss },
uReflection: { value: cfg.reflection },
uFalloff: { value: cfg.falloff },
};
const objMat = new THREE.ShaderMaterial({
vertexShader: OBJ_VERT,
fragmentShader: OBJ_FRAG,
uniforms: { ...shared, uMirror: { value: 0 } },
});
const mirrorMat = new THREE.ShaderMaterial({
vertexShader: OBJ_VERT,
fragmentShader: OBJ_FRAG,
uniforms: { ...shared, uMirror: { value: 1 } },
transparent: true,
depthWrite: false,
side: THREE.BackSide, // зеркальная копия вывернута, поэтому рисуем обратные грани
});
const floorGeo = new THREE.PlaneGeometry(16, 16, 1, 1);
const floorMat = new THREE.ShaderMaterial({
vertexShader: FLOOR_VERT,
fragmentShader: FLOOR_FRAG,
uniforms: { uShadow: { value: cfg.shadow }, uTint: { value: cfg.tint } },
transparent: true,
depthWrite: false,
});
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.renderOrder = 1;
scene.add(floor);
let geo: THREE.BufferGeometry | null = null;
let obj: THREE.Mesh | null = null;
let mirror: THREE.Mesh | null = null;
/**
* Опора считается ПО ГЕОМЕТРИИ, а не задаётся числом. С постоянной высотой
* вращающийся предмет то висит, то уходит ниже плоскости пола, и тогда он
* читается утопленным: полупрозрачный пол не режет то, что под ним, а лишь
* подкрашивает, и на кромке появляется «ватерлиния».
*
* Ограничивающая коробка для этого не годится — у тора её углы далеко от
* поверхности, и предмет всплывает на зазор. Берём разреженное облако самих
* вершин и ищем нижнюю точку после поворота.
*/
const CONTACT = 0.012; // волосок зазора, чтобы контактная тень читалась
let hull = new Float32Array(0);
function buildHull(g: THREE.BufferGeometry): void {
const pos = g.getAttribute("position") as THREE.BufferAttribute;
const stride = Math.max(1, Math.floor(pos.count / 900));
const n = Math.floor(pos.count / stride);
hull = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
const j = i * stride;
hull[i * 3] = pos.getX(j);
hull[i * 3 + 1] = pos.getY(j);
hull[i * 3 + 2] = pos.getZ(j);
}
}
const basis = new THREE.Matrix4();
const vtx = new THREE.Vector3();
/** Нижняя точка повёрнутого предмета — она и садится на пол. */
function lowest(rot: THREE.Euler): number {
basis.makeRotationFromEuler(rot);
let min = Infinity;
for (let i = 0; i < hull.length; i += 3) {
vtx.set(hull[i]!, hull[i + 1]!, hull[i + 2]!).applyMatrix4(basis);
if (vtx.y < min) min = vtx.y;
}
return min === Infinity ? 0 : min;
}
function build(): void {
if (obj) scene.remove(obj);
if (mirror) scene.remove(mirror);
geo?.dispose();
geo = shapeGeometry(cfg.shape);
buildHull(geo);
obj = new THREE.Mesh(geo, objMat);
obj.position.y = -lowest(obj.rotation) + CONTACT;
obj.renderOrder = 2;
scene.add(obj);
// зеркальная копия: та же геометрия, отражённая относительно пола
mirror = new THREE.Mesh(geo, mirrorMat);
mirror.position.y = -obj.position.y;
mirror.scale.set(1, -1, 1);
mirror.renderOrder = 0;
scene.add(mirror);
}
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);
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;
let time = animate ? 0 : 1.6;
function frame(now = 0): void {
const dt = animate && last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
time += dt * cfg.spin;
pointer.x += (pointer.tx - pointer.x) * 0.05;
pointer.y += (pointer.ty - pointer.y) * 0.05;
shared.uTint.value = cfg.tint;
shared.uGloss.value = cfg.gloss;
shared.uReflection.value = cfg.reflection;
shared.uFalloff.value = cfg.falloff;
floorMat.uniforms.uShadow!.value = cfg.shadow;
floorMat.uniforms.uTint!.value = cfg.tint;
if (obj && mirror) {
const ry = time * 0.9 + pointer.x * cfg.tilt * 2.2;
const rx = Math.sin(time * 0.5) * 0.2 - pointer.y * cfg.tilt * 0.9;
obj.rotation.set(rx, ry, 0);
// отражение обязано повторять поворот ТОЧНО, иначе пол читается фальшивым
mirror.rotation.set(-rx, ry, 0);
// предмет садится на пол при ЛЮБОМ повороте, дыхание идёт уже поверх опоры
obj.position.y = -lowest(obj.rotation) + CONTACT + (Math.sin(time * 0.8) * 0.5 + 0.5) * 0.05;
mirror.position.y = -obj.position.y;
}
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;
};
build();
resize();
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<StudioFloorOptions>;
const reshape = patch.shape !== undefined && patch.shape !== cfg.shape;
Object.assign(cfg, patch);
if (reshape) build();
if (!animate || reshape) 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();
objMat.dispose();
mirrorMat.dispose();
floorGeo.dispose();
floorMat.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}