refactor: separate React views, Redux snapshots and Three.js runtime

This commit is contained in:
2026-10-28 12:26:40 +03:30
parent 6bd9b17173
commit d0da416923
77 changed files with 4941 additions and 3723 deletions
+688
View File
@@ -0,0 +1,688 @@
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;
}
},
};
}