Unity实现Townscaper风格有机网格生成全解析

引言

在独立游戏《Townscaper》中,那种看似随意却又和谐统一的建筑布局让无数开发者着迷。这种独特的视觉效果背后,隐藏着一套精妙的程序化网格生成算法。本文将带你从零开始,在Unity中完整复现这套算法,并深入探讨每个技术环节的实现细节。

与单纯讲解数学原理不同,我们更关注如何在Unity编辑器中可视化地构建和调试这套系统。通过C#脚本与Unity编辑器的无缝集成,你可以实时观察网格从基础三角形到最终有机四边形的完整演变过程。

1. 环境准备与基础架构

1.1 创建Unity项目与基础脚本

首先新建一个3D Unity项目,创建名为 OrganicGridGenerator 的C#脚本。这个脚本将作为我们整个生成系统的核心:

[ExecuteInEditMode]
public class OrganicGridGenerator : MonoBehaviour
{
    [Range(2, 12)] public int gridSize = 6;
    [Range(0, 100)] public int randomSeed = 42;
    
    private List<Vector2> points;
    private List<Triangle> triangles;
    private List<Quad> quads;
    
    void OnValidate() { Regenerate(); }
    void Regenerate() { /* 后续填充 */ }
    
    void OnDrawGizmos()
    {
        // 绘制网格的辅助代码
    }
}

关键点说明:

  • ExecuteInEditMode 特性允许我们在不运行游戏的情况下看到网格变化
  • OnValidate 确保参数修改后自动重新生成
  • 使用 List<T> 存储几何数据以提高灵活性

1.2 数据结构设计

我们需要三种核心数据结构来表示网格元素:

public struct Point
{
    public Vector2 position;
    public bool isBoundary;
}

public struct Triangle
{
    public int a, b, c;
    public bool isValid;
}

public struct Quad
{
    public int a, b, c, d;
}

为什么选择这样的结构?

  • Vector2 足够表示2D网格位置
  • isBoundary 标记用于后续的边缘处理
  • isValid 标志控制边的随机剔除

2. 核心算法实现

2.1 Delaunay三角剖分基础

虽然原文提到使用正六边形作为基础,但实际项目中我们采用更通用的方法:

void GenerateDelaunayTriangulation()
{
    points = new List<Vector2>();
    triangles = new List<Triangle>();
    
    // 泊松圆盘采样生成随机点
    PoissonDiskSampling.GeneratePoints(gridSize, out points);
    
    // 使用Bowyer-Watson算法进行三角剖分
    DelaunayTriangulator.Triangulate(points, out triangles);
}

参数调优建议

参数 影响 推荐值
采样半径 点密度 0.8-1.2
排斥次数 均匀度 20-30
边界扩展 边缘质量 1.5倍范围

2.2 随机边剔除与四边形形成

这是算法中最关键的一步,决定网格的有机感:

void RandomEdgeRemoval()
{
    System.Random rand = new System.Random(randomSeed);
    quads = new List<Quad>();
    
    for(int i=0; i<triangles.Count; i++)
    {
        if(!triangles[i].isValid) continue;
        
        // 寻找共享边的相邻三角形
        var neighbors = FindAdjacentTriangles(i);
        if(neighbors.Count == 0) continue;
        
        // 随机决定是否合并
        if(rand.NextDouble() > 0.6f) 
        {
            var quad = MergeTriangles(i, neighbors[0]);
            quads.Add(quad);
            triangles[i].isValid = false;
            triangles[neighbors[0]].isValid = false;
        }
    }
}

提示:0.6的合并阈值是个不错的起点,值越大四边形越多,但可能留下更多未合并的三角形

2.3 网格细分技术

将剩余三角形和初步四边形统一细分为更小的四边形:

void SubdivideToQuads()
{
    var newQuads = new List<Quad>();
    
    // 处理四边形
    foreach(var quad in quads)
    {
        var center = CalculateCenter(quad);
        int centerIndex = points.Count;
        points.Add(center);
        
        newQuads.Add(new Quad(quad.a, quad.b, centerIndex, quad.a));
        // 添加其他三个子四边形...
    }
    
    // 处理三角形(转换为三个四边形)
    foreach(var tri in triangles)
    {
        if(!tri.isValid) continue;
        
        var center = CalculateCenter(tri);
        int centerIndex = points.Count;
        points.Add(center);
        
        newQuads.Add(new Quad(tri.a, tri.b, centerIndex, tri.a));
        // 添加另外两个子四边形...
    }
    
    quads = newQuads;
}

细分后的网格会明显变得更加密集,为后续的松弛步骤做好准备。

3. 网格优化与可视化

3.1 松弛算法实现

松弛步骤让网格看起来更自然:

void RelaxGrid(int iterations)
{
    for(int i=0; i<iterations; i++)
    {
        var newPositions = new Vector2[points.Count];
        
        for(int j=0; j<points.Count; j++)
        {
            if(points[j].isBoundary) continue;
            
            Vector2 avg = Vector2.zero;
            var neighbors = FindAdjacentPoints(j);
            foreach(var n in neighbors)
                avg += points[n].position;
            
            newPositions[j] = avg / neighbors.Count;
        }
        
        // 应用新位置
        for(int j=0; j<points.Count; j++)
            if(!points[j].isBoundary)
                points[j].position = newPositions[j];
    }
}

迭代次数的影响

  • 3-5次:保持原始形状特征
  • 10-15次:更均匀但可能过度平滑
  • 20+次:完全松弛,失去有机感

3.2 Unity编辑器可视化

利用Gizmos实现实时预览:

void OnDrawGizmos()
{
    if(points == null) return;
    
    // 绘制点
    Gizmos.color = Color.white;
    foreach(var p in points)
        Gizmos.DrawSphere(new Vector3(p.position.x, 0, p.position.y), 0.05f);
    
    // 绘制四边形
    Gizmos.color = Color.green;
    foreach(var q in quads)
    {
        DrawQuadGizmo(q);
    }
    
    // 绘制原始三角形(调试用)
    if(showTriangles)
    {
        Gizmos.color = Color.red;
        foreach(var t in triangles)
        {
            if(t.isValid) DrawTriangleGizmo(t);
        }
    }
}

通过添加这些调试可视化,你可以直观地观察算法每个阶段的效果,方便参数调整。

4. 高级技巧与性能优化

4.1 参数化控制系统

在Inspector中添加更多控制参数:

[Header("Generation Parameters")]
[Range(0.1f, 2f)] public float pointDensity = 1f;
[Range(0f, 1f)] public float edgeRemovalChance = 0.6f;
[Range(1, 5)] public int subdivisionLevels = 2;

[Header("Relaxation")]
[Range(0, 20)] public int relaxationIterations = 5;
[Range(0f, 1f)] public float boundaryStiffness = 0.3f;

[Header("Debug")]
public bool showTriangles = false;
public bool showSubdivision = true;

4.2 性能优化策略

对于大型网格,这些优化很关键:

  1. 空间分区 :使用网格或四叉树加速邻域查询
  2. 并行计算 :对独立顶点使用Job System进行并行松弛
  3. 增量更新 :只重新计算修改部分而非整个网格
// 示例:使用Unity的Job System并行松弛
public struct RelaxJob : IJobParallelFor
{
    public NativeArray<Vector2> positions;
    [ReadOnly] public NativeMultiHashMap<int, int> adjacency;
    
    public void Execute(int index)
    {
        // 实现松弛逻辑...
    }
}

4.3 与地形系统集成

将生成的网格应用到Unity地形上:

void GenerateTerrain()
{
    var mesh = new Mesh();
    
    // 将2D网格转换为3D网格
    var vertices = new Vector3[points.Count];
    for(int i=0; i<points.Count; i++)
        vertices[i] = new Vector3(points[i].position.x, 0, points[i].position.y);
    
    // 构建三角形(将四边形拆分为两个三角形)
    var meshTriangles = new List<int>();
    foreach(var q in quads)
    {
        meshTriangles.AddRange(new[]{q.a, q.b, q.c});
        meshTriangles.AddRange(new[]{q.a, q.c, q.d});
    }
    
    mesh.vertices = vertices;
    mesh.triangles = meshTriangles.ToArray();
    mesh.RecalculateNormals();
    
    GetComponent<MeshFilter>().mesh = mesh;
}

5. 实际应用案例

5.1 建筑布局生成

基于网格创建有机城镇布局:

void GenerateBuildings()
{
    foreach(var quad in quads)
    {
        var center = CalculateQuadCenter(quad);
        var size = CalculateQuadSize(quad);
        
        if(size.magnitude > minBuildingSize)
        {
            var building = Instantiate(buildingPrefab, center, Quaternion.identity);
            building.transform.localScale = size * 0.8f;
        }
    }
}

5.2 道路网络生成

从网格边派生道路系统:

void GenerateRoads()
{
    var edges = new HashSet<Edge>();
    
    foreach(var quad in quads)
    {
        edges.Add(new Edge(quad.a, quad.b));
        edges.Add(new Edge(quad.b, quad.c));
        // 添加其他边...
    }
    
    foreach(var edge in edges)
    {
        var p1 = points[edge.a].position;
        var p2 = points[edge.b].position;
        GenerateRoadSegment(p1, p2);
    }
}

在实际项目中,这套系统经过适当调整后,可以生成非常自然的城镇布局。一个常见的技巧是根据网格单元的面积决定放置建筑还是开放空间,较大的单元适合放置主要建筑,而较小的单元则可以作为花园或小路。

更多推荐