This commit is contained in:
parent
cb3c323c9f
commit
9529e3747c
29 changed files with 8906 additions and 1326 deletions
660
apps/site/src/showcases/twentyoz-core.js
Normal file
660
apps/site/src/showcases/twentyoz-core.js
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue