import * as THREE from 'three'; import { nearestSegment, DRIVABLE_MAX_TYPE } from './routing.js'; import { blocked, insidePolygon } from './collision.js'; // Road widths in metres per OSM highway type. Dual carriageways are two separate one-way ways. const WIDTH = { trunk: 11, primary: 10, secondary: 9, tertiary: 8, unclassified: 6.5, residential: 6, living_street: 5, service: 4, pedestrian: 4, footway: 2.4, cycleway: 2, path: 1.6, track: 3, steps: 2.2, }; const BUCKET = { trunk: 'major', primary: 'major', secondary: 'major', tertiary: 'minor', unclassified: 'minor', residential: 'minor', living_street: 'minor', service: 'minor', pedestrian: 'paved', footway: 'paved', cycleway: 'paved', steps: 'paved', path: 'dirt', track: 'dirt', }; // Ground layers: height above the ground plane and polygon offset, so overlaps never flicker. const LAYER = { industrial: [0.006, 1], farmland: [0.008, 1], campus: [0.01, 1], park: [0.02, 2], grass: [0.025, 3], forest: [0.025, 3], pitch: [0.03, 4], water: [0.035, 5], sidewalk: [0.04, 6], dirt: [0.045, 7], paved: [0.05, 8], minor: [0.06, 9], major: [0.07, 10], }; const AREA_COLOR = { campus: '#d8ccb2', park: '#7a9a55', grass: '#8aa660', forest: '#5d7f47', pitch: '#5f9a58', water: '#4f93a8', industrial: '#bdb6a6', farmland: '#a8a46e', }; const BUILDING_COLORS = [ '#dccbaa', '#e7dcc4', '#c9a585', '#bdb4a3', '#ece6da', '#d2b48f', '#b7957a', ].map((c) => new THREE.Color(c)); function canvasTexture(width, height, draw, repeat = true) { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; draw(canvas.getContext('2d'), width, height); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; if (repeat) texture.wrapS = texture.wrapT = THREE.RepeatWrapping; texture.anisotropy = 8; return texture; } function noise(c, w, h, base, spread, count) { c.fillStyle = base; c.fillRect(0, 0, w, h); for (let i = 0; i < count; i++) { const v = Math.random() * spread - spread / 2; c.fillStyle = v > 0 ? `rgba(255,255,255,${v})` : `rgba(0,0,0,${-v})`; c.fillRect(Math.random() * w, Math.random() * h, 2, 2); } } const textures = () => ({ major: canvasTexture(128, 256, (c, w, h) => { noise(c, w, h, '#4a4d50', 0.12, 1800); c.fillStyle = '#e8e4d6'; c.fillRect(6, 0, 4, h); c.fillRect(w - 10, 0, 4, h); c.fillRect(w / 2 - 2, 0, 4, h * 0.45); }), minor: canvasTexture(128, 256, (c, w, h) => noise(c, w, h, '#58595a', 0.12, 1600)), paved: canvasTexture(128, 128, (c, w, h) => { noise(c, w, h, '#c4b597', 0.1, 900); c.strokeStyle = 'rgba(90,70,50,.25)'; for (let i = 0; i <= 4; i++) { c.beginPath(); c.moveTo(0, i * 32); c.lineTo(w, i * 32); c.moveTo(i * 32 + (i % 2) * 16, 0); c.lineTo(i * 32 + (i % 2) * 16, h); c.stroke(); } }), sidewalk: canvasTexture(128, 128, (c, w, h) => { noise(c, w, h, '#b9b1a1', 0.1, 900); c.fillStyle = '#d9d4c8'; c.fillRect(0, 0, 10, h); c.fillRect(w - 10, 0, 10, h); }), dirt: canvasTexture(64, 64, (c, w, h) => noise(c, w, h, '#b39d78', 0.15, 500)), walls: canvasTexture(128, 128, (c, w, h) => { noise(c, w, h, '#f1ede4', 0.06, 600); const g = c.createLinearGradient(0, 30, 0, 96); g.addColorStop(0, '#7c95a3'); g.addColorStop(1, '#3b4b55'); c.fillStyle = '#8e8679'; c.fillRect(28, 26, 72, 70); c.fillStyle = g; c.fillRect(32, 30, 64, 62); c.fillStyle = 'rgba(255,255,255,.18)'; c.fillRect(34, 32, 18, 58); c.fillStyle = '#8e8679'; c.fillRect(62, 30, 4, 62); c.fillStyle = 'rgba(0,0,0,.12)'; c.fillRect(0, 118, w, 10); }), }); // Pushes a strip of quads along a polyline into a geometry buffer. function ribbon(out, pts, width, y, repeat) { const n = pts.length / 2; if (n < 2) return; const base = out.pos.length / 3, half = width / 2; let along = 0; for (let i = 0; i < n; i++) { const x = pts[i * 2], z = pts[i * 2 + 1]; if (i) along += Math.hypot(x - pts[i * 2 - 2], z - pts[i * 2 - 1]); const seg = (a, b) => { const dx = pts[b * 2] - pts[a * 2], dz = pts[b * 2 + 1] - pts[a * 2 + 1], len = Math.hypot(dx, dz) || 1; return [-dz / len, dx / len]; }; const n1 = seg(Math.max(0, i - 1), Math.max(1, i)), n2 = seg(Math.min(n - 2, i), Math.min(n - 1, i + 1)); let nx = n1[0] + n2[0], nz = n1[1] + n2[1]; const len = Math.hypot(nx, nz) || 1; nx /= len; nz /= len; const miter = half / Math.max(0.45, nx * n1[0] + nz * n1[1]); out.pos.push(x + nx * miter, y, z + nz * miter, x - nx * miter, y, z - nz * miter); out.uv.push(0, along / repeat, 1, along / repeat); if (i) { const a = base + (i - 1) * 2; // Wound so the strip faces up (+y) and survives back-face culling. out.idx.push(a, a + 2, a + 1, a + 1, a + 2, a + 3); } } } function disc(out, x, z, radius, y) { const base = out.pos.length / 3; out.pos.push(x, y, z); out.uv.push(0.25, 0); for (let i = 0; i <= 10; i++) { const a = (i / 10) * Math.PI * 2; out.pos.push(x + Math.cos(a) * radius, y, z + Math.sin(a) * radius); out.uv.push(0.25, 0); if (i) out.idx.push(base, base + i + 1, base + i); } } function polygonTriangles(pts) { const contour = []; for (let i = 0; i < pts.length; i += 2) contour.push(new THREE.Vector2(pts[i], pts[i + 1])); try { return THREE.ShapeUtils.triangulateShape(contour, []); } catch { return []; } } // Flat polygon facing up; triangles are flipped where needed so normals point to the sky. function flatPolygon(out, pts, y, uvScale = 0, color = null) { const base = out.pos.length / 3; for (let i = 0; i < pts.length; i += 2) { out.pos.push(pts[i], y, pts[i + 1]); out.uv?.push(pts[i] * uvScale, pts[i + 1] * uvScale); if (color) out.col.push(color.r, color.g, color.b); } for (const [a, b, c] of polygonTriangles(pts)) { const cross = (pts[b * 2 + 1] - pts[a * 2 + 1]) * (pts[c * 2] - pts[a * 2]) - (pts[b * 2] - pts[a * 2]) * (pts[c * 2 + 1] - pts[a * 2 + 1]); out.idx.push(base + a, ...(cross >= 0 ? [base + b, base + c] : [base + c, base + b])); } } function toMesh(out, material, normals = 'up') { const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.Float32BufferAttribute(out.pos, 3)); if (out.uv?.length) geometry.setAttribute('uv', new THREE.Float32BufferAttribute(out.uv, 2)); if (out.col?.length) geometry.setAttribute('color', new THREE.Float32BufferAttribute(out.col, 3)); geometry.setIndex(out.idx); if (normals === 'up') geometry.setAttribute( 'normal', new THREE.Float32BufferAttribute( new Float32Array(out.pos.length).map((_, i) => (i % 3 === 1 ? 1 : 0)), 3, ), ); else geometry.computeVertexNormals(); geometry.computeBoundingSphere(); const mesh = new THREE.Mesh(geometry, material); mesh.receiveShadow = true; return mesh; } const groundMaterial = (options, layer) => new THREE.MeshStandardMaterial({ roughness: 0.95, ...options, polygonOffset: true, polygonOffsetFactor: -layer, polygonOffsetUnits: -layer * 2, }); function seeded(seed) { return () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; }; } function signedArea(pts) { let a = 0; for (let i = 0; i < pts.length; i += 2) { const j = (i + 2) % pts.length; a += pts[i] * pts[j + 1] - pts[j] * pts[i + 1]; } return a / 2; } // The world is split into 2 km chunks (buildings: 500 m blocks); meshes out of view range are hidden. const CHUNK = 2000, BLOCK = 500; // With satellite imagery the photo shows the real roads and land, so the drawn roads and areas are // left out; `extraBuildings` are footprints beyond OSM (Microsoft's, see buildings-data.js). export function createCity( scene, data, { collision, segments, extraBuildings = [], satellite = false, lowQuality = false }, ) { const VIEW_RANGE = lowQuality ? 1500 : 2400; const tex = textures(), group = new THREE.Group(); scene.add(group); const random = seeded(1405); const chunked = [], buckets = new Map(); const bucket = (name, x, z, make) => { const key = `${name}|${Math.floor(x / CHUNK)},${Math.floor(z / CHUNK)}`; if (!buckets.has(key)) buckets.set(key, { name, out: make() }); return buckets.get(key).out; }; const addChunked = (mesh) => { mesh.geometry.computeBoundingSphere(); chunked.push(mesh); group.add(mesh); return mesh; }; // Open ground that follows the camera, so the horizon never shows an edge. const ground = new THREE.Mesh( new THREE.PlaneGeometry(14000, 14000), groundMaterial({ color: '#c6baa0' }, 0), ); ground.rotation.x = -Math.PI / 2; ground.position.y = -0.02; ground.receiveShadow = true; group.add(ground); for (const area of satellite ? [] : data.areas) { const kind = data.areaKinds[area[0]]; flatPolygon( bucket(`area:${kind}`, area[1], area[2], () => ({ pos: [], idx: [] })), area.slice(1), LAYER[kind][0], ); } const areaMaterials = {}; for (const kind of data.areaKinds) areaMaterials[kind] = groundMaterial( { color: AREA_COLOR[kind], roughness: kind === 'water' ? 0.25 : 0.95, metalness: kind === 'water' ? 0.2 : 0, }, LAYER[kind][1], ); const road = (name, x, z) => bucket(`road:${name}`, x, z, () => ({ pos: [], uv: [], idx: [] })); for (const r of satellite ? [] : data.roads) { const type = data.roadTypes[r[0]], kind = BUCKET[type], pts = r.slice(2), width = WIDTH[type]; ribbon(road(kind, pts[0], pts[1]), pts, width, LAYER[kind][0], kind === 'major' ? 9 : 8); if (r[0] <= DRIVABLE_MAX_TYPE) { if (type !== 'service') ribbon(road('sidewalk', pts[0], pts[1]), pts, width + 4, LAYER.sidewalk[0], 6); disc(road(kind, pts[0], pts[1]), pts[0], pts[1], width / 2, LAYER[kind][0]); disc(road(kind, pts[0], pts[1]), pts.at(-2), pts.at(-1), width / 2, LAYER[kind][0]); } } const roadMaterials = {}; for (const kind of ['major', 'minor', 'paved', 'dirt', 'sidewalk']) roadMaterials[kind] = groundMaterial({ map: tex[kind] }, LAYER[kind][1]); // Buildings: textured walls with one window per 3.6 m bay and 3.2 m storey, flat roofs. There are // ~145,000 of them (OSM plus Microsoft footprints), so each 500 m block is built only when the // camera first comes near it, one block per frame. const wallMaterial = new THREE.MeshStandardMaterial({ map: tex.walls, vertexColors: true, roughness: 0.85, }); const roofMaterial = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.9 }); const blocks = new Map(); for (const building of [...data.buildings, ...extraBuildings]) { const key = `${Math.floor(building[1] / BLOCK)},${Math.floor(building[2] / BLOCK)}`; if (!blocks.has(key)) { const [bx, bz] = key.split(',').map(Number); blocks.set(key, { x: (bx + 0.5) * BLOCK, z: (bz + 0.5) * BLOCK, buildings: [], meshes: null, }); } blocks.get(key).buildings.push(building); } const pendingBlocks = [...blocks.values()]; function buildBlock(block) { const walls = { pos: [], uv: [], col: [], idx: [] }, roofs = { pos: [], col: [], idx: [] }; for (const building of block.buildings) { const height = building[0]; let pts = building.slice(1); if (signedArea(pts) > 0) { const reversed = []; for (let i = pts.length - 2; i >= 0; i -= 2) reversed.push(pts[i], pts[i + 1]); pts = reversed; } // Colour picked from the position, so a block looks the same every time it is built. const shade = Math.abs(Math.sin(pts[0] * 12.9898 + pts[1] * 78.233) * 43758.5453) % 1; const color = BUILDING_COLORS[Math.floor(shade * BUILDING_COLORS.length)] .clone() .multiplyScalar(0.9 + ((shade * 7) % 1) * 0.15); let along = 0; for (let i = 0; i < pts.length; i += 2) { const j = (i + 2) % pts.length, ax = pts[i], az = pts[i + 1], bx = pts[j], bz = pts[j + 1]; const len = Math.hypot(bx - ax, bz - az), base = walls.pos.length / 3; walls.pos.push(ax, 0, az, bx, 0, bz, bx, height, bz, ax, height, az); walls.uv.push( along / 3.6, 0, (along + len) / 3.6, 0, (along + len) / 3.6, height / 3.2, along / 3.6, height / 3.2, ); for (let k = 0; k < 4; k++) walls.col.push(color.r, color.g, color.b); walls.idx.push(base, base + 1, base + 2, base, base + 2, base + 3); along += len; } flatPolygon(roofs, pts, height, 0, color.clone().multiplyScalar(0.78)); } block.meshes = [toMesh(walls, wallMaterial, 'compute'), toMesh(roofs, roofMaterial)]; for (const mesh of block.meshes) { mesh.castShadow = true; addChunked(mesh); } } // Rooftop water tanks and air coolers, typical of Mashhad apartment blocks, on the OSM buildings. const tanks = [], units = []; for (const building of data.buildings) { const height = building[0], pts = building.slice(1); let cx = 0, cz = 0; for (let i = 0; i < pts.length; i += 2) { cx += pts[i]; cz += pts[i + 1]; } cx /= pts.length / 2; cz /= pts.length / 2; if (insidePolygon(pts, cx, cz) && random() < 0.6) { (random() < 0.5 ? tanks : units).push([ cx + (random() - 0.5) * 2, height, random() * Math.PI, cz + (random() - 0.5) * 2, 0, ]); } } for (const { name, out } of buckets.values()) { const [kind, sub] = name.split(':'); addChunked(kind === 'area' ? toMesh(out, areaMaterials[sub]) : toMesh(out, roadMaterials[sub])); } const dummy = new THREE.Object3D(); // One instanced mesh per chunk, so small props are culled with the rest of their chunk. // Each item is an array whose first entry is x and second-to-last entry is z. const instanced = (geometry, material, items, place) => { const byChunk = new Map(); items.forEach((item, i) => { const key = `${Math.floor(item[0] / CHUNK)},${Math.floor(item.at(-2) / CHUNK)}`; if (!byChunk.has(key)) byChunk.set(key, []); byChunk.get(key).push([item, i]); }); const meshes = []; for (const list of byChunk.values()) { const mesh = new THREE.InstancedMesh(geometry, material, list.length); list.forEach(([item], i) => { place(item); dummy.updateMatrix(); mesh.setMatrixAt(i, dummy.matrix); }); mesh.castShadow = mesh.receiveShadow = true; mesh.computeBoundingSphere(); mesh.userData.items = list.map(([, i]) => i); chunked.push(mesh); group.add(mesh); meshes.push(mesh); } return meshes; }; instanced( new THREE.CylinderGeometry(0.55, 0.55, 1.1, 10), new THREE.MeshStandardMaterial({ color: '#d8d8d2', metalness: 0.4, roughness: 0.4 }), tanks, ([x, y, , z]) => { dummy.position.set(x, y + 0.55, z); dummy.rotation.set(0, 0, 0); dummy.scale.setScalar(1); }, ); instanced( new THREE.BoxGeometry(1, 0.8, 0.9), new THREE.MeshStandardMaterial({ color: '#a9aaa4', roughness: 0.6 }), units, ([x, y, r, z]) => { dummy.position.set(x, y + 0.4, z); dummy.rotation.set(0, r, 0); dummy.scale.setScalar(1); }, ); // Trees fill parks and line the boulevards, kept off paths and out of buildings. const trees = []; const freeSpot = (x, z) => !blocked(collision, x, z) && !nearestSegment(segments, x, z, 3); for (const area of data.areas) { const kind = data.areaKinds[area[0]]; if (!['park', 'forest', 'grass'].includes(kind)) continue; const pts = area.slice(1), step = kind === 'grass' ? 16 : 10; let ax = Infinity, az = Infinity, bx = -Infinity, bz = -Infinity; for (let i = 0; i < pts.length; i += 2) { ax = Math.min(ax, pts[i]); bx = Math.max(bx, pts[i]); az = Math.min(az, pts[i + 1]); bz = Math.max(bz, pts[i + 1]); } for (let x = ax; x < bx; x += step) for (let z = az; z < bz; z += step) { const tx = x + (random() - 0.5) * step * 0.8, tz = z + (random() - 0.5) * step * 0.8; if (insidePolygon(pts, tx, tz) && freeSpot(tx, tz)) trees.push([tx, 0.8 + random() * 0.6, tz, 0]); } } for (const road of data.roads) { const type = data.roadTypes[road[0]]; if (road[0] > 3) continue; const offset = WIDTH[type] / 2 + 2.6; for (let i = 4; i < road.length; i += 2) { const ax = road[i - 2], az = road[i - 1], dx = road[i] - ax, dz = road[i + 1] - az, len = Math.hypot(dx, dz); for (let d = random() * 12; d < len; d += 12) for (const side of [-1, 1]) { const tx = ax + (dx / len) * d - (dz / len) * offset * side, tz = az + (dz / len) * d + (dx / len) * offset * side; if ( !blocked(collision, tx, tz) && !nearestSegment( segments, tx, tz, WIDTH[type] / 2 + 1.5, (s) => s[4] <= DRIVABLE_MAX_TYPE, ) ) trees.push([tx, 0.7 + random() * 0.4, tz, 0]); } } } instanced( new THREE.CylinderGeometry(0.14, 0.22, 2.6, 5, 1, true), new THREE.MeshStandardMaterial({ color: '#6a5039', roughness: 0.9 }), trees, ([x, s, z]) => { dummy.position.set(x, 1.3 * s, z); dummy.rotation.set(0, 0, 0); dummy.scale.setScalar(s); }, ); const crowns = instanced( new THREE.IcosahedronGeometry(1.7, 0), new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.85, flatShading: true }), trees, ([x, s, z]) => { dummy.position.set(x, 3.4 * s, z); dummy.rotation.set(0, (x * 13) % 6, 0); dummy.scale.set(s, s * (1 + ((x * 7) % 1) * 0.3), s); }, ); const crownColors = ['#4d7a3e', '#5f8a45', '#3f6b3a', '#6f9150'].map((c) => new THREE.Color(c)); for (const mesh of crowns) mesh.userData.items.forEach((tree, i) => mesh.setColorAt(i, crownColors[tree % crownColors.length]), ); // Metro station entrances with the Mashhad Urban Railway "M" sign. const signTexture = canvasTexture( 128, 128, (c, w, h) => { c.fillStyle = '#d2232a'; c.beginPath(); c.arc(w / 2, h / 2, 60, 0, Math.PI * 2); c.fill(); c.fillStyle = '#fff'; c.font = 'bold 84px sans-serif'; c.textAlign = 'center'; c.fillText('M', w / 2, h / 2 + 30); }, false, ); const canopy = new THREE.MeshStandardMaterial({ color: '#59646b', metalness: 0.5, roughness: 0.4, }); const glass = new THREE.MeshStandardMaterial({ color: '#9fc4cf', transparent: true, opacity: 0.45, roughness: 0.1, }); for (const label of data.labels.filter((l) => l.kind === 'metro')) { const entrance = new THREE.Group(); entrance.position.set(label.x, 0, label.z); const road = nearestSegment(segments, label.x, label.z, 40, (s) => s[4] <= DRIVABLE_MAX_TYPE); if (road) entrance.rotation.y = Math.atan2(road.dx, road.dz); const box = (w, h, d, m, x, y, z) => { const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m); mesh.position.set(x, y, z); mesh.castShadow = mesh.receiveShadow = true; entrance.add(mesh); }; box(4.4, 0.2, 7, canopy, 0, 3, 0); box(0.15, 3, 7, glass, -2.1, 1.5, 0); box(0.15, 3, 7, glass, 2.1, 1.5, 0); box(0.25, 4.2, 0.25, canopy, 2.6, 2.1, 3.6); const sign = new THREE.Mesh( new THREE.CircleGeometry(0.6, 24), new THREE.MeshBasicMaterial({ map: signTexture }), ); sign.position.set(2.6, 4.4, 3.75); entrance.add(sign); const back = sign.clone(); back.rotation.y = Math.PI; back.position.z = 3.45; entrance.add(back); group.add(entrance); } // A continuous ridge line around the horizon: the Binalood range high to the south-west, // the lower Hezar Masjed hills to the north-east. Hazy colours stand in for distance fog. const ridgePos = [], ridgeCol = [], ridgeIdx = [], segmentsAround = 240; const foot = new THREE.Color('#cfc5b0'), crest = new THREE.Color('#a39a8a'); for (let i = 0; i <= segmentsAround; i++) { const a = (i / segmentsAround) * Math.PI * 2, x = Math.cos(a), z = Math.sin(a); // South-west is −x (west) and +z (south); peaks grow with how far this direction points there. const southWest = Math.max(0, (-x + z) / Math.SQRT2), rough = Math.sin(a * 7) * 0.5 + Math.sin(a * 17 + 1) * 0.3 + Math.sin(a * 41) * 0.15; const height = 90 + southWest ** 1.5 * 420 + rough * (60 + southWest * 120), r = 4300; ridgePos.push(x * r, -20, z * r, x * r, height, z * r); ridgeCol.push(foot.r, foot.g, foot.b, crest.r, crest.g, crest.b); if (i) { const b = (i - 1) * 2; ridgeIdx.push(b, b + 1, b + 2, b + 1, b + 3, b + 2); } } const ridgeGeometry = new THREE.BufferGeometry(); ridgeGeometry.setAttribute('position', new THREE.Float32BufferAttribute(ridgePos, 3)); ridgeGeometry.setAttribute('color', new THREE.Float32BufferAttribute(ridgeCol, 3)); ridgeGeometry.setIndex(ridgeIdx); const ridge = new THREE.Mesh( ridgeGeometry, new THREE.MeshBasicMaterial({ vertexColors: true, fog: false, side: THREE.DoubleSide }), ); group.add(ridge); let lastCheck = null; return { group, // Keeps the ground and horizon around the camera and shows only chunks within range. update(camera) { ground.position.set(camera.x, -0.02, camera.z); ridge.position.set(camera.x, 0, camera.z); // Build the nearest unbuilt block in range, one per frame to avoid stutter. let next = null, nextDistance = VIEW_RANGE + BLOCK; for (const block of pendingBlocks) { const d = Math.hypot(block.x - camera.x, block.z - camera.z); if (d < nextDistance) { next = block; nextDistance = d; } } if (next) { pendingBlocks.splice(pendingBlocks.indexOf(next), 1); buildBlock(next); lastCheck = null; } if (lastCheck && Math.hypot(camera.x - lastCheck.x, camera.z - lastCheck.z) < 60) return; lastCheck = { x: camera.x, z: camera.z }; for (const mesh of chunked) { const sphere = mesh.boundingSphere ?? mesh.geometry.boundingSphere; mesh.visible = Math.hypot(sphere.center.x - camera.x, sphere.center.z - camera.z) - sphere.radius < VIEW_RANGE; } }, }; }