1577 lines
81 KiB
Markdown
1577 lines
81 KiB
Markdown
---
|
||
title: 【unity urp】从零模仿复刻实现自己的终末地人物卡通渲染 - 知乎
|
||
source: https://zhuanlan.zhihu.com/p/2028819446546932894
|
||
created: 2026-05-09 15:44
|
||
tags: [zhihu, article, unity, urp, rendering, cel-shading, anime-shader, arknights]
|
||
---
|
||
|
||
# 【unity urp】从零模仿复刻实现自己的终末地人物卡通渲染 - 知乎
|
||
|
||
**来源:** [https://zhuanlan.zhihu.com/p/2028819446546932894](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里,你需要保证有albedo,normal,以及一张材质属性贴图,以小羊的衣服部位举例
|
||
|
||
材质属性贴图范例
|
||
|
||
其中r通道是metallic,g是reflectivity,b是ao,w是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 dir,normal,viewdir之类的
|
||
|
||
比较特殊的是需要额外获取一个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 NoL,NoL这部分应该在着色渲染方程中出现,这里提前作用上去是因为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,再次之前我们给其他项里补充一个特殊trick,NoF变化
|
||
|
||
// 二次采样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的时候是1,1的时候直接是mainLight的color
|
||
|
||
2. NoL进行特殊处理,不需要y轴的影响,只需要mainLightdir的xz部分,把人物当成了一个圆柱去照亮边缘光
|
||
|
||
3. 边缘光的强度受到日光强度影响,0的时候直接不要现在的NoLxz的边缘光影响
|
||
|
||
4. 依旧是1-NoV + 范围重映射去计算边缘光的范围,NoV_mask =saturate(5.0*(0.4-NoV))是我直接复现代码的结果,应该是类似smoothstep(0,0.2,1-NoV)这样的数值运算
|
||
|
||
5. 作用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 dir,right dir,up 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, 2*faceNoL - 1);
|
||
float sdf_width = min(1, 2*faceNoL) - 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.z*2-1), sdfTex_var.z*2-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
|
||
|
||
有一说一,上哪里找又喜欢传颂,还玩方舟的人。
|
||
|
||
想说的就这些,下一篇文章应该是关于体积云和大气天空相关的,大海蓝天,实在是太美了,怎么都得全研究一下
|
||
|
||
那么祝各位读者学业事业双双顺利,咋们下次再见咯
|