perf(city): optimize rendering for expanded city bounds

Tighten directional light shadow frustum to follow the player and
batch static geometries into merged meshes, reducing draw calls
significantly. Expand city world bounds to support 72 blocks and
add per-frame performance monitoring instrumentation.

BREAKING CHANGE: city bounds changed from ±80 to ±120/±160, update
any hardcoded client-side world assumptions.
This commit is contained in:
2026-10-31 20:07:13 -03:00
parent b4f80cee24
commit 4b71a9d0ff
5 changed files with 285 additions and 56 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

+7 -4
View File
@@ -28,10 +28,13 @@ class CityWorldSeeder extends Seeder
['slug' => 'city10'],
[
'name' => 'City10',
'bounds_min_x' => -80,
'bounds_max_x' => 80,
'bounds_min_z' => -80,
'bounds_max_z' => 80,
// Performance-tuned size: 72 blocks (9 × 8) after the
// 40-unit road grid and its outer margin are derived from
// these bounds (layout −160..200 × −160..160).
'bounds_min_x' => -120,
'bounds_max_x' => 160,
'bounds_min_z' => -120,
'bounds_max_z' => 120,
'is_active' => true,
'expansions' => 0,
],
+246 -52
View File
@@ -46,12 +46,17 @@ const sun = new THREE.DirectionalLight(0xfff2d9, 1.15);
sun.position.set(80, 140, 60);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -200;
sun.shadow.camera.right = 200;
sun.shadow.camera.top = 200;
sun.shadow.camera.bottom = -200;
// Tight shadow window that follows the player (see updateDayNight): the
// city is far larger than the frustum now, and a follow box keeps the
// per-frame caster list small while doubling effective shadow resolution.
sun.shadow.camera.left = -120;
sun.shadow.camera.right = 120;
sun.shadow.camera.top = 120;
sun.shadow.camera.bottom = -120;
sun.shadow.camera.far = 500;
sun.shadow.bias = -0.0004;
scene.add(sun);
scene.add(sun.target);
// ------------------------------------------------------------------
// Texture helpers
@@ -129,8 +134,8 @@ function zebraTexture(barsAcrossU) {
const zebraTexOverZRoad = zebraTexture(true);
const zebraTexOverXRoad = zebraTexture(false);
function facadePair(baseColor = '#c9a84c', floorCount = 6, windowsPerFloor = 4) {
const w = 512, h = 512;
function facadePair(baseColor = '#c9a84c', floorCount = 6, windowsPerFloor = 4, size = 512) {
const w = size, h = size;
const c = document.createElement('canvas');
c.width = w; c.height = h;
const ctx = c.getContext('2d');
@@ -210,6 +215,8 @@ const lampHeads = [];
let grid = null;
let crosswalkCount = 0;
let sidewalkSegmentCount = 0;
// Per-frame cost split (update JS vs render submit) for perf monitoring.
const perfFrame = { update: 0, render: 0 };
let trafficSystem = null;
let vehicleSystem = null;
let pedestrianSystem = null;
@@ -319,7 +326,16 @@ function updateDayNight() {
scene.fog.color.copy(sky);
const ang = ((hour - 6) / 12) * Math.PI;
sun.position.set(Math.cos(ang) * 120, Math.max(8, Math.sin(ang) * 150), 60);
// Anchor sun + shadow frustum on the player so the shadow pass only
// sees casters around them, wherever they are in the expanded city.
const focus = player.pos;
sun.position.set(
focus.x + Math.cos(ang) * 120,
Math.max(8, Math.sin(ang) * 150),
focus.z + 60
);
sun.target.position.set(focus.x, 0, focus.z);
sun.target.updateMatrixWorld();
sun.intensity = (isDay ? elevation : 0) * 1.25 * weatherDim + lightningFlash * 2;
sun.color.setHSL(0.09, 0.55, isDay ? 0.55 + elevation * 0.4 : 0.5);
@@ -529,6 +545,76 @@ function stripSegments(lines, a, b, half) {
return segs;
}
/**
* Batches static same-material geometries into single merged meshes so a
* large city costs a handful of draw calls instead of thousands. Entries
* are baked into world space at flush(); only truly static, non-
* interactive meshes belong here (roadsides, poles, housings, roofs).
*
* @returns {{add: Function, flush: Function}}
*/
function makeStaticBatcher() {
/** @type {Map<THREE.Material, {entries: Array<{geometry: THREE.BufferGeometry, matrix: THREE.Matrix4}>, cast: boolean, receive: boolean}>} */
const buckets = new Map();
return {
add(geometry, material, matrix, cast = false, receive = false) {
let bucket = buckets.get(material);
if (!bucket) {
bucket = { entries: [], cast: false, receive: false };
buckets.set(material, bucket);
}
bucket.entries.push({ geometry, matrix });
bucket.cast = bucket.cast || cast;
bucket.receive = bucket.receive || receive;
},
flush(parent) {
for (const [material, bucket] of buckets) {
if (!bucket.entries.length) continue;
const parts = bucket.entries.map(({ geometry, matrix }) => {
const g = geometry.index ? geometry.toNonIndexed() : geometry.clone();
if (matrix) g.applyMatrix4(matrix);
return g;
});
let total = 0;
for (const g of parts) total += g.getAttribute('position').count;
const pos = new Float32Array(total * 3);
const nor = new Float32Array(total * 3);
const uv = new Float32Array(total * 2);
let o3 = 0;
let o2 = 0;
for (const g of parts) {
const count = g.getAttribute('position').count;
pos.set(g.getAttribute('position').array, o3);
const n = g.getAttribute('normal');
if (n) nor.set(n.array, o3);
const u = g.getAttribute('uv');
if (u) uv.set(u.array, o2);
o3 += count * 3;
o2 += count * 2;
g.dispose();
}
const merged = new THREE.BufferGeometry();
merged.setAttribute('position', new THREE.BufferAttribute(pos, 3));
merged.setAttribute('normal', new THREE.BufferAttribute(nor, 3));
merged.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
merged.computeBoundingSphere();
const mesh = new THREE.Mesh(merged, material);
mesh.castShadow = bucket.cast;
mesh.receiveShadow = bucket.receive;
parent.add(mesh);
}
buckets.clear();
},
};
}
// ------------------------------------------------------------------
// Traffic light system
// ------------------------------------------------------------------
@@ -556,12 +642,18 @@ function pedStateForAxis(axis) {
return vehicle === 'red' ? 'green' : 'red';
}
// Shared housings: hundreds of signal bodies merge into one draw call
// while the lamp spheres stay individual (animated, distance-culled).
const pedHousingMat = new THREE.MeshStandardMaterial({ color: 0x141820, roughness: 0.75 });
const signalHousingMat = new THREE.MeshStandardMaterial({ color: 0x1c1c20, roughness: 0.8 });
function makePedSignal() {
const g = new THREE.Group();
const box = new THREE.Mesh(
new THREE.BoxGeometry(0.28, 0.7, 0.22),
new THREE.MeshStandardMaterial({ color: 0x141820, roughness: 0.75 })
pedHousingMat
);
box.userData.batchStatic = true;
g.add(box);
// Top = red "don't walk", bottom = green "walk".
const red = new THREE.Mesh(
@@ -584,8 +676,9 @@ function makeSignalHead() {
const g = new THREE.Group();
const box = new THREE.Mesh(
new THREE.BoxGeometry(0.32, 1.0, 0.26),
new THREE.MeshStandardMaterial({ color: 0x1c1c20, roughness: 0.8 })
signalHousingMat
);
box.userData.batchStatic = true;
g.add(box);
const colors = [0xff2222, 0xffcc00, 0x22cc44];
const lamps = [];
@@ -602,7 +695,23 @@ function makeSignalHead() {
return g;
}
function initTrafficLights(g) {
/**
* Move a signal head's static housing into the batcher, leaving the
* animated lamp spheres in the group (they are distance-culled instead).
*
* @param {THREE.Group} head
* @param {ReturnType<typeof makeStaticBatcher>} batcher
*/
function batchHeadStatic(head, batcher) {
head.updateMatrixWorld(true);
for (const child of [...head.children]) {
if (!child.userData.batchStatic) continue;
batcher.add(child.geometry, child.material, child.matrixWorld);
head.remove(child);
}
}
function initTrafficLights(g, batcher) {
const group = new THREE.Group();
const heads = [];
const pedHeads = [];
@@ -615,33 +724,41 @@ function initTrafficLights(g) {
for (const x of g.xs) {
for (const z of g.zs) {
// SE-corner pole, planted on the sidewalk.
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.1, 4.2, 6), poleMat);
// SE-corner pole, planted on the sidewalk (group/world are at
// identity, so local matrices are world matrices here).
const poleGeo = new THREE.CylinderGeometry(0.08, 0.1, 4.2, 6);
const pole = new THREE.Mesh(poleGeo, poleMat);
pole.position.set(x + sw, 2.1, z + sw);
pole.castShadow = true;
group.add(pole);
pole.updateMatrix();
batcher.add(poleGeo, poleMat, pole.matrix, true);
// Mast arm over the vertical road (northbound lane), facing
// south so approaching traffic sees the signal. Northbound
// drives on the east half (right-hand traffic); the SE-corner
// pole sits on that same (right) side.
const armNS = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.1, 2.2), poleMat);
const armNSGeo = new THREE.BoxGeometry(0.12, 0.1, 2.2);
const armNS = new THREE.Mesh(armNSGeo, poleMat);
armNS.position.set(x + sw, 4.0, z + sw - 1.1);
group.add(armNS);
armNS.updateMatrix();
batcher.add(armNSGeo, poleMat, armNS.matrix);
const headNS = makeSignalHead();
headNS.position.set(x + sw, 3.7, z + sw - 2.2);
headNS.rotation.y = 0; // face +Z (south — toward northbound traffic)
group.add(headNS);
batchHeadStatic(headNS, batcher);
heads.push({ head: headNS, axis: 'z' });
// Mast arm over the horizontal road (eastbound lane), facing west.
const armEW = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.1, 0.12), poleMat);
// Mast arm over the horizontal (eastbound lane), facing west.
const armEWGeo = new THREE.BoxGeometry(2.2, 0.1, 0.12);
const armEW = new THREE.Mesh(armEWGeo, poleMat);
armEW.position.set(x + sw - 1.1, 4.0, z + sw);
group.add(armEW);
armEW.updateMatrix();
batcher.add(armEWGeo, poleMat, armEW.matrix);
const headEW = makeSignalHead();
headEW.position.set(x + sw - 2.2, 3.7, z + sw);
headEW.rotation.y = -Math.PI / 2; // face -X (west)
group.add(headEW);
batchHeadStatic(headEW, batcher);
heads.push({ head: headEW, axis: 'x' });
// Pedestrian signals: short posts on the same corner, well
@@ -651,24 +768,30 @@ function initTrafficLights(g) {
// signal follows the x-axis vehicle state. It sits on the east
// sidewalk facing north, for walkers arriving from the north.
const pedEast = makePedSignal();
const pedEastPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
const pedEastPoleGeo = new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6);
const pedEastPole = new THREE.Mesh(pedEastPoleGeo, pedPoleMat);
pedEastPole.position.set(x + sw + 0.6, 1.2, z + sw - 0.6);
group.add(pedEastPole);
pedEastPole.updateMatrix();
batcher.add(pedEastPoleGeo, pedPoleMat, pedEastPole.matrix);
pedEast.position.set(x + sw + 0.6, 2.5, z + sw - 0.6);
pedEast.rotation.y = Math.PI; // faces north (approach from the north)
group.add(pedEast);
batchHeadStatic(pedEast, batcher);
pedHeads.push({ head: pedEast, axis: 'x' });
// The south crosswalk crosses the vertical (z) road, so its
// signal follows the z-axis vehicle state. It sits on the south
// sidewalk facing west, for walkers arriving from the east.
const pedSouth = makePedSignal();
const pedSouthPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
const pedSouthPoleGeo = new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6);
const pedSouthPole = new THREE.Mesh(pedSouthPoleGeo, pedPoleMat);
pedSouthPole.position.set(x + sw - 0.6, 1.2, z + sw + 0.6);
group.add(pedSouthPole);
pedSouthPole.updateMatrix();
batcher.add(pedSouthPoleGeo, pedPoleMat, pedSouthPole.matrix);
pedSouth.position.set(x + sw - 0.6, 2.5, z + sw + 0.6);
pedSouth.rotation.y = -Math.PI / 2; // faces west (approach from the east)
group.add(pedSouth);
batchHeadStatic(pedSouth, batcher);
pedHeads.push({ head: pedSouth, axis: 'z' });
}
}
@@ -714,6 +837,21 @@ function initTrafficLights(g) {
lamps.red.material.emissiveIntensity = ped === 'red' ? 2.4 : 0.06;
lamps.green.material.emissiveIntensity = ped === 'green' ? 2.4 : 0.06;
}
// Lamp dots are sub-pixel at distance — hide them far away.
// Housings are batched separately and always stay visible.
const camX = camera.position.x;
const camZ = camera.position.z;
const hideD2 = 60 * 60;
for (const { head } of this.heads) {
const dx = head.position.x - camX;
const dz = head.position.z - camZ;
head.visible = dx * dx + dz * dz < hideD2;
}
for (const { head } of this.pedHeads) {
const dx = head.position.x - camX;
const dz = head.position.z - camZ;
head.visible = dx * dx + dz * dz < hideD2;
}
},
};
}
@@ -857,7 +995,7 @@ function buildCarMesh(colorHex, isBus = false) {
[[wx, wz], [wx, -wz], [-wx, wz], [-wx, -wz]].forEach(([px, pz]) => {
const w = new THREE.Mesh(wheelGeo, rubber);
w.position.set(px, 0.34, pz);
w.castShadow = true;
w.castShadow = false; w.userData.lodDetail = true;
g.add(w);
const rim = new THREE.Mesh(rimGeo, chrome);
rim.position.set(px, 0.34, pz);
@@ -918,7 +1056,7 @@ function buildMotoMesh(colorHex) {
[[0.74, 0], [-0.56, 0]].forEach(([px]) => {
const w = new THREE.Mesh(wheelGeo, rubber);
w.position.set(px, 0.34, 0);
w.castShadow = true;
w.castShadow = false; w.userData.lodDetail = true;
g.add(w);
const rim = new THREE.Mesh(rimGeo, chrome);
rim.position.set(px, 0.34, 0);
@@ -1017,7 +1155,7 @@ function buildBikeMesh(colorHex) {
[[0.58], [-0.58]].forEach(([px]) => {
const w = new THREE.Mesh(wheelGeo, rubber);
w.position.set(px, 0.34, 0);
w.castShadow = true;
w.castShadow = false; w.userData.lodDetail = true;
g.add(w);
// Spokes.
for (let i = 0; i < 6; i++) {
@@ -1169,6 +1307,8 @@ function initVehicles(g) {
else mesh = buildCarMesh(color, kind === 'bus');
worldGroup.add(mesh);
const lodDetail = [];
mesh.traverse(o => { if (o.userData.lodDetail) lodDetail.push(o); });
const v = {
mesh, pts: loop.pts, L: loop.L,
s: offsetS, speed: 0, kind,
@@ -1176,6 +1316,7 @@ function initVehicles(g) {
accel: kind === 'moto' ? 7 : kind === 'bike' ? 3.5 : 5,
decel: 10,
loopKey: loop,
lodDetail, lodFar: false,
};
vehicles.push(v);
return v;
@@ -1207,6 +1348,9 @@ function initVehicles(g) {
}
for (const sh of shuttles) {
worldGroup.add(sh.mesh);
sh.lodDetail = [];
sh.mesh.traverse(o => { if (o.userData.lodDetail) sh.lodDetail.push(o); });
sh.lodFar = false;
vehicles.push(sh);
}
@@ -1233,12 +1377,26 @@ function initVehicles(g) {
return out;
},
update(dt) {
const camX = camera.position.x;
const camZ = camera.position.z;
const lodD2 = 45 * 45;
for (const v of vehicles) {
if (v.pts) {
updateLoopVehicle(v, dt);
} else {
updateShuttleVehicle(v, dt);
}
// Wheels past the near band are a handful of pixels — skip
// their draw calls (body/cabin stay; state flips only on change).
if (v.lodDetail && v.lodDetail.length) {
const dx = v.mesh.position.x - camX;
const dz = v.mesh.position.z - camZ;
const far = dx * dx + dz * dz > lodD2;
if (far !== v.lodFar) {
v.lodFar = far;
for (const part of v.lodDetail) part.visible = !far;
}
}
}
},
};
@@ -2845,6 +3003,8 @@ function buildCity(data) {
const roadMat = new THREE.MeshStandardMaterial({ map: asphaltTex, roughness: 0.9, metalness: 0.05 });
const walkMat = new THREE.MeshStandardMaterial({ map: sidewalkTex.clone(), roughness: 0.95 });
walkMat.map.repeat.set(4, 1);
// Static same-material geometry is merged into single draw calls.
const staticBatch = makeStaticBatcher();
layout.roads.forEach(r => {
const [x1,, z1] = r.from;
@@ -2861,7 +3021,8 @@ function buildCity(data) {
mesh.position.set(mx, 0.01, mz);
if (r === layoutExtras.bridgeRoad) mesh.position.y = 0.07;
mesh.receiveShadow = true;
worldGroup.add(mesh);
mesh.updateMatrix();
staticBatch.add(geo, roadMat, mesh.matrix, false, true);
if (r === layoutExtras.bridgeRoad) return; // bridge has its own deck
@@ -2888,13 +3049,15 @@ function buildCity(data) {
sm.position.set(cmid, 0.12, mz + side * offset);
}
sm.receiveShadow = true;
worldGroup.add(sm);
sm.updateMatrix();
staticBatch.add(sg, walkMat, sm.matrix, false, true);
sidewalkSegmentCount++;
}
});
});
// ---- Curbs (interrupted at crosswalks — dropped-curb look) ----
const curbMat = new THREE.MeshStandardMaterial({ color: 0x888c94, roughness: 0.9 });
layout.roads.forEach(r => {
if (r === layoutExtras.bridgeRoad) return;
const [x1,, z1] = r.from;
@@ -2913,13 +3076,14 @@ function buildCity(data) {
const curbGeo = r.axis === 'z'
? new THREE.BoxGeometry(0.2, 0.14, segLen)
: new THREE.BoxGeometry(segLen, 0.14, 0.2);
const curb = new THREE.Mesh(curbGeo, new THREE.MeshStandardMaterial({ color: 0x888c94, roughness: 0.9 }));
const curb = new THREE.Mesh(curbGeo, curbMat);
if (r.axis === 'z') {
curb.position.set(mx + side * offset, 0.07, cmid);
} else {
curb.position.set(cmid, 0.07, mz + side * offset);
}
worldGroup.add(curb);
curb.updateMatrix();
staticBatch.add(curbGeo, curbMat, curb.matrix);
}
});
});
@@ -2956,7 +3120,8 @@ function buildCity(data) {
zebra.rotation.x = -Math.PI / 2;
zebra.position.set(vx, 0.02, vz + side * (crossHalf + crossBand / 2));
zebra.receiveShadow = true;
worldGroup.add(zebra);
zebra.updateMatrix();
staticBatch.add(zebra.geometry, zebraMatOverZ, zebra.matrix, false, true);
crosswalkCount++;
}
@@ -2974,7 +3139,8 @@ function buildCity(data) {
zebra.rotation.x = -Math.PI / 2;
zebra.position.set(vx + side * (crossHalf + crossBand / 2), 0.02, vz);
zebra.receiveShadow = true;
worldGroup.add(zebra);
zebra.updateMatrix();
staticBatch.add(zebra.geometry, zebraMatOverX, zebra.matrix, false, true);
crosswalkCount++;
}
}
@@ -2982,6 +3148,9 @@ function buildCity(data) {
// ---- Street lamps (on the sidewalk, never in the roadway) ----
const lampPoleMat = new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.7, metalness: 0.5 });
// One shared material for every cobra head: all fade together at night.
const streetHeadMat = new THREE.MeshStandardMaterial({ color: 0xfff8dc, emissive: 0xffe9a0, emissiveIntensity: 0.4 });
lampHeads.push(streetHeadMat);
const lampSwOffset = grid.roadW / 2 + 1; // sidewalk centre = 5 from road centre
const nearIntersection = (lx, lz) => {
// Skip spots within 7 units of any crossing so lamps never sit
@@ -3008,30 +3177,32 @@ function buildCity(data) {
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.1, 4.5, 6), lampPoleMat);
pole.position.set(px, 2.25, pz);
pole.castShadow = true;
worldGroup.add(pole);
pole.updateMatrix();
staticBatch.add(pole.geometry, lampPoleMat, pole.matrix, true);
// Curved arm reaching over the curb toward the road.
const dirX = r.axis === 'z' ? -side : 0;
const dirZ = r.axis === 'x' ? -side : 0;
const arm = new THREE.Mesh(
new THREE.BoxGeometry(r.axis === 'z' ? 1.3 : 0.1, 0.08, r.axis === 'x' ? 1.3 : 0.1),
lampPoleMat
);
const armGeo = new THREE.BoxGeometry(r.axis === 'z' ? 1.3 : 0.1, 0.08, r.axis === 'x' ? 1.3 : 0.1);
const arm = new THREE.Mesh(armGeo, lampPoleMat);
arm.position.set(px + dirX * 0.6, 4.55, pz + dirZ * 0.6);
worldGroup.add(arm);
arm.updateMatrix();
staticBatch.add(armGeo, lampPoleMat, arm.matrix);
const headMat = new THREE.MeshStandardMaterial({ color: 0xfff8dc, emissive: 0xffe9a0, emissiveIntensity: 0.4 });
const headGeo = new THREE.BoxGeometry(0.55, 0.18, 0.28);
// Compact cobra-style head sitting at the arm tip, hanging slightly down.
const head = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.18, 0.28), headMat);
const head = new THREE.Mesh(headGeo, streetHeadMat);
head.position.set(px + dirX * 1.2, 4.45, pz + dirZ * 1.2);
worldGroup.add(head);
lampHeads.push(headMat);
head.updateMatrix();
staticBatch.add(headGeo, streetHeadMat, head.matrix);
}
});
// ---- Traffic lights ----
initTrafficLights(grid);
initTrafficLights(grid, staticBatch);
// Merge every static bucket collected so far (roadsides, poles,
// housings, lamp heads) into one mesh per material.
staticBatch.flush(worldGroup);
// ---- Stores ----
const palette = ['#c9a84c', '#b8763a', '#8a7a5a', '#9a9a8a', '#a08060', '#7a8a6a'];
@@ -3089,7 +3260,15 @@ function buildCity(data) {
});
// ---- Filler buildings ----
const occupied = stores.map(s => ({ x: s.position[0], z: s.position[2] }));
// Fillers only exist on empty ground: skip cells near stores AND near
// any placed building (hospital, school, city hall, player houses) so
// nothing ever spawns on top of a real structure.
const occupied = [
...stores.map(s => ({ x: s.position[0], z: s.position[2] })),
...(data.buildings || []).map(b => ({ x: b.position[0], z: b.position[2] })),
];
const fillerRoofMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 });
const roofBatch = makeStaticBatcher();
(layout.blocks || []).forEach((b, bi) => {
const cx = (b.min[0] + b.max[0]) / 2;
const cz = (b.min[2] + b.max[2]) / 2;
@@ -3101,7 +3280,9 @@ function buildCity(data) {
const bh = 6 + (bi * 13) % 20;
const color = palette[(bi + 2) % palette.length];
const floors = Math.max(2, Math.round(bh / 3));
const pair = facadePair(color, floors, Math.max(2, Math.round(bw / 3)));
// Filler blocks repeat across the whole city — half-resolution
// facades keep VRAM flat while the city grows (stores keep 512).
const pair = facadePair(color, floors, Math.max(2, Math.round(bw / 3)), 256);
const geo = new THREE.BoxGeometry(bw, bh, bd);
const sideMat = new THREE.MeshStandardMaterial({
@@ -3109,11 +3290,10 @@ function buildCity(data) {
emissive: 0xffd9a0, emissiveIntensity: 0, roughness: 0.85,
});
facadeMaterials.push(sideMat);
const mats = Array.from({ length: 6 }, () => sideMat);
mats[2] = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.9 });
mats[3] = new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.9 });
// One material → one draw call per building (the roof slab covers
// the top face; multi-material boxes cost up to six calls each).
const m = new THREE.Mesh(geo, mats);
const m = new THREE.Mesh(geo, sideMat);
m.position.set(cx, bh / 2, cz);
m.castShadow = true;
m.receiveShadow = true;
@@ -3122,11 +3302,13 @@ function buildCity(data) {
const roof = new THREE.Mesh(
new THREE.BoxGeometry(bw + 0.3, 0.3, bd + 0.3),
new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 })
fillerRoofMat
);
roof.position.set(cx, bh + 0.15, cz);
worldGroup.add(roof);
roof.updateMatrix();
roofBatch.add(roof.geometry, fillerRoofMat, roof.matrix);
});
roofBatch.flush(worldGroup);
// ---- Agents (mayor, shopkeepers handled in their systems; pedestrians walk) ----
const walkingTypes = new Set(['pedestrian', 'mayor', 'shopkeeper']);
@@ -4245,6 +4427,7 @@ function animate() {
const dt = Math.min(0.05, (now - lastT) / 1000);
lastT = now;
simTimeAcc += dt;
const tUpdate = performance.now();
updateClock(dt);
updateWeather(dt);
@@ -4281,7 +4464,10 @@ function animate() {
statsEl.textContent = buildStatsText();
}
perfFrame.update = performance.now() - tUpdate;
const tRender = performance.now();
renderer.render(scene, camera);
perfFrame.render = performance.now() - tRender;
}
animate();
@@ -4810,6 +4996,14 @@ window.__city10 = {
trafficLights: trafficSystem ? trafficSystem.heads.length / 2 : 0,
crosswalks: crosswalkCount,
sidewalkSegments: sidewalkSegmentCount,
render: {
calls: renderer.info.render.calls,
triangles: renderer.info.render.triangles,
geometries: renderer.info.memory.geometries,
textures: renderer.info.memory.textures,
updateMs: +perfFrame.update.toFixed(1),
submitMs: +perfFrame.render.toFixed(1),
},
airplanes: airplaneSystem ? airplaneSystem.count() : 0,
trains: trainSystem ? 1 : 0,
river: !!riverSystem,
+32
View File
@@ -44,4 +44,36 @@ class CityWorldSeedingTest extends TestCase
$active = User::where('is_active', true)->where('status', '!=', User::STATUS_ACTIVE)->count();
$this->assertSame(0, $active);
}
/**
* The performance-tuned city delivers exactly 72 blocks (9 × 8) with
* the matching road grid and bounds in the world payload.
*/
public function test_layout_has_72_blocks_after_performance_resize(): void
{
$this->seed(CityWorldSeeder::class);
$user = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]);
Character::ensureForUser($user);
$this->actingAs($user);
$payload = $this->getJson('/city/data')->assertOk()->json();
$this->assertCount(72, $payload['layout']['blocks']);
$zRoads = count(array_filter($payload['layout']['roads'], fn (array $r): bool => $r['axis'] === 'z'));
$xRoads = count(array_filter($payload['layout']['roads'], fn (array $r): bool => $r['axis'] === 'x'));
// Server payload: 10 vertical + 9 horizontal grid roads (the bridge
// and north extension are appended client-side at build time).
$this->assertSame(10, $zRoads);
$this->assertSame(9, $xRoads);
$this->assertEquals(-120, $payload['city']['bounds']['min_x']);
$this->assertEquals(160, $payload['city']['bounds']['max_x']);
$this->assertEquals(-120, $payload['city']['bounds']['min_z']);
$this->assertEquals(120, $payload['city']['bounds']['max_z']);
// The sparse plaza pattern (~1 in 4 cells) scales with the city.
$this->assertGreaterThanOrEqual(10, count($payload['plazas']));
}
}