import * as THREE from 'three'; import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'; import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'; import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'; import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js'; import { clamp, lerp, easeInOut } from '../util.js'; // ============================================================ // RENDER ENGINE — renderer, scene, camera rig, post fx // ============================================================ export const scene = new THREE.Scene(); export let renderer = null; export let composer = null; let bloomPass = null; export const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 300); // --- camera orbit rig --- const rig = { target: new THREE.Vector3(0, 0.6, 0), tTarget: new THREE.Vector3(0, 0.6, 0), radius: 16, tRadius: 16, azimuth: Math.PI * 0.08, tAzimuth: Math.PI * 0.08, polar: 0.98, tPolar: 0.98, vRadius: 0, }; const FOCUS_STACK = []; export function initEngine(container) { renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.12; renderer.outputColorSpace = THREE.SRGBColorSpace; container.appendChild(renderer.domElement); composer = new EffectComposer(renderer); composer.addPass(new RenderPass(scene, camera)); bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.38, 0.55, 0.86); composer.addPass(bloomPass); composer.addPass(new OutputPass()); window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); composer.setSize(window.innerWidth, window.innerHeight); }); return renderer; } export function applyQuality(q) { if (!renderer) return; const high = q !== 'low'; renderer.shadowMap.enabled = high; bloomPass.enabled = high; renderer.setPixelRatio(high ? Math.min(window.devicePixelRatio, 2) : 1); scene.traverse(o => { if (o.isMesh && o.material) o.material.needsUpdate = true; }); } // ---------- camera control ---------- export function orbitRotate(dx, dy) { rig.tAzimuth -= dx * 0.005; rig.tPolar = clamp(rig.tPolar - dy * 0.004, 0.32, 1.35); } export function orbitZoom(delta) { rig.tRadius = clamp(rig.tRadius * (1 + delta * 0.0012), 6, 46); } export function orbitPan(dx, dz) { // pan in camera space const s = rig.radius * 0.0016; const cos = Math.cos(rig.azimuth), sin = Math.sin(rig.azimuth); rig.tTarget.x += (-dx * cos - dz * sin) * s * -1; rig.tTarget.z += (dx * sin - dz * cos) * s * -1; clampTarget(); } export function setOrbit(target, radius, azimuth, polar, instant = false) { rig.tTarget.copy(target); rig.tRadius = radius; if (azimuth !== undefined) rig.tAzimuth = azimuth; if (polar !== undefined) rig.tPolar = polar; if (instant) { rig.target.copy(rig.tTarget); rig.radius = rig.tRadius; rig.azimuth = rig.tAzimuth; rig.polar = rig.tPolar; } } export function pushFocus(name) { if (!FOCUS_STACK.includes(name)) FOCUS_STACK.push(name); } export function popFocus(name) { const i = FOCUS_STACK.indexOf(name); if (i >= 0) FOCUS_STACK.splice(i, 1); } function clampTarget() { const lim = 26; rig.tTarget.x = clamp(rig.tTarget.x, -lim, lim); rig.tTarget.z = clamp(rig.tTarget.z, -lim, lim); } export function updateCamera(dt) { const k = 1 - Math.pow(0.0001, dt); // smooth exponential approach rig.target.lerp(rig.tTarget, k * 0.9); rig.radius = lerp(rig.radius, rig.tRadius, k * 0.9); rig.azimuth = lerp(rig.azimuth, rig.tAzimuth, k * 0.85); rig.polar = lerp(rig.polar, rig.tPolar, k * 0.85); const sinP = Math.sin(rig.polar), cosP = Math.cos(rig.polar); camera.position.set( rig.target.x + rig.radius * sinP * Math.sin(rig.azimuth), rig.target.y + rig.radius * cosP, rig.target.z + rig.radius * sinP * Math.cos(rig.azimuth) ); camera.lookAt(rig.target); camera.userData.lookTarget = rig.target; return { polar: rig.polar, azimuth: rig.azimuth }; } export function renderFrame() { if (composer) composer.render(); } // ---------- raycasting helper ---------- const raycaster = new THREE.Raycaster(); const ndc = new THREE.Vector2(); export function pickAt(clientX, clientY, objects, recursive = true) { ndc.x = (clientX / window.innerWidth) * 2 - 1; ndc.y = -(clientY / window.innerHeight) * 2 + 1; raycaster.setFromCamera(ndc, camera); const hits = raycaster.intersectObjects(objects, recursive); return hits.length ? hits[0] : null; } export function screenRay(clientX, clientY) { ndc.x = (clientX / window.innerWidth) * 2 - 1; ndc.y = -(clientY / window.innerHeight) * 2 + 1; raycaster.setFromCamera(ndc, camera); return raycaster.ray; } // ---------- shared material cache ---------- const matCache = new Map(); export function mat(color, opts = {}) { const key = `${color}|${opts.rough ?? .8}|${opts.metal ?? 0}|${opts.flat ?? 1}|${opts.emissive ?? 0}|${opts.emissiveIntensity ?? 1}|${opts.transparent ?? 0}|${opts.opacity ?? 1}`; if (!matCache.has(key)) { const m = new THREE.MeshStandardMaterial({ color, roughness: opts.rough ?? 0.8, metalness: opts.metal ?? 0, flatShading: opts.flat ?? true, emissive: opts.emissive || 0x000000, emissiveIntensity: opts.emissiveIntensity ?? 1, transparent: !!opts.transparent, opacity: opts.opacity ?? 1, }); matCache.set(key, m); } return matCache.get(key); } export function basicMat(color, opacity = 1) { const key = `b|${color}|${opacity}`; if (!matCache.has(key)) { matCache.set(key, new THREE.MeshBasicMaterial({ color, transparent: opacity < 1, opacity })); } return matCache.get(key); } // ---------- geometry helpers ---------- export function roundedBox(w, h, d, r = 0.06, seg = 2) { // lightweight rounded box via BoxGeometry + bevel illusion (cheap): we just use box here // (RoundedBoxGeometry import kept out to save bundle; visual difference minor at this scale) return new THREE.BoxGeometry(w, h, d, 1, 1, 1); } export function cyl(rt, rb, h, seg = 12) { return new THREE.CylinderGeometry(rt, rb, h, seg); } export function sph(r, seg = 14) { return new THREE.SphereGeometry(r, seg, Math.max(8, seg / 2)); } export function disposeObject(obj) { obj.traverse(o => { if (o.geometry) o.geometry.dispose(); // materials are cached/shared — don't dispose }); obj.parent?.remove(obj); }