205 lines
		
	
	
		
			8.9 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
		
		
			
		
	
	
			205 lines
		
	
	
		
			8.9 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
|  | --- | |||
|  | title: 卡通面部阴影控制 | |||
|  | date: 2023-03-08 10:18:36 | |||
|  | excerpt:  | |||
|  | tags:  | |||
|  | rating: ⭐ | |||
|  | --- | |||
|  | # 相关资料
 | |||
|  | - [虚幻5渲染编程(风格化渲染篇) 第七卷: Toon shadow control](https://zhuanlan.zhihu.com/p/519728086) | |||
|  | - [二次元角色卡通渲染—面部篇](https://zhuanlan.zhihu.com/p/411188212) | |||
|  | - [掰法线的方法 SP Normal Convert Shader](https://note.com/sfna32121/n/n8d46090005d1) | |||
|  | - 绘制SDF贴图方法 | |||
|  | 	- [卡通脸部阴影贴图生成 渲染原理](https://zhuanlan.zhihu.com/p/389668800) | |||
|  | 	- [【教程】使用csp等高线填充工具制作三渲二面部阴影贴图](https://www.bilibili.com/video/BV16y4y1x7J1/) | |||
|  | - SDF Shadow Shader | |||
|  | 	- [神作面部阴影渲染还原](https://zhuanlan.zhihu.com/p/279334552) | |||
|  | 
 | |||
|  | 
 | |||
|  | # SDF面部阴影
 | |||
|  | ## Signed Distance Fields
 | |||
|  | - 生成距离场算法: http://www.codersnotes.com/notes/signed-distance-fields/ | |||
|  | 
 | |||
|  | >这实质上就是找目标点及左上方四个点中,SDF最小的值。PASS0就是按照从上到下,从左到右的顺序,遍历整个图像,遍历完成之后,对于所有物体外的点,如果距离它最近的物体是在它的左上方,那么它的SDF值就已确定。类似的,PASS1就是按照从下到上,从右到左的顺序,依次比较右下方的四个点,遍历完成之后,对于所有物体外的点,如果距离它最近的物体是在它的右下方,那么它的SDF也已经确定了。两个PASS结合,那么整个图像的SDF就都计算出来了。(其实这里称SDF并不准确,因为只算了物体外到物体边界的距离,是正值,并没有signed一说,只有做完下一步计算物体内到物体外的距离,两个距离相减,才是SDF) | |||
|  | 
 | |||
|  | >第二个grid的GenerateSDF就很好理解了,就是**计算物体内部到外部的距离**。因为一个点要么在物体内要么在物体外,所以两次的SDF值要么全为零(在边界上),要么一个为0,一个为距离值。用:grid1(pixel).sdf - grid2(pixel).sdf就能得到完整的SDF。 | |||
|  | 
 | |||
|  | 1. 读取传入的贴图,并且构建2个RT,一个黑(inside Zero)一个白(Outside  infinitely),当亮度大于0.5(128)时则反转。 | |||
|  | 2. 分别对这2个RT执行 GenerateSDF()。 | |||
|  | 3. 将2个RT记录的距离值相减,获得最终的SDF。 | |||
|  | 
 | |||
|  | 链接中的代码: | |||
|  | ```c++ | |||
|  | struct Point | |||
|  | { | |||
|  |     int dx, dy; | |||
|  |     int DistSq() const { return dx*dx + dy*dy; } | |||
|  | }; | |||
|  | 
 | |||
|  | struct Grid | |||
|  | { | |||
|  |     Point grid[HEIGHT][WIDTH]; | |||
|  | }; | |||
|  | ``` | |||
|  | 
 | |||
|  | ```c++ | |||
|  | void Compare( Grid &g, Point &p, int x, int y, int offsetx, int offsety ) | |||
|  | { | |||
|  |     Point other = Get( g, x+offsetx, y+offsety ); | |||
|  |     other.dx += offsetx; | |||
|  |     other.dy += offsety; | |||
|  | 
 | |||
|  |     if (other.DistSq() < p.DistSq()) | |||
|  |         p = other; | |||
|  | } | |||
|  | 
 | |||
|  | //采用分离式计算SDF | |||
|  | //GenerateSDF:计算并且取得当前UV坐标附近(第一次找到)像素最小值(模型使用 0值填充,即dx,dy=0),放到RT中。 | |||
|  | void GenerateSDF( Grid &g ) | |||
|  | { | |||
|  |     // Pass 0 | |||
|  |     for (int y=0;y<HEIGHT;y++) | |||
|  |     { | |||
|  |         for (int x=0;x<WIDTH;x++) | |||
|  |         { | |||
|  |             Point p = Get( g, x, y ); | |||
|  |             Compare( g, p, x, y, -1,  0 ); | |||
|  |             Compare( g, p, x, y,  0, -1 ); | |||
|  |             Compare( g, p, x, y, -1, -1 ); | |||
|  |             Compare( g, p, x, y,  1, -1 ); | |||
|  |             Put( g, x, y, p ); | |||
|  |         } | |||
|  | 
 | |||
|  |         for (int x=WIDTH-1;x>=0;x--) | |||
|  |         { | |||
|  |             Point p = Get( g, x, y ); | |||
|  |             Compare( g, p, x, y, 1, 0 ); | |||
|  |             Put( g, x, y, p ); | |||
|  |         } | |||
|  |     } | |||
|  | 
 | |||
|  |     // Pass 1 | |||
|  |     for (int y=HEIGHT-1;y>=0;y--) | |||
|  |     { | |||
|  |         for (int x=WIDTH-1;x>=0;x--) | |||
|  |         { | |||
|  |             Point p = Get( g, x, y ); | |||
|  |             Compare( g, p, x, y,  1,  0 ); | |||
|  |             Compare( g, p, x, y,  0,  1 ); | |||
|  |             Compare( g, p, x, y, -1,  1 ); | |||
|  |             Compare( g, p, x, y,  1,  1 ); | |||
|  |             Put( g, x, y, p ); | |||
|  |         } | |||
|  | 
 | |||
|  |         for (int x=0;x<WIDTH;x++) | |||
|  |         { | |||
|  |             Point p = Get( g, x, y ); | |||
|  |             Compare( g, p, x, y, -1, 0 ); | |||
|  |             Put( g, x, y, p ); | |||
|  |         } | |||
|  |     } | |||
|  | } | |||
|  | ``` | |||
|  | 
 | |||
|  | ```c++ | |||
|  | int dist1 = (int)( sqrt( (double)Get( grid1, x, y ).DistSq() ) ); | |||
|  | int dist2 = (int)( sqrt( (double)Get( grid2, x, y ).DistSq() ) ); | |||
|  | int dist = dist1 - dist2; | |||
|  | ``` | |||
|  | 
 | |||
|  | ## SDF面部阴影
 | |||
|  | 本质是使用SDF的过度原理(对X张贴图生成SDF贴图),使用HalfNoL来查询一个(0,180)区间的阴影。 | |||
|  | 
 | |||
|  | ### 效果
 | |||
|  | - SDF面部阴影效果32秒处:https://www.bilibili.com/video/BV1JG4y1r7EP/?spm_id_from=333.337.search-card.all.click&vd_source=d47c0bb42f9c72fd7d74562185cee290 | |||
|  | 
 | |||
|  | ![[SDFShadowEffect.mp4]] | |||
|  | 
 | |||
|  | ### 绘制方法
 | |||
|  | - 等高线绘制方法:https://www.bilibili.com/video/BV16y4y1x7J1/?spm_id_from=autoNext&vd_source=d47c0bb42f9c72fd7d74562185cee290 | |||
|  | 	- 注意:等高线曲线需要关闭绘制抗锯齿。 | |||
|  | 	- 使用色阶就可以把各个角度的SDF贴图提取出来。 | |||
|  | - Maya烘焙绘制方法(应该也可以用UE) | |||
|  | 	1. 给脸部模型一个Lambert材质,并且根据X轴角度要求设置好方向光。 | |||
|  | 	2. 使用Arnold的RenderToTexture,将贴图输出。 | |||
|  | 	3. 使用PS的色阶功能将颜色二值化,并在基础上进行修改。  | |||
|  | 	4. **对8张贴图两两计算SDF值,之后进行重映射** 。最终亮度=亮度/8 * index | |||
|  | 	5. 将结果合并到一起。 | |||
|  | 
 | |||
|  | ### 代码
 | |||
|  | ```c++ | |||
|  |     float3 UP = float3(0,1,0); // cs脚本里动态修改 | |||
|  |     float3 Front = float3(0,0,1); | |||
|  |     float3 Left = cross(UP, Front); | |||
|  |     float3 Right = -cross(UP, Front); | |||
|  |     float FrontL = dot(normalize(Front.xz), normalize(L.xz)); | |||
|  |     float LeftL = dot(normalize(Left.xz), normalize(L.xz)); | |||
|  |     float RightL = dot(normalize(Right.xz), normalize(L.xz)); | |||
|  | 
 | |||
|  |     float lightAttenuation = (FrontL > 0) * min( | |||
|  |         (surfaceData._lightMap.r > LeftL), | |||
|  |         1-(1 - surfaceData._lightMap.r < RightL) | |||
|  |     ); | |||
|  | ``` | |||
|  | 
 | |||
|  | ## Multi SDFFaceShadow
 | |||
|  | - X轴(正面):1°~90°(8张)  =>  | |||
|  | 	- X轴(背面-1°~-90°):考虑**单个阴影贴图**或者**边缘光计算方式的阴影**。 | |||
|  | - Y轴:30°、 90°、150°+ 背面阴影 | |||
|  | - Y轴(多张贴图):30、50°、70°、90°、110°、130°+ 背面阴影 | |||
|  | 	-  -60° 、-40° 、-20° 、0、20°、40° | |||
|  | 	- 考虑硬编码写死,所以可以使用不同步长的角度。 | |||
|  | - 贴图精度可以考虑8bit=>16bit | |||
|  | 
 | |||
|  | >Y轴结果插值可以考虑使用扩散Shader | |||
|  | https://www.shadertoy.com/view/tlyfRw | |||
|  | ShaderBits的 UV出血填充贴图方法。 | |||
|  | 
 | |||
|  | 
 | |||
|  | # 距离场+法线混合计算
 | |||
|  | https://www.youtube.com/watch?v=T2iI9hbNqLI | |||
|  | 
 | |||
|  | ![[SDF_Normal_Shadow.webp]]![[SDF_Normal_Shadow_Effect.mp4]] | |||
|  | 虽然分享中并没有说明具体怎么计算,不过有了这个思路,做出类似的效果并不是难事,以下简单还原的过程。 | |||
|  | 首先主要代码如下:(图中还有两块区域,即额头偏亮的区域和鼻部底层暗下去的区域,由于并不清楚这两部分的应用场景,下面的计算就忽略这两部分区域,另外也忽略了精度问题) | |||
|  | ```c++ | |||
|  | float3 shaodwRamp = tex2D(_ShaodwRamp, input.uv); | |||
|  | float3 lightDirH = normalize(float3(L.x, 0, L.z)); | |||
|  | float NoL = dot(N, lightDirH); | |||
|  | float triArea = saturate((shaodwRamp.g - 0.5) * 2); | |||
|  | float noseArea = saturate(shaodwRamp.g * 2); | |||
|  | 
 | |||
|  | //lightAtten | |||
|  | float lightAtten = dot(lightDirH, forward); | |||
|  | lightAtten = saturate(pow(abs(fmod(lightAtten + _TriAreaOffset, 1)  | |||
|  |              - 0.5) * _TriAreaLimitUp, _TriAreaPow) * _TriAreaLimitDown - _TriAreaLimitDown + 1); | |||
|  | 
 | |||
|  | //uvMask | |||
|  | float filpU = saturate(sign(dot(L, left))); | |||
|  | float cutU = step(0.5, input.uv.x); | |||
|  | float uvMask = lerp(1 - cutU, cutU, filpU); | |||
|  | 
 | |||
|  | //NoL | |||
|  | float faceShadow = step(0, NoL); | |||
|  | //三角区域 | |||
|  | float triAreaLight = step(lightAtten, triArea) * uvMask; | |||
|  | //鼻部区域 | |||
|  | float noseAreaLight = step(lightAtten, 1 - noseArea) * (1 - uvMask); | |||
|  | float noseAreaShadow = (step(lightAtten, noseArea) - 1) * uvMask + 1; | |||
|  | 
 | |||
|  | //最后的阴影混合 | |||
|  | float Shadow = min(max(max(faceShadow, triAreaLight), noseAreaLight), noseAreaShadow); | |||
|  | ``` | |||
|  | 
 | |||
|  | `SDF取值的思路和上一小节SDF的思路是类似的,也是利用lightAtten的结果来取值,只不过这张图分别记录了两个区域的值,中间值是0.5,三角区域的阈值在(0.5, 1],鼻尖区域的阈值在[0, 0.5)。将这两部分区域重新映射回[0, 1]的区间上就可以使用之前的step操作了,然后利用计算的uvMask屏蔽掉不需要的区域,最后混合三个部分(三角亮区,鼻部亮区,鼻部暗区)的结果就可以了。` | |||
|  | 
 | |||
|  | 另外由于SDF与NdotL的结果要互相配合,所以要对lightAtten的结果重新映射以便控制,重新映射的结果如下图的紫色曲线(链接内有每个参数的值)。 | |||
|  |  | |||
|  | 
 | |||
|  | # Matcap
 | |||
|  | 又到了万能的Matcap出场了,《七つの大罪 光と闇の交戦 : グラクロ》的技术分享中讲述了这种方式,作者在分享中讲到使用这种方式的原因:「根据(动画)演出中非现实的阴影设定,比起物理事实更重视感情的传达」 | |||
|  | 
 | |||
|  | 在大部分情况下卡通画面不需要保证光照的正确性,只要画面中的效果是感观舒适,就是可行的。所以在非动态光照的下,使用Matcap表现角色的光影结构切分是非常简单并且效果还不错的方式。 | |||
|  | 
 | |||
|  |  |