Files
西风逍遥游 fb3f0d8579 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 断言,朝向由
“无内向三角形”断言。
2026-09-18 17:58:01 +08:00

348 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 六边形立体地图(Hex Map)
**脚本入口:** `hexmap`(`eve.HexMap`,模块槽位 `hexmap`)
`hexmap` 是引擎的**可编辑 3D 六边形地图**层:持有尖顶(pointy-top)六边形格网、
按 5×5 分块、按面(terrain / water / river / road)生成 CPU 网格并上传 GPU,同时
提供世界坐标拾取、邻居查询与笔刷编辑。参考实现是 Catlike Coding 的
[Hex Map](https://catlikecoding.com/unity/hex-map/) 工程;坐标、高程台地、悬崖、
水面与河道几何均与之一致。
## 布局与常量
| 项 | 值 | 说明 |
|----|----|------|
| 朝向 | 尖顶 | XZ 平面,+X 向东,+Z 向北,Y 为上 |
| `outerRadius` | 10 | 六边形外接圆半径 |
| `innerRadius` | 8.66025404 | `outerRadius * √3/2` |
| `elevationStep` | 3 | 每级高程的世界高度 |
| `solidFactor` / `waterFactor` | 0.8 / 0.6 | 实心区与水面内缩因子 |
| `terracesPerSlope` | 2(`terraceSteps` = 5) | 斜坡台地级数 |
| `kChunkSizeX/Z` | 5 / 5 | 分块尺寸;地图尺寸必须是它的整数倍 |
| 地面扰动 | XZ ±4,Y ±1.5 | `HexNoise`(种子决定,无需贴图资源) |
偏移坐标到轴坐标:`axial = offset - (z/2, 0)`(`z/2` 向下取整)。世界坐标:
`x = (axialX + 0.5·axialZ) · innerDiameter`,`z = axialZ · outerRadius · 1.5`。
`HexCoordinates::fromWorldPosition` 是其精确逆变换(含立方体取整修正)。
## C++ API 快查
### `HexMetrics`([HexMetrics.h](../../../src/modules/hexmap/HexMetrics.h))
常量与角点/边采样助手:`firstCorner` / `firstSolidCorner` / `firstWaterCorner` /
`bridge` / `waterBridge` / `solidEdgeMiddle` / `terraceLerp` / `wallLerp`,
以及 `elevationY` / `streamBedY` / `waterSurfaceY`。`EdgeVertices` 是边上 5 个
采样点(v1…v5)。
### `HexCoordinates`([HexCoordinates.h](../../../src/modules/hexmap/HexCoordinates.h))
轴坐标 `(x, z)`,`y = -x - z`。`step(direction, distance)`、`distanceTo`、
`fromOffset` / `offsetX` / `offsetZ`、`chunkColumn` / `chunkRow`、
`toWorldPosition` / `fromWorldPosition`。
### `HexMap`([HexMap.h](../../../src/modules/hexmap/HexMap.h))
值类型格网,`reset(cellCountX, cellCountZ, seed)` 建立地图(尺寸必须是 5 的倍数)。
- 拓扑:`contains` / `indexOf` / `coordinatesAt` / `tryGetNeighbor` / `edgeTypeTo`
- 分块:`chunkIndexOf` / `chunkColumnOf` / `chunkRowOf` / `chunkCenter` /
`takeDirtyChunk` / `markAllChunksDirty` / `dirtyChunkCount`
- 拾取:`pickCell(rayOrigin, rayDirection)` —— 无碰撞体,改为在候选格高程平面上
迭代求交并二分收敛
- 查询:`elevation` / `waterLevel` / `terrainType` / `urbanLevel` / `farmLevel` /
`plantLevel` / `specialIndex` / `isUnderwater` / `hasRiver` / `hasRoad` / `isWalled`
- 编辑:`setElevation` / `setWaterLevel` / `setTerrainType` / `setUrbanLevel` /
`setFarmLevel` / `setPlantLevel` / `setSpecialIndex` / `setWalled` /
`setOutgoingRiver` / `removeRiver` / `addRoad` / `removeRoads`
- 笔刷:`editElevation` / `editWaterLevel` / `editTerrainType` / `editFeatureLevel`
(六边形圆盘,`collectBrush` 可单独枚举)
跨格约束与参考工程一致:河流只能顺坡或从湖面流出;道路不能跨两级以上高程、
不能穿过河流中段、城市/特殊地物会移除道路;特殊地物在有河时被忽略。
### 网格生成([HexMapMesh.h](../../../src/modules/hexmap/HexMapMesh.h))
`buildChunkSurfaceMesh(map, chunkIndex, surface, out)`,`surface` 取
`HexSurface::{Terrain, Water, River, Road}`。生成物是 `HexMeshData`
(position / normal / uv / index,三角形不共享顶点,`finalize()` 写平面法线)。
分块所有权与参考工程一致:地面扇形按本块 25 格生成,**只有 NE / E / SE**
方向的混合带、台地与悬崖归属本块,其余由邻块生成,因此块与块之间不会重复或漏缝。
顶点只有两个纹理坐标,地形三层信息按
`u = layerA + layerB·8 + layerC·64`、`v = weightB + weightC·16` 打包;这是当前
顶点布局的**兼容桥**,不是地形数据格式。
`HexSurface::Fog`(值 4)是唯一不能由 `buildChunkSurfaceMesh` 生成的流,因为它
除了格子还需要可见性计数器,由 `buildFogMesh(map, visibility, chunkIndex, out)`
生成:每个"当前不可见"的格子输出一根六棱柱(顶面 + 侧裙),裙脚垂到最低可编辑
高程之下,因此迷雾边界上的悬崖立面也会被覆盖;第一纹理坐标 `u` 承载明暗
(`0` = 曾经探索过但此刻无人看见,`1` = 从未探索)。迷雾流在可见性跨越 0/1 时
自动把所在块标脏。
另外两条流由 [HexFeatures.h](../../../src/modules/hexmap/HexFeatures.h) 生成,也不走
`buildChunkSurfaceMesh`:`HexSurface::Wall`(值 5,`buildWallMesh`)输出城墙、墙塔
与桥梁,`HexSurface::Feature`(值 6,`buildFeatureMesh`)输出 urban / farm / plant /
special 地物。两者共用一条片元着色器,因为第一纹理坐标已经标明了部件种类:
| `u` | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| 部件 | 城墙 | 墙塔 | 桥梁 | urban | farm | plant | special |
城墙的归属规则与地形一致:只有当 `d ∈ {NE, E, SE}` 或该方向已经在网格之外时,
才由本块生成该边的城墙——这样每条内部边恰好被一侧生成,而地图边界仍然闭合。
道路横穿城墙边界时会开出城门(只生成两侧外段 + 两个端帽);高程差产生楔形挡墙。
### 地物与城墙([HexFeatures.h](../../../src/modules/hexmap/HexFeatures.h))
参考工程实例化 prefab 模型;本引擎不携带资源,因此 `HexFeatures.cpp` 用程序化几何
替代(墙=两侧面 + 顶盖,塔/桥/建筑=盒体,plant=压扁八面体,special=锥形柱)。
`HexHashGrid` 是参考工程 `hashGrid` 表的确定性替代:由 `(gridX, gridZ, seed,
channel)` 的整数哈希直接导出五个分量,因此不需要分配表、也没有全局状态,同一地图
种子永远得到同一套地物布局。`featureThreshold(level, index)` 返回参考工程的
三级阈值表 `{0,0,0.4} / {0,0.4,0.6} / {0.4,0.6,0.8}`,越界参数被钳制。
拾取规则与参考工程一致:urban / farm / plant 三个集合各自用格子哈希判定是否
命中,命中的里面哈希最小者胜出;`specialIndex` 非零时该格不生成普通地物。
### 地图生成([HexMapGenerator.h](../../../src/modules/hexmap/HexMapGenerator.h))
`generateHexMap(map, settings)` 把参考工程 `MapGenerator` 的流水线移植为纯 CPU 步骤:
1. 全部格子初始化到 `elevationMinimum`;
2. `regionCount` 个陆地区域从种子格生长(抖动、随机抬升/下沉,遵守地图与区域边界);
3. 海岸线侵蚀直至陆地占比接近 `landPercentage`;
4. 顺坡开凿河流(含湖泊概率与已有的汇流判定);
5. 40 轮下风向水汽传输(蒸发、降水、坡面径流、下渗),得到湿度与温度;
6. 按参考工程的生物群系表写地形类型与植被等级。
可复现性只依赖 `settings.seed` 与网格本身:不使用时间、`random_device` 或可变静态
状态。`elevationMaximum` 会被钳制到 `HexMetrics::kMaxElevation`;不合法或放不下的
区域参数在改动地图之前就以 `InvalidArgument` 拒绝。生成后的地图**全部格子未探索**,
因此调用方必须重新放置单位并重建全部块。
### 搜索与寻路([HexSearch.h](../../../src/modules/hexmap/HexSearch.h))
- `HexSearchContext`:可复用的搜索草稿(每格一条 `HexSearchData` + 按优先级分桶
的前沿队列)。用**相位**(`beginPhase()`,每次 +2)代替"清空 visited",因此一次
搜索不付全图重置的代价。
- `isValidDestination(map, c, occupied)`:`explored && explorable && !underwater &&
!occupied`;地形部分由 `HexCell.h` 的 `canHoldUnit()` 单点定义。
- `moveCost(map, from, to, direction, occupied)`:悬崖与墙界不可通行;起点有路 = 1,
否则平地 5 / 斜坡 10,再加目标格的 urban+farm+plant 等级。返回**负数**表示阻断。
- `findPath(map, scratch, from, to, rules, occupied) -> Result<HexPath>`:A\*(启发式
为六边形距离),代价按 `speed` 折算成"回合",`HexPath::turns` 给出每个格子到达
时的回合数。目标不可进入时返回 `NotFound`,端点越界或 scratch 尺寸不匹配返回
`InvalidArgument`。
- `collectVisibleCells(map, scratch, from, range, out)`:视线可达格(距离 + 目标格
`viewElevation` 与 `range + 起点 viewElevation` 比较,且不超过直接六边形距离)。
### 可见性与迷雾([HexVisibility.h](../../../src/modules/hexmap/HexVisibility.h))
`HexVisibility` 是**引用计数**而非布尔:多个观察者重叠时,只有最后一个离开才让格子
转回不可见;某格第一次被看见时同时把地图上的 `explored` **单向闩锁**置位,此后不再
回落。`increase` / `decrease` / `clear` / `resetVisibility`(按当前单位重算全图)。
### 单位([HexUnits.h](../../../src/modules/hexmap/HexUnits.h))
`HexUnitRegistry` 持有单位、寻路占用与视野贡献:
- `addUnit` 立即授予视野;`unitIdAt` / `isOccupied` 用于寻路占用谓词
(`occupancyQuery()` 返回借用 `*this` 的可调用对象)。
- 单位 id 就是注册表下标,删除会让后面的 id 前移;调用方不要跨删除缓存 id。
- 移动沿二次贝塞尔曲线(Unity 参考工程的转角做法),`advance(map, visibility,
scratch, id, dt)` 既推进动画又返回**当前位姿样本**(世界坐标已含高程与扰动),
`dt <= 0` 表示只读不推进。**目的地自 `beginTravel` 起即被占用**,视野则跟着
实际走过的格子移动(`visionIndex`)。
- `restore` 先清空再校验,被拒绝的载荷不会留下"半恢复"的注册表。
### 存档([HexSerializer.h](../../../src/modules/hexmap/HexSerializer.h))
`saveHexMap(map, units, out)` / `loadHexMap(bytes, map, units)`:小端、显式逐字节
写出的自描述载荷(magic `EVEHEX\0\0` + 版本 + 尺寸 + 种子 + 每格 `HexValues` /
`HexFlags` + 单位表)。版本不匹配会**拒绝**而不是猜测;载荷在触碰输出前整体校验
(长度、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
hexmap.newGrid(gfx, cellCountX, cellCountZ, seed) // -> Result
hexmap.hasGrid() / cellCountX() / cellCountZ() / chunkCountX() / chunkCountZ() / chunkCount() / seed()
hexmap.surfaceCount() // -> 7,见下面的 surface 编号表
hexmap.takeDirtyChunk() // -> 脏块索引,-1 表示没有
hexmap.rebuildChunk(gfx, chunkIndex) // -> 生成出几何的面数
hexmap.rebuildDirtyChunks(gfx) // -> 重建全部脏块,返回面数合计
hexmap.chunkMeshAt(chunkIndex, surface) // -> Mesh(借用),无几何时为 null
hexmap.releaseMeshes(gfx) // 释放本模块创建的全部网格
hexmap.elevation(x, z) / waterLevel(x, z) / terrainType(x, z) / urbanLevel(x, z) / ...
hexmap.isUnderwater(x, z) / hasRiver(x, z) / hasRoad(x, z) / isWalled(x, z)
hexmap.specialIndex(x, z) / farmLevel(x, z) / plantLevel(x, z)
hexmap.setElevation(x, z, v) / setWaterLevel(x, z, v) / setTerrainType(x, z, v) / ...
hexmap.setUrbanLevel(x, z, v) / setFarmLevel(x, z, v) / setPlantLevel(x, z, v) / setSpecialIndex(x, z, v)
hexmap.setWalled(x, z, v) / setOutgoingRiver(x, z, direction) / removeRiver(x, z)
hexmap.addRoad(x, z, direction) / removeRoads(x, z)
hexmap.editElevation(x, z, radius, delta) / editWaterLevel(x, z, radius, delta)
hexmap.editTerrainType(x, z, radius, terrainType) / editFeatureLevel(x, z, radius, feature, delta)
// 世界坐标(含高程与扰动);同一坐标系的逐分量版本,供脚本直接喂给 renderable
hexmap.cellPositionX(x, z) / cellPositionY(x, z) / cellPositionZ(x, z)
hexmap.chunkCenterX(chunkIndex) / chunkCenterY(chunkIndex) / chunkCenterZ(chunkIndex)
hexmap.pickCell(ox, oy, oz, dx, dy, dz) // -> {ok, value=[x, z]}
// 迷雾
hexmap.isExplored(x, z) / isExplorable(x, z) / setExplored(x, z, v) / setExplorable(x, z, v)
hexmap.isCellVisible(x, z) / visibleCellCount() / resetVisibility()
// 单位
hexmap.unitCount() / addUnit(x, z, orientation) / removeUnit(id) / removeAllUnits()
hexmap.unitIdAt(x, z)
hexmap.unitSample(id) // -> {ok, value=[cellX, cellZ, worldX, worldY, worldZ, yaw, traveling]}
hexmap.travelUnit(id, path) / advanceUnits(dt)
// 寻路 / 存档 / 生成
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`。
`x, z` 是偏移坐标(列、行)——`pickCell` 的返回值同样是偏移坐标,与 `elevation` /
`editElevation` / `cellPositionX` 等所有格子接口同一坐标系,因此拾取结果可以直接回喂
给它们(`findPath` 与 `unitSample` 返回的格子也已经是偏移坐标)。`unitSample` 复用
`advance(dt=0)`,因此返回的是含高程与扰动的插值位姿,不是地格中心。`generateMap` 按
**当前网格尺寸**重新生成,会丢弃全部单位与迷雾状态,调用方必须在成功后重新放置单位
并重建全部块。
球面接口用 `cell`(稠密 id,`0 .. sphereCellCount()-1`),与平面接口的偏移坐标 `(x, z)`
是**两套互不相通的地址空间**:不要拿平面坐标去喂球面接口,反之亦然。`sphereDirection`
与 `sphereCornerDirection` 返回 `[x, y, z]` 单位向量。`newSphere` 只重建拓扑,
`generateSphere` 才写入单元并重建网格;两者都会释放上一颗星球的网格,因此调用方必须在
成功后重新绑定 renderable。`rebuildSphere` 是批量编辑后的重建入口——球面是**整张一个
网格**(没有分块),每次重建都是全量,应当攒够一批编辑再调一次,而不是每改一格就调。
## 生命周期与所有权
- 模块**只持有一张活动地图**(参考工程的编辑器同样只编辑一张格子);`newGrid`
会释放上一张地图生成的全部 GPU 网格。
- 模块拥有 `HexMap` 与它创建的每个 `graphics::Mesh`;`chunkMeshAt` 返回**借用**
指针,`Renderable3D` 只能在模块存活且未换图期间引用它。
- 某面在一张地图内可能从"有几何"变为"无几何"(例如最后一条道路被擦掉):此时
该面的 GPU 网格**延迟释放**(保留到下次 `newGrid`),`chunkMeshAt` 返回
`null`,调用方据此隐藏 renderable,不会出现悬垂指针。
- `loadMap` 走 `adoptGrid`:先释放旧网格、接管已解码的地图、重建搜索草稿与迷雾
计数器,再恢复单位。**调用方必须在成功后重建全部块并重新绑定 renderable**,
因为所有块都是脏的且没有任何 renderable 指向新网格。
- 主/渲染线程亲和;不调用未知回调,不跨外部调用持锁。
## 示例
`examples/hex-terrain-3d` 是本模块的完整示例(与之无关的 `examples/hex-terrain` 是
`mesh.hexterrain` 配方,保持原样):`WASD` 平移、`Q/E` 旋转、滚轮缩放,
`1`–`5` 切换高程/水面/地形/河流/道路模式,`[`/`]` 调整笔刷半径,`,`/`.` 切换地形
调色板,左右键升降或拖拽绘制河流与道路,`R` 换种子、`F` 换地图尺寸、`N` 重建。
第二轮补上战争迷雾、单位与寻路、存档:`tab` 切换选中单位,`g` 规划到光标格的路径,
`space` 出发,`escape` 取消预览,`t`/`y` 增删单位,`F5`/`F9` 存档与读档。第三轮补上
城墙/桥梁/地物与地图生成器:`m` 按当前尺寸生成一张程序化地图(重新随机种子)。
存档载荷经 `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 世界单位的地形会被静默裁掉,远端的缩放档位因此只
能看到空屏。示例现在每帧按地图跨度推导远裁剪面,并让最远缩放距离随之放大
(`ZOOM_FAR_MARGIN`),所以任何地图尺寸都能完整入画。
## 已知边界(尚未实现)
瀑布与河口(estuary)几何尚未移植;河道是等宽中心线带,未做 7 分支交汇解算;迷雾按
"已探索 / 当前可见"两态渲染(不区分"当前可见"之外的中间态透明度),且迷雾柱顶取
自身与邻居的最高面,因此在高差极大的边界上会比参考工程略"鼓";地物是程序化几何
而非参考工程的 prefab 比例。
水面与参考工程逐方向对齐:邻居是水就发一个中心三角形 + 桥接四边形(桥接只由
NE/E/SE 侧发,避免水/水边界重复),邻居是陆地就发四个细分楔形三角形 + 四段岸带;
岸带**平铺在水面高度**,只借用邻格的实体角点平面位置,不借用它的高程——倾斜到岸顶的
写法会在每条六边形边上留下一条亮带。`HexSurface::Water` 因此完全水平,测试
`hexmap.mesh.waterSurfaceIsFlatAtAShore` 会断言所有水面顶点同高、所有法线朝 +Y。
地图生成器的两处刻意偏离参考工程(均已在源码中注释):侵蚀取共享搜索前沿中最低的
格子,而参考工程是均匀随机挑选可侵蚀格;温度抖动使用私有的值噪声,而参考工程采样
噪声贴图。生成器的 `HemisphereMode` 只实现参考工程的 `North` 映射。
球面后端的已知边界:球面**没有**河流、道路、城墙、地物、迷雾与单位的网格与查询接口
(`HexFlags` 的位可以设置,但没有对应的球面几何与寻路);海洋是每格一个六边形盖面,
没有浅滩、折射与岸线浪花,且按模块自身的规则 `waterLevel == elevation` 不算淹没,
所以海平面上的格子会露出;崖壁是径向直墙,没有悬垂或侵蚀细节;球面生成器没有河流、
侵蚀与板块构造——平面的 `HexMapGenerator` 没有移植过来。球面地图也**没有存档格式**。