Skip to content

C++ / 引擎拔高

虚函数调用为什么可能影响缓存友好性?

cpp-virtual-call-cache-friendliness

标准答案

虚函数调用可能影响缓存友好性,核心不是“虚函数一定慢”,而是它常常带来三类问题:

第一,调用路径多一次间接跳转。 普通函数调用可以直接跳到固定地址;虚函数要先从对象里读 vptr,再从 vtable 里读真实函数地址,最后做一次间接调用。

第二,编译器不容易内联。 如果编译期不知道真实类型,虚函数通常不能直接内联,后续的循环展开、向量化、常量传播也会受影响。

第三,也是最重要的:多态对象常常分散在堆上。 例如 vector<Base*> objects 里存的是指针,指针数组本身连续,但真正的 GoblinOrcBoss 对象可能散落在堆的不同位置。CPU 遍历时会不断跳到不同内存区域,数据缓存命中率会下降。

底层原理

虚调用大概是:

c
obj->Update();

底层近似理解为:

c
vptr = obj->vptr;
func = vptr[UpdateIndex];
func(obj);

这里有几个性能影响:

  • 读对象本身,可能 miss;
  • vptr,多一次内存依赖;
  • vtable,再多一次地址访问;
  • 间接跳转,分支预测更难;
  • 不同类型跳到不同函数,指令缓存也可能不稳定。

但面试里要说清楚:虚函数本身的开销通常不是最大问题,真正影响缓存友好性的常常是面向对象多态带来的内存布局离散。

代码例子

c
#include <vector> // 使用 std::vector 容器

class Enemy // 定义敌人基类
{ // 类开始
public: // 公开接口开始
    virtual void Update() = 0; // 定义虚函数,真实调用目标由运行时类型决定
    virtual ~Enemy() = default; // 定义虚析构,保证通过基类指针删除安全
}; // 类结束

void UpdateAll(std::vector<Enemy*>& enemies) // 遍历基类指针数组
{ // 函数开始
    for (Enemy* enemy : enemies) // 每次取出一个基类指针
    { // 循环开始
        enemy->Update(); // 虚调用,需要通过 vptr 和 vtable 找真实函数
    } // 循环结束
} // 函数结束

这段代码的问题不是语法,而是:

  • enemies 里的指针连续;
  • 但每个 Enemy 对象可能不连续;
  • 每个对象的真实类型可能不同;
  • 每次 Update 可能跳到不同函数;
  • CPU 很难稳定预取数据和预测分支。

更缓存友好的思路

热点逻辑可以按类型批处理:

c
#include <vector> // 使用 std::vector 容器

struct Goblin // 定义 Goblin 的连续数据结构
{ // 结构体开始
    float x; // 保存 x 坐标
    float y; // 保存 y 坐标
    float hp; // 保存生命值
}; // 结构体结束

void UpdateGoblins(std::vector<Goblin>& goblins) // 批量更新同类型对象
{ // 函数开始
    for (Goblin& goblin : goblins) // 连续遍历 Goblin 数组
    { // 循环开始
        goblin.x += 1.0f; // 直接访问连续数据,缓存更友好
    } // 循环结束
} // 函数结束

这种写法的优势是:

  • 数据连续;
  • 函数目标固定;
  • 容易被编译器内联;
  • 更容易向量化;
  • CPU 预取更有效;
  • 分支预测压力更小。

游戏引擎里怎么取舍

虚函数适合:

  • 非热点逻辑;
  • 复杂对象接口;
  • 编辑器扩展;
  • 资源系统抽象;
  • 渲染后端抽象;
  • 平台层抽象。

不太适合:

  • 每帧几十万次实体更新;
  • 粒子系统;
  • 大量 AI tick;
  • ECS 组件遍历;
  • 物理或动画批处理;
  • 大规模寻路节点遍历。

热点路径里更常见的做法是:

  • 按类型分组;
  • 数据连续存储;
  • SoA 或 ECS;
  • 批量更新;
  • 少用虚调用;
  • final 帮助去虚化;
  • 模板或 CRTP 做静态多态;
  • 把虚接口放在外层调度,不放在内层循环。

面试加分句

NOTE

“虚函数影响缓存友好性,不只是因为多一次 vtable 查找,而是它常和 vector<Base*>、堆分配、多态对象混合遍历一起出现。这样会导致数据缓存 miss、指令缓存不稳定、间接分支预测失败,并且限制编译器内联优化。引擎热点路径通常会把对象按类型或组件连续存储,批量处理,把虚调用移出内层循环。”

ECS 为什么常说更 cache friendly?

unity-ecs-cache-friendly

标准答案

ECS 常说更 cache friendly,核心原因是:它把数据按组件连续存储,让系统批量遍历连续内存,而不是像传统 OOP 那样通过一堆对象指针到处跳。

CPU Cache 喜欢连续访问。 如果一个系统只需要 PositionVelocity,ECS 就让它集中遍历这些组件数据,少读无关字段,少追指针,Cache 命中率更高。

底层原理

CPU 不是每次只从内存里拿一个变量,而是按 Cache Line 一段一段加载,常见可以粗略理解成 64 字节一段。

如果数据连续:

c
Position[0], Position[1], Position[2], Position[3] ...

CPU 读 Position[0] 时,附近几个 Position 很可能一起进 Cache。 后面循环继续读 Position[1]、Position[2],就更容易命中 Cache。

如果是传统 OOP:

c
vector<Entity*> entities;

指针数组可能是连续的,但真实对象可能散落在堆上。 遍历时每个对象都要跳到不同内存区域,容易产生 Cache Miss。

OOP 的问题

传统写法可能是:

c
using System.Collections.Generic; // 引入 List 容器

public class Enemy // 定义一个敌人对象
{ // 类开始
    public float x; // 保存 x 坐标
    public float y; // 保存 y 坐标
    public float vx; // 保存 x 方向速度
    public float vy; // 保存 y 方向速度
    public int hp; // 保存生命值,但移动系统不一定需要它
    public string name; // 保存名字,移动系统更不需要它
} // 类结束

public static class EnemyUpdater // 定义敌人更新器
{ // 类开始
    public static void MoveAll(List<Enemy> enemies, float dt) // 批量移动所有敌人
    { // 方法开始
        foreach (Enemy enemy in enemies) // 遍历每个敌人对象
        { // 循环开始
            enemy.x += enemy.vx * dt; // 更新 x 坐标
            enemy.y += enemy.vy * dt; // 更新 y 坐标
        } // 循环结束
    } // 方法结束
} // 类结束

问题是移动逻辑只需要 x、y、vx、vy,但对象里可能还混着 hp、name、AI、装备、动画状态 等数据。 CPU 读进 Cache 的一部分数据对当前系统是没用的。

ECS 的做法

ECS 会把数据拆成组件:

c
Position 组件数组
Velocity 组件数组
Health 组件数组
AIState 组件数组

移动系统只查询:

c
Position + Velocity

然后连续遍历:

c
using Unity.Entities; // 引入 Unity ECS 的实体系统命名空间
using Unity.Mathematics; // 引入 float3 等数学类型

public struct Position : IComponentData // 定义位置组件,只保存位置数据
{ // 结构体开始
    public float3 Value; // 保存实体的世界位置
} // 结构体结束

public struct Velocity : IComponentData // 定义速度组件,只保存速度数据
{ // 结构体开始
    public float3 Value; // 保存实体的移动速度
} // 结构体结束

public partial struct MoveSystem : ISystem // 定义移动系统,负责批量处理移动逻辑
{ // 系统开始
    public void OnUpdate(ref SystemState state) // 每帧执行系统更新
    { // 方法开始
        float dt = SystemAPI.Time.DeltaTime; // 获取当前帧间隔时间
        foreach (var item in SystemAPI.Query<RefRW<Position>, RefRO<Velocity>>()) // 查询同时拥有 Position 和 Velocity 的实体
        { // 循环开始
            item.Item1.ValueRW.Value += item.Item2.ValueRO.Value * dt; // 用速度更新位置
        } // 循环结束
    } // 方法结束
} // 系统结束

这类循环的特点是:

  • 数据更连续;
  • 每次循环读的字段更少;
  • 函数路径更稳定;
  • 更容易被 Burst 优化;
  • 更容易拆成 Job 并行;
  • 更容易做 SIMD 向量化。

Unity ECS 里的具体机制

Unity Entities 常用 Archetype Chunk 存储。 同一类组件组合的实体会放在相同 Archetype 的 Chunk 里。

例如:

c
Archetype: Position + Velocity + Health
Chunk:
Position: P0 P1 P2 P3 ...
Velocity: V0 V1 V2 V3 ...
Health:   H0 H1 H2 H3 ...

当 MoveSystem 只查询 Position + Velocity 时,它可以按 Chunk 批量扫数据。 这比一个个 GameObject.GetComponent<Transform>() 或一个个对象虚函数 Update() 更符合 CPU 的工作方式。

为什么更适合大量对象

ECS 在这些场景优势明显:

  • 大量子弹;
  • 大量怪物;
  • 大量粒子;
  • 大量状态更新;
  • 大量寻路节点;
  • 大量 Buff Tick;
  • 大量简单 AI;
  • 大量可见性或距离检测。

因为这些逻辑通常是“同一种操作应用到很多数据”。

但不是万能

ECS 也有成本:

  • 写法复杂;
  • 调试门槛高;
  • 结构变化有成本,比如频繁 Add/Remove Component;
  • 随机访问、复杂对象关系不一定适合;
  • 小规模 UI 或业务逻辑用 ECS 可能反而过度设计;
  • managed component 会削弱数据连续和 Burst 优势。

面试加分句

TIP

“ECS cache friendly 的根本原因是数据布局改变了。传统 OOP 是对象聚合数据,系统遍历时容易追指针、读无关字段;ECS 是按组件类型和 Archetype Chunk 存储,System 只遍历自己需要的连续数据,所以更容易命中 D-Cache,也更利于 Burst、SIMD 和 Job 并行。”

SoA 和 AoS 内存布局区别是什么?

memory-layout-soa-vs-aos

标准答案

AoS 和 SoA 是两种数据内存布局。

AoS:Array of Structures,结构体数组。 每个对象的数据放在一起:

c
Unit0: position, velocity, hp
Unit1: position, velocity, hp
Unit2: position, velocity, hp

SoA:Structure of Arrays,数组结构。 同一类字段放在一起:

c
position[]: p0, p1, p2
velocity[]: v0, v1, v2
hp[]:       h0, h1, h2

底层区别

CPU Cache 喜欢连续访问。如果移动系统只需要 positionvelocity,AoS 可能会把 hpstatename 这类暂时不用的数据也加载进 Cache。

SoA 则可以只连续扫描:

c
position[]
velocity[]

不碰 hp[]。 所以在大量对象批量更新时,SoA 更容易提高 Cache 命中率,也更适合 SIMD、Burst、Job System 这类批处理优化。

代码例子

AoS 写法更直观:

c
public struct Unit // 定义一个单位结构体,所有字段打包在一起
{ // 结构体开始
    public float x; // 保存 x 坐标
    public float y; // 保存 y 坐标
    public float vx; // 保存 x 方向速度
    public float vy; // 保存 y 方向速度
    public int hp; // 保存生命值
} // 结构体结束

public static void MoveAoS(Unit[] units, float dt) // 使用 AoS 布局批量移动单位
{ // 方法开始
    for (int i = 0; i < units.Length; i++) // 遍历每一个单位
    { // 循环开始
        units[i].x += units[i].vx * dt; // 更新当前单位的 x 坐标
        units[i].y += units[i].vy * dt; // 更新当前单位的 y 坐标
    } // 循环结束
} // 方法结束

SoA 写法更适合批量处理:

c
public sealed class UnitSoA // 定义一个 SoA 数据容器
{ // 类开始
    public float[] x; // 保存所有单位的 x 坐标
    public float[] y; // 保存所有单位的 y 坐标
    public float[] vx; // 保存所有单位的 x 方向速度
    public float[] vy; // 保存所有单位的 y 方向速度
    public int[] hp; // 保存所有单位的生命值
} // 类结束

public static void MoveSoA(UnitSoA units, int count, float dt) // 使用 SoA 布局批量移动单位
{ // 方法开始
    for (int i = 0; i < count; i++) // 按下标连续遍历所有单位
    { // 循环开始
        units.x[i] += units.vx[i] * dt; // 连续读取 vx 并更新 x
        units.y[i] += units.vy[i] * dt; // 连续读取 vy 并更新 y
    } // 循环结束
} // 方法结束

怎么选择

AoS 适合:

  • 代码更直观;
  • 单个对象整体读写;
  • 业务逻辑对象;
  • 数据量不大;
  • 对性能不敏感;
  • 对象字段经常一起使用。

SoA 适合:

  • 大量对象批量更新;
  • 只访问少数字段;
  • 粒子、子弹、怪物移动;
  • ECS 组件系统;
  • 渲染实例数据;
  • 物理或动画批处理;
  • 需要 SIMD / Burst / Job 优化。

面试加分句

IMPORTANT

“AoS 是以对象为中心,代码自然但批量处理时可能加载无关字段;SoA 是以字段为中心,数据连续,更符合 CPU Cache 和 SIMD 的访问方式。游戏引擎热点系统,比如 ECS、粒子、子弹、Transform 批处理,通常更偏 SoA 或接近 SoA 的布局。”

引擎中为什么常用句柄 Handle 而不是裸指针?

engine-handle-vs-raw-pointer

标准答案

引擎里常用 Handle 而不是裸指针,核心原因是:Handle 不直接暴露对象地址,而是暴露一个稳定的“身份 ID”,通过 Handle 表去解析真实对象。这样可以更好地控制生命周期、失效检测、对象移动、池复用和调试。

裸指针的问题是:

  • 对象释放后,指针可能变成悬空指针;
  • 内存槽位复用后,旧指针可能误指向新对象;
  • 对象移动或内存整理后,外部指针全部失效;
  • 很难判断一个指针是否仍然有效;
  • 跨系统传递裸指针容易让生命周期失控。

Handle 通常会设计成:

c
Handle = index + generation

index 用来查 Handle 表里的槽位。 generation 用来判断这个槽位是不是已经被销毁后复用了。

底层原理

裸指针:

c
Object* -> 真实对象地址

Handle:

c
Handle(index, generation)

HandleTable[index]

检查 generation 是否一致

一致:返回对象
不一致:说明已经失效

如果对象 A 被销毁,槽位后来复用给对象 B,generation 会增加。 旧 Handle 里的 generation 和表里的 generation 对不上,就能判断这个 Handle 已经过期。

简化代码

c
#include <vector> // 使用 std::vector 保存对象槽位

struct Handle // 定义一个句柄结构
{ // 结构体开始
    int index; // 保存槽位下标
    int generation; // 保存代数,用来判断槽位是否被复用
}; // 结构体结束

struct Slot // 定义对象槽位
{ // 结构体开始
    void* ptr; // 保存真实对象指针
    int generation; // 保存当前槽位的代数
    bool alive; // 标记这个槽位是否有效
}; // 结构体结束

void* Resolve(const std::vector<Slot>& slots, Handle handle) // 根据 Handle 解析真实对象
{ // 函数开始
    if (handle.index < 0) return nullptr; // 下标小于 0,说明 Handle 非法
    if (handle.index >= static_cast<int>(slots.size())) return nullptr; // 下标越界,说明 Handle 非法
    const Slot& slot = slots[handle.index]; // 根据 index 找到槽位
    if (!slot.alive) return nullptr; // 槽位未存活,说明对象已经释放
    if (slot.generation != handle.generation) return nullptr; // generation 不一致,说明旧 Handle 已失效
    return slot.ptr; // 校验通过,返回真实对象指针
} // 函数结束

为什么引擎喜欢这种方式

1. 防悬空指针

旧对象被销毁后,Handle 可以通过 generation 判断无效。 裸指针则可能继续指向一块已经释放或被复用的内存。

2. 方便对象池复用

对象池会频繁复用槽位。 Handle 能区分“同一个槽位上的旧对象”和“后来创建的新对象”。

3. 支持对象移动

资源管理器或内存池可能会搬移对象。 裸指针一旦地址变了就失效。 Handle 只需要更新表里的真实地址,外部 Handle 不变。

4. 生命周期集中管理

所有对象访问都经过 Handle 表,引擎可以统一做:

  • 是否存活;
  • 是否加载完成;
  • 引用计数;
  • 所属类型;
  • 所属资源包;
  • 调试名字;
  • 创建来源;
  • 泄漏检查。

5. 更适合跨系统传递

渲染系统、资源系统、脚本系统、编辑器、序列化系统之间传对象时,Handle 比裸指针安全。 尤其是资源异步加载、热更新、场景切换、跨线程任务里,裸指针风险更高。

代价

Handle 不是没有成本:

  • 多一层表查询;
  • 需要维护 Handle 表;
  • generation 可能溢出,需要设计位宽;
  • 多线程访问表要考虑同步;
  • 热点路径上过度 Resolve 也会有开销。

所以引擎里常见做法是:

  • 外部长期保存 Handle;
  • 内部短时间使用指针;
  • 热点循环里先 Resolve 一次,再批量处理;
  • 不把裸指针暴露给不该管理生命周期的系统。

面试加分句

可以这样总结:

WARNING

“Handle 本质是用一次间接访问换生命周期安全。裸指针暴露的是地址,Handle 暴露的是稳定身份。引擎里对象池、资源系统、实体系统、GPU 资源管理都需要销毁、复用、移动、异步加载和调试追踪,所以更适合用 index + generation 的 Handle 表,而不是到处传裸指针。”

资源系统如何避免悬空引用?

resource-system-avoid-dangling-reference

标准答案

资源系统避免悬空引用的核心原则是:外部不要长期持有裸指针或裸资源对象,而是持有 ResourceHandle;每次使用资源时都通过资源管理器 Resolve,并校验资源是否仍然有效。

悬空引用常见于:

  • 资源已经卸载,但外部还拿着旧引用;
  • 对象池或资源池复用了槽位,旧引用误指向新对象;
  • 异步加载回调回来时,原请求已经取消或场景已经切走;
  • Addressables / AssetBundle 已释放,但实例或材质还在用;
  • C++ 引擎里裸指针指向已释放内存。

底层做法

资源系统通常会用:

c
ResourceHandle = index + generation

index 用来查资源表槽位。 generation 用来判断槽位是否已经被销毁后复用。

访问流程是:

c
外部拿 Handle

ResourceManager.Resolve(handle)

检查 index 是否有效

检查 generation 是否一致

检查资源状态是否 Loaded

返回真实资源对象

如果资源已经卸载,或者槽位被复用,generation 不一致,旧 Handle 就会被判定无效,而不是访问到错误资源。

简化代码

c
using UnityEngine; // 引入 UnityEngine,用来表示 Unity 资源对象

public readonly struct ResourceHandle // 定义资源句柄,外部只保存它
{ // 结构体开始
    public readonly int Index; // 保存资源表下标
    public readonly int Generation; // 保存资源槽位代数
    public ResourceHandle(int index, int generation) // 创建资源句柄
    { // 构造函数开始
        Index = index; // 记录资源表下标
        Generation = generation; // 记录当前槽位代数
    } // 构造函数结束
} // 结构体结束

public sealed class ResourceSlot // 定义资源表里的槽位
{ // 类开始
    public Object Asset; // 保存真实 Unity 资源对象
    public int Generation; // 保存槽位当前代数
    public int RefCount; // 保存资源引用计数
    public bool Loaded; // 标记资源是否加载完成
} // 类结束

public sealed class ResourceManager // 定义资源管理器
{ // 类开始
    private readonly ResourceSlot[] slots; // 保存所有资源槽位

    public ResourceManager(ResourceSlot[] slots) // 构造资源管理器
    { // 构造函数开始
        this.slots = slots; // 保存外部传入的资源槽位表
    } // 构造函数结束

    public Object Resolve(ResourceHandle handle) // 根据 Handle 解析真实资源
    { // 方法开始
        if (handle.Index < 0) return null; // 下标小于 0,说明 Handle 非法
        if (handle.Index >= slots.Length) return null; // 下标越界,说明 Handle 非法
        ResourceSlot slot = slots[handle.Index]; // 根据下标找到资源槽位
        if (slot.Generation != handle.Generation) return null; // 代数不一致,说明旧 Handle 已失效
        if (!slot.Loaded) return null; // 资源没有加载完成,不能返回给外部使用
        if (slot.Asset == null) return null; // 真实资源为空,说明资源不可用
        return slot.Asset; // 校验通过,返回真实资源对象
    } // 方法结束
} // 类结束

工程上还要做什么

1. 引用计数

资源被使用时 Acquire,不用时 Release。 只有引用计数归零,才允许进入卸载流程。

2. 状态机

资源状态要明确:

c
Unloaded
Loading
Loaded
Unloading
Invalid

不能一边加载、一边卸载、一边被使用。

3. 异步请求版本号

异步加载很容易出问题。 例如 UI 打开时请求加载图标,但加载完成前 UI 关闭了。 回调回来时必须检查:

  • 请求是否还有效;
  • owner 是否还活着;
  • handle generation 是否一致;
  • 场景是否已经切换;
  • 资源是否仍然需要。

4. 不让业务直接卸载资源

业务系统只表达“我要用”和“我不用了”。 真正加载、卸载、复用、销毁,由 ResourceManager 统一处理。

5. 弱引用或失效回调

对非强依赖资源,可以用弱 Handle。 资源卸载时通知使用者清空引用,或者下次 Resolve 返回 null。

Unity 里怎么落地

Addressables 里要注意:

  • LoadAssetAsync 对应 Release
  • InstantiateAsync 对应 ReleaseInstance
  • LoadSceneAsync 对应 UnloadSceneAsync
  • 不要只 Destroy 实例就以为资源计数归还了
  • 不要丢掉 AsyncOperationHandle
  • 场景切换时统一清理本场景 owner 持有的资源 handle

AssetBundle 里要注意:

  • Bundle 和 Asset 生命周期不同;
  • 卸载 Bundle 不一定等于实例资源马上释放;
  • 依赖 Bundle 还有引用时不能卸;
  • Unload(false)Unload(true) 语义不同;
  • 资源引用链要能追踪。

面试加分句

NOTE

“我会把资源访问设计成 Handle 化,而不是让业务长期保存裸资源引用。Handle 里带 index 和 generation,每次 Resolve 都检查槽位是否还有效;资源本身用引用计数和状态机管理;异步加载用请求版本号防止过期回调;场景或模块销毁时统一 Release owner 持有的资源。这样可以避免旧引用访问已卸载资源,也能发现重复释放和资源泄漏。”

Job System 如何处理任务依赖?

unity-job-system-dependencies

标准答案

Unity Job System 用 JobHandle 处理任务依赖。 每个 Job 被 Schedule 后会返回一个 JobHandle,它表示“这个任务什么时候完成”。如果后续 Job 依赖前一个 Job,就把前一个 JobHandle 传给后续 Job 的 Schedule

简单说:

c
Job A Schedule -> 返回 handleA
Job B Schedule(handleA) -> B 会等 A 完成后再执行

如果一个 Job 依赖多个 Job,可以用:

c
JobHandle.CombineDependencies(handleA, handleB)

合并成一个依赖,再传给后续任务。

底层理解

Job System 内部会把任务组织成一个依赖图,也就是 DAG,有向无环图。

没有依赖关系的任务可以并行执行。 有依赖关系的任务必须按顺序执行。

例如:

Job A:计算位置
Job B:计算速度
Job C:根据位置和速度做最终更新

如果 C 要读 A 和 B 的结果,那么 C 不能和 A、B 同时执行。 必须等 A、B 都完成后,C 才能执行。

代码例子

c
using Unity.Burst; // 引入 Burst,用于提升 Job 执行性能
using Unity.Collections; // 引入 NativeArray 等原生容器
using Unity.Jobs; // 引入 Job System 的接口和 JobHandle

[BurstCompile] // 让 Burst 编译这个 Job,提高性能
public struct AddJob : IJob // 定义一个加法 Job
{ // 结构体开始
    public NativeArray<int> Values; // 保存需要处理的数据数组

    public void Execute() // Job 在线程中执行的入口
    { // 方法开始
        Values[0] += 1; // 修改数组第一个元素
    } // 方法结束
} // 结构体结束

[BurstCompile] // 让 Burst 编译这个 Job,提高性能
public struct MultiplyJob : IJob // 定义一个乘法 Job
{ // 结构体开始
    public NativeArray<int> Values; // 保存需要处理的数据数组

    public void Execute() // Job 在线程中执行的入口
    { // 方法开始
        Values[0] *= 10; // 修改数组第一个元素
    } // 方法结束
} // 结构体结束

public static class JobDependencyExample // 定义一个 Job 依赖示例类
{ // 类开始
    public static void Run() // 定义运行示例的方法
    { // 方法开始
        NativeArray<int> values = new NativeArray<int>(1, Allocator.TempJob); // 创建 NativeArray,供 Job 使用
        values[0] = 1; // 初始化第一个元素

        AddJob addJob = new AddJob { Values = values }; // 创建加法 Job,并传入数据
        JobHandle addHandle = addJob.Schedule(); // 调度加法 Job,返回它的 JobHandle

        MultiplyJob multiplyJob = new MultiplyJob { Values = values }; // 创建乘法 Job,并传入同一份数据
        JobHandle multiplyHandle = multiplyJob.Schedule(addHandle); // 调度乘法 Job,并声明它依赖加法 Job

        multiplyHandle.Complete(); // 主线程等待乘法 Job 完成,确保结果可安全读取
        int result = values[0]; // Job 完成后,主线程读取最终结果

        values.Dispose(); // 释放 NativeArray,避免原生内存泄漏
    } // 方法结束
} // 类结束

这段代码里,如果不传 addHandle,两个 Job 可能同时访问 values[0],会产生数据竞争。 传入依赖后,Unity 就知道必须先执行 AddJob,再执行 MultiplyJob

多个依赖怎么处理

如果两个任务可以并行:

Job A
Job B

但 Job C 必须等 A 和 B 都完成:

c
JobHandle combined = JobHandle.CombineDependencies(handleA, handleB);
JobHandle handleC = jobC.Schedule(combined);

意思是:

A 和 B 可以并行
C 等 A 和 B 都结束

Complete 的作用

Complete() 是主线程同步等待。 它会阻塞主线程,直到这个 Job 和它依赖的 Job 都完成。

所以:

  • 主线程要读 Job 写过的数据前,必须 Complete()
  • 要释放 Job 使用的 NativeArray 前,必须确保 Job 完成;
  • 但不要太早 Complete(),否则会把并行变成主线程等待,削弱 Job System 的意义。

常见坑

忘记传依赖:多个 Job 读写同一个 NativeArray,可能数据竞争。

过早 Complete:刚 Schedule 就 Complete,相当于主线程等它做完,并行收益很低。

Job 没完成就 Dispose:NativeArray 还被 Job 使用时释放,会报错或产生未定义问题。

依赖链过长:所有 Job 串成一条线,无法并行。

把 Unity API 放进 Job:普通 Job 里不能随便访问 GameObjectTransformMonoBehaviour

面试加分句

TIP

“Job System 的依赖靠 JobHandle 表达。Schedule 返回 Handle,后续 Schedule 传入依赖 Handle,多个依赖用 CombineDependencies 合并。它本质是在构建任务 DAG,让无冲突任务并行,让有读写冲突的任务按顺序执行。主线程读结果或释放 NativeContainer 前必须 Complete,但 Complete 过早会损失并行收益。”

双缓冲和三缓冲分别解决什么问题?

rendering-double-vs-triple-buffering

标准答案

双缓冲主要解决两个问题:

第一,避免显示器和 GPU 同时读写同一块帧缓冲。 显示器正在扫描当前画面时,GPU 不应该直接往这块画面里写,否则屏幕上半部分可能是旧帧,下半部分是新帧,这就是画面撕裂。

第二,让渲染和显示分离。 Front Buffer 给显示器读,Back Buffer 给 GPU 画。GPU 画完后,在合适时机交换或 Present。

三缓冲主要解决的是:

在开启 VSync 时,双缓冲可能因为等待刷新点而阻塞 GPU 或 CPU。 三缓冲多给一个 Back Buffer,让一帧在等待显示时,GPU 还能继续渲染下一帧,提高吞吐和帧率稳定性。

底层理解

单缓冲:

显示器读 Buffer
GPU 同时写 Buffer

风险是撕裂。

双缓冲:

c
Front Buffer:显示器正在读
Back Buffer:GPU 正在写

GPU 画完 Back Buffer 后,等到垂直同步点再 Present。 如果刚好错过 VSync,可能要等下一次刷新,所以帧率可能从 60 掉到 30 这种不平滑状态。

三缓冲:

c
Front Buffer:显示器正在读
Back Buffer A:等待 Present
Back Buffer B:GPU 继续渲染

这样 GPU 不容易因为“没有可写缓冲”而停住。

双缓冲解决什么

双缓冲解决的是:

  • 读写同一帧缓冲的问题;
  • 画面撕裂问题;
  • 当前帧显示和下一帧渲染互相干扰的问题。

但双缓冲在 VSync 下有一个问题: 如果 GPU 画完一帧时刚错过显示器刷新点,它可能只能等下一轮刷新。这会造成等待。

三缓冲解决什么

三缓冲解决的是:

  • VSync 等待导致的阻塞;
  • GPU 空等;
  • 帧率抖动;
  • 双缓冲错过刷新点后吞吐下降的问题。

但三缓冲的代价是:

  • 多一个缓冲,占更多显存;
  • 可能产生更多排队帧;
  • 输入到显示的延迟可能增加;
  • 如果 CPU 提交太快,还可能出现帧排队过多。

简单伪代码

c
public sealed class SwapChainConcept // 定义一个交换链概念类
{ // 类开始
    private int frontIndex = 0; // 当前显示器正在读取的缓冲下标
    private int backIndex = 1; // 当前 GPU 正在渲染的后台缓冲下标

    public void RenderFrame() // 渲染一帧
    { // 方法开始
        RenderToBuffer(backIndex); // GPU 把新画面渲染到后台缓冲
        Present(backIndex); // 把后台缓冲提交给显示系统等待显示
        SwapBuffers(); // 交换前台和后台缓冲
    } // 方法结束

    private void RenderToBuffer(int index) // 渲染到指定缓冲
    { // 方法开始
    } // 方法结束

    private void Present(int index) // 提交指定缓冲给显示系统
    { // 方法开始
    } // 方法结束

    private void SwapBuffers() // 交换前后台缓冲
    { // 方法开始
        int temp = frontIndex; // 临时保存当前前台缓冲下标
        frontIndex = backIndex; // 把后台缓冲变成前台缓冲
        backIndex = temp; // 把旧前台缓冲变成新的后台缓冲
    } // 方法结束
} // 类结束

真实图形 API 会更复杂,会涉及 SwapChain、Present Mode、VSync、GPU Queue、Fence、Semaphore 等概念,但核心思想就是:不要直接往正在显示的缓冲里画。

Unity/游戏里的理解

在 Unity 里你一般不直接管理 SwapChain,但这些概念会影响:

  • VSync 开关;
  • Application.targetFrameRate
  • 输入延迟;
  • 帧率稳定性;
  • 移动端功耗;
  • GPU 是否被 Present 阻塞;
  • Profiler 里等待 Present 或 Gfx.WaitForPresent 的表现。

如果看到 GPU 不忙但帧时间卡在刷新率附近,可能不是渲染计算本身慢,而是等待显示同步。

面试加分句

TIP

“双缓冲解决的是显示和渲染争用同一帧缓冲导致的撕裂问题;三缓冲是在双缓冲基础上多一个后台缓冲,主要缓解 VSync 下 Present 等待造成的阻塞,让 GPU 有更多机会继续工作。它提升吞吐和平滑度,但代价是显存增加和潜在输入延迟增加。”

渲染线程和逻辑线程如何同步?

render-thread-logic-thread-sync

标准答案

渲染线程和逻辑线程同步的核心原则是:逻辑线程不要让渲染线程直接读写实时场景对象,而是生成一份渲染命令或渲染快照,交给渲染线程消费。

典型流程是:

逻辑线程更新世界

生成渲染命令 / 渲染快照

渲染线程消费命令

提交给图形 API

GPU 异步执行

Fence 表示 GPU 执行进度

底层原理

逻辑线程负责:

  • 游戏对象状态;
  • Transform;
  • 动画状态;
  • AI;
  • 物理结果;
  • 可见性判断;
  • 本帧要画哪些对象。

渲染线程负责:

  • 消费渲染命令;
  • 设置材质、Shader、贴图;
  • 提交 DrawCall;
  • 管理图形 API 调用;
  • 和 GPU 队列交互。

GPU 不是立刻执行 CPU 提交的命令,而是异步排队执行。 所以资源释放、Buffer 复用、贴图销毁,都不能只看 CPU 逻辑是否不用了,还要看渲染线程和 GPU 是否已经用完。

同步方式

1. 命令队列

逻辑线程不直接让渲染线程读 GameObjectTransform,而是把本帧需要的渲染数据打包成命令:

c
Draw MeshA with MaterialB at MatrixC

这样渲染线程读的是稳定快照,不读正在变化的场景对象。

2. 双缓冲或多缓冲

常见做法是:

逻辑线程写 Buffer A
渲染线程读 Buffer B
下一帧交换

避免一个线程写数据时,另一个线程同时读同一块数据。

3. Fence

Fence 用来判断 GPU 是否执行到某个点。 比如资源要销毁前,要确认 GPU 不再使用它。

否则会出现:

c
CPU 释放 Texture
GPU 还在执行上一帧 DrawCall
DrawCall 仍然引用这张 Texture

这就是典型的过早释放资源问题。

简化代码

c
using System.Collections.Generic; // 引入 List,用来保存渲染命令
using UnityEngine; // 引入 Unity 基础类型,例如 Matrix4x4

public struct RenderCommand // 定义一个渲染命令
{ // 结构体开始
    public int MeshId; // 保存网格资源 ID
    public int MaterialId; // 保存材质资源 ID
    public Matrix4x4 WorldMatrix; // 保存本帧渲染使用的世界矩阵快照
} // 结构体结束

public sealed class RenderCommandBuffer // 定义一个简化的双缓冲命令缓冲
{ // 类开始
    private readonly List<RenderCommand>[] buffers = { new List<RenderCommand>(), new List<RenderCommand>() }; // 创建两个命令列表
    private int writeIndex = 0; // 逻辑线程当前写入的缓冲下标
    private int readIndex = 1; // 渲染线程当前读取的缓冲下标
    private readonly object locker = new object(); // 创建锁对象,用于交换缓冲时同步

    public List<RenderCommand> BeginWrite() // 逻辑线程开始写入本帧命令
    { // 方法开始
        List<RenderCommand> list = buffers[writeIndex]; // 取出当前写缓冲
        list.Clear(); // 清空旧命令,准备写入新一帧
        return list; // 返回写缓冲给逻辑线程填充
    } // 方法结束

    public void EndWriteAndSwap() // 逻辑线程写完后交换读写缓冲
    { // 方法开始
        lock (locker) // 加锁,避免读写线程同时交换
        { // 加锁代码块开始
            int temp = readIndex; // 暂存当前读缓冲下标
            readIndex = writeIndex; // 把刚写完的缓冲交给渲染线程读取
            writeIndex = temp; // 把旧读缓冲变成下一帧写缓冲
        } // 加锁代码块结束
    } // 方法结束

    public List<RenderCommand> GetReadBuffer() // 渲染线程获取当前可读命令缓冲
    { // 方法开始
        lock (locker) // 加锁,保证读取到稳定的 readIndex
        { // 加锁代码块开始
            return buffers[readIndex]; // 返回渲染线程要消费的命令列表
        } // 加锁代码块结束
    } // 方法结束
} // 类结束

真实引擎不会这么简单,通常会有无锁队列、Frame Index、多帧资源、GPU Fence、Command Buffer、Render Graph、资源状态转换等机制。 但核心思想一样:逻辑线程写世界,渲染线程读快照,GPU 异步执行。

常见同步点

会发生同步或等待的地方包括:

  • 主线程等待渲染线程追上;
  • 渲染线程等待资源上传完成;
  • CPU 等 GPU Fence;
  • Present 等待 VSync;
  • 资源销毁前等待 GPU 不再使用;
  • 读取 GPU 结果,比如 ReadPixels、AsyncGPUReadback 不当使用。

常见坑

直接共享场景对象:渲染线程读 Transform,逻辑线程同时写 Transform,会数据竞争。

过早释放资源:CPU 以为资源不用了,但 GPU 上一帧命令还在用。

同步点过多:频繁 Wait 会让多线程流水线退化成串行。

快照过大:每帧复制太多数据,也会造成 CPU 和内存带宽压力。

面试加分句

IMPORTANT

“渲染线程和逻辑线程一般不是靠一个大锁共享场景对象,而是通过渲染命令队列或帧快照解耦。逻辑线程负责生成稳定的渲染数据,渲染线程消费并提交给 GPU。资源生命周期还要考虑 GPU 异步执行,所以释放资源前要用 Fence 或延迟销毁机制确认不再被使用。”

Frame Graph 是什么?

rendering-frame-graph

标准答案

Frame Graph 是一种现代渲染架构。它把“一帧怎么渲染”描述成一张依赖图:

  • 节点是 Render Pass;
  • 边是 Texture、Buffer 等资源;
  • 每个 Pass 声明自己读哪些资源、写哪些资源;
  • 系统根据读写关系自动推导执行顺序、资源生命周期、同步屏障和可裁剪 Pass。

一句话:传统渲染是手写固定流程,Frame Graph 是声明依赖关系,再由系统编译出实际渲染流程。

底层原理

传统写法更像这样:

先画 Shadow
再画 GBuffer
再画 Lighting
再画 PostProcess
最后 Present

Frame Graph 不只写顺序,而是写依赖:

c
ShadowPass  -> 写 ShadowMap
GBufferPass -> 写 GBuffer
Lighting    -> 读 ShadowMap + GBuffer,写 Color
PostProcess -> 读 Color,写 BackBuffer

系统看到这些读写关系后,就能知道:

  • Lighting 必须等 ShadowPassGBufferPass 完成;
  • PostProcess 必须等 Lighting 完成;
  • ShadowMap 只在 Lighting 前后有用;
  • 某个 Pass 的输出没人读,可以裁掉;
  • 两个生命周期不重叠的临时 RT,可以复用同一块内存;
  • 哪些资源状态要从 RenderTarget 切到 ShaderRead;
  • 哪些地方要插入 Barrier 或同步。

为什么需要 Frame Graph

大型渲染管线里 Pass 很多:

  • Shadow Pass;
  • Depth Prepass;
  • GBuffer;
  • SSAO;
  • Lighting;
  • Reflection;
  • Transparent;
  • Bloom;
  • TAA;
  • Color Grading;
  • UI;
  • PostProcess。

如果全部手写顺序和资源释放,很容易出问题:

  • 临时 RenderTexture 忘记释放;
  • Pass 顺序写错;
  • 资源状态切换漏掉;
  • 无用 Pass 还在跑;
  • 同一张 RT 生命周期难追踪;
  • 新增一个后处理要改很多地方。

Frame Graph 就是为了解决这些复杂度。

简化伪代码

c
public sealed class FrameGraphBuilder // 定义一个简化的 Frame Graph 构建器
{ // 类开始
    public void AddPass(string name) // 添加一个渲染 Pass 节点
    { // 方法开始
    } // 方法结束

    public void ReadTexture(string pass, string texture) // 声明某个 Pass 会读取某张纹理
    { // 方法开始
    } // 方法结束

    public void WriteTexture(string pass, string texture) // 声明某个 Pass 会写入某张纹理
    { // 方法开始
    } // 方法结束

    public void Compile() // 编译 Frame Graph,推导执行顺序和资源生命周期
    { // 方法开始
    } // 方法结束

    public void Execute() // 执行编译后的渲染命令
    { // 方法开始
    } // 方法结束
} // 类结束

实际引擎里的 Frame Graph 会更复杂,会处理 RenderTarget、Buffer、Resource State、Barrier、Async Compute、Pass Culling、Transient Resource、Render Pass Merge 等。

能优化什么

Frame Graph 能做几类优化:

  • Pass Culling:输出没人使用的 Pass 可以不执行。
  • 资源生命周期分析:知道临时资源什么时候创建、什么时候释放。
  • Transient Resource 复用:生命周期不重叠的临时 RT 可以复用内存。
  • 自动 Barrier:根据读写关系插入资源状态转换。
  • 异步计算机会:发现某些 Pass 可以放到 Async Compute。
  • 调试可视化:能看到一帧里每个 Pass 读写了什么资源。

Unity/引擎里的理解

Unity SRP 里常见的 Render Graph 思想,本质就是 Frame Graph 的一种实现方向。 你不是直接到处手动申请 RT、释放 RT,而是把 Pass 加进图里,声明资源读写,由系统统一调度。

面试里可以说:

“Frame Graph 的价值不是把流程画得更漂亮,而是让渲染管线从手写顺序变成数据驱动的依赖图。这样系统可以统一管理资源、同步和优化。”

常见坑

Frame Graph 也不是没有代价:

  • Pass 声明必须准确,漏声明读写会出错;
  • 调试门槛比直接写 CommandBuffer 更高;
  • 有副作用的 Pass 不好裁剪;
  • 图编译本身也有 CPU 成本;
  • 简单项目强行上 Frame Graph 可能过度设计;
  • 资源命名、生命周期、Pass 边界要规范。

面试加分句

TIP

“Frame Graph 是一帧渲染的资源依赖图。Pass 只声明自己读写哪些资源,系统根据依赖编译出执行顺序、资源生命周期和同步屏障。它的核心价值是减少手写管线里资源管理和同步管理的复杂度,并给 Pass 裁剪、临时资源复用、Barrier 自动化提供基础。”

Render Hardware Interface 为什么需要抽象?

render-hardware-interface-abstraction

标准答案

Render Hardware Interface,简称 RHI,本质是“渲染系统和底层图形 API 之间的一层抽象接口”。

它存在的核心原因是:游戏引擎不能让上层渲染逻辑直接依赖 DirectX、Vulkan、Metal、OpenGL 这些具体 API。否则材质系统、渲染队列、后处理、阴影、资源系统都会被平台细节污染,后期移植和维护成本会非常高。

底层原理

不同图形 API 做的是同一类事:创建 Buffer、Texture、Shader、Pipeline,录制 DrawCall,处理资源状态,提交命令给 GPU。

但它们的细节差异很大:

  • D3D12、Vulkan 更显式,需要开发者管理资源状态和同步。
  • Metal 有自己的对象模型和平台限制。
  • OpenGL 更偏状态机,很多状态是隐式的。
  • 主机平台通常还有自己的图形 API。

RHI 把这些差异藏在后端实现里,上层只面对统一接口:

c
public interface IRhiDevice // 定义统一的 RHI 设备接口
{ // 接口开始
    IRhiTexture CreateTexture(TextureDesc desc); // 上层只描述要创建什么纹理
    IRhiBuffer CreateBuffer(BufferDesc desc); // 上层只描述要创建什么缓冲区
    IRhiCommandList CreateCommandList(); // 上层通过命令列表记录渲染命令
    void Submit(IRhiCommandList commandList); // 上层提交命令,不关心底层队列细节
} // 接口结束

public sealed class VulkanRhiDevice : IRhiDevice // Vulkan 后端实现同一套接口
{ // 类开始
    public IRhiTexture CreateTexture(TextureDesc desc) => CreateVulkanImage(desc); // 把统一纹理描述翻译成 Vulkan Image
    public IRhiBuffer CreateBuffer(BufferDesc desc) => CreateVulkanBuffer(desc); // 把统一缓冲描述翻译成 Vulkan Buffer
    public IRhiCommandList CreateCommandList() => CreateVulkanCommandList(); // 创建 Vulkan 命令录制对象
    public void Submit(IRhiCommandList commandList) => SubmitToVulkanQueue(commandList); // 提交到 Vulkan Queue
} // 类结束

为什么需要抽象

  1. 跨平台:同一套渲染逻辑可以跑在 Windows、Android、iOS、主机平台上。
  2. 隔离 API 差异:资源状态、同步屏障、命令队列、SwapChain 差异由 RHI 后端处理。
  3. 降低耦合:材质、Shader、后处理、Frame Graph 不直接依赖某个图形 API。
  4. 方便调试:RHI 可以统一记录 DrawCall、资源创建、状态切换、GPU 提交。
  5. 方便扩展:以后新增 Vulkan、Metal、主机后端,不需要重写整个渲染系统。

优缺点

优点是架构清晰、可移植、可测试、可维护。 缺点是设计成本高,而且抽象过厚会损失底层 API 能力,抽象过薄又会导致上层仍然被平台差异污染。

Unity/游戏引擎场景

NOTE

Unity、Unreal、自研引擎都会有类似 RHI 的层。比如 Unity 上层渲染管线不应该直接关心当前是 D3D、Metal 还是 Vulkan,而是通过底层图形抽象提交渲染命令。

面试可以这样说:

“RHI 的重点不是简单包一层 API 名字,而是把资源、命令、同步、队列、平台差异都建模成稳定边界。这样上层渲染系统关注渲染逻辑,底层后端关注具体图形 API。”

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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