Loading

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

写在前面

面试题目仅靠回忆,复盘过程借助 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": [
              // 省略
          ]
      }
  ]
}
posted @ 2025-11-10 02:42  Senesi  阅读(3)  评论(0)    收藏  举报