Skip to content

大型场景题

开放世界地图怎么加载?

核心解释

开放世界地图不是一次性全部加载,而是做成流式加载 Streaming: 把大地图切成很多 Chunk,玩家走到哪里,就加载附近区域;离玩家远的区域就卸载或降级成低精度远景。

csharp-unity-open-world-map-streaming-design

系统怎么拆

WorldStreamingManager:核心管理器,根据玩家位置决定加载和卸载哪些地图块。 ChunkConfig:地图块配置,记录坐标、资源路径、邻接关系、怪物刷新点、任务点。 ChunkLoader:异步加载地图块,可以用 AddressablesAssetBundleAdditive SceneChunkInstance:运行时地图块对象,记录当前是否加载、是否激活、引用了哪些资源。 LODManager:远处显示低模、烘焙贴图或代理地形,近处显示完整细节。 MemoryBudget:控制同时加载的地图块数量和资源内存。 PriorityQueue:按距离、玩家朝向、任务目标决定加载优先级。 FloatingOrigin:地图特别大时,用原点重置缓解浮点精度问题。

一次加载流程

每隔一段时间检查玩家位置。 根据玩家坐标算出当前所在的 Chunk。 以当前 Chunk 为中心,计算加载半径内需要哪些块。 已经加载的块保持不动。 缺失的块加入异步加载队列。 玩家前方、距离近、任务相关的块优先加载。 超过卸载半径的块加入卸载队列。 卸载前先关闭 AI、碰撞、特效,再释放资源。 远处区域保留低精度远景,避免玩家看到空洞。

简单 C# 示例

c
using System.Collections.Generic; // 引入 Dictionary、HashSet、List 集合类型
using UnityEngine; // 引入 Unity 引擎基础类型

public sealed class WorldStreamingManager : MonoBehaviour // 定义开放世界流式加载管理器
{ // 类开始
    [SerializeField] private Transform player; // 引用玩家 Transform
    [SerializeField] private int chunkSize = 100; // 每个地图块的世界尺寸
    [SerializeField] private int loadRadius = 2; // 加载半径,表示玩家周围加载几圈 Chunk
    [SerializeField] private int unloadRadius = 3; // 卸载半径,通常比加载半径大一点

    private readonly Dictionary<Vector2Int, GameObject> loadedChunks = new Dictionary<Vector2Int, GameObject>(); // 保存已经加载的地图块
    private Vector2Int lastPlayerChunk; // 保存上一次玩家所在的地图块坐标

    private void Start() // 游戏开始时调用
    { // 方法开始
        lastPlayerChunk = GetPlayerChunk(); // 计算玩家初始所在 Chunk
        RefreshChunks(); // 立即刷新附近地图块
    } // 方法结束

    private void Update() // 每帧调用
    { // 方法开始
        Vector2Int currentChunk = GetPlayerChunk(); // 计算玩家当前所在 Chunk
        if (currentChunk == lastPlayerChunk) // 如果玩家还在同一个 Chunk
        { // if 开始
            return; // 不需要重新计算加载范围
        } // if 结束

        lastPlayerChunk = currentChunk; // 更新玩家所在 Chunk
        RefreshChunks(); // 玩家跨 Chunk 后刷新加载和卸载
    } // 方法结束

    private Vector2Int GetPlayerChunk() // 根据玩家位置计算 Chunk 坐标
    { // 方法开始
        int x = Mathf.FloorToInt(player.position.x / chunkSize); // 计算玩家所在的 X 方向 Chunk
        int z = Mathf.FloorToInt(player.position.z / chunkSize); // 计算玩家所在的 Z 方向 Chunk
        return new Vector2Int(x, z); // 返回 Chunk 坐标
    } // 方法结束

    private void RefreshChunks() // 刷新地图块加载状态
    { // 方法开始
        Vector2Int center = GetPlayerChunk(); // 获取当前中心 Chunk
        HashSet<Vector2Int> neededChunks = new HashSet<Vector2Int>(); // 创建本次需要保留的 Chunk 集合

        for (int x = -loadRadius; x <= loadRadius; x++) // 遍历加载半径内的 X 偏移
        { // for 开始
            for (int z = -loadRadius; z <= loadRadius; z++) // 遍历加载半径内的 Z 偏移
            { // for 开始
                Vector2Int chunk = new Vector2Int(center.x + x, center.y + z); // 计算目标 Chunk 坐标
                neededChunks.Add(chunk); // 标记这个 Chunk 需要存在
                if (!loadedChunks.ContainsKey(chunk)) // 如果这个 Chunk 还没有加载
                { // if 开始
                    LoadChunk(chunk); // 加载这个 Chunk
                } // if 结束
            } // for 结束
        } // for 结束

        List<Vector2Int> unloadList = new List<Vector2Int>(); // 创建需要卸载的 Chunk 列表

        foreach (Vector2Int chunk in loadedChunks.Keys) // 遍历已经加载的 Chunk
        { // foreach 开始
            int distance = Mathf.Max(Mathf.Abs(chunk.x - center.x), Mathf.Abs(chunk.y - center.y)); // 计算 Chunk 到玩家中心的格子距离
            if (distance > unloadRadius) // 如果距离超过卸载半径
            { // if 开始
                unloadList.Add(chunk); // 加入卸载列表
            } // if 结束
        } // foreach 结束

        foreach (Vector2Int chunk in unloadList) // 遍历需要卸载的 Chunk
        { // foreach 开始
            UnloadChunk(chunk); // 卸载这个 Chunk
        } // foreach 结束
    } // 方法结束

    private void LoadChunk(Vector2Int chunk) // 加载指定 Chunk
    { // 方法开始
        GameObject chunkObject = new GameObject("Chunk_" + chunk.x + "_" + chunk.y); // 示例创建地图块对象
        loadedChunks[chunk] = chunkObject; // 把地图块记录到已加载字典
        Debug.Log("加载 Chunk:" + chunk); // 输出加载日志
    } // 方法结束

    private void UnloadChunk(Vector2Int chunk) // 卸载指定 Chunk
    { // 方法开始
        GameObject chunkObject = loadedChunks[chunk]; // 找到要卸载的地图块对象
        Destroy(chunkObject); // 销毁地图块对象
        loadedChunks.Remove(chunk); // 从已加载字典中移除
        Debug.Log("卸载 Chunk:" + chunk); // 输出卸载日志
    } // 方法结束
} // 类结束

这段代码是面试手写简化版。真实项目里 LoadChunk 不会直接 new GameObject,而是异步加载 Additive SceneAddressablesAssetBundle,并且实例化对象要分帧做,避免同一帧卡顿。

面试高分回答

IMPORTANT

开放世界地图一般会做分块流式加载。地图按固定大小切成 Chunk,每个 Chunk 可以对应一个 Additive Scene 或一个资源包。运行时根据玩家所在 Chunk 计算加载范围,加载半径内的块异步加载,卸载半径外的块延迟卸载。为了避免玩家来回走导致频繁加载卸载,卸载半径会比加载半径更大。加载优先级会考虑距离、玩家移动方向、任务目标和镜头朝向。远处区域不加载完整逻辑,只显示低精度 LOD 或远景代理。整个系统还要受内存预算控制,超过预算时优先释放远处、低优先级资源。

容易被追问的点

加载要异步,实例化也要分帧,否则会卡。 远处不要跑 AI、碰撞和复杂脚本,只保留视觉表现。 加载半径太小会穿帮,太大会爆内存。 卸载半径要比加载半径大,避免玩家在边界来回走时反复加载。 开放世界坐标太大时会有浮点精度问题,可以用 Floating Origin。 大世界常用 Additive Scene 管理地图块,也可以用 Addressables 管资源块。

一句话记忆

开放世界地图加载就是:地图切 Chunk,玩家驱动 Streaming,近处加载细节,远处显示代理,离远异步卸载,全程受内存预算控制。

百人同屏怎么优化?

核心解释

百人同屏优化的核心不是“把某个脚本改快一点”,而是做一套分级策略:近处角色完整表现,远处角色降级,不可见角色暂停,所有系统都按重要性少算、少画、少分配。

csharp-unity-hundred-players-onscreen-optimization

优化方向

渲染:控制角色面数、材质数、贴图大小,远处用低模,开启 LOD、GPU Instancing、SRP Batcher。 动画:Animator 和骨骼更新很贵,远处角色降频更新,关闭 IK、复杂 Layer、面部动画。 AI:近处正常决策,远处分帧或低频,不可见单位只保留关键状态。 物理:远处关闭不必要的 Rigidbody、Collider,技能范围检测先用空间划分过滤。 特效:限制同屏粒子数量,远处技能只播低配特效,小特效直接不播。 UI:不要给 100 个人都显示血条名字,只显示选中、受击、队友或近距离目标。 网络:只同步兴趣范围内的对象,远处低频同步或只同步状态摘要。 GC:对象池复用角色、子弹、特效、伤害数字,避免 Update 中 LINQ、字符串拼接和临时集合。

简单分级更新代码

c
using System.Collections.Generic; // 引入 List 集合类型
using UnityEngine; // 引入 Unity 引擎基础类型
public sealed class CrowdUpdateScheduler : MonoBehaviour // 定义百人同屏分级更新调度器
{ // 类开始
    [SerializeField] private Transform player; // 保存玩家位置引用
    [SerializeField] private float nearDistance = 15f; // 近距离阈值
    [SerializeField] private float middleDistance = 35f; // 中距离阈值
    [SerializeField] private List<CrowdUnit> units = new List<CrowdUnit>(); // 保存所有同屏单位
    private int frameIndex; // 保存当前帧编号
    private void Update() // 每帧调用
    { // 方法开始
        frameIndex++; // 帧编号递增
        for (int i = 0; i < units.Count; i++) // 遍历所有单位
        { // for 开始
            CrowdUnit unit = units[i]; // 取出当前单位
            float distance = Vector3.Distance(player.position, unit.transform.position); // 计算单位和玩家距离
            if (distance <= nearDistance) // 判断是否是近处单位
            { // if 开始
                unit.UpdateLogic(Time.deltaTime); // 近处单位每帧更新完整逻辑
                unit.SetVisualLevel(0); // 近处单位使用最高表现等级
            } // if 结束
            else if (distance <= middleDistance) // 判断是否是中距离单位
            { // else if 开始
                if (frameIndex % 3 == i % 3) // 中距离单位每三帧分批更新
                { // if 开始
                    unit.UpdateLogic(Time.deltaTime * 3f); // 用放大的 deltaTime 补偿低频更新
                } // if 结束
                unit.SetVisualLevel(1); // 中距离单位使用中等表现等级
            } // else if 结束
            else // 处理远距离单位
            { // else 开始
                if (frameIndex % 10 == i % 10) // 远距离单位每十帧分批更新
                { // if 开始
                    unit.UpdateSimpleState(); // 远距离单位只更新简单状态
                } // if 结束
                unit.SetVisualLevel(2); // 远距离单位使用最低表现等级
            } // else 结束
        } // for 结束
    } // 方法结束
} // 类结束
public sealed class CrowdUnit : MonoBehaviour // 定义同屏单位
{ // 类开始
    public void UpdateLogic(float deltaTime) { } // 更新完整逻辑
    public void UpdateSimpleState() { } // 更新简化逻辑
    public void SetVisualLevel(int level) { } // 设置表现等级
} // 类结束

面试高分回答

TIP

我会先用 Profiler 判断瓶颈在 CPU、GPU、动画、物理还是 GC。百人同屏不能所有角色都按主角标准更新,而是按距离和重要性分级。近处角色保留完整模型、动画、AI 和 UI;中距离角色降低动画和逻辑频率;远距离角色用低模、低频同步、关闭复杂碰撞和小特效;不可见角色直接暂停表现层,只保留必要状态。最后用 Profiler、Frame Debugger 和 Memory Profiler 对比优化前后的帧耗时、DrawCall、SetPass、GC Alloc 和内存占用。

一句话记忆

百人同屏优化就是:看得见才画,离得近才细,重要的先算,远处低频,特效限量,UI 少刷,资源复用,数据证明。

大量弹幕怎么优化?

核心解释

大量弹幕优化的核心是:不要让每颗子弹都像一个完整角色一样运行。弹幕应该是“轻量数据”,由一个管理器集中更新;表现对象从对象池里复用;碰撞先粗筛;出屏、超时、命中后立刻回收。

csharp-unity-bullet-hell-optimization

主要优化点

对象池:子弹频繁生成销毁,必须复用,避免 InstantiateDestroy 造成卡顿和 GC。 集中更新:不要每颗子弹挂一个 Update,用 BulletManager 统一遍历更新。 轻量碰撞:小弹幕优先用圆形、AABB、距离判断,少用复杂 Collider 和 Rigidbody。 空间粗筛:先用网格、四叉树、Layer 找附近目标,再做精确命中判断。 渲染合批:同类弹幕共享材质和贴图,大量同款弹幕可以考虑 GPU Instancing 或粒子系统。 生命周期:超时、出屏、命中后马上回收,避免无意义更新。 零 GC:Update 中不要 LINQ、不要临时 List、不要字符串拼接、不要频繁 new。

简单 C# 示例

c
using System.Collections.Generic; // 引入 List 和 Queue 集合类型
using UnityEngine; // 引入 Unity 引擎基础类型

public struct BulletData // 定义轻量子弹数据
{ // 结构体开始
    public Vector3 Position; // 保存子弹当前位置
    public Vector3 Velocity; // 保存子弹移动速度
    public float LifeTime; // 保存子弹剩余生命时间
    public GameObject View; // 保存子弹表现对象
} // 结构体结束

public sealed class BulletManager : MonoBehaviour // 定义弹幕管理器
{ // 类开始
    [SerializeField] private GameObject bulletPrefab; // 子弹预制体
    [SerializeField] private int initialCount = 500; // 初始对象池数量

    private readonly List<BulletData> activeBullets = new List<BulletData>(1024); // 保存正在飞行的子弹数据
    private readonly Queue<GameObject> pool = new Queue<GameObject>(); // 保存可复用的子弹表现对象

    private void Awake() // 初始化时调用
    { // 方法开始
        for (int i = 0; i < initialCount; i++) // 预创建指定数量的子弹对象
        { // for 开始
            GameObject obj = Instantiate(bulletPrefab); // 创建一个子弹表现对象
            obj.SetActive(false); // 先隐藏对象
            pool.Enqueue(obj); // 放入对象池等待复用
        } // for 结束
    } // 方法结束

    public void Spawn(Vector3 position, Vector3 direction, float speed, float lifeTime) // 生成一颗子弹
    { // 方法开始
        GameObject view = pool.Count > 0 ? pool.Dequeue() : Instantiate(bulletPrefab); // 优先从池里取对象
        view.transform.position = position; // 设置子弹初始位置
        view.SetActive(true); // 激活子弹表现对象

        BulletData data = new BulletData(); // 创建子弹数据
        data.Position = position; // 记录子弹位置
        data.Velocity = direction.normalized * speed; // 计算子弹速度
        data.LifeTime = lifeTime; // 设置子弹生命周期
        data.View = view; // 绑定表现对象

        activeBullets.Add(data); // 加入活跃子弹列表
    } // 方法结束

    private void Update() // 每帧统一更新所有子弹
    { // 方法开始
        float deltaTime = Time.deltaTime; // 获取本帧时间间隔

        for (int i = activeBullets.Count - 1; i >= 0; i--) // 倒序遍历,方便移除
        { // for 开始
            BulletData bullet = activeBullets[i]; // 取出当前子弹数据
            bullet.LifeTime -= deltaTime; // 减少剩余生命时间
            bullet.Position += bullet.Velocity * deltaTime; // 更新子弹位置
            bullet.View.transform.position = bullet.Position; // 同步表现对象位置

            if (bullet.LifeTime <= 0f) // 如果子弹生命周期结束
            { // if 开始
                Recycle(i, bullet); // 回收当前子弹
                continue; // 继续处理下一颗子弹
            } // if 结束

            activeBullets[i] = bullet; // 把修改后的子弹数据写回列表
        } // for 结束
    } // 方法结束

    private void Recycle(int index, BulletData bullet) // 回收指定子弹
    { // 方法开始
        bullet.View.SetActive(false); // 隐藏子弹表现对象
        pool.Enqueue(bullet.View); // 把表现对象放回对象池
        activeBullets.RemoveAt(index); // 从活跃列表中移除子弹数据
    } // 方法结束
} // 类结束

面试高分回答

NOTE

我会把弹幕分成逻辑数据和表现对象两层。逻辑层用数组或 List 存位置、速度、生命周期,由 BulletManager 批量更新;表现层用对象池复用 GameObject 或用粒子和 GPU Instancing 批量绘制。碰撞上不会让每颗子弹检测所有敌人,而是先用 Layer、网格、四叉树做粗筛,再对少量候选目标做圆形或 AABB 判定。出屏、命中、超时立刻回收,并且 Update 中避免 GC。

一句话记忆

大量弹幕优化就是:对象池复用,数据集中管,位置批量算,碰撞先粗筛,表现可降级,出界立刻回收,全程零 GC。

大量 UI Item 怎么优化?

核心解释

大量 UI Item 的核心优化是:数据可以很多,但真实创建出来的 UI 对象要很少。 比如背包有 5000 个道具,不能真的创建 5000 个格子,而是只创建屏幕里看得见的十几个,再随着滚动复用。

csharp-unity-large-ui-items-optimization

主要优化点

虚拟列表:只创建可见区域的 Item,加一点缓冲,不按数据总数创建。 对象池:Item 滑出屏幕后不销毁,换个 index 重新绑定数据继续用。 局部刷新:某个道具数量变化,只刷新这一格,不要整页重刷。 减少重建:少用动态 LayoutGroupContentSizeFitter,Item 位置尽量手动计算。 拆 Canvas:频繁变化的列表区域可以单独一个 Canvas,避免影响整个 UI。 关闭射线:不能点击的 ImageText 关闭 Raycast Target。 图集优化:同类图标进同一图集,减少材质切换和 DrawCall。 避免 GC:刷新时不要 LINQ、字符串拼接、临时 List,组件引用要缓存。

简单虚拟列表示例

c
using System.Collections.Generic; // 引入 List 集合类型
using UnityEngine; // 引入 Unity 引擎基础类型
using UnityEngine.UI; // 引入 Unity UI 组件类型

public sealed class VirtualList : MonoBehaviour // 定义虚拟列表组件
{ // 类开始
    [SerializeField] private RectTransform content; // 保存 ScrollView 的 Content 节点
    [SerializeField] private ScrollRect scrollRect; // 保存 ScrollRect 组件
    [SerializeField] private UIItem itemPrefab; // 保存 Item 预制体
    [SerializeField] private float itemHeight = 80f; // 保存每个 Item 的高度
    [SerializeField] private int visibleCount = 8; // 保存屏幕可见 Item 数量
    [SerializeField] private int bufferCount = 2; // 保存额外缓冲 Item 数量

    private readonly List<string> dataList = new List<string>(); // 保存所有数据
    private readonly List<UIItem> itemPool = new List<UIItem>(); // 保存复用的 UI Item
    private int lastStartIndex = -1; // 保存上一次起始索引

    private void Awake() // 初始化时调用
    { // 方法开始
        int createCount = visibleCount + bufferCount; // 计算真实需要创建的 Item 数量
        for (int i = 0; i < createCount; i++) // 循环创建少量 Item
        { // for 开始
            UIItem item = Instantiate(itemPrefab, content); // 创建 Item 并挂到 Content 下
            itemPool.Add(item); // 把 Item 加入复用池
        } // for 结束

        scrollRect.onValueChanged.AddListener(OnScrollChanged); // 监听滚动变化
    } // 方法结束

    public void SetData(List<string> data) // 设置列表数据
    { // 方法开始
        dataList.Clear(); // 清空旧数据
        dataList.AddRange(data); // 添加新数据
        content.sizeDelta = new Vector2(content.sizeDelta.x, dataList.Count * itemHeight); // 设置 Content 总高度
        RefreshVisibleItems(); // 刷新当前可见 Item
    } // 方法结束

    private void OnScrollChanged(Vector2 value) // 滚动变化时调用
    { // 方法开始
        RefreshVisibleItems(); // 根据滚动位置刷新可见 Item
    } // 方法结束

    private void RefreshVisibleItems() // 刷新可见 Item
    { // 方法开始
        float scrollY = content.anchoredPosition.y; // 获取 Content 当前滚动距离
        int startIndex = Mathf.FloorToInt(scrollY / itemHeight); // 根据滚动距离计算起始数据索引
        startIndex = Mathf.Clamp(startIndex, 0, Mathf.Max(0, dataList.Count - 1)); // 限制起始索引范围

        if (startIndex == lastStartIndex) // 如果起始索引没有变化
        { // if 开始
            return; // 不需要重复刷新
        } // if 结束

        lastStartIndex = startIndex; // 记录新的起始索引

        for (int i = 0; i < itemPool.Count; i++) // 遍历所有复用 Item
        { // for 开始
            int dataIndex = startIndex + i; // 计算当前 Item 对应的数据索引
            UIItem item = itemPool[i]; // 取出当前复用 Item

            if (dataIndex >= dataList.Count) // 如果数据索引超出范围
            { // if 开始
                item.gameObject.SetActive(false); // 隐藏这个多余 Item
                continue; // 继续处理下一个 Item
            } // if 结束

            item.gameObject.SetActive(true); // 显示当前 Item
            item.Rect.anchoredPosition = new Vector2(0f, -dataIndex * itemHeight); // 设置 Item 在 Content 中的位置
            item.Bind(dataIndex, dataList[dataIndex]); // 绑定对应数据
        } // for 结束
    } // 方法结束
} // 类结束

public sealed class UIItem : MonoBehaviour // 定义列表 Item
{ // 类开始
    [SerializeField] private Text label; // 保存文本组件
    public RectTransform Rect { get; private set; } // 暴露 RectTransform 引用

    private void Awake() // 初始化时调用
    { // 方法开始
        Rect = GetComponent<RectTransform>(); // 缓存 RectTransform 组件
    } // 方法结束

    public void Bind(int index, string text) // 绑定数据到 Item
    { // 方法开始
        label.text = index + " : " + text; // 更新显示文本
    } // 方法结束
} // 类结束

面试高分回答

TIP

我会先看是不是 ScrollView 大量 Item 导致的 Canvas.BuildBatchLayoutGC Alloc 升高。优化上我不会一次性创建所有 Item,而是做虚拟列表:根据滚动位置计算当前可见的数据索引,只创建可见数量加缓冲数量的 Item,滑出屏幕的 Item 通过对象池复用到新的数据索引上。布局位置尽量手动计算,减少 LayoutGroupContentSizeFitter 动态重建;图片用图集,非交互图关掉 Raycast Target,数据变化时只局部刷新。

一句话记忆

大量 UI Item 优化就是:数据很多没关系,UI 对象只保留可见数量;对象池复用,手动算位置,局部刷新,少重建,少射线,零 GC。

大量怪物 AI 怎么优化?

核心解释

大量怪物 AI 优化的核心是:不要让所有怪物每帧都跑完整 AI。 近处怪物完整计算,中距离低频计算,远距离简化计算,不可见怪物尽量休眠。

csharp-unity-large-monster-ai-optimization

主要优化点

AI LOD:按距离和可见性分成 NearMidFarSleep。 分帧调度:不要同一帧更新所有怪物,把 AI Tick 分散到多帧。 感知粗筛:怪物找目标时,先用距离、Layer、空间网格筛掉大部分对象。 寻路限流:A* 或 NavMesh 请求进入队列,每帧限制处理数量。 行为树降频:远处怪物不要每帧从根节点完整跑行为树。 技能决策缓存:技能 CD、攻击距离、目标仇恨值不要每帧重复全量计算。 休眠机制:离玩家很远、不可见、无战斗关系的怪物暂停 AI。 零 GC:候选目标列表、路径结果、AI 上下文都要复用。

简单 C# 分帧调度示例

c
using System.Collections.Generic; // 引入 List 集合类型
using UnityEngine; // 引入 Unity 引擎基础类型

public sealed class MonsterAIScheduler : MonoBehaviour // 定义怪物 AI 调度器
{ // 类开始
    [SerializeField] private Transform player; // 保存玩家位置引用
    [SerializeField] private float nearDistance = 15f; // 近距离阈值
    [SerializeField] private float midDistance = 35f; // 中距离阈值
    [SerializeField] private List<MonsterAI> monsters = new List<MonsterAI>(); // 保存所有怪物 AI

    private int frameIndex; // 保存当前帧编号

    private void Update() // 每帧调用
    { // 方法开始
        frameIndex++; // 帧编号递增

        for (int i = 0; i < monsters.Count; i++) // 遍历所有怪物
        { // for 开始
            MonsterAI monster = monsters[i]; // 取出当前怪物
            float distance = Vector3.Distance(player.position, monster.transform.position); // 计算怪物到玩家的距离

            if (distance <= nearDistance) // 如果怪物在近距离范围
            { // if 开始
                monster.TickFullAI(Time.deltaTime); // 近处怪物每帧跑完整 AI
            } // if 结束
            else if (distance <= midDistance) // 如果怪物在中距离范围
            { // else if 开始
                if (frameIndex % 3 == i % 3) // 中距离怪物分成三批更新
                { // if 开始
                    monster.TickSimpleAI(Time.deltaTime * 3f); // 中距离怪物低频更新简化 AI
                } // if 结束
            } // else if 结束
            else // 如果怪物在远距离范围
            { // else 开始
                if (frameIndex % 10 == i % 10) // 远距离怪物分成十批更新
                { // if 开始
                    monster.TickSleepAI(); // 远处怪物只更新极简状态
                } // if 结束
            } // else 结束
        } // for 结束
    } // 方法结束
} // 类结束

public sealed class MonsterAI : MonoBehaviour // 定义怪物 AI
{ // 类开始
    public void TickFullAI(float deltaTime) // 更新完整 AI
    { // 方法开始
        SenseTarget(); // 感知目标
        RequestPathIfNeeded(); // 必要时请求寻路
        DecideSkill(); // 决策技能
        MoveAndAttack(deltaTime); // 移动和攻击
    } // 方法结束

    public void TickSimpleAI(float deltaTime) // 更新简化 AI
    { // 方法开始
        UpdateHateState(); // 更新仇恨状态
        MoveRoughly(deltaTime); // 做粗略移动
    } // 方法结束

    public void TickSleepAI() // 更新休眠 AI
    { // 方法开始
        CheckWakeUpCondition(); // 只检查是否需要唤醒
    } // 方法结束

    private void SenseTarget() { } // 感知目标
    private void RequestPathIfNeeded() { } // 必要时请求路径
    private void DecideSkill() { } // 决策技能
    private void MoveAndAttack(float deltaTime) { } // 移动和攻击
    private void UpdateHateState() { } // 更新仇恨状态
    private void MoveRoughly(float deltaTime) { } // 粗略移动
    private void CheckWakeUpCondition() { } // 检查唤醒条件
} // 类结束

面试高分回答

TIP

我会先用 Profiler 看瓶颈是不是在 BehaviourUpdate、寻路、物理检测、动画还是 GC。大量怪物不能每帧都跑完整 AI,所以我会做 AI LOD:近处怪物完整更新,包括感知、寻路、技能、攻击;中距离怪物低频更新;远距离怪物只维护简单状态;不可见且远离玩家的怪物休眠。感知用空间分区粗筛,寻路请求进队列限流,行为树降低 Tick 频率,技能和目标选择做缓存,所有临时列表复用,避免 GC。

一句话记忆

大量怪物 AI 优化就是:近处完整算,中距离低频算,远处简单算,不可见尽量不算;感知先粗筛,寻路进队列,AI 分帧跑。

大量特效怎么优化?

核心解释

大量特效优化的核心是:给特效做预算和分级。 不是所有特效都要完整播放:近处、重要、玩家关注的特效高配;远处、普通、看不清的特效降级;不可见特效暂停或不播。

csharp-unity-large-vfx-optimization

主要优化点

对象池:技能、爆炸、命中特效频繁出现,必须复用,避免频繁创建销毁。 粒子预算:限制同屏特效数量、粒子总数、爆发粒子峰值。 VFX LOD:近处高配,远处低配,低配减少粒子数、发射率、拖尾、灯光。 Overdraw:透明粒子反复覆盖同一片屏幕,大面积烟雾、火焰、光效尤其贵。 材质合批:同类特效共享材质和图集,减少 SetPass 和贴图切换。 可见性裁剪:摄像机外、距离太远、被遮挡的特效不播放或暂停。 预加载:战斗前预加载常用技能特效,避免首次释放技能卡顿。 回收清理:回收前要 StopClear,拖尾要清掉,否则复用时会残留上一段轨迹。

简单特效预算管理代码

c
using System.Collections.Generic; // 引入 Queue 和 Dictionary 集合类型
using UnityEngine; // 引入 Unity 引擎基础类型

public sealed class VfxManager : MonoBehaviour // 定义特效管理器
{ // 类开始
    [SerializeField] private int maxPlayingVfx = 80; // 限制同屏最多播放的特效数量
    [SerializeField] private GameObject hitVfxPrefab; // 保存命中特效预制体

    private readonly Queue<GameObject> pool = new Queue<GameObject>(); // 保存可复用的特效对象池
    private readonly List<GameObject> playing = new List<GameObject>(); // 保存正在播放的特效对象

    public void PlayHitVfx(Vector3 position, float importance) // 播放命中特效
    { // 方法开始
        if (playing.Count >= maxPlayingVfx && importance < 1f) // 如果超过预算且不是重要特效
        { // if 开始
            return; // 直接不播放普通特效
        } // if 结束

        GameObject obj = GetVfxObject(); // 从对象池获取特效对象
        obj.transform.position = position; // 设置特效播放位置
        obj.SetActive(true); // 激活特效对象

        ParticleSystem particle = obj.GetComponent<ParticleSystem>(); // 获取粒子系统组件
        particle.Clear(true); // 清理旧粒子,避免复用残留
        particle.Play(true); // 播放粒子特效

        playing.Add(obj); // 记录正在播放的特效
        StartCoroutine(RecycleAfterPlay(obj, particle)); // 播放结束后回收特效
    } // 方法结束

    private GameObject GetVfxObject() // 获取一个特效对象
    { // 方法开始
        if (pool.Count > 0) // 如果对象池里有可用对象
        { // if 开始
            return pool.Dequeue(); // 取出一个旧对象复用
        } // if 结束

        return Instantiate(hitVfxPrefab); // 对象池不够时创建新对象
    } // 方法结束

    private System.Collections.IEnumerator RecycleAfterPlay(GameObject obj, ParticleSystem particle) // 等待播放完成后回收
    { // 协程开始
        while (particle.IsAlive(true)) // 只要粒子系统还活着
        { // while 开始
            yield return null; // 等待下一帧继续检查
        } // while 结束

        particle.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); // 停止并清理粒子
        obj.SetActive(false); // 隐藏特效对象
        playing.Remove(obj); // 从播放列表中移除
        pool.Enqueue(obj); // 放回对象池复用
    } // 协程结束
} // 类结束

面试高分回答

TIP

我会先用 Profiler 和 Frame Debugger 判断瓶颈在 CPU、GPU、Overdraw、粒子模拟还是 GC。大量特效不能只靠对象池,还要做预算系统和 LOD。比如同屏最多播放多少个特效、最多多少粒子、低端机使用哪套低配特效。近处重要技能完整播放,远处技能减少粒子数和贴图尺寸,关闭粒子灯光、碰撞、复杂 Sub Emitter;大面积透明烟雾要控制 Overdraw;相同特效尽量共享材质和图集;技能释放前预加载,播放结束后清理并回收到对象池。

一句话记忆

大量特效优化就是:特效从池里来,播放先看预算,近处高配,远处低配,透明少叠,材质共享,播完清理回收。

大量网络消息怎么处理?

核心解释

大量网络消息的核心处理思路是:网络线程只负责收包、拆包、解码,业务处理放到主线程按预算分发。 不能消息一来就立刻改角色、UI、场景对象,否则容易线程不安全,也容易一帧处理太多导致卡顿。

csharp-unity-massive-network-messages-handling

主要优化点

接收缓冲区:TCP 是字节流,要处理粘包和半包,通常用 包长度 + 消息 ID + 内容 拆完整包。 消息队列:网络线程解码后放入线程安全队列,主线程再取出来处理。 主线程预算:每帧最多处理一定数量或一定毫秒,避免一帧消息过多卡死。 优先级队列:战斗、移动、断线、结算优先级高;聊天、日志、表现同步优先级低。 消息合并:位置、朝向、血量快照可以只保留最新,旧状态消息可以覆盖。 背压保护:队列积压时要限流、合并、降频、丢弃低优先级消息,严重时请求重同步。 对象池:消息对象、byte buffer、解析上下文复用,避免每条消息都 new。 兴趣管理:服务器只发玩家关心范围内的消息,这是减少消息量的根本。

简单 C# 示例

c
using System.Collections.Concurrent; // 引入线程安全队列
using System.Collections.Generic; // 引入 Dictionary 集合类型
using UnityEngine; // 引入 Unity 引擎基础类型

public sealed class NetMessage // 定义网络消息对象
{ // 类开始
    public int MessageId; // 保存消息 ID
    public byte[] Payload; // 保存消息内容
} // 类结束

public sealed class NetworkMessageDispatcher : MonoBehaviour // 定义网络消息分发器
{ // 类开始
    private readonly ConcurrentQueue<NetMessage> queue = new ConcurrentQueue<NetMessage>(); // 保存线程安全消息队列
    private readonly Dictionary<int, System.Action<NetMessage>> handlers = new Dictionary<int, System.Action<NetMessage>>(); // 保存消息 ID 到处理函数的映射
    [SerializeField] private int maxMessagePerFrame = 100; // 限制每帧最多处理多少条消息

    public void EnqueueFromNetworkThread(NetMessage message) // 网络线程调用,把消息放入队列
    { // 方法开始
        queue.Enqueue(message); // 把消息加入线程安全队列
    } // 方法结束

    public void RegisterHandler(int messageId, System.Action<NetMessage> handler) // 注册消息处理函数
    { // 方法开始
        handlers[messageId] = handler; // 保存消息处理函数
    } // 方法结束

    private void Update() // Unity 主线程每帧调用
    { // 方法开始
        int count = 0; // 记录本帧已经处理的消息数量

        while (count < maxMessagePerFrame && queue.TryDequeue(out NetMessage message)) // 在预算内不断取消息
        { // while 开始
            Dispatch(message); // 分发这条消息
            RecycleMessage(message); // 回收消息对象或缓冲区
            count++; // 本帧处理数量加一
        } // while 结束
    } // 方法结束

    private void Dispatch(NetMessage message) // 分发消息给业务模块
    { // 方法开始
        if (!handlers.TryGetValue(message.MessageId, out System.Action<NetMessage> handler)) // 如果没有找到处理函数
        { // if 开始
            Debug.LogWarning("未注册消息:" + message.MessageId); // 输出警告日志
            return; // 不继续处理
        } // if 结束

        handler.Invoke(message); // 调用对应业务处理函数
    } // 方法结束

    private void RecycleMessage(NetMessage message) // 回收消息对象
    { // 方法开始
        message.Payload = null; // 清理消息内容引用
    } // 方法结束
} // 类结束

面试高分回答

NOTE

我会把网络消息处理拆成 IO 层、协议层、队列层和业务分发层。IO 线程只负责收包,协议层处理粘包半包并反序列化成消息对象,然后放入线程安全队列。Unity 主线程每帧按时间预算或条数预算消费队列,再根据消息 ID 分发给对应模块。对于位置同步这种状态类消息,可以合并,只保留最新;对于伤害、奖励、交易这种关键消息,必须按顺序可靠处理。队列积压时要做背压,比如限流、降频、丢弃低优先级消息或请求重同步。

一句话记忆

大量网络消息处理就是:网络线程收和拆,主线程按预算分发;关键消息不丢,状态消息保最新;队列积压就限流、合并、降级。

大量资源如何分包?

核心解释

大量资源分包不是按文件夹随便切,而是按:使用时机、复用关系、更新频率、平台语言、下载策略来拆。 目标是:首包小、下载快、依赖清楚、不重复打包、热更时少下载。

csharp-unity-resource-package-splitting

怎么分包

首包:只放启动必须资源,比如登录、基础 UI、公共 Shader、基础字体、加载页。 场景包:主城、副本、大世界 Chunk 单独打包,进入前下载,离开后可释放。 玩法包:活动、剧情章节、特殊副本、限时玩法资源按需下载。 公共依赖包:公共材质、Shader、通用图集、共享音效独立出来,避免重复依赖。 角色包:角色模型、动画、技能特效、语音可以按角色或职业拆。 语言包:文本、语音、字幕按语言拆,玩家只下载当前语言。 平台包:Android、iOS、PC 的贴图格式和压缩方式不同,最好分平台构建。 画质包:高清贴图、低清贴图分开,低端机不用下载高清资源。 热更包:高频变化的配置、活动资源、小版本内容尽量拆小,避免改一点导致大包重下。

分包流程

先扫描所有资源。 根据资源用途打标签,比如 first_packagescene_cityrole_001common_shader。 分析依赖关系,找出公共依赖。 公共依赖单独打包,业务包只引用它。 构建 AssetBundle 或 Addressables Group。 生成 Manifest,记录包名、Hash、大小、版本、依赖。 客户端启动后对比本地 Manifest 和远端 Manifest。 缺什么下什么,版本不一致就更新。 下载后校验 Hash,成功后写入缓存。 失败时重试或回滚旧版本。

简单配置示例

c
using System.Collections.Generic; // 引入 List 集合类型
using UnityEngine; // 引入 Unity 基础类型

public enum PackageType // 定义资源包类型
{ // 枚举开始
    FirstPackage, // 首包资源
    Common, // 公共依赖资源
    Scene, // 场景资源
    Role, // 角色资源
    Activity, // 活动资源
    Language, // 语言资源
    Platform // 平台资源
} // 枚举结束

public sealed class ResourcePackageInfo // 定义资源包信息
{ // 类开始
    public string PackageName; // 保存资源包名字
    public PackageType Type; // 保存资源包类型
    public string Hash; // 保存资源包 Hash,用来判断版本是否变化
    public long Size; // 保存资源包大小
    public List<string> Dependencies = new List<string>(); // 保存依赖的其他资源包
    public List<string> Assets = new List<string>(); // 保存资源包里包含的资源 ID
} // 类结束

public sealed class PackageManifest // 定义资源包清单
{ // 类开始
    public string Version; // 保存 Manifest 版本号
    public List<ResourcePackageInfo> Packages = new List<ResourcePackageInfo>(); // 保存所有资源包信息
} // 类结束

面试高分回答

CAUTION

我会先把资源分成首包资源、公共依赖资源、场景资源、角色资源、活动资源、语言资源和平台资源。首包只放启动必须内容,保证安装包和首次进入尽量小;场景、副本、活动这类资源按需下载;公共 Shader、材质、图集要独立成公共包,避免多个业务包重复打包。构建后生成 Manifest,记录每个包的 Hash、大小、依赖和版本,客户端通过 Manifest 做版本对比、下载、校验、缓存和回滚。

容易被追问的坑

WARNING

包太大:更新一点点内容就要重下大包。 包太小:请求数量多,依赖复杂,加载管理成本高。 公共依赖没抽出来:多个包重复打同一张贴图或材质,包体变大。 循环依赖:包之间互相依赖,加载和卸载都很麻烦。 热更失败没回滚:玩家可能卡在坏版本。 平台资源混在一起:Android 下载了 iOS 贴图,浪费包体。

一句话记忆

大量资源分包就是:首包放必须,场景玩法按需,公共依赖独立,平台语言分开,高频热更拆小,Manifest 管版本和依赖。

大量配置表如何管理?

核心解释

大量配置表管理的核心是:把配置表当成一套数据工程,而不是一堆 Excel 文件。 要有表结构规范、导表工具、自动校验、强类型代码生成、索引缓存、版本热更和问题追踪。

csharp-unity-large-config-table-management

系统怎么拆

ConfigSource:原始表,比如 Excel、CSV、Google Sheet。 ExportTool:导表工具,把原始表导成 JSON、二进制、Lua 表或自定义格式。 Validator:校验工具,检查重复 ID、空字段、类型错误、跨表引用错误。 CodeGenerator:代码生成器,根据表结构生成 C# 强类型类。 ConfigManager:运行时统一入口,负责加载、查询、缓存配置。 IndexBuilder:建立主键索引和二级索引,比如按怪物类型、关卡 ID 查询。 VersionManifest:记录每张表的 Hash、版本、大小,用于热更新对比。 DebugTool:运行时查表工具,方便定位某个 ID 来自哪张表哪一行。

管理流程

策划编辑配置表。 导表工具解析表头、字段类型和数据行。 自动校验重复 ID、空引用、非法范围、跨表引用。 校验通过后生成运行时数据文件。 同时生成 C# 强类型配置类。 客户端启动或进入模块时加载配置。 加载后建立索引。 业务通过 ConfigManager.GetItem(id) 查询。 热更时对比表 Hash,只下载变化表。 新表校验通过才替换旧表,失败则回滚旧版本。

简单 C# 示例

c
using System.Collections.Generic; // 引入 Dictionary 集合类型
using UnityEngine; // 引入 Unity 引擎基础类型

public sealed class ItemConfig // 定义道具配置数据
{ // 类开始
    public int Id; // 道具唯一 ID
    public string Name; // 道具名字
    public int Quality; // 道具品质
    public int Price; // 道具价格
} // 类结束

public sealed class ConfigManager // 定义配置管理器
{ // 类开始
    private readonly Dictionary<int, ItemConfig> itemConfigs = new Dictionary<int, ItemConfig>(); // 保存道具 ID 到道具配置的索引

    public void LoadItemConfigs(List<ItemConfig> configs) // 加载道具配置表
    { // 方法开始
        itemConfigs.Clear(); // 清空旧配置

        for (int i = 0; i < configs.Count; i++) // 遍历所有配置行
        { // for 开始
            ItemConfig config = configs[i]; // 取出当前配置
            if (itemConfigs.ContainsKey(config.Id)) // 如果 ID 已经存在
            { // if 开始
                Debug.LogError("道具表重复 ID:" + config.Id); // 输出重复 ID 错误
                continue; // 跳过这条错误配置
            } // if 结束

            itemConfigs.Add(config.Id, config); // 把配置加入索引表
        } // for 结束
    } // 方法结束

    public ItemConfig GetItemConfig(int id) // 根据 ID 查询道具配置
    { // 方法开始
        if (!itemConfigs.TryGetValue(id, out ItemConfig config)) // 如果找不到配置
        { // if 开始
            Debug.LogError("找不到道具配置:" + id); // 输出找不到配置错误
            return null; // 返回空配置
        } // if 结束

        return config; // 返回查询到的配置
    } // 方法结束
} // 类结束

面试高分回答

IMPORTANT

我会把配置表管理拆成导表阶段和运行时阶段。导表阶段由工具统一解析 Excel 或 CSV,自动校验字段类型、重复 ID、空引用、跨表引用和数值范围,然后生成运行时数据文件和 C# 强类型代码。运行时由 ConfigManager 统一加载配置,建立 Dictionary 主键索引和必要的二级索引,业务模块不能自己随便读文件。线上热更时,每张表都有 Hash 和版本号,只下载变化的表,新表校验成功后才替换旧表,失败则回滚。

容易被追问的坑

TIP

重复 ID 会导致查询结果不稳定。 跨表引用没校验,比如技能引用了不存在的特效 ID,会运行时才爆。 字段改名或删字段要做兼容,否则旧版本数据可能读不了。 客户端和服务端关键表必须保持一致,比如伤害、掉落、价格。 大表不一定都要启动加载,可以按模块懒加载。 多语言文本最好用 textId 引用语言表,不要把大段文本塞进玩法表。

一句话记忆

大量配置表管理就是:表要有规范,导出要自动,错误要校验,访问要强类型,查询要建索引,更新要看版本,失败要回滚。

大量日志如何采集和分析?

核心解释

大量日志采集和分析的核心不是多打 Debug.Log,而是做一套线上问题定位系统: 日志要结构化、分级、限流、批量上报、可检索、可聚合、可告警。

csharp-unity-large-log-collection-analysis

系统怎么拆

LogManager:统一日志入口,业务不要到处直接 Debug.LogLogLevel:日志等级,比如 DebugInfoWarnErrorFatalLogContext:上下文,比如用户 ID、sessionId、版本、设备、场景、关卡。 RingBuffer:本地环形缓冲,保留最近 N 条关键日志。 FileWriter:关键错误和崩溃前日志写入本地文件。 UploadQueue:批量上传队列,网络不好时先缓存。 Sampler:采样和限流,避免同类日志爆量。 LogServer:服务端接收、清洗、入库、检索、聚合和告警。

采集流程

业务模块调用统一日志接口。 日志系统自动补充上下文,比如版本、账号、设备、场景。 日志进入内存缓冲队列。 关键错误写入本地文件。 满足数量或时间条件后批量上传。 服务端清洗日志并入库。 按版本、渠道、机型、模块、错误码聚合。 异常率升高时触发告警。 排查时用 sessionId 串起玩家一次完整流程。

简单 C# 示例

c
using System.Collections.Generic; // 引入 Queue 集合类型
using UnityEngine; // 引入 Unity 引擎基础类型

public enum LogLevel // 定义日志等级
{ // 枚举开始
    Debug, // 调试日志
    Info, // 普通信息日志
    Warn, // 警告日志
    Error, // 错误日志
    Fatal // 致命错误日志
} // 枚举结束

public sealed class LogRecord // 定义一条日志记录
{ // 类开始
    public LogLevel Level; // 保存日志等级
    public string Module; // 保存日志所属模块
    public string Message; // 保存日志内容
    public string SessionId; // 保存本次会话 ID
    public string Version; // 保存客户端版本
    public float Time; // 保存日志产生时间
} // 类结束

public sealed class LogManager : MonoBehaviour // 定义日志管理器
{ // 类开始
    private readonly Queue<LogRecord> uploadQueue = new Queue<LogRecord>(); // 保存待上传日志队列
    [SerializeField] private int maxQueueCount = 200; // 限制本地队列最大长度

    public void Log(LogLevel level, string module, string message) // 记录一条日志
    { // 方法开始
        LogRecord record = new LogRecord(); // 创建日志记录对象
        record.Level = level; // 设置日志等级
        record.Module = module; // 设置日志模块
        record.Message = message; // 设置日志内容
        record.SessionId = GetSessionId(); // 设置会话 ID
        record.Version = Application.version; // 设置客户端版本
        record.Time = Time.realtimeSinceStartup; // 设置日志时间

        if (uploadQueue.Count >= maxQueueCount) // 如果队列已经太长
        { // if 开始
            uploadQueue.Dequeue(); // 丢弃最旧的一条普通日志
        } // if 结束

        uploadQueue.Enqueue(record); // 把日志加入上传队列

        if (level >= LogLevel.Error) // 如果是错误或致命错误
        { // if 开始
            Debug.LogError("[" + module + "] " + message); // 本地输出错误日志
        } // if 结束
    } // 方法结束

    private string GetSessionId() // 获取本次会话 ID
    { // 方法开始
        return SystemInfo.deviceUniqueIdentifier; // 示例使用设备标识,真实项目建议使用服务端生成的 sessionId
    } // 方法结束

    public void Flush() // 批量上传日志
    { // 方法开始
        while (uploadQueue.Count > 0) // 如果还有待上传日志
        { // while 开始
            LogRecord record = uploadQueue.Dequeue(); // 取出一条日志
            Upload(record); // 上传这条日志
        } // while 结束
    } // 方法结束

    private void Upload(LogRecord record) // 上传单条日志
    { // 方法开始
        Debug.Log("上传日志:" + record.Module + " " + record.Message); // 示例输出,真实项目里会批量 HTTP 上报
    } // 方法结束
} // 类结束

面试高分回答

NOTE

我会把日志系统设计成结构化采集,而不是简单输出字符串。客户端统一走 LogManager,每条日志带上等级、模块、用户、sessionId、版本、设备、场景、错误码等上下文。日志先进入本地缓冲,关键错误落盘,普通日志按数量或时间批量上报。大量日志要做采样和限流,同类错误可以合并计数,避免日志系统拖垮性能。服务端按版本、机型、渠道、模块聚合,做 Top 错误、影响人数、趋势图和告警。

一句话记忆

大量日志系统就是:日志要结构化,客户端先缓冲,网络批量传,服务端能检索,异常能聚合,问题能告警,敏感信息要脱敏。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0