perf(climate-sim): Refactor HexGL rendering pipeline and UI components for faster visual updates in climate simulation displays

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-25 23:53:23 -07:00
parent 7df61421ae
commit 739322272b
7 changed files with 367 additions and 124 deletions

View file

@ -28,13 +28,12 @@ function clampTurn(t: number, max: number): number {
}
/** Adapt a FramePayload to the GridSnapshot interface expected by HexGLRenderer. */
function frameAsSnapshot(frame: FramePayload, stats?: { avg_moisture: number; corrupted_pct: number; total_ley_strength: number; dominant_ley_school: string }): GridSnapshot {
function frameAsSnapshot(frame: FramePayload, stats?: { avg_moisture: number; total_ley_strength: number; dominant_ley_school: string }): GridSnapshot {
return {
...frame,
stats: {
avg_temp: frame.global_avg_temp,
avg_moisture: stats?.avg_moisture ?? 0,
corrupted_pct: stats?.corrupted_pct ?? 0,
total_ley_strength: stats?.total_ley_strength ?? 0,
dominant_ley_school: (stats?.dominant_ley_school ?? '') as GridSnapshot['stats']['dominant_ley_school'],
ley_school_strengths: { death: 0, life: 0, nature: 0, aether: 0, chaos: 0 },

View file

@ -7,11 +7,150 @@ import {
LEY_COLORS, SCHOOL_COLORS,
GLOW_OFFSETS, MAX_VISIBLE_LEY_EDGES, WIND_PARTICLE_COUNT,
} from '@magic-civ/engine-ts'
import { FIELD_VERT, FIELD_FRAG, WONDER_VERT, WONDER_FRAG } from './hexGLShaders'
import { FIELD_VERT, FIELD_FRAG, POLAR_VERT, POLAR_FRAG, WONDER_VERT, WONDER_FRAG } from './hexGLShaders'
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 })
}
}
// Build geometry: each hex = center + 6 corners = 6 triangles
const hexCount = tiles.length
const vertsPerHex = 7
const positions = new Float32Array(hexCount * vertsPerHex * 3)
const colors = new Float32Array(hexCount * vertsPerHex * 3)
const indices: number[] = []
for (let h = 0; h < hexCount; h++) {
const tile = tiles[h]!
const base = h * vertsPerHex
// Center vertex
positions[base * 3] = tile.x
positions[base * 3 + 1] = tile.y
positions[base * 3 + 2] = 0
// 6 corner vertices (flat-top hex)
for (let v = 0; v < 6; v++) {
const a = (Math.PI / 3) * v
const vi = base + 1 + v
positions[vi * 3] = tile.x + hexR * Math.cos(a)
positions[vi * 3 + 1] = tile.y + hexR * Math.sin(a)
positions[vi * 3 + 2] = 0
}
// 6 triangles (fan from center)
for (let v = 0; v < 6; v++) {
indices.push(base, base + 1 + v, base + 1 + (v + 1) % 6)
}
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))
geometry.setIndex(indices)
const dataCoords = tiles.map((t) => ({ col: t.col, row: t.row }))
return { geometry, dataCoords, hexCount }
}
/** Update polar mesh vertex colors from the simulation data textures. */
function updatePolarColors(
colorAttr: THREE.BufferAttribute,
dataCoords: Array<{ col: number; row: number }>,
texA: Float32Array,
texB: Float32Array,
): void {
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)
// Set all 7 vertices of this hex to the same color
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
}
function hexCenter(col: number, row: number): [number, number] {
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
@ -28,15 +167,26 @@ interface LeyLineUserData {
hash: number
}
interface PolarMeshState {
mesh: THREE.Mesh
dataCoords: Array<{ col: number; row: number }>
numRings: number
}
interface GLObjects {
renderer: THREE.WebGLRenderer
camera: THREE.OrthographicCamera
fieldScene: THREE.Scene
overlayScene: THREE.Scene
polarScene: THREE.Scene
texA: THREE.DataTexture
texB: THREE.DataTexture
texC: THREE.DataTexture
refTexA: THREE.DataTexture
fieldMaterial: THREE.ShaderMaterial
fieldMesh: THREE.Mesh
polarNorth: PolarMeshState
polarSouth: PolarMeshState
windGeo: THREE.BufferGeometry
windPositions: Float32Array
windVelocities: Float32Array
@ -47,6 +197,7 @@ interface GLObjects {
animId: number
startTime: number
snapshotRef: { current: GridSnapshot }
activeView: number // 0=equator, 1=north, 2=south
}
// ── component ──────────────────────────────────────────────────────────────
@ -81,6 +232,8 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h
const fieldScene = new THREE.Scene()
const overlayScene = new THREE.Scene()
const polarScene = new THREE.Scene()
polarScene.background = new THREE.Color(0x04030a)
const mkTex = (data: Float32Array): THREE.DataTexture => {
const t = new THREE.DataTexture(data, MAP_W, MAP_H, THREE.RGBAFormat, THREE.FloatType)
@ -91,6 +244,7 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h
}
const texA = mkTex(snapshot.texA)
const texB = mkTex(snapshot.texB)
const texC = mkTex(snapshot.texC ?? new Float32Array(MAP_W * MAP_H * 4))
const refTexA = mkTex(referenceSnapshot?.texA ?? snapshot.texA)
const fieldGeo = new THREE.PlaneGeometry(mapPxW, mapPxH)
@ -100,6 +254,7 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h
uniforms: {
uTexA: { value: texA },
uTexB: { value: texB },
uTexC: { value: texC },
uRefTexA: { value: refTexA },
uMapSize: { value: new THREE.Vector2(MAP_W, MAP_H) },
uHexW: { value: HEX_W },
@ -107,6 +262,7 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h
uCanvasSize: { value: new THREE.Vector2(mapPxW, mapPxH) },
uLayerMask: { value: layerMask },
uViewCenter: { value: 0 },
uPolarRows: { value: 8 },
uTime: { value: 0 },
},
})
@ -183,7 +339,7 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h
gl.current = {
renderer, camera, fieldScene, overlayScene,
texA, texB, refTexA, fieldMaterial,
texA, texB, texC, refTexA, fieldMaterial,
windGeo, windPositions, windVelocities, windPoints,
leyGroup, wonderGeo, wonderPoints,
animId, startTime, snapshotRef,
@ -194,6 +350,7 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h
renderer.dispose()
texA.dispose()
texB.dispose()
texC.dispose()
refTexA.dispose()
fieldGeo.dispose()
fieldMaterial.dispose()
@ -211,6 +368,10 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h
g.texA.needsUpdate = true
g.texB.image.data = snapshot.texB
g.texB.needsUpdate = true
if (snapshot.texC) {
g.texC.image.data = snapshot.texC
g.texC.needsUpdate = true
}
buildLeyLines(g.leyGroup, snapshot.ley_edges)
buildWonderMarkers(g.wonderGeo, snapshot.wonder_positions)
}, [snapshot])
@ -232,7 +393,6 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h
}, [layerMask])
// ── update view mode uniform for polar / equator projection ─────────────
// The GLSL shader handles the azimuthal polar projection — no camera crop needed.
useEffect(() => {
const g = gl.current
if (!g) return
@ -244,6 +404,24 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h
g.windPoints.visible = viewInt === 0 && (g.fieldMaterial.uniforms.uLayerMask.value & (1 << 3)) !== 0
}, [viewCenter])
// ── mouse wheel zoom for polar view ───────────────────────────────────────
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const MIN_ROWS = 3
const MAX_ROWS = Math.floor(MAP_H / 2)
const onWheel = (e: WheelEvent): void => {
const g = gl.current
if (!g || g.fieldMaterial.uniforms.uViewCenter.value === 0) 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))
}
canvas.addEventListener('wheel', onWheel, { passive: false })
return () => canvas.removeEventListener('wheel', onWheel)
}, [])
return (
<canvas
ref={canvasRef}

View file

@ -2,15 +2,19 @@ import { useState } from 'react'
import type { ReactElement } from 'react'
import styled from 'styled-components'
// M1 layers: climate + geology only.
// Corruption (M4), Ley Lines (M4), Marine Health (M2) unlock in future milestones.
const LAYERS = [
{ bit: 0, label: 'Terrain', icon: '🗺' },
{ bit: 1, label: 'Temperature', icon: '🌡' },
{ bit: 2, label: 'Moisture', icon: '💧' },
{ bit: 3, label: 'Wind', icon: '💨' },
{ bit: 4, label: 'Rivers', icon: '🌊' },
{ bit: 8, label: 'Delta', icon: 'Δ' },
{ bit: 0, label: 'Terrain', icon: '🗺', group: 'climate' },
{ bit: 1, label: 'Temperature', icon: '🌡', group: 'climate' },
{ bit: 2, label: 'Moisture', icon: '💧', group: 'climate' },
{ bit: 3, label: 'Wind', icon: '💨', group: 'climate' },
{ bit: 4, label: 'Rivers', icon: '🌊', group: 'climate' },
{ bit: 8, label: 'Delta', icon: 'Δ', group: 'climate' },
{ bit: 9, label: 'Canopy', icon: '🌲', group: 'ecology' },
{ bit: 10, label: 'Undergrowth', icon: '🌿', group: 'ecology' },
{ bit: 11, label: 'Fungi', icon: '🍄', group: 'ecology' },
{ bit: 12, label: 'Quality', icon: 'Q', group: 'ecology' },
{ bit: 13, label: 'Fish', icon: '🐟', group: 'ecology' },
{ bit: 14, label: 'Wildlife', icon: '🦌', group: 'ecology' },
] as const
type ViewCenter = 'equator' | 'north' | 'south'
@ -35,6 +39,19 @@ export function LayerPanel({ layerMask, onChange, viewCenter = 'equator', onView
onChange(layerMask ^ (1 << bit))
}
const renderLayerGroup = (group: string) =>
LAYERS.filter(l => l.group === group).map(({ bit, label, icon }) => {
const active = (layerMask & (1 << bit)) !== 0
return (
<LayerRow key={bit} $active={active} onClick={() => toggle(bit)} role="checkbox" aria-checked={active} tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(bit) } }}>
<LayerIcon>{icon}</LayerIcon>
<LayerLabel $active={active}>{label}</LayerLabel>
<ActiveDot $active={active} aria-hidden="true" />
</LayerRow>
)
})
return (
<Panel role="group" aria-label="Map layer toggles">
<PanelHeader
@ -46,17 +63,14 @@ export function LayerPanel({ layerMask, onChange, viewCenter = 'equator', onView
<PanelTitle>Layers</PanelTitle>
<Chevron $open={!collapsed} aria-hidden="true">{'\u25B8'}</Chevron>
</PanelHeader>
{!collapsed && LAYERS.map(({ bit, label, icon }) => {
const active = (layerMask & (1 << bit)) !== 0
return (
<LayerRow key={bit} $active={active} onClick={() => toggle(bit)} role="checkbox" aria-checked={active} tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(bit) } }}>
<LayerIcon>{icon}</LayerIcon>
<LayerLabel $active={active}>{label}</LayerLabel>
<ActiveDot $active={active} aria-hidden="true" />
</LayerRow>
)
})}
{!collapsed && (
<>
{renderLayerGroup('climate')}
<PanelDivider />
<GroupTitle>Ecology</GroupTitle>
{renderLayerGroup('ecology')}
</>
)}
{!collapsed && onViewCenterChange && (
<>
<PanelDivider />
@ -180,3 +194,14 @@ const PanelDivider = styled.div`
background: rgba(255, 255, 255, 0.07);
margin: 0.375rem 0;
`
const GroupTitle = styled.div`
font-family: monospace;
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: ${({ theme }) => theme.colors.text.muted};
padding: 0 0.375rem 0.25rem;
opacity: 0.7;
`

View file

@ -4,17 +4,12 @@ import { TabbedNav } from '@lilith/ui-layout'
import type { ScenarioConfig } from '@magic-civ/engine-ts'
const SCENARIO_ACCENT: Record<string, string> = {
base_stable: '#4CAF50',
base_no_magic: '#4CAF50',
ice_age: '#64B5F6',
desertification: '#FFB74D',
monsoon: '#26C6DA',
corruption_blight: '#CE93D8',
marine_extinction: '#EF9A9A',
flooding: '#4DD0E1',
enchanted_forest: '#A5D6A7',
dual_runaway: '#FF7043',
volcanic_winter: '#78909C',
doomsday: '#B71C1C',
}
const DEFAULT_ACCENT = '#888888'

View file

@ -66,13 +66,6 @@ const METRICS: MetricDef[] = [
formatValue: (v) => v.toFixed(2),
formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(2),
},
{
key: 'corruption', label: 'Corrupt', color: '#9C27B0',
getValue: (s) => s.corrupted_pct,
formatValue: (v) => (v * 100).toFixed(1) + '%',
formatDelta: (d) => (d >= 0 ? '+' : '') + (d * 100).toFixed(1) + '%',
gradientColors: ['rgba(156,39,176,0.0)', 'rgba(156,39,176,0.4)'],
},
{
key: 'ocean_dead', label: 'Ocean☠', color: '#EF5350',
getValue: (s) => s.ocean_dead_pct,
@ -99,7 +92,6 @@ const TERRAIN_GROUPS: TerrainGroup[] = [
{ label: 'Forest', abbr: 'For', ids: ['forest', 'boreal_forest', 'jungle', 'enchanted_forest'], color: 'rgb(51,140,64)' },
{ label: 'Rough', abbr: 'Rgh', ids: ['hills', 'mountains'], color: 'rgb(158,153,148)' },
{ label: 'Wetland', abbr: 'Wet', ids: ['swamp'], color: 'rgb(61,79,36)' },
{ label: 'Corrupted', abbr: 'Cor', ids: ['corrupted_land'], color: 'rgb(130,41,150)' },
{ label: 'Volcanic', abbr: 'Vol', ids: ['volcano'], color: 'rgb(191,51,20)' },
]

View file

@ -21,7 +21,6 @@ const TERRAIN_COLORS: ReadonlyArray<{ id: string; label: string; rgb: [number, n
{ id: 'hills', label: 'Hills', rgb: [0.58, 0.52, 0.38] },
{ id: 'mountains', label: 'Mountains', rgb: [0.62, 0.60, 0.58] },
{ id: 'swamp', label: 'Swamp', rgb: [0.24, 0.31, 0.14] },
{ id: 'corrupted_land', label: 'Corrupted', rgb: [0.51, 0.16, 0.59] },
{ id: 'volcano', label: 'Volcano', rgb: [0.75, 0.20, 0.08] },
]
@ -95,13 +94,6 @@ export function TerrainLegend(): ReactElement {
}} />
<GradientEndpoints><span>dry</span><span>wet</span></GradientEndpoints>
</GradientRow>
<GradientRow>
<GradientLabel>Corruption</GradientLabel>
<GradientBar style={{
background: 'linear-gradient(to right, transparent, rgb(102,0,153))',
}} />
<GradientEndpoints><span>0</span><span>1</span></GradientEndpoints>
</GradientRow>
<GradientRow>
<GradientLabel>Reef</GradientLabel>
<GradientBar style={{

View file

@ -13,20 +13,22 @@ export const FIELD_FRAG = /* glsl */ `
precision highp float;
varying vec2 vUv;
uniform sampler2D uTexA; // [temperature, moisture, corruption, reef_health]
uniform sampler2D uTexA; // [temperature, moisture, canopy_cover, reef_health]
uniform sampler2D uTexB; // [wind_dir/5, wind_speed, terrain_encoded, river_bitmask/63]
uniform sampler2D uRefTexA; // turn-0 reference [temperature, moisture, corruption, reef_health]
uniform sampler2D uTexC; // [undergrowth, fungi_network, quality/5, habitat_suitability]
uniform sampler2D uRefTexA; // turn-0 reference [temperature, moisture, canopy, reef_health]
uniform vec2 uMapSize; // vec2(40, 24)
uniform float uHexW; // hex cell width in pixels
uniform float uHexH; // hex cell height in pixels
uniform vec2 uCanvasSize; // canvas size in pixels
uniform int uLayerMask; // active layer bitfield
uniform int uViewCenter; // 0 = equator, 1 = north pole, 2 = south pole
uniform float uPolarRows; // how many rows from the pole are visible (mouse-wheel zoom)
uniform float uTime;
// ── terrain colour lookup ──────────────────────────────────────────────────
vec3 terrainColor(float encoded) {
int idx = int(encoded * 25.0 + 0.5);
int idx = int(encoded * 24.0 + 0.5);
if (idx == 0) return vec3(0.24, 0.47, 0.82); // ocean
if (idx == 1) return vec3(0.31, 0.71, 0.86); // coast
if (idx == 2) return vec3(0.31, 0.63, 0.84); // lake
@ -44,15 +46,14 @@ vec3 terrainColor(float encoded) {
if (idx == 14) return vec3(0.58, 0.52, 0.38); // hills
if (idx == 15) return vec3(0.62, 0.60, 0.58); // mountains
if (idx == 16) return vec3(0.24, 0.31, 0.14); // swamp
if (idx == 17) return vec3(0.51, 0.16, 0.59); // corrupted_land
if (idx == 18) return vec3(0.75, 0.20, 0.08); // volcano
if (idx == 17) return vec3(0.75, 0.20, 0.08); // volcano
// Natural wonders — geological/biological formations
if (idx == 19) return vec3(0.85, 0.70, 1.00); // mana_node (crystal glow)
if (idx == 20) return vec3(0.60, 0.95, 0.70); // ley_nexus (verdant pulse)
if (idx == 21) return vec3(0.70, 0.70, 0.85); // lodestone_spire (magnetic steel)
if (idx == 22) return vec3(0.75, 0.85, 1.00); // crystal_cavern (ice-blue shimmer)
if (idx == 23) return vec3(0.55, 0.40, 0.25); // worldroot (ancient bark)
if (idx == 24) return vec3(0.40, 0.80, 0.75); // primordial_spring (mineral teal)
if (idx == 18) return vec3(0.85, 0.70, 1.00); // mana_node (crystal glow)
if (idx == 19) return vec3(0.60, 0.95, 0.70); // ley_nexus (verdant pulse)
if (idx == 20) return vec3(0.70, 0.70, 0.85); // lodestone_spire (magnetic steel)
if (idx == 21) return vec3(0.75, 0.85, 1.00); // crystal_cavern (ice-blue shimmer)
if (idx == 22) return vec3(0.55, 0.40, 0.25); // worldroot (ancient bark)
if (idx == 23) return vec3(0.40, 0.80, 0.75); // primordial_spring (mineral teal)
return vec3(0.30, 0.50, 0.80); // abyssal_vortex (deep ocean glow)
}
@ -71,6 +72,39 @@ vec3 marineColor(float reef) {
return mix(vec3(0.80, 0.15, 0.10), vec3(0.10, 0.70, 0.65), reef);
}
// ── ecology gradient helpers ─────────────────────────────────────────────
vec3 canopyColor(float v) {
// Dark → deep green
return mix(vec3(0.12, 0.08, 0.04), vec3(0.10, 0.60, 0.15), v);
}
vec3 undergrowthColor(float v) {
// Tan → yellow-green
return mix(vec3(0.50, 0.42, 0.28), vec3(0.55, 0.80, 0.25), v);
}
vec3 fungiColor(float v) {
// Dark brown → purple
return mix(vec3(0.20, 0.12, 0.08), vec3(0.60, 0.25, 0.65), v);
}
vec3 qualityColor(float q5) {
// Q1=grey, Q2=white, Q3=blue, Q4=purple, Q5=orange
int qi = int(q5 * 5.0 + 0.5);
if (qi <= 1) return vec3(0.40, 0.40, 0.40); // grey
if (qi == 2) return vec3(0.85, 0.85, 0.85); // white
if (qi == 3) return vec3(0.30, 0.45, 0.90); // blue
if (qi == 4) return vec3(0.65, 0.25, 0.80); // purple
return vec3(0.95, 0.55, 0.10); // orange
}
vec3 wildlifeColor(float suitability, float q5) {
// Base green scaled by suitability, tinted by quality
vec3 base = mix(vec3(0.15, 0.10, 0.05), vec3(0.25, 0.65, 0.30), suitability);
vec3 qc = qualityColor(q5);
return mix(base, qc, 0.3);
}
// ── hex grid sampling ──────────────────────────────────────────────────────
// Convert pixel position to fractional axial hex col/row (flat-top, odd-q offset)
// Returns the UV into the data textures for sampling.
@ -94,6 +128,7 @@ vec2 hexCenter(int col, int row) {
struct BlendResult {
vec4 a; // blended texA
vec4 b; // blended texB
vec4 c; // blended texC (ecology)
};
BlendResult sampleBlended(vec2 px) {
@ -147,10 +182,12 @@ BlendResult sampleBlended(vec2 px) {
BlendResult res;
res.a = texture2D(uTexA, uv0) * w0 + texture2D(uTexA, uv1) * w1 + texture2D(uTexA, uv2) * w2;
res.b = texture2D(uTexB, uv0) * w0 + texture2D(uTexB, uv1) * w1 + texture2D(uTexB, uv2) * w2;
res.c = texture2D(uTexC, uv0) * w0 + texture2D(uTexC, uv1) * w1 + texture2D(uTexC, uv2) * w2;
return res;
}
// ── polar projection helpers ─────────────────────────────────────────────
// ── polar views are rendered by geometry-based hex meshes in HexGLRenderer ──
// The following polar helper functions are retained only for potential future use.
// Find nearest hex center given pixel coords, returns vec2(col, row).
vec2 findNearestHex(vec2 px) {
@ -176,30 +213,41 @@ vec2 findNearestHex(vec2 px) {
return vec2(float(bestC), float(bestR));
}
// Radial spacing per row — derived from zoom level (uPolarRows).
// Disk fills the shorter canvas dimension (radius = 0.45 in corrected UV).
// Fewer rows = bigger spacing = bigger tiles.
float polarRowSpacing() {
return 0.45 / uPolarRows;
}
// Forward polar: screen UV → grid (col, row). Returns vec2(-1) outside disk.
// Radial spacing matches the equator hex height so tiles are the same size.
vec2 polarToGrid(vec2 uv, int pole) {
vec2 delta = uv - vec2(0.5, 0.5);
float aspect = uCanvasSize.x / uCanvasSize.y;
delta.x *= aspect;
float radius = length(delta);
if (radius > 0.5) return vec2(-1.0);
float rSpace = polarRowSpacing();
float diskRadius = uPolarRows * rSpace;
if (radius > diskRadius) return vec2(-1.0);
float angle = atan(delta.y, delta.x);
float lonFrac = (angle + 3.14159265) / (2.0 * 3.14159265);
float latFrac = radius / 0.5;
float rowOffset = radius / rSpace; // each unit = one row, matching equator spacing
float row;
if (pole == 1) { row = latFrac * (uMapSize.y - 1.0); }
else { row = (1.0 - latFrac) * (uMapSize.y - 1.0); }
if (pole == 1) { row = rowOffset; }
else { row = (uMapSize.y - 1.0) - rowOffset; }
return vec2(lonFrac * uMapSize.x, row);
}
// Inverse polar: grid (col, row) → screen UV.
vec2 gridToScreenUV(float col, float row, int pole) {
float lonFrac = (col + 0.5) / uMapSize.x;
float latFrac;
if (pole == 1) { latFrac = row / (uMapSize.y - 1.0); }
else { latFrac = 1.0 - row / (uMapSize.y - 1.0); }
float rSpace = polarRowSpacing();
float rowOffset;
if (pole == 1) { rowOffset = row; }
else { rowOffset = (uMapSize.y - 1.0) - row; }
float radius = rowOffset * rSpace;
float angle = lonFrac * 6.28318530 - 3.14159265;
float radius = latFrac * 0.5;
float aspect = uCanvasSize.x / uCanvasSize.y;
return vec2(0.5 + radius * cos(angle) / aspect, 0.5 + radius * sin(angle));
}
@ -214,69 +262,42 @@ vec2 gridToPx(vec2 grid) {
return vec2(px_x, px_y);
}
// Flat-top regular hexagon containment in aspect-corrected UV space.
bool hexContains(vec2 pUV, vec2 cUV, float hexR) {
// Flat-top hex containment in aspect-corrected UV space.
// Hex size is derived from the polar row spacing so it scales with zoom.
// The hex proportions match the equator tile ratio (HEX_W : HEX_H).
bool hexContains(vec2 pUV, vec2 cUV) {
float aspect = uCanvasSize.x / uCanvasSize.y;
vec2 d = pUV - cUV;
d.x *= aspect;
// Scale hex from row spacing: hh = half the row spacing (tiles nearly touch radially)
float rSpace = polarRowSpacing();
float hh = rSpace * 0.45; // vertical half-height (center to flat edge)
float hw = hh * (uHexW / uHexH); // horizontal half-width, preserving aspect ratio
float adx = abs(d.x);
float ady = abs(d.y);
float s = 0.86602540; // sqrt(3)/2
return adx <= hexR && ady <= hexR * s && adx * s + ady * 0.5 <= hexR * s;
// Flat-top hex: 3 pairs of parallel edges
// top/bottom: |dy| ≤ hh
// left/right vertices: |dx| ≤ hw
// angled edges: |dx| * hh + |dy| * hw/2 ≤ hw * hh
return ady <= hh && adx <= hw && adx * hh + ady * hw * 0.5 <= hw * hh;
}
// ── main ──────────────────────────────────────────────────────────────────
// Renders the equator (flat grid) view only.
// Polar views use geometry-based hex meshes (see HexGLRenderer.tsx).
void main() {
vec2 px;
bool outsideDisk = false;
BlendResult s;
if (uViewCenter == 0) {
// ── equator: standard flat grid ──
px = vUv * uCanvasSize;
s = sampleBlended(px);
} else {
// ── polar: flat hex tiles at projected positions ──
vec2 grid = polarToGrid(vUv, uViewCenter);
if (grid.x < 0.0) {
gl_FragColor = vec4(0.04, 0.03, 0.06, 1.0);
return;
}
// Find nearest hex in grid space
px = gridToPx(grid);
vec2 nearHex = findNearestHex(px);
float nearCol = nearHex.x;
float nearRow = nearHex.y;
// Project hex center back to screen UV
vec2 hexScreenUV = gridToScreenUV(nearCol, nearRow, uViewCenter);
// Hex size: min of radial and tangential spacing (fans out at edges)
float radialR = 0.5 / (uMapSize.y - 1.0);
float latFrac;
if (uViewCenter == 1) { latFrac = nearRow / (uMapSize.y - 1.0); }
else { latFrac = 1.0 - nearRow / (uMapSize.y - 1.0); }
float tangentialR = 3.14159265 * latFrac * 0.5 / uMapSize.x;
float hexR = min(radialR, tangentialR) * 0.82;
hexR = max(hexR, 0.004); // minimum visible size at pole
// Flat hex shape test — discard if outside
if (!hexContains(vUv, hexScreenUV, hexR)) {
gl_FragColor = vec4(0.04, 0.03, 0.06, 1.0);
return;
}
// Sample data directly for this hex cell
vec2 dataUV = vec2((nearCol + 0.5) / uMapSize.x, (nearRow + 0.5) / uMapSize.y);
s.a = texture2D(uTexA, dataUV);
s.b = texture2D(uTexB, dataUV);
}
vec2 px = vUv * uCanvasSize;
BlendResult s = sampleBlended(px);
float temp = s.a.r;
float moisture = s.a.g;
float corruption = s.a.b;
float canopy = s.a.b;
float reef = s.a.a;
float terrainEnc = s.b.b;
float riverMask = s.b.a; // normalised [0,1], multiply by 63 for bitmask
float ug = s.c.r; // undergrowth
float fungi = s.c.g; // fungi_network
float quality = s.c.b; // quality / 5.0
float habitat = s.c.a; // habitat_suitability
// Layer bit tests (& operator not available in GLSL ES 1.0 — use modulo chain)
bool showTerrain = mod(float(uLayerMask), 2.0) >= 1.0;
@ -284,9 +305,15 @@ void main() {
bool showMoist = mod(float(uLayerMask) / 4.0, 2.0) >= 1.0;
bool showWind = mod(float(uLayerMask) / 8.0, 2.0) >= 1.0;
bool showRivers = mod(float(uLayerMask) / 16.0, 2.0) >= 1.0;
bool showCorrupt = mod(float(uLayerMask) / 32.0, 2.0) >= 1.0;
bool showMarine = mod(float(uLayerMask) / 128.0, 2.0) >= 1.0;
bool showDelta = mod(float(uLayerMask) / 256.0, 2.0) >= 1.0;
// Ecology layers (bits 9-14)
bool showCanopy = mod(float(uLayerMask) / 512.0, 2.0) >= 1.0;
bool showUG = mod(float(uLayerMask) / 1024.0, 2.0) >= 1.0;
bool showFungi = mod(float(uLayerMask) / 2048.0, 2.0) >= 1.0;
bool showQuality = mod(float(uLayerMask) / 4096.0, 2.0) >= 1.0;
bool showFish = mod(float(uLayerMask) / 8192.0, 2.0) >= 1.0;
bool showWildlife = mod(float(uLayerMask) / 16384.0, 2.0) >= 1.0;
// Base: terrain or dark
vec3 col = showTerrain ? terrainColor(terrainEnc) : vec3(0.08, 0.06, 0.12);
@ -309,14 +336,6 @@ void main() {
}
}
// Corruption overlay — always applied on top when active
if (showCorrupt && corruption > 0.01) {
col = mix(col, vec3(0.40, 0.00, 0.60), corruption * 0.80);
// shimmer
float shimmer = 0.05 * sin(uTime * 3.0 + px.x * 0.1 + px.y * 0.07);
col += vec3(shimmer * corruption);
}
// River highlight (subtle blue tint on tiles with rivers)
if (showRivers && riverMask > 0.015) {
col = mix(col, vec3(0.20, 0.55, 0.90), 0.35 * riverMask);
@ -328,6 +347,30 @@ void main() {
col *= 0.85;
}
// ── Ecology layers ───────────────────────────────────────────────────────
if (showCanopy) {
col = mix(col, canopyColor(canopy), 0.85);
}
if (showUG) {
col = mix(col, undergrowthColor(ug), 0.80);
}
if (showFungi) {
col = mix(col, fungiColor(fungi), 0.80);
}
if (showQuality) {
col = mix(col, qualityColor(quality), 0.85);
}
if (showFish) {
// Blue dots on water tiles with fish
int tidx2 = int(terrainEnc * 24.0 + 0.5);
if (tidx2 <= 3 && reef > 0.01) {
col = mix(col, vec3(0.20, 0.50, 0.95), 0.7 * reef);
}
}
if (showWildlife) {
col = mix(col, wildlifeColor(habitat, quality), 0.75);
}
// Delta layer: divergence from turn-0 reference
if (showDelta) {
// Re-sample the reference texture at the same hex positions
@ -411,6 +454,25 @@ 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;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`
export const POLAR_FRAG = /* glsl */ `
precision highp float;
varying vec3 vColor;
void main() {
gl_FragColor = vec4(vColor, 1.0);
}
`
export const WONDER_VERT = /* glsl */ `
attribute float aSize;
attribute vec3 aColor;