1017 lines
30 KiB
JavaScript
1017 lines
30 KiB
JavaScript
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));
|
||
/**
|
||
* Runs the canvas texture operation for this subsystem.
|
||
* @param {*} width - width.
|
||
* @param {*} height - height.
|
||
* @param {*} draw - draw.
|
||
* @param {*} repeat - repeat.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
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;
|
||
}
|
||
/**
|
||
* Runs the noise operation for this subsystem.
|
||
* @param {*} c - c.
|
||
* @param {*} w - w.
|
||
* @param {*} h - h.
|
||
* @param {*} base - base.
|
||
* @param {*} spread - spread.
|
||
* @param {*} count - count.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
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);
|
||
}
|
||
}
|
||
/**
|
||
* Runs the textures operation for this subsystem.
|
||
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
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.
|
||
/**
|
||
* Runs the ribbon operation for this subsystem.
|
||
* @param {*} out - out.
|
||
* @param {*} pts - pts.
|
||
* @param {*} width - width.
|
||
* @param {*} y - y.
|
||
* @param {*} repeat - repeat.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
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]);
|
||
/**
|
||
* Runs the seg operation for this subsystem.
|
||
* @param {*} a - a.
|
||
* @param {*} b - b.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
/**
|
||
* Runs the disc operation for this subsystem.
|
||
* @param {*} out - out.
|
||
* @param {*} x - x.
|
||
* @param {*} z - z.
|
||
* @param {*} radius - radius.
|
||
* @param {*} y - y.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
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);
|
||
}
|
||
}
|
||
/**
|
||
* Runs the polygon triangles operation for this subsystem.
|
||
* @param {*} pts - pts.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
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.
|
||
/**
|
||
* Runs the flat polygon operation for this subsystem.
|
||
* @param {*} out - out.
|
||
* @param {*} pts - pts.
|
||
* @param {*} y - y.
|
||
* @param {*} uvScale - uv Scale.
|
||
* @param {*} color - color.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
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]));
|
||
}
|
||
}
|
||
/**
|
||
* Runs the to mesh operation for this subsystem.
|
||
* @param {*} out - out.
|
||
* @param {*} material - material.
|
||
* @param {*} normals - normals.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
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;
|
||
}
|
||
/**
|
||
* Runs the ground material operation for this subsystem.
|
||
* @param {*} options - options.
|
||
* @param {*} layer - layer.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
const groundMaterial = (options, layer) =>
|
||
new THREE.MeshStandardMaterial({
|
||
roughness: 0.95,
|
||
...options,
|
||
polygonOffset: true,
|
||
polygonOffsetFactor: -layer,
|
||
polygonOffsetUnits: -layer * 2,
|
||
});
|
||
/**
|
||
* Runs the seeded operation for this subsystem.
|
||
* @param {*} seed - seed.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function seeded(seed) {
|
||
return () => {
|
||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||
return seed / 4294967296;
|
||
};
|
||
}
|
||
/**
|
||
* Runs the signed area operation for this subsystem.
|
||
* @param {*} pts - pts.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
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).
|
||
/**
|
||
* Creates city resources for the game.
|
||
* @param {*} scene - scene.
|
||
* @param {*} data - data.
|
||
* @param {*} options2 - Operation options.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
export function createCity(
|
||
scene,
|
||
data,
|
||
{ collision, segments, extraBuildings = [], satellite = false, lowQuality = false },
|
||
) {
|
||
const build = {
|
||
scene,
|
||
data,
|
||
collision,
|
||
segments,
|
||
extraBuildings,
|
||
satellite,
|
||
lowQuality,
|
||
};
|
||
for (const [name, method] of Object.entries(CITY_METHODS))
|
||
build[name] = (...args) => method(build, ...args);
|
||
for (const initialize of CITY_STEPS) initialize(build);
|
||
return build.result;
|
||
}
|
||
/**
|
||
* Runs the city build block operation for this subsystem.
|
||
* @param {*} build - build.
|
||
* @param {*} block - block.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function cityBuildBlock(build, 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, build.wallMaterial, 'compute'), toMesh(roofs, build.roofMaterial)];
|
||
for (const mesh of block.meshes) {
|
||
mesh.castShadow = true;
|
||
build.addChunked(mesh);
|
||
}
|
||
}
|
||
|
||
// Rooftop water tanks and air coolers, typical of Mashhad apartment blocks, on the OSM buildings.
|
||
/**
|
||
* Runs the city build view_range operation for this subsystem.
|
||
* @param {*} build - build.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function cityBuildVIEW_RANGE(build) {
|
||
build.VIEW_RANGE = build.lowQuality ? 1500 : 2400;
|
||
build.tex = textures();
|
||
build.group = new THREE.Group();
|
||
build.scene.add(build.group);
|
||
build.random = seeded(1405);
|
||
build.chunked = [];
|
||
build.buckets = new Map();
|
||
build.bucket = (name, x, z, make) => {
|
||
const key = `${name}|${Math.floor(x / CHUNK)},${Math.floor(z / CHUNK)}`;
|
||
if (!build.buckets.has(key))
|
||
build.buckets.set(key, {
|
||
name,
|
||
out: make(),
|
||
});
|
||
return build.buckets.get(key).out;
|
||
};
|
||
build.addChunked = (mesh) => {
|
||
mesh.geometry.computeBoundingSphere();
|
||
build.chunked.push(mesh);
|
||
build.group.add(mesh);
|
||
return mesh;
|
||
};
|
||
}
|
||
/**
|
||
* Runs the city build ground operation for this subsystem.
|
||
* @param {*} build - build.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function cityBuildGround(build) {
|
||
// Open ground that follows the camera, so the horizon never shows an edge.
|
||
build.ground = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(14000, 14000),
|
||
groundMaterial(
|
||
{
|
||
color: '#c6baa0',
|
||
},
|
||
0,
|
||
),
|
||
);
|
||
build.ground.rotation.x = -Math.PI / 2;
|
||
build.ground.position.y = -0.02;
|
||
build.ground.receiveShadow = true;
|
||
build.group.add(build.ground);
|
||
for (const area of build.satellite ? [] : build.data.areas) {
|
||
const kind = build.data.areaKinds[area[0]];
|
||
flatPolygon(
|
||
build.bucket(`area:${kind}`, area[1], area[2], () => ({
|
||
pos: [],
|
||
idx: [],
|
||
})),
|
||
area.slice(1),
|
||
LAYER[kind][0],
|
||
);
|
||
}
|
||
build.areaMaterials = {};
|
||
for (const kind of build.data.areaKinds)
|
||
build.areaMaterials[kind] = groundMaterial(
|
||
{
|
||
color: AREA_COLOR[kind],
|
||
roughness: kind === 'water' ? 0.25 : 0.95,
|
||
metalness: kind === 'water' ? 0.2 : 0,
|
||
},
|
||
LAYER[kind][1],
|
||
);
|
||
}
|
||
/**
|
||
* Runs the city build road operation for this subsystem.
|
||
* @param {*} build - build.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function cityBuildRoad(build) {
|
||
build.road = (name, x, z) =>
|
||
build.bucket(`road:${name}`, x, z, () => ({
|
||
pos: [],
|
||
uv: [],
|
||
idx: [],
|
||
}));
|
||
for (const r of build.satellite ? [] : build.data.roads) {
|
||
const type = build.data.roadTypes[r[0]],
|
||
kind = BUCKET[type],
|
||
pts = r.slice(2),
|
||
width = WIDTH[type];
|
||
ribbon(build.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(build.road('sidewalk', pts[0], pts[1]), pts, width + 4, LAYER.sidewalk[0], 6);
|
||
disc(build.road(kind, pts[0], pts[1]), pts[0], pts[1], width / 2, LAYER[kind][0]);
|
||
disc(build.road(kind, pts[0], pts[1]), pts.at(-2), pts.at(-1), width / 2, LAYER[kind][0]);
|
||
}
|
||
}
|
||
build.roadMaterials = {};
|
||
}
|
||
/**
|
||
* Runs the city build geometry operation for this subsystem.
|
||
* @param {*} build - build.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function cityBuildGeometry(build) {
|
||
for (const kind of ['major', 'minor', 'paved', 'dirt', 'sidewalk'])
|
||
build.roadMaterials[kind] = groundMaterial(
|
||
{
|
||
map: build.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.
|
||
build.wallMaterial = new THREE.MeshStandardMaterial({
|
||
map: build.tex.walls,
|
||
vertexColors: true,
|
||
roughness: 0.85,
|
||
});
|
||
build.roofMaterial = new THREE.MeshStandardMaterial({
|
||
vertexColors: true,
|
||
roughness: 0.9,
|
||
});
|
||
build.blocks = new Map();
|
||
}
|
||
/**
|
||
* Runs the city build geometry2 operation for this subsystem.
|
||
* @param {*} build - build.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function cityBuildGeometry2(build) {
|
||
for (const building of [...build.data.buildings, ...build.extraBuildings]) {
|
||
const key = `${Math.floor(building[1] / BLOCK)},${Math.floor(building[2] / BLOCK)}`;
|
||
if (!build.blocks.has(key)) {
|
||
const [bx, bz] = key.split(',').map(Number);
|
||
build.blocks.set(key, {
|
||
x: (bx + 0.5) * BLOCK,
|
||
z: (bz + 0.5) * BLOCK,
|
||
buildings: [],
|
||
meshes: null,
|
||
});
|
||
}
|
||
build.blocks.get(key).buildings.push(building);
|
||
}
|
||
build.pendingBlocks = [...build.blocks.values()];
|
||
build.tanks = [];
|
||
build.units = [];
|
||
}
|
||
/**
|
||
* Runs the city build geometry3 operation for this subsystem.
|
||
* @param {*} build - build.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function cityBuildGeometry3(build) {
|
||
for (const building of build.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) && build.random() < 0.6) {
|
||
(build.random() < 0.5 ? build.tanks : build.units).push([
|
||
cx + (build.random() - 0.5) * 2,
|
||
height,
|
||
build.random() * Math.PI,
|
||
cz + (build.random() - 0.5) * 2,
|
||
0,
|
||
]);
|
||
}
|
||
}
|
||
for (const { name, out } of build.buckets.values()) {
|
||
const [kind, sub] = name.split(':');
|
||
build.addChunked(
|
||
kind === 'area'
|
||
? toMesh(out, build.areaMaterials[sub])
|
||
: toMesh(out, build.roadMaterials[sub]),
|
||
);
|
||
}
|
||
build.dummy = new THREE.Object3D();
|
||
}
|
||
/**
|
||
* Runs the city build instanced operation for this subsystem.
|
||
* @param {*} build - build.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function cityBuildInstanced(build) {
|
||
// 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.
|
||
build.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);
|
||
build.dummy.updateMatrix();
|
||
mesh.setMatrixAt(i, build.dummy.matrix);
|
||
});
|
||
mesh.castShadow = mesh.receiveShadow = true;
|
||
mesh.computeBoundingSphere();
|
||
mesh.userData.items = list.map(([, i]) => i);
|
||
build.chunked.push(mesh);
|
||
build.group.add(mesh);
|
||
meshes.push(mesh);
|
||
}
|
||
return meshes;
|
||
};
|
||
}
|
||
/**
|
||
* Runs the city build geometry4 operation for this subsystem.
|
||
* @param {*} build - build.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function cityBuildGeometry4(build) {
|
||
build.instanced(
|
||
new THREE.CylinderGeometry(0.55, 0.55, 1.1, 10),
|
||
new THREE.MeshStandardMaterial({
|
||
color: '#d8d8d2',
|
||
metalness: 0.4,
|
||
roughness: 0.4,
|
||
}),
|
||
build.tanks,
|
||
([x, y, , z]) => {
|
||
build.dummy.position.set(x, y + 0.55, z);
|
||
build.dummy.rotation.set(0, 0, 0);
|
||
build.dummy.scale.setScalar(1);
|
||
},
|
||
);
|
||
build.instanced(
|
||
new THREE.BoxGeometry(1, 0.8, 0.9),
|
||
new THREE.MeshStandardMaterial({
|
||
color: '#a9aaa4',
|
||
roughness: 0.6,
|
||
}),
|
||
build.units,
|
||
([x, y, r, z]) => {
|
||
build.dummy.position.set(x, y + 0.4, z);
|
||
build.dummy.rotation.set(0, r, 0);
|
||
build.dummy.scale.setScalar(1);
|
||
},
|
||
);
|
||
|
||
// Trees fill parks and line the boulevards, kept off paths and out of buildings.
|
||
build.trees = [];
|
||
build.freeSpot = (x, z) =>
|
||
!blocked(build.collision, x, z) && !nearestSegment(build.segments, x, z, 3);
|
||
}
|
||
/**
|
||
* Runs the city build geometry5 operation for this subsystem.
|
||
* @param {*} build - build.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function cityBuildGeometry5(build) {
|
||
for (const area of build.data.areas) {
|
||
const kind = build.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 + (build.random() - 0.5) * step * 0.8,
|
||
tz = z + (build.random() - 0.5) * step * 0.8;
|
||
if (insidePolygon(pts, tx, tz) && build.freeSpot(tx, tz))
|
||
build.trees.push([tx, 0.8 + build.random() * 0.6, tz, 0]);
|
||
}
|
||
}
|
||
}
|
||
/**
|
||
* Runs the city build geometry6 operation for this subsystem.
|
||
* @param {*} build - build.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function cityBuildGeometry6(build) {
|
||
for (const road of build.data.roads) {
|
||
const type = build.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 = build.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(build.collision, tx, tz) &&
|
||
!nearestSegment(
|
||
build.segments,
|
||
tx,
|
||
tz,
|
||
WIDTH[type] / 2 + 1.5,
|
||
(s) => s[4] <= DRIVABLE_MAX_TYPE,
|
||
)
|
||
)
|
||
build.trees.push([tx, 0.7 + build.random() * 0.4, tz, 0]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
/**
|
||
* Runs the city build geometry7 operation for this subsystem.
|
||
* @param {*} build - build.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function cityBuildGeometry7(build) {
|
||
build.instanced(
|
||
new THREE.CylinderGeometry(0.14, 0.22, 2.6, 5, 1, true),
|
||
new THREE.MeshStandardMaterial({
|
||
color: '#6a5039',
|
||
roughness: 0.9,
|
||
}),
|
||
build.trees,
|
||
([x, s, z]) => {
|
||
build.dummy.position.set(x, 1.3 * s, z);
|
||
build.dummy.rotation.set(0, 0, 0);
|
||
build.dummy.scale.setScalar(s);
|
||
},
|
||
);
|
||
build.crowns = build.instanced(
|
||
new THREE.IcosahedronGeometry(1.7, 0),
|
||
new THREE.MeshStandardMaterial({
|
||
color: '#ffffff',
|
||
roughness: 0.85,
|
||
flatShading: true,
|
||
}),
|
||
build.trees,
|
||
([x, s, z]) => {
|
||
build.dummy.position.set(x, 3.4 * s, z);
|
||
build.dummy.rotation.set(0, (x * 13) % 6, 0);
|
||
build.dummy.scale.set(s, s * (1 + ((x * 7) % 1) * 0.3), s);
|
||
},
|
||
);
|
||
build.crownColors = ['#4d7a3e', '#5f8a45', '#3f6b3a', '#6f9150'].map((c) => new THREE.Color(c));
|
||
for (const mesh of build.crowns)
|
||
mesh.userData.items.forEach((tree, i) =>
|
||
mesh.setColorAt(i, build.crownColors[tree % build.crownColors.length]),
|
||
);
|
||
|
||
// Metro station entrances with the Mashhad Urban Railway "M" sign.
|
||
}
|
||
/**
|
||
* Runs the city build sign texture operation for this subsystem.
|
||
* @param {*} build - build.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function cityBuildSignTexture(build) {
|
||
build.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,
|
||
);
|
||
build.canopy = new THREE.MeshStandardMaterial({
|
||
color: '#59646b',
|
||
metalness: 0.5,
|
||
roughness: 0.4,
|
||
});
|
||
build.glass = new THREE.MeshStandardMaterial({
|
||
color: '#9fc4cf',
|
||
transparent: true,
|
||
opacity: 0.45,
|
||
roughness: 0.1,
|
||
});
|
||
}
|
||
/**
|
||
* Runs the city build geometry8 operation for this subsystem.
|
||
* @param {*} build - build.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function cityBuildGeometry8(build) {
|
||
for (const label of build.data.labels.filter((l) => l.kind === 'metro')) {
|
||
const entrance = new THREE.Group();
|
||
entrance.position.set(label.x, 0, label.z);
|
||
const road = nearestSegment(
|
||
build.segments,
|
||
label.x,
|
||
label.z,
|
||
40,
|
||
(s) => s[4] <= DRIVABLE_MAX_TYPE,
|
||
);
|
||
if (road) entrance.rotation.y = Math.atan2(road.dx, road.dz);
|
||
/**
|
||
* Runs the box operation for this subsystem.
|
||
* @param {*} w - w.
|
||
* @param {*} h - h.
|
||
* @param {*} d - d.
|
||
* @param {*} m - m.
|
||
* @param {*} x - x.
|
||
* @param {*} y - y.
|
||
* @param {*} z - z.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
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, build.canopy, 0, 3, 0);
|
||
box(0.15, 3, 7, build.glass, -2.1, 1.5, 0);
|
||
box(0.15, 3, 7, build.glass, 2.1, 1.5, 0);
|
||
box(0.25, 4.2, 0.25, build.canopy, 2.6, 2.1, 3.6);
|
||
const sign = new THREE.Mesh(
|
||
new THREE.CircleGeometry(0.6, 24),
|
||
new THREE.MeshBasicMaterial({
|
||
map: build.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);
|
||
build.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.
|
||
}
|
||
/**
|
||
* Runs the city build ridge pos operation for this subsystem.
|
||
* @param {*} build - build.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function cityBuildRidgePos(build) {
|
||
build.ridgePos = [];
|
||
build.ridgeCol = [];
|
||
build.ridgeIdx = [];
|
||
build.segmentsAround = 240;
|
||
build.foot = new THREE.Color('#cfc5b0');
|
||
build.crest = new THREE.Color('#a39a8a');
|
||
}
|
||
/**
|
||
* Runs the city build geometry9 operation for this subsystem.
|
||
* @param {*} build - build.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function cityBuildGeometry9(build) {
|
||
for (let i = 0; i <= build.segmentsAround; i++) {
|
||
const a = (i / build.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;
|
||
build.ridgePos.push(x * r, -20, z * r, x * r, height, z * r);
|
||
build.ridgeCol.push(
|
||
build.foot.r,
|
||
build.foot.g,
|
||
build.foot.b,
|
||
build.crest.r,
|
||
build.crest.g,
|
||
build.crest.b,
|
||
);
|
||
if (i) {
|
||
const b = (i - 1) * 2;
|
||
build.ridgeIdx.push(b, b + 1, b + 2, b + 1, b + 3, b + 2);
|
||
}
|
||
}
|
||
build.ridgeGeometry = new THREE.BufferGeometry();
|
||
build.ridgeGeometry.setAttribute('position', new THREE.Float32BufferAttribute(build.ridgePos, 3));
|
||
build.ridgeGeometry.setAttribute('color', new THREE.Float32BufferAttribute(build.ridgeCol, 3));
|
||
build.ridgeGeometry.setIndex(build.ridgeIdx);
|
||
}
|
||
/**
|
||
* Runs the city build ridge operation for this subsystem.
|
||
* @param {*} build - build.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function cityBuildRidge(build) {
|
||
build.ridge = new THREE.Mesh(
|
||
build.ridgeGeometry,
|
||
new THREE.MeshBasicMaterial({
|
||
vertexColors: true,
|
||
fog: false,
|
||
side: THREE.DoubleSide,
|
||
}),
|
||
);
|
||
build.group.add(build.ridge);
|
||
build.lastCheck = null;
|
||
}
|
||
/**
|
||
* Runs the city build result operation for this subsystem.
|
||
* @param {*} build - build.
|
||
* @returns {*} Operation result, when applicable.
|
||
*/
|
||
function cityBuildResult(build) {
|
||
build.result = {
|
||
group: build.group,
|
||
// Keeps the ground and horizon around the camera and shows only chunks within range.
|
||
update(camera) {
|
||
build.ground.position.set(camera.x, -0.02, camera.z);
|
||
build.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 = build.VIEW_RANGE + BLOCK;
|
||
for (const block of build.pendingBlocks) {
|
||
const d = Math.hypot(block.x - camera.x, block.z - camera.z);
|
||
if (d < nextDistance) {
|
||
next = block;
|
||
nextDistance = d;
|
||
}
|
||
}
|
||
if (next) {
|
||
build.pendingBlocks.splice(build.pendingBlocks.indexOf(next), 1);
|
||
build.buildBlock(next);
|
||
build.lastCheck = null;
|
||
}
|
||
if (
|
||
build.lastCheck &&
|
||
Math.hypot(camera.x - build.lastCheck.x, camera.z - build.lastCheck.z) < 60
|
||
)
|
||
return;
|
||
build.lastCheck = {
|
||
x: camera.x,
|
||
z: camera.z,
|
||
};
|
||
for (const mesh of build.chunked) {
|
||
const sphere = mesh.boundingSphere ?? mesh.geometry.boundingSphere;
|
||
mesh.visible =
|
||
Math.hypot(sphere.center.x - camera.x, sphere.center.z - camera.z) - sphere.radius <
|
||
build.VIEW_RANGE;
|
||
}
|
||
},
|
||
};
|
||
}
|
||
const CITY_METHODS = {
|
||
buildBlock: cityBuildBlock,
|
||
};
|
||
const CITY_STEPS = [
|
||
cityBuildVIEW_RANGE,
|
||
cityBuildGround,
|
||
cityBuildRoad,
|
||
cityBuildGeometry,
|
||
cityBuildGeometry2,
|
||
cityBuildGeometry3,
|
||
cityBuildInstanced,
|
||
cityBuildGeometry4,
|
||
cityBuildGeometry5,
|
||
cityBuildGeometry6,
|
||
cityBuildGeometry7,
|
||
cityBuildSignTexture,
|
||
cityBuildGeometry8,
|
||
cityBuildRidgePos,
|
||
cityBuildGeometry9,
|
||
cityBuildRidge,
|
||
cityBuildResult,
|
||
];
|