2023-06-29 11:55:02 +08:00

8.9 KiB
Raw Blame History

title, date, excerpt, tags, rating
title date excerpt tags rating
卡通面部阴影控制 2023-03-08 10:18:36

相关资料

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。

  1. 读取传入的贴图并且构建2个RT一个黑inside Zero一个白Outside infinitely当亮度大于0.5128时则反转。
  2. 分别对这2个RT执行 GenerateSDF()。
  3. 将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区间的阴影。

效果

!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. 将结果合并到一起。

代码

    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 虽然分享中并没有说明具体怎么计算,不过有了这个思路,做出类似的效果并不是难事,以下简单还原的过程。 首先主要代码如下:(图中还有两块区域,即额头偏亮的区域和鼻部底层暗下去的区域,由于并不清楚这两部分的应用场景,下面的计算就忽略这两部分区域,另外也忽略了精度问题)

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