在 Unity URP 中实现莱万汀角色渲染
材质、光照、后处理与调试记录 · 2026 年 9 月 7 日
这次练习从 Alicia 的莱万汀模型出发,对照 HurnCrngohh 的渲染文章和三张展示截图,在 Unity 2022.3.62f1、URP 14.0.12 中搭建一个可旋转、可近距离观察的角色场景。主要工作集中在头发高光、脸部明暗、黑色衣料反射、裙摆发光,以及最后的画面调色。
最初容易把问题理解成“做一个角色 Shader”。实际调试时,影响观感的因素分散在模型、贴图、材质、环境反射、阴影和后处理里。同样一组材质参数,在后处理没有真正接通时看起来合适,接通之后就可能完全失衡。本文沿着这些依赖关系说明当前实现,并记录几次确实影响结果的错误。
本文对应当前工程中的实现,不能当作原游戏渲染方案。已有素材主要是模型与颜色贴图;法线、材质遮罩、脸部明暗控制等缺失数据由本工程估算或制作。目前仍未达到参考截图的一比一还原,尤其是衣料反射的细节分布、眉毛透发和眼睛层次。

图 1:当前 Unity 实际渲染。图片来自相机输出,经过两倍分辨率渲染后缩小;不是 WebGL 性能或画质验证结果。
1. 先确定要匹配哪些视觉关系
参考图中的信息很多,但优先级并不相同。第一眼能认出的特征,是红发与浅色皮肤、黑色外裙与亮粉色内衬之间的大面积关系;往下看才是肩部翻领、金属附件、裙边的橙色亮点;放大之后,头发高光的断续形状和眼睛的层次才开始占主要影响。
因此,调试不宜从小块高光的亮度开始。先看整个人物在灰色背景上是否站得住:头发有没有红得发白,皮肤是否脱离衣服,黑衣是否还有体积,发光有没有变成一条等宽的橙色边。大关系不对时,继续加细节通常只会让画面更杂。
视觉目标来自文末署名的公开技术文章和用户提供的参考截图;本文不重新分发处理后的参考图。当前模型的姿态、相机与背景也没有与参考严格配准,因此不能直接用整图像素误差评价还原程度。
本轮约束是继续调整渲染,保留已有姿态。这个约束有实际意义:裙摆朝向、腿部遮挡和头部角度都会改变高光、阴影与轮廓,如果每轮同时改变姿态,就很难判断究竟是哪项材质修改带来了改善。
1.1 哪些资料可以直接用,哪些只能估算
| 数据 | 当前来源 | 使用时的边界 |
|---|---|---|
| 角色网格、骨骼、UV、原色贴图 | Alicia 模型转换结果 | 保留原资源,派生数据另外存放 |
| 材质类型与部分区域划分 | 材质槽、UV 覆盖及原图观察 | 同一张图集不等于同一种材质 |
| 细节法线 | 原图高频信息的保守估算 | 原图里的亮暗可能是绘制高光,不能视为真实凹凸 |
| 金属度、反射率、AO、光滑度 | 区域标记与颜色分类 | 数值是制作选择,无法从一张颜色图唯一求出 |
| 脸部控制图 | 在模型空间制作阈值场,再栅格化到 UV | 文件名为 FaceSDF,实际不是恢复出的原始距离场 |
| 环境反射 | 程序构造的摄影棚亮卡与 GGX 预过滤 | 用来组织反射形状,不代表参考图的真实布光 |
把这些边界写在前面,是为了让后面的参数有上下文。例如,衣料法线强度较小,是因为它本身就只是一份谨慎的估算;盲目把强度拉高,会把颜色贴图上的明暗也当成凹凸。
2. 一帧画面如何组成
场景使用 Linear 色彩空间和 HDR 渲染。一个主方向光负责主要明暗与投影,球谐环境光提供基础漫反射,独立的摄影棚 Cubemap 提供衣料与金属反射。角色 Shader 没有累加额外点光源,当前实现服务于单角色展示场景。
从依赖关系看,可以把画面拆成下面几步。它说明各部分需要什么输入,并不表示 URP 所有内部 Pass 的精确调度顺序。
模型、骨骼与 UV
├─ 颜色贴图 / 数值贴图 / 材质参数
├─ 主光源阴影
└─ 提前生成的角色深度
↓
角色着色:衣料、皮肤、脸、头发、眼睛
↓
环境反射 + 发光 + 边缘光 + 场景雾
↓
URP 后处理:Bloom、调色与 ACES
↓
最终相机画面
角色共用 FractalMiner/Reference/Character,通过 _MaterialType 选择着色方式:0 为衣料与金属,1 为皮肤,2 为脸,3 为头发,4 为眼睛。材质类型通常对整个 Draw Call 固定;手套区域是一个例外,它在皮肤材质内按遮罩转入衣料分支。
共用 Shader 的好处是可见性、深度和阴影规则容易保持一致。不过,当前代码仍保留多组贴图采样和分支,不能据此推断它已经适合大量角色或移动端。是否拆成多个更轻的 Shader,应由实际构建与 GPU 测量决定。
2.1 可见性规则要在所有 Pass 中一致
ReadBase 先读取颜色贴图,再根据 _AlphaClip 决定是否采用贴图 Alpha。无论是否使用贴图 Alpha,材质色 _BaseColor.a 都参与最后的裁剪。因此,将材质 Alpha 设为 0,可以隐藏整个材质槽。
同样的判断被用于颜色、阴影和深度 Pass。否则常见的结果是:头发片已经看不见,阴影里却还留着;或者隐藏的附件继续写深度,把边缘光和后续效果挡住。
独立描边材质也必须同步颜色图、裁剪开关、阈值和材质 Alpha。仅在主材质里隐藏一个槽位不够。
3. 数值贴图:先约定通道,再写采样代码
本工程目前有 15 张派生数值图。它们采用 Default 类型、关闭 sRGB、保留输入 Alpha,并使用未压缩数据。原颜色贴图仍按颜色纹理读取。
这里没有把法线图导入为 Unity 的 NormalMap 类型,因为 Shader 读取的是直接编码在 RGB 中的切线空间 XYZ。使用 NormalMap 导入后,Unity 可能按平台重新打包通道;继续用 rgb * 2 - 1 解码就不再成立。通道约定和解码函数必须成对选择。
| 文件 | 通道内容 | 当前用途 |
|---|---|---|
| HairNormal | RGB:原始切线空间法线 | 少量发丝细节 |
| HairSmoothNormal | RGB:平滑头皮法线 | 已生成;当前材质选择解析头皮法线回退 |
| HairData | RG:发流;B:高光遮罩;A:偏移 | 控制发束高光 |
| HairMask | R:金属度;G:反射率;B:AO 近似;A:光滑度 | 绑定通用 _MaskMap |
| FaceSDF | R:明暗阈值;G:排除区;B:覆盖;A:未使用 | 脸部明暗控制 |
| Cloth01Normal、Cloth02Normal | RGB:原始切线空间法线 | 两组衣料细节 |
| Cloth01Mask、Cloth02Mask | R:金属度;G:反射率;B:AO 近似;A:光滑度 | 衣料与附件分区 |
| Cloth01Emission | RGBA:当前全零 | 避免袖口被误判为发光裙摆 |
| Cloth02Emission | RGB:辐射颜色;A:烧蚀粉色布料调色区 | 裙摆及拖尾 |
| BootEmission | RGB:粉色滚边发光;A:0 | 仅用于 M005 靴子 |
| BodyGloveMask | R:手套覆盖 | 在现有手部表面划分手套 |
| RingNormal、RingMask | 法线;金属度/反射率/AO/光滑度 | 仅用于 M036 金属环 |
HairMask.png 的命名尤其容易误导。它是通用材质参数图,绑定 _MaskMap;Shader 中另有一个可选 _HairMask,其通道是外壳混合、高光遮罩、AO 和暗发束,二者不能互换。
3.1 怎样把模型信息写入 UV
烘焙脚本先从转换后的网格导出三角形的 UV、位置、法线、切线及切线符号,再在 1024×1024 图像中进行重心坐标插值。这样,每个覆盖到的像素都能对应模型上的一个位置,也就可以在模型空间中判断“这是头顶还是下垂发梢”“这是低处拖尾还是上层裙摆”。
UV 岛外侧需要留出延伸区。过滤器并不知道某个黑像素只是“没有网格覆盖”,它仍会把这个像素与岛内数据平均。法线和发光如果贴着 UV 边缘结束,缩小画面时就容易出现接缝或亮度衰减。
每张图的来源、生成方法、尺寸、通道范围与 SHA-256 都记录在 map-manifest.json。这些信息用于确认当前材质到底读取哪一版数据,尤其适合多轮烘焙后排查“脚本改了,效果却没变”。
4. 头发:先让高光连贯,再处理发束细节

图 2:当前近景。高光已经跟随头部与光照变化,但发片接缝、发束形状和眼睛层次仍与参考不同。
头发的主要难点是两种尺度同时存在:整个头部需要连续的体积光照,单个发束又需要断续变化。直接使用发片法线时,每片头发都会按自己的面朝向亮起来,高光容易碎;把所有法线都改成球面法线,又会失去发束的结构。
当前实现保留细节法线,同时构造一份平滑头皮法线,再进行混合。
4.1 用头部局部基构造椭球法线
设头皮中心为 C,像素世界位置为 P,头部右、上、前方向分别为 R、U、F,椭球半径为 rx、ry、rz。将相对位置投影到这三个方向,得到:
q = (dot(P-C, R)/rx²,
dot(P-C, U)/ry²,
dot(P-C, F)/rz²)
Nscalp = normalize(q.x·R + q.y·U + q.z·F)
这里使用半径平方,与椭球隐式面的梯度方向一致。头部转动后,C、R、U、F 都通过 MaterialPropertyBlock 更新,因此平滑法线不会固定在世界坐标系里。
当前 _HairSmoothNormal 为 0.8,即最终法线以平滑形体为主,保留少量发片变化。工程也生成了切线空间的 HairSmoothNormal 图,但这一版明确把 _UseHairSmoothNormalMap 设为 0,使用解析椭球路径。文件存在,不等于运行时正在使用。
4.2 发流必须落在同一个切平面里
基础发流由头部上方向投影到平滑法线的切平面得到。HairData 的 RG 则记录 UV 空间的方向,先经网格切线、副切线转换到世界空间,再参与混合。
早期实现忽略了一个区别:网格的切平面与平滑头皮的切平面通常不同。直接混合,会把一个沿平滑法线的分量带进发流,后续高光偏移因此悄悄改变。修正过程如下:
float3 flowWS = tangentWS * flow.x + bitangentWS * flow.y;
flowWS -= smoothNormal * dot(flowWS, smoothNormal);
flowWS = dot(flowWS, flowWS) > 0.0001
? normalize(flowWS) : baseStrand;
flowWS *= dot(flowWS, baseStrand) < 0 ? -1 : 1;
strand = normalize(lerp(baseStrand, flowWS, dataStrength * 0.22));
最后的同向处理用于避免镜像 UV 岛产生正反相反的方向,插值后互相抵消。对一条发丝来说,轴线的正反未必影响未偏移的高光;但这里还有带符号的偏移,所以混合之前仍要统一方向。
当前 HairData 的 RG 使用固定的 UV 纵向发流,B、A 从原图局部细节中制作。它并没有恢复一张逐发束精确绘制的原始 Flow Map。
4.3 两个高光波瓣与局部断开
令 H = normalize(L + V),T 为发流方向,代码中的单个高光波瓣为:
S(T,H,p) = [max(0, 1 - dot(T,H)²)]^(p/2)
当半角向量接近垂直于发丝轴线时,高光较强。这是当前 Kajiya–Kay 风格高光的基础。代码通过给发流叠加少量平滑法线来移动高光带,再组合一个较宽波瓣和一个更窄、略有偏移的波瓣。另一个偏移波瓣用于扣出间断形状。
形状确定后,还做两层收束:
- 对每个波瓣执行
saturate((S - threshold)/(1 - threshold)),裁掉低强度尾部,减少整片泛白。 - 对高光遮罩取幂,强化原有的发束断续关系。它不会产生新的发丝细节。
当前参数是高光 Power 220、阈值 0.4、遮罩指数 2.3、强度 0.5、偏移 −0.2。调整顺序是先检查正面和两侧的高光位置,再收窄范围,最后调整强度与颜色。若位置本身不对,增加强度只会让错误更显眼。
5. 脸与皮肤:分别控制明暗
身体与脸共用暖色皮肤处理,但明暗分界的来源不同。身体仍依赖几何法线,使用较宽的过渡:smoothstep(-0.3, 0.85, N·L),再受主光源投影限制。这样可以保留腿部、肩部的圆润体积,而不会出现过硬的卡通切面。
皮肤的掠射暖色项与 (1 - saturate(N·V))⁴ 成正比,并受受光方向控制。这只是一个低成本的外观近似,没有求解皮下光传播,也没有实现屏幕空间散射;把它称为完整 SSS 会夸大当前实现。
5.1 FaceSDF 实际上是什么
本工程的 FaceSDF 是一张明暗阈值图。烘焙时,脚本依据脸部位置制作左右变化的阈值,并在鼻部附近做轻微修整,然后写入脸部 UV。它并没有计算某条轮廓的严格有符号距离。
运行时,先把光方向投影到头部水平面,求出相对于头部前方的朝向 facing。再构造光照阈值:
lightThreshold = 0.5 × (1 - facing)
光在正前方时阈值接近 0,侧面接近 0.5,背面接近 1。用这个阈值与贴图 R 通道比较,决定脸上哪些位置进入暗部。光从另一侧照来时镜像采样 U,使左右明暗可以切换。
G 通道负责排除不适合阈值控制的侧面区域,B 通道负责有效覆盖。两者之外回退到由几何法线和头部前向混合得到的明暗。该方案能减少鼻部几何造成的碎阴影,但质量仍取决于阈值图的制作和镜像 UV 是否正确。
5.2 刘海阴影只做了受限近似
脸的主要明暗由阈值图控制,普通投影不直接决定整张脸的明暗。为了保留刘海附近的遮挡感,额外读取主光源阴影,并按头部高度把作用范围限制在上半脸。
覆盖从头皮中心下方约 9.75 cm 开始渐变,到下方约 2.25 cm 达到完整覆盖;这一数值对应当前 0.15 m 的头部纵向半径。强度还被限制在 0~0.5,当前使用 0.35。
额外阴影只对已有颜色做暖色乘法,不会把额头直接压成纯黑。即使强度达到上限,单独这一项也至少保留原颜色的约 88% 红、78% 绿、74% 蓝。这里说的是“这一项的影响”,并不意味着整张脸最终亮度有同样的下限。
它使用普通几何阴影图,无法区分投影来自头发还是别的物体,也无法替代专门的头发投影缓冲。参考方案中的眉毛、睫毛透发合成目前尚未实现。
6. 衣料与金属:反射形状比单纯提亮更重要
黑色衣料的问题常常是:颜色已经够黑,却仍不像布或皮革。原因可能不在底色,而在反射里缺少能解释形体的明暗结构。把环境强度整体拉高,会让整条裙子泛灰;在环境里放入方向明确的亮卡,反而能保留黑底,只在合适位置出现亮带。
当前衣料分支使用 URP 的 GGX 直接光高光和环境反射采样。材质图中的金属度、反射率、光滑度分别影响高光颜色、强度与范围。
介质的法线入射反射率采用:
F0_dielectric = 0.16 × reflectivity²
F0 = lerp(F0_dielectric, albedo, metallic)
因此反射率参数 0.5 对应约 4% 的介质 F0。金属度增加时,高光颜色转向底色。当前直接高光还设有峰值限制,以控制局部过亮。
环境项使用一个简化的视角 Fresnel 插值,它没有完整采用带 BRDF LUT 的 split-sum 合成。所以,这一版虽然使用 GGX 预过滤环境,也不宜写成完整、严格能量守恒的标准 PBR 实现。
6.1 黑衣、烧蚀布料和内衬分别调色
一套材质图集里同时有黑衣、浅色翻领和粉色布料。只乘统一颜色,会让本来正确的部分跟着改变。当前使用三组不同的覆盖:
| 覆盖区域 | 判断依据 | 当前控制 |
|---|---|---|
| 暗色中性衣料 | 线性亮度较低,且相对色度较低 | _ClothDarkTone,常规衣料当前为 1.35 |
| 烧蚀粉色区域 | 粉色分类 × Emission Alpha | _ClothAccentTone,使用 Cloth02Emission 时为 0.5 |
| 粉色内衬 | 粉色分类 × (1 − Emission Alpha) | _ClothLiningGain,M009 为 2 |
暗色中性覆盖由线性亮度 Y = 0.2126R + 0.7152G + 0.0722B 与相对色度 (max-min)/max 共同决定。这样,深粉色即使亮度低,也不会自动被当成黑衣处理。
_ClothDarkTone 这个名字保留了早期用途,当前值大于 1,实际是在提升选中区域的线性底色。读参数时应看公式,不能只看名字。
内衬单独使用线性增益,是调试后留下的一个选择。之前尝试过大幅提高材质 Color,再用别的比例压回烧蚀区,但 Color 属性、颜色空间与遮罩混合使这种“反向抵消”很难保持局部关系。把内衬提升直接放在 Shader 的线性底色阶段,作用范围和强度都更容易判断。
6.2 金属环单独制作遮罩
M036 金属环增加了 RingNormal 与 RingMask。法线由原图高频信息估算,遮罩提供金属度、反射率、AO 近似和光滑度。它们只绑定到这一材质槽,避免改变共用图集的其他部件。
当前 _MaskStrength=1,意味着光滑度由 RingMask 的 Alpha 决定。Inspector 上仍能看到标量光滑度 0.48,但它只是回退值;当前贴图中的光滑度大约在 0.36~0.72。排查“调了滑条却没变化”时,要先看贴图混合权重。
7. 摄影棚环境为什么要做 GGX 预过滤
环境里有两张柔边矩形亮卡:一张偏暖的主亮卡,一张偏冷的侧后亮卡,背景是低亮度的上下渐变。亮卡负责反射形状,主方向光负责角色明暗和投影,两者独立调节。
如果只生成一张清晰 Cubemap,再让 Unity 自动制作普通 mip,得到的是图像缩小结果。粗糙表面需要的却是按反射分布积分后的环境响应。两者可能都“变模糊”,但亮卡在不同粗糙度下的形状与能量并不相同。
ReferenceEnvironment.Bake 使用 128×128、RGBAHalf 的 Cubemap,对每个较粗糙 mip 做 128 次确定性的 Hammersley 采样。采样 GGX 半角向量时采用 V=N 的预过滤近似,将半角向量反射得到入射方向,再以正的 N·L 加权累积环境辐射,并用总权重归一化。
归一化的一个好处是常量环境仍保持原来的辐射值,不会因为粗糙度改变而无故变亮或变暗。当前实现只负责环境辐射预过滤部分,前面提到的简化 Fresnel 合成仍然存在。
7.1 mip 与粗糙度必须和 URP 对得上
URP 14 的环境采样使用如下感知粗糙度映射,其中 p = 1 - smoothness:
mip = p × (1.7 - 0.7p) × 6
烘焙时反过来求每层 mip 对应的粗糙度。否则,制作端以为“第三层对应这个粗糙度”,采样端却按另一套映射读取,滑动光滑度时就会出现不自然的变化。
这张 128 像素 Cubemap 实际有 8 层 mip,URP 上述默认反射映射的最后索引为 6。额外的物理 mip 7 也保存最大粗糙度的卷积结果。
所有 mip 写完后,使用 Apply(false, false) 上传。第一个 false 很关键:不再自动生成 mip,否则刚算好的 GGX 卷积会被普通缩小结果覆盖。
验证除了查看画面,还检查了 HDR 数值有限、各层响应确实变化,以及结果与普通 box mip 存在差异。它能证明预过滤代码在工作,但不能证明当前亮卡位置已经匹配参考。
8. 裙边发光:窄轮廓、尖端变化与 UV 重叠

图 3:侧面用来观察衣料反射、内衬与拖尾发光。一个视角里看起来合适的亮边,在侧面可能被拉成宽条,所以发光要同时检查距离与视角。
参考里的橙色亮点集中在烧蚀边缘和尖端,并不是沿整条边均匀描一圈。当前脚本从选定的粉色区域与网格 UV 覆盖中提取一像素轮廓,再用局部覆盖率估算尖端,让尖端较亮、连续长边较暗。低处拖尾还结合烘焙位置单独选择,防止把其他暖色图案一起点亮。
之前较宽的三像素边,在模型上会随 UV 拉伸变成粗橙条。改为窄轮廓后,又遇到缩小画面时发光消失的问题。原因是 mip 过滤混入 UV 岛外的黑色。最终保留一像素的有效轮廓,并向 UV 岛外延伸六像素数据,让远处采样仍能保住亮度。
当前裙边的基础辐射颜色为线性 RGB (1, 0.12, 0.008),材质发光倍率为 4。Bloom 只负责后续扩散,发光的位置与形状先由贴图决定。若贴图本身已经是一条过宽的亮带,降低 Bloom 也不能恢复细窄轮廓。
8.1 为什么靴子必须拆成一张图
靴子和裙摆使用的 UV 区域存在重叠。尝试把粉色靴子滚边写进共用 Cloth02Emission 时,裙摆在相同 UV 位置也读到了这份辐射,整片布料出现不应有的亮粉色。
这个问题不能靠修改橙色或降低全局发光强度解决,因为错误发生在“哪个材质应该读取哪个数据”这一层。最终新增 BootEmission,只给 M005 靴子绑定;裙摆继续使用 Cloth02Emission。材质共享图集时,必须检查 UV 复用关系,不能默认每个槽位占据独立区域。
8.2 Emission Alpha 不是透明度
Cloth02Emission 的 RGB 是发光,Alpha 是烧蚀粉色布料的调色覆盖。Alpha 为 0 不代表该像素透明,也不代表一定没有发光。它只用于前面的局部底色调整。
这个约定在导入器中也要保留:从输入读取 Alpha,关闭 alphaIsTransparency。否则编辑器可能按透明纹理的用途处理 RGB 边缘,破坏这张数值图的含义。
9. 描边与深度边缘光
描边采用独立的反向外壳:复制蒙皮渲染器,使用同一网格、骨骼和对应的逐槽材质;顶点沿世界法线外扩,剔除正面,只渲染背面。宽度单位是世界米,而非固定屏幕像素,所以它会随相机距离变化。
不能简单地往原渲染器后面追加一套描边材质。材质数量超过子网格数量时,Unity 不会自动给每个子网格再画一遍;可能只重复最后一个子网格。独立渲染器使对应关系更明确,但也增加了绘制和蒙皮相关成本。
头发细缝处的点状边线仍是当前问题之一。降低宽度只能缓解,不能替代专门制作的平滑描边网格。
深度边缘光则在屏幕空间沿视空间法线方向偏移采样深度,比较邻居深度与当前表面深度。如果旁边明显更远,就给当前位置增加少量亮度。这要求在角色前向着色之前,当前帧深度已经可用。
因此 Renderer 使用 CopyDepthMode.ForcePrepass。仅打开 Camera 的 Depth Texture 请求还不够:如果深度在不透明物体之后才复制,角色绘制时读到的就不是所需数据。排查深度类效果时,应先确认生成时间,再看贴图是否存在。
10. 后处理:先接通,再调色
后处理经历了两类不同的问题,表面上都像“参数不对”。
第一类发生在 Renderer 资源。场景中有 Volume,相机也开启了后处理,但程序创建的 Renderer 没有绑定 PostProcessData.asset。于是参数面板存在,实际的 ACES 与 Bloom 却没有按预期参与画面。
修正是在 ReferenceSceneBuilder.ActivatePipeline() 中显式绑定已安装 URP 的 PostProcessData,并在资源缺失时立即报错。这之后需要重新判断材质曝光,不能把旧画面下调好的参数直接当作有效基线。
第二类发生在截图路径,下一节单独说明。
10.1 本轮选定的参数
| 设置 | 调整前 | 当前 |
|---|---|---|
| Tonemapping | ACES | ACES |
| Post Exposure | 0.20 | 0.25 |
| Contrast | 10 | 7 |
| Saturation | 10 | 5 |
| Lift:中性轮 W | 0 | 0.004 |
| Gamma:中性轮 W | 0 | 0.04 |
| Gain:中性轮 W | 0 | −0.015 |
| Bloom Threshold | 1.0 | 1.2 |
| Bloom Intensity | 2.0 | 1.25 |
| Bloom Scatter | 0.35 | 0.28 |
| High Quality Filtering | 开启 | 开启 |
Lift、Gamma、Gain 的 RGB 都保持 (1,1,1),只调整 W,所以没有故意向阴影或高光加入新的色偏。这些数值是 Unity 色轮的参数形式,不能把 W 直接理解成最终屏幕像素增加的亮度。
四组同机位比较后,保留了较轻的提亮。更高的 Lift 虽然让裙子暗部容易看见,却也让黑衣和地面像蒙了一层灰。最终用少量暗部与中间调提升,略收高光,再减少全局饱和度和 Bloom 扩散。

图 4:本轮后处理调整前,已经启用真实后处理的基线。

图 5:相同场景与机位下的当前调色。主要变化在暗部与颜色收束,没有改动姿态、灯光或材质。
Bloom 的 Threshold 控制哪些亮部参与,Intensity 控制效果强度,Scatter 控制扩散范围;它们不能代替发光贴图里的区域设计。Threshold 是 Unity 面板中的 gamma 空间亮度输入,不应把表里的 1.2 直接与贴图的线性辐射数值横向比较。具体属性说明可查阅 URP 14 Bloom 文档。
这里只把后处理作为一轮小幅整理。参考图中的地面阴影更浅,衣料亮带也有不同方向;继续提高全局 Lift 会把角色一起洗灰,这类差异仍需在光照与材质层处理。
10.2 让保存的 Profile 和重建结果一致
参数集中在 ReferencePostProcessing.ApplyTo 中,由场景 Builder 和 Apply Studio Post Processing 菜单共用。新增 Volume 组件时,不仅加入 Profile,还在持久化 Profile 上作为子资源保存。
这样可以避免“Inspector 手调很好,重建场景后又回到旧参数”,也能防止组件只活在内存里,重开工程后消失。测试会连续应用两次,检查四个组件都已持久化,且第二次不会继续改变结果。
11. 截图与验证:先证明读到的是当前状态
本轮第一次调色对比,四组参数导出的图片完全相同,文件哈希也一致。这比肉眼感觉“变化很小”更明确:问题不在美术判断,而在截图没有使用刚改过的 Volume 状态。
查阅本机 URP 14 源码后确认,SingleCameraRequest 进入单相机渲染路径时,不主动执行常规相机流程中的 UpdateVolumeFramework。多渲染几次能稳定某些相机相关状态,却不会自动修正这项缺失。
当前截图前显式执行:
var data = camera.GetComponent<UniversalAdditionalCameraData>();
VolumeManager.instance.ResetMainStack();
VolumeManager.instance.Update(
data.volumeTrigger != null ? data.volumeTrigger : camera.transform,
data.volumeLayerMask);
随后先预热一帧,再导出稳定结果。触发位置优先采用 Camera 的 volumeTrigger,未设置时才使用相机 Transform,与 URP 的规则保持一致。
11.1 对比测试不要改坏场景
调色 sweep 会复制 Profile 及其组件,在临时副本上试参数,结束时恢复原 Profile。相机也只记录并恢复 yaw、pitch、distance、target 和 Transform 数值。
曾尝试用 EditorJsonUtility 序列化再覆盖整个控制器来恢复状态,结果丢失了场景对象引用。该次未保存场景,直接重载原场景恢复;最终工具改为恢复明确的数值字段。对这种小范围临时测试,显式恢复比整对象覆盖更可靠。
模型落地验证也有类似的坐标问题。这个 FBX 的渲染器带有 100 倍缩放,测量蒙皮后的顶点时,需要确认 BakeMesh(mesh, true) 与后续 Transform 的组合,不能按常规网格经验重复施加缩放。此前的落地检查已将误差压到浮点精度量级。
11.2 已测到什么,尚未测到什么
| 检查 | 当前证据 | 能说明的内容 |
|---|---|---|
| 后处理重复应用 | 两次组件序列化结果一致 | 没有累计调色漂移 |
| 场景保持 | 752 个 Transform、相机及控制器未改变 | 本轮确实只改了后处理 |
| 保存与重载 | Profile 保留 Lift 0.004,四个子资源有效 | 设置不是临时内存状态 |
| 真实图像变化 | 全身 RGB 平均绝对变化 6.283/255;近景 5.733/255 | 截图已经读到新调色 |
| 原有暗像素 | 全身原暗区平均 RGB 从 13.401 到 24.078 | 暗部被提起 |
| 大面积纯白 | 前后均无三个通道同时 ≥250 的像素 | 未出现这一判据下的白色剪切 |
最后一项不排除单通道饱和,也不证明皮肤或头发的局部层次一定足够。以上数值均来自同机位的编辑器截图,不能当作相似度百分比,更不能代替参考图比对。
全身截图输出 1920×1280,近景输出 1200×1400,均先按宽高两倍渲染再缩小。这会改善细边缘显示,但实际像素工作量是原分辨率的四倍,不能用它代替实时运行性能测试。
12. 发布取舍:静态技术文章
当前 WebGL 输出已完整重建并通过离线结构检查:加载器、数据包、Framework 与 WASM 均存在;数据包可解压,外层 UnityWebData 中的 UnityFS 包含 level0 和 globalgamemanagers;WASM 头也有效。压缩后的四个播放器文件合计约 684 MB。这个结果说明保存场景确实进入了构建,但浏览器中的实际加载、Shader 编译、画面、交互和性能仍未验证。
因此,个人主页发布的是本文和当前 Unity 相机截图,不包含 WebGL 播放器资源,也不提供浏览器交互或性能承诺。作品入口直接打开技术文章;读者看到的所有效果图都来自编辑器中的固定机位渲染。这既避免让访问者下载未经运行验证的大体积文件,也让当前可复核的实现、参数和局限成为作品主体。
构建使用与编辑器版本一致的 WebGL Build Support,只包含保存的 LaevatainReference.unity,不在打包前重建材质或重新摆姿态。目标为 WebGL 2,对应 OpenGLES3 图形 API;角色与描边 Shader 采用 target 3.5,仍需要实际平台编译与浏览器渲染确认。
ReferenceWebGLBuild.Queue() 先排队切换目标,再利用 SessionState 在域重载后继续构建。这样发起构建的调用可以先返回,长时间编译则由 Unity 自己完成。状态写到构建目录中的 build-status.json,用于区别“排队中”“构建中”“成功”和“失败”。
构建准备中遇到过两种容易混淆的状态。WebGL 模块尚未被当前 Unity 进程加载时,场景资源统计已经生成,后处理阶段才报告 Build target 'WebGL' not supported;另一次被中断的构建只留下运行时初始化文件和默认资源,没有 level0 场景。构建入口现使用 BuildOptions.CleanBuildCache 完整重建,构建前清理旧输出,发布检查另外解开数据包,确认场景、全局配置及 WASM 文件都在。这个检查针对实际遇到的残留半成品问题,不能替代后面的浏览器运行验证。
当前配置使用 Gzip、解压回退、数据缓存及哈希文件名。启用解压回退后,Unity 会为构建资源使用 .unityweb 后缀,在不能依赖服务器原生解压的情况下由加载器处理;代价是不能获得原生解压路径的全部加载优势。若以后单独发布交互版本,仍要核对响应内容与压缩设置,不能把“服务器返回 200”当作加载成功。相关规则见 Unity 2022.3 WebGL 部署文档。
交互版本进入公开发布前仍需检查:
- 浏览器下载的是正确的加载器、WASM 和数据文件;路径错误时没有被主页回退规则伪装成 HTML 200。
- 角色、阴影和后处理正常显示,四个视角能切换,旋转缩放可用。
- 桌面与不同 GPU 环境下的内存、加载时间和帧率。编辑器截图不能代替这些数据。
现有角色渲染仍有多材质、描边额外绘制、实时阴影及高质量 Bloom 等成本。网页限制了设备像素比上限,但当前没有测得可公开宣称的帧率或移动端适配结论。
13. 继续迭代时的顺序
13.1 2026-09-08:从整体调色回到局部职责
本轮重新对照正面、侧面和脸部固定机位后,没有继续增加全局曝光或 Lift。上一版最明显的问题之一是地面投影接近纯黑;如果用后处理整体抬升,黑裙和手套也会一起泛灰。因此主光的阴影强度单独降到 0.68,地面环境色由 (0.17, 0.16, 0.15) 提到 (0.24, 0.23, 0.23)。这能减轻阴影块的压黑程度,同时保留角色衣料自身的明暗范围。
头发基色倍率由偏粉的 (1.20, 0.98, 1.01) 收到更深红的 (1.08, 0.80, 0.84),双波瓣高光强度由 0.50 提到 0.58、锐度由 220 提到 235。目的不是把整头头发提亮,而是让顶部高光和侧后方暗红形成更明确的体积关系。眼睛沿用原贴图中的紫金虹膜与竖瞳,只把高光强度从 0.80 提到 1.18、视差从 0.008 提到 0.012;当前没有把程序噪声伪装成新的虹膜细节。
这轮仍未实现参考图中的摄影棚立体网格、眉毛透发或专用头发投影缓冲。尝试过程序化地面网格,但第一版 Shader 在实际相机中显示为错误材质,已撤回,没有进入当前截图和发布文件。现在的结果是一次可复核的局部收敛,并非一比一复刻。
下一轮仍应从固定机位开始。先确定主光方向与衣料反射亮带的位置,再检查红发与皮肤的亮度关系;之后处理发束高光形状、烧蚀轮廓和眼睛细节;最后只用少量后处理统一画面。
当前最明显的差距是衣料反射形状与细节、眼睛层次、眉毛透发,以及头发细缝处的描边。前三项需要更准确的数据或新的着色、合成路径,不能仅靠调色解决。背景网格与参考姿态也没有严格对齐,但它们属于另外的调整范围。
从这几轮结果看,最有用的习惯是每次只改能够解释的一组变量,并保存同机位对比。比如,发光消失时先查 UV 边缘和 mip;四组调色完全一致时先查 Volume 更新;黑衣发灰时先分辨漫反射、环境反射还是 Lift 在抬亮。找到具体发生错误的一层,比继续增加参数更容易得到稳定结果。
附录 A:代码与数据入口
以下路径以工程根目录为起点。
| 路径 | 内容 |
|---|---|
Assets/LaevatainReferencePreview/LaevatainReference.unity |
当前保存的展示场景 |
Assets/LaevatainReferencePreview/Shaders/ReferenceCharacter.shader |
五类材质着色与支撑 Pass |
Assets/LaevatainReferencePreview/Shaders/ReferenceOutline.shader |
独立反向外壳描边 |
Assets/LaevatainReferencePreview/Shaders/shader-contract.md |
参数、通道与兼容性约定 |
Assets/LaevatainReferencePreview/Editor/ReferenceSceneBuilder.cs |
场景、管线与资源接入 |
Assets/LaevatainReferencePreview/Editor/ReferencePolish.cs |
当前材质与摄影棚参数 |
Assets/LaevatainReferencePreview/Editor/ReferenceEnvironment.cs |
环境亮卡及 GGX 预过滤 |
Assets/LaevatainReferencePreview/Editor/ReferencePostProcessing.cs |
当前后处理参数与保存 |
Assets/LaevatainReferencePreview/Editor/ReferenceWebGLBuild.cs |
WebGL 排队构建与状态 |
Assets/LaevatainReferencePreview/ReferenceShowcase.cs |
相机预设、旋转缩放、头部局部基更新 |
Assets/WebGLTemplates/Laevatain/index.html |
WebGL 加载界面与网页控制 |
Assets/LaevatainReferencePreview/Textures/map-manifest.json |
15 张派生图的数据来源和校验 |
Tools/reference_bake_mesh.py、Tools/reference_bake_maps.py |
网格数据导出与数值图生成 |
Tools/reference_bake_ring.py |
金属环局部贴图 |
Tools/reference_capture_set.cs |
三个全身与三个近景截图 |
Tools/reference_polish_validate.cs |
材质重复应用与姿态保持检查 |
Tools/reference_postprocess_validate.cs |
后处理持久化与场景保持检查 |
Tools/reference_environment_validate.cs |
环境卷积数据检查 |
工程中的几个操作入口
FractalMiner > Reference > Open URP Showcase 打开当前场景。Apply Visual Refinement 重应用材质、光照与反射参数,默认不改姿态;只有 Builder 显式启用姿态步骤。Apply Studio Post Processing 只重应用后处理。两者的职责不同,调色时不用重新执行整个材质流程。
需要重建场景时,先保存有效的手工修改。Builder 会重设其管理的场景与材质参数,它不是一次无影响的刷新。
附录 B:排查速查表
| 现象 | 优先检查 |
|---|---|
| Volume 参数存在,Bloom 和 ACES 却没有反应 | Renderer 的 PostProcessData、相机开关、Volume 层与权重 |
| 修改 Profile 后离线截图完全一样 | SingleCameraRequest 前是否显式更新 VolumeManager |
| 头发高光在镜像区域断裂或偏移异常 | 发流是否投影到平滑法线切平面、方向是否同向 |
| 法线图颜色正常,表面方向却异常 | Default/NormalMap 导入方式是否与解码匹配 |
| 裙子突然出现靴子滚边的粉光 | 共用图集的 UV 重叠及 Emission 绑定 |
| 近看有发光,拉远后消失 | 轮廓宽度、UV 岛外延伸及 mip 过滤 |
| 透明或隐藏部件仍留下影子 | ShadowCaster、Depth 和 Outline 是否使用同样的 Alpha 规则 |
| 调光滑度滑条没有反应 | _MaskStrength 是否让贴图完全接管参数 |
| 粗糙度变化时反射不自然 | 环境是否真正预过滤,mip 映射是否与 URP 一致 |
| 深度边缘光不稳定 | 当前帧深度是否在角色着色前生成 |
参考与署名
角色模型与原始贴图来自 Alicia 的莱万汀模型,原作者说明保留在工程资源中。视觉目标来自 HurnCrngohh 的《【Unity URP】从零开始仿终末地莱万汀渲染学习记录》及用户提供的截图。本文讲述的是当前工程的制作与调试,没有把参考文章中尚未实现的功能计入完成范围。
代码核对以工程实际安装的 URP 14.0.12 为准,尤其是 UniversalRenderPipeline.cs 的单相机请求与 Volume 更新路径、Core 包 ImageBasedLighting.hlsl 的 mip 映射,以及 ColorUtils.cs 的 Lift/Gamma/Gain 参数准备。网页文档用于说明公开接口,具体行为仍需与所用版本源码和实际输出交叉核对。