From 739322272bbf4bb344d657f419a3c3a1b51caaef Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 25 Mar 2026 23:53:23 -0700 Subject: [PATCH] =?UTF-8?q?perf(climate-sim):=20=E2=9A=A1=20Refactor=20Hex?= =?UTF-8?q?GL=20rendering=20pipeline=20and=20UI=20components=20for=20faste?= =?UTF-8?q?r=20visual=20updates=20in=20climate=20simulation=20displays?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../climate-sim/ClimateSimDisplay.tsx | 3 +- .../components/climate-sim/HexGLRenderer.tsx | 184 ++++++++++++++- .../src/components/climate-sim/LayerPanel.tsx | 63 +++-- .../components/climate-sim/ScenarioTabs.tsx | 7 +- .../components/climate-sim/StatsDashboard.tsx | 8 - .../components/climate-sim/TerrainLegend.tsx | 8 - .../components/climate-sim/hexGLShaders.ts | 218 +++++++++++------- 7 files changed, 367 insertions(+), 124 deletions(-) diff --git a/guide/engine/src/components/climate-sim/ClimateSimDisplay.tsx b/guide/engine/src/components/climate-sim/ClimateSimDisplay.tsx index 40d20d0d..8360dff3 100644 --- a/guide/engine/src/components/climate-sim/ClimateSimDisplay.tsx +++ b/guide/engine/src/components/climate-sim/ClimateSimDisplay.tsx @@ -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 }, diff --git a/guide/engine/src/components/climate-sim/HexGLRenderer.tsx b/guide/engine/src/components/climate-sim/HexGLRenderer.tsx index 92ec9fb0..68bb4eb5 100644 --- a/guide/engine/src/components/climate-sim/HexGLRenderer.tsx +++ b/guide/engine/src/components/climate-sim/HexGLRenderer.tsx @@ -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 ( + LAYERS.filter(l => l.group === group).map(({ bit, label, icon }) => { + const active = (layerMask & (1 << bit)) !== 0 + return ( + toggle(bit)} role="checkbox" aria-checked={active} tabIndex={0} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(bit) } }}> + {icon} + {label} + + ) + }) + return ( Layers - {!collapsed && LAYERS.map(({ bit, label, icon }) => { - const active = (layerMask & (1 << bit)) !== 0 - return ( - toggle(bit)} role="checkbox" aria-checked={active} tabIndex={0} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(bit) } }}> - {icon} - {label} - - ) - })} + {!collapsed && ( + <> + {renderLayerGroup('climate')} + + Ecology + {renderLayerGroup('ecology')} + + )} {!collapsed && onViewCenterChange && ( <> @@ -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; +` diff --git a/guide/engine/src/components/climate-sim/ScenarioTabs.tsx b/guide/engine/src/components/climate-sim/ScenarioTabs.tsx index 1143c230..14bca42e 100644 --- a/guide/engine/src/components/climate-sim/ScenarioTabs.tsx +++ b/guide/engine/src/components/climate-sim/ScenarioTabs.tsx @@ -4,17 +4,12 @@ import { TabbedNav } from '@lilith/ui-layout' import type { ScenarioConfig } from '@magic-civ/engine-ts' const SCENARIO_ACCENT: Record = { - 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' diff --git a/guide/engine/src/components/climate-sim/StatsDashboard.tsx b/guide/engine/src/components/climate-sim/StatsDashboard.tsx index 8dc61fbf..456eed86 100644 --- a/guide/engine/src/components/climate-sim/StatsDashboard.tsx +++ b/guide/engine/src/components/climate-sim/StatsDashboard.tsx @@ -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)' }, ] diff --git a/guide/engine/src/components/climate-sim/TerrainLegend.tsx b/guide/engine/src/components/climate-sim/TerrainLegend.tsx index 89dc3ba4..20d5407f 100644 --- a/guide/engine/src/components/climate-sim/TerrainLegend.tsx +++ b/guide/engine/src/components/climate-sim/TerrainLegend.tsx @@ -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 { }} /> drywet - - Corruption - - 01 - Reef 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;