perf: InstancedMesh for portal tori + merged tile edge geometry (#415)
All checks were successful
CI / validate (pull_request) Successful in 10s
CI / auto-merge (pull_request) Successful in 4s

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 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-24 18:16:59 -04:00
parent b4f6ff5222
commit 8ca8e82b50
3 changed files with 69 additions and 41 deletions

View File

@@ -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);

View File

@@ -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

View File

@@ -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() {