diff --git a/.playwright-mcp/canto-lojas-corrigido.png b/.playwright-mcp/canto-lojas-corrigido.png new file mode 100644 index 0000000..7b826b2 Binary files /dev/null and b/.playwright-mcp/canto-lojas-corrigido.png differ diff --git a/.playwright-mcp/quadra-padaria-corrigida.png b/.playwright-mcp/quadra-padaria-corrigida.png new file mode 100644 index 0000000..5a62628 Binary files /dev/null and b/.playwright-mcp/quadra-padaria-corrigida.png differ diff --git a/database/seeders/CityWorldSeeder.php b/database/seeders/CityWorldSeeder.php index 5febd11..491c596 100755 --- a/database/seeders/CityWorldSeeder.php +++ b/database/seeders/CityWorldSeeder.php @@ -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, ], diff --git a/public/js/city/app.js b/public/js/city/app.js index 2715922..afbf4ce 100755 --- a/public/js/city/app.js +++ b/public/js/city/app.js @@ -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, 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} 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, diff --git a/tests/Feature/CityWorldSeedingTest.php b/tests/Feature/CityWorldSeedingTest.php index 621402b..de984b7 100755 --- a/tests/Feature/CityWorldSeedingTest.php +++ b/tests/Feature/CityWorldSeedingTest.php @@ -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'])); + } }