Spaces:
Paused
Paused
Download src/main.js from Mike0021/lumen-harbor: direct link, hf CLI and curl.
- Browser
- Download file 19.5 kB
-
https://huggingface.co/spaces/Mike0021/lumen-harbor/resolve/main/src/main.js
- Command line
-
hf download hf://spaces/Mike0021/lumen-harbor/src/main.js
-
curl -L -o main.js https://huggingface.co/spaces/Mike0021/lumen-harbor/resolve/main/src/main.js
19.5 kB
| 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'; | |
| }); | |