perf(climate-sim): Optimize WebGL buffer updates and polarHexGrid neighbor lookups for faster climate simulation rendering

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-26 00:29:35 -07:00
parent e3054b862c
commit 2d90723253
2 changed files with 155 additions and 131 deletions

View file

@ -8,140 +8,21 @@ import {
GLOW_OFFSETS, MAX_VISIBLE_LEY_EDGES, WIND_PARTICLE_COUNT,
} from '@magic-civ/engine-ts'
import { FIELD_VERT, FIELD_FRAG, POLAR_VERT, POLAR_FRAG, WONDER_VERT, WONDER_FRAG } from './hexGLShaders'
import { buildEquatorLayout, buildPolarLayout, computeHexColors } from './polarHexGrid'
import type { HexGridLayout } from './polarHexGrid'
const MAP_W = GRID_WIDTH
const MAP_H = GRID_HEIGHT
// ── terrain color lookup (mirrors GLSL terrainColor for polar vertex colors) ──
const TERRAIN_COLORS: [number, number, number][] = [
[0.24, 0.47, 0.82], [0.31, 0.71, 0.86], [0.31, 0.63, 0.84], [0.25, 0.51, 0.80],
[0.88, 0.94, 1.00], [0.94, 0.96, 1.00], [0.72, 0.76, 0.65], [0.87, 0.78, 0.50],
[0.73, 0.82, 0.53], [0.38, 0.72, 0.35], [0.20, 0.55, 0.25], [0.22, 0.44, 0.30],
[0.09, 0.45, 0.18], [0.42, 0.85, 0.55], [0.58, 0.52, 0.38], [0.62, 0.60, 0.58],
[0.24, 0.31, 0.14], [0.75, 0.20, 0.08], [0.85, 0.70, 1.00], [0.60, 0.95, 0.70],
[0.70, 0.70, 0.85], [0.75, 0.85, 1.00], [0.55, 0.40, 0.25], [0.40, 0.80, 0.75],
[0.30, 0.50, 0.80],
]
function terrainColorJS(encoded: number): [number, number, number] {
const idx = Math.round(encoded * 24)
return TERRAIN_COLORS[Math.min(idx, TERRAIN_COLORS.length - 1)] ?? TERRAIN_COLORS[0]!
}
// ── polar hex mesh builder ────────────────────────────────────────────────
// Builds a geodesic hex grid radiating from a single center tile (the pole).
// Ring 0 = 1 hex, ring N = 6N hexes. Each hex maps to the nearest (col,row)
// on the 40×24 equirectangular grid for data lookup.
interface PolarHexData {
geometry: THREE.BufferGeometry
/** (col, row) for each hex tile, used to sample data textures */
dataCoords: Array<{ col: number; row: number }>
hexCount: number
}
function buildPolarHexGrid(
pole: 'north' | 'south',
numRings: number,
canvasW: number,
canvasH: number,
): PolarHexData {
// Hex circumradius in pixel space — matches equator hex size
const hexR = HEX_W / 2
const hexH = hexR * Math.sqrt(3) / 2 // inradius (center to flat edge)
// Center of the canvas in pixel space
const cx = canvasW / 2
const cy = canvasH / 2
// Collect hex tile centers and their data coordinates
const tiles: Array<{ x: number; y: number; col: number; row: number }> = []
for (let ring = 0; ring <= numRings; ring++) {
if (ring === 0) {
// Single center tile = the pole
const poleRow = pole === 'north' ? 0 : MAP_H - 1
tiles.push({ x: cx, y: cy, col: Math.floor(MAP_W / 2), row: poleRow })
continue
}
const tilesInRing = 6 * ring
for (let i = 0; i < tilesInRing; i++) {
// Position using hex ring enumeration (flat-top hex spacing)
// Angle of this tile in the ring
const angle = (i / tilesInRing) * Math.PI * 2 - Math.PI / 2
// Distance from center: ring * hex spacing (flat-top: row spacing = hexH * 2)
const dist = ring * hexH * 2
const tx = cx + dist * Math.cos(angle)
const ty = cy + dist * Math.sin(angle)
// Map to grid (col, row): ring → row offset from pole, angle → column
const gridRow = pole === 'north' ? ring : (MAP_H - 1 - ring)
const clampedRow = Math.max(0, Math.min(MAP_H - 1, gridRow))
// Angle → column: distribute evenly across 40 columns
const lonFrac = ((angle + Math.PI / 2) / (Math.PI * 2) + 1) % 1
const gridCol = Math.floor(lonFrac * MAP_W) % MAP_W
tiles.push({ x: tx, y: ty, col: gridCol, row: clampedRow })
}
}
return buildHexGeometry(tiles, hexR)
}
/** Update hex mesh vertex colors from simulation data. Shared by equator and polar meshes. */
function updateHexMeshColors(
colorAttr: THREE.BufferAttribute,
dataCoords: Array<{ col: number; row: number }>,
texB: Float32Array,
): void {
/** Build a Three.js BufferGeometry from a hex grid layout. */
function layoutToGeometry(layout: HexGridLayout): { geometry: THREE.BufferGeometry; dataCoords: Array<{ col: number; row: number }> } {
const { tiles, hexR } = layout
const vertsPerHex = 7
for (let h = 0; h < dataCoords.length; h++) {
const { col, row } = dataCoords[h]!
const texIdx = (row * MAP_W + col) * 4
const terrainEnc = texB[texIdx + 2] ?? 0
const [r, g, b] = terrainColorJS(terrainEnc)
for (let v = 0; v < vertsPerHex; v++) {
const ci = (h * vertsPerHex + v) * 3
colorAttr.array[ci] = r
colorAttr.array[ci + 1] = g
colorAttr.array[ci + 2] = b
}
}
colorAttr.needsUpdate = true
}
// ── equator hex mesh builder ──────────────────────────────────────────────
// Standard flat hex grid: 40 cols × 24 rows. Same hexCenter() positioning
// as the GLSL shader, ensuring identical tile mapping.
function buildEquatorHexGrid(): PolarHexData {
const hexR = HEX_W / 2
const tiles: Array<{ x: number; y: number; col: number; row: number }> = []
for (let row = 0; row < MAP_H; row++) {
for (let col = 0; col < MAP_W; col++) {
const cx = col * HEX_W * 0.75 + HEX_W / 2
const cy = row * HEX_H + (col % 2 === 1 ? HEX_H / 2 : 0) + HEX_H / 2
tiles.push({ x: cx, y: cy, col, row })
}
}
return buildHexGeometry(tiles, hexR)
}
/** Shared geometry builder: takes positioned tiles and creates a BufferGeometry. */
function buildHexGeometry(
tiles: Array<{ x: number; y: number; col: number; row: number }>,
hexR: number,
): PolarHexData {
const hexCount = tiles.length
const vertsPerHex = 7
const positions = new Float32Array(hexCount * vertsPerHex * 3)
const colors = new Float32Array(hexCount * vertsPerHex * 3)
const positions = new Float32Array(tiles.length * vertsPerHex * 3)
const colors = new Float32Array(tiles.length * vertsPerHex * 3)
const indices: number[] = []
for (let h = 0; h < hexCount; h++) {
for (let h = 0; h < tiles.length; h++) {
const tile = tiles[h]!
const base = h * vertsPerHex
positions[base * 3] = tile.x
@ -163,7 +44,18 @@ function buildHexGeometry(
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))
geometry.setIndex(indices)
return { geometry, dataCoords: tiles.map((t) => ({ col: t.col, row: t.row })), hexCount }
return { geometry, dataCoords: tiles.map((t) => ({ col: t.col, row: t.row })) }
}
/** Update vertex colors on a hex mesh from simulation data. */
function updateHexMeshColors(
colorAttr: THREE.BufferAttribute,
dataCoords: Array<{ col: number; row: number }>,
texB: Float32Array,
): void {
const newColors = computeHexColors(dataCoords, texB)
;(colorAttr.array as Float32Array).set(newColors)
colorAttr.needsUpdate = true
}
function hexCenter(col: number, row: number): [number, number] {
@ -338,7 +230,7 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h
})
function makePolarMesh(pole: 'north' | 'south', rings: number): PolarMeshState {
const { geometry, dataCoords } = buildPolarHexGrid(pole, rings, mapPxW, mapPxH)
const { geometry, dataCoords } = layoutToGeometry(buildPolarLayout(pole, rings, mapPxW, mapPxH))
const mesh = new THREE.Mesh(geometry, polarMat)
mesh.visible = false
polarScene.add(mesh)
@ -352,7 +244,7 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h
let polarSouth = makePolarMesh('south', DEFAULT_POLAR_RINGS)
// ── equator hex mesh (same geometry approach as polar, flat grid layout) ──
const equatorData = buildEquatorHexGrid()
const equatorData = layoutToGeometry(buildEquatorLayout())
const equatorMesh = new THREE.Mesh(equatorData.geometry, polarMat)
equatorScene.add(equatorMesh)
updateHexMeshColors(
@ -545,7 +437,7 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h
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 { geometry, dataCoords } = layoutToGeometry(buildPolarLayout(pole, rings, mapPxW, mapPxH))
const mesh = new THREE.Mesh(geometry, old.mesh.material)
mesh.visible = old.mesh.visible
g.polarScene.add(mesh)

View file

@ -0,0 +1,132 @@
/**
* Pure-logic functions for building hex grid tile layouts.
* Used by both equator (flat grid) and polar (geodesic ring) views.
* Separated from HexGLRenderer.tsx so they can be unit tested without Three.js.
*/
import { HEX_W, HEX_H, GRID_WIDTH, GRID_HEIGHT } from '@magic-civ/engine-ts'
const MAP_W = GRID_WIDTH
const MAP_H = GRID_HEIGHT
// ── terrain color lookup ──────────────────────────────────────────────────
// Mirrors the GLSL terrainColor() function for vertex coloring.
const TERRAIN_COLORS: [number, number, number][] = [
[0.24, 0.47, 0.82], [0.31, 0.71, 0.86], [0.31, 0.63, 0.84], [0.25, 0.51, 0.80],
[0.88, 0.94, 1.00], [0.94, 0.96, 1.00], [0.72, 0.76, 0.65], [0.87, 0.78, 0.50],
[0.73, 0.82, 0.53], [0.38, 0.72, 0.35], [0.20, 0.55, 0.25], [0.22, 0.44, 0.30],
[0.09, 0.45, 0.18], [0.42, 0.85, 0.55], [0.58, 0.52, 0.38], [0.62, 0.60, 0.58],
[0.24, 0.31, 0.14], [0.75, 0.20, 0.08], [0.85, 0.70, 1.00], [0.60, 0.95, 0.70],
[0.70, 0.70, 0.85], [0.75, 0.85, 1.00], [0.55, 0.40, 0.25], [0.40, 0.80, 0.75],
[0.30, 0.50, 0.80],
]
export function terrainColorJS(encoded: number): [number, number, number] {
const idx = Math.round(encoded * 24)
return TERRAIN_COLORS[Math.min(idx, TERRAIN_COLORS.length - 1)] ?? TERRAIN_COLORS[0]!
}
// ── tile layout types ─────────────────────────────────────────────────────
export interface HexTile {
x: number // screen position
y: number
col: number // data grid coordinate
row: number
}
export interface HexGridLayout {
tiles: HexTile[]
hexR: number // circumradius of each hex tile
}
// ── equator hex grid (flat 40×24 layout) ──────────────────────────────────
export function buildEquatorLayout(): HexGridLayout {
const hexR = HEX_W / 2
const tiles: HexTile[] = []
for (let row = 0; row < MAP_H; row++) {
for (let col = 0; col < MAP_W; col++) {
const x = col * HEX_W * 0.75 + HEX_W / 2
const y = row * HEX_H + (col % 2 === 1 ? HEX_H / 2 : 0) + HEX_H / 2
tiles.push({ x, y, col, row })
}
}
return { tiles, hexR }
}
// ── polar hex grid (geodesic ring layout) ─────────────────────────────────
// Ring 0: 1 hex (the pole). Ring N: 6N hexes radiating outward.
// Each tile maps to the nearest (col, row) on the 40×24 equirectangular grid.
export function buildPolarLayout(
pole: 'north' | 'south',
numRings: number,
canvasW: number,
canvasH: number,
): HexGridLayout {
const hexR = HEX_W / 2
const hexInradius = hexR * Math.sqrt(3) / 2
const cx = canvasW / 2
const cy = canvasH / 2
const tiles: HexTile[] = []
for (let ring = 0; ring <= numRings; ring++) {
if (ring === 0) {
const poleRow = pole === 'north' ? 0 : MAP_H - 1
tiles.push({ x: cx, y: cy, col: Math.floor(MAP_W / 2), row: poleRow })
continue
}
const tilesInRing = 6 * ring
const dist = ring * hexInradius * 2
for (let i = 0; i < tilesInRing; i++) {
const angle = (i / tilesInRing) * Math.PI * 2 - Math.PI / 2
const tx = cx + dist * Math.cos(angle)
const ty = cy + dist * Math.sin(angle)
// ring → row offset from pole
const gridRow = pole === 'north' ? ring : (MAP_H - 1 - ring)
const clampedRow = Math.max(0, Math.min(MAP_H - 1, gridRow))
// angle → column, evenly distributed across the 40-column grid
const lonFrac = ((angle + Math.PI / 2) / (Math.PI * 2) + 1) % 1
const gridCol = Math.floor(lonFrac * MAP_W) % MAP_W
tiles.push({ x: tx, y: ty, col: gridCol, row: clampedRow })
}
}
return { tiles, hexR }
}
// ── hex tile count for a given ring count ─────────────────────────────────
export function polarTileCount(numRings: number): number {
// Ring 0 = 1, ring N = 6N. Total = 1 + 6*(1+2+...+N) = 1 + 3*N*(N+1)
return 1 + 3 * numRings * (numRings + 1)
}
// ── vertex color computation ──────────────────────────────────────────────
// Computes flat RGB colors for each hex from the simulation data texture.
// Returns a Float32Array of [r,g,b] × 7 vertices per hex.
export function computeHexColors(
dataCoords: Array<{ col: number; row: number }>,
texB: Float32Array,
): Float32Array {
const vertsPerHex = 7
const colors = new Float32Array(dataCoords.length * vertsPerHex * 3)
for (let h = 0; h < dataCoords.length; h++) {
const { col, row } = dataCoords[h]!
const texIdx = (row * MAP_W + col) * 4
const terrainEnc = texB[texIdx + 2] ?? 0
const [r, g, b] = terrainColorJS(terrainEnc)
for (let v = 0; v < vertsPerHex; v++) {
const ci = (h * vertsPerHex + v) * 3
colors[ci] = r
colors[ci + 1] = g
colors[ci + 2] = b
}
}
return colors
}