《逆水寒》做毛绒大衣:Shell 绒毛渲染“从能用到好用”的实战经验公开!
前言:从 Demo 到生产管线
在现有的公开技术资料中,关于 Shell绒毛的基础原理与 Shader 实现已有详尽的论述。然而,在实际的项目开发中,仅实现“能看”的 Shader 仅仅是第一步。
要实现大规模量产,我们需要搭建一套完整、易用的工具链,赋予美术人员对细节的精准掌控力;同时,必须在复杂的环境中,寻求视觉表现与性能开销的完美平衡。
本文将基于《逆水寒》端游的落地实战经验,基于自研引擎的开发,分享如何构建高效的绒毛工具链,并深入解析开发过程中遇到的痛点、深层原因及解决方案。
在正式开始之前,我们先对Shell绒毛的原理进行一个快速回顾。对这部分很熟悉的同学可以直接跳过。
0.1 Shell毛原理回顾
想象我们现在有一个光秃秃的圆球模型,也就是图中的这个红球。Shell技术就是在这个圆球外面套上很多层壳。或者也可以理解为,把这个红色的圆球模型以一个预设好的数值慢慢放大,形成外部的一层层的壳。
但这样显然是不够的,如果每层壳都是有实心颜色的,那么看起来只是物体变厚了。
为了使其看起来像毛发,我们需要给每一层壳进行”挖洞“处理,”挖“掉一些部分的颜色。于是,我们给每层壳都用上类似下面的噪点贴图,并给每层壳设置一个阈值Threshold。
每一层壳都按照UV对这张贴图进行采样, 得到一个数值Value后,与预设的阈值Threshold进行比较,若Value<Threshold,则将这个像素丢弃;反之,则保留这个位置上的壳的颜色。
当我们把几十层这样带着斑点的透明壳堆叠在一起的时候,这些原本是平面的斑点在空间中连成了一条线。这种视觉错觉使得我们在远处看时,就像是一根根竖起来的毛发。
但如果每一层壳上的斑点都一样大,毛发就会看起来像一根根均匀的柱子,就像牙签插在球上,非常丑陋。为了模拟真实毛发发根粗、发尖细的效果,我们会对每层用于clip的alpha进行适当的缩放,使得里层的斑点比较大和密集,外层的斑点变小。
0.2 外层壳的生产方式
如何制造出外层的壳呢?有两种可行的方式:
0.2.1 CPU驱动
假设我们需要长出n层壳,那么就让CPU发起n次对原始模型的drawcall,并在每次drawcall时传入当前为第几层的信息,在VertexShader中对原始模型的顶点位置进行偏移和修改,从而输出一个更大的壳。这个偏移会沿着模型本身的法线方向进行。自然而然的,如果我们想要改变绒毛的生长方向,就可以在这里引入一些自定义的方向信息,从而缓慢对每层壳进行偏移。而控制毛发的长度的一种方案,也可以在这个过程中控制每层壳向外生长的速度。
0.2.2 GPU驱动
CPU的多次drawcall当然会有较大的开销。既然CPU驱动的壳的制造也是自动化的对原始模型网格进行改变,为何不把这些内容全部扔到GPU上执行呢?
因此,GPU驱动的壳生成是这样的:CPU只发起一次对原始模型的Drawcall,当模型数据到达GPU后,可以使用Domain/Geometry Shader对模型进行进一步的网格细分和重构,从而最终制造出多层壳。
ok,下面正文开始,详细分享如何构建高效的绒毛工具链。
一、 交互丝滑:所见即所得的绒毛笔刷
绒毛形态的丰富度,很大程度上取决于笔刷工具的灵活性。
笔刷工作的核心原理:
我们采用了 RGBA 四通道贴图来存储绒毛形态的局部数据(RGB 通道存储生长方向,Alpha 通道存储长度),并在 Vertex Shader 中采样该贴图以驱动绒毛形态。基于此,我们开发了一套能够直接在模型表面绘制并实时修改该贴图的笔刷系统。笔刷通过修改该贴图数据来调整局部的绒毛方向。
笔刷的实现流程:
将屏幕空间的笔刷参数(强度、大小、NDC 坐标、笔刷的rgba数值)以参数的形式传递至 Shader。Vertex Shader 将模型 UV 映射至 NDC 空间,Pixel Shader 计算当前片元是否处于笔刷影响范围。在笔刷影响范围内的pixel,根据笔刷类型计算得到最终的rgba数值,最终输出绘制颜色。
对于笔刷的rgba数值是这样填充的:将笔刷移动的方向编码到rgb三个通道上;将绒毛长度的值编码到a通道上。
然而,要实现“丝滑”的交互体验,我们必须解决以下三个关键的技术难题:
1.1 解决笔刷“跳变”:严格的色彩空间管理
在绘制过程中,可能会出现生长方向与预览不一致的瞬间“跳变”。这并非简单的逻辑错误,而是数据读写过程中的色彩空间(Color Space)不匹配导致的。
· 问题根源:数据错位。一种可能的具体配置是:源贴图被标记为 sRGB 格式(硬件采样时自动进行 x2.2 解码至 Linear 空间),而笔刷 Shader 的 RenderTarget 配置为 UNorm 格式。数据经历了 读取(sRGB -> Linear) → 计算 → 写入(Linear -> UNorm) 的过程。若在写入前未进行正确的重编码(Encoding),下一次采样时的数值基准就会发生偏移,导致视觉跳变。
· 解决方案:必须在 Shader 中严格管理数据的编解码(Encoding & Decoding):
o 法线向量映射(Normal):写入时需将区间 [−1,1] 重映射(Pack)至 [0,1];采 样时进行反向解包(Unpack)。
o 色彩数值映射(Color):必须确保读写端 Gamma 校正的一致性。若计算结果在 Linear 空间,写入 sRGB 贴图前必须进行 Gamma 编码(约 1/2.2次幂);反之采样时需确保硬件或手动进行了 Gamma 解码。
1.2 镜像笔刷支持:基于 TBN 的方向重构采样方向
· 问题原因:为了节省 UV 空间,美术常对对称模型(如衣袖)使用重叠 UV。对于 Shell 毛发,简单的纹理采样会导致镜像侧的毛发“向内生长”或方向错误。

如果只是简单采样贴图的方向并应用,会导致左袖子的毛方向刷对了,但是右袖子的毛陷进模型里了的视觉错误。
· 解决方案:不能直接使用采样得到的方向向量。必须结合模型表面的 TBN 矩阵(切线空间),将采样得到的切线空间方向变换到模型/世界空间,从而确保无论 UV 如何镜像,绒毛生长方向始终符合物理直觉。

1.3 笔刷柔边优化:引入球面线性插值(Slerp)
为了避免笔刷边缘生硬,我们需要“柔边”效果。
· 问题原因:方向向量插值算法不能使用线性插值。对于方向向量而言,简单的线性插值(Lerp)是错误的。因为这会造成地线偏离问题,即,当两个方向向量夹角较大时,线性插值会“抄近路”穿过单位球体内部,导致插值结果长度小于 1,且方向变化不均匀,视觉上表现为硬边或方向突变。
· 解决方案:采用 球面线性插值(Spherical Linear Interpolation, Slerp)。确保插值路径沿着单位球面的大圆弧(Great Arc)进行,保证方向场在任意权重下都能平滑过渡,实现自然的毛流梳理效果。
下面我们展示一个真实模型的控制绒毛生长方向的贴图,用于对比方向硬边笔刷的效果与柔边笔刷的效果。
二、 视觉稳定:性能与表现的动态均衡
Shell 技术相比于普通材质,核心性能代价在于层数,层数越多,开销越大。这也限制了Shell技术制作长毛的效果。更何况,在多人同屏场景下,几十个半透明Shell毛的角色的渲染开销是巨大的。
我们采用了基于视觉稳定性的动态调整开销的策略,由此取得性能和表现的均衡
2.1 引入 FOV 的层数动态衰减算法
传统的 LOD 往往只考虑相机距离(Distance),这在 Shell 渲染中存在巨大缺陷。
· 痛点:当相机距离较远但 FOV 很小(长焦镜头)时,模型视觉占比依然很大。若仅因距离远就减少层数,会导致绒毛瞬间稀疏甚至“秃顶”。
· 修正:引入 FOV 作为修正系数。层数计算公式需同时考量 Distance 与 FOV,确保在任何镜头参数下,屏幕像素密度(Pixel Density)与绒毛层数相匹配。此外,我们还将画质分级、显卡性能纳入考量,综合计算最终层数。
2.2 廓形优先的性能优化决策
在降低层数时,如何处理毛发长度是一个关键的美学决策。
方案对比:
· 方案 A:保持每层间距不变,减少层数。导致毛发总长度变短,模型轮廓(Silhouette)塌陷,长毛变短毛。
· 方案 B:保持毛发总长度不变,增大每层间距。虽然精度下降,但模型剪影和核心特征得以保留。
我们选择了方案 B。对于时装表现而言,轮廓记忆点优于内部噪点细节。
2.3 半透绒毛的针对性优化策略
为了更好的绒毛表现,我们的一部分Shell绒毛采用的是半透明的渲染模式。由于半透渲染的排序和混合需求,对这些绒毛是无法进行简单合批的。
· 不透明优化:在低画质或性能瓶颈时,我们将半透绒毛切换为不透明渲染。此时即可开启合批。
· 特殊合批:Shell 毛的层数是由 CPU 实时计算并动态唤起 DrawCall 的。普通的合批仅判断 Mesh 和 Material 是否一致。对于 Shell 毛,必须额外判断 “当前层数” 是否一致,否则不同层数的物体强制合批会导致严重的模型闪烁
2.4 性能实测
作为参考,我们将分享这套衣香鬓影时装在游戏内的实际开销和对应表现(应用上所有优化技术)。
单件时装使用不同实现方案,在不同层数下的对比,近视角(注:层数指材质上设置的层数,并非实际看到的层数)
测试机型:高配机:4060Ti(32GB),i7-13700K,画质高配

多件时装同屏,使用Multi-Pass方案,在不同人数下的对比
测试机型:低配机:750Ti(2GB) ,i5-2300,画质高配

三、创作无感:构建流畅无阻的工具链
技术不仅要服务于最终画面,更要服务于制作流程。流畅的工具链应当满足两项要求:美术和实际使用的技术应当所见即所得,以及各个职能间的工作流没有反复循环的节点。
3.1 渲染管线的分歧与统一
实际上,我们实装了两套 Shell 渲染方案,对应的是两类shell材质:
1. GPU 驱动(GS/DS 方案):利用 Domain/Geometry Shader 在 GPU 端进行网格细分。优势是 CPU 仅需 1 次 DrawCall,开销主要移动到GPU上,而GPU的高度并行化能够大大加速计算;劣势是无法保证半透明排序,仅适用于不透明/Alpha Test 效果。
2. CPU 驱动(Multi-pass 方案):在 CPU 端初始化特殊的渲染图元,唤起多次 DrawCall。优势是支持完美的半透明混合,而半透明的绒毛效果能够制造出柔和的边缘效果和层叠的感受(从上面的图例中应该也能看出来,半透绒毛的视觉效果明显好一些);劣势是CPU需要频繁drawcall,开销明显增加。
这两类渲染方案各有优劣,因此我们均保留了下来,将GPU驱动的方案用在不透毛的渲染上,CPU驱动的毛用在半透明毛的渲染上。
3.2 工作流痛点的解决
问题:
模型美术在 DCC 软件制作模型时,并不清楚(也不应被强制要求清楚)最终会使用哪种技术方案(GS 还是 Multi-pass)。如果将图元初始化的标记直接写死在 Mesh 上,一旦美术更换材质类型(如从半透改为不透),如果不能及时切换图元的渲染类型,会很容易造成GPU驱动的方案叠加使用CPU驱动的方案(即多次drawcall的同时还要进行GPU上的网格细分)。尽管在表现上没有差异,但是会造成性能的浪费。
更进一步的,动作美术在制作蒙皮时会反复修改导出的Mesh的数据,但是他们并不清楚(也不应被强制要求清楚)这个Shell毛究竟使用了哪种材质。若他们没有办法给图元赋予正确的标记(实际上,也很难做到正确),就会造成模型美术需要反复重新指定图元标记,严重阻碍了模型制作与材质调节的并行工作流。
解决方案:
由于网格资源和材质球资源是解耦的, 在游戏中我们可以自由的对同一个网格资源切换不同的材质球,如果每次切换材质球都需要重新初始化网格资源,显然不是一个灵活的做法。
因此我们将所有的绒毛网格都首先初始化成特殊图元,并在Drawcall唤起前判断是否为GS材质,若是,则直接修改drawcall次数为1;否则正常进行多次drawcall。
通过这种数据驱动(Data-Driven)的加载流程,美术人员可以随意切换材质效果,而无需反复修改模型源文件,真正实现了技术对艺术创作的无感支持。
四 结语
当整个工具链搭建完毕后,绒毛的效果上限就更依赖于贴图资产,特别是毛形态的噪声贴图的制作了。通过改变噪声形态,我们可以开发出多种多样的绒毛效果,实现绒毛自由了!





