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

24 KiB
Raw Permalink Blame History

六边形立体地图(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 步骤:

  1. 全部格子初始化到 elevationMinimum;
  2. regionCount 个陆地区域从种子格生长(抖动、随机抬升/下沉,遵守地图与区域边界);
  3. 海岸线侵蚀直至陆地占比接近 landPercentage;
  4. 顺坡开凿河流(含湖泊概率与已有的汇流判定);
  5. 40 轮下风向水汽传输(蒸发、降水、坡面径流、下渗),得到湿度与温度;
  6. 按参考工程的生物群系表写地形类型与植被等级。

可复现性只依赖 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)完全一致,因此地形着色器的读法不变。三处平面假设被替换:

  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)

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 没有移植过来。球面地图也没有存档格式。