这是「如何制作一款开放世界游戏」系列的第二篇。上一篇我们拆的那款开放世界,工程重心落在”让世界动起来”——怎么让几百个 NPC、上百辆车同时活着、还有序地动。这一篇换一款引擎、也换一种游戏类型:一款 RPG 都市开放世界。表面上它也是一座能自由探索的大城市,但只要往工程里看一眼,就会发现它的重心挪了位置。
本文基于对这款游戏引擎与客户端代码的源码级逆向阅读整理,覆盖从底层引擎到玩法、叙事、联机、存档的游戏端系统。所有架构与命名均已通用化处理,讲的是”这一类 RPG 开放世界的典型设计”,而非特指任何一款具体产品。读透的深讲、没读透的标注盲区——这是写作的纪律。
引言:当”开放世界”是一款 RPG
先把全篇的判断摆在最前面:同样叫”开放世界”,上一篇那款与这一篇的 RPG,工程重心截然不同。 上一篇那款开放世界,难点在于”让世界动起来”——让几百个 NPC、上百辆车有序地同时运转;而这一篇这款 RPG 都市开放世界,难点挪到了另一头——让一座城市能讲故事,让一个角色能被养成。这不是题材标签的差异,而是工程重心的差异:前者把最重的预算压在调度、物理、AI 上,后者把它压在数据驱动(data-driven)与叙事编排上。这篇文章,讲的就是后者。

为什么敢下这个判断?先说一个观察。
如果你打开这款游戏的引擎代码,按模块的体量排个序,你大概会预期排在最前面的是渲染、是物理、是那些”看起来最硬核”的底层。但实际上,这套引擎里最大的一个模块,是负责”怎么演一段剧情”的叙事系统——它的源文件数量接近九百个,比渲染器还厚。紧随其后的几个大模块里,有一个是数据驱动的配置库,专门用来描述”一个角色能怎么改造自己、一件武器有哪些属性、一项技能加成多少”——它预留的数据条目,是以百万计的。
这两个数字,几乎就把这篇文章想讲的事说清楚了。
上一篇那款开放世界,把最重的工程预算压在了”让世界动起来”:帧调度、物理、AI 的有序推进。而这一款,因为它是一款 RPG,它额外扛起了两座上一篇那款不必扛的大山——叙事密度和角色深度。
- 叙事密度:几十条手作任务线,要能同时推进、随时存读、还能互相影响(你在这条线上的选择,改变那条线的走向)。这不是写几段脚本能解决的,它是一个并发的、可恢复的状态机集群。
- 角色深度:义体、技能、武器、物品、词条……一款 RPG 的”深度”体现在玩家能无限地改造自己的角色,而这意味着内容量比上一篇那类高出一个数量级,还要能不停服调整平衡。这套东西不可能写死在代码里。
值得强调的是,这两座山并非美术或策划层面的选择,而是工程层面的必然。一旦一款游戏决定让叙事可分支、可并行、可存读,命令式脚本就会在并发与可恢复性上迅速失效,叙事便不得不被重构为状态机集群——这一步不是想不想做的问题,而是规模逼出来的结果。同理,一旦内容量跨过某个量级,把数值与关系写进代码就会让每一次平衡调整都退化为一次发版、让内容生产卡在程序员的编译周期上,数据驱动于是从”一种实现方式”变成”唯一可行的方式”。这也是为什么,这两座山在源码体量上压过了渲染与物理:它们不是被额外堆上去的功能,而是这一品类的工程重心本身所在。
所以这一篇,我们沿着”一座 RPG 都市开放世界”的工程栈,从世界底座一路走到玩法核心,看这两座大山是怎么被工程接住的。需要先交代一条全篇的暗线:无论是叙事的并发、数据的热重载,还是人口与物理的分帧,它们能够成立的共同前提,是一套”发起即返回、完成靠信号”的异步地基——这条线索会从第一章的任务调度起步,在此后每一章反复浮现,并在第七章收束。
整篇文章会围绕三条副线展开,它们贯穿始终:
- 内容即数据。 玩法——角色 build、物品属性、世界物件的行为——不写死在代码里,而是由一套巨型可编辑配置库来描述。内容量能上数量级、平衡能不停服调,都源于此。
- 叙事是工程,不是脚本。 几十条任务线的并行、存读、互相影响,被做成”图执行 + 事实总线(facts bus)”的状态机集群。这是全引擎投入最重的一块。
- 成本分层让城市有人气。 一座垂直高密度的都市,加上满街的人和车,靠的是”轻表示先行、按预算实体化(instantiation)”——绝大多数对象平时以最廉价的形态存在,只有玩家身边那一小撮才付出完整代价。
需要提醒的是边界。我能讲透的,是”运行时怎么被组织起来”——世界流送、人口、数据驱动、叙事图、状态机、网络复制这些架构层面的”为什么”。我没读透的,是底层算法的内核:碰撞求解器、shader 数学、navmesh 的生成、寻路求解的内部、音频 DSP——这些我只到结构层或接入点,并且每一处都会标出来。还有一类东西我刻意不写:具体的数值玩法设计(某把武器该怎么配、某套 build 怎么搭),那是策划的设计,不是工程,我手上的材料也不覆盖,不会去编。
全文较长,给一个简短的阅读导航:若只想抓住这一品类的核心判断,可直接看第四章(数据驱动)、第五章(叙事)、第七章(异步纪律的收口);若关心开放世界的底层如何搭建,可看第一至第三章(底座、流送、人口);若关心表现层与工程边界,可看第六章。各章彼此独立又相互呼应,按需取用即可。
下面逐章展开。

第一章 · 引擎底座:一帧怎么推进、世界怎么解耦
异步引擎的地基,不在于线程多,而在于能把”发起”与”完成”分开。
这一章要解决的问题:在展开那两座 RPG 大山之前,先把地基交代清楚——这个系统如何在”引擎能力”和”游戏内容”之间划界,一帧又是依照什么次序推进的。
这一层与大多数成熟的开放世界引擎是共通的,因此点到为止。但它规定了后面所有 RPG 系统挂载上去的方式:边界划在哪里、一帧按什么节拍推进、每一拍里的具体任务由谁来执行。三件事自上而下,构成整篇文章脚下的底座。
1.1 引擎与游戏的边界
任何一个意图长期演进的引擎,都要先回答一个问题:引擎层与游戏层的边界划在哪里?
这套系统的处理与上一篇那款属于同一思路——引擎层提供能力与节奏(何时初始化、何时推进一帧、何时关闭),游戏层提供内容与规则(这一帧具体推进什么),两侧经由窄接口通信,互不需要知道对方的内部细节。这是成熟引擎的共识,不再展开。值得点明的是它对全篇的意义:后面那两座 RPG 的大山——数据驱动与叙事——本质上都属于”游戏层的内容与规则”,它们挂在这条边界之上,由下面这套帧节拍来驱动。换句话说,边界划清之后,引擎层才能保持稳定、游戏层才能高速迭代;这套引擎之所以能把叙事和养成做得那样深,前提正是这条边界足够干净,让游戏层可以独立生长而不牵动引擎层。
1.2 一帧怎么推进:相位与分档
底座真正值得展开的,是帧推进。这部分与上一篇那款引擎是同一类思路,此处简述其要,重点放在它如何为后文的系统定义节拍。
一帧的推进并非”让每个对象自行决定何时更新”,而是由一个世界级的编排器,将一帧切分为若干有序的相位(phase):先收集事件、运行 AI 决策、推进各类状态机(确定所有对象这一帧的”意图”),再施加移动、运行物理,最后在渲染之前完成那些依赖”最终结果”的工作(例如依赖最终骨骼矩阵的计算)。这套”按阶段切分、由世界统一推进”的骨架,上一篇已详述其必然性——核心在于:对象之间的更新约束是”按阶段”成立的,而非”按对象”成立的,因此必须由一个统一的编排器来保证阶段次序,此处不再重复论证。
除相位之外,引擎的每帧更新还分档位(tier)。并非每一帧、每个场景都需要付出全量模拟的代价:在某些无需完整模拟的状态下(例如加载态或过渡态),引擎可走一条精简的更新档,省下相应开销。相位回答的是”一帧内各类工作的先后”,档位回答的是”这一帧整体投入多少”,二者正交,共同构成帧推进的两个维度。
这两块——相位编排与分档更新——是后面所有 RPG 系统挂载的”节拍器”。叙事系统的推进、人口系统的池化、状态机的转换,全部挂在这套节拍之上。需要强调的是:相位只规定了”哪一拍做哪一类工作”,而这一拍里那成千上万个具体任务由谁执行、在哪个线程上执行,则要再向下挖一层——这正是下一节的主题。
启发:无论哪一类开放世界,只要世界规模上去,”谁先算、谁后算”就会从实现细节升级为正确性的前提。 帧推进这一层如果你也在做开放世界,建议回看上一篇对”世界级相位调度”的展开——它是整个系列里对架构直觉影响最大的一节。本篇在此只需确立一点:相位编排是节拍,而节拍之下还需要一套执行机构,把每一拍的工作真正分派到多个线程上跑起来。
🔧 设计复盘 · 帧节拍
为什么会这样设计? 启发里那条原则——”谁先算、谁后算是正确性前提”——落到机制上,根源在于一个事实:开放世界中对象之间的更新约束是”按阶段”成立的,而非”按对象”成立的(AI 要先拿到完整事件、物理要等所有意图确定、渲染要等最终矩阵)。这一点决定了相位编排不是优化、而是正确性的地基。
踩过什么坑? 最常见的,是新系统挂载时选错了相位——在意图尚未收齐时便去读取别的对象的状态,取得的是上一帧的陈旧值;或将本应等待”最终结果”的计算放入了早期相位,从而依赖了尚未算出的矩阵。相位编排的价值,恰在于迫使开发者想清楚”这段逻辑应在哪一拍运行”。这与第一章任务调度中”job 须在依赖归零后方可执行”是同一种约束在不同粒度上的体现。
传统方案差在哪? 让每个对象在各自的 Tick 中独立计算,在小场景中并无问题,到开放世界这般规模便会令跨对象的先后关系彻底失控——同一帧内 A 读到 B 的旧值、B 读到 A 的新值,结果取决于谁先被遍历,且极难复现。世界级相位调度并非为了优雅,而是大规模并发更新下”正确性”的前提。
1.3 任务调度:相位之下的执行机构
相位编排规定了”一帧分几拍、每拍处理哪一类工作”,但每一拍内部那成千上万个具体任务——加载某个扇区、运行某个 AI、采样某段动画——究竟由谁、按何种次序、在哪个线程上执行,相位本身并不回答。回答它的,是整个引擎的任务调度系统(job system)。它是相位编排的执行层,也是本文后续每一个系统共同的底层依托:流送、人口、叙事、物理之所以都能做成异步,前提是脚下有这样一套调度器在统一编排。
之所以需要一套专门的调度器,是因为现代引擎重度依赖多线程:一帧之内要并行完成海量彼此存在依赖的工作,同时必须收敛在固定的帧预算之内。若由每段代码各自创建线程、各自 join 等待,会落入两类典型困境——线程数量失控、上下文切换吞噬性能,或彼此等待形成死锁。调度器收编的正是这件事:所有异步工作统一表达为 job,提交给它集中调度。这套调度器有三处设计值得展开。
其一,优先级分层。job 并非一视同仁地排入单一队列,而是按四个优先级通道(lane)组织——大致为:本帧必须完成的 Immediate、关键路径上的 CriticalPath、渲染路径的 RenderPath,以及可跨帧延后的 Latent。调度线程依优先级由高到低弹出 job,交予 Worker 线程执行;消费者在必要时甚至可以强行倒排依赖。如此,关键工作不会被大量非紧迫任务阻塞在队列后段,而可跨帧分摊的工作(例如远处资源的预取)则沉入最低优先级、在有余裕时才执行。固定帧预算之所以能稳定,依靠的正是这种”紧要者先行、非紧要者垫后”的分层。这里有一处必须留意的约束:各优先级队列的容量是初始化时固定的,并不动态扩展;常规提交路径在压满时自旋重试,而 Worker 本地路径可向本地缓冲做应急入栈(emergency-push)以降低死锁风险——这意味着持续的过载会转化为自旋重试的内耗,调度规模需要事先按容量规划。
其二,依赖以计数器加等待列表表达,而非轮询。一个 job 往往需等待另一批 job 完成方可启动。朴素实现会让它反复检查前置是否就绪,既空耗 CPU,又难以控制时序。这里采用的做法是:每个 job 关联一个计数器,发起时先核对其依赖的计数器是否为零——非零则挂入等待列表、不占用线程,为零则直接入队。每个前置 job 完成便将计数器递减;当计数器递减至零的瞬间,才唤醒并入队那些挂起的 job。整个流程是事件驱动的:无人空转等待,一切由”计数器归零”这一事件推动。这正是把”轮询”换成”通知”——不让线程反复追问”好了没有”,而让”完成”这件事主动找上门来。
其三,构建与同步由 Builder / Fence 显式管理。任务通过 job::Builder 组织——它不只是一个创建 job 的工具,更把一组异步任务的等待、派发、释放边界写进流程之中:谁负责等待、谁负责释放、何时可以进入下一轮同步,都不再依赖口头约定,而由 fence 显式划定。这样做的好处,是异步任务的所有权变得明确;代价是调用方必须严格遵守这些边界,否则便会出现计数器过早释放、等待关系不确定一类隐蔽错误。这里有一个反复出现的张力:越是把同步的细节交给调用方掌控(以换取灵活与性能),机制本身能替你兜底的就越少——它依赖的是调用方的纪律,而非在底层强制正确。
其四,生命周期的边界即契约。这类调度器还有一批散落的隐含约束:并行循环的收尾逻辑运行在”恰好观测到终止”的那个工作单元上,并不固定线程,因而不能假设自己跑在主线程;主线程的刷新接口拒绝重入,来自回调内部的嵌套等待会直接触发断言。它们单独看都不起眼,累积起来却构成了”使用这套调度器必须遵守的契约”——把一部分正确性的责任,从机制下放给了调用方的纪律。这也是这类底层设施的共同特征:越快的快路径,往往附带越严格的使用前提。
至此,本篇的核心纪律在最底层显形,且根源正在于此:RunJob 返回,并不代表该任务已经开始执行——它可能正挂在等待列表中,等待依赖归零;即便完成入队,也不代表已被 Worker 取走运行。当整个引擎的异步执行都建立在”发起一个 job、等待其计数器归零”之上时,”已发起”与”已完成”被拆分为两个相互独立、各自可观察的事件,便是顺理成章的结果。后文反复出现的”受理不等于完成”(acceptance ≠ completion),无论在流送、人口、叙事还是物理中现身,追根溯源都指向这套调度器的这一基本语义。值得一提的是,这条纪律并非这套引擎独有的发明——任何在固定帧预算内调度海量异步工作的严肃引擎,迟早都会走到”将受理与完成分离”这一步;但这个系统把它贯彻得格外彻底,彻底到几乎每一个子系统的 API 都恪守同一边界,读其代码便会在加载、生成、动作、叙事、复制中反复与之相遇。

启发:应将任务调度视为一等地基——以优先级通道表达轻重缓急,以计数器依赖加等待列表表达先后关系。 这是海量异步任务在固定帧预算下既不死锁、又不空耗的前提。当引擎规模上升,”各自创建线程、各自 join”的朴素方案难以为继;唯有一套统一的调度器立稳,上层系统才可放心地”发起即返回、于回调中收尾”。本文后续的流送、人口、叙事、物理之所以能够全面异步化,正是因为这套地基已经先行就位。
第二章 · 装下一座立体的城市:流送与多阶段就绪
“加载”不是一个原子动作,而是一条横跨时间、空间、就绪状态的链。
这一章要解决的问题:一座垂直高密度的都市——楼上楼下、室内室外、近处的招牌与远景的天际线——比内存大几个数量级。如何做到近处精细、远处廉价,而且切换之际不产生卡顿?
“无限大世界与有限内存”是所有开放世界共同面对的矛盾,上一篇已有论述。但一座 RPG 都市把这一矛盾推向了新的难度:它的密度是立体的——不仅是平铺于地面的街区,还有可进入的室内、层叠的楼层、悬于头顶的高架与广告。同样一平方公里的占地,需加载的内容远多于一片郊野。
它的应对,核心是将”加载”拆解为一条多阶段的就绪链,再以掩码(mask)驱动的即时取舍决定每一帧具体加载与卸载什么。本章会沿着三个正交的维度展开:先是”加载到哪一步”(就绪链与里程碑),再是”这一帧加载谁”(掩码驱动的即时决策),最后是”世界在空间上如何被切分与裁剪”(场景组织)。三个维度合起来,才是完整的流送(streaming)。
2.1 二阶段世界挂载:资源就绪 ≠ 世界就绪
流送的第一个认知陷阱,是把”资源加载完成”直接等同于”世界可用”。
这套引擎将世界的挂载明确拆为两个阶段。第一阶段是资源就绪:异步地将世界数据加载进来,待全部加载任务完成,才置上”流送世界”标志(源码中对应 m_streamingWorld 的设置)。第二阶段才是世界就绪:把扇区描述符转换为运行时的扇区包装结构、建立位掩码、搭建永驻内容的包装器,待这一整套结构全部就位,世界才真正进入”已挂载”(Mounted)状态。
之所以拆为两段,是因为”数据驻留内存”与”世界可供工作”之间,隔着一整套结构的搭建。数据加载完成仅意味着原材料到位;要让上层系统(导航、渲染、AI)能够在此世界中工作,还须先组织好扇区、建立代理节点的网格、挂载永驻内容。这套结构的搭建本身有其代价,把它与资源加载分作两个阶段,正是为了让二者各自拥有明确的完成信号。
启发:应为”资源可用”与”世界可用”各自设立完成信号,而非共用一个。 这是流送的第一课。上层逻辑——尤其是那些”世界一经加载即开始工作”的系统——若无法区分这两个信号,就会在世界结构尚未就绪时即行操作,从而遭遇一类典型故障:数据确已加载,结构却尚未建立,表现为对象在逻辑上存在、在运行时却无法正常响应。
2.2 掩码驱动的扇区流送,而非全局优先队列
世界挂载完成之后,真正的持续性工作随即开始:每一帧,引擎都须裁决哪些扇区应当流入、哪些应当流出。
一种直觉的方案,是维护一个全局优先级队列——为每个待加载区块计算优先级(例如按其与玩家的距离),优先级高者排在前列、依次加载。这套流送系统并未走这条路线,原因在下文的权衡中说明。
它采用的是掩码驱动加每帧即时决策。每一帧,流送网格(源码中为 NodeStreamingGrid,其核心处理函数为 Process())依次完成几件事:收集场景中所有”观察者”的位置——须注意,观察者不仅限于玩家相机,还包括 AI 及其他需要内容在场的系统;继而对每一个节点,依其到各观察者的距离、叠加多重位掩码(启用、锁定、预抓取、遮挡剔除等),当场裁决它这一帧应当流入抑或流出。这一裁决发生在 ProcessSectorStreaming 之中:它先计算内容观察者的位置、依距离阈值检测传送(teleport,即观察者位置发生跳变的情形),再构建扇区掩码、驱动加载与卸载,整个过程在 m_sectorsLock 的保护之下进行。
其中有若干工程细节值得展开:
- 同一节点对不同观察者可处于不同状态。 它可能正被玩家相机注视(须加载),同时又落入某个 AI 的预抓取范围。掩码天然能表达这种”多主体、多状态”的情形,而单一优先级值无法承载。
- 以 SOA(结构数组)加 SIMD 批量处理。 扇区包围盒被组织为 16 字节对齐的结构数组,由 SIMD 指令一次处理一批。具体而言,
ProcessSectorStreaming_CollectInRangeSectors扫描这片 SOA 包围盒缓冲、执行 16 字节对齐的 SIMD 加载,继而套用启用、锁定、预抓取掩码筛选出在范围内的扇区。须强调的是:这一对齐要求是 fatal-assert 级别的——任何对扇区体积分配或计数的改动,都必须维持 SIMD 所依赖的对齐假设,否则将直接触发致命断言。面对一座城市成千上万的流送对象,这种数据布局的优化并非锦上添花,而是必需。 - 预算化、分帧推进。 加载工作设有每帧时间预算:
TimeLimitSectorLoadPerFrame限制已完成扇区的晋升速率,TimeLimitSectorUnloadPerFrame限制卸载速率。如此,引擎不会在某一帧内一次性加载大片区域而造成帧尖峰,而是将其分摊至多帧。
但这套设计中潜藏着一个尤须警惕的反直觉点:流送的”完成掩码”中,包含那些加载失败的代理。也就是说,一个节点出现在”已完成流送”的掩码里,并不代表它真正可用——它可能加载失败,却仍被标入完成掩码。掩码成员资格不等于实例可用。 若上层代码据”该节点在完成掩码中”推断”该对象已加载就绪、可对其发起操作”,就会对一个实际加载失败的对象施加操作;这类故障在画面上往往难以察觉,直至某个本应在场的内容悄然缺席,才暴露。

启发:对海量流送对象而言,”位掩码加每帧即时评估”较之维护全局优先队列,既更省开销、也更能动态重估。 全局队列须维护排序,是 O(n log n) 的持续开销,且一个节点只能持有单一优先级,无法表达”对 A 玩家高优先、对 B AI 预抓取”这类多主体情形;掩码加即时决策则将这些一并化解。但须始终记得:掩码表示的是”流送态”,而非”可用态”——不可将”位于完成掩码中”当作”可以安全使用”。
2.3 流送是一串里程碑:一个扇区从”请求”到”可用”
上一节讲的是流送的”即时决策面”——每一帧当场算哪些扇区该进、该出。但一个被决定”该加载”的扇区,从”开始加载”到”真正能用”,中间其实要走过一串分离的里程碑,每一道都是一个单独的、要单独等的信号。这一面,可以叫流送的”时间面”。
先看世界级。加载整个流送世界(LoadStreamingWorld)被拆成三段:异步请求启动(立即返回)、令牌等待作业完成(置上 m_streamingWorld 标志,此刻数据进内存了)、再到 MountWorld 把世界挂上(置 Mounted)。但即便到了 Mounted,缓存资源的加载还在后台等计数器——挂载只代表结构搭好了,不代表全部世界运行时流送都完成了。这里有个尤其要小心的早退路径:如果版本不匹配,加载可以在挂载之前就返回,留下一个”场景已附加、但流送世界标志是失败”的中间状态。
再看扇区级。单个扇区的 RequestLoad 只是启动异步请求、立即返回;真正把它注册进流送网格,要等 UpdateLoadingState 检查到令牌”已完成/已加载”之后,才创建代理分配器、注册进 NodeStreamingGrid。也就是说,”发起加载”和”注册成功”是两个不同的时刻。
最后是预取(prefetch)级,它对”完成”的定义最为严格:扇区加载完成仍不算完。预取分三阶段——先等待所有预取扇区加载完成,再建立一个流送网格的预取快照,最后唯有当快照报告全部节点均已流送,才通知预取完成回调(IStreamingPrefetchCallback)。”扇区加载完成”远不等于”预取完成”。这里还有一处易被忽略的回调语义:ClearActivePrefetch 在中止或替换某次预取时,即便回调从未被正式通知过,也仍会调用 OnStreamingPrefetchCompleted 再清除活跃状态——这意味着调用方在中止路径上,可能观察到一个与正常完成”形状相同”的回调。换言之,”收到完成回调”本身也不足以区分”真正预取完成”与”预取被中止后的收尾”,调用方须结合自身状态加以甄别。

启发:流送的每一道里程碑都该是可单独观察、可单独等待的信号——请求、加载完、挂载、节点就绪、预取完成,是五件不同的事。 把它们折叠成一个笼统的”加载好了”,就会在某一层的半成品上操作:你以为世界挂载完就能用了,其实缓存资源还在加载;你以为扇区加载完预取就好了,其实节点还没全部就位。这一节是上一节(即时决策)的另一面——前者管”这一帧加载谁”,后者管”加载到哪一步了”,两面合起来才是完整的流送。
2.4 场景组织:世界在空间上怎么被切分、裁剪
流送还有第三个面,和前两个正交——空间面。前面讲的是”何时加载”和”加载到哪一步”,这一节讲”世界在空间上是怎么被切开、组织、裁剪的”。
切分的基本单位是扇区(sector)。 世界资源序列化为多个扇区描述符,按分类(外部、内部、任务、导航)注册为 SectorWrapper;每个 wrapper 持有加载令牌、代理分配器与编译好的扇区切片,经 CreateNodeProxies 将切片注册进 NodeStreamingGrid。扇区即流送的基本空间单元,节点代理即被流送管理的对象。这里有一处贴合城市形态的设计:路感知裁剪并不采用均匀网格,而是由 StreamingQuery 加载道路数据后构建一棵四叉树,使裁剪沿道路结构展开——城市内容是顺着街道铺陈的,四叉树较之均匀格子更契合其分布,也能在沿街移动时以更少的节点覆盖玩家可达的范围。
而空间组织中最值得展开的,是可见性。 “哪些内容应被绘制”本身即是一个须精心组织的问题,这里依靠的是遮挡剔除(occlusion culling),其核心为 RuntimeSystemVisibility,它管理遮挡剔除代理的注册、渲染附加与分离队列、以及可见性状态。一个遮挡器(occluder)从”加载”到”真正参与裁剪”,须跨越三道边界——在这里,那条贯穿全篇的纪律呈现为一种具体形态:代理注册到位,并不代表渲染器已经把它附加进场景:
- 初始化边界:
OnInitialize返回true,仅代表初始化工作已被受理,不代表遮挡剔除代理已注册,更不代表已附加至渲染场景。 - 注册边界:须至
OnAttach之中,才创建OccluderProxy、插入RuntimeSystemVisibility的注册表(入口为AddOccluder)。静态遮挡器在OnAttach期间注册;实例化遮挡器则先生成合并资源、再于OnAttach注册。若加载失败,则留下空网格、跳过代理创建。 - 渲染附加边界:可见代理先排队进入
m_occludersToAttach,由PreRenderUpdate(或队列累积至 64)才发出渲染命令、真正附加——至此它才开始参与遮挡裁剪。
最隐蔽的一处是:代码能够观察到”已注册”(GetOccluders 可见到注册表中的代理),但这并不等于”渲染器已将其附加”。三道边界各有各的”完成”,若混为一谈,便会误以为某个遮挡器已在生效,而它实则仍在排队。此外,实例化遮挡器在合并阶段还潜藏一个数值边界:当源顶点数乘以实例数超过 65535 时,索引类型的转换仅断言 Uint32 小于顶点数,却未断言其小于 UINT16_MAX——这是一处在极端实例规模下需格外留意的隐患。

启发:“哪些对象该被渲染”本身就是一条异步流水线,别假设”加载好的遮挡器立刻在裁剪里生效”。 世界在空间上被切成扇区、喂进流送网格,这部分和流送同源;而可见性把”该画谁”的判定,做成了初始化→注册→渲染附加的三段链——每一环都只完成自己那一步。空间组织和流送是同一件事的两个侧面:流送决定”内存里有什么”,空间组织决定”这些东西在哪、该不该被看见”。
2.5 用队列把剧情与资源解耦
这一节,是本章与后面”叙事”章之间的一座桥。
在一款 RPG 中,剧情经常需要直接改变世界的内容:某段任务要求一栋楼内突然出现一组此前不存在的预制(prefab),或某个区域的布置在任务推进后须更换一批。问题随之而来——剧情逻辑运行在它自身的节奏上,世界流送运行在它自身的节奏上,二者如何协调?
一种最不可取的做法,是让剧情逻辑直接、同步地锁住扇区、修改节点状态。如此则剧情每推进一步,都须阻塞等待世界加载,两个系统被紧紧耦合在一起,彼此的节奏相互牵制。
这套引擎采用的是以队列解耦。当剧情要求某区域的预制加载模式改变时,其调用的接口(源码中类似 SetQuestPrefabLoadingMode)仅在锁的保护下将这一”模式变更”请求入队,随即返回——剧情逻辑无需等待,继续推进。真正的应用发生在后处理阶段:引擎批量地将这些排队请求转换为扇区的流入标志,再由一段待处理状态的轮询逻辑检查节点是否完成,完成后方在锁外复制监听器列表、触发回调。
也就是说,剧情一侧”发起请求”后即行返回,世界流送与剧情之间的同步,由队列在后台逐步对齐。这是本文首次见到”叙事直接驱动世界”——这条线索,至第五章讲述叙事时将再次浮现。
启发:游戏逻辑的请求(任务、剧情、UI)与资源流送之间,应走解耦通道、以队列衔接,而非直接同步调用。 在后处理阶段再批量应用,可将昂贵的同步点压至最低。一旦剧情逻辑直接阻塞于资源加载,任务系统便会被世界流送的节奏所牵制,两侧皆难推进。
2.6 预制件:场景物件如何被组织成可复用单元
上一节 SetQuestPrefabLoadingMode 中的”预制”(prefab),值得单列一节——它回答了开放世界绕不开的一个问题:一座城市如此众多的街区、房间、街边设施,是否由美术逐件手工摆放? 答案是否定的。它们是将预制件在世界中复用无数次拼装而成。这与第四章将要讲述的材质”以少生多”是同一条逻辑,只是对象由”材质外观”换成了”场景物件”。
一份定义,满城实例。 一个预制是一份资源定义——包含一组节点、支持分组、并可嵌套(prefab 之内再套 prefab)。同一份定义被包装为多个实例(源码中为 PrefabNodeInstance)散布于世界各处:街角一个、广场一个、巷中一个。每个实例各自独立地走异步加载、各自携带自身的实例数据。美术制作一次,引擎复用数十乃至上百次——这便是预制”以少生多”的第一层。
同一预制如何变出差异? 依靠两种手段。其一为变换覆盖:实例化时应用各自的缩放,并随代理距离调整,使同一预制近处放大、远处缩小,节点拓扑不变而尺寸千差万别。其二为变体开关:一套变体管理器(源码中为 PrefabVariantsManager)将”预制变体描述符”映射为按 group 的显隐切换——同一预制的破损版与完整版、昼与夜,本质上是同一份骨架的不同 group 组合。于是,少数几套预制,经缩放与变体的排列组合,便铺满了整座城市的街区与房间。
剧情如何驱动其出现与消失? 这便接回了上一节:剧情要求某区域”生成”或”撤除”一组预制时,请求被入队、后处理批量应用、轮询节点就绪后再通知监听器——剧情一侧发起后即返回,预制的出现与消失在后台异步对齐。它同样恪守那条熟悉的边界:实例化是分阶段的,”已发起加载”不代表”预制已真正可见”,监听器收到的就绪通知也须结合实际状态甄别,而非照单全收。换言之,剧情对世界的改写,走的是与资源流送同一套”发起即返回、完成靠信号”的解耦通道。

启发:将”场景物件”做成可复用、可参数化、可被剧情调度的预制单元,是大世界内容量绕不开的工程。 美术制作一套预制件,引擎将其放置无数次、每个实例可缩放、可切换变体、可被剧情加载或撤除——一座以人工无法逐件完成的城市,便由此拼装而成。这与材质、数据驱动同属一条”以少生多”的逻辑:内容量要上数量级,依靠的不是以人力堆砌更多素材,而是将素材做成可复用、可参数化的单元。
🔧 设计复盘 · 多阶段流送
为什么会这样设计? 因为在一座立体高密度的城市中,”加载”本就不是一个原子动作,而是一条横跨资源 IO、结构搭建、代理注册、渲染挂载的链。不同的消费者关心链上不同的节点——导航系统只需导航数据就绪、渲染器须渲染代理注册完毕、AI 须实体可供查询。给出一个笼统的”已加载”信号,反而无人敢于信赖。将就绪拆分为多个可观察的阶段,正是为了让每个系统各取所需。
踩过什么坑? 最隐蔽的是”完成掩码中的假成功”——加载失败的代理同样被标入完成掩码。若以掩码成员资格推断”实例已加载、可供使用”,便会对一个尚未真正就位的对象发起操作。这类故障在画面上往往难以察觉,直至某个本应在场的内容悄然缺席,或一次操作落在空对象之上,才显形。
传统方案差在哪? “加载完成回调一次、上层即视作一切就绪”,在小场景中并无问题,于流式大世界则会把”资源到位”误作”世界可用”,使半成品被当作成品消费——故障出在时序而非逻辑,因而极难定位。同理,让剧情逻辑直接同步地操作世界加载,在线性关卡中尚可运行,到开放世界 RPG 便会令叙事与流送相互死锁。多阶段就绪加队列解耦并非为了形式上的优雅,而是这种规模与密度之下的必需。
第三章 · 城里的人与生活:人口、人群与智能对象
城市的鲜活,不来自”每个人都完整模拟”,而来自”恰当的人在恰当的距离上付出恰当的代价”。
这一章要解决的问题:一座 RPG 都市须有”人气”——满街路人、川流的车、坐在酒吧里的人、街边修缮器物的人——但显然不可能为每一个都运行完整的 AI 与物理。如何让城市看上去是鲜活的,同时不拖垮机器?
人口(population)这一话题上一篇亦有涉及。但在本篇中,我将其作为”城市生活感”的工程来讲——对一款 RPG 都市而言,”街上有无行人、那些行人是否像在过自己的日子”,正是沉浸感的根基。核心思路是第三条副线的主场:轻表示先行、按预算实体化。 本章沿一条线索展开:人如何以最廉价的形态存在,远近不同的人如何分层,那些”在做事”的人又如何被驱动——再在章末,把支撑这一切动起来的物理与导航补齐。
3.1 stub-first:NPC 先是一条记录,按需才成为角色
先提一个最朴素的问题:要让一条街上有一百个路人,是否就须在内存中创建一百个完整的角色对象?
若果真如此,这座城市的内存与 CPU 将迅速不堪重负。一个完整的 NPC 角色——附带骨骼网格、AI、物理与各类组件——是相当重的。一座城市同一时刻”在场”的潜在 NPC 数以千计,若全部实现为完整对象,资源开销无法承受。
答案是 stub-first(存根先行)。一个 NPC,默认并非完整角色,而是一个轻量的存根(EntityStub)——本质上只是一条记录、一组元数据,几乎不占资源。唯有当它满足条件(进入预算范围、变得可见、距离足够近)时,才会被”解析”(源码中此动作为 Resolve())——由实体存根系统将其升格为一个真正的实体。
这条升格路径本身亦是分阶段的。人口系统对外的接口(如 PopulationSystem::AddEntity)被调用之后,仅是将请求入队受理,并不代表 NPC 已经出现。真正的处理在后续的池化(pooling)逻辑之中:请求先被注册,继而计算出生成记录(spawn record)与模板、排入生成队列,再经实体的生成令牌(token)、attach 调度、事件广播,一个 NPC 才真正现身于世界。而这条生成队列是限流的——同时进行的生成被压制在约十个之内,必要时还会取消优先级较低的生成,为更要紧者腾出名额。限流之所以必要,是因为实体化本身是重操作,若不设上限,一次大范围的可见性变化(例如玩家转过街角)就可能在单帧内引爆成百上千次实体化、击穿帧预算。
由此点出本篇反复出现的一个工程现实:这些接口的”返回成功”,含义是”请求已受理”,而非”工作已完成”。 AddEntity 返回,并不代表那个 NPC 已经站在街上——它可能仍在队列中排队,或正经过生成令牌链的某一环。在人口系统里,这条纪律的形态是:请求被接收,不代表实体已经可用。它几乎贯穿这套系统的每一个子系统,我们将在最后一章集中收束;而它的根源,正是第一章末尾那套以 job 计数器为核心的调度器。
启发:应将”存在”做成最廉价的元数据态,将”实体化”做成按需触发的升级。 海量对象之所以能”存在”而不致内存崩溃,是因为它们绝大多数时候只是一条记录,而非一个完整对象。需要时才升格,而非创建即完整——这是一座城市得以容纳一座城市人口的根本。
3.2 三层人群:完整 NPC / 简化群体 / 远景近似
stub-first 解决了”潜在 NPC 如何廉价地存在”,但仍有一个问题:那些已经在场的人,也不应是清一色的完整角色。一个站在你面前与你对话的 NPC,与一公里外天桥上走过的一个人影,所应付出的代价显然天差地别。
这套引擎将”城市中的人”按成本与保真度分为三层:
- 完整 NPC:附带完整 AI、物理、实体的角色。这是玩家身边的一小撮——可交互、有行为、参与战斗。它最为昂贵,因此数量最少。
- 人群存根(CrowdStubs):简化的行为加视觉表现。它们从交通车道(traffic lane)派生出位置,存储的是轻量的存根指针,删除时由回调清理。它们使中距离的街道”有人走动”,却不运行完整 AI——介于”完整实体”与”纯视觉”之间的中间档。
- 远景人群(DistantCrowd):纯视觉近似。这是最远、最廉价的一层——其本质是一台多帧的状态机,在车道之间挑选、维护一批”点”(dot)缓冲、将渲染数据发出。它甚至能反过来经由车道上的点,请求生成常规人群,从而在玩家靠近时平滑地过渡到更高保真的档位。
这里有一个必须讲清的反直觉点:远景人群”渲染有效”不等于”游戏实体存在”。 你立于高处,望见远处街道上稀疏的人流移动——那很可能只是一批用于渲染的点,其背后并无真正的 NPC 实体。它们不可交互、无 AI、不参与任何游戏逻辑,纯粹是为使远景”看上去有人”。当你走近,这些纯视觉的点才会按需被替换为真正的(哪怕是简化的)NPC。换言之,三层之间并非静态划分,而是随玩家距离动态升降的连续阶梯。

启发:人群是一道”成本-保真度阶梯”,而非非黑即白的”有人/无人”。 越远,采用越廉价的近似——完整角色、简化群体、纯视觉的点,三层各管一段距离。将省下的预算,悉数投向玩家眼前那一小撮真正需要高保真的角色。这正是”满街皆人”与”帧率稳定”得以同时成立的关键。
3.3 智能对象与工作点:让城市的人”在做事”
仅有”有人走动”仍不足够。一座可信的城市,人是在做事的——倚墙抽烟、坐于长椅、于摊位后忙碌、操作某台设备。这种”在做事”的观感,并非依靠为每个 NPC 写死一段脚本,而是依靠一套智能对象(smart object)加工作点(workspot)的系统。
智能对象,是世界中那些”可被 NPC 使用”的物件——一把椅子、一处倚靠点、一台可操作的设备。其占用机制颇值得玩味:并非”先到先得、独占持有”的锁,而是一套“需求加预约”。一个 NPC 欲使用某个智能对象时,登记一个”需求”(源码中类似 AddSmartObjectDemand),多个 NPC 由此可排队等待同一对象,而非彼此争抢、相互阻塞。更进一步,这些对象的容量管理是按扇区预算化的,而非维护一个全局对象池——一个区域内可同时激活多少智能对象,由预算约束。这一点与流送的扇区预算同源:城市生活的密度,同样被纳入”按区域分配预算”的统一框架。
工作点,则是绑定了一段动画序列的”工位”——它界定了”在此处可播放哪些动作”。这里再次浮现本篇的母题:命令提交不等于播放完成。 当系统令一个 NPC 在某工作点开始一段动作时,提交命令的接口(SendCommand)仅将命令入队,其返回值表示的是”是否有活跃实例接收了这条命令”,而非”动作已播放完毕”。真正的完成,须等待后续的完成回调(OnCompleted)。倘若某个动作缺失对应的动画资源,系统不会硬性失败,而是走超时回退的路径——宁可让该 NPC 优雅地退出此动作、继续其行为,也不令其卡死在一个永远无法完成的工位上。
这意味着,街边那个”正在修车”的 NPC、那个”倚栏看手机”的 NPC,其背后皆是一次”在某个智能对象的工作点上提交一段工作点命令、等待其播放完成”的完整流程——可调度、可排队、可被打断,而非一段硬编码的循环动画。这条”需求加预约”的占用、”提交加完成”的双信号,使城市生活成为一个可被统一调度的系统,而非散落各处、彼此无关的脚本动画。

启发:环境互动用”需求 + 预约”而非抢占锁,用”命令提交 / 播放完成”双信号而非单一布尔。 前者让多个 NPC 能自然地排队等一把椅子,而不是互相踩;后者让”开始一个动作”和”动作真的完成”成为两条可分别观察的信号,缺失资源时还能优雅回退。这样,城市里”人在做事”这件事,就成了一个可调度的系统,而不是满世界硬编码的循环动画。
🔧 设计复盘 · 让城市有人气
为什么会这样设计? 启发已点明”廉价存在、按需升级”的思路,这里只补一句它为何是三层而非两层:完整 NPC 与纯存根之间,还隔着大量”中距离、需要看起来在动、却不值得完整模拟”的人——若没有”简化群体”这一中间层,要么近处的高保真预算被摊薄、要么中景突然空掉,二者都会破坏城市的连续感。三层人群、加上”在做事”所需的智能对象调度,本质都是同一道权衡的不同档位:在保真度与规模之间,按距离切出多个甜点,而非二选一。
踩过什么坑? 最易犯的,是将任一生成接口的返回当作”人已就位”。
AddEntity仅是入队受理,真正可用须待整条生成令牌链走完;在其后立即操作那个尚不存在的实体,便会取得空值。另一处是远景人群的”视觉幻觉”——将那些纯渲染的点误作真实 NPC 施加游戏逻辑,会发现它们既无 AI、亦不可交互。这两处的根源是同一个:把”渲染上可见”或”接口已受理”误作”游戏实体已就绪”。传统方案差在哪? “在场景中预先摆放全部 NPC”或”实时地按距离对整个角色做 LOD”,在小场景中尚可支撑,但在千人量级的城市里,前者直接耗尽内存、后者击穿帧率。stub-first 将”存在”的成本压至一条记录、将”实体化”做成按需升级,再以三层人群拉开不同距离的保真度档次,方能在一张地图上容纳一座城市的人——代价是上层必须接受”一切皆异步交付”,不可再假设”我请求一个 NPC,它便即刻在场”。这正是全篇主轴在人口系统上的具体落点。
3.4 物理:把同步的物理引擎,接进异步的世界
城里的人和车要”活着”,光有 AI 还不够——他们要踩在地上、撞到墙、躲开障碍、被子弹击中。这些都要物理。而物理在这套引擎里有个特殊的工程难题:它底层用的是一个同步模型的商业物理引擎(PhysX 这一类),而开放世界是异步、分帧的。怎么把一个”你调用它、它立刻给你算”的同步中间件,接进一个”一切都发起就走、回调里收尾”的异步引擎?算法内核(碰撞怎么求解)是盲区,但这个”怎么接”的架构问题,正好在能讲透的范围里。
核心思路是:同步的 API,异步的场景改动。 当你要把一个物理代理加进场景(AttachProxy),它不会立即去改 PhysX 的场景——而是把这个操作设成一个 PendingProxyAction、入队。真正的 attach 推迟到物理模拟的处理阶段(OnSimulate_ProcessPendingProxyActions),在一个独占窗口里批量地做。为什么要这么绕?因为单帧里可能有大量增删,集中到独占窗口批量处理,既避免了反复触发回调,又把对 PhysX 场景的改动锁在一个受控的时机。代价就是那条熟悉的纪律:AttachProxy 返回,不代表代理进了场景——要等模拟跑完(WaitSimulation)才能保证。
一帧的物理模拟(KickSimulation)被编排成一串 job:先排空 pending 的代理操作、pre-step,再并行跑 PhysX/布料模拟,然后做接触回调的 fanout、async 查询、最后推送 dirty transform 并 flush。这样物理帧就和游戏帧解耦了——主线程发起后可以继续干别的。
这里有两个特别要警惕的坑。一个是异步查询的”Success 陷阱”:一次 raycast/sweep 的异步查询返回 Success,既包括”打中了”也包括”没打中但算过了”——NoHit 也映射成 Success。所以你不能只看状态,必须去查 hit array 才知道到底有没有命中。另一个是几何缓存的三态:物理几何随扇区流送,状态在 Pending/Loaded/Abandoned 之间转——如果一个扇区在它的几何加载完成之前就被卸载了,几何会转成 Abandoned,之后对它的查询就拿不到几何、返回 Invalid。

启发:把一个同步的中间件接进异步引擎,关键是给它一个”独占改动窗口” + “延迟回调/释放”——让外部的同步调用,落到内部受控的时机上执行。 物理是个典型例子:API 层收下你的请求(AttachProxy、发起查询),但真正的场景改动、回调触发、内存释放,都被收拢进模拟的处理阶段统一做。这样既保住了 PhysX 的同步语义,又让它能在开放世界的固定帧预算里和其他系统并行。当你要集成任何”同步模型”的第三方库时,这个”同步外壳 + 异步内核”的模式都用得上。
3.5 导航:寻路网格随世界流送,路径靠”令牌 + 多帧组装”
人和车要在城里走,得寻路。但开放世界的寻路有个独特难处:世界是无缝流送的——你走到哪、哪里的导航网格才加载。这意味着寻路不能假设”整张地图的导航数据都在内存里”,它得和流送协同。寻路算法的内核(A\*、Detour 的数学)是盲区,但”导航怎么和流送协同、一条路径怎么跨帧组装”这个架构问题,能讲。
这里把导航分了三层:RuntimeSystemNavigation 管导航网格瓦片(tile)的流送与缓存;RuntimeSystemPathfinding 管多帧路径组装;RuntimeSystemTraffic 管全局交通(车道、路口、车辆生成)。行人走导航网格,车辆走车道网络。
导航网格随流送加载,同样是一串里程碑——令牌入队,不代表瓦片已可查询。 RegisterTiles 只是把一个注册令牌入队、通知监听器——瓦片此刻还不可用。真正的 Detour 瓦片插入发生在 RegisterQueuedTiles 里,按单帧时间预算逐个插、处理不完的 requeue,直到 NotifyStreamedIn 报告全部令牌成功,导航网格才真正能查。
一条路径不是一次算出来的,是跨多帧组装的。 StartPathfinding 返回的是一个令牌,不是路径本身——它把查询入队、发出首个查询就返回了。之后每一帧 Update 推进活跃查询、批量写结果;调用方靠轮询 TryToGetPath 来取——有结果才返回,没好就继续等。如果寻路时相关区域还在流送,查询会暴露”部分结果”、保持活跃、每隔几秒重试,而不是直接失败。
还有一层多路回退让寻路更鲁棒:GPSQueryBuilder 会在导航网格和交通车道之间选分支,并用”回退目标集”——单个路段失败不必让整条路径失败,要把所有回退目标都试过了才算真失败。也正因为有这些回退和”部分结果”,导航的”成功”信号格外要小心:isReady 也好、非空的结果指针也好,都不足以证明真成功——它可能是一条空的占位路径、或一个部分结果,必须验证有效性。

启发:在流送的大世界里,寻路必须把”网格加载”和”路径计算”都做成异步、可跨帧、能容忍数据暂缺的过程。 导航网格随扇区流送(RegisterTiles 只是受理、分摊插入、全令牌成功才可用),路径做成令牌、跨帧组装、调用方轮询,再加多路回退兜底——这样 NPC 才能在一个”边走边加载”的世界里寻路,而不会因为脚下的街道刚被流送进来就卡死。(诚实边界:寻路算法内核、上百 NPC 并发的帧预算分配、街道重流送时的主动重规划、动态避障,这些笔记没读透,本节不展开。)
第四章 · RPG 的核心:数据驱动的角色养成
RPG 的深度,首先是一个数据工程问题。
这一章要解决的问题:一款 RPG 要让玩家无限地改造自己的角色——义体、技能、武器、物品、词条——内容量比上一篇那类高出一个数量级,还要能不停服调平衡。这套东西怎么可能写死在代码里?答案是:它根本不在代码里。
这是这款游戏与上一篇那款开放世界最根本的工程分野,也是本篇我认为最有料的一章。一款 RPG 的”深度”,玩家感受到的是”我能怎么捏我的角色、搭我的 build”,但落到工程上,它的本质是一套数据驱动的能力——把玩法从代码里搬出来,搬进一套可编辑、可热改、能装下海量内容的数据系统。
我按一条线索展开:玩法怎么从代码搬进数据 → 这套数据库怎么撑住百万条目 → 数据怎么被运行时安全消费 → 它怎么支持不停服调参。
4.1 玩法即数据:一套巨型可编辑配置库
先看朴素的做法。一把武器的伤害是多少、一个义体提供什么效果、一项技能每级加成多少——最直觉的实现,是把这些写成代码里的常量,或者塞进若干配置文件,运行时读进来。
这在内容量小时完全够用。但一款 RPG 的内容量不在这个量级。它有成百上千种武器、义体、技能、物品,每一种又含几十条属性、词条、关联关系;它们彼此之间还相互引用(这把武器属于哪个类别、这个义体需要何种前置、这套技能影响哪些数值)。将这些写入代码,等于把整套玩法的血肉固化于程序之中——改一个数值须重新编译,加一件装备须程序员动手,平衡性调整须发布一个新版本。当内容以千计、调整以日计时,这种耦合的成本便不再是”麻烦”,而是直接决定了内容产能与迭代速度的上限。
这套引擎的做法,是建一套统一的、巨型的可编辑配置库(一套数据驱动的数据库)。游戏里几乎所有的玩法数值与关系,都用两种基本单位来描述:
- 记录(record):一个有结构的对象,比如”某把武器””某个义体””某项技能”。它有自己的类型、自己的一组字段。
- 扁平值(flat):记录里那些具体的属性值——伤害数字、加成系数、引用的其他记录的 ID 等等,被扁平地存在一张大表里。
玩家在游戏里搭的每一套 build,本质上都是在和这个库里的记录打交道——装上一个义体,是激活了库里对应的一条记录;换一把武器,是切换到另一条记录。
这套库的规模有多大?一个数字便很能说明问题:它的扁平值存储,预留(reserve)容量为一百八十万条(源码中那个硬编码的 reserve 数字即 1,800,000)。一个系统将其内部存储预留至百万量级,这本身即印证了内容规模的层级——它绝非几百个配置项的小型系统,而是要容纳一整款 RPG 全部玩法数据的大型数据库。记录与扁平值在运行时分别由不同的锁保护:record 与 flat 各持其锁,这意味着对结构对象的访问与对具体数值的访问可在不同的临界区中进行,互不阻塞——这是一套面向高频并发查询所做的设计取舍。

启发:RPG 的”深度”,首先是一个数据工程问题。 玩家以为自己体验的是”丰富的角色养成系统”,而工程上所构建的,是”一套能容纳海量玩法数据、并能高效查询的数据库”。将玩法从代码搬入可编辑的数据,是内容量得以上数量级、迭代得以不依赖程序员的前提。若你正在制作一款具备深度养成的游戏,这套数据驱动的底座,便是绕不过去的第一块硬骨头。
4.2 撑住百万条目:预热、校验与生成式 schema
一个要容纳百万条目的数据库,仅”能容纳”尚不足够——它还须能快速加载、能校验完整性、并让上层代码以类型安全的方式访问。这个系统在这三件事上各有讲究,且共同构成一种”分层的成功观”。
快速加载依靠预热。 启动时若逐条解析百万量级的数据,耗时将无法接受。因此它采用”优化二进制 blob 预热”的路线(源码中对应 LoadOptimized 一类路径)——将数据预先处理为一块紧凑的二进制块,加载时整块灌入、快速展开,而非逐条解析。这把”解析成本”从运行时前移到了离线烹饪(cook)阶段。
完整性依靠校验。 它对加载的数据执行 CRC 校验,以确认文件未损坏。
类型安全依靠生成式 schema。 这套配置库的记录类型与字段定义,很大一部分是自动生成的——那近千个源文件里,多数是工具依据数据 schema 生成的记录头与类型定义,外层再包裹一层手写的扁平化运行时。如此,每新增一种记录类型,无须程序员手写其内存布局与访问代码,由生成器产出;运行时再借助引擎的类型系统按字段元信息读写它。这是一条清晰的三层分界:编辑端的数据文件、生成式的 schema 边界、手写的运行时,三者各司其职、互不越界。
但这里潜藏着一个极为重要、也极为隐蔽的反直觉点:扫描成功、预热成功、CRC 成功,皆不等于数据语义有效。 CRC 只能表明”文件字节未损坏”,预热只能表明”数据已展开进内存”——它们无法表明”这条武器记录所引用的那个义体 ID 确实存在””这项技能的数值处于合理区间”。外层的这些校验尽数通过,里层的数据语义仍可能是缺失的、错误的、自相矛盾的。这正是”分层成功”的含义:每一层的校验只为它那一层的正确性背书,绝不替更里层担保。

启发:海量数据驱动应分三层——编辑端的数据文件、生成式的 schema 边界、手写的运行时——并须接受”分级的成功”。 最外层的完整性校验(CRC、预热)通过,绝不等于数据语义有效。将二者混为一谈,是数据驱动系统中最易使人放松警惕之处:你见到”加载成功”的日志,便以为数据无虞,而实际上一条引用错误的记录正静卧库中,等待在某个玩家的 build 里显形。完整性校验回答”字节对不对”,语义校验回答”内容对不对”,后者必须由你自己的工具链与流程来承担。
4.3 安全消费:取值接口的”默认回退”是一把双刃剑
上层代码怎么从这个库里取一个值?比如”取这把武器的伤害”。
这里提供了模板化的取值接口(源码中类似 Val<T>)。它的行为包含一项影响深远的设计决策:当所取的那条数据不存在时,它不会报错或抛出异常,而是自动回退至该类型的默认值。
这一设计自有其益处——它使取值代码无须四处编写防御性的判空,某个值缺失时,系统仍能以默认值继续运行,不致频繁中断。但它是一把双刃剑,且对一款 RPG 而言,这把剑的另一刃格外锋利。
设想这样一种情形:一个义体的某项效果,因某条数据未配置,取值时悄然回退为默认值(例如 0)。游戏不会中断,日志中也可能并无明显错误——但这个义体在玩家手中,就是不生效,或生效得不正确。对一款以数值为核心玩法的 RPG 而言,这种”静默兜底”是最危险的缺陷之一:它不抛出异常,只是默默地错,直至玩家察觉某件装备”似乎无用”,才回头排查,而彼时数据早已上线。它的隐蔽性恰恰来自”看似稳健”——一个永不崩溃的接口,把”内容缺失”这一本应立即暴露的问题,转化为了一种沉默的错误。
因此,这套引擎在接口层面提供了显式的存在性检查(HasEntry)——调用方应当在取值前先确认该条数据确实存在,而非依赖默认回退。换言之,”默认回退”是兜底的安全网,却不应被当作正常路径来使用。

启发:数据驱动的取值接口,要么强制进行存在性检查,要么让缺失显式报错,切勿以静默的默认值掩盖内容缺口。 对一款数值即玩法的游戏,”取不到便回退默认值”看似稳健,实则会把”内容配置错误”这一本应即时暴露的问题,转化为潜伏至上线后才发作的隐患。安全网可以存在,但不应让团队养成依赖安全网行事的习惯——否则安全网就从”兜底”退化为了”遮蔽”。
4.4 不停服调参:热重载与类型漂移
数据驱动最大的回报体现在迭代速度上:策划改动一个数值,无须等待程序员重新编译、无须发布新版本,改完数据即可见到效果。这种三层分离(编辑端文件、生成 schema、手写运行时)正是为此服务——它支持热重载:在运行时重新加载配置数据,使改动即时生效。
对一款需要持续打磨平衡性的 RPG,这一能力几乎是必需的。武器强度、敌人血量、技能加成、经济数值……皆须反复调试、反复试玩,若每改一个数值都要重新编译启动,迭代效率将被严重拖累。热重载使”改数值”从一次工程操作,变为一次近乎即时的调整。
但热重载是有代价的,且代价藏于一个极易被忽略之处:reload 之时,类型变更的校验被放松了。 当重新加载的数据中,某条记录的某个字段类型与原先不一致时,这里在 reload 路径上采用的是 static_cast——它不执行严格的类型检查,直接按新类型解释那块内存。也就是说,热重载以”放松类型安全”换取了”运行时更换数据”的能力。常规的数值改动并无问题,但若在热重载中误改了某个字段的类型,它不会报错,只会悄然以错误的方式解释数据。这与上一节的”静默默认值”是同一类隐患的两个面相:前者在”取不到”时沉默,后者在”类型错”时沉默——它们都不抛出异常,只是默默地错,而”默默地错”恰是最难定位的一类故障。

启发:数据驱动的杀手锏是热重载——让策划改数值不必等编译——但你要想清楚 reload 时的类型/schema 漂移边界,别让”能热改”变成”能改坏”。 热重载放松类型安全是个合理的工程取舍(毕竟严格校验会让热重载变慢甚至做不动),但这个边界要被清楚地标出来、被工具或流程约束住,否则一次手滑的类型改动,就能在运行时种下一颗静默的雷。
🔧 设计复盘 · 玩法即数据
为什么会这样设计? 启发已说清”内容量逼出数据驱动”这条因果,这里补一个常被忽略的推论:一旦内容量大到要让策划独立于程序员迭代,数据就必须自带运行时的校验、热重载与类型系统,否则”可编辑”会反噬成”可改坏”。那个一百八十万条的预留容量,证明的不只是规模,更是这套校验与热重载机制必须扛住的压力量级——它把原本由编译器承担的正确性保障,整个搬到了运行时。这也正是下面那些坑的来由。
踩过什么坑? 最隐蔽的是”静默默认值”——取不到的配置自动回退为一个看似合理的默认,于是一个未配置妥当的义体不会中断、只会默默地不生效,潜伏至上线后方被玩家察觉。其次是热重载时的类型漂移:reload 以
static_cast绕过了类型检查,误改字段类型不会报错,只会悄然以错误方式解释内存。这两处的共同点是——它们都不抛出异常,只是默默地错,而”默默地错”恰是最难排查的一类故障。它们与第七章收口处所述的地基层”假成功”同源:每一层校验都只为它那一层背书,语义的正确性必须由更上层自行承担。传统方案差在哪? 将玩法数值写成代码常量或散落的配置文件,在小体量游戏中够用;到 RPG 这般内容量,便会让每一次平衡调整都退化为一次发版、让内容生产受制于程序员、让策划每改一个数值都要等待一轮编译。数据驱动并非为了优雅,而是 RPG 内容规模与迭代速度逼出的硬需求——其代价,是你须亲手承担”数据正确性”这层原本由编译器免费提供的保障。编译器为你拦下的类型错误、引用错误,如今都须由你自己的工具链、校验流程与纪律来拦。这是一笔清晰的交换:以编译期的安全,换运行期的灵活与迭代速度。
第五章 · 叙事是工程:手作任务的图编排与事实总线
叙事不是脚本,而是一个可并行、可恢复、可传播影响的运行时系统。
这一章要解决的问题:一款剧情驱动的 RPG,几十条任务线要并行推进、随时存读、还能互相影响——你在这条线上的选择,改变那条线的走向。怎么把这种叙事密度做成一个可工程化的系统,而不是一堆纠缠不清的脚本?
我在开篇说过一个数字:这套引擎里最大的模块,是负责叙事的场景系统,源文件接近九百个。 现在我们正式走到它面前。这个事实本身,就是一个有力的论断——对这款游戏,叙事不是收尾阶段写几段脚本的活儿,它是头等工程,是整个引擎投入最重的一块。
为什么叙事会重到这个程度?因为开放世界 RPG 的叙事,有三个线性游戏不必面对的硬需求:
- 并行:几十条任务线同时存在,玩家可以在它们之间随意切换,每条线都在自己的进度上。
- 可恢复:随时存档、随时读档,而且读档后整个叙事状态要精确地接着上次的进度——不是从头,是从”那个进行到一半的世界”。
- 互相影响:一条线的选择和结果,要能被另一条线看到、做出反应。
这三个需求,命令式的脚本(”播放这段、然后那段、然后判断一下”)根本扛不住。它的答案,是把叙事做成“图执行 + 事实总线”的状态机集群——第二条副线的主场。
我按一条线索展开:一段剧情怎么被编排 → 几十条任务怎么并行 → 任务之间怎么互相影响 → 这一切怎么被存下来再续上。
5.1 场景系统:把一段过场/对话编排成状态机
先看最小的叙事单元——一段过场、一段对话。
朴素的想法是:过场就是”按顺序播放一串东西”——播这句台词、切这个镜头、播那个动作。但开放世界 RPG 的过场远比这复杂:它可能被玩家打断、可能要等某个资源加载、中途可能插入一个需要玩家做选择的节点、对话里某句话可能触发别的系统。一段线性的”播放列表”表达不了这些。
这里将过场做成一台由 tick 驱动的状态机。当某段场景被请求时,请求接口(DoQueueRequest)仅将这一控制请求入队,随即返回一个请求 ID——真正的工作分作几道边界:入队(立即完成)、预处理(于下一帧建立内部存根)、执行(在 tick 状态机中推进)。这三道边界各自独立,正是后续一切灵活性的结构前提。
这里再次浮现那条贯穿全篇的反直觉:所得到的请求 ID,仅是一份”受理凭证”,而非”完成证明”。 更进一步——场景的资源句柄(handle)甚至可在资源尚未加载完成时便已存在(它可能处于”正在获取”乃至”获取失败”的状态)。对话的激活、选择枢纽(choice-hub)的注册,同样都是”调度边界”——它们表示”此事已被安排”,而非”音频已经播放””玩家已经选择”。换言之,从请求受理到演出真正落地,中间隔着数道各自可观察的边界,调用方不可将其中任意一道误作终点。
这套设计所换取的,是叙事演出的灵活性:一段过场因是状态机而非播放列表,故能被打断(切入一个新状态)、能等待资源(停于某个等待状态)、能插入选择(转入一个选择状态再返回)。这些皆是线性脚本无法提供的。
启发:将”演出”做成可调度的状态机、而非线性的播放脚本,是过场能打断、能存读、能插入交互的前提。 玩家视为理所当然的”对话进行到一半可以跳过””选择之后剧情继续推进”,其背后是把每一段演出都拆解为可进入、可退出、可中断的状态。一旦将过场写成”顺序播放”,这些便无一能够实现。
5.2 任务图:事件驱动 + 索引化续延,让几十条线并行
单段过场之上,是任务(quest)。一款 RPG 同时有几十条任务线在推进,它们怎么不互相打架?
这套引擎把任务做成事件驱动的图。一条任务是一张图,图里的节点代表任务的各个阶段,节点之间的流转由事件驱动——某个条件满足了、某个事实变了、玩家做了某个动作,就推动图往下走一步。
任务的启动本身亦是异步的:发起一条任务(RunAsync),仅是将其追加进一个待运行队列(m_questsToRun),真正的激活须待后续的 tick 逻辑(TickPendingQuests)处理——在锁的保护下取出、将任务的哈希推进活跃列表、继而启动图的根节点。这意味着”发起任务”与”任务激活”同样是两个分离的时刻。
而真正精妙之处,在于它处理事件续延的方式。当一批事件需要推动多条任务前进时,一种朴素的实现会直接持事件遍历、触发节点——但此处存在一个隐患:触发节点的过程中,可能又产生新的事件、又须向队列追加内容,一边遍历一边修改同一容器,极易引发迭代器失效一类的并发错误。
它的解法是以索引取代指针:处理事件时,先将待处理事件的索引复制出来、清空共享队列,再以这些索引解析对应的节点路径、续延任务。如此,在”处理本批事件”的同时,新产生的事件可安全地进入已清空的队列、留待下一轮处理——两批互不干扰。它还以一套跳过追踪(skip-tracking)防止索引在多帧处理中被复用或错位。这一”先快照、再处理”的模式,与第一章任务调度中”先复制待处理索引、清空队列、再续延”如出一辙——同一种并发纪律,在不同层级反复出现。
启发:事件驱动的图,应采用索引化的续延,而非裸指针。 这是一个看似微小、实则极为关键的技巧。它使”处理本批的同时安全地接收下一批”成为可能——这正是几十条任务线得以并行推进、而不致因”一边触发一边新增”陷入混乱的关键。若以裸指针直接遍历触发,迟早会遭遇”遍历过程中容器被修改”这一类极难定位的故障。
5.3 事实总线:一张让任务互相影响的全局事实表
并行解决了,还剩最后一个、也是最能体现 RPG 叙事特点的需求:任务之间怎么互相影响?
它的答案,是一张全局的事实表(FactsDB)。任务、场景、玩家状态,都读写同一张表——这条任务线把某个”事实”置成真,那条任务线就能读到它、做出反应。它本质上是一条跨叙事线的”影响总线”。
这张表有一项设计决策,初看令人迟疑,细想却自有其道理:当一个事实被改写时,它在锁的内部即直接触发了回调。 具体而言,改写事实的函数(FactsTable::Do)在改完值之后、尚未离开锁之时,便调用了 FireCallbacks——于是,一次事实写入,可立即沿依赖图续延下游的监听器(脚本、实体事件、其他任务的监听器),无须再经过一层额外的队列。
“在锁内同步触发回调”,在许多场景下属于危险模式——它易引发重入与死锁。但这套引擎在此将其作为一项刻意的特性:它所追求的,正是”这条线的事实一经改变,那条线即刻知晓”的即时传播;倘若中间隔一层队列,跨叙事线的因果就会延迟一帧乃至数帧,”选择立即生效”的叙事体验便无从谈起。其代价是,回调链必须自我约束——若一次事实写入所触发的回调,又去改写别的事实、又触发别的回调,链条便可能失控地递归下去。因此这套机制的使用,要求开发者审计回调的递归深度,并确保回调内部不会无界地反向写回事实表。这是一处典型的、以”可控性的纪律”换取”即时性的收益”的权衡。

启发:以一张全局事实表加同步回调,充当跨剧情线的”影响总线”,可使状态变更即时传播、省去中间的队列层。 这是让”你在 A 任务中的选择即刻被 B 任务感知”变得自然的工程手段。但务必为回调链划定明确边界、审计其递归深度——同步触发的代价,是一次写入可能引发一长串未曾预料的连锁反应。即时性与可控性,在此是一对须谨慎权衡的矛盾。
5.4 三段式存档:让一个进行到一半的世界能被存下再续上
最后一个需求:存读档。这对 RPG 来说格外难,难在——它的世界是活的。
不同于线性游戏”存个关卡进度”,一款开放世界 RPG 存档的瞬间,几十条任务进行到各自不同的阶段、场景状态、玩家状态、世界里被改变过的物件……这整个”活着的状态机集群”的快照,都要被存下来,读档时再精确地接续上。
这个系统把存档做成三段式:
- pre-save(存档前):在阶段锁的保护下,把任务、任务实例、事件、事实、预制状态、场景数据、玩家映射,统统序列化成快照。
- load(读档):把这些数据注水回各个子系统。
- restored(恢复后):注意,读档不是注水完就结束了。恢复阶段只清掉等待标志——而真正的”信号重建”被延迟到后续的 tick 里,等相关的预制资源真正就绪,才去重建那些信号。
这个”延迟重建”是关键。因为读档时,世界的资源(预制、实体)还在异步加载,如果在它们就绪之前就急着重建任务信号,会重建到一堆还不存在的东西上。所以这里让恢复分阶段进行——load 完成 ≠ 任务可玩,中间还隔着一个”已注水、但信号尚未重建”的窗口,要等资源就绪后才真正补全。

启发:复杂叙事状态的存档,分三段——pre-save 锁定快照、load 注水、restored 等依赖就绪再重建信号。 别指望一次读档就让世界回到完全可玩的状态。一个活着的世界,它的恢复必然是分阶段、有先后的:先把数据放回去,再等资源到位,最后才重建那些依赖资源的信号。把这三段混成一步,就会读档读出一个”看起来加载完了、其实还没真正就绪”的半成品世界。
🔧 设计复盘 · 叙事是工程
为什么会这样设计? 因为一款剧情驱动的开放世界 RPG,须让几十条任务线并行、随时存读、并能互相影响——命令式脚本无法承载这种并发与可恢复性。将叙事拆为”入队的请求加图的节点加事实总线加三段式存档”,方能使其像一个状态机集群般运转、逐帧续延、断点恢复。这也正是场景系统成为全引擎最大模块的原因——这款游戏将对叙事的工程投入,置于与渲染、物理同等乃至更高的位置。
踩过什么坑? 两处,且皆与”同步”相关。其一,事实总线的回调在重入锁内同步触发,一次事实写入可能沿依赖图引发一长串连锁回调、递归失控;其二,将读档的 load 完成误作任务就绪,而信号其实须待预制资源就绪后方在 tick 中重建,于那个窗口内操作任务便会落空。这两处皆非逻辑写错,而是对”完成的时机”判断有误——这与表现层那一章的教训如出一辙。
传统方案差在哪? 将剧情写成一长串顺序脚本,在线性叙事中完全够用,到开放世界 RPG 便会立即败于”并行加可恢复加互相影响”这三项硬需求——无法让多条脚本安全地交错运行,无法把一个鲜活的世界干净地存下再续上,也无法让一条线的选择干净地影响另一条线。叙事引擎的本质是图执行与事实总线,而非脚本编写。理解了这一点,方能理解这款游戏为何愿意将最大的一块代码投入到”如何讲述故事”之上。
第六章 · 表现与操作:从动作状态到渲染提交
表现层的省,靠的是把昂贵的工作摊开到多帧,把不可信的瞬态挡在消费者之外。
这一章要解决的问题:玩家最终看到、操作的一切——角色动画、战斗中的状态切换、开车、各种视效——都要每帧大量计算。怎么在不掉帧的前提下把它们喂出来?而 RPG 还多了一层难度:战斗里复杂的姿态、技能、武器状态切换,要靠状态机扛住。
表现层我只点到结构层(再往下的算法内核是盲区,后面会标)。这一章里,我想重点拎出状态机——因为在这套引擎里,状态机承载了大量玩家和 NPC 的战斗与姿态逻辑,它是”操作手感”背后的骨架。
6.1 状态机:同帧转换有界 + 切换回调的反直觉顺序
战斗里经常有”一帧之内连环切换状态”的需求:角色落地的同一帧里,要从”坠落”切到”着地”、再切到”待机”或”起身”。这种连环切换,靠的是状态机的同帧转换链。
但同帧转换潜藏一个风险:若 A 状态转入 B、B 又即刻满足条件转入 C、C 又转回 A……便会在一帧之内无限转换下去,导致该帧无法结束。这里的处理很务实——为同帧转换链设一个硬上限,约二十次(超出即触发 debug 断言报警),此外 buffered 命令队列另设有约 64 的上限。这个具体数字本身便颇具意味:它是从实践中试得的经验值,既允许图定义的逻辑在一帧内收敛(二十步足以走完大多数合理的连环切换),又防住了失控的无限循环。一个经验上限加一道断言,便同时拿住了”灵活”与”可控”。
状态切换还有一处反直觉的顺序设计。直觉上,状态切换应是”先退出旧状态、再进入新状态、最后方算切换完成”。但这里的次序是相反的:它先将当前状态替换为新状态,继而调用切换回调,最后才执行退出与进入的处理。这意味着,当观察者(例如一段脚本)在旧状态的退出逻辑仍在运行时查询当前状态,它所见到的已是新状态。
为何如此设计?因为它确保观察者一律见到终态,而非某个”切换中途”的中间态。这消除了一类极为隐蔽的故障——观察者读到一个转瞬即逝的中间状态、据此作出错误判断。其代价是,退出逻辑本身须能容忍”当前状态已变为新状态”这一事实。这与第五章场景系统”观察者只应见到调度边界的确定结果”是同一种考量:让外部观察永远落在一个自洽的状态上,而非半完成的瞬态。

启发:同帧状态转换要有界——给一个经验上限加 debug 断言,既允许一帧内的逻辑收敛,又防住无限转换。 而切换回调放在状态交换之后,让所有观察者一律看到最终态,能消除”读到瞬态”这一类隐蔽 bug。这两条放在一起,是把状态机的”灵活”和”可控”同时拿住——朴素的递归式状态切换,往往只拿到了灵活,丢了可控。
6.2 玩家动作与战斗:请求调度 + 对象池
玩家的每一个动作——开火、换弹、翻滚、用技能——在这套引擎里都是一个”动作”对象。它们的组织有两个值得说的工程点。
第一,玩家的动作是包装器。玩家状态机里的一个动作,进入时(OnEnter)拉取参数、请求一个具体类型的底层动作、调用 Setup/Queue;它的”是否完成”(IsDone)是把包装器自己的状态和底层动作的状态合并起来判断的。这里再次出现母题:包装器的”完成”,是本地这个动作生命周期的信号,不是外部系统的完成。 一个开火动作的包装器”完成”了,不代表网络已经确认这次射击、不代表命中已经判定、不代表弹药已经对账——那些是更下游的事。
第二,动作的分配走对象池。一款战斗频繁的游戏,每秒要创建、销毁大量动作对象,如果每次都走常规的内存分配,开销会很可观。这个系统用的是带空闲链表(free-list)的 arena 池——按动作类型分别维护空闲链表头,分配时优先复用,arena 的大小是按”动作数量上限 × 单个动作大小 × 一个倍数”预先开好的(源码里这个 arena 尺寸是 ACTIONS_COUNT * sizeof(CAction) * 512)。

启发:高频的战斗动作用按类型分的 free-list 对象池来降低分配开销,但要核对 arena 容量能覆盖峰值并发。 同时,把动作的”请求调度”和”业务完成”分开——一个动作的本地状态,别折叠成”这次攻击在游戏层面彻底完成了”。战斗系统里,”动作播完”和”伤害结算、网络确认”是不同的事,混在一起会让战斗逻辑在联机时出现各种对不上的问题。
6.3 动画与载具:分阶段流送 + 平滑层
最后扼要点出两处表现层的省法,它们各自示范了一种通用手段。
动画这一侧,关键在于把”加载”拆为三个互不假设的状态:请求加载(load-requested)、缓冲就绪(buffer-ready)、采样安全(sample-safe)。一段动画数据报告”已加载”,不代表下一帧对它采样便是安全的——这三态之间隔着流送的中间环节,缓冲就绪只意味着数据已进入缓冲,而采样安全还须等待流送链的后续步骤完成。它还以位压缩的环形缓冲(ring-buffer)支持触发式加载与快速重放,并将每帧的预加载请求限制在约十个之内,以防加载峰值——这与流送、人口、物理处处可见的”每帧上限”是同一种摊平峰值的手段。三态隔离的工程意义在于:它把”中间态不可被当作可用态”这条纪律,固化进了类型与状态机本身,而非依赖每个调用方自觉判断。
载具这一侧,则有一处颇具代表性的”平滑层”设计。车轮与地面的接触由物理查询(trace)得出,而物理查询带有抖动——某一帧未查中、下一帧又查中。若将这种抖动直接传递给音频与视效(轮胎声、尘土特效),表现便会断续跳变。因此这里在物理接触与音画之间垫入一层平滑:接触数据经合成与平滑,即便某一帧物理 trace 未查中,合成出的接触也会继续衰减一段,而非骤然消失。音画消费的是这层平滑后的接触,故听感与观感皆连续。此外,载具的若干状态(如电台台号、被召唤的位置)存于持久化层、与运行时解耦——这使存读档与重载更为省心。平滑层的通用价值在于:任何”物理驱动表现”的环节,都应在物理输出与最终呈现之间垫一道平滑或衰减,不让底层信号的天然抖动直达玩家的感官。

启发:表现层的”省”,靠的是把每件事分阶段、token 化,再用平滑层和选择性跳过抹平峰值。 物理驱动的表现(轮胎接触、打击反馈)尤其要垫一层平滑/衰减,别让物理查询的天然抖动直接传到玩家的眼睛和耳朵。把昂贵的工作摊开到多帧、把抖动的信号过一道平滑——这是表现层在固定帧预算里活下来的常规手段。
6.4 材质:一套模板,怎么撑出满世界的外观变化
表现层还有一块绕不开——材质。一座赛博都市有成千上万种材质外观:生锈的钢、喷漆的金属、磨损的塑料、湿漉漉的水泥……但底层显然不可能是成千上万套独立材质,那样显存和制作量都扛不住。它靠的,是和第四章数据驱动同源的一招——以少生多。
第一层是模板 → 实例。一个 CMaterialTemplate 是一份主材质,定义了一组参数(基础色、金属度、粗糙度、法线、各种 profile)。同一个模板派生出无数个 CMaterialInstance,每个实例只存自己覆盖(override)的差异参数:生锈钢把基础色改成锈红、粗糙度拉到 0.9;喷漆金属把金属度设满、粗糙度压低。实例追踪 base-material 链、注册进依赖表,参数最终被 CompileDataBuffer 按类型编译成一块着色器能读的 buffer。一套模板、千百组覆盖值,就是千百种外观——而且实例只存差异,显存不爆。
第二层,也是最能”生多”的一层,是多层材质 + 遮罩。Multilayer_Setup 把几个图层模板(比如水泥层、漆面层、锈迹层)按一张 Multilayer_Mask 遮罩混合:CreateRenderResource 把层模板引用展平成 rend::MultilayerLayer 数据、应用每层的覆盖参数、按遮罩混合成单一表面。同样几张层贴图,配上不同的遮罩,就是满街风格各异的旧墙、地面、车漆——这正是城市那种”每一面墙都有故事”的脏旧质感的来源。
撑住这套”以少生多”的,还有两个机制。一是着色器缓存:ShouldRecompile 比对材质修改时间戳与源文件,命中缓存就直接拿编译好的字节码,让同一模板的多个变种复用编译结果、不重复编译。二是依赖传播:改了 base 模板,ForceRecompilation 会持依赖锁迭代所有从属实例、递归更新——改一处主材质,满世界用它的实例一起更新。(这里也有坑:迭代时持锁、从属更新会回调渲染器;编辑器改一张纹理甚至会触发全局重编。)

启发:丰富的材质外观,不是堆出无数套材质,而是”少数模板 × 参数覆盖 × 多层遮罩 × 缓存复用”。 这和数据驱动、prefab 是同一条逻辑的第三次现身:内容量要上数量级,靠的是把素材做成可复用、可参数化的单元,而不是线性地堆更多素材。一套主材质定义参数池、实例只存差异、多层加遮罩把几张图叠成无穷变化——这是大世界视觉丰富度的工程答案。
6.5 渲染管线:游戏与渲染怎么解耦,”完成”分几层
最后落到最末端——渲染。这里要先划清楚边界:shader 数学、光照算法、后处理这些算法内核是盲区,不讲。能讲的是结构层——数据怎么从游戏流到 GPU、谁提交给谁、用什么并行。
最核心的结构,是游戏线程和渲染线程的双缓冲解耦。游戏线程要改场景(增删一个渲染代理),不会直接去动渲染场景——而是调 AddPendingProxy,把操作记进一个待处理队列。渲染线程在帧边界用 GetPendingProxiesAndAdvanceFrame 原子地交换这个队列、推进一帧,然后批量地排序/合并、更新 BVH 和代理数组。两条线程靠”一帧的快照”协作,谁也不卡谁。代价同样是那条纪律:代理的”收到”不等于”应用”,增删改甚至可能被折叠成另一个有效操作。
绘制提交也是一条流水线:CPU 侧 Drawer::Finish 把顶点/索引填进 DrawBuffer,SubmitDrawBuffer 只是把它推进队列,真正生成 GPU 命令(MSAA 解析、mip、材质合并)要等后面的 render job。这里有个容易踩的合约细节:DrawBuffer 分配返回的裸指针只到下次同类分配前有效,要持久存的是 offset 而不是指针。
渲染图的节点执行还有个有意思的设计:ExecuteParallel 故意把节点洗牌进确定性的伪随机桶、用无 fence 的方式批量跑——这是在强制”节点声明顺序 ≠ 执行顺序”,逼你把任何真实的前后依赖显式建成渲染图依赖,否则就会随机出错。代价是节点实现必须自己守住渲染流分配和命令列表所有权的规则,没有 fence 兜底。
而把全篇那条主轴推到极致的,是渲染的”完成五层“。”提交渲染”从来只是受理,真正的完成要穿过五个不同的信号:Attempt(受理)→ Queued(入队)→ Job(已派发、生成命令)→ GPU Fence(GPU 真正执行完)→ Visible(present 之后玩家才看见)。每一层都只完成自己那一步。截图就是个好例子:命令入队、FrameTick 派发、延迟 job 写文件、回调从延迟 job 触发——回调不是从你原来的调用点回来的,缩略图没就绪时还会合理地跳帧。

启发:渲染是全篇”受理 ≠ 完成”这条纪律最长的一条链——五层信号,每层只完成自己那一步。 游戏线程往待处理队列记、渲染线程原子交换后批量应用,是 game/render 解耦的经典做法;而”提交 ≠ 送达 GPU ≠ 可见”则把异步推到了极致。如果你要做渲染层,第一件要内化的事就是:别假设”我提交了”等于”画出来了”——GPU 的完成要等 fence,玩家看见要等 present,这中间隔着好几个你必须分别对待的阶段。
🔧 设计复盘 · 表现层的省
为什么会这样设计? 因为表现层每帧须喂出成百上千次的动画采样、物理查询、特效计算,同帧内同步硬算必然击穿帧预算。将每件事拆为”请求→中间态→完成”的异步零件,以环形缓冲、对象池、平滑层、每帧上限把峰值摊平,是表现层在固定帧预算下得以维系的根本办法。状态机的有界转换、动作的对象池、动画的三态隔离、载具的平滑层,皆是同一压力之下的不同应对——它们看似分属四个子系统,实则共享同一条设计原则:把昂贵的工作摊开到多帧,把不可信的瞬态挡在消费者之外。
踩过什么坑? 最隐蔽的是”状态报告的错位”——动画缓冲报告”已加载”,采样却尚不安全;动作包装器报告”完成”,游戏层面的结算却尚未发生。将这些中间态当作终态使用,画面上往往看不出异常,直至某一帧采到了脏的姿态、或对着一个尚未真正完成的动作结算了伤害。这类故障的共同点在于:它出在”对完成时机的判断”,而非逻辑本身,因而极难复现与定位。
传统方案差在哪? “加载完即采样、动作播完即视作结算完成、物理查中即直接喂给音画”,在低密度场景中尚无问题,到城市级的表现密度便会瞬间击穿帧预算,并令物理查询的天然抖动直接传导至玩家的视听。分阶段、token 化、加平滑层,并非为了形式上的优雅,而是被帧预算逼出的必需——朴素的同步写法节省的是开发者的心智成本,却把这份成本转嫁为玩家所见的卡顿与跳变。
第七章 · 联机与底座:复制、内存,以及那条贯穿全篇的纪律
受理不等于完成——这是贯穿全篇、从任务调度到渲染的异步引擎底层纪律。
这一章要解决的问题:上面所有的系统,都站在同一层地基上——联机同步、内存分配。这层地基怎么撑得住?以及,前面六章里反复闪现的那条工程纪律,到这里该收个口了。
这一章是收束。我会先把联机复制和内存这两块地基点到,然后把全篇散落各处的一个共性——几乎每个子系统的 API 返回都只是”受理”,真正的完成靠后续阶段交付——集中讲清楚,并且坦白它的来历。
7.1 联机复制:receive → queue → apply 三阶段
一款支持多人的开放世界,要把一台机器上的状态同步到另一台。这套引擎的复制(replication)系统,把这件事严格拆成三个阶段:
- 接收(receive):收到网络数据时,
OnDataReceived先把数据入队,并不立即应用。 - 应用(apply):真正的状态应用在
ApplyStateUpdates里——这时才创建对象、应用状态、触发回调。 - 发送(send):而发送侧,
SendUpdates只是一次”发包尝试”,不代表远端已经收到、更不代表已经应用。
这套三阶段设计里,藏着几个要小心的工程现实。其一,发送只是尝试——SendUpdates 返回成功不代表对端收到了。其二,可靠通道和不可靠通道的溢出处理是不对称的:可靠(reliable)队列溢出,是一个严重错误(critical);而不可靠(unreliable)队列溢出,是静默丢弃。这个不对称是合理的——可靠通道承载的是不能丢的关键状态,溢出了必须报警;不可靠通道承载的是可以丢几帧的高频状态(比如位置插值),溢出了丢掉就丢掉。其三,新鲜度靠序号的回绕(wraparound)来判断——用半区间比较来识别哪个状态更新、哪个是过时的乱序包。

启发:复制的三阶段(收 / 入队 / 应用)各自用独立的锁,能保证每一帧的安全,但不提供全局的原子性。 而可靠通道和不可靠通道,要分开设计溢出策略——关键的、不能丢的行为走可靠通道并对溢出报警,高频的、可丢的状态走不可靠通道并允许静默丢弃。把这两类混用一套策略,要么让关键状态被悄悄丢掉,要么让高频状态把可靠队列撑爆。
7.2 内存:三层池 + 显式线程注册
支撑这一切的最底层,是内存分配。这套系统按分配的尺度分了三层:
- 小分配走每线程的无锁 slab 分配器(lockless slab)——但有一个前提:线程必须显式注册过,未注册的线程用不了它,会直接报错(fatal)。
- 中等分配走 TLSF(一种适合实时系统的分配器)。
- 大分配直接走系统分配。
各层各自有独立的锁,避免了一个全局锁成为所有分配的竞争瓶颈。这里有几个工程细节值得一提:TLSF 的原地增长(grow)被禁用了(注释里标着这条路有 bug),所以 realloc 只能走”分配新的、拷贝、释放旧的”,指针不保证稳定;帧分配器(FrameAllocator)是无锁的指针推进(bump),只支持 LIFO 式的回退。
最值得说的,还是那个显式线程注册的设计。用最快的无锁 slab 是有门槛的——你的线程得先注册过。这是一个 opt-in(主动选择加入)而非隐式的契约:引擎不替你猜哪个线程该用 slab,而是要求你明确地把线程注册进来,注册了才有资格走这条无锁快路,没注册就直接失败、不给你蒙混过关的余地。

启发:内存按尺度分层——小用无锁 slab、中用 TLSF、大用系统——匹配不同的负载特征。 而用”显式线程注册换无锁快路”是个好模式,但要把”未注册即失败”作为明确的契约,而不是悄悄回退到一个慢路径。明确的失败比隐式的降级好——前者让你立刻发现”这个线程忘了注册”,后者会让你在某个慢得莫名其妙的路径上排查半天。
7.3 收口:一条贯穿全篇的纪律——”受理 ≠ 完成”
行至此处,前面每一章都在反复印证同一件事。现将它们汇齐——这份清单本身,就是一幅全篇的索引:
- 第一章,任务调度的
RunJob只是发起,job 是否开始执行要看计数器是否归零(它可能仍在等待列表中)。 - 第二章,扇区的
RequestLoad只是发起请求,扇区是否就位要看后续轮询;遮挡器的OnInitialize返回 true,也不代表它已注册、已附加。 - 第三章,NPC 的
AddEntity只是入队受理,NPC 是否出现要等整条生成令牌链;工作点的SendCommand只是收下命令,动作是否播完要等完成回调。 - 第三章,物理的
AttachProxy只是记下一个待处理操作,代理是否进入场景要等模拟的处理阶段;异步查询的Success也不区分”命中”与”未命中但已计算”。 - 第三章,导航的
RegisterTiles只是入队、StartPathfinding只是返回令牌,瓦片是否可用、路径是否就绪都要后续轮询。 - 第五章,场景的请求 ID 只是受理凭证,过场是否演完要看 tick 状态机。
- 第六章,渲染的提交穿过”受理→入队→job→GPU fence→可见”五层,每层只完成自己那一步。
- 第七章,复制的
SendUpdates只是一次发包尝试,对端是否收到无从保证。
这是同一条纪律在不同子系统中的反复投影:几乎每个子系统的 API 返回,都仅代表”请求已受理”,而非”工作已完成”。真正的完成,由令牌(token)表示中间态、由多阶段的信号链、由回调来交付。
须如实指出:这条纪律并非这款游戏独有的发明。任何一个严肃的异步引擎——无论面向哪一类游戏,无论是这一款还是其他——只要它须在固定的帧预算内调度海量异步工作,迟早都会走到”将受理与完成分离”这一步。它是现代异步引擎的共性,而非这款游戏的专利。
但这款游戏将它贯彻得格外彻底。彻底到阅读其代码,会在几乎每一个子系统中反复与这条纪律相遇——加载、生成、动作、叙事、复制,无一例外。而正如第一章所揭示的,这份彻底并非偶然:当整个引擎的异步执行都建立在 job 调度器”发起即返回、归零方完成”的语义之上时,”受理与完成分离”便不再是各子系统各自的约定,而是自底层调度器一以贯之、向上层层传导的同一种结构。这种彻底,本身即是工程成熟度的体现:它不在任何一处图省事地假设”调用即完成”,而是自始至终守住”受理不等于完成”的边界。

启发:异步接口要把”受理”和”完成”做成两个可观察的状态——这是所有异步引擎的通用纪律,越早内化越好。 别让一个接口的返回值,同时背负”我收到请求了”和”我做完了”两个含义。把它们分开,让调用方能分别观察、分别等待,是异步系统不出”幽灵 bug”的根本。这条纪律不华丽,但它是把异步做对的地基。
🔧 设计复盘 · 地基的统一纪律
为什么会这样设计? 因为上层所有系统都要求”能渐进加载、能热重载、能并发、能联机”,地基若提供的是同步、强一致、一旦失败即中断的接口,上层那套异步能力便无从谈起。将延迟(令牌、延迟缓冲)、解耦(队列、多阶段)、软失败(默认回退、分级成功)贯彻至数据、序列化、联机、内存这些最底层,正是为了让上层的异步契约得以成立。地基先行接受”延迟与不确定”,上层的能力才有承载之基。
踩过什么坑? 最隐蔽的,仍是地基层的”假成功”——完整性校验通过,数据未必语义有效;序列化成功,对象未必就绪;发包返回,对端未必收到。任何一处将”外层的成功”误作”里层的完成”,故障都会上浮至上层发作,却极难回溯至地基这一层来定位。
传统方案差在哪? “加载完即可用、序列化成功即完整、发出即送达、任意线程皆可分配”——这些同步强一致的假设,在单机小型项目中尚且成立,到流式大世界叠加多人联机便会逐条失效。地基必须先行接受”延迟与不确定”,上层的异步契约方有立足之地。这条纪律的真正价值,不在于它如何精巧,而在于它被一以贯之地守住——而这,恰是最难之处。
结语:一座可叙事、可养成的城市,是怎么被工程接住的
我们从一个观察出发——这款开放世界最大的一块代码,不在物理、不在渲染,而在”怎么演一段剧情”;它最复杂的一套数据,是用来描述”一个角色能怎么改造自己”——走过了底座、流送、人口、RPG 核心、叙事、表现、联机七层。现在回头看,开篇那个观察的分量已经清晰:
两款”开放世界”之间的工程分野,是真实存在的。 上一篇那款开放世界,把最重的预算押在”让海量对象有序地动起来”;而这一款,因为它是一款 RPG,把最重的预算押在了另外两座山上——让一座城市能讲故事,让一个角色能被养成。同样是”开放世界”这座山,因为游戏类型不同,工程的重心压在了不同的坡上。
把全文收束成一句话:
做一款 RPG 都市开放世界,难点不只是”让海量对象动起来”,更在于”让一座城市能讲故事、让一个角色能被养成”——这要求把玩法搬进数据(内容才上得了量、平衡才调得动),把叙事做成图执行加事实总线(几十条线才能并行、可存读、互相影响),再把成本一层层分下去(城市才装得下、才有人气)。
如果说这趟旅程留下三句最值得记住的判断,那便是:
数据驱动,是 RPG 深度的工程地基。
叙事是工程,不是脚本。
受理不等于完成,是异步引擎的底层纪律。
第一句解释了内容为何要离开代码、走进数据;第二句解释了为何全引擎最大的模块会是叙事;第三句则贯穿了从任务调度到渲染的每一层——它们分别对应本篇的三条副线,现在可以逐一收口了:
- 内容即数据——从第四章那个一百八十万条的数据驱动库,到义体、技能、武器、物品、世界物件行为全部用数据来描述,”把玩法从代码里搬进可编辑的数据”,是 RPG 内容能上数量级、平衡能不停服调的同一条底层逻辑。
- 叙事是工程——从第五章那个全引擎最大的场景系统,到索引化续延的任务图、锁内同步触发的事实总线、三段式的存读档,”叙事是一个状态机集群,不是一堆脚本”,是这款游戏投入最重的同一个判断。
- 成本分层让城市有人气——从第二章掩码驱动的扇区流送,到第三章 stub-first 的人口和三层人群,”让对象先以最廉价的形态存在、按预算才实体化”,是一座垂直高密度的都市能装得下、跑得动的同一条逻辑。
如果要把这趟旅程浓缩成几条能直接拿去用的启发,我会留下这八条:
- 任务调度是一等地基。 用优先级通道表达轻重缓急、用计数器加等待列表表达依赖先后,海量异步任务才能在固定帧预算下既不死锁、又不空耗。上层一切系统的异步能力,都建立在这套调度器之上;它”发起即返回、归零方完成”的语义,正是全篇那条主轴的源头。
- RPG 的深度,首先是一个数据工程问题。 把玩法(数值、关系、内容)从代码搬进一套可编辑、可热重载的数据库,是内容上量、迭代提速的前提。代价是你须亲手扛起”数据正确性”——那层原本由编译器免费提供的保障。
- 叙事要做成图执行加事实总线,而非顺序脚本。 任务是事件驱动的图,以索引化续延支持并行;跨线影响以一张全局事实表充当总线;存读档分 pre-save、load、restored 三段,让一个鲜活的世界能被精确地存下再续上。
- 城市的人气靠成本分层。 NPC 默认是一条廉价的存根,按预算、可见、距离才实体化;远近不同的人分为完整 NPC、简化群体、纯视觉近似三层;环境互动以”需求加预约”和”提交加完成双信号”做成可调度的系统。
- 流送的”完成”是分阶段的。 资源就绪、世界就绪、代理注册、渲染挂载是不同的信号,不可以一个笼统的”已加载”含混带过;尤须警惕”完成掩码中的假成功”。同一份扇区组织,还须从空间面(切分与可见性裁剪)一并理解。
- 以少生多,是大世界内容量的统一答案。 数据驱动的记录加扁平值、prefab 的一份定义加多份实例、材质的模板加实例覆盖加多层遮罩——三者同属一条逻辑:内容量上数量级,依靠的是把素材做成可复用、可参数化的单元,而非以人力线性堆砌。
- 将同步中间件接入异步引擎,靠”独占窗口加延迟交付”。 物理是典型:以 pending 队列收拢场景改动、以模拟的处理阶段统一执行、以延迟回调与延迟释放对齐时序——既保住中间件的同步语义,又使其能在固定帧预算下与其他系统并行。导航的”网格随流送、路径靠令牌”亦是同一思路在另一域的体现。
- “受理不等于完成”是异步引擎的通用纪律。 让每个异步接口将”已收到请求”与”已完成工作”做成两个可观察的状态。这条不华丽,却是把异步做对的地基;渲染的”受理→入队→job→GPU fence→可见”五层,是它最长的一条链。这款游戏的成熟,很大程度正体现在它将这条纪律一以贯之地守住。
最后,必须把诚实的边界说清楚。我能讲透的,是”运行时怎么被组织起来”——世界流送、人口、数据驱动、叙事图、状态机、网络复制这些架构层面的”为什么”。我没读透的,是底层算法的内核:碰撞求解器、shader 数学、navmesh 的生成、寻路求解的内部、音频 DSP——这些我只到结构层或接入点,文中每一处都标了出来。还有一类东西我刻意没写:具体的数值玩法设计——某把武器该怎么配、某套 build 怎么搭、某个义体的平衡如何——那是策划的设计,不是引擎的工程,我手上的材料也不覆盖,不会去编。把读透的讲透、把没读透的标清楚、把不属于工程的留给设计——这是这篇文章想守住的纪律。
和上一篇放在一起看,会更有意思。上一篇看的那款开放世界——重心在怎么让海量对象有序地动、谁拥有状态、成本怎么编码进对象;这一篇看的是一款 RPG 开放世界——怎么让城市可叙事、角色可养成、内容怎么搬进数据。两篇拆的是同一座”开放世界”的山,但因为游戏类型不同,看到的是两条截然不同的上山路。把它们对照着读,你会对”开放世界”这四个字背后的工程多样性,有一个更立体的认识——它从来不是一套标准答案,而是一组随游戏类型而变的工程取舍。
这同样只是一个开始。这篇深入剖析,是系列里继续往下走的一站。 接下来,我会沿着这一篇里的几个深点继续挖——数据驱动库到底怎么组织那百万条记录、叙事图加事实总线怎么把几十条任务线编织起来、stub-first 的人口怎么让一座空城长出人气——每一个,都会从”是什么”讲到”为什么这么设计”。而整个系列真正想抵达的终点始终没变:怎么在今天的引擎(比如 UE)里,把这些能力重新做出来。 一款 RPG 开放世界所需要的数据驱动、叙事编排、人口分层,在 UE 里该怎么重新定制——这是我想一步步走到的地方。
城市很大,故事很多,但把它们做出来的道理,是可以一层层讲清楚的。我们下一篇见。
*本文基于对一款成熟商业 RPG 都市开放世界游戏的引擎与客户端代码的源码级逆向阅读整理,覆盖范围从底层引擎一直延伸到玩法、叙事、联机、存档等游戏端系统。文中所有架构、命名均已通用化处理,讲的是”这一类 RPG 开放世界游戏的典型设计”,而非特指任何一款具体产品。读透的深讲、没读透的标注盲区、不属于工程的留给设计——这是写作的纪律。*
《如何制作开放世界游戏:一座可叙事的城市》有1条评论