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:
parent
7df61421ae
commit
739322272b
7 changed files with 367 additions and 124 deletions
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)' },
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue