在已合入的平面 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 断言,朝向由 “无内向三角形”断言。
24 KiB
六边形立体地图(Hex Map)
脚本入口: hexmap(eve.HexMap,模块槽位 hexmap)
hexmap 是引擎的可编辑 3D 六边形地图层:持有尖顶(pointy-top)六边形格网、
按 5×5 分块、按面(terrain / water / river / road)生成 CPU 网格并上传 GPU,同时
提供世界坐标拾取、邻居查询与笔刷编辑。参考实现是 Catlike Coding 的
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)
常量与角点/边采样助手:firstCorner / firstSolidCorner / firstWaterCorner /
bridge / waterBridge / solidEdgeMiddle / terraceLerp / wallLerp,
以及 elevationY / streamBedY / waterSurfaceY。EdgeVertices 是边上 5 个
采样点(v1…v5)。
HexCoordinates(HexCoordinates.h)
轴坐标 (x, z),y = -x - z。step(direction, distance)、distanceTo、
fromOffset / offsetX / offsetZ、chunkColumn / chunkRow、
toWorldPosition / fromWorldPosition。
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)
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 生成,也不走
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)
参考工程实例化 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)
generateHexMap(map, settings) 把参考工程 MapGenerator 的流水线移植为纯 CPU 步骤:
- 全部格子初始化到
elevationMinimum; regionCount个陆地区域从种子格生长(抖动、随机抬升/下沉,遵守地图与区域边界);- 海岸线侵蚀直至陆地占比接近
landPercentage; - 顺坡开凿河流(含湖泊概率与已有的汇流判定);
- 40 轮下风向水汽传输(蒸发、降水、坡面径流、下渗),得到湿度与温度;
- 按参考工程的生物群系表写地形类型与植被等级。
可复现性只依赖 settings.seed 与网格本身:不使用时间、random_device 或可变静态
状态。elevationMaximum 会被钳制到 HexMetrics::kMaxElevation;不合法或放不下的
区域参数在改动地图之前就以 InvalidArgument 拒绝。生成后的地图全部格子未探索,
因此调用方必须重新放置单位并重建全部块。
搜索与寻路(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)
HexVisibility 是引用计数而非布尔:多个观察者重叠时,只有最后一个离开才让格子
转回不可见;某格第一次被看见时同时把地图上的 explored 单向闩锁置位,此后不再
回落。increase / decrease / clear / resetVisibility(按当前单位重算全图)。
单位(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)
saveHexMap(map, units, out) / loadHexMap(bytes, map, units):小端、显式逐字节
写出的自描述载荷(magic EVEHEX\0\0 + 版本 + 尺寸 + 种子 + 每格 HexValues /
HexFlags + 单位表)。版本不匹配会拒绝而不是猜测;载荷在触碰输出前整体校验
(长度、magic、版本、尺寸上限 512 且为 5 的倍数、单位格在界内、单位格可站立、
单位不重复),因此被拒绝的载荷不会留下部分写入的地图。修订号字段只作诊断。
球面后端(HexSphereTopology.h、HexSphereMap.h、HexSphereMesh.h、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)完全一致,因此地形着色器的读法不变。三处平面假设被替换:
- 边数不是 6(五边形是 5 条),没有任何循环可以写死
kHexDirectionCount; - 边归属:平面版靠分块网格(
direction <= SE)保证每条共享边只发一次,球面改用 较小的单元 id 拥有该边——同一组边上的全序,同样保证恰好一次; - "垂直"是径向:坡度台地沿弧走水平步、沿半径走垂直步。
平面版的梯田带用弦 v1 → v5 跨越整条边,这在平面上安全只因为它的 5 个采样点共线;
球面上采样点在弧上,弦会在每条带坡度或崖壁的边留下 T 型接缝,因此球面版按扇形同样的
4 段拆分。地形顶点扰动也改为切向并把结果投影回原半径:切向偏移会以 O(d²/r) 改变
半径,而这里扰动量是单元尺寸的可观比例。
generateSphereMap 采样三维值噪声:HexNoise 是 XZ 平面场,用单元方向去采样会在
两极塌缩(方向的 XZ 投影趋零)、在经度回绕处接缝。海平面由噪声自身的分位数确定,因此
landPercentage 真正决定陆地占比,而不是听任噪声分布。输出是
(seed, 拓扑, 半径) 的纯函数。
脚本 API(hexmap)
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 没有移植过来。球面地图也没有存档格式。