实现基于四叉树的无限网格
解读
国内 Unity 面试中,这道题不是考“能不能画格子”,而是考大规模动态地形/无限地图的实时调度能力。
面试官真正想听的是:
- 你能否用四叉树把“无限”切成“可见”,只加载玩家周围的节点;
- 能否在移动端(低端安卓 30 fps)上零 GC、零卡顿地滑动;
- 当玩家高速移动时,如何防止节点反复创建与销毁导致的抖动与闪烁;
- 与美术、策划的对接:节点大小、LOD 级别、资源包粒度、热更新方案如何设计。
一句话:把“无限”做成“可产品化”。
知识点
- 四叉树空间索引:节点分裂/合并阈值、包围盒与摄像机视锥快速裁剪。
- Unity 的坐标极限:float 精度 7 位,>50 km 会抖动,需**原点移位(Floating Origin)**技术。
- JobSystem+Burst:四叉树遍历放在 IJobParallelForBatch,每帧 <0.5 ms 完成 2 000 节点筛选。
- 对象池+Addressable:Mesh、Material、贴图统一用 Addressable 加载,节点销毁只归还索引,不卸载 AB,防止反复 IO。
- LOD 与裂缝消除:相邻节点差一层时,**“裙边”或“过渡条”**保证法线连续,T-Junction 0 像素。
- 移动端优化:
- 节点网格顶点缓存静态化,IndexBuffer 动态 patch;
- 用 16 bit 索引,单节点 ≤ 65 k 顶点,兼容 GLES3.0;
- 高度图采样用稀疏纹理(Unity SparseTexture),节省 70% 显存。
- 数据驱动:节点唯一 key = MortonCode(世界坐标>>节点尺寸),服务器只下发差分数据,支持热更。
- 线程安全:主线程只负责渲染指令,所有四叉树操作在 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 分包,服务器只推送差异文件,客户端后台下载,玩家无感知。
拓展思考
- 多人同屏:每个玩家周围节点独立预测,服务器只同步“关键节点变更事件”,不同步整树;如何用时间轮+版本号解决节点并发合并冲突?
- 大规模植被:四叉树叶子节点挂接 GPU Instanced 草,密度图用 Virtual Texture,支持运行时刷草,如何与地形 LOD 保持同步?
- 真实地球级规模:当网格延伸到球面,四叉树升级为球面立方体(CubeMap)+ 地理坐标转局部切线空间,如何解决极点三角退化?
- Unity DOTS 1.0:整棵树用 ECS 表示,QuadNode 作为 Entity,NodeRenderer 作为 Hybrid Renderer,如何设计 Component 粒度,让Burst 批量生成 Mesh 仍保持 60 fps?
- 项目实战坑:
- 低端安卓 GPU 带宽不足,64×64 节点贴图压缩为 ETC2_RGBA8,但透明通道被裁,如何自定义打包脚本把高度图拆成两张 ETC2_RGB?
- 策划要求“挖洞”做地道,四叉树节点出现空洞后裂缝指数级放大,如何用“边界边索引重映射”在 1 ms 内修补?
把上面任意一个点答透,面试官会直接给你发 Offer。