feat(hexmap): 新增二十面体球面后端与 examples/hex-planet 星球示例

在已合入的平面 hexmap 之上增加球面世界,复用其单元记录(HexCellData / HexValues /
HexFlags)、几何常量(HexMetrics)、网格容器(HexMeshData)与地形顶点编码
(HexTerrainVertexCode):

- HexSphereTopology:二十面体(Goldberg)拓扑。单元是细分后二十面体的顶点,
  cellCount = 10f^2 + 2,12 个五边形恒为 [0,12)。方向不是算术:五边形只有 5 条边,
  跨边查询一律走 directionOf(cell, other),因为不存在处处成立的 opposite。
- HexSphereMap:球面对应物。逐格脏标记(球面无分块网格),高程为径向,
  含拾取、距离与笔刷。
- HexSphereMesh:整球地形与海洋网格。平面版用弦 v1->v5 跨越梯田带,这在平面上安全
  只因其 5 个采样点共线;球面采样点在弧上,故按扇形同样拆成 4 段以免 T 型接缝。
- HexSphereGenerator:三维值噪声 fBm、按噪声自身分位数定海平面、生物群系。
- HexMapModule:28 个 hexmap.sphere* 脚本绑定。
- examples/hex-planet:轨道相机 + 两个片元着色器。平面着色器把 vWorldPos.y 当海拔、
  N.y 当“上”、细节噪声投影到 XZ、远处加距离雾,这四点在球面上均不成立。
- test/hexmap_sphere.cpp:31 个用例;docs/usr/modules/hexmap.md 补充球面章节。

验证:hexmap 套件 66/66 通过(含 31 个球面用例);示例实机跑通并自捕获
hex-planet.png。网格闭合性由焊接后的欧拉特征 V - E + F = 2 断言,朝向由
“无内向三角形”断言。
This commit is contained in:
西风逍遥游 committed 2026-09-18 17:58:01 +08:00
1 parent 2df8f97f39
commit fb3f0d8579
21 files changed
+4592

No files matched your search

+80
View File
@@ -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=<cell>}
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` 没有移植过来。球面地图也**没有存档格式**。
+1
View File
@@ -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、顶点编辑器 |
+105
View File
@@ -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.
+9
View File
@@ -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
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

+359
View File
@@ -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();
};
@@ -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);
}
@@ -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);
}
+172
View File
@@ -7,6 +7,7 @@
#include "graphics/Mesh.h"
#include "hexmap/HexFeatures.h"
#include "hexmap/HexMapGenerator.h"
#include "hexmap/HexSphereMesh.h"
#include <simplesquirrel/simplesquirrel.hpp>
@@ -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<void> 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<void> 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<void>::failure(generated.status());
return rebuildSphere(gfx);
}
Result<void> 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<int>(data.vertexCount());
const int indexCount = static_cast<int>(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<void>::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<std::uint32_t>(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<std::uint32_t>(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<std::int64_t>(cell)); };
if (!self) {
return script::projectResult(
vm,
Result<HexSphereCell>::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
+73
View File
@@ -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<void> 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<void> 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<void> 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
+191
View File
@@ -0,0 +1,191 @@
#include "hexmap/HexSphereGenerator.h"
#include "common/Diagnostic.h"
#include "hexmap/HexCell.h"
#include "hexmap/HexMetrics.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <string>
#include <vector>
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<std::uint32_t>(x) * 0x8da6b343u;
h ^= static_cast<std::uint32_t>(y) * 0xd8163841u;
h ^= static_cast<std::uint32_t>(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<float>(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<int>(fx);
const int y0 = static_cast<int>(fy);
const int z0 = static_cast<int>(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<std::uint32_t>(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<float>& sorted, float fraction) noexcept {
if (sorted.empty()) return 0.f;
const float position = std::clamp(fraction, 0.f, 1.f) * static_cast<float>(sorted.size() - 1);
return sorted[static_cast<std::size_t>(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<std::int32_t>(HexTerrainType::Snow);
if (elevation >= 7) return static_cast<std::int32_t>(HexTerrainType::Snow);
if (elevation >= 5) return static_cast<std::int32_t>(HexTerrainType::Stone);
if (absLatitude > 0.72f && elevation >= 2) return static_cast<std::int32_t>(HexTerrainType::Snow);
// Flooded floor: dark, and mostly hidden by the ocean surface.
if (elevation <= waterLevel) return static_cast<std::int32_t>(HexTerrainType::Mud);
if (elevation == waterLevel + 1) return static_cast<std::int32_t>(HexTerrainType::Sand);
if (elevation >= 4) return static_cast<std::int32_t>(HexTerrainType::Stone);
if (moisture < 0.36f && absLatitude < 0.45f) return static_cast<std::int32_t>(HexTerrainType::Sand);
if (moisture > 0.62f) return static_cast<std::int32_t>(HexTerrainType::Mud);
return static_cast<std::int32_t>(HexTerrainType::Grass);
}
} // namespace
Result<void> generateSphereMap(HexSphereMap& map, const HexSphereGeneratorSettings& settings) {
if (map.empty()) {
return Result<void>::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<float> continents(static_cast<std::size_t>(cellCount));
std::vector<float> moisture(static_cast<std::size_t>(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<std::size_t>(cell)] = fbm3(coarse, octaves, settings.seed);
moisture[static_cast<std::size_t>(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<float> sorted = continents;
std::sort(sorted.begin(), sorted.end());
const float landFraction = std::clamp(static_cast<float>(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<float>(HexMetrics::kMaxElevation - waterLevel - 1);
const float oceanSpan = static_cast<float>(waterLevel - HexMetrics::kMinElevation);
for (HexSphereCell cell = 0; cell < cellCount; ++cell) {
const float above = continents[static_cast<std::size_t>(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::int32_t>(std::lround(unit * landSpan));
} else {
elevation = waterLevel + static_cast<std::int32_t>(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<std::size_t>(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<void>::success();
}
} // namespace eve::hexmap
+53
View File
@@ -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 <cstdint>
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<void> generateSphereMap(HexSphereMap& map, const HexSphereGeneratorSettings& settings);
} // namespace eve::hexmap
+683
View File
@@ -0,0 +1,683 @@
#include "hexmap/HexSphereMap.h"
#include "common/Diagnostic.h"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <string>
#include <utility>
namespace eve::hexmap {
namespace {
/** @brief Builds the module's standard "bad argument" void failure. */
[[nodiscard]] Result<void> invalidArgument(std::string message) {
return Result<void>::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<void> 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<void>::failure(built.status());
topology_ = std::move(built).takeValue();
sphereRadius_ = radius;
elevationStep_ = radius * kDefaultElevationRatio;
noise_.reset(seed);
cells_.assign(static_cast<std::size_t>(topology_.cellCount()), HexCellData{});
cellDirty_.assign(cells_.size(), 0u);
dirtyQueue_.clear();
dirtyHead_ = 0;
revision_ = 0;
markAllDirty();
return Result<void>::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<float>(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<float>(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<std::size_t>(cell)];
}
HexCellData* HexSphereMap::mutableCell(HexSphereCell cell) noexcept {
if (!contains(cell)) return nullptr;
return &cells_[static_cast<std::size_t>(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<HexDirection>(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<HexSphereCell> HexSphereMap::pickCell(HexVec3 rayOrigin, HexVec3 rayDirection) const {
if (empty())
return Result<HexSphereCell>::failure(
Diagnostic::error(DiagnosticCode::NotFound, "hex sphere map is empty", "hexmap.sphere.pick"));
const float directionLength = length3(rayDirection);
if (!(directionLength > 1e-6f))
return Result<HexSphereCell>::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<float>(HexMetrics::kMaxElevation) * elevationStep_;
float enter = 0.f;
float exit = 0.f;
if (!raySphereSpan(rayOrigin, direction, boundingRadius, enter, exit))
return Result<HexSphereCell>::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<HexSphereCell>::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<HexSphereCell>::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<std::uint8_t> visited(cells_.size(), 0u);
std::vector<HexSphereCell> frontier;
std::vector<HexSphereCell> next;
frontier.push_back(a);
visited[static_cast<std::size_t>(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<std::size_t>(neighbour)]) continue;
if (neighbour == b) return depth;
visited[static_cast<std::size_t>(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<HexSphereCell>& out) const {
out.clear();
if (!contains(center) || radius < 0) return;
std::vector<std::uint8_t> visited(cells_.size(), 0u);
out.push_back(center);
visited[static_cast<std::size_t>(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<std::size_t>(neighbour)]) continue;
visited[static_cast<std::size_t>(neighbour)] = 1u;
out.push_back(neighbour);
}
}
}
}
// --- dirty tracking ---------------------------------------------------------
void HexSphereMap::markCellDirty(HexSphereCell cell) noexcept {
if (!contains(cell)) return;
if (cellDirty_[static_cast<std::size_t>(cell)]) return;
cellDirty_[static_cast<std::size_t>(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<HexSphereCell>(i));
}
}
std::int32_t HexSphereMap::takeDirtyCell() noexcept {
while (dirtyHead_ < dirtyQueue_.size()) {
const HexSphereCell cell = dirtyQueue_[dirtyHead_++];
if (!contains(cell)) continue;
if (!cellDirty_[static_cast<std::size_t>(cell)]) continue;
cellDirty_[static_cast<std::size_t>(cell)] = 0u;
if (dirtyHead_ >= 4096u && dirtyHead_ * 2u >= dirtyQueue_.size()) {
dirtyQueue_.erase(dirtyQueue_.begin(),
dirtyQueue_.begin() + static_cast<std::ptrdiff_t>(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<HexDirection>(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<void> 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<void>::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<HexDirection>(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<void>::success();
}
Result<void> 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<void>::success();
data->values = data->values.withWaterLevel(clamped);
refreshCellDependents(c);
validateRivers(c);
return Result<void>::success();
}
Result<void> 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<void>::success();
}
Result<void> 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<void>::success();
}
Result<void> 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<void>::success();
}
Result<void> 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<void>::success();
}
Result<void> 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<void>::success();
data->values = data->values.withSpecialIndex(clampInt(index, 0, 3));
removeRoads(c).ignore("special features replace roads");
refreshCellDependents(c);
return Result<void>::success();
}
Result<void> 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<void>::success();
}
Result<void> 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<void>::success();
}
Result<void> 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<void>::success();
}
Result<void> 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<void>::success();
}
Result<void> 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<HexDirection>(direction);
const auto in = static_cast<HexDirection>(back);
if (data->flags.hasAnyRiverOut() && data->flags.hasRiverOut(out)) return Result<void>::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<HexDirection>(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<HexDirection>(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<void>::success();
}
Result<void> 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<void>::success();
for (std::int32_t d = 0; d < kHexDirectionCount; ++d) {
const auto direction = static_cast<HexDirection>(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<HexDirection>(back);
HexCellData* other = mutableCell(neighbour);
if (other) other->flags = other->flags.withoutRiverIn(in).withoutRiverOut(in);
refreshCellDependents(neighbour);
}
refreshCellDependents(c);
return Result<void>::success();
}
Result<void> 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<HexDirection>(direction);
const auto in = static_cast<HexDirection>(back);
if (data->flags.hasRoad(out)) return Result<void>::success();
if (data->flags.hasRiverThrough(out)) return Result<void>::success();
if (data->flags.hasRiver() && !data->flags.hasRiverBeginOrEnd()) {
// A road may only join a river at a begin or end cell.
return Result<void>::success();
}
HexCellData* other = mutableCell(neighbour);
if (other->values.specialIndex() != 0 || data->values.specialIndex() != 0) return Result<void>::success();
if (other->flags.hasRiverThrough(in)) return Result<void>::success();
const std::int32_t delta = elevation(neighbour) - elevation(c);
if (delta > 1 || delta < -1) return Result<void>::success();
data->flags = data->flags.withRoad(out);
other->flags = other->flags.withRoad(in);
refreshCellDependents(c);
refreshCellDependents(neighbour);
return Result<void>::success();
}
Result<void> 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<void>::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<HexDirection>(back));
refreshCellDependents(neighbour);
}
refreshCellDependents(c);
return Result<void>::success();
}
// --- brush authoring --------------------------------------------------------
Result<void> 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<HexSphereCell> 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<void>::success();
}
Result<void> 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<HexSphereCell> cells;
collectBrush(center, radius, cells);
for (const HexSphereCell cell : cells)
setWaterLevel(cell, waterLevel(cell) + delta).ignore("brush water edit clamps per cell");
return Result<void>::success();
}
Result<void> 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<HexSphereCell> cells;
collectBrush(center, radius, cells);
for (const HexSphereCell cell : cells)
setTerrainType(cell, terrainType).ignore("brush terrain edit clamps per cell");
return Result<void>::success();
}
Result<void> 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<HexSphereCell> 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<void>::success();
}
} // namespace eve::hexmap
+346
View File
@@ -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 <cstdint>
#include <vector>
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<void> 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<std::int32_t>(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<HexSphereCell> 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<HexSphereCell>& out) const;
/** @brief Raises or lowers every cell within `radius` steps of `center`. */
[[nodiscard]] Result<void> editElevation(HexSphereCell center, std::int32_t radius, std::int32_t delta);
/** @brief Applies `additive` water-level change over a hex brush. */
[[nodiscard]] Result<void> editWaterLevel(HexSphereCell center, std::int32_t radius, std::int32_t delta);
/** @brief Paints a terrain palette index over a hex brush. */
[[nodiscard]] Result<void> 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<void> 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<void> setElevation(HexSphereCell c, std::int32_t elevation);
/** @brief Sets a cell's water level, clamped to `[0, kMaxElevation]`. */
[[nodiscard]] Result<void> setWaterLevel(HexSphereCell c, std::int32_t waterLevel);
/** @brief Sets a cell's terrain palette index, clamped to the palette range. */
[[nodiscard]] Result<void> setTerrainType(HexSphereCell c, std::int32_t terrainType);
/** @brief Sets a cell's urban level, clamped to `[0, 3]`. */
[[nodiscard]] Result<void> setUrbanLevel(HexSphereCell c, std::int32_t level);
/** @brief Sets a cell's farm level, clamped to `[0, 3]`. */
[[nodiscard]] Result<void> setFarmLevel(HexSphereCell c, std::int32_t level);
/** @brief Sets a cell's plant level, clamped to `[0, 3]`. */
[[nodiscard]] Result<void> setPlantLevel(HexSphereCell c, std::int32_t level);
/** @brief Sets a cell's special-feature index; ignored while the cell carries a river. */
[[nodiscard]] Result<void> setSpecialIndex(HexSphereCell c, std::int32_t index);
/** @brief Sets whether a cell is walled. */
[[nodiscard]] Result<void> setWalled(HexSphereCell c, bool walled);
/** @brief Sets the explored (fog-of-war) latch of a cell. */
[[nodiscard]] Result<void> setExplored(HexSphereCell c, bool explored);
/** @brief Sets whether a cell may ever be explored. */
[[nodiscard]] Result<void> 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<void> setCellState(HexSphereCell c, HexValues values, HexFlags flags);
/** @brief Forces a river to leave the cell through `direction`, mirroring the neighbour. */
[[nodiscard]] Result<void> setOutgoingRiver(HexSphereCell c, std::int32_t direction);
/** @brief Removes every river connection of the cell and its neighbours. */
[[nodiscard]] Result<void> removeRiver(HexSphereCell c);
/** @brief Adds a road through `direction` when both cells can carry one. */
[[nodiscard]] Result<void> addRoad(HexSphereCell c, std::int32_t direction);
/** @brief Removes every road of the cell and its neighbours. */
[[nodiscard]] Result<void> 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<std::int32_t>(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<HexCellData> cells_;
std::vector<std::uint8_t> cellDirty_;
std::vector<std::int32_t> dirtyQueue_;
std::size_t dirtyHead_ = 0;
std::uint64_t revision_ = 0;
float sphereRadius_ = 0.f;
float elevationStep_ = 0.f;
};
} // namespace eve::hexmap
+705
View File
@@ -0,0 +1,705 @@
#include "hexmap/HexSphereMesh.h"
#include "hexmap/HexMapMesh.h"
#include "hexmap/HexMetrics.h"
#include "hexmap/HexNoise.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <cstddef>
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<float>(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<float>(step) * HexMetrics::kHorizontalTerraceStepSize;
const float vertical = static_cast<float>((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<HexDirection>(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<HexDirection>(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<HexVec3, 3> positions{corner.up, corner.left, corner.right};
const std::array<const HexCellData*, 3> cells{corner.upCell, corner.leftCell, corner.rightCell};
std::array<std::int32_t, 3> 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<std::size_t>(order[static_cast<std::size_t>(j)])]) <
elevationOf(cells[static_cast<std::size_t>(order[static_cast<std::size_t>(i)])])) {
std::swap(order[static_cast<std::size_t>(i)], order[static_cast<std::size_t>(j)]);
}
}
}
const HexVec3 bottom = positions[static_cast<std::size_t>(order[0])];
const HexVec3 low = positions[static_cast<std::size_t>(order[1])];
const HexVec3 high = positions[static_cast<std::size_t>(order[2])];
const HexCellData* bottomCell = cells[static_cast<std::size_t>(order[0])];
const HexCellData* lowCell = cells[static_cast<std::size_t>(order[1])];
const HexCellData* highCell = cells[static_cast<std::size_t>(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<float>(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<float>(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<float>(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<std::uint32_t>(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<float>(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<std::uint32_t>(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<std::uint32_t>(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<std::uint32_t>(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<std::uint32_t>(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<std::uint32_t>(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<std::uint32_t>(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<float>(i) * 0.25f;
const float t1 = static_cast<float>(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<float>(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<float>(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<std::uint32_t>(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
+58
View File
@@ -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
+372
View File
@@ -0,0 +1,372 @@
#include "hexmap/HexSphereTopology.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <unordered_map>
#include <utility>
#include <vector>
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<std::uint32_t>(i < j ? i : j);
const std::uint32_t high = static_cast<std::uint32_t>(i < j ? j : i);
return (static_cast<std::uint64_t>(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<HexSphereTopology> sphereFailure(const char* message) {
return Result<HexSphereTopology>::failure(
Diagnostic::error(DiagnosticCode::InvalidArgument, message, "hexmap.sphere"));
}
} // namespace
Result<HexSphereTopology> 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<Vec> vertices;
vertices.reserve(12u);
for (const auto& vertex : kIcosahedronVertices) vertices.push_back(normalize(Vec{vertex[0], vertex[1], vertex[2]}));
std::vector<Triangle> 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<std::uint64_t, std::int32_t> midpoints;
midpoints.reserve(vertices.size() * 2u);
std::vector<Triangle> 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<std::int32_t>(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<std::int32_t>(vertices.size());
topology.directions_ = std::move(vertices);
// --- dual corners: one per primal face ----------------------------------
std::vector<Vec> 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<std::int32_t>(triangles.size());
std::vector<std::int32_t> incidenceCount(static_cast<std::size_t>(cellCount), 0);
for (const Triangle& triangle : triangles) {
++incidenceCount[triangle.a];
++incidenceCount[triangle.b];
++incidenceCount[triangle.c];
}
std::vector<std::int32_t> incidenceOffset(static_cast<std::size_t>(cellCount) + 1u, 0);
for (std::int32_t cell = 0; cell < cellCount; ++cell) {
incidenceOffset[cell + 1] = incidenceOffset[cell] + incidenceCount[cell];
}
std::vector<std::int32_t> incidence(static_cast<std::size_t>(incidenceOffset[cellCount]), -1);
{
std::vector<std::int32_t> 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<std::uint64_t, EdgeFaces> 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<std::size_t>(cellCount) + 1u, 0);
topology.neighbors_.assign(static_cast<std::size_t>(incidenceOffset[cellCount]), 0);
topology.corners_.assign(topology.neighbors_.size(), 0);
std::vector<std::int32_t> 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<std::int32_t>(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<HexSphereTopology>::success(std::move(topology));
}
std::int32_t HexSphereTopology::neighborCount(HexSphereCell cell) const noexcept {
if (!contains(cell)) return 0;
return cellOffsets_[static_cast<std::size_t>(cell) + 1u] - cellOffsets_[static_cast<std::size_t>(cell)];
}
HexSphereCell HexSphereTopology::neighbor(HexSphereCell cell, std::int32_t direction) const noexcept {
if (direction < 0 || direction >= neighborCount(cell)) return kNoHexSphereCell;
return neighbors_[static_cast<std::size_t>(cellOffsets_[static_cast<std::size_t>(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<std::size_t>(cell)];
const std::int32_t degree = neighborCount(cell);
for (std::int32_t direction = 0; direction < degree; ++direction) {
if (neighbors_[static_cast<std::size_t>(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<std::size_t>(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<std::size_t>(cellOffsets_[static_cast<std::size_t>(cell)] + cornerIndex)];
if (id < 0 || id >= cornerCount()) return HexVec3{0.f, 1.f, 0.f};
return cornerDirections_[static_cast<std::size_t>(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<std::size_t>(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<std::size_t>(cellOffsets_[static_cast<std::size_t>(current)] + direction)];
const float score = dot(directions_[static_cast<std::size_t>(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
+158
View File
@@ -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 <cstdint>
#include <vector>
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<HexSphereTopology> 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<std::int32_t>(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<std::int32_t>(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<std::int32_t>(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<HexVec3> directions_; /**< Cell centres, unit length. */
std::vector<std::int32_t> cellOffsets_; /**< `cellCount + 1` offsets into the two flat tables. */
std::vector<HexSphereCell> neighbors_; /**< Neighbour per (cell, direction). */
std::vector<std::int32_t> corners_; /**< Corner id per (cell, direction). */
std::vector<HexVec3> cornerDirections_; /**< Corner positions, unit length. */
std::int32_t subdivision_ = 0;
std::int32_t frequency_ = 1;
};
} // namespace eve::hexmap
+955
View File
@@ -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 <algorithm>
#include <array>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <map>
#include <utility>
#include <vector>
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<float>(index) + 0.5f) / static_cast<float>(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<float>(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<HexVec3> groups;
std::vector<std::int32_t> 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<std::int32_t>(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<HexSphereCell> drainDirty(HexSphereMap& map) {
std::vector<HexSphereCell> dirty;
for (HexSphereCell cell = map.takeDirtyCell(); cell != kNoHexSphereCell; cell = map.takeDirtyCell())
dirty.push_back(cell);
return dirty;
}
[[nodiscard]] bool holds(const std::vector<HexSphereCell>& 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<HexSphereCell>{0, 13, 40}) {
for (const std::int32_t level : std::vector<std::int32_t>{-4, -1, 0, 3, 8}) {
CHECK(map.setElevation(cell, level).ok());
CHECK_EQ(map.elevation(cell), level);
const float radius = 100.f + static_cast<float>(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<std::int32_t>(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<std::int32_t>(HexTerrainType::Snow));
// Every edit owed a rebuild of the cell and of all of its neighbours.
const std::vector<HexSphereCell> 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<std::size_t>(map.cellCount());
std::vector<std::uint8_t> 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<std::size_t>(cell) < expected);
CHECK_EQ(seen[static_cast<std::size_t>(cell)], 0);
seen[static_cast<std::size_t>(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<HexSphereCell>{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<HexSphereCell>{0, 13, 100}) {
for (const HexSphereCell b : std::vector<HexSphereCell>{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<HexSphereCell> cells;
map.collectBrush(0, 0, cells);
CHECK_EQ(static_cast<std::int32_t>(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<std::int32_t>(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<std::int32_t>(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<std::uint8_t> inBrush(static_cast<std::size_t>(map.cellCount()), 0u);
for (const HexSphereCell cell : cells) inBrush[static_cast<std::size_t>(cell)] = 1u;
for (HexSphereCell cell = 0; cell < map.cellCount(); ++cell) {
const bool within = map.distance(13, cell) <= radius;
CHECK_EQ(static_cast<bool>(inBrush[static_cast<std::size_t>(cell)]), within);
}
}
// Breadth-first order is deterministic for a given topology.
std::vector<HexSphereCell> 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<HexSphereCell>{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<float>::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<int>(std::llround(static_cast<double>(p.x) * kQuantum)),
static_cast<int>(std::llround(static_cast<double>(p.y) * kQuantum)),
static_cast<int>(std::llround(static_cast<double>(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<WeldKey, std::uint32_t> welded;
std::vector<std::uint32_t> 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<std::uint32_t>(welded.size()));
ids.push_back(inserted.first->second);
}
std::map<std::pair<std::uint32_t, std::uint32_t>, 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<std::size_t>(0));
CHECK_EQ(report.nonManifold, static_cast<std::size_t>(0));
CHECK_EQ(report.vertices + report.triangles - report.edges, static_cast<std::size_t>(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<std::size_t>(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<std::size_t>(indices[t + static_cast<std::size_t>(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<std::size_t>(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<float>(HexMetrics::kMinElevation) * step;
const float highest = map.sphereRadius() + static_cast<float>(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<std::size_t>(0));
CHECK_EQ(mesh.triangleCount(), static_cast<std::size_t>(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);
}