From c83d7e9ad81cc562fb66729300d1e5d488f8734f Mon Sep 17 00:00:00 2001 From: Evan Scamehorn Date: Tue, 16 Dec 2025 18:59:09 -0600 Subject: [PATCH] optimizations to rendering --- src/main.js | 313 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 205 insertions(+), 108 deletions(-) diff --git a/src/main.js b/src/main.js index 7eb7424..86fb592 100644 --- a/src/main.js +++ b/src/main.js @@ -30,12 +30,22 @@ const SETTINGS = { files: { visual: './city_data.json', routing: './routing_graph.json' + }, + graphics: { + shadows: true, + antialias: true, + maxPixelRatio: 1.0, // lower == more blurry, 2 or 3 for high res + materialType: 'standard', // or 'standard' + farClip: 9000, // view distance limit } }; let scene, camera, renderer, controls; let inputManager, routeManager, uiManager, gameManager, vehicleSystem; +let cityMesh; // The single mesh containing all buildings +let buildingRegistry = []; // Stores { data, nearestNodeId, startIndex, count } for each building + const clock = new THREE.Clock(); let currentViewMode = 'none'; // 'none', 'zoning', 'approval' @@ -101,67 +111,74 @@ function init() { } function updateBuildingColors() { - scene.traverse((obj) => { - if (obj.name === 'BUILDING_MESH') { - const data = obj.userData.cityData; - if (!data) return; + if (!cityMesh || !buildingRegistry.length) return; - // 1. STANDARD VIEW - if (currentViewMode === 'none') { - obj.material.color.copy(SETTINGS.colors.building); - } + const colorAttribute = cityMesh.geometry.attributes.color; + const colorArray = colorAttribute.array; - // 2. ZONING VIEW - else if (currentViewMode === 'zoning') { - if (data.type === 'residential') { - const color = SETTINGS.colors.building.clone(); - color.lerp(SETTINGS.colors.zoningRes, data.density || 0.5); - obj.material.color.copy(color); - } else if (data.type === 'commercial') { - const color = SETTINGS.colors.building.clone(); - color.lerp(SETTINGS.colors.zoningCom, data.density || 0.5); - obj.material.color.copy(color); - } else { - obj.material.color.copy(SETTINGS.colors.building); - } - } + // Temp variables to avoid creating objects in loop + const _color = new THREE.Color(); - // 3. APPROVAL / COVERAGE VIEW (GRADIENT) - else if (currentViewMode === 'approval') { - // Get graph node position - const nearestId = obj.userData.nearestNodeId; - // RouteManager has logic for this - const node = routeManager.graphData.nodes[nearestId]; + // Iterate through every building in our registry + for (let i = 0; i < buildingRegistry.length; i++) { + const entry = buildingRegistry[i]; + const data = entry.data; - if (node) { - // Calculate distance to nearest transit - // node.y is Z in world space - const dist = routeManager.getDistanceToNearestTransit(node.x, node.y); + // --- 1. Determine Target Color based on Mode --- - // Color Logic: - // < 100m = Green (Great) - // < 300m = Yellow (Okay) - // > 600m = Red (Bad) + // STANDARD VIEW + if (currentViewMode === 'none') { + _color.copy(SETTINGS.colors.building); + } - if (dist === Infinity) { - obj.material.color.copy(SETTINGS.colors.coverageBad); // Deep Red - } else { - const MAX_DIST = 600; - const factor = Math.min(1.0, dist / MAX_DIST); // 0.0 (Close) to 1.0 (Far) - - // Lerp from Green to Red - // (Green at 0, Red at 1) - const color = SETTINGS.colors.coverageGood.clone(); - // We can lerp to Red. - // Or use a Yellow midpoint? - // Simple lerp: Green -> Red - color.lerp(SETTINGS.colors.coverageBad, factor); - obj.material.color.copy(color); - } - } + // ZONING VIEW + else if (currentViewMode === 'zoning') { + if (data.type === 'residential') { + _color.copy(SETTINGS.colors.building).lerp(SETTINGS.colors.zoningRes, data.density || 0.5); + } else if (data.type === 'commercial') { + _color.copy(SETTINGS.colors.building).lerp(SETTINGS.colors.zoningCom, data.density || 0.5); + } else { + _color.copy(SETTINGS.colors.building); } } - }); + + // APPROVAL VIEW + else if (currentViewMode === 'approval') { + // Use the pre-calculated nearest ID from registry + const node = routeManager.graphData.nodes[entry.nearestNodeId]; + + if (node) { + const dist = routeManager.getDistanceToNearestTransit(node.x, node.y); + + if (dist === Infinity) { + _color.copy(SETTINGS.colors.coverageBad); + } else { + const MAX_DIST = 600; + const factor = Math.min(1.0, dist / MAX_DIST); + // Lerp Good -> Bad + _color.copy(SETTINGS.colors.coverageGood).lerp(SETTINGS.colors.coverageBad, factor); + } + } else { + // Fallback if node not found + _color.copy(SETTINGS.colors.coverageBad); + } + } + + // --- 2. Apply Color to Vertices --- + // We update the specific range of vertices belonging to this building + const start = entry.startIndex; + const end = start + entry.count; + + for (let v = start; v < end; v++) { + const idx = v * 3; + colorArray[idx] = _color.r; + colorArray[idx + 1] = _color.g; + colorArray[idx + 2] = _color.b; + } + } + + // Flag that the geometry colors have changed so GPU updates + colorAttribute.needsUpdate = true; } @@ -173,12 +190,21 @@ function setupScene() { scene.background = new THREE.Color(SETTINGS.colors.background); scene.fog = new THREE.FogExp2(SETTINGS.colors.background, 0.0002); - camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 20000); + camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, SETTINGS.graphics.farClip); camera.position.set(0, 800, 800); - renderer = new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true }); - renderer.setSize(window.innerWidth, window.innerHeight); - renderer.shadowMap.enabled = true; + renderer = new THREE.WebGLRenderer({ antialias: SETTINGS.graphics.antialias, logarithmicDepthBuffer: true }); + renderer.domElement.style.width = '100%'; + renderer.domElement.style.height = '100%'; + renderer.domElement.style.display = 'block'; + + document.documentElement.style.margin = '0'; + document.documentElement.style.height = '100%'; + document.body.style.margin = '0'; + document.body.style.height = '100%'; + document.body.style.overflow = 'hidden'; // Prevents scrollbars + + renderer.shadowMap.enabled = SETTINGS.graphics.shadows; document.body.appendChild(renderer.domElement); const ambient = new THREE.HemisphereLight(0xffffff, 0x555555, 0.7); @@ -186,15 +212,17 @@ function setupScene() { const dirLight = new THREE.DirectionalLight(0xffffff, 1.5); dirLight.position.set(500, 1000, 500); - dirLight.castShadow = true; - dirLight.shadow.mapSize.set(2048, 2048); - dirLight.shadow.camera.left = -1500; - dirLight.shadow.camera.right = 1500; - dirLight.shadow.camera.top = 1500; - dirLight.shadow.camera.bottom = -1500; - dirLight.shadow.camera.near = 0.5; - dirLight.shadow.camera.far = 3000; // Must be > 1225 to reach the ground - dirLight.shadow.bias = -0.01; // Clean up shadow artifacts + dirLight.castShadow = SETTINGS.graphics.shadows; + if (SETTINGS.graphics.shadows) { + dirLight.shadow.mapSize.set(2048, 2048); + dirLight.shadow.camera.left = -1500; + dirLight.shadow.camera.right = 1500; + dirLight.shadow.camera.top = 1500; + dirLight.shadow.camera.bottom = -1500; + dirLight.shadow.camera.near = 0.5; + dirLight.shadow.camera.far = 3000; // Must be > 1225 to reach the ground + dirLight.shadow.bias = -0.0001; // Clean up shadow artifacts + } scene.add(dirLight); @@ -205,26 +233,45 @@ function setupScene() { plane.rotation.x = -Math.PI / 2; plane.position.y = -0.5; plane.name = "GROUND"; - plane.receiveShadow = true; + plane.receiveShadow = SETTINGS.graphics.shadows; scene.add(plane); controls = new MapControls(camera, renderer.domElement); - controls.dampingFactor = 0.05; + controls.dampingFactor = 0.07; controls.enableDamping = true; controls.maxPolarAngle = Math.PI / 2 - 0.1; - window.addEventListener('resize', () => { - camera.aspect = window.innerWidth / window.innerHeight; - camera.updateProjectionMatrix(); - renderer.setSize(window.innerWidth, window.innerHeight); - }); +} + +function resizeRendererToDisplaySize() { + const canvas = renderer.domElement; + const rect = canvas.getBoundingClientRect(); + + const pixelRatio = Math.min(window.devicePixelRatio, SETTINGS.graphics.maxPixelRatio); + + // Calculate the required resolution + const width = Math.round(rect.width * pixelRatio); + const height = Math.round(rect.height * pixelRatio); + + // Check if the canvas is already the right size + const needResize = canvas.width !== width || canvas.height !== height; + + if (needResize) { + // Resize the render buffer, but do NOT change CSS style (false) + renderer.setSize(width, height, false); + } + + return needResize; } // ========================================== // 3. Visual Rendering // ========================================== + function renderCity(data) { - const createLayer = (items, color, height, lift, isExtruded) => { + // Helper for non-interactive layers (Water, Parks, Roads) - Optimizing these too is good practice + // We will merge these per type as well to keep draw calls low + const createMergedLayer = (items, color, height, lift, isExtruded) => { if (!items || !items.length) return; const geometries = []; @@ -246,39 +293,40 @@ function renderCity(data) { }); } + let geom; if (isExtruded) { - const geom = new THREE.ExtrudeGeometry(shape, { depth: item.height || height, bevelEnabled: false }); - geom.rotateX(-Math.PI / 2); - geometries.push(geom); + geom = new THREE.ExtrudeGeometry(shape, { depth: item.height || height, bevelEnabled: false }); } else { - const geom = new THREE.ShapeGeometry(shape); - geom.rotateX(-Math.PI / 2); - geom.translate(0, lift, 0); - geometries.push(geom); + geom = new THREE.ShapeGeometry(shape); } + + geom.rotateX(-Math.PI / 2); + if (!isExtruded) geom.translate(0, lift, 0); + + geometries.push(geom); }); - if (!geometries.length) return; - const merged = BufferGeometryUtils.mergeGeometries(geometries); - const mat = new THREE.MeshStandardMaterial({ color: color, roughness: 0.8, side: THREE.DoubleSide }); - const mesh = new THREE.Mesh(merged, mat); - mesh.receiveShadow = true; - if (isExtruded) mesh.castShadow = true; + if (geometries.length === 0) return; + + const mergedGeom = BufferGeometryUtils.mergeGeometries(geometries); + const mat = new THREE.MeshLambertMaterial({ color: color }); // Simple color for static layers + const mesh = new THREE.Mesh(mergedGeom, mat); + mesh.receiveShadow = SETTINGS.graphics.shadows; + if (isExtruded) mesh.castShadow = SETTINGS.graphics.shadows; scene.add(mesh); }; - // Dedicated Building Creator to cache Nearest Node ID + // --- OPTIMIZED BUILDING GENERATION --- const createBuildingLayer = (buildings) => { if (!buildings || !buildings.length) return; - const mat = new THREE.MeshStandardMaterial({ - color: SETTINGS.colors.building, - roughness: 0.6, - side: THREE.DoubleSide, - shadowSide: THREE.DoubleSide - }); + const geometries = []; + buildingRegistry = []; // Reset registry + + let currentVertexOffset = 0; buildings.forEach(b => { + // 1. Create Shape const shape = new THREE.Shape(); if (b.shape.outer.length < 3) return; shape.moveTo(b.shape.outer[0][0], b.shape.outer[0][1]); @@ -293,36 +341,85 @@ function renderCity(data) { }); } + // 2. Create Geometry const geom = new THREE.ExtrudeGeometry(shape, { depth: b.height, bevelEnabled: false }); geom.rotateX(-Math.PI / 2); - const mesh = new THREE.Mesh(geom, mat.clone()); - mesh.castShadow = true; - mesh.receiveShadow = true; - mesh.name = 'BUILDING_MESH'; - mesh.userData.cityData = b.data; - - // CALCULATE NEAREST NODE FOR APPROVAL MECHANIC - // We use the first point of the outer ring as a proxy for position + // 3. Pre-calculate Logic Data (Nearest Node) const bx = b.shape.outer[0][0]; const by = b.shape.outer[0][1]; - const nearestId = routeManager.findNearestNode(bx, -by); - mesh.userData.nearestNodeId = nearestId; - scene.add(mesh); + // 4. Register Metadata + // We need to know how many vertices this building has to color it later + const vertexCount = geom.attributes.position.count; + + buildingRegistry.push({ + data: b.data, // Zoning/Density data + nearestNodeId: nearestId, // For approval view + startIndex: currentVertexOffset, + count: vertexCount + }); + + currentVertexOffset += vertexCount; + geometries.push(geom); }); + + if (geometries.length === 0) return; + + // 5. Merge + const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries); + + // 6. Initialize Vertex Colors Attribute + // Create a color buffer filled with white (1,1,1) by default + const count = mergedGeometry.attributes.position.count; + const colors = new Float32Array(count * 3); + for (let i = 0; i < count * 3; i++) { + colors[i] = 1; + } + mergedGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + + // 7. Material Setup + let mat; + if (SETTINGS.graphics.materialType === 'standard') { + mat = new THREE.MeshStandardMaterial({ + vertexColors: true, // IMPORTANT: valid for merged mesh + roughness: 0.6, + side: THREE.DoubleSide, + shadowSide: THREE.BackSide + }); + } else { + mat = new THREE.MeshLambertMaterial({ + vertexColors: true, // IMPORTANT + roughness: 0.6, + shadowSide: THREE.BackSide + }); + } + + // 8. Create and Add Single Mesh + cityMesh = new THREE.Mesh(mergedGeometry, mat); + cityMesh.castShadow = SETTINGS.graphics.shadows; + cityMesh.receiveShadow = SETTINGS.graphics.shadows; + cityMesh.name = 'CITY_MESH'; + scene.add(cityMesh); }; createBuildingLayer(data.buildings); - createLayer(data.water, SETTINGS.colors.water, 0, 0.1, false); - createLayer(data.parks, SETTINGS.colors.park, 0, 0.2, false); - createLayer(data.roads, SETTINGS.colors.road, 0, 0.3, false); + createMergedLayer(data.water, SETTINGS.colors.water, 0, 0.1, false); + createMergedLayer(data.parks, SETTINGS.colors.park, 0, 0.2, false); + createMergedLayer(data.roads, SETTINGS.colors.road, 0, 0.3, false); } function animate() { requestAnimationFrame(animate); + + if (resizeRendererToDisplaySize()) { + const canvas = renderer.domElement; + camera.aspect = canvas.clientWidth / canvas.clientHeight; + camera.updateProjectionMatrix(); + } + const delta = clock.getDelta(); // Get time since last frame controls.update();