北京疯狂游戏 Unity 游戏客户端开发面经(一面 二面)

news/2025/11/13 2:58:32/文章来源:https://www.cnblogs.com/Senes1-wsx/p/19205671

写在前面

面试题目仅靠回忆,复盘过程借助 Gemini 2.5 Pro,内容可能存在错误或偏差。

疯狂游戏主要做小程序游戏,知名作品有《咸鱼之王》等。在Boss直聘上投递天津的岗位,简历被转给北京,因此面试的是北京的项目组,没有看到JD,不清楚具体是哪个项目组。

面试时间2025.11,对方的响应速度非常快,简历投递后当天下午约面试,第二天下午连续两场面试,第三天下午出结果。

面试结果:一面通过,二面技术总监面已挂。之后补充二面面经。

一面的部分问题针对项目的网络同步和性能优化方面提问,项目演示视频链接:Unity 25届客户端求职 | 从零搭建格斗游戏框架(含数据驱动 & 网络同步)_哔哩哔哩_bilibili

一面

1. 当前项目(状态同步)如果要改为帧同步,确定性逻辑改进

  • 彻底替换物理引擎

完全移除对 Physics2D 的所有依赖,包括物理查询(Raycast/Overlap)和碰撞体的物理检测(OnTriggerEnter等)。Unity 的物理循环和物理查询在不同平台或版本上都可能产生非确定性的结果。必须使用自定义的数学逻辑(例如AABB包围盒检测)来重写整个碰撞检测和响应管线。

  • 使用定点数代替浮点数

目前项目仍在使用 floatVector3 等浮点数进行坐标和速度运算。浮点数在不同的 CPU/FPU 硬件架构上,计算结果的精度可能不一致,这将可能导致状态发散。需要改用定点数来实现所有的数学、运动和物理计算,例如:用 long 存储,并约定最后10000代表1.0000。

  • 固定的时间步长

当前的自定义重力等计算中,依赖了 Time.fixedDeltaTime 等Unity时间。帧同步的模拟必须由一个固定的、整数驱动的逻辑帧来驱动。例如,规定每帧固定前进16ms(整数),所有的速度和加速度计算都必须基于这个固定的整数增量,而不是 Unity 提供的浮点数时间。

  • 确定性的数据结构和 API

DictionaryHashSet 等数据结构的迭代顺序在不同的 .NET 版本或 AOT/JIT 环境下可能是不确定的。必须确保所有容器的迭代是确定性的(例如使用 List / Array),或使用确定性的哈希表实现。

2. 清版动作游戏存在多玩家和多敌人的情景下 (1 vs 1 -> N vs M)

  1. 如何保证确定性

    在转向PvE时,保持确定性的核心挑战从‘玩家’扩展到了‘敌人AI’。我必须确保所有客户端上的AI在同一帧做出完全相同的决策。

    • 确定性的AI决策
      • 随机数:必须禁用所有 Random.RangeSystem.Random。所有AI的“随机”行为(如攻击概率、巡逻间隔)都必须使用一个确定性的伪随机数生成器(PRNG)。这个PRNG必须在游戏开始时用一个所有客户端共享的种子来初始化,并且在每次调用后同步消耗。
      • 目标选择:AI选择攻击哪个玩家时,不能依赖不确定的数据。例如,遍历Dictionary(迭代顺序不确定)来找玩家是禁止的。必须使用List或数组,并基于确定性逻辑(如“选择索引最小的玩家”或“使用定点数计算最近的玩家”)来选择目标。
      • 状态机: AI的状态机(FSM)的切换条件必须是100%确定性的(例如“血量低于30%”、“与目标的定点数距离小于5.0”)。
    • 确定性的AI“输入”
      • 在架构上,将AI的决策结果视作与玩家输入类似的数据。在每个逻辑帧,AI逻辑会运行,并产出这一帧的决策输入
      • AI的决策输入会和玩家的输入一起,被传入确定性的游戏模拟层。这样只要AI的决策逻辑是确定性的,所有客户端的模拟结果就必然是一致的。
    • 确定性的AI寻路
      • 禁用 Unity 的 NavMeshAgent,它是非确定性的。
      • 使用确定性的寻路方案,例如基于网格的A*算法,并且所有计算(如G、H、F值)都必须使用整数或定点数
  2. 如果计算结果出现偏差如何修正?

    我的回答:少量状态同步(修正状态“发散”);回滚(修正“预测错误”)

    情况一:如果是‘回滚网络’中的‘预测偏差’(即预测了对手出拳,但他实际按了防御)

    • 这在回滚网络中是正常流程,不是Bug。
    • 修正机制: 当收到网络传来的‘真实输入’(第T帧)时,发现它与我本地的‘预测输入’不符。
      1. 立刻回滚(Rollback):从内存中取出第T-1帧的游戏状态快照
      2. 重算(Re-simulate):用‘真实输入’(而不是预测)重新模拟第T帧,并用新的预测输入模拟T+1、T+2...直到当前帧。
      3. 这个重算过程会在一帧渲染之内完成,玩家只会感觉到一个微小的“视觉突变”,但体验是即时的。

    情况二:如果是‘确定性逻辑’本身出现‘状态发散’(即Bug,比如一个浮点数错误导致两个客户端状态不一致)

    • 这是真正的Desync(同步灾难),是必须修复的Bug。
    • 修正机制(检测与强行纠正):
      1. 检测: 所有客户端在关键帧(例如每100帧)会计算一次游戏状态的校验和(Checksum)(例如把所有单位的定点数坐标和血量相加)并发给主机。
      2. 纠正: 如果主机的校验和与某个客户端不符,证明该客户端已“发散”。主机(或权威方)会序列化一份当前帧的‘正确’状态,强制发送给那个掉队的客户端。
      3. 掉队的客户端在收到这个‘状态包’后,必须丢弃自己的错误状态,强行“啪”地一下将游戏世界观(所有单位的位置、血量)设置为这个权威状态,然后再继续模拟。这会造成一次巨大的视觉卡顿。
  3. 如果有玩家掉线,如何断线重连?

    我的回答:序列化存储玩家输入和敌人AI决策,重连是重新模拟

    帧同步的断线重连,本质上是一个‘加速的模拟(Fast-Forward)’过程。

    • 前提(日志): 主机(或服务器)必须记录从第0帧开始的每一帧的全部输入,包括所有玩家的按键和所有AI的决策。

    • 重连过程:

      1. 掉线的玩家重新连接后,他会从主机那里获取两个东西:游戏开始的种子,以及那份完整的‘历史输入日志’
      2. 客户端关闭自己的渲染,进入‘追赶模式’。
      3. 它在本地从第0帧开始,用这份日志作为输入,以CPU最快的速度(例如在一个渲染帧内模拟几百个逻辑帧)重新模拟整个游戏进程
      4. 当它模拟到主机的‘当前帧’时,它就成功‘追上’了,此时再打开渲染,无缝加入游戏。
    • (关键优化点):

      • 如果游戏已经进行了20分钟(例如72000帧),从头模拟会非常慢。

      • 所以,主机会定期(例如每5分钟/18000帧)保存一个完整的‘游戏状态快照’

      • 当玩家在第75000帧重连时,他不需要从第0帧模拟。他只需要下载第72000帧的那个‘快照’,并加载它,然后只模拟最近的3000帧输入就可以了。这极大地加快了重连速度。

  4. 补充:关于玩家按键输入和AI决策的序列化

    玩家按键输入和Ai决策输入不是一套逻辑,不能使用同一种数据结构存储。每一帧传输的数据包结构大致如下:

    public struct TickInputPackage
    {// 玩家1的原始按键输入public PlayerInput Player1_Input; // 玩家2的原始按键输入public PlayerInput Player2_Input;// ... 玩家N// 敌人AI的确定性“输入”// 注意:这是一个列表!因为AI可能在同一帧做出多个决策public List<AIRequest> Enemy_Inputs; // 这一帧的“确定性随机数种子”// (如果AI决策需要“随机”,就从这里取)public int RngSeed; 
    }
    

3. 摄像机和渲染方面的优化

我的回答:屏幕外不渲染(视锥剔除)和遮挡剔除

考察对渲染管线性能瓶颈的理解。在H5或移动端,最大的瓶颈主要是CPU,CPU需要准备数据并告诉GPU需要画什么(即 Drawcall)。CPU每一帧需要处理:

  1. Culling(剔除):找出摄像机能看到什么。
  2. Batching(合批):把成千上万个“我要画这个”的请求,合并为几个大请求。

参考答案:

  1. CPU侧:减少 Drawcall(合批是关键)
    • Culling
      • 视锥剔除:屏幕外不渲染。Unity 自动完成,我们能做的就是保证场景里不要有太多的零碎小物体,增加它剔除的负担
      • 遮挡剔除:在复杂的3D室内场景效果很好,但在开阔场景(清版动作/H5游戏),它的烘焙成本和运行时开销可能过高。
      • (补充)距离/细节剔除(Level of Detail,LOD): 远处的敌人不渲染或切换为极低的模型面数
    • Batching:减少 Drawcall 的核心就是让 GPU 一次性画更多的东西
      • 静态合批:把场景中所有绝对不会动的物体(如地面、建筑、石头等)标记为 Static。Unity 会在构建时把它们合并成一个大模型,在一次Drawcall 中进行
      • GPU 实例化:针对场景中大量重复敌人的特效药。只要它们使用完全相同的Mesh和Material,就可以开启 GPU Instancing,这样渲染100个敌人也只需要1个Drawcall。
      • 图集(Atlas):2D游戏和UI优化的基础。把所有UI元素、2D特效、甚至小道具的贴图合并到一张或几张大纹理(图集)上,这样它们就能共享材质,从而被 Unity 的 动态合批 或 UI Batching 合并。
  2. GPU侧:降低像素和内存压力
    • OverDraw(过度绘制):在2D和UI中,避免大量半透明的特效或UI面板重叠
    • Shader(着色器):避免使用 Unity 的 Standard (标准)Shader,因为它计算量很大。优先使用 Unlit(无光照)、Mobile优化过的Shader,或针对项目需求手写最简单的Shader。
    • 纹理压缩:确保所有纹理都根据平台(如H5或WebGL)进行了适当的压缩,减少内存带宽占用

4. Transform组件相关

  1. 世界坐标与本地坐标的互相换算

    世界坐标(transform.position)是物体在整个场景空间中的绝对位置。本地坐标(transform.localPosition)是物体相对于其父物体(Parent) 坐标轴的位置。

    • 本地 -> 世界:

      • transform.TransformPoint(Vector3 localPosition):这是最常用的,它会将一个本地坐标点(比如一个子物体的localPosition)转换为世界坐标。

      • transform.TransformDirection(Vector3 localDirection):这个用于转换方向。它会应用父物体的旋转,但忽略父物体的缩放和位移,适合用于(比如“本地的前方”)。

      • transform.TransformVector(Vector3 localVector):这个用于转换矢量。它会应用父物体的旋转缩放,但不应用位移。

    • 世界 -> 本地:

      • transform.InverseTransformPoint(Vector3 worldPosition):将一个世界坐标点转换为该物体的本地坐标。

      • transform.InverseTransformDirection(Vector3 worldDirection):将一个世界方向转换为本地方向(忽略缩放)。

      • transform.InverseTransformVector(Vector3 worldVector):将一个世界矢量转换为本地矢量(应用缩放)。

    (深入)在底层,这些API是帮我们封装了矩阵的乘法。TransformPoint 实际上就是将本地坐标点乘以该物体的 localToWorldMatrix(本地到世界矩阵),而 InverseTransformPoint 则是乘以 worldToLocalMatrix(世界到本地矩阵)。

  2. Transform中已经有3个 Vector3 变量表示物体位置、旋转和缩放,为什么还要有一个4维矩阵

    我的回答:(答歪了)四元数,万向节处理

    • 四元数 (Quaternion):是用来存储旋转(R) 的。它用4个浮点数(x,y,z,w)来避免万向节锁,这没错。
    • 4x4矩阵 (Matrix4x4):是用来组合 P、R、S(平移、旋转、缩放) 的。

    参考答案:

    这个矩阵是为了组合平移(P)、旋转(R)、缩放(S)三种变换,并且使用齐次坐标(Homogeneous Coordinates)

    • 无法组合: 一个3x3的矩阵只能表示旋转缩放。它无法通过矩阵乘法来表示平移(位置)
    • 齐次坐标: 为了能用一个矩阵同时表示P、R、S,我们必须引入齐次坐标,即升维到4D(x, y, z, w)
    • 最终目的: Transform 内部的4x4矩阵(比如localToWorldMatrix),就是那个组合了P、R、S的变换矩阵。它最大的好处是,GPU(尤其是Shader)可以拿到这个矩阵,然后用一次矩阵乘法,就把一个模型的本地顶点(Model Space)转换到最终的世界坐标(World Space),效率极高。
  3. 如何将物体的世界坐标换算为屏幕上的坐标,包括屏幕外的物体

    我的回答:三角函数计算、投影(思路大致正确,但可能没有回应面试官的考察点:对摄像机渲染管线的理解)

    API:Camera.main.WorldToScreenPoint(Vector3 worldPosition),返回值为 Vector3

    • x 和 y 是屏幕上的像素坐标(左下角是(0, 0))
    • z 是该点相对于摄像机前方平面的距离(以世界单位计)

    对于屏幕外物体,主要通过 z 值判断:

    • 物体在摄像机背后: WorldToScreenPoint 返回的 z 值是负数,那么这个物体在摄像机的‘近裁剪平面’的后方(即摄像机根本看不见它)。此时它的x, y坐标是无意义的(通常是反向的)。
    • 物体在摄像机前方,但在屏幕外: 如果 z正数,但 x 小于0 或 大于Screen.width,或者 y 小于0 或 大于Screen.height,那么这个物体就在摄像机前方,但在屏幕的侧面、上方或下方。

    应用:制作屏幕外敌人的指示箭头时,检查API返回值的z值:

    • 如果z < 0,我会反转xy(因为背后的投影是反的),然后把这个点钳制(Clamp)在屏幕边缘。
    • 如果z > 0,我直接把xy钳制在屏幕边缘。

    深入:在底层,WorldToScreenPoint 这个函数封装了完整的MVP矩阵变换

    • 它首先将‘世界坐标’乘以摄像机的View矩阵worldToCameraMatrix),得到‘摄像机空间坐标’。
    • 然后再乘以摄像机的Projection矩阵projectionMatrix),得到‘裁剪空间坐标’(Clip Space)。
    • 最后再转换为屏幕像素坐标。

5. 割草类游戏中,角色可以发射非常多的子弹,敌人数量也非常多,一帧内可能发生非常多子弹与敌人的碰撞,如何减少这方面开销

问题核心:如何将 O(N * M) 复杂度(N个子弹 × M个敌人)的碰撞检测问题,优化到接近 O(N) 或 O(N log M)

  1. 减少碰撞次数

    核心思想:减少不必要的检测。不应该让一个子弹与屏幕另一边的敌人做计算

    • 基础:Unity 物理分层 (LayerMask)

      确保“子弹”只与“敌人”发生碰撞,关闭“子弹层”与其他层的检测。但这个只能解决令M个检测对象中都是敌人,无法解决 M 本身数量大的问题。

    • 解决方案:空间划分,建立地图索引,让子弹只与它附近的敌人比较

      实现方式(网格Grid):

      1. 在逻辑层,将整个游戏区域划分为一个粗糙的网格(比如 10x10 或 20x20)。
      2. 敌人(M): 每一帧(或者每隔几帧),所有敌人根据自己的位置,在网格中“登记”。(例如:一个 Dictionary<Cell_ID, List<Enemy>>)。
      3. 子弹(N): 每一帧,子弹根据自己的位置,向网格查询自己所在单元格(以及周边单元格)List<Enemy>
      4. 结果: 复杂度从 O(N * M) 急剧下降到 O(N * k),其中 k 是每个单元格内的平均敌人数量,这个k远小于M
  2. 降低单次碰撞检测的开销

    假设一次检测不可避免,如何让这次检测尽可能快

    • 解决方案1:使用最简单的碰撞体
      • 不同的 Collider,计算开销天差地别。
      • 开销对比
        1. 球体/圆形 (Sphere/CircleCollider2D): 最快。它在数学上只是一个距离比较((P1-P2).sqrMagnitude < (r1+r2)*(r1+r2)),CPU开销极低。
        2. AABB/方盒 (BoxCollider2D/BoxCollider): 速度很快。
        3. 胶囊体 (CapsuleCollider): 速度较快。
        4. 凸多边形 (PolygonCollider2D / Convex MeshCollider):
        5. 任意网格 (MeshCollider): 开销巨大,绝对应该避免用在子弹和敌人上。
      • 结论:割草游戏中,所有子弹和敌人都应该强制使用圆形或球形碰撞体
    • 解决方案2:脱离 Unity 引擎,手写检测
      • OnTriggerEnter 是由Unity的物理引擎(Box2D/PhysX)驱动的。这个引擎是为了模拟真实物理(摩擦、反弹、力)而设计的,对于割草游戏这种“只需要知道是否碰到了”的场景来说,它的开销是“杀鸡用牛刀”。
      • 实现方式:
        1. 移除所有子弹和敌人身上的 ColliderRigidbody 组件。
        2. 自己管理: 创建一个(或多个)BulletManagerEnemyManager,用List或数组持有所有单位的引用。
        3. 手写循环: 结合“方案一(空间网格)”和“方案二(简单碰撞体)”的逻辑,在UpdateFixedUpdate中:
          • 遍历所有子弹 (N)。
          • 对每个子弹,从空间网格中获取附近的敌人 (k)。
          • 在C#循环中,手动计算这个子弹和这 k 个敌人之间的圆形重叠(即上面那个距离平方的数学公式)。
      • 优势:
        • 零物理引擎开销
        • 逻辑完全由C#掌控,可以被 Unity 的 Burst CompilerJobs System 彻底优化
    • 解决方案3(折中):使用物理查询API
      • 原理: 这是介于“方案二(手写)”和“完全依赖OnTriggerEnter”之间的方案。
      • 实现:
        • 子弹(或敌人)需要Rigidbody
        • 在子弹的Update中,主动向物理引擎发起查询,而不是等待事件。
        • 使用 Physics2D.OverlapCircleNonAlloc(...)Physics.OverlapSphereNonAlloc(...)
        • 这个API会“在当前位置,画一个圆,立即告诉我碰到了哪些敌人”,并将结果填充到一个你预先分配好的数组中(NonAlloc代表零GC)。
      • 优势:
        • OnTriggerEnter的事件模型更可控,开销更低。
        • 仍然在利用Unity物理引擎的空间加速结构(它内部就是用的AABB Tree),所以比你“手写网格+手写检测”要简单,且性能通常也非常好。
    • 总结:对于割草游戏这种极端场景:
      1. 最佳组合拳是: 空间划分(网格/Grid) + 主动查询API(OverlapCircleNonAlloc + 简单碰撞体(CircleCollider2D
      2. 终极方案是: 空间划分(网GGrid) + 手写C#检测(完全脱离物理) + Burst/Jobs

6. 假设有一个卡牌物体,当卡牌的正面暴露在摄像机视野内,就加上粒子特效。摄像机如何检测卡牌的正面

​ 我先回答在正面添加一层物体遮罩,摄像机通过射线检测检查是否接触来判断正面是否暴露。被提示用数学计算,默认卡牌物体z轴大于0为正面。被提示后我说将卡牌平面的法向量与摄像机中心的向量做角度运算

步骤一:使用点积(Dot Product)判断“朝向”

比较两个向量:

  1. 向量A(卡牌法线): 卡牌正面的法向量。根据提示(Z轴为正),这在卡牌的本地空间是 Vector3.forward (0,0,1)。在世界空间中,它就是 card.transform.forward
  2. 向量B(视线向量):卡牌指向摄像机的向量。它的计算方式是 camera.transform.position - card.transform.position

然后计算这两个向量的点积

Vector3 normalA = card.transform.forward;
Vector3 directionB = camera.transform.position - card.transform.position;float dotProduct = Vector3.Dot(normalA, directionB);

判断依据:

  • 如果dotProduct > 0(点积为正):
    • 这在数学上意味着两个向量的夹角小于90度
    • 通俗地说,就是卡牌的“正面”和“指向摄像机的视线”在同一个大方向上。
    • 结论: 卡牌的正面朝向摄像机。
  • 如果 dotProduct < 0(点积为负):
    • 这意味着两个向量的夹角大于90度
    • 结论: 卡牌的背面朝向摄像机。(我们应该剔除它)。

步骤二:使用坐标转换判断“是否在视野内”

单步骤一还不够,因为卡牌可能朝向计算机,但它可能在摄像机背后或屏幕外

因此,结合 世界坐标与屏幕坐标换算 问题的答案:

  1. 使用 camera.WorldToScreenPoint(card.transform.position) 来获取卡牌中心的屏幕坐标。
  2. 检查返回的 Vector3 screenPos
    • screenPos.z 必须大于0(确保物体在摄像机的近裁剪平面之前)。
    • screenPos.x 必须在 0 和 Screen.width 之间。
    • screenPos.y 必须在 0 和 Screen.height 之间。

最终结论:

在 Update 中,同时进行两项检查:

  1. Vector3.Dot(card.transform.forward, (camera.transform.position - card.transform.position)) > 0
  2. 并且,card.transform.position 经过 WorldToScreenPoint 转换后,其x, y, z 坐标均在有效范围内。

只有当这两个条件同时满足时,我才判定‘卡牌正面暴露在视野内’,并激活粒子特效。

7. 编程题:扁平数组转换 Tree

(本地IDE编程,面试官通过飞书会议屏幕共享实时观看。没有测试用例,不要求输入输出处理,重点展示核心功能代码,需自定义结构)

给定一个扁平数组,数组内每个对象的id属性是唯一的。每个对象具有pid属性,pid属性为0表示为根节点(根节点只有一个),其它表示自己的父节点id。
编写一段程序,输入为给定的扁平数组,输出要求为一个树结构,为其中每个对象增加children数组属性(里面存放child对象)。附加条件:输入数组内对象的id属性不一定是递增的,有些对象可能存在不在数组内的pid
解法有很多种,性能最优的方案最佳。可以不用处理输入输出,专注实现核心逻辑即可给定输入:
[{id: 1, name: '部门1', pid: 0},{id: 2, name: '部门2', pid: 1},{id: 3, name: '部门3', pid: 1},{id: 4, name: '部门4', pid: 3},{id: 5, name: '部门5', pid: 4},
]给定输出:
{"id": 1,"name": "部门1","pid": 0,"children": [{"id": 2,"name": "部门2","pid": 1,"children": []},{"id": 3,"name": "部门3","pid": 1,"children": [// 省略]}]
}

二面

二面说是聊聊天,结果上来连自我介绍都省了,库库问技术八股。

本来吃了一面的堑,想在面试时录屏,结果OBS开着飞书会议摄像头有问题,时间紧急被迫关掉OBS。

复盘的时候问题记的不是很完整,顺序也打乱了。二面结束后半周才开始复盘,在此之前断断续续的把想起来的问题记在草稿本上,甚至半夜睡觉前突然想起来了从床上跳下来记录问题。

面试官说话比较和蔼,给的压力比较小,基本没有追问。(然后满怀期待的被挂掉了)

提问重点也是网络、性能优化,还有对游戏打包相关的细节,此外涉及Unity相关功能和异步。

关于 Unity 6

  1. (针对H5的满分答案)对 WebGPU 的正式支持:
    • 这是我最关注的一点。我知道H5游戏目前依赖的WebGL在性能和多线程上受限很大。Unity 6全面拥抱WebGPU,意味着H5游戏未来可以在浏览器里获得接近原生(Native)的性能,这对于(像‘天天台球’ 这样)需要高帧率和复杂物理模拟的小游戏来说,是一个革命性的性能飞跃。
  2. (展现技术深度)Data-Oriented Technology Stack (DOTS) 的成熟:
    • Unity 6会带来更成熟的DOTS和Burst编译器。虽然这套架构在H5端的应用可能还需要时间验证,但它代表的‘数据驱动’和‘高性能C#’的思想,能极大提升CPU密集型任务(比如一面聊到的“割草游戏”里的海量碰撞检测)的处理能力。
  3. (展现广度)渲染管线 (URP / HDRP) 的迭代:
    • Unity 6在URP和HDRP上也做了很多升级,比如新的光照系统(Adaptive Probe Volumes),能让游戏的视觉表现力和性能兼顾得更好。

(总结) 总的来说,我理解Unity 6的核心是‘性能’。特别是它在WebGPU上的突破,让我非常兴奋,因为它能彻底解决很多H5小游戏现在的性能瓶颈。”

如何减小游戏导出的包体大小

我的回答:分包,只包含必要文件,其他文件通过服务器下载

游戏包体构建优化(完整措施)

一个完整的优化方案,应该包含“资源压缩”(减小总大小)和“资源拆分”(减小首包大小)两个层面。

1. 纹理 (Texture) 优化(最重要)

纹理通常是包体的最大头(占比50%-70%),优化它的ROI(投资回报率)最高。

  • 压缩格式 (Compression):
    • 这是最优先的。永远不要使用PNG/JPG原图。
    • 针对H5/小程序平台,优先使用 Crunch 压缩。它是一种有损压缩,包体体积很小,加载时再解压为平台支持的格式(如DXT/PVRTC/ASTC)。
    • 在移动端App,应使用平台原生的硬件压缩格式,如iOS的 ASTCPVRTC,Android的 ASTCETC2
  • 分辨率 (Resolution):
    • 遵循“够用就好”原则。一个在屏幕上只占100x100像素的图标,没必要用1024x1024的贴图。
    • 确保纹理尺寸是“2的N次方”(POT, Power of Two),如512x512, 1024x256。这是硬件压缩格式的基本要求
  • Mipmaps:
    • Mipmaps会额外增加约33%的体积,因为它存储了多层级的低分辨率图像。
    • 必须关闭:对于2D游戏、Sprite和所有UI元素(它们永远不会在3D空间中“变远”),必须关闭Mipmaps
    • 需要开启:对于3D场景中的模型贴图(如地面、墙壁),需要开启,以防止远处闪烁。
  • 图集 (Atlas):
    • 使用Unity的 Sprite Atlas 功能将零碎的UI图片和Sprite合并到一张或几张大图集中。
    • 这不仅能极大减少DrawCall(渲染性能优化),还能因为共享纹理而提高压缩效率,减少总包体。

2. 音频 (Audio) 优化

音频是包体的第二大头。

  • 压缩格式与码率 (Bitrate):
    • BGM(背景音乐): 应使用 Vorbis (Unity默认) 或 MP3 格式,并降低码率(例如96kbps或128kbps)。人耳很难听出高码率的BGM和中等码率的区别。
    • SFX(音效): 应使用 ADPCM。它解压极快,CPU开销小,非常适合短促、频繁播放的音效。
  • 声道 (Channels):
    • 除非是需要立体环绕感的BGM,否则所有音效(SFX)都应设为“单声道”(Mono)。这能直接减少50%的体积。
  • 加载方式 (Load Type):
    • BGM(长): 设为 Streaming(流式加载)。这不会减小包体,但会极大降低内存峰值。
    • SFX(短): 设为 Decompress On Load(加载时解压)。包体最小,但加载时有微小CPU开销。
    • 常用SFX(中): 设为 Compressed In Memory(内存中压缩)。包体中等,CPU开销最小。

3. 模型 (Model) 与动画 (Animation) 优化

  • 网格压缩 (Mesh Compression): 在模型的Import Settings中开启High级别的网格压缩。
  • 动画压缩 (Animation Compression): 开启 Keyframe Reduction(关键帧削减)或使用 Optimal 压缩。
  • LODs (Level of Detail): (这更多是渲染优化)通过使用LOD,远处的低精度模型体积更小,有助于减少包体。
  • Rig (骨骼): 2D游戏或简单3D物体,应禁用Avatar/Rig的导入。

4. 引擎与代码裁剪 (Stripping)

这对于H5和小程序平台至关重要,因为它能减小引擎本身的体积。

  • 代码剥离 (Stripping Level):Project Settings -> Player -> Other Settings中,将Managed Stripping Level设为High。这会通过IL2CPP分析并移除所有未被引用的C#代码
  • 模块管理器 (Module Manager):Project Settings -> Packages 中,可以禁用你根本用不到的Unity引擎模块。例如,如果你的游戏是2D的,就应该禁用Physics 3DVRVehicle等所有无关模块。每禁用一个,都能从最终的(H5)包体中移除一部分引擎代码。

5. 资源打包与分包策略

“分包”,是交付策略的核心。

  • 首包 (Main Package):
    • 在H5/小程序项目中,首包必须做到最小
    • 只应包含:必要的引擎代码、所有核心脚本、启动场景(Logo/Loading界面)以及第一个可玩关卡(或核心玩法)所需的最少资源。
  • 分包/子包 (Sub-packages / Asset Bundles):
    • 使用Unity的 Addressables 系统(或手动的Asset Bundles)来管理资源。
    • 所有非首包所需的资源,如“第二关卡”、“新的角色皮肤”、“不常用的UI面板”、“失败/胜利界面”等,都应打成AB包。
    • 这些AB包被上传到CDN(内容分发网络) 或平台的“子包”存储中。
    • 当玩家即将需要这些资源时(例如,在加载Level 1时,后台预下载Level 2的资源),游戏再通过网络将它们下载到本地。
    • 这保证了玩家的“首次启动时间”极短。

纹理压缩和相关概念

1. 什么是纹理压缩?

首先,纹理压缩 不是 我们熟知的 PNGJPG

  • JPG / PNG(文件压缩):
    • 目的: 减小磁盘(硬盘)上的文件大小。
    • 工作方式: 游戏加载时,CPU必须完整解压它,变成一张巨大的、未压缩的位图(Bitmap),然后才能交给GPU(显卡)。
    • 缺点: 不节省内存。一张1MB的PNG(1024x1024),加载到内存里还是会占用4MB。
  • 纹理压缩(如 ASTC, ETC, DXT):
    • 目的: 减小VRAM(显存)RAM(内存) 的占用,并极大提升渲染速度。
    • 工作方式: 这是一种特殊的有损压缩。GPU不需要解压它,而是可以直接读取这种压缩格式。
    • 优点: 内存占用永久性地降低了(例如,从4MB降到0.66MB)。
    • 核心优势: 减少内存带宽(Memory Bandwidth)。GPU每帧渲染时,需要从显存里读取纹理。读取一个0.66MB的压缩数据,远比读取一个4MB的原始数据要快,这能直接提高帧率

一句话总结: PNG 只省硬盘,纹理压缩 省硬盘、省内存、还省带宽(提帧率)。

2. 相关的核心概念

A. 硬件格式(GPU的“母语”)

GPU是芯片,它只认识特定的几种“语言”。ETC, PVRTC, ASTC 就是这些“语言”。

  • DXT / BCn (PC / Desktop): DXT1, DXT5 (或 BC1, BC3) 是PC端GPU的标准格式。
  • ETC (Android):
    • ETC1: 老格式,所有安卓机都支持,但不支持Alpha(透明)通道
    • ETC2: 新标准,支持Alpha通道,几乎所有现存的安卓机都支持(OpenGL ES 3.0+)。这是安卓平台首选
  • PVRTC (iOS): 苹果 PowerVR 芯片的格式,主要用于老iPhone。
  • ASTC (现代移动端):
    • 这是“未来”和“现在”。苹果(新iPhone)和安卓(高端机)都支持。
    • 极其灵活,允许你通过调整“块大小”(Block Size)来自由换取质量和体积。比如,UI用高质量的4x4块,天空盒用低质量的12x12块。

B. 块压缩(Block Compression)

这是所有硬件格式的工作原理。它们不逐个像素压缩,而是把纹理切成N个小“块”(例如4x4像素),然后只存储这个“块”的“近似颜色”。这就是为什么它是有损的,也是为什么它能被GPU随机访问(GPU可以直接跳到第50个块,不用读前面49个)。

C. Crunch 压缩

这是Unity特有的一个概念,你可能会在Inspector里看到。

  • Crunch是压缩的压缩,它是“文件压缩”
  • 它把 ETCDXT 格式再压缩一次,让包体变得更小。
  • 注意: 游戏加载时,CPU会先解压Crunch,把它还原成 ETC / DXT,再交给GPU。它只减小包体,不减小运行时的内存(运行时内存还是 ETC 的大小)。

3. 纹理优化的具体措施

面试官最想听的就是你能做什么

  1. 选择正确的平台格式(最重要):
    • 坚决不能用 Uncompressed(未压缩)
    • 在Unity中,使用 Override for Android/iOS手动指定格式。
    • 策略: 安卓(H5/小程序)首选 ASTC,如果需要兼容老古董机,才用 ETC2。PC用 DXT
    • 陷阱: 如果你选了一个平台不支持的格式(比如在安卓上用了 PVRTC),Unity会在加载时强制解压它。你的包体小了,但内存占用会暴增4-6倍,比不用压缩还惨。
  2. 合理设置 ASTC 块大小 (Block Size):
    • 这是 ASTC 独有的大杀器。
    • 4x4:质量最高,体积最大(用于角色脸部、关键UI)。
    • 6x6 / 8x8:平衡点,质量不错,体积小(用于大多数场景道具、地面)。
    • 12x12:质量最低,体积最小(用于天空盒、不重要的背景)。
  3. 关闭 Mipmaps
    • Mipmaps 是为了解决“物体在远处闪烁”的问题,它会额外存储一系列低分辨率的纹理。
    • 这会增加33%的内存和包体
    • 优化: 所有的 UI、Sprite(2D精灵)和任何不会在3D空间中变远的物体,都必须关闭 Mipmaps
  4. 使用图集 (Texture Atlasing):
    • 把N张零碎的小图标(UI)合并成一张大图集。
    • 目的: 这主要是为了减少DrawCall(渲染性能优化),但它也能提高压缩效率。
  5. 控制分辨率(2的N次方):
    • 硬件压缩格式要求纹理的分辨率是“2的N次方”(POT),例如 512x512, 1024x256
    • 优化: 永远不要用一张 1000x1000 的图,Unity会强制把它拉伸到 1024x1024,浪费内存。你宁可用 512x512 然后拉伸它,也比用 1000x1000 要好。

AssetsBundle

基本没回答上来,这里利用AI完整总结一下:

AssetBundle (简称AB) 是Unity中最基础、最核心的资源管理和热更新方案。

我们来全面地讲解它,包含它的概念、工作流、使用情景,以及它最关键的管理(和“坑”)。

1. 核心概念 (What is it?)

  • 资源容器: 简单来说,AssetBundle 就是一个“资源包”“资源容器”。它可以被看作一个Unity专用的、只读的“压缩文件”。
  • 内容: 它可以包含几乎所有Unity资源,如模型、纹理、材质、预制体(Prefab)、音频、甚至场景(Scene)。
  • 目的: 它的核心目的,就是将这些资源从游戏主包体(.apk / .ipa / .html)中分离出来,以便在运行时(Runtime) 从外部(如CDN服务器、本地磁盘)动态加载
  • 平台相关: AB是平台相关的。你为Android打包的AB(包含ETC纹理 ),不能在iOS(需要PVRTC/ASTC纹理 )上使用。

2. 为什么需要AB?(Why?)

要理解AB,就要理解它解决了什么问题。它主要解决了 Resources 文件夹的两大原罪

  1. 包体问题: Resources 文件夹里的所有内容,会被无条件地、全部打包进游戏的首包中。这对于H5/小程序平台是致命的,因为平台对首包大小有严格限制(例如4MB或8MB)。
  2. 热更新问题: Resources 里的资源是只读且内置的。一旦游戏打包发布,你就无法在不“重新发版”的情况下,去更新修复这些资源。

AssetBundle 完美地解决了这两个问题:它允许资源“外置”“动态加载”


3. 用法:核心工作流 (How?)

AB的工作流分为两大部分:

A. 打包 (Build Time - 在Editor中)

  1. 标记: 在Unity Editor中,选中一个资源(如 Prefab),在Inspector面板的底部,为它指定一个 AssetBundle 名称(例如 ui/login_panel)和变体(Variant,可选)。
  2. 构建: 编写一个Editor脚本,核心是调用 AssetDatabase.BuildAssetBundles API。
  3. 产物: Unity会根据你的标记,生成一堆AB文件。同时,它还会生成一个清单文件(Manifest)。这个 Manifest 文件极其重要,它记录了所有AB包的列表,以及它们之间的依赖关系

B. 加载 (Run Time - 在游戏中)

  1. 获取AB包:
    • H5/Web/小程序: 使用 UnityWebRequestAssetBundle.GetAssetBundle(string url) 从CDN(内容分发网络)下载AB包。
    • 本地App: 使用 AssetBundle.LoadFromFileAsync(string path) 从本地磁盘读取AB包。
  2. 加载资源:
    • 当AB包被加载到内存后(它本身是一个 AssetBundle 对象),你再调用 myBundle.LoadAssetAsync<GameObject>("MyPrefabName") 来异步加载这个包内部的具体资源。
  3. 实例化:
    • 拿到加载的资源(如Prefab)后,调用 GameObject.Instantiate(prefab) 将其实例化到场景中。
  4. 卸载 (关键):
    • 当你不再需要这个AB包时,必须手动管理内存,调用 myBundle.Unload(bool) 来卸载它。

4. 关键使用情景 (Scenarios)

这就是总监最关心你是否理解的部分:

  • 1. H5/小程序分包 (首包优化):
    • 情景: 这是H5游戏最核心的应用。平台要求首包在4MB以内。
    • 用法: 你的主包体(.html)只包含最核心的引擎代码、加载(Loading)场景、和一个最小化的“新手引导”或“核心玩法”所需的资源。
    • 所有其他资源(如第二关、后续UI、高清角色、BGM ),全部打成AB,放到CDN服务器上
    • 流程: 玩家加载游戏 -> 播放Loading界面 -> 后台异步下载核心AB -> 下载完成,进入游戏。 玩家在玩第一关时,后台预下载第二关的AB。
  • 2. 游戏热更新 (Hotfix):
    • 情景: 游戏上线后,发现一个角色的模型贴图错了(比如有Bug或裸露)。
    • 用法: 程序员不需要重新构建App并提交商店审核。美术只需要修复这个贴图,团队重新打包这个贴图所在的AB,然后把这个新的AB文件上传并覆盖CDN上的旧文件
    • 流程: 玩家下次启动游戏时,游戏会检查到CDN上有新版本的AB,自动下载它。当玩家加载到这个角色时,看到的就是修复后的贴图。
  • 3. DLC / 内容扩展 (DLC):
    • 情景: 游戏卖一个新的角色皮肤、一个新的关卡包。
    • 用法: 这些新内容被制作成全新的AB包。只有当玩家付费购买后,游戏才回去下载并加载这些AB包,解锁新内容。
  • 4. 平台/配置差异化 (Variants):
    • 情景: 你希望高配手机加载4K高清贴图,低配手机加载512标清贴图。
    • 用法: 使用AB的变体(Variant) 功能。你制作 textures.hdtextures.sd 两个AB变体。游戏启动时检测手机性能,决定去加载哪一套AB。

5. 相关内容 (最关键的“坑”)

总监不仅想知道你“怎么用”,更想知道你是否理解它的“复杂性”。AB的手动管理,有两大“地狱”:

A. 依赖管理地狱 (Dependency Hell)

这是AB管理最复杂的部分。

  • 问题:
    1. Prefab A (在 A.ab 中) 依赖 Material B (在 B.ab 中)。
    2. Material B (在 B.ab 中) 依赖 Texture C (在 C.ab 中)。
  • 后果:
    • 你必须手动管理依赖链。 在你加载 A.ab 之前,你必须保证 B.abC.ab 已经被加载到内存中。否则,Prefab A 加载出来就是“紫色的”(材质丢失)或“红色的”(Shader丢失)。
    • 依赖重复。 如果你管理不当(例如,Material B 没有被单独打包),它可能会被同时打包进 Prefab A 的包和 Prefab D 的包。这会导致包体冗余,更糟糕的是,它们在内存中是两份不同的实例,导致内存浪费
  • 解决方案:
    • 依赖 Manifest 文件。在加载 A.ab 之前,先查询 Manifest,获取 A 的所有依赖项(BC),然后递归地、优先加载这些依赖项。

B. 内存管理地狱 (Memory Hell)

  • 问题: AB包和从它加载的资源,都会占用内存。你必须手动释放。
  • 关键API: myBundle.Unload(bool unloadAllLoadedObjects)
  • myBundle.Unload(false)
    • 含义: 只卸载AB包本身(即那个压缩的“容器”文件在内存中的镜像)。
    • 效果: 所有已经从它 LoadAsset 出来的资源实例(如Texture, Prefab)会继续保留在内存中
    • 用法: 这是最常用的。当你的资源已经加载并实例化(例如,Prefab已经Instantiate到场景中)后,你就可以调用 Unload(false) 来释放掉AB包本身的内存。
  • myBundle.Unload(true):(非常危险!)
    • 含义: 卸载AB包所有从它加载的资源实例
    • 后果: 如果场景中还有任何地方在引用(Reference)这些资源(比如一个GameObject还在使用那个Texture),这个引用会立即失效(变为null)。
    • 用法: 必须在确认场景中所有对该AB资源的引用都已被销毁(例如,切换了场景,或手动Destroy了所有实例)之后,才能调用它来“彻底清场”。

6. 现代的解决方案:Addressables

因为手动管理AB的“两大地狱”实在太复杂,Unity推出了 Addressables (AA) 可寻址系统

  • 关系: Addressables 是 AB系统的上层封装。它的底层仍然是AB。
  • 区别: AA 帮我们自动做了所有最脏最累的活:
    • 自动依赖分析: 你只管标记,AA自动分析依赖,自动生成AB(和依赖链)。
    • 自动加载/卸载: 你只需要调用 Addressables.LoadAssetAsync("MyPrefabAddress")。AA会自动去CDN或本地加载 MyPrefabAddress 所在的AB,以及它所依赖的所有AB。
    • 自动内存管理: AA使用引用计数。当10个对象都引用了某个AB,它会Unload(false)。当最后一个引用它的对象被销毁时,AA会自动帮你Unload(true),彻底释放内存。

总结: 对于H5/小程序游戏,Addressables 是绝对的首选方案,因为它极大地简化了分包、热更和内存管理。但理解其底层的AB原理(依赖、内存)是面试中展现你技术深度的关键。

CDN

1. 核心概念:CDN是什么?

CDN(Content Delivery Network)的中文意思是“内容分发网络”。

你可以把它理解为一个全球化、智能化的“快递中转站”系统

  • 没有CDN(传统方式):
    • 你把你的游戏资源(比如level2.ab)放在一个“源服务器”(Origin Server)上,比如这个服务器在上海
    • 当一个北京的玩家玩游戏时,他从上海服务器下载,速度还行
    • 当一个伦敦的玩家玩游戏时,他也要跨越半个地球,连接到你上海的服务器下载。这会非常慢(高延迟),而且下载容易中断
    • 如果10万个玩家同时下载,你上海的服务器可能就崩溃了。
  • 有了CDN(现代方式):
    • 你还是把资源放在上海的“源服务器”上。
    • 但是,你“勾结”了CDN服务商。CDN在全球(如北京、伦敦、纽约、东京)都有“边缘节点”(Edge Server)或叫“缓存服务器”
    • 第一次有伦敦玩家请求level2.ab时,CDN的“伦敦节点”会发现自己没有这个文件,于是它会代表玩家,从你上海的“源服务器”那里把文件取过来交给伦敦玩家,并随手在自己(伦敦节点)的硬盘上缓存一份
    • 下一次,第二个、第三个伦敦玩家再请求level2.ab时,伦敦节点会说:“别去上海了,我这里有!” 然后立即从本地缓存把文件发给他们。
    • 结果: 伦敦玩家获得了“本地下载”般的高速体验。你上海的“源服务器”压力也大大降低,因为它只需要在“缓存过期”或“首次请求”时被访问一次。

2. 与其他部分的协作流程

参与方:

  1. 你(开发者): 负责生产资源。
  2. 源服务器(Origin): 你的“仓库”,(例如AWS S3、阿里云OSS、或你自己的FTP)。
  3. CDN网络: 你的“全球快递系统”。
  4. 游戏客户端(H5/小程序): 玩家,“收快递的人”。

协作流程(以热更新level2.ab为例):

阶段一:开发者(发布资源)

  1. 构建AB包: 你在Unity中,使用AddressablesAssetBundle系统,构建出了level2.ab
  2. 上传源站: 你将这个level2.ab文件,上传到你的“源服务器”(例如阿里云OSS)。
  3. 刷新CDN缓存(关键): 你登录CDN的管理后台,输入http://my-cdn.com/level2.ab这个文件的URL,点击“刷新缓存”或“预热”。
    • “刷新/清除”:是告诉全球所有边缘节点:“你们手里的旧level2.ab(如果有的话)作废了,下次有人要,必须回源站(OSS)来取最新的。”
    • “预热”:是主动命令所有边缘节点:“立即从源站下载最新的level2.ab存好等着玩家来要。”(这可以防止玩家“首次访问”时的卡顿)。

阶段二:玩家(请求资源)

  1. 触发请求: 玩家玩到第一关关底,游戏客户端的代码(如Addressables.LoadAssetAsync)被触发,需要下载第二关的资源。
  2. 发起HTTP请求: 客户端向http://my-cdn.com/level2.ab这个CDN域名发起下载请求。
  3. CDN DNS解析: 用户的设备请求DNS,CDN的智能DNS会接管,它会分析这个玩家的IP地址,找到离他最近、最快的那个边缘节点(例如,一个伦敦玩家会被指向伦敦节点)。
  4. CDN 边缘节点(缓存命中):
    • 伦敦节点收到请求。
    • 它检查自己的本地缓存,发现(因为你“预热”过)它有level2.ab这个文件。
    • 这被称为“缓存命中”(Cache Hit)。
    • 立即将文件发给玩家。
  5. (如果未命中):
    • 如果伦敦节点没有这个文件(例如,你是第一个请求,而开发者没有“预热”)。
    • 这被称为“缓存未命中”(Cache Miss)。
    • 伦敦节点会立即向“源服务器”(阿里云OSS)发起请求,下载level2.ab缓存一份,发给玩家。
  6. 客户端(完成加载): 玩家的客户端下载完AB包,加载资源,进入第二关。

总结: 在这个流程中,CDN扮演了一个透明的“加速层”。你的游戏客户端完全不需要关心CDN的存在,它只需要(通过Addressables)请求一个URL;你的源服务器也不需要关心CDN,它只需要在CDN“回源”时提供文件。CDN无缝地衔接了“开发者”和“玩家”,提供了低延迟、高可用的资源下载服务。

LoD (Level of Detail)

LOD 是一种在计算机图形学和游戏开发中至关重要的性能优化技术。

其核心思想是:根据物体距离摄像机(观察者)的远近,来展示不同精细程度的模型。

  • 近处: 物体距离摄像机很近,玩家能看清细节。此时,引擎会渲染高精度模型(例如,50,000个多边形),显示所有细节。
  • 远处: 物体距离摄像机很远,玩家只能看到一个轮廓。此时,引擎会自动切换到低精度模型(例如,500个多边形),并使用更简单的材质。

为什么使用LOD?

核心目的:提升性能,提高帧率。

游戏引擎渲染画面的主要开销之一是处理模型的“多边形”(或称“面数”)。

  1. 减少多边形数量: 一个在远处的敌人,如果仍然用高精度模型来渲染,是对GPU(显卡)性能的巨大浪费。LOD技术通过在远处使用面数更少的模型,极大地降低了引擎每帧需要绘制的总多边形数量。
  2. 降低DrawCall: (进阶)除了模型,LOD也可以用于切换更简单的材质(Shader)或关闭特定组件(如粒子特效),进一步减少渲染开销。

LOD是如何实现的?

在像Unity这样的现代游戏引擎中,LOD通常通过一个名为LOD Group (LOD组)的组件来实现。

  1. 创建多个模型: 美术师会为同一个物体创建多个版本的网格(Mesh)。例如:
    • LOD 0: 原始的、最高精度的模型。
    • LOD 1: 中等精度的模型(面数减少50%)。
    • LOD 2: 低精度的模型(面数减少90%)。
    • Cull (剔除): 物体太远时,完全不渲染。
  2. 配置LOD Group: 开发者将这几个模型拖入LOD Group组件。
  3. 设置触发距离: 开发者在组件上设置一个百分比滑块,这个百分比代表“该模型高度占屏幕总高度的百分比”。
    • 例如,设置LOD 0在模型占屏幕50%以上时显示,LOD 1在10%-50%时显示,LOD 2在10%以下时显示。
    • 当玩家(摄像机)后退时,模型在屏幕上的占比变小,引擎会自动从LOD 0切换到LOD 1,再到LOD 2。

Unity 的物理系统

Unity的物理系统是一套完整的工具集,用于模拟现实世界中的力、运动和碰撞,或者检测物体之间的空间关系。

简单来说,它主要包含两个独立的世界三大核心组件

1. 两个独立的世界

首先,Unity的物理系统被严格分为3D物理2D物理,它们是完全独立、互不相通的两个系统:

  • Physics (3D):
    • 底层: 基于NVIDIA的 PhysX 引擎。
    • 组件: Rigidbody, Box Collider, Sphere Collider, Capsule Collider, Mesh Collider, Physic Material
    • 用途: 绝大多数3D游戏,如FPS、赛车、3D平台跳跃等。
  • Physics 2D:
    • 底层: 基于 Box2D 引擎。
    • 组件: Rigidbody 2D, Box Collider 2D, Circle Collider 2D, Polygon Collider 2D, Physics Material 2D
    • 用途: 2D游戏,如横版过关、愤怒的小鸟,以及你的2D格斗游戏项目(即使你只用了它的碰撞检测)。

重要原则: 3D组件(如Box Collider)永远无法和2D组件(如Box Collider 2D)发生任何交互。

2. 三大核心组件

无论是3D还是2D,物理系统都依赖这三类组件来协同工作:

A. Rigidbody (刚体)

  • 概念: 这是物理世界的“演员”。
  • 用途: 任何需要受力移动的物体必须添加Rigidbody(或Rigidbody 2D)组件。它赋予物体“质量(Mass)”、“阻力(Drag)”和“重力(Gravity)”等属性。
  • 用法:
    • 模拟运动:不应该通过transform.position去移动一个刚体(这会破坏物理模拟),而应该通过rigidbody.AddForce()(施加力)或rigidbody.velocity(设置速度)来控制它。
    • Dynamic (动态): 默认模式,物体会受重力、受力、会碰撞反弹。
    • Kinematic (运动学): 切换到这个模式后,物体不再受力,但你可以通过transform.positionrigidbody.MovePosition()手动控制它。它仍然可以移动并触发碰撞事件,非常适合用于角色控制器或你项目中的自定义物理(你在项目中将Rigidbody 2D设为Kinematic,放弃了Unity的物理模拟,就是这个用法的体现)。

B. Collider (碰撞体)

  • 概念: 这是物理世界的“形状”和“边界”。
  • 用途: 它为GameObject定义了一个(通常是简化的)物理形状,用于检测碰撞。它本身是不可见的。
  • 用法:
    • 基本形状: Box, Sphere, Capsule。它们的计算开销最低,应被优先使用
    • 复杂形状: Mesh Collider (3D) 或 Polygon Collider 2D (2D)。它们可以完美贴合模型的网格,但计算开销巨大。通常只用于静态的复杂地形。
    • 复合形状: 你可以通过在一个GameObject的子物体上挂载多个基本形状,来“拼凑”出一个复杂的碰撞体(例如,用一个Capsule做身体,一个Box做盾牌)。

C. Physic Material (物理材质)

  • 概念: 这是物理世界的“表面材质”。
  • 用途: 定义一个表面的摩擦力(Friction)*和*弹力(Bounciness)
  • 用法: 你需要创建一个Physic Material(或Physics Material 2D)资源文件,设置好摩擦系数,然后将其拖拽到对应物体的Collider组件的Material字段上。例如,给“冰块”设置0摩擦力,给“橡胶球”设置高弹力。

3. 主要用途(用法总结)

基于以上组件,Unity的物理系统主要有三大用途:

A. 模拟真实物理 (Simulation)

  • 情景: 一个球从高处落下,撞到斜坡,然后弹起、滚动。
  • 实现: 给球添加 RigidbodySphere Collider,并赋予 Physic Material(高弹力)。
  • 事件: 当它碰到斜坡时,会触发 OnCollisionEnter() / OnCollisionStay() / OnCollisionExit() 脚本事件。

B. 碰撞检测 (Trigger)

  • 情景: 玩家走进一个区域,自动开门;或者子弹碰到敌人,敌人扣血。
  • 实现:
    1. 在门区域(或子弹)的Collider上,勾选 Is Trigger (是触发器) 选项。
    2. 勾选后,这个Collider不再提供物理阻挡(玩家/子弹可以穿过它),它只负责检测“谁进来了”。
    3. 它不再触发OnCollisionEnter,而是触发OnTriggerEnter() / OnTriggerStay() / OnTriggerExit() 脚本事件。

C. 空间查询 (Querying / Raycasting)

  • 情景: 你不需要模拟或碰撞,你只想“”物理系统一个问题。
    • FPS射击: “从我的枪口,沿着我的准星,发射一条射线(Ray),它第一个碰到的物体是谁?”
    • 角色跳跃: “从我的脚底,向下发射一条0.1米的短射线,是否碰到了‘地面’层?”
  • 实现:
    • 在脚本中主动调用 Physics.Raycast()(3D)或 Physics2D.Raycast()(2D)。
    • 这是性能最高效的检测方式之一,因为它只在那一瞬间进行一次计算。
    • (在割草游戏中,Physics.OverlapSphere()(检测一个球形范围内的所有敌人)也是这类用法)。

实现时间轮转(类似定时任务)

我的回答:协程

1. 协程 (Coroutine) 的优缺点

你回答的协程方案,我们先来复盘它的优缺点。

  • 优点:
    • 语法简单: yield return new WaitForSeconds(5f); 非常直观。
    • 上下文保留: 可以在yield前后保留函数的状态,适合编写复杂的“分步”逻辑(例如新手引导)。
  • 缺点(总监想听的):
    • GC Alloc(垃圾回收): 最大的问题。每次 new WaitForSeconds(5f) 都会产生一次GC分配。如果你的游戏(比如割草游戏)中有几百个子弹或特效都在用这个,会高频产生小垃圾,导致H5/小程序游戏频繁卡顿
    • 时间缩放(TimeScale)依赖: WaitForSeconds 依赖 Time.timeScale。如果你在游戏暂停时将 Time.timeScale 设为0,所有这些定时器都会被“冻结”。要规避,你必须使用 WaitForSecondsRealtime,但它同样有GC问题。
    • 生命周期依赖: 协程必须依附于一个MonoBehaviour。如果这个GameObjectDisableDestroy,协程会意外中止
    • 管理困难: 很难从外部精确地“暂停”、“取消”或“查询”一个正在运行的协程。

2. 其他(更优的)实现方法

除了协程,这里有几种工业级的实现方案,从简单到高效:

方法一:Update 驱动的管理器(最实用)

这是最常见、最可控的“轮询”方案。

  • 概念: 创建一个单例 TimerManager。它内部维护一个List<TimerTask>
  • TimerTask 对象: 这是一个C#对象(class),包含:
    • public float targetTime; (目标执行时间)
    • public Action onComplete; (一个委托/事件,用于执行回调)
    • public bool isLooping; (是否循环)
  • 工作流:
    1. 当你需要一个5秒的定时器时,你调用 TimerManager.Instance.AddTask(5.0f, MyCallback)
    2. TimerManagernew 一个 TimerTask,设置 targetTime = Time.time + 5.0f,然后把它加入List
    3. TimerManagerUpdate() 方法每一帧都会遍历这个List
    4. 如果 Time.time >= task.targetTime,就执行task.onComplete(),然后从List移除这个task
  • 优点:
    • 零GC(关键): 你可以(也必须)使用对象池(Object Pool) 来管理 TimerTask 对象。TimerManager 在创建和移除任务时,只在池中存取,运行时完全没有GC
    • 时间可控: 你可以自由选择使用 Time.time(受缩放影响)还是 Time.unscaledTime(不受缩放影响)。
    • 集中管理: 所有定时器都在一个地方,方便暂停、取消、统计。
    • 生命周期:不依赖任何GameObject,是全局的。

方法二:时间轮 (Time Wheel)(最高效)

这可能是面试官提到“时间轮转”时,想考察的高级数据结构

  • 概念: 这是一个用于处理海量(成千上万个)定时器的高性能算法。
  • 结构: 想象一个钟表,有60个“刻度”(一个数组List<TimerTask>[60])。每个“刻度”都是一个List,代表“将在这一秒(或这一帧)执行的任务”。
  • 工作流:
    1. TimerManager 有一个“指针”(currentTick)。
    2. 当你添加一个5秒后的任务时,你把它加到 (currentTick + 5) % 60 这个“刻度”的List里。
    3. Manager 不再遍历所有任务。它在Update每秒(或每帧)只做一件事:把“指针”向前拨一格currentTick++)。
    4. 然后,它只执行“指针”当前指向的那个“刻度”里的所有任务。
  • 优点:
    • O(1) 复杂度: Update的开销与定时器总数无关。无论你有10个还是100万个定时器,Update的开销都是恒定的O(1)(只拨动指针和处理当前格)。
    • 适用情景: Moba或MMORPG中的Buff/Debuff系统。几百个小兵和英雄,每个人身上都可能有多个Buff在倒计时,用时间轮是最佳方案。
  • 缺点: 实现比方法一复杂。

方法三:UniTask (async/await)(最现代)

  • 概念: 使用现代C#的async/await语法,配合UniTask这个(目前已在Unity包管理器中)的第三方插件。

  • 用法: UniTask 提供了零GCasync支持。

    // 告别协程,使用零GC的"UniTask"
    async UniTask MyTimerTask()
    {// 零GC,且可以选择是否受TimeScale影响await UniTask.Delay(TimeSpan.FromSeconds(5), ignoreTimeScale: true);// 5秒后执行MyCallback();
    }
    
  • 优点:

    • 零GC: UniTask.Delay 不产生GC
    • 语法优雅: async/await 是C#的未来,比yield更清晰。
    • 功能强大: UniTask 可以轻松处理取消、超时、多任务并行等。
  • 缺点: 需要引入UniTask这个库(尽管它现在非常流行)。

方法四:Invoke / InvokeRepeating(不推荐)

  • 用法: Invoke("MyCallback", 5.0f);
  • 缺点:
    • 基于字符串: Invoke 使用“魔术字符串”,无法被编译器检查,如果方法名拼错,运行时才会报错。
    • 性能开销: 它依赖反射来查找方法,性能比协程或Update慢。
    • 限制: 同样受TimeScale影响,且无法传递参数。
  • 结论: 属于应被淘汰的遗留(Legacy)API。

异步:UniTask

UniTask:为Unity而生的异步方案

UniTask 是一个第三方库(现在在Unity Package Manager中可用),它重写了C#的async/await异步模型,使其原生适配Unity的架构。

核心特性:

  1. 零GC(Zero Allocation):
    • 这是它最重要的特性。
    • 传统的C# Taskclass(类),每次await都会产生GC。Unity的协程(Coroutine)在yield return new WaitForSeconds()时也会产生GC。
    • UniTask 是基于struct(结构体)的,并且大量使用了对象池技术。在99%的游戏循环中(如await UniTask.Delay(), await UniTask.Yield()),它不产生任何GC
  2. 基于Unity游戏循环(Main Thread):
    • UniTask 默认运行在Unity的主线程上。
    • 当你await UniTask.Yield()时,它不等同于线程切换,而是像协程一样,“将任务的剩余部分”注册到下一帧的Update”。
    • 这意味着你可以在await之后安全地访问Unity API(如transform.position),这是C# Task默认做不到的。
  3. 强大的Unity集成:
    • UniTask 提供了大量Unity原生的“可等待”扩展:
    • await UniTask.Delay(TimeSpan.FromSeconds(1)); (零GC,可替代WaitForSeconds)
    • await UniTask.Yield(PlayerLoopTiming.FixedUpdate); (等待到下一个FixedUpdate)
    • await Addressables.LoadAssetAsync("MyPrefab"); (无缝转换Unity的AsyncOperation)
    • await UniTask.WaitUntil(() => player.IsDead); (等待一个条件)

C# Task 与 UniTask

C# Task 是为通用 .NET 应用(如服务器) 设计的,它基于多线程;而 UniTask 是为 Unity 游戏专门设计的,它(默认)基于Unity的单线程游戏循环,并且核心目标是零GC(零垃圾回收)

在H5/小程序游戏开发中,GC是性能卡顿的最大杀手之一,因此 UniTask 几乎是必选方案。

特性 C# Task (System.Threading.Tasks) UniTask
设计目标 通用 .NET 应用(服务器、桌面) Unity 游戏(H5, 移动端)
核心结构 Task<T> 是一个 class(类) UniTask<T> 是一个 struct(结构体)
GC(垃圾) 高频GC。每次创建Task和状态机切换都会分配内存。 零GC(核心优势)。使用struct和池化,几乎无GC。
执行线程 默认运行在 ThreadPool(线程池)上。 默认运行在 Unity Main Thread(主线程)上。
Unity API 不安全。在await后(线程池中)绝对不能访问transform等API。 安全await后仍在主线程,可随时访问Unity API。
用途 适合CPU密集型的纯计算(如寻路算法),需要手动切回主线程。 适合游戏逻辑(如定时器、异步加载、等待动画)。

IK / Joint(其中一个,记不太清了)

不是很了解,结合动画中的IK约束和在B站刷到的一个蛇形运动案例简单说了点

1. Joint (关节) - 物理系统

Joint(关节)是物理系统 (Physics System) 的一部分。

  • 核心概念: Joint 是一个约束(Constraint)。它用于连接两个 Rigidbody (刚体),并限制它们之间的相对运动
  • 用途:
    • 布娃娃 (Ragdoll): Joint最主要用途。通过 Character JointHinge Joint 将角色的各个肢体(Rigidbody)连接起来,在角色死亡或被击飞时,就能产生逼真的、受物理驱动的布偶效果。
    • 链条 / 绳索: 使用一连串的 Hinge Joint (铰链关节) 或 Spring Joint (弹簧关节) 可以模拟链条、吊桥或物理绳索。
    • 门 / 轮子: Hinge Joint 可以用来模拟一扇门(绕着门轴旋转)或一个轮子(绕着车轴旋转)。
    • 物理活塞: Slider Joint 可以模拟活塞或只能在单个轴上滑动的物体。
  • 你的“蛇形”案例 (Joint版):
    • 你的“蛇形”案例如果用Joint实现,会是:一连串的 Rigidbody(蛇的每一“节”),通过 Hinge JointConfigurable Joint 连接起来
    • 当你给“蛇头”的 Rigidbody 施加一个力时,这个力会通过Joint约束,物理上拉动后面的身体,产生“物理驱动”的蛇形运动。

2. IK (反向动力学) - 动画系统

IK (Inverse Kinematics) 是动画系统 (Animation System) 的一部分。

  • 核心概念: 它是一种“反向”计算骨骼旋转的方式。
    • 正向动力学 (FK): (默认方式)“我转动肩膀肘部跟着动,也跟着动。”(从根到梢)
    • 反向动力学 (IK): “我不管肩膀和肘部,我只要求必须抓住这个门把手。请系统自动反向计算出我的肘部和肩膀应该旋转到什么角度。”(从梢到根)
  • 用途: IK 用于在运行时动态调整动画,使其能“适应” 游戏世界。
    • 脚步落地 (Foot Grounding): 最主要用途。当角色走在不平整的楼梯或斜坡上时,使用IK“吸附”角色的脚(IK Target)到地面,防止脚悬空或穿模。
    • 手部交互 (Hand Grabbing): 角色开门时,使用IK让角色的“手”精确地抓在“门把手”上,无论角色站位有多大偏差。
    • 头部追踪 (Head LookAt): 使用IK让角色的头部(或炮塔)自动转向并“盯住”一个目标(如玩家或敌人)。
    • 持枪 / 握方向盘: 使用IK强制角色的双手“握”在武器或方向盘的特定位置。
  • 你的“蛇形”案例 (IK版):
    • 你的“蛇形”案例如果用IK实现,会是:一整条骨骼链(蛇身),“蛇头”被设置为IK的“末端执行器”(Target)
    • 你只需要在脚本中移动这个“蛇头”IK系统就会自动计算蛇的每一节骨骼(节点)应该如何弯曲,以动画上平滑地跟随这个“蛇头”。

传输层协议 UDP

1. 讲解题目:UDP (User Datagram Protocol)

A. UDP的核心特征

  1. 无连接 (Connectionless):
    • TCP在发送数据前,需要进行三次“握手”来建立连接。
    • UDP不需要。它拿起数据包,写上地址,就直接“扔”到网络上。这使得它的连接延迟几乎为0。
  2. 不可靠 (Unreliable):
    • 这是UDP最著名的特征。它不保证数据包:
      • 一定送达(可能会丢失)。
      • 按序送达(包A可能比包B晚到,即使B先发)。
      • 不会重复(网络异常时可能收到重复包)。
  3. 数据报 (Datagram-based):
    • 它保留了“消息的边界”。你Send()一个100字节的包,接收方Receive()一定会收到一个完整的100字节的包(如果它没丢的话)。
    • 这与TCP的“流式(Stream-based)”完全不同。TCP会把数据当作一个“水流”,它可能会把你的100字节拆成2个50字节,或和别的包合并成一个200字节。
  4. 低开销 (Low Overhead):
    • UDP的头部(Header)非常小(仅8字节),而TCP的头部很复杂(20字节以上)。

**B. 为什么游戏"偏爱"UDP **

  • 速度!速度!还是速度!
  • 游戏,特别是实时动作游戏(如FPS、MOBA、格斗),最不能忍受的就是延迟(Latency)*和*卡顿(Stall)
  • TCP有一个“致命”的缺陷,叫做“队头阻塞”(Head-of-Line Blocking)
    • 因为TCP必须保证“按序送达”,如果它发送了1、2、3、4、5号包,而3号包在网络上丢失了。
    • 即使你的电脑已经收到了4号和5号包,TCP的操作系统内核“扣住” 这两个包,死活不给你的应用程序(游戏)。
    • 它会暂停一切,疯狂重传3号包,直到3号包被成功接收,它才会把3、4、5号包一起按顺序交给你的游戏。
    • 这个“暂停”对游戏来说,就是一次“网络卡顿”“画面冻结”
  • 而UDP没有“队头阻塞”。如果3号包丢了,游戏照样能收到4号和5号包。游戏逻辑(应用层)可以自己决定:“哦,3号包丢了,无所谓,4号包是更新的位置,我用最新的就行了。”
  • 应用层封装可靠性 (你的答案):
    • 这就是“可靠UDP(R-UDP)”的由来。
    • 对于“玩家开枪”或“玩家死亡”这种必须送达的消息,我们仍然使用UDP发送,但我们在应用层(游戏代码里)为这个包手动加上一个“需要回执(ACK)”的标记。
    • 如果我们没收到回执,我们的代码就手动重传这个包。
    • 好处: 这种“应用层”的重传,会阻塞“死亡”这一个事件,不会阻塞“玩家移动”这种无关的数据。

2. TCP vs. UDP 的游戏使用场景

特性 TCP (传输控制协议) UDP (用户数据报协议)
连接 面向连接(需握手) 无连接(直接发)
可靠性 可靠 不可靠
顺序 保证按序 不保证按序
核心问题 队头阻塞(高延迟) 需要自己处理丢包(高复杂度)
别名 “可靠的慢车” “超快的摩托车(会掉零件)”

A. 什么时候用 TCP?

数据准确性顺序性远比“实时性”重要时:

  1. 登录与鉴权:
    • 场景: 玩家输入账号密码登录。
    • 理由: 必须可靠送达。你不能容忍密码丢一个字母。这个过程慢半秒玩家也无所谓。
  2. 游戏大厅与聊天:
    • 场景: 玩家聊天、查看排行榜、匹配。
    • 理由: 聊天记录必须按顺序、可靠送达。
  3. 回合制游戏:
    • 场景: 卡牌游戏(如炉石)、战棋。
    • 理由: 玩家“打出一张牌”,这个指令必须100%送达。几百毫秒的延迟在回合制里完全可以接受。
  4. H5/小程序游戏 (WebSockets):
    • 场景: 这是疯狂游戏 的业务方向。浏览器(H5)和小程序中,与服务器通信的主要方式是 WebSocket
    • 理由: WebSocket 协议本身是构建在 TCP 之上的。这意味着H5游戏在网络上天生就受TCP“队头阻塞”的限制。因此,H5游戏(如“天天台球”) 的网络架构必须想办法规避(或忍受)TCP的延迟。
  5. State-Sync (状态同步):
    • 场景: 权威服务器游戏(例如你的格斗游戏项目 中,如果用Mirror的默认TCP传输)。
    • 理由: 服务器计算出“最终结果”(如血量、位置),通过TCP可靠地发给客户端。这种方案简单可靠,但容易在网络抖动时因“队头阻塞”而卡顿。

B. 什么时候用 UDP?

实时性是第一要求,且我们可以容忍(或自行处理) 丢包时:

  1. FPS / MOBA / 赛车游戏:
    • 场景: 玩家的位置、朝向、开火。
    • 理由: 这些数据每秒更新30-60次。丢掉一个包,16毫秒后下一个包就会覆盖它,毫无影响。但如果用TCP,一次卡顿就是毁灭性的。
  2. 帧同步 / 回滚网络 (Frame Sync / Rollback):
    • 场景: 这是你的格斗游戏项目 的“理想方案”。
    • 理由: 游戏通过UDP发送玩家的输入(Input)。所有客户端本地的模拟都是确定性的。UDP的低延迟是回滚网络的必要前提
  3. 语音聊天 (VoIP):
    • 场景: 游戏内开黑。
    • 理由: 丢失一帧(几十毫秒)的语音,听起来只是一个微小的“跳音”或“杂音”,这远比TCP“卡住一秒”再“快放”要好得多。

HTTP协议中GET和POST的区别

GET 和 POST 是 HTTP 协议中两种最常用的请求方法

GET 与 POST 的核心区别

最简单、最核心的区别是:

  • GET (获取): 用于从服务器“获取”数据。它是只读的。
  • POST (提交): 用于向服务器“提交”数据。它会改变服务器上的内容。
特性 GET (获取) POST (提交)
语义目的 从服务器读取(Read)资源。 在服务器上创建(Create)或更新(Update)资源。
数据位置 数据放在 URL 的“查询字符串”中(?key=value&key2=value2)。 数据放在 HTTP 请求体 (Request Body) 中。
可见性/安全 不安全。数据在URL中完全暴露,会被保存在浏览器历史、服务器日志中。 相对安全。数据在请求体中,不会显示在URL或历史记录里。(但仍需HTTPS加密)
数据大小 限制。URL有最大长度限制(通常2048字符左右)。 限制。可以提交大量数据(如JSON、文件)。
可缓存性 可以被缓存。浏览器/CDN可以缓存GET请求的结果,提高性能。 不可被缓存。(默认)
幂等性 幂等 (Idempotent)。执行100次和执行1次,服务器状态相同。 非幂等。执行100次,可能会在服务器上创建100条新数据。
书签 可以被收藏为书签。 不可被收藏为书签。

在H5/小程序游戏中的使用场景

什么时候用 GET?

当你的游戏客户端需要“拉取” 数据时:

  1. 加载 Asset Bundles / 资源: UnityWebRequestAssetBundle.GetAssetBundle(cdn_url),这在底层就是一个 GET 请求。
  2. 获取排行榜: 请求 .../api/leaderboard?top=100
  3. 获取玩家信息: 请求 .../api/player/profile?id=12345
  4. 获取游戏配置: 游戏启动时,GET 一个config.json文件,以获取最新的活动信息或服务器列表。

什么时候用 POST?

当你的游戏客户端需要“推送” 数据,尤其是敏感大量数据时:

  1. 玩家登录/注册:
    • 必须用 POST。你绝对不能把用户名和密码用GET放在URL里(.../login?user=abc&pass=123),这是最严重的安全漏洞。
    • POSTuserpass 放在请求体中,保护了数据不被暴露。
  2. 提交游戏分数/战绩:
    • .../api/game/submit_score
    • 请求体中包含一个 JSON 对象:{ "score": 9999, "level": 10, "signature": "xxx" } (签名防作弊)。
  3. 上报分析/埋点数据:
    • 玩家在一局游戏中产生了大量行为数据(几KB的JSON)。这些数据太大,无法用GET发送,必须用 POST 提交。
  4. 进行内购验证:
    • 客户端向服务器 POST 苹果/微信返回的支付凭证(Receipt),服务器再去验证。这个凭证很长,也必须用POST。

最近在玩的游戏

端游我回答了守望先锋2、街头霸王6和剑星,手游回答了明日方舟。然后面试官挑了几个游戏询问其中的一些实现

守望先锋2的帧同步与断线重连

我结合看过的GDC守望先锋程序员关于网络代码和ECS架构的演讲,基于输入序列化回答了相关问题,并提及回访系统。面试官问我一局游戏内具体的同步流程,关于比赛数据(玩家游戏数据、目标点进度等)的同步部分我不太了解。

1. 概念澄清:OW2的网络模型

首先,我们(和你)的回答都基于一个共识:

  1. 权威服务器 (Authoritative Server): 游戏逻辑在服务器上运行。客户端是“哑”的,只负责发送输入和渲染结果。
  2. 确定性模拟 (Deterministic Simulation): 这是你提到“ECS”和“输入序列化”的核心。服务器(和回放系统)是基于一个确定性的引擎。给定相同的“输入流”(玩家按键、AI决策),它永远会模拟出完全相同的游戏结果。
  3. 状态同步 (State Synchronization): 客户端向服务器发送输入(Inputs)。服务器运行模拟后,向所有客户端广播结果(Game State)

2. 问题的核心:同步的“数据”不是一种

面试官的问题(玩家数据、目标点进度)之所以难以回答,是因为这些数据不属于同一类型

一个好的架构会把“比赛数据”严格区分为两种类型

  1. 模拟-关键数据 (Simulation-Critical Data): 必须在服务器上被确定性模拟,必须高频同步的数据。
  2. 聚合-元数据 (Aggregated/Meta Data): 由模拟产生的数据,影响模拟本身,可以低频同步的数据。

3. 讲解:“目标点进度” (Payload, Capture Point)

这属于第一类:模拟-关键数据。

“目标点进度”不是一个单独的变量,它和玩家的生命值、位置一样,是确定性模拟的核心部分

  • 如何工作?
    1. 游戏规则(Rule): 服务器的确定性模拟中有一条规则:“如果 玩家A在区域B内, 玩家A是进攻方, 区域B内没有防守方, CapturePoint.Progress += Rate * DeltaTime。”
    2. 确定性状态: CapturePoint.Progress(目标点进度)这个变量,和玩家的HealthPosition一样,是被服务器的确定性逻辑(ECS系统)每一Tick(每一帧)更新的。
    3. 高频同步: 因为这个进度直接影响游戏胜负和逻辑(例如“加时赛”的触发),它必须和玩家位置、血量一样,被打包在最高优先级、最高频率(例如60Hz)的网络包体中,广播给所有客户端。
    4. 客户端: 客户端收到这个数据后,只是忠实地渲染它(例如:UI上的进度条从 30.1% 变到 30.2%)。

一句话总结: “目标点进度”就是游戏状态,它和玩家位置一起被高频同步。

4. 讲解:“玩家游戏数据” (K/D/A, 伤害量)

这属于第二类:聚合-元数据。

这是你问题的关键。“玩家的伤害量”影响模拟。“伤害量”是模拟已经发生的“结果”。

  • 如何工作?
    1. 模拟(Simulation): 服务器的确定性模拟中,玩家A的枪械击中玩家B。
    2. 事件(Event): 模拟系统(ECS)在处理这个逻辑后,会产生一个事件(Event),例如:Event_PlayerDidDamage(Attacker: A, Victim: B, Damage: 100)
    3. 解耦的服务(Service): 服务器上有一个独立的、非确定性的“统计服务”(StatsService)。它订阅了所有这些事件。
    4. 聚合(Aggregation): StatsService 收到这个事件后,在自己的一个Dictionary(或数据库)里更新:Scoreboard[Player_A].Damage += 100
    5. 低频同步: 这个Scoreboard对象(包含所有人的K/D/A、伤害、治疗)根本不需要60Hz的同步。它只需要在数据发生变化时(或者每隔1-2秒)被打包成一个单独的、低优先级的网络消息,发送给所有客户端。
    6. 客户端: 客户端收到这个“Scoreboard_Update”消息后,才去更新你按Tab键看到的那个计分板UI。

一句话总结: “玩家数据”是模拟产生的事件,由服务器上的另一个服务聚合后,低频(或脏数据)同步给客户端UI。

复盘总结(理想回答)

“这是一个很好的问题。我的理解是,OW2会把这两类数据分开处理:

  1. 目标点进度: 这是模拟-关键数据。它和玩家的血量、位置一样,是服务器确定性模拟的一部分,会被打包在高频(如60Hz)的状态同步包里发给客户端,客户端的UI只是忠实地渲染这个值。
  2. 玩家数据(K/D/A): 这是聚合-元数据。服务器的模拟在运行时会产生事件(如‘击杀’、‘造成伤害’)。服务器上有个单独的统计服务会监听这些事件,并聚合成计分板。这份计分板数据不需要高频同步,它会作为一个独立的、低优先级的消息,在数据变化时(或每隔几秒)才同步给客户端的UI。

这种分离,可以确保最高优先级的带宽只留给玩家位置和目标点这些关键状态。”

明日方舟的阻挡机制

我先提出最简单的方案是碰撞体检测,然后提出了一个改进方案,由于地图按照网格划分,每个网格同一时间只能存在一名我方干员(干员存在最大可同时阻挡数),因此网格可以记录我方干员的部署信息。当敌人在经过一个地图网格时,读取是否有干员在这个网格,如果有就检查该干员是否还能阻挡敌人(比较当前阻挡数量和最大可同时阻挡数),如果能则该敌方单位停止移动进入被阻挡状态,否则正常移动。

具体实现

我们可以把你的方案细化为三个关键的数据模型和一个核心算法:

A. 数据模型
  1. GridCell (地图网格):
    • 它是一个C#对象,代表地图上的一个格子。
    • bool IsWalkable; (是否可走)
    • bool IsDeployable; (是否可部署干员)
    • Operator deployedOperator; (关键: 引用当前部署在该格子的干员)
  2. Operator (我方干员):
    • int MaxBlockCount; (最大阻挡数,如:1, 2, 3)
    • int CurrentBlockCount; (关键: 当前阻挡的敌人数量)
    • List<Enemy> blockingEnemies; (关键: 引用它当前正在阻挡的所有敌人)
  3. Enemy (敌方单位):
    • bool IsBlocked; (是否被阻挡)
    • Operator blockedBy; (关键: 引用当前正在阻挡它的那个干员)
    • List<GridCell> path; (它的移动路径,由A*算法算出)
B. 核心算法 (State Machine)

你已经描述了核心算法,我们把它补全:

1. 部署干员 (Deploy Operator)

  • 玩家在 GridCell [5,5] 部署干员 O
  • GridCell [5,5].IsWalkable = false; (敌人不能再走这个格子)
  • GridCell [5,5].deployedOperator = O;

2. 敌人移动 (Enemy Move - 你的核心逻辑)

  • 敌人 E 沿着它的路径 path 移动。
  • 它的下一个格子是 [5,5]
  • E 检查 GridCell [5,5].IsWalkable -> 发现是 false
  • E 检查 GridCell [5,5].deployedOperator -> 发现了干员 O
  • (这就是你的逻辑) E “请求阻挡”:
    • if (O.CurrentBlockCount < O.MaxBlockCount):
      • // 阻挡成功!
      • O.CurrentBlockCount++
      • O.blockingEnemies.Add(E)
      • E.IsBlocked = true
      • E.blockedBy = O
      • E.StopMovement() (敌人的GameObject在“表现层”停止移动)
    • else:
      • // 阻挡失败(干员已满)
      • 《明日方舟》的逻辑是:敌人会在原地等待,直到O有空位。你的方案“否则正常移动”也是一种可行的设计,这取决于游戏策划

3. 状态清理 (Cleanup - 关键的另一半)

  • 情况一:被阻挡的敌人 E 死亡
    • E.blockedBy.CurrentBlockCount-- (干员 O 的阻挡数-1)
    • E.blockedBy.blockingEnemies.Remove(E) (干员 O 释放对 E 的引用)
  • 情况二:干员 O 撤退或死亡
    • GridCell [5,5].IsWalkable = true
    • GridCell [5,5].deployedOperator = null
    • 遍历 O.blockingEnemies 列表中的所有敌人(E1, E2...)
    • E1.IsBlocked = false
    • E1.blockedBy = null
    • E1.ResumeMovement() (敌人在“表现层”重新开始寻路和移动)

反问阶段

项目主要技术栈

性能、稳定性、开发速度的权衡

如何学习快速适应工作和团队

(个人问题)对考察方向(完全不涉及数据驱动、架构设计等方向)感到困惑,得到回答:

  • 格斗游戏国内不做
  • 架构设计等工作内容不会给应届生做

总结

一面面试官主要从项目切入,以网络和性能优化为主要考察方向,会进行一定程度的追问,给我的压力有点大,但复盘下来发现大部分问题都有点到核心,因此一面通过了。也给了我盲目自信。

二面基本不涉及项目内容,通过八股考察基础和知识储备,提问时节奏比较快,一个问题问完会快速进入下一个问题,基本不太会追问深究。其中性能优化方面我只在项目中学习和实践了代码方面的优化,对于渲染优化程序包体优化方面,完全处于我的知识盲区,相关问题一个都没答上来。此外网络部分比较偏向传统Web,可能是因为微信小游戏有点像前端那一套(?),问了一些计算机网络里HTTP的GET和POST方法、UDP协议等概念,自从学习Unity以来就没怎么接触四大件了,这次也是发现底层知识不能丢 : (

关于最近常玩的游戏,多少对机制和技术有一些了解,自认为这方面回答的还不错(也是我二面感觉良好的来源)。但其实有些深入理解的游戏好像也刚好就最近在玩的这几款,其他玩的久的游戏好像没想过分析和拆解。。

基于一面通过的盲目自信,加上二面过程中的自我感觉良好,在二面后第二天收到暂不匹配通知时是有点落差的。好在及时复盘,也是发现短板了。也了解到某些公司(通常规模不会太大)需要新入职员工快速上手,会结合项目方向针对性提问八股,因此得到面试前要针对项目组方向准备的教训。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/963887.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

HIL/SIL/PIL介绍

https://zhuanlan.zhihu.com/p/5907578226 https://blog.csdn.net/Pangbo1711/article/details/133426683 https://www.cnblogs.com/zhjblogs/p/13773571.html https://zhuanlan.zhihu.com/p/148337548

Notepad++下载安装教程

转载自:上羽伴水 https://zhuanlan.zhihu.com/p/1920981416533010207 一、Notepad++软件介绍 1.界面与操作介绍 Notepad++ v8.6.4 界面简洁直观。顶部菜单栏涵盖文件、编辑、搜索等功能选项;工具栏放置常用操作快捷按…

Linux下安装VirtualBox 7.2.4(含坑),以及微信输入法与微软输入法哪个大

apt list找不到virtualbox,到Oracle网站去下载的。 选择了for Debian 12的: 109M 11月13日 01:18 virtualbox-7.2_7.2.4-170995~Debian~bookworm_amd64.deb 下载速度很快 dpkg -i *.deb 装上了。 virtualbox进去了,…

Linux下安装VirtualBox 7.2.4(含坑)

apt list找不到virtualbox,到Oracle网站去下载的。 选择了for Debian 12的: 109M 11月13日 01:18 virtualbox-7.2_7.2.4-170995~Debian~bookworm_amd64.deb 下载速度很快 dpkg -i *.deb 装上了。 virtualbox进去了,…

Spring Boot 项目安全配置,放行指定规则的 HTTP 请求

Spring Boot 项目安全配置,放行指定规则的 HTTP 请求package com.joyupx.config;import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.spr…

今晚不加班!我用SpeedAI撸完了一篇1.2万字的初稿 - BUAA

15:30 实验室例会结束,老板甩来一句“下周交项目书”。 我表面淡定,内心慌得一批:上周数据刚跑完,字儿还一个没写,下周就要交?行,摸鱼也要讲效率,今天就让AI替我打工。 15:32 回到工位,打开SpeedAI,首页干净…

使用cline集成aws的mcp服务和搜索功能

参考资料https://aws.amazon.com/cn/blogs/containers/accelerating-application-development-with-the-amazon-eks-model-context-protocol-server/在vscode中安装cline后,为了避免高额模型费用和兼容性问题,通过li…

Emacs中文等宽字体设置(要用英文名称,系统中的名称)

Emacs中文等宽字体设置(要用英文名称,系统中的名称)本文为和AI大模型KIMI对话记录,仅供参考。 Emacs中文等宽字体设置(要用英文名称,系统中的名称) 直接在菜单栏的Option-Default Font设置没用,这样保存配置时…

Python 字符串形式与嵌套规则:从 C 语言注释谜题到 Python 引号逻辑

Python 字符串形式与嵌套规则:从 C 语言注释谜题到 Python 引号逻辑 要理解 Python 字符串的嵌套解析和“大嘴法”,我们可以从你提供的 C 语言代码谜题入手——这段代码的输出结果是 1,而非直观预期的 0。这个反常识…

Python 字符串格式化全解析:%、format() 与 f-string 的前世今生

Python 字符串格式化全解析:%、format() 与 f-string 的前世今生 字符串格式化是程序开发中不可或缺的基础能力,它负责将变量、表达式等动态内容嵌入固定文本模板中,生成人类可读的字符串。Python 提供了三种主流的…

newDay20

1.今天经学有期中考试,费的时间比较多,简单背了背单词 2.明天没啥事了,多学学 3.感觉总是干一会歇一会

20251112 之所思 - 人生如梦

20251112 之所思今天有两件事做的很好:1. 软件出现重大问题,但是第一时间没有藏着,而是非常勇敢的将相关责任方邀请,汇报问题以及相关原因,计划下一步的行动计划和弥补措施。组织的相当给力,原以为老板会大发雷霆…

102302134陈蔡裔数据采集第三次作业

第一题 核心代码和运行结果 import os import requests from bs4 import BeautifulSoup import threading from urllib.parse import urljoinclass MiniCrawler:def __init__(self):self.downloaded = 0self.visited =…

VB6版GUID生成器 - 开源研究系列文章 - 个人小作品

VB6版GUID生成器 - 开源研究系列文章 - 个人小作品Posted on 2025-11-13 00:07 lzhdim 阅读(0) 评论(0) 收藏 举报 这几天闲来无事,把原来VB6的代码进行了整理和修改,用最新的架构进行了重构。这次把原来…

Pandas - How to know which columns of a dataframe has null value?

Pandas - How to know which columns of a dataframe has null value? df = pd.read_csv(housing.csv)df.info() <class pandas.core.frame.DataFrame> RangeIndex: 13580 entries, 0 to 13579 Data columns (t…

三分法

参考算法学习笔记(62): 三分法 - 知乎 众所周知,二分法主要用来求函数的零点,那么三分法是二分法的变种,主要用来求单峰函数的极值点。 三分法的原理非常简单,每次对一个区间[l,r]求三等分点lsec和rsec:l = l + l…

vue-element el-select 赋值选择项后选择事件不生效

1、截图2、描述 2.1 控件代码<el-form-item label="处理状态" prop="processStatus"><el-select v-model="form.processStatus"@change="$forceUpdate()"placeholde…

Python正则表达式操作速查表(全面版v1.0 - 2025年11月12日修订)

Python 正则表达式操作速查表(全面版v1.1 - 2025年11月12日修订) 📌 使用说明 时间复杂度:O(n) = 线性级(随字符串长度增长),O(nk) = 取决于字符串长度与模式复杂度 🔴 正则匹配默认区分大小写,需通过 flag…

11月12日日记

1.今天离散数学测试,学习马哲 2.明天体育课篮球比赛 3.init 方法的 load-on-startup 参数作用是什么?