雨世界渲染管线

Rain World 的关卡图片是离线"烤"出来的。下面用一个小场景把原版编辑器的渲染过程拆成 5 站:第 1、2 站讲场景怎么叠起来,第 3–5 站再细分成小步,按 Lingo 源码一步步复原。每一步都可以拖滑块,或者把鼠标放到画面上看像素。第 4、5 站还可以切换到 Drizzle 刚渲染出来的真实关卡 GW_B01。

第 1 站

场景是 30 张叠起来的薄片

levelRendering.lingo · renderProps.lingo

几何编辑器有前、中、后 3 个工作层,每层再分 10 个子深度,一共 30 层。第 0 层在最前,第 29 层在最后。

一个实心方块会占满它所在工作层的全部 10 片;横梁、草、藤蔓这类细节只占其中一片。

美术只用几种纯色作画,颜色表示的是这块面朝哪边,不是最终颜色:
蓝 = 朝上(高光) 绿 = 正面 红 = 朝下(暗面)
另外还有 品红 / 青 = 特效色 A / B(苔藓之类),以及亮度 150 的"暗色"版本。

试试:把"展开"拉到最大,再拖动"选中层",看每一片里画了什么。
30 片层叠(侧面看)
第 0 层
第 2 站

每层按深度缩放,从后往前叠

spelrelaterat.lingo · depthPnt()

合成时,每一层都朝消失点缩放:越靠后缩得越小。消失点固定在画面水平中线、上方 1/3 处。

缩放 = 1 + 0.0025 × (层号 − 5)
屏幕坐标 = (点 − 消失点) / 缩放 + 消失点

原版的缩放幅度很小,最后一层只比第 5 层小 6%。但正是这点差别,让方块的顶面从后面几片里"露"出来,画面就有了立体感。

试试:把"夸张倍数"拉大,看方块蓝色的顶面怎么出现。也可以点画面,换一个消失点位置。
消失点 (280, 107)
第 3 站

光沿着一个方向,一层一层往后推

renderLightStart.lingo · renderLight.lingo · finalize.lingo

阴影分三小步算出来:先在光照编辑器里画好"额外的遮光物",然后从第 0 层走到第 29 层,每走一层就把光照图沿光线方向推一段,最后用透视把每层的阴影合成成一张 shadowImage。全部在编辑器里离线完成,游戏运行时不再计算这些阴影。

3a在光照编辑器里画遮光形状

lightEditor.lingo · degToVec()

3a、3b、3c 用的是同一个 3 层小场景:横梁(第 3 层)、柱子(第 12 层)、后墙(第 25 层)。你在这里改的东西,3b、3c 会跟着变。

光照编辑器里有一张"光照图"(lightImage)。你可以在上面画形状,代表画面外的树冠、屋顶这类遮光物(紫色)。另外有两个参数:

光线角度:原版限制在 90°–180°,用 degToVec 换算成方向,90° 是往右,180° 是往下。
flatness:1–10,每往后一层,光照图挪几次。越大,影子拖得越远。

试试:在画面上按住拖动来画遮光形状,再去 3b、3c 看影子怎么变。
紫色 = 你画的遮光形状(lightImage)· 底下压暗的是 3 层小场景

3b从前往后,逐层推进光照图 · 一步一步看

也可以用键盘 ← →

3c按透视合成出一张阴影图 · 一步一步看

也可以用键盘 ← →
第 4 站

把画面压成一张"数据图"

finalize.lingo · renderColors.lingo

这一站分两半:finalize 先用透视合成出几张 1400×800 的中间图,renderColors 再逐行逐像素读这些中间图,把信息打包进输出 PNG 的 R、G、B 三个通道。

4a合成出 4 张中间图

finalize.lingo

finalImage:每个像素最前面那层的 tile 颜色(蓝/绿/红、品红/青、暗色)。
fogImage:灰度图,越靠后越暗,用来查出层号(见 4b)。
shadowImage:第 3 站算出的受光/阴影。
gradient:特效色的强度(比如苔藓有多浓),由特效渲染阶段画进每一层。

试试:切换 4 张图,再把鼠标放到画面上,右边会同时列出这个像素在 4 张图里的值。
finalImage
把鼠标放到左边的画面上(手机上点一下)。

4b用"混色次数"记住层号

finalize.lingo · fogImage / fogDptsL

Director 没法方便地记录"这个像素来自第几层",于是 Joar 用了一个小技巧:从最后一层开始,重复 30 次——贴上一层,再把整张图往黑色混 5%。

越早贴上的层(越靠后)被混的次数越多,就越暗。第 L 层最后的灰度是 255 × 0.95L+1。

同时用完全相同的步骤做一条 30 格的灰度条(fogDptsL)。renderColors 读到一个灰度值,就去这条灰度条里找它在第几格,得到层号 1–30。

试试:拖动"第几次",看墙在前面的层贴上之后被一点点盖住,同时下面的灰度条一格格被填上。
第 30 次
格 0 ↔ 第 29 层(最后)格 29 ↔ 第 0 层(最前)

4c逐像素打包 R、G、B

renderColors.lingo · newFrame
R = (朝向−1)×30 + 层号 + 受光×90
朝向:红 1 / 绿 2 / 蓝 3(品红、青算 2)
G = 特效色 + 4×边缘 + 8×贴花 + 16×暗色
特效色:品红 1 / 青 2 / 自定义 3
B = 特效色强度(来自 gradient)
天空保持白色 255,255,255

边缘标记(源码里叫 rainbow):只看最前面几层的像素,比较它和周围 8 个像素的雾值(隔 2 格的上下左右,加 4 个对角)。差得多的邻居计 1 分,比它更靠前的邻居计 2 分,超过 5 分就标记为边缘,并顺手给左、上、右、下四个邻居也加上标记。

试试:切到"只看 G",黄色就是边缘标记。再切到真实关卡对照看看。
小场景
把鼠标放到左边的画面上(手机上点一下)。

只看 G 的颜色:特效色 A 特效色 B 边缘 贴花 暗色

第 5 站

游戏读数据图,查调色板上色

游戏端 shader(LevelColor)· 按社区逆向资料复原,细节为近似

游戏 shader 的源码不在编辑器里,这一站是按 Rain World 社区的逆向文档复原的:查表这一步比较确定,后面几层效果的具体公式是近似。下面的画面和控件对应 3 个小步。

5aR 通道 → 调色板的哪一格

R > 90 表示受光,先减 90。然后:

朝向 = ⌊(R−1)/30⌋ → 0 暗面 / 1 正面 / 2 高光
深度 = (R−1) mod 30 → 第几列
第 0 行天空、雾、黑等单独的颜色 第 1 行污渍(grime)渐变 第 2–4 行受光:高光 / 正面 / 暗面 第 5–7 行阴影:高光 / 正面 / 暗面 下半张同样的布局,下雨时用

每一行从左到右是第 1–30 层,越往右越接近雾色,所以空气透视已经烤在调色板里了。

试试:点不同的调色板;鼠标放到画面上时,调色板上会框出这个像素查的是哪一格。
palette0
palette0
把鼠标放到上面的画面上(手机上点一下)。

5bG、B 通道叠加的效果

暗色(G≥16):受光时改用这一行最暗的一格(第 0 列),用于平台的配色。
特效色(G=1/2):按 B/255 的比例混向房间设置的特效色 A/B。游戏内置 22 种,这里用取色器代替;在阴影里会暗一些。
边缘标记(G 含 4):开了污渍(grime)的房间,这些像素会按噪声混进调色板第 1 行的颜色。
雾:按深度混向第 0 行的雾色,越远越浓。

这几条效果的混合方式是近似,只用来看每个通道起什么作用。

5b · 叠加效果(作用于上面的画面)

5c时间和天气:在调色板之间渐变

下雨:雨季逼近时,游戏从调色板上半张渐变到下半张。
渐变调色板:一个房间可以再指定一张调色板,按比例混合(比如进出黄昏)。

数据图本身一个像素都不用改,只是查的表变了。这就是 Rain World 同一个房间能有多种气氛的原因。

5c · 时间和天气(作用于上面的画面)