From 8ca8e82b50a2bad6e6843e5cbed62dc08d11d412 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 18:16:59 -0400 Subject: [PATCH] perf: InstancedMesh for portal tori + merged tile edge geometry (#415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two geometry batching wins to cut draw calls: 1. portals.js — Convert N individual portal Mesh objects to a single InstancedMesh. Online/offline brightness is encoded into per-instance color (AdditiveBlending: output = bg + color), so one shared material handles all portals. Exports refreshPortalInstanceColors() for health checks to call after status updates. 2. platform.js — Replace 69 individual LineSegments (one per glass tile, each with a cloned material) with a single merged LineSegments whose BufferGeometry holds all tile edge vertices pre-transformed to world position. glassEdgeMaterials export is kept as an empty array for API compatibility. 3. weather.js — Update runPortalHealthChecks() to call refreshPortalInstanceColors() instead of iterating portal group children to update per-mesh material opacity. Draw call reduction: ~72 → ~2 for these elements (69 edge + 3 portals). Refs #415 Co-Authored-By: Claude Sonnet 4.6 --- modules/platform.js | 30 +++++++++++++------- modules/portals.js | 67 +++++++++++++++++++++++++++++++-------------- modules/weather.js | 13 ++------- 3 files changed, 69 insertions(+), 41 deletions(-) diff --git a/modules/platform.js b/modules/platform.js index 62c3eb1..d1464ef 100644 --- a/modules/platform.js +++ b/modules/platform.js @@ -51,10 +51,8 @@ const GLASS_TILE_STEP = GLASS_TILE_SIZE + GLASS_TILE_GAP; export const GLASS_RADIUS = 4.55; const tileGeo = new THREE.PlaneGeometry(GLASS_TILE_SIZE, GLASS_TILE_SIZE); -const tileEdgeGeo = new THREE.EdgesGeometry(tileGeo); - /** @type {Array<{mat: THREE.LineBasicMaterial, distFromCenter: number}>} */ -export const glassEdgeMaterials = []; +export const glassEdgeMaterials = []; // kept for API compat; no longer populated const _tileDummy = new THREE.Object3D(); /** @type {Array<{x: number, z: number, distFromCenter: number}>} */ @@ -81,14 +79,26 @@ for (let i = 0; i < _tileSlots.length; i++) { glassTileIM.instanceMatrix.needsUpdate = true; glassPlatformGroup.add(glassTileIM); -for (const { x, z, distFromCenter } of _tileSlots) { - const mat = glassEdgeBaseMat.clone(); - const edges = new THREE.LineSegments(tileEdgeGeo, mat); - edges.rotation.x = -Math.PI / 2; - edges.position.set(x, 0.002, z); - glassPlatformGroup.add(edges); - glassEdgeMaterials.push({ mat, distFromCenter }); +// Merge all tile edge geometry into a single LineSegments draw call. +// Each tile contributes 4 edges (8 vertices). Previously this was 69 separate +// LineSegments objects with cloned materials — a significant draw-call overhead. +const _HS = GLASS_TILE_SIZE / 2; +const _edgeVerts = new Float32Array(_tileSlots.length * 8 * 3); +let _evi = 0; +for (const { x, z } of _tileSlots) { + const y = 0.002; + _edgeVerts[_evi++]=x-_HS; _edgeVerts[_evi++]=y; _edgeVerts[_evi++]=z-_HS; + _edgeVerts[_evi++]=x+_HS; _edgeVerts[_evi++]=y; _edgeVerts[_evi++]=z-_HS; + _edgeVerts[_evi++]=x+_HS; _edgeVerts[_evi++]=y; _edgeVerts[_evi++]=z-_HS; + _edgeVerts[_evi++]=x+_HS; _edgeVerts[_evi++]=y; _edgeVerts[_evi++]=z+_HS; + _edgeVerts[_evi++]=x+_HS; _edgeVerts[_evi++]=y; _edgeVerts[_evi++]=z+_HS; + _edgeVerts[_evi++]=x-_HS; _edgeVerts[_evi++]=y; _edgeVerts[_evi++]=z+_HS; + _edgeVerts[_evi++]=x-_HS; _edgeVerts[_evi++]=y; _edgeVerts[_evi++]=z+_HS; + _edgeVerts[_evi++]=x-_HS; _edgeVerts[_evi++]=y; _edgeVerts[_evi++]=z-_HS; } +const _mergedEdgeGeo = new THREE.BufferGeometry(); +_mergedEdgeGeo.setAttribute('position', new THREE.BufferAttribute(_edgeVerts, 3)); +glassPlatformGroup.add(new THREE.LineSegments(_mergedEdgeGeo, glassEdgeBaseMat)); export const voidLight = new THREE.PointLight(NEXUS.colors.accent, 0.5, 14); voidLight.position.set(0, -3.5, 0); diff --git a/modules/portals.js b/modules/portals.js index e9a673d..93947e0 100644 --- a/modules/portals.js +++ b/modules/portals.js @@ -10,32 +10,57 @@ scene.add(portalGroup); export let portals = []; +// Shared geometry and material for all portal tori — populated in createPortals() +const _portalGeo = new THREE.TorusGeometry(3.0, 0.2, 16, 100); +const _portalMat = new THREE.MeshBasicMaterial({ + color: 0xffffff, // instance color provides per-portal tint + transparent: true, + opacity: 1.0, // online/offline brightness encoded into instance color + blending: THREE.AdditiveBlending, + side: THREE.DoubleSide, + depthWrite: false, +}); +const _portalDummy = new THREE.Object3D(); +const _portalColor = new THREE.Color(); + +/** @type {THREE.InstancedMesh|null} */ +let _portalIM = null; + function createPortals() { - const portalGeo = new THREE.TorusGeometry(3.0, 0.2, 16, 100); + // One InstancedMesh for all portal tori — N portals = 1 draw call. + _portalIM = new THREE.InstancedMesh(_portalGeo, _portalMat, portals.length); + _portalIM.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + _portalIM.userData.zoomLabel = 'Portal'; + _portalIM.userData.portals = portals; // for instanceId look-up on click - portals.forEach(portal => { + portals.forEach((portal, i) => { const isOnline = portal.status === 'online'; + // Encode online/offline brightness into the instance color so we need + // only one shared material (AdditiveBlending: output = bg + color). + _portalColor.set(portal.color).convertSRGBToLinear() + .multiplyScalar(isOnline ? 0.7 : 0.15); + _portalIM.setColorAt(i, _portalColor); - const portalMat = new THREE.MeshBasicMaterial({ - color: new THREE.Color(portal.color).convertSRGBToLinear(), - transparent: true, - opacity: isOnline ? 0.7 : 0.15, - blending: THREE.AdditiveBlending, - side: THREE.DoubleSide, - }); - - const portalMesh = new THREE.Mesh(portalGeo, portalMat); - - portalMesh.position.set(portal.position.x, portal.position.y + 0.5, portal.position.z); - portalMesh.rotation.y = portal.rotation.y; - portalMesh.rotation.x = Math.PI / 2; - - portalMesh.name = `portal-${portal.id}`; - portalMesh.userData.destinationUrl = portal.destination?.url || null; - portalMesh.userData.portalColor = new THREE.Color(portal.color).convertSRGBToLinear(); - - portalGroup.add(portalMesh); + _portalDummy.position.set(portal.position.x, portal.position.y + 0.5, portal.position.z); + _portalDummy.rotation.set(Math.PI / 2, portal.rotation.y || 0, 0); + _portalDummy.updateMatrix(); + _portalIM.setMatrixAt(i, _portalDummy.matrix); }); + + _portalIM.instanceColor.needsUpdate = true; + _portalIM.instanceMatrix.needsUpdate = true; + portalGroup.add(_portalIM); +} + +/** Update per-instance colors after a portal health check. */ +export function refreshPortalInstanceColors() { + if (!_portalIM) return; + portals.forEach((portal, i) => { + const brightness = portal.status === 'online' ? 0.7 : 0.15; + _portalColor.set(portal.color).convertSRGBToLinear().multiplyScalar(brightness); + _portalIM.setColorAt(i, _portalColor); + }); + _portalIM.instanceColor.needsUpdate = true; } // rebuildGravityZones forward ref diff --git a/modules/weather.js b/modules/weather.js index 7623686..734fcda 100644 --- a/modules/weather.js +++ b/modules/weather.js @@ -4,6 +4,7 @@ import { scene, ambientLight } from './scene-setup.js'; import { cloudMaterial } from './platform.js'; import { rebuildRuneRing } from './effects.js'; import { S } from './state.js'; +import { refreshPortalInstanceColors } from './portals.js'; // === PORTAL HEALTH CHECKS === const PORTAL_HEALTH_CHECK_MS = 5 * 60 * 1000; @@ -41,16 +42,8 @@ export async function runPortalHealthChecks() { rebuildRuneRing(); if (_rebuildGravityZonesFn) _rebuildGravityZonesFn(); - if (_portalGroupRef) { - for (const child of _portalGroupRef.children) { - const portalId = child.name.replace('portal-', ''); - const portalData = _portalsRef.find(p => p.id === portalId); - if (portalData) { - const isOnline = portalData.status === 'online'; - child.material.opacity = isOnline ? 0.7 : 0.15; - } - } - } + // Refresh portal InstancedMesh colors to reflect new online/offline statuses. + refreshPortalInstanceColors(); } export function initPortalHealthChecks() { -- 2.43.0