尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C++实现非噪声地图生成器:Voronoi图与元胞自动机混合方案

C++实现非噪声地图生成器:Voronoi图与元胞自动机混合方案 1. 项目概述最近在琢磨一个挺有意思的C小项目做一个自定义的地图生成器。市面上很多地图生成器无论是游戏里的地形还是模拟软件里的场景都离不开一个核心算法——噪声算法比如Perlin噪声、Simplex噪声。这些算法确实强大能生成非常自然、连续的地形。但这次我想玩点不一样的标题里也写了“无需噪音算法”。说白了就是想抛开这些现成的、复杂的噪声函数看看能不能用更直接、更可控、甚至更“笨”一点的方法捣鼓出有意思的地形来。这项目听起来可能有点反潮流毕竟噪声算法是行业标准。但它的价值恰恰就在这里。当你不再依赖一个黑盒般的噪声函数时你对地图上每一个像素、每一个格子的控制力就回来了。你可以更容易地实现一些特定风格的地图比如规整的棋盘格迷宫、充满几何感的科幻基地或者是完全由你自己定义的、带有强烈叙事性的场景布局。这对于独立游戏开发、特定场景的快速原型构建甚至是教学演示都很有意义。它强迫你去思考地形构成的本质逻辑而不是简单地调几个频率和振幅参数。2. 核心思路与方案设计2.1 为什么“抛弃”噪声算法首先得澄清不是噪声算法不好而是我们这个项目的目标不同。噪声算法的核心优势在于生成“自然”的、连续变化的随机图案。它的随机性是平滑的、相关的相邻点之间高度相似这完美模拟了山川、云朵等自然现象。但它的“缺点”也在于此控制粒度较粗难以精确指定“在坐标x, y处必须是一个湖泊”或者“生成一个完美的圆形平原”。虽然可以通过域扭曲Domain Warping、多层叠加等技术进行一定程度的引导但终究是“概率性”的引导而非“确定性”的绘制。我们这个自定义地图生成器的核心思路是把地图看作一个二维的、由离散单元可以叫格子、像素、瓦片组成的画布。我们放弃那种全局的、连续的随机函数转而采用更基础的构建块和规则来“绘制”地图。思路可以归结为以下几种过程化生成非噪声类使用如元胞自动机、扩散限制聚集DLA、泊松圆盘采样、Voronoi图划分等算法。这些算法能产生有机的、但规则不同于噪声的图案。基于种子的几何构造例如随机放置一些“种子点”代表山峰、湖泊中心然后根据距离衰减、最近邻等规则计算地图上每一点的性质高度、类型。手工绘制与程序化结合先由开发者绘制一张低分辨率的“控制图”或“权重图”然后用程序根据这些权重图去生成高分辨率的地形细节。这给了开发者最高的艺术控制权。分形几何非随机中点位移使用如L-system、迭代函数系统IFS来生成具有自相似性的海岸线、河流或树木形态其随机性来源于初始条件和规则而非连续的噪声场。对于这个C项目我决定采用一种混合策略以Voronoi图泰森多边形作为地图的骨架再结合元胞自动机进行平滑和细节化最后通过基于距离的衰减函数来生成高度图。这个方案结构清晰每一步都易于理解和控制并且能生成出既有大块区域特征Voronoi细胞又有自然过渡边缘元胞自动机平滑的地形。2.2 技术选型与工具准备既然是C项目开发环境的选择就很重要。我个人偏爱使用Visual Studio Code配合CMake来管理项目这比传统的Visual Studio单一解决方案更灵活也便于跨平台。编译器方面MSVCWindows或GCC/ClangLinux/macOS都可以确保开启C11或更高标准以使用方便的智能指针和容器算法。核心的第三方库我们只需要一个用于生成Voronoi图的库。虽然可以自己实现但为了效率和稳定性我选择使用Boost.Polygon库中的Voronoi功能。Boost库在C社区中久经考验Boost.Polygon的Voronoi实现非常高效且健壮。如果你的项目不希望引入庞大的Boost也可以考虑轻量级的替代品比如Voro或CGAL但对于这个项目Boost.Polygon的易用性更胜一筹。安装Boost对于新手可能是个小门槛。在Windows上我推荐使用vcpkg这个包管理器只需执行vcpkg install boost-polygon:x64-windows即可。在Linux上通常可以通过包管理器安装如sudo apt-get install libboost-all-dev。记得在CMakeLists.txt中正确找到并链接Boost库。项目的代码结构我规划如下MapGenerator/ ├── CMakeLists.txt ├── include/ │ ├── MapGenerator.h │ ├── VoronoiDiagram.h │ └── CellularAutomata.h ├── src/ │ ├── main.cpp │ ├── MapGenerator.cpp │ ├── VoronoiDiagram.cpp │ └── CellularAutomata.cpp └── assets/ (可选用于存放控制图等资源)这个结构将不同功能的类分离MapGenerator作为主类协调Voronoi和元胞自动机的工作。3. 核心模块实现详解3.1 构建地图骨架Voronoi图生成Voronoi图的思想很简单在地图上随机撒下一把“种子点”每个种子点生长出一个区域这个区域包含所有离该种子点最近的地图空间。最终整个地图被划分成一个个凸多边形细胞。这些细胞天然地形成了丘陵、平原、盆地等大尺度地理结构的边界。第一步是生成随机种子点。这里有个技巧完全随机的分布可能导致细胞大小差异悬殊。为了得到更均匀的分布我采用“泊松圆盘采样”的简化版——抖动网格采样。具体做法是将地图网格划分成大小为cellSize的格子在每个格子里随机取一个点作为种子。这样可以保证种子之间有一个最小距离避免细胞过小。// 在 VoronoiDiagram.cpp 中 std::vectorPoint VoronoiDiagram::generateSeeds(int width, int height, int cellSize) { std::vectorPoint seeds; std::random_device rd; std::mt19937 gen(rd()); std::uniform_real_distribution dis(0, cellSize); for (int gridY cellSize / 2; gridY height; gridY cellSize) { for (int gridX cellSize / 2; gridX width; gridX cellSize) { float jitterX dis(gen); float jitterY dis(gen); seeds.emplace_back(gridX jitterX, gridY jitterY); } } return seeds; }注意cellSize参数直接决定了地图的“粗糙度”。cellSize越小种子点越多Voronoi细胞就越小生成的地图细节越多但计算量也越大。通常可以设置为地图宽高的1/20到1/50作为起点进行调整。有了种子点就可以利用Boost.Polygon来生成Voronoi图了。我们需要将种子点转换为库需要的格式并处理输出。#include boost/polygon/voronoi.hpp using namespace boost::polygon; // 定义点类型 typedef double coordinate_type; typedef point_datacoordinate_type Point; typedef voronoi_diagramcoordinate_type VoronoiDiagram; // 生成Voronoi图并提取细胞信息 void VoronoiDiagram::buildDiagram(const std::vectorPoint seeds, int width, int height) { voronoi_diagramcoordinate_type vd; construct_voronoi(seeds.begin(), seeds.end(), vd); // 遍历Voronoi图的所有细胞cell for (const auto cell : vd.cells()) { if (!cell.is_open() cell.contains_seed()) { // 获取该细胞对应的种子点索引 std::size_t seedIndex cell.source_index(); // 遍历细胞的半边half-edge获取多边形的顶点 const auto* edge cell.incident_edge(); std::vectorPoint polygonVertices; do { if (edge-is_finite()) { // 获取边的起点和终点构建多边形 // ... (具体顶点坐标提取代码需处理无限边) } edge edge-next(); } while (edge ! cell.incident_edge()); // 将多边形和种子索引存储起来供后续使用 storeCell(seedIndex, polygonVertices); } } }这里有个关键点需要处理Voronoi图在边界处会产生无限延伸的边。对于地图生成我们需要将这些无限边“裁剪”到地图的边界矩形内。Boost.Polygon提供了相关的工具函数但实现起来需要一些几何计算。一个实用的方法是如果遇到无限边就用它对应的直线与地图边界求交用交点作为多边形的顶点。3.2 赋予细胞属性与高度初值每个Voronoi细胞生成后我们需要给它赋予初始属性。最简单的就是给每个种子点随机分配一个“基础高度”和“细胞类型”如海洋、平原、山地。struct CellData { int seedId; Point seedPoint; double baseHeight; // 0.0 ~ 1.0 enum class Type { OCEAN, PLAIN, HILL, MOUNTAIN } type; std::vectorPoint polygon; }; std::mapint, CellData m_cells; // 存储所有细胞数据 void VoronoiDiagram::assignInitialProperties() { std::uniform_real_distribution heightDist(0.0, 1.0); for (auto [id, cell] : m_cells) { cell.baseHeight heightDist(m_rng); // 根据高度简单分配类型阈值可调 if (cell.baseHeight 0.2) { cell.type CellData::Type::OCEAN; cell.baseHeight * 0.5; // 海洋区域高度压得更低 } else if (cell.baseHeight 0.5) { cell.type CellData::Type::PLAIN; } else if (cell.baseHeight 0.8) { cell.type CellData::Type::HILL; } else { cell.type CellData::Type::MOUNTAIN; } } }现在我们得到了一张由许多多边形色块拼成的地图每个色块有一个统一的高度和类型。但这看起来非常生硬像一块块彩色的玻璃碎片。下一步就需要让它们的边界变得自然。3.3 平滑与自然化元胞自动机应用元胞自动机是处理这类离散网格“平滑”和“有机化”的利器。我们这里不模拟生命游戏而是用它来混合细胞边界。首先需要将基于多边形的Voronoi细胞数据栅格化到一个二维网格std::vectorstd::vectordouble上。网格的每个格子像素存储其高度值。初始化时每个格子的高度等于它所在的那个Voronoi细胞的baseHeight。然后我们运行数轮元胞自动机规则。一个非常有效的平滑规则是每个格子的新高度等于其周围3x3邻域内格子高度的平均值。这本质上是一个高斯模糊的离散近似。// 在 CellularAutomata.cpp 中 void CellularAutomata::smoothHeightMap(std::vectorstd::vectordouble heightMap, int iterations) { int height heightMap.size(); int width heightMap[0].size(); auto newMap heightMap; // 创建副本用于写入新状态 for (int iter 0; iter iterations; iter) { for (int y 1; y height - 1; y) { for (int x 1; x width - 1; x) { double sum 0.0; for (int dy -1; dy 1; dy) { for (int dx -1; dx 1; dx) { sum heightMap[y dy][x dx]; } } newMap[y][x] sum / 9.0; // 3x3均值滤波 } } heightMap.swap(newMap); // 交换进行下一轮迭代 // 注意边界格子需要特殊处理例如采用镜像或重复边界条件 } }实操心得迭代次数iterations是关键参数。1-2次迭代只能轻微模糊边界5次以上会使地形特征严重流失变得过于平坦。对于1024x1024的地图3-4次迭代通常能在保持大结构的同时产生足够自然的过渡。此外可以在平滑后对特定类型如山地的细胞中心区域进行一次轻微的“锐化”即稍微调高中心点高度以防止山地被过度平滑成丘陵。3.4 生成最终高度图与生物群系经过元胞自动机平滑后我们得到了一张连续变化的高度图。但这张图可能还缺乏一些中尺度的细节。我们可以再引入一个步骤基于距离的衰减噪声。这不是Perlin噪声而是为每个Voronoi细胞在其内部生成一个简单的、随机的“凹凸”模式。思路是对于网格中的每个点计算它到所属Voronoi细胞种子点的归一化距离0在种子点1在细胞边界。然后用一个简单的函数如cos或多项式根据这个距离来调制高度。double applyDistanceFalloff(const Point gridPoint, const CellData cell) { // 计算到种子点的距离并归一化到[0,1] (1代表到达细胞边界) double dist distance(gridPoint, cell.seedPoint); double maxDist estimateCellRadius(cell.polygon); // 估算细胞半径 double normalizedDist std::min(dist / maxDist, 1.0); // 使用一个衰减函数例如 cos使得中心高边缘低 double falloff 0.5 * (1.0 std::cos(normalizedDist * M_PI)); // 从1衰减到0 // 为每个细胞引入一个随机强度因子模拟小起伏 double cellNoise cell.randomFactor; // 之前为每个细胞生成一个0.9~1.1的随机因子 return cell.baseHeight * falloff * cellNoise; }这个cell.randomFactor可以在给细胞赋初值时生成。这样每个细胞内部就有了从中心向边缘缓缓降低的“穹顶”感不同细胞的“穹顶”高度和起伏程度还不同叠加平滑后的边界地形一下子就生动了许多。最后根据最终的高度值我们可以像传统噪声方法一样设定阈值来划分生物群系Biome。enum class Biome { OCEAN, BEACH, GRASSLAND, FOREST, DESERT, MOUNTAIN, SNOW }; Biome determineBiome(double height, double moisture) { // 这里moisture可以来自另一张独立生成的简单随机图 if (height WATER_LEVEL) return Biome::OCEAN; if (height WATER_LEVEL 0.02) return Biome::BEACH; // 很窄的沙滩带 if (height 0.8) { return (moisture 0.5) ? Biome::SNOW : Biome::MOUNTAIN; } else if (height 0.6) { return (moisture 0.6) ? Biome::FOREST : Biome::GRASSLAND; } else if (height 0.3) { return (moisture 0.3) ? Biome::DESERT : Biome::GRASSLAND; } else { // 低地 return (moisture 0.7) ? Biome::FOREST : Biome::GRASSLAND; } }这里的moisture湿度图可以用另一个独立的、更简单的随机过程生成比如用一组不同频率的正弦余弦函数叠加或者干脆用另一组参数不同的Voronoi图来生成。关键是让它与高度图相关性不大以产生更丰富的生物群系组合。4. 性能优化与高级技巧4.1 处理大规模地图与无限生成上述流程在生成固定大小如1024x1024的地图时表现良好。但如果想支持超大地图或无限地图就需要优化。Voronoi图生成和全局栅格化平滑在无限地图上是不可行的。解决方案是“按需生成”和“分块处理”。我们可以将世界划分为固定大小的“区块”Chunk例如256x256像素。当玩家移动到某个区块时才生成该区块的内容。全局种子与一致性使用一个全局种子初始化所有随机数生成器。这样对于给定的世界坐标区块坐标生成的随机种子点集合是确定的。区块相关的Voronoi生成区块时只考虑那些可能影响该区块的Voronoi种子点。通常一个区块只会被其自身及相邻8个区块内的种子点所影响。你需要一个函数根据世界种子和区块坐标计算出影响该区块的局部种子点列表。这需要一种可复现的、基于哈希的随机点生成方法。局部栅格化与平滑只对当前区块的网格进行栅格化和元胞自动机平滑。但要注意平滑操作需要邻域信息因此需要从相邻已生成的区块中读取边界格子高度或者生成一个略大于区块的临时区域进行平滑再裁剪出中心部分作为最终区块。4.2 增加更多可控性与艺术导向自定义生成器的最大优势是可控性。我们可以轻松引入“控制图”的概念。高度控制图一张灰度图白色代表高处黑色代表低处。在生成Voronoi细胞的基础高度时不仅参考随机数还采样控制图对应位置的值进行混合。finalBaseHeight lerp(randomHeight, controlMapValue, influenceFactor)。这样你就能手工“绘制”出大陆的轮廓。生物群系控制图一张RGB图不同颜色代表不同的期望生物群系。在最终确定生物群系时将过程化生成的生物群系与控制图指示的生物群系进行混合优先采用控制图的权重。这让你可以精确放置沙漠、雪原等特殊区域。特征点放置在Voronoi的种子点生成阶段可以手动指定一些“关键点”的坐标和属性如“主城”、“火山口”确保它们一定出现在地图上并且其所在的细胞会被赋予特定的属性。4.3 从高度图到三维网格或瓦片地图生成的高度图是一个二维的double数组。如何用它对于3D地形将高度值0.0-1.0缩放到一个实际的高度范围如0-100米。然后将每个网格点转换为3D顶点x, y, height。用三角形带或索引缓冲区连接这些顶点形成网格。还可以根据生物群系信息为顶点上色或分配纹理坐标。对于2D瓦片地图将每个网格点对应到一个地图瓦片。根据高度和生物群系决定这个格子是“深水”、“浅水”、“草地”、“森林”、“山地”等并选择对应的瓦片精灵Sprite进行渲染。平滑的高度过渡可以通过瓦片的混合Blending或使用多套过渡性瓦片来实现。5. 常见问题与调试心得在实际编码和调试这个生成器的过程中我踩过不少坑这里总结一下问题1Voronoi细胞边界出现锯齿或异常尖刺。原因这通常是由于浮点数精度问题或者在裁剪无限边到地图边界时直线求交计算不够鲁棒。解决使用double而非float进行几何计算。在裁剪时不仅要计算与边界的交点还要对生成的多边形顶点进行“简化”合并距离过近的点并确保顶点顺序是顺时针或逆时针的。Boost.Polygon的voronoi_diagram对象提供了edges()和vertices()的访问处理它们时需要仔细。问题2元胞自动机平滑后地图整体变“平”了失去特色。原因迭代次数过多或者平滑核邻域太大。解决减少迭代次数从5次降到3次试试。尝试使用加权平均的平滑核例如中心格子权重最高如0.2直接相邻的4个格子权重次之各0.15对角4个格子权重最低各0.05总和为1.0。这能在平滑的同时更好地保留特征。问题3生成的地图有明显的“网格状”或“条带状”人工痕迹。原因Voronoi种子点的分布模式过于规则抖动网格采样仍有网格基底或者湿度图与高度图使用了过于简单的相关噪声。解决对于种子点可以尝试更纯粹的泊松圆盘采样算法虽然计算更慢但分布更均匀随机。对于湿度图不要用和高度图类似的Voronoi过程可以改用完全独立的伪随机哈希函数如Perlin噪声的简化版或单纯基于坐标的哈希来生成每个点的湿度值打破相关性。问题4性能瓶颈在哪里如何优化分析对于固定大小地图主要开销在1) Voronoi图构建O(n log n)2) 栅格化O(像素数 * 细胞数) 如果暴力判断3) 元胞自动机平滑O(像素数 * 迭代次数 * 邻域大小)。优化Voronoi使用高效的库如Boost并控制种子点数量。栅格化这是最大的潜在瓶颈。不要对每个像素遍历所有细胞判断归属。可以利用扫描线算法对每个Voronoi细胞多边形计算其包围盒只对包围盒内的像素进行“点是否在多边形内”的判断。更高效的方法是使用多边形扫描转换算法直接填充。平滑使用分离的高斯模糊先水平后垂直可以将O(k²)的卷积降为O(2k)其中k是核大小。对于3x3均值滤波优化提升不大但对于更大的核如5x5效果显著。问题5如何让生成的地图更有“故事性”技巧在过程化生成之后加入一个“后处理”阶段。例如河流生成从高海拔细胞随机选择起点按照高度梯度向下“流动”直到流入海洋或湖泊将流经的格子标记为河流并降低其高度。道路生成在重要的细胞如类型为“城镇”的细胞之间使用A*算法寻找路径并稍微平整路径上的地形。添加地标在特定类型的细胞中心如最高的山地细胞强制替换为一个特殊的地形瓦片或模型。最后这个“无需噪音算法”的地图生成器项目其魅力不在于替代噪声算法而在于提供另一种思维框架。它让你从“调整参数等待惊喜”转变为“设计规则构建世界”。虽然初期结果可能不如成熟的噪声方法那么“自然”但它在表达特定设计意图、实现确定性布局以及教育理解地形生成原理方面有着独特的优势。当你亲手用代码将一个个多边形细胞变成有起伏、有生命的虚拟地貌时那种成就感是完全不同的。
返回列表