Type Tunnel
Камера летит сквозь коридор объёмных слов: они проносятся мимо, поворачиваются боком и тают в тумане. Объём у слова настоящий, а не подрисованный тенью: каждое слово — стопка параллельных срезов одной маски, передний срез светлый (лицевая пластина), дальние темнее (боковая стенка). Пока слово идёт по центру, видно только лицо; когда оно уходит вбок, перспектива сама разводит срезы и раскрывает стенку. Колесо мыши подталкивает полёт, не перехватывая скролл страницы, курсор ведёт камеру. Словарь переключается: русский, латиница, код.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/type-tunnel.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 { createTypeTunnel, type TypeTunnel as Engine, type TypeTunnelOptions } from "@/lib/type-tunnel-engine";
export type TypeTunnelProps = Omit<TypeTunnelOptions, "animate"> & {
/** Подложка под холст: CSS `background` или `false`, если её задаёт секция. */
backdrop?: string | false;
};
/**
* Подложка идёт ЗА палитрой: слова тают в свой цвет дымки, и на чужом фоне
* дальний план виден отдельным пятном вместо дали.
*/
const BACKDROPS: Record<"ice" | "ember" | "mono", string> = {
ice: "radial-gradient(120% 90% at 50% 50%, oklch(0.22 0.03 262) 0%, oklch(0.13 0.02 260) 45%, oklch(0.07 0.012 258) 100%)",
ember: "radial-gradient(120% 90% at 50% 50%, oklch(0.23 0.04 48) 0%, oklch(0.13 0.025 40) 45%, oklch(0.07 0.012 36) 100%)",
mono: "radial-gradient(120% 90% at 50% 50%, oklch(0.21 0.005 270) 0%, oklch(0.12 0.004 268) 45%, oklch(0.07 0.003 266) 100%)",
};
/**
* Type Tunnel — камера летит сквозь коридор объёмных слов: они проносятся мимо,
* поворачиваются боком и тают в тумане. Колесо подталкивает полёт, курсор ведёт камеру.
*
* Сцена — `createTypeTunnel` из соседнего файла; компонент держит холст, прокидывает
* крутилки через `set()` и разбирает всё при размонтировании.
* `prefers-reduced-motion` и `data-shot` рисуют один стоячий кадр.
*/
export function TypeTunnel({ backdrop, ...options }: TypeTunnelProps = {}) {
const ground = backdrop === undefined ? BACKDROPS[options.palette ?? "ice"] : backdrop;
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 = createTypeTunnel(canvas, { ...first.current, animate });
return () => {
engine.current?.destroy();
engine.current = null;
};
}, []);
const { lexicon, speed, depth, spread, fog, palette, swirl, motes, steer } = options;
useEffect(() => {
engine.current?.set({ lexicon, speed, depth, spread, fog, palette, swirl, motes, steer });
}, [lexicon, speed, depth, spread, fog, palette, swirl, motes, steer]);
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";
/**
* Type Tunnel — движок на голом three.js (без React). Камера летит сквозь коридор
* ОБЪЁМНЫХ слов: они проносятся мимо, поворачиваются боком и тают в тумане.
*
* Объём у слова настоящий, а не подрисованный тенью: каждое слово — стопка
* параллельных срезов одной и той же маски (layered extrusion). Передний срез
* светлый, это лицевая пластина; дальние темнее, это боковая стенка. Пока слово
* идёт по центру, видно только лицо; когда оно уходит вбок, перспектива разводит
* срезы, и стенка раскрывается сама. Отсюда же честный параллакс: глубина у слова
* в мире, а не в шейдере.
*
* `createTypeTunnel(canvas, options)` → { set(options), destroy() }.
* Колесо мыши подталкивает полёт (страницу при этом не перехватывает), курсор
* ведёт камеру. Цикл засыпает вне зоны видимости и на скрытой вкладке;
* `animate: false` (reduced-motion / `data-shot`) рисует один стоячий кадр.
*/
export interface TypeTunnelOptions {
/** Свой набор слов. Если задан — перебивает `lexicon`. */
words?: string[];
/** Готовый словарь: `ru` · `en` · `code`. */
lexicon?: "ru" | "en" | "code";
/** Скорость полёта, условных единиц в секунду. */
speed?: number;
/** Глубина выдавливания слова. */
depth?: number;
/** Разлёт слов от оси коридора. */
spread?: number;
/** Плотность тумана: выше — ближе растворяется даль. */
fog?: number;
/** Палитра: `ice` · `ember` · `mono`. */
palette?: "ice" | "ember" | "mono";
/** Закрутка коридора вокруг оси. */
swirl?: number;
/** Плотность пыли, которая даёт ощущение скорости. */
motes?: number;
/** Насколько камера ведётся на курсор. */
steer?: number;
/** false — один стоячий кадр (reduced-motion, скриншоты). */
animate?: boolean;
}
export interface TypeTunnel {
set(next: Partial<TypeTunnelOptions>): void;
destroy(): void;
}
const LEXICONS: Record<"ru" | "en" | "code", string[]> = {
ru: ["МОУШН", "ГЛУБИНА", "СВЕТ", "РИТМ", "ФОРМА", "ТИПОГРАФИКА", "СКОРОСТЬ"],
en: ["MOTION", "DEPTH", "LIGHT", "RHYTHM", "FORM", "TYPOGRAPHY", "SPEED"],
code: ["render()", "depth++", "0xFFAA33", "async", "GPU", "vec3", "shader"],
};
const PALETTES: Record<"ice" | "ember" | "mono", { face: string; side: string; haze: string; mote: string }> = {
ice: { face: "#eaf2ff", side: "#3d5b98", haze: "#080a12", mote: "#9fc0ff" },
ember: { face: "#fff0dd", side: "#9a4a25", haze: "#0e0806", mote: "#ffb27a" },
mono: { face: "#f4f4f6", side: "#43434c", haze: "#0a0a0c", mote: "#c9c9d2" },
};
const DEFAULTS = {
words: null as string[] | null,
lexicon: "ru" as "ru" | "en" | "code",
speed: 2.0,
depth: 0.34,
spread: 1,
fog: 0.85,
palette: "ice" as "ice" | "ember" | "mono",
swirl: 0.25,
motes: 0.55,
steer: 0.6,
animate: true,
};
type Config = typeof DEFAULTS;
/** Слов в коридоре одновременно: больше — гуще поток и дороже кадр. */
const SLOTS = 16;
/** Шаг между словами по оси коридора. */
const SPAN = 3.4;
/** Срезов на слово: из них и складывается объём. */
const SLICES = 26;
/** Высота пластины в мире (длинные слова ужимаются, см. `plateFor`). */
const LETTER_H = 0.72;
/** Длина замкнутого коридора. */
const LEN = SLOTS * SPAN;
/**
* Цвет БЕЗ пересчёта пространств. Шейдер пишет значение прямо в кадр, поэтому
* палитру держим в экранных величинах: пропусти её через обычный `new Color("#…")`
* — три переведёт sRGB в линейное, и картинка сядет темнее задуманной.
*/
function raw(hex: string): THREE.Color {
const n = parseInt(hex.slice(1), 16);
return new THREE.Color().setRGB(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255, THREE.LinearSRGBColorSpace);
}
const VERT = /* glsl */ `
attribute float aT;
uniform float uDepth;
uniform float uFogDensity;
varying vec2 vUv;
varying float vT;
varying float vFog;
void main() {
vUv = uv;
vT = aT;
// срез уезжает ВГЛУБЬ по локальной оси: стопка срезов и есть объём слова
vec3 pos = position;
pos.z -= aT * uDepth;
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
float dist = max(0.0, -mv.z);
vFog = 1.0 - exp(-pow(dist * uFogDensity * 0.085, 1.9));
gl_Position = projectionMatrix * mv;
}
`;
const FRAG = /* glsl */ `
precision highp float;
uniform sampler2D uMap;
uniform vec3 uFace;
uniform vec3 uSide;
uniform vec3 uHaze;
uniform float uGlow;
varying vec2 vUv;
varying float vT;
varying float vFog;
void main() {
float tex = texture2D(uMap, vUv).a;
// Дальние срезы берут РЕЗКУЮ маску, передний — мягкую. Иначе полупрозрачная
// кромка глифа копится через два десятка срезов и буква обрастает бахромой.
float face = 1.0 - smoothstep(0.0, 0.09, vT);
float a = mix(smoothstep(0.44, 0.56, tex), tex, face);
if (a < 0.02) discard;
// лицо светлое, стенка темнеет вглубь — так читается фаска
vec3 col = mix(uSide * (1.0 - vT * 0.5), uFace, face);
col += uGlow * face * 0.35;
col = mix(col, uHaze, vFog * 0.65);
gl_FragColor = vec4(col, a * (1.0 - vFog));
}
`;
/**
* Пыль. Своя пара шейдеров, а не `PointsMaterial`, ровно по одной причине: встроенный
* туман гасит точку В ЦВЕТ тумана, и на светлой середине подложки дальняя пыль
* собиралась у горизонта тёмным комком. Здесь точка гаснет ПРОЗРАЧНОСТЬЮ, по тому же
* закону, что и слова.
*/
const MOTE_VERT = /* glsl */ `
uniform float uSize;
uniform float uScale;
uniform float uFogDensity;
varying float vFade;
void main() {
vec4 mv = modelViewMatrix * vec4(position, 1.0);
float dist = max(0.05, -mv.z);
vFade = exp(-pow(dist * uFogDensity * 0.085, 1.9));
gl_PointSize = uSize * uScale / dist;
gl_Position = projectionMatrix * mv;
}
`;
const MOTE_FRAG = /* glsl */ `
precision highp float;
uniform vec3 uColor;
uniform float uOpacity;
varying float vFade;
void main() {
float r = 1.0 - smoothstep(0.15, 0.5, length(gl_PointCoord - 0.5));
float a = r * uOpacity * vFade;
if (a < 0.01) discard;
gl_FragColor = vec4(uColor, a);
}
`;
/** Слово → маска в текстуре. Рисуется один раз на слово и кешируется. */
function wordTexture(word: string, mono: boolean): { tex: THREE.CanvasTexture; aspect: number } {
const size = 128;
const pad = 26;
const font = mono
? `700 ${size}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`
: `800 ${size}px ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif`;
const probe = document.createElement("canvas").getContext("2d")!;
probe.font = font;
const w = Math.ceil(probe.measureText(word).width) + pad * 2;
const h = size + pad * 2;
const cv = document.createElement("canvas");
cv.width = w;
cv.height = h;
const ctx = cv.getContext("2d")!;
ctx.font = font;
ctx.fillStyle = "#fff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(word, w / 2, h / 2 + size * 0.04);
const tex = new THREE.CanvasTexture(cv);
tex.generateMipmaps = true;
tex.minFilter = THREE.LinearMipmapLinearFilter;
tex.magFilter = THREE.LinearFilter;
tex.wrapS = tex.wrapT = THREE.ClampToEdgeWrapping;
return { tex, aspect: w / h };
}
/**
* Пластина под слово. Длинные слова ужимаются по высоте: иначе «ТИПОГРАФИКА»
* в коридоре втрое шире «СВЕТ» и перекрывает собой весь кадр.
*/
function plateFor(aspect: number): THREE.BufferGeometry {
const h = LETTER_H * Math.min(1, 3.6 / aspect);
const hw = (h * aspect) / 2;
const hh = h / 2;
const geo = new THREE.BufferGeometry();
geo.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array([-hw, -hh, 0, hw, -hh, 0, hw, hh, 0, -hw, hh, 0]), 3),
);
geo.setAttribute("uv", new THREE.BufferAttribute(new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), 2));
geo.setIndex([0, 1, 2, 0, 2, 3]);
return geo;
}
interface Slot {
mesh: THREE.InstancedMesh;
mat: THREE.ShaderMaterial;
/** Место слова в исходном порядке коридора. */
base: number;
/** Угол вокруг оси коридора. */
angle: number;
/** Свой множитель радиуса: без него коридор читается ровной трубой. */
ring: number;
/** Собственный доворот, чтобы даже слово на оси показывало стенку. */
yaw: number;
}
export function createTypeTunnel(canvas: HTMLCanvasElement, options: TypeTunnelOptions = {}): TypeTunnel {
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();
const camera = new THREE.PerspectiveCamera(58, 1, 0.08, 90);
/* ------------------------------------------------------------ слова */
const texCache = new Map<string, { tex: THREE.CanvasTexture; aspect: number }>();
const geoCache = new Map<string, THREE.BufferGeometry>();
const slots: Slot[] = [];
/** Срез 0 идёт САМЫМ ДАЛЬНИМ: внутри одной отрисовки порядок инстанций — порядок смешивания. */
const depths = new Float32Array(SLICES);
for (let i = 0; i < SLICES; i++) depths[i] = 1 - i / (SLICES - 1);
function geometryFor(word: string): THREE.BufferGeometry {
let geo = geoCache.get(word);
if (!geo) {
geo = plateFor(texCache.get(word)!.aspect);
geo.setAttribute("aT", new THREE.InstancedBufferAttribute(depths, 1));
geoCache.set(word, geo);
}
return geo;
}
function buildSlots(): void {
for (const s of slots) {
corridor.remove(s.mesh);
s.mesh.dispose();
s.mat.dispose();
}
slots.length = 0;
const words = cfg.words && cfg.words.length ? cfg.words : LEXICONS[cfg.lexicon];
// словарь кода набирается моноширинным: пропорциональный шрифт врёт про природу слова
const mono = cfg.lexicon === "code" && !(cfg.words && cfg.words.length);
for (const w of words) if (!texCache.has(w)) texCache.set(w, wordTexture(w, mono));
const pal = PALETTES[cfg.palette];
for (let i = 0; i < SLOTS; i++) {
const word = words[i % words.length]!;
const mat = new THREE.ShaderMaterial({
vertexShader: VERT,
fragmentShader: FRAG,
uniforms: {
uMap: { value: texCache.get(word)!.tex },
uDepth: { value: cfg.depth },
uFogDensity: { value: cfg.fog },
uFace: { value: raw(pal.face) },
uSide: { value: raw(pal.side) },
uHaze: { value: raw(pal.haze) },
uGlow: { value: 0.4 },
},
transparent: true,
depthWrite: false,
// порядок задаём сами по глубине: буфер глубины тут только мешал бы прозрачности
depthTest: false,
});
const mesh = new THREE.InstancedMesh(geometryFor(word), mat, SLICES);
mesh.frustumCulled = false;
corridor.add(mesh);
// золотой угол: слова не выстраиваются пружиной и не слипаются парами
slots.push({
mesh,
mat,
base: i * SPAN,
angle: i * 2.3999,
ring: 0.78 + ((i * 0.37) % 1) * 0.5,
yaw: (i % 2 ? 1 : -1) * (0.16 + ((i * 0.23) % 1) * 0.12),
});
}
}
const corridor = new THREE.Group();
scene.add(corridor);
buildSlots();
/* -------------------------------------------------------------- пыль */
const MOTES = 700;
const motePos = new Float32Array(MOTES * 3);
for (let i = 0; i < MOTES; i++) {
const a = Math.random() * Math.PI * 2;
const r = 0.8 + Math.random() * 4.2;
motePos[i * 3] = Math.cos(a) * r;
motePos[i * 3 + 1] = Math.sin(a) * r * 0.7;
motePos[i * 3 + 2] = -Math.random() * LEN;
}
const moteGeo = new THREE.BufferGeometry();
moteGeo.setAttribute("position", new THREE.BufferAttribute(motePos, 3));
const moteMat = new THREE.ShaderMaterial({
vertexShader: MOTE_VERT,
fragmentShader: MOTE_FRAG,
uniforms: {
uSize: { value: 0.04 },
uScale: { value: 300 },
uFogDensity: { value: cfg.fog },
uColor: { value: raw(PALETTES[cfg.palette].mote) },
uOpacity: { value: 0.6 },
},
transparent: true,
depthWrite: false,
depthTest: false,
});
const motes = new THREE.Points(moteGeo, moteMat);
motes.frustumCulled = false;
motes.renderOrder = -1e6; // пыль всегда позади слов: так она не мылит буквы
scene.add(motes);
/* ------------------------------------------------------------- ввод */
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;
};
// Колесо ПОДТАЛКИВАЕТ полёт, но страницу не перехватывает: preventDefault здесь
// означал бы «каталог не скроллится над сценой», а это дороже эффекта.
let boost = 0;
const onWheel = (e: WheelEvent) => {
if (!animate) return;
boost = Math.max(-6, Math.min(14, boost + e.deltaY * 0.012));
};
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerleave", onLeave);
canvas.addEventListener("wheel", onWheel, { passive: true });
function resize(): void {
const w = canvas.clientWidth || window.innerWidth;
const h = canvas.clientHeight || window.innerHeight;
renderer.setSize(w, h, false);
moteMat.uniforms.uScale!.value = (renderer.getDrawingBufferSize(new THREE.Vector2()).y || h) * 0.5;
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
resize();
let raf = 0;
let last = 0;
let travel = animate ? 0 : LEN * 0.37;
let time = animate ? 0 : 2.2;
function frame(now = 0): void {
const dt = animate && last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
time += dt;
boost *= Math.exp(-dt * 1.8);
travel += (cfg.speed + boost) * dt;
pointer.x += (pointer.tx - pointer.x) * 0.05;
pointer.y += (pointer.ty - pointer.y) * 0.05;
camera.position.set(pointer.x * cfg.steer * 1.5, pointer.y * cfg.steer, 0);
camera.lookAt(pointer.x * cfg.steer * 0.6, pointer.y * cfg.steer * 0.4, -7);
const pal = PALETTES[cfg.palette];
const face = raw(pal.face);
const side = raw(pal.side);
const haze = raw(pal.haze);
const radius = 1.85 * cfg.spread;
for (const s of slots) {
// коридор замкнут: ушедшее за спину слово возвращается в дальний конец
const z = ((((s.base + travel - 2) % LEN) + LEN) % LEN) - LEN + 2;
const a = s.angle + time * cfg.swirl * 0.25;
// Коридор РАСКРЫВАЕТСЯ с глубиной. Ровная труба сходится в точку, и дальние
// слова сваливаются в кучу у горизонта — вместо дали получается грязь в центре.
const flare = 1 + Math.max(0, -z - 5) * 0.038;
const x = Math.cos(a) * radius * s.ring * flare;
const y = Math.sin(a) * radius * s.ring * flare * 0.62;
s.mesh.position.set(x, y, z);
// слово развёрнуто к оси коридора: мимо зрителя оно идёт вполоборота,
// и ровно тогда стопка срезов раскрывается стенкой
s.mesh.rotation.set(y * 0.18, s.yaw - x * 0.3, Math.sin(a) * 0.06);
// ближние рисуются последними, иначе дальнее слово ляжет поверх ближнего
s.mesh.renderOrder = Math.round(z * 100);
const u = s.mat.uniforms;
u.uDepth!.value = cfg.depth;
u.uFogDensity!.value = cfg.fog;
(u.uFace!.value as THREE.Color).copy(face);
(u.uSide!.value as THREE.Color).copy(side);
(u.uHaze!.value as THREE.Color).copy(haze);
}
// пыль идёт тем же потоком, но медленнее — так глубина читается слоями
motes.position.z = ((travel * 0.72) % LEN) - LEN + 2;
const mu = moteMat.uniforms;
mu.uOpacity!.value = 0.12 + cfg.motes * 0.55;
mu.uSize!.value = 0.02 + cfg.motes * 0.045;
mu.uFogDensity!.value = cfg.fog;
(mu.uColor!.value as THREE.Color).copy(raw(pal.mote));
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<TypeTunnelOptions>;
const relist = ("lexicon" in patch && patch.lexicon !== cfg.lexicon) || "words" in patch;
Object.assign(cfg, patch);
if (relist) buildSlots();
if (!animate) frame();
},
destroy() {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerleave", onLeave);
canvas.removeEventListener("wheel", onWheel);
canvas.removeEventListener("webglcontextlost", onLost);
for (const s of slots) {
s.mesh.dispose();
s.mat.dispose();
}
for (const g of geoCache.values()) g.dispose();
for (const t of texCache.values()) t.tex.dispose();
moteGeo.dispose();
moteMat.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}