First-person shell: chunk streaming, instanced buildings + facade texture atlas (22 facade materials -> 1; 5 skin materials city-wide), signs, awnings, furniture, day/night lighting, minimap, pixel-accurate collision (incl. arbitrary-angle lots), door raycast -> procity:enterShop, DBG harness, all-fallback mode. Renders Lane A's full generated town (seed 20261990 'Boolarra Heads', 493 shops); worst continuous- walk view ~261 draws (<=300 gate). 6 adversarial-review bugs fixed (collision sign, house doors, awning skin, sign atlas, furniture seed hash, shot-mode restore). index.html and skins.js include Lane F's inline integration seam edits (marked '[Lane F integration]'): interior/keeper/citizen wiring + facade filename fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
105 lines
4.9 KiB
JavaScript
105 lines
4.9 KiB
JavaScript
// PROCITY Lane B — ground.js
|
||
// Roads, footpaths, kerbs, the market plaza, and a base ground plane, built ONCE as merged strips
|
||
// per material (not per-chunk streamed). Rationale: the street graph is a handful of edges; merged
|
||
// flat quads are a few draw calls total and — unlike per-chunk road clipping — can never seam.
|
||
// UV tiling is baked into the geometry so one shared ground material serves strips of any length.
|
||
// (If Lane A ships a many-hundred-edge city, Lane F revisits streaming the ground.)
|
||
|
||
import * as THREE from 'three';
|
||
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
|
||
import { resolveEdges } from './planutil.js';
|
||
|
||
const FOOT = 3.5; // footpath width
|
||
const TILE = 5; // metres per texture tile
|
||
const OVERLAP = 1.5; // road segments overrun their nodes so intersections fill
|
||
|
||
// Horizontal quad in XZ centred at (cx,cz), aligned to unit dir/perp, size len×wid, at height y.
|
||
// UVs baked to tile every TILE metres.
|
||
function hQuad(cx, cz, dir, perp, len, wid, y) {
|
||
const hl = len / 2, hw = wid / 2;
|
||
const ax = dir.x * hl, az = dir.z * hl; // along
|
||
const bx = perp.x * hw, bz = perp.z * hw; // across
|
||
const P = (sa, sb) => [cx + sa * ax + sb * bx, y, cz + sa * az + sb * bz];
|
||
const c0 = P(-1, -1), c1 = P(1, -1), c2 = P(1, 1), c3 = P(-1, 1);
|
||
const repU = Math.max(1, len / TILE), repV = Math.max(1, wid / TILE);
|
||
const g = new THREE.BufferGeometry();
|
||
// winding c0,c2,c1 / c0,c3,c2 so the geometric normal points +Y (up) — FrontSide stays visible.
|
||
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array([
|
||
...c0, ...c2, ...c1, ...c0, ...c3, ...c2]), 3));
|
||
g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([
|
||
0, 0, repU, repV, repU, 0, 0, 0, 0, repV, repU, repV]), 2));
|
||
g.setAttribute('normal', new THREE.BufferAttribute(new Float32Array([
|
||
0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0]), 3));
|
||
return g;
|
||
}
|
||
|
||
// Axis-aligned rectangle helper (plaza, base plane).
|
||
function rectGeo(cx, cz, w, d, y, tile = TILE) {
|
||
return hQuad(cx, cz, new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 1), w, d, y);
|
||
}
|
||
|
||
export function buildGround(plan, skins) {
|
||
const group = new THREE.Group();
|
||
group.name = 'ground';
|
||
const edges = resolveEdges(plan);
|
||
|
||
const roadGeos = [], footGeos = [], kerbGeos = [];
|
||
const dir = new THREE.Vector3(), perp = new THREE.Vector3();
|
||
|
||
for (const e of edges) {
|
||
const dx = e.bx - e.ax, dz = e.bz - e.az;
|
||
const len = Math.hypot(dx, dz) || 1;
|
||
dir.set(dx / len, 0, dz / len);
|
||
perp.set(-dir.z, 0, dir.x);
|
||
const cx = (e.ax + e.bx) / 2, cz = (e.az + e.bz) / 2;
|
||
const roadLen = len + OVERLAP * 2;
|
||
roadGeos.push(hQuad(cx, cz, dir, perp, roadLen, e.width, 0.0));
|
||
// footpaths flank the road
|
||
const off = e.width / 2 + FOOT / 2;
|
||
footGeos.push(hQuad(cx + perp.x * off, cz + perp.z * off, dir, perp, roadLen, FOOT, 0.02));
|
||
footGeos.push(hQuad(cx - perp.x * off, cz - perp.z * off, dir, perp, roadLen, FOOT, 0.02));
|
||
// low kerbs between road and footpath
|
||
const koff = e.width / 2 + 0.12;
|
||
kerbGeos.push(hQuad(cx + perp.x * koff, cz + perp.z * koff, dir, perp, roadLen, 0.24, 0.06));
|
||
kerbGeos.push(hQuad(cx - perp.x * koff, cz - perp.z * koff, dir, perp, roadLen, 0.24, 0.06));
|
||
}
|
||
|
||
// market plaza (brickpave) over the central square
|
||
const sq = (plan.districts || []).find((d) => d.kind === 'market');
|
||
const plazaGeos = [];
|
||
if (sq) plazaGeos.push(rectGeo(0, 0, 46, 46, 0.015));
|
||
|
||
// base ground plane under the whole town (grass/dirt), sized to the plan extent
|
||
let minX = -60, maxX = 60, minZ = -60, maxZ = 60;
|
||
for (const n of plan.streets.nodes) { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minZ = Math.min(minZ, n.z); maxZ = Math.max(maxZ, n.z); }
|
||
for (const l of plan.lots) { minX = Math.min(minX, l.x); maxX = Math.max(maxX, l.x); minZ = Math.min(minZ, l.z); maxZ = Math.max(maxZ, l.z); }
|
||
const bw = (maxX - minX) + 120, bd = (maxZ - minZ) + 120;
|
||
const bcx = (minX + maxX) / 2, bcz = (minZ + maxZ) / 2;
|
||
const baseGeo = rectGeo(bcx, bcz, bw, bd, -0.03);
|
||
|
||
const meshes = [];
|
||
const add = (geos, material, receive = true) => {
|
||
if (!geos.length) return;
|
||
const merged = mergeGeometries(geos, false);
|
||
geos.forEach((g) => g.dispose());
|
||
const m = new THREE.Mesh(merged, material);
|
||
m.receiveShadow = receive; m.castShadow = false;
|
||
group.add(m); meshes.push(m);
|
||
};
|
||
|
||
const roadSkin = 'djsim-road', footSkin = 'djsim-footpath', grassSkin = 'grass', plazaSkin = 'brickpave';
|
||
add([baseGeo], skins.groundMat(grassSkin));
|
||
add(roadGeos, skins.groundMat(roadSkin));
|
||
add(footGeos, skins.groundMat(footSkin));
|
||
add(plazaGeos, skins.groundMat(plazaSkin));
|
||
const kerbMat = new THREE.MeshStandardMaterial({ color: 0x9a948c, roughness: 0.9 });
|
||
add(kerbGeos, kerbMat);
|
||
|
||
function dispose() {
|
||
for (const m of meshes) m.geometry.dispose();
|
||
kerbMat.dispose();
|
||
group.clear();
|
||
}
|
||
return { group, dispose };
|
||
}
|