improved osm renderning: full building outlines, roads, greenspace, water

This commit is contained in:
Evan Scamehorn
2025-11-26 16:09:03 -06:00
parent c4b29f5a20
commit 059a58fbf7
3 changed files with 288 additions and 162 deletions

View File

@@ -1,64 +1,56 @@
import * as THREE from 'three';
import { MapControls } from 'three/addons/controls/MapControls.js';
import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
// ==========================================
// 1. Configuration & Constants
// ==========================================
const SETTINGS = {
colors: {
background: 0xcccccc,
ground: 0x999999,
building: 0xccffff,
background: 0xe0e0e0, // Lighter fog
ground: 0xdddddd,
building: 0xffffff,
water: 0x4fa4e4,
park: 0x98d98e,
road: 0x999999,
sun: 0xffffff,
ambient: 0x444444,
ambient: 0x777777,
},
camera: {
fov: 60,
near: 0.1,
fov: 45, // Narrower FOV for better city look
near: 1,
far: 20000,
initialPos: { x: 500, y: 400, z: 400 }
initialPos: { x: 0, y: 600, z: 800 }
},
shadows: {
enabled: true,
mapSize: 4096,
areaSize: 2000, // Size of the shadow camera view
bias: -0.0005
areaSize: 1500,
bias: -0.0001
},
dataUrl: './city_data.json'
};
// Global variables for core components
let scene, camera, renderer, controls;
let sunLight; // Needs to be global to update position in render loop
let scene, camera, renderer, controls, sunLight;
// ==========================================
// 2. Initialization Logic
// 2. Initialization
// ==========================================
function init() {
// Setup core Three.js components
setupScene();
setupLighting();
createGround();
// Initialize controls
setupControls();
// Load external data
loadCityData();
// Start event listeners and loop
window.addEventListener('resize', onWindowResize);
animate();
}
/**
* Sets up the Scene, Camera, and Renderer.
*/
function setupScene() {
scene = new THREE.Scene();
scene.background = new THREE.Color(SETTINGS.colors.background);
// Fog blends the floor into the background at distance for depth perception
scene.fog = new THREE.FogExp2(SETTINGS.colors.background, 0.0001);
camera = new THREE.PerspectiveCamera(
@@ -75,7 +67,7 @@ function setupScene() {
renderer = new THREE.WebGLRenderer({
antialias: true,
logarithmicDepthBuffer: true // Prevents z-fighting on large scale scenes
logarithmicDepthBuffer: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = SETTINGS.shadows.enabled;
@@ -89,24 +81,14 @@ function setupScene() {
* Configures shadow properties for the directional light.
*/
function setupLighting() {
// Ambient light for general illumination (so shadows aren't pitch black)
const hemiLight = new THREE.HemisphereLight(
SETTINGS.colors.sun,
SETTINGS.colors.ambient,
0.6
);
scene.add(hemiLight);
const ambient = new THREE.HemisphereLight(SETTINGS.colors.sun, SETTINGS.colors.ground, 0.6);
scene.add(ambient);
// Directional light acting as the Sun
sunLight = new THREE.DirectionalLight(SETTINGS.colors.sun, 1.5);
sunLight.position.set(200, 400, 100);
sunLight = new THREE.DirectionalLight(SETTINGS.colors.sun, 2.0);
sunLight.position.set(100, 500, 200);
sunLight.castShadow = true;
// Optimize shadow quality
sunLight.shadow.mapSize.width = SETTINGS.shadows.mapSize;
sunLight.shadow.mapSize.height = SETTINGS.shadows.mapSize;
// Define the box in which shadows are calculated
sunLight.shadow.mapSize.set(SETTINGS.shadows.mapSize, SETTINGS.shadows.mapSize);
const d = SETTINGS.shadows.areaSize;
sunLight.shadow.camera.left = -d;
sunLight.shadow.camera.right = d;
@@ -114,93 +96,185 @@ function setupLighting() {
sunLight.shadow.camera.bottom = -d;
sunLight.shadow.camera.near = 0.5;
sunLight.shadow.camera.far = 4000;
sunLight.shadow.bias = SETTINGS.shadows.bias; // Reduces shadow artifacts (striping)
sunLight.shadow.camera.far = 4500;
sunLight.shadow.bias = SETTINGS.shadows.bias;
scene.add(sunLight);
}
function setupControls() {
controls = new MapControls(camera, renderer.domElement);
controls.enableDamping = true; // Adds weight to movement for smoother feel
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.target.set(-100, 0, 200);
controls.minDistance = 50;
controls.maxDistance = 3000;
controls.maxPolarAngle = Math.PI / 2 - 0.1; // Don't go below ground
}
// ==========================================
// 3. Scene Content Generation
// ==========================================
function createGround() {
const geometry = new THREE.PlaneGeometry(5000, 5000);
const material = new THREE.MeshStandardMaterial({
color: SETTINGS.colors.ground
});
const ground = new THREE.Mesh(geometry, material);
ground.rotation.x = -Math.PI / 2; // Rotate to lie flat
ground.receiveShadow = true;
scene.add(ground);
const geom = new THREE.PlaneGeometry(10000, 10000);
const mat = new THREE.MeshLambertMaterial({ color: SETTINGS.colors.ground });
const mesh = new THREE.Mesh(geom, mat);
mesh.rotation.x = -Math.PI / 2;
mesh.position.y = -0.5; // Slightly below features
mesh.receiveShadow = true;
scene.add(mesh);
}
// ==========================================
// 3. Data Processing & Mesh Generation
// ==========================================
function loadCityData() {
fetch(SETTINGS.dataUrl)
.then(res => {
if (!res.ok) throw new Error(`HTTP Error: ${res.status}`);
return res.json();
.then(res => res.json())
.then(data => {
console.log("City data loaded. Generating geometry...");
generateCity(data);
})
.then(buildingData => {
generateCityMesh(buildingData);
})
.catch(error => {
console.error("Failed to load city data:", error);
// Optional: Add UI feedback here
});
.catch(err => console.error("Error loading city data:", err));
}
function generateCity(data) {
// 1. Water (Flat Polygons)
if (data.water && data.water.length) {
createPolygonLayer(data.water, SETTINGS.colors.water, 0, 0.1);
}
// 2. Parks (Flat Polygons)
if (data.parks && data.parks.length) {
createPolygonLayer(data.parks, SETTINGS.colors.park, 0, 0.2);
}
// 3. Roads (Lines)
if (data.roads && data.roads.length) {
createRoadLayer(data.roads, SETTINGS.colors.road, 0.3);
}
// 4. Buildings (Extruded Polygons)
if (data.buildings && data.buildings.length) {
createBuildingLayer(data.buildings, SETTINGS.colors.building);
}
}
/**
* efficiently renders thousands of buildings using InstancedMesh.
* @param {Array} data - Array of arrays [x, z, width, depth, height]
* Creates flat meshes for things like water or parks.
* Uses BufferGeometryUtils to merge them into one draw call.
*/
function generateCityMesh(data) {
if (!data || data.length === 0) return;
function createPolygonLayer(items, color, height, yOffset) {
const geometries = [];
// Create a single geometry template
const geometry = new THREE.BoxGeometry(1, 1, 1);
// Shift pivot point to bottom of box so scaling grows upwards
geometry.translate(0, 0.5, 0);
items.forEach(item => {
const shape = createShapeFromPoints(item.shape);
const geometry = new THREE.ShapeGeometry(shape);
// Rotate to lie flat on XZ plane
geometry.rotateX(-Math.PI / 2);
geometry.translate(0, yOffset, 0); // Lift slightly to avoid z-fighting
geometries.push(geometry);
});
if (geometries.length === 0) return;
const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries);
const material = new THREE.MeshLambertMaterial({ color: color, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(mergedGeometry, material);
mesh.receiveShadow = true;
scene.add(mesh);
}
/**
* Creates extruded meshes for buildings.
*/
function createBuildingLayer(buildings, color) {
const geometries = [];
buildings.forEach(b => {
const shape = createShapeFromPoints(b.shape);
// Extrude Settings
const extrudeSettings = {
depth: b.height,
bevelEnabled: false,
};
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
geometry.rotateX(-Math.PI / 2);
geometries.push(geometry);
});
if (geometries.length === 0) return;
const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries);
// Center the geometry to optimize bounding sphere calculations
mergedGeometry.computeBoundingSphere();
const material = new THREE.MeshStandardMaterial({
color: SETTINGS.colors.building,
roughness: 0.5,
color: color,
roughness: 0.6,
metalness: 0.1
});
const instancedMesh = new THREE.InstancedMesh(geometry, material, data.length);
instancedMesh.castShadow = true;
instancedMesh.receiveShadow = true;
const mesh = new THREE.Mesh(mergedGeometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
}
// Disable frustum culling to prevent flickering if the bounding sphere
// calculation is off for the entire group. Re-enable if performance is an issue.
instancedMesh.frustumCulled = false;
/**
* Creates simple lines for roads.
* Note: THREE.Line doesn't cast shadows easily, but it's performant.
*/
function createRoadLayer(roads, color, yOffset) {
const positions = [];
const transformHelper = new THREE.Object3D();
data.forEach((building, index) => {
const [posX, posZ, width, depth, height] = building;
transformHelper.position.set(posX, 0, posZ);
transformHelper.scale.set(width, height, depth);
transformHelper.updateMatrix();
instancedMesh.setMatrixAt(index, transformHelper.matrix);
roads.forEach(road => {
const path = road.path;
for (let i = 0; i < path.length - 1; i++) {
positions.push(path[i][0], path[i][1], 0);
positions.push(path[i + 1][0], path[i + 1][1], 0);
}
});
scene.add(instancedMesh);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.rotateX(-Math.PI / 2);
geometry.translate(0, yOffset, 0);
const material = new THREE.LineBasicMaterial({
color: color,
opacity: 0.8,
transparent: true
});
const lineSegments = new THREE.LineSegments(geometry, material);
scene.add(lineSegments);
}
/**
* Helper to convert array of [x,z] points into a THREE.Shape
*/
function createShapeFromPoints(points) {
const shape = new THREE.Shape();
if (!points || points.length === 0) return shape;
// First point
shape.moveTo(points[0][0], points[0][1]);
// Subsequent points
for (let i = 1; i < points.length; i++) {
shape.lineTo(points[i][0], points[i][1]);
}
return shape;
}
// ==========================================
// 4. Animation & Event Loop
// 4. Animation Loop
// ==========================================
function onWindowResize() {
@@ -210,13 +284,10 @@ function onWindowResize() {
}
function updateSunPosition() {
// Move the light with the camera to simulate a "sun" that always
// casts high-res shadows near the player (Pseudo-Cascaded Shadow Map)
if (sunLight && camera) {
// Keep shadow map centered around camera
sunLight.position.x = camera.position.x + 100;
sunLight.position.z = camera.position.z + 100;
// Ensure the light points at the camera's ground position
sunLight.position.z = camera.position.z + 200;
sunLight.target.position.set(camera.position.x, 0, camera.position.z);
sunLight.target.updateMatrixWorld();
}
@@ -224,12 +295,10 @@ function updateSunPosition() {
function animate() {
requestAnimationFrame(animate);
controls.update();
updateSunPosition();
renderer.render(scene, camera);
}
// Start the application
// Start
init();