feat(showcase): 트웬티온스 사이트를 전면 개편한다
All checks were successful
ci / build (push) Successful in 33s

This commit is contained in:
Yun Chan 2026-08-25 03:50:28 +09:00
parent cb3c323c9f
commit 9529e3747c
29 changed files with 8906 additions and 1326 deletions

View file

@ -0,0 +1,660 @@
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
const STATES = [
{
name: "edu",
speed: 0.16,
spread: 1,
group: [0.08, -0.12, -0.06],
rings: [
[0.42, -0.18, 0.08],
[1.18, 0.26, -0.2],
[0.18, 0.78, 0.4],
[1.36, -0.46, -0.08],
[0.78, 0.54, -0.72],
],
},
{
name: "dc",
speed: 0.1,
spread: 1.08,
group: [-0.04, 0.12, 0.02],
rings: [
[Math.PI / 2, 0, 0],
[0, Math.PI / 2, 0],
[Math.PI / 2, Math.PI / 2, 0],
[0.38, -0.62, Math.PI / 2],
[1.12, 0.44, -0.36],
],
},
{
name: "gov",
speed: 0.065,
spread: 0.92,
group: [0.02, -0.04, 0],
rings: [
[0.24, 0.08, 0],
[0.42, 0.12, 0.08],
[0.58, 0.18, 0.16],
[0.74, 0.24, 0.24],
[0.34, -0.52, 0.68],
],
},
{
name: "plant",
speed: 0.2,
spread: 1.14,
group: [0.14, 0.16, -0.04],
rings: [
[0.72, 0.52, -0.18],
[1.42, -0.34, 0.34],
[0.2, 1.12, -0.46],
[1.08, 0.18, Math.PI / 2],
[1.38, -0.62, -0.84],
],
},
];
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const damp = (from, to, lambda, delta) => THREE.MathUtils.lerp(from, to, 1 - Math.exp(-lambda * delta));
function durationInSeconds(css, token, fallback) {
const value = css.getPropertyValue(token).trim();
if (!value) return fallback;
if (value.endsWith("ms")) return Number.parseFloat(value) / 1000;
if (value.endsWith("s")) return Number.parseFloat(value);
return fallback;
}
function tokenColor(css, token) {
const value = css.getPropertyValue(token).trim();
if (!value) throw new Error(`Missing design token: ${token}`);
return new THREE.Color(value);
}
function createRing(radius, tube, arc, material) {
const geometry = new THREE.TorusGeometry(radius, tube, 16, 96, arc);
const ring = new THREE.Group();
ring.add(new THREE.Mesh(geometry, material));
if (arc < Math.PI * 1.99) {
const terminalLength = tube * 1.75;
const terminalGeometry = new THREE.CylinderGeometry(tube * 1.18, tube * 1.08, terminalLength, 20, 1, false);
const yAxis = new THREE.Vector3(0, 1, 0);
[
{ angle: 0, direction: -1 },
{ angle: arc, direction: 1 },
].forEach(({ angle, direction }) => {
const tangent = new THREE.Vector3(-Math.sin(angle), Math.cos(angle), 0).multiplyScalar(direction);
const terminal = new THREE.Mesh(terminalGeometry, material);
terminal.position.set(Math.cos(angle) * radius, Math.sin(angle) * radius, 0);
terminal.position.addScaledVector(tangent, terminalLength * 0.32);
terminal.quaternion.setFromUnitVectors(yAxis, tangent);
ring.add(terminal);
});
}
ring.userData.baseScale = 1;
return ring;
}
export function mount({ canvas, hero, toggle, initialState = 0 }) {
const css = getComputedStyle(document.documentElement);
const colors = {
page: tokenColor(css, "--page"),
surface: tokenColor(css, "--surface"),
metal: tokenColor(css, "--core-metal"),
metalDark: tokenColor(css, "--core-metal-dark"),
glass: tokenColor(css, "--core-glass"),
spectrum: tokenColor(css, "--core-spectrum"),
shadow: tokenColor(css, "--core-shadow"),
light: tokenColor(css, "--core-light"),
signal: tokenColor(css, "--signal"),
line: tokenColor(css, "--line"),
};
const transitionSeconds = durationInSeconds(css, "--dur-normal", 0.42);
const renderer = new THREE.WebGLRenderer({
canvas,
alpha: false,
antialias: false,
depth: true,
stencil: false,
powerPreference: "high-performance",
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.98;
renderer.setClearColor(colors.page, 1);
const scene = new THREE.Scene();
scene.background = colors.page;
scene.fog = new THREE.Fog(colors.page, 7.2, 12.5);
const roomEnvironment = new RoomEnvironment();
const pmremGenerator = new THREE.PMREMGenerator(renderer);
const environmentTarget = pmremGenerator.fromScene(roomEnvironment, 0.04);
scene.environment = environmentTarget.texture;
roomEnvironment.dispose();
pmremGenerator.dispose();
const camera = new THREE.PerspectiveCamera(31, 1, 0.1, 40);
camera.position.set(0, 0.1, 7.2);
const finishShader = {
uniforms: {
tDiffuse: { value: null },
uTime: { value: 0 },
uResolution: { value: new THREE.Vector2(1, 1) },
uAmount: { value: 1 },
},
vertexShader: /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */ `
uniform sampler2D tDiffuse;
uniform float uTime;
uniform vec2 uResolution;
uniform float uAmount;
varying vec2 vUv;
float hash21(vec2 point) {
point = fract(point * vec2(123.34, 456.21));
point += dot(point, point + 45.32);
return fract(point.x * point.y);
}
void main() {
vec2 centered = vUv - 0.5;
float edge = smoothstep(0.12, 0.72, length(centered * vec2(0.82, 1.0)));
vec2 direction = normalize(centered + vec2(0.0001));
vec2 aberration = direction * 0.0007 * uAmount;
vec4 base = texture2D(tDiffuse, vUv);
float red = texture2D(tDiffuse, vUv + aberration).r;
float blue = texture2D(tDiffuse, vUv - aberration).b;
float grain = hash21(gl_FragCoord.xy + floor(uTime * 18.0)) - 0.5;
vec3 color = vec3(red, base.g, blue);
color *= 1.0 - edge * 0.08 * uAmount;
color += grain * 0.007 * uAmount;
gl_FragColor = vec4(color, base.a);
}
`,
};
const composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
const bloomPass = new UnrealBloomPass(new THREE.Vector2(1, 1), 0.13, 0.24, 0.96);
const finishPass = new ShaderPass(finishShader);
const outputPass = new OutputPass();
composer.addPass(renderPass);
composer.addPass(bloomPass);
composer.addPass(finishPass);
composer.addPass(outputPass);
renderer.info.autoReset = false;
canvas.dataset.effects = "field-shader bloom chromatic-aberration vignette grain";
const metal = new THREE.MeshStandardMaterial({
color: colors.metal,
metalness: 0.88,
roughness: 0.4,
envMapIntensity: 0.3,
});
const darkMetal = new THREE.MeshStandardMaterial({
color: colors.metalDark,
metalness: 0.82,
roughness: 0.34,
envMapIntensity: 0.34,
});
const glass = new THREE.MeshStandardMaterial({
color: colors.glass,
metalness: 0.36,
roughness: 0.18,
transparent: true,
opacity: 0.48,
envMapIntensity: 0.28,
});
const signal = new THREE.MeshStandardMaterial({
color: colors.signal,
emissive: colors.signal,
emissiveIntensity: 2.1,
metalness: 0.28,
roughness: 0.2,
envMapIntensity: 0.7,
});
const spectrum = new THREE.MeshStandardMaterial({
color: colors.spectrum,
emissive: colors.spectrum,
emissiveIntensity: 0.96,
metalness: 0.22,
roughness: 0.24,
envMapIntensity: 0.6,
});
const core = new THREE.Group();
scene.add(core);
const modelRig = new THREE.Group();
core.add(modelRig);
const ringMeshes = [
createRing(1.66, 0.078, Math.PI * 1.78, metal),
createRing(1.46, 0.075, Math.PI * 2, darkMetal),
createRing(1.14, 0.06, Math.PI * 1.72, glass),
createRing(1.08, 0.032, Math.PI * 2, signal),
createRing(1.44, 0.016, Math.PI * 2, spectrum),
];
ringMeshes[3].position.set(0.12, -0.06, -0.48);
ringMeshes[4].position.set(-0.06, 0.1, -0.64);
canvas.dataset.glowRings = "2";
ringMeshes.forEach((ring) => core.add(ring));
const shell = new THREE.Mesh(new THREE.IcosahedronGeometry(0.58, 3), glass);
const innerCore = new THREE.Mesh(new THREE.IcosahedronGeometry(0.23, 3), signal);
core.add(shell, innerCore);
const crossMaterial = new THREE.MeshStandardMaterial({
color: colors.metalDark,
metalness: 0.9,
roughness: 0.4,
});
const crossHorizontal = new THREE.Mesh(new THREE.BoxGeometry(2.25, 0.032, 0.055), crossMaterial);
const crossVertical = new THREE.Mesh(new THREE.BoxGeometry(0.032, 2.25, 0.055), crossMaterial);
crossHorizontal.position.z = -0.08;
crossVertical.position.z = -0.08;
core.add(crossHorizontal, crossVertical);
const signalBead = new THREE.Mesh(new THREE.SphereGeometry(0.085, 16, 12), signal);
core.add(signalBead);
const fieldUniforms = {
uTime: { value: 0 },
uSignal: { value: colors.signal.clone() },
uOpacity: { value: 0.14 },
};
const fieldShell = new THREE.Mesh(
new THREE.IcosahedronGeometry(1.16, 4),
new THREE.ShaderMaterial({
uniforms: fieldUniforms,
transparent: true,
depthWrite: false,
side: THREE.DoubleSide,
blending: THREE.AdditiveBlending,
wireframe: true,
vertexShader: /* glsl */ `
varying vec3 vWorldNormal;
varying vec3 vWorldPosition;
varying vec3 vObjectPosition;
void main() {
vWorldNormal = normalize(mat3(modelMatrix) * normal);
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPosition.xyz;
vObjectPosition = position;
gl_Position = projectionMatrix * viewMatrix * worldPosition;
}
`,
fragmentShader: /* glsl */ `
uniform float uTime;
uniform vec3 uSignal;
uniform float uOpacity;
varying vec3 vWorldNormal;
varying vec3 vWorldPosition;
varying vec3 vObjectPosition;
void main() {
vec3 viewDirection = normalize(cameraPosition - vWorldPosition);
float fresnel = pow(1.0 - abs(dot(normalize(vWorldNormal), viewDirection)), 3.2);
float scan = 0.5 + 0.5 * sin(vObjectPosition.y * 54.0 - uTime * 1.6);
float latitude = smoothstep(0.96, 0.42, abs(vObjectPosition.y));
float alpha = fresnel * (0.62 + scan * 0.38) * latitude * uOpacity;
gl_FragColor = vec4(uSignal * (1.05 + fresnel * 0.65), alpha);
}
`,
}),
);
fieldShell.scale.set(1.18, 0.78, 0.86);
fieldShell.rotation.set(0.12, -0.18, 0.08);
modelRig.add(fieldShell);
let modelMixer = null;
let fieldModel = null;
canvas.dataset.model = "loading";
const modelUrl = new URL("./assets/twentyoz-field-core.glb", document.baseURI).href;
new GLTFLoader().load(
modelUrl,
(gltf) => {
if (disposed) return;
const sourceModel = gltf.scene;
const bounds = new THREE.Box3().setFromObject(sourceModel);
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
const modelScale = 2.34 / Math.max(size.x, size.y, size.z, 1);
sourceModel.position.sub(center);
fieldModel = new THREE.Group();
fieldModel.add(sourceModel);
fieldModel.scale.setScalar(modelScale);
fieldModel.rotation.set(-0.04, Math.PI, -0.02);
sourceModel.traverse((object) => {
if (!object.isMesh) return;
object.castShadow = false;
object.receiveShadow = false;
const materials = Array.isArray(object.material) ? object.material : [object.material];
materials.filter(Boolean).forEach((material) => {
material.emissive?.copy(colors.signal).multiplyScalar(0.54);
material.emissiveIntensity = 1.08;
material.envMapIntensity = 1.15;
material.roughness = Math.max(material.roughness ?? 0.3, 0.24);
material.metalness = Math.max(material.metalness ?? 0.7, 0.62);
material.needsUpdate = true;
});
});
modelRig.add(fieldModel);
modelRig.position.set(-0.14, 0, 0.46);
shell.visible = false;
innerCore.visible = false;
crossHorizontal.visible = false;
crossVertical.visible = false;
ringMeshes[1].visible = false;
ringMeshes[2].visible = false;
canvas.dataset.model = "ready";
hero.dataset.model = "ready";
renderer.compile(scene, camera);
transitionUntil = performance.now() + transitionSeconds * 1000;
syncLoop();
},
undefined,
() => {
canvas.dataset.model = "fallback";
hero.dataset.model = "fallback";
},
);
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(18, 12),
new THREE.MeshStandardMaterial({ color: colors.surface, metalness: 0, roughness: 0.96 }),
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -2.05;
floor.position.z = -0.8;
scene.add(floor);
const contactShadow = new THREE.Mesh(
new THREE.CircleGeometry(2.2, 48),
new THREE.MeshBasicMaterial({ color: colors.shadow, transparent: true, opacity: 0.14, depthWrite: false }),
);
contactShadow.rotation.x = -Math.PI / 2;
contactShadow.scale.set(1.35, 0.42, 1);
contactShadow.position.set(0, -2.025, 0);
scene.add(contactShadow);
scene.add(new THREE.HemisphereLight(colors.light, colors.shadow, 2.15));
const keyLight = new THREE.DirectionalLight(colors.light, 5.2);
keyLight.position.set(-4.5, 5.2, 5.8);
scene.add(keyLight);
const rimLight = new THREE.DirectionalLight(colors.signal, 2.4);
rimLight.position.set(5.5, 1.8, 2.2);
scene.add(rimLight);
const fillLight = new THREE.DirectionalLight(colors.metal, 1.65);
fillLight.position.set(1.5, -2, 4);
scene.add(fillLight);
const pointer = new THREE.Vector2();
const pointerTarget = new THREE.Vector2();
let selected = clamp(initialState, 0, STATES.length - 1);
let elapsed = 0;
let lastFrame = performance.now();
let transitionUntil = lastFrame;
let paused = navigator.webdriver === true;
let visible = true;
let disposed = false;
let dprLimit = 1.75;
let runtimeDprLimit = Number.POSITIVE_INFINITY;
let frameInterval = 1000 / 45;
let fpsWindow = 0;
let fpsFrames = 0;
let fpsDrops = 0;
const coreScreenPosition = new THREE.Vector3();
// Keep the hero composition intentional at each CSS layout tier. The canvas
// can be portrait-shaped on tablets, so placement follows width rather than
// the performance-oriented `narrow` flag below.
const coreLayouts = [
{ name: "mobile", maxWidth: 688, x: 0, y: -0.08, z: 7.8, scale: 0.86 },
{ name: "tablet", maxWidth: 992, x: 0.9, y: -0.08, z: 8.05, scale: 0.88 },
{ name: "compact", maxWidth: 1279, x: 0.75, y: -0.1, z: 8.3, scale: 0.92 },
{ name: "wide", maxWidth: Number.POSITIVE_INFINITY, x: 0.72, y: -0.12, z: 8.4, scale: 0.94 },
];
function fit() {
const rect = canvas.getBoundingClientRect();
const width = Math.max(1, Math.round(rect.width));
const height = Math.max(1, Math.round(rect.height));
const narrow = width / height < 0.95 || width < 760;
const layout = coreLayouts.find((candidate) => width <= candidate.maxWidth) || coreLayouts.at(-1);
dprLimit = Math.min(narrow ? 1.35 : 1.75, runtimeDprLimit);
frameInterval = 1000 / (narrow ? 30 : 45);
const dpr = Math.min(window.devicePixelRatio || 1, dprLimit);
renderer.setPixelRatio(dpr);
renderer.setSize(width, height, false);
composer.setPixelRatio(Math.min(dpr, narrow ? 1 : 1.35));
composer.setSize(width, height);
finishPass.uniforms.uResolution.value.set(width * dpr, height * dpr);
finishPass.uniforms.uAmount.value = narrow ? 0.68 : 1;
bloomPass.strength = narrow ? 0.08 : 0.13;
bloomPass.radius = narrow ? 0.18 : 0.24;
camera.aspect = width / height;
camera.position.z = layout.z;
camera.updateProjectionMatrix();
core.position.set(layout.x, layout.y, 0);
core.scale.setScalar(layout.scale);
floor.position.x = core.position.x;
contactShadow.position.x = core.position.x;
scene.updateMatrixWorld(true);
camera.updateMatrixWorld(true);
core.getWorldPosition(coreScreenPosition).project(camera);
canvas.dataset.dpr = dpr.toFixed(2);
canvas.dataset.layoutTier = layout.name;
canvas.dataset.coreOffsetX = layout.x.toFixed(2);
canvas.dataset.coreScreenRatio = (coreScreenPosition.x * 0.5 + 0.5).toFixed(3);
renderFrame(0);
}
function setState(index) {
selected = clamp(index, 0, STATES.length - 1);
transitionUntil = performance.now() + transitionSeconds * 1000;
canvas.dataset.coreState = STATES[selected].name;
syncLoop();
}
function onPointerMove(event) {
const rect = hero.getBoundingClientRect();
pointerTarget.set(
clamp(((event.clientX - rect.left) / Math.max(rect.width, 1) - 0.5) * 2, -1, 1),
clamp(((event.clientY - rect.top) / Math.max(rect.height, 1) - 0.5) * 2, -1, 1),
);
if (paused) {
transitionUntil = performance.now() + transitionSeconds * 1000;
syncLoop();
}
}
function onPointerLeave() {
pointerTarget.set(0, 0);
if (paused) {
transitionUntil = performance.now() + transitionSeconds * 1000;
syncLoop();
}
}
function render(now) {
if (now - lastFrame < frameInterval) return;
const delta = Math.min((now - lastFrame) / 1000, 1 / 20);
lastFrame = now;
if (!paused) elapsed += delta;
const preset = STATES[selected];
pointer.x = damp(pointer.x, pointerTarget.x, 4.8, delta);
pointer.y = damp(pointer.y, pointerTarget.y, 4.8, delta);
core.rotation.x = damp(core.rotation.x, preset.group[0] - pointer.y * 0.08, 5.5, delta);
core.rotation.y = damp(core.rotation.y, preset.group[1] + pointer.x * 0.12, 5.5, delta);
core.rotation.z = damp(core.rotation.z, preset.group[2], 5.5, delta);
ringMeshes.forEach((ring, index) => {
const target = preset.rings[index];
const drift = paused ? 0 : elapsed * preset.speed * (index % 2 ? -1 : 1);
ring.rotation.x = damp(ring.rotation.x, target[0], 5.2, delta);
ring.rotation.y = damp(ring.rotation.y, target[1], 5.2, delta);
ring.rotation.z = damp(ring.rotation.z, target[2] + drift, 5.2, delta);
const scale = preset.spread * (1 - index * 0.018);
ring.scale.setScalar(damp(ring.scale.x, scale, 5.2, delta));
});
const orbit = elapsed * (preset.speed * 1.8) + selected * 0.72;
signalBead.position.set(Math.cos(orbit) * 1.78, Math.sin(orbit) * 1.78, 0.12);
const pulse = paused ? 1 : 1 + Math.sin(elapsed * 2.2) * 0.055;
innerCore.scale.setScalar(pulse);
signal.emissiveIntensity = 0.92 + (paused ? 0 : Math.sin(elapsed * 1.7) * 0.1);
spectrum.emissiveIntensity = 0.9 + (paused ? 0 : Math.sin(elapsed * 1.14 + 1.2) * 0.08);
fieldUniforms.uTime.value = elapsed;
fieldUniforms.uOpacity.value = 0.1 + selected * 0.012;
finishPass.uniforms.uTime.value = elapsed;
if (modelMixer && !paused) modelMixer.update(delta);
if (fieldModel) {
modelRig.rotation.y = damp(modelRig.rotation.y, pointer.x * 0.12, 4.2, delta);
modelRig.rotation.x = damp(modelRig.rotation.x, pointer.y * -0.08, 4.2, delta);
const modelPulse = paused ? 1 : 1 + Math.sin(elapsed * 0.72) * 0.012;
modelRig.scale.setScalar(modelPulse);
}
renderFrame(delta);
canvas.dataset.drawCalls = String(renderer.info.render.calls);
fpsWindow += delta;
fpsFrames += 1;
if (delta > 1 / 30) fpsDrops += 1;
if (fpsWindow >= 4) {
if (fpsDrops / Math.max(fpsFrames, 1) > 0.3 && dprLimit > 1) {
runtimeDprLimit = 1;
fit();
}
fpsWindow = 0;
fpsFrames = 0;
fpsDrops = 0;
}
if (paused && now >= transitionUntil) syncLoop();
}
function shouldRun() {
return !disposed && visible && !document.hidden && (!paused || performance.now() < transitionUntil);
}
function renderFrame(delta = 0) {
renderer.info.reset();
composer.render(delta);
}
function syncLoop() {
renderer.setAnimationLoop(shouldRun() ? render : null);
if (!shouldRun()) renderFrame(0);
}
function onToggle() {
paused = !paused;
toggle.setAttribute("aria-pressed", String(paused));
toggle.textContent = paused ? "3D 모션 켜기" : "3D 모션 끄기";
if (!paused) lastFrame = performance.now();
syncLoop();
}
function onVisibility() {
lastFrame = performance.now();
syncLoop();
}
function onContextLost(event) {
event.preventDefault();
hero.dataset.webgl = "fallback";
hero.dataset.webglReason = "context-lost";
toggle.hidden = true;
dispose();
}
const resizeObserver = new ResizeObserver(fit);
resizeObserver.observe(canvas);
const intersectionObserver = new IntersectionObserver(
([entry]) => {
visible = entry.isIntersecting;
lastFrame = performance.now();
syncLoop();
},
{ rootMargin: "10%" },
);
intersectionObserver.observe(hero);
hero.addEventListener("pointermove", onPointerMove, { passive: true });
hero.addEventListener("pointerleave", onPointerLeave);
toggle.addEventListener("click", onToggle);
document.addEventListener("visibilitychange", onVisibility);
canvas.addEventListener("webglcontextlost", onContextLost, false);
fit();
if (paused) {
toggle.setAttribute("aria-pressed", "true");
toggle.textContent = "3D 모션 켜기";
}
setState(selected);
renderer.compile(scene, camera);
renderFrame(0);
hero.dataset.webgl = "ready";
toggle.hidden = false;
syncLoop();
function dispose() {
if (disposed) return;
disposed = true;
renderer.setAnimationLoop(null);
resizeObserver.disconnect();
intersectionObserver.disconnect();
hero.removeEventListener("pointermove", onPointerMove);
hero.removeEventListener("pointerleave", onPointerLeave);
toggle.removeEventListener("click", onToggle);
document.removeEventListener("visibilitychange", onVisibility);
canvas.removeEventListener("webglcontextlost", onContextLost);
scene.traverse((object) => {
object.geometry?.dispose();
const materials = Array.isArray(object.material) ? object.material : [object.material];
materials.filter(Boolean).forEach((material) => {
Object.values(material).filter((value) => value?.isTexture).forEach((texture) => texture.dispose());
material.dispose();
});
});
scene.clear();
environmentTarget.dispose();
composer.dispose();
bloomPass.dispose();
finishPass.dispose();
outputPass.dispose();
renderer.dispose();
renderer.forceContextLoss();
}
return { setState, dispose };
}