图形学基础|基于SDF的卡通阴影图

图形学基础|基于SDF的卡通阴影图

一、前言

回顾之前做的卡通渲染,角色面部阴影是靠一张阴影贴图实现的。

为了更好地控制角色的脸部阴影,通过计算脸部朝向与光照方向的夹角和一张单色的FaceMap来判断。

FaceMap的每个像素表示:该像素判断为阴影时候需要达到的夹角

例如:

  • 当方向光正对角色的时候,夹角为0°,对应的值为0,此时,FaceMap整个脸部的值都大于0。不满足阴影条件。所以都判断为亮部。
  • 当方向光继续向角色右脸旋转,当到达36°(归一化值为0.2,36°除以180°),那么图中0.2对应的那条线左边的像素值都小于0.2,判断为阴影区域。右侧为亮部。

在这里插入图片描述

这张贴图的生成方法有:

  1. 通过美术家手动绘制一张完整的;
  2. 通过美术家绘制多张中间过程的图,通过SDF生成最终结果;

通常,绘制一张完整的贴图费时费力,且不方便修改效果。

因此,使用方法2借助程序化进行生成自然是更好。

本文将介绍基于有向距离场(SDF,Signed Distance Field)生成阴影图。

其中,如何快速生成混合卡通光照图 提供了可执行的EXE以及效果。

在这里插入图片描述

在这里插入图片描述

笔者的实现参考了很多知乎上大佬的文章和代码,以下是笔者的一些笔记。

二、SDF(Signed Distance Field)

有向距离场(SDF,Signed Distance Field),可以分为2D和3D。

在本文将使用的是2D的SDF,其定义是:

每个像素(体素)记录自己与距离自己最近物体之间的距离:

  • 如果在物体内,则距离为负,距离越远,值越小;
  • 如果正好在物体边界上,则值为0;
  • 如果在物体外,那么距离为正,距离越远,值越大;

下图是一个例子:

  • 白色像素表示物体,黑色像素为空。

在这里插入图片描述

那么其对应的SDF图如下:

  • 为了图片显示,将有向距离值映射到了[0-1]范围0.5表示物体内外的分界线
  • 可以清楚看到,位于圆中心的点是最黑的,因为它处于物体最内部,而图片的四个角最白,因为它们距离圆最远。

在这里插入图片描述

有向距离场有很多应用场景,如:

  • 边界融合;
  • 抗锯齿;
  • 字体渲染;
  • 图形绘制;

边界融合

如下图,A表示距离左侧1/3面积都是黑色,B表示距离左侧2/3面积都是黑色,A和B做一次blend,得到的结果就是1/3黑色(左侧),1/3灰色(中间),1/3白色(右侧)。

对A单独做一次SDF,就可以得到A上任意一点的距离函数SDF(A)。

  • A的黑白分界线为0,若一个点越接近于黑白分界线,距离函数的值越小,越接近于0,向右(白)为正,向左(黑)为负。

同理对B也单独做一次SDF,得到SDF(B)。

将SDF(A)和SDF(B)做一次blend,得到blend( SDF(A),SDF(B))。

那么这个blend后的图像中间即为0,向右(白)为正,向左(黑)为负。

把这个blend( SDF(A),SDF(B))通过SDF再恢复成原来的形状,就可以知道,0的地方就是他们的边界,非0的地方不是。即blend两个对应的SDF,实际就是在blend他们的边界。

在这里插入图片描述

抗锯齿或基于SDF渲染字体

相比常规的渲染方式,基于SDF渲染文字可无限放大并保持清晰,几乎没有开销就可实现描边,发光,抗锯齿等效果。

将每个像素存储的颜色值换成距离文字轮廓最短距离

  • 当像素在文字内,则用正数距离,在文字外则用负数距离,文字轮廓距离则是零。

因此只要判断像素,如果是正数,就输出颜色,否则丢弃颜色即可。

18号字体:

在这里插入图片描述

18号字体的位图放大15倍:

在这里插入图片描述

基于SDF渲染字体放大15倍:

在这里插入图片描述

图形绘制

大名鼎鼎的ShaderToy网站上有各种通过SDF结合Raymarching实现的绘制图形的例子。

iq大神博客展示了各种神仙操作。

其中,Selfie Girl 以纯数学的方式绘制了一个自拍的女孩。

在这里插入图片描述

三、阴影图生成

美术绘制的多张中间过程的图如下:

在这里插入图片描述

要求如下:

  • 图片为二值图;
  • 图片必须连续且后一张必须可以覆盖前一张(可以是暗部覆盖也可以是亮部覆盖,但只能是一种)。

算法如下:

  1. 对每一张图片生成对应的SDF图;
  2. 利用SDF进行插值,得到最后的结果;

第一个步骤,网上有完整的代码可以使用。

第二个步骤,从道理上很简单,但笔者实现之前,却一直没有比较完整的讲解,比较困惑,因而自己尝试实现了一下。

下面先给出笔者的结果:

  • 注,这里显示的就是线性的数值。而非Gamma SRGB。(笔者在输出图像前先作了逆Gamma校正)。

在这里插入图片描述

3.1 SDF生成

Signed Distance Field给出了完整的8ssedt算法的介绍。

8ssedt生成SDF的具体算法过程如下:

  1. 遍历一遍图像,将物体内和物体外的点标记出来;分为为两个Grid;
  2. 分别生成有向距离场,计算物体外到物体的距离,以及物体内部的点到边界的距离,前者为正值,后者为计算为负值。
  3. 二者相减得到最终的SDF图像;

首先,假设像素点值为0表示空。1表示为物体。

那么对于任何一个像素点,我们要找距离它最近的目标像素点,就有以下几种情况:

  • 像素值为1:自身就是目标点,所以距离为0;
  • 像素为值0:目标点应该在自己是周围。但不确定。

对于第一种情况,很简单。

第二种情况:

  • 假如当前像素点周围最近的某个像素(上下左右四个方向距离为1的像素)正好为1,那我这个像素点的SDF就应该为1,因为不会有更近的情况了。
  • 其次就是左上、左下、右上、右下四个点,如果有为1的点,那该像素点的SDF值就应该为根号2。

以此类推,如果知道了当前像素点周围所有像素的SDF值,那么该像素点的SDF值一定为:

MinimumSDF(near.sdf + distance(now,near))
- near表示附近像素点,now表示当前像素,near.sdf表示near的SDF值,distance表示两点之间距离。

伪代码如下:

now.sdf = 999999;
if(now in object)
{
   now.sdf = 0;
}
else
{
   foreach(near in nearPixel(now))
   {
        now.sdf = min(now.sdf,near.sdf + distance(now,near));
   }
}

这是动态规划的递推公式。

实现的方式如下:

for (int x=0;x<WIDTH;x++)
{
    // 找目标点及左上方四个点中,SDF最小的值。
    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 );
}

void Compare( Grid &g, Point &p, int x, int y, int offsetx, int offsety )
{
    // 获得邻居点
   Point other = Get( g, x+offsetx, y+offsety );
    // 邻居点距离边界的距离 加上测试点和邻居点的距离 即near.sdf + distance(now,near)
   other.dx += offsetx;
   other.dy += offsety;
    // 求min,如果比自己小,那么就用这个距离
   if (other.DistSq() < p.DistSq())
       p = other;
}

完整的示例代码如下:

#define WIDTH  256
#define HEIGHT 256

struct Point
{
    int dx, dy;

    int DistSq() const { return dx*dx + dy*dy; }
};

struct Grid
{
    Point grid[HEIGHT][WIDTH];
};

Point inside = { 0, 0 };
Point empty = { 9999, 9999 };
Grid grid1, grid2;

Point Get( Grid &g, int x, int y )
{
    // OPTIMIZATION: you can skip the edge check code if you make your grid 
    // have a 1-pixel gutter.
    if ( x >= 0 && y >= 0 && x < WIDTH && y < HEIGHT )
        return g.grid[y][x];
    else
        return empty;
}

void Put( Grid &g, int x, int y, const Point &p )
{
    g.grid[y][x] = p;
}

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;
}

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 main( int argc, char* args[] )
{
    for( int y=0;y<HEIGHT;y++ )
    {
        for ( int x=0;x<WIDTH;x++ )
        {
            Uint8 r,g,b;
            Uint32 *src = ( (Uint32 *)( (Uint8 *)temp->pixels + y*temp->pitch ) ) + x;
            SDL_GetRGB( *src, temp->format, &r, &g, &b );           
            // Points inside get marked with a dx/dy of zero.
            // Points outside get marked with an infinitely large distance.
            if ( g < 128 )
            {
                Put( grid1, x, y, inside );
                Put( grid2, x, y, empty );
            } else {
                Put( grid2, x, y, inside );
                Put( grid1, x, y, empty );
            }
        }
    }
    ......
    // Generate the SDF.
    GenerateSDF( grid1 );
    GenerateSDF( grid2 );
    ......
}

PASS0就是按照从上到下,从左到右的顺序,遍历整个图像,遍历完成之后,对于所有物体外的点,如果距离它最近的物体是在它的左上方,那么它的SDF值就已确定。

类似的,PASS1就是按照从下到上,从右到左的顺序,依次比较右下方的四个点,遍历完成之后,对于所有物体外的点,如果距离它最近的物体是在它的右下方,那么它的SDF也已经确定了。

在计算得到两个Grid的SDF后,生成最终的SDF的代码如下:

float scale = 3f;
for (int y = 0; y < imageHeight; ++y)
{
    for (int x = 0; x < imageWidth; ++x)
    {
        // calculate the actual distance from the dx/dy
        int dist1 = (int)(sqrt((double)Get(testGrid1, x, y).DistSq()));
        int dist2 = (int)(sqrt((double)Get(testGrid2, x, y).DistSq()));
        int dist = dist1 - dist2;

        // clamp and scale for display purpose
        int c = dist * scale + 128;
        if (c < 0) c = 0;
        if (c > 255) c = 255;

        sdf[y * imageWidth + x] = c;
    }
}

注意:

  • scale控制SDF图的锐利程度,值越大,SDF图越锐利。

scale为1:

在这里插入图片描述

scale为3:

在这里插入图片描述

3.2 插值

在融合贴图这块,笔者花了比较多的时间,最终想到了一个实现的方法。

这里将进行方法的介绍,以上述的提到的输入作为示例,进行分析。

在这里插入图片描述

最简单的合并方法即:直接用每张贴图对应的像素值进行叠加取平均

用三张贴图ABC简单叠加在一起。假设像素x在贴图A的值为0,B为1,C为1,叠加结果像素x的值为:

x = ( 0 + 1 + 1 ) / 3 = 0.666667 x= (0+1+1)/3 = 0.666667 x=(0+1+1)/3=0.666667

这样是可以生成一张贴图,但是各个带之间就没有过渡了,而是一个常数值,没有利用的SDF的信息。

不过这给了笔者一个启发,就是我们需要做的就是结合SDF的距离信息进行插值

他们对应的SDF图如下:

在这里插入图片描述

我们需要通过这些贴图进行插值生成我们的阴影图。

那么就需要解决以下几个问题:

  1. 如何确定插值的区域;
  2. 插值的权重;
  3. 插值的上下限是什么;

插值的区域

我们想要做的是对过渡的区域进行插值,首先就需要找到这些过渡的区域!

这里笔者采用的方法是:根据SDF的符号进行判断

以g、h为例合并,黄色区域表示我们要插值的区域,根据他们的SDF图,可以看出这块这块区域sdf_h为黑,即负数,sdf_g为白,即正数。

在这里插入图片描述

所以需要插值的区域sdf乘积小于0的区域,伪代码为:

if(sdf_g * sdf_h < 0)
{
    // 插值
}

插值的权重

有SDF,距离就是一种特别好的权重!因此

float totalDis = abs(sdf_g) + abs(sdf_h);
float t = abs(sdf_g) / totalDis;

插值的上下限

插值的区域,我们可以使用了SDF的左边界来确定了范围,如上面提到的g和h。

通过我们要产生的贴图可知,从左到右。数值是从0一直到1的,每次通过SDF的符号可以得到一个带插值的区域。

我们一共有a、b、c、d、e、f、g、h八张图,有八个左边界,可以确定的插值区域有7个。

那么每个区域的插值上下限就是 [ 0 , 1 / 7 ] [0,1/7] [0,1/7] [ 0 , 2 / 7 ] [0,2/7] [0,2/7] [ 0 , 3 / 7 ] [0,3/7] [0,3/7] [ 0 , 4 / 7 ] [0,4/7] [0,4/7] [ 0 , 5 / 7 ] [0,5/7] [0,5/7] [ 0 , 6 / 7 ] [0,6/7] [0,6/7] [ 0 , 7 / 7 ] [0,7/7] [0,7/7]

至于a区域右边界和h的右边界,这块区域直接赋值为1。

完整的代码

float scale = 1.f / (images.size() - 1);
	for (int step = 0, i = images.size() - 1; i >= 1; --i, ++step)
	{
		for (int y = 0; y < imageHeight; ++y)
		{
			for (int x = 0; x < imageWidth; ++x)
			{
				// lerp
				int sdf_index = y * imageWidth + x;
				int pixel_index = sdf_index * imageChannel;

				int left_border_index = i;
				int right_border_index = i - 1;

				float sdf1 = images[left_border_index]->sdf[sdf_index] / 255.f;
				float sdf2 = images[right_border_index]->sdf[sdf_index] / 255.f;
				sdf1 = 2.f * sdf1 - 1.f;
				sdf2 = 2.f * sdf2 - 1.f;

				float left = step * scale;
				float right = (step + 1) * scale;

				if (sdf1 * sdf2 > 0)
				{
					if (right_border_index == 0)
					{
						if (sdf1 < 0)
						{
							for (int c = 0; c < imageChannel; ++c)
								image[pixel_index + c] = 255;
						}
					}
					else
					{
						// nothing
					}
				}
				else
				{
					// sdf1 < 0 , sdf2 > 0
					float totalDis = abs(sdf1) + abs(sdf2);
					float t = abs(sdf2) / totalDis;
					t = 1 - t;
					for (int c = 0; c < imageChannel; ++c)
					{
						float dst = left * (1 - t) + (right * t);
						// dst = std::pow(dst, 1 / 2.2f); // gamma for linear display
						image[pixel_index + c] = dst * 255;
					}
				}
			}
		}
	}

Github代码:SDF-LightMap

参考博文

  • 15
    点赞
  • 80
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值