import './styles.css'; import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { Sky } from 'three/addons/objects/Sky.js'; import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'; import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'; import { Cityscape } from './city.js'; const $ = (selector) => document.querySelector(selector); const sceneHost = $('#scene'); const loading = $('#loading'); const loadingProgress = $('#loading-progress'); const loadingLabel = $('#loading-label'); const interfaceLayer = $('#interface'); const intro = $('#intro'); const hint = $('#hint'); const buildingCard = $('#building-card'); const helpPanel = $('#help-panel'); const qualityBadge = $('#quality'); const mobile = matchMedia('(max-width: 760px), (pointer: coarse)').matches; const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; function supportsWebGL() { try { const canvas = document.createElement('canvas'); return Boolean(canvas.getContext('webgl2')); } catch { return false; } } if (!supportsWebGL()) { loading.style.display = 'none'; $('#webgl-error').hidden = false; throw new Error('WebGL unavailable'); } let renderer; try { renderer = new THREE.WebGLRenderer({ antialias: !mobile, alpha: false, powerPreference: 'high-performance', stencil: false, }); } catch (error) { loading.style.display = 'none'; $('#webgl-error').hidden = false; throw error; } renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 0.74; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFShadowMap; renderer.setSize(window.innerWidth, window.innerHeight); function preferredPixelRatio() { const cap = mobile ? 1.25 : 1.7; const pixelBudget = mobile ? 2_400_000 : 5_600_000; const budgetRatio = Math.sqrt(pixelBudget / (window.innerWidth * window.innerHeight)); return Math.min(window.devicePixelRatio, cap, Math.max(0.78, budgetRatio)); } let renderPixelRatio = preferredPixelRatio(); renderer.setPixelRatio(renderPixelRatio); sceneHost.append(renderer.domElement); const scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0x7b8993, 0.00172); const camera = new THREE.PerspectiveCamera(44, window.innerWidth / window.innerHeight, 0.5, 1800); camera.position.set(322, 205, 380); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.055; controls.minDistance = 13; controls.maxDistance = 510; controls.minPolarAngle = THREE.MathUtils.degToRad(10); controls.maxPolarAngle = THREE.MathUtils.degToRad(89.2); controls.screenSpacePanning = false; controls.zoomToCursor = true; controls.target.set(0, 45, 0); controls.update(); const sky = new Sky(); sky.scale.setScalar(450000); scene.add(sky); const skyUniforms = sky.material.uniforms; const hemisphereLight = new THREE.HemisphereLight(0xb7d5e6, 0x1a2026, 1.65); scene.add(hemisphereLight); const ambientLight = new THREE.AmbientLight(0x718291, 0.24); scene.add(ambientLight); const sunLight = new THREE.DirectionalLight(0xffc590, 3.4); sunLight.castShadow = true; sunLight.shadow.mapSize.set(mobile ? 1024 : 2048, mobile ? 1024 : 2048); sunLight.shadow.camera.left = -265; sunLight.shadow.camera.right = 265; sunLight.shadow.camera.top = 265; sunLight.shadow.camera.bottom = -265; sunLight.shadow.camera.near = 1; sunLight.shadow.camera.far = 850; sunLight.shadow.bias = -0.00018; sunLight.shadow.normalBias = 0.045; sunLight.target.position.set(0, 0, 0); scene.add(sunLight, sunLight.target); const composer = new EffectComposer(renderer); composer.setPixelRatio(renderPixelRatio); composer.addPass(new RenderPass(scene, camera)); const bloomPass = new UnrealBloomPass( new THREE.Vector2(window.innerWidth, window.innerHeight), mobile ? 0 : 0.18, 0.48, 1.08, ); bloomPass.enabled = !mobile; composer.addPass(bloomPass); composer.addPass(new OutputPass()); const LIGHTING = { sunrise: { label: 'Sunrise', clock: '06:24', weather: '11° / CLEAR', elevation: 5.5, azimuth: 112, turbidity: 8.4, rayleigh: 2.7, mieCoefficient: 0.008, mieDirectionalG: 0.86, sunColor: 0xffa86d, sunIntensity: 4.7, hemiSky: 0xb6d0dd, hemiGround: 0x372f2d, hemiIntensity: 1.5, ambientIntensity: 0.24, fogColor: 0x9a8d88, fogDensity: 0.00166, exposure: 0.79, night: 0.36, bloom: 0.12, }, day: { label: 'Day', clock: '13:42', weather: '19° / CLEAR', elevation: 42, azimuth: 148, turbidity: 6.1, rayleigh: 1.8, mieCoefficient: 0.004, mieDirectionalG: 0.8, sunColor: 0xfff0d5, sunIntensity: 4.2, hemiSky: 0xd5e8ee, hemiGround: 0x53605d, hemiIntensity: 1.78, ambientIntensity: 0.31, fogColor: 0xa9bcc3, fogDensity: 0.00124, exposure: 0.84, night: 0.03, bloom: 0.05, }, dusk: { label: 'Dusk', clock: '20:18', weather: '14° / CLEAR', elevation: 1.4, azimuth: 286, turbidity: 10.4, rayleigh: 3.4, mieCoefficient: 0.013, mieDirectionalG: 0.89, sunColor: 0xff9c68, sunIntensity: 3.15, hemiSky: 0x7f9fbe, hemiGround: 0x171c28, hemiIntensity: 1.32, ambientIntensity: 0.2, fogColor: 0x101821, fogDensity: 0.00212, exposure: 0.72, night: 0.78, bloom: mobile ? 0 : 0.18, }, night: { label: 'Night', clock: '23:47', weather: '12° / CLEAR', elevation: -7, azimuth: 262, turbidity: 12, rayleigh: 0.78, mieCoefficient: 0.02, mieDirectionalG: 0.91, sunColor: 0x6f8db3, sunIntensity: 0.24, hemiSky: 0x293c5d, hemiGround: 0x0c1018, hemiIntensity: 0.72, ambientIntensity: 0.14, fogColor: 0x080e18, fogDensity: 0.00224, exposure: 0.66, night: 1, bloom: mobile ? 0 : 0.24, }, }; function createEnvironmentTexture(top, horizon, ground) { const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 32; const context = canvas.getContext('2d'); const gradient = context.createLinearGradient(0, 0, 0, canvas.height); gradient.addColorStop(0, top); gradient.addColorStop(0.42, top); gradient.addColorStop(0.51, horizon); gradient.addColorStop(0.61, ground); gradient.addColorStop(1, ground); context.fillStyle = gradient; context.fillRect(0, 0, canvas.width, canvas.height); const texture = new THREE.CanvasTexture(canvas); texture.mapping = THREE.EquirectangularReflectionMapping; texture.colorSpace = THREE.SRGBColorSpace; return texture; } const ENVIRONMENTS = { sunrise: createEnvironmentTexture('#516c80', '#d4a17b', '#292826'), day: createEnvironmentTexture('#86adbf', '#d9e3e3', '#4a5552'), dusk: createEnvironmentTexture('#263b58', '#ad776c', '#171e25'), night: createEnvironmentTexture('#091426', '#1b304d', '#080c12'), }; scene.environment = ENVIRONMENTS.dusk; function cloneLighting(source) { return { ...source, sunColor: new THREE.Color(source.sunColor), hemiSky: new THREE.Color(source.hemiSky), hemiGround: new THREE.Color(source.hemiGround), fogColor: new THREE.Color(source.fogColor), }; } let lightingState = cloneLighting(LIGHTING.dusk); let lightingTarget = cloneLighting(LIGHTING.dusk); let activeTime = 'dusk'; const sunPosition = new THREE.Vector3(); function applyLighting(delta = 1) { const factor = delta >= 1 ? 1 : 1 - Math.exp(-delta * 2.25); const numericKeys = [ 'elevation', 'azimuth', 'turbidity', 'rayleigh', 'mieCoefficient', 'mieDirectionalG', 'sunIntensity', 'hemiIntensity', 'ambientIntensity', 'fogDensity', 'exposure', 'night', 'bloom', ]; for (const key of numericKeys) lightingState[key] = THREE.MathUtils.lerp(lightingState[key], lightingTarget[key], factor); lightingState.sunColor.lerp(lightingTarget.sunColor, factor); lightingState.hemiSky.lerp(lightingTarget.hemiSky, factor); lightingState.hemiGround.lerp(lightingTarget.hemiGround, factor); lightingState.fogColor.lerp(lightingTarget.fogColor, factor); skyUniforms.turbidity.value = lightingState.turbidity; skyUniforms.rayleigh.value = lightingState.rayleigh; skyUniforms.mieCoefficient.value = lightingState.mieCoefficient; skyUniforms.mieDirectionalG.value = lightingState.mieDirectionalG; const phi = THREE.MathUtils.degToRad(90 - lightingState.elevation); const theta = THREE.MathUtils.degToRad(lightingState.azimuth); sunPosition.setFromSphericalCoords(1, phi, theta); skyUniforms.sunPosition.value.copy(sunPosition); sunLight.position.copy(sunPosition).multiplyScalar(420); sunLight.position.y = Math.max(18, sunLight.position.y); sunLight.color.copy(lightingState.sunColor); sunLight.intensity = lightingState.sunIntensity; hemisphereLight.color.copy(lightingState.hemiSky); hemisphereLight.groundColor.copy(lightingState.hemiGround); hemisphereLight.intensity = lightingState.hemiIntensity; ambientLight.intensity = lightingState.ambientIntensity; scene.fog.color.copy(lightingState.fogColor); scene.fog.density = lightingState.fogDensity; renderer.toneMappingExposure = lightingState.exposure; bloomPass.strength = lightingState.bloom; } applyLighting(); const CAMERA_VIEWS = { skyline: { position: new THREE.Vector3(224, 132, 292), target: new THREE.Vector3(-8, 44, -6), }, street: { position: new THREE.Vector3(29, 8.2, 196), target: new THREE.Vector3(28, 6.4, 66), }, harbor: { position: new THREE.Vector3(-246, 82, 344), target: new THREE.Vector3(-10, 52, 34), }, }; const tourCurve = new THREE.CatmullRomCurve3([ new THREE.Vector3(250, 122, 300), new THREE.Vector3(-35, 68, 332), new THREE.Vector3(-278, 105, 126), new THREE.Vector3(-225, 118, -205), new THREE.Vector3(32, 84, -292), new THREE.Vector3(268, 112, -82), ], true, 'catmullrom', 0.42); let transition = null; let tourActive = false; let tourProgress = 0.045; let city; let selectedHelper = null; let pointerOrigin = null; let adaptiveSampleTime = 0; let adaptiveFrames = 0; let qualityReduced = false; let shadowRefresh = 0; function setLoading(value, label) { loadingProgress.style.width = `${Math.round(value * 100)}%`; if (label) loadingLabel.textContent = label; } function startCameraTransition(view, duration = 1.65) { const destination = typeof view === 'string' ? CAMERA_VIEWS[view] : view; tourActive = false; controls.enabled = true; $('#tour-toggle').classList.remove('is-active'); $('#tour-toggle').setAttribute('aria-label', 'Start cinematic tour'); transition = { elapsed: 0, duration: reducedMotion ? 0.01 : duration, fromPosition: camera.position.clone(), toPosition: destination.position.clone(), fromTarget: controls.target.clone(), toTarget: destination.target.clone(), }; } function easeInOutCubic(value) { return value < 0.5 ? 4 * value ** 3 : 1 - ((-2 * value + 2) ** 3) / 2; } function setView(name) { document.querySelectorAll('[data-view]').forEach((button) => { button.classList.toggle('is-active', button.dataset.view === name); }); intro.classList.add('is-hidden'); hint.classList.add('is-hidden'); startCameraTransition(name); } function toggleTour(force) { tourActive = force ?? !tourActive; transition = null; const button = $('#tour-toggle'); button.classList.toggle('is-active', tourActive); button.setAttribute('aria-label', tourActive ? 'Stop cinematic tour' : 'Start cinematic tour'); if (tourActive) { intro.classList.add('is-hidden'); hint.classList.add('is-hidden'); controls.enabled = false; } else { controls.enabled = true; } } function setTime(name) { activeTime = name; lightingTarget = cloneLighting(LIGHTING[name]); scene.environment = ENVIRONMENTS[name]; renderer.shadowMap.needsUpdate = true; $('#time-label').textContent = LIGHTING[name].label; $('#condition-time').textContent = LIGHTING[name].clock; $('#condition-weather').textContent = LIGHTING[name].weather; document.querySelectorAll('[data-time]').forEach((button) => { button.classList.toggle('is-active', button.dataset.time === name); }); } function clearSelection() { buildingCard.classList.remove('is-visible'); if (selectedHelper) { scene.remove(selectedHelper); selectedHelper.geometry.dispose(); selectedHelper.material.dispose(); selectedHelper = null; } } function showBuilding(metadata) { clearSelection(); $('#building-district').textContent = metadata.district; $('#building-name').textContent = metadata.name; $('#building-height').textContent = `${metadata.height} m`; $('#building-floors').textContent = metadata.floors; $('#building-year').textContent = metadata.year; buildingCard.classList.add('is-visible'); selectedHelper = new THREE.Box3Helper(metadata.bounds.clone().expandByScalar(0.55), 0xc6e7ff); selectedHelper.material.transparent = true; selectedHelper.material.opacity = 0.74; selectedHelper.material.depthTest = false; selectedHelper.renderOrder = 10; scene.add(selectedHelper); } const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); function selectAt(event) { const rect = renderer.domElement.getBoundingClientRect(); pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(pointer, camera); const hits = raycaster.intersectObjects(city.selectables, false); const metadata = city.getBuildingFromIntersection(hits[0]); if (metadata) showBuilding(metadata); else clearSelection(); } function handleResize() { const width = window.innerWidth; const height = window.innerHeight; if (!qualityReduced) { renderPixelRatio = preferredPixelRatio(); renderer.setPixelRatio(renderPixelRatio); composer.setPixelRatio(renderPixelRatio); } camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height); composer.setSize(width, height); bloomPass.setSize(width, height); } function lowerQuality() { if (qualityReduced) return; qualityReduced = true; renderPixelRatio = Math.max(1, renderPixelRatio * 0.76); renderer.setPixelRatio(renderPixelRatio); composer.setPixelRatio(renderPixelRatio); bloomPass.enabled = !mobile; qualityBadge.querySelector('b').textContent = 'Balanced'; qualityBadge.querySelector('span').style.background = '#e4be76'; } function wireInterface() { document.querySelectorAll('[data-view]').forEach((button) => { button.addEventListener('click', () => setView(button.dataset.view)); }); document.querySelectorAll('[data-time]').forEach((button) => { button.addEventListener('click', () => setTime(button.dataset.time)); }); $('#begin-explore').addEventListener('click', () => setView('street')); $('#tour-toggle').addEventListener('click', () => toggleTour()); $('#close-building').addEventListener('click', clearSelection); const toggleHelp = (show) => { const visible = show ?? !helpPanel.classList.contains('is-visible'); helpPanel.classList.toggle('is-visible', visible); $('#help-toggle').classList.toggle('is-active', visible); }; $('#help-toggle').addEventListener('click', () => toggleHelp()); $('#help-close').addEventListener('click', () => toggleHelp(false)); renderer.domElement.addEventListener('pointerdown', (event) => { pointerOrigin = { x: event.clientX, y: event.clientY, time: performance.now() }; hint.classList.add('is-hidden'); if (tourActive) toggleTour(false); }, { capture: true }); renderer.domElement.addEventListener('pointerup', (event) => { if (!pointerOrigin) return; const distance = Math.hypot(event.clientX - pointerOrigin.x, event.clientY - pointerOrigin.y); const elapsed = performance.now() - pointerOrigin.time; if (distance < 5 && elapsed < 450) selectAt(event); pointerOrigin = null; }); renderer.domElement.addEventListener('pointercancel', () => { pointerOrigin = null; }); controls.addEventListener('start', () => { transition = null; intro.classList.add('is-hidden'); }); window.addEventListener('keydown', (event) => { if (event.key === '1') setView('skyline'); if (event.key === '2') setView('street'); if (event.key === '3') setView('harbor'); if (event.key.toLowerCase() === 't') toggleTour(); if (event.key.toLowerCase() === 'h') toggleHelp(); if (event.key === 'Escape') { clearSelection(); toggleHelp(false); } }); window.addEventListener('resize', handleResize, { passive: true }); } const timer = new THREE.Timer(); timer.connect(document); function animate(timestamp) { if (document.hidden) return; timer.update(timestamp); const delta = Math.min(timer.getDelta(), 0.05); const elapsed = timer.getElapsed(); if (transition) { transition.elapsed += delta; const progress = Math.min(1, transition.elapsed / transition.duration); const eased = easeInOutCubic(progress); camera.position.lerpVectors(transition.fromPosition, transition.toPosition, eased); controls.target.lerpVectors(transition.fromTarget, transition.toTarget, eased); if (progress >= 1) transition = null; } else if (tourActive) { tourProgress = (tourProgress + delta / 54) % 1; tourCurve.getPointAt(tourProgress, camera.position); const lead = tourCurve.getPointAt((tourProgress + 0.025) % 1); const cityFocus = new THREE.Vector3(0, 46 + Math.sin(tourProgress * Math.PI * 2) * 14, 0); controls.target.copy(lead.lerp(cityFocus, 0.68)); } controls.update(); applyLighting(delta); const lightingChanging = Math.abs(lightingState.elevation - lightingTarget.elevation) > 0.025 || Math.abs(lightingState.azimuth - lightingTarget.azimuth) > 0.025; shadowRefresh += delta; if (lightingChanging && shadowRefresh > 0.12) { renderer.shadowMap.needsUpdate = true; shadowRefresh = 0; } city.setNightLevel(lightingState.night); city.update(elapsed, delta); composer.render(); adaptiveSampleTime += delta; adaptiveFrames += 1; if (!qualityReduced && adaptiveSampleTime > 6) { const fps = adaptiveFrames / adaptiveSampleTime; if (fps < 43) lowerQuality(); adaptiveSampleTime = 0; adaptiveFrames = 0; } } async function init() { setLoading(0.06, 'Preparing atmosphere'); await new Promise(requestAnimationFrame); city = new Cityscape(scene, { mobile, onProgress: (value, label) => setLoading(0.08 + value * 0.76, label), }); $('#building-count').textContent = city.buildings.length; $('#traffic-count').textContent = city.traffic.count; city.setNightLevel(lightingState.night); setLoading(0.9, 'Compiling city lights'); await renderer.compileAsync(scene, camera); renderer.shadowMap.autoUpdate = false; renderer.shadowMap.needsUpdate = true; setLoading(1, 'City online'); wireInterface(); renderer.setAnimationLoop(animate); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); loading.classList.add('is-complete'); interfaceLayer.classList.add('is-ready'); if (reducedMotion) { camera.position.copy(CAMERA_VIEWS.skyline.position); controls.target.copy(CAMERA_VIEWS.skyline.target); intro.classList.add('is-hidden'); } else { startCameraTransition('skyline', 4.2); } } init().catch((error) => { console.error(error); loadingLabel.textContent = 'Unable to build scene'; loadingProgress.style.background = '#db7272'; });