Halftone Scene
Живая 3D-сцена выведена полиграфическим растром: точками переменного размера, штриховкой или ASCII-глифами. Растрируется не картинка, а кадр сцены — свет ходит за курсором, форма дышит, и узор пересчитывается каждый кадр вместе с ними. Поэтому точка здесь означает свет на форме, а не яркость пикселя готового снимка. Яркость берётся в центре клетки повёрнутого растра, как в типографском экране; площадь точки пропорциональна темноте. Переключаются способ растра, предмет, шаг и угол экрана, краска и бумага.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/halftone-scene.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 { createHalftoneScene, type HalftoneScene as Engine, type HalftoneSceneOptions } from "@/lib/halftone-scene-engine";
export type HalftoneSceneProps = Omit<HalftoneSceneOptions, "animate"> & {
/** Подложка под холст: CSS `background` или `false`, если её задаёт секция. */
backdrop?: string | false;
};
/** Подложка = бумага выбранной палитры: холст растрирует кадр целиком, кромка не должна спорить. */
const PAPERS: Record<"print" | "neon" | "ash", string> = {
print: "oklch(0.89 0.02 85)",
neon: "oklch(0.16 0.02 250)",
ash: "oklch(0.17 0.005 270)",
};
/**
* Halftone Scene — живая 3D-сцена, выведенная полиграфическим растром: точки,
* штриховка или ASCII. Свет ходит за курсором, форма дышит, узор пересчитывается
* вместе с ними.
*
* Сцена — `createHalftoneScene` из соседнего файла; компонент держит холст, прокидывает
* крутилки через `set()` и разбирает всё при размонтировании.
* `prefers-reduced-motion` и `data-shot` рисуют один стоячий кадр.
*/
export function HalftoneScene({ backdrop, ...options }: HalftoneSceneProps = {}) {
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 = createHalftoneScene(canvas, { ...first.current, animate });
return () => {
engine.current?.destroy();
engine.current = null;
};
}, []);
const { mode, shape, cell, angle, contrast, palette, speed, light } = options;
useEffect(() => {
engine.current?.set({ mode, shape, cell, angle, contrast, palette, speed, light });
}, [mode, shape, cell, angle, contrast, palette, speed, light]);
const ground = backdrop === undefined ? PAPERS[options.palette ?? "print"] : backdrop;
return (
<div
style={{
position: "absolute",
inset: 0,
overflow: "hidden",
...(ground === false ? null : { background: ground }),
}}
>
<canvas ref={canvasRef} aria-hidden="true" style={{ display: "block", width: "100%", height: "100%" }} />
</div>
);
}
import * as THREE from "three";
/**
* Halftone Scene — движок на голом three.js (без React). Живая 3D-сцена рисуется
* в свой буфер, а на экран попадает ПОЛИГРАФИЧЕСКИМ РАСТРОМ: точками переменного
* размера, штриховкой или ASCII-глифами.
*
* Отличие от растра по фотографии (`ascii-image`): растрируется не картинка, а кадр
* живой сцены — свет ходит за курсором, форма дышит, и узор пересчитывается каждый
* кадр вместе с ней. Поэтому точка здесь означает СВЕТ НА ФОРМЕ, а не яркость пикселя
* готового снимка.
*
* `createHalftoneScene(canvas, options)` → { set(options), destroy() }.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `animate: false`
* (reduced-motion / `data-shot`) рисует один стоячий кадр.
*/
export interface HalftoneSceneOptions {
/** Чем растрируем: `dots` · `lines` · `ascii`. */
mode?: "dots" | "lines" | "ascii";
/** Предмет в кадре: `blob` · `ring` · `knot`. */
shape?: "blob" | "ring" | "knot";
/** Шаг растра в пикселях. */
cell?: number;
/** Угол растра в градусах (у ASCII не применяется: глифы читаются только прямыми). */
angle?: number;
/** Контраст перед растрированием. */
contrast?: number;
/** Краска и бумага: `print` · `neon` · `ash`. */
palette?: "print" | "neon" | "ash";
/** Скорость жизни формы. */
speed?: number;
/** Насколько свет идёт за курсором. */
light?: number;
/** false — один стоячий кадр (reduced-motion, скриншоты). */
animate?: boolean;
}
export interface HalftoneScene {
set(next: Partial<HalftoneSceneOptions>): void;
destroy(): void;
}
const DEFAULTS = {
mode: "dots" as "dots" | "lines" | "ascii",
shape: "blob" as "blob" | "ring" | "knot",
cell: 9,
angle: 27,
contrast: 1,
palette: "print" as "print" | "neon" | "ash",
speed: 0.5,
light: 0.8,
animate: true,
};
type Config = typeof DEFAULTS;
/** Краска / бумага в экранных величинах: шейдер пишет их в кадр без пересчёта. */
const PALETTES: Record<"print" | "neon" | "ash", { ink: [number, number, number]; paper: [number, number, number] }> = {
print: { ink: [0.07, 0.08, 0.1], paper: [0.9, 0.87, 0.8] },
neon: { ink: [0.49, 0.98, 1.0], paper: [0.03, 0.04, 0.07] },
ash: { ink: [0.88, 0.89, 0.93], paper: [0.1, 0.1, 0.12] },
};
/** Глифы от пустого к плотному: по ним ASCII-режим выбирает символ на клетку. */
const RAMP = " .:-=+*#%@";
const OBJ_VERT = /* glsl */ `
uniform float uTime;
uniform float uWarp;
varying vec3 vNormal;
varying vec3 vView;
// Поле деформации: три бегущие волны. Нужен не «шум ради шума», а гладкая
// поверхность с крупными горбами — растр читает именно КРУПНУЮ форму,
// мелкая рябь в нём превращается в грязь.
float field(vec3 p) {
return sin(p.x * 1.5 + uTime) * sin(p.y * 1.35 - uTime * 0.8) * sin(p.z * 1.6 + uTime * 0.6)
+ 0.4 * sin(p.y * 2.6 + uTime * 1.3 + p.x) * sin(p.z * 2.4 - uTime);
}
vec3 shift(vec3 dir) {
return dir * (1.0 + field(dir * 1.15) * uWarp);
}
void main() {
vec3 n = normalize(position);
vec3 p = shift(n);
// нормаль берём по двум соседям на сфере: градиент поля сам по себе даёт
// рваную нормаль, а по соседям она остаётся гладкой
vec3 t1 = normalize(cross(n, abs(n.y) < 0.9 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0)));
vec3 t2 = cross(n, t1);
float e = 0.045;
vec3 pa = shift(normalize(n + t1 * e));
vec3 pb = shift(normalize(n + t2 * e));
vec3 nrm = normalize(cross(pa - p, pb - p));
if (dot(nrm, n) < 0.0) nrm = -nrm;
vec4 world = modelMatrix * vec4(p, 1.0);
vNormal = normalize(mat3(modelMatrix) * nrm);
vView = normalize(cameraPosition - world.xyz);
gl_Position = projectionMatrix * viewMatrix * world;
}
`;
const SOLID_VERT = /* glsl */ `
varying vec3 vNormal;
varying vec3 vView;
void main() {
vec4 world = modelMatrix * vec4(position, 1.0);
vNormal = normalize(mat3(modelMatrix) * normal);
vView = normalize(cameraPosition - world.xyz);
gl_Position = projectionMatrix * viewMatrix * world;
}
`;
/** Свет пишем в ЯРКОСТЬ: растру нужен честный диапазон от тени до блика. */
const OBJ_FRAG = /* glsl */ `
precision highp float;
uniform vec3 uLight;
varying vec3 vNormal;
varying vec3 vView;
void main() {
vec3 n = normalize(vNormal);
float key = max(0.0, dot(n, normalize(uLight)));
float fill = max(0.0, dot(n, normalize(vec3(-0.6, -0.3, 0.4)))) * 0.22;
float rim = pow(1.0 - max(0.0, dot(n, normalize(vView))), 2.6) * 0.5;
float spec = pow(max(0.0, dot(reflect(-normalize(uLight), n), normalize(vView))), 26.0) * 0.7;
// Потолок ниже бумаги (0.9 против 0.96): освещённый бок, равный по яркости
// бумаге, растворяется в поле — силуэт предмета пропадает наполовину.
float l = 0.12 + key * 0.72 + fill + rim * 0.35 + spec * 0.6;
gl_FragColor = vec4(vec3(clamp(l, 0.0, 0.9)), 1.0);
}
`;
/**
* Задник — СВЕТЛОЕ поле с мягким уходом в тень по углам. Тёмный задник кажется
* логичным («сцена в темноте»), но в растре он даёт сплошную заливку краской:
* точки смыкаются, и кадр становится чёрным прямоугольником с дыркой под предмет.
*/
const BACK_FRAG = /* glsl */ `
precision highp float;
varying vec2 vUv;
void main() {
float g = smoothstep(0.0, 1.0, vUv.y);
float r = length(vUv - vec2(0.5, 0.58));
float l = 0.96 - g * 0.07 - smoothstep(0.4, 0.85, r) * 0.12;
gl_FragColor = vec4(vec3(l), 1.0);
}
`;
const BACK_VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const QUAD_VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
/**
* Растр. Клетка берётся в ПОВЁРНУТОЙ системе координат (как типографский экран),
* яркость читается в ЦЕНТРЕ клетки — иначе точка красится собственной яркостью
* и растра не возникает вовсе, получается просто затемнённый кадр.
*/
const SCREEN_FRAG = /* glsl */ `
precision highp float;
uniform sampler2D uScene;
uniform sampler2D uRamp;
uniform vec2 uRes;
uniform float uCell;
uniform float uAngle;
uniform float uContrast;
uniform float uGlyphs;
uniform int uMode;
uniform vec3 uInk;
uniform vec3 uPaper;
varying vec2 vUv;
float luma(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
/** Яркость клетки: четыре отсчёта вокруг центра. Один отсчёт дрожит на движении. */
float cellLuma(vec2 px) {
vec2 o = vec2(uCell * 0.22, 0.0);
float l = luma(texture2D(uScene, px / uRes).rgb) * 0.4;
l += luma(texture2D(uScene, (px + o.xy) / uRes).rgb) * 0.15;
l += luma(texture2D(uScene, (px - o.xy) / uRes).rgb) * 0.15;
l += luma(texture2D(uScene, (px + o.yx) / uRes).rgb) * 0.15;
l += luma(texture2D(uScene, (px - o.yx) / uRes).rgb) * 0.15;
return clamp((l - 0.5) * uContrast + 0.5, 0.0, 1.0);
}
void main() {
vec2 px = vUv * uRes;
float a = uMode == 2 ? 0.0 : uAngle; // глифы читаются только прямыми
float s = sin(a), c = cos(a);
mat2 rot = mat2(c, -s, s, c);
mat2 inv = mat2(c, s, -s, c);
vec2 q = (rot * px) / uCell;
vec2 cellId = floor(q) + 0.5;
vec2 centerPx = inv * (cellId * uCell);
float l = cellLuma(centerPx);
float dark = 1.0 - l;
float ink = 0.0;
if (uMode == 0) {
// Площадь точки пропорциональна темноте — отсюда корень, а не сама величина.
// Множитель 0.62 подобран по кадру: при 0.78 соседние точки смыкаются уже
// на полутоне (радиус больше половины клетки), и форма заливается сплошной краской.
float r = sqrt(dark) * 0.62;
float d = length(q - cellId);
float aa = fwidth(d) + 0.001;
ink = smoothstep(r + aa, r - aa, d);
} else if (uMode == 1) {
float t = abs(fract(q.y) - 0.5);
float w = dark * 0.5;
float aa = fwidth(t) + 0.001;
ink = smoothstep(w + aa, w - aa, t);
} else {
vec2 f = fract(q);
float idx = floor(clamp(dark, 0.0, 0.999) * uGlyphs);
vec2 auv = vec2((idx + f.x) / uGlyphs, f.y);
ink = texture2D(uRamp, auv).a;
}
gl_FragColor = vec4(mix(uPaper, uInk, clamp(ink, 0.0, 1.0)), 1.0);
}
`;
/** Полоса глифов от пустого к плотному — атлас для ASCII-режима. */
function rampTexture(): { tex: THREE.CanvasTexture; count: number } {
const cellW = 48;
const cellH = 48;
const cv = document.createElement("canvas");
cv.width = cellW * RAMP.length;
cv.height = cellH;
const ctx = cv.getContext("2d")!;
ctx.font = `700 ${Math.round(cellH * 0.86)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`;
ctx.fillStyle = "#fff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (let i = 0; i < RAMP.length; i++) {
ctx.fillText(RAMP[i]!, i * cellW + cellW / 2, cellH / 2 + 2);
}
const tex = new THREE.CanvasTexture(cv);
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
tex.wrapS = tex.wrapT = THREE.ClampToEdgeWrapping;
return { tex, count: RAMP.length };
}
export function createHalftoneScene(canvas: HTMLCanvasElement, options: HalftoneSceneOptions = {}): HalftoneScene {
const cfg: Config = { ...DEFAULTS, ...options };
const animate = cfg.animate;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: "high-performance" });
const dpr = Math.min(window.devicePixelRatio || 1, 2);
renderer.setPixelRatio(dpr);
/* ------------------------------------------------- живая сцена (проход 1) */
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(46, 1, 0.1, 40);
camera.position.set(0, 0, 4.6);
const lightUniform = { value: new THREE.Vector3(0.6, 0.8, 0.9) };
const timeUniform = { value: 0 };
const backGeo = new THREE.PlaneGeometry(2, 2);
const backMat = new THREE.ShaderMaterial({ vertexShader: BACK_VERT, fragmentShader: BACK_FRAG, depthWrite: false });
const back = new THREE.Mesh(backGeo, backMat);
back.renderOrder = -1;
scene.add(back);
const blobGeo = new THREE.SphereGeometry(1, 128, 96);
const ringGeo = new THREE.TorusGeometry(1.15, 0.42, 48, 160);
const knotGeo = new THREE.TorusKnotGeometry(0.95, 0.3, 220, 40);
const blobMat = new THREE.ShaderMaterial({
vertexShader: OBJ_VERT,
fragmentShader: OBJ_FRAG,
uniforms: { uTime: timeUniform, uWarp: { value: 0.22 }, uLight: lightUniform },
});
const solidMat = new THREE.ShaderMaterial({
vertexShader: SOLID_VERT,
fragmentShader: OBJ_FRAG,
uniforms: { uLight: lightUniform },
});
const blob = new THREE.Mesh(blobGeo, blobMat);
const ring = new THREE.Mesh(ringGeo, solidMat);
const knot = new THREE.Mesh(knotGeo, solidMat);
blob.scale.setScalar(1.12);
ring.scale.setScalar(0.95);
knot.scale.setScalar(1.02);
scene.add(blob, ring, knot);
function applyShape(): void {
blob.visible = cfg.shape === "blob";
ring.visible = cfg.shape === "ring";
knot.visible = cfg.shape === "knot";
}
applyShape();
const target = new THREE.WebGLRenderTarget(1, 1, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
depthBuffer: true,
});
/* ------------------------------------------------------- растр (проход 2) */
const ramp = rampTexture();
const screenUniforms = {
uScene: { value: target.texture },
uRamp: { value: ramp.tex },
uRes: { value: new THREE.Vector2(1, 1) },
uCell: { value: cfg.cell },
uAngle: { value: (cfg.angle * Math.PI) / 180 },
uContrast: { value: cfg.contrast },
uGlyphs: { value: ramp.count },
uMode: { value: 0 },
uInk: { value: new THREE.Vector3() },
uPaper: { value: new THREE.Vector3() },
};
const quadGeo = new THREE.PlaneGeometry(2, 2);
const quadMat = new THREE.ShaderMaterial({
vertexShader: QUAD_VERT,
fragmentShader: SCREEN_FRAG,
uniforms: screenUniforms,
});
const quadScene = new THREE.Scene();
const quadCam = new THREE.Camera();
quadScene.add(new THREE.Mesh(quadGeo, quadMat));
function resize(): void {
const w = canvas.clientWidth || window.innerWidth;
const h = canvas.clientHeight || window.innerHeight;
renderer.setSize(w, h, false);
const bw = Math.max(2, Math.round(w * dpr));
const bh = Math.max(2, Math.round(h * dpr));
target.setSize(bw, bh);
screenUniforms.uRes.value.set(bw, bh);
camera.aspect = w / h;
camera.updateProjectionMatrix();
// Задник СЧИТАЕТСЯ ПО КАДРУ, а не задаётся на глаз: на широком превью плоскость
// фиксированного размера не дотягивалась до кромок, и по краям оставалась
// сплошная краска — растр выглядел рамкой.
const dist = 7;
const hh = Math.tan((camera.fov * Math.PI) / 360) * dist;
back.position.set(0, 0, camera.position.z - dist);
back.scale.set(hh * camera.aspect * 1.05, hh * 1.05, 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.1;
function frame(now = 0): void {
const dt = animate && last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
time += dt * cfg.speed;
timeUniform.value = time;
pointer.x += (pointer.tx - pointer.x) * 0.06;
pointer.y += (pointer.ty - pointer.y) * 0.06;
lightUniform.value.set(0.2 + pointer.x * cfg.light * 1.8, 0.55 + pointer.y * cfg.light * 1.4, 0.85).normalize();
applyShape();
const spin = time * 0.35;
blob.rotation.set(spin * 0.4, spin, 0);
ring.rotation.set(0.9 + Math.sin(time * 0.5) * 0.25, spin, 0.2);
knot.rotation.set(spin * 0.5, spin * 0.8, 0);
const pal = PALETTES[cfg.palette];
screenUniforms.uCell.value = Math.max(3, cfg.cell) * dpr;
screenUniforms.uAngle.value = (cfg.angle * Math.PI) / 180;
screenUniforms.uContrast.value = cfg.contrast;
screenUniforms.uMode.value = cfg.mode === "dots" ? 0 : cfg.mode === "lines" ? 1 : 2;
screenUniforms.uInk.value.set(pal.ink[0], pal.ink[1], pal.ink[2]);
screenUniforms.uPaper.value.set(pal.paper[0], pal.paper[1], pal.paper[2]);
renderer.setRenderTarget(target);
renderer.render(scene, camera);
renderer.setRenderTarget(null);
renderer.render(quadScene, quadCam);
}
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<HalftoneSceneOptions>;
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);
blobGeo.dispose();
ringGeo.dispose();
knotGeo.dispose();
backGeo.dispose();
backMat.dispose();
blobMat.dispose();
solidMat.dispose();
quadGeo.dispose();
quadMat.dispose();
ramp.tex.dispose();
target.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}