Files
vending-machine-game/src/game/world/city.js
T

689 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
},
};
}