前言
在做策略类游戏的大地图时,我需要一套能程序化生成"板块感"明显的地图方案。之前我讲过泊松图的实现,经过我的研究,最终实现了一套基于泊松盘采样 → 德劳内三角剖分 → 沃罗诺伊图的地图生成算法,下面给大家分享我的实现思路。
整体流程大致如下:
- 泊松盘采样:在地图上均匀撒点,作为沃罗诺伊的种子
- 德劳内三角剖分:将散点连成三角网
- 沃罗诺伊图:由三角网的外心构造区域边界
- 噪声扰动:打碎笔直的边界线,让边界更自然
- 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);
}
}
扩展点生成
泊松盘采样给我们一堆内部种子点,但这些点的凸包边界是折线感很强的多边形,直接用作海岸线太生硬。这一步的目标是:把凸包向外"撑开",再用噪声打碎,得到自然的陆地轮廓。
思路
- 对所有种子点求凸包,得到边界点集
- 对每个边界点,沿指向外侧的方向,左右各偏转一个角度,各生成一个新扩展点
- 对所有扩展点再求一次凸包,得到更大的边界多边形
- 对凸包的每条边做噪声扰动,打碎直线感
扩展点公式
对每个边界点 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 渲染
每一步都可以独立替换或调参,比如调整噪声强度可以控制海岸线的弯曲程度,这里只是给大家分享一下核心的算法实现思想,很多代码没有放出来,如果只是这么多的话会有一堆问题需要解决,大家可以根据自己的需求进行研究和修改