从规则到世界:程序化地形的 C++ 运行时验证

——从逆向生成模型到 UE5 落地前的工程验证

AI 协作做游戏 · 资产逆向系列 · 地形落地前验证篇

定位:本系列前几篇把一款游戏的程序化关卡逆向拆开——地形篇读出「地形不存高度图、而由规则和种子现算」的生成配方,UE5 实现篇把落地思路铺成理论。但逆向出规则只完成了一半。这一篇,用一次真实的 C++ 落地实测,回答「这套规则能否脱离分析环境、在正式语言与运行时中复现」以及「生成成本是否撑得起真实游戏的加载流程」。 它是整条链上从「理解一个成熟系统」到「自己实现一个系统」的桥梁——落地之前的工程验证。

引子:从逆向规则到工程验证

在前面的程序化关卡逆向系列里,我们回答了一个问题:一颗星球的地形不是一张地图,而是一套可以由规则和种子生成的配方。 地形篇读出了这套配方——地形不存高度图,而是给一套规则、一个种子,运行时现场算出来;UE5 实现篇顺着这个思路,把整片地归结成一个纯函数:给定坐标 (x, y) 和种子,返回该点高度,三层自内向外——离散平台搭骨架、噪声铺肌理、印章盖可控内容。

但逆向得到规则,只完成了一半。 真正要把它变成游戏开发的一环,还得回答两个工程问题:

  1. 这套规则能否脱离分析环境,在正式语言和运行时中复现?——原型是浏览器里的 JavaScript,正式引擎跑的是 C++,两者算出的地必须是同一片。
  2. 生成成本是否足以支撑真实游戏的加载流程?——运行时现算意味着开图瞬间上千万次求值,慢了玩家就得干等。

这两个问题各自藏着一个必须用真实数字回答的追问。一致性这一头:原型是浏览器里的 JavaScript,便于实时预览与调参,但它不是引擎;用 C++ 重写并搬进引擎时,「重写」二字之下藏着一个隐蔽风险——相差一个像素无妨,但若某个环节译写有误,整片地形会长成另一个样子,此前数月对着原型调校出的地貌便前功尽弃。性能这一头:理论篇给出的方向是「后台并行烘焙即可」,但「即可」二字之下,是一个绕不过去的数字——在一台普通玩家的机器上,生成一片 2K 分辨率的地形,究竟需要几秒?

先把定位说诚实:这套算法和这个 demo,都还处在初期。 它是一次探路——验证「运行时生成地形」这条路在工程上走不走得通,而不是一个打磨完成的成品。地貌还在逼近真实游戏的过程中,参数也还在迭代(第三章会讲到,一部分参数至今只能靠反推逼近、无法精确复现)。本篇要交付的,是「这条路可行」的证据,不是「这套地形已经做完了」的宣告。

这一篇要做的,就是把开头那两个问题坐实。方法很直接:将 JavaScript 原型的地形函数逐字移植为 C++,再验证两件事——它与原型逐像素一致(形态正确),它在普通机器上足够快(性能可承受)。 前者以「差异图」为证,后者以「加载阶段的耗时」为证。

先给结论:两个问题的答案都是肯定的。 C++ 版与原型逐像素一致,误差仅到浮点舍入的最后一位;性能上,一片 2K 地形在八核机器上约 1.5 秒生成完毕——纳入加载流程毫无压力。但通往这两个结论的路上,踩中了两处「足以让整片地形失真」的精度陷阱,也暴露出一个「慢了二十倍」的低效实现——这些才是本篇真正值得讲述的部分。


一、方案选型:为什么是「运行时 CPU 生成」

动手之前,先说清一个可能被质疑的前提。「运行时生成地形」听起来费劲,而摆在面前其实有三条路:离线全烘焙(美术烘好高度图、打包进游戏,开图直接读)、GPU 生成(用显卡的并行算力现场算)、运行时 CPU 生成(本篇的选择)。前两条是很多人的第一反应,却都被否掉了。把「为什么不是它们」讲清楚,才能说明「运行时 CPU 生成」不是唯一会做的做法,而是想明白之后的选择。

为什么不能「提前全烘焙」

大多数游戏正是这么做的——美术烘一张高度图,存盘,开图加载。稳、快、省心。但这条路在这里走不通,被三条硬需求逐一堵死:

其一,要能「换种子重新生成」。 这是这类游戏的核心机制——同一批星球反复游玩,每一局的地形都需不同,否则第二遍便索然无味。地形若是离线烘焙的一张固定图,换种子也变不出新的地貌。只有运行时生成,才能让种子真正驱动地貌——换一个种子,Voronoi 的剖分就变了,整片地的布局随之改变。地形是「活」的,这是玩法的前提,而「活」的代价就是不能烘死成一张静态图。

其二,联机时不能让地形占带宽。 一片 2K 高度图存成文件是几 MB,若要在联机时确保各端地形完全一致,最直接的办法是主机把这几 MB 传给所有人——这在实时游戏里是不可接受的流量。而「纯函数 + 种子」给了一个漂亮的解法:服务器只下发一个整数种子,各端各自用同一个函数算出同一片地,分毫不差。 地形不走网络,带宽占用为零。这是「算」相对「存」在联机上的决定性优势,但它有个前提——这个函数必须是确定的:同样的输入,任何机器、任何时候,都得给出逐位相同的输出。这条「确定性」要求,会在后文的一致性验证一章变成一个非常具体的技术约束,也是下面把 CPU 定为权威路径的关键。

其三,数百颗星球烘不起,也维护不起。 这条地形篇已经讲透——没有哪个团队会为数百颗星球手工烘出同样数量的地形图。规则化生成才能用极少的数据撑起整个星系。

需要澄清一个容易混淆的点:否掉「全烘焙」,不等于否掉「烘焙」本身。 落地时仍有一个「编辑器烘焙」的环节——那是开发期把一颗星球的地形固化成资产、供美术精修和做「标准答案」用的,属于工具链的一部分。这里否掉的是「把成品地形提前烘死、运行时只读」这种发布形态,而非烘焙这个动作。二者的区别,在第四章的落地路线图里会看得很清楚。

为什么暂时选择 CPU 作为权威生成路径

既然地形本质是「对每个像素并行地算一个函数」,一个自然的念头是:这不正是 GPU 最擅长的吗? 上千万个像素一起算,显卡的并行度远超 CPU。为什么不把这个函数写成计算着色器,丢给 GPU?

这个念头本身没错,但它和上一节那条「确定性」底线之间有几处需要额外成本才能调和的地方。综合权衡下来,当前阶段把 CPU 作为权威生成路径,是更稳妥的选择。三点考量:

其一,跨设备确定性有额外成本。 联机的前提是各端算出逐位相同的地——服务器据此才能判定谁在作弊。GPU 并非无法实现确定性计算,但要在不同显卡厂商、不同驱动版本之间保持逐位一致,需要额外约束计算路径(避免依赖实现有差异的超越函数、乘加融合等),这是一笔不小的工程投入。对渲染而言末位差异无所谓,但对「必须逐位相同」的权威地形计算,把这份约束加在 GPU 上,不如直接用行为可控的 CPU 整数/规范浮点来得省心——这正是本篇花大力气验证「逐像素一致」时所依赖的基础。

其二,权威服务器需要独立重放。 反作弊要求服务器能独立、无渲染地把同一片地算出来,与客户端结果比对。而游戏服务器通常是不带显卡的机房机器——因此生成逻辑不应依赖客户端的 GPU 环境,否则服务器无从复算、也就无从验证客户端是否篡改了地形。权威计算要能在纯 CPU 的服务器上重放,这一条让 CPU 成为权威计算源的自然选择。

其三,权威与渲染分离,而非二选一。 把 GPU 从权威计算里请出来,不代表它无事可做——它照样负责它最该干的活:渲染、光照、地表材质的着色增强。真正的分工是「CPU 算权威高度(确定、可验证、可联机重放),GPU 做渲染呈现(快、美、不参与判定)」。把「算什么地」和「怎么把地画好看」分到 CPU 和 GPU 上,而不是让 GPU 既算又画——这才是这套方案的职责边界。(也就是说,未来若为特定平台把生成也搬上 GPU,那属于渲染侧的加速副本,权威仍在 CPU。)

三条需求否掉了全烘焙,三点考量让 GPU 暂时退居渲染侧,剩下的运行时 CPU 生成,就是当前阶段同时满足「可换种子、可联机、可验证」且工程成本最低的一条路。而这套方案的性能账,就落在了本篇要验证的这个 CPU 函数身上。

三方案选型对比:离线全烘焙(换种子✗/带宽✗/维护✗)、GPU 生成(跨设备一致✗/服务器无 GPU✗)、运行时 CPU 生成(三项全绿),底部标注 GPU 仍负责渲染

*配图占位:三栏对比表。第一栏「离线全烘焙」——三个红叉:换种子无效、联机传数 MB、几百张图难维护。第二栏「GPU 生成」——两个红叉:跨设备浮点不一致(无法联机判定)、服务器无 GPU(无法反作弊复算),一个灰注:仅适合渲染。第三栏「运行时 CPU 生成」——全绿:换种子即变、只传种子、可在无 GPU 服务器重放。底部一条横带:CPU 算权威高度 + GPU 做渲染呈现,职责分离。白底浅色风。*


二、原型是对的,但原型是 JavaScript

前面几个月的工作,产出了一个能用的地形函数——但它活在浏览器里,是用 JavaScript 写的。

这个选择当初是对的。地形调参是个极度依赖肉眼的活:高度分布对不对、陡坡够不够、坑会不会太深,全得看着图一轮轮改。浏览器 + JavaScript 给了最快的「改一行、立刻看到结果」的循环——一个网页里,左边滑条调参数,右边实时渲染出地形的三维预览,还能从下拉里切换数据源,把真实游戏的地形图和我们生成的并排比对。这套原型环境把「对齐真实游戏地貌」这件事的迭代成本压到了最低。

预览工具里:左为真实游戏的地形高度图(逆向导出),右为我们生成的地形,同一渲染器绘制

*配图占位:左右并排两张预览工具的三维渲染截图。左=真实游戏地形(tool_real_h6.png,圆盘形,是贴在星球表面的一块区域),右=我们生成的地形(tool_gen_424242.png,方形地块)。形状边界不同(真图是圆盘、我们是方形 tile),但内部的坑、丘、洼地、平台等地貌纹理属于同一类。可加一行标注「边界形状不同,内部纹理同类——统计上难以区分」。*

(这套对齐工作本身——高度分布怎么从「扎堆」调成「铺开」、坑怎么从「深坑」修成「浅碟」——是另一段故事,这里只需要知道:原型已经调到位了,它生成的地貌被验证与真实游戏的高起伏地形在统计上难以区分。

而「换种子重新生成」这件事,在工具里也能直接看到——同一套配方,只换随机种子,就长出布局完全不同、但风格一致的地:

同一配方换三个不同种子生成的地形,布局各异但风格统一

*配图占位:三张横排的预览工具三维渲染截图(tool_seed_20260705.png / tool_seed_424242.png / tool_seed_77777.png),同一套配方、三个不同种子。三片地的坑群、洼地、高台位置各不相同,但都是同一种地貌风格。标注「同配方 · 换种子 · 布局重生成——这正是第一章说的『地形是活的』」。*

但原型终究是原型。真要落地,有一条底线绕不过去:权威的地形计算,必须在 C++、在 CPU 上、在引擎里跑。 理由回到第一章那三条需求——尤其是联机确定性:各端要算出逐位相同的地,就不能一端跑 JavaScript、一端跑 C++ 各算各的;权威版本只能有一个,而这个版本必须是引擎能直接调用的 C++。JavaScript 原型的历史使命,是把地貌调对;它的终点,是被一份逐像素等价的 C++ 实现接替。

于是任务变得很具体:把这个 JavaScript 函数,逐字翻译成 C++。 听起来是个机械活——照着抄就是了。但「照着抄」恰恰是最危险的错觉。这个函数内部大量使用哈希噪声——它们对每一个比特都敏感,某个整数运算的行为在两种语言里差一丁点,输出就不是「稍微不同」,而是「面目全非」。翻译的难点不在逻辑,在于两种语言在整数、浮点、位运算上的细微行为差异,必须被逐一对齐

这些细微差异的接缝上就藏着两个陷阱,它们的共同特征是:不会报错、不会崩溃,只是让地形悄悄长错——而且是整片长错(第五章详述)。但在钻进那两个陷阱之前,得先框定一件事:C++ 要复现的到底是哪套规则、这套规则里哪些是确定的、哪些还只是逼近——这决定了「一致」这两个字到底该拿什么当标准。这是下一章的事。


三、C++ 移植前提:需要复现的是哪套地形规则

在把函数搬进 C++ 之前,必须先框定一件事:要复现的到底是什么。 C++ 这一步的任务不是「设计一套地形算法」,而是「把逆向得到的那套规则,一字不差地在正式语言里重建出来」——因此得先说清这套规则从哪来、哪些部分是确定的、哪些是逼近的。否则后面验证「C++ 和原型一致」,就成了「和一个来路不明的东西一致」,验证也就失去了意义。这一章交代的,正是移植所要复现的对象。

从逆向到可执行模型:不是发明

这套地形算法的骨架,是逆向得来的,不是我们坐在白板前想出来的。本系列前几篇做的正是这件事:把那款游戏的地形生成拆开,读出它的「配方」——地形不存高度图,而是用一套规则现算;规则的核心是三样东西:

  • Voronoi 切块——把地面剖分成不规则的多边形块;
  • 6 档离散高度——每块从 6 个预设高度里挑一个,块与块之间是跳变的(这就是那种「大块平台 + 陡坡」地貌的来源);
  • Stamp 印章——用圆、矩形、样条往地上盖基座、道路、坑洞这些「必须确定」的内容。

这三样是逆向读出来的明文规则,是我们这套算法的出发点。所以严格地说,我们做的不是「设计一套地形算法」,而是「把逆向出的规则,实现成一个能跑的函数,并调到与真实游戏的地貌足够接近」。发明的成分很少,还原和逼近的成分很多。

参数获取:直接读取与统计逼近

规则的骨架是明文,但「具体参数」是另一回事——一部分能直接读,一部分只能从成品统计着逼近。 这直接决定了我们用两种不同的方法来获取参数:

方法一:正向分析——从明文规则里直接读。 那款游戏的类型定义(描述所有数据结构的元信息)是明文的,能读出一批确定的参数:地形块是 6 档离散高度、尺寸按四叉树倍增(31→63→127→255 米)、顶点密度恒为每 0.5 米一个、印章分圆/矩形/样条几类。这些是逆向直接给出的硬参数,照着实现即可,没有猜测成分。

方法二:对比结果反推——从成品高度图倒推统计规律。 问题在于,真正决定「这颗星球长什么样」的那批生成参数(具体的区域规则数值、切块参数、高度分布),被认证加密锁死了——文件信息熵接近满值、每个文件独立加盐,静态破解没有入口。这条路走不通,就换一条:既然拿不到生成参数,那就从它生成的成品(高度图)反推。 具体做法是——把真实游戏的高度图导出一批,量出它们的统计指纹(起伏跨度、高度分布形态、坑丘的密度与深浅、聚集程度等十来项指标),然后调我们自己算法的参数,让我们的输出在这些统计指标上,落进真实高度图的范围内——落进去了,就说明「统计上分辨不出这是我们生成的还是游戏生成的」。

这套反推有一套自检框架,回答三个层层递进的问题:能不能对齐(我们的统计指标是否落进真图范围)、会不会撞车(多个种子生成的地是否真的不同,而非千篇一律)、盖不盖得全(换种子能否覆盖真图那样的多样性,而不是只会生成一张标准图)。这套框架本身可以复用到任何「拿不到源算法、只能从成品反推」的逆向场景。

两类参数落到实处,大致是这样分的:

  • 确定参数(直接读,精确):Voronoi 切块方式、6 档离散高度、地块四叉树尺寸、顶点密度、Stamp 印章类型。
  • 逼近参数(统计对齐,非精确):高度分布形态、坑与丘的比例、整体起伏程度、聚集/离散程度。

这里就要诚实标注边界了:正向能读的参数是精确的,反推的参数是逼近的——我们只能做到「统计上难以区分」,做不到「和某颗具体星球逐块相同」(那需要它的种子和加密的生成数据,拿不到)。这也是为什么说这套算法还在初期:一半参数靠反推逼近,随着分析深入还会继续调整。当前的成果是「地貌在统计上与真实游戏的高起伏地形难以区分」,这已经足够支撑「运行时生成」的可行性验证,但它不是终点。

两种参数还原方法对比:正向分析(读明文类型定义→硬参数,精确)vs 对比结果反推(成品高度图→统计指纹→调参逼近,因生成参数加密),标注「逼近而非精确复现」

*配图占位:左右两栏。左栏「正向分析」——图标是一份「类型定义/字段表」,箭头指向「6 档高度 / 四叉树尺寸 / 印章类型」等硬参数,绿标「精确·直接读出」。右栏「对比结果反推」——上方一把锁标注「生成参数加密锁死」,下方一张真实高度图→提取「统计指纹(起伏/分布/坑丘密度…)」→我们的算法调参→两条分布曲线逐渐重合,橙标「逼近·统计不可区分」。底部一行:正向读硬参数,反推逼近软参数——后者决定了当前仍是初期。白底浅色风。*

三层生成模型:只保留与 C++ 验证相关的部分

参数就位,算法把地形归结成一个纯函数——给定坐标 (x, y) 和种子,返回该点高度。它三层嵌套、自内向外求值:骨架(Voronoi 切块 + 6 档离散高度,决定「大块平台 + 陡坡」的地貌签名)→肌理(每块平台上叠加多层噪声,细化出可作战的缓坡)→可控内容(印章把降落区/基座压平、把道路压通、把坑放到设计位置)。关于这个模型的完整拆解,前文已有详述,这里只挑出两条与后续验证直接相关的约束:

  • 数值秩序:肌理层的噪声振幅必须远小于骨架层的档位间距。一旦起伏盖过台阶落差,「大块平台」的语义就瓦解,退化成普通噪声丘陵——这条约束在移植时必须原样保留,改错一个系数就会让地貌失去辨识度。
  • 叠加顺序:印章必须在噪声之后。先压平再叠噪声,压好的基座会被重新扰乱。顺序错了不会报错,只会让地形悄悄长歪——正是第五章要防的那类「不报错的错」。

而这个「骨架层」,恰恰是后面第六章性能瓶颈的所在——它每算一个像素要嵌套查询几十个 Voronoi 站点。这里先记住它是三层里最重的一层。

三层生成模型剖面:骨架(Voronoi + 6 档离散)→ 肌理(噪声,振幅 ≪ 档距)→ 可控内容(印章压平/下挖),标注两条移植必须保留的约束

*配图占位:从左到右三个剖面演进。第一格「骨架」——几块高低不同的平台,台阶状跳变。第二格「肌理」——平台表面叠上小起伏(缓坡),标注「振幅 ≪ 档距」绿勾,旁边一个反例「振幅 ≥ 档距→退化成丘陵」红叉。第三格「可控内容」——在起伏地形上盖一个压平的圆形基座 + 一条道路,标注「印章必在噪声后」。白底浅色风。*

结构性难题:高度分布「一坨」,以及怎么解开

三层合成拼出来的地,形状对了,但有一个反复出现、怎么调参都解不掉的毛病:高度分布「扎堆」。 真实游戏的高度是「铺开」的——从低到高比较均匀;而我们的地,高度总是挤在中间一坨,两头稀疏。渲染出来就是「中间一大片同高、四周零星高低」,和真图那种层次分明的地貌不像。

一开始以为是某个参数没调好,换着调了好几轮——全都没用。 后来才想明白,这不是参数问题,是结构问题:我们的高度是多层噪声叠加出来的,而多尺度噪声叠加,会让高度值倾向于向中间区域聚集——这与「多个随机量相加后取值趋于集中」的现象是同一类。换句话说,「扎堆」是这种合成方式自带的倾向,不是某个参数没调对。只要合成方式还是「多层噪声相加」,无论怎么调各层的参数,都很难把这个集中趋势摊平。

既然如此,问题就不该是「继续调噪声参数」,而应转向「调整分布的映射方式」——解法落在直方图匹配上。做法是——把生成的整片高度,按每个点在本图里的高低排名(分位),重新映射到真实游戏高度图的分布曲线上。通俗说:如果真图里「最高的 10% 区域」覆盖某个高度范围,就把我们这张图里「最高的 10%」也拉到那个范围。空间格局(哪里高哪里低)完全不动,只把高度的分布形态换成真图的。 这一步做完,高度分布从「一坨」变成了「铺开」,和真图逐档吻合。

给想深挖的读者:这就是前面「对比结果反推」的一个具体落地——直方图匹配的目标曲线,正是从真实高度图统计出来的分布(准确说是一组同类真图的平均累积分布)。这里还有个副作用要处理:直方图匹配会把「坑」的底部也一起拉到最低分位,导致坑变得过深。解法是先把坑的凹陷量单独抽出来、只对「去掉坑的地形」做匹配、之后再把坑叠回去——地形分布铺开了,坑深保持原样。这个「结构问题不靠调参、靠换合成方式解决」的思路,是整套算法里最关键的一次突破。

高度分布的结构性难题与直方图匹配:左「多层噪声相加→高度向中间聚集」,中「真图分布=铺开」,右「按分位重映射到真图分布→铺开、空间格局不变」,附坑保护说明

*配图占位:三段式。左「问题」——一条中间高、两头低的分布曲线,标注「多层噪声相加 → 高度向中间聚集」,配一小张「中间一坨白」的地形缩略图。中「目标」——一条较平的真图分布曲线,标注「真实游戏 = 铺开」。右「解法:直方图匹配」——一个箭头把左边曲线按分位拉成右边形状,标注「空间格局不变,只换分布形态」,下方一行小字「坑保护:先抽出坑的凹陷、只匹配地形、再叠回」。白底浅色风,分布曲线用清晰的浅色描边。*

至此,「地形函数」这个此前反复提及的黑盒,就有了完整的交代:它的规则是逆向来的,参数一半正向读、一半反推逼近,合成是三层纯函数,最后靠直方图匹配把高度分布对齐真图。 它算出的地貌之所以「对」,是因为它在统计上贴着真实游戏——而不是因为它看起来好看。也正因为反推的部分还在逼近,它仍是一个初期的、会继续迭代的版本。

接下来,才是把这个(初期但已验证有效的)函数,逐像素等价地搬进 C++——本篇的主线。 但在动手之前,先花一章把这一步放回整条落地计划里,看清它是五个阶段中的哪一步。


四、这一篇在整条落地计划里的位置

先划清一条边界,免得后面性能数字被误读:本篇验证的是「地形生成核心」,而不是「完整星球的运行时」。 后面会给出「2K 在普通机器上 1.5 秒可成」,那指的是「把高度场算出来」这一步,不等于「一次进图加载 1.5 秒完成」——真正的加载还要叠加建网格、碰撞、材质、流送、以及地形之上的建筑与植被。本篇只负责把这条链里最核心、也最不确定的一环钉死。

把地形从「浏览器原型」做到「可联机的正式游戏功能」,不是一步到位,而是一条五个阶段的链——每一阶段都有明确的产出,且后一阶段依赖前一阶段的成果。本篇处在这条链的第二阶段,是从「原型」跨向「引擎」的验证关口。整条链是这样的:

① 算法原型(JavaScript,已完成)。 在浏览器里把地形函数调对、冻结——这是整套系统里唯一的算法权威。所有后续实现,都以它为「标准答案」。前面几个月对齐真实游戏地貌的工作,产出的就是这一步。

② C++ 移植与验证(本篇)。 把原型逐字移植为 C++,并证明两件事——逐像素一致(形态没译错)、性能可承受(跑得够快)。这一步不产出可玩的地形,它产出的是「可以放心往下做」的信心,以及一份踩过坑的清单。它是「原型」与「引擎落地」之间的验证关口——本篇讲的就是这一关。

③ 编辑器版·烘焙(后续)。 把验证过的 C++ 核接进引擎编辑器,一键把一颗星球的地形烘焙成正式地形资产(Landscape)。这一步的意义有两层:一是给美术一个「标准答案」——运行时拼出来的地,必须和编辑器烘出来的这份对得上;二是留出美术精修的入口(官方笔刷做局部微调,材质与植被程序化生成)。注意这里的「烘焙」是开发期工具,不是发布形态——正是第一章澄清过的那个区别。

④ 运行时版·拼接(后续)。 把同一个 C++ 核搬到运行时,在玩家开图时按需生成、拼接地块——渲染、物理、LOD 都在这一层落地。本篇验证的「2K 在普通机器上 1.5 秒可成」,正是为这一步扫清性能疑虑。这是玩家真正体验到的那一层。

⑤ 服务器验证(后续,横贯④)。 这一步最容易被忽略,却是联机的命门。服务器不带显卡、不做渲染,但要独立地把同一片地算出来,和客户端上报的结果比对——对不上,就是客户端在篡改地形。 这也解释了本篇为何对「逐像素一致」如此较真:不是洁癖,而是因为最终有四个角色(浏览器原型、编辑器、玩家客户端、服务器)要算出逐位相同的地,服务器才敢据此判定合法性。一致性不是锦上添花,是整个联机反作弊的地基。

把这五步连起来看,有一条红线贯穿始终——确定性:从浏览器原型,到编辑器,到客户端,到服务器,同一个种子必须处处算出逐位相同的地。这条红线,就是第一章把 CPU 定为权威路径、本篇死磕「逐像素一致」的共同理由。本篇(第二阶段)是这条红线上的第一个「跨语言」验证点:如果 JavaScript 到 C++ 这一跳都对不齐,后面编辑器、客户端、服务器的对齐更无从谈起。

程序化地形的五阶段落地路线图:①JS 原型(算法权威)→ ②C++ 移植验证(本篇)→ ③编辑器烘焙(开发期工具)→ ④运行时拼接(玩家体验)→ ⑤服务器验证(反作弊),底部一条「确定性:全链逐位对齐」贯穿

*配图占位:横向五阶段流程图,从左到右五个节点:① JS 原型(标注「算法权威·已完成」,浏览器图标)→ ② C++ 移植验证(高亮标注「本篇」,放大加边框)→ ③ 编辑器烘焙(标注「开发期工具·出标准答案」,UE 图标)→ ④ 运行时拼接(标注「玩家体验·按需生成」)→ ⑤ 服务器验证(画在 ④ 下方并列,标注「无渲染·独立算·反作弊」,与④之间画双向对齐箭头)。底部一条贯穿全链的横带:「确定性:浏览器 / 编辑器 / 客户端 / 服务器 逐位相同」。② 节点用醒目色高亮表示「你在这里」。白底浅色风,已完成用实心、后续用描边空心区分。*


五、逐像素一致:两处足以让整片地形失真的精度陷阱

把 JavaScript 译成 C++ 之后,第一次拿两版的输出一比——整片地偏了。不是某个角落不对,是通篇的高度都对不上,最大偏差二十几米(作为参照,整片地的总起伏也就三十几米——这等于把地形译成了另一片地)。

这时候有两种排查思路。一种是「盯着最不对的那个点,反推是哪一步算错的」;另一种是「把中间每一层的值都打印出来,逐层比对,看哪一层开始分岔」。第一种看似直接,实则是在一团耦合里猜;第二种笨,但每一步都是确定的。选了第二种——逐层打印中间值,二分定位。分岔点一旦锁定,真凶就藏不住了。这次揪出来的是两个陷阱,都极其隐蔽。

陷阱一:两种「加盐」,长得几乎一样

地形函数里到处在用「带盐的哈希」——把坐标和一个固定的「盐」(一个魔数常量)混进哈希函数,得到一个伪随机值。问题是,原型里有两种加盐写法,它们在 JavaScript 里长得几乎一样,但语义完全不同:

  • 一种是「直接盐」:把种子和盐异或一下,直接用。
  • 一种是「哈希盐」:把种子和盐异或后,再套一层哈希才用。

差别就在那「再套一层哈希」。翻译时若把某个「直接盐」的点误当成「哈希盐」,多包了一层哈希——那个点的伪随机值就彻底变了,而它恰好喂给了决定地块「档位」和「位移倍率」的环节,一错就是整片地的骨架错位。这不是算法本身错了,而是移植过程中语义丢失了:原型的逻辑没问题,只是「这一处该不该包哈希」这个信息,在从一种语言誊到另一种语言时被译错了。

给想深挖的读者:两种写法在原型里是这样的——直接盐是 (seed ^ SALT) >>> 0,哈希盐是 hash((seed ^ SALT) >>> 0)。翻译时,前者在 C++ 里必须写成裸的异或、不能包哈希;后者才包。全套函数里一共有七处「直接盐」被我一开始误包了哈希——分布在决定子区域类型、子区域抖动、站点抖动、边缘扭曲这几个环节。定位靠的是在偏差最大的那个像素上,把两版的中间变量一路打印下来,直到发现某一步的「站点档位」在两版里一个是 0.1、一个是 2.8——顺着这个 2.8 反查,才揪出那层多余的哈希。教训很朴素:翻译任何一个加盐点之前,先回原文确认它是哪一种,别凭手感。

陷阱二:JavaScript 的「算错」,才是正确答案

修完加盐,偏差从二十几米降下去了,但完整管线一比,又冒出来六米的整片偏移。这次的真凶,堪称整个移植里最反直觉的一处。

地形的最后一道工序,要根据种子从五套预设里挑一套来用(挑哪套,决定了这片地整体的高度分布形态)。「挑哪套」是拿种子乘一个大常数、再取余得出的。C++ 老老实实用精确的整数乘法算——结果挑错了套,整片地的高度分布就跟着错。

问题出在:这个乘法的结果,超出了 JavaScript 数字能精确表示的范围。 JavaScript 里所有数字都是浮点数,一旦乘积大到某个界限,它会悄悄丢掉低位精度——算出来的是一个「近似值」。而原型这套地形,是在 JavaScript 里调出来、验证过的——也就是说,JavaScript 那个「算错了的近似值」,才是这套地形实际使用的、被验收认可的「真值」。这里的「真值」需要说清楚:它指的是兼容已有原型行为的工程规格,而不是数学意义上正确的那个值。C++ 用精确整数算出的「数学正确结果」,反而和原型对不上,因为原型压根没用那个正确结果——它用的是那个近似值,而整套地貌是照着近似值调好的。

这就绕出一个哲学味很浓的结论:移植的目标不是「算对」,而是「和原型算得一模一样」——包括一模一样地犯同一个错。 解法是让 C++ 故意复现 JavaScript 的浮点丢精度行为,而不是用它那本来更精确的整数运算。

给想深挖的读者:那个选择器是 (seed * 2246822519) >>> 0。种子乘这个常数,乘积轻松超过 2 的 53 次方——这是 JavaScript 浮点数(IEEE 754 双精度)能精确表示整数的上限。超过之后,尾数被舍入,>>> 0 取低 32 位时,取到的是「舍入后的近似值的低 32 位」。C++ 用 64 位整数精确计算,取余后选出的套次,和 JavaScript 差了 1——一套之差,高度分布形态完全不同,整片偏六米。修法:C++ 用 double 做乘法、再对 2³² 取模,主动复现 JavaScript 的浮点舍入。铁律:移植任何「大数相乘再截断」的哈希或选择器时,先估算乘积量级——一旦越过 2⁵³,源语言(JavaScript)必然丢精度,那个丢精度的行为本身就是要复现的规格。

两个陷阱都修完,再比——逐像素一致。同一个种子、同一分辨率,C++ 和原型的输出最大偏差 0.000002 米(两微米,即千分之二毫米),且这点残差纯粹是浮点运算最后一位的舍入差异,无关对错。把两版的差异放大两千倍画成图——整张纯黑,找不出一个亮点

C++ 与原型的差异图:一张纯黑的方图,标注「差异放大 2000 倍仍全黑,最大偏差 0.000002 米 = 浮点舍入」,旁边小字列两个已修陷阱

*配图占位:中间一张接近纯黑的方形「差异图」,配一行标注「差异 ×2000 仍全黑」。左侧两个小卡片:卡片一「加盐陷阱:直接盐 vs 哈希盐,误判 7 处 → 曾偏 25.6m」;卡片二「浮点陷阱:JS 乘法丢精度才是真值,C++ 精确整数反而错 → 曾偏 6m」。底部大字「最大偏差 0.000002m = 千分之二毫米」。白底浅色风,差异图用深灰不用纯黑以免糊。*

一致性这一关,过了。接下来是性能。


六、从「能跑」到「能用」:性能瓶颈溯源

C++ 版逐像素对齐了,但第一次测性能——慢得惊人。一片 2K 地形,多核并行下要七秒多;单核则要两分钟。这个数字若是终点,运行时生成的方案便直接出局——没有玩家愿意为一片地等两分钟。

但「慢」这件事,值得先问一句「慢在哪」,而不是急着换语言或加机器。做法是数一件事:生成一个像素,到底调用了多少次底层的噪声函数?(噪声函数是整个地形计算的基本砖块,绝大部分时间都花在它上面。)数下来的结果令人吃惊:每生成一个像素,要调用约 2742 次噪声函数。而其中 98% ——约 2700 次——全部堆在「骨架层」一个函数里。

为什么一个像素要算 2700 次?因为这个骨架层函数是层层嵌套的:

  • 每个像素,要看它周围 7×7 = 49 个「站点」(Voronoi 的种子点),做加权平均;
  • 每个站点,又要在它周围 3×3 = 9 个子区域里找最近的;
  • 每个子区域,要算两次噪声(一次定档位、一次定海拔基调),每次噪声内部又展开成 3 层——6 次底层噪声

乘起来:49 × 9 × 6 ≈ 2700 次。一个像素。 一张 2K 的地有四百万像素,就是上百亿次噪声调用。慢,理所当然。

但真正的问题不是「算得多」,而是「算的全是重复的」。关键在于:一个站点的档位和类型,只取决于它自己的整数坐标——和从哪个像素来看它无关。 相邻的两个像素,它们各自要看的 49 个站点里,有 48 个是重叠的、完全一样的。可现在的代码,每个像素都把周围所有站点从头重算一遍。

打个比方:这就像每次想知道邻居家几口人,都重新挨家挨户做一次人口普查——而其实只要查一次登记表就够了。相邻像素之间,这份「人口普查」被重复做了几百遍。

每像素 2742 次噪声调用的嵌套爆炸图:骨架层占 98%,展开 7×7 站点 × 3×3 子区 × 6 次噪声 ≈ 2700,配「相邻像素 48/49 站点重叠却重算」的说明

*配图占位:中心一个像素,向外爆炸式展开三层——第一层 7×7=49 个站点方格,第二层从一个站点展开 3×3=9 子区,第三层从一个子区展开 6 次噪声。每层标注倍数,末端汇总「≈2700 次/像素,98% 在此」。右侧一个小对比图:两个相邻像素的 7×7 窗口高度重叠(48/49 格相同),配红字「重叠部分被反复重算」。底部:位移层等其余只占 40 次(2%)。白底浅色风。*

性能瓶颈的根源就此定位:它不是「C++ 不够快」,也不是「算法本身太重」,而是一个把计算结果反复丢弃、又反复重算的低效实现。根源既然是「重复」,解法也随之明确。


七、站点预计算表:二十倍加速

解法只有一句话:别每个像素重做人口普查,开局查一次登记表,之后所有像素查表就行。

具体地说:既然每个站点的档位和类型只由它的整数坐标决定,那就在开始生成之前,把所有站点的档位算一次,存进一张小表。之后每个像素要用某个站点的档位,直接查表——O(1) 的一次内存读取,而不是把那个站点的 9 个子区、几十次噪声再从头算一遍。

这张表有多小、多快?一张 2K 的地,覆盖到的站点大约 1936 个,每个存一个档位和一个倍率——几 KB 的表,构建耗时 1 毫秒。用这 1 毫秒,换掉了骨架层里 98% 的重复计算。

效果非常显著:

分辨率 裸翻译(32 核) 加预计算表(32 核) 提速
1024² 1341 毫秒 92 毫秒 14.6×
2048²(2K) 7153 毫秒 352 毫秒 20.3×
4096²(4K) 39810 毫秒 1272 毫秒 31.3×

2K 从七秒多降到 0.35 秒,二十倍。 而且分辨率越大提速越猛——因为图越大,相邻像素之间重叠的站点越多,省掉的重复计算越多。加了表之后,骨架层从占 98% 的时间降到几乎可忽略,整片地的耗时主要花在真正必要的位移层那 40 次噪声上——那才是这个算法的「真实成本」。

值得一提的是,这个优化并不是什么高深技巧——原型的 JavaScript 版本其实已经做了一半(它用了一个缓存,避免重复算站点)。C++ 第一版为了先跑通、先对齐一致性,故意没做这个缓存,才暴露出「裸翻译」的真实代价有多高。这也从侧面说明:性能焦虑常常来自「实现偷懒」,而不是「算法本身慢」——把病根定位到「重复计算」,比盲目换语言、堆机器有效得多。

预计算表的前后对比:左「每像素重算 2700 次噪声」,右「开局建表 1ms → 每像素查 49 次内存」,中间性能柱状图 2K 从 7153ms 砍到 352ms

*配图占位:左右对比。左「裸翻译」——一个像素射出 2700 条噪声调用线,标红。右「预计算表」——顶部一张小表(站点→档位),像素只从表里查,标绿。中间一组柱状图,三档分辨率各一对柱(裸翻译高柱 vs 优化矮柱),2K 那对最醒目标「20.3×」。底部一行:表构建仅 1ms。白底浅色风。*


八、性能判定:加载阶段的耗时预算

有了优化后的数字,引子里那个关键问题——「在普通玩家机器上,生成一片 2K 地形需要几秒」——终于可以正面回答了。

测试机是 32 核的开发机,但玩家不会都有这么多核。这里按核心规模做一个粗略估算:把普通玩家机按八核算(约为测试机核数的四分之一),耗时按比例放大——这只是一个数量级上的换算,实际数字需要在目标硬件上复测(内存带宽、缓存、单核频率都会影响,并非严格线性):

分辨率 32 核开发机(实测) 八核玩家机(粗略估算)
1024² 0.09 秒 ~0.4 秒
2048²(2K) 0.35 秒 ~1.5 秒
4096²(4K) 1.27 秒 ~5 秒

判定很清楚:

  • 1K、2K 完全可行。 八核机上 0.4 到 1.5 秒,纳入加载流程毫无压力——玩家甚至感觉不到地形是运行时生成的。
  • 4K 也可行。 八核机约 5 秒,对一次进图加载来说可以接受;何况实战中还能用「先算出生点周边、其余边玩边补」的策略把这 5 秒摊开。
  • 前提是必须做预计算表。 裸翻译版的 2K 在八核机上要将近 30 秒——那是不可接受的。可行与不可行之间,就隔着第七章那张 1 毫秒的表。

回到理论篇留下的那句「后台并行烘焙即可」——现在「即可」二字有了具体支撑:不是「随意计算即可」,而是「用预计算表消除重复计算之后,2K 在普通机器上 1.5 秒可成」。 运行时生成这条路径,在性能上成立。

需要诚实标注一个边界:这里测量的是高度场计算本身——即把每个点的高度算出来。真正落地时,高度场之外还有「依高度建网格」「生成碰撞体」「挂载材质」等步骤,各有开销,需在真实引擎中另行测量。但高度场是整条链路里最重、也是唯一「必须运行时生成、无法预存」的一环——它通过验证,意味着最关键的一块已经落定。

性能判定图:三档分辨率在八核机的秒数(0.4/1.5/5s),1K/2K 标绿「进 loading 无压力」,4K 标黄「可接受」,底部标注「裸翻译 2K 需 30s = 不可行,差距在那张 1ms 的表」

*配图占位:一条横向的「loading 时间轴」,从 0 到 30 秒。三个绿/黄标记点:1024²@0.4s、2048²@1.5s(均落在绿色「可接受区」)、4096²@5s(黄色「勉强可接受」)。远处 30s 处一个红色叉标「裸翻译 2K」。三档各配八核机图标。底部一行:可行与否,隔着一张 1ms 的预计算表。白底浅色风。*


九、一致性验证:两版并入同一预览工具比对

数字证明了性能可承受,但形态一致性还欠一个直观的交代——差异图全黑固然是铁证,却略显抽象。最直观的验证,是将 C++ 生成的地形与原型生成的地形,用同一个渲染器绘制成三维地形,并排比对

做法是:让 C++ 生成一张完整的地形,编码成预览工具能读的格式,再从工具的数据源里导入——走的是和原型完全相同的那条三维渲染管线。于是同一个工具里,可以来回切换「C++ 生成的地」和「原型生成的地」,用同一个视角、同一套光照渲染。

结果如下——上图是 C++ 版,下图是原型版:

C++ 生成的地形,导入预览工具的三维渲染

*配图占位:C++ 全管线生成的 1024² 地形的三维渲染截图(已有 view3d_cpp.png)。可见右下的绿色洼地群、散布的圆形陨石坑、成团的土包、左上大片平坦高地。*

原型生成的同一片地形,导入同一预览工具

*配图占位:原型(JavaScript)全管线生成的同一片地形三维渲染(已有 view3d_js.png),与上图肉眼无法区分。*

两张图肉眼完全一致——同一个种子下,C++ 和原型渲染出的是同一片地。图里能读出完整管线的各层贡献:右下和底部的绿色洼地是高度重映射铺开的低地叠上聚集的坑场,散布的小圆环是陨石坑印章,成团的白色鼓包是土包丘团层,左上大片平坦白区是骨架层的高台地。这些细节——坑、丘、台地、洼地——在两版里逐一对应,位置、形状、数量分毫不差。

至此,两个悬念都有了答案:C++ 版与原型逐像素一致(形态正确),2K 地形在普通机器上 1.5 秒可成(性能可承受)。 程序化地形在运行时生成,其可行性——验证通过。


十、本篇的位置与可迁移经验

把这一篇收成一句话:它证明了「运行时生成地形」并非纸面构想——原型的地形函数能逐像素等价地移植进 C++,且优化之后的速度足以纳入加载流程。

第三章从开发阶段看过本篇的位置(五阶段中的第二阶段);这里换个角度,从系列文章看它的位置:

  • 拆解篇——搞清这款游戏怎么用「规则 + 种子」做程序化关卡;
  • 地形篇——地形不存高度图、存配方,切块 + 离散高度 + 印章 + 程序上色;
  • UE5 实现篇——把落地思路铺成理论:三层纯函数、后台并行烘焙、玩家位置驱动流送;
  • 本篇(落地前验证)——用一次真实的 C++ 落地,把理论篇留的两个悬念(性能是否可承受、形态是否一致)逐一坐实;
  • 落地篇(后续)——把验证通过的这套东西真正搬进 UE5,连网格、碰撞、材质、流送一起跑起来。

这一篇是「理论」和「落地」之间的那道验证关口。 它不生产可玩的地形,它生产的是「可以放心往下做」的信心——以及一份踩过坑的清单,让真正落地时不必再踩一遍。

对想做程序化地形的人,这一篇可迁移的经验有三条:

  • 原型和引擎实现之间,最大的风险是「悄悄译错」。 哈希和噪声对每个比特敏感,两种语言的整数/浮点行为差异必须逐一对齐;验证的唯一可靠手段是「逐像素比对 + 差异图」,肉眼看渲染图是不够的。
  • 「移植的目标是算对」是个错觉——目标是「和原型算得一模一样」。 如果原型因为语言特性算出了某个「近似值」并据此调好了效果,那个近似值就是规格,C++ 必须复现它,哪怕这意味着主动复现一个「错误」。
  • 性能焦虑常常来自实现偷懒,而非算法本身。 先量「一个像素到底算了多少次」,把病根定位到「重复计算」,往往一张预计算表就能换来一个数量级的提速——这比盲目换语言、堆硬件有效得多。

十一、AI 协作复盘

这是本系列的固定栏目:记录做这一篇时,人和 AI 具体怎么配合、哪里翻了车、人怎么补的位。

AI 帮上忙的地方

  • 逐像素移植的对齐,是 AI 擅长的机械核对活。 把一个上百行、布满哈希与噪声的函数从一种语言逐字译到另一种,并逐层比对中间值直到分岔点收敛——这种「确定性强、需要极度耐心、不能有一处含糊」的工作,正是 AI 稳定发挥的领域。两个精度陷阱的定位,靠的都是「在偏差最大的像素上把中间变量一路打印下来」,而不是靠猜。
  • 性能病根的定位,靠的是「先量再改」。 面对「慢」,AI 没有急着换语言或加线程,而是先数「每个像素调了多少次噪声」,把 98% 的开销精确归因到骨架层的重复计算——这个数字一出来,「预计算表」这个解法就是自然而然的,提速二十倍也在意料之中。量化归因比直觉猜测有效,这一条反复被验证。
  • 一致性验证做到了「可视化的铁证」。 不满足于「数字上差不多」,而是把两版差异放大两千倍画成图、把两版都导入同一个三维渲染器并排看——让「一致」这件事从一个数字变成一眼可见的黑图和两张重合的地形。

失误与人的补位

  • 「深坑」bug 曾连改三次都没改对,因为盯着一处猜。 迭代中出现过「某些地方深深下陷」的问题,我一开始盯着「坑场」那段逻辑连改三次(改保护逻辑、改碗形、改互斥),全无效——因为真凶根本不在那段,而在另一处早期遗留的、被忽略的生成点。是「把所有生成坑的地方都搜出来、把实际的深度值打印出来」这个笨办法,才两秒定位到真凶。教训:改 bug 先把所有相关的生成点都找齐、把真实的值打印出来,别盯着最可疑的一处反复猜。这个教训是人在旁边看着「怎么又没好」时点破的。
  • 「算对」和「算得一样」的区别,是排查中才醒悟的。 那个浮点丢精度的陷阱,第一反应是「C++ 算得更精确,应该是原型错了」——差点就去「修正」原型。是「原型是被验收过的真值、C++ 要复现它包括复现它的近似」这个认识扭转了方向。AI 有「追求正确」的本能,但在移植语境下,「忠实复现源实现」才是正确——这个价值排序需要人来锚定。
  • 归档时数据与代码的边界,是按既定规范把关的。 这次产出里既有该入库的代码和文档,也有体积大的对比图和高度图数据。按「代码入库、数据不传」的规范,对比图和导入用的高度图被排除在版本库外、只留在本地——这条规范是人早先定的,AI 需要在每次归档时主动执行,而不是把所有产物一股脑提交。

如何解决的:把「逐层打印中间值、二分定位」作为移植排查的默认手段(而非盯着终值猜);把「移植的目标是忠实复现源实现,包括复现其近似行为」立为原则;把「先量化归因再动手优化」作为性能问题的第一反应;数据与代码的入库边界交由人在回路中把关。这一套下来,才使「逐像素一致 + 运行时可行」这两个结论既有铁证、又不夸大。


*配图均为自绘信息图(白底,与正文逐条对应);三维地形对比图为预览工具实际渲染截图。文中性能数据来自 C++ 实现在开发机(32 核)上的实测,八核玩家机数字为按核数比例的保守估算,落地时需在真实引擎中复测;一致性数据(最大偏差 0.000002 米、差异图纯黑)来自同种子下 C++ 与原型输出的逐像素比对。文中涉及的具体游戏地貌形态,均为规则粒度的还原,不涉及原作加密的具体生成参数。*

发表评论

了解 AI Native Game Development 的更多信息

立即订阅以继续阅读并访问完整档案。

继续阅读