Unity云消散理论基础:实现SDF的8SSEDT算法

Posted 九九345

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Unity云消散理论基础:实现SDF的8SSEDT算法相关的知识,希望对你有一定的参考价值。

距离元旦假期已经过去5天了(从31号算起!),接着开始学习! 

游戏中的很多渲染效果都离不开SDF,那么SDF究竟是什么呢?到底是个怎么样的技术?为什么能解决那么多问题?

1 SDF

SDF,即signed distance field,有向距离场or带符号距离场(直译)。

1.1 记录了什么

SDF贴图,每个像素记录自己与距离自己最近的物体边界的距离。也就是说,SDF的贴图记录的竟然不是颜色信息,而是距离。 

图像的矢量表达

Signed Distance Field与Multi-channel signed distance field这篇文章更加直白的介绍了SDF——SDF本质上就是在一个光栅图里存了一个图像的矢量表达,说的太好了,真得就是这么一回事。

signed

所谓的signed体现在:像素点在物体内距离为负刚好在物体边界边界上为0。但其实这个物体内距离是正是负、其实只是一个判断的问题?(不太清楚这样说正确与否)

直接举几个例子吧。

1(直接截图了Signed Distance Field的内容)

2 直接了当的展示了signed(直接截图了SDF(signed distance field)基础理论和计算): 

3 这个表示方法更加形象理解“矢量”这个描述(直接截图了Signed Distance Field与Multi-channel signed distance field):

1.2 能干什么

Ray Marching

首先,Ray Marching,202有学习过:Lecture5 Real-time Environment Mapping

卡渲的面部光照

其次,风格化卡通渲染的人物面部光照,很多文章都有介绍,例如卡通渲染之基于SDF生成面部阴影贴图的效果实现(URP)

SDF可以实现图像之间的平滑过渡,直接对两张SDF进行lerp插值就能得到视觉上的平滑过渡效果实际上是SDF贴图记录的距离之间的平滑过渡。 

其他的

除了RayMarching和面部光照,还有字体渲染(Unity中的TextMeshPro插件就是基于SDF的,还可以看看这篇文章)、做一些形变动画等等。

1.3 怎么做

8SSEDT算法(8-points Signed Sequential Euclidean Distance Transform),这是一种能在线性时间内计算出SDF的算法,基本上实现SDF都用的是8SSEDT吧。下面会着重学习一下这个算法是如何实现的。

2 8SSEDT算法

8ssedt其实是实现SDF的一种算法,还有其他方法,至于方法之间的对比以及简易程度这里我就略过啦!

2.1 算法核心:递推 

SDF的宗旨一直都是记录当前像素点离最近物体边界的距离。我们假设用0和1来表示当前像素点,0表示像素点值为空,1表示在物体,那么要找到距离当前点最近的目标像素点,就有以下两种情况:

  • 像素值为1:代表像素点自己就是目标点,距离为0
  • 像素值为0:代表当前像素点不是目标点,意味着向四周任意方向(上下左右/左上左下右上右下)都有可能是目标点

像素值为0时有以下两种情况: 

  • 上下左右的某个像素点的像素值为1
  • 左上左下右上右下的某个像素点的像素值为1

该像素点的SDF取值为:MinmumSDF(now.sdf, near.sdf + distance(now, near)),即附近像素点的SDF值+当前像素点到附近像素点的距离。

而公式里的near.sdf也同样需要用上述的式子计算,你会发现SDF图像记录的距离都是连续的,所以SDF算法的内核其实是递推,把复杂的问题拆解成了连续的简单问题。

伪代码

now.sdf = 999999;// 初始为空,距离尽可能大
if(now in object)// 
    now.sdf = 0;// sdf距离为0
else// 像素点值为0
    foreach(near in nearPixel(now))
        now.sdf = min(now.sdf,near.sdf + distance(now,near));// 递推
    

2.2 算法核心思路

STEP1 加载图片并创建2个Grid

假设我们拿到一张黑白图,加载图片后,遍历一遍黑白图,此时的原图里假设是白色为物体,黑色为空,建立两个Grid来记录网格像素数据,用以确定像素点是位于物体内部还是外部,如何建立如下:

  • 一个Grid用于计算物体外到物体的距离:那么从距离的角度来讲,我们可以标记白色像素距离为0,黑色像素距离为一个尽可能大的数(例如上面的now.sdf = 999999),这个Grid用于推导向外的距离场
  • 一个Grid用于计算物体内部点到物体外的距离:即黑色像素标记距离为0,白色像素标记为一个尽可能大的值,这个Grid用于推导向内的距离场

也就是一个Grid缓存内部为0,外部为无穷;一个Grid缓存内部为无穷,外部为0。

STEP2 计算距离

接下来干什么呢?让一个像素与周围的8个像素分别进行比较

按照“从左往右,从上到下”(如果是引擎UV生成还要考虑方向,比如Unity的UV是从下往上)的顺序遍历所有的像素。

文章Signed Distance Field - 知乎 (zhihu.com)中把过程拆解成了两个PASS,每个Grid都经历一次PASS0和PASS1,具体过程可以直接戳这篇文章看就行!过程包括伪代码都写的很详细~

STEP3 两个Grid作差

grid1(pixel).sdf - grid2(pixel).sdf就行。

2.3 Excel直观理解算法

这里我简单实践一下帮助更好的理解,选择了一个笨蛋办法,在excel里进行:

STEP1 计算Grid1

第一个Grid原始值如下(用9直接替代∞):

PASS0后:

PASS1后最终:

STEP2 计算Grid2

第二个Grid原始值如下(用9直接替代∞):

PASS0和PASS1后最终:

STEP3 相减

对每个对应位置像素Grid1.sdf - Grid.sdf后,才算得到最终的signed结果:

这样得到的就是最后的SDF贴图每个像素点储存的距离了,当然这只是个简单的例子,得到真实的应该是类似于下图的样子(这张截图来自这篇文章,上面也有类似的例子展示):

3 代码实现

学习的过程中我发现绝大部分文章的代码都来自于这篇文章:Signed Distance Fields (codersnotes.com)Signed Distance Field中对过程做了解释,但是呢如果直接运行你会发现缺少一个SDL库。

3.1 SDL库

上述代码运行还需要添加SDL库,具体如何添加以及SDL库是什么可以直接看这篇文章:

SDL库的介绍与安装

捣鼓捣鼓你会发现,上述代码其实用的是SDL1.2版本(毕竟是2006年的文章了),有一些函数已经被替换/删除了,且64位的电脑上支持不了一些头文件,例如SDL_config.h就是SDL_config_win32.h

所以我们不仅要添入SDL库,还需要对比1.2和2.0的不同修改代码,修改的过程就不赘述了,后面的完整代码里我会写一些注释,这里简单列举一下我参考的一些文章:

SDL2常用函数&结构分析:SDL_Surface&SDL_GetWindowSurface&SDL_LoadBMP

SDL学习笔记(3)——窗口绘制 

3.2 最后的完整代码

部分函数的使用我写了一些注释方便理解,修改后的最终代码如下:


#include <SDL.h>
#include <SDL_main.h>
#include <math.h>

#pragma comment(lib, "SDL2.lib")
#pragma comment(lib, "SDL2main.lib")

#define SDL_MAIN_HANDLED

#define WIDTH  256
#define HEIGHT 256

struct Point

	//dx,dy表示对于当前点的偏移值
	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);
	//给获取的点的dx和dy设置对应的偏移值
	other.dx += offsetx;
	other.dy += offsety;

	if (other.DistSq() < p.DistSq())
		p = other;


// Now all we have to do is run the propagation algorithm. See the paper for exactly what's happening here, 
// but basically the idea is to see what the neighboring pixel has for it's dx/dy,
//  then try adding it onto ours to see if it's better than what we already have.
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[])

    // SDL_Init(SDL_INIT_VIDEO) -- 初始化视频子系统
	if (SDL_Init(SDL_INIT_VIDEO) == -1)
		return 1;

	// 创建一个窗体
	SDL_Window* window = SDL_CreateWindow("W", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, WIDTH, HEIGHT, 0);
	// 创建一个与窗体关联的surface,赋值给screen
	SDL_Surface* screen = SDL_GetWindowSurface(window);
	if (!screen)
		return 1;

	// 加载位图文件
	// SDL_LoadBMP -- 加载指定的bmp文件,一定要是bmp图像的文件
	SDL_Surface* temp = SDL_LoadBMP("test.bmp");

	// 调整窗口
	// SDL_ConvertSurface -- 图片被加载时对其以屏幕相同格式进行转化来保证投射过程中不再发生转化,以新的格式返回原来的surface
	temp = SDL_ConvertSurface(temp, screen->format, SDL_SWSURFACE);

	SDL_LockSurface(temp);
	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.
			// 两个网格,一个内部设置成0,外部设置成正无穷,另一个网格相反。
			if (g < 128)
			
				Put(grid1, x, y, inside);
				Put(grid2, x, y, empty);
			
			else 
				Put(grid2, x, y, inside);
				Put(grid1, x, y, empty);
			
		
	
	SDL_UnlockSurface(temp);

	// Generate the SDF.
	GenerateSDF(grid1);
	GenerateSDF(grid2);

	// Render out the results.
	SDL_LockSurface(screen);
	for (int y = 0; y < HEIGHT; y++)
	
		for (int x = 0; x < WIDTH; x++)
		
			// Calculate the actual distance from the dx/dy
			//计算偏移值的点距离当前点的距离
			int dist1 = (int)(sqrt((double)Get(grid1, x, y).DistSq()));
			int dist2 = (int)(sqrt((double)Get(grid2, x, y).DistSq()));
			int dist = dist1 - dist2;

			// Clamp and scale it, just for display purposes.
			int c = dist * 3 + 128;
			if (c < 0) c = 0;
			if (c > 255) c = 255;

			Uint32* dest = ((Uint32*)((Uint8*)screen->pixels + y * screen->pitch)) + x;
			*dest = SDL_MapRGB(screen->format, c, c, c);
		
	
	SDL_UnlockSurface(screen);

	// Wait for a keypress
	SDL_Event event;
	while (true)
	
		if (SDL_PollEvent(&event))
			switch (event.type)
			
			case SDL_QUIT:
			case SDL_KEYDOWN:
				return true;
			
		// 更新窗口,才能看到
		SDL_UpdateWindowSurface(window);
		// 保存成位图
		SDL_SaveBMP(screen, "save2.bmp");
	
	
	
	return 0;

3.3 运行展示

首先是一个基础的:

PS随便画了一张(需要保存成bmp格式):

4 面部阴影(挖个坑)

SDF在风格化渲染中用的最多的地方其实是实现面部阴影,通常是美术绘制好特定光线角度时的面部阴影,通过SDF插值计算出中间的过程,将过程叠加到一张图上,通过简单的blur或者smooth操作实现平滑。

目前已经有大佬给出快速生成上述图的程序了:如何快速生成混合卡通光照图 - 知乎 (zhihu.com)

这里就简单的提一下吧,也是挖个坑(要学的好多!),等实现了天空盒之后就来继续学习面部阴影!! 

参考

(其实参考了非常多的文章,但是后面写着写着忘记了都有哪些了,这里简单的罗列三个吧~)

【有趣的技术】Unity中的SDF(有向距离场) - 简书 (jianshu.com)

Signed Distance Field - 知乎 (zhihu.com)

Tech-Artist 学习笔记:Signed Distance Field 8SSEDT 算法 - 知乎 (zhihu.com)

Unity天空盒卡通渲染中如何实现云的消散效果

写在前面

完成大气渲染之后,接下来就是考虑云渲染了。因为我想做的天空盒本身是想跟着这位大佬Unity 卡通渲染 程序化天空盒 - 知乎里叙述的进程来的,里面云实现的是原神里的云,原神又是在崩3的基础上加上了消散效果。但现在能找到的一些教程or展示的视频里,很多天空盒的云都是通过贴图+noise map实现的,如何实现类似原神那种云伴随着太阳光的消散效果少有涉及,因此打算写一篇文章简单的记录一下学习过程,然后亲自实现一下~

1 原神的消散云

首先是原神里的消散的云效果,在玩的时候蛮好看的,既然原神是在崩坏3的基础上加上了消散效果,那先来看看崩坏3是如何实现云的:

1.1 崩坏3如何实现云

指路崩坏3的技术分享From mobile to high-end PC: Achieving high quality anime style rendering on Unity

跳过前面的(有时间可以看完!收获很大!!1),直接重点看这一部分:

重点:多层着色

为了让玩家感受到纵深、具有各种丰富形态以及动态光照变化的云渲染系统,游戏中实现了24小时动态变化的云,但并没有直接储存庞大数量的贴图,而是选择多层着色实现这个目的。

使用了4个通道来表示云的光照及阴影,如下图所示,从左到右依次为:基础照明、阴影1、阴影2和边缘光层。

1.2 原神的云贴图

上面提到的文章的做法:

以及评论区有人说:

那么我们拿到原神的云贴图,看看它每个通道是不是这样的。

RGBA

R

嗯,阴影,Shadow Layer。

G

G是边缘光,对应上面崩坏3里的Rim Layer。

B

B通道就是上面评论区提到的SDF!

可喜可贺,我们知道了云贴图每个通道对应的内容是什么,那接下来就是如何拿着这个帖图去实现效果了。

补充:SD中拆分

2 别人是怎么做的

2.1 两个案例

搜刮遍了只能找到两位大佬做出来我想要的效果:

Unity NPR 原神Cloud,Sky,Shader

Unity 卡通渲染 程序化天空盒 昼夜变化

2.2 简单分析

看看会发现,两位大佬实现的效果都有一种伴随着太阳光消散的感觉。第一位大佬没有具体说明该怎么做这个云,但是这个时候又要善于看评论区了!

!解决了,这个生长数据(灰度图),就是上面展示的贴图里通道B的“灰度图”。  

再看看第二位大佬的方法,很开心,知乎文章Unity 卡通渲染 程序化天空盒里最后一部分介绍了云如何实现的,思路跟第一位大佬几乎一致!

接下来就是跟着文章里面介绍的实现思路来复刻一遍。

3 学习安排

3.1 获得灰度图

好的,现在已知方法,就差如何实践了,手上已经有了现成的云贴图,那么制作SDF灰度图的过程可以跳过:

与之对应的就是这张图:

PS查看阈值

那么我们模仿教程里的进度,也查看查看SDF图。

3.2 学习SDF【已】

虽然我们的SDF图不需要自己去做了,但是!SDF与卡通渲染一定是紧密相关的,要掌握!

图形学基础|基于SDF的卡通阴影图_桑来93的博客-CSDN博客

卡通渲染之基于SDF生成面部阴影贴图的效果实现(URP) - 知乎 (zhihu.com)

 学习记录【Unity云消散】理论基础:实现SDF的8SSEDT算法_九九345的博客-CSDN博客

3.3 Blender做云面片模型

对应教程中的这一步:

打算趁机再巩固一下UV映射(本身是美术苦手TAT刚好学习一波!)

Blender 2.8 UV 映射 Blender 2.8 UV Mapping

3.4 卡通渲染中的边缘光

在之前的《入门精要》学习中,14.1就已经介绍过了NPR,由于这次想实现的天空盒其实就是卡通渲染的天空盒,所以这里再趁机进行巩固!结合下面这篇不错的文章进行边缘光的实现的学习。

【Cel-Shading】边缘光的实现 | Invictus maneo (x-wflo.github.io)

3.5 跟教程写shader

有了上面Blender获得的cloudTex和理论知识储备后,就可以开始参考教程中的代码进行实现了!


总结一下,通过这次云的实现我可以:复习Blender展UV、学习NPR边缘光等、学习SDF等,接下来就是学习+实现! 

以上是关于Unity云消散理论基础:实现SDF的8SSEDT算法的主要内容,如果未能解决你的问题,请参考以下文章

Unity Shader 赛博小人01UV准备 SD制作特效贴图

Unity云消散巩固step,lerp和smoothstep

我想学习unity3d游戏程序开发 要学习啥?有推荐的书么?

unity游戏开发看什么书?

shader编程-RayMarching与SDF搭建三维场景实现Blinn-Phong光照(WebGL-Shader开发基础08)

shader编程-RayMarching与SDF搭建三维场景实现Blinn-Phong光照(WebGL-Shader开发基础08)