Portal Window
Сцена рисуется в свой буфер (render target) и попадает на экран через форму окна — и за стеклом стоит МЕСТО, а не абстракция: закатная равнина, солнце низко над горизонтом, силуэты скал в глубине, светящаяся сетка, уходящая к кромке земли, звёзды выше зарева и пыль в воздухе. Ключ приёма в том, что мир за стеклом существует целиком, поэтому он честно параллаксит при движении курсора: планы расходятся с разной скоростью, чего сдвигом картинки не подделать. Силуэты и нужны в первую очередь для этого — без планов сдвиг камеры не читается вовсе. Форма окна — SDF: она вырезает мир, даёт нормаль для преломления на кромке и держит тонкий кант. Размер окна крутится от врезанного прямоугольника до полного кадра; на полном кадре кант, фаска и скругление обнуляются сами. Тон мира — одна крутилка: от закатного янтаря до холодной синей ночи.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/portal-window.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 { createPortalWindow, type PortalWindow as Engine, type PortalWindowOptions } from "@/lib/portal-window-engine";
export type PortalWindowProps = Omit<PortalWindowOptions, "animate">;
/**
* Portal Window — окно, за которым живёт отдельная сцена: она рисуется в свой буфер
* и попадает на экран через форму окна, поэтому честно параллаксит за курсором,
* а кромка окна её преломляет.
*
* Сцена — `createPortalWindow` из соседнего файла; компонент держит холст, прокидывает
* крутилки через `set()` и разбирает всё при размонтировании.
* `prefers-reduced-motion` и `data-shot` рисуют один стоячий кадр.
*/
export function PortalWindow(options: PortalWindowProps = {}) {
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 = createPortalWindow(canvas, { ...first.current, animate });
return () => {
engine.current?.destroy();
engine.current = null;
};
}, []);
const { size, radius, parallax, refraction, speed, tint, motes } = options;
useEffect(() => {
engine.current?.set({ size, radius, parallax, refraction, speed, tint, motes });
}, [size, radius, parallax, refraction, speed, tint, motes]);
return (
<div style={{ position: "absolute", inset: 0, overflow: "hidden", background: "#06070c" }}>
<canvas ref={canvasRef} aria-hidden="true" style={{ display: "block", width: "100%", height: "100%" }} />
</div>
);
}
import * as THREE from "three";
/**
* Portal Window — движок на голом three.js (без React). В кадре стоит окно-портал,
* и за ним ЖИВЁТ ОТДЕЛЬНАЯ СЦЕНА: она рисуется в свой буфер (render target) и только
* потом попадает на экран через форму окна. Отсюда главное свойство приёма: мир за
* окном существует целиком, поэтому он честно параллаксит при движении курсора,
* а кромка окна его преломляет.
*
* `createPortalWindow(canvas, options)` → { set(options), destroy() }.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `animate: false`
* (reduced-motion / `data-shot`) рисует один стоячий кадр.
*/
export interface PortalWindowOptions {
/** Размер окна, доля высоты кадра. */
size?: number;
/** Скругление углов окна: 0.5 — круг. */
radius?: number;
/** Глубина параллакса мира за окном. */
parallax?: number;
/** Преломление на кромке окна. */
refraction?: number;
/** Скорость жизни мира за окном. */
speed?: number;
/** Тон мира за окном, 0–1 по кругу. */
tint?: number;
/** Плотность звёздной пыли в портале. */
motes?: number;
/** false — один стоячий кадр (reduced-motion, скриншоты). */
animate?: boolean;
}
export interface PortalWindow {
set(next: Partial<PortalWindowOptions>): void;
destroy(): void;
}
const DEFAULTS: Required<PortalWindowOptions> = {
size: 0.86,
radius: 0.32,
parallax: 0.6,
refraction: 0.55,
speed: 0.5,
tint: 0.08,
motes: 0.6,
animate: true,
};
const QUAD_VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
/**
* Композиция портала. Форма окна — SDF скруглённого прямоугольника, и она делает три
* вещи сразу: вырезает мир за окном, даёт нормаль для преломления на кромке и задаёт
* стеклянный кант. Мир берётся из текстуры, нарисованной первым проходом.
*/
const COMPOSE_FRAG = /* glsl */ `
precision highp float;
uniform sampler2D uWorld;
uniform vec2 uRes;
uniform float uSize;
uniform float uRadius;
uniform float uRefraction;
uniform vec2 uShift;
uniform float uTime;
varying vec2 vUv;
float sdBox(vec2 p, vec2 half_, float r) {
vec2 q = abs(p) - half_ + r;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
void main() {
float aspect = uRes.x / uRes.y;
vec2 p = vec2((vUv.x - 0.5) * aspect, vUv.y - 0.5);
// Окно повторяет ПРОПОРЦИЮ КАДРА: при размере 1 оно занимает его целиком.
// Прежняя жёсткая пропорция 1.15 на широком превью оставляла поля по бокам,
// и «на всю ширину» было недостижимо ни при каком размере.
vec2 half_ = vec2(aspect * 0.5, 0.5) * uSize;
// На полном размере скругление обнуляется: иначе у кадра остаются
// чёрные срезанные углы, а просили «на всю высоту и ширину».
float framedR = smoothstep(0.995, 0.9, uSize);
float r = clamp(uRadius, 0.0, 0.5) * min(half_.x, half_.y) * 2.0 * framedR;
float d = sdBox(p, half_, r);
// Фаска окна: на ней мир за стеклом уводится по нормали. На полном размере
// фаска почти обнуляется — иначе по кромке кадра остаётся тёмная рамка
// толщиной в десяток пикселей, и «во весь кадр» опять не получается.
float bevel = mix(0.0015, 0.055, smoothstep(0.995, 0.9, uSize));
float inside = smoothstep(0.0, -bevel, d);
float e = 1.5 / uRes.y;
float gx = (smoothstep(0.0, -bevel, sdBox(p + vec2(e, 0.0), half_, r))
- smoothstep(0.0, -bevel, sdBox(p - vec2(e, 0.0), half_, r))) / (2.0 * e);
float gy = (smoothstep(0.0, -bevel, sdBox(p + vec2(0.0, e), half_, r))
- smoothstep(0.0, -bevel, sdBox(p - vec2(0.0, e), half_, r))) / (2.0 * e);
vec2 n = vec2(-gx, -gy);
// мир за окном сдвигается ОБРАТНО движению камеры: так глаз читает глубину
vec2 worldUv = vUv + uShift + n * uRefraction * 0.045;
vec3 world = texture2D(uWorld, worldUv).rgb;
// Комната перед порталом и кант ЖИВУТ ТОЛЬКО У ВРЕЗАННОГО ОКНА. На полном
// размере им негде быть: свечение по кромке превращалось в светлую рамку
// вокруг всего кадра, а это ровно то, что мешает смотреть в тоннель.
float framed = smoothstep(0.995, 0.9, uSize);
vec3 room = vec3(0.035, 0.04, 0.06);
float rim = exp(-abs(d) * 320.0) * framed;
float innerShade = exp(-abs(d + 0.025) * 45.0) * 0.22 * framed;
vec3 col = mix(room, world, inside);
col *= 1.0 - innerShade * inside;
col += rim * vec3(0.72, 0.68, 0.62) * 0.35;
col = vec3(1.0) - exp(-col * 1.3);
gl_FragColor = vec4(col, 1.0);
}
`;
/**
* Полотнище сияния. Геометрия — узкая лента, уложенная по спирали вокруг оси
* тоннеля: форма постоянна, а свет БЕЖИТ по ней, как у настоящего полярного
* сияния. Поэтому лента читается мягкой и живой, хотя не перестраивается ни разу.
*
* У самого выхода лента РАССЫПАЕТСЯ: по ней идёт порог растворения, и клочья
* отваливаются пятнами. Хеш берётся от координат ЛЕНТЫ, а не от экранных —
* иначе крошка стоит на месте, а лента едет сквозь неё.
*/
/**
* НЕБО мира за окном. Рисуется изнутри большой сферы, поэтому стоит на бесконечности
* и не шевелится от сдвига камеры — ровно как настоящий горизонт. Солнце низко
* над кромкой: от него и рим-свет на скалах, и общий тон кадра.
*/
const SKY_VERT = /* glsl */ `
varying vec3 vDir;
void main() {
vDir = normalize(position);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const SKY_FRAG = /* glsl */ `
precision highp float;
uniform float uHue;
uniform vec3 uSun;
varying vec3 vDir;
vec3 hue2rgb(float h) {
vec3 k = mod(vec3(5.0, 3.0, 1.0) + h * 6.0, 6.0);
return 1.0 - clamp(min(k, 4.0 - k), 0.0, 1.0);
}
float hash(vec2 p) { return fract(sin(dot(p, vec2(41.7, 289.1))) * 43758.5453); }
void main() {
vec3 d = normalize(vDir);
float up = clamp(d.y * 0.5 + 0.5, 0.0, 1.0);
vec3 low = mix(hue2rgb(uHue + 0.03) * 0.34, vec3(0.05, 0.045, 0.07), 0.45);
vec3 high = vec3(0.012, 0.014, 0.028);
vec3 col = mix(low, high, smoothstep(0.42, 0.78, up));
// зарево у горизонта со стороны солнца
float toSun = max(0.0, dot(d, normalize(uSun)));
col += hue2rgb(uHue + 0.02) * pow(toSun, 13.0) * 0.5;
col += hue2rgb(uHue - 0.02) * pow(toSun, 3.0) * 0.055;
col += hue2rgb(uHue + 0.06) * pow(toSun, 60.0) * 1.6;
// сам диск: по нему глаз и понимает, что смотрит на закат, а не в градиент
col += mix(hue2rgb(uHue + 0.08), vec3(1.0), 0.45) * smoothstep(0.9986, 0.9995, toSun) * 2.2;
/* Звёзды: клетка берётся от САМОГО НАПРАВЛЕНИЯ, а не от плоскости xz с делением
на y. Делёж на y растягивает клетки у горизонта, и звёзды превращаются
в вертикальные штрихи — на снимке это читалось царапинами по небу. */
vec3 g3 = floor(d * 120.0);
vec2 g = g3.xy + g3.z * 7.13;
float st = step(0.9972, hash(g)) * smoothstep(0.08, 0.45, d.y) * (0.5 + hash(g + 3.1) * 0.5);
col += vec3(0.85, 0.9, 1.0) * st;
gl_FragColor = vec4(col, 1.0);
}
`;
/** Земля: сетка-стяжка, уходящая к горизонту, и туман по расстоянию. */
const GROUND_VERT = /* glsl */ `
varying vec3 vWorld;
varying float vDist;
void main() {
vec4 world = modelMatrix * vec4(position, 1.0);
vWorld = world.xyz;
vec4 mv = viewMatrix * world;
vDist = -mv.z;
gl_Position = projectionMatrix * mv;
}
`;
const GROUND_FRAG = /* glsl */ `
precision highp float;
uniform float uHue;
uniform vec3 uSun;
uniform float uFog;
uniform float uTime;
varying vec3 vWorld;
varying float vDist;
vec3 hue2rgb(float h) {
vec3 k = mod(vec3(5.0, 3.0, 1.0) + h * 6.0, 6.0);
return 1.0 - clamp(min(k, 4.0 - k), 0.0, 1.0);
}
float hash(vec2 p) { return fract(sin(dot(p, vec2(27.1, 61.7))) * 43758.5453); }
float vnoise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0)), f.x), f.y);
}
void main() {
vec2 p = vWorld.xz;
// светящаяся сетка: линии тоньше к горизонту, поэтому глубина читается сразу
vec2 grid = abs(fract(p * 0.25) - 0.5);
float line = smoothstep(0.02 + vDist * 0.0012, 0.0, min(grid.x, grid.y));
float dune = vnoise(p * 0.12 + vec2(uTime * 0.01, 0.0)) * 0.6 + vnoise(p * 0.45) * 0.3;
vec3 base = mix(vec3(0.03, 0.028, 0.042), hue2rgb(uHue + 0.02) * 0.2, dune);
vec3 col = base + hue2rgb(uHue + 0.04) * line * 0.2;
// туман: дальний план уходит в цвет неба, иначе плоскость видна плитой
float fog = 1.0 - exp(-pow(vDist * uFog * 0.026, 1.7));
vec3 far = mix(hue2rgb(uHue + 0.03) * 0.22, vec3(0.05, 0.045, 0.07), 0.5);
col = mix(col, far, fog);
gl_FragColor = vec4(col, 1.0);
}
`;
/** Скалы-монолиты: тёмный силуэт с рим-светом от солнца и тем же туманом. */
const MONO_VERT = /* glsl */ `
varying vec3 vNormal;
varying float vDist;
varying float vUpY;
void main() {
vNormal = normalize(mat3(modelMatrix) * normal);
vec4 world = modelMatrix * vec4(position, 1.0);
vUpY = position.y;
vec4 mv = viewMatrix * world;
vDist = -mv.z;
gl_Position = projectionMatrix * mv;
}
`;
const MONO_FRAG = /* glsl */ `
precision highp float;
uniform float uHue;
uniform vec3 uSun;
uniform float uFog;
varying vec3 vNormal;
varying float vDist;
varying float vUpY;
vec3 hue2rgb(float h) {
vec3 k = mod(vec3(5.0, 3.0, 1.0) + h * 6.0, 6.0);
return 1.0 - clamp(min(k, 4.0 - k), 0.0, 1.0);
}
void main() {
vec3 n = normalize(vNormal);
float key = max(0.0, dot(n, normalize(uSun)));
// силуэт почти чёрный, свет берёт только кромку, обращённую к солнцу
vec3 col = vec3(0.02, 0.02, 0.03) + hue2rgb(uHue + 0.05) * pow(key, 2.2) * 0.5;
col += hue2rgb(uHue + 0.02) * smoothstep(0.0, 1.0, vUpY) * 0.05;
float fog = 1.0 - exp(-pow(vDist * uFog * 0.026, 1.7));
vec3 far = mix(hue2rgb(uHue + 0.03) * 0.22, vec3(0.05, 0.045, 0.07), 0.5);
col = mix(col, far, fog);
gl_FragColor = vec4(col, 1.0);
}
`;
/** Пыль в воздухе: редкие искры перед камерой, они и дают ощущение объёма. */
const DUST_VERT = /* glsl */ `
attribute float aSeed;
uniform float uZ;
uniform float uSize;
uniform float uScale;
uniform float uSpan;
varying float vFade;
void main() {
vec3 p = position;
p.z = mod(p.z + uZ + aSeed * 0.7, uSpan) - uSpan * 0.5;
vec4 mv = modelViewMatrix * vec4(p, 1.0);
float dist = max(0.2, -mv.z);
vFade = smoothstep(26.0, 6.0, dist) * (0.35 + aSeed * 0.65);
gl_PointSize = uSize * uScale / dist;
gl_Position = projectionMatrix * mv;
}
`;
const DUST_FRAG = /* glsl */ `
precision highp float;
uniform vec3 uColor;
uniform float uOpacity;
varying float vFade;
void main() {
float r = 1.0 - smoothstep(0.1, 0.5, length(gl_PointCoord - 0.5));
float a = r * uOpacity * vFade;
if (a < 0.01) discard;
gl_FragColor = vec4(uColor * a, a);
}
`;
export function createPortalWindow(canvas: HTMLCanvasElement, options: PortalWindowOptions = {}): PortalWindow {
const cfg: Required<PortalWindowOptions> = { ...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);
/* ---------------------------------------------- мир за окном (первый проход) */
const worldScene = new THREE.Scene();
const worldCam = new THREE.PerspectiveCamera(55, 1, 0.1, 60);
worldCam.position.set(0, 0, 7);
/**
* МИР ЗА ОКНОМ — место, а не абстракция. Ровно этого не хватало: за стеклом были
* ленты сияния, и кадр читался завитушкой на чёрном. В референсах жанра (портал
* как линза, за которой ДРУГОЙ мир) работает связка «горизонт + солнце низко +
* силуэты в глубине + туман»: глаз мгновенно понимает, что смотрит куда-то,
* а параллакс между планами делает это «куда-то» объёмным.
*/
const SPAN = 70;
const worldUniforms = {
uHue: { value: cfg.tint },
uSun: { value: new THREE.Vector3(-0.5, 0.055, -1).normalize() },
uFog: { value: 1 },
uTime: { value: 0 },
};
// небо стоит на бесконечности: рисуется изнутри большой сферы
const skyGeo = new THREE.SphereGeometry(60, 40, 24);
const skyMat = new THREE.ShaderMaterial({
vertexShader: SKY_VERT,
fragmentShader: SKY_FRAG,
uniforms: { uHue: worldUniforms.uHue, uSun: worldUniforms.uSun },
side: THREE.BackSide,
depthWrite: false,
});
const sky = new THREE.Mesh(skyGeo, skyMat);
worldScene.add(sky);
const groundGeo = new THREE.PlaneGeometry(220, 220, 1, 1);
const groundMat = new THREE.ShaderMaterial({
vertexShader: GROUND_VERT,
fragmentShader: GROUND_FRAG,
uniforms: worldUniforms,
});
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -1.6;
worldScene.add(ground);
/**
* Силуэты в глубине. Их задача одна: дать ПЛАНЫ, между которыми виден параллакс.
* Без них сдвиг камеры не читается вообще — у неба и плоскости земли нет разницы
* в скорости, и мир кажется картинкой.
*/
const monoGeo = new THREE.BoxGeometry(1, 1, 1);
const monoMat = new THREE.ShaderMaterial({
vertexShader: MONO_VERT,
fragmentShader: MONO_FRAG,
uniforms: worldUniforms,
});
const monos: { mesh: THREE.Mesh; base: number; speed: number }[] = [];
/* Раскладка силуэтов. Ближние стоят ШИРОКО в стороны и невысоко: поставленные
близко к оси, они загораживают горизонт, и вместо «мира» получается коридор
из стен. Дальние выше — так читается масштаб. */
const PLAN: [number, number, number, number][] = [
// x, z, ширина, высота
[-9.5, -12, 1.4, 3.2],
[8.5, -16, 1.8, 4.2],
[-13, -24, 2.4, 5.5],
[12.5, -30, 2.8, 6.5],
[-19, -38, 3.4, 8],
[17, -46, 3.8, 9],
[-7, -56, 4.4, 11],
[24, -64, 5, 12],
];
for (const [x, z, w, h] of PLAN) {
const mesh = new THREE.Mesh(monoGeo, monoMat);
mesh.scale.set(w, h, w * 0.8);
mesh.position.set(x, -1.6 + h / 2, z);
mesh.rotation.y = (x + z) * 0.11;
worldScene.add(mesh);
monos.push({ mesh, base: z, speed: 1 });
}
const moteCount = 900;
const motePos = new Float32Array(moteCount * 3);
const moteSeed = new Float32Array(moteCount);
for (let i = 0; i < moteCount; i++) {
motePos[i * 3] = (Math.random() - 0.5) * 26;
motePos[i * 3 + 1] = -1.4 + Math.random() * 9;
motePos[i * 3 + 2] = -Math.random() * SPAN;
moteSeed[i] = Math.random();
}
const moteGeo = new THREE.BufferGeometry();
moteGeo.setAttribute("position", new THREE.BufferAttribute(motePos, 3));
moteGeo.setAttribute("aSeed", new THREE.BufferAttribute(moteSeed, 1));
const moteMat = new THREE.ShaderMaterial({
vertexShader: DUST_VERT,
fragmentShader: DUST_FRAG,
uniforms: {
uZ: { value: 0 },
uSize: { value: 0.05 },
uScale: { value: 300 },
uSpan: { value: SPAN },
uColor: { value: new THREE.Vector3(0.9, 0.93, 1.0) },
uOpacity: { value: 0.7 },
},
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
premultipliedAlpha: true,
});
const motes = new THREE.Points(moteGeo, moteMat);
motes.frustumCulled = false;
worldScene.add(motes);
const worldTarget = new THREE.WebGLRenderTarget(1, 1, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
depthBuffer: true,
});
/* ------------------------------------------- композиция (второй проход) */
const quadScene = new THREE.Scene();
const quadCam = new THREE.Camera();
const uniforms = {
uWorld: { value: worldTarget.texture },
uRes: { value: new THREE.Vector2(1, 1) },
uSize: { value: cfg.size },
uRadius: { value: cfg.radius },
uRefraction: { value: cfg.refraction },
uShift: { value: new THREE.Vector2(0, 0) },
uTime: { value: 0 },
};
const quadGeo = new THREE.PlaneGeometry(2, 2);
const quadMat = new THREE.ShaderMaterial({ vertexShader: QUAD_VERT, fragmentShader: COMPOSE_FRAG, uniforms });
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);
uniforms.uRes.value.set(w, h);
worldTarget.setSize(Math.max(2, Math.round(w * dpr)), Math.max(2, Math.round(h * dpr)));
// размер точки пыли считается в пикселях буфера — держим масштаб в униформе
moteMat.uniforms.uScale!.value = Math.max(2, Math.round(h * dpr)) * 0.5;
worldCam.aspect = w / h;
worldCam.updateProjectionMatrix();
}
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 : 3.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.06;
pointer.y += (pointer.ty - pointer.y) * 0.06;
// камера мира ходит за курсором — отсюда параллакс между полотнищами
worldCam.position.x = pointer.x * cfg.parallax * 3.2;
worldCam.position.y = pointer.y * cfg.parallax * 2.2;
worldCam.lookAt(0, 0, -6);
// камера мира ЕДЕТ вперёд: мир живёт, но не крутится вьюгой
const travel = time * 2.4;
worldCam.position.x = pointer.x * cfg.parallax * 2.6;
worldCam.position.y = 0.4 + pointer.y * cfg.parallax * 1.6;
worldCam.position.z = 7 - (travel % 12);
worldCam.lookAt(pointer.x * cfg.parallax * 1.2, 0.2 + pointer.y * cfg.parallax * 0.8, -20);
sky.position.copy(worldCam.position);
worldUniforms.uHue.value = cfg.tint;
worldUniforms.uTime.value = time;
worldUniforms.uFog.value = 0.6 + cfg.refraction * 0.5;
// силуэты зацикливаются: ушедший за спину уезжает в дальний конец
for (const m of monos) {
const z = ((m.base + travel + SPAN) % SPAN) - SPAN + 8;
m.mesh.position.z = z;
}
motes.position.z = 0;
moteMat.uniforms.uZ!.value = travel * 0.5;
moteMat.uniforms.uOpacity!.value = 0.15 + cfg.motes * 0.75;
moteMat.uniforms.uSize!.value = 0.03 + cfg.motes * 0.04;
uniforms.uSize.value = cfg.size;
uniforms.uRadius.value = cfg.radius;
uniforms.uRefraction.value = cfg.refraction;
uniforms.uTime.value = time;
// мир сдвигается ПРОТИВ движения курсора: так окно читается дырой, а не картинкой
uniforms.uShift.value.set(-pointer.x * cfg.parallax * 0.05, -pointer.y * cfg.parallax * 0.05);
renderer.setRenderTarget(worldTarget);
renderer.render(worldScene, worldCam);
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<PortalWindowOptions>;
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);
skyGeo.dispose();
skyMat.dispose();
groundGeo.dispose();
groundMat.dispose();
monoGeo.dispose();
monoMat.dispose();
moteGeo.dispose();
moteMat.dispose();
quadGeo.dispose();
quadMat.dispose();
worldTarget.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}