Files
obsidian-notes/InBox/zhihu_arknights_endfield_cel_shading.md
Build Bot f7310caea0 同步
2026-05-18 01:20:38 +08:00

81 KiB
Raw Blame History

title, source, created, tags
title source created tags
【unity urp】从零模仿复刻实现自己的终末地人物卡通渲染 - 知乎 https://zhuanlan.zhihu.com/p/2028819446546932894 2026-05-09 15:44
zhihu
article
unity
urp
rendering
cel-shading
anime-shader
arknights

【unity urp】从零模仿复刻实现自己的终末地人物卡通渲染 - 知乎

来源: https://zhuanlan.zhihu.com/p/2028819446546932894


闲话

瞅了一眼上一期文章的发布时间,没想到已经半年多没有发布新文章了

迫于毕业压力必须抽出大量精力和时间去做一些其他方面的研究因此中途搁置了一段ta渲染的学习

好在最近总算是事情告一段落了,来把前面允诺的一些技术整理成教程继续给大伙们写一写

这一期是接着前面星铁米哈游卡渲的后续进阶文章本来我的想法是把极度日式赛璐璐风格的米哈游风格卡渲讲完紧接着引入少前的pbr风格卡渲效果里承上启下的。但是本人作为一个舟批在终末地发售之后第一时间也去拆解研究复刻了一下终末地的渲染。由于也是走的pbr卡渲的风格不少地方和少前也是异曲同工之妙于是决定把第二期卡渲换成了终末地渲染效果的复刻实现教程

刚好读者也可以把我这篇博客内容与蛋白胨大佬的少前渲染教程进行对比学习可以在大脑里构建更加完善的人物卡渲方案系统了解两种不同的pbr技术方案产生的直观画面效果 但其实本质是少前的复刻有些基础性问题文章写了大半了不想从头改了hhhh

那么在具体讲解之前,还是照例说一下我的初衷,和之前写文章一样,我希望读者看了我的文章可以对整个实现有一个全面的了解,最好是可以从零开始跟着实现出一模一样的效果出来

因此我的文章会相对较长,并且尽我可能的让新入门的小白们也能对着我的技术文章一点一点的复刻出来,在这个过程学习和成长

同样的, 我也会在后面放出对应的代码仓库但依旧不包含任何的美术资源只有纯粹的shader我希望你可以对着完整的shader去学习理解当然现在最好的做法可能就是把我的这篇博客和我开源的代码仓库直接扔给code agent帮你分析并且复现出来体验极致的学习效率

本次终末地的卡通渲染复现,并没有参考过多的文章和他人实现,本质是一个我自己的逆向方案研究过程

结合某神秘截帧工具 + gemini的神力尝试着去复现完整的游戏shader也因此花费了不少时间

事先声明文章里依旧会存在不少与原游戏效果偏差的地方我也没有办法把所有地方全部研究清楚在ai的辅助分析下结合自己已有的经验做出来的复刻效果最后成品如下更加细致的展示可以去b站搜索我同名id查找

00:29

具体的技术方案总结目录会在下面章节说明,在这里提前说一下我没有去重点实现的一些地方,如果你后续能够在我的基础上把这些补上,那就太好了

  1. 羊毛绒裙边的渲染,尝试按照自己的理解和研究复刻了一下,但仍然没有抓到核心,效果差距较大,从视频里的裙边也可以看到,还有火焰呼吸变化的特效也没有去研究

  2. 描边的着色逻辑描边的外扩依旧是平滑但是着色有一套自己的规律我解读下来觉得就是正常的pbr卡渲逻辑但最后出现的效果也和游戏里有差别应该加了些trick设计

  3. 多光源着色逻辑没有涉及,笔者也没有对游戏里的多光源效果进行观察分析

然后从效果展示里也可以观察到一些着色上和原游戏的差异,可能在着色代码的完整度,灯光配置,后处理选择上都会存在各种各样的区别

如果你觉得本篇文章复刻的不是很好,额,那我争取后面再研究的更透彻一点,卡渲目前就准备先研究到这里了

闲话就到此结束,最后放一下开源仓库链接,我们正式开讲

目录

还是老样子,先从目录开始写起,学习一门技术,对整体的把控是非常重要的

整篇

人物模型资源贴图分析与整理 衣服 toon base shader zmd的toon shader特点分析存在明暗两种着色状态 不同状态下的主光源变化 基于三层色阶变化 + 背光补偿的toon漫反射 基于相机方向的风格化pbr高光 近似数学函数模拟的IBL高光 基于菲涅尔的边缘光 + 基于光照的边缘光 不同材质情况下的一些trick透明材质和特殊物件材质 皮肤 skin toon shader toon base基础上的着色逻辑去除IBL高光 基于lut图的统一皮肤暗部颜色获取 基于菲涅尔的sss透光效果 脸部 face toon shader 依旧是基于sdf贴图变化的光照实现方式 toon base基础上的着色逻辑去除IBL高光去除正常的边缘光 表情贴图的trick脸与脖子衔接处的平滑trick边缘光定制化trick 眼睛 eye toon shader 基于眼睛特殊法向的漫反射 基于matcap的眼睛高光 其余部分采用一个toon base的漫反射着色 头发 hair toon shader 各项异性的卡通高光实现,基于平滑法向和高光颜色贴图 toon base基础上的着色逻辑去除IBL高光去除正常的高光 描边 outline shader 基于平滑法向的外扩 毛绒裙边 fur shader 多层pass实例渲染基于形状贴图 基于噪声贴图的火焰特效 其余各种trick 刘海阴影,基于偏移的刘海模型 眼部阴影 后处理 lut图调色 + bloom + 抗锯齿

整个技术架构就是上述目录的部分,后续我也会按照这个流程进行讲述

本文终究只是个人的复刻实现而已,很多地方纯属经验猜想,还请进行理解

资源分析与整理

资源的获取方式和处理流程我在这里不会讲解,还请读者自己解决资源的获取以及规范化模型与贴图资源

如果不清楚怎么做,可以翻阅我前面的卡渲或者查阅其他人的文章学习

这里我会讲述后续渲染流程中必须需要的一些美术资源以及对应的作用,然后再实现的过程里不会再提及这些贴图,默认你已经清楚存在这些贴图资源

首先toon base shader里你需要保证有albedonormal以及一张材质属性贴图以小羊的衣服部位举例

材质属性贴图范例

其中r通道是metallicg是reflectivityb是aow是smothness粗糙度记得反向

记得确保albedo的w通道是存在的关系到剔除和半透材质的实现

同时每个部位都存在自己的ramp梯度图小羊的人物着色中衣服上存在三张不同部位下的ramp颜色图皮肤和脸部共用一张ramp头发一张毛绒一张

并没有学米家少前将多张ramp拼接然后使用id进行读取因此需要我们手动确认ramp图是否全部获取

衣服上的三张ramp图示意

记得后续实现的时候ramp图不要给错就行同时zmd的ramp图的rgb是颜色映射w通道是NoL映射得到定制化的光照明暗分布

ramp图的w通道

然后toon base存在自发光贴图

根据部位的不同,存在三张不同的自发光,用于特定部位下的美术效果补充,贴图比较有辨识度,全黑的中间存在颜色就是自发光贴图

环境贴图选一张cubemap环境贴图即可也可以直接拿原游戏里的环境贴图使用是一张被多次使用的环境贴图在少前那里我也见到过

同时还需要有一张高光颜色映射贴图与ramp的逻辑不一样是IBL预积分贴图的采样逻辑提前预积分好了一张高光brdf映射结果用来调整特殊物件的F0颜色

然后是skin shader需要有一张lut调色图的存在

face shader 上需要一张sdf魔法图和一张区域限制图里面藏了一些trick设计

以及一张四格区分的表情贴图

eye shader上需要一张matcap我觉得能看我这篇博客的读者应该是知道matcap的特点的也不会漏掉这里就不贴了

hair shader 上需要注意有一张高光颜色映射贴图

整个流程中比较特殊的贴图我应该都举例展示了,你在获取资源的时候遇到了这样的贴图记得存储和标记就行

还有有一些比如在毛绒shader里会遇到的一些噪声贴图我就不讲解了比较常见且识别度很高一眼就知道大概作用是做什么的

模型资源上你只需要注意存在一个平滑法向在uv中即可

有点类似把一个平滑法向贴图存到了uv上这个逻辑是我从截帧数据中分析的也许平滑逻辑并不是这么做的但我这边测试下来确实是有用的

toon base shader

确保模型贴图资源都大致获取后,就可以来开始正式的实现了

特点分析

在实现之前,先来对游戏里的效果进行一个分析,涉及到后面所有着色逻辑里的一个关键设置

来到武陵最佳光照位置,呆在原地看一会

可以看到环境中的主光太阳不是一直不变的,强度会随着时间变化,影响因素可能与天空中的云朵,当前天气等等有关

重点观察人物身上的着色变化,整体呈现了一个变暗的趋势,也许你会说,这种变暗效果直接动态调整主光强度不就行了吗?

下面我们再在一些其他位置观察一下

其实实现上是完全可以的,根据天气情况和人物所处的位置去动态调整太阳光的强度,但是一些细节上会存在缺失

比如仍然会感觉到主光源方向的强烈分布影响,因为是整体的强度减弱,光照影响的分布范围还是保持一致的

但是人物处在一个墙角位置,比如第三张图的时候,理应这种强烈的主光方向的引导效果不会存在,因为人物更多是被周围的次级光源给照亮的

比如在这个时候,我们可以让与主光有关的边缘光消失,一些只有在主光直射找到才会产生的效果进行减弱或者去除,所以我们需要区分两个着色状态,决定太阳直射日光,或者说主光源对我们的直接影响

之后把这个称之日光直射强度随便取得能get到意思就行

从视频的展示里,你可以看到两个状态的切换,但一般不会直接拉到最低,根据情况进行选择

由于涉及到两个状态,因此我们后面的实现,都需要时刻注意到是否要对两个状态做不同的处理

比如toon base shader里的漫反射部分我们在日光直射强度1的时候是这种计算强度0的时候走另外一种计算逻辑可能在这个逻辑里我们去除了一些需要主光源直射才会产生的一些影响比如把NoL减弱

日光直射强度变化影响

在大世界里这种场景和光照变化复杂的地方,这种状态的设置应该会比实时调整光源的强度要更加准确,同时细节呈现的更加丰富

前置数据处理

现在我们来正式进入渲染的实现流程如标题所示本文全部实现均在unity urp上且在蛋白胨大佬的框架下实现这里首先要对蛋白胨大佬进行致谢分享了一套优秀且成熟的卡渲管线

其中很多代码细节可能在讲解的过程中不会提及读者最好是对着我分享的完成代码进行对比理解但我仍然会按照从0到1的搭建实现过程慢慢讲解

// 基础贴图资源获取 float4 mainTex_var = SAMPLE_TEXTURE2D(_AlbedoTex, sampler_AlbedoTex, i.uv0);
float4 ormTex_var = SAMPLE_TEXTURE2D(_OrmTex, sampler_OrmTex, i.uv0);
float4 normalTex_var = SAMPLE_TEXTURE2D(_NormalTex, sampler_NormalTex, i.uv0);
ormTex_var = lerp(float4(0,1,1,0),ormTex_var,_IsNeedOrmTex);

// 光源属性获取 float3 mainLightDir = _MainLightPosition;
// 获取xz平面光源dir
float3 mainLightDir_xz = normalize(float3(mainLightDir.x, 6.10351562e-05, mainLightDir.z));

// normal处理
float3x3 tangentTransform = float3x3( i.tangenWS, i.bitangenWS, i.normalWS); // TBN矩阵
float3 normalTex_processed = UnpackNormalFromTex(normalTex_var, _BumpScale);
float3 normalWS = lerp( i.normalWS ,normalize(mul(normalTex_processed, tangentTransform)), _IsNeedNormalMap);
// 面朝向
float facing = isFrontFace ? 1.0 : -1.0;
normalWS = normalWS * facing;

// view dir float3 viewDir = normalize(_WorldSpaceCameraPos.xyz-i.posWS.xyz); // 相机forward dir
float3 cameraForward = UNITY_MATRIX_V[2].xyz;
cameraForward = normalize(cameraForward);
viewDir = lerp(viewDir, cameraForward, _ForwardDirStrength);
viewDir = normalize(viewDir);

在实现之前,先把需要的一些基础属性全部计算获取,代码比较直观,就是常规的 light dirnormalviewdir之类的

比较特殊的是需要额外获取一个camera forward dir这个相机朝向会在后面多次利用这个相机朝向你可以手动输出看一下依旧和light dir这些dir是一样的一定是从当前像素指向外边的注意不是相机指向人物的那个朝向就行

光源计算

终末地的人物渲染中光源有一个特殊trick设计我们可以认为他给每个人物的头顶额外设置了一盏顶光

我们把主光太阳光定义为mainLight然后这个顶光效果我们定义为otherLight

如果你想在游戏里观察顶光,小羊正好是一个比较好观察的对象

可以看到游戏中存在一个明显的从上往下照才能出现的光照分布效果,但是你观察地上的阴影,你就会发觉主光一定是在人物前面左方的位置

主光mainLight的光照分布大致是这样

从分布上可以看到明显的区别,在明确光源方案后,我们来实现光源相关的计算出来

前面获取了mainLight的light dir这里把强度和color以及光照分布NoL获取一下

// 获取光源颜色 float3 mainLightColor = _MainLightColor; float mainLightIntensity = max(0.001, (0.299 * mainLightColor.r + 0.587 * mainLightColor.g + 0.114 * mainLightColor.b)); mainLightColor = mainLightColor / mainLightIntensity; float NoL = dot(normalWS, mainLightDir);

NoL大致效果如下

主光的属性部分这里计算完毕,剩下把顶光 otherLight获取一下

// 头顶补光光源计算 float3 otherLightDir = float3(0,1,0); float otherLightNoL = dot(otherLightDir, normalWS); otherLightNoL = saturate(otherLightNoL + _OtherLightOffset); otherLightNoL = otherLightNoL * _OtherLightStrength + _OtherLightStrength_Offset; float3 otherLightColor = _OtherLightColor; float3 otherLightResult = otherLightColor * otherLightNoL;

由于是顶光dir就是0 1 0然后由于是一个主观上的光源设计因此我们需要对NoL的假如手动调节的效果color颜色依旧是自定义的参数效果

得到的顶光NoL分布如下

这里我按照原游戏的效果调节了一下整体分布强度比较偏向NoL*0.5+0.5的分布二次元风格里不需要那么强硬的光照分布因此这里需要手动调整一下前面mainLight的NoL后面的计算过程中也会经过相似的处理

代码里最后把otherLight的result定义成 color x NoLNoL这部分应该在着色渲染方程中出现这里提前作用上去是因为mainLight后续的NoL部分需要经过ramp图的处理会进行一个特殊映射我会在下一小节讲解到这个otherLightResult为什么这么实现

前面我们提到了zmd的着色的一个特点我们需要针对日光直射强度1和0设计两个不同的状态

其中光源上就要进行体现首先日光直射强度0的时候主光light部分应该去掉这是合理的主光light不产生影响头顶的顶光区域会随着日光直射强度进行怎样的变化呢首先1的时候不能太强本质是一个补充作用类似电影里的人物打光一样多一些光的效果可以让人物更加立体好看

0的时候此时主光影响几乎为0但很显然不能然人物完全暗下去一个环境光是不够的我们也不走复杂的全局GI不如就让顶光占据主体着色效果最后我们的光源实现如下

// 计算日光强度两个临界情况的补光结果 float3 otherLightResult_day1 = otherLightResult * _OtherLightResultStrength_day1; float3 otherLightResult_day0 = otherLightResult * _OtherLightResultStrength_day0; // 主光和补光相融合 得到最终主光源 float3 mainLightColor_final = lerp( otherLightResult_day0, mainLightColor + otherLightResult_day1 ,_DayStrength);

我这里把光源强度分出来了你把强度融入到color里也是可以的

从代码里可以看到我们给顶光设计了两个状态的强度控制参数用来自定义调节然后在日光直射强度1的时候主光 + 顶光补光的强度抑制结果0的时候去除主光只要顶光的结果

也就是我们前面分析的想法,具体的光源效果我们在下一节漫反射里取观察

漫反射实现

zmd的漫反射实现本质和我前面几篇卡通渲染的文章里讲述的逻辑是一致的本质是对渲染方程中的brdf进行修改把NoL的光照分布项转变为颜色上的修改而不是纯粹的强度变化

我们依旧按照传统的渲染方程来看我们的漫反射实现流程

light brdf NoL 其余项(shadow ao等)

现在light部分我们前面计算了还需要得到剩余三个部分我们先把单独的shadow和ao计算一下

// shadow 获取 float shadowAttenuation = 1; float2 screenUV = GetNormalizedScreenSpaceUV(i.pos.xy); shadowAttenuation = SAMPLE_TEXTURE2D(_ScreenSpaceShadowmapTexture, sampler_PointClamp, screenUV).x; shadowAttenuation = min(shadowAttenuation, SamplePerObjectScreenSpaceShadowmap(screenUV)); float shadowScene = (SigmoidSharp(shadowAttenuation, _ShadowCenter, _ShadowSmoothness) + _ShadowOffset) * _ShadowStrength ; shadowScene = saturate(shadowScene);

shadow的计算方式按照读者你已有的shadow方案即可但最好是逐人物阴影这种高精度的方式然后做一个手动的sigmod明暗分界控制效果

// 初始化材质属性 float roughness = 1 - ormTex_var.w; float roughness2 = max(roughness * roughness, 0.0078); float metallic = ormTex_var.r; float reflectivity = ormTex_var.g; float ao = ormTex_var.b; ao = pow(ao,_AoStrength);

获取ao的同时我们把其他pbr属性也一并获取一下给ao一个pow的调整效果用来自定义一下ao的强度

后续的一些效果展示里,我没有打开后处理调色,因此你如果直接对比游戏效果可能会有些区别,但从渲染逻辑上讲这样会更好一些

接下来我们来处理brdf和NoL部分其实逻辑依旧是用NoL去采样ramp图得到漫反射brdf的颜色映射变化

但是zmd在ramp图的漫反射brdf映射之上还加入了传统的卡渲漫反射的方案基于光照分布的明暗分层手动给两个自定义的颜色来做明暗变化可以参考我很久前面的日本toon shader那篇文章

我那篇老文章里的传统卡渲做法把物理的光照分布人为控制到非常硬的分层效果有物理变成了风格化的光照分布。由于zmd的走的pbr结合的卡渲方案因此分层不会做的很硬走的真实物理光照分布只是人为控制明暗上的颜色

在这种设计下,物理的光照分布+自定义颜色控制提供了主体的漫反射效果ramp图更像是一种调色处理来增加更多的细节质感

终末地选择漫反射上去用物理光照分布作为主体效果所以你在zmd人物衣服身上不太能看到很明显的明暗分界线显得更加真实

和少前的方案实现上是一致的少前也是以物理光照分布作为主体影响没有过度去影响整个分布然后去采样ramp图只是把ramp图的颜色效果作为主体zmd里把ramp图的效果作为次要

至于是否依赖ramp图可能与最初的美术方案设计有关个人感觉zmd这一套在泛用性上会更好ramp图可以脱离解耦开来调整起来更方便但实际上最终效果不会差别太大

扯远了我们回到实现上明确漫反射的brdf和NoL部分需要结合传统的卡渲分层颜色调整+ramp采样颜色映射

我们先来实现传统的卡渲分层颜色调整

既然要分层那么要几层zmd选择分了三层那么我们需要三个颜色分别是亮部暗部暗中暗

亮部颜色就是直接采样albedo贴图得到的颜色暗部颜色在亮部颜色基础上给予一个强度衰减zmd在实现上还给了一个饱和度衰减暗中暗颜色在暗部颜色基础上再衰减到0.65即可

// albedo颜色 明暗处理
float3 baseColor = mainTex_var.xyz * _BaseColor.xyz;
baseColor = pow(baseColor,_BaseColorPow); // 亮部颜色

float3 baseColor_dark = baseColor * _AlbedoDarkStrength;
float baseColor_dark_strength = MyCalculateLightStrength(baseColor_dark);
baseColor_dark = lerp(baseColor_dark_strength.xxx, baseColor_dark, _AlbedoDarkSaturation); // 暗部颜色

// 漫反射颜色获取 float energyDistribution_metallic = 0.96 - 0.96 * metallic; float3 mainDiffuseColor_Light = baseColor * energyDistribution_metallic; float3 mainDiffuseColor_Dark = baseColor_dark * energyDistribution_metallic;

float3 mainDiffuseColor_Dark_attention = mainDiffuseColor_Dark * 0.65 ;// 暗中暗颜色

其中有一个饱和度调节就是与黑白结果后的颜色做一个lerp

然后读者回忆一下pbr的逻辑应该会记得漫反射和高光有能量守恒的准则因此我们需要根据金属度进行能量分配得到漫反射color也就是渲染方程中的brdf的部分

最后得到这样的三个颜色

三层的颜色有了在计算每两层之间的权重分布之前我们先去采样一下ramp图获取下颜色在资源分析小节里我有提到ramp图的w通道神秘作用我们的NoL光照分布项也需要经过ramp图的调整这样我们就不用学少前一些传统做法去通过sigmod smoothness去重映射范围因此为了得到光照分布项NoL我们必须先解决ramp的采样这里的NoL均是主光的NoL分布前面把顶光的NoL提前作用到光源上了因此不需要考虑顶光NoL在brdf后续的影响作用

// 采样ramp的NoL计算 float rampNoL = 0.5 - 0.5 * NoL * NoL; float NoL_rampFinal = rampNoL * backLight + NoL; // 增加背光补偿 NoL_rampFinal = max(NoL_rampFinal, -1); NoL_rampFinal = min(NoL_rampFinal, 1); NoL_rampFinal = NoL_rampFinal * 0.5 + 0.5; // 采样ramp float4 rampColor = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(NoL_rampFinal, 0.5));

zmd的ramp采样就是直接拿物理光照分布采样即可但是zmd在上面加了光照补偿一个背光补偿项

目的是为了解决二次元角色在完全逆光时,受光面几乎消失、角色显得太暗、轮廓感支离破碎的问题。

可以认为是一个风格化的trick设计在物理之上的风格插入这是一个很好的选择

背光补偿的计算代码如下

// 背光判断计算 float2 cameraForward_xz = normalize(cameraForward.xz); float backLight = saturate(-dot(cameraForward_xz, mainLightDir_xz.xz)); // y轴作用 float backLight_y = saturate(-abs(cameraForward.y) + 0.75); backLight_y = MySmoothstep(backLight_y); backLight = backLight * backLight_y;

简单解释一下这段背光的逻辑首先根据相机dir的xz和光源的xz去dot从xz平面上去判断现在相机是正对光源还是背对光源然后第二个y轴的作用是当相机俯视和仰视的时候进行一个限制比如极端视角下不需要这种补偿效果

具体的画面效果读者可以实现了在引擎里查看一下,大致效果如下

总结下来就是先判断水平面是不是逆光如果是再看看相机的俯仰角有没有太高或太低。如果角度太极端就掐掉这个背光补偿但在掐掉的过程中用moothstep让过渡看起来平滑

采样ramp的结果如下左边是颜色xyz右边是重映射的w通道NoL

回到我们的着色逻辑上我们需要计算我们用来分层的光照分布现在得到了这个分布NoL并且在物理之上加入了人为映射修正

回到渲染方程 light brdf NoL 其他项(ao shadow),四个部分我们都已经全部先计算出来了

在之前的文章里我有提到如何在卡通渲染里理解brdf的特殊性可以参考

这里我简短讲述一下以漫反射为例brdf其实就是一个颜色并且无论光照条件怎么变化材质的固有色都不会变化也就是brdf 颜色本身不会变化但是卡渲的特殊就在于需要如同画画一样在光照分布下产生不同的颜色变化效果因此brdf会随着光照分布产生变化

所以在卡渲的逻辑里渲染方程中的四个项不再是相乘NoL + 其他项会去影响brdf最后得到的一个变化分布的brdf颜色即使后面三项的完整结果

我们即将做的三层漫反射着色也是这个原理我们来得到三层变化下的漫反射brdf再次之前我们给其他项里补充一个特殊trickNoF变化

// 二次采样ramp 得到边缘明暗变化 float NoF = dot(normalWS, cameraForward); NoF = NoF * 0.5 + 0.5; float rampColor_NoF = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(NoF, 0.5)).w;

得到的变化分布是这样的

比较接近一种在画画里ao的画法在各种边缘位置进行一个涂黑过渡来增加立体感这个部分主要用于暗中暗具体为什么只用于暗中暗应该是zmd美术们设计的结果

好,现在我们来利用 NoL ao shadow NoF来制作三层之间的分布首先是明与暗采用min的方式我们可以得到边界最明显的分布效果

那么对于明与暗这种差距极大的两层我们应当选用min的方式去组合强度分布

float ao_shadow = ao * shadowScene; float min_shadowEffect = min(ao,shadowScene); min_shadowEffect = min(min_shadowEffect,rampColor.w);

你得到的分布效果应该大致如下

然后暗部与暗中暗的强度分布我们采用如下计算方式

float ao_shadow_NoFRamp = ao * shadowScene * rampColor_NoF; saturate(ao_shadow_NoFRamp + rampColor.w)

其他项我们选择乘法然后我们加到NoL的主体上分布效果大致如下

可以看到极大程度压缩了暗部的区域本质上把暗中暗的部分计算了出来因为暗中暗需要暗部里面去找到区域从计算上可以这么理解只有在NoL的暗部区域且同时其他项均是暗部才可以得到真正的暗中暗

并且大部分暗中暗都偏向于 ao NoF这种立体塑造(在日式绘画层面上?)因此暗部与暗中暗的分布zmd的实现如上

但其实这种两层之间的分布定义不是说一定要这样,只要你在合理的物理分布项上去进行有道理有逻辑的组合就可以了

最后来对三层颜色进行lerp混合得到我们传统卡渲方案下的漫反射brdf结果

// 主光漫反射 // 第一层lerp 暗部之中分出两层 float3 mainDiffuseColor_Dark_lerp = lerp(mainDiffuseColor_Dark_attention, mainDiffuseColor_Dark , saturate(ao_shadow_NoFRamp + rampColor.w)); // 第二层lerp 明暗区分 float3 mainDiffuseBrdf = lerp(mainDiffuseColor_Dark_lerp, mainDiffuseColor_Light, min_shadowEffect);

然后我们需要把ramp图的颜色映射加上去直接把rampColor乘上去即可和正常的ramp使用方式是一样的

float3 mainDiffuseBrdf_rampColor = mainDiffuseBrdf * rampColor;

然而我在研究的时候发现zmd实际上没有这么做在ramp color上额外做了一个纯色相影响的操作

需要修改一下代码

// rampColor xyz差异得到一个饱和度分配结果 float rampColor_max = max(max(rampColor.x, rampColor.y), rampColor.z); float rampColor_min = min(min(rampColor.x, rampColor.y), rampColor.z); float rampColor_xyzStrength = rampColor_max - rampColor_min;

// 作用rampColor影响 // xyz通道影响 float3 rampColor_xyzEffect = rampColor * rampColor_xyzStrength + 1 - rampColor_xyzStrength; float3 mainDiffuseBrdf_rampColor = mainDiffuseBrdf * rampColor_xyzEffect;

问了下ai在色彩学中Max(RGB) - Min(RGB)就是色度,它直接代表了这个颜色的饱和度。根据 Ramp 颜色的饱和度在纯白1,1,1和 Ramp 原色之间进行插值

高饱和度 = 保留 Ramp 染色,高饱和度 = 保留 Ramp 染色

某种意义上这个设计应证了我前面说的zmd里的ramp映射只是一个细节辅助作用不是主体这个设计就像是进一步减弱ramp的影响作用

或者这是zmd的美术在设计的时候提出的一个想法希望有一个自适应的动态ramp调色效果而不是我给什么颜色就是什么颜色我希望更加方便我给出的不同颜色会有一个不同的影响效果看起来颜色过渡更加丝滑好看

背后的原因不得而知

最后重新得到的brdf结果实际上大部分区域没有影响变化明显的地方我标注了一下可以观察之前的rampColor结果

如同前面光源计算一般我们仍然需要针对日光直射强度去设计两个brdf状态

上面我们花费了大量功夫计算的brdf是日光直射强度1下的brdf状态我们还需要一个0状态下的结果

思考一下这个情况下的特点是什么首先主光也就是太阳光的影响应该消失不见去除NoL的影响

此时回忆一下前面为什么需要把顶光的NoL先作用上去这里就可以看出来了为了和日光直射强度0状态下的一些计算进行配合

然后也不需要三层这种复杂的变化效果2层应该就可以了只需要明暗两层ramp颜色也是不需要加的因为这个是与主光NoL产生作用的设计。分布的话我们采用最普通的相乘

float3 mainDiffuseBrdf_lowLight = lerp( mainDiffuseColor_Dark, mainDiffuseColor_Light, ao_shadow_NoFRamp);

最后根据日光直射强度对两个brdf进行lerp即可

// 漫反射brdf根据日光强度lerp float3 mainDiffuseBrdf_final = lerp(mainDiffuseBrdf_lowLight, mainDiffuseBrdf_rampColor * rampColor_control, _DayStrength);

light brdf NoL 其他项我们都全部处理完了,直接的到最后的漫反射结果

// 漫反射结果计算 float3 mainDiffuseResult = mainLightColor_final * mainDiffuseBrdf_final;

日光直射强度从高到低的变化结果至此我们完成了toon base shader的漫反射部分

高光实现

高光没有像少前那样专门做了一个ramp图去调色在brdf公式上还是按照pbr流程走的但是在高光的分布计算上进行了大改

我们先来得到一个特殊的light dir

// 得到forward light dir float forwardLightDir_y = lerp(0.5, mainLightDir.y, _DayStrength); float3 forwardLightDir = normalize(float3(cameraForward.x, forwardLightDir_y, cameraForward.z));

从计算上可以感觉出来,这是一个从相机打向人物的光

我们需要把这个虚拟光方向与主光的light dir进行混合其中混合逻辑受到日光直射强度影响

// 得到forward light dir float forwardLightDir_y = lerp(0.5, mainLightDir.y, _DayStrength); float3 forwardLightDir = normalize(float3(cameraForward.x, forwardLightDir_y, cameraForward.z)); float NoV = saturate(dot(normalWS, viewDir)); float3 mainLightDir_new = mainLightDir * _DayStrength + 2*forwardLightDir; float3 halfDir_new = viewDir * (2 + _DayStrength) + mainLightDir_new; halfDir_new = normalize(halfDir_new); float NoH = dot(normalWS, halfDir_new);

在zmd的高光里这个forward light dir会占据高光计算的主体方向影响如果日光强度1的状态下mainLight dir占比会增大0的时候会完全去除

可以说高光的形状分布是一个非常风格化的处理我们通过NoH对比观察一下区别

左边是采用了forwardLightdir右边正常的light dir可以看到正常物理结果下可以感觉到场景斜上方光源的方向但是左边就明显没有那么强烈了

得到NoH后我们按照pbr经典的一套高光brdf公式去计算高光结果然后我们再对比观察一下

float roughness = 1 - ormTex_var.w; float roughness2 = max(roughness * roughness, 0.0078); float metallic = ormTex_var.r; float reflectivity = ormTex_var.g; float3 F0 = 0.04 * reflectivity.xxx + metallic * (baseColor - reflectivity.xxx * 0.04); // 高光部分计算 // D项 float a2 = roughness2 * roughness2; float specular_D = (NoH * a2 - NoH)*NoH + 1; specular_D *= specular_D; specular_D = a2 / specular_D;

// V项
float specular_V = NoV * 2 + roughness2 + 9.99999975e-05;
specular_V = 0.5/specular_V;

// DVF结合 float specular_DV = specular_D * specular_V - 6.10351562e-05; specular_DV = max(0, specular_DV); specular_DV = min(20, specular_DV); float3 specular_brdf = specular_DV * F0; // 计算高光结果 float3 specularLight = mainLightColor_final * (ao_shadow_lowLight*0.5+0.5); float3 mainLightSpeuclarResult = specularLight * specular_brdf; mainLightSpeuclarResult *= _SpecularStrength;

其中我们同样需要作用 ao shadow这些其他项zmd在之后的计算里给这些其他项做了一个统一的日光直射强度变化因为漫反射里我们在brdf计算的时候做了这个状态变化区别高光的计算部分就直接用这个其他项变化+NoH变化来充当 0 1状态上的不同

float ao_shadow_lowLight = lerp(ao_shadow_NoFRamp, min_shadowEffect, _DayStrength);

这个就是纯粹的风格化的设计你也可以依旧选择正常的NoH运算我个人感觉高光上的这个trick主要是为了日光直射强度0的时候准备的类似头顶光这个补光设计一样当0的时候总不能让顶光去产生高光吧因此选择了一个相机方向的高光

当然,这个高光可能在美术上会更符合二次元直觉,但我在画画的时候,高光也都是从光源方向去思考出发的,则部分设计我也没办法完全理解

漫反射与高光的融合

漫反射与高光结合的时候需要给漫反射结果作用一个albedo w通道的作用

// 漫反射 高光结合 float diffuseSpecularBlend = lerp(1,mainTex_var.w,_DiffuseBlendEffect); float3 mainLightResult = mainDiffuseResult * diffuseSpecularBlend + mainLightSpeuclarResult;

开一个参数来控制一下是否需要使用albedo贴图w通道

先来看一下w通道是什么

可以看到大部分都是1在一些特殊材质区域上会有强度区分这个本质上是一个sss材质上会出现的漫反射能量控制因为次表面散射下不能按照正常的漫反射能量分布去思考因此这个部分可以理解是对sss材质brdf的一个拟合效果

后面讲到这些特殊材质的时候会额外引入sss项自发光项这些项会分走漫反射的能量部分因此这里的削弱从物理上分析是正常的

IBL高光

zmd采取了一个近似数学函数拟合因此没什么可以讲的这部分我也是拜托ai实现的具体的数学公式应该是有图形学参考的但笔者并没有花更多的时间去研究

让ai帮忙分析了一下出处主要参考了NVIDIA NRD (NVIDIA Real-Time Denoisers) 库中的 DFG 拟合函数然后引入了Imageworks 的 Kulla-Conty 多级弹射补偿

我的建议是把这个方法对应的结果记到脑海里下次需要使用IBL高光的时候拿出来试一试效果作为baseline去魔改的时候再细致研究也不急

// IBL高光计算 float roughness4 = roughness2 * roughness2; float roughness6 = roughness4 * roughness2; float NoV2 = NoV * NoV; float NoV3 = NoV2 * NoV; float fit_A = 3.32707 * NoV + 0.0365463; float fit_B = -9.04755 * NoV + 9.0632; float IBLspecular_brdf1 = fit_A + fit_B * roughness2;

float fitX = 3.59685 * NoV2 - 1.36772 * NoV3 + 1.0;
float fitY = 9.22949 * NoV3 - 16.3174 * NoV2 + 9.04401;
float fitZ = -20.2123 * NoV3 + 19.7886 * NoV2 + 5.56589;
float3 nvFactors = float3(fitX, fitY, fitZ);
float IBLspecular_brdf2 = dot(nvFactors, float3(1, roughness2, roughness6));

float IBLspecular_brdf = IBLspecular_brdf1/IBLspecular_brdf2; 

// IBL高光函数计算继续 拟合Lut图查找部分 DFG拟合板块
float scale_fit_part1 = dot(float2(-1.28514, 1.0), float2(NoV, 0.990440011));
float scale_fit_part2 = dot(float2(1.0, -0.75591), float2(1.29678, NoV));
float env_scale = dot(float2(scale_fit_part1, scale_fit_part2), float2(1, roughness2));
float bias_fit_x = dot(float3(2.92338, 59.4188, 1.0), float3(NoV, NoV3, 1.0));
float bias_fit_y = dot(float3(1.0, -27.0302, 222.592), float3(20.3225, NoV, NoV3));
float bias_fit_z = dot(float3(626.130, 316.627, 1.0), float3(NoV, NoV3, 121.563004));
float bias_denominator = dot(float3(bias_fit_x, bias_fit_y, bias_fit_z), float3(1,roughness2,roughness6));
float env_bias = env_scale / bias_denominator;

float3 IBLspecular_brdf_final = IBLspecular_brdf * F0 + env_bias;
float IBLspecular_brdf_final_noF0 = IBLspecular_brdf  + env_bias;
// 把IBL的传统预积分过程拟合成了一个标准数学函数


// IBL高光补充项 多级弹射补偿 前面是单次弹射的函数拟合
float directionalAlbedo = IBLspecular_brdf_final_noF0;
// 计算多级弹射补偿因子 (Kulla-Conty Approximation)
// 目的:找回在微表面缝隙中经过多次弹射后才射出的光线
float energyLossFactor = (1.0 - directionalAlbedo) / directionalAlbedo;
// 补偿颜色主要受 F0 影响
float3 ms_compensation = F0 * energyLossFactor;
// 最终的 IBL BRDF = 基础项 + 补偿项
// 这能显著提升粗糙材质在高光下的饱和度和亮度
float3 final_ibl_brdf = IBLspecular_brdf_final * (1.0 + ms_compensation);

// IBL高光 light部分获取
float3 reflectDir = reflect(-viewDir, normalWS);
reflectDir.x = -reflectDir.x;
// reflectDir.y = -reflectDir.y;
reflectDir.z = -reflectDir.z;

// 旋转环境贴图
float angle = _EnvRotation * 0.0174532925; // 将角度转换为弧度 (PI/180)
float s, c;
sincos(angle, s, c); // 同时获得正弦和余弦

float3 rotatedDir;
rotatedDir.x = reflectDir.x * c - reflectDir.z * s; // 旋转 X
rotatedDir.y = reflectDir.y;                       // Y 保持不变
rotatedDir.z = reflectDir.x * s + reflectDir.z * c; // 旋转 Z

float envMap_level = log2(max(0.01, roughness));
envMap_level = envMap_level * 1.2 + 5.0;
float3 envMap_color = SAMPLE_TEXTURECUBE_LOD(_EnvMap, sampler_LinearRepeat, rotatedDir, envMap_level);
envMap_color *= _EnvColor;

// IBL高光结果获取
float3 indirLightSpecular = envMap_color * final_ibl_brdf * _EnvLightStrength ;

看一下笔者的代码实现,然后原封不动复刻出来就好

边缘光实现

边缘光zmd的实现分为两部分

首先是最常见的基于NoV的菲涅尔边缘光

// rimLIght float rimStart = _RimLightArea * -0.6 + 0.8; float rimEnd = _RimLightArea * -0.4 + 0.9; float rimWidth = rimEnd - rimStart; float rimt = ( (1.0 - NoV) - rimStart ) / rimWidth; rimt = saturate(rimt); float rimArea = rimt * rimt * (3.0 - 2.0 * rimt); // rim平滑处理 这里做了一个smoothstep处理 float3 rimLight = rimArea * _RimLightColor * _RimLightStrength; float3 rimLight_effectd = rimLight * min(ao,shadowScene); // rimLight brdf float3 rimLight_brdf = lerp(0.25, mainDiffuseColor_Light, _RimLightDiffuseColorEffect); float3 rimLightResult = rimLight_brdf * rimLight_effectd;

分析一下这个NoV边缘光的实现首先用smoothstep对1 - NoV做一个范围映射决定边缘光的一个范围大小

然后作用color作用ao shadow的影响得到rimLight_effectd最后看一下是否需要引入原漫反射颜色的影响

最后得到的结果如下

右边是完全引入了漫反射颜色的效果,左边是部引用

一言蔽之1-NoV过一个自定义的范围映射+原固有色的影响控制

除了基于NoV的还有基于光照的边缘光效果这个在我之前的toon shader 2博客里提到过蛋白胨大佬的少前博客里也提到过是一个非常二次元的边缘光效果只不过范围依旧是走的NoV没有通过深度差去实现

// NoLxz rim // 根据光源变化的rimLight float3 rim_mainLight = lerp(1, mainLightColor * mainLightIntensity, _DayStrength); float NoLxz = dot(normalWS, mainLightDir_xz); float NoLxz_refine = 0.5 - (0.5*NoLxz - 1) * NoLxz; NoLxz_refine *= _DayStrength; float3 rim_mainLightResult = rim_mainLight * NoLxz_refine; float NoV_mask = saturate(5.0 * (0.4-NoV)); // 0.2的width min是0 max是0.2 大概这样 NoV_mask = MySmoothstep(NoV_mask); rim_mainLightResult *= NoV_mask; rim_mainLightResult *= min(ao,shadowScene); rim_mainLightResult *= _RimLightNoLxzStrength;

来分析一下这个边缘光的试下逻辑相比于NoV的要复杂一点因为又涉及到了日光直射强度的状态变化

rimLight的color部分与日光强度有关0的时候是11的时候直接是mainLight的color

  1. NoL进行特殊处理不需要y轴的影响只需要mainLightdir的xz部分把人物当成了一个圆柱去照亮边缘光

  2. 边缘光的强度受到日光强度影响0的时候直接不要现在的NoLxz的边缘光影响

  3. 依旧是1-NoV + 范围重映射去计算边缘光的范围NoV_mask =saturate(5.0*(0.4-NoV))是我直接复现代码的结果应该是类似smoothstep(0,0.2,1-NoV)这样的数值运算

  4. 作用ao shadow自定义强度控制的影响

最后结果如下

会受到明显的向光面影响,实际上也没有那么复杂,主要是涉及到一个状态变化的设计

整合最终结果

把前面部分加起来,得到最终结果

float3 resultColor = mainLightResult + indirLightSpecular + max(rimLight_finalResult,0);

注意rimLight的部分做一个安全限制实际上我目前实现的代码里还有不少安全控制没做这种细节太多了一个人复刻确实有点管理部过来

toon base shader最终结果

还没有加后处理调色,某些地方会有些过曝,后面对比度调整后就正常了

特殊材质变体

toon base shader基本上结束了剩下需要针对一些特殊部位做变体补充

首先是衣服之外的两个小零件部位需要额外加入高光上的特殊处理

两个特殊部位需要特殊处理

加入变体的shader写法我这里不会讲解我们定义一个_SPECULARREFINE_ON 变体

然后需要用到前面说的一张颜色映射图 + 自发光贴图

下面那张黑色的就是自发光贴图,复现的时候错误的认成了自定义高光,理解意思就行

在高光计算的过程中对F0做一个修正

// F0 refine 过程 #ifdef _SPECULARREFINE_ON

float f0Refine_u = lerp(specular_D * roughness2, NoV * NoV, _RefineF0U_lerp);
float f0Refine_v = roughness * (1 - ao);

float4 F0RefineTex_var = SAMPLE_TEXTURE2D(_SpecularRefineF0Tex, sampler_AlbedoTex, float2(f0Refine_u,1 - f0Refine_v));
F0 *= F0RefineTex_var.xyz;
#endif

采样颜色映射图逻辑就是类似IBL预积分lut图的采样方式至于为什么是粗糙度和NoV不要问因为这是zmd预积分好的我只是复刻出来而已

最后得到的修正F0如下

左边是修正后的,可以看到有些许颜色变化,这种纯金属特殊物体美术可能希望在手动添加一些风格化的高光特色,总之游戏里这么做了,我这里也复刻出来

// 自发光效果 #ifdef _SPECULARREFINE_ON

float4 specularRefineColorTex_var = SAMPLE_TEXTURE2D(_SpecularRefineColorTex, sampler_AlbedoTex, i.uv0);
float3 specularRefineResult = specularRefineColorTex_var.xyz * _SpecularRefineColor * _SpecularRefineColorStrength * diffuseSpecularBlend;
mainLightResult += specularRefineResult;

#endif

然后在主光着色结果上加一个自发光效果主要体现在玩偶还有缎带上下面是specularRefineResult结果

然后自发光需要受到albedo贴图的w控制diffuseSpecularBlend在前面漫反射高光融合那里可以找到定义

在复现研究的过程中确实会感觉到zmd美术组对细节的各种巧思设计大伙们确实是想着在游戏里完全复刻精致的美术插画原案效果这部分trick我的感觉就是为了对其美术原画效果

下一个特殊材质变体是黑丝部分只需要额外加入sss项

作用到最初的albedo获取那里去改变固有色

// albedo颜色 明暗处理 float3 baseColor = mainTex_var.xyz * _BaseColor.xyz; baseColor = pow(baseColor,_BaseColorPow); // 黑丝等带有sss效果的特殊材质 float NoV_sss = saturate(dot(normalWS, viewDir)); float sss_area = pow(1.05 - NoV_sss, 1 + mainTex_var.w * _SssPowStrength); baseColor = lerp( baseColor, _SssColor, lerp(0, sss_area, _IsNeedSss));

还是同样的用1-NoV去计算边缘区域然后作为sss的分布权重算是最简单的sss的实现方式

我这里甚至没加变体因为没涉及到贴图采样计算也不是特别复杂就直接用lerp控制一下好了

左边是有sss右边是没有sss影响albedo的结果

最后是透明材质,也就是小羊袖子这一块部位

定义_TRANSPARENT_ON变体首先你需要记得把材质的blend和shader渲染顺序手动调节然后在最后着色结果上加上一个自发光部分即可

#ifdef _TRANSPARENT_ON finalAlpha = mainTex_var.w * _CustomAlpha; float3 emissionColor = SAMPLE_TEXTURE2D(_EmissionTex, sampler_LinearClamp, i.uv0 ).xyz;

// 加入自发光颜色调整流程
resultColor += _EmissionColor * _EmissionColorStrength * emissionColor * lerp(1,mainTex_var.w,0.8);

#endif

需要注意的可能是这个部位的ramp图是单独做的一个记得给对然后自发光贴图不要给错

同时需要调一调颜色,这与的话会更接近于游戏里的效果,不过我这边复刻的效果确实和游戏里的效果有些差距,就先这样吧

skin toon shader

最难啃的大山已经结束了有了toon base shader作为base接下来的shader实现都会简单许多

之后的shader里都会在base shader上的着色逻辑进行修改和添加因此我会直接说这个shader需要toon base shader什么项然后直接讲解其他全新的重点功能

在皮肤的实现上首先不需要IBL的高光至少我在游戏里没看到其余漫反射高光和边缘光逻辑保持一致

然后我们同样需要加入sss效果

sss效果 // sss效果实现 albedo处理 float NoV = dot(normalWS, viewDir); float sss_NoV = saturate(NoV); sss_NoV = sss_NoV * 0.85 + 0.15; float sss_area = saturate(_SSSArea * (1.0 - sss_NoV)); float3 sssColorEffect = lerp(float3(1.0, 1.0, 1.0), _SSSColor, sss_area); float3 albedo_sssRefine = mainTex_var.rgb * sssColorEffect;

对1-NoV的范围映射有些区别然后需要对原有颜色进行作用而不是之前黑丝材质上的lerp

左边是sss作用后的albedo右边是原albedo效果也很明显sssColor需要你手动调一下来接近游戏里的效果

基于Lut图的暗部albedo

其余着色逻辑和toon base是一致的但是有些许区别首先皮肤没有材质属性贴图需要我们默认把材质给上

// 材质属性初始化处理 float metallic = 0; float energyDistribution_metallic = 0.96 - 0.96 * metallic; float3 mainDiffuseColor_Light = albedo_sssRefine * energyDistribution_metallic; float reflectivity = 0.04 * _ReflectivityStrength; float3 F0 = float3(1,1,1) * reflectivity; float roughness2 = max(0.0078125, _Roughness * _Roughness);

把粗糙度和反射度开放成参数就可以了

然后是皮肤的一个特点为了皮肤的统一复杂效果设计了一个通用的颜色lut图来映射暗部可以看出zmd美术对皮肤的用心程度皮肤的明暗变化相比于ramp图直接用了一套更复杂的颜色映射方式lut来实现一般lut都是拿去给最终屏幕后处理调色用在这里只是为了皮肤的细节变化

总之我们toon base里的暗部颜色获取在这里需要通过贴图实现

我这边截取出来的结果是上下颠倒的因此我会对后面的采样uv做一个反向

下面是基于这张帖图来获取暗部颜色的方式暗中暗依旧是在暗部上作用0.65

// 漫反射暗部颜色计算 float3 albedo_sRGB = linear2sRGB(mainTex_var.brg); float2 lut_uv = albedo_sRGB.xz * float2(31,0.96875); float lut_uv_floorx = floor(lut_uv.x); float2 lut_uv_yz = albedo_sRGB.yz * float2(0.0302734375,0.96875) + float2(0.00048828125,0.015625); float lut_uv_x = lut_uv_floorx * 0.03125 + lut_uv_yz.x; float2 lut_uv_final = float2(lut_uv_x, 1 - lut_uv_yz.y);

float lutTex_lerp = albedo_sRGB.x * 31 - lut_uv_floorx;
float3 lutTex_var1 = SAMPLE_TEXTURE2D(_LutColorTex, sampler_LutColorTex, lut_uv_final).xyz;
float3 lutTex_var2 = SAMPLE_TEXTURE2D(_LutColorTex, sampler_LutColorTex, lut_uv_final + float2(0.03125,0.015625)).xyz;

float3 albedo_lutRefine = lerp(lutTex_var1,lutTex_var2,lutTex_lerp);
float3 mainDiffuseColor_dark = albedo_lutRefine * energyDistribution_metallic * _AlbedoDarkStrength;

// 计算暗中暗颜色
float3 mainDiffuseColor_darkindark = mainDiffuseColor_dark * 0.65;

采样逻辑我推荐你去询问ai或者查阅资料讲述lut图的采样方式对本文的主题意义不是很大代码中的一些brg错位是因为这部分我依旧是拜托ai帮忙整理的

我们需要关注采样后的颜色结果就是暗部albedo不需要像ramp那样还作用一下

因为lut图类似一个一对一的哈希表这个颜色就是会直接变成另一个颜色

亮部亮部颜色x0.5得到暗部颜色基于lut的暗部颜色

可以看到区别很明显基于lut图的暗部可能会更加符合皮肤质感但我对真实人物的渲染研究还不够多这里的原因仅仅只是推测而已

得到暗部颜色后之后继续按照toon base的逻辑去着色即可

最后得到的皮肤结果,右边是边缘光增强的结果,最新的游戏版本里人物界面边缘光拉的挺高,一般不是特殊场景不会拉的很亮

face toon shader

卡通渲染的脸部shader技术现在基本都是基于sdf的zmd也不例外重点就是如何在sdf的光照变化上加入各种细节trick

在toon base shader的逻辑上我们去除IBL高光去除所有边缘光只要漫反射高光着色逻辑然后特点是获取基于sdf的光照变化分布NoL来代替漫反射过程中的各种NoL分布

sss+lut暗部颜色

由于face也属于皮肤所以皮肤的特性都存在前面我们实现的sss效果+lut图暗部颜色获取在脸部依旧需要实现

代码我这边展示一下关键位置sss上需要根据贴图做一些特殊处理

// sss范围限制 float view_sssStrength = lerp( saturate(headFDotCamerF + 0.5), 1, sdfRefineTex_var.y) * sdfRefineTex_var.x;

。。。

float sss_area = saturate(_SSSArea * view_sssStrength * (1.0 - sss_NoV)); float3 sssColorEffect = lerp(float3(1.0, 1.0, 1.0), _SSSColor, sss_area); float3 albedo_sssRefine = albedo * sssColorEffect;

。。。

float3 albedo_lutRefine = lerp(lutTex_var1,lutTex_var2,lutTex_lerp); // 暗部颜色 float3 mainDiffuseColor_dark = albedo_lutRefine * energyDistribution_metallic * _AlbedoDarkStrength;

// 计算暗中暗颜色
float3 mainDiffuseColor_darkindark = mainDiffuseColor_dark * 0.65;

这里的sdfRefineTex_var就是除了sdf魔法图的另一张范围限制图其中x和y通道就是用来限制sss范围的由于脸部的建模比较特殊如果直接通过1-NoV得不到二次元日式的风格化效果因此需要通过贴图进行限制

贴图的限制效果对比如下

左边是贴图限制后的结果,右边是没有限制的,是一目了然的

如果你后续遇到了脸部sss效果的实现不妨也这么做应该是一个成熟的做法

trick表情图的使用方式

贴图资源里存在一张用于表情的贴图,是一个四格存储的贴图,只需要按照采样逻辑去采样即可

得到的结果与固有色albedo做一个lerp

// trick图获取 float emojiType = _TrickType * 0.5; float2 emojiUV = float2(frac(abs(emojiType)), floor(emojiType) * 0.5); float4 trickTex_var = SAMPLE_TEXTURE2D(_TrickTex, sampler_TrickTex, i.uv0*0.5 + emojiUV); float3 trickColor = trickTex_var.xyz ;

albedo = lerp(albedo, trickColor, trickTex_var.w * _TrickStrength);

在获取albedo后就可以接上表情的颜色影响

采样逻辑参考代码上的逻辑,很简单,后续我们都只采用无表情的模式,也就是第二个效果展示

最后一个表情应该还需要和五官配合一下,但我这边没有去额外加上

魔法图sdf

如果你还记得米家星铁的脸部魔法图的采样方式,那你应该记得我们需要从外部传入面部的 forward dirright dirup dir三个方向

// face dir float4 _FaceForward; float4 _FaceRight; float4 _FaceUp;

这三个方向的获取你可以问问ai或者查阅资料这里我也不会讲述比较读者再看这一节之前去回顾一下我之前米家星铁sdf贴图采样的过程

回顾一下这张sdf图采样需要注意的地方首先我们需要判断光照在左还是在右需要决定贴图采样的方向顺序

// 光照法向判断 用于采样sdf float3 mainLightDir_xz_faceDir = normalize(float3( dot(mainLightDir, _FaceRight), 6.10351562e-05, dot(mainLightDir, _FaceForward))); float sdf_uvFlag = step(0, mainLightDir_xz_faceDir.x);

通过mainLightDir和faceRight的dot来判断左右即可如果读者不能直观理解的话可以去引擎里输出看一下

根据光源所处的左右位置来决定采样的uv处理

// 采样sdf float sdf_u = sdf_uvFlag * (2 * i.uv0.x - 1) + 1 - i.uv0.x; // lerp(1-x, x, flag) float sdf_v = i.uv0.y; float4 sdfTex_var = SAMPLE_TEXTURE2D(_SdfTex, sampler_SdfTex, float2(sdf_u, sdf_v));

采样sdf魔法图后我们需要根据当前光照方向来决定当前sdf应该是什么形状

用一个smoothstep来理解smoothstep( x , y , sdf_var)我们通过调整x和y来让sdf贴图的结果在脸部上呈现出丝滑的过渡变化效果这个结果我们用代替物理的光照分布NoL

现在我们的问题就是x和y是什么sdf var又是贴图的哪个通道

先说sdf_var的部分是x通道+y通道的归一化0.5 * (sdfTex_var.x + sdfTex_var.y)

在脸上的效果大概是这样

不要问为什么这样做我的分析是觉得也可以存到一个通道上的但是zmd选择了分成两个通道去烘焙可能涉及到一些优化上的处理但我们的目的只是复刻出来

剩下x和y的部分也就是min和max的部分需要与光照方向产生联系为了得到丝滑的脸部光照方向分布NoL我们需要进行一些替换在sdf的烘焙逻辑里N就是一个固定的向前朝向也就是_FaceForward

通过dot faceForward和mainLightDir我们可以得FoL分布

观察前面的sdf图现在假设存在一个step(x, sdf_var)x从0变化到1会得到什么效果呢

0的时候是全亮0.5的时候是明暗sdf变化的一半1的时候是全黑

0的时候应该是光照完全照亮脸部的时候也就是FoL等于1的时候反之1的时候就是FoL等于0的时候

因此如果我们要用FoL来作为smoothstep的x部分需要进行反向

float faceNoL = mainLightDir_xz_faceDir.z; float halfFaceNoL = faceNoL * 0.5; faceNoL = saturate(-faceNoL * 0.5 + 0.5); float sdf_min = max(0, 2*faceNoL - 1);

现在用min代替step里的部分可以得到这样的光照分布变化

过渡太硬了因此我们需要加上一个width作为max的部分max定义为min(1, 2*faceNoL)不需要对这个数值产生疑惑你也可以修改这个max的部分只需要知道目的是让过渡边界不这么生硬

// smoothstep部分特殊计算 float sdf_min = max(0, 2faceNoL - 1); float sdf_width = min(1, 2faceNoL) - sdf_min; float sdf_smoothVar = saturate((0.5 * (sdfTex_var.x + sdfTex_var.y) - sdf_min) / sdf_width); sdf_smoothVar = sdf_smoothVar * sdf_smoothVar * (3 - 2 * sdf_smoothVar);

上面这一块就是smoothstep(min, max, sdf var)

这感觉一下子就上来了不错然后让我们来看一下zmd特殊处理

float faceNoL_compensation = -0.5 * mainLightDir_xz_faceDir.z * mainLightDir_xz_faceDir.z + 0.5; // 背光判断 float2 cameraForward_xz = normalize(cameraForward.xz); float backLight = saturate(-dot(cameraForward_xz, mainLightDir_xz.xz)) * saturate(-mainLightDir_xz_faceDir.z); float faceNoL = mainLightDir_xz_faceDir.z + backLight * faceNoL_compensation;

把我们toon base shader背光补偿逻辑加了上去当人物背对光源正对相机的时候并不会让脸部全黑

我们只输出backLight * faceNoL_compensation观察一下

不会让这个情况下人物脸部陷入全黑当作一个trick加上去即可

此时我们可以得到后续toon base shader着色逻辑里用到的NoL部分

float sdf_NoL = sdf_smoothVar * 2 - 1;

因为NoL的范围分布是-1 1所以这里做一个x2-1的映射

脖子与脸衔接处处理

说起来,这个问题还曾经在面试的过程中被问到过,之前没有研究过,在这次复现的过程中反而体会到了这一点的重要性

// 脖子衔接处的NoL计算 float NoL = dot(i.normalWS, mainLightDir); float ramp_NoL = lerp( sdf_NoL, NoL, sdfRefineTex_var.y); float ramp_u = ramp_NoL * 0.5 + 0.5; float ramp_v = 0.5; float4 rampTex_var = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(ramp_u, ramp_v));

在漫反射着色阶段里的ramp采样之前我们需要对NoL做一个lerp其中sdfRefineTex_var.y很有意思是一个区域mask图0代表是脸部区域1代表是靠近脖子区域也就是说脖子上的NoL采用模型法向和光照方向的夹角即可

为什么需要单独处理脖子,核心就是为了靠近脖子的区域和脖子皮肤材质的平滑拼接

上面是我已经处理好的结果但可以看到脸部和脖子的衔接区域仍然有一条裂缝一样的存在如果不处理的话这一块区域会非常明显因为你计算出来的光照分布和紧挨着的脖子skin toon shader的着色中的光照分布不一样自然就会出现差异

定制化边缘光

我们去除了toon base shader里的边缘光逻辑这里我们需要实现一个基于贴图的定制化边缘光效果

// sdf normal获取 float sdf_z = lerp( -(sdfTex_var.z2-1), sdfTex_var.z2-1, sdf_uvFlag); float3 sdfDir = float3(sdf_z, 6.10351562e-05, 1 - abs(sdf_z)); sdfDir = normalize(sdfDir); // 转换空间 float3 faceSpace_x = float3(_FaceRight.x, _FaceUp.x, _FaceForward.x); float3 faceSpace_y = float3(_FaceRight.y, _FaceUp.y, _FaceForward.y); float3 faceSpace_z = float3(_FaceRight.z, _FaceUp.z, _FaceForward.z); float3 sdfNormal = normalize(float3(dot(faceSpace_x, sdfDir), dot(faceSpace_y, sdfDir), dot(faceSpace_z, sdfDir))); float3 normalWS = lerp(sdfNormal, i.normalWS, sdfRefineTex_var.y);

还记得前面sss的实现上出现的问题吗直接1-NoV是行不通的sss通过贴图去限制

边缘光上的实现决定通过重新算一个normal + 基于sdfRefineTex_var的w通道限制区域

左边是重新计算的normal右边是脸部模型normal

在这个特殊处理后的normal下重新计算NoV的菲涅尔边缘光

// rimLIght float rim_NoV = dot(normalWS, viewDir); float rimStart = _RimLightArea * -0.6 + 0.8; float rimEnd = _RimLightArea * -0.4 + 0.9; float rimWidth = rimEnd - rimStart; float rimt = ( (1.0 - rim_NoV ) - rimStart ) / rimWidth; rimt = saturate(rimt); float rimArea = rimt * rimt * (3.0 - 2.0 * rimt); // rim平滑处理

逻辑依旧是对1-NoV过一个自定义控制的smoothstep重映射

看起来还可以但是脸部还需要根据sdfRefineTex_var.w定制化一下

// 使用基于 物理的rim 还是 基于mask的rim float rimArea_mask = sdfRefineTex_var.w; float rimArea_final = lerp( rimArea, rimArea_mask, _RimMaskStrength);

这个笔尖边缘光效果确实不错,游戏里也是可以观察到的,可以确认肯定是使用了的

这两个边缘光的效果直接相加的话效果不太对应该还是做一个lerp比较好这里是我的处理我也没研究清楚游戏里的是怎么实现的

然后边缘光默认是会对称出现,但是游戏里不是这样的效果,二次元日式风格里,一般都是只有一边的效果,所以我们还需要对出现时机进行一个限制

// 限制一半的区域 float rimHalfArea = saturate(dot(cameraRight, normalWS));

最后给边缘光作用上ao shadow的影响得到最终的边缘光结果

float3 rimLight_effectd = rimLight * min(ao,shadowScene);

eye toon shader

这部分难度不大首先我们只要toon base shader的漫反射部分高光走一个matcap实现如果你在看后续过程中对某些地方上下文理解出现了问题一定要参考我分享的代码仓库对照着完整shader去理解

眼睛区域划分 // 获取 眼睛中心mask区域 float2 fracUV = frac(i.uv0); float2 eyeCenterAreaUV = fracUV - float2(0.5, 0.5); float eyeCenterArea = step(0.25, dot(eyeCenterAreaUV, eyeCenterAreaUV));

基于uv直接计算出一个眼睛的中心区域后面会多次用到日式二次元画风中的特殊高光区域

只需要注意眼睛内部区域,其余部分不会这么处理

基于特殊法向的着色

toon base shader的着色逻辑没有太多变化但是法向不能直接用模型法向

// 计算特殊法向
float2 centeredUV = eyeCenterAreaUV * 2.0;
float uvSq = dot(centeredUV, centeredUV);
float zHemi = sqrt(max(0.0, 1.0 - min(1.0, uvSq))); // max和min用来防溢出报错
zHemi = max(1e-16, zHemi); // 防止出现完美的 0 导致后续归一化出 NaN

float2 corneaTS_xy = 0.125 * _CorneaBumpStrength * centeredUV;
float3 corneaNormalTS = float3(corneaTS_xy, zHemi); 

corneaNormalTS = lerp(corneaNormalTS, float3(0, 0, 1), eyeCenterArea);
// 特殊位置需要0 0 1法向不变
corneaNormalTS.x = -corneaNormalTS.x;

float3 customNormalWS = corneaNormalTS.x * tangentWS + 
                        corneaNormalTS.y * bitangentWS + 
                        corneaNormalTS.z * normalWS;
// TBN变换 切线空间到世界空间
float3 corneaNormalWS = normalize(customNormalWS);

左边是计算后的特殊法向,右边是模型法向

特殊法向的逻辑很有意思通过uv根据距离得到了一个平滑过渡的切线法向可以把uv看作一个二位函数场然后我们求取梯度得到了一个平滑变化的法向效果

由于眼球的uv特殊性刚好可以让uv的中心在眼球正中心所以可以采用这种方法这种眼睛的特殊技巧我个人觉得是可以留一个心眼的也许在其他的一些设计上也可以采用同样的方案产生意想不到的收获

把这个特殊法向作用到后续的漫反射着色过程中即可

自定义区域发光trick

在漫反射的双层lerp过程中需要做如下修改

// 自定义颜色调节 float3 specularTrickColor = lerp(1, _SpecularTrickColor * 2.5, eyeCenterArea); float3 eyeInTrickColor = lerp(1, _EyeInTrickColor * 2.5, mainTex_var.w); float3 trickAlbedo = specularTrickColor * eyeInTrickColor;

。。。

// 第一层lerp 暗部之中分出两层 暗中暗和暗部的区分 float3 mainDiffuseColor_Dark_lerp = lerp(mainDiffuseColor_Dark_final, mainDiffuseColor_dark , saturate(rampTex_NoF + rampNoL)); // 第二层lerp 明暗区分 float3 mainDiffuseBrdf = lerp(mainDiffuseColor_Dark_lerp, mainDiffuseColor_Light * trickAlbedo, rampNoL);

在亮部albedo上需要作用一个颜色修正其中eyeCenterArea前面已经看到了是指眼睛的日式高光区域然后mainTex_var.w比较特殊也是一个日式二次元画风中比较重要的区域

这两个区域都需要单独给上一个颜色作用,颜色确定可以对照游戏里的效果去设定

基于matcap的高光 // 日光变化的 暗部影响因素 float DayDarkEffect = lerp(rampTex_NoF, rampNoL, _DayStrength);

// matcap采样 view空间下的normal
float3 corneaNormalVS = mul((float3x3)UNITY_MATRIX_V, corneaNormalWS);
corneaNormalVS = normalize(corneaNormalVS);
float2 matcapUV = corneaNormalVS.xy * 0.5 + 0.5;
float4 specularMatcapVar = SAMPLE_TEXTURE2D(_SpecularMatcap, sampler_SpecularMatcap, matcapUV);
float3 specularColor = specularMatcapVar.xyz; // 高光 RGB 颜色
float specularW = specularMatcapVar.w;        // 高光 Alpha (通常用于控制高光的范围或与底色Alpha叠加)

// specular brdf 获取
float3 specularBrdf = specularColor * _SpecularStrength + _SpecularColor * specularW;
float specularDarkEffect = DayDarkEffect * 0.5 + 0.5;
specularDarkEffect *= lerp(_SelfAoShadowStrength, 1, DayDarkEffect);
// 高光结果
mainSpecularResult = mainLightColor_final * specularBrdf * specularDarkEffect;

就是正常的matcap采样逻辑需要得到view空间下的normal然后用xy去采样

其中matcap的w通道给你划分了一个高光区域的mask你可以在matcap的基础上加上一个自定义的高光颜色

matcap w通道

最后作用shadow ao同时注意日光直射强度的状态影响得到最后的高光结果

非常好看的一个高光效果

其余眉毛等部分

不需要加入高光不需要计算新的normal直接走toon base shader的漫反射逻辑即可

hair toon shader

在toon base shader的基础上去除IBL高光+主光源高光部分

头发的着色逻辑也算是比较特殊的一个,需要做不少处理

获取平滑法向

你可以在游戏里观察到人物的高光都是一个整体的排列,非常的日式二次元,为了做到这种整齐的效果,我们必须得到平滑法向

但是这个法向我们只用于高光,漫反射效果我们仍然需要保留模型法向的物理效果去着色

可以在模型里存储平滑法向,也是通用的做法,但是头发的平滑法向是通过贴图完成的

在你获取的头发的法向贴图的zw通道上你可以发现和前面的法向贴图不用这两个通道是有值的

把这两个通道按照法相贴图的逻辑作用到模型法向上,你就可以得到一个平滑法向结果

// HNormal处理 float3 normalTex_Hprocessed = UnpackNormalFromTex(normalTex_var.zwzw, _BumpScale); float3 HNormalWS = lerp( i.normalWS ,normalize(mul(normalTex_Hprocessed, tangentTransform)), _IsNeedNormalMap);

左边是法向贴图zw通道平滑后的结果右边是模型法向

但我在使用的过程中,这个平滑的效果还不够,可能是我对贴图的使用方式有问题,我这里选择自己找一个取巧的替代方案

// 计算平滑效果 基于center float3 sphereNormal = normalize(i.posWS.xyz - _FaceCenter.xyz);

HNormalWS = lerp(sphereNormal, HNormalWS, ormTex_var.x);

确定一个中心点,然后直接做一个球体法向效果,缺点就是实现过于简单暴力,效果与原游戏仍有不少差距

应该是需要定制一套平滑法向才行,这样才可以保证高光的稳定形状,但是我这边确实没有办法完美复现基于贴图的平滑法向,就先这样替代一下

伪平滑法向

读者只需要知道这里需要给头发计算出一套平滑法向用于后续的高光即可,只要怎么得到这个平滑方式很多

笔者这里选择的是最暴力的方式,没必要学我这么做

二次元高光实现

虽然说是二次元高光,但实际上依旧参考了头发的各向异性高光实现, 基于最通用的Kajiya-Kay Shading

把模型看成一根根圆柱组成,高光是在这些圆柱上生成的,这些圆柱的特殊法向可以通过发丝方向切线去求取,经过公式整合后,可以直接把高光结果与切线联系起来,这个技术可以参考的文章很多,下面我罗列一篇

也因此高光的形状会依赖于一个定制化的uv因为uv与切线息息相关但这种事情美术已经帮我们处理好了

最后我们计算高光的公式就是sqrt( 1- pow(dot(T,H) ) )然后对这个结果x做一个pow的操作来限制高光的大小形状pow(x , _specularPowStrength)

H的获取是非常简单的就是viewdir + mainLightdir

需要思考发丝方向切线怎么获取,这个方向你需要有一个可视化的印象,沿着头发丝向上走的这么一个方向

因为我们不用模型方向因此unity求取的切线是不能用的我们需要手动重新求取

float dotRight = dot(HNormalWS, cameraRight); float3 cylinderNormal = normalize(HNormalWS - dotRight * cameraRight); float3 flatHNormal = normalize(lerp(HNormalWS, cylinderNormal, _SpecularTrick_Flatten)); float3 fakeTangent = normalize(cross(float3(0,1,0), flatHNormal)); float3 hairBiNormal = normalize(cross(flatHNormal, fakeTangent));

无视掉前三行先看后两行fakeTangent =normalize(cross(float3(0,1,0), flatHNormal))计算了一个假切线然后我们基于这个切线与平滑normal叉乘得到了发丝方向可以简单可视化几何画一画图就知道了

然后再看前三行我为了实现完全平行整齐排列的高光效果做了一个trick设计把法向上与cameraRight重合的部分去除这样法向在任何地方都是只朝着正前方的是存在forward和up上的变化不会存在左右上的变化

效果大概如下

之所以加入这么个trick主要是我调整光源方向一直没能做到游戏里的整齐高光效果于是出了这么一个下策

然后在前面给view dir加入一个偏移

float3 hairViewDir = normalize(viewDir + float3(0, _ViewDirYOffset*(1 - ormTex_var.x), 0));

这里对view dir的y进行偏移可以让最后的高光产生上下位置的移动用来后续调整

最后用这个发丝方向带入Kajiya-Kay Shading公式我们可以得到如下结果

float ToH_lut = dot(hairHalfDir, hairBiNormal_flatten); float lutUV_u = 1 - ToH_lut * ToH_lut;

高光位置拿到了,剩下就是怎么进行着色,这里需要用到美术准备好的颜色贴图

我们需要基于算出来的高光位置分布去采样这张颜色贴图

从我的变量名可以看出我们已经拿到的u上的结果还差v由于这依旧是预计算处理好的图因此你不需要由于v的计算由来只需要直接使用我复刻的结果即可

// v计算 float2 viewDirProj = float2(dot(viewDir, cameraRight), dot(viewDir, cameraForward)); float2 HNormalProj = float2(dot(HNormalWS, cameraRight), dot(HNormalWS, cameraForward)); float VoHN_horizontal = saturate(dot(viewDirProj, HNormalProj)); VoHN_horizontal = pow(VoHN_horizontal, _LutVPowStrength); float directionMask = step(0.0, ToH_lut); float lutUV_v = VoHN_horizontal * VoHN_horizontal * directionMask;

最后采样的结果如下

用这个颜色修正F0然后执行一个简化版的高光渲染不走pbr的逻辑

float3 lutF0 = SpecularRefineF0Tex_var.xyz * F0; float3 backF0 = _SpecularBackF0 * ormTex_var.w * pow(sqrt(1 - ToH_lut * ToH_lut), trunc(200 * _SpecularBackF0_ToHPowStrength)); float3 finalF0 = lutF0 * 7 + backF0; // 日光变化下的 暗部因素 float ao_shadow_lowLight = lerp(ao_shadow_NoFRamp, min_shadowEffect, _DayStrength); float selfAoShadowEffect = lerp(_SelfAoShadowStrength, 1, ao_shadow_lowLight);

// 高光结果
float3 mainLightSpecularResult = mainLightColor_final * selfAoShadowEffect * finalF0;

同样引入了日光直射强度的变化,读者记得复刻前面一些内容的时候,需要时刻注意是否考虑两个状态的实现

还加入了一个trick涉及在我们算好的高光结果上又计算了一次Kajiya-Kay Shading算了一个高光光晕的效果也就是backF0

最后组合到一起,得到最终的高光结果

之后的漫反射着色和边缘光参考toon base shader逻辑实现出来即可

outline shader

如果读者你一边学习复刻看到了这里,作为笔者的我还是很感激的

主体渲染部分我们已经全部结束了剩下的部分就是去整理各种细枝末节的地方但对于美术而言细节这个东西是没有上限的可以无限打磨而且区别90和100分的差距也都在上面

所以在稍微坚持一会

描边的着色逻辑我这边直接用的toon base shader漫反射很暴力

你不需要这么做根据光源color 光源dir固有色之类的自己设计一个着色方案即可

我们只讲外扩的实现

法线外扩 // 平滑法向过程 float weightN = sqrt(saturate(1.0 - dot(v.uv1.xy, v.uv1.xy))); float3 smoothNormalOS = v.uv1.x * v.tangent.xyz + v.uv1.y * biTangentOS + weightN * v.normal;

            // 将平滑法线转换到观察空间 (View Space)
            float3 smoothNormalVS = mul((float3x3)UNITY_MATRIX_IT_MV, smoothNormalOS);

            // 将 View 空间的法线投影到 Clip 空间 (只取 XY这就是屏幕上的 2D 延伸方向)
            float2 clipNormal2D = mul((float2x3)UNITY_MATRIX_P, smoothNormalVS);
            // 归一化,保证基础方向长度为 1
            float2 normal2D = normalize(clipNormal2D);
            normal2D.x /= ScaleX;

            // 深度修正
            float depthScale = o.positionHCS.w;
            float outlineDepthClamp = min(depthScale, 20.0);

            // 距离自定义修正
            float depthRefine = lerp( 1, _ZMinRefine, smoothstep( 1, 12, depthScale));
            
            // 计算最终的 2D 偏移向量
            float2 offset = normal2D * (_OutlineWidth * outlineDepthClamp * 0.01 * depthRefine);

            o.positionHCS.xy += offset;

注意基于平滑uv的法向计算以及根据相机距离去修正描边的宽度最后需要根据屏幕空间去修正外扩大小

左边是平滑后的结果

fur shader

如何执行多层pass我这里不讲述了然后fur shader拥有toon base shader的所有着色逻辑漫反射高光IBL边缘光加上去

我们来重点讲解一下形状贴图的使用

// Voronoi 细胞噪声实时计算 float2 scaledUV = i.uv * _ClumpScale; float2 cellID = floor(scaledUV * 2.0);
float4 hashInput = float4(123.0, 123.0, 123.1, 123.1) + cellID.xyxy;

// 随机角度
float rand1 = frac(sin(dot(hashInput.xy, float2(127.1, 311.7))) * 43758.5469);
float angle = rand1 * 6.28318548; // * 2π

// 随机半径
float rand2 = frac(sin(dot(hashInput.zw, float2(127.1, 311.7))) * 43758.5469);
float radius = sqrt(rand2);

// 特征点偏移与距离
float2 randomOffset = float2(cos(angle), sin(angle)) * radius * 0.25;
float2 cellCenter = cellID * 0.5 + float2(0.25, 0.25);
float2 featurePoint = cellCenter + randomOffset;

float2 diff = scaledUV - featurePoint; // 对应 r14.zwfloat dist = length(diff); // 对应 r1.w (Voronoi 距离)

float4 flowMap_var = SAMPLE_TEXTURE2D(_NoiseTex, sampler_LinearRepeat, i.uv * _NoiseTex_ST.xy);
float3 flowMap = flowMap_var.xyz;
float2 flowDir = flowMap.xy * 2.0 - 1.0;
flowDir *= _FlowStrength;

// 基于层级高度的随机毛躁 Hash (对应 Bug 修复的 w1.xx)float jitter = frac(sin(dot(float2(i.h, i.h), float2(12.9898, 78.233))) * 43758.5469);
jitter = jitter * 2.0 - 1.0;
jitter = jitter * _JitterStrength * 0.05;

// 合并基础偏移
float2 totalDistortion = flowDir * 0.005 + float2(jitter, jitter);

// 强行拉向细胞中心 (成簇偏移)
totalDistortion = i.h * diff + totalDistortion;

// 计算最终用于采样的弯曲 UV (虽然形状图是1不需要采样但这里还原完整逻辑)
float2 shellUV = -totalDistortion * i.h + i.uv;

float thicknessThreshold = lerp(baseThickness, sqrt(baseThickness), _TaperCurve);

// 视角边缘补偿 (掠射角增厚防穿帮) float3 viewDirWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
float NoV = saturate(dot(i.normalWS, viewDirWS));

float heightCubedInv = 1.0 - (i.h * i.h * i.h);
float viewComp = -_ViewCompThreshold + NoV;
float grazingMask = viewComp + heightCubedInv;

// 细胞距离成簇衰减
float clumpDist = min(1.0, dist * 2.8284);
float heightFactor = saturate(i.h * 2.0); // 只有上半段毛发强烈收缩
float clumpAttenuation = heightFactor * (clumpDist - 1.0) + 1.0;

// 合并形状
float rawAlpha = clumpAttenuation * shapeTexMask;

// 构建 Smoothstep 过渡区间 [-0.25, 0.25]float2 bounds = float2(-0.25, 0.25) + thicknessThreshold;
float lowerBound = max(0.0, bounds.x);
float upperBound = min(1.0, bounds.y);

// 执行平滑阶跃,生成极其丝滑的毛发边缘
float smoothAlpha = smoothstep(lowerBound, upperBound, rawAlpha);

// 保证紧贴皮肤的最底层绝对不透明
float isRoot = (i.h <= 0.01) ? 1.0 : 0.0;
float finalAlpha = lerp(smoothAlpha, 1.0, isRoot);

// 乘以边缘补偿遮罩
finalAlpha = saturate(grazingMask * finalAlpha);

clip(finalAlpha - 0.003);

比较复杂你要学习理解的话喂给ai比较好我这里简单梳理一下

通过Voronoi噪声去形成簇状结构出现毛发一簇一簇的集中效果 基于形状贴图的xy方向产生毛发偏移效果实验中没什么直接效果 基于形状贴图的z通道决定毛发细节形状效果 最后过一个smoothstep来控制分布平滑

但这只是我复刻的效果,和游戏里最终效果差距较大,我也不建议你学习我的做法,试试自己研究研究能不能做出更好的效果,笔者捣鼓了一段时间没能完全复刻出来

至于火焰特效,这个不在笔者的重点研究对象上,我就随便实现了一下,呼吸变化效果也没有加

直接采样特效噪声图,做一个缩放,给一个颜色,因此和原游戏效果差距很大

哦对,你如果不进行剔除和透明效果,单层的话大概是这样的效果,用来给读者你复刻学习的时候对照使用

当作正常的toon base shader先去实现即可

其余各种trick shader

应该是小羊全身都剖析的差不多了剩下的部分就是一些小trick的shader我挑了我觉得必须要实现的

刘海阴影

最喜欢的一集,实现起来最轻松的一次

在你截帧获取资源的时候,应该可以拿到这么一个特殊的刘海模型,还帮你错位好了,直接做一个模板渲染即可

着色方式按照喜欢的来根据日光直射强度产生变化根据光照方向产生偏移根据光照color产生色相影响

等等都可以

我这边只加了一个日光直射强度变化在0的时候把刘海阴影去除没有主光直射的话应该是不会产生这么明显的阴影效果

眼部阴影

眼部阴影效果跟大部分游戏一样,会给好一个面片然后做透明渲染

实现的时候用到了一张权重贴图

读取贴图,做一个强度渐变效果,甚至不用去做数学函数拟合,很方便

后处理

三个通用后处理lut图调色 + bloom + 抗锯齿

后面两个按照读者自己的实现来就可以

lut图调色需要你去游戏里获取这么一张lut图资源

同时不要用urp自带的lut图volume直接用现成的feature就行在后处理之前执行调色

左边是调色前右边是调色后差异还是挺大的可以根据需求去调整lut图影响强度

尾声

这篇的长度好像和上一篇flux water差不多了还是感谢能看到这里的各位读者

希望这篇文章可以给你起到帮助也希望你不要因为过长而感到厌烦我确实比较喜欢写这种长文章全面系统一点更适合他人学习觉得太长了就交给ai工具吧

距离上次间隔了半年多中途虽然比较忙但也看了不了玩了不少终末地还是台服和我胃口了美术玩法我都很喜欢剧情暂且先不谈主打一个相信就是我连续5次大保是不是不太对啊

总之按照惯例,最后我想安利的作品是传颂之物即将推出的最新作 传颂之物 循白之证

作为传颂老粉看到这一作标榜着系列ip的终结心里的想法难以言表传颂的手游也走到了尽头可惜藤原叔走的太早了种田嗓子也遭遇了不幸好在手游里给小白等人物都算是给了一个幸福的结局

最后也想趁这个机会宣传一下传颂ip本质可以看成以文字剧情形式呈现十分出彩的jrpg并且是古早的那一套日式jrpg味道。但传颂的核心魅力应该在于所处的科技背景并非现代人类科技都市经过了一次世界危机倒退回了农耕时代但又遗留着过去的核心科技这种科幻背景。在里面和美少女冒险的同时会给一种莫名的放松休闲感你会感觉这个社会恰好处于一个压迫又没那么压迫的氛围二次元滤镜。总之求求你了玩一玩传颂吧hhhh

有一说一,上哪里找又喜欢传颂,还玩方舟的人。

想说的就这些,下一篇文章应该是关于体积云和大气天空相关的,大海蓝天,实在是太美了,怎么都得全研究一下

那么祝各位读者学业事业双双顺利,咋们下次再见咯