洛克王国精灵渲染复刻笔记

个人向的复刻记录:从 RenderDoc 截帧反推《洛克王国》精灵的渲染方式,再到 UE 中复刻材质与后处理。文中保留了截帧分析得到的 HLSL 代码,方便对照搭建材质蓝图。

一、美术资产准备

一开始打算用 FModel 解包《洛克王国》PC 版,但网上找到的 5 月 AES 密钥似乎已经失效,于是放弃了这条路线。

后来一番查找,在模之屋找到了对应的精灵美术资源。(补充:我向资源发布者询问过,他说密钥其实没有失效,可能是我第一次尝试解包时操作不对。)

二、Codex 接入 RenderDoc MCP

之前在 Claude 上装过 RenderDoc MCP,最近主要用 Codex,就把它迁移到了 Codex 上。重装时遇到一堆问题,主要原因是 Codex 桌面端权限不够,导致各种连接异常,最后让 Codex 自己检查并修正了对应路径和权限。

三、游戏截帧

刚开始直接在《洛克王国》PC 端尝试截帧,但没有魔改版 RenderDoc,截帧会被直接检测到,无法进行。

改用 MuMu 模拟器后可以正常启动游戏并截帧,可《洛克王国》不允许在模拟器上游玩,进入游戏待机界面就会被强制退出。

后来在网上关于《洛克王国》模拟器的视频评论区看到有人留言说雷电模拟器可以玩,于是转到雷电模拟器。

不过我下载的是雷电 14,用我当前版本的 RenderDoc 截帧时,模拟器左上角会出现:

Capturing OpenGL. Captures disabled.
Unsupported function used: glBindFragDataLocationIndexedEXT

导致无法截帧。询问 AI 后确认,是这个函数不被我这个版本的 RenderDoc 支持,所以无法截帧。

最后换到雷电 9,截帧成功。

四、具体复刻环节

本章按复刻顺序记录:先做精灵身体(4.1),再做眼睛与嘴巴(4.2),最后是后处理(4.3)。

4.1 身体基础色

用到的贴图:

  • BaseColor(固有色)

    • A 通道没有值。
  • NormalMap(法线贴图)

    • RG = 法线的两个分量;B 通道像是提亮因子,且带有 AO 效果;A 通道用来区分精灵网格体的部位。
  • LUT

    • 明暗到颜色的查找表。
具体实现:先在 UE 里把材质的着色模型设为“无光照(Unlit)”

4.1.1 法线贴图处理

这是压缩过的法线贴图,RG 通道直接用 UE 的 DeriveNormalZ 节点解压。因为《洛克王国》本身就是 UE 引擎,所以不需要对 G 通道再做处理。

  • B 通道:用来提亮模型暗处的亮度,也可以当作 AO 来用,后面的明暗处理部分会详细讲解。
  • A 通道:用不同数值区分模型的不同部位,主要分为眼睛区、虹膜高光区、主体区、透明边缘,后面会说明如何分区及其意义。

按 band(分区编号)划分:

  • band = 2:裙底
  • band = 3:围巾、发箍
  • band = 4:头发、眼睛、嘴巴,以及一些内饰描边
  • band = 5:面部、衣服、玩偶、发箍的一部分

4.1.2 卡通明暗因子(对 Lambert 的改造)

首先自然是普通的 Lambert 漫反射,再把它映射到 0~1,然后与前面的 Nz(B 通道的提亮因子)取平均,这样能有效提高背光面的亮度,避免死黑。

另外,单独输出 Nz 可以看到:Nz 在一些类似凹槽的地方会有暗部,叠加基础 Lambert 后还能起到 AO 的作用。

对于 Nz < 0.05 的地方,明暗因子直接置 0;Nz > 0.95 的地方置 1。这种直接跳变能让阴影更有卡通感(不过实测效果似乎并不明显)。

但这还不是最终的明暗因子。为了卡通渲染的效果,需要让明暗过渡更“硬”一些,过渡区域选定在 0.4~0.45,这个受光带因子会用在后面的 LUT 部分。

受光带确定后,暗部和亮部之间也需要有明显的差别,不能直接套用前面的明暗因子(太光滑)。于是用过渡带因子做 Lerp:暗处调用前面的明暗因子、但映射到 0.6~1.0,亮处恒为 1.0。这个结果就是 lightDark,作为 LUT 采样的 U 坐标。

4.1.3 基础色(含反色曲线)

基础色本身很简单:基础色贴图没有 A 通道信息,直接采样输出即可。

补充(之前没注意到的点):《洛克王国》的固有色(BaseColor)还需要经过一条反色曲线(反色调映射,Inverse Tone Mapping),把 0~1 的值映射到 HDR 范围。经过这条曲线后,暗处会更暗一点、亮处会更亮,并且可以突破 1。

这张贴图就是美术的“HDR 分配图”

  • 光照负责全局亮度(方向、强度、影子——本质是一个乘数)。
  • 贴图负责逐像素决定“哪里能突破 1.0”:白色衣服、光效、高光边 → albedo 高 → 工作空间起点就 >1 → 光照一照就发光;皮肤、暗部 → albedo 低 → 老老实实待在低区。
  • 所以“哪里发光、哪里不发光”这个艺术决定,完全由美术画在贴图里控制,而不是交给灯光系统去“碰运气”。这正是风格化渲染要的:动态范围的分配权在美术手里,不在光照强度手里。

为什么必须放在 shader 里做

  • 存储限制:8 位贴图的物理上限就是 1.0,存不下 1.42 这种值;HDR 贴图在移动端又太贵,所以“展开”这一步只能在采样后现算。
  • 顺序要求:展开必须发生在“乘光照之前”,光照之后才有东西可叠。所以它只能放在材质里,没法画进贴图。

顺带把“光照也能超 1”那层说圆

  • 光照确实能让数值超 1,但那是全局、无差别的超——整个精灵一起变亮。
  • 反色曲线给的是局部的、贴图控制的超——只有美术画亮的部位超。风格化渲染要的恰恰是这个“局部控制”,所以原版选择在贴图上做文章,而不是靠光照。

一句话总结:贴图表达 HDR 意图(显示参考)→ 受 0~1 存储限制 → 展开步骤放进 shader → 实现“哪里发光由贴图说了算”的风格化控制。

下面是对比图(左:BaseColor;右:反色曲线后的 HDR 结果):

4.1.4 LUT 查表

LUT 采样坐标里,U 就是前面算出的 lightDarkV 由法线贴图 A 通道确定的 band 值映射到对应行号,从而让不同部位有不同的明暗效果。不过从本次截帧分析的数据来看,这里各 band 的 V 值实际都是 0。

LUT 的 UV 是这样计算的:U = lightDark(见 4.1.2);V = (LutRow + 0.5) / 128。拆开看:

  1. lutRow × 1/128:行距换算
    这张 256² 的 LUT 并不是一张“图”,而是把 128 条 1D 渐变竖着叠在了一张贴图里(每条渐变占 2 个纹素高度,256 / 128 = 2)。整数行号(比如 7)本身不是 UV,必须乘上“每行在 UV 里占的高度”(1/128)才能变成 V 坐标,这和“把一维数组下标变成贴图坐标”是同一件事。
  2. + 0.5/128:半纹素对中(关键)
    如果不加 0.5,v = 7/128 ≈ 0.0546875,换算成纹素线是 0.0546875 × 256 = 14.0,正好落在第 7 行条带的顶边。而采样器是双线性过滤:采样点压在两条纹素线边界上时,会各取 50% 混合上下两行 → 第 7 行的渐变会混进第 6 行的颜色,出现行间串色
    加上 0.5 之后:v = 7.5/128 ≈ 0.05859375,纹素线 = 15.0,正好是第 7 行条带(纹素线 14~15)的正中心,双线性取到的就是这一行自己的颜色。

LutRow 的映射为:

float lutRow = band == 2 || band == 3 ? 0.0 : band == 4 ? 7.0 : 6.0;

其中 band 来自法线贴图 A 通道的计算:band 2/3 → 0 行;band 4→ 7 行;band 5→ 6 行。

4.1.5 LUT 调整

采样出来的 LUT 不能直接用:LUT 采样得到的只是“明暗比例 + 轻微色调”,不是最终颜色。真正决定颜色的是固有色 base——我们量过第 7 行,整条渐变都在 0.96~0.98 附近(接近白、只有很淡的偏色),它更像一把“刻度尺”。所以原版在采样后又补了 3 个 Lerp,每步都有明确目的:

float3 lutBlend = lerp(lut * 0.85, lut, 0.2508);                        // ① 先轻微压暗(约 0.8876 倍)
lutBlend = lerp(lutBlend, 1.0, h89);                                    // ② 高光带处往白色提
lutBlend = lerp(float3(0.4375, 0.4375, 0.4375), lutBlend, 0.8);         // ③ 与固定灰混合,权重 0.8
步骤表达式(本帧数值)目的
① 压暗lerp(lut×0.85, lut, 0.2508)lut×0.8876整体压暗约 11%,配合输出曲线防止亮部过曝
② 高光带lerp(lutBlend, 1.0, h89)明暗交界处(h89>0)往白拉,做出那条卡通高光带
③ 灰兜底lerp(0.4375灰, lutBlend, 0.8)把 LUT 偏色拉回中性 20%,并给暗部 0.0875 的保底灰
④ 合成lit = base×(h89/π) + lutBlend×base乘固有色 + 高光带额外漫反射

每步为什么必要

  • ② 高光带:LUT 的值域上限就是 1(白),表达不了“比亮部更亮”的效果。卡通高光带必须“溢出”,所以用 h89 直接往白提,并在第④步再加一份 base × h89/π,让交界处能超过 1。
  • ③ 灰兜底:LUT 渐变可能带色相(比如暗部偏蓝),直接乘到固有色上会让全身发色偏;混 20% 中性灰把它拉回来。同时 0.4375 × 20% = 0.0875 的保底灰也能避免暗部黑死。
  • ① 压暗:LUT 内容 + 最终 sqrt(lit × 0.0577) 输出曲线是配套设计的(等价于 sqrt(lit × 0.9236 × 0.0625)),这 11% 是基线微调,跳过的话亮部形状会对不上。
  • ④ 固有色乘法:没有它,整只精灵会变成 LUT 的灰白色,颜色全丢。

一句话:LUT 是“明暗色源”,处理链把它修正成“可以直接乘固有色”的最终明暗色——少了任何一步,要么丢固有色、要么没有高光带、要么发色偏或黑死。

最后的 lutBlend 效果:

4.1.6 高光

这里的高光,从最终观察来看,主要是头发的高光、天使环的高光和蝴蝶结上的高光。

float3 kBand = band==2 ? float3(1,0,0.5) : band==3 ? float3(1,0,0.5)
             : band==4 ? float3(0.5,1,0) : band==5 ? float3(1,0,1)
             : float3(0.35,0.001,0.5);
float x2 = kBand.x * kBand.x;
float A  = NdotH * x2;
float D  = x2 / (1.0 - NdotH*NdotH + A*A);         // 修正:分子是 x²
float hl = (kBand.x * 0.25 + 0.25) * min(D*D, 2048);
float lo = 0.49 - kBand.z, hi = kBand.z + 0.49 - lo;
float gm = saturate((saturate((hl + mask.x) * 0.5) - lo) / hi);
gm *= lerp(saturate(hl), hl, kBand.z * kBand.z);
gm *= kBand.y * h85 * mask.x * 2.0;
float3 glintTerm = float3(1,1,1) * gm * v87;
  • kGlintColor(Glint = 闪光点):高光的颜色,这里直接就是纯白色。
  • kBandColor:不同部位的高光参数,不同部位不一样,后面会详细说明。

① kBand —— 按部位选一套高光参数

float3 kBand = band==2 ? float3(1,0,0.5) : band==3 ? float3(1,0,0.5)
             : band==4 ? float3(0.5,1,0) : band==5 ? float3(1,0,1)
             : float3(0.35,0.001,0.5);

一个 band 一套参数,xyz 分工:

  • x:高光宽度/锐度(越大高光点越窄)
  • y:高光强度权重(越大越亮)
  • z:软阈值控制(后面 lo/hi 用)

截帧里 band2/3/5 的 y=0,所以本帧只有 band4(头发和发箍部分)有高光,其他部位被权重关掉了。

② 高光形状 —— GGX 风格的窄分布

float x2 = kBand.x * kBand.x;
float A  = NdotH * x2;
float D  = x2 / (1.0 - NdotH*NdotH + A*A);
float hl = (kBand.x * 0.25 + 0.25) * min(D*D, 2048);
  • NdotH:法线和半角向量(光+视线中间)的贴合度,0~1,越接近 1 说明高光方向越正。
  • x2 = x²:用 kBand.x 把 NdotH 压扁。x 大 → NdotH 被压得更狠 → 只有接近 1 的像素才有值 → 高光点更窄更尖
  • D = x² / (1 - NdotH² + A²):这是 GGX 分布项 D 的简化版,roughness 由 x 控制。它专门让高光在峰值附近极陡。
  • :再平方一次,把高光收得更窄。
  • hl:最终高光强度(可能 >1,比如 3.5,这是 HDR 亮点)。min(..., 2048) 只是防止数值爆炸。

直观理解:hl 就是你“正对高光反射方向”时那个刺眼小亮点的亮度,越偏离越接近 0。

③ 软阈值 —— 决定多亮才算“高光出现”

float lo = 0.49 - kBand.z;
float hi = kBand.z + 0.49 - lo;      // = 2 * kBand.z
float gm = saturate((saturate((hl + mask.x) * 0.5) - lo) / hi);
  • mask.x = albedo 贴图的 R 通道,是“高光遮罩”——只有部分区域有值,其他地方为 0。注意它是加在 hl 上再一起过阈值,相当于给头发区域的高光一个基础加分。

mask.x:

  • lo 是斜坡起点,hi 是斜坡宽度。
  • kBand.z 越大 → lo 越小、hi 越大 → 高光越容易触发、过渡越柔和;z=0hi=0 → 变成硬阈值(超过 0.49 直接全亮)。

这就是之前问的“0.49 是干嘛的”:它是一个固定的“居中偏移”,让阈值默认在 0.5 附近,再由 kBand.z 左右拨。

④ 强度整形 + 遮罩缩放

gm *= lerp(saturate(hl), hl, kBand.z * kBand.z);
gm *= kBand.y * h85 * mask.x * 2.0;
  • 第一行:z 大时用原始 hl(可以 >1,超亮),z 小时用 saturate(hl)(限制在 1 内),按 z² 过渡。
  • 第二行:最后的总强度

    • kBand.y:该部位的高光强度权重(band4=1 满强度,其他=0)
    • h85:受光带因子(只有面向光的面才有眼神光)
    • mask.x:眼睛遮罩(再乘一次,双保险,只有眼睛亮)
    • *2.0:整体增益

⑤ 颜色合成

float3 glintTerm = float3(1,1,1) * gm * v87;
  • 眼神光颜色是白色 (1,1,1),乘 gm 得到亮度。
  • 再乘 v87(固有色/反色曲线后的底色):让高光点染上底色色调,而不是死白——这样高光和皮肤、眼白融合得更自然。

整体流程一句话

用 GGX 风格的窄分布算出“高光多亮” → 用 kBand.z 做软阈值筛选出亮到一定程度的小点 → 用高光遮罩 + 受光带 + 部位权重决定哪里能亮 → 最后乘白色和底色得到高光。

而 band2/3/5 因为 kBand.y = 0gm 直接被清零,眼神光只出现在头发区域。

笔者的理解hl 这个高光点需要先和 eyeRim.x(高光遮罩)平均后再做软阈值处理,这和前面 Lambert 的处理思路类似;之后的 lerp 是判断是否需要 HDR(即超过 1 的范围);再结合 Lambert(h85,光照正面)、eyeRim.x 遮罩和 kBandColor.y,意思就是只有特定部位才会有这个高光——根据之前的对比,这个部位就是 band 4(头发、眼睛、嘴巴区域)。另外这里的 k.z 扮演着“高光有多柔和、是否需要 Bloom 处理”的旋钮。最后就是把它和高光颜色、固有色结合。

最后的高光部分效果(忽略眼睛部分):

4.1.7 边缘光(Rim)

边缘光的思路:法线越“侧对”相机的地方加一层淡淡的轮廓光,正对相机的面不加;只作用于遮罩区域(albedo 的 G 通道);颜色亮暗还跟着受光带走(也就是前面的 h89,保证背光处不产生边缘光)。

7.1 基础 Fresnel——越侧越亮

NdotV = saturate(dot(Nw, V)),数值越侧越小,再通过 OneMinus 反转,就得到“越侧越亮”的 Fresnel。

7.2 相机高度因子——区分平视与俯仰视
float h132 = smoothstep(0, 1, abs(dot(WorldPos - CamPos, float3(0, 0, 1))) * 0.0001);

这个因子用来判断相机是平视还是俯仰视。因为 UE 的世界单位是厘米,距离数值很大,为了防止 smoothstep 动不动就顶满 1,所以给 alpha 乘了 0.0001(即 1e-4)。

7.3 侧视增强——侧面看轮廓光更强
float h136 = lerp(1.0,
                  smoothstep(0.2, lerp(1.0, 0.9, h132), abs(dot(Nw, camRight))),
                  lerp(0.9, 1.0, h132));

这里的 camRight相机右方向(截帧里是 (1,0,0))。复刻时可以通过 MPC(材质参数合集)或蓝图把相机右向量传进材质。

  • 第一个 Lerp:A = 1,B = smoothstep(0.2, lerp(1.0, 0.9, h132), abs(dot(Nw, camRight)))。俯仰视角下 B 的上界会小一点(0.9),smoothstep 更容易截到 1,相当于俯仰视角下的侧面增强更充分。
  • B 的输入是 abs(dot(Nw, camRight)):法线越偏向相机侧向,值越大——也就是在判断“哪些面是侧面”,侧面处第一个 Lerp 的 B 值就越大。
  • 第一个 Lerp 的 alpha 用 lerp(0.9, 1.0, h132) 插值:俯仰视角时权重更大(1.0),平视时稍小(0.9),差距不大。
  • 综合下来:俯仰视角下的侧面值比平视要大,也就是从斜上方/斜下方观察时轮廓光更强。

和 7.1 的区别

  • Fresnel(1 - N·V):只看法线偏离视线多少,不区分方向。球体正对相机时,整个外圈(上下左右)都会亮,是一圈均匀的轮廓光。
  • h136(|N·Right|):看法线贴不贴相机右方向。同样那个球,右边(法线朝右)→ |N·Right| 大 → 增强;上下边缘(法线朝上/下)→ |N·Right| ≈ 0 → 不增强;左边 → 也增强(因为取绝对值)。
7.4 正对裁切 + 强度 + 遮罩

正面裁切:

float facingCut = 1 - saturate((NdotV - 0.05) / (lerp(0.3, -0.1, abs(dot(V, float3(0,0,1)))) + 0.1));

把正对相机的部分(NdotV > 0.05)直接砍掉,防止正面也发轮廓光;lerp(0.3, -0.1, abs(dot(V,(0,0,1)))) + 0.1 这部分控制过渡带的长度,思路和前面的 Lambert 过渡类似。

所以最终的 Fresnel 是:

float fres = (1 - NdotV) * h136 * facingCut;

h136 就是前面的侧面增强部分,俯仰视的效果已经包含在里面了。

最后再软阈值 + 强度 + 遮罩:

float rimMask = saturate((fres - 0.5) / 0.4) * 0.2 * maskY;
  • saturate((fres - 0.5) / 0.4):软阈值,让 fres 从 0.5 到 0.9 之间才逐渐亮起来;
  • 0.2:边缘光强度,可以作为外部调节参数;
  • maskY:前面提取的遮罩贴图 G 通道。

fres 软阈值后:

maskY:

这处的 maskY 几乎覆盖了所有需要边缘光的部位。最终 rimMask:

7.5 颜色与混合
float3 rimColor = lerp(0.15915, 0.3183, h85);

这里的 h85 就是前面的受光带因子:受光部分用亮一点的边缘光颜色,背光部分用暗一点的,这两个值是自行设置的参数(0.15915 = 0.5/π0.3183 = 1/π,亮度可按需求调整)。

4.1.8 环境项

float3 env = float3(1.0, 0.0, 0.35) * clamp((albedo.a - 0.04) * 1.1111, 0, 1);

这里有个疑问:我提取到的 BaseColor 贴图没有 A 通道,但截帧分析里确实存在 albedo.a 的采样。目前先按截帧数据保留这个公式,实际复刻时可以用常量或遮罩代替。

4.1.9 合成

目前已经算出了:HDR 后的 Albedo、LUT 处理后的 lutBlend、高光部分、Rim 部分,接下来把它们混合起来。

HDR 后的 Albedo:
lutBlend:
高光部分(其实就是天使环高光):
Rim:

主体合成

float3 body = (v87 * h85 + glintTerm) * 0.31831 + lutTerm;
  • v87:HDR 后的 Albedo(基础色);
  • h85:受光带因子;
  • glintTerm:眼神光/高光;
  • lutTerm:LUT 主明暗。

理解:lutTerm 是主明暗,前面括号里的是附加光(补光 + 高光)。

为什么要乘 1/π

0.31831 = 1/π,而且只乘给附加光,不乘给 LUT

  • 附加光(补光 + 眼神光)属于物理光照贡献,漫反射 BRDF 的归一化因子是 1/π,乘上它才能让光照能量落在合理范围(不过曝),这是能量守恒;
  • LUT 不乘 1/π,因为 LUT 的值是美术直接画好的“最终明暗”,它不是物理光照,本身已经是 0~1 的设计值,不需要归一化。

补光部分:
lutTerm:
最后的 body:

与 Rim、环境光结合

float3 lit = lerp(body, rim, saturate(rimMask)) + env * 0;   // env 先乘 0,后面再调

最终效果(未加环境部分):

注:这里 RimDensity 的值比较小,所以边缘光不太明显。

4.2 眼睛与嘴巴

截帧中眼睛/嘴巴部分的采样 UV 是通过顶点色索引的。为什么要用顶点色?AI 的解答如下:

游戏里眼睛表情的切换是通过“顶点色选图集块 + 换表情网格”实现的——每个表情网格在建模时就把顶点色 Z 烘焙进不同档位,材质和 shader 全程不变,动画系统只需要切换哪个网格可见。每帧真正绘制的仍然只有一个眼睛网格,Draw Call 和运行时计算几乎零增加,显存里多几个小网格也完全可以忽略。它远比 Morph Target 每帧插值、顶点动画每帧刷缓冲、或换材质实例打断合批这些方案都便宜。

顶点色方案的本质是把“表情”变成网格属性而不是材质属性,让所有表情网格共享同一材质,既避免了表情切换带来的渲染状态变化,也为同屏多角色/多表情共存时合批留了可能(同材质是合批的必要条件而非充分条件,合批的收益主要体现在多表情同屏的场景,而非单角色播动画)。所以它是一套“把昂贵计算前移到离线烘焙、运行时只做一次显隐判断”的移动端标准做法。复刻单角色时不必照搬,直接用一个材质参数驱动表情块号即可,效果一致且更简单。

4.2.1 选图集块

眼睛图集 59993 是 2×4 共 8 块,每块是一对眼睛表情。用顶点色 Z(本帧钉死 0)算块偏移:

float2 tile = float2(0.5 * floor(sel % 2), 0.25 * floor(sel / 2));
float4 atlas = FaceTex.Sample(sam, UV0 + tile);   // 模型 UV 已缩到单块大小

4.2.2 图集 RGB 与 A 通道的分工

  • atlas.rgb = 虹膜/眼白颜色;
  • atlas.a = 区域遮罩,它同时管两件事

    • 镂空:alpha 太低的像素丢弃,做出眼睛形状;
    • 眼白/高光区判断isWhite = step(0.79, atlas.a),alpha ≥ 0.79 的像素被标成“眼白/高光区”。这个阈值是硬编码的,kGlintBias/Scale 是整体平移缩放这张遮罩的调节钮。

4.2.3 反色曲线解码图集 RGB

图集是美术在 LDR 里画的,直接用会“假死”在 0~1。shader 把它解回 HDR 空间(0.5→0.44、1.0→1.42,亮部抬到 >1):

float3 v36 = ((-0.56*x + 0.047) - sqrt(-0.2072*x*x + 0.70896*x + 0.002209))
             / (2.0*(0.93*x - 1.36));

4.2.4 卡通明暗:没有法线贴图

这是眼睛和主体最大的区别:眼睛不采样法线贴图,它的“朝前程度”直接用一个常量 Nz = 0.5,所以眼睛永远是半亮的卡通明暗,不随角度变化:

float NdotL = dot(normalize(WorldNormal), kLightDir) * 0.5 + 0.5;
float h72   = (NdotL + 0.5) * 0.5;              // +Nz 提亮,暗部不会死黑
float h76   = 斜坡(h72);                         // 受光带因子

4.2.5 LUT:不查 LUT 贴图,用两个常量色

主体查 LUT 贴图,眼睛/嘴巴直接把“暗色 0.5 灰”和“亮色 1.0 白”两个常量当两行 LUT 用:

float3 lutEye = lerp(0.5, 1.0, 受光带因子);

4.2.6 合成与出口(和主体完全一样)

float3 lit = v36 * (受光带 * 0.3183 + lutEye);   // 补光 1/π + LUT 主明暗
lit = clamp(lit, 0, 100);
lit = sqrt(lit * 0.0625);                        // 压暗存储编码(UE 里可省)
由于我暂时不打算给模型增加额外的嘴部/眼部独立网格体,这里先留了一个材质实例外露参数来控制表情变化,方便以后直接做动画,所以暂时不考虑性能优化。

眼睛图集:
嘴巴图集:

4.3 后处理部分

后处理顺序:还原 → 大气雾 → 色调曲线 → 深度描边 → 阴影/光斑 → Bloom → 最终合成 → sRGB 上屏

总览(一个全屏 pass 链)

材质输出 (sqrt 压暗后的 HDR)
   ↓ ① 还原 c²×16
   ↓ ② 大气雾(淡蓝 HDR,深度越远雾越浓)
   ↓ ③ 色调曲线(暗部微调,亮部恒等)
   ↓ ④ 深度描边(深度差分 → 轮廓 → 混深色 + 重写深度)
   ↓ ⑤ 阴影/光斑(30 个全屏四边形,深度投影 + 径向衰减)
   ↓ ⑥ Bloom(多级降采样 → 上采样 → 加法合成)
   ↓ ⑦ 最终合成 → sRGB 编码上屏

① 还原(c²×16)

我这次材质端是直接输出 lit,没有做最后的压暗存储,所以这一步实际上不存在。

材质输出端做了 sqrt(lit × 0.0625) 压暗(为了在低精度 RT 里存 HDR 不丢细节),后处理第一步还原:

c = c² × 16;   // (sqrt(lit * 0.0625))² × 16 = lit

目的:把“压暗存储”恢复成真实 HDR 亮度再继续处理。

② 大气雾

全屏重建每像素世界位置(深度反投影),做指数高度雾:

T     = exp(-雾密度 * 距离);           // 透射率
color = sceneColor * T + fogColor * (1 - T);
  • 雾色是淡蓝 HDR:截帧里在 (0.15, 0.76, 2.0) ~ (0.41, 1.15, 3.0) 之间,乘 0.63 压过。
  • 效果:精灵本体离相机近,几乎不受影响;背景被雾染成蓝灰,拉开层次。
  • UE 里直接放 Exponential Height Fog 组件调色即可,公式一样。

③ 色调曲线

对雾后颜色做轻微分级,只动暗部:

luma = dot(c, float3(0.299, 0.587, 0.114));
f    = 1 - smoothstep(0.0, 0.09, luma);   // 只有暗部权重为 1
out  = lerp(c, pow(c, 1.085), f);         // 暗部走 gamma 曲线,亮部恒等
  • 1/0.9216 ≈ 1.085 的指数非常温和,是给整张画面统一“暗部造型”的微调。
  • UE 里:Post Process 材质两三个节点搞定(或 Post Process Volume 的 Color Grading 阴影 gamma 近似)。

④ 深度描边(前面详细讲过,这里只提它在链里的位置)

  • 读精灵深度,4 邻域差分找轮廓 → 边缘混深蓝灰描边色;
  • 同时把边缘像素深度改成邻域最小值(DepthReplacinggl_FragDepth)——这一步是为了让后面的阴影/光斑也认这条轮廓,否则描边和阴影接不上。
  • UE 里:CustomDepth 差分 + Stencil 门控。

⑤ 阴影/光斑

02 文档 9.3 的实证:约 30 个全屏四边形,每个 = 一个光斑/阴影。公式:

fade   = pow(v.y, a) * pow(v.x * (1 - v.x), b);        // 顶点 UV 径向衰减
shadow = clamp(强度 * fade * saturate((精灵深度 - 当前深度) / 范围), 0, 1);
  • 采样精灵深度,和当前像素深度比较,产生“影子落在精灵后面”的软阴影;
  • 叠加颜色 + 阴影强度进 HDR。
  • UE 里:场景里用 Decal/投影或直接手摆半透明面片近似。

⑥ Bloom(辉光链)

  • 降采样:每级 4 点平均(e579 的 (a+b+c+d) * 0.25),一路降到约 1/64 分辨率;
  • 上采样合成:逐级回采,加法加回 HDR 场景色;
  • 作用:精灵亮部(眼神光、受光带)泛出一圈柔光。
  • UE 里:Post Process Volume 直接开 Bloom,Intensity 0.5~1,不需要自己做。

⑦ 最终合成 + sRGB 上屏

e810 的实证,最后把线性 HDR 编码成 sRGB:

c' = c <= 0.0031308 ? c * 12.92
                    : 1.055 * pow(c, 1/2.4) - 0.055;

这就是显示器要的标准 sRGB 编码,UE 引擎会自动做,不用管。

在 UE 里的实际操作

整体对应关系

截帧 E527 做的事UE 里的对应方案
c² × 16 还原材质输出可选,取决于材质输出端怎么写的
大气雾(指数高度雾 + 蓝色 HDR 散射)放一个 Exponential Height Fog 组件调参,公式就是它
色调曲线(仅暗部调整,亮部恒等)Post Process 材质,一两组节点
BloomUE 自带 Bloom,Post Process Volume 打开即可

第一步:决定“还原”做不做

截帧材质输出端做了 sqrt(lit * 0.0625) 压暗,后处理再 c²×16 还原——这是他们魔改管线的中间存储手段。UE 里有两种选择:

  • 方案 A(照搬):材质端保留 sqrt(lit * 0.0625),后处理第一步 Pow(c, 2) * 16 还原。
  • 方案 B(推荐,简化):材质端直接输出 lit(不用压暗),后处理不做还原。UE 的 SceneColor 本来就是 HDR,不需要这层保护。效果一致,节点更少。

选 B 的话,下面教程跳过“还原”那步。

第二步:Post Process 材质(色调曲线)

  1. 新建材质,设置:

    • Material Domain = Post Process
    • Blendable Location = Before Tonemapping(默认就是)
  2. 加入节点 SceneTexture: PostProcessInput0,这是“进后处理时的场景颜色”。
  3. 按截帧公式搭:
luma = dot(c, float3(0.299, 0.587, 0.114));
f    = 1 - smoothstep(0, 0.09, luma);     // 只有暗部(luma<0.09)的权重为1
out  = lerp(c, pow(c, 1.085), f);         // 暗部走 gamma 曲线,亮部保持原样
  • 截帧的 pow 指数是 1/0.9216 ≈ 1.085(RGB 略有差异:1.085 / 1.037 / 1.0,近似用 1.085 就行,很温和)。
  • 这一步就是“暗部微调曲线”,改动很小,效果是压平暗部、保持亮部不变。
  1. 输出接到 Emissive Color
  2. 保存后,把该材质加进 Post Process Volume 的 Post Process Materials 数组(或项目设置的 Global Post Process)。

第三步:大气雾(用引擎组件,别自己写)

E527 的雾就是标准指数高度雾公式,UE 自带的 Exponential Height Fog 组件一模一样:

  1. 场景里放一个 Exponential Height Fog
  2. 参数建议(对着截帧的蓝色散射色调):

    • Fog Density:小值,比如 0.0001~0.001(精灵离相机近,雾对它影响很小)
    • Fog Inscattering Color:淡蓝 HDR,参考 (0.15, 0.76, 2.0) * 0.63 ≈ (0.09, 0.48, 1.26)
    • Fog Max OpacityStart Distance:看你场景背景多远,背景会被雾染成蓝灰色
  3. 目的是让背景沉下去、和精灵拉开层次,精灵本体基本不受影响。

第四步:Bloom

Post Process Volume 里:

  • Bloom 打开,Intensity0.5~1.0 试。
  • 精灵身上的高光(眼神光、亮部)会因此柔和泛光。

第五步:后处理描边

依然是多做一个后处理材质:通过左右上下偏移取 UV,采样对应位置的深度值,与原深度相减后取 abs,最后用 smoothstep 作为场景颜色与描边颜色的插值因子 alpha,具体数值根据需求调整。需要把材质里用到的 SceneTexture 设置为自定义深度(Custom Depth),再在对应网格体的细节面板里启用自定义深度。参考教程:CSDN - 在 UE 中做描边

第六步(踩坑):描边抖动与抗锯齿

复刻时发现描边会抖动,排查后是抗锯齿设置的问题:SMAA 和 FXAA 不会出现描边抖动,TAA 和 TSR 会有抖动

AI 给出的原因:

核心原因一句话:TAA/TSR 是时间性的(每帧移动采样点),SMAA/FXAA 是空间性的(每帧固定栅格),而你的描边是“单像素深度差分”,对采样点位置极其敏感。

  1. SMAA / FXAA——空间抗锯齿,不抖
    它们只在当前这一帧内工作:在固定的像素栅格上检测边缘、混合邻近像素。每帧的采样位置、差分结果都是确定的,不随时间变化 → 描边 mask 每帧一模一样 → 纹丝不动。
  2. TAA——时间抗锯齿,抖的根源是 jitter
    TAA 每帧给相机投影加一个亚像素偏移(jitter),让每帧的采样栅格错开零点几个像素,再靠历史帧混合出平滑效果。你的描边 mask = smoothstep(阈值, 深度差分),深度差分是像素级梯度。jitter 让深度采样点每帧在边缘处移动半像素 → 差分值在边缘像素上每帧在“大/小”之间跳 → mask 在 0/1 之间切换 → 描边闪。雪上加霜:TAA 还会把前后帧的 mask 混合,不稳定边缘直接变成“残影式闪烁”。
  3. TSR——比 TAA 更明显
    TSR 是时间超分:先在低分辨率渲染,再用 jitter + 多帧信息重建高分辨率。jitter 幅度按像素比例算更大,且它额外做时域累积和锐化,会把不稳定的描边边缘放大得更厉害 → 抖动最明显。
  4. 为什么“关掉 TAA 就不抖”正好验证了这一点
    关掉后没有 jitter,深度差分变成纯确定性计算,描边位置固定,自然不闪。

顺带说清一个关键点

描边是在 Before Tonemapping 跑的,TAA 也在这个阶段附近。描边读到的深度是“当前帧 jitter 后的采样位置”,所以它必然受 jitter 影响。SMAA/FXAA 即使把画面弄模糊一点,也从来不碰采样栅格,所以它们不会让描边抖。

解法回顾(对应这个原因)

  • 让描边对 jitter 不敏感:mask 平滑、差分步长加大(±2 像素 / 3×3 Max-Min)、阈值带宽拉开;
  • 让描边躲开 jitter:Blendable Location 挪到 After Tonemapping(TAA 跑完之后);
  • 想要完全稳定又不牺牲抗锯齿:描边挪到 TAA 之后 + mask 平滑,两者一起上基本无感。

最终效果对比与后续计划

复刻效果:

游戏中:

整体看下来,复刻效果和游戏里的精灵展示页还是有一些差别,可能还有某些参数没调对,继续微调参数或许能更接近原版。目前先告一段落,场景部分的复刻以后再做。

补充:

之后通过适当调整饱和度等一些参数来试图达到游戏里的效果。只能说是东施效颦的感觉了。

4.4 场景复刻

场景这里我没有通过截帧分析的来做,因为通过ai分析出来的流程,异常奇怪,让我很烦躁。干脆就自己照虎画猫,勉强做了个相似的。

4.4.1 背景墙

背景墙就是一个plane,通过世界坐标Z坐标进行lerp插值。下深上亮。需要注意的是世界坐标的值很大需要自己除以一个比较大的值,并自己调整。

而背景墙上的动态贴图则是直接通过将plane的uv进行处理,通过tiling来调整图案数量与密度,这里是7x7。具体的数值需要通过场景的大小和最后摄像机呈现的画面进行调整。

最后的贴图采样出的rgb值与上面插值出的渐变色进行lerp插值,alpha值就是贴图采样的A,通过乘以一个参数来手动调整,最后贴图的透明度。
Pasted image 20260903215909.png

4.4.2 地面

地面我就是完全取巧的去做的,实在没啥好说的。就是使用表面默认光照着色模型,把渐变色放在自发光引脚上,然后在基础色部分,在外面实例中适当调整来显现阴影,再配合调整渐变颜色来试图达到游戏里的地面和墙颜色相差无几,但这效果一点也不好。之所以出现这种窘境,还是如果把渐变色放在基础色引脚上,lit着色模型,自然会在不同的光照角度下显现的效果不同导致墙壁和地面的区别特别大。而如果连上自发光后,基础色完全为黑色,那阴影就显示不了。目前我的水平还没有快速又两全齐美的方案。就暂且这样吧。

Pasted image 20260903220015.png

最终效果:

Pasted image 20260903214212.png

标签: none

添加新评论