diff --git a/docs/usr/modules/hexmap.md b/docs/usr/modules/hexmap.md index 5fdd747e..7761752c 100644 --- a/docs/usr/modules/hexmap.md +++ b/docs/usr/modules/hexmap.md @@ -164,6 +164,47 @@ channel)` 的整数哈希直接导出五个分量,因此不需要分配表、 (长度、magic、版本、尺寸上限 512 且为 5 的倍数、单位格在界内、单位格可站立、 单位不重复),因此被拒绝的载荷不会留下部分写入的地图。修订号字段只作诊断。 +### 球面后端([HexSphereTopology.h](../../../src/modules/hexmap/HexSphereTopology.h)、[HexSphereMap.h](../../../src/modules/hexmap/HexSphereMap.h)、[HexSphereMesh.h](../../../src/modules/hexmap/HexSphereMesh.h)、[HexSphereGenerator.h](../../../src/modules/hexmap/HexSphereGenerator.h)) + +前面所有小节都是**平面**地图:单元身份是 `HexCoordinates`,邻居是固定的整数方向偏移。 +球面后端复用同一套单元记录(`HexCellData` / `HexValues` / `HexFlags`)、同一套几何常量 +(`HexMetrics`)、同一个 `HexMeshData` 与同一份顶点编码,只替换这两件事——因为在闭合 +球面上它们才真正不成立: + +- **单元身份**是稠密的 `HexSphereCell`(`int32`),来自**二十面体(Goldberg)拓扑**: + 二十面体按 `f = 2^subdivision` 细分后的**顶点**就是单元,于是 + `cellCount = 10f² + 2`,其中恰好 12 个是五边形(恒为 `[0, 12)`,因为中点细分从不 + 重编号原始顶点);`edgeCount = 30f²`,`cornerCount = 20f²`,每个角点恰好被 3 格共享。 +- **邻居关系**来自半边表。**方向不是算术**:六条边的方位逐格变化,而五边形只有五条边, + 因此不存在处处成立的 `opposite(direction)`——所有跨边查询走 + `directionOf(cell, other)`。方向 `d` 的边位于 `corner(d)` 与 `corner(d+1)` 之间, + 这两个角点由该边两侧的单元**以相反顺序**共享。 + +`HexSphereMap` 是 `HexMap` 的球面对应物:持有单元存储、`HexNoise`、**逐格**脏标记 +(球面没有分块网格,拓扑已经给每格一个全局稳定 id)、拾取、距离、刷子与全套编辑接口。 +高程是**径向**的:`surfaceRadius = sphereRadius + elevation · elevationStep`。 +`elevationStep` 默认取半径的 `0.006`,而不是平面的 `HexMetrics::kElevationStep` +(3 世界单位)——后者在半径 100 的星球上会让 13 级高程跨越 39% 半径。`HexFlags` 的 +道路/河流 6 个 bit 槽直接由方向索引寻址,五边形上槽 5 永不置位。 + +`buildSphereTerrainMesh` / `buildSphereWaterMesh` 沿用平面构建器的角点矩阵方案,顶点 +编码(`HexTerrainVertexCode`)完全一致,因此地形着色器的读法不变。三处平面假设被替换: + +1. **边数**不是 6(五边形是 5 条),没有任何循环可以写死 `kHexDirectionCount`; +2. **边归属**:平面版靠分块网格(`direction <= SE`)保证每条共享边只发一次,球面改用 + **较小的单元 id** 拥有该边——同一组边上的全序,同样保证恰好一次; +3. **"垂直"是径向**:坡度台地沿**弧**走水平步、沿**半径**走垂直步。 + +平面版的梯田带用弦 `v1 → v5` 跨越整条边,这在平面上安全**只因为**它的 5 个采样点共线; +球面上采样点在弧上,弦会在每条带坡度或崖壁的边留下 T 型接缝,因此球面版按扇形同样的 +4 段拆分。地形顶点扰动也改为**切向**并把结果投影回原半径:切向偏移会以 `O(d²/r)` 改变 +半径,而这里扰动量是单元尺寸的可观比例。 + +`generateSphereMap` 采样**三维**值噪声:`HexNoise` 是 XZ 平面场,用单元方向去采样会在 +两极塌缩(方向的 XZ 投影趋零)、在经度回绕处接缝。海平面由噪声自身的分位数确定,因此 +`landPercentage` 真正决定陆地占比,而不是听任噪声分布。输出是 +`(seed, 拓扑, 半径)` 的纯函数。 + ## 脚本 API(`hexmap`) ```squirrel @@ -207,6 +248,22 @@ hexmap.findPath(fx, fz, tx, tz) // -> {ok, value=[[x, z, turn], ...]} hexmap.saveMap() // -> {ok, value=<二进制安全字符串>} hexmap.loadMap(gfx, blob) // -> Result hexmap.generateMap(gfx, seed, landPercentage, waterLevel, riverPercentage) // -> Result + +// 球面(与平面地图相互独立,可同时存在) +hexmap.newSphere(gfx, subdivision, radius, seed) // -> Result;重建拓扑并释放上一颗星球的网格 +hexmap.sphereReady() / sphereCellCount() / spherePentagonCount() / sphereSubdivision() +hexmap.sphereRadius() / sphereElevationStep() / sphereCellSpacing() +hexmap.sphereIsPentagon(cell) / sphereNeighborCount(cell) / sphereNeighbor(cell, direction) +hexmap.sphereDirection(cell) / sphereCornerDirection(cell, corner) / sphereCornerCount(cell) +hexmap.sphereElevation(cell) / sphereWaterLevel(cell) / sphereTerrainType(cell) / sphereIsUnderwater(cell) +hexmap.sphereCellAt(x, y, z) / sphereDistance(a, b) +hexmap.spherePickCell(ox, oy, oz, dx, dy, dz) // -> {ok, value=} +hexmap.sphereSetElevation(cell, v) / sphereSetTerrainType(cell, v) +hexmap.sphereEditElevation(cell, radius, delta) / sphereEditTerrainType(cell, radius, terrainType) +hexmap.generateSphere(gfx, seed, landPercentage, waterLevel) // -> Result;生成并重建两个网格 +hexmap.rebuildSphere(gfx) // -> Result;批量编辑后重建 +hexmap.sphereTerrainMesh() / sphereWaterMesh() // -> Mesh(借用),无几何时为 null +hexmap.sphereReleaseMeshes(gfx) ``` `surface` 取 `0=Terrain, 1=Water, 2=River, 3=Road, 4=Fog, 5=Wall, 6=Feature`。 @@ -217,6 +274,13 @@ hexmap.generateMap(gfx, seed, landPercentage, waterLevel, riverPercentage) // **当前网格尺寸**重新生成,会丢弃全部单位与迷雾状态,调用方必须在成功后重新放置单位 并重建全部块。 +球面接口用 `cell`(稠密 id,`0 .. sphereCellCount()-1`),与平面接口的偏移坐标 `(x, z)` +是**两套互不相通的地址空间**:不要拿平面坐标去喂球面接口,反之亦然。`sphereDirection` +与 `sphereCornerDirection` 返回 `[x, y, z]` 单位向量。`newSphere` 只重建拓扑, +`generateSphere` 才写入单元并重建网格;两者都会释放上一颗星球的网格,因此调用方必须在 +成功后重新绑定 renderable。`rebuildSphere` 是批量编辑后的重建入口——球面是**整张一个 +网格**(没有分块),每次重建都是全量,应当攒够一批编辑再调一次,而不是每改一格就调。 + ## 生命周期与所有权 - 模块**只持有一张活动地图**(参考工程的编辑器同样只编辑一张格子);`newGrid` @@ -244,6 +308,16 @@ hexmap.generateMap(gfx, seed, landPercentage, waterLevel, riverPercentage) // 存档载荷经 `eve.Filesystem().writeText()` 落到保存目录,读回时先整体校验再接管 网格,因此损坏的文件不会破坏当前地图。 +`examples/hex-planet` 是**球面后端**的示例:轨道相机绕星球旋转,`LMB` 拖拽改变 +方位角/仰角,滚轮缩放,`space` 自动旋转,`R` 换种子,`[`/`]` 切换细分级别,`-`/`=` +调整陆地占比,`F5` 抓帧。默认姿态是静止的,所以两次运行抓到的画面几乎逐像素 +一致(仅 ImGui 覆盖层有像素级差异)。它自带两个片元着色器:平面地形着色器把 +`vWorldPos.y` 当海拔、`N.y` 当"上"、细节噪声投影到 XZ、远处用距离雾——这四件事在球面上 +都不成立,因此球面版保留调色板与 BRDF、只替换这四处。另外宿主会在片元输出之后再套一层 +Reinhard 风格的 tonemap(调试着色器输出常量 `1.0` 到达帧缓冲是 `224/255`,即 +`y = x/(x+0.139)`),两个球面着色器都用 `hostTonemapInverse` 反解这条曲线,否则颜色会被 +压缩两次且无法调参。 + 相机方面示例做了一个必要的修正:`eve.Camera3D()` 的默认裁剪范围是 `0.1..100`, 比一张地图的跨度还小,超过 100 世界单位的地形会被静默裁掉,远端的缩放档位因此只 能看到空屏。示例现在每帧按地图跨度推导远裁剪面,并让最远缩放距离随之放大 @@ -265,3 +339,9 @@ NE/E/SE 侧发,避免水/水边界重复),邻居是陆地就发四个细 地图生成器的两处刻意偏离参考工程(均已在源码中注释):侵蚀取共享搜索前沿中最低的 格子,而参考工程是均匀随机挑选可侵蚀格;温度抖动使用私有的值噪声,而参考工程采样 噪声贴图。生成器的 `HemisphereMode` 只实现参考工程的 `North` 映射。 + +球面后端的已知边界:球面**没有**河流、道路、城墙、地物、迷雾与单位的网格与查询接口 +(`HexFlags` 的位可以设置,但没有对应的球面几何与寻路);海洋是每格一个六边形盖面, +没有浅滩、折射与岸线浪花,且按模块自身的规则 `waterLevel == elevation` 不算淹没, +所以海平面上的格子会露出;崖壁是径向直墙,没有悬垂或侵蚀细节;球面生成器没有河流、 +侵蚀与板块构造——平面的 `HexMapGenerator` 没有移植过来。球面地图也**没有存档格式**。 diff --git a/examples/README.md b/examples/README.md index 8f33e9a7..7a7bcbff 100644 --- a/examples/README.md +++ b/examples/README.md @@ -146,6 +146,7 @@ Windows 本地直接跑同样的命令即可:脚本自身按 UTF-8 写 stdout/ | [linear-structures](linear-structures/README.md) | 线性可拼接结构:栅栏 / 石墙 / 桥 / 长城 / 树篱 / 拒马 | | [hex-terrain](hex-terrain/README.md) | 无源美术的 3D 六边形世界:`mesh.hexterrain` 配方 | | [hex-terrain-3d](hex-terrain-3d/README.md) | 可交互 3D 六边形地图:高程台地与悬崖、水面、河流、道路、城墙与地物、战争迷雾、单位寻路、程序化地图生成、存档、相机操控与笔刷编辑 | +| [hex-planet](hex-planet/README.md) | 球面 hex 地形:二十面体(Goldberg)六边形拓扑、径向高程与台地/悬崖、程序化大陆与海洋、轨道相机(`hexmap` 球面后端) | | [terrain-preview](terrain-preview/README.md) | 直写交换链的 3D 地形预览:三种侵蚀 + 河湖水面 + 自动抓帧 | | [terrain-gallery](terrain-gallery/README.md) | 三个固定 seed 在相同生成参数、光照与材质下的并排对比 | | [mesh-modifier-lab](mesh-modifier-lab/README.md) | 13 种网格变形变体并排对照:类型化修饰图(bend / twist / FFD / 切面 / 样条 / 声波)、雕刻笔刷、粘液回弹、Mesh Fit、顶点编辑器 | diff --git a/examples/hex-planet/README.md b/examples/hex-planet/README.md new file mode 100644 index 00000000..22fc63ad --- /dev/null +++ b/examples/hex-planet/README.md @@ -0,0 +1,105 @@ +# hex-planet + +The `hexmap` module's hex terrain, wrapped onto a sphere and viewed from orbit. + +This is the spherical counterpart of [`examples/hex-terrain-3d`](../hex-terrain-3d): +the same module, the same `HexCellData` / `HexValues` / `HexFlags` cell records, the +same `HexMetrics` geometry constants, the same `HexMeshData` container and the same +`HexTerrainVertexCode` vertex encoding. What changes is the surface: an icosahedral +(Goldberg) topology instead of a rectangular chunk grid, and a radial "up" instead of +world Y. + +``` +make run/win32-debug GAME=examples/hex-planet # Windows +make run/linux-debug GAME=examples/hex-planet # Linux (headless: see AGENTS.md) +``` + +## What it looks like + +Orbit camera around a generated planet, with a hexagonal tessellation you can read at +the limb and in the faceted coastlines. `hex-planet.png` in this directory is captured +by the example itself after 90 frames. The default pose is static, so two runs agree to +within the ImGui overlay: measured across two runs, **25 of 1,024,000 pixels differ** +(the HUD), and the globe itself is identical. + +## Controls + +| Input | Action | +|---|---| +| LMB drag | orbit / pitch | +| wheel | zoom | +| `space` | toggle auto-spin | +| `R` | new seed | +| `[` `]` | subdivision level down / up | +| `-` `=` | land share down / up | +| `esc` | reset the view | +| `F5` | save `hex-planet.png` | + +## How it is put together + +Everything geometric is C++; the script owns only the camera, the input mapping, the +two renderables and the HUD. + +| Piece | Where | Role | +|---|---|---| +| `HexSphereTopology` | `src/modules/hexmap` | icosahedral cell graph: ids, neighbours, edge directions, corners | +| `HexSphereMap` | `src/modules/hexmap` | editable sphere: cell records, radial elevation, picking, brushes, dirty cells | +| `HexSphereMesh` | `src/modules/hexmap` | builds the terrain and ocean meshes of the whole sphere | +| `HexSphereGenerator` | `src/modules/hexmap` | continents, sea level, biomes from 3D noise | +| script bindings | `HexMapModule` | `hexmap.sphere*` | +| this directory | — | orbit camera, renderables, HUD, shaders | + +### The four things that do not survive the change of surface + +1. **Edge count.** A cell has six edges, or five if it is one of the twelve pentagons. + Nothing iterates a fixed six directions, and there is no `opposite(direction)` + offset that works everywhere: the sphere map uses `directionOf(cell, other)` + throughout, because a hexagon–pentagon edge has no arithmetic opposite. +2. **Edge ownership.** The planar builder relies on the chunk grid to emit each shared + boundary exactly once. A sphere has no chunk grid, so the lower cell id owns the + edge instead — still a total order over the same set of edges. +3. **"Vertical" is radial.** Positions are a unit direction times a radius, and the + terrace interpolation of a slope moves along the *arc* by the horizontal step and + along the *radius* by the vertical one. +4. **Shading has no horizon.** The planar terrain shader reads `vWorldPos.y` as + altitude, `vNormal.y` as up, projects detail noise into XZ and fades into distance + fog. On a sphere Y spans the whole planet, XZ collapses at the poles, the normal is + the radial direction, and there is no horizon to fade into. `hex_planet_terrain` + keeps the palette and the BRDF byte-for-byte and replaces only those four. + +### Notes for anyone editing the shaders + +The host applies its own Reinhard-style tonemap to this fragment stage's output — a +debug shader emitting a constant `1.0` arrives in the frame buffer as `224/255`, i.e. +`y = x / (x + 0.139)`. Both planet shaders invert that curve +(`hostTonemapInverse`) so the palette they author is the sRGB that actually ships; +without it every colour is silently compressed twice and cannot be tuned. + +The two fragment shaders are compiled to SPIR-V, because runtime GLSL compilation is +unavailable on Windows: + +```sh +glslc -o shaders/hex_planet_terrain.frag.spv shaders/hex_planet_terrain.frag +glslc -o shaders/hex_planet_water.frag.spv shaders/hex_planet_water.frag +``` + +## Relationship to the other hex examples + +| Example | Surface | Recipe / module | Camera | +|---|---|---|---| +| `hex-terrain-3d` | flat, chunked | `hexmap` (editable map) | orbiting a ground map | +| `hex-planet` | spherical, one mesh | `hexmap` sphere backend | orbiting the globe | +| `hex-terrain` | flat mesh | `mesh.hexterrain` (procgen) | ground level | + +## Known gaps + +- The ocean is a plain hexagonal cap per flooded cell: it has no shore shelf, no + refraction and no shoreline foam, and it does not cover beaches at sea level + (`waterLevel == elevation` is not flooded, matching the module's own rule). Sea ice + is a shading term on that cap, not separate geometry. +- Cliffs are radial walls with no overhang or erosion detail. +- The generator has no rivers, erosion or plate tectonics; the planar + `HexMapGenerator` did not carry over. +- The sphere has no rivers, roads, walls, features, fog-of-war or units, and no save + format. The cell *flags* can be set, but there is no spherical geometry, search or + serialization behind them. diff --git a/examples/hex-planet/config.nut b/examples/hex-planet/config.nut new file mode 100644 index 00000000..03d0dbf5 --- /dev/null +++ b/examples/hex-planet/config.nut @@ -0,0 +1,9 @@ +// examples/hex-planet -- orbital view of the spherical hex map. +// No `modules` key: the example runs against the full default module set. +config = { + width = 1280 + height = 800 + title = "EVEngine Hex Planet" + debug = false + hotReload = true +}; diff --git a/examples/hex-planet/hex-planet.png b/examples/hex-planet/hex-planet.png new file mode 100644 index 00000000..9ebae154 Binary files /dev/null and b/examples/hex-planet/hex-planet.png differ diff --git a/examples/hex-planet/main.nut b/examples/hex-planet/main.nut new file mode 100644 index 00000000..476c6126 --- /dev/null +++ b/examples/hex-planet/main.nut @@ -0,0 +1,359 @@ +// examples/hex-planet -- the hex terrain system wrapped onto a sphere, from orbit. +// +// This is the spherical counterpart of `examples/hex-terrain-3d`: the same module, +// the same `HexCellData` / `HexValues` / `HexFlags` records and the same terrain +// vertex encoding, but the grid is an icosahedral (Goldberg) topology reached through +// `hexmap`'s sphere bindings instead of the flat chunk grid. +// +// Everything geometric lives in C++ (`HexSphereTopology`, `HexSphereMap`, +// `HexSphereMesh`, `HexSphereGenerator`); this script owns the orbit camera, the +// input mapping, the two renderables and the HUD. + +const DEG_TO_RAD = 0.017453292519943295; + +// Topology levels: cells = 10 * 4^s + 2. Level 4 is 2562 cells and is the default; +// level 6 is 40962 and still generates and builds in well under a second. +const SUBDIVISION_MIN = 1; +const SUBDIVISION_MAX = 6; + +const DEFAULT_SUBDIVISION = 4; +const DEFAULT_RADIUS = 100.0; +const DEFAULT_SEED = 20260918; +const DEFAULT_LAND = 45; +const DEFAULT_WATER_LEVEL = 0; + +// Framing: the globe has to fit the vertical field of view with room for the relief. +const FOV_DEGREES = 45.0; +const FIT_MARGIN = 1.16; + +const ORBIT_SPEED = 0.30; // degrees of rotation per pixel dragged +const PITCH_MIN = -78.0; // never exactly +/-90: the up vector degenerates there +const PITCH_MAX = 78.0; +const ZOOM_STEP = 0.12; +const ZOOM_MIN = 1.12; // as a multiple of the framing distance +const ZOOM_MAX = 4.50; +const SPIN_SPEED = 4.0; // degrees per second while auto-spin is on + +persist planet = null; + +function createState() { + return { + camera = null, + terrainShader = null, + waterShader = null, + terrain = null, + water = null, + + subdivision = DEFAULT_SUBDIVISION, + radius = DEFAULT_RADIUS, + seed = DEFAULT_SEED, + landPercent = DEFAULT_LAND, + waterLevel = DEFAULT_WATER_LEVEL, + + yaw = 35.0, + pitch = 38.0, + zoom = 1.0, + spinning = false, + + dragging = false, + lastMouseX = 0, + lastMouseY = 0, + + elapsed = 0.0, + frames = 0, + screenshotSaved = false, + statusText = "building...", + uiBuilt = false, + triangles = 0, + }; +} + +// --- camera ----------------------------------------------------------------- + +function fitDistance(st) { + return (st.radius * FIT_MARGIN) / sin(FOV_DEGREES * 0.5 * DEG_TO_RAD); +} + +function applyCamera(st) { + if (st.camera == null) return; + local distance = fitDistance(st) * st.zoom; + local yawRad = st.yaw * DEG_TO_RAD; + local pitchRad = st.pitch * DEG_TO_RAD; + local horizontal = cos(pitchRad) * distance; + + st.camera.setEye(horizontal * sin(yawRad), sin(pitchRad) * distance, horizontal * cos(yawRad)); + // The globe is centred on the origin, so the target is its centre and the relief + // is symmetric around it. + st.camera.setTarget(0.0, 0.0, 0.0); + st.camera.setClipPlanes(distance * 0.02, distance * 3.0); +} + +function updateCamera(st, dt) { + if (st.spinning) st.yaw += SPIN_SPEED * dt; + + local x = mouse.getX(); + local y = mouse.getY(); + local down = mouse.isDown(1); + if (down) { + if (st.dragging) { + st.yaw -= (x - st.lastMouseX) * ORBIT_SPEED; + st.pitch = clampf(st.pitch + (y - st.lastMouseY) * ORBIT_SPEED, PITCH_MIN, PITCH_MAX); + } + st.dragging = true; + } else { + st.dragging = false; + } + st.lastMouseX = x; + st.lastMouseY = y; + + local wheel = mouse.getWheelY(); + if (wheel != 0.0) st.zoom = clampf(st.zoom - wheel * ZOOM_STEP, ZOOM_MIN, ZOOM_MAX); + + applyCamera(st); +} + +// --- shaders and renderables ------------------------------------------------ + +function createShaders(st) { + if (st.terrainShader != null) return true; + + // The engine's built-in Mesh3D vertex stage already produces the varyings both + // fragment stages consume, so only the fragment stages ship -- as SPIR-V, because + // runtime GLSL compilation is unavailable on Windows. + local terrain = gfx.loadMeshShaderSpv("", "shaders/hex_planet_terrain.frag.spv"); + if (!terrain.ok) { + print("hex planet: terrain shader failed: " + terrain.status.summary + "\n"); + return false; + } + local water = gfx.loadMeshShaderSpv("", "shaders/hex_planet_water.frag.spv"); + if (!water.ok) { + print("hex planet: water shader failed: " + water.status.summary + "\n"); + return false; + } + st.terrainShader = terrain.value; + st.waterShader = water.value; + return true; +} + +function createRenderables(st) { + if (!createShaders(st)) return; + + st.terrain = eve.Renderable3D(); + st.terrain.setShader(st.terrainShader); + // The globe does not cast onto or receive from anything here: there is one + // surface, and self-shadowing it only produces acne along every hex boundary. + st.terrain.setCastShadow(false); + st.terrain.setReceiveShadow(false); + st.terrain.setVisible(false); + + st.water = eve.Renderable3D(); + st.water.setShader(st.waterShader); + st.water.setCastShadow(false); + st.water.setReceiveShadow(false); + st.water.setVisible(false); +} + +function bindMeshes(st) { + if (st.terrain == null) return; + + local terrainMesh = hexmap.sphereTerrainMesh(); + if (terrainMesh != null) { + st.terrain.setMesh(terrainMesh); + st.terrain.setVisible(true); + } else { + st.terrain.setVisible(false); + } + + local waterMesh = hexmap.sphereWaterMesh(); + if (waterMesh != null) { + st.water.setMesh(waterMesh); + st.water.setVisible(true); + } else { + // Nothing is flooded at this sea level; the renderable must not keep drawing + // the previous ocean. + st.water.setVisible(false); + } +} + +// --- world ------------------------------------------------------------------ + +function describePlanet(st) { + return hexmap.sphereCellCount() + " cells, " + hexmap.spherePentagonCount() + " pentagons, " + + hexmap.sphereSubdivision() + " subdivisions"; +} + +/** Reports what the generator actually produced, so a washed-out frame is measurable. */ +function reportDistribution(st) { + local counts = [0, 0, 0, 0, 0]; + local underwater = 0; + local count = hexmap.sphereCellCount(); + for (local cell = 0; cell < count; cell++) { + local terrain = hexmap.sphereTerrainType(cell); + if (terrain >= 0 && terrain < 5) counts[terrain] = counts[terrain] + 1; + if (hexmap.sphereIsUnderwater(cell)) underwater = underwater + 1; + } + print("hex planet: sand " + counts[0] + " grass " + counts[1] + " mud " + counts[2] + " stone " + counts[3] + + " snow " + counts[4] + " | underwater " + underwater + " of " + count + "\n"); + print("hex planet: terrain mesh " + (hexmap.sphereTerrainMesh() != null) + ", water mesh " + + (hexmap.sphereWaterMesh() != null) + "\n"); +} + +function buildPlanet(st) { + local created = hexmap.newSphere(gfx, st.subdivision, st.radius, st.seed); + if (!created.ok) { + st.statusText = "sphere failed: " + created.status.summary; + print("hex planet: " + st.statusText + "\n"); + return false; + } + + local generated = hexmap.generateSphere(gfx, st.seed, st.landPercent, st.waterLevel); + if (!generated.ok) { + st.statusText = "generate failed: " + generated.status.summary; + print("hex planet: " + st.statusText + "\n"); + return false; + } + + st.triangles = 0; + st.statusText = describePlanet(st); + print("hex planet: " + describePlanet(st) + ", radius " + st.radius + ", seed " + st.seed + "\n"); + print("hex planet: " + st.landPercent + "% land, water line " + st.waterLevel + ", spacing " + + hexmap.sphereCellSpacing() + "\n"); + reportDistribution(st); + print("hex planet: LMB drag orbit | wheel zoom | space spin | R new seed | [ ] subdivision | - = land\n"); + return true; +} + +function regenerate(st) { + local generated = hexmap.generateSphere(gfx, st.seed, st.landPercent, st.waterLevel); + if (!generated.ok) { + st.statusText = "generate failed: " + generated.status.summary; + return; + } + bindMeshes(st); + st.statusText = describePlanet(st) + ", seed " + st.seed; +} + +// --- input ------------------------------------------------------------------ + +function handleKeys(st) { + if (key_just_pressed("r")) { + st.seed += 1; + regenerate(st); + print("hex planet: seed " + st.seed + "\n"); + } + if (key_just_pressed("space", "Space")) { + st.spinning = !st.spinning; + st.statusText = st.spinning ? "auto-spin on" : "auto-spin off"; + } + if (key_just_pressed("escape", "Escape")) { + st.spinning = false; + st.yaw = 35.0; + st.pitch = 38.0; + st.zoom = 1.0; + applyCamera(st); + } + if (key_just_pressed("F5")) { + if (gfx.saveFramePng("hex-planet.png")) st.statusText = "saved hex-planet.png"; + } + + // Rebuilding a subdivision level replaces the whole topology, so it goes through + // the full create path rather than the cheaper regenerate. + local subdivision = st.subdivision; + if (key_just_pressed("]")) subdivision = subdivision + 1; + if (key_just_pressed("[")) subdivision = subdivision - 1; + if (subdivision < SUBDIVISION_MIN) subdivision = SUBDIVISION_MIN; + if (subdivision > SUBDIVISION_MAX) subdivision = SUBDIVISION_MAX; + if (subdivision != st.subdivision) { + st.subdivision = subdivision; + buildPlanet(st); + bindMeshes(st); + } + + local land = st.landPercent; + if (key_just_pressed("=")) land = land + 5; + if (key_just_pressed("-")) land = land - 5; + if (land < 5) land = 5; + if (land > 95) land = 95; + if (land != st.landPercent) { + st.landPercent = land; + regenerate(st); + print("hex planet: " + land + "% land\n"); + } +} + +// --- HUD -------------------------------------------------------------------- + +function buildHud(st) { + // Widgets only exist inside a window, so the panel is built once and then updated + // by label in `refreshHud`. + ui.beginBuild(); + ui.beginWindow("HexPlanet", "root"); + ui.text("EVEngine Hex Planet", "title"); + ui.text("", "planet"); + ui.text("", "view"); + ui.separator("sep"); + ui.text("[LMB]orbit [wheel]zoom [space]spin [R]seed", "help1"); + ui.text("[[/]]subdivision [-/=]land [F5]shot [esc]reset", "help2"); + ui.end(); + ui.mountBuildAs("hud"); + ui.select("hud"); + ui.setHostOverlay(true); + ui.setHostVisible(true); + ui.setHostPos(12.0, 12.0, 0.0, 0.0); + st.uiBuilt = true; +} + +function refreshHud(st) { + if (!st.uiBuilt) return; + ui.select("hud"); + ui.setText("planet", "planet: " + st.statusText); + ui.setText("view", "view: yaw " + st.yaw + " pitch " + st.pitch + " zoom " + st.zoom); +} + +// --- entry points ----------------------------------------------------------- + +eve_init = function() { + if (planet == null) planet = createState(); + local st = planet; + + st.camera = eve.Camera3D(); + st.camera.setFov(FOV_DEGREES); + // The default view looks down on the northern cap (pitch 38) and the light sits well + // off the view axis: a lower pitch foreshortens the ice into the very top edge, and a + // light near the camera puts every visible pixel at N.L ~ 1, which flattens the globe + // into a bright disc with no terminator to read the sphere's shape from. + st.camera.setAmbient(0.17, 0.20, 0.27); + st.camera.setActive(true); + + gfx.setBackgroundColor(0.012, 0.016, 0.030, 1.0); + gfx.setDirectionalLight(-0.78, 0.42, 0.46, 1.45, 1.34, 1.16); + gfx.setCloudShadows(0.22, 6.0, 0.0, 0.42, 0.40, 0.52, 0.62); + + createRenderables(st); + buildPlanet(st); + bindMeshes(st); + buildHud(st); + applyCamera(st); +}; + +eve_update = function(dt) { + local st = planet; + if (st == null) return; + st.elapsed += dt; + st.frames += 1; + + handleKeys(st); + updateCamera(st, dt); + refreshHud(st); + + // Readback is enabled by the first call, so retry until the frame lands. The + // default pose is static, so the saved frame is reproducible run to run. + if (!st.screenshotSaved && st.frames > 90 && gfx.saveFramePng("hex-planet.png")) + st.screenshotSaved = true; +}; + +eve_render = function() { + gfx.clear(); + gfx.render3D(); + ui.beginFrameAndRender(); +}; diff --git a/examples/hex-planet/shaders/hex_planet_terrain.frag b/examples/hex-planet/shaders/hex_planet_terrain.frag new file mode 100644 index 00000000..121a8016 --- /dev/null +++ b/examples/hex-planet/shaders/hex_planet_terrain.frag @@ -0,0 +1,168 @@ +#version 450 + +// Terrain surface of the spherical hex map (examples/hex-planet). +// +// The vertex encoding is exactly the one the planar builder emits, so this shader +// and `hex_map_terrain.frag` share the decode and the palette (see +// HexMapMesh.h / HexTerrainVertexCode): +// uv.x = layerA + layerB * 8 + layerC * 64 +// uv.y = weightB + weightC * 16 +// +// What cannot be shared is everything that quietly assumed a flat map. The planar +// shader reads `vWorldPos.y` as altitude, `vNormal.y` as "up", projects its detail +// noise into XZ and fades the far distance into fog; on a sphere Y spans the whole +// planet, XZ collapses at the poles, the surface normal is the radial direction and +// there is no horizon to fade into. Those four are replaced by radial equivalents. +// The palette and the BRDF below are deliberately the same numbers. + +layout(location=0) in vec3 vNormal; +layout(location=1) in vec2 vUV; +layout(location=2) in vec4 vTint; +layout(location=3) in vec3 vWorldPos; +layout(location=4) in vec3 vCameraPos; +layout(location=5) in vec3 vViewPos; + +struct Light3D { vec4 posRadius; vec4 color; }; +layout(set=0,binding=0,std140) uniform Frame { + mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint; + vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax; + mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind; +} ubo; +layout(location=0) out vec4 outColor; + +const float PI = 3.14159265359; + +float hash21(vec2 p) { + p = fract(p * vec2(123.34, 456.21)); + p += dot(p, p + 45.32); + return fract(p.x * p.y); +} +float noise(vec2 p) { + vec2 i = floor(p), f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x), + mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y); +} +float fbm(vec2 p) { + float s = 0.0, a = 0.5; + for (int i = 0; i < 4; ++i) { s += noise(p) * a; p = mat2(1.7, -1.3, 1.3, 1.7) * p; a *= 0.5; } + return s; +} + +/** + * Detail-noise coordinates of a point on the sphere. + * + * Dropping the dominant axis keeps the field roughly isotropic. Projecting into XZ + * as the planar shader does would collapse to a point at both poles, which reads as + * a radial smear of stretched noise exactly where the twelve pentagons sit. + */ +vec2 sphereCoords(vec3 dir) { + vec3 a = abs(dir); + if (a.y >= a.x && a.y >= a.z) return dir.xz; + if (a.x >= a.z) return dir.yz; + return dir.xy; +} + +/** + * Inverts the host's own tonemap. + * + * A debug fragment stage that emitted a constant 1.0 arrives in the frame buffer as + * 224/255, i.e. the host applies a second, Reinhard-style curve `y = x / (x + 0.139)` + * to whatever this stage returns. This shader authors final sRGB values, so it undoes + * that curve instead of being silently darkened by it; without this the palette below + * cannot be tuned at all, because every change is compressed by an invisible stage. + */ +vec3 hostTonemapInverse(vec3 srgb) { + srgb = clamp(srgb, vec3(0.0), vec3(0.95)); + return (0.139 * srgb) / max(vec3(1.0) - srgb, vec3(0.001)); +} + +// Unity-parity palette: 0 Sand, 1 Grass, 2 Mud, 3 Stone, 4 Snow. +vec3 terrainAlbedo(int id, vec2 p, float grain, float patchiness) { + if (id == 0) return mix(vec3(0.74, 0.66, 0.42), vec3(0.88, 0.80, 0.55), grain); + if (id == 1) return mix(vec3(0.24, 0.45, 0.14), vec3(0.44, 0.62, 0.22), grain * 0.7 + patchiness * 0.3); + if (id == 2) return mix(vec3(0.31, 0.23, 0.14), vec3(0.45, 0.35, 0.22), grain); + if (id == 3) return mix(vec3(0.36, 0.36, 0.37), vec3(0.62, 0.61, 0.58), grain); + return mix(vec3(0.80, 0.86, 0.92), vec3(0.98, 0.99, 1.00), grain * 0.6 + 0.35); +} +float terrainRoughness(int id) { + if (id == 0) return 0.92; + if (id == 1) return 0.88; + if (id == 2) return 0.74; + if (id == 3) return 0.68; + return 0.42; +} + +void main() { + float packedIndices = vUV.x; + float packedWeights = vUV.y; + int layerA = int(mod(packedIndices, 8.0) + 0.5); + int layerB = int(mod(floor(packedIndices / 8.0), 8.0) + 0.5); + int layerC = int(floor(packedIndices / 64.0) + 0.5); + float weightB = fract(packedWeights); + float weightC = floor(packedWeights) / 16.0; + float weightA = max(0.0, 1.0 - weightB - weightC); + + float time = ubo.cloud.z; + vec3 radial = normalize(vWorldPos); + vec2 p = sphereCoords(radial) * 14.0; + float grain = noise(p * 9.0); + float patchiness = fbm(p * 1.6); + + vec3 albedo = terrainAlbedo(layerA, p, grain, patchiness) * weightA + + terrainAlbedo(layerB, p + 11.3, grain, patchiness) * weightB + + terrainAlbedo(layerC, p + 23.9, grain, patchiness) * weightC; + float roughness = terrainRoughness(layerA) * weightA + + terrainRoughness(layerB) * weightB + + terrainRoughness(layerC) * weightC; + + // The generator already places the snow line, the rock and the deserts, so the + // continental scale is in the vertex layers; this only breaks up the flat fills. + albedo *= mix(0.90, 1.08, fbm(p * 0.8)); + + float detail = fbm(p * 6.0); + vec3 N = normalize(vNormal + vec3(dFdx(detail), 0.0, dFdy(detail)) * 0.22); + vec3 V = normalize(vCameraPos - vWorldPos); + vec3 L = normalize(ubo.lightDirIntensity.xyz); + vec3 H = normalize(V + L); + float ndl = max(dot(N, L), 0.0); + float ndv = max(dot(N, V), 0.001); + float ndh = max(dot(N, H), 0.0); + float alpha = max(0.03, roughness * roughness), a2 = alpha * alpha; + float D = a2 / max(PI * pow(ndh * ndh * (a2 - 1.0) + 1.0, 2.0), 0.001); + float k = pow(roughness + 1.0, 2.0) / 8.0; + float G = (ndl / (ndl * (1.0 - k) + k)) * (ndv / (ndv * (1.0 - k) + k)); + vec3 F0 = vec3(0.04); + vec3 F = F0 + (1.0 - F0) * pow(1.0 - max(dot(H, V), 0.0), 5.0); + vec3 spec = D * G * F / max(4.0 * ndl * ndv, 0.001); + // 1.5 rather than the planar shader's 2.6: a globe has no distance fog and far less + // self-occlusion, so the same light energy lands on every visible pixel and the + // snow and sand fills blow straight through the tonemap. + vec3 direct = ((1.0 - F) * albedo / PI + spec) * ndl * ubo.lightColor.rgb * 1.5; + + // "Up" is the radial direction here, not world Y: N.y would run from -1 at the + // south pole to +1 at the north and turn the sky term into a latitude gradient. + float up = dot(N, radial); + float sky = max(0.0, up) * 0.16 + 0.06; + vec3 color = direct + albedo * (ubo.ambient.rgb * 0.55 + vec3(0.10, 0.12, 0.15) * sky); + + // Slope shading so cliffs read as rock even without a texture. + color *= mix(0.72, 1.0, clamp(up * 1.35 + 0.20, 0.0, 1.0)); + + // Cloud banding, sampled on the sphere rather than in world XZ. + float cloudField = fbm(sphereCoords(radial) * 22.0 + ubo.cloudWind.xy * time * 0.012); + float cloudMask = smoothstep(ubo.cloudWind.z - 0.15, ubo.cloudWind.z + 0.15, cloudField); + color *= 1.0 - ubo.cloud.x * cloudMask * 0.30; + + // Reinhard keeps mid-tones saturated instead of clipping to white. + color = color / (1.0 + color * 1.05); + color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2)); + + // No distance fog: a planet seen from orbit has no haze band to fade into, and + // the planar `smoothstep(240, 520, viewDepth)` would erase the whole globe. A + // limb glow takes its place so the silhouette separates from the starfield. + float rim = pow(1.0 - clamp(ndv, 0.0, 1.0), 3.0); + color += vec3(0.10, 0.22, 0.44) * rim * 0.40; + + outColor = vec4(hostTonemapInverse(color * vTint.rgb), 1.0); +} diff --git a/examples/hex-planet/shaders/hex_planet_terrain.frag.spv b/examples/hex-planet/shaders/hex_planet_terrain.frag.spv new file mode 100644 index 00000000..0f0d8ce9 Binary files /dev/null and b/examples/hex-planet/shaders/hex_planet_terrain.frag.spv differ diff --git a/examples/hex-planet/shaders/hex_planet_water.frag b/examples/hex-planet/shaders/hex_planet_water.frag new file mode 100644 index 00000000..79b29a3c --- /dev/null +++ b/examples/hex-planet/shaders/hex_planet_water.frag @@ -0,0 +1,104 @@ +#version 450 + +// Ocean surface of the spherical hex map (examples/hex-planet). +// +// Shares the planar water contract for its texture coordinates -- `vUV.x` is the +// shore parameter, 0 in open ocean and 1 where the water ends against land -- but +// takes its wave coordinates from the sphere rather than from world XZ, for the +// same reason the terrain shader does: XZ collapses at the poles. + +layout(location=0) in vec3 vNormal; +layout(location=1) in vec2 vUV; +layout(location=2) in vec4 vTint; +layout(location=3) in vec3 vWorldPos; +layout(location=4) in vec3 vCameraPos; +layout(location=5) in vec3 vViewPos; + +struct Light3D { vec4 posRadius; vec4 color; }; +layout(set=0,binding=0,std140) uniform Frame { + mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint; + vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax; + mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind; +} ubo; +layout(location=0) out vec4 outColor; + +const float PI = 3.14159265359; + +float hash21(vec2 p) { + p = fract(p * vec2(123.34, 456.21)); + p += dot(p, p + 45.32); + return fract(p.x * p.y); +} +float noise(vec2 p) { + vec2 i = floor(p), f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x), + mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y); +} +float fbm(vec2 p) { + float s = 0.0, a = 0.5; + for (int i = 0; i < 4; ++i) { s += noise(p) * a; p = mat2(1.7, -1.3, 1.3, 1.7) * p; a *= 0.5; } + return s; +} + +vec2 sphereCoords(vec3 dir) { + vec3 a = abs(dir); + if (a.y >= a.x && a.y >= a.z) return dir.xz; + if (a.x >= a.z) return dir.yz; + return dir.xy; +} + +/** Inverts the host's second tonemap stage; see the terrain shader for the measurement. */ +vec3 hostTonemapInverse(vec3 srgb) { + srgb = clamp(srgb, vec3(0.0), vec3(0.95)); + return (0.139 * srgb) / max(vec3(1.0) - srgb, vec3(0.001)); +} + +void main() { + float time = ubo.cloud.z; + vec3 radial = normalize(vWorldPos); + vec2 p = sphereCoords(radial) * 26.0; + + // Two travelling wave trains plus fbm chop: a flat normal makes an ocean read as + // a painted disc from orbit, where the only clue to liquid is the specular lobe. + float wave = sin(p.x * 0.55 + p.y * 0.35 - time * 1.05) * 0.45 + + sin((p.x + p.y) * 0.85 + time * 2.10) * 0.28 + + fbm(p * 1.4 + time * 0.05) * 0.32; + + float shore = clamp(vUV.x, 0.0, 1.0); + vec3 deep = vec3(0.012, 0.045, 0.095); + vec3 shallow = vec3(0.040, 0.170, 0.240); + vec3 albedo = mix(deep, shallow, shore * 0.85); + + // Sea ice. Without it the polar oceans stay dark navy under a grazing light angle + // and the caps read as holes rather than ice. + float ice = smoothstep(0.80, 0.94, abs(radial.y)); + albedo = mix(albedo, vec3(0.74, 0.80, 0.87), ice); + + vec3 N = normalize(vNormal + vec3(dFdx(wave), 0.0, dFdy(wave)) * 0.55 + radial * wave * 0.035); + vec3 V = normalize(vCameraPos - vWorldPos); + vec3 L = normalize(ubo.lightDirIntensity.xyz); + vec3 H = normalize(V + L); + + float ndl = max(dot(N, L), 0.0); + float ndv = max(dot(N, V), 0.001); + float ndh = max(dot(N, H), 0.0); + + // Tight, strong highlight: the sun glint is what sells the surface as water. + float shininess = mix(64.0, 900.0, 1.0 - fbm(p * 0.6)); + float spec = pow(ndh, shininess) * 0.40; + vec3 F = vec3(0.02) + vec3(0.98) * pow(1.0 - max(dot(H, V), 0.0), 5.0); + + vec3 color = albedo * (ubo.ambient.rgb * 0.55 + ubo.lightColor.rgb * ndl * 0.72); + color += F * spec * ubo.lightColor.rgb * 3.0; + + // Fresnel on the limb: shallow grazing angles turn the ocean into a mirror of the + // sky, which is also what keeps the far hemisphere from reading as flat navy. + float fresnel = pow(1.0 - clamp(ndv, 0.0, 1.0), 4.0); + color = mix(color, vec3(0.22, 0.42, 0.68), fresnel * 0.70); + + color = color / (1.0 + color * 1.05); + color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2)); + + outColor = vec4(hostTonemapInverse(color * vTint.rgb), 1.0); +} diff --git a/examples/hex-planet/shaders/hex_planet_water.frag.spv b/examples/hex-planet/shaders/hex_planet_water.frag.spv new file mode 100644 index 00000000..c769d0b7 Binary files /dev/null and b/examples/hex-planet/shaders/hex_planet_water.frag.spv differ diff --git a/src/modules/hexmap/HexMapModule.cpp b/src/modules/hexmap/HexMapModule.cpp index eb29f5f2..0f77726f 100644 --- a/src/modules/hexmap/HexMapModule.cpp +++ b/src/modules/hexmap/HexMapModule.cpp @@ -7,6 +7,7 @@ #include "graphics/Mesh.h" #include "hexmap/HexFeatures.h" #include "hexmap/HexMapGenerator.h" +#include "hexmap/HexSphereMesh.h" #include @@ -187,6 +188,83 @@ std::int32_t HexMapModule::rebuildDirtyChunks(graphics::Graphics* gfx) { return rebuilt; } +// --- spherical map ---------------------------------------------------------- + +void HexMapModule::releaseSphereMeshes(graphics::Graphics* gfx) noexcept { + if (gfx != nullptr) { + if (sphereTerrain_ != nullptr) (void)gfx->releaseMesh(sphereTerrain_); + if (sphereWater_ != nullptr) (void)gfx->releaseMesh(sphereWater_); + } + sphereTerrain_ = nullptr; + sphereWater_ = nullptr; +} + +Result HexMapModule::newSphere(graphics::Graphics* gfx, std::int32_t subdivision, float radius, + std::uint32_t seed) { + if (gfx == nullptr) return invalidArgument("hex sphere needs a graphics device"); + releaseSphereMeshes(gfx); + return sphere_.reset(subdivision, radius, seed); +} + +Result HexMapModule::generateSphere(graphics::Graphics* gfx, std::uint32_t seed, std::int32_t landPercentage, + std::int32_t waterLevel) { + if (gfx == nullptr) return invalidArgument("hex sphere needs a graphics device"); + if (sphere_.empty()) return invalidArgument("hex sphere map has no cells"); + + HexSphereGeneratorSettings settings{}; + settings.seed = seed; + settings.landPercentage = landPercentage; + settings.waterLevel = waterLevel; + + // The generator's only failure is an empty map, which the guard above already + // rejected, so it cannot leave the cells half-written. + auto generated = generateSphereMap(sphere_, settings); + if (!generated.ok()) return Result::failure(generated.status()); + return rebuildSphere(gfx); +} + +Result HexMapModule::rebuildSphere(graphics::Graphics* gfx) { + if (gfx == nullptr) return invalidArgument("hex sphere needs a graphics device"); + if (sphere_.empty()) return invalidArgument("hex sphere map has no cells"); + + HexMeshData terrain; + buildSphereTerrainMesh(sphere_, terrain); + HexMeshData water; + buildSphereWaterMesh(sphere_, water); + + bool uploaded = true; + const auto upload = [&](HexMeshData& data, graphics::Mesh*& mesh) { + if (data.empty()) { + // Nothing is flooded any more, for example: retire the mesh rather than draw + // stale geometry. + if (mesh != nullptr) { + (void)gfx->releaseMesh(mesh); + mesh = nullptr; + } + return; + } + const int vertexCount = static_cast(data.vertexCount()); + const int indexCount = static_cast(data.indices().size()); + if (mesh != nullptr && + !gfx->updateMeshVertices(mesh, data.positions().data(), data.normals().data(), data.uvs().data(), + vertexCount, data.indices().data(), indexCount)) { + // The backend rejected the in-place update; recreate rather than keep stale geometry. + (void)gfx->releaseMesh(mesh); + mesh = nullptr; + } + if (mesh == nullptr) { + mesh = gfx->newMeshFromArrays(data.positions().data(), data.normals().data(), data.uvs().data(), + vertexCount, data.indices().data(), indexCount); + } + if (mesh == nullptr) uploaded = false; + }; + + upload(terrain, sphereTerrain_); + upload(water, sphereWater_); + if (!uploaded) return invalidArgument("the graphics backend refused the hex sphere mesh upload"); + return Result::success(); +} + // --- script bindings -------------------------------------------------------- void HexMapModule::expose(ssq::Table& table) { @@ -534,6 +612,100 @@ void HexMapModule::expose(ssq::Class& cls) { if (!loaded) return script::projectResult(vm, std::move(loaded)); return script::projectResult(vm, self->adoptGrid(gfx, std::move(restored), units)); }); + + // --- spherical map --- + cls.addFunc( + "newSphere", [vm](HexMapModule* self, graphics::Graphics* gfx, int subdivision, float radius, int seed) { + if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available")); + if (seed < 0) seed = 0; + return script::projectResult(vm, + self->newSphere(gfx, subdivision, radius, static_cast(seed))); + }); + cls.addFunc("generateSphere", [vm](HexMapModule* self, graphics::Graphics* gfx, int seed, int landPercentage, + int waterLevel) { + if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available")); + if (seed < 0) seed = 0; + return script::projectResult( + vm, self->generateSphere(gfx, static_cast(seed), landPercentage, waterLevel)); + }); + cls.addFunc("rebuildSphere", [vm](HexMapModule* self, graphics::Graphics* gfx) { + if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available")); + return script::projectResult(vm, self->rebuildSphere(gfx)); + }); + cls.addFunc("sphereReady", [](HexMapModule* self) { return self != nullptr && !self->sphere().empty(); }); + cls.addFunc("sphereCellCount", [](HexMapModule* self) { return self ? self->sphere().cellCount() : 0; }); + cls.addFunc("spherePentagonCount", [](HexMapModule* self) { return self ? self->sphere().pentagonCount() : 0; }); + cls.addFunc("sphereSubdivision", [](HexMapModule* self) { return self ? self->sphere().subdivision() : 0; }); + cls.addFunc("sphereRadius", [](HexMapModule* self) { return self ? self->sphere().sphereRadius() : 0.f; }); + cls.addFunc("sphereElevationStep", [](HexMapModule* self) { return self ? self->sphere().elevationStep() : 0.f; }); + cls.addFunc("sphereCellSpacing", [](HexMapModule* self) { return self ? self->sphere().cellSpacing() : 0.f; }); + cls.addFunc("sphereIsPentagon", + [](HexMapModule* self, int cell) { return self != nullptr && self->sphere().isPentagon(cell); }); + cls.addFunc("sphereNeighborCount", + [](HexMapModule* self, int cell) { return self ? self->sphere().neighborCount(cell) : 0; }); + cls.addFunc("sphereNeighbor", [](HexMapModule* self, int cell, int direction) { + return self ? self->sphere().neighbor(cell, direction) : kNoHexSphereCell; + }); + cls.addFunc("sphereDirection", [](HexMapModule* self, int cell) { + const HexVec3 direction = self ? self->sphere().direction(cell) : HexVec3{}; + return Value(Value::Array{Value(direction.x), Value(direction.y), Value(direction.z)}); + }); + cls.addFunc("sphereCornerDirection", [](HexMapModule* self, int cell, int corner) { + const HexVec3 direction = self ? self->sphere().cornerDirection(cell, corner) : HexVec3{}; + return Value(Value::Array{Value(direction.x), Value(direction.y), Value(direction.z)}); + }); + cls.addFunc("sphereCornerCount", + [](HexMapModule* self, int cell) { return self ? self->sphere().cornerCountOf(cell) : 0; }); + cls.addFunc("sphereElevation", [](HexMapModule* self, int cell) { return self ? self->sphere().elevation(cell) : 0; }); + cls.addFunc("sphereWaterLevel", + [](HexMapModule* self, int cell) { return self ? self->sphere().waterLevel(cell) : 0; }); + cls.addFunc("sphereTerrainType", + [](HexMapModule* self, int cell) { return self ? self->sphere().terrainType(cell) : 0; }); + cls.addFunc("sphereIsUnderwater", + [](HexMapModule* self, int cell) { return self != nullptr && self->sphere().isUnderwater(cell); }); + cls.addFunc("sphereCellAt", [](HexMapModule* self, float x, float y, float z) { + return self ? self->sphere().topology().cellAt(HexVec3{x, y, z}) : kNoHexSphereCell; + }); + cls.addFunc("sphereDistance", [](HexMapModule* self, int a, int b) { + return self ? self->sphere().distance(a, b) : kNoHexSphereCell; + }); + cls.addFunc("spherePickCell", + [vm](HexMapModule* self, float ox, float oy, float oz, float dx, float dy, float dz) { + const auto project = [](HexSphereCell cell) { return Value(static_cast(cell)); }; + if (!self) { + return script::projectResult( + vm, + Result::failure(Diagnostic::error( + DiagnosticCode::NotFound, "hex map module is not available", "hexmap.sphere.pick")), + project); + } + return script::projectResult(vm, + self->sphere().pickCell(HexVec3{ox, oy, oz}, HexVec3{dx, dy, dz}), + project); + }); + cls.addFunc("sphereSetElevation", [vm](HexMapModule* self, int cell, int value) { + if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available")); + return script::projectResult(vm, self->sphere().setElevation(cell, value)); + }); + cls.addFunc("sphereSetTerrainType", [vm](HexMapModule* self, int cell, int value) { + if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available")); + return script::projectResult(vm, self->sphere().setTerrainType(cell, value)); + }); + cls.addFunc("sphereEditElevation", [vm](HexMapModule* self, int cell, int radius, int delta) { + if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available")); + return script::projectResult(vm, self->sphere().editElevation(cell, radius, delta)); + }); + cls.addFunc("sphereEditTerrainType", [vm](HexMapModule* self, int cell, int radius, int terrainType) { + if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available")); + return script::projectResult(vm, self->sphere().editTerrainType(cell, radius, terrainType)); + }); + cls.addFunc("sphereTerrainMesh", + [](HexMapModule* self) -> graphics::Mesh* { return self ? self->sphereTerrainMesh() : nullptr; }); + cls.addFunc("sphereWaterMesh", + [](HexMapModule* self) -> graphics::Mesh* { return self ? self->sphereWaterMesh() : nullptr; }); + cls.addFunc("sphereReleaseMeshes", [](HexMapModule* self, graphics::Graphics* gfx) { + if (self) self->releaseSphereMeshes(gfx); + }); } } // namespace eve::hexmap diff --git a/src/modules/hexmap/HexMapModule.h b/src/modules/hexmap/HexMapModule.h index 8dff715f..41ad2aa3 100644 --- a/src/modules/hexmap/HexMapModule.h +++ b/src/modules/hexmap/HexMapModule.h @@ -8,6 +8,8 @@ #include "hexmap/HexMapMesh.h" #include "hexmap/HexSearch.h" #include "hexmap/HexSerializer.h" +#include "hexmap/HexSphereGenerator.h" +#include "hexmap/HexSphereMap.h" #include "hexmap/HexUnits.h" #include "hexmap/HexVisibility.h" @@ -105,6 +107,73 @@ public: /** @brief Whether a grid has been created. */ [[nodiscard]] bool hasGrid() const noexcept { return !map_.empty(); } + // --- spherical map ------------------------------------------------------ + + /** + * @brief Replaces the spherical map and releases the meshes of the previous one. + * + * The sphere is a second, independent world alongside the flat grid: it shares the + * module's device and its cell record types, but neither the flat map nor its + * meshes are touched, so a script may hold both without one invalidating the other. + * + * @param gfx Device used to release the previously created sphere meshes; required. + * @param subdivision Topology level in `[0, kMaxHexSphereSubdivision]`. + * @param radius Sphere radius in world units; must be finite and positive. + * @param seed Deterministic seed for the sphere's noise field. + * @return Success, or InvalidArgument for a missing device or a bad shape. + * @cost Releases the previous sphere meshes and rebuilds the whole topology. + */ + [[nodiscard]] Result newSphere(graphics::Graphics* gfx, std::int32_t subdivision, float radius, + std::uint32_t seed); + + /** + * @brief Regenerates the spherical map procedurally and rebuilds both its meshes. + * + * @param gfx Device that owns the sphere meshes; required. + * @param seed Deterministic seed for the generator. + * @param landPercentage Target share of land, in percent. + * @param waterLevel Cells at or below this elevation are flooded. + * @return Success, InvalidArgument when there is no sphere or no device, or the + * generator's own failure status. + * @cost Overwrites every cell, then one CPU mesh build and one GPU upload for the + * terrain and for the ocean. + */ + [[nodiscard]] Result generateSphere(graphics::Graphics* gfx, std::uint32_t seed, + std::int32_t landPercentage, std::int32_t waterLevel); + + /** + * @brief Rebuilds the whole-sphere terrain and ocean meshes from the current cells. + * + * @param gfx Device that owns the sphere meshes; required. + * @return Success, or InvalidArgument when there is no sphere or no device. + * @cost One CPU mesh build plus one GPU upload per stream; proportional to the cell + * count, so build it after a batch of edits rather than after each one. + */ + [[nodiscard]] Result rebuildSphere(graphics::Graphics* gfx); + + /** + * @brief Releases every mesh owned by the spherical map. + * @param gfx Device that created them; null releases nothing. + */ + void releaseSphereMeshes(graphics::Graphics* gfx) noexcept; + + /** @brief The active spherical map. */ + [[nodiscard]] const HexSphereMap& sphere() const noexcept { return sphere_; } + /** @brief The active spherical map (mutable, for internal build steps). */ + [[nodiscard]] HexSphereMap& sphere() noexcept { return sphere_; } + /** + * @brief Borrowed GPU terrain mesh of the sphere. + * @ownership Borrowed; the module retains ownership until `releaseSphereMeshes`. + * @nullable Yes; null before the first build or when the sphere has no cells. + */ + [[nodiscard]] graphics::Mesh* sphereTerrainMesh() const noexcept { return sphereTerrain_; } + /** + * @brief Borrowed GPU ocean mesh of the sphere, or null when nothing is flooded. + * @ownership Borrowed; the module retains ownership until `releaseSphereMeshes`. + * @nullable Yes. + */ + [[nodiscard]] graphics::Mesh* sphereWaterMesh() const noexcept { return sphereWater_; } + /** @brief Fog-of-war counters of the active map. */ [[nodiscard]] HexVisibility& visibility() noexcept { return visibility_; } /** @brief Units of the active map. */ @@ -167,6 +236,10 @@ private: HexSearchContext scratch_{}; HexVisibility visibility_{}; HexUnitRegistry units_{}; + + HexSphereMap sphere_{}; + graphics::Mesh* sphereTerrain_ = nullptr; + graphics::Mesh* sphereWater_ = nullptr; }; } // namespace eve::hexmap diff --git a/src/modules/hexmap/HexSphereGenerator.cpp b/src/modules/hexmap/HexSphereGenerator.cpp new file mode 100644 index 00000000..b49c3165 --- /dev/null +++ b/src/modules/hexmap/HexSphereGenerator.cpp @@ -0,0 +1,191 @@ +#include "hexmap/HexSphereGenerator.h" + +#include "common/Diagnostic.h" +#include "hexmap/HexCell.h" +#include "hexmap/HexMetrics.h" + +#include +#include +#include +#include +#include + +namespace eve::hexmap { +namespace { + +// --- 3D value noise --------------------------------------------------------- +// +// `HexNoise` is a planar field sampled in XZ. Sampling it with a cell's direction +// would degenerate at the poles - the direction's XZ projection goes to zero there - +// and would seam where the longitude wraps, so the spherical generator carries its +// own lattice. It is the spherical analogue of the planar field, not a second +// source of truth for it: nothing else in the module samples it. + +/** @brief Integer hash of a lattice point; the whole noise field derives from it. */ +[[nodiscard]] std::uint32_t hashLattice(int x, int y, int z, std::uint32_t seed) noexcept { + std::uint32_t h = seed ^ 0x9e3779b9u; + h ^= static_cast(x) * 0x8da6b343u; + h ^= static_cast(y) * 0xd8163841u; + h ^= static_cast(z) * 0xcb1ab31fu; + h ^= h >> 15; + h *= 0x2c1b3c6du; + h ^= h >> 12; + h *= 0x297a2d39u; + h ^= h >> 15; + return h; +} + +/** @brief Lattice value in `[-1, 1)`. */ +[[nodiscard]] float latticeValue(int x, int y, int z, std::uint32_t seed) noexcept { + return static_cast(hashLattice(x, y, z, seed) & 0xffffffu) / 8388608.0f - 1.f; +} + +/** @brief Hermite fade curve of the trilinear blend. */ +[[nodiscard]] float fade(float t) noexcept { return t * t * t * (t * (t * 6.f - 15.f) + 10.f); } + +[[nodiscard]] float valueNoise3(HexVec3 point, std::uint32_t seed) noexcept { + const float fx = std::floor(point.x); + const float fy = std::floor(point.y); + const float fz = std::floor(point.z); + const int x0 = static_cast(fx); + const int y0 = static_cast(fy); + const int z0 = static_cast(fz); + const float tx = fade(point.x - fx); + const float ty = fade(point.y - fy); + const float tz = fade(point.z - fz); + + const float c000 = latticeValue(x0, y0, z0, seed); + const float c100 = latticeValue(x0 + 1, y0, z0, seed); + const float c010 = latticeValue(x0, y0 + 1, z0, seed); + const float c110 = latticeValue(x0 + 1, y0 + 1, z0, seed); + const float c001 = latticeValue(x0, y0, z0 + 1, seed); + const float c101 = latticeValue(x0 + 1, y0, z0 + 1, seed); + const float c011 = latticeValue(x0, y0 + 1, z0 + 1, seed); + const float c111 = latticeValue(x0 + 1, y0 + 1, z0 + 1, seed); + + const auto blend = [](float a, float b, float t) { return a + (b - a) * t; }; + const float x00 = blend(c000, c100, tx); + const float x10 = blend(c010, c110, tx); + const float x01 = blend(c001, c101, tx); + const float x11 = blend(c011, c111, tx); + return blend(blend(x00, x10, ty), blend(x01, x11, ty), tz); +} + +/** @brief Fractal Brownian motion of the 3D field, remapped into `[0, 1]`. */ +[[nodiscard]] float fbm3(HexVec3 point, std::int32_t octaves, std::uint32_t seed) noexcept { + float total = 0.f; + float amplitude = 1.f; + float sum = 0.f; + HexVec3 cursor = point; + for (std::int32_t octave = 0; octave < octaves; ++octave) { + total += valueNoise3(cursor, seed + static_cast(octave) * 7919u) * amplitude; + sum += amplitude; + amplitude *= 0.5f; + cursor = HexVec3{cursor.x * 2.03f + 13.1f, cursor.y * 2.03f - 7.7f, cursor.z * 2.03f + 3.3f}; + } + if (!(sum > 0.f)) return 0.5f; + return std::clamp(total / sum * 0.5f + 0.5f, 0.f, 1.f); +} + +/** @brief Value at a `fraction` quantile of an already sorted vector. */ +[[nodiscard]] float quantile(const std::vector& sorted, float fraction) noexcept { + if (sorted.empty()) return 0.f; + const float position = std::clamp(fraction, 0.f, 1.f) * static_cast(sorted.size() - 1); + return sorted[static_cast(std::lround(position))]; +} + +/** + * @brief Terrain palette entry chosen from elevation, moisture and latitude. + * + * Latitude is `direction.y`, so `|latitude|` is the sine of the latitude and the + * bands below are *area* fractions of the sphere, not angular ones: `|latitude| > 0.86` + * is the 14% of the surface inside 59 degrees, and `> 0.62` would already be 38%. + * That is why the snow caps are placed on the highest band only - the planar + * generator has no such coupling between a coordinate and a share of the map. + */ +[[nodiscard]] std::int32_t terrainFor(std::int32_t elevation, float moisture, float latitude, + std::int32_t waterLevel) noexcept { + const float absLatitude = std::fabs(latitude); + if (absLatitude > 0.80f) return static_cast(HexTerrainType::Snow); + if (elevation >= 7) return static_cast(HexTerrainType::Snow); + if (elevation >= 5) return static_cast(HexTerrainType::Stone); + if (absLatitude > 0.72f && elevation >= 2) return static_cast(HexTerrainType::Snow); + // Flooded floor: dark, and mostly hidden by the ocean surface. + if (elevation <= waterLevel) return static_cast(HexTerrainType::Mud); + if (elevation == waterLevel + 1) return static_cast(HexTerrainType::Sand); + if (elevation >= 4) return static_cast(HexTerrainType::Stone); + if (moisture < 0.36f && absLatitude < 0.45f) return static_cast(HexTerrainType::Sand); + if (moisture > 0.62f) return static_cast(HexTerrainType::Mud); + return static_cast(HexTerrainType::Grass); +} + +} // namespace + +Result generateSphereMap(HexSphereMap& map, const HexSphereGeneratorSettings& settings) { + if (map.empty()) { + return Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, + "cannot generate into an empty hex sphere map", + "hexmap.sphere.generate")); + } + + const std::int32_t cellCount = map.cellCount(); + const float frequency = std::max(0.05f, settings.frequency); + const std::int32_t octaves = std::clamp(settings.octaves, 1, 8); + + std::vector continents(static_cast(cellCount)); + std::vector moisture(static_cast(cellCount)); + for (HexSphereCell cell = 0; cell < cellCount; ++cell) { + const HexVec3 direction = map.direction(cell); + const HexVec3 coarse{direction.x * frequency, direction.y * frequency, direction.z * frequency}; + const HexVec3 damp{direction.x * frequency * 2.7f + 41.3f, direction.y * frequency * 2.7f - 17.9f, + direction.z * frequency * 2.7f + 5.1f}; + continents[static_cast(cell)] = fbm3(coarse, octaves, settings.seed); + moisture[static_cast(cell)] = fbm3(damp, 3, settings.seed ^ 0x5bf03635u); + } + + // The sea level is chosen from the field itself rather than assumed, so + // `landPercentage` is what decides how much of the planet is dry - the noise's own + // distribution stays whatever the octave count made it. + std::vector sorted = continents; + std::sort(sorted.begin(), sorted.end()); + const float landFraction = std::clamp(static_cast(settings.landPercentage) / 100.f, 0.f, 1.f); + const float seaLevel = quantile(sorted, 1.f - landFraction); + + float highest = 0.f; + float lowest = 0.f; + for (const float value : continents) { + highest = std::max(highest, value - seaLevel); + lowest = std::max(lowest, seaLevel - value); + } + highest = std::max(highest, 1e-4f); + lowest = std::max(lowest, 1e-4f); + + const std::int32_t waterLevel = std::clamp(settings.waterLevel, HexMetrics::kMinElevation, HexMetrics::kMaxElevation); + const float landSpan = static_cast(HexMetrics::kMaxElevation - waterLevel - 1); + const float oceanSpan = static_cast(waterLevel - HexMetrics::kMinElevation); + + for (HexSphereCell cell = 0; cell < cellCount; ++cell) { + const float above = continents[static_cast(cell)] - seaLevel; + const float unit = above / (above >= 0.f ? highest : lowest); + + std::int32_t elevation = 0; + if (above >= 0.f) { + elevation = waterLevel + 1 + static_cast(std::lround(unit * landSpan)); + } else { + elevation = waterLevel + static_cast(std::lround(unit * oceanSpan)); + } + elevation = std::clamp(elevation, HexMetrics::kMinElevation, HexMetrics::kMaxElevation); + + const float latitude = map.direction(cell).y; + map.setElevation(cell, elevation).ignore("generator clamps every elevation itself"); + map.setTerrainType(cell, terrainFor(elevation, moisture[static_cast(cell)], latitude, waterLevel)) + .ignore("generator sets every terrain type itself"); + map.setWaterLevel(cell, elevation <= waterLevel ? waterLevel : 0) + .ignore("generator sets every water level itself"); + } + + map.markAllDirty(); + return Result::success(); +} + +} // namespace eve::hexmap diff --git a/src/modules/hexmap/HexSphereGenerator.h b/src/modules/hexmap/HexSphereGenerator.h new file mode 100644 index 00000000..235c7fc0 --- /dev/null +++ b/src/modules/hexmap/HexSphereGenerator.h @@ -0,0 +1,53 @@ +#pragma once + +/** @file HexSphereGenerator.h @brief Procedural terrain generator for a spherical hex map. */ + +#include "common/Result.h" +#include "hexmap/HexSphereMap.h" + +#include + +namespace eve::hexmap { + +/** + * @brief Tunables of the spherical generator. + * + * The planar generator works in the map's own XZ plane, so its noise field, its + * plate seeding and its river carving all have a coordinate frame to live in. A + * sphere has none: this generator therefore samples a **3D** noise field along each + * cell's own direction, which is the only formulation that does not pinch at the + * poles or seam at the date line. + */ +struct HexSphereGeneratorSettings { + /** @brief Deterministic seed; the same seed always yields the same planet. */ + std::uint32_t seed = 0u; + /** @brief Target share of cells that end up above the water line, in percent. */ + std::int32_t landPercentage = 45; + /** @brief Cells at or below this elevation are flooded. */ + std::int32_t waterLevel = 0; + /** @brief fBm octaves of the continent field. */ + std::int32_t octaves = 5; + /** @brief Continent field frequency; higher makes more, smaller land masses. */ + float frequency = 1.55f; +}; + +/** + * @brief Regenerates every cell of `map` procedurally. + * + * Overwrites the whole cell storage: elevation, water level, terrain type and + * special index. Rivers, roads, walls and the fog latches are left as they are, so + * a caller that wants a clean planet must `reset` the map first (which is what the + * module's generate path does). + * + * The result is a pure function of the map's topology, its radius and the settings, + * so two runs of the same settings produce identical cells. + * + * @param map Map to fill; must not be empty. + * @param settings Generator tunables. + * @return Success, or InvalidArgument when the map has no cells. + * @cost One 3D fBm evaluation per cell for the continents and one more for the + * moisture, so proportional to the cell count and dominated by the noise. + */ +[[nodiscard]] Result generateSphereMap(HexSphereMap& map, const HexSphereGeneratorSettings& settings); + +} // namespace eve::hexmap diff --git a/src/modules/hexmap/HexSphereMap.cpp b/src/modules/hexmap/HexSphereMap.cpp new file mode 100644 index 00000000..bcc6627b --- /dev/null +++ b/src/modules/hexmap/HexSphereMap.cpp @@ -0,0 +1,683 @@ +#include "hexmap/HexSphereMap.h" + +#include "common/Diagnostic.h" + +#include +#include +#include +#include +#include + +namespace eve::hexmap { +namespace { + +/** @brief Builds the module's standard "bad argument" void failure. */ +[[nodiscard]] Result invalidArgument(std::string message) { + return Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), "hexmap.sphere")); +} + +[[nodiscard]] constexpr std::int32_t clampInt(std::int32_t value, std::int32_t low, std::int32_t high) noexcept { + return value < low ? low : (value > high ? high : value); +} + +/** @brief Maps a brush feature index onto its packed layer; anything but 0/1 is plants. */ +[[nodiscard]] constexpr std::int32_t layerIndex(std::int32_t feature) noexcept { + return feature == 0 ? 0 : (feature == 1 ? 1 : 2); +} + +[[nodiscard]] float dot3(HexVec3 a, HexVec3 b) noexcept { return a.x * b.x + a.y * b.y + a.z * b.z; } + +[[nodiscard]] float length3(HexVec3 a) noexcept { return std::sqrt(dot3(a, a)); } + +/** + * @brief Intersection span of a ray with a sphere centred on the origin. + * + * @return False when the ray misses the sphere entirely or the whole sphere lies + * behind the origin; otherwise the entry and exit parameters, which may + * be negative when the origin is inside the sphere. + */ +[[nodiscard]] bool raySphereSpan(HexVec3 origin, HexVec3 direction, float radius, float& enter, + float& exit) noexcept { + const float b = dot3(origin, direction); + const float c = dot3(origin, origin) - radius * radius; + const float discriminant = b * b - c; + if (discriminant < 0.f) return false; + const float root = std::sqrt(discriminant); + enter = -b - root; + exit = -b + root; + return exit > 0.f; +} + +} // namespace + +// --- construction ----------------------------------------------------------- + +Result HexSphereMap::reset(std::int32_t subdivision, float radius, std::uint32_t seed) { + if (subdivision < 0 || subdivision > kMaxHexSphereSubdivision) + return invalidArgument("subdivision level is out of range"); + if (!std::isfinite(radius) || radius <= 0.f) return invalidArgument("sphere radius must be finite and positive"); + + auto built = HexSphereTopology::build(subdivision); + if (!built.ok()) return Result::failure(built.status()); + + topology_ = std::move(built).takeValue(); + sphereRadius_ = radius; + elevationStep_ = radius * kDefaultElevationRatio; + noise_.reset(seed); + cells_.assign(static_cast(topology_.cellCount()), HexCellData{}); + cellDirty_.assign(cells_.size(), 0u); + dirtyQueue_.clear(); + dirtyHead_ = 0; + revision_ = 0; + markAllDirty(); + return Result::success(); +} + +void HexSphereMap::setElevationStep(float step) noexcept { + elevationStep_ = step; + ++revision_; +} + +// --- geometry --------------------------------------------------------------- + +float HexSphereMap::surfaceRadius(HexSphereCell cell) const noexcept { + if (!contains(cell)) return sphereRadius_; + return sphereRadius_ + static_cast(elevation(cell)) * elevationStep_; +} + +float HexSphereMap::cellSpacing() const noexcept { + const std::int32_t count = cellCount(); + if (count < 2) return 0.f; + // Mean area per cell is `4 * pi / count` of the sphere, so the mean spacing of a + // hexagonal packing of that area is the square root of it. + return sphereRadius_ * std::sqrt(4.f * 3.14159265358979f / static_cast(count)); +} + +HexVec3 HexSphereMap::cellPosition(HexSphereCell cell) const noexcept { + return topology_.direction(cell) * surfaceRadius(cell); +} + +HexVec3 HexSphereMap::cellGroundPosition(HexSphereCell cell) const noexcept { + return topology_.direction(cell) * sphereRadius_; +} + +HexEdgeType HexSphereMap::edgeTypeTo(HexSphereCell a, HexSphereCell b) const noexcept { + if (!contains(a) || !contains(b)) return HexEdgeType::Flat; + if (topology_.directionOf(a, b) < 0) return HexEdgeType::Flat; + return edgeType(elevation(a), elevation(b)); +} + +// --- cell access ------------------------------------------------------------ + +const HexCellData* HexSphereMap::cellAt(HexSphereCell cell) const noexcept { + if (!contains(cell)) return nullptr; + return &cells_[static_cast(cell)]; +} + +HexCellData* HexSphereMap::mutableCell(HexSphereCell cell) noexcept { + if (!contains(cell)) return nullptr; + return &cells_[static_cast(cell)]; +} + +std::int32_t HexSphereMap::elevation(HexSphereCell c) const noexcept { + const HexCellData* data = cellAt(c); + return data ? data->values.elevation() : 0; +} +std::int32_t HexSphereMap::waterLevel(HexSphereCell c) const noexcept { + const HexCellData* data = cellAt(c); + return data ? data->values.waterLevel() : 0; +} +std::int32_t HexSphereMap::terrainType(HexSphereCell c) const noexcept { + const HexCellData* data = cellAt(c); + return data ? data->values.terrainType() : 0; +} +std::int32_t HexSphereMap::urbanLevel(HexSphereCell c) const noexcept { + const HexCellData* data = cellAt(c); + return data ? data->values.urbanLevel() : 0; +} +std::int32_t HexSphereMap::farmLevel(HexSphereCell c) const noexcept { + const HexCellData* data = cellAt(c); + return data ? data->values.farmLevel() : 0; +} +std::int32_t HexSphereMap::plantLevel(HexSphereCell c) const noexcept { + const HexCellData* data = cellAt(c); + return data ? data->values.plantLevel() : 0; +} +std::int32_t HexSphereMap::specialIndex(HexSphereCell c) const noexcept { + const HexCellData* data = cellAt(c); + return data ? data->values.specialIndex() : 0; +} +bool HexSphereMap::isUnderwater(HexSphereCell c) const noexcept { + const HexCellData* data = cellAt(c); + return data && data->values.isUnderwater(); +} +bool HexSphereMap::hasRiver(HexSphereCell c) const noexcept { + const HexCellData* data = cellAt(c); + return data && data->flags.hasRiver(); +} +bool HexSphereMap::hasRoad(HexSphereCell c) const noexcept { + const HexCellData* data = cellAt(c); + return data && data->flags.hasRoad(); +} +bool HexSphereMap::hasRiverThrough(HexSphereCell c, std::int32_t direction) const noexcept { + const HexCellData* data = cellAt(c); + if (!data || direction < 0 || direction >= kHexDirectionCount) return false; + return data->flags.hasRiverThrough(static_cast(direction)); +} +bool HexSphereMap::isWalled(HexSphereCell c) const noexcept { + const HexCellData* data = cellAt(c); + return data && data->flags.isWalled(); +} +bool HexSphereMap::isExplored(HexSphereCell c) const noexcept { + const HexCellData* data = cellAt(c); + return data && data->flags.isExplored(); +} +bool HexSphereMap::isExplorable(HexSphereCell c) const noexcept { + const HexCellData* data = cellAt(c); + return data && data->flags.isExplorable(); +} +HexValues HexSphereMap::values(HexSphereCell c) const noexcept { + const HexCellData* data = cellAt(c); + return data ? data->values : HexValues{}; +} +HexFlags HexSphereMap::flags(HexSphereCell c) const noexcept { + const HexCellData* data = cellAt(c); + return data ? data->flags : HexFlags{}; +} + +// --- picking ---------------------------------------------------------------- + +Result HexSphereMap::pickCell(HexVec3 rayOrigin, HexVec3 rayDirection) const { + if (empty()) + return Result::failure( + Diagnostic::error(DiagnosticCode::NotFound, "hex sphere map is empty", "hexmap.sphere.pick")); + const float directionLength = length3(rayDirection); + if (!(directionLength > 1e-6f)) + return Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, + "ray direction must be non-zero", + "hexmap.sphere.pick")); + const HexVec3 direction = rayDirection * (1.f / directionLength); + + // The surface is a step function of direction, so the ray is marched against + // the bounding sphere and then bisected. Intersecting a single sphere and + // refining would return the far-side cell whenever the camera sits between + // the surface and that bounding sphere, which is exactly the low-orbit case + // this map exists for. + const float boundingRadius = + sphereRadius_ + static_cast(HexMetrics::kMaxElevation) * elevationStep_; + float enter = 0.f; + float exit = 0.f; + if (!raySphereSpan(rayOrigin, direction, boundingRadius, enter, exit)) + return Result::failure( + Diagnostic::error(DiagnosticCode::NotFound, "ray misses the hex planet", "hexmap.sphere.pick")); + + const float marchStep = std::max(boundingRadius * 0.01f, 1e-4f); + float tInside = 0.f; + HexSphereCell hit = kNoHexSphereCell; + for (float t = std::max(0.f, enter); t <= exit + marchStep; t += marchStep) { + const HexVec3 point = rayOrigin + direction * t; + const float span = length3(point); + if (span <= 1e-6f) continue; + const HexSphereCell cell = topology_.cellAt(point); + if (cell == kNoHexSphereCell) continue; + if (span <= surfaceRadius(cell)) { + hit = cell; + tInside = t; + break; + } + } + if (hit == kNoHexSphereCell) + return Result::failure(Diagnostic::error( + DiagnosticCode::NotFound, "ray never reached the hex planet surface", "hexmap.sphere.pick")); + + // Bisect between the last sample above the surface and the first one below it. + float tOutside = std::max(std::max(0.f, enter), tInside - marchStep); + for (std::int32_t iteration = 0; iteration < 24; ++iteration) { + const float middle = 0.5f * (tOutside + tInside); + const HexVec3 point = rayOrigin + direction * middle; + const float span = length3(point); + if (span <= 1e-6f) break; + const HexSphereCell cell = topology_.cellAt(point); + if (cell == kNoHexSphereCell) break; + if (span <= surfaceRadius(cell)) { + hit = cell; + tInside = middle; + } else { + tOutside = middle; + } + } + return Result::success(hit); +} + +// --- neighbourhood queries -------------------------------------------------- + +std::int32_t HexSphereMap::distance(HexSphereCell a, HexSphereCell b) const { + if (!contains(a) || !contains(b)) return kNoHexSphereCell; + if (a == b) return 0; + + std::vector visited(cells_.size(), 0u); + std::vector frontier; + std::vector next; + frontier.push_back(a); + visited[static_cast(a)] = 1u; + + std::int32_t depth = 0; + while (!frontier.empty()) { + ++depth; + next.clear(); + for (const HexSphereCell cell : frontier) { + const std::int32_t count = topology_.neighborCount(cell); + for (std::int32_t d = 0; d < count; ++d) { + const HexSphereCell neighbour = topology_.neighbor(cell, d); + if (neighbour == kNoHexSphereCell) continue; + if (visited[static_cast(neighbour)]) continue; + if (neighbour == b) return depth; + visited[static_cast(neighbour)] = 1u; + next.push_back(neighbour); + } + } + frontier.swap(next); + } + // The neighbour graph of a sphere is connected, so this is unreachable. + return kNoHexSphereCell; +} + +void HexSphereMap::collectBrush(HexSphereCell center, std::int32_t radius, + std::vector& out) const { + out.clear(); + if (!contains(center) || radius < 0) return; + + std::vector visited(cells_.size(), 0u); + out.push_back(center); + visited[static_cast(center)] = 1u; + + std::size_t head = 0; + for (std::int32_t step = 0; step < radius; ++step) { + const std::size_t levelEnd = out.size(); + for (; head < levelEnd; ++head) { + const HexSphereCell cell = out[head]; + const std::int32_t count = topology_.neighborCount(cell); + for (std::int32_t d = 0; d < count; ++d) { + const HexSphereCell neighbour = topology_.neighbor(cell, d); + if (neighbour == kNoHexSphereCell) continue; + if (visited[static_cast(neighbour)]) continue; + visited[static_cast(neighbour)] = 1u; + out.push_back(neighbour); + } + } + } +} + +// --- dirty tracking --------------------------------------------------------- + +void HexSphereMap::markCellDirty(HexSphereCell cell) noexcept { + if (!contains(cell)) return; + if (cellDirty_[static_cast(cell)]) return; + cellDirty_[static_cast(cell)] = 1u; + dirtyQueue_.push_back(cell); +} + +void HexSphereMap::markCellDirtyAndNeighbors(HexSphereCell cell) noexcept { + if (!contains(cell)) return; + markCellDirty(cell); + const std::int32_t count = topology_.neighborCount(cell); + for (std::int32_t d = 0; d < count; ++d) markCellDirty(topology_.neighbor(cell, d)); +} + +void HexSphereMap::markAllDirty() noexcept { + dirtyQueue_.clear(); + dirtyHead_ = 0; + cellDirty_.assign(cells_.size(), 0u); + dirtyQueue_.reserve(cells_.size()); + for (std::size_t i = 0; i < cells_.size(); ++i) { + cellDirty_[i] = 1u; + dirtyQueue_.push_back(static_cast(i)); + } +} + +std::int32_t HexSphereMap::takeDirtyCell() noexcept { + while (dirtyHead_ < dirtyQueue_.size()) { + const HexSphereCell cell = dirtyQueue_[dirtyHead_++]; + if (!contains(cell)) continue; + if (!cellDirty_[static_cast(cell)]) continue; + cellDirty_[static_cast(cell)] = 0u; + if (dirtyHead_ >= 4096u && dirtyHead_ * 2u >= dirtyQueue_.size()) { + dirtyQueue_.erase(dirtyQueue_.begin(), + dirtyQueue_.begin() + static_cast(dirtyHead_)); + dirtyHead_ = 0u; + } + return cell; + } + dirtyQueue_.clear(); + dirtyHead_ = 0u; + return kNoHexSphereCell; +} + +// --- cell authoring --------------------------------------------------------- + +void HexSphereMap::refreshCellDependents(HexSphereCell cell) noexcept { + ++revision_; + markCellDirtyAndNeighbors(cell); +} + +void HexSphereMap::validateRivers(HexSphereCell cell) noexcept { + const HexCellData* data = cellAt(cell); + if (!data || !data->flags.hasRiver()) return; + // A river may only leave a cell that is at least as high as its neighbour, + // unless the cell itself is a lake at exactly its own elevation. + const std::int32_t fromElevation = elevation(cell); + const std::int32_t fromWater = waterLevel(cell); + const std::int32_t count = neighborCount(cell); + for (std::int32_t d = 0; d < count; ++d) { + if (!data->flags.hasRiverOut(static_cast(d))) continue; + const HexSphereCell neighbour = topology_.neighbor(cell, d); + if (neighbour == kNoHexSphereCell) continue; + const bool canFlow = fromElevation >= elevation(neighbour) || fromWater == fromElevation; + if (!canFlow) { + removeRiver(cell).ignore("river validation must keep the map consistent"); + return; + } + } +} + +Result HexSphereMap::setElevation(HexSphereCell c, std::int32_t value) { + HexCellData* data = mutableCell(c); + if (!data) return invalidArgument("cell is outside the hex sphere map"); + const std::int32_t clamped = clampInt(value, HexMetrics::kMinElevation, HexMetrics::kMaxElevation); + if (data->values.elevation() == clamped) return Result::success(); + data->values = data->values.withElevation(clamped); + + // Roads cannot span a difference of more than one elevation step. + bool hasInvalidRoad = false; + const std::int32_t count = neighborCount(c); + for (std::int32_t d = 0; d < count && !hasInvalidRoad; ++d) { + if (!data->flags.hasRoad(static_cast(d))) continue; + const HexSphereCell neighbour = topology_.neighbor(c, d); + if (neighbour == kNoHexSphereCell) continue; + const std::int32_t delta = elevation(neighbour) - clamped; + if (delta > 1 || delta < -1) hasInvalidRoad = true; + } + if (hasInvalidRoad) removeRoads(c).ignore("elevation change must drop invalid roads"); + refreshCellDependents(c); + validateRivers(c); + for (std::int32_t d = 0; d < count; ++d) { + const HexSphereCell neighbour = topology_.neighbor(c, d); + if (neighbour != kNoHexSphereCell) validateRivers(neighbour); + } + return Result::success(); +} + +Result HexSphereMap::setWaterLevel(HexSphereCell c, std::int32_t value) { + HexCellData* data = mutableCell(c); + if (!data) return invalidArgument("cell is outside the hex sphere map"); + const std::int32_t clamped = clampInt(value, 0, HexMetrics::kMaxElevation); + if (data->values.waterLevel() == clamped) return Result::success(); + data->values = data->values.withWaterLevel(clamped); + refreshCellDependents(c); + validateRivers(c); + return Result::success(); +} + +Result HexSphereMap::setTerrainType(HexSphereCell c, std::int32_t value) { + HexCellData* data = mutableCell(c); + if (!data) return invalidArgument("cell is outside the hex sphere map"); + data->values = data->values.withTerrainType(clampTerrainType(value)); + ++revision_; + return Result::success(); +} + +Result HexSphereMap::setUrbanLevel(HexSphereCell c, std::int32_t value) { + HexCellData* data = mutableCell(c); + if (!data) return invalidArgument("cell is outside the hex sphere map"); + data->values = data->values.withUrbanLevel(clampInt(value, 0, 3)); + refreshCellDependents(c); + return Result::success(); +} + +Result HexSphereMap::setFarmLevel(HexSphereCell c, std::int32_t value) { + HexCellData* data = mutableCell(c); + if (!data) return invalidArgument("cell is outside the hex sphere map"); + data->values = data->values.withFarmLevel(clampInt(value, 0, 3)); + refreshCellDependents(c); + return Result::success(); +} + +Result HexSphereMap::setPlantLevel(HexSphereCell c, std::int32_t value) { + HexCellData* data = mutableCell(c); + if (!data) return invalidArgument("cell is outside the hex sphere map"); + data->values = data->values.withPlantLevel(clampInt(value, 0, 3)); + refreshCellDependents(c); + return Result::success(); +} + +Result HexSphereMap::setSpecialIndex(HexSphereCell c, std::int32_t index) { + HexCellData* data = mutableCell(c); + if (!data) return invalidArgument("cell is outside the hex sphere map"); + if (data->flags.hasRiver()) return Result::success(); + data->values = data->values.withSpecialIndex(clampInt(index, 0, 3)); + removeRoads(c).ignore("special features replace roads"); + refreshCellDependents(c); + return Result::success(); +} + +Result HexSphereMap::setWalled(HexSphereCell c, bool walled) { + HexCellData* data = mutableCell(c); + if (!data) return invalidArgument("cell is outside the hex sphere map"); + data->flags = data->flags.withWalled(walled); + refreshCellDependents(c); + return Result::success(); +} + +Result HexSphereMap::setExplored(HexSphereCell c, bool explored) { + HexCellData* data = mutableCell(c); + if (!data) return invalidArgument("cell is outside the hex sphere map"); + data->flags = data->flags.withExplored(explored); + // Fog state changes do not alter geometry, so the cells stay clean: only the + // revision is bumped so a renderer can tell that the visible set changed. + ++revision_; + return Result::success(); +} + +Result HexSphereMap::setExplorable(HexSphereCell c, bool explorable) { + HexCellData* data = mutableCell(c); + if (!data) return invalidArgument("cell is outside the hex sphere map"); + data->flags = data->flags.withExplorable(explorable); + ++revision_; + return Result::success(); +} + +Result HexSphereMap::setCellState(HexSphereCell c, HexValues values, HexFlags flags) { + HexCellData* data = mutableCell(c); + if (!data) return invalidArgument("cell is outside the hex sphere map"); + data->values = values; + data->flags = flags; + refreshCellDependents(c); + return Result::success(); +} + +Result HexSphereMap::setOutgoingRiver(HexSphereCell c, std::int32_t direction) { + HexCellData* data = mutableCell(c); + if (!data) return invalidArgument("cell is outside the hex sphere map"); + if (direction < 0 || direction >= neighborCount(c)) + return invalidArgument("river direction is not an edge of the cell"); + const HexSphereCell neighbour = topology_.neighbor(c, direction); + if (neighbour == kNoHexSphereCell) return invalidArgument("river direction has no neighbour"); + const std::int32_t back = topology_.oppositeDirection(c, direction); + if (back < 0) return invalidArgument("river direction has no opposite edge"); + + const auto out = static_cast(direction); + const auto in = static_cast(back); + if (data->flags.hasAnyRiverOut() && data->flags.hasRiverOut(out)) return Result::success(); + if (elevation(c) < elevation(neighbour) && waterLevel(c) != elevation(c)) + return invalidArgument("a river cannot flow uphill"); + + // Drop only the links this call replaces, mirroring the reference cell's + // `RemoveOutgoingRiver` + same-edge `RemoveIncomingRiver`. Calling the full + // `removeRiver` here also erased the *incoming* link from the upstream cell, + // so carving a channel step by step wiped the step behind it and only the + // last edge survived. + const std::int32_t count = neighborCount(c); + for (std::int32_t d = 0; d < count; ++d) { + const auto previous = static_cast(d); + if (!data->flags.hasRiverOut(previous)) continue; + data->flags = data->flags.withoutRiverOut(previous); + const HexSphereCell previousCell = topology_.neighbor(c, d); + const std::int32_t previousBack = topology_.oppositeDirection(c, d); + if (previousCell != kNoHexSphereCell && previousBack >= 0) { + HexCellData* previousData = mutableCell(previousCell); + if (previousData) previousData->flags = previousData->flags.withoutRiverIn(static_cast(previousBack)); + refreshCellDependents(previousCell); + } + } + + data = mutableCell(c); + // A river may not leave and enter through the same edge. + if (data->flags.hasRiverIn(out)) { + data->flags = data->flags.withoutRiverIn(out); + HexCellData* other = mutableCell(neighbour); + if (other) other->flags = other->flags.withoutRiverOut(in); + } + + data = mutableCell(c); + data->flags = data->flags.withRiverOut(out).withoutRoad(out); + data->values = data->values.withSpecialIndex(0); + HexCellData* other = mutableCell(neighbour); + other->flags = other->flags.withRiverIn(in).withoutRoad(in); + other->values = other->values.withSpecialIndex(0); + refreshCellDependents(c); + refreshCellDependents(neighbour); + return Result::success(); +} + +Result HexSphereMap::removeRiver(HexSphereCell c) { + HexCellData* data = mutableCell(c); + if (!data) return invalidArgument("cell is outside the hex sphere map"); + if (!data->flags.hasRiver()) return Result::success(); + for (std::int32_t d = 0; d < kHexDirectionCount; ++d) { + const auto direction = static_cast(d); + data->flags = data->flags.withoutRiverIn(direction).withoutRiverOut(direction); + } + const std::int32_t count = neighborCount(c); + for (std::int32_t d = 0; d < count; ++d) { + const HexSphereCell neighbour = topology_.neighbor(c, d); + const std::int32_t back = topology_.oppositeDirection(c, d); + if (neighbour == kNoHexSphereCell || back < 0) continue; + const auto in = static_cast(back); + HexCellData* other = mutableCell(neighbour); + if (other) other->flags = other->flags.withoutRiverIn(in).withoutRiverOut(in); + refreshCellDependents(neighbour); + } + refreshCellDependents(c); + return Result::success(); +} + +Result HexSphereMap::addRoad(HexSphereCell c, std::int32_t direction) { + HexCellData* data = mutableCell(c); + if (!data) return invalidArgument("cell is outside the hex sphere map"); + if (direction < 0 || direction >= neighborCount(c)) + return invalidArgument("road direction is not an edge of the cell"); + const HexSphereCell neighbour = topology_.neighbor(c, direction); + if (neighbour == kNoHexSphereCell) return invalidArgument("road direction has no neighbour"); + const std::int32_t back = topology_.oppositeDirection(c, direction); + if (back < 0) return invalidArgument("road direction has no opposite edge"); + + const auto out = static_cast(direction); + const auto in = static_cast(back); + if (data->flags.hasRoad(out)) return Result::success(); + if (data->flags.hasRiverThrough(out)) return Result::success(); + if (data->flags.hasRiver() && !data->flags.hasRiverBeginOrEnd()) { + // A road may only join a river at a begin or end cell. + return Result::success(); + } + HexCellData* other = mutableCell(neighbour); + if (other->values.specialIndex() != 0 || data->values.specialIndex() != 0) return Result::success(); + if (other->flags.hasRiverThrough(in)) return Result::success(); + const std::int32_t delta = elevation(neighbour) - elevation(c); + if (delta > 1 || delta < -1) return Result::success(); + + data->flags = data->flags.withRoad(out); + other->flags = other->flags.withRoad(in); + refreshCellDependents(c); + refreshCellDependents(neighbour); + return Result::success(); +} + +Result HexSphereMap::removeRoads(HexSphereCell c) { + HexCellData* data = mutableCell(c); + if (!data) return invalidArgument("cell is outside the hex sphere map"); + if (!data->flags.hasRoad()) return Result::success(); + data->flags = data->flags.without(HexFlags::roadMask()); + const std::int32_t count = neighborCount(c); + for (std::int32_t d = 0; d < count; ++d) { + const HexSphereCell neighbour = topology_.neighbor(c, d); + const std::int32_t back = topology_.oppositeDirection(c, d); + if (neighbour == kNoHexSphereCell || back < 0) continue; + HexCellData* other = mutableCell(neighbour); + if (other) other->flags = other->flags.withoutRoad(static_cast(back)); + refreshCellDependents(neighbour); + } + refreshCellDependents(c); + return Result::success(); +} + +// --- brush authoring -------------------------------------------------------- + +Result HexSphereMap::editElevation(HexSphereCell center, std::int32_t radius, std::int32_t delta) { + if (!contains(center)) return invalidArgument("brush centre is outside the hex sphere map"); + if (radius < 0) return invalidArgument("brush radius must be non-negative"); + std::vector cells; + collectBrush(center, radius, cells); + for (const HexSphereCell cell : cells) { + const std::int32_t next = elevation(cell) + delta; + setElevation(cell, next).ignore("brush elevation edit clamps per cell"); + } + return Result::success(); +} + +Result HexSphereMap::editWaterLevel(HexSphereCell center, std::int32_t radius, std::int32_t delta) { + if (!contains(center)) return invalidArgument("brush centre is outside the hex sphere map"); + if (radius < 0) return invalidArgument("brush radius must be non-negative"); + std::vector cells; + collectBrush(center, radius, cells); + for (const HexSphereCell cell : cells) + setWaterLevel(cell, waterLevel(cell) + delta).ignore("brush water edit clamps per cell"); + return Result::success(); +} + +Result HexSphereMap::editTerrainType(HexSphereCell center, std::int32_t radius, std::int32_t terrainType) { + if (!contains(center)) return invalidArgument("brush centre is outside the hex sphere map"); + if (radius < 0) return invalidArgument("brush radius must be non-negative"); + std::vector cells; + collectBrush(center, radius, cells); + for (const HexSphereCell cell : cells) + setTerrainType(cell, terrainType).ignore("brush terrain edit clamps per cell"); + return Result::success(); +} + +Result HexSphereMap::editFeatureLevel(HexSphereCell center, std::int32_t radius, std::int32_t feature, + std::int32_t delta) { + if (!contains(center)) return invalidArgument("brush centre is outside the hex sphere map"); + if (radius < 0) return invalidArgument("brush radius must be non-negative"); + std::vector cells; + collectBrush(center, radius, cells); + const std::int32_t layer = layerIndex(feature); + for (const HexSphereCell cell : cells) { + const HexCellData* data = cellAt(cell); + if (!data || data->flags.hasRiver()) continue; + if (data->values.specialIndex() != 0) continue; + const std::int32_t current = layer == 0 ? data->values.urbanLevel() + : layer == 1 ? data->values.farmLevel() + : data->values.plantLevel(); + const std::int32_t next = clampInt(current + delta, 0, 3); + if (layer == 0) + setUrbanLevel(cell, next).ignore("brush urban edit clamps per cell"); + else if (layer == 1) + setFarmLevel(cell, next).ignore("brush farm edit clamps per cell"); + else + setPlantLevel(cell, next).ignore("brush plant edit clamps per cell"); + } + return Result::success(); +} + +} // namespace eve::hexmap diff --git a/src/modules/hexmap/HexSphereMap.h b/src/modules/hexmap/HexSphereMap.h new file mode 100644 index 00000000..ecf01354 --- /dev/null +++ b/src/modules/hexmap/HexSphereMap.h @@ -0,0 +1,346 @@ +#pragma once + +/** @file HexSphereMap.h @brief Editable spherical hex map over an icosahedral hex topology. */ + +#include "common/Result.h" +#include "hexmap/HexCell.h" +#include "hexmap/HexMetrics.h" +#include "hexmap/HexNoise.h" +#include "hexmap/HexSphereTopology.h" + +#include +#include + +namespace eve::hexmap { + +/** + * @brief An editable hex map wrapped onto a sphere. + * + * This is the spherical counterpart of `HexMap`. It reuses the whole + * per-cell data model unchanged — `HexCellData`, `HexValues` and `HexFlags` — + * and the geometry constants of `HexMetrics`, and replaces only the two things + * that cannot survive the change of surface: cell **identity** and the + * **neighbour relation**. + * + * Cell identity is a dense `HexSphereCell` id from `HexSphereTopology` instead + * of axial/offset `HexCoordinates`, and neighbours are reached through the + * topology's half-edge tables instead of integer direction offsets. There is no + * chunk grid: the topology already gives every cell a stable id over the whole + * sphere, so dirty tracking is per cell. + * + * Elevation is radial. A cell's surface sits at + * `sphereRadius + elevation * elevationStep`, so `elevationStep` is the world + * distance one elevation level lifts the surface, and it defaults to a fraction + * of the radius rather than to the planar `HexMetrics::kElevationStep` (three + * world units), which would be an absurd relief on a planet-sized sphere. The + * editable range, the packed layout and every clamping rule stay exactly those + * of `HexValues`. + * + * Direction indices are the topology's edge indices in + * `[0, neighborCount(cell))`, i.e. six for a hexagon and five for a pentagon. + * `HexFlags` stores roads and rivers in six one-bit slots indexed by + * `HexDirection`, so a direction index is cast into that enumeration directly; + * slot 5 is simply never set on a pentagon. + * + * Ownership and lifetime: a value type owning its topology, its cell storage and + * its noise field. It performs no I/O, opens no GPU resources and invokes no + * callbacks. + * + * Thread affinity: affine to one thread, like `HexMap`. The type is not + * synchronized. + * + * Determinism: the topology, the noise field and every derived position depend + * only on the subdivision level, the radius and the seed. Two maps built with + * the same triple produce identical topology, cell storage and positions. + */ +class HexSphereMap { +public: + /** @brief Ratio of the default radial elevation step to the sphere radius. */ + static constexpr float kDefaultElevationRatio = 0.006f; + + HexSphereMap() = default; + + /** + * @brief Replaces the map with a new sphere of `10 * 4^subdivision + 2` cells. + * + * Every cell starts as a default record at elevation 0 and the whole sphere + * is marked dirty. The subdivision level fixes the cell count permanently for + * the lifetime of this map. + * + * @param subdivision Subdivision level in `[0, kMaxHexSphereSubdivision]`. + * @param radius Sphere radius in world units; must be finite and positive. + * @param seed Deterministic seed for the noise field. + * @return Success, or InvalidArgument when an argument is out of range, or the + * topology construction's own failure status. + * @cost Builds the topology's five tables; proportional to the cell count. + */ + [[nodiscard]] Result reset(std::int32_t subdivision, float radius, std::uint32_t seed); + + // --- shape and identity ------------------------------------------------- + + /** @brief Whether the map holds any cell. */ + [[nodiscard]] bool empty() const noexcept { return cells_.empty(); } + /** @brief The subdivision level this map was built at. */ + [[nodiscard]] std::int32_t subdivision() const noexcept { return topology_.subdivision(); } + /** @brief Subdivision frequency `f = 2^subdivision`. */ + [[nodiscard]] std::int32_t frequency() const noexcept { return topology_.frequency(); } + /** @brief Number of cells (`10 * f^2 + 2`). */ + [[nodiscard]] std::int32_t cellCount() const noexcept { return static_cast(cells_.size()); } + /** @brief Number of pentagonal cells (12 for a non-empty map). */ + [[nodiscard]] std::int32_t pentagonCount() const noexcept { return topology_.pentagonCount(); } + /** @brief Sphere radius in world units. */ + [[nodiscard]] float sphereRadius() const noexcept { return sphereRadius_; } + /** + * @brief Mean centre-to-centre distance between neighbouring cells, in world units. + * + * The spherical replacement for `HexMetrics::kOuterRadius` as a *scale*: geometry + * that the planar builders express as a fixed number of world units has to be + * expressed as a fraction of the cell size on a sphere, because the cell size is + * set by the subdivision level and the radius together. + */ + [[nodiscard]] float cellSpacing() const noexcept; + /** @brief World distance one elevation level lifts the surface. */ + [[nodiscard]] float elevationStep() const noexcept { return elevationStep_; } + /** + * @brief Sets the radial world distance of one elevation level. + * + * Only geometry changes, so the cells stay clean; the revision is bumped so a + * renderer can tell that the built meshes are stale. + */ + void setElevationStep(float step) noexcept; + /** @brief The seed this map was built with. */ + [[nodiscard]] std::uint32_t seed() const noexcept { return noise_.seed(); } + /** @brief Monotonic counter bumped by every mutation; used to detect stale meshes. */ + [[nodiscard]] std::uint64_t revision() const noexcept { return revision_; } + /** @brief Deterministic noise field shared by this map's mesh builders. */ + [[nodiscard]] const HexNoise& noise() const noexcept { return noise_; } + /** + * @brief The topology backing this map. + * @ownership Borrowed; valid until this map is destroyed or `reset` is called. + */ + [[nodiscard]] const HexSphereTopology& topology() const noexcept { return topology_; } + + // --- topology ----------------------------------------------------------- + + /** @brief Whether `cell` is a valid id of this map. */ + [[nodiscard]] bool contains(HexSphereCell cell) const noexcept { return topology_.contains(cell); } + /** @brief Whether `cell` has five edges instead of six. */ + [[nodiscard]] bool isPentagon(HexSphereCell cell) const noexcept { return topology_.isPentagon(cell); } + /** + * @brief Number of edges of `cell` (5 or 6), or 0 when out of range. + * + * On the sphere this is not a constant, so a caller enumerating the edges of a + * cell must bound its loop by this and never by `kHexDirectionCount`. + */ + [[nodiscard]] std::int32_t neighborCount(HexSphereCell cell) const noexcept { + return topology_.neighborCount(cell); + } + /** @brief Neighbour of `cell` across edge `direction`, or `kNoHexSphereCell`. */ + [[nodiscard]] HexSphereCell neighbor(HexSphereCell cell, std::int32_t direction) const noexcept { + return topology_.neighbor(cell, direction); + } + /** @brief Edge index shared with `other`, or `kNoHexSphereCell` when not adjacent. */ + [[nodiscard]] std::int32_t directionOf(HexSphereCell cell, HexSphereCell other) const noexcept { + return topology_.directionOf(cell, other); + } + /** @brief Edge index of `cell`'s edge towards its neighbour across `direction`. */ + [[nodiscard]] std::int32_t oppositeDirection(HexSphereCell cell, std::int32_t direction) const noexcept { + return topology_.oppositeDirection(cell, direction); + } + /** @brief Number of corners of `cell` (5 or 6), or 0 when out of range. */ + [[nodiscard]] std::int32_t cornerCountOf(HexSphereCell cell) const noexcept { + return topology_.cornerCountOf(cell); + } + /** @brief Relationship between two adjacent cells; Flat when not adjacent. */ + [[nodiscard]] HexEdgeType edgeTypeTo(HexSphereCell a, HexSphereCell b) const noexcept; + + // --- geometry ----------------------------------------------------------- + + /** @brief Unit direction from the sphere centre to the centre of `cell`. */ + [[nodiscard]] HexVec3 direction(HexSphereCell cell) const noexcept { return topology_.direction(cell); } + /** + * @brief Unit direction of corner `corner` of `cell`. + * + * A corner is not a fixed angular offset of its cell on a sphere, so the mesh + * builder must read it here instead of deriving it from edge directions. + */ + [[nodiscard]] HexVec3 cornerDirection(HexSphereCell cell, std::int32_t corner) const noexcept { + return topology_.corner(cell, corner); + } + /** @brief Radius of the cell's surface: `sphereRadius + elevation * elevationStep`. */ + [[nodiscard]] float surfaceRadius(HexSphereCell cell) const noexcept; + /** @brief World-space centre of the cell's surface. */ + [[nodiscard]] HexVec3 cellPosition(HexSphereCell cell) const noexcept; + /** @brief World-space centre of the cell at radius, ignoring elevation. */ + [[nodiscard]] HexVec3 cellGroundPosition(HexSphereCell cell) const noexcept; + + // --- cell access -------------------------------------------------------- + + /** + * @brief Cell record by id, or null when out of range. + * @ownership Borrowed; the map owns the record. + * @lifetime Valid until this map is destroyed or `reset` is called; any + * mutation of the map's cell storage invalidates the pointer. + */ + [[nodiscard]] const HexCellData* cellAt(HexSphereCell cell) const noexcept; + + [[nodiscard]] std::int32_t elevation(HexSphereCell c) const noexcept; + [[nodiscard]] std::int32_t waterLevel(HexSphereCell c) const noexcept; + [[nodiscard]] std::int32_t terrainType(HexSphereCell c) const noexcept; + [[nodiscard]] std::int32_t urbanLevel(HexSphereCell c) const noexcept; + [[nodiscard]] std::int32_t farmLevel(HexSphereCell c) const noexcept; + [[nodiscard]] std::int32_t plantLevel(HexSphereCell c) const noexcept; + [[nodiscard]] std::int32_t specialIndex(HexSphereCell c) const noexcept; + [[nodiscard]] bool isUnderwater(HexSphereCell c) const noexcept; + [[nodiscard]] bool hasRiver(HexSphereCell c) const noexcept; + [[nodiscard]] bool hasRoad(HexSphereCell c) const noexcept; + [[nodiscard]] bool hasRiverThrough(HexSphereCell c, std::int32_t direction) const noexcept; + [[nodiscard]] bool isWalled(HexSphereCell c) const noexcept; + /** @brief Whether a cell has ever been seen by a viewer (the fog-of-war latch). */ + [[nodiscard]] bool isExplored(HexSphereCell c) const noexcept; + /** @brief Whether a cell may ever be revealed by a viewer. */ + [[nodiscard]] bool isExplorable(HexSphereCell c) const noexcept; + /** @brief Full packed value record of a cell (zero when out of range). */ + [[nodiscard]] HexValues values(HexSphereCell c) const noexcept; + /** @brief Full packed flag record of a cell (zero when out of range). */ + [[nodiscard]] HexFlags flags(HexSphereCell c) const noexcept; + + // --- picking ------------------------------------------------------------ + + /** + * @brief Finds the cell under a world-space ray. + * + * The map has no collision geometry, so the ray is intersected with the + * sphere at the cell's own surface radius and the result is refined a few + * times: the first hit is taken against the bounding sphere, then the radius + * of the cell found there is used for the next intersection. A camera outside + * the sphere converges in a handful of iterations. + * + * @param rayOrigin Ray origin in world space. + * @param rayDirection Non-zero ray direction in world space; normalized internally. + * @return The hit cell, or NotFound when the ray misses the sphere, or + * InvalidArgument when the direction is zero. + * @cost A fixed small number of `HexSphereTopology::cellAt` walks. + */ + [[nodiscard]] Result pickCell(HexVec3 rayOrigin, HexVec3 rayDirection) const; + + // --- neighbourhood queries ---------------------------------------------- + + /** + * @brief Number of edges between two cells, or `kNoHexSphereCell` when either is invalid. + * + * This is a breadth-first walk over the neighbour graph: a sphere has no + * cheap closed form for hex distance, unlike the planar offset arithmetic. + * + * @cost O(cellCount) time and memory; intended for authoring and tests, not + * for per-frame queries over many pairs. + */ + [[nodiscard]] std::int32_t distance(HexSphereCell a, HexSphereCell b) const; + + /** + * @brief Enumerates the cells within `radius` steps of `center` into `out`. + * + * `out` is cleared first and then filled in breadth-first order, which is + * deterministic for a given topology. The centre is always the first entry. + * + * @param center Brush centre. + * @param radius Number of steps the brush reaches; 0 collects only the centre. + * @param out Receives the cell ids; cleared first. + * @cost O(cellCount) memory for the visited set, plus the brush size in time. + */ + void collectBrush(HexSphereCell center, std::int32_t radius, std::vector& out) const; + + /** @brief Raises or lowers every cell within `radius` steps of `center`. */ + [[nodiscard]] Result editElevation(HexSphereCell center, std::int32_t radius, std::int32_t delta); + /** @brief Applies `additive` water-level change over a hex brush. */ + [[nodiscard]] Result editWaterLevel(HexSphereCell center, std::int32_t radius, std::int32_t delta); + /** @brief Paints a terrain palette index over a hex brush. */ + [[nodiscard]] Result editTerrainType(HexSphereCell center, std::int32_t radius, std::int32_t terrainType); + /** @brief Applies `additive` change to one feature level (`0` urban, `1` farm, `2` plant). */ + [[nodiscard]] Result editFeatureLevel(HexSphereCell center, std::int32_t radius, std::int32_t feature, + std::int32_t delta); + + // --- cell authoring ----------------------------------------------------- + + /** @brief Sets a cell's elevation, clamped to the editable range, and refreshes dependents. */ + [[nodiscard]] Result setElevation(HexSphereCell c, std::int32_t elevation); + /** @brief Sets a cell's water level, clamped to `[0, kMaxElevation]`. */ + [[nodiscard]] Result setWaterLevel(HexSphereCell c, std::int32_t waterLevel); + /** @brief Sets a cell's terrain palette index, clamped to the palette range. */ + [[nodiscard]] Result setTerrainType(HexSphereCell c, std::int32_t terrainType); + /** @brief Sets a cell's urban level, clamped to `[0, 3]`. */ + [[nodiscard]] Result setUrbanLevel(HexSphereCell c, std::int32_t level); + /** @brief Sets a cell's farm level, clamped to `[0, 3]`. */ + [[nodiscard]] Result setFarmLevel(HexSphereCell c, std::int32_t level); + /** @brief Sets a cell's plant level, clamped to `[0, 3]`. */ + [[nodiscard]] Result setPlantLevel(HexSphereCell c, std::int32_t level); + /** @brief Sets a cell's special-feature index; ignored while the cell carries a river. */ + [[nodiscard]] Result setSpecialIndex(HexSphereCell c, std::int32_t index); + /** @brief Sets whether a cell is walled. */ + [[nodiscard]] Result setWalled(HexSphereCell c, bool walled); + /** @brief Sets the explored (fog-of-war) latch of a cell. */ + [[nodiscard]] Result setExplored(HexSphereCell c, bool explored); + /** @brief Sets whether a cell may ever be explored. */ + [[nodiscard]] Result setExplorable(HexSphereCell c, bool explorable); + /** + * @brief Writes a complete cell record verbatim. + * + * This is the restore path used by a save format. It deliberately skips the + * neighbour fix-ups of the individual authoring setters, because a payload + * carries both sides of every connection; the caller is responsible for a + * consistent payload and must finish with `markAllDirty()`. + * + * @return Success, or InvalidArgument when `c` is out of range. + */ + [[nodiscard]] Result setCellState(HexSphereCell c, HexValues values, HexFlags flags); + /** @brief Forces a river to leave the cell through `direction`, mirroring the neighbour. */ + [[nodiscard]] Result setOutgoingRiver(HexSphereCell c, std::int32_t direction); + /** @brief Removes every river connection of the cell and its neighbours. */ + [[nodiscard]] Result removeRiver(HexSphereCell c); + /** @brief Adds a road through `direction` when both cells can carry one. */ + [[nodiscard]] Result addRoad(HexSphereCell c, std::int32_t direction); + /** @brief Removes every road of the cell and its neighbours. */ + [[nodiscard]] Result removeRoads(HexSphereCell c); + + // --- dirty tracking ----------------------------------------------------- + + /** @brief Marks every cell dirty. */ + void markAllDirty() noexcept; + /** @brief Marks one cell and its direct neighbours dirty. */ + void markCellDirtyAndNeighbors(HexSphereCell cell) noexcept; + /** + * @brief Pops the next dirty cell. + * + * @return The dirty cell id, or `kNoHexSphereCell` when nothing is pending. + * @note Callers loop until `kNoHexSphereCell` is returned; the returned cell is + * no longer reported dirty until it is mutated again. + */ + [[nodiscard]] std::int32_t takeDirtyCell() noexcept; + /** @brief Number of cells currently pending a rebuild. */ + [[nodiscard]] std::int32_t dirtyCellCount() const noexcept { + return static_cast(dirtyQueue_.size() - dirtyHead_); + } + +private: + void markCellDirty(HexSphereCell cell) noexcept; + void refreshCellDependents(HexSphereCell cell) noexcept; + void validateRivers(HexSphereCell cell) noexcept; + /** + * @brief Mutable cell record for an in-place edit. + * @ownership Borrowed; the map owns the record. + * @lifetime Valid until this map is destroyed or `reset` is called. + */ + HexCellData* mutableCell(HexSphereCell cell) noexcept; + + HexSphereTopology topology_{}; + HexNoise noise_{}; + std::vector cells_; + std::vector cellDirty_; + std::vector dirtyQueue_; + std::size_t dirtyHead_ = 0; + std::uint64_t revision_ = 0; + float sphereRadius_ = 0.f; + float elevationStep_ = 0.f; +}; + +} // namespace eve::hexmap diff --git a/src/modules/hexmap/HexSphereMesh.cpp b/src/modules/hexmap/HexSphereMesh.cpp new file mode 100644 index 00000000..ba235c69 --- /dev/null +++ b/src/modules/hexmap/HexSphereMesh.cpp @@ -0,0 +1,705 @@ +#include "hexmap/HexSphereMesh.h" + +#include "hexmap/HexMapMesh.h" +#include "hexmap/HexMetrics.h" +#include "hexmap/HexNoise.h" + +#include +#include +#include +#include +#include + +namespace eve::hexmap { + +namespace { + +/** + * @brief Perturbation contract of this translation unit. + * + * `SphereMesher::vertex` is the single place a position is displaced, so no position + * is displaced twice. Unlike the planar mesher, whose vertical noise term is folded + * into `HexMap::cellPosition`, every position here is built from a topological + * direction and a radius only, and the perturbation is purely tangential. It is a + * pure function of the unperturbed position, which is what keeps the shared corners + * of a cell's two adjacent edges - and of the three cells meeting at a corner - in + * exact agreement, so the surface stays closed. + */ + +[[nodiscard]] float dotOf(HexVec3 a, HexVec3 b) noexcept { return a.x * b.x + a.y * b.y + a.z * b.z; } + +[[nodiscard]] float lengthOf(HexVec3 v) noexcept { return std::sqrt(dotOf(v, v)); } + +[[nodiscard]] HexVec3 crossOf(HexVec3 a, HexVec3 b) noexcept { + return HexVec3{a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x}; +} + +[[nodiscard]] HexVec3 normalize(HexVec3 v) noexcept { + const float len = lengthOf(v); + if (!(len > 1e-9f)) return HexVec3{0.f, 1.f, 0.f}; + const float inverse = 1.f / len; + return HexVec3{v.x * inverse, v.y * inverse, v.z * inverse}; +} + +/** @brief Interpolates two **unit** directions along their great circle. */ +[[nodiscard]] HexVec3 slerpDirection(HexVec3 a, HexVec3 b, float t) noexcept { + const float cosine = std::clamp(dotOf(a, b), -1.f, 1.f); + const float angle = std::acos(cosine); + if (angle < 1e-5f) { + return normalize(HexVec3{a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t}); + } + const float sine = std::sin(angle); + const float wa = std::sin((1.f - t) * angle) / sine; + const float wb = std::sin(t * angle) / sine; + return HexVec3{a.x * wa + b.x * wb, a.y * wa + b.y * wb, a.z * wa + b.z * wb}; +} + +/** + * @brief Five samples along the arc from `a` to `b` with the radius interpolated linearly. + * + * The spherical replacement for `EdgeVertices(c1, c2)`, which interpolates in a + * straight line in XZ. On a sphere the surface path between two points of an edge is + * an arc, so the direction is slerped while the radius stays linear. + */ +[[nodiscard]] EdgeVertices sphereEdge(HexVec3 a, HexVec3 b) noexcept { + const HexVec3 dirA = normalize(a); + const HexVec3 dirB = normalize(b); + const float ra = lengthOf(a); + const float rb = lengthOf(b); + + EdgeVertices edge; + HexVec3* samples[5] = {&edge.v1, &edge.v2, &edge.v3, &edge.v4, &edge.v5}; + for (std::int32_t i = 0; i < 5; ++i) { + const float t = static_cast(i) * 0.25f; + const float radius = ra + (rb - ra) * t; + *samples[i] = slerpDirection(dirA, dirB, t) * radius; + } + return edge; +} + +/** + * @brief Terraced interpolation of two positions. + * + * The planar `HexMetrics::terraceLerp` moves horizontally in XZ by the terrace step + * and vertically in Y by its paired step. On a sphere "horizontal" is the surface + * arc and "vertical" is the radius, so the direction is slerped by the horizontal + * step and the radius is blended by the vertical one. + */ +[[nodiscard]] HexVec3 sphereTerraceLerp(HexVec3 a, HexVec3 b, std::int32_t step) noexcept { + const float horizontal = static_cast(step) * HexMetrics::kHorizontalTerraceStepSize; + const float vertical = static_cast((step + 1) / 2) * HexMetrics::kVerticalTerraceStepSize; + const HexVec3 direction = slerpDirection(normalize(a), normalize(b), horizontal); + const float ra = lengthOf(a); + const float rb = lengthOf(b); + return direction * (ra + (rb - ra) * vertical); +} + +/** @brief `sphereTerraceLerp` across all five samples of an edge. */ +[[nodiscard]] EdgeVertices sphereEdgeTerraceLerp(const EdgeVertices& a, const EdgeVertices& b, + std::int32_t step) noexcept { + EdgeVertices result; + result.v1 = sphereTerraceLerp(a.v1, b.v1, step); + result.v2 = sphereTerraceLerp(a.v2, b.v2, step); + result.v3 = sphereTerraceLerp(a.v3, b.v3, step); + result.v4 = sphereTerraceLerp(a.v4, b.v4, step); + result.v5 = sphereTerraceLerp(a.v5, b.v5, step); + return result; +} + +/** + * @brief Tangential-only perturbation, `±strength` along two tangent axes. + * + * The planar builder displaces XZ and leaves Y alone; on a sphere the corresponding + * "do not move in or out" rule means displacing inside the tangent plane at the + * vertex. Both tangent axes are driven by the noise field, so the wobble stays + * isotropic instead of collapsing at the poles. + * + * The displaced point is projected back onto its own radius. A raw tangent offset + * would change the radius by `O(d^2 / r)`, which is not negligible here: at planet + * scale the perturbation is a sizeable fraction of a cell, and a radius that drifts + * would lift and lower the relief independently of the cell's elevation. + */ +[[nodiscard]] HexVec3 tangentPerturb(const HexNoise& noise, HexVec3 position, float strength) noexcept { + const float radius = lengthOf(position); + if (!(radius > 1e-6f)) return position; + const HexVec3 up = position * (1.f / radius); + // A tangent frame that stays well conditioned over the whole sphere: the world axis + // least aligned with `up` is never parallel to it. + const HexVec3 axis = std::fabs(up.y) < 0.9f ? HexVec3{0.f, 1.f, 0.f} : HexVec3{1.f, 0.f, 0.f}; + const HexVec3 tangentX = normalize(crossOf(axis, up)); + const HexVec3 tangentY = crossOf(up, tangentX); + + const HexVec4 first = noise.sample(position.x, position.z); + const HexVec4 second = noise.sample(position.y + 37.f, position.x - 11.f); + const float alongX = (first.x * 2.f - 1.f) * strength; + const float alongY = (second.z * 2.f - 1.f) * strength; + return normalize(position + tangentX * alongX + tangentY * alongY) * radius; +} + +/** @brief Terrain palette index of a cell, or `0` when the cell is absent. */ +[[nodiscard]] std::int32_t terrainOf(const HexCellData* cell) noexcept { + return cell == nullptr ? 0 : cell->values.terrainType(); +} + +/** @brief One of the five samples of an edge. */ +[[nodiscard]] HexVec3 sampleAt(const EdgeVertices& edge, std::int32_t index) noexcept { + switch (index) { + case 0: return edge.v1; + case 1: return edge.v2; + case 2: return edge.v3; + case 3: return edge.v4; + default: return edge.v5; + } +} + +/** @brief Elevation of a cell, or `0` when the cell is absent. */ +[[nodiscard]] std::int32_t elevationOf(const HexCellData* cell) noexcept { + return cell == nullptr ? 0 : cell->values.elevation(); +} + +/** @brief The three cells meeting at a corner, kept together while they are sorted by elevation. */ +struct CornerCells { + HexVec3 up{}; + HexVec3 left{}; + HexVec3 right{}; + const HexCellData* upCell = nullptr; + const HexCellData* leftCell = nullptr; + const HexCellData* rightCell = nullptr; +}; + +/** @brief Emits the ground fans, blend strips, terraces and cliffs of one sphere. */ +class SphereMesher { +public: + SphereMesher(const HexSphereMap& map, HexMeshData& out) noexcept + : map_(map), out_(out), + // The planar mesher perturbs by a fixed 40% of a cell's outer radius. The + // same fraction of the mean cell spacing keeps the wobble scale-invariant + // across subdivision levels and radii, which a fixed world-unit figure + // would not be. + perturbStrength_((HexMetrics::kCellPerturbStrength / HexMetrics::kOuterRadius) * map.cellSpacing()), + // River beds drop by the planar stream-bed offset expressed in elevation + // steps, so the channel keeps the same depth relative to the relief. + streamBedDrop_((HexMetrics::kStreamBedElevationOffset / HexMetrics::kElevationStep) * map.elevationStep()) {} + + /** @brief Builds every cell of the sphere. */ + void run() { + for (HexSphereCell cell = 0; cell < map_.cellCount(); ++cell) buildCell(cell); + } + +private: + // --- edge frames -------------------------------------------------------- + + /** @brief Solid corner `corner` of `cell`: its topological corner pulled towards the centre. */ + [[nodiscard]] HexVec3 solidCornerOf(HexSphereCell cell, std::int32_t corner) const noexcept { + const HexVec3 mixed = + slerpDirection(map_.direction(cell), map_.cornerDirection(cell, corner), HexMetrics::kSolidFactor); + return mixed * map_.surfaceRadius(cell); + } + + /** @brief Solid border of edge `d` of `cell`, from its corner `d` to its corner `d + 1`. */ + [[nodiscard]] EdgeVertices solidEdgeOf(HexSphereCell cell, std::int32_t d) const noexcept { + const std::int32_t edges = map_.neighborCount(cell); + const std::int32_t nextD = (d + 1) % edges; + return sphereEdge(solidCornerOf(cell, d), solidCornerOf(cell, nextD)); + } + + /** + * @brief Solid border of the same shared edge seen from `neighbour`, wound to match. + * + * The neighbour traverses the shared edge in the opposite corner order, so its v1 + * would sit at this cell's corner `d + 1`; the two ends are swapped back so that + * `near.v1` pairs with `far.v1` across the strip and the quad does not twist. + */ + [[nodiscard]] EdgeVertices farEdgeOf(HexSphereCell cell, std::int32_t d, HexSphereCell neighbour) const noexcept { + const std::int32_t back = map_.oppositeDirection(cell, d); + const std::int32_t nEdges = map_.neighborCount(neighbour); + if (back < 0 || nEdges <= 0) return EdgeVertices{}; + return sphereEdge(solidCornerOf(neighbour, (back + 1) % nEdges), solidCornerOf(neighbour, back)); + } + + /** @brief Whether a river crosses the edge shared with `d`'s neighbour. */ + [[nodiscard]] bool riverThrough(HexSphereCell cell, std::int32_t d, const HexCellData* cellData) const noexcept { + if (cellData != nullptr && cellData->flags.hasRiverThrough(static_cast(d))) return true; + const HexSphereCell neighbour = map_.neighbor(cell, d); + if (neighbour == kNoHexSphereCell) return false; + const HexCellData* other = map_.cellAt(neighbour); + const std::int32_t back = map_.oppositeDirection(cell, d); + return other != nullptr && back >= 0 && other->flags.hasRiverThrough(static_cast(back)); + } + + // --- per-cell entry point ----------------------------------------------- + + /** @brief Emits the fans of one cell plus the connection strips it owns. */ + void buildCell(HexSphereCell cell) { + const HexCellData* cellData = map_.cellAt(cell); + if (cellData == nullptr) return; + + const HexVec3 center = map_.cellPosition(cell); + const std::int32_t edges = map_.neighborCount(cell); + for (std::int32_t d = 0; d < edges; ++d) { + EdgeVertices near = solidEdgeOf(cell, d); + if (riverThrough(cell, d, cellData)) { + // Carve a shallow channel along the river centreline (the middle sample only). + near.v3 = normalize(near.v3) * (lengthOf(near.v3) + streamBedDrop_); + } + appendEdgeFan(center, near, cellData); + + const HexSphereCell neighbour = map_.neighbor(cell, d); + if (neighbour == kNoHexSphereCell) continue; + const HexCellData* neighbourData = map_.cellAt(neighbour); + if (neighbourData == nullptr) continue; + appendConnection(cell, d, near, cellData, neighbour, neighbourData); + } + } + + /** @brief Emits the strip/terrace/cliff towards one neighbour and the corner it closes. */ + void appendConnection(HexSphereCell cell, std::int32_t d, const EdgeVertices& near, const HexCellData* cellData, + HexSphereCell neighbour, const HexCellData* neighbourData) { + // Edge ownership rule. The planar mesher leans on the chunk grid here; a sphere + // has none, so the lower cell id owns the edge instead. That is a total order + // over the same set of shared edges, so every boundary is still emitted exactly + // once, and never by both of its cells. + if (cell > neighbour) return; + + const EdgeVertices far = farEdgeOf(cell, d, neighbour); + + const HexTerrainWeights nearWeights = HexTerrainWeights::primary(); + const HexTerrainWeights farWeights = HexTerrainWeights::blend(1.f); + + switch (map_.edgeTypeTo(cell, neighbour)) { + case HexEdgeType::Flat: + appendBlendStrip(near, far, nearWeights, farWeights, cellData, neighbourData); + break; + case HexEdgeType::Slope: + appendEdgeTerraces(near, nearWeights, far, farWeights, cellData, neighbourData); + break; + case HexEdgeType::Cliff: + // The blend strip would already be a radial wall here, but its winding + // flips with the sign of the elevation step; the vertical winding is + // correct in both directions. Split into the same four sub-quads as a + // flat strip so the wall does not leave a T-junction against the fan. + for (std::int32_t i = 0; i < 4; ++i) { + emitQuadVertical(sampleAt(near, i), sampleAt(near, i + 1), sampleAt(far, i), sampleAt(far, i + 1), + nearWeights, nearWeights, farWeights, farWeights, cellData, neighbourData, + cellData); + } + break; + } + + appendCorner(cell, d, cellData, neighbour, neighbourData); + } + + // --- corner ------------------------------------------------------------- + + /** + * @brief Emits the triangle closing the gap between three cells sharing a corner. + * + * The three cells meet at one topological corner, so each of their solid corners + * lies on the same unit direction and differs only by the cell's own surface + * radius. That is what makes the patch exact: the three solid corners are pulled + * towards three different cell centres, and the shared corner direction is the one + * thing they have in common. + */ + void appendCorner(HexSphereCell cell, std::int32_t d, const HexCellData* cellData, HexSphereCell neighbour, + const HexCellData* neighbourData) { + const std::int32_t edges = map_.neighborCount(cell); + const std::int32_t nextD = (d + 1) % edges; + const HexSphereCell nextCell = map_.neighbor(cell, nextD); + if (nextCell == kNoHexSphereCell) return; + const HexCellData* nextData = map_.cellAt(nextCell); + if (nextData == nullptr) return; + + // The corner between edge `d` and edge `d + 1` of `cell` is corner `d + 1`. Each + // neighbour reaches that same corner from the far end of its own shared edge, + // which the topology stores in the opposite order. + const std::int32_t back = map_.oppositeDirection(cell, d); + const std::int32_t back2 = map_.oppositeDirection(cell, nextD); + const std::int32_t nextEdges = map_.neighborCount(nextCell); + if (back < 0 || back2 < 0 || nextEdges <= 0) return; + + CornerCells corner{}; + corner.up = solidCornerOf(cell, nextD); + corner.right = solidCornerOf(neighbour, back); + corner.left = solidCornerOf(nextCell, (back2 + 1) % nextEdges); + corner.upCell = cellData; + corner.rightCell = neighbourData; + corner.leftCell = nextData; + appendCornerTriangles(corner); + } + + /** @brief Sorts the three corner cells by elevation and dispatches on the two edge types. */ + void appendCornerTriangles(const CornerCells& corner) { + const std::array positions{corner.up, corner.left, corner.right}; + const std::array cells{corner.upCell, corner.leftCell, corner.rightCell}; + std::array order{0, 1, 2}; + for (std::int32_t i = 0; i < 2; ++i) { + for (std::int32_t j = i + 1; j < 3; ++j) { + if (elevationOf(cells[static_cast(order[static_cast(j)])]) < + elevationOf(cells[static_cast(order[static_cast(i)])])) { + std::swap(order[static_cast(i)], order[static_cast(j)]); + } + } + } + + const HexVec3 bottom = positions[static_cast(order[0])]; + const HexVec3 low = positions[static_cast(order[1])]; + const HexVec3 high = positions[static_cast(order[2])]; + const HexCellData* bottomCell = cells[static_cast(order[0])]; + const HexCellData* lowCell = cells[static_cast(order[1])]; + const HexCellData* highCell = cells[static_cast(order[2])]; + + const HexEdgeType lowEdge = edgeType(elevationOf(bottomCell), elevationOf(lowCell)); + const HexEdgeType highEdge = edgeType(elevationOf(bottomCell), elevationOf(highCell)); + + if (lowEdge == HexEdgeType::Slope && highEdge == HexEdgeType::Slope) { + cornerTerraces(bottom, low, high, bottomCell, lowCell, highCell); + } else if (lowEdge == HexEdgeType::Slope && highEdge == HexEdgeType::Flat) { + cornerTerraces(low, high, bottom, lowCell, highCell, bottomCell); + } else if (lowEdge == HexEdgeType::Flat && highEdge == HexEdgeType::Slope) { + cornerTerraces(high, bottom, low, highCell, bottomCell, lowCell); + } else if (lowEdge == HexEdgeType::Slope && highEdge == HexEdgeType::Cliff) { + cornerTerracesCliff(bottom, low, high, bottomCell, lowCell, highCell, lowEdge); + } else if (lowEdge == HexEdgeType::Cliff && highEdge == HexEdgeType::Slope) { + cornerCliffTerraces(bottom, low, high, bottomCell, lowCell, highCell, highEdge); + } else if (edgeType(elevationOf(lowCell), elevationOf(highCell)) == HexEdgeType::Slope) { + if (elevationOf(lowCell) < elevationOf(highCell)) { + cornerCliffTerraces(high, bottom, low, highCell, bottomCell, lowCell, highEdge); + } else { + cornerTerracesCliff(low, high, bottom, lowCell, highCell, bottomCell, lowEdge); + } + } else { + // Mirror of the reference winding: this engine's front face is the opposite + // handedness, so the flat corner fan is emitted high-before-low. + emitTriangle(bottom, high, low, HexTerrainWeights::primary(), bottomCell, lowCell, highCell); + } + } + + /** @brief Five-step terraced fan across a corner whose both sides of `bottom` are slopes. */ + void cornerTerraces(const HexVec3& bottom, const HexVec3& left, const HexVec3& right, const HexCellData* bottomCell, + const HexCellData* leftCell, const HexCellData* rightCell) { + const HexTerrainWeights bottomWeights = HexTerrainWeights::primary(); + const HexTerrainWeights leftWeights = HexTerrainWeights::primary(); + const HexTerrainWeights rightWeights = HexTerrainWeights::primary(); + + HexVec3 lastLeft = bottom; + HexVec3 lastRight = bottom; + HexTerrainWeights lastLeftWeights = bottomWeights; + HexTerrainWeights lastRightWeights = bottomWeights; + for (std::int32_t step = 1; step < HexMetrics::kTerracesPerSlope * 2; ++step) { + const float t = static_cast(step) * HexMetrics::kHorizontalTerraceStepSize; + const HexTerrainWeights wl = HexTerrainWeights::lerp(bottomWeights, leftWeights, t); + const HexTerrainWeights wr = HexTerrainWeights::lerp(bottomWeights, rightWeights, t); + const HexVec3 boundaryLeft = sphereTerraceLerp(bottom, left, step); + const HexVec3 boundaryRight = sphereTerraceLerp(bottom, right, step); + + emitQuadForward(boundaryLeft, boundaryRight, lastLeft, lastRight, wl, wr, lastLeftWeights, lastRightWeights, + bottomCell, leftCell, rightCell); + lastLeft = boundaryLeft; + lastRight = boundaryRight; + lastLeftWeights = wl; + lastRightWeights = wr; + } + emitQuadForward(lastLeft, lastRight, left, right, lastLeftWeights, lastRightWeights, leftWeights, rightWeights, + bottomCell, leftCell, rightCell); + } + + /** + * @brief Fills the sliver whose `left`/`right` sides are constant-weight edges. + * + * One triangle per terrace step walks from `bottom` to `right`; the corner's third cell vanishes at + * `bottom`, so the band ends flush with the cliff foot. + */ + void appendBoundaryTriangle(HexVec3 bottom, const HexTerrainWeights& bottomWeights, HexVec3 left, + const HexTerrainWeights& leftWeights, HexVec3 right, + const HexTerrainWeights& rightWeights, const HexCellData* bottomCell, + const HexCellData* leftCell, const HexCellData* rightCell) { + HexVec3 leftSide = sphereTerraceLerp(bottom, left, 1); + HexVec3 rightSide = sphereTerraceLerp(bottom, right, 1); + for (std::int32_t step = 1; step < HexMetrics::kTerracesPerSlope * 2; ++step) { + const float t = static_cast(step) * HexMetrics::kHorizontalTerraceStepSize; + const HexTerrainWeights wl = HexTerrainWeights::lerp(bottomWeights, leftWeights, t); + const HexTerrainWeights wr = HexTerrainWeights::lerp(bottomWeights, rightWeights, t); + + emitTriangle(leftSide, rightSide, bottom, wl, wr, bottomWeights, bottomCell, leftCell, rightCell); + + leftSide = rightSide; + rightSide = sphereTerraceLerp(bottom, right, step + 1); + } + emitTriangle(leftSide, rightSide, bottom, leftWeights, rightWeights, bottomWeights, bottomCell, leftCell, + rightCell); + } + + /** @brief Terraced corner where the `high` side rises to a cliff foot. */ + void cornerTerracesCliff(const HexVec3& bottom, const HexVec3& low, const HexVec3& high, + const HexCellData* bottomCell, const HexCellData* lowCell, const HexCellData* highCell, + HexEdgeType lowEdge) { + const HexTerrainWeights bottomWeights = HexTerrainWeights::primary(); + const HexTerrainWeights lowWeights = HexTerrainWeights::primary(); + const HexTerrainWeights highWeights = HexTerrainWeights::primary(); + + const float span = static_cast(elevationOf(highCell) - elevationOf(bottomCell)); + const float blend = span > 1e-6f ? 1.f / span : 1.f; + const HexVec3 boundary = lerp(bottom, high, blend); + const HexTerrainWeights boundaryWeights = HexTerrainWeights::lerp(bottomWeights, highWeights, blend); + + const std::uint32_t anchor = static_cast(out_.vertexCount()); + vertex(boundary, boundaryWeights, bottomCell, lowCell, highCell); + appendBoundaryTriangle(bottom, bottomWeights, low, lowWeights, boundary, boundaryWeights, bottomCell, lowCell, + highCell); + emitTriangleFrom(anchor, low, bottom, lowWeights, bottomWeights, bottomCell, lowCell, highCell); + if (lowEdge == HexEdgeType::Slope) { + appendBoundaryTriangle(boundary, boundaryWeights, bottom, bottomWeights, high, highWeights, bottomCell, + lowCell, highCell); + } else { + emitTriangle(high, bottom, low, highWeights, bottomWeights, lowWeights, bottomCell, lowCell, highCell); + } + } + + /** @brief Terraced corner where the `low` side rises to a cliff foot (mirror of the above). */ + void cornerCliffTerraces(const HexVec3& bottom, const HexVec3& low, const HexVec3& high, + const HexCellData* bottomCell, const HexCellData* lowCell, const HexCellData* highCell, + HexEdgeType highEdge) { + const HexTerrainWeights bottomWeights = HexTerrainWeights::primary(); + const HexTerrainWeights lowWeights = HexTerrainWeights::primary(); + const HexTerrainWeights highWeights = HexTerrainWeights::primary(); + + const float span = static_cast(elevationOf(lowCell) - elevationOf(bottomCell)); + const float blend = span > 1e-6f ? 1.f / span : 1.f; + const HexVec3 boundary = lerp(bottom, low, blend); + const HexTerrainWeights boundaryWeights = HexTerrainWeights::lerp(bottomWeights, lowWeights, blend); + + const std::uint32_t anchor = static_cast(out_.vertexCount()); + vertex(boundary, boundaryWeights, bottomCell, lowCell, highCell); + appendBoundaryTriangle(bottom, bottomWeights, high, highWeights, boundary, boundaryWeights, bottomCell, + highCell, lowCell); + emitTriangleFrom(anchor, high, bottom, highWeights, bottomWeights, bottomCell, highCell, lowCell); + if (highEdge == HexEdgeType::Slope) { + appendBoundaryTriangle(boundary, boundaryWeights, bottom, bottomWeights, low, lowWeights, bottomCell, + highCell, lowCell); + } else { + emitTriangle(low, bottom, high, lowWeights, bottomWeights, highWeights, bottomCell, highCell, lowCell); + } + } + + // --- vertex and triangle primitives ------------------------------------- + + /** @brief Emits one vertex; the single perturbation point of this file. */ + void vertex(HexVec3 position, const HexTerrainWeights& weights, const HexCellData* t0, const HexCellData* t1, + const HexCellData* t2) { + const float u = HexTerrainVertexCode::encodeIndices(terrainOf(t0), terrainOf(t1), terrainOf(t2)); + const float v = HexTerrainVertexCode::encodeWeights(weights.b, weights.c); + out_.addVertex(tangentPerturb(map_.noise(), position, perturbStrength_), u, v); + } + + /** @brief Emits a triangle whose three vertices share one weight set and one cell triple. */ + void emitTriangle(const HexVec3& p0, const HexVec3& p1, const HexVec3& p2, const HexTerrainWeights& w, + const HexCellData* t0, const HexCellData* t1, const HexCellData* t2) { + const std::uint32_t i0 = static_cast(out_.vertexCount()); + vertex(p0, w, t0, t1, t2); + vertex(p1, w, t0, t1, t2); + vertex(p2, w, t0, t1, t2); + out_.addTriangle(i0, i0 + 1u, i0 + 2u); + } + + /** @brief Emits a triangle with one weight set per vertex. */ + void emitTriangle(const HexVec3& p0, const HexVec3& p1, const HexVec3& p2, const HexTerrainWeights& w0, + const HexTerrainWeights& w1, const HexTerrainWeights& w2, const HexCellData* t0, + const HexCellData* t1, const HexCellData* t2) { + const std::uint32_t i0 = static_cast(out_.vertexCount()); + vertex(p0, w0, t0, t1, t2); + vertex(p1, w1, t0, t1, t2); + vertex(p2, w2, t0, t1, t2); + out_.addTriangle(i0, i0 + 1u, i0 + 2u); + } + + /** + * @brief Emits a surface quad `(a, b, c, d)` as `(a, c, b)` and `(b, c, d)`. + * + * The argument order is the `a -> b -> d -> c` ring used by `HexMeshData::addQuad`. + */ + void emitQuadForward(const HexVec3& p0, const HexVec3& p1, const HexVec3& p2, const HexVec3& p3, + const HexTerrainWeights& w0, const HexTerrainWeights& w1, const HexTerrainWeights& w2, + const HexTerrainWeights& w3, const HexCellData* t0, const HexCellData* t1, + const HexCellData* t2) { + const std::uint32_t i0 = static_cast(out_.vertexCount()); + vertex(p0, w0, t0, t1, t2); + vertex(p1, w1, t0, t1, t2); + vertex(p2, w2, t0, t1, t2); + vertex(p3, w3, t0, t1, t2); + out_.addTriangle(i0, i0 + 2u, i0 + 1u); + out_.addTriangle(i0 + 1u, i0 + 2u, i0 + 3u); + } + + /** @brief Emits a radial quad in the bottom/top alternating order used by cliff strips. */ + void emitQuadVertical(const HexVec3& p0, const HexVec3& p1, const HexVec3& p2, const HexVec3& p3, + const HexTerrainWeights& w0, const HexTerrainWeights& w1, const HexTerrainWeights& w2, + const HexTerrainWeights& w3, const HexCellData* t0, const HexCellData* t1, + const HexCellData* t2) { + const std::uint32_t i0 = static_cast(out_.vertexCount()); + vertex(p0, w0, t0, t1, t2); + vertex(p1, w1, t0, t1, t2); + vertex(p2, w2, t0, t1, t2); + vertex(p3, w3, t0, t1, t2); + out_.addTriangle(i0, i0 + 2u, i0 + 1u); + out_.addTriangle(i0 + 1u, i0 + 2u, i0 + 3u); + } + + /** @brief Emits a triangle that reuses an already emitted anchor vertex as its first corner. */ + void emitTriangleFrom(std::uint32_t anchor, const HexVec3& p1, const HexVec3& p2, const HexTerrainWeights& w1, + const HexTerrainWeights& w2, const HexCellData* t0, const HexCellData* t1, + const HexCellData* t2) { + const std::uint32_t i1 = static_cast(out_.vertexCount()); + vertex(p1, w1, t0, t1, t2); + vertex(p2, w2, t0, t1, t2); + out_.addTriangle(anchor, i1, i1 + 1u); + } + + /** @brief Four triangles fanning from `center` along one solid edge. */ + void appendEdgeFan(HexVec3 center, const EdgeVertices& edge, const HexCellData* cellData) { + const HexTerrainWeights w = HexTerrainWeights::primary(); + const HexVec3 samples[5] = {edge.v1, edge.v2, edge.v3, edge.v4, edge.v5}; + for (std::int32_t i = 0; i < 4; ++i) { + emitTriangle(center, samples[i], samples[i + 1], w, cellData, cellData, cellData); + } + } + + /** @brief Four quads bridging `near` to `far`, blending the two cells' terrain types. */ + void appendBlendStrip(const EdgeVertices& near, const EdgeVertices& far, const HexTerrainWeights& nearWeights, + const HexTerrainWeights& farWeights, const HexCellData* nearCell, + const HexCellData* farCell) { + const HexVec3 nearPositions[5] = {near.v1, near.v2, near.v3, near.v4, near.v5}; + const HexVec3 farPositions[5] = {far.v1, far.v2, far.v3, far.v4, far.v5}; + for (std::int32_t i = 0; i < 4; ++i) { + const float t0 = static_cast(i) * 0.25f; + const float t1 = static_cast(i + 1) * 0.25f; + const HexTerrainWeights w0 = HexTerrainWeights::lerp(nearWeights, farWeights, t0); + const HexTerrainWeights w1 = HexTerrainWeights::lerp(nearWeights, farWeights, t1); + // Both vertices of an edge share their parameter: the near pair carries w0 + // and the far pair carries w1. + emitQuadForward(nearPositions[i], nearPositions[i + 1], farPositions[i], farPositions[i + 1], w0, w0, w1, + w1, nearCell, farCell, nearCell); + } + } + + /** @brief Terraces a slope between two edges; one band per terrace step plus the closing band. */ + void appendEdgeTerraces(EdgeVertices near, const HexTerrainWeights& nearWeights, EdgeVertices far, + const HexTerrainWeights& farWeights, const HexCellData* nearCell, + const HexCellData* farCell) { + for (std::int32_t step = 1; step < HexMetrics::kTerracesPerSlope * 2; ++step) { + const HexTerrainWeights w1 = HexTerrainWeights::lerp( + nearWeights, farWeights, static_cast(step) * HexMetrics::kHorizontalTerraceStepSize); + const EdgeVertices middle = sphereEdgeTerraceLerp(near, far, step); + appendEdgeBand(near, middle, nearWeights, w1, nearCell, farCell); + near = middle; + } + appendEdgeBand(near, far, nearWeights, farWeights, nearCell, farCell); + } + + /** + * @brief Bridges two parallel edges that share their five samples. + * + * The planar builder spans a terrace band with the single chord `v1 -> v5`, which is + * safe there only because its five samples are collinear. On a sphere the samples sit + * on an arc, so a chord would leave a T-junction against the four-segment boundary of + * the cell fan and open a crack along every terraced or vertical edge. The band is + * therefore split exactly the way the flat blend strip and the fan are. + */ + void appendEdgeBand(const EdgeVertices& from, const EdgeVertices& to, const HexTerrainWeights& fromWeights, + const HexTerrainWeights& toWeights, const HexCellData* nearCell, const HexCellData* farCell) { + for (std::int32_t i = 0; i < 4; ++i) { + emitQuadForward(sampleAt(from, i), sampleAt(from, i + 1), sampleAt(to, i), sampleAt(to, i + 1), + fromWeights, fromWeights, toWeights, toWeights, nearCell, farCell, nearCell); + } + } + + const HexSphereMap& map_; + HexMeshData& out_; + float perturbStrength_; + float streamBedDrop_; +}; + +/** @brief Emits one capped hexagonal sheet per flooded cell. */ +class SphereWaterMesher { +public: + SphereWaterMesher(const HexSphereMap& map, HexMeshData& out) noexcept : map_(map), out_(out) {} + + /** @brief Builds every flooded cell of the sphere. */ + void run() { + for (HexSphereCell cell = 0; cell < map_.cellCount(); ++cell) buildCell(cell); + } + +private: + /** + * @brief Radius of the water surface over a cell. + * + * The planar `kWaterElevationOffset` is expressed in world units; on a sphere it + * is converted to the same fraction of one elevation step the planar constant + * already has, so the surface sits just under its own level at every radius. + */ + [[nodiscard]] float waterRadius(std::int32_t waterLevel) const noexcept { + const float offset = HexMetrics::kWaterElevationOffset / HexMetrics::kElevationStep; + return map_.sphereRadius() + (static_cast(waterLevel) + offset) * map_.elevationStep(); + } + + void buildCell(HexSphereCell cell) { + const HexCellData* data = map_.cellAt(cell); + if (data == nullptr || !data->values.isUnderwater()) return; + + // The shore parameter the water shader expects: 1 where the water ends against + // land, 0 in open ocean. + float shore = 0.f; + const std::int32_t edges = map_.neighborCount(cell); + for (std::int32_t d = 0; d < edges; ++d) { + const HexCellData* neighbour = map_.cellAt(map_.neighbor(cell, d)); + if (neighbour == nullptr || !neighbour->values.isUnderwater()) { + shore = 1.f; + break; + } + } + + const float radius = waterRadius(data->values.waterLevel()); + const HexVec3 center = map_.direction(cell) * radius; + const std::int32_t corners = map_.cornerCountOf(cell); + // Same winding as the terrain fan: centre, then the corners in increasing index. + for (std::int32_t k = 0; k < corners; ++k) { + const HexVec3 a = map_.cornerDirection(cell, k) * radius; + const HexVec3 b = map_.cornerDirection(cell, (k + 1) % corners) * radius; + const std::uint32_t i0 = static_cast(out_.vertexCount()); + out_.addVertex(center, shore, 0.f); + out_.addVertex(a, shore, 0.f); + out_.addVertex(b, shore, 0.f); + out_.addTriangle(i0, i0 + 1u, i0 + 2u); + } + } + + const HexSphereMap& map_; + HexMeshData& out_; +}; + +} // namespace + +void buildSphereTerrainMesh(const HexSphereMap& map, HexMeshData& out) { + out.clear(); + if (map.empty()) { + out.finalize(); + return; + } + + SphereMesher mesher(map, out); + mesher.run(); + out.finalize(); +} + +void buildSphereWaterMesh(const HexSphereMap& map, HexMeshData& out) { + out.clear(); + if (map.empty()) { + out.finalize(); + return; + } + + SphereWaterMesher mesher(map, out); + mesher.run(); + out.finalize(); +} + +} // namespace eve::hexmap diff --git a/src/modules/hexmap/HexSphereMesh.h b/src/modules/hexmap/HexSphereMesh.h new file mode 100644 index 00000000..13cbe04c --- /dev/null +++ b/src/modules/hexmap/HexSphereMesh.h @@ -0,0 +1,58 @@ +#pragma once + +/** @file HexSphereMesh.h @brief Mesh generation for the spherical hex map surface. */ + +#include "hexmap/HexMeshData.h" +#include "hexmap/HexSphereMap.h" + +namespace eve::hexmap { + +/** + * @brief Builds the ground, terrace and cliff surface of a spherical hex map. + * + * This is the spherical counterpart of `buildTerrainMesh`. The whole sphere is one + * mesh, because the topology gives every cell a global id and the surface has no + * chunk borders to hide a seam in. + * + * The construction is the same corner-matrix scheme the planar builder uses, with + * the three planar assumptions replaced: + * + * - **Edge count.** A cell has five edges if it is pentagonal and six otherwise, so + * nothing iterates a fixed six directions. + * - **Edge ownership.** The planar builder leans on the chunk grid (`direction <= SE`) + * to emit every shared boundary exactly once. A sphere has no chunk grid, so the + * *lower cell id* owns the edge instead: the strip across `cell -> neighbour` is + * built only while `cell < neighbour`. The rule is a total order on the same set of + * edges, so it still emits each shared boundary exactly once. + * - **"Vertical" is radial.** Positions are built as a unit direction times a radius, + * and the terrace interpolation of a slope moves *along the surface* by the + * horizontal step and *outward* by the vertical step, instead of moving in XZ and Y. + * + * All three terrain layers are encoded exactly as the planar builder encodes them + * (see `HexTerrainVertexCode`), so `mesh3d`'s terrain shader consumes this mesh + * unchanged. + * + * @param map Source map. An empty map produces an empty mesh. + * @param out Destination mesh; cleared and finalized by this call. + */ +void buildSphereTerrainMesh(const HexSphereMap& map, HexMeshData& out); + +/** + * @brief Builds the ocean surface of a spherical hex map. + * + * Only flooded cells contribute. Unlike the planar water stream there is no shore + * band: a flooded cell draws its whole hexagonal cap at its water radius, so two + * neighbouring flooded cells share their corners exactly and the ocean is one + * closed sheet with a clean edge where it meets land. + * + * The texture coordinates follow the same contract as the planar water mesh: `u` is + * the shore parameter (`0` in open ocean, `1` where the water ends against land) + * and `v` is reserved. + * + * @param map Source map. An empty map, or one with no flooded cell, produces an + * empty mesh. + * @param out Destination mesh; cleared and finalized by this call. + */ +void buildSphereWaterMesh(const HexSphereMap& map, HexMeshData& out); + +} // namespace eve::hexmap diff --git a/src/modules/hexmap/HexSphereTopology.cpp b/src/modules/hexmap/HexSphereTopology.cpp new file mode 100644 index 00000000..774508e7 --- /dev/null +++ b/src/modules/hexmap/HexSphereTopology.cpp @@ -0,0 +1,372 @@ +#include "hexmap/HexSphereTopology.h" + +#include +#include +#include +#include +#include +#include + +namespace eve::hexmap { +namespace { + +using Vec = HexVec3; + +/** @brief Golden ratio, the edge/radius proportion of an icosahedron. */ +constexpr float kGoldenRatio = 1.618033988749895f; + +/** @brief Dot product of two direction vectors. */ +[[nodiscard]] constexpr float dot(Vec a, Vec b) noexcept { + return a.x * b.x + a.y * b.y + a.z * b.z; +} + +/** @brief Cross product of two direction vectors. */ +[[nodiscard]] constexpr Vec cross(Vec a, Vec b) noexcept { + return Vec{a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x}; +} + +/** @brief Euclidean length of a vector. */ +[[nodiscard]] float length(Vec a) noexcept { return std::sqrt(dot(a, a)); } + +/** + * @brief Unit vector in the direction of `a`. + * + * A degenerate input cannot occur for the construction below (every midpoint of + * two distinct unit vertices is non-zero), so the fallback only keeps the + * function total; it is not a recoverable geometry case. + */ +[[nodiscard]] Vec normalize(Vec a) noexcept { + const float len = length(a); + if (len <= 1e-20f) return Vec{0.f, 1.f, 0.f}; + return Vec{a.x / len, a.y / len, a.z / len}; +} + +/** @brief The twelve icosahedron vertices: `(0, +-1, +-phi)` and its cyclic permutations. */ +constexpr float kIcosahedronVertices[12][3] = { + {-1.f, kGoldenRatio, 0.f}, {1.f, kGoldenRatio, 0.f}, {-1.f, -kGoldenRatio, 0.f}, {1.f, -kGoldenRatio, 0.f}, + {0.f, -1.f, kGoldenRatio}, {0.f, 1.f, kGoldenRatio}, {0.f, -1.f, -kGoldenRatio}, {0.f, 1.f, -kGoldenRatio}, + {kGoldenRatio, 0.f, -1.f}, {kGoldenRatio, 0.f, 1.f}, {-kGoldenRatio, 0.f, -1.f}, {-kGoldenRatio, 0.f, 1.f}, +}; + +/** + * @brief The twenty icosahedron faces as vertex triples. + * + * The table carries no winding guarantee; `build` flips each triangle whose + * normal points inward, so the table can be transcribed from any source without + * having to be re-derived by hand. + */ +constexpr std::int32_t kIcosahedronFaces[20][3] = { + {0, 11, 5}, {0, 5, 1}, {0, 1, 7}, {0, 7, 10}, {0, 10, 11}, {1, 5, 9}, {5, 11, 4}, {11, 10, 2}, + {10, 7, 6}, {7, 1, 8}, {3, 9, 4}, {3, 4, 2}, {3, 2, 6}, {3, 6, 8}, {3, 8, 9}, {4, 9, 5}, + {2, 4, 11}, {6, 2, 10}, {8, 6, 7}, {9, 8, 1}, +}; + +/** @brief One oriented primal triangle (a geodesic-sphere face). */ +struct Triangle { + std::int32_t a = 0; + std::int32_t b = 0; + std::int32_t c = 0; +}; + +/** @brief Order-independent key of the undirected edge `(i, j)`. */ +[[nodiscard]] std::uint64_t edgeKey(std::int32_t i, std::int32_t j) noexcept { + const std::uint32_t low = static_cast(i < j ? i : j); + const std::uint32_t high = static_cast(i < j ? j : i); + return (static_cast(high) << 32u) | low; +} + +/** @brief The two primal faces carrying one edge. */ +struct EdgeFaces { + std::int32_t first = -1; + std::int32_t second = -1; +}; + +/** @brief A construction failure; every case is an internal-consistency error. */ +[[nodiscard]] Result sphereFailure(const char* message) { + return Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, message, "hexmap.sphere")); +} + +} // namespace + +Result HexSphereTopology::build(std::int32_t subdivision) { + if (subdivision < 0 || subdivision > kMaxHexSphereSubdivision) { + return sphereFailure("hex sphere subdivision out of range"); + } + + HexSphereTopology topology; + topology.subdivision_ = subdivision; + topology.frequency_ = std::int32_t{1} << subdivision; + + // --- primal polyhedron -------------------------------------------------- + std::vector vertices; + vertices.reserve(12u); + for (const auto& vertex : kIcosahedronVertices) vertices.push_back(normalize(Vec{vertex[0], vertex[1], vertex[2]})); + + std::vector triangles; + triangles.reserve(20u); + for (const auto& face : kIcosahedronFaces) triangles.push_back(Triangle{face[0], face[1], face[2]}); + for (Triangle& triangle : triangles) { + const Vec normal = cross(vertices[triangle.b] - vertices[triangle.a], vertices[triangle.c] - vertices[triangle.a]); + const Vec outward = vertices[triangle.a] + vertices[triangle.b] + vertices[triangle.c]; + if (dot(normal, outward) < 0.f) std::swap(triangle.b, triangle.c); + } + + // --- midpoint subdivision, frequency 2^subdivision ----------------------- + // + // Splitting every triangle into four and welding the shared midpoints by edge + // key keeps one vertex per physical point, so vertex ids are stable and the + // original twelve keep their ids 0..11 -- which is what makes the twelve + // pentagons addressable as cells [0, 12). + for (std::int32_t level = 0; level < subdivision; ++level) { + std::unordered_map midpoints; + midpoints.reserve(vertices.size() * 2u); + std::vector refined; + refined.reserve(triangles.size() * 4u); + + const auto midpoint = [&](std::int32_t i, std::int32_t j) { + const std::uint64_t key = edgeKey(i, j); + const auto found = midpoints.find(key); + if (found != midpoints.end()) return found->second; + const std::int32_t index = static_cast(vertices.size()); + vertices.push_back(normalize(vertices[i] + vertices[j])); + midpoints.emplace(key, index); + return index; + }; + + for (const Triangle& triangle : triangles) { + const std::int32_t ab = midpoint(triangle.a, triangle.b); + const std::int32_t bc = midpoint(triangle.b, triangle.c); + const std::int32_t ca = midpoint(triangle.c, triangle.a); + refined.push_back(Triangle{triangle.a, ab, ca}); + refined.push_back(Triangle{ab, triangle.b, bc}); + refined.push_back(Triangle{ca, bc, triangle.c}); + refined.push_back(Triangle{ab, bc, ca}); + } + triangles.swap(refined); + } + + const std::int32_t cellCount = static_cast(vertices.size()); + topology.directions_ = std::move(vertices); + + // --- dual corners: one per primal face ---------------------------------- + std::vector cornerDirections(triangles.size()); + for (std::size_t i = 0; i < triangles.size(); ++i) { + const Triangle& triangle = triangles[i]; + cornerDirections[i] = + normalize(topology.directions_[triangle.a] + topology.directions_[triangle.b] + topology.directions_[triangle.c]); + } + + // --- vertex -> incident face incidence, in CSR form ---------------------- + const std::int32_t faceCount = static_cast(triangles.size()); + std::vector incidenceCount(static_cast(cellCount), 0); + for (const Triangle& triangle : triangles) { + ++incidenceCount[triangle.a]; + ++incidenceCount[triangle.b]; + ++incidenceCount[triangle.c]; + } + std::vector incidenceOffset(static_cast(cellCount) + 1u, 0); + for (std::int32_t cell = 0; cell < cellCount; ++cell) { + incidenceOffset[cell + 1] = incidenceOffset[cell] + incidenceCount[cell]; + } + std::vector incidence(static_cast(incidenceOffset[cellCount]), -1); + { + std::vector cursor(incidenceOffset.begin(), incidenceOffset.end() - 1); + for (std::int32_t face = 0; face < faceCount; ++face) { + const Triangle& triangle = triangles[face]; + incidence[cursor[triangle.a]++] = face; + incidence[cursor[triangle.b]++] = face; + incidence[cursor[triangle.c]++] = face; + } + } + + // --- edge -> its one or two faces --------------------------------------- + std::unordered_map edgeFaces; + edgeFaces.reserve(triangles.size() * 3u); + for (std::int32_t face = 0; face < faceCount; ++face) { + const Triangle& triangle = triangles[face]; + const std::int32_t pairs[3][2] = {{triangle.a, triangle.b}, {triangle.b, triangle.c}, {triangle.c, triangle.a}}; + for (const auto& pair : pairs) { + EdgeFaces& entry = edgeFaces[edgeKey(pair[0], pair[1])]; + if (entry.first < 0) entry.first = face; + else if (entry.second < 0) entry.second = face; + } + } + + const auto slotOf = [&](std::int32_t face, std::int32_t vertex) { + const Triangle& triangle = triangles[face]; + if (triangle.a == vertex) return 0; + if (triangle.b == vertex) return 1; + return 2; + }; + const auto vertexAt = [&](std::int32_t face, std::int32_t slot) { + const Triangle& triangle = triangles[face]; + if (slot == 0) return triangle.a; + if (slot == 1) return triangle.b; + return triangle.c; + }; + const auto nextVertexInFace = [&](std::int32_t face, std::int32_t vertex) { + return vertexAt(face, (slotOf(face, vertex) + 1) % 3); + }; + const auto otherFaceOfEdge = [&](std::int32_t face, std::int32_t i, std::int32_t j) { + const auto found = edgeFaces.find(edgeKey(i, j)); + if (found == edgeFaces.end()) return -1; + if (found->second.first == face) return found->second.second; + if (found->second.second == face) return found->second.first; + return -1; + }; + + // --- rotate around every vertex to get its cyclic corner and edge order --- + topology.cellOffsets_.assign(static_cast(cellCount) + 1u, 0); + topology.neighbors_.assign(static_cast(incidenceOffset[cellCount]), 0); + topology.corners_.assign(topology.neighbors_.size(), 0); + + std::vector orderedFaces(6, -1); + for (std::int32_t cell = 0; cell < cellCount; ++cell) { + const std::int32_t begin = incidenceOffset[cell]; + const std::int32_t degree = incidenceOffset[cell + 1] - begin; + if (degree < 5 || degree > 6) return sphereFailure("hex sphere cell has an unsupported edge count"); + topology.cellOffsets_[cell] = begin; + + const std::int32_t firstFace = incidence[begin]; + std::int32_t current = firstFace; + for (std::int32_t direction = 0; direction < degree; ++direction) { + orderedFaces[direction] = current; + const std::int32_t nextFace = otherFaceOfEdge(current, cell, nextVertexInFace(current, cell)); + if (nextFace < 0) return sphereFailure("hex sphere half-edge walk left the surface"); + current = nextFace; + } + if (current != firstFace) return sphereFailure("hex sphere half-edge walk did not close"); + + for (std::int32_t direction = 0; direction < degree; ++direction) { + topology.corners_[begin + direction] = orderedFaces[direction]; + topology.neighbors_[begin + direction] = nextVertexInFace(orderedFaces[direction], cell); + } + } + topology.cellOffsets_[cellCount] = static_cast(topology.neighbors_.size()); + topology.cornerDirections_ = std::move(cornerDirections); + + // --- handedness --------------------------------------------------------- + // + // Walking "the other face of the edge to the next vertex" is a uniform rule, + // so every cell comes out with the same handedness and the only question is + // which one. The shared edge of two adjacent cells must expose the same two + // corners from both sides, in reversed order; if it does not, reversing every + // cell's cyclic order flips the handedness of all of them at once. + const auto cornersAgree = [&topology]() { + for (std::int32_t cell = 0; cell < topology.cellCount(); ++cell) { + const std::int32_t degree = topology.neighborCount(cell); + for (std::int32_t direction = 0; direction < degree; ++direction) { + const HexSphereCell other = topology.neighbor(cell, direction); + const std::int32_t back = topology.directionOf(other, cell); + if (back < 0) return false; + const std::int32_t otherDegree = topology.neighborCount(other); + const std::int32_t here = topology.corners_[topology.cellOffsets_[cell] + direction]; + const std::int32_t hereNext = topology.corners_[topology.cellOffsets_[cell] + (direction + 1) % degree]; + const std::int32_t there = topology.corners_[topology.cellOffsets_[other] + back]; + const std::int32_t thereNext = topology.corners_[topology.cellOffsets_[other] + (back + 1) % otherDegree]; + if (here != thereNext || hereNext != there) return false; + } + } + return true; + }; + + if (!cornersAgree()) { + for (std::int32_t cell = 0; cell < cellCount; ++cell) { + const auto begin = topology.neighbors_.begin() + topology.cellOffsets_[cell]; + const auto end = topology.neighbors_.begin() + topology.cellOffsets_[cell + 1]; + const auto cornerBegin = topology.corners_.begin() + topology.cellOffsets_[cell]; + const auto cornerEnd = topology.corners_.begin() + topology.cellOffsets_[cell + 1]; + std::reverse(begin, end); + std::reverse(cornerBegin, cornerEnd); + } + if (!cornersAgree()) return sphereFailure("hex sphere winding is inconsistent"); + } + + // --- Euler characteristic ------------------------------------------------ + if (topology.cellCount() - topology.edgeCount() + topology.cornerCount() != 2) { + return sphereFailure("hex sphere does not close into a sphere"); + } + + return Result::success(std::move(topology)); +} + +std::int32_t HexSphereTopology::neighborCount(HexSphereCell cell) const noexcept { + if (!contains(cell)) return 0; + return cellOffsets_[static_cast(cell) + 1u] - cellOffsets_[static_cast(cell)]; +} + +HexSphereCell HexSphereTopology::neighbor(HexSphereCell cell, std::int32_t direction) const noexcept { + if (direction < 0 || direction >= neighborCount(cell)) return kNoHexSphereCell; + return neighbors_[static_cast(cellOffsets_[static_cast(cell)] + direction)]; +} + +std::int32_t HexSphereTopology::directionOf(HexSphereCell cell, HexSphereCell other) const noexcept { + if (!contains(cell) || !contains(other)) return kNoHexSphereCell; + const std::int32_t begin = cellOffsets_[static_cast(cell)]; + const std::int32_t degree = neighborCount(cell); + for (std::int32_t direction = 0; direction < degree; ++direction) { + if (neighbors_[static_cast(begin + direction)] == other) return direction; + } + return kNoHexSphereCell; +} + +std::int32_t HexSphereTopology::oppositeDirection(HexSphereCell cell, std::int32_t direction) const noexcept { + const HexSphereCell other = neighbor(cell, direction); + if (other == kNoHexSphereCell) return kNoHexSphereCell; + return directionOf(other, cell); +} + +HexVec3 HexSphereTopology::direction(HexSphereCell cell) const noexcept { + if (!contains(cell)) return HexVec3{0.f, 1.f, 0.f}; + return directions_[static_cast(cell)]; +} + +std::int32_t HexSphereTopology::cornerCountOf(HexSphereCell cell) const noexcept { return neighborCount(cell); } + +HexVec3 HexSphereTopology::corner(HexSphereCell cell, std::int32_t cornerIndex) const noexcept { + if (cornerIndex < 0 || cornerIndex >= neighborCount(cell)) return HexVec3{0.f, 1.f, 0.f}; + const std::int32_t id = corners_[static_cast(cellOffsets_[static_cast(cell)] + cornerIndex)]; + if (id < 0 || id >= cornerCount()) return HexVec3{0.f, 1.f, 0.f}; + return cornerDirections_[static_cast(id)]; +} + +HexSphereCell HexSphereTopology::cellAt(HexVec3 unitDirection) const noexcept { + if (empty()) return kNoHexSphereCell; + const Vec target = normalize(unitDirection); + + HexSphereCell current = 0; + float best = dot(directions_[0], target); + const std::int32_t seeds = cellCount() < 12 ? cellCount() : 12; + for (std::int32_t cell = 1; cell < seeds; ++cell) { + const float score = dot(directions_[static_cast(cell)], target); + if (score > best) { + best = score; + current = cell; + } + } + + for (;;) { + bool improved = false; + const std::int32_t degree = neighborCount(current); + for (std::int32_t direction = 0; direction < degree; ++direction) { + const HexSphereCell candidate = + neighbors_[static_cast(cellOffsets_[static_cast(current)] + direction)]; + const float score = dot(directions_[static_cast(candidate)], target); + if (score > best + 1e-7f) { + best = score; + current = candidate; + improved = true; + } + } + if (!improved) break; + } + return current; +} + +float HexSphereTopology::angularDistance(HexVec3 a, HexVec3 b) noexcept { + const float cosine = dot(normalize(a), normalize(b)); + return std::acos(std::clamp(cosine, -1.f, 1.f)); +} + +} // namespace eve::hexmap diff --git a/src/modules/hexmap/HexSphereTopology.h b/src/modules/hexmap/HexSphereTopology.h new file mode 100644 index 00000000..cc085330 --- /dev/null +++ b/src/modules/hexmap/HexSphereTopology.h @@ -0,0 +1,158 @@ +#pragma once + +/** @file HexSphereTopology.h @brief Icosahedral hex topology: twelve pentagons and hexagons on a sphere. */ + +#include "common/Result.h" +#include "hexmap/HexMetrics.h" + +#include +#include + +namespace eve::hexmap { + +/** @brief Dense identifier of one cell of a spherical hex topology. */ +using HexSphereCell = std::int32_t; + +/** @brief Returned by a spherical cell query that has no answer. */ +inline constexpr HexSphereCell kNoHexSphereCell = -1; + +/** @brief Largest supported subdivision level; `10 * 4^7 + 2 = 163842` cells. */ +inline constexpr std::int32_t kMaxHexSphereSubdivision = 7; + +/** + * @brief Icosahedral (Goldberg) hex topology for a spherical hex map. + * + * The polyhedron is the dual of a geodesic sphere: an icosahedron subdivided + * `subdivision` times at frequency `f = 2^subdivision`, whose **vertices** become + * the cells. A cell therefore has six neighbours, except the twelve cells that + * descend from the original icosahedron vertices, which have five. That makes + * `cellCount() == 10 * f^2 + 2` and `pentagonCount() == 12` for every level, + * matching the `mesh.hexplanet` recipe of the procgen module. + * + * Cell ids are dense and stable: cells `[0, 12)` are exactly the twelve + * pentagons, because midpoint subdivision never renumbers the original vertices. + * + * Directions are not arithmetic. The six planar directions of `HexMetrics` are + * constant integer offsets in one global frame; on a sphere the edge order around + * a cell depends on the cell, and a pentagon has only five edges, so there is no + * `opposite(direction)` offset that works everywhere. Every cross-edge query goes + * through `directionOf` instead. + * + * Direction `d` of a cell is the edge between `corner(d)` and + * `corner((d + 1) % neighbourCount)`. Both corners are shared with the neighbour + * across that edge, in the opposite order, which is what lets a mesh builder walk + * a boundary from either side. + * + * Ownership and lifetime: a value type owning three flat arrays; no I/O, no GPU + * resources, no callbacks. Immutable once built, so it is safe to share. + * + * Determinism: the construction is a pure function of `subdivision`; two builds + * at the same level produce identical ids, directions, neighbours and corners. + */ +class HexSphereTopology { +public: + HexSphereTopology() = default; + + /** + * @brief Builds the topology of one subdivision level. + * + * @param subdivision Subdivision level in `[0, kMaxHexSphereSubdivision]`; + * level 0 is the bare icosahedron dual (12 pentagons, 20 corners). + * @return The topology, or InvalidArgument when the level is out of range, or + * a construction failure when the half-edge walk cannot close (an + * internal consistency error that a valid icosahedron never triggers). + * @cost Builds all five tables at once; proportional to the cell count and + * dominated by the `10 * 4^subdivision` cells, amortized over every + * query the caller makes afterwards. + */ + [[nodiscard]] static Result build(std::int32_t subdivision); + + /** @brief Whether the topology holds no cell. */ + [[nodiscard]] bool empty() const noexcept { return directions_.empty(); } + /** @brief The subdivision level this topology was built at. */ + [[nodiscard]] std::int32_t subdivision() const noexcept { return subdivision_; } + /** @brief Subdivision frequency `f = 2^subdivision`. */ + [[nodiscard]] std::int32_t frequency() const noexcept { return frequency_; } + /** @brief Number of cells (`10 * f^2 + 2`). */ + [[nodiscard]] std::int32_t cellCount() const noexcept { return static_cast(directions_.size()); } + /** @brief Number of pentagonal cells (12 for every non-empty topology). */ + [[nodiscard]] std::int32_t pentagonCount() const noexcept { return empty() ? 0 : 12; } + /** @brief Number of cell-to-cell edges (`30 * f^2`). */ + [[nodiscard]] std::int32_t edgeCount() const noexcept { return static_cast(neighbors_.size() / 2u); } + /** @brief Number of distinct corners (`20 * f^2`), each shared by three cells. */ + [[nodiscard]] std::int32_t cornerCount() const noexcept { + return static_cast(cornerDirections_.size()); + } + + /** @brief Whether `cell` is a valid id of this topology. */ + [[nodiscard]] bool contains(HexSphereCell cell) const noexcept { return cell >= 0 && cell < cellCount(); } + /** @brief Whether `cell` has five edges instead of six; false when out of range. */ + [[nodiscard]] bool isPentagon(HexSphereCell cell) const noexcept { + return contains(cell) && neighborCount(cell) == 5; + } + /** @brief Number of edges of `cell` (5 or 6), or 0 when out of range. */ + [[nodiscard]] std::int32_t neighborCount(HexSphereCell cell) const noexcept; + /** + * @brief Neighbour of `cell` across edge `direction`. + * + * @param cell Source cell. + * @param direction Edge index in `[0, neighborCount(cell))`. + * @return The neighbouring cell, or `kNoHexSphereCell` when either argument is + * out of range. + */ + [[nodiscard]] HexSphereCell neighbor(HexSphereCell cell, std::int32_t direction) const noexcept; + /** + * @brief Edge index of the edge shared with `other`. + * + * The spherical replacement for `opposite`: a hexagon-hexagon edge happens to + * satisfy `d' == (d + 3) % 6`, but a hexagon-pentagon edge has no arithmetic + * form, so callers must always ask this instead of computing an offset. + * + * @return Edge index in `[0, neighborCount(cell))`, or `kNoHexSphereCell` when + * `other` is not adjacent to `cell`. + */ + [[nodiscard]] std::int32_t directionOf(HexSphereCell cell, HexSphereCell other) const noexcept; + /** @brief Edge index of `cell`'s edge towards its neighbour across `direction`. */ + [[nodiscard]] std::int32_t oppositeDirection(HexSphereCell cell, std::int32_t direction) const noexcept; + + /** @brief Unit direction from the sphere centre to the centre of `cell`. */ + [[nodiscard]] HexVec3 direction(HexSphereCell cell) const noexcept; + /** @brief Number of corners of `cell` (5 or 6), or 0 when out of range. */ + [[nodiscard]] std::int32_t cornerCountOf(HexSphereCell cell) const noexcept; + /** + * @brief Unit direction of corner `corner` of `cell`. + * + * Corner `d` lies between edge `d - 1` and edge `d` (both cyclic), and is + * shared by three cells. On a sphere a corner is not a fixed angular offset of + * its cell, so the mesh builder reads it here instead of deriving it. + * + * @return A unit vector, or the sphere's north pole when an argument is out of + * range (callers gate on `contains`). + */ + [[nodiscard]] HexVec3 corner(HexSphereCell cell, std::int32_t corner) const noexcept; + + /** + * @brief The cell whose centre direction is nearest to `unitDirection`. + * + * Descends the neighbour graph from the best of the twelve pentagons, which + * makes the query cost proportional to the angular distance walked rather than + * to the cell count. + * + * @param unitDirection Any non-zero direction; normalized internally. + * @return The containing cell, or `kNoHexSphereCell` when the topology is empty. + */ + [[nodiscard]] HexSphereCell cellAt(HexVec3 unitDirection) const noexcept; + /** @brief Great-circle angle between two directions, in radians. */ + [[nodiscard]] static float angularDistance(HexVec3 a, HexVec3 b) noexcept; + +private: + std::vector directions_; /**< Cell centres, unit length. */ + std::vector cellOffsets_; /**< `cellCount + 1` offsets into the two flat tables. */ + std::vector neighbors_; /**< Neighbour per (cell, direction). */ + std::vector corners_; /**< Corner id per (cell, direction). */ + std::vector cornerDirections_; /**< Corner positions, unit length. */ + std::int32_t subdivision_ = 0; + std::int32_t frequency_ = 1; +}; + +} // namespace eve::hexmap diff --git a/test/hexmap_sphere.cpp b/test/hexmap_sphere.cpp new file mode 100644 index 00000000..6df62ab1 --- /dev/null +++ b/test/hexmap_sphere.cpp @@ -0,0 +1,955 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "hexmap/HexSphereGenerator.h" +#include "hexmap/HexSphereMap.h" +#include "hexmap/HexSphereMesh.h" +#include "hexmap/HexSphereTopology.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace eve::hexmap; + +namespace { + +/** @brief Builds a topology the test depends on, failing the case when it cannot. */ +[[nodiscard]] HexSphereTopology makeTopology(std::int32_t subdivision) { + auto built = HexSphereTopology::build(subdivision); + REQUIRE(built.ok()); + return built.value(); +} + +/** @brief Dot product, so the tests do not depend on module internals. */ +[[nodiscard]] float dot(HexVec3 a, HexVec3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; } + +/** @brief Length of a vector. */ +[[nodiscard]] float magnitude(HexVec3 a) { return std::sqrt(dot(a, a)); } + +/** @brief Whether two directions denote the same point within float tolerance. */ +[[nodiscard]] bool sameDirection(HexVec3 a, HexVec3 b) { return magnitude(a - b) < 1e-5f; } + +/** + * @brief A deterministic, low-discrepancy sample of the sphere. + * + * A golden-angle spiral is used instead of a PRNG so a failure always reproduces + * and the samples do not clump at the poles, where a naive uniform latitude + * sampling would over-weight the twelve pentagons. + */ +[[nodiscard]] HexVec3 sampleDirection(std::uint32_t index, std::uint32_t count) { + constexpr float kGoldenAngle = 2.399963229728653f; + const float fraction = (static_cast(index) + 0.5f) / static_cast(count); + const float y = 1.f - 2.f * fraction; + const float radius = std::sqrt(std::max(0.f, 1.f - y * y)); + const float angle = kGoldenAngle * static_cast(index); + return HexVec3{radius * std::cos(angle), y, radius * std::sin(angle)}; +} + +} // namespace + +TEST_CASE("hexmap.sphere.countsMatchTheGoldbergFormula") { + for (std::int32_t subdivision = 0; subdivision <= 4; ++subdivision) { + const HexSphereTopology topology = makeTopology(subdivision); + const std::int32_t frequency = std::int32_t{1} << subdivision; + const std::int32_t cellCount = 10 * frequency * frequency + 2; + + CHECK_EQ(topology.subdivision(), subdivision); + CHECK_EQ(topology.frequency(), frequency); + CHECK_EQ(topology.cellCount(), cellCount); + CHECK_EQ(topology.pentagonCount(), 12); + CHECK_EQ(topology.edgeCount(), 30 * frequency * frequency); + CHECK_EQ(topology.cornerCount(), 20 * frequency * frequency); + // Euler: a closed surface of genus zero. + CHECK_EQ(topology.cellCount() - topology.edgeCount() + topology.cornerCount(), 2); + } +} + +TEST_CASE("hexmap.sphere.pentagonsAreTheTwelveIcosahedronVertices") { + const HexSphereTopology topology = makeTopology(3); + CHECK_EQ(topology.pentagonCount(), 12); + + for (HexSphereCell cell = 0; cell < topology.cellCount(); ++cell) { + const bool pentagon = cell < 12; + CHECK_EQ(topology.isPentagon(cell), pentagon); + CHECK_EQ(topology.neighborCount(cell), pentagon ? 5 : 6); + CHECK_EQ(topology.cornerCountOf(cell), topology.neighborCount(cell)); + } + + // Ids are stable across levels, so the twelve pentagons are the same cells at + // every subdivision and their directions are the icosahedron's vertices. + const HexSphereTopology coarse = makeTopology(0); + const HexSphereTopology fine = makeTopology(4); + for (HexSphereCell cell = 0; cell < 12; ++cell) { + CHECK(sameDirection(coarse.direction(cell), fine.direction(cell))); + CHECK(fine.isPentagon(cell)); + } +} + +TEST_CASE("hexmap.sphere.neighboursAreMutualAndShareBothCorners") { + const HexSphereTopology topology = makeTopology(3); + + for (HexSphereCell cell = 0; cell < topology.cellCount(); ++cell) { + const std::int32_t degree = topology.neighborCount(cell); + // Kept out of the assertion expression: zeroerr decomposes `a || b` and + // would collide with the built-in boolean operator. + const bool degreeIsHexOrPentagon = degree == 5 || degree == 6; + REQUIRE(degreeIsHexOrPentagon); + + for (std::int32_t direction = 0; direction < degree; ++direction) { + const HexSphereCell other = topology.neighbor(cell, direction); + REQUIRE(other != kNoHexSphereCell); + CHECK(topology.contains(other)); + + const std::int32_t back = topology.directionOf(other, cell); + REQUIRE(back >= 0); + CHECK_EQ(back, topology.oppositeDirection(cell, direction)); + + // The shared edge must expose the same two corners from both sides, + // in reversed order; that is what lets a mesh builder walk a boundary + // from either cell. + const std::int32_t otherDegree = topology.neighborCount(other); + const HexVec3 here = topology.corner(cell, direction); + const HexVec3 hereNext = topology.corner(cell, (direction + 1) % degree); + const HexVec3 there = topology.corner(other, back); + const HexVec3 thereNext = topology.corner(other, (back + 1) % otherDegree); + + CHECK(sameDirection(here, thereNext)); + CHECK(sameDirection(hereNext, there)); + CHECK(!sameDirection(here, hereNext)); + + // Adjacency is symmetric in the other direction too: `other` reports + // `cell` exactly once. + std::int32_t matches = 0; + for (std::int32_t candidate = 0; candidate < otherDegree; ++candidate) { + if (topology.neighbor(other, candidate) == cell) ++matches; + } + CHECK_EQ(matches, 1); + } + } +} + +TEST_CASE("hexmap.sphere.directionsAndCornersAreUnitVectors") { + const HexSphereTopology topology = makeTopology(3); + + for (HexSphereCell cell = 0; cell < topology.cellCount(); ++cell) { + CHECK(std::fabs(magnitude(topology.direction(cell)) - 1.f) < 1e-5f); + const std::int32_t degree = topology.neighborCount(cell); + for (std::int32_t corner = 0; corner < degree; ++corner) { + CHECK(std::fabs(magnitude(topology.corner(cell, corner)) - 1.f) < 1e-5f); + } + } +} + +TEST_CASE("hexmap.sphere.everyCornerIsSharedByExactlyThreeCells") { + const HexSphereTopology topology = makeTopology(2); + + std::vector groups; + std::vector members; + + for (HexSphereCell cell = 0; cell < topology.cellCount(); ++cell) { + const std::int32_t degree = topology.neighborCount(cell); + for (std::int32_t corner = 0; corner < degree; ++corner) { + const HexVec3 position = topology.corner(cell, corner); + std::size_t group = groups.size(); + for (std::size_t candidate = 0; candidate < groups.size(); ++candidate) { + if (sameDirection(groups[candidate], position)) { + group = candidate; + break; + } + } + if (group == groups.size()) { + groups.push_back(position); + members.push_back(1); + } else { + ++members[group]; + } + } + } + + CHECK_EQ(static_cast(groups.size()), topology.cornerCount()); + for (const std::int32_t count : members) CHECK_EQ(count, 3); +} + +TEST_CASE("hexmap.sphere.edgeLengthsAreNearUniform") { + const HexSphereTopology topology = makeTopology(3); + + float shortest = 10.f; + float longest = 0.f; + for (HexSphereCell cell = 0; cell < topology.cellCount(); ++cell) { + const std::int32_t degree = topology.neighborCount(cell); + for (std::int32_t direction = 0; direction < degree; ++direction) { + const float length = HexSphereTopology::angularDistance( + topology.direction(cell), topology.direction(topology.neighbor(cell, direction))); + shortest = std::min(shortest, length); + longest = std::max(longest, length); + } + } + + CHECK(shortest > 0.f); + // Hexagon-hexagon edges are slightly longer than the edges around a pentagon; + // the ratio stays close to one because both are governed by the same level. + CHECK(longest / shortest < 1.3f); +} + +TEST_CASE("hexmap.sphere.cellAtMatchesBruteForceNearest") { + const HexSphereTopology topology = makeTopology(3); + + constexpr std::uint32_t kSamples = 400; + for (std::uint32_t index = 0; index < kSamples; ++index) { + const HexVec3 sample = sampleDirection(index, kSamples); + + HexSphereCell best = 0; + float bestScore = -2.f; + for (HexSphereCell cell = 0; cell < topology.cellCount(); ++cell) { + const float score = dot(topology.direction(cell), sample); + if (score > bestScore) { + bestScore = score; + best = cell; + } + } + + const HexSphereCell found = topology.cellAt(sample); + REQUIRE(found != kNoHexSphereCell); + CHECK_EQ(found, best); + + // The reported cell must actually contain the sample: the sample is closer + // to its own centre than to every neighbour's. + for (std::int32_t direction = 0; direction < topology.neighborCount(found); ++direction) { + CHECK(dot(topology.direction(found), sample) > + dot(topology.direction(topology.neighbor(found, direction)), sample)); + } + } +} + +TEST_CASE("hexmap.sphere.subdivisionOutOfRangeIsRejected") { + auto tooLow = HexSphereTopology::build(-1); + CHECK(!tooLow.ok()); + + auto tooHigh = HexSphereTopology::build(kMaxHexSphereSubdivision + 1); + CHECK(!tooHigh.ok()); + + auto base = HexSphereTopology::build(0); + REQUIRE(base.ok()); + CHECK_EQ(base.value().cellCount(), 12); + CHECK_EQ(base.value().pentagonCount(), 12); + CHECK_EQ(base.value().cornerCount(), 20); + CHECK_EQ(base.value().edgeCount(), 30); + for (HexSphereCell cell = 0; cell < 12; ++cell) { + CHECK(base.value().isPentagon(cell)); + CHECK_EQ(base.value().neighborCount(cell), 5); + } +} + +TEST_CASE("hexmap.sphere.buildIsDeterministic") { + const HexSphereTopology first = makeTopology(2); + const HexSphereTopology second = makeTopology(2); + + REQUIRE(first.cellCount() == second.cellCount()); + for (HexSphereCell cell = 0; cell < first.cellCount(); ++cell) { + CHECK(sameDirection(first.direction(cell), second.direction(cell))); + const std::int32_t degree = first.neighborCount(cell); + CHECK_EQ(degree, second.neighborCount(cell)); + for (std::int32_t direction = 0; direction < degree; ++direction) { + CHECK_EQ(first.neighbor(cell, direction), second.neighbor(cell, direction)); + CHECK(sameDirection(first.corner(cell, direction), second.corner(cell, direction))); + } + } +} + +TEST_CASE("hexmap.sphere.outOfRangeQueriesAreTotal") { + const HexSphereTopology topology = makeTopology(1); + + CHECK_EQ(topology.neighborCount(-1), 0); + CHECK_EQ(topology.neighborCount(topology.cellCount()), 0); + CHECK_EQ(topology.neighbor(-1, 0), kNoHexSphereCell); + CHECK_EQ(topology.neighbor(0, -1), kNoHexSphereCell); + CHECK_EQ(topology.neighbor(0, topology.neighborCount(0)), kNoHexSphereCell); + CHECK_EQ(topology.directionOf(0, topology.cellCount()), kNoHexSphereCell); + CHECK_EQ(topology.oppositeDirection(-1, 0), kNoHexSphereCell); + + // `directionOf` must report exactly the cell's own degree, no more and no less. + std::int32_t adjacent = 0; + for (HexSphereCell cell = 0; cell < topology.cellCount(); ++cell) { + if (topology.directionOf(0, cell) >= 0) ++adjacent; + } + CHECK_EQ(adjacent, topology.neighborCount(0)); + + const HexSphereTopology empty; + CHECK(empty.empty()); + CHECK_EQ(empty.cellCount(), 0); + CHECK_EQ(empty.pentagonCount(), 0); + CHECK_EQ(empty.edgeCount(), 0); + CHECK_EQ(empty.cornerCount(), 0); + CHECK_EQ(empty.cellAt(HexVec3{0.f, 1.f, 0.f}), kNoHexSphereCell); +} + +// --- HexSphereMap ----------------------------------------------------------- + +namespace { + +/** @brief Builds a map the test depends on, failing the case when it cannot. */ +[[nodiscard]] HexSphereMap makeMap(std::int32_t subdivision, float radius = 100.f, std::uint32_t seed = 7u) { + HexSphereMap map; + auto built = map.reset(subdivision, radius, seed); + REQUIRE(built.ok()); + return map; +} + +/** @brief Drains the dirty queue into a vector of cell ids. */ +[[nodiscard]] std::vector drainDirty(HexSphereMap& map) { + std::vector dirty; + for (HexSphereCell cell = map.takeDirtyCell(); cell != kNoHexSphereCell; cell = map.takeDirtyCell()) + dirty.push_back(cell); + return dirty; +} + +[[nodiscard]] bool holds(const std::vector& cells, HexSphereCell cell) { + return std::find(cells.begin(), cells.end(), cell) != cells.end(); +} + +} // namespace + +TEST_CASE("hexmap.sphereMap.resetBuildsEveryCell") { + const HexSphereMap map = makeMap(2, 250.f, 11u); + + CHECK(!map.empty()); + CHECK_EQ(map.subdivision(), 2); + CHECK_EQ(map.frequency(), 4); + CHECK_EQ(map.cellCount(), 10 * 4 * 4 + 2); + CHECK_EQ(map.pentagonCount(), 12); + CHECK_EQ(map.topology().cellCount(), map.cellCount()); + CHECK_EQ(map.seed(), 11u); + CHECK_EQ(map.sphereRadius(), 250.f); + CHECK(map.elevationStep() > 0.f); + + for (HexSphereCell cell = 0; cell < map.cellCount(); ++cell) { + CHECK_EQ(map.elevation(cell), 0); + CHECK_EQ(map.waterLevel(cell), 0); + CHECK_EQ(map.terrainType(cell), 0); + CHECK(!map.hasRiver(cell)); + CHECK(!map.hasRoad(cell)); + CHECK(!map.isUnderwater(cell)); + CHECK(!map.isWalled(cell)); + } + + // A fresh map owes a rebuild of every cell, exactly once. + CHECK_EQ(map.dirtyCellCount(), map.cellCount()); +} + +TEST_CASE("hexmap.sphereMap.elevationIsRadial") { + HexSphereMap map = makeMap(1, 100.f, 3u); + const float step = map.elevationStep(); + + for (const HexSphereCell cell : std::vector{0, 13, 40}) { + for (const std::int32_t level : std::vector{-4, -1, 0, 3, 8}) { + CHECK(map.setElevation(cell, level).ok()); + CHECK_EQ(map.elevation(cell), level); + + const float radius = 100.f + static_cast(level) * step; + const HexVec3 direction = map.direction(cell); + CHECK(std::fabs(map.surfaceRadius(cell) - radius) < 1e-3f); + CHECK(std::fabs(map.cellPosition(cell).x - direction.x * radius) < 1e-3f); + CHECK(std::fabs(map.cellPosition(cell).y - direction.y * radius) < 1e-3f); + CHECK(std::fabs(map.cellPosition(cell).z - direction.z * radius) < 1e-3f); + + // The ground position is the same point at the sphere radius. + CHECK(std::fabs(map.cellGroundPosition(cell).x - direction.x * 100.f) < 1e-3f); + CHECK(std::fabs(map.cellGroundPosition(cell).y - direction.y * 100.f) < 1e-3f); + CHECK(std::fabs(map.cellGroundPosition(cell).z - direction.z * 100.f) < 1e-3f); + } + } +} + +TEST_CASE("hexmap.sphereMap.authoringClampsAndMarksDirty") { + HexSphereMap map = makeMap(1); + // A fresh map owes a rebuild of every cell, so draining it must yield them all. + CHECK_EQ(static_cast(drainDirty(map).size()), map.cellCount()); + CHECK_EQ(map.dirtyCellCount(), 0); + + const HexSphereCell cell = 20; + CHECK(map.setElevation(cell, 99).ok()); + CHECK_EQ(map.elevation(cell), HexMetrics::kMaxElevation); + CHECK(map.setElevation(cell, -99).ok()); + CHECK_EQ(map.elevation(cell), HexMetrics::kMinElevation); + CHECK(map.setWaterLevel(cell, 99).ok()); + CHECK_EQ(map.waterLevel(cell), HexMetrics::kMaxElevation); + CHECK(map.setTerrainType(cell, 99).ok()); + CHECK_EQ(map.terrainType(cell), kHexTerrainTypeCount - 1); + CHECK_EQ(map.terrainType(cell), static_cast(HexTerrainType::Snow)); + + // Every edit owed a rebuild of the cell and of all of its neighbours. + const std::vector dirty = drainDirty(map); + CHECK(holds(dirty, cell)); + for (std::int32_t d = 0; d < map.neighborCount(cell); ++d) CHECK(holds(dirty, map.neighbor(cell, d))); + + CHECK_EQ(map.dirtyCellCount(), 0); + CHECK_EQ(map.takeDirtyCell(), kNoHexSphereCell); +} + +TEST_CASE("hexmap.sphereMap.dirtyQueueDrainsEachCellOnce") { + HexSphereMap map = makeMap(2); + const auto expected = static_cast(map.cellCount()); + + std::vector seen(expected, 0u); + std::size_t count = 0; + for (std::int32_t cell = map.takeDirtyCell(); cell != kNoHexSphereCell; cell = map.takeDirtyCell()) { + REQUIRE(cell >= 0); + REQUIRE(static_cast(cell) < expected); + CHECK_EQ(seen[static_cast(cell)], 0); + seen[static_cast(cell)] = 1u; + ++count; + } + CHECK_EQ(count, expected); + CHECK_EQ(map.dirtyCellCount(), 0); + CHECK_EQ(map.takeDirtyCell(), kNoHexSphereCell); +} + +TEST_CASE("hexmap.sphereMap.distanceIsSymmetricAndMatchesNeighbours") { + const HexSphereMap map = makeMap(3); + CHECK_EQ(map.distance(13, 13), 0); + CHECK_EQ(map.distance(-1, 0), kNoHexSphereCell); + CHECK_EQ(map.distance(0, map.cellCount()), kNoHexSphereCell); + + for (const HexSphereCell cell : std::vector{0, 13, 100, 641}) { + for (std::int32_t d = 0; d < map.neighborCount(cell); ++d) { + const HexSphereCell neighbour = map.neighbor(cell, d); + CHECK_EQ(map.distance(cell, neighbour), 1); + CHECK_EQ(map.distance(neighbour, cell), 1); + } + } + + for (const HexSphereCell a : std::vector{0, 13, 100}) { + for (const HexSphereCell b : std::vector{200, 400, 641}) { + CHECK_EQ(map.distance(a, b), map.distance(b, a)); + CHECK(map.distance(a, b) > 1); + } + } +} + +TEST_CASE("hexmap.sphereMap.brushIsExactlyTheBreadthFirstBall") { + const HexSphereMap map = makeMap(2); + std::vector cells; + + map.collectBrush(0, 0, cells); + CHECK_EQ(static_cast(cells.size()), 1); + CHECK_EQ(cells.front(), 0); + + // The centre is pentagonal, so its one-step brush is smaller than a hexagon's. + map.collectBrush(0, 1, cells); + CHECK_EQ(static_cast(cells.size()), 1 + map.neighborCount(0)); + CHECK_EQ(map.neighborCount(0), 5); + CHECK_EQ(map.neighborCount(13), 6); + map.collectBrush(13, 1, cells); + CHECK_EQ(static_cast(cells.size()), 7); + + // The brush must be exactly the set of cells within `radius` steps: nothing + // farther than the radius, and nothing missing. + for (std::int32_t radius = 0; radius <= 3; ++radius) { + map.collectBrush(13, radius, cells); + std::vector inBrush(static_cast(map.cellCount()), 0u); + for (const HexSphereCell cell : cells) inBrush[static_cast(cell)] = 1u; + for (HexSphereCell cell = 0; cell < map.cellCount(); ++cell) { + const bool within = map.distance(13, cell) <= radius; + CHECK_EQ(static_cast(inBrush[static_cast(cell)]), within); + } + } + + // Breadth-first order is deterministic for a given topology. + std::vector again; + map.collectBrush(13, 2, cells); + map.collectBrush(13, 2, again); + CHECK_EQ(cells.size(), again.size()); + for (std::size_t i = 0; i < cells.size(); ++i) CHECK_EQ(cells[i], again[i]); + + // An invalid centre or a negative radius yields an empty brush. + map.collectBrush(-1, 2, cells); + CHECK(cells.empty()); + map.collectBrush(13, -1, cells); + CHECK(cells.empty()); +} + +TEST_CASE("hexmap.sphereMap.riversMirrorAcrossTheSharedEdge") { + HexSphereMap map = makeMap(2); + + // A pentagon's edges have no arithmetic opposite, so this is precisely the + // case a planar `opposite(direction)` would get wrong. + const HexSphereCell pentagon = 0; + const HexSphereCell neighbour = map.neighbor(pentagon, 2); + const std::int32_t back = map.oppositeDirection(pentagon, 2); + REQUIRE(neighbour != kNoHexSphereCell); + REQUIRE(back >= 0); + CHECK_EQ(map.neighbor(neighbour, back), pentagon); + + CHECK(map.setOutgoingRiver(pentagon, 2).ok()); + CHECK(map.hasRiverThrough(pentagon, 2)); + CHECK(map.hasRiverThrough(neighbour, back)); + + CHECK(map.removeRiver(pentagon).ok()); + CHECK(!map.hasRiver(pentagon)); + CHECK(!map.hasRiver(neighbour)); + + // A river may not be carved into an edge the cell does not have. + CHECK(!map.setOutgoingRiver(pentagon, map.neighborCount(pentagon)).ok()); + CHECK(!map.setOutgoingRiver(pentagon, -1).ok()); +} + +TEST_CASE("hexmap.sphereMap.roadsMirrorAndRejectSteepEdges") { + HexSphereMap map = makeMap(2); + const HexSphereCell a = 13; + const std::int32_t dir = 1; + const HexSphereCell b = map.neighbor(a, dir); + const std::int32_t back = map.oppositeDirection(a, dir); + REQUIRE(b != kNoHexSphereCell); + REQUIRE(back >= 0); + + CHECK(map.addRoad(a, dir).ok()); + CHECK(map.hasRoad(a)); + CHECK(map.hasRoad(b)); + + // Raising the neighbour by more than one step must drop the road again. + CHECK(map.setElevation(b, map.elevation(a) + 3).ok()); + CHECK(!map.hasRoad(a)); + CHECK(!map.hasRoad(b)); + + // A road may only be added across an existing edge, and only over a slope. + CHECK(!map.addRoad(a, map.neighborCount(a)).ok()); + CHECK(map.setElevation(b, map.elevation(a) + 2).ok()); + CHECK(map.addRoad(a, dir).ok()); + CHECK(!map.hasRoad(a)); +} + +TEST_CASE("hexmap.sphereMap.pickCellFindsTheFacingCell") { + HexSphereMap map = makeMap(2, 100.f, 5u); + + for (const HexSphereCell expected : std::vector{0, 5, 11, 13, 100, 161}) { + const HexVec3 direction = map.direction(expected); + const HexVec3 origin = direction * 400.f; + auto picked = map.pickCell(origin, HexVec3{-direction.x, -direction.y, -direction.z}); + REQUIRE(picked.ok()); + CHECK_EQ(picked.value(), expected); + } + + // Elevation must not be ignored: a cell raised well above the sphere still + // answers for the ray aimed at it, and its neighbour does not. + CHECK(map.setElevation(13, HexMetrics::kMaxElevation).ok()); + { + const HexVec3 direction = map.direction(13); + auto picked = map.pickCell(direction * 400.f, HexVec3{-direction.x, -direction.y, -direction.z}); + REQUIRE(picked.ok()); + CHECK_EQ(picked.value(), 13); + } + + const HexVec3 away = map.direction(13) * 400.f; + auto missed = map.pickCell(away, map.direction(13)); + CHECK(!missed.ok()); + CHECK(missed.code() == eve::StatusCode::NotFound); + + auto degenerate = map.pickCell(HexVec3{0.f, 0.f, 300.f}, HexVec3{}); + CHECK(!degenerate.ok()); + CHECK(degenerate.code() == eve::StatusCode::Rejected); + + const HexSphereMap empty; + auto nothing = empty.pickCell(HexVec3{0.f, 0.f, 300.f}, HexVec3{0.f, 0.f, -1.f}); + CHECK(!nothing.ok()); + CHECK(nothing.code() == eve::StatusCode::NotFound); +} + +TEST_CASE("hexmap.sphereMap.rejectsBadShapeArguments") { + HexSphereMap map; + + CHECK(!map.reset(-1, 100.f, 1u).ok()); + CHECK(!map.reset(kMaxHexSphereSubdivision + 1, 100.f, 1u).ok()); + CHECK(!map.reset(1, 0.f, 1u).ok()); + CHECK(!map.reset(1, -5.f, 1u).ok()); + CHECK(!map.reset(1, std::numeric_limits::quiet_NaN(), 1u).ok()); + CHECK(map.empty()); + CHECK_EQ(map.cellCount(), 0); + CHECK_EQ(map.takeDirtyCell(), kNoHexSphereCell); + + CHECK(map.reset(1, 100.f, 1u).ok()); + CHECK(!map.empty()); + CHECK_EQ(map.cellCount(), 42); +} + +TEST_CASE("hexmap.sphereMap.isDeterministicForTheSameShape") { + const HexSphereMap first = makeMap(2, 100.f, 9u); + const HexSphereMap second = makeMap(2, 100.f, 9u); + + CHECK_EQ(first.cellCount(), second.cellCount()); + CHECK_EQ(first.elevationStep(), second.elevationStep()); + for (HexSphereCell cell = 0; cell < first.cellCount(); ++cell) { + CHECK(sameDirection(first.direction(cell), second.direction(cell))); + CHECK_EQ(first.neighborCount(cell), second.neighborCount(cell)); + for (std::int32_t d = 0; d < first.neighborCount(cell); ++d) { + CHECK_EQ(first.neighbor(cell, d), second.neighbor(cell, d)); + CHECK(sameDirection(first.cornerDirection(cell, d), second.cornerDirection(cell, d))); + } + } +} + +TEST_CASE("hexmap.sphereMap.invalidCellQueriesAreTotal") { + const HexSphereMap map = makeMap(1); + + CHECK(!map.contains(-1)); + CHECK(!map.contains(map.cellCount())); + CHECK_EQ(map.cellAt(-1), nullptr); + CHECK_EQ(map.cellAt(map.cellCount()), nullptr); + CHECK_EQ(map.neighborCount(-1), 0); + CHECK_EQ(map.elevation(-1), 0); + CHECK_EQ(map.waterLevel(-1), 0); + CHECK(!map.hasRiver(-1)); + CHECK(!map.hasRoad(-1)); + CHECK(!map.hasRiverThrough(-1, 0)); + CHECK(!map.isUnderwater(-1)); + CHECK_EQ(map.edgeTypeTo(-1, 0), HexEdgeType::Flat); + CHECK_EQ(map.surfaceRadius(-1), map.sphereRadius()); + + const HexSphereMap empty; + CHECK_EQ(empty.surfaceRadius(0), 0.f); +} + +// --- spherical mesh --------------------------------------------------------- + +namespace { + +/** @brief Exact vector equality; a shared topological corner must be bit-identical. */ +[[nodiscard]] bool identical(HexVec3 a, HexVec3 b) { return a.x == b.x && a.y == b.y && a.z == b.z; } + +/** @brief Quantised position key used to weld the deliberately unshared vertex stream. */ +struct WeldKey { + int x = 0; + int y = 0; + int z = 0; + [[nodiscard]] bool operator<(const WeldKey& other) const { + if (x != other.x) return x < other.x; + if (y != other.y) return y < other.y; + return z < other.z; + } +}; + +/** + * @brief Welds a position to a 1e-3 grid. + * + * The mesher never shares a vertex between triangles, so two triangles only "meet" if + * their positions are equal - and shared corners are emitted as an exactly equal pure + * function of the topological position, so a coarse quantum is enough to weld them + * while staying far below the distance between two distinct corners. + */ +[[nodiscard]] WeldKey weldKey(HexVec3 p) { + constexpr double kQuantum = 1000.0; + return WeldKey{static_cast(std::llround(static_cast(p.x) * kQuantum)), + static_cast(std::llround(static_cast(p.y) * kQuantum)), + static_cast(std::llround(static_cast(p.z) * kQuantum))}; +} + +/** @brief Edge-use census of a welded mesh. */ +struct WeldReport { + std::size_t vertices = 0; + std::size_t edges = 0; + std::size_t boundaryEdges = 0; + std::size_t nonManifold = 0; + std::size_t triangles = 0; +}; + +/** + * @brief Welds the mesh by position and counts how many triangles meet at every edge. + * + * A closed triangulated sphere has no boundary: every welded edge is shared by exactly + * two triangles. A count of one is a crack and a count above two is a duplicated or + * overlapping sheet. Both are invisible to a triangle count, and both are exactly what + * a wrong winding, a wrong edge-span or a wrong corner index produces. + */ +[[nodiscard]] WeldReport analyseWeld(const HexMeshData& mesh) { + WeldReport report; + report.triangles = mesh.triangleCount(); + + const auto& positions = mesh.positions(); + const std::size_t count = positions.size() / 3u; + std::map welded; + std::vector ids; + ids.reserve(count); + for (std::size_t i = 0; i < count; ++i) { + const HexVec3 position{positions[i * 3u], positions[i * 3u + 1u], positions[i * 3u + 2u]}; + const auto inserted = welded.emplace(weldKey(position), static_cast(welded.size())); + ids.push_back(inserted.first->second); + } + + std::map, std::int32_t> edgeUse; + const auto& indices = mesh.indices(); + for (std::size_t t = 0; t + 2u < indices.size(); t += 3u) { + const std::uint32_t triangle[3] = {ids[indices[t]], ids[indices[t + 1u]], ids[indices[t + 2u]]}; + for (std::int32_t e = 0; e < 3; ++e) { + std::uint32_t a = triangle[e]; + std::uint32_t b = triangle[(e + 1) % 3]; + if (a > b) std::swap(a, b); + ++edgeUse[std::make_pair(a, b)]; + } + } + + report.edges = edgeUse.size(); + report.vertices = welded.size(); + for (const auto& entry : edgeUse) { + if (entry.second == 1) ++report.boundaryEdges; + else if (entry.second > 2) ++report.nonManifold; + } + return report; +} + +/** + * @brief Asserts the welded surface is closed, manifold and of genus zero. + * + * `V - E + F = 2` is what turns "no boundary edges were found" into "this really is a + * closed sphere": a weld that silently merged or missed vertices would still show no + * boundary edges, but it could not also satisfy the Euler characteristic. + */ +void checkClosedSphere(const WeldReport& report) { + CHECK(report.triangles > 0u); + CHECK(report.vertices > 0u); + CHECK(report.edges > 0u); + CHECK_EQ(report.boundaryEdges, static_cast(0)); + CHECK_EQ(report.nonManifold, static_cast(0)); + CHECK_EQ(report.vertices + report.triangles - report.edges, static_cast(2)); +} + +/** @brief Number of triangles whose stored normal points back towards the sphere centre. */ +[[nodiscard]] std::size_t countInwardFacingTriangles(const HexMeshData& mesh) { + const auto& positions = mesh.positions(); + const auto& normals = mesh.normals(); + const auto& indices = mesh.indices(); + + std::size_t inward = 0; + for (std::size_t t = 0; t + 2u < indices.size(); t += 3u) { + const std::size_t normalBase = static_cast(indices[t]) * 3u; + const HexVec3 normal{normals[normalBase], normals[normalBase + 1u], normals[normalBase + 2u]}; + + HexVec3 centroid{}; + for (std::int32_t k = 0; k < 3; ++k) { + const std::size_t base = static_cast(indices[t + static_cast(k)]) * 3u; + centroid.x += positions[base]; + centroid.y += positions[base + 1u]; + centroid.z += positions[base + 2u]; + } + centroid = centroid * (1.f / 3.f); + + if (normal.x * centroid.x + normal.y * centroid.y + normal.z * centroid.z <= 0.f) ++inward; + } + return inward; +} + +/** @brief Raises a scattered subset of cells so that slopes and cliffs both occur. */ +void scatterElevation(HexSphereMap& map) { + for (HexSphereCell cell = 0; cell < map.cellCount(); ++cell) { + const std::int32_t level = (cell % 5 == 0) ? 3 : ((cell % 7 == 0) ? 1 : 0); + REQUIRE(map.setElevation(cell, level).ok()); + } +} + +} // namespace + +TEST_CASE("hexmap.sphereMesh.isAWatertightSurface") { + // Flat: only fans and blend strips are exercised. + { + HexSphereMap map = makeMap(1, 100.f, 4u); + HexMeshData mesh; + buildSphereTerrainMesh(map, mesh); + REQUIRE(!mesh.empty()); + + const WeldReport report = analyseWeld(mesh); + checkClosedSphere(report); + } + + // Scattered elevations: terraces and radial cliff walls are exercised as well. + { + HexSphereMap map = makeMap(1, 100.f, 4u); + scatterElevation(map); + + HexMeshData mesh; + buildSphereTerrainMesh(map, mesh); + checkClosedSphere(analyseWeld(mesh)); + } + + // A finer level, to catch a crack that only appears when a pentagon meets hexagons. + { + HexSphereMap map = makeMap(2, 100.f, 4u); + scatterElevation(map); + + HexMeshData mesh; + buildSphereTerrainMesh(map, mesh); + const WeldReport report = analyseWeld(mesh); + checkClosedSphere(report); + // Guard against a vacuously small mesh passing the census. + CHECK(report.triangles > 1000u); + } +} + +TEST_CASE("hexmap.sphereMesh.facesPointAwayFromTheCentre") { + HexSphereMap map = makeMap(2, 100.f, 6u); + scatterElevation(map); + + HexMeshData mesh; + buildSphereTerrainMesh(map, mesh); + REQUIRE(!mesh.empty()); + REQUIRE(mesh.hasNormals()); + + // A clockwise fan or a reversed strip would light the planet from inside out. + CHECK_EQ(countInwardFacingTriangles(mesh), static_cast(0)); +} + +TEST_CASE("hexmap.sphereMesh.verticesStayOnTheirSurfaceRadius") { + HexSphereMap map = makeMap(1, 100.f, 2u); + CHECK(map.setElevation(0, HexMetrics::kMaxElevation).ok()); + CHECK(map.setElevation(1, HexMetrics::kMinElevation).ok()); + CHECK(map.setElevation(2, HexMetrics::kMaxElevation).ok()); + + HexMeshData mesh; + buildSphereTerrainMesh(map, mesh); + REQUIRE(!mesh.empty()); + + const float step = map.elevationStep(); + const float lowest = map.sphereRadius() + static_cast(HexMetrics::kMinElevation) * step; + const float highest = map.sphereRadius() + static_cast(HexMetrics::kMaxElevation) * step; + const float deepest = lowest + (HexMetrics::kStreamBedElevationOffset / HexMetrics::kElevationStep) * step; + + // The perturbation slides *along* the surface, so it must not move a vertex in or + // out; every radius has to stay inside the relief the elevations can produce. + const auto& positions = mesh.positions(); + for (std::size_t i = 0; i + 2u < positions.size(); i += 3u) { + const HexVec3 point{positions[i], positions[i + 1u], positions[i + 2u]}; + const float radius = std::sqrt(point.x * point.x + point.y * point.y + point.z * point.z); + CHECK(radius >= deepest - 1e-2f); + CHECK(radius <= highest + 1e-2f); + } +} + +TEST_CASE("hexmap.sphereMesh.isDeterministicAndReusable") { + const HexSphereMap map = makeMap(1, 100.f, 8u); + + HexMeshData first; + HexMeshData second; + buildSphereTerrainMesh(map, first); + buildSphereTerrainMesh(map, second); + + REQUIRE(!first.empty()); + CHECK_EQ(first.vertexCount(), second.vertexCount()); + CHECK_EQ(first.triangleCount(), second.triangleCount()); + CHECK(first.positions() == second.positions()); + CHECK(first.normals() == second.normals()); + CHECK(first.uvs() == second.uvs()); + CHECK(first.indices() == second.indices()); + + // Rebuilding into a mesh that already holds data must replace it, not append. + HexMeshData reused; + buildSphereTerrainMesh(map, reused); + buildSphereTerrainMesh(map, reused); + CHECK_EQ(reused.vertexCount(), first.vertexCount()); + CHECK_EQ(reused.triangleCount(), first.triangleCount()); +} + +TEST_CASE("hexmap.sphereMesh.emptyMapProducesAnEmptyMesh") { + const HexSphereMap map; + HexMeshData mesh; + buildSphereTerrainMesh(map, mesh); + + CHECK(mesh.empty()); + CHECK_EQ(mesh.vertexCount(), static_cast(0)); + CHECK_EQ(mesh.triangleCount(), static_cast(0)); +} + +TEST_CASE("hexmap.sphere.cornerIndicesAgreeAcrossTheThreeCellsAtACorner") { + // The mesher reaches a shared corner from three different cells by index arithmetic + // alone; this is that arithmetic, checked against the topology's own corner table. + for (std::int32_t subdivision = 0; subdivision <= 3; ++subdivision) { + const HexSphereTopology topology = makeTopology(subdivision); + for (HexSphereCell cell = 0; cell < topology.cellCount(); ++cell) { + const std::int32_t edges = topology.neighborCount(cell); + for (std::int32_t d = 0; d < edges; ++d) { + const std::int32_t nextD = (d + 1) % edges; + const HexSphereCell left = topology.neighbor(cell, d); + const HexSphereCell right = topology.neighbor(cell, nextD); + if (left == kNoHexSphereCell || right == kNoHexSphereCell) continue; + + const std::int32_t back = topology.oppositeDirection(cell, d); + const std::int32_t back2 = topology.oppositeDirection(cell, nextD); + REQUIRE(back >= 0); + REQUIRE(back2 >= 0); + + const HexVec3 shared = topology.corner(cell, nextD); + CHECK(identical(topology.corner(left, back), shared)); + CHECK(identical(topology.corner(right, (back2 + 1) % topology.neighborCount(right)), shared)); + } + } + } +} + +// --- spherical generator ---------------------------------------------------- + +namespace { + +/** @brief Generates a planet at `landPercentage` and returns it. */ +[[nodiscard]] HexSphereMap generatePlanet(std::int32_t landPercentage) { + HexSphereMap map = makeMap(2, 100.f, 12u); + HexSphereGeneratorSettings settings{}; + settings.seed = 1234u; + settings.landPercentage = landPercentage; + settings.waterLevel = 0; + auto generated = generateSphereMap(map, settings); + REQUIRE(generated.ok()); + return map; +} + +/** @brief Cells the generator left above their own water line. */ +[[nodiscard]] std::int32_t landCells(const HexSphereMap& map) { + std::int32_t land = 0; + for (HexSphereCell cell = 0; cell < map.cellCount(); ++cell) { + if (map.elevation(cell) > map.waterLevel(cell)) ++land; + } + return land; +} + +} // namespace + +TEST_CASE("hexmap.sphereGenerator.matchesItsLandTargetAndIsDeterministic") { + const HexSphereMap first = generatePlanet(45); + const HexSphereMap second = generatePlanet(45); + + for (HexSphereCell cell = 0; cell < first.cellCount(); ++cell) { + CHECK_EQ(first.elevation(cell), second.elevation(cell)); + CHECK_EQ(first.waterLevel(cell), second.waterLevel(cell)); + CHECK_EQ(first.terrainType(cell), second.terrainType(cell)); + } + + for (HexSphereCell cell = 0; cell < first.cellCount(); ++cell) { + CHECK(first.elevation(cell) >= HexMetrics::kMinElevation); + CHECK(first.elevation(cell) <= HexMetrics::kMaxElevation); + const std::int32_t terrain = first.terrainType(cell); + CHECK(terrain >= 0); + CHECK(terrain < kHexTerrainTypeCount); + } + + // The sea level is picked from the noise's own distribution, so the share of land + // has to follow the requested percentage rather than wherever the field fell. + const std::int32_t expected = first.cellCount() * 45 / 100; + const std::int32_t actual = landCells(first); + CHECK(actual > expected - first.cellCount() / 10); + CHECK(actual < expected + first.cellCount() / 10); +} + +TEST_CASE("hexmap.sphereGenerator.landShareFollowsTheSetting") { + CHECK(landCells(generatePlanet(25)) < landCells(generatePlanet(70))); +} + +TEST_CASE("hexmap.sphereGenerator.rejectsAnEmptyMap") { + HexSphereMap map; + HexSphereGeneratorSettings settings{}; + auto generated = generateSphereMap(map, settings); + + CHECK(!generated.ok()); + CHECK(generated.code() == eve::StatusCode::Rejected); +}