Skip to content

引擎开发岗

C++ 是核心

cpp-is-core

面试回答:

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 函数结束

图形学、数学、内存、并发很重要

graphics-math-memory-concurrency-important

面试回答:

图形学、数学、内存、并发确实很重要,尤其是游戏客户端和引擎方向。它们不是四个孤立知识点,而是支撑复杂游戏项目的底层能力。

图形学决定你能不能理解渲染管线、Shader、DrawCall、Overdraw、阴影和后处理。数学决定你能不能处理角色移动、相机、碰撞、寻路、插值、旋转和空间判断。内存决定项目是否稳定,能不能减少 GC、泄漏、峰值内存和频繁分配。并发决定你能不能处理异步加载、AI 分帧、任务队列、网络消息和后台计算。

加分说法:

TIP

“我觉得图形学、数学、内存、并发是客户端工程能力的四个底座。功能能写出来只是第一步,真正项目里还要考虑表现效果、空间计算、性能稳定和任务调度。比如我做角色移动会用向量和四元数,做特效会关注 Overdraw,做资源加载会控制内存生命周期,做大量 AI 或网络消息会考虑分帧和任务队列。”

要理解 Game Loop、组件系统、资源系统

game-loop-component-resource-system

面试回答:

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

render-pipeline-and-shader-explain

面试回答:

要能讲渲染管线和 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 函数结束

要理解编辑器、序列化、反射或脚本绑定

editor-serialization-reflection-script-binding

面试回答:

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

small-engine-renderer-toolchain-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 使用

unity-api-bottom-layer-principles

面试回答:

IMPORTANT

会问底层原理,说明面试官不满足于“我会用 Unity API”。会调用 InstantiateRaycastResources.LoadGetComponent 只是第一层,更重要的是能说出它们背后的执行机制、性能成本和优化方式。

比如 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 函数结束

会看代码设计和性能取舍

code-design-performance-tradeoff

面试回答:

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 类结束

会问你如何调试复杂底层问题

debug-complex-low-level-problems

面试回答:

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 类结束

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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