Appearance
大型场景题
开放世界地图怎么加载?
核心解释
开放世界地图不是一次性全部加载,而是做成流式加载 Streaming: 把大地图切成很多 Chunk,玩家走到哪里,就加载附近区域;离玩家远的区域就卸载或降级成低精度远景。
系统怎么拆
WorldStreamingManager:核心管理器,根据玩家位置决定加载和卸载哪些地图块。 ChunkConfig:地图块配置,记录坐标、资源路径、邻接关系、怪物刷新点、任务点。 ChunkLoader:异步加载地图块,可以用 Addressables、AssetBundle 或 Additive Scene。 ChunkInstance:运行时地图块对象,记录当前是否加载、是否激活、引用了哪些资源。 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 Scene、Addressables 或 AssetBundle,并且实例化对象要分帧做,避免同一帧卡顿。
面试高分回答
IMPORTANT
开放世界地图一般会做分块流式加载。地图按固定大小切成 Chunk,每个 Chunk 可以对应一个 Additive Scene 或一个资源包。运行时根据玩家所在 Chunk 计算加载范围,加载半径内的块异步加载,卸载半径外的块延迟卸载。为了避免玩家来回走导致频繁加载卸载,卸载半径会比加载半径更大。加载优先级会考虑距离、玩家移动方向、任务目标和镜头朝向。远处区域不加载完整逻辑,只显示低精度 LOD 或远景代理。整个系统还要受内存预算控制,超过预算时优先释放远处、低优先级资源。
容易被追问的点
加载要异步,实例化也要分帧,否则会卡。 远处不要跑 AI、碰撞和复杂脚本,只保留视觉表现。 加载半径太小会穿帮,太大会爆内存。 卸载半径要比加载半径大,避免玩家在边界来回走时反复加载。 开放世界坐标太大时会有浮点精度问题,可以用 Floating Origin。 大世界常用 Additive Scene 管理地图块,也可以用 Addressables 管资源块。
一句话记忆
开放世界地图加载就是:地图切 Chunk,玩家驱动 Streaming,近处加载细节,远处显示代理,离远异步卸载,全程受内存预算控制。
百人同屏怎么优化?
核心解释
百人同屏优化的核心不是“把某个脚本改快一点”,而是做一套分级策略:近处角色完整表现,远处角色降级,不可见角色暂停,所有系统都按重要性少算、少画、少分配。
优化方向
渲染:控制角色面数、材质数、贴图大小,远处用低模,开启 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 少刷,资源复用,数据证明。
大量弹幕怎么优化?
核心解释
大量弹幕优化的核心是:不要让每颗子弹都像一个完整角色一样运行。弹幕应该是“轻量数据”,由一个管理器集中更新;表现对象从对象池里复用;碰撞先粗筛;出屏、超时、命中后立刻回收。
主要优化点
对象池:子弹频繁生成销毁,必须复用,避免 Instantiate 和 Destroy 造成卡顿和 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 个格子,而是只创建屏幕里看得见的十几个,再随着滚动复用。
主要优化点
虚拟列表:只创建可见区域的 Item,加一点缓冲,不按数据总数创建。 对象池:Item 滑出屏幕后不销毁,换个 index 重新绑定数据继续用。 局部刷新:某个道具数量变化,只刷新这一格,不要整页重刷。 减少重建:少用动态 LayoutGroup、ContentSizeFitter,Item 位置尽量手动计算。 拆 Canvas:频繁变化的列表区域可以单独一个 Canvas,避免影响整个 UI。 关闭射线:不能点击的 Image、Text 关闭 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.BuildBatch、Layout 或 GC Alloc 升高。优化上我不会一次性创建所有 Item,而是做虚拟列表:根据滚动位置计算当前可见的数据索引,只创建可见数量加缓冲数量的 Item,滑出屏幕的 Item 通过对象池复用到新的数据索引上。布局位置尽量手动计算,减少 LayoutGroup 和 ContentSizeFitter 动态重建;图片用图集,非交互图关掉 Raycast Target,数据变化时只局部刷新。
一句话记忆
大量 UI Item 优化就是:数据很多没关系,UI 对象只保留可见数量;对象池复用,手动算位置,局部刷新,少重建,少射线,零 GC。
大量怪物 AI 怎么优化?
核心解释
大量怪物 AI 优化的核心是:不要让所有怪物每帧都跑完整 AI。 近处怪物完整计算,中距离低频计算,远距离简化计算,不可见怪物尽量休眠。
主要优化点
AI LOD:按距离和可见性分成 Near、Mid、Far、Sleep。 分帧调度:不要同一帧更新所有怪物,把 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 分帧跑。
大量特效怎么优化?
核心解释
大量特效优化的核心是:给特效做预算和分级。 不是所有特效都要完整播放:近处、重要、玩家关注的特效高配;远处、普通、看不清的特效降级;不可见特效暂停或不播。
主要优化点
对象池:技能、爆炸、命中特效频繁出现,必须复用,避免频繁创建销毁。 粒子预算:限制同屏特效数量、粒子总数、爆发粒子峰值。 VFX LOD:近处高配,远处低配,低配减少粒子数、发射率、拖尾、灯光。 Overdraw:透明粒子反复覆盖同一片屏幕,大面积烟雾、火焰、光效尤其贵。 材质合批:同类特效共享材质和图集,减少 SetPass 和贴图切换。 可见性裁剪:摄像机外、距离太远、被遮挡的特效不播放或暂停。 预加载:战斗前预加载常用技能特效,避免首次释放技能卡顿。 回收清理:回收前要 Stop、Clear,拖尾要清掉,否则复用时会残留上一段轨迹。
简单特效预算管理代码
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、场景对象,否则容易线程不安全,也容易一帧处理太多导致卡顿。
主要优化点
接收缓冲区: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 分发给对应模块。对于位置同步这种状态类消息,可以合并,只保留最新;对于伤害、奖励、交易这种关键消息,必须按顺序可靠处理。队列积压时要做背压,比如限流、降频、丢弃低优先级消息或请求重同步。
一句话记忆
大量网络消息处理就是:网络线程收和拆,主线程按预算分发;关键消息不丢,状态消息保最新;队列积压就限流、合并、降级。
大量资源如何分包?
核心解释
大量资源分包不是按文件夹随便切,而是按:使用时机、复用关系、更新频率、平台语言、下载策略来拆。 目标是:首包小、下载快、依赖清楚、不重复打包、热更时少下载。
怎么分包
首包:只放启动必须资源,比如登录、基础 UI、公共 Shader、基础字体、加载页。 场景包:主城、副本、大世界 Chunk 单独打包,进入前下载,离开后可释放。 玩法包:活动、剧情章节、特殊副本、限时玩法资源按需下载。 公共依赖包:公共材质、Shader、通用图集、共享音效独立出来,避免重复依赖。 角色包:角色模型、动画、技能特效、语音可以按角色或职业拆。 语言包:文本、语音、字幕按语言拆,玩家只下载当前语言。 平台包:Android、iOS、PC 的贴图格式和压缩方式不同,最好分平台构建。 画质包:高清贴图、低清贴图分开,低端机不用下载高清资源。 热更包:高频变化的配置、活动资源、小版本内容尽量拆小,避免改一点导致大包重下。
分包流程
先扫描所有资源。 根据资源用途打标签,比如 first_package、scene_city、role_001、common_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 文件。 要有表结构规范、导表工具、自动校验、强类型代码生成、索引缓存、版本热更和问题追踪。
系统怎么拆
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,而是做一套线上问题定位系统: 日志要结构化、分级、限流、批量上报、可检索、可聚合、可告警。
系统怎么拆
LogManager:统一日志入口,业务不要到处直接 Debug.Log。 LogLevel:日志等级,比如 Debug、Info、Warn、Error、Fatal。 LogContext:上下文,比如用户 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 错误、影响人数、趋势图和告警。
一句话记忆
大量日志系统就是:日志要结构化,客户端先缓冲,网络批量传,服务端能检索,异常能聚合,问题能告警,敏感信息要脱敏。