[Godot] 沃罗诺伊图生成算法

前言

在做策略类游戏的大地图时,我需要一套能程序化生成"板块感"明显的地图方案。之前我讲过泊松图的实现,经过我的研究,最终实现了一套基于泊松盘采样 → 德劳内三角剖分 → 沃罗诺伊图的地图生成算法,下面给大家分享我的实现思路。

整体流程大致如下:

  1. 泊松盘采样:在地图上均匀撒点,作为沃罗诺伊的种子
  2. 德劳内三角剖分:将散点连成三角网
  3. 沃罗诺伊图:由三角网的外心构造区域边界
  4. 噪声扰动:打碎笔直的边界线,让边界更自然
  5. GPU 渲染:将所有内容绘制到纹理

效果展示

说明:最终效果包含泊松盘采样、沃罗诺伊图分区、噪声扰动海岸线,以及着色渲染


泊松盘采样

算法讲解

如果随机撒点,点会扎堆,生成的区域大小会极度不均匀,视觉效果很差。

泊松盘采样(Poisson Disk Sampling) 保证每两个点之间的距离不小于半径 r,让点尽量均匀分布,同时保持一定的随机性------比规则网格自然,比纯随机均匀。

核心思路

维护一个"活跃点"列表,每次从中随机取一个点,尝试在其周围 [r, 2r] 范围内生成新点,若新点与所有已有点距离均 ≥ r,则接受;否则尝试失败,超过最大次数后将该点从活跃列表中移除。

cs 复制代码
private void Poisson()
{
    //注意我使用了全局points和场景节点testPoint,大家根据自己的需求来
    List<PointScript> activePoints = new();

    // 从中心出发
    var firstPoint = testPoint.Instantiate() as PointScript;
    firstPoint.Position = new Vector2(mapX / 2, mapY / 2);
    points.Add(firstPoint);
    activePoints.Add(firstPoint);

    while (activePoints.Count > 0 && points.Count < pointCount)
    {
        int index = (int)ScriptHelper.RandRange(_rng, 0, activePoints.Count - 1);
        var basePoint = activePoints[index];

        bool found = false;
        for (int i = 0; i < tryCount; i++)
        {
            float angle = ScriptHelper.Randf(_rng) * MathF.Tau;
            float dist = ScriptHelper.RandRange(_rng, radius, radius * 2f);
            Vector2 newPos = basePoint.Position + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * dist;

            // 边界检测
            if (newPos.X < 0 || newPos.X > mapX || newPos.Y < 0 || newPos.Y > mapY) continue;

            // 距离检测
            bool valid = points.All(p => p.Position.DistanceTo(newPos) >= radius);
            if (valid)
            {
                var point = testPoint.Instantiate() as PointScript;
                point.Position = newPos;
                points.Add(point);
                activePoints.Add(point);
                found = true;
                break;
            }
        }

        if (!found) activePoints.RemoveAt(index);
    }
}

扩展点生成

泊松盘采样给我们一堆内部种子点,但这些点的凸包边界是折线感很强的多边形,直接用作海岸线太生硬。这一步的目标是:把凸包向外"撑开",再用噪声打碎,得到自然的陆地轮廓

思路

  1. 对所有种子点求凸包,得到边界点集
  2. 对每个边界点,沿指向外侧的方向,左右各偏转一个角度,各生成一个新扩展点
  3. 对所有扩展点再求一次凸包,得到更大的边界多边形
  4. 对凸包的每条边做噪声扰动,打碎直线感

扩展点公式

对每个边界点 P P P,设地图中心为 C C C,方向向量 d ⃗ = normalize ( P − C ) \vec{d} = \text{normalize}(P - C) d =normalize(P−C),基础角为:

θ = atan2 ( d ⃗ . y , d ⃗ . x ) \theta = \text{atan2}(\vec{d}.y,\ \vec{d}.x) θ=atan2(d .y, d .x)

在基础角左右各偏转 angleOffset 度,向外延伸距离 dist,生成两个扩展点:

P A = P + ( cos ⁡ ( θ + Δ θ ) , sin ⁡ ( θ + Δ θ ) ) × d i s t P_A = P + (\cos(\theta + \Delta\theta),\ \sin(\theta + \Delta\theta)) \times dist PA=P+(cos(θ+Δθ), sin(θ+Δθ))×dist

P B = P + ( cos ⁡ ( θ − Δ θ ) , sin ⁡ ( θ − Δ θ ) ) × d i s t P_B = P + (\cos(\theta - \Delta\theta),\ \sin(\theta - \Delta\theta)) \times dist PB=P+(cos(θ−Δθ), sin(θ−Δθ))×dist

cs 复制代码
private List ExpandPoints(List hullPoints)
{
    List newPoints = new();

    // 计算中心
    Vector2 center = Vector2.Zero;
    foreach (var p in hullPoints) center += p;
    center /= hullPoints.Count;

    foreach (var point in hullPoints)
    {
        Vector2 dir = (point - center).Normalized();
        float baseAngle = Mathf.Atan2(dir.Y, dir.X);
        float rad = Mathf.DegToRad(angleOffst);   // 偏转角度

        float angleA = baseAngle + rad;
        float angleB = baseAngle - rad;

        newPoints.Add(point + new Vector2(Mathf.Cos(angleA), Mathf.Sin(angleA)) * dist);
        newPoints.Add(point + new Vector2(Mathf.Cos(angleB), Mathf.Sin(angleB)) * dist);
    }

    return newPoints;
}

组合:凸包 + 噪声扰动 → 最终陆地边界(海岸线)

cs 复制代码
private (Vector2[] boundarys, Vector2 center) IterateExpand()
{
    // 1. 对种子点求凸包
    var hull = Geometry2D.ConvexHull(points.Select(p => p.Position).ToArray());

    // 2. 扩展点 → 再求凸包
    var expanded = ExpandPoints(hull.ToList());
    var boundarys = Geometry2D.ConvexHull(expanded.ToArray());

    // 3. 对凸包每条边做噪声扰动(噪声扰动代码在下面会讲)
    var noiseMap = new FastNoiseLite { Seed = Seed, Frequency = 0.02f,
                                       NoiseType = FastNoiseLite.NoiseTypeEnum.Simplex };
    var newBoundarys = new List();

    for (int i = 0, j = boundarys.Length - 1; i < boundarys.Length; j = i++)
        newBoundarys.AddRange(NoiseDisturBance(boundarys[j], boundarys[i], noiseMap, 10, 15));

    // 4. 计算重心,并按极角重新排序(保证多边形顶点顺序正确)
    float cx = newBoundarys.Average(p => p.X);
    float cy = newBoundarys.Average(p => p.Y);
    newBoundarys.Sort((a, b) =>
        Math.Atan2(a.Y - cy, a.X - cx).CompareTo(Math.Atan2(b.Y - cy, b.X - cx)));

    return (newBoundarys.ToArray(), new Vector2(cx, cy));
}

为什么最后要重新排序?

噪声扰动在每条边上插入了大量中间点,这些点的顺序可能被打乱,直接用作多边形会出现自交。按极角排序可以保证顶点绕中心单调排列,确保多边形合法。

这个 boundarys 就是最终用于裁剪沃罗诺伊格子的陆地边界,也是渲染陆地 Polygon2D 的轮廓。

德劳内三角剖分

介绍

德劳内三角剖分(Delaunay Triangulation)是把一组散点连成三角形网格的方法,它满足一个关键性质:任意三角形的外接圆内部不包含其他点

这个性质让三角形尽量"胖",避免出现又细又长的三角形,在图形学里非常重要。

代码

Godot 内置了 Geometry2D.TriangulateDelaunay(),直接调用即可,返回三角形顶点的索引数组,每 3 个为一组:

cs 复制代码
int[] triangleIndex = Geometry2D.TriangulateDelaunay(pointsArray);
// triangleIndex[0], [1], [2] → 第一个三角形的三个顶点索引
// triangleIndex[3], [4], [5] → 第二个三角形 ...

沃罗诺伊图(核心)

原理

沃罗诺伊图和德劳内三角剖分互为对偶图

  • 德劳内三角剖分的每个顶点 ,对应沃罗诺伊图的一个区域
  • 德劳内三角剖分的每个三角形 ,对应沃罗诺伊图的一个顶点(外心)

因此,构造沃罗诺伊图的关键是:计算每个德劳内三角形的外心,然后将相邻三角形的外心依次连线

外心的数学推导

三角形的外心,是三条边的垂直平分线的交点,也是三角形外接圆的圆心。

设三角形三顶点为 A ( x 1 , y 1 ) A(x_1, y_1) A(x1,y1), B ( x 2 , y 2 ) B(x_2, y_2) B(x2,y2), C ( x 3 , y 3 ) C(x_3, y_3) C(x3,y3),令:

d = 2 [ x 1 ( y 2 − y 3 ) + x 2 ( y 3 − y 1 ) + x 3 ( y 1 − y 2 ) ] d = 2\left[x_1(y_2 - y_3) + x_2(y_3 - y_1) + x_3(y_1 - y_2)\right] d=2[x1(y2−y3)+x2(y3−y1)+x3(y1−y2)]

q A = x 1 2 + y 1 2 , q B = x 2 2 + y 2 2 , q C = x 3 2 + y 3 2 q_A = x_1^2 + y_1^2, \quad q_B = x_2^2 + y_2^2, \quad q_C = x_3^2 + y_3^2 qA=x12+y12,qB=x22+y22,qC=x32+y32

则外心坐标为:

O x = q A ( y 2 − y 3 ) + q B ( y 3 − y 1 ) + q C ( y 1 − y 2 ) d O_x = \frac{q_A(y_2 - y_3) + q_B(y_3 - y_1) + q_C(y_1 - y_2)}{d} Ox=dqA(y2−y3)+qB(y3−y1)+qC(y1−y2)

O y = q A ( x 3 − x 2 ) + q B ( x 1 − x 3 ) + q C ( x 2 − x 1 ) d O_y = \frac{q_A(x_3 - x_2) + q_B(x_1 - x_3) + q_C(x_2 - x_1)}{d} Oy=dqA(x3−x2)+qB(x1−x3)+qC(x2−x1)

cs 复制代码
//计算三角形外心
private Vector2 CalculateOuterCenter(Vector2 A, Vector2 B, Vector2 C)
{
    float d = 2 * (A.X * (B.Y - C.Y) + B.X * (C.Y - A.Y) + C.X * (A.Y - B.Y));

    float qA = A.X * A.X + A.Y * A.Y;
    float qB = B.X * B.X + B.Y * B.Y;
    float qC = C.X * C.X + C.Y * C.Y;

    float x = (qA * (B.Y - C.Y) + qB * (C.Y - A.Y) + qC * (A.Y - B.Y)) / d;
    float y = (qA * (C.X - B.X) + qB * (A.X - C.X) + qC * (B.X - A.X)) / d;

    return new Vector2(x, y);
}

可视化演示:

构造过程

遍历所有三角形,计算每个三角形的外心,并记录每个原始点 拥有哪些外心;再将每个点的所有外心按角度排序后依次连线,就得到了它的沃罗诺伊多边形。

cs 复制代码
// 1. 统计每个点关联的所有外心
Dictionary<int, List<Vector2>> pointOutCenters = new();

for (int i = 0; i < triangleIndex.Length; i += 3)
{
    int idA = triangleIndex[i], idB = triangleIndex[i+1], idC = triangleIndex[i+2];
    Vector2 outerCenter = CalculateOuterCenter(
        pointsArray[idA], pointsArray[idB], pointsArray[idC]);

    pointOutCenters[idA].Add(outerCenter);
    pointOutCenters[idB].Add(outerCenter);
    pointOutCenters[idC].Add(outerCenter);
}

// 2. 对每个点,将其外心集合按极角排序,然后连线
foreach (var (pointID, outCenters) in pointOutCenters)
{
    Vector2 origin = pointsArray[pointID];
    outCenters.Sort((a, b) =>
        Math.Atan2(a.Y - origin.Y, a.X - origin.X)
            .CompareTo(Math.Atan2(b.Y - origin.Y, b.X - origin.X)));

    for (int i = 0, j = outCenters.Count - 1; i < outCenters.Count; j = i++)
        result.Add((outCenters[i], outCenters[j]));
}

处理边界外心

地图边缘的三角形只有一个邻居,它们的"外心"会跑到地图外面去。处理方式是:从边界边的中点出发,沿外法线方向延伸一个极远点,封闭边界区域。

cs 复制代码
// 找到只有一侧三角形的边(边界边)
if (edgeCount.Value.Count == 1)
{
    Vector2 mid = (pointsArray[edge.Item1] + pointsArray[edge.Item2]) / 2;
    Vector2 dir = (outCenter - mid).Normalized();

    // 确保方向朝外
    if ((pointsArray[insideIdx] - mid).Dot(dir) > 0) dir = -dir;

    // 延伸极远点
    Vector2 farPoint = outCenter + dir * 50000f;
    pointOutCenters[edge.Item1].Add(farPoint);
    pointOutCenters[edge.Item2].Add(farPoint);
}

裁剪到边界多边形

为了生成我们需要的大陆,我们需要用 Geometry2D.IntersectPolygons() 把每个多边形按扩展点生成的海岸线裁剪到陆地范围内:

cs 复制代码
var cutOut = Geometry2D.IntersectPolygons(cellPolygon, boundarys);

噪声扰动海岸线

直线的边界看起来很假,用 Simplex 噪声对每段边界线做法向扰动,让海岸线更自然:

原理

在线段 A B AB AB 上均匀采样若干中间点,对每个中间点沿法向量偏移一个噪声值:

P i ′ = P i + n ⃗ × noise ( P i ) × strength P'_i = P_i + \vec{n} \times \text{noise}(P_i) \times \text{strength} Pi′=Pi+n ×noise(Pi)×strength

其中 n ⃗ \vec{n} n 是线段方向的法向量(旋转 90°), noise \text{noise} noise 是 2D Simplex 噪声。

cs 复制代码
private List<Vector2> NoiseDisturBance(Vector2 a, Vector2 b, FastNoiseLite noiseMap,
                                        int sampleDist, float strength)
{
    var pts = new List<Vector2>();
    int step = Math.Max(1, (int)a.DistanceTo(b) / sampleDist);

    var dir = (b - a).Normalized();
    var normal = new Vector2(-dir.Y, dir.X);  // 法向量

    for (int s = 0; s <= step; s++)
    {
        var p = a.Lerp(b, (float)s / step);
        // 首尾不偏移,保证端点准确
        if (s > 0 && s < step)
            p += normal * noiseMap.GetNoise2D(p.X, p.Y) * strength;
        pts.Add(p);
    }
    return pts;
}

GPU 渲染到纹理

所有绘制操作放在一个 SubViewport 里,最终提取为 Texture2D,渲染完毕后立即释放 Viewport,避免内存泄漏:

cs 复制代码
//大家可以根据自己的需求设置颜色
private async Task<Texture2D> DrawLand(Vector2[] boundarys, List<(Vector2, Vector2)> lines)
{
    var vp = new SubViewport();
    vp.Size = new Vector2I(mapX, mapY);
    AddChild(vp);

    // 海洋底色
    var sea = new ColorRect { Size = new Vector2I(mapX, mapY) };
    sea.Color = new Color(0.804f, 0.776f, 0.639f, 0.6f);
    vp.AddChild(sea);

    // 陆地多边形
    var land = new Polygon2D { Polygon = boundarys };
    land.Color = new Color(0.704f, 0.676f, 0.539f, 1);
    vp.AddChild(land);

    // 绘制沃罗诺伊边界线(噪声扰动后)
    foreach (var line in lines)
    {
        var lineNode = new Line2D();
        lineNode.Points = NoiseDisturBance(line.Item1, line.Item2, noiseMap, 5, 10).ToArray();
        lineNode.DefaultColor = new Color(0.278f, 0.220f, 0.059f);
        lineNode.Width = 1;
        vp.AddChild(lineNode);
    }

    // 等待 GPU 完成渲染
    vp.RenderTargetUpdateMode = SubViewport.UpdateMode.Always;
    await ToSignal(RenderingServer.Singleton, "frame_post_draw");
    await ToSignal(RenderingServer.Singleton, "frame_post_draw");

    var image = vp.GetTexture().GetImage();
    vp.QueueFree();
    return ImageTexture.CreateFromImage(image);
}

注意frame_post_draw 需要等待两帧,第一帧提交指令,第二帧才能保证纹理已写入

最终效果

程序生成结果 :

加入Shader后 :

如果大家需要的话可以在GodotShaders找到我发布的shader代码


结尾

整个流程的核心链路是:

泊松盘(均匀散点)→ 德劳内(连三角网)→ 外心连线(沃罗诺伊)→ 噪声扰动(自然化)→ SubViewport 渲染

每一步都可以独立替换或调参,比如调整噪声强度可以控制海岸线的弯曲程度,这里只是给大家分享一下核心的算法实现思想,很多代码没有放出来,如果只是这么多的话会有一堆问题需要解决,大家可以根据自己的需求进行研究和修改

我的博客

相关推荐
像污秽一样2 小时前
算法设计与分析-算法效率分析基础-蛮力法
数据结构·算法·排序算法
格林威2 小时前
工业相机图像高速存储(C#版):先存内存,后批量转存方法,附 Basler 相机实战代码!
开发语言·人工智能·数码相机·计算机视觉·c#·视觉检测·工业相机
IMPYLH2 小时前
Lua 的 UTF-8 模块
开发语言·笔记·后端·游戏引擎·lua
祁同伟.2 小时前
【算法】优选 · 双指针
c++·算法·容器·stl
项目申报小狂人2 小时前
基于迁移学习与丢弃法的神经网络算法在无人机失移动目标搜索中的应用,含代码
神经网络·算法·迁移学习
stolentime2 小时前
洛谷P15652 [省选联考 2026] 排列游戏 / perm题解
c++·算法·交互·洛谷·联合省选2026
仰泳的熊猫2 小时前
题目1834:蓝桥杯2016年第七届真题-路径之谜
数据结构·c++·算法·蓝桥杯·深度优先·图论
机器学习之心2 小时前
198种组合算法+优化SVR支持向量机回归+SHAP分析+新数据预测!机器学习可解释分析,强烈安利,粉丝必备!
算法·shap分析·新数据预测·优化svr支持向量机回归
自信150413057592 小时前
数据结构之队列的实现
c语言·数据结构·算法·链表