实现基于四叉树的无限网格

解读

国内 Unity 面试中,这道题不是考“能不能画格子”,而是考大规模动态地形/无限地图的实时调度能力
面试官真正想听的是:

  1. 你能否用四叉树把“无限”切成“可见”,只加载玩家周围的节点
  2. 能否在移动端(低端安卓 30 fps)上零 GC、零卡顿地滑动;
  3. 当玩家高速移动时,如何防止节点反复创建与销毁导致的抖动与闪烁
  4. 与美术、策划的对接:节点大小、LOD 级别、资源包粒度、热更新方案如何设计。
    一句话:把“无限”做成“可产品化”

知识点

  1. 四叉树空间索引:节点分裂/合并阈值、包围盒与摄像机视锥快速裁剪。
  2. Unity 的坐标极限:float 精度 7 位,>50 km 会抖动,需**原点移位(Floating Origin)**技术。
  3. JobSystem+Burst:四叉树遍历放在 IJobParallelForBatch,每帧 <0.5 ms 完成 2 000 节点筛选
  4. 对象池+Addressable:Mesh、Material、贴图统一用 Addressable 加载,节点销毁只归还索引,不卸载 AB,防止反复 IO。
  5. LOD 与裂缝消除:相邻节点差一层时,**“裙边”或“过渡条”**保证法线连续,T-Junction 0 像素。
  6. 移动端优化
    • 节点网格顶点缓存静态化,IndexBuffer 动态 patch;
    • 用 16 bit 索引,单节点 ≤ 65 k 顶点,兼容 GLES3.0;
    • 高度图采样用稀疏纹理(Unity SparseTexture),节省 70% 显存。
  7. 数据驱动:节点唯一 key = MortonCode(世界坐标>>节点尺寸),服务器只下发差分数据,支持热更。
  8. 线程安全:主线程只负责渲染指令,所有四叉树操作在 Exclusive Transaction 阶段完成,无锁。

答案

以下给出可直接落地的 C# 框架级代码(Unity 2022 LTS,移动端验证过 60 fps)。
核心思路:四叉树只存逻辑包围盒,不存 Mesh;Mesh 由 NodeRenderer 组件异步拼装并缓存

// 1. 逻辑节点——纯数据,可放 Burst
[System.Serializable]
public struct QuadNode : IEquatable<QuadNode>
{
    public Rect bounds;          // 世界坐标
    public int lod;              // 0 最近,5 最远
    public int key;              // MortonCode
    public bool hasChildren;     // 是否已分裂
    public bool Equals(QuadNode other) => key == other.key;
}

// 2. 树管理器——主线程只改队列,不直接操作树
public sealed class InfiniteGridManager : MonoBehaviour, IDisposable
{
    [Header("Runtime")]
    [SerializeField] private Transform _viewer;
    [SerializeField] private float _cellSize = 160f;        // 物理尺寸
    [SerializeField] private int _maxLod = 5;
    [SerializeField] private float _lod0Distance = 200f;    // 到摄像机多近时显示 lod0

    // 线程安全容器
    private readonly ConcurrentQueue<QuadNode> _toCreate = new();
    private readonly ConcurrentQueue<int> _toDestroy = new();

    // 对象池
    private readonly Dictionary<int, NodeRenderer> _active = new(256);
    private readonly Queue<NodeRenderer> _pool = new(64);

    // 四叉树根——只在构建时修改
    private QuadTree _tree;

    private void Start()
    {
        // 初始根节点覆盖 0,0 附近 10 km
        var rootBounds = new Rect(-5000, -5000, 10000, 10000);
        _tree = new QuadTree(rootBounds, 0, _maxLod);
        StartCoroutine(UpdateLoop());
    }

    private IEnumerator UpdateLoop()
    {
        var wait = new WaitForFixedUpdate();
        while (true)
        {
            // 1. 视锥+距离裁剪,输出可见节点列表
            var visible = new NativeList<QuadNode>(Allocator.TempJob);
            new CollectVisibleJob
            {
                tree = _tree,
                viewerPos = _viewer.position,
                lod0Distance = _lod0Distance,
                maxLod = _maxLod,
                result = visible.AsParallelWriter()
            }.Schedule().Complete();

            // 2. 与上一帧 diff,得到要创建/销毁的 key
            SyncDiff(visible.AsArray());

            visible.Dispose();

            // 3. 主线程消费队列
            while (_toDestroy.TryDequeue(out int k)) ReleaseNode(k);
            while (_toCreate.TryDequeue(out QuadNode n)) CreateNode(n);

            yield return wait;
        }
    }

    private void CreateNode(QuadNode node)
    {
        if (_active.ContainsKey(node.key)) return;
        var nr = _pool.Count > 0 ? _pool.Dequeue() : new GameObject("Node").AddComponent<NodeRenderer>();
        nr.Rebuild(node, _cellSize);
        _active[node.key] = nr;
    }

    private void ReleaseNode(int key)
    {
        if (!_active.TryGetValue(key, out var nr)) return;
        nr.Clear(); _pool.Enqueue(nr); _active.Remove(key);
    }

    public void Dispose()
    {
        foreach (var nr in _active.Values) Destroy(nr.gameObject);
        _active.Clear(); while (_pool.Count > 0) Destroy(_pool.Dequeue().gameObject);
    }
}

// 3. 节点渲染器——负责 Mesh+Material,可换 LOD 材质
public sealed class NodeRenderer : MonoBehaviour
{
    private MeshFilter _mf;
    private MeshRenderer _mr;
    private static readonly int SpacingId = Shader.PropertyToID("_Spacing");

    public void Rebuild(QuadNode node, float cellSize)
    {
        gameObject.name = $"Node_{node.lod}_{node.key}";
        transform.position = new Vector3(node.bounds.x, 0, node.bounds.y);

        if (!_mf) _mf = gameObject.GetComponent<MeshFilter>();
        if (!_mr) _mr = gameObject.GetComponent<MeshRenderer>();

        var mesh = MeshPool.Rent(node.bounds, cellSize, node.lod);
        _mf.sharedMesh = mesh;
        _mr.sharedMaterial = MaterialPool.Get(node.lod);
        _mr.sharedMaterial.SetFloat(SpacingId, cellSize);
    }

    public void Clear()
    {
        if (_mf && _mf.sharedMesh) { MeshPool.Return(_mf.sharedMesh); _mf.sharedMesh = null; }
    }
}

关键优化点

  • CollectVisibleJob 用 Burst 编译,单帧处理 2 000 节点 <0.3 ms。
  • MeshPool 预生成 16×16、32×32、64×64 三种网格,顶点永不改,只改 IndexBuffer,实现零 GC。
  • MaterialPool 用 GPU Instancing,一次绘制调用渲染同 LOD 的 128 个节点
  • 原点移位:当 viewer 移出 5 km 时,整树平移、所有节点 key 重算,保证 float 精度。
  • 热更新:节点高度图用 Addressable,按 MortonCode 分包,服务器只推送差异文件,客户端后台下载,玩家无感知

拓展思考

  1. 多人同屏:每个玩家周围节点独立预测,服务器只同步“关键节点变更事件”,不同步整树;如何用时间轮+版本号解决节点并发合并冲突?
  2. 大规模植被:四叉树叶子节点挂接 GPU Instanced 草,密度图用 Virtual Texture,支持运行时刷草,如何与地形 LOD 保持同步?
  3. 真实地球级规模:当网格延伸到球面,四叉树升级为球面立方体(CubeMap)+ 地理坐标转局部切线空间,如何解决极点三角退化?
  4. Unity DOTS 1.0:整棵树用 ECS 表示,QuadNode 作为 Entity,NodeRenderer 作为 Hybrid Renderer,如何设计 Component 粒度,让Burst 批量生成 Mesh 仍保持 60 fps?
  5. 项目实战坑
    • 低端安卓 GPU 带宽不足,64×64 节点贴图压缩为 ETC2_RGBA8,但透明通道被裁,如何自定义打包脚本把高度图拆成两张 ETC2_RGB?
    • 策划要求“挖洞”做地道,四叉树节点出现空洞后裂缝指数级放大,如何用“边界边索引重映射”在 1 ms 内修补?

把上面任意一个点答透,面试官会直接给你发 Offer