perf(climate-sim): Optimize HexGLRenderer with new fragment shaders for faster climate simulation rendering

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-26 00:06:47 -07:00
parent 28773e2864
commit f65a169f8c
2 changed files with 140 additions and 17 deletions

View file

@ -305,12 +305,35 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h
const wonderPoints = new THREE.Points(wonderGeo, wonderMat)
overlayScene.add(wonderPoints)
// ── polar hex meshes (geodesic ring layout) ──────────────────────────
const DEFAULT_POLAR_RINGS = 8
const polarMat = new THREE.ShaderMaterial({
vertexShader: POLAR_VERT,
fragmentShader: POLAR_FRAG,
vertexColors: true,
})
function makePolarMesh(pole: 'north' | 'south', rings: number): PolarMeshState {
const { geometry, dataCoords } = buildPolarHexGrid(pole, rings, mapPxW, mapPxH)
const mesh = new THREE.Mesh(geometry, polarMat)
mesh.visible = false
polarScene.add(mesh)
// Initialize colors from current snapshot
const colorAttr = geometry.getAttribute('color') as THREE.BufferAttribute
updatePolarColors(colorAttr, dataCoords, snapshot.texA, snapshot.texB)
return { mesh, dataCoords, numRings: rings }
}
let polarNorth = makePolarMesh('north', DEFAULT_POLAR_RINGS)
let polarSouth = makePolarMesh('south', DEFAULT_POLAR_RINGS)
const snapshotRef = { current: snapshot }
buildLeyLines(leyGroup, snapshot.ley_edges)
buildWonderMarkers(wonderGeo, snapshot.wonder_positions)
const startTime = performance.now()
let animId = 0
let activeView = 0 // 0=equator, 1=north, 2=south
const tick = (): void => {
animId = requestAnimationFrame(tick)
@ -331,18 +354,24 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h
}
renderer.autoClear = true
renderer.render(fieldScene, camera)
renderer.autoClear = false
renderer.render(overlayScene, camera)
const currentView = gl.current?.activeView ?? 0
if (currentView === 0) {
renderer.render(fieldScene, camera)
renderer.autoClear = false
renderer.render(overlayScene, camera)
} else {
renderer.render(polarScene, camera)
}
}
tick()
gl.current = {
renderer, camera, fieldScene, overlayScene,
texA, texB, texC, refTexA, fieldMaterial,
renderer, camera, fieldScene, overlayScene, polarScene,
texA, texB, texC, refTexA, fieldMaterial, fieldMesh,
polarNorth, polarSouth,
windGeo, windPositions, windVelocities, windPoints,
leyGroup, wonderGeo, wonderPoints,
animId, startTime, snapshotRef,
animId, startTime, snapshotRef, activeView,
}
return () => {
@ -354,6 +383,9 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h
refTexA.dispose()
fieldGeo.dispose()
fieldMaterial.dispose()
polarNorth.mesh.geometry.dispose()
polarSouth.mesh.geometry.dispose()
polarMat.dispose()
windGeo.dispose()
wonderGeo.dispose()
}
@ -374,6 +406,11 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h
}
buildLeyLines(g.leyGroup, snapshot.ley_edges)
buildWonderMarkers(g.wonderGeo, snapshot.wonder_positions)
// Update polar mesh colors from new data
const colorN = g.polarNorth.mesh.geometry.getAttribute('color') as THREE.BufferAttribute
updatePolarColors(colorN, g.polarNorth.dataCoords, snapshot.texA, snapshot.texB)
const colorS = g.polarSouth.mesh.geometry.getAttribute('color') as THREE.BufferAttribute
updatePolarColors(colorS, g.polarSouth.dataCoords, snapshot.texA, snapshot.texB)
}, [snapshot])
// ── update reference texture for delta layer ────────────────────────────
@ -392,31 +429,118 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h
g.windPoints.visible = (layerMask & (1 << 3)) !== 0
}, [layerMask])
// ── update view mode uniform for polar / equator projection ─────────────
// ── camera zoom helper ─────────────────────────────────────────────────
// Sets the orthographic camera to show a region centered on (cx, cy) with given half-extents.
// Maintains the canvas aspect ratio.
const setCameraView = (g: GLObjects, cx: number, cy: number, halfW: number, halfH: number): void => {
g.camera.left = cx - halfW
g.camera.right = cx + halfW
g.camera.top = cy + halfH
g.camera.bottom = cy - halfH
g.camera.updateProjectionMatrix()
}
const mapPxWRef = useRef(MAP_W * HEX_W * 0.75 + HEX_W * 0.25)
const mapPxHRef = useRef(MAP_H * HEX_H + HEX_H * 0.5)
const zoomRef = useRef(1.0) // 1.0 = fit-to-view, <1 = zoomed in, >1 = zoomed out
/** Fit the camera to the polar mesh radius with current zoom */
const fitPolarCamera = (g: GLObjects, numRings: number): void => {
const hexR = HEX_W / 2
const hexInradius = hexR * Math.sqrt(3) / 2
const meshRadius = numRings * hexInradius * 2 + hexR // radius in pixels + margin
const cx = mapPxWRef.current / 2
const cy = mapPxHRef.current / 2
const aspect = mapPxWRef.current / mapPxHRef.current
const halfH = meshRadius * 1.15 * zoomRef.current
const halfW = halfH * aspect
setCameraView(g, cx, cy, halfW, halfH)
}
/** Fit the camera to the equator map with current zoom */
const fitEquatorCamera = (g: GLObjects): void => {
const w = mapPxWRef.current
const h = mapPxHRef.current
const halfW = (w / 2) * zoomRef.current
const halfH = (h / 2) * zoomRef.current
setCameraView(g, w / 2, h / 2, halfW, halfH)
}
// ── switch between equator field mesh and polar hex meshes ──────────────
useEffect(() => {
const g = gl.current
if (!g) return
const viewInt = viewCenter === 'north' ? 1 : viewCenter === 'south' ? 2 : 0
g.fieldMaterial.uniforms.uViewCenter.value = viewInt
// Hide overlays in polar mode — they render in world-pixel space, not projected
g.activeView = viewInt
zoomRef.current = 1.0 // reset zoom on view switch
// Show/hide meshes
g.fieldMesh.visible = viewInt === 0
g.polarNorth.mesh.visible = viewInt === 1
g.polarSouth.mesh.visible = viewInt === 2
// Overlays only in equator mode
g.leyGroup.visible = viewInt === 0
g.wonderPoints.visible = viewInt === 0
g.windPoints.visible = viewInt === 0 && (g.fieldMaterial.uniforms.uLayerMask.value & (1 << 3)) !== 0
// Set camera to fit the current view
if (viewInt === 0) {
fitEquatorCamera(g)
} else {
const polar = viewInt === 1 ? g.polarNorth : g.polarSouth
fitPolarCamera(g, polar.numRings)
}
}, [viewCenter])
// ── mouse wheel zoom for polar view ───────────────────────────────────────
// ── mouse wheel zoom (works for all views) ────────────────────────────────
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const MIN_ROWS = 3
const MAX_ROWS = Math.floor(MAP_H / 2)
const MIN_RINGS = 3
const MAX_RINGS = Math.floor(MAP_H / 2)
const mapPxW = mapPxWRef.current
const mapPxH = mapPxHRef.current
const rebuildPolar = (g: GLObjects, pole: 'north' | 'south', rings: number): PolarMeshState => {
const old = pole === 'north' ? g.polarNorth : g.polarSouth
g.polarScene.remove(old.mesh)
old.mesh.geometry.dispose()
const { geometry, dataCoords } = buildPolarHexGrid(pole, rings, mapPxW, mapPxH)
const mesh = new THREE.Mesh(geometry, old.mesh.material)
mesh.visible = old.mesh.visible
g.polarScene.add(mesh)
const colorAttr = geometry.getAttribute('color') as THREE.BufferAttribute
updatePolarColors(colorAttr, dataCoords, g.snapshotRef.current.texA, g.snapshotRef.current.texB)
return { mesh, dataCoords, numRings: rings }
}
const ZOOM_STEP = 0.1
const MIN_ZOOM = 0.3
const MAX_ZOOM = 2.0
const onWheel = (e: WheelEvent): void => {
const g = gl.current
if (!g || g.fieldMaterial.uniforms.uViewCenter.value === 0) return
if (!g) return
e.preventDefault()
const current = g.fieldMaterial.uniforms.uPolarRows.value as number
const delta = e.deltaY > 0 ? 1 : -1 // scroll down = zoom out (more rows)
g.fieldMaterial.uniforms.uPolarRows.value = Math.max(MIN_ROWS, Math.min(MAX_ROWS, current + delta))
if (g.activeView === 0) {
// Equator: camera zoom
const delta = e.deltaY > 0 ? ZOOM_STEP : -ZOOM_STEP
zoomRef.current = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoomRef.current + delta))
fitEquatorCamera(g)
} else {
// Polar: change ring count + fit camera
const polar = g.activeView === 1 ? g.polarNorth : g.polarSouth
const pole = g.activeView === 1 ? 'north' as const : 'south' as const
const delta = e.deltaY > 0 ? 1 : -1
const newRings = Math.max(MIN_RINGS, Math.min(MAX_RINGS, polar.numRings + delta))
if (newRings === polar.numRings) return
const newState = rebuildPolar(g, pole, newRings)
if (pole === 'north') g.polarNorth = newState
else g.polarSouth = newState
fitPolarCamera(g, newRings)
}
}
canvas.addEventListener('wheel', onWheel, { passive: false })
return () => canvas.removeEventListener('wheel', onWheel)

View file

@ -457,7 +457,6 @@ void main() {
// ── polar hex mesh shaders ────────────────────────────────────────────────
// Vertex colors are computed in JS from the data textures each frame.
export const POLAR_VERT = /* glsl */ `
attribute vec3 color;
varying vec3 vColor;
void main() {
vColor = color;