开篇
经过一段时间的持续输出,社区中越来越多的人踏上了3D图形渲染学习之旅,麒麟子非常开心,说明输出的内容对大家都产生了实际的帮助。
特别是上一篇 《用实时反射Shader增强画面颜值》 的文章发表后,有开发者竟然等不及了,找上门来索取。
这样的要求还算合理,毕竟实时3D渲染最终的目的,是创造出更高效、炫酷的画面效果。
因此,在后面的内容输出中,麒麟子的案例会尽可能兼顾这个广大人民群众都能感知到的需求。
上个星期,一个朋友问我如下所示的水面深度效果是如何实现的。
还有一个朋友问我,为什么别人游戏里的特效都非常柔和(参考本文开头的动图),而自己做的就有很明显的接缝,如下所示:
这些事,通常依靠深度图才能够解决。
如果说高级Shader效果中,什么东西最重要的话,那肯定要数深度图了。
所以今天向大家完整介绍一下深度相关知识,希望大家看完本文后,对深度图相关问题再无死角。
深度缓冲区(Depth Buffer)
深度缓冲区解决的问题
假如有两个 3D 模型 A 和 B,且 A 和 B 有一定的交叉。
图形管线在渲染模型的时候是以单个几何体为单位的,会先渲染完一个,再渲染另一个。
这种情况下,不管是先渲染 A 还是先渲染 B,都需要解决像素遮挡问题,如果没有深度缓冲区的协助,可能出现下图中,左边这样的情况:
什么是深度缓冲区
各类计算机图形学书籍、文章上都对深度缓冲区作出了解释,麒麟子综合各家之言,给一个通俗点的解释:
深度缓冲区为非透明物体提供了像素级的前后关系判断能力。
深度缓冲区用于记录帧缓冲区(帧缓冲区用于存放颜色信息)中每个像素的深度值。
深度缓冲区的宽高通常与帧缓冲区大小相等,每一个像素位置的值是 0.0 ~ 1.0,若输出为图片,则如下所示:
离摄像机越近的值越小,摄像机近裁面处的值为 0.0。
离摄像机越远的值越大,摄像机远裁面处的值为 1.0。
所以,上图中离摄像机越近的像素越黑,离摄像机越远的像素越白。
深度写入
并不是所有的像素都会写入深度的!
3D 模型在渲染时,可以通过渲染状态决定是否开启深度写入,材质面板的开关如下所示:
当一个像素被渲染时,除了将颜色信息写入帧缓冲区外,同时还会将它的深度值写入深度缓冲区。
深度测试
写入的深度拿来干什么呢?
当一个像素被渲染时,在颜色被写入帧缓冲区之前,会进行深度测试,此时就会用到深度缓冲区。
我们可以通过开启深度测试,来丢弃较后绘制但被 “遮挡” 的像素,从而保证渲染结果的正确性。
如上图所示,深度测试有两个控制因子,一个是开关,另一个是比较函数。
默认的比较方式为 LESS ,表示小于则通过测试(除非有特别的需要,否则应该保持这个值)。
通过深度测试的像素才会改写深度缓冲区和帧缓冲区的值,否则会被丢弃,不再执行后面的像素处理流程。
深度测试的过程如下图所示:
有了深度缓冲区以后,绘制非透明物体时的顺序就不那么重要了,任意顺序都能获得正确的渲染效果。
麒麟小贴士:
非透明物体的深度写入开启,深度测试开启,绘制顺序可以与远近无关。
半透明物体的深度写入关闭,深度测试开启,需要按从远及近的顺序进行绘制。
深度图
什么是深度图
深度图是一种其像素内容可以反映深度缓冲区内容的纹理,通常是一张渲染纹理(Render Texture)。
对于一些特殊的场合,需要使用深度图才能实现更好的效果。比如:
- 半透明物体与非透明物体接缝柔和处理
- 景深等后期效果
- 水体深浅效果
- 动态阴影等
下面是一张完整的深度图效果:
深度图的存储格式
常见的深度图存储方式有 3 种,接下来我们从兼容性、数值精度、存取效率三个维度来比较的它们的优缺点。
方式1:使用 RGBA8 格式纹理中的 R 通道
由于每个像素的深度值在 0.0~1.0 之间,我们很容易想到,直接将深度信息存储到某个颜色通道中即可。
兼容性: 优,RGBA8 可以兼容所有设备。
数值精度: 差,RGBA8 中每个通道可以表示的浮点数精度为 1.0/255.0,约等于 0.0039。精度不够将会导致深度图在用于深度判定时出现较大误差。
存取效率: 优,直接存取,无额外运算。
2、R32F 浮点纹理
R32F 浮点纹理是单通道纹理,且这个通道可以存储32位浮点数。
兼容性: 一般,原生平台普及度已经非常高,但在 Web 平台,WebGL 2.0 才正式支持浮点纹理。
数值精度: 优,可以达到 32 位浮点数精度。
存取效率: 优,直接存取,无额外运算。
3、RGBA8 格式纹理编码
此方案是将深度信息编码到 RGBA8 格式纹理的四个通道中,使用时再用与编码对等的解码方式进行解码。
兼容性: 优,RGBA8 可以兼容所有设备。
数值精度: 优,可以达到 32 位浮点数精度。
存取效率: 一般,需要额外的编码、解码运算。
Cocos Creator 引擎内的深度信息编码、解码函数,如下图所示:
**麒麟小贴士:**只需要添加 #include< packing > ,就可以在自己写的 Cocos Shader 中调用。
深度图的获取
深度图的获取是非常简单的,只需要写一个简单的 Cocos Shader 就可以。
1、Vertex Shader
新建一个 Cocos Effect,写上下图这样的 unlit-vs,将投影过后的位置信息通过 v_screenPos 传给 fs。
2、Fragment Shader
在 fs 中,执行透视除法,将 v_screenPos 转换到 NDC空间, NDC 空间中的 z 值,就是深度值。
麒麟子在这里写了两种深度显示方法,depth_8bits 用于 R 通道存储, depth_32bits 用于RGBA 四通道编码存储。
3、模型渲染
我们新建两个材质,分别使用 8bits 和 32bits 两种方法渲染模型。
depth_8bits 的内容如下所示:
depth_32bits 的内容如下所示(不必感到诧异,它只是没有解码而已,解码之后的显示效果与上图一致):
麒麟小贴士:
负责渲染深度信息的摄像机的 clearFlags 属性需要设置为 SOLID_COLOR,clearColor 属性需要设置为纯白色(255,255,255,255)。
4、深度图
新建一个渲染纹理(Render Texture),并将负责渲染深度材质的摄像机输出到此渲染纹理。
5、注意事项
本示例中,我们在 v_screenPos.z/v_screenPos.w 的基础上还进行了 *0.5 + 0.5 操作。
这是由于在 Cocos Creator 引擎中,v_screenPos.z/v_screenPos.w 的值域为 [-1.0,1.0],我们需要将它映射到 [0.0,1.0]。
对于这个值域的范围,在进阶阅读:线性深度和非线性深度会讲到。
我们可以通过下面的简单测试来验证其范围:
如上图所示,只需要加上,若小于零就显示为红色的判断,就能得到如下图所示的深度图。
图中有红色,表示这个值是会小于零的,说明 depth 的值域为 [-1.0,1.0],需要修正到 [0.0,1.0]。
深度图使用案例
接下来,我们以如何实现柔和特效为例,讲解深度图的使用。
第一步:
新建一个场景,拖入我们的平台模型。如下图所示:
第二步:
复制此模型作深度渲染使用,并将所有子节点的材质,改为 material-depth-32bits。如下图所示:
第三步:
新增一个 LAYER,取名为 DEPTH,并将上一步复制的模型 LAYER 切换为 DEPTH(包括其子节点),如下图所示:
第四步:
新建一个摄像机,作为 Main Camera 的子节点。
需要注意以下几点:
1、确保此摄像机的 position、rotation、scale 均为初始化状态。
2、确保此摄像机的参数,如 fov、near、far 等与 Main Camera 一致。
3、确保此摄像机的渲染层级(Visibility)仅保留 DEPTH。
4、确保此摄像机的 clearFlags 为 SOLID_COLOR,clearColor 为纯白色(255,255,255,255)。
详细情况,如下图所示:
第五步:
新建一个 Render Texture,起名为 RT_Depth,并赋值给上一步创建的摄像机。
第六步:
拖入本系列教程上一节课中使用的 “虚空幻镜入口” 特效,如下图所示:
第七步:
复制一个 builtin-unlit.effect,并加入深度判断。
核心内容为,求得当前特效像素的深度与深度图中的差值,并做一个线性过渡。如下图所示:
完整 Shader 请看 DEMO 源文件 assets/tutorial_7/effects/effect-unlit.effect
第八步:
将第六步特效使用的材质的 effect 切换为 effect-unlit.effect,并将 RT_Depth 赋值给材质的 DepthMap 参数。
最终得到的效果如下所示:
从上面的动图中,我们可以很清晰的看到,不管是近处还是远处,半透明特效与非透明物体的接缝变得柔和了。
进阶阅读:线性深度与非线性深度
如果不太想面对数学的朋友,可以直接拖到末尾点赞、评论、转发了,避免中途流失。
初中几何角度解释非线性
上图为透视投影视锥体,所有视锥体内的物体会投影到**近裁面(Front/Near Clipping Plane)**成像。
由初中学过的三角形比例定理我们可以推导出,越远的物体在近裁面成像时缩小越多,从而出现了近大远小的效果。
代数角度解释非线性深度
如上图所示,是非常经典的 OPENGL 透视投影矩阵(不同引擎由于选择的坐系不同,正方向不同,会略有差异)。
在与 Cocos Creator 引擎的 Mat4.perspective 对比后,确认 Cocos Creator 也是用的上图的透视投影矩阵。
设观察空间中有一个点 Pview(x,y,z,1.0) ,那它投影后的各值为:
- Pproj.x = x*cot(fovy/2)/aspect
- Pproj.y = y*cot(fovy/2)
- Pproj.z = z*-(f+n)/(f-n)-(2*f*n/(f-n))
- Pproj.w = -z
为方便阅读
Pview.z 简写为 z
Pproj.z 简写为 z’
Pproj.z/Pproj.w 简写为 z’'
由此可得:
此时的 z’’ (Pproj.z/Pproj.w) 是一个处于[-1.0~1.0]区间的值,将其映射到[0.0~1.0]区间,就能得到我们的非线性深度。
最终公式整理后可得:
depth = z’’ * 0.5 + 0.5
为了更直观的体现这个非线性关系,麒麟子花了较长时间做了下面的图:
需要关注几个点:
1、上面的投影公式中,正方向为 -Z,所以图中 Pview.z 值为负。
2、Pview.z为 -10 时,depth 就已经大于 0.9 了,这就说明靠近摄像机跟前的这 10个单位,占用了 90% 的深度缓冲区精度。
3、整个表很长, -1.0~-1000.0,无法完整截图。
线性深度
线性深度一般用于需要精确表达两个物体深度差异、反算像素在观察坐标系中的位置或者基于物理公式的运算等情况。
线性深度有两种表示方法:
- LinearEyeDepth = -Pview.z
- Linear01Depth = (-Pview.z-n)/(f-n) 或者 -Pview.z/f
麒麟小贴士: 再次提醒,由于系统中是以 -Z(0.0,0.0,-1.0) 为正方向,所以求深度的时候,z 值需要取反。
线性深度的使用场合,一般是指当我们拥有深度图时,此时并没有 Pview.z 这个数据。
所以上面的公式只能作为理解什么是线性深度用,在实际生产环境中派不上用场。
下面我们来研究,如何在拥有深度图的情况下,反算出线性深度。
LinearEyeDepth 推导过程
由我们刚刚讲到的非线性深度公式,可以反推出以下结论:
设 depth 为 z’’'
则可以得到 z’’ = z’’’ * 2.0 - 1.0
代入上面的投影公式可以反向求解出观察坐标系下的 Pview.z :
由于坐标系以 -Z 为正方向,这样求出来的 Pview.z 是负数,取反即可得到我们视野空间的线性深度。
最后我们得到的 LinearEyeDepth 计算公式如下:
很容易做一个验算:
- 当 z’’’ 为 0 时,LinearEyeDepth 为 n。
- 当 z’’’ 为 1 时,LinearEyeDepth 为 f。
如果担心端点是特例难以信服,可以在 Excel 中,双向验证公式,如下图所示:
Linear01Depth 推导过程
线性深度除了用 -Pview.z 表示外,还可以将其归一化到 [0,1] 区间。
实际上,[0,1]的线性深度使用率远高于 LinearEyeDepth,因为它可以不用关注 near、far 的数值变化。
Linear01Depth 有两个公式可以使用:
- 公式 A: (-Pview.z-n)/(f-n)
- 公式 B: -Pview.z/f
两个公式的区别在于区间的选择。
公式 A 仅考虑 n 和 f 之间的值,当 Pview.z 值为 -n 时,Linear01Depth 为 0。
公式 B 则将观察点也纳入了考虑,当 Linear01Depth 为 0 时,Pview.z 与观察点重合。
两个公式都是可以的,但由于 公式 B 的运算更为简单,且能够处理 Pview.z 比 n 离摄像机更近的情况,所以大多数系统中采用了公式 B。
采用 公式 B,只需将 LinearEyeDepth 除以 f,即可得到 Linear01Depth 最终计算公式,如下图所示:
核心代码
代码反而是最简单的了,如下图所示:
敲黑板!
学习过程中可能遇上的疑惑!!!
如果一切都像上面这样的话,本文就可以开心的结束。
但麒麟子在核实深度图相关内容的过程中,在网上找到了一个出场率非常高的内容,如下所示:
如果盲目照着这段代码来的处理的话,只会掉入一个深坑。这段代码有一个大前提是:
该引擎的运行时中,OPENGL 环境下拿到的深度信息处于 [-1.0,1.0] 的非线性区间。D3D 环境下拿到的深度信息处于 [0.0,1.0]区间。
因此,采用下面哪个组合,是要根据深度信息的范围而定,而不是平台和图形 API。
-
zc0 = 1.0 - f/n , zc1 = f/n
-
zc0 = (1.0 - f/n)*2.0 , zc1 = (1.0 + f/n) * 2.0
线性深度与非线性深度曲线
由于 LinearEyeDepth 即为 -Pview.z,不方便展示,所以下图仅展示了 Linear01Depth(红线):
值得注意的是,Linear01Depth 的精度是受 f 决定的。f 的值会影响同一视角下同一像素的深度值,如下图所示:
关于写作
有人问过麒麟子,说市面上已经有非常多的计算机图形学、3D渲染相关教程了,为什么我还要重写。
我统一答复一下吧:
市面上的文章、教程,不管是讲某引擎的,还是讲某API的。
要么是贴代码,要么是抄公式,去吻合计算机图形学理论,生怕与理论相悖。
最终导致了国内3D图形相关人才与项目需求的断层。
而麒麟子输出的内容,是结合实际项目需求中广泛采用的方案来讲解,从原理、实现、到应用。
麒麟子提供的不是理论,是理论知识在行业中的实际应用。
相信这样的方式更能提升大家的学习效率,做到学以致用。
知道你们喜欢看视频,特意做了一个科普视频给大家:
DEMO免费获取
方式一:
https://store.cocos.com/app/detail/3521
**方式二:**Cocos Dashboard -> 商城 搜索 kylins