三、引擎开发:理解游戏世界的底层运转

本文来源于 AI 撰写,本人已经仔细审阅过,介意者勿读!!!

如果说客户端开发是"在引擎上盖房子",引擎开发就是"造砖头和水泥"。大多数游戏开发者不需要从零造引擎,但理解引擎的工作原理能让你成为更好的开发者。就像开汽车不需要懂发动机原理,但赛车手必须懂。

这篇文章将深入引擎的底层子系统:渲染管线、场景管理、内存管理、任务系统、资源管线、脚本系统、物理引擎、音频引擎,以及ECS架构。每个系统都会讲解它的设计思想、实现原理和性能考量。


什么时候需要了解引擎

引擎知识的掌握程度可以分为三个层次:使用(Use)、理解(Understand)、自建(Build)。

使用:知道引擎提供了什么功能,会配置参数、写脚本。90%的游戏开发者停留在这个层次。对于大多数项目来说,这完全够用。

理解:知道引擎内部如何工作,能在遇到性能瓶颈时定位问题,能合理地选择技术方案。这是资深开发者和架构师需要的层次。比如你知道渲染管线的每个阶段在做什么,就能明白为什么"Draw Call太多会导致CPU瓶颈",而不是简单地记住"Draw Call要少"。

自建:从零实现一个引擎。这是引擎程序员和图形程序员的领域。你可能永远不会真的从零写引擎,但理解这个过程能让你对现有引擎有更深的认识。

决策建议:除非你在做引擎相关的技术研究,或者现有引擎确实无法满足你的需求,否则不要从零写引擎。Unreal和Unity的渲染管线经过了十几年的打磨,一个人从零写一个能达到同等质量的渲染器,可能需要数年时间。


渲染管线(Rendering Pipeline)

渲染管线是将3D场景转化为2D画面的完整流程。理解这个流程是理解游戏图形学的基础。

渲染一帧的完整过程

当引擎需要渲染一帧画面时,会依次执行以下步骤:

应用阶段(Application Stage):CPU执行的阶段。包括输入处理、物理模拟、动画更新、场景遍历、渲染命令生成。这个阶段的输出是一系列渲染命令(Draw Call),交给GPU执行。

几何处理阶段(Geometry Stage):GPU执行的阶段。包括顶点着色(Vertex Shader)、图元装配(Primitive Assembly)、裁剪(Clipping)、屏幕映射(Screen Mapping)。这个阶段将3D坐标转化为2D屏幕坐标。

光栅化阶段(Rasterization Stage):GPU将2D三角形转化为像素片段(Fragment)。包括三角形设置(Triangle Setup)、三角形遍历(Triangle Traversal)、像素着色(Pixel/Fragment Shader)、合并操作(Merging/Blending)。

每个阶段都有其特定的性能瓶颈。应用阶段的瓶颈通常是CPU(Draw Call太多或逻辑太复杂),几何处理阶段的瓶颈通常是顶点数量太多,光栅化阶段的瓶颈通常是像素着色器太复杂或Overdraw太多。

前向渲染 vs 延迟渲染

前向渲染(Forward Rendering):最传统的渲染方式。对于每个物体,遍历所有光源,计算光照贡献。简单直接,但当光源数量增加时,计算量急剧上升。一个物体有N个光源就需要N次光照计算。

前向渲染的优点是:支持MSAA(多重采样抗锯齿)、透明物体处理简单、内存占用低。缺点是:多光源性能差。适合光源数量有限的场景,如移动端游戏或风格化渲染。

延迟渲染(Deferred Rendering):将渲染分为两个Pass。第一个Pass(几何Pass)不计算光照,只将物体的法线、颜色、深度等信息写入G-Buffer。第二个Pass(光照Pass)根据G-Buffer中的信息计算所有光源的贡献。

延迟渲染的优点是:光源数量增加时性能下降平缓(因为光照计算与几何复杂度解耦)。缺点是:G-Buffer占用大量显存、不支持MSAA、透明物体需要单独处理。适合光源密集的场景,如《赛博朋克2077》的夜之城。

特性 前向渲染 延迟渲染
多光源性能
内存占用 高(G-Buffer)
MSAA支持
透明物体 简单 需要单独处理
适用场景 移动端/风格化 3A/写实风格

Tile-Based 渲染

Tile-Based渲染是移动端GPU的核心技术。它将屏幕分成若干个小块(Tile,通常16×16或32×32像素),对每个Tile单独进行光照计算。因为每个Tile覆盖的屏幕区域很小,涉及的光源也很少,所以能显著减少光照计算量。

iOS和Android的GPU(如Apple的A系列芯片、高通的Adreno)都采用Tile-Based架构。在这些平台上,延迟渲染的G-Buffer需要写入主存再读回,性能开销很大,所以Tile-Based Forward+通常是更好的选择。

Clustered 渲染

Clustered渲染是Tile-Based思想在3D空间的扩展。它不只将屏幕分成2D的Tile,还将视锥体在深度方向上分成多个Cluster。每个Cluster独立计算光照贡献。这让Clustered渲染能更好地处理深度差异大的场景(比如远处有一个巨大的光源,近处有一个小光源)。

《毁灭战士》(DOOM 2016)和《毁灭战士:永恒》使用了Clustered渲染技术,在保持60fps的同时支持大量动态光源。

Nanite 与 Lumen

Nanite 是UE5引入的虚拟化几何系统。它的核心思想是:不再需要手动制作LOD模型。Nanite会自动将高精度模型(可能有数百万面)转化为多层次的三角形集群(Cluster),然后根据屏幕上每个像素的大小选择合适精度的三角形进行渲染。

Nanite的关键创新在于:它在GPU上实现了软件光栅化,绕过了传统硬件光栅化器的限制。这使得它能处理极高密度的几何体,而不会像传统管线那样被顶点处理阶段卡住。

Lumen 是UE5的全局光照和反射系统。它使用多种技术的组合(软件光追、屏幕空间追踪、表面缓存)来实现实时的全局光照效果。Lumen的核心优势在于:完全动态,不需要预计算光照贴图。设计师可以随意移动光源和物体,光照会实时更新。


场景管理

场景管理的核心问题是:如何高效地组织和查询场景中的物体?当你需要回答"摄像机能看到哪些物体?"“角色周围有哪些敌人?”"鼠标点击了哪个物体?"这些空间查询问题时,场景管理数据结构就派上用场了。

八叉树(Octree)

八叉树是3D空间最常用的划分结构。它将空间递归地分成八个等大的子区域(八分体),每个子区域包含该区域内的物体。查询时从根节点开始,根据查询位置决定进入哪个子节点。

八叉树适合物体分布相对均匀的场景。缺点是:当物体分布不均匀时(比如所有物体都集中在场景中心),八叉树会退化成链表,性能急剧下降。

BSP(二叉空间划分)

BSP树用平面递归地将空间分成两半。每一层递归选择一个分割平面,将空间分为"平面前面"和"平面后面"两个子空间。BSP的优势在于:它可以高效地确定物体的前后遮挡关系,非常适合做室内场景的渲染排序。

《雷神之锤》(Quake)是第一个大规模使用BSP的商业游戏。引擎将关卡数据预处理成BSP树,运行时只需要遍历树就能高效地确定可见性。

空间哈希(Spatial Hashing)

空间哈希将空间划分成均匀的网格,每个网格单元维护一个物体列表。查询时只需要计算查询点所在的网格单元,然后遍历该单元及相邻单元的物体。

空间哈希的插入和删除都是O(1)操作,非常适合物体频繁移动的场景(如粒子系统)。缺点是网格大小需要合理选择:太大会导致每个单元内物体太多,太小会导致内存浪费。

ECS(Entity-Component-System)

ECS是一种完全不同的场景组织方式。它不按空间位置组织物体,而是按数据结构组织。关于ECS的详细讲解,将在后面的章节展开。


内存管理

游戏对内存管理的要求远比普通应用严格。Web应用或桌面应用可以依赖垃圾回收器(GC),但游戏不行。

为什么GC不行

GC(垃圾回收)的核心问题是:它会在不可预测的时间点暂停程序执行。对于Web应用,几十毫秒的暂停用户几乎察觉不到。但对于游戏,16毫秒的暂停就意味着掉一帧。如果GC暂停超过一帧,玩家就能明显感受到卡顿。

更糟糕的是,GC的暂停时间通常与堆内存大小成正比。游戏的堆内存可能有几个GB,一次完整的GC可能需要几十毫秒甚至几百毫秒。这是完全不可接受的。

真实案例:很多Unity手游在运行一段时间后会出现周期性的卡顿,玩家称之为"GC炸弹"。这是因为Unity的Mono运行时使用标记-清除GC,每次GC都会暂停主线程。解决这个问题需要大量使用对象池和减少堆内存分配。

线性分配器(Linear/Bump Allocator)

线性分配器是最简单的分配器:它维护一个指针,每次分配时将指针向前移动。分配速度极快(只需要一次指针移动和一次对齐),但不能单独释放内存。只能一次性释放所有内存(重置指针)。

线性分配器非常适合每帧分配的临时数据。在一帧开始时重置指针,所有帧内分配都在这个分配器上进行,帧结束时一次性重置。这避免了逐个释放的开销。

池分配器(Pool Allocator)

池分配器预先分配一批大小相同的内存块,分配和释放都是O(1)操作。非常适合需要频繁分配和释放相同类型对象的场景(如粒子、子弹、特效)。

帧分配器(Frame Allocator)

帧分配器是线性分配器的变体,专门用于每帧的临时分配。每帧开始时重置,所有帧内数据都从这里分配。帧结束时不需要逐个释放,直接重置指针即可。这在引擎开发中是最常用的分配策略之一。


任务系统

现代CPU通常有多个核心。充分利用多核并行是提升性能的关键。但直接使用线程编程非常痛苦:竞态条件、死锁、数据竞争……任务系统的目标是让并行编程变得更容易。

线程池(Thread Pool)

线程池维护一组预创建的线程,任务提交到队列中,空闲线程从队列中取任务执行。优点是避免了频繁创建和销毁线程的开销。

Job System

Job System是比线程池更高层的抽象。每个Job是一个独立的工作单元,Job System负责调度、依赖管理和负载均衡。Unity的Job System和Unreal的TaskGraph都是这种架构。

任务图(Task Graph)

任务图将任务之间的依赖关系表示为一个有向无环图(DAG)。比如"渲染准备"依赖于"场景更新",“场景更新"依赖于"输入处理”。任务图调度器会自动分析依赖关系,将没有依赖的任务并行执行,有依赖的任务按序执行。

《毁灭战士:永恒》使用了复杂的任务图系统来并行化游戏逻辑。在8核CPU上,游戏逻辑的并行度通常能达到6-7,接近理论上限。


资源管线

资源管线负责将美术资产(模型、贴图、音频)转化为引擎可使用的运行时格式。这个过程通常包括序列化、烘焙、压缩、异步加载等步骤。

序列化(Serialization)

序列化是将内存中的数据结构转化为可存储的二进制格式。好的序列化格式应该满足:读写速度快、格式紧凑、向前兼容(新版本引擎能读旧格式)、支持随机访问。

烘焙(Baking)

烘焙是将编辑器中的数据预处理成运行时高效的格式。典型的烘焙过程包括:模型的LOD生成、贴图的Mipmap生成、光照贴图的计算、动画压缩、NavMesh生成。

烘焙通常在开发阶段执行,而不是运行时。这是因为烘焙过程可能非常耗时(光照贴图烘焙可能需要几小时),不能在运行时进行。

异步加载与流式加载

大型开放世界游戏不能一次性加载所有资源。流式加载(Streaming)根据玩家的位置,动态加载和卸载资源。比如玩家在地图A时,只加载A附近的资源;当玩家移动到B时,卸载A的资源,加载B的资源。

《原神》的开放世界就使用了流式加载。玩家在地图上移动时,远处的地形和建筑会逐步加载。如果网络不好或硬盘速度慢,玩家可能会看到远处的建筑"突然出现"(Pop-in),这就是流式加载的延迟导致的。

热重载

热重载允许在运行时替换资源和代码,而不需要重启游戏。这在开发阶段极其有用:美术修改了模型后,不需要重新启动游戏就能看到效果。热重载的实现需要维护资源的引用关系,确保替换时所有使用该资源的对象都被正确更新。


脚本系统

现代游戏引擎通常支持多种编程语言。C++负责底层系统和性能敏感的逻辑,脚本语言(Lua、C#、WASM)负责游戏逻辑和快速迭代。

Lua

Lua是游戏行业最流行的脚本语言。它的优点是:轻量(整个解释器只有几百KB)、启动快、与C/C++交互方便(通过栈式API)。《魔兽世界》《文明》《愤怒的小鸟》都使用了Lua。

Lua的缺点是:性能相对较低(比C++慢10-50倍)、单线程、缺少现代语言特性(如类、模块系统)。但在游戏逻辑层面,这些缺点通常不是问题。

C#

C#是Unity的脚本语言(通过Mono或IL2CPP运行时)。它的优点是:语法现代、有完善的类库支持、开发效率高。缺点是:依赖较大的运行时、GC问题、与C++的交互不如Lua方便。

WASM(WebAssembly)

WASM是一种新兴的脚本方案。它允许在引擎中运行C++、Rust等语言编译的字节码,性能接近原生代码。WebAssembly在浏览器之外的游戏引擎中也开始得到应用,作为安全沙箱化的脚本运行时。

脚本绑定(Script Binding)

脚本绑定是将C++引擎API暴露给脚本语言的技术。手写绑定代码非常繁琐,但现代引擎通常使用自动化工具(如SWIG、 tolua、cppsharp)来生成绑定代码。

绑定的质量直接影响脚本层的性能。每调用一次脚本函数,都需要进行类型转换和参数传递,这会产生额外开销。好的绑定设计应该尽量减少跨语言调用次数,将多个小函数调用合并为一个大函数调用。


物理引擎

游戏物理引擎负责模拟物体的运动和碰撞。它通常包含碰撞检测和物理模拟两个部分。

碰撞检测的完整流程

碰撞检测通常采用两阶段策略:

Broad Phase(粗筛):使用简单的空间划分结构(如AABB树、空间哈希)快速筛选出可能碰撞的物体对。这个阶段的目标是将O(n²)的检测复杂度降低到接近O(n)。

Narrow Phase(精筛):对Broad Phase筛选出的候选对进行精确的几何检测。使用GJK、SAT(分离轴定理)等算法判断两个凸形状是否重叠,并计算碰撞点和碰撞法线。

约束求解器(Constraint Solver)

碰撞检测告诉你"这两个物体重叠了",约束求解器告诉你"重叠后该怎么办"。它通过迭代求解一组约束方程来计算物体的新速度和位置,使得碰撞的物体被正确地分开,并且满足摩擦力、弹性等物理特性。

约束求解器的迭代次数直接影响模拟质量。迭代次数太少,物体会"穿透"或抖动;迭代次数太多,计算开销太大。通常需要在质量和性能之间找到平衡。

主流物理引擎对比

引擎 类型 特点 代表游戏
PhysX 通用3D 功能全面、GPU加速 《巫师3》《赛博朋克2077》
Box2D 2D 简单易用、稳定 《愤怒的小鸟》《坎巴拉太空计划》
Bullet 通用3D 开源、布料模拟好 《GTA》系列
Jolt 通用3D 高性能、多线程 UE5可选后端

选择建议:如果你在用Unity或Unreal,直接用引擎内置的物理系统(基于PhysX或Chaos)。如果你需要自定义物理引擎,Box2D是2D项目的最佳起点,Jolt是3D项目的现代选择。


音频引擎

音频引擎负责游戏中所有声音的播放、混合和空间化处理。

DSP链(Digital Signal Processing Chain)

现代音频引擎通常使用DSP链架构。每个声音源经过一系列DSP节点处理(均衡器、压缩器、混响器、低通滤波器等),最终混合输出到扬声器。这种架构允许对每个声音进行精细的处理控制。

3D空间音频

3D空间音频让玩家能"听出"声音的来源方向和距离。它通过计算声音源到听者的距离和方向,应用距离衰减、多普勒效应、HRTF(头部相关传递函数)等处理,模拟真实世界中的声音传播。

《艾尔登法环》的音频系统在空间化处理上做得非常好。Boss的脚步声会根据玩家的视角方向变化,远处的战斗声会有明显的混响,这些细节极大地增强了沉浸感。


ECS架构

ECS(Entity-Component-System)是近年来游戏开发中最受关注的架构模式。它彻底改变了传统OOP的游戏对象组织方式。

传统OOP的问题

传统OOP将游戏对象表示为"类"。一个"敌人"类可能包含移动逻辑、战斗逻辑、渲染逻辑、AI逻辑。这种设计有几个严重问题:

继承爆炸:如果需要一个新的敌人类型"飞行的远程敌人",你可能需要创建一个继承自"飞行敌人"的类,但"飞行敌人"又继承自"敌人"。随着类型增加,继承树会变得极其复杂。

缓存不友好:OOP将对象的所有数据放在一起。但在遍历所有敌人的某个属性时(比如更新位置),你需要访问每个对象的完整内存块,导致大量缓存未命中。

数据耦合:所有逻辑都写在对象类中,很难独立地修改或优化某个系统。

Entity-Component-System 详解

ECS将游戏对象拆分为三个概念:

  • Entity(实体):只是一个ID,没有任何数据或逻辑。它是一个"标签",用来关联Component。
  • Component(组件):纯数据,没有任何逻辑。比如PositionComponent只包含x、y、z坐标。
  • System(系统):纯逻辑,没有任何数据。比如MovementSystem只负责更新所有有PositionComponent和VelocityComponent的实体的位置。
// ECS代码示例

// 组件:纯数据
struct Position {
    float x, y, z;
};

struct Velocity {
    float dx, dy, dz;
};

struct Health {
    float current, max;
};

// 系统:纯逻辑
void MovementSystem(std::vector<Entity>& entities, float deltaTime) {
    for (auto& entity : entities) {
        // 只处理同时有Position和Velocity的实体
        if (HasComponent<Position>(entity) && HasComponent<Velocity>(entity)) {
            auto& pos = GetComponent<Position>(entity);
            const auto& vel = GetComponent<Velocity>(entity);
            
            pos.x += vel.dx * deltaTime;
            pos.y += vel.dy * deltaTime;
            pos.z += vel.dz * deltaTime;
        }
    }
}

数据局部性(Data Locality)

ECS的一个核心优势是数据局部性。由于Component是按类型连续存储的,遍历某一类Component时,CPU缓存命中率极高。

想象一下:场景中有1000个实体,每个实体都有PositionComponent。在ECS中,这1000个PositionComponent在内存中是连续排列的。遍历时,CPU可以高效地预取数据,缓存命中率接近100%。

在传统OOP中,这1000个实体可能散布在堆内存的各个位置。遍历时,每次访问都可能导致缓存未命中,性能差距可以达到5-10倍。

性能对比

根据《守望先锋》技术分享的数据:使用ECS架构后,游戏逻辑的更新速度提升了3-4倍。这主要归功于数据局部性的改善和更好的并行化支持。

ECS的另一个优势是天然适合多线程。由于不同System操作不同的Component,它们之间通常没有数据依赖,可以安全地并行执行。MovementSystem处理位置,AnimationSystem处理动画,它们可以同时运行在不同的CPU核心上。

什么时候用ECS

ECS不是银弹。它适合:实体数量大(数百到数千)、系统逻辑相对独立、性能要求高的场景。它不适合:原型开发(ECS的学习曲线较陡)、实体类型多变(频繁添加新的Component类型会增加复杂度)、简单项目(传统OOP可能更直观)。

Unity的DOTS(Data-Oriented Technology Stack)是ECS在主流引擎中的代表实现。它将ECS、Job System和Burst Compiler结合在一起,提供了接近原生C++的性能,同时保留了C#的开发效率。


现代引擎趋势

游戏引擎技术正在快速发展。以下是几个值得关注的趋势。

DOD(Data-Oriented Design)

DOD是一种编程范式,强调以数据的布局和访问模式为中心来设计程序。它的核心思想是:程序的性能取决于数据在内存中的组织方式,而不是代码的组织方式。ECS是DOD在游戏开发中的典型应用。

多线程渲染

传统游戏引擎的渲染是单线程的:一个线程负责提交所有Draw Call。现代引擎开始将渲染命令的生成和提交分散到多个线程上,充分利用多核CPU的性能。UE5的RHI(Render Hardware Interface)就是多线程渲染的典型实现。

GPU计算

GPU不仅是图形处理器,它还是一个强大的并行计算设备。越来越多的引擎开始利用GPU进行物理模拟、粒子计算、后处理效果等非图形计算。UE5的Niagara粒子系统就大量使用了GPU Compute Shader。

光线追踪

光线追踪是渲染技术的"圣杯"。它通过模拟光线的真实传播路径来生成图像,效果远超传统的光栅化渲染。NVIDIA的RTX系列显卡提供了硬件加速的光线追踪支持,使得实时光线追踪成为可能。

模块化引擎

传统引擎(如Unreal)是一个巨大的单体程序。现代引擎趋势是模块化:将引擎拆分为独立的模块(渲染、物理、音频、脚本等),开发者可以只使用需要的模块。Godot的架构就是这种思想的体现。


学习路径

第一阶段:计算机基础

在深入引擎开发之前,你需要扎实的基础知识:数据结构与算法、计算机组成原理、操作系统基础、计算机图形学基础。这些知识是理解引擎底层原理的前提。

第二阶段:图形编程

从OpenGL或Vulkan开始,实现一个简单的渲染器。你需要理解:顶点处理、片段处理、纹理映射、坐标变换、光照模型。这个阶段的目标是能从零渲染一个旋转的3D立方体。

第三阶段:引擎架构

阅读开源引擎的源码(Godot、O3DE、或者小型引擎如Sokol)。理解引擎的整体架构、各子系统之间的交互方式、资源管理策略。这个阶段的目标是能设计一个简单的引擎架构。

第四阶段:专项深入

选择一个方向深入研究:渲染管线优化、物理引擎实现、ECS架构设计、脚本系统集成。这个阶段需要阅读学术论文和工业界的技术分享,理解前沿技术的原理。


自建 vs 使用决策矩阵

维度 使用现有引擎 自建引擎
开发速度
学习成本 中(学习引擎API) 高(从零学习)
性能上限 受引擎限制 完全可控
功能覆盖 取决于引擎 完全自定义
维护成本 引擎团队负责 自己负责
团队需求 1-3人即可启动 至少需要3-5名资深工程师
适用场景 大多数项目 引擎研究、特殊需求

最终建议:对于95%的游戏项目,使用现有引擎是正确选择。自建引擎应该是出于技术研究、学习目的或极端的性能/功能需求,而不是"现有引擎不够好用"。Unreal和Unity的"不够好用",通常是因为你还没有充分掌握它们。