lumen-harbor / src /main.js
Mike0021's picture
Blend atmospheric horizon for final blue-hour look
be3d999 verified
Raw History Blame Contribute Delete
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';
});