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表现角色的光影结构切分是非常简单并且效果还不错的方式。
|
|||
|
|
|||
|

|