Volume Fog
Объёмный свет: луч камеры идёт сквозь дымку шагами и на каждом шаге набирает свет, гася то, что за ним. Свет падает через гобо — жалюзи, сетку или кольца, — поэтому в дымке видны настоящие лучи, а не нарисованные полосы. Тень считается проекцией точки на плоскость гобо, а не вторым маршем к источнику: направление на свет известно, плоскость одна, значит тень берётся одной формулой. Отсюда цена приёма — один проход в половинном разрешении. Курсор поворачивает источник, шар в кадре режет лучи собственной тенью.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/volume-fog.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 { createVolumeFog, type VolumeFog as Engine, type VolumeFogOptions } from "@/lib/volume-fog-engine";
export type VolumeFogProps = Omit<VolumeFogOptions, "animate"> & {
/** Подложка под холст: CSS `background` или `false`, если её задаёт секция. */
backdrop?: string | false;
};
/**
* Volume Fog — объёмный свет: лучи падают через решётку и живут в дымке. Курсор
* поворачивает источник, лучи идут за ним.
*
* Сцена — `createVolumeFog` из соседнего файла; компонент держит холст, прокидывает
* крутилки через `set()` и разбирает всё при размонтировании.
* `prefers-reduced-motion` и `data-shot` рисуют один стоячий кадр.
*/
export function VolumeFog({ backdrop = "oklch(0.09 0.01 260)", ...options }: VolumeFogProps = {}) {
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 = createVolumeFog(canvas, { ...first.current, animate });
return () => {
engine.current?.destroy();
engine.current = null;
};
}, []);
const { gobo, density, beams, softness, hue, speed, orb, steer } = options;
useEffect(() => {
engine.current?.set({ gobo, density, beams, softness, hue, speed, orb, steer });
}, [gobo, density, beams, softness, hue, speed, orb, steer]);
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";
/**
* Volume Fog — движок на голом three.js (без React). Объёмный свет: луч камеры идёт
* сквозь дымку шагами (raymarching), на каждом шаге набирает свет и гасит то, что
* за ним. Свет падает через ГОБО — решётку жалюзи, сетку или кольца, — поэтому
* в дымке видны настоящие лучи, а не нарисованные полосы.
*
* Тень считается не маршем к источнику (это второй цикл на каждый шаг), а проекцией
* точки на плоскость гобо: направление на свет известно, плоскость одна, значит тень
* берётся одной формулой. Отсюда цена приёма: один проход в половинном разрешении.
*
* `createVolumeFog(canvas, options)` → { set(options), destroy() }.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `animate: false`
* (reduced-motion / `data-shot`) рисует один стоячий кадр.
*/
export interface VolumeFogOptions {
/** Через что падает свет: `blinds` · `grid` · `rings`. */
gobo?: "blinds" | "grid" | "rings";
/** Плотность дымки. */
density?: number;
/** Сила света в лучах. */
beams?: number;
/** Мягкость кромки луча. */
softness?: number;
/** Тон света, 0–1 по кругу. */
hue?: number;
/** Скорость движения дымки. */
speed?: number;
/** Шар в кадре: 0 — только лучи. */
orb?: number;
/** Насколько свет идёт за курсором. */
steer?: number;
/** false — один стоячий кадр (reduced-motion, скриншоты). */
animate?: boolean;
}
export interface VolumeFog {
set(next: Partial<VolumeFogOptions>): void;
destroy(): void;
}
const DEFAULTS = {
gobo: "blinds" as "blinds" | "grid" | "rings",
density: 0.55,
beams: 0.62,
softness: 0.45,
hue: 0.11,
speed: 0.5,
orb: 1,
steer: 0.7,
animate: true,
};
type Config = typeof DEFAULTS;
/** Доля разрешения для прохода дымки: объёмный свет считается дорого, а он мягкий. */
const FOG_SCALE = 0.8;
/** Шагов марша. Больше — глаже луч и дороже кадр. */
const STEPS = 40;
const QUAD_VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
const FOG_FRAG = /* glsl */ `
precision highp float;
uniform vec2 uRes;
uniform float uTime;
uniform float uDensity;
uniform float uBeams;
uniform float uSoftness;
uniform float uOrb;
uniform int uGobo;
uniform vec3 uTint;
uniform vec3 uLightDir;
varying vec2 vUv;
const float ORB_R = 0.85;
/**
* Дымка: пара бегущих волн вместо шума (луч и так рисует структуру, шум тут мешает),
* и — главное — ТОНКИЙ СЛОЙ ПОПЕРЁК ВЗГЛЯДА. Дымка на всю глубину сцены убивает лучи:
* один пиксельный луч пересекает десяток полос подряд, их вклад усредняется, и вместо
* шафтов остаётся ровная пелена. Толщина слоя и шаг решётки должны быть одного порядка.
*/
float density(vec3 p) {
float slab = exp(-p.z * p.z * 0.22) * exp(-p.y * p.y * 0.045);
float w = sin(p.x * 0.55 + uTime) * sin(p.z * 0.47 - uTime * 0.7) * sin(p.y * 0.6 + uTime * 0.4);
float w2 = sin(p.x * 1.3 - uTime * 0.8) * sin(p.z * 1.1 + uTime * 0.5);
return max(0.0, (0.55 + 0.32 * w + 0.18 * w2)) * slab;
}
/**
* Рисунок гобо в его собственной плоскости. Порог держится ВЫСОКО (0.62 и выше):
* решётка обязана перекрывать больше, чем пропускать, иначе «лучей» не видно —
* весь кадр оказывается освещён, и остаётся ровная дымка с одной тенью.
*/
float pattern(vec2 q) {
float e = uSoftness * 0.3 + 0.03;
if (uGobo == 0) {
float s = abs(fract(q.y * 0.21) - 0.5) * 2.0;
return smoothstep(0.68 - e, 0.68 + e, s);
} else if (uGobo == 1) {
float a = abs(fract(q.x * 0.16) - 0.5) * 2.0;
float b = abs(fract(q.y * 0.16) - 0.5) * 2.0;
return smoothstep(0.66 - e, 0.66 + e, min(a, b));
}
float r = length(q);
float s = abs(fract(r * 0.15) - 0.5) * 2.0;
return smoothstep(0.6 - e, 0.6 + e, s);
}
/**
* Тень в точке. Плоскость гобо стоит поперёк направления на свет; точку сносим
* вдоль луча света до этой плоскости и смотрим рисунок. Один проход вместо
* второго марша к источнику — отсюда цена всего приёма.
*/
float shade(vec3 p, vec3 L) {
float denom = max(0.15, dot(L, L));
float t = (7.0 - dot(p, L)) / denom;
vec3 hit = p + L * t;
vec3 up = abs(L.y) < 0.9 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);
vec3 ax = normalize(cross(L, up));
vec3 ay = cross(L, ax);
float lit = pattern(vec2(dot(hit, ax), dot(hit, ay)) * 2.2);
// шар тоже перекрывает свет: без его тени он висит в лучах, но их не режет
vec3 toC = -p;
float proj = dot(toC, L);
float dist = length(toC - L * proj);
float sphereShadow = proj > 0.0 ? smoothstep(ORB_R * 0.9, ORB_R * 1.35, dist) : 1.0;
return lit * mix(1.0, sphereShadow, uOrb);
}
void main() {
float aspect = uRes.x / uRes.y;
vec2 uv = (vUv - 0.5) * vec2(aspect, 1.0);
vec3 ro = vec3(0.0, 0.4, 6.2);
vec3 rd = normalize(vec3(uv * 1.05, -1.0));
vec3 L = normalize(uLightDir);
// шар: пересечение считается аналитически, марш до него и обрывается
float orbT = 1e9;
if (uOrb > 0.01) {
float b = dot(ro, rd);
float c = dot(ro, ro) - ORB_R * ORB_R;
float h = b * b - c;
if (h > 0.0) orbT = -b - sqrt(h);
}
float t = 1.0;
float far = 13.0;
float dt = (far - t) / float(${STEPS});
// сдвиг старта по пикселю: без него марш рисует свои шаги кольцами
float jitter = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453);
t += jitter * dt;
vec3 acc = vec3(0.0);
float trans = 1.0;
for (int i = 0; i < ${STEPS}; i++) {
if (t > far || (orbT > 0.0 && t > orbT) || trans < 0.02) break;
vec3 p = ro + rd * t;
float d = density(p) * uDensity;
if (d > 0.002) {
float lit = shade(p, L);
// Множители малы намеренно: сумма по сорока шагам с затуханием даёт примерно
// пятнадцатикратное накопление, и «естественные» величины засвечивают кадр добела.
acc += trans * d * dt * (0.02 + lit * uBeams * 1.15) * uTint;
trans *= exp(-d * dt * 0.55);
}
t += dt;
}
// Шар здесь НЕ рисуется — только обрывает марш и бросает тень. Его поверхность
// складывает второй проход в полном разрешении: в этом (72%) буфере кромка шара
// получалась лестницей, которую апскейл лишь размазывал.
// В альфу уходит остаточная прозрачность до точки обрыва — ею второй проход
// и умножает цвет шара.
gl_FragColor = vec4(acc, trans);
}
`;
const COMPOSE_FRAG = /* glsl */ `
precision highp float;
uniform sampler2D uFog;
uniform vec2 uRes;
uniform vec2 uFogTexel;
uniform float uOrb;
uniform vec3 uTint;
uniform vec3 uLightDir;
varying vec2 vUv;
const float ORB_R = 0.85;
void main() {
// Дымка берётся ЧЕТЫРЬМЯ отсчётами вокруг точки: буфер вчетверо мельче кадра,
// и одиночный отсчёт показывает его собственную пиксельную сетку на градиентах.
vec2 o = uFogTexel * 0.5;
vec4 fog = texture2D(uFog, vUv + vec2( o.x, o.y))
+ texture2D(uFog, vUv + vec2(-o.x, o.y))
+ texture2D(uFog, vUv + vec2( o.x, -o.y))
+ texture2D(uFog, vUv + vec2(-o.x, -o.y));
fog *= 0.25;
vec3 col = fog.rgb;
if (uOrb > 0.01) {
float aspect = uRes.x / uRes.y;
vec2 p2 = (vUv - 0.5) * vec2(aspect, 1.0);
vec3 ro = vec3(0.0, 0.4, 6.2);
vec3 rd = normalize(vec3(p2 * 1.05, -1.0));
vec3 L = normalize(uLightDir);
// Кромка сглаживается АНАЛИТИЧЕСКИ: расстояние от луча до центра шара —
// гладкая величина, и её экранная производная даёт ровно ту ширину перехода,
// которая нужна этому пикселю. Никакого сглаживания буфером для этого не надо.
float b = dot(ro, rd);
float perp = sqrt(max(0.0, dot(ro, ro) - b * b));
float w = max(fwidth(perp), 1e-4) * 0.8;
float cover = smoothstep(ORB_R + w, ORB_R - w, perp);
if (cover > 0.001) {
float hh = max(0.0, ORB_R * ORB_R - perp * perp);
vec3 hit = ro + rd * (-b - sqrt(hh));
vec3 n = normalize(hit);
float key = max(0.0, dot(n, L));
float rim = pow(1.0 - max(0.0, dot(n, -rd)), 3.0);
vec3 orb = (vec3(0.05) + uTint * (key * 0.85 + rim * 0.5)) * uOrb;
col += orb * fog.a * cover;
}
}
col = vec3(1.0) - exp(-col * 1.1);
gl_FragColor = vec4(col, 1.0);
}
`;
/** Тон → RGB, чтобы палитра оставалась одной крутилкой. */
function hueColor(h: number): THREE.Vector3 {
const c = new THREE.Color().setHSL(((h % 1) + 1) % 1, 0.72, 0.6);
return new THREE.Vector3(c.r, c.g, c.b);
}
export function createVolumeFog(canvas: HTMLCanvasElement, options: VolumeFogOptions = {}): VolumeFog {
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);
renderer.setClearColor(0x05060a, 1);
const fogUniforms = {
uRes: { value: new THREE.Vector2(1, 1) },
uTime: { value: 0 },
uDensity: { value: cfg.density },
uBeams: { value: cfg.beams },
uSoftness: { value: cfg.softness },
uOrb: { value: cfg.orb },
uGobo: { value: 0 },
uTint: { value: hueColor(cfg.hue) },
uLightDir: { value: new THREE.Vector3(-0.5, 0.75, 0.42).normalize() },
};
const quadGeo = new THREE.PlaneGeometry(2, 2);
const fogMat = new THREE.ShaderMaterial({ vertexShader: QUAD_VERT, fragmentShader: FOG_FRAG, uniforms: fogUniforms });
const fogScene = new THREE.Scene();
const quadCam = new THREE.Camera();
fogScene.add(new THREE.Mesh(quadGeo, fogMat));
// Половинная точность: в байтовом буфере накопленный свет обрезается на единице,
// и на «силе лучей» выше единицы луч превращался в плоское белое пятно.
const fogTarget = new THREE.WebGLRenderTarget(1, 1, {
type: THREE.HalfFloatType,
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
depthBuffer: false,
});
const composeUniforms = {
uFog: { value: fogTarget.texture },
uRes: { value: new THREE.Vector2(1, 1) },
uFogTexel: { value: new THREE.Vector2(1, 1) },
uOrb: { value: cfg.orb },
uTint: { value: hueColor(cfg.hue) },
uLightDir: { value: fogUniforms.uLightDir.value },
};
const composeMat = new THREE.ShaderMaterial({
vertexShader: QUAD_VERT,
fragmentShader: COMPOSE_FRAG,
uniforms: composeUniforms,
});
const composeScene = new THREE.Scene();
composeScene.add(new THREE.Mesh(quadGeo, composeMat));
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));
composeUniforms.uRes.value.set(bw, bh);
const fw = Math.max(2, Math.round(bw * FOG_SCALE));
const fh = Math.max(2, Math.round(bh * FOG_SCALE));
fogTarget.setSize(fw, fh);
fogUniforms.uRes.value.set(fw, fh);
composeUniforms.uFogTexel.value.set(1 / fw, 1 / fh);
}
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.4;
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.05;
pointer.y += (pointer.ty - pointer.y) * 0.05;
fogUniforms.uTime.value = time;
fogUniforms.uDensity.value = cfg.density;
fogUniforms.uBeams.value = cfg.beams;
fogUniforms.uSoftness.value = cfg.softness;
fogUniforms.uOrb.value = cfg.orb;
fogUniforms.uGobo.value = cfg.gobo === "blinds" ? 0 : cfg.gobo === "grid" ? 1 : 2;
fogUniforms.uTint.value.copy(hueColor(cfg.hue));
fogUniforms.uLightDir.value
.set(-0.5 + pointer.x * cfg.steer * 1.4, 0.75 + pointer.y * cfg.steer * 0.9, 0.42)
.normalize();
composeUniforms.uOrb.value = cfg.orb;
(composeUniforms.uTint.value as THREE.Vector3).copy(fogUniforms.uTint.value as THREE.Vector3);
renderer.setRenderTarget(fogTarget);
renderer.render(fogScene, quadCam);
renderer.setRenderTarget(null);
renderer.render(composeScene, 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<VolumeFogOptions>;
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);
quadGeo.dispose();
fogMat.dispose();
composeMat.dispose();
fogTarget.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}