Appearance
引擎开发岗
C++ 是核心
面试回答:
C++ 是游戏客户端和引擎方向的核心,因为它直接决定你能不能理解底层性能、内存生命周期、对象模型、容器代价、多线程同步和编译链接问题。
Unity 项目虽然很多业务用 C# 写,但面试官问 C++,通常不是只想听语法,而是想判断你有没有继续往引擎、性能优化、底层框架方向成长的能力。
为什么重要:
C++ 能体现这些能力:内存管理、RAII、智能指针、虚函数和虚表、STL 容器选择、迭代器失效、多线程、缓存友好、对象池、内存池、编译链接。
面试加分说法:
NOTE
“我理解 C++ 不只是语法,而是它把对象生命周期、内存分配、函数调用、多态成本和性能问题都暴露出来。做游戏客户端时,即使业务层用 C#,理解 C++ 也能帮助我看懂引擎机制、定位性能问题,并设计更可靠的底层模块。”
小代码示例:RAII 管理资源
c
#include <iostream> // 引入标准输出库,用来模拟打印释放日志
class TextureHandle // 定义一个资源句柄类,用来模拟管理一份贴图资源
{ // 类定义开始
private: // 私有区域开始,外部不能直接访问
int id; // 保存资源 id,用来代表某个已经申请到的资源
public: // 公有区域开始,外部可以调用
TextureHandle(int resourceId) : id(resourceId) // 构造函数,在对象创建时保存资源 id
{ // 构造函数函数体开始
std::cout << "Load resource: " << id << std::endl; // 模拟加载资源
} // 构造函数函数体结束
~TextureHandle() // 析构函数,在对象生命周期结束时自动调用
{ // 析构函数函数体开始
std::cout << "Release resource: " << id << std::endl; // 模拟释放资源
} // 析构函数函数体结束
}; // 类定义结束
int main() // 程序入口函数
{ // main 函数开始
TextureHandle texture(1001); // 创建局部对象,进入作用域时加载资源
return 0; // main 结束,局部对象离开作用域,会自动调用析构函数释放资源
} // main 函数结束图形学、数学、内存、并发很重要
面试回答:
图形学、数学、内存、并发确实很重要,尤其是游戏客户端和引擎方向。它们不是四个孤立知识点,而是支撑复杂游戏项目的底层能力。
图形学决定你能不能理解渲染管线、Shader、DrawCall、Overdraw、阴影和后处理。数学决定你能不能处理角色移动、相机、碰撞、寻路、插值、旋转和空间判断。内存决定项目是否稳定,能不能减少 GC、泄漏、峰值内存和频繁分配。并发决定你能不能处理异步加载、AI 分帧、任务队列、网络消息和后台计算。
加分说法:
TIP
“我觉得图形学、数学、内存、并发是客户端工程能力的四个底座。功能能写出来只是第一步,真正项目里还要考虑表现效果、空间计算、性能稳定和任务调度。比如我做角色移动会用向量和四元数,做特效会关注 Overdraw,做资源加载会控制内存生命周期,做大量 AI 或网络消息会考虑分帧和任务队列。”
要理解 Game Loop、组件系统、资源系统
面试回答:
TIP
要理解 Game Loop、组件系统、资源系统,因为它们是游戏客户端运行机制的三根主梁。
Game Loop 决定游戏每一帧怎么推进,比如输入、逻辑、物理、动画、渲染的顺序。组件系统决定对象能力怎么组织,比如一个角色不是靠继承堆出来,而是由 Transform、渲染、碰撞、AI、技能等组件组合出来。资源系统决定资源怎么加载、缓存、引用、卸载,直接影响加载速度、内存峰值和包体管理。
加分说法:
“我理解客户端不是只写业务逻辑。Game Loop 负责驱动每一帧,组件系统负责拆分对象能力,资源系统负责管理资源生命周期。比如角色移动、动画播放、特效加载、UI 打开,本质上都会经过这三个系统协作。”
简单伪代码:
c
public class SimpleGameLoop // 定义一个简单游戏循环类,用来模拟客户端主循环
{ // 类开始
public void Tick(float deltaTime) // 每一帧调用一次 Tick,并传入帧间隔时间
{ // Tick 方法开始
InputSystem.UpdateInput(); // 先更新输入系统,读取玩家本帧操作
ComponentSystem.UpdateComponents(deltaTime); // 再更新组件系统,让角色、AI、技能等逻辑推进
ResourceSystem.UpdateLoading(); // 然后更新资源系统,处理异步加载和释放队列
RenderSystem.RenderFrame(); // 最后提交渲染,让本帧画面显示出来
} // Tick 方法结束
} // 类结束要能讲渲染管线和 Shader
面试回答:
要能讲渲染管线和 Shader,因为这能证明你不是只会调材质,而是理解“一帧画面怎么画出来”。
渲染管线可以按这个顺序讲:
CPU 收集物体 -> 剔除/排序/合批 -> 提交 DrawCall -> 顶点着色器 -> 裁剪 -> 光栅化 -> 片元着色器 -> 深度/模板测试 -> 混合 -> 屏幕显示Shader 重点讲两个阶段:顶点着色器负责把模型顶点变到裁剪空间,片元着色器负责采样贴图、计算光照、输出颜色。
加分句:
“我会把渲染管线分成 CPU 提交和 GPU 执行两部分。CPU 负责组织渲染数据,GPU 负责把顶点变成屏幕像素。性能问题也能对应到阶段,比如 DrawCall 多影响 CPU 提交,Overdraw 高影响片元阶段,Shader 太复杂会增加 GPU 计算成本。”
核心 Shader 代码片段:
c
sampler2D _MainTex; // 声明主贴图采样器,用来在片元阶段采样颜色
struct appdata // 定义模型输入到顶点着色器的数据
{ // appdata 结构体开始
float4 vertex : POSITION; // 模型空间顶点坐标
float2 uv : TEXCOORD0; // 模型 UV 坐标
}; // appdata 结构体结束
struct v2f // 定义顶点着色器传给片元着色器的数据
{ // v2f 结构体开始
float4 pos : SV_POSITION; // 裁剪空间坐标,GPU 用它决定顶点在屏幕哪里
float2 uv : TEXCOORD0; // 传给片元着色器的 UV 坐标
}; // v2f 结构体结束
v2f vert(appdata v) // 顶点着色器函数,每个顶点执行一次
{ // vert 函数开始
v2f o; // 创建输出数据
o.pos = UnityObjectToClipPos(v.vertex); // 把模型空间顶点转换到裁剪空间
o.uv = v.uv; // 把 UV 传递给片元阶段
return o; // 返回顶点处理结果
} // vert 函数结束
fixed4 frag(v2f i) : SV_Target // 片元着色器函数,每个片元执行一次
{ // frag 函数开始
fixed4 color = tex2D(_MainTex, i.uv); // 根据 UV 采样贴图颜色
return color; // 输出最终像素颜色
} // frag 函数结束要理解编辑器、序列化、反射或脚本绑定
面试回答:
TIP
要理解编辑器、序列化、反射或脚本绑定,因为这代表你不只是会写业务逻辑,还懂客户端工程化和运行时扩展。
编辑器工具负责提高生产效率,比如批量改资源、检查配置、生成代码。序列化负责把对象、Prefab、场景、配置保存下来。反射可以在运行时读取类型、字段、方法和 Attribute,常用于配置表映射、工具自动化、热更辅助。脚本绑定则是让 C#、Lua、C++ 互相调用,常见于热更新、插件系统、自研引擎扩展。
加分说法:
“编辑器解决怎么生产内容,序列化解决怎么保存内容,反射解决怎么自动化读取和调用,脚本绑定解决不同语言之间怎么协作。这些能力合起来,能支撑配置表、工具链、热更新和运行时扩展。”
简单代码:反射读取配置字段
c
using System; // 引入 Attribute、Type 等基础反射相关类型
using System.Reflection; // 引入 FieldInfo 等反射类型
[AttributeUsage(AttributeTargets.Field)] // 声明这个特性只能标记在字段上
public class ConfigFieldAttribute : Attribute // 定义一个配置字段特性类
{ // ConfigFieldAttribute 类开始
public string ColumnName { get; private set; } // 保存配置表里的列名
public ConfigFieldAttribute(string columnName) // 构造函数接收列名
{ // 构造函数开始
ColumnName = columnName; // 把传入的列名保存起来
} // 构造函数结束
} // ConfigFieldAttribute 类结束
public class RoleConfig // 定义一个角色配置类
{ // RoleConfig 类开始
[ConfigField("id")] public int Id; // 标记 Id 字段对应配置表的 id 列
[ConfigField("name")] public string Name; // 标记 Name 字段对应配置表的 name 列
} // RoleConfig 类结束
public static class ConfigReflectionDemo // 定义一个反射演示工具类
{ // ConfigReflectionDemo 类开始
public static void PrintConfigFields() // 定义一个打印配置字段映射的方法
{ // PrintConfigFields 方法开始
Type type = typeof(RoleConfig); // 获取 RoleConfig 的类型信息
FieldInfo[] fields = type.GetFields(); // 获取 RoleConfig 中所有公开字段
foreach (FieldInfo field in fields) // 遍历每一个字段
{ // foreach 循环开始
ConfigFieldAttribute attr = field.GetCustomAttribute<ConfigFieldAttribute>(); // 读取字段上的 ConfigField 特性
if (attr != null) // 判断这个字段是否配置了映射特性
{ // if 判断开始
Console.WriteLine(field.Name + " -> " + attr.ColumnName); // 打印字段名和配置表列名的对应关系
} // if 判断结束
} // foreach 循环结束
} // PrintConfigFields 方法结束
} // ConfigReflectionDemo 类结束最好有小型引擎、渲染器或工具链 Demo
面试回答:
TIP
最好有一个小型引擎、渲染器或工具链 Demo,因为它能证明你不是只会写零散功能,而是能把底层知识做成可运行、可讲解、可验证的工程成果。
三种方向怎么选:
小型引擎 Demo:适合展示 Game Loop、组件系统、资源系统、事件系统、对象池、状态机。
小型渲染器 Demo:适合展示相机、网格、材质、Shader、光照、后处理、DrawCall 和 GPU 优化理解。
工具链 Demo:适合展示编辑器扩展、资源检查、配置表生成、自动打包、依赖分析、热更新版本对比。
加分说法:
“我的 Demo 不追求大而全,而是保证能跑、结构清楚、能解释取舍。面试时我会先演示效果,再讲架构图,然后打开关键代码,最后说明遇到的问题和优化数据。”
简单 Demo 入口代码:
c
using UnityEngine; // 引入 Unity 引擎命名空间,用来使用 MonoBehaviour 和 Debug
public class DemoLauncher : MonoBehaviour // 定义一个 Demo 启动器组件,挂到场景中的空物体上
{ // DemoLauncher 类开始
private void Start() // Unity 在组件第一次启用并进入逻辑帧前调用 Start
{ // Start 方法开始
RunMiniEngineDemo(); // 启动小型引擎 Demo,用来展示主循环和模块调度
RunRendererDemo(); // 启动渲染 Demo,用来展示材质、Shader 或后处理效果
RunToolchainDemo(); // 启动工具链 Demo,用来展示配置、资源或打包流程
} // Start 方法结束
private void RunMiniEngineDemo() // 定义小型引擎 Demo 的启动方法
{ // RunMiniEngineDemo 方法开始
Debug.Log("Mini Engine Demo: GameLoop + Component + Resource"); // 输出小型引擎 Demo 的核心展示点
} // RunMiniEngineDemo 方法结束
private void RunRendererDemo() // 定义渲染器 Demo 的启动方法
{ // RunRendererDemo 方法开始
Debug.Log("Renderer Demo: Camera + Mesh + Shader + Light"); // 输出渲染器 Demo 的核心展示点
} // RunRendererDemo 方法结束
private void RunToolchainDemo() // 定义工具链 Demo 的启动方法
{ // RunToolchainDemo 方法开始
Debug.Log("Toolchain Demo: Config Check + Asset Scan + Build"); // 输出工具链 Demo 的核心展示点
} // RunToolchainDemo 方法结束
} // DemoLauncher 类结束会问底层原理,不满足于 Unity API 使用
面试回答:
IMPORTANT
会问底层原理,说明面试官不满足于“我会用 Unity API”。会调用 Instantiate、Raycast、Resources.Load、GetComponent 只是第一层,更重要的是能说出它们背后的执行机制、性能成本和优化方式。
比如 Instantiate 背后涉及对象创建、组件初始化、内存分配,所以大量创建要用对象池。GetComponent 频繁调用有查找成本,所以常缓存引用。Resources.Load 同步加载可能阻塞主线程,所以大型资源要异步加载并管理引用。Raycast 不是随便射,它会走物理查询,还要考虑 Layer 过滤和调用频率。
加分说法:
“我理解 API 只是入口。面试时我会按四步回答:怎么用、背后做了什么、有什么性能代价、项目里怎么优化和验证。”
简单示例:缓存组件引用
c
using UnityEngine; // 引入 Unity 引擎命名空间,用来使用 MonoBehaviour 和 Transform
public class CachedMove : MonoBehaviour // 定义一个移动脚本,演示缓存组件引用
{ // CachedMove 类开始
private Transform cachedTransform; // 缓存 Transform 引用,避免每帧重复获取
private void Awake() // Awake 在脚本实例初始化时调用,适合做引用缓存
{ // Awake 方法开始
cachedTransform = transform; // 把 Unity 提供的 transform 缓存到字段中
} // Awake 方法结束
private void Update() // Update 每帧调用一次,适合处理普通移动逻辑
{ // Update 方法开始
float speed = 3f; // 定义移动速度,单位可以理解为每秒移动距离
Vector3 dir = Vector3.forward; // 定义移动方向,这里表示朝世界前方移动
cachedTransform.position += dir * speed * Time.deltaTime; // 使用缓存引用移动,并乘 deltaTime 保证帧率无关
} // Update 方法结束
} // CachedMove 类结束会深挖编译链接、对象模型、模板、STL
面试回答:
TIP
会深挖编译链接、对象模型、模板、STL,说明面试官在看你是不是真的懂 C++,而不是只会写语法。
编译链接主要问:代码怎么从 .cpp 变成可执行文件,头文件本质是什么,符号表是什么,为什么会有 unresolved external symbol,ODR 是什么。
对象模型主要问:对象内存布局、对齐填充、虚函数、虚表、虚指针、多继承、构造析构顺序、为什么基类析构函数要 virtual。
模板主要问:模板实例化、函数模板和类模板、特化、SFINAE、可变参数模板、为什么模板会导致编译慢。
STL 主要问:vector 连续内存和扩容、list 链表结构、map 红黑树、unordered_map 哈希表、迭代器失效、复杂度和分配器。
加分说法:
“C++ 面试不能只停留在会用。我会尽量从四层回答:语法怎么写,编译器生成什么,内存里怎么布局,运行时有什么成本。比如 vector 不只是会 push_back,还要知道扩容会搬迁元素并导致迭代器失效。”
小代码:vector 扩容导致旧指针失效
c
#include <iostream> // 引入标准输出库,用来打印地址信息
#include <vector> // 引入 vector 容器,用来演示扩容行为
int main() // 程序入口函数
{ // main 函数开始
std::vector<int> nums; // 创建一个 int 类型的动态数组
nums.reserve(2); // 先预留 2 个元素容量,方便观察后续扩容
nums.push_back(10); // 插入第 1 个元素
nums.push_back(20); // 插入第 2 个元素
int* oldAddress = &nums[0]; // 保存第 1 个元素当前地址
nums.push_back(30); // 插入第 3 个元素,可能触发扩容和搬迁
int* newAddress = &nums[0]; // 重新获取第 1 个元素的新地址
std::cout << oldAddress << std::endl; // 打印扩容前保存的旧地址
std::cout << newAddress << std::endl; // 打印扩容后当前的新地址
return 0; // 程序正常结束
} // main 函数结束会看代码设计和性能取舍
面试回答:
CAUTION
会看代码设计和性能取舍,说明你不是只会“把功能写出来”,而是能判断代码能不能长期维护、性能瓶颈在哪里、优化值不值得做。
代码设计主要看:职责是否清楚、模块边界是否干净、依赖是否合理、生命周期是否可控、扩展点是否自然。性能取舍主要看:是不是热点路径、是否真的有瓶颈、优化收益有多大、会不会增加复杂度和 Bug 风险。
加分说法:
“我不会一上来就为了性能把代码写复杂。普通业务优先清晰和可维护,热点路径才会针对性优化。优化前我会先用 Profiler 找瓶颈,优化后用数据对比,比如帧耗时、GC Alloc、内存峰值、DrawCall 或加载时间。”
小代码:热点路径避免每帧分配
c
using System.Collections.Generic; // 引入 List 容器,用来缓存搜索结果
using UnityEngine; // 引入 Unity 引擎命名空间,用来使用 MonoBehaviour 和 Transform
public class EnemyScanner : MonoBehaviour // 定义敌人扫描组件,用来演示性能取舍
{ // EnemyScanner 类开始
private readonly List<Transform> results = new List<Transform>(64); // 复用结果列表,避免每帧 new List 产生 GC
public void Scan(List<Transform> allEnemies, float maxDistance) // 扫描指定范围内的敌人
{ // Scan 方法开始
results.Clear(); // 清空旧结果,但保留内部数组容量,减少重复分配
Vector3 selfPosition = transform.position; // 缓存自己的位置,避免循环里重复访问属性
float maxDistanceSqr = maxDistance * maxDistance; // 使用平方距离,避免每次开方计算
for (int i = 0; i < allEnemies.Count; i++) // 使用 for 循环遍历敌人列表,方便控制索引和减少枚举器风险
{ // for 循环开始
Transform enemy = allEnemies[i]; // 取出当前敌人的 Transform 引用
Vector3 offset = enemy.position - selfPosition; // 计算敌人与自己的位置差
if (offset.sqrMagnitude <= maxDistanceSqr) // 用平方距离判断是否在范围内
{ // if 判断开始
results.Add(enemy); // 把符合条件的敌人加入复用列表
} // if 判断结束
} // for 循环结束
} // Scan 方法结束
} // EnemyScanner 类结束会问你如何调试复杂底层问题
面试回答:
CAUTION
如果面试官问我如何调试复杂底层问题,我会强调:不靠猜,先复现,再缩小范围,再分层定位,最后用工具和数据验证。
我的排查顺序一般是:先明确现象、机型、版本、场景和复现步骤;然后做最小复现,排除无关模块;接着按业务层、框架层、引擎层、系统层分层定位;最后用日志、调用栈、Profiler、Memory Profiler、Frame Debugger、RenderDoc 等工具拿证据。
加分说法:
“复杂问题最怕凭感觉改。我会先建立证据链,比如卡顿看帧耗时和调用栈,内存问题看引用链和峰值,渲染问题看 Pass、材质状态和 Overdraw,并发问题看锁、竞态和时序。修复后还要做回归验证和监控,避免问题再次出现。”
简单代码:给热点逻辑加 Profiler 标记
c
using Unity.Profiling; // 引入 Unity Profiler 标记工具,用来在 Profiler 中定位代码耗时
using UnityEngine; // 引入 Unity 引擎命名空间,用来使用 MonoBehaviour
public class HotPathProfiler : MonoBehaviour // 定义一个热点路径分析组件
{ // HotPathProfiler 类开始
private static readonly ProfilerMarker UpdateMarker = new ProfilerMarker("Demo.HotPath.Update"); // 创建一个 Profiler 标记,用来包住热点逻辑
private void Update() // Unity 每帧调用 Update,适合演示帧内热点定位
{ // Update 方法开始
using (UpdateMarker.Auto()) // 开始记录这个代码块的性能耗时,结束时自动关闭标记
{ // using 代码块开始
SimulateHotPath(); // 执行需要排查的热点逻辑
} // using 代码块结束
} // Update 方法结束
private void SimulateHotPath() // 定义一个模拟热点逻辑的方法
{ // SimulateHotPath 方法开始
for (int i = 0; i < 1000; i++) // 模拟一个循环任务,用来观察 Profiler 中的耗时
{ // for 循环开始
float value = Mathf.Sqrt(i); // 模拟计算操作,实际项目中可能是 AI、寻路或筛选逻辑
} // for 循环结束
} // SimulateHotPath 方法结束
} // HotPathProfiler 类结束