回顾之前做的卡通渲染,角色面部阴影是靠一张阴影贴图实现的。
为了更好地控制角色的脸部阴影,通过计算脸部朝向与光照方向的夹角和一张单色的FaceMap来判断。
FaceMap的每个像素表示:该像素判断为阴影时候需要达到的夹角。
例如:
这张贴图的生成方法有:
通常,绘制一张完整的贴图费时费力,且不方便修改效果。
因此,使用方法2借助程序化进行生成自然是更好。
本文将介绍基于有向距离场(SDF,Signed Distance Field)生成阴影图。
其中,如何快速生成混合卡通光照图 提供了可执行的EXE以及效果。
笔者的实现参考了很多知乎上大佬的文章和代码,以下是笔者的一些笔记。
有向距离场(SDF,Signed Distance Field),可以分为2D和3D。
在本文将使用的是2D的SDF,其定义是:
每个像素(体素)记录自己与距离自己最近物体之间的距离:
下图是一个例子:
那么其对应的SDF图如下:
有向距离场有很多应用场景,如:
边界融合
如下图,A表示距离左侧1/3面积都是黑色,B表示距离左侧2/3面积都是黑色,A和B做一次blend,得到的结果就是1/3黑色(左侧),1/3灰色(中间),1/3白色(右侧)。
对A单独做一次SDF,就可以得到A上任意一点的距离函数SDF(A)。
同理对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 以纯数学的方式绘制了一个自拍的女孩。
美术绘制的多张中间过程的图如下:
要求如下:
算法如下:
第一个步骤,网上有完整的代码可以使用。
第二个步骤,从道理上很简单,但笔者实现之前,却一直没有比较完整的讲解,比较困惑,因而自己尝试实现了一下。
下面先给出笔者的结果:
Signed Distance Field给出了完整的8ssedt算法的介绍。
8ssedt生成SDF的具体算法过程如下:
首先,假设像素点值为0表示空。1表示为物体。
那么对于任何一个像素点,我们要找距离它最近的目标像素点,就有以下几种情况:
对于第一种情况,很简单。
第二种情况:
以此类推,如果知道了当前像素点周围所有像素的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
完整的示例代码如下:
#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=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;xpixels + 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:
在融合贴图这块,笔者花了比较多的时间,最终想到了一个实现的方法。
这里将进行方法的介绍,以上述的提到的输入作为示例,进行分析。
最简单的合并方法即:直接用每张贴图对应的像素值进行叠加取平均。
用三张贴图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图如下:
我们需要通过这些贴图进行插值生成我们的阴影图。
那么就需要解决以下几个问题:
插值的区域
我们想要做的是对过渡的区域进行插值,首先就需要找到这些过渡的区域!
这里笔者采用的方法是:根据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