8.9 KiB
title, date, excerpt, tags, rating
title | date | excerpt | tags | rating |
---|---|---|---|---|
卡通面部阴影控制 | 2023-03-08 10:18:36 | ⭐ |
相关资料
- 虚幻5渲染编程(风格化渲染篇) 第七卷: Toon shadow control
- 二次元角色卡通渲染—面部篇
- 掰法线的方法 SP Normal Convert Shader
- 绘制SDF贴图方法
- SDF Shadow Shader
SDF面部阴影
Signed Distance Fields
这实质上就是找目标点及左上方四个点中,SDF最小的值。PASS0就是按照从上到下,从左到右的顺序,遍历整个图像,遍历完成之后,对于所有物体外的点,如果距离它最近的物体是在它的左上方,那么它的SDF值就已确定。类似的,PASS1就是按照从下到上,从右到左的顺序,依次比较右下方的四个点,遍历完成之后,对于所有物体外的点,如果距离它最近的物体是在它的右下方,那么它的SDF也已经确定了。两个PASS结合,那么整个图像的SDF就都计算出来了。(其实这里称SDF并不准确,因为只算了物体外到物体边界的距离,是正值,并没有signed一说,只有做完下一步计算物体内到物体外的距离,两个距离相减,才是SDF)
第二个grid的GenerateSDF就很好理解了,就是计算物体内部到外部的距离。因为一个点要么在物体内要么在物体外,所以两次的SDF值要么全为零(在边界上),要么一个为0,一个为距离值。用:grid1(pixel).sdf - grid2(pixel).sdf就能得到完整的SDF。
- 读取传入的贴图,并且构建2个RT,一个黑(inside Zero)一个白(Outside infinitely),当亮度大于0.5(128)时则反转。
- 分别对这2个RT执行 GenerateSDF()。
- 将2个RT记录的距离值相减,获得最终的SDF。
链接中的代码:
struct Point
{
int dx, dy;
int DistSq() const { return dx*dx + dy*dy; }
};
struct Grid
{
Point grid[HEIGHT][WIDTH];
};
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 );
}
}
}
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)区间的阴影。
效果
绘制方法
- 等高线绘制方法:https://www.bilibili.com/video/BV16y4y1x7J1/?spm_id_from=autoNext&vd_source=d47c0bb42f9c72fd7d74562185cee290
- 注意:等高线曲线需要关闭绘制抗锯齿。
- 使用色阶就可以把各个角度的SDF贴图提取出来。
- Maya烘焙绘制方法(应该也可以用UE)
- 给脸部模型一个Lambert材质,并且根据X轴角度要求设置好方向光。
- 使用Arnold的RenderToTexture,将贴图输出。
- 使用PS的色阶功能将颜色二值化,并在基础上进行修改。
- 对8张贴图两两计算SDF值,之后进行重映射 。最终亮度=亮度/8 * index
- 将结果合并到一起。
代码
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_Effect.mp4
虽然分享中并没有说明具体怎么计算,不过有了这个思路,做出类似的效果并不是难事,以下简单还原的过程。
首先主要代码如下:(图中还有两块区域,即额头偏亮的区域和鼻部底层暗下去的区域,由于并不清楚这两部分的应用场景,下面的计算就忽略这两部分区域,另外也忽略了精度问题)
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表现角色的光影结构切分是非常简单并且效果还不错的方式。