99 lines
3 KiB
JavaScript
99 lines
3 KiB
JavaScript
|
|
(function () {
|
||
|
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||
|
|
if (!window.WebGLRenderingContext || !window.THREE) return;
|
||
|
|
|
||
|
|
const canvas = document.getElementById('scene');
|
||
|
|
if (!canvas) return;
|
||
|
|
|
||
|
|
const root = document.documentElement;
|
||
|
|
const readVar = (name) => getComputedStyle(root).getPropertyValue(name).trim();
|
||
|
|
const readOpacity = () => parseFloat(readVar('--scene-opacity')) || 0.18;
|
||
|
|
|
||
|
|
const scene = new THREE.Scene();
|
||
|
|
const camera = new THREE.PerspectiveCamera(
|
||
|
|
60, window.innerWidth / window.innerHeight, 0.1, 100
|
||
|
|
);
|
||
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||
|
|
renderer.setSize(window.innerWidth, window.innerHeight, false);
|
||
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||
|
|
|
||
|
|
const geometry = new THREE.TorusKnotGeometry(2.5, 0.4, 130, 20);
|
||
|
|
const material = new THREE.MeshPhongMaterial({
|
||
|
|
color: readVar('--accent') || '#c03a28',
|
||
|
|
wireframe: true,
|
||
|
|
transparent: true,
|
||
|
|
opacity: readOpacity()
|
||
|
|
});
|
||
|
|
const core = new THREE.Mesh(geometry, material);
|
||
|
|
scene.add(core);
|
||
|
|
|
||
|
|
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
|
||
|
|
const dir = new THREE.DirectionalLight(0xffffff, 0.8);
|
||
|
|
dir.position.set(5, 5, 5);
|
||
|
|
scene.add(dir);
|
||
|
|
|
||
|
|
const BASE_Z = 9;
|
||
|
|
camera.position.z = BASE_Z;
|
||
|
|
|
||
|
|
let scrollY = window.scrollY || 0;
|
||
|
|
window.addEventListener('scroll', () => {
|
||
|
|
scrollY = window.scrollY || 0;
|
||
|
|
}, { passive: true });
|
||
|
|
|
||
|
|
let baseOpacity = readOpacity();
|
||
|
|
|
||
|
|
let running = true;
|
||
|
|
function tick() {
|
||
|
|
if (!running) return;
|
||
|
|
requestAnimationFrame(tick);
|
||
|
|
|
||
|
|
// Continuous slow drift.
|
||
|
|
core.rotation.y += 0.0015;
|
||
|
|
core.rotation.z += 0.0006;
|
||
|
|
|
||
|
|
// Scroll-driven motion: zoom in, scale up, tilt.
|
||
|
|
const s = Math.min(scrollY, 2000);
|
||
|
|
camera.position.z = BASE_Z - s * 0.0022;
|
||
|
|
const scale = 1 + s * 0.00035;
|
||
|
|
core.scale.set(scale, scale, scale);
|
||
|
|
core.rotation.x = s * 0.0008;
|
||
|
|
|
||
|
|
// Fade past hero so feature cards stay readable.
|
||
|
|
const vh = window.innerHeight;
|
||
|
|
const fadeStart = vh * 0.5;
|
||
|
|
const fadeEnd = vh * 1.4;
|
||
|
|
const t = Math.max(0, Math.min(1, (scrollY - fadeStart) / (fadeEnd - fadeStart)));
|
||
|
|
material.opacity = baseOpacity * (1 - t * 0.92);
|
||
|
|
|
||
|
|
renderer.render(scene, camera);
|
||
|
|
}
|
||
|
|
tick();
|
||
|
|
|
||
|
|
window.addEventListener('resize', () => {
|
||
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
||
|
|
camera.updateProjectionMatrix();
|
||
|
|
renderer.setSize(window.innerWidth, window.innerHeight, false);
|
||
|
|
});
|
||
|
|
|
||
|
|
document.addEventListener('visibilitychange', () => {
|
||
|
|
if (document.hidden) {
|
||
|
|
running = false;
|
||
|
|
} else if (!running) {
|
||
|
|
running = true;
|
||
|
|
tick();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||
|
|
const updateTheme = () => {
|
||
|
|
const accent = readVar('--accent');
|
||
|
|
if (accent) material.color.set(accent);
|
||
|
|
baseOpacity = readOpacity();
|
||
|
|
};
|
||
|
|
if (mql.addEventListener) {
|
||
|
|
mql.addEventListener('change', updateTheme);
|
||
|
|
} else if (mql.addListener) {
|
||
|
|
mql.addListener(updateTheme);
|
||
|
|
}
|
||
|
|
})();
|