前言
本书所剖析的Unity 3D内置着色器代码版本是2017.2.0f3,读者可以从Unity 3D官网下载这些着色器代码。这些代码以名为builtin_shaders-2017.2.0f3.zip的压缩包的形式提供,解压缩后,内有4个目录和1个license.txt文件。
目录CGIncludes存放了37个扩展名为cginc的文件,两个扩展名为glslinc的文件。这些文件就Unity 3D提供的内置着色器的头文件。重点部分如下:
目录DefaultResources存放了Unity 3D引擎内置的简单着色器。
目录DefaultResourcesExtra提供了大量渲染效果的着色器实现,Mobile子目录下的shader文件就是移动平台下的漫反射效果、粒子系统、法线贴图和光照图效果的实现。
目录Editor中唯一的文件是StandardShaderUI.cs。该段代码是当材质文件使用了标准着色器时,材质对应的inspector界面的实现。
文件license.txt用于说明Unity 3D开发公司对这些着色器代码的版权。
Unity 3D有两种渲染方式:一种是前向渲染,另一种是延迟渲染。Unity 3D提供的标准着色器文件Standard.shader中有这两种渲染方式的实现。
图形渲染的两大主题是光照和阴影的计算。Unity 3D引擎除了支持光源对物体的照明计算(即直接照明)之外,还支持物体之间的光照效果,即间接照明。两者统称为全局照明。
1.1、概述
在计算机体系结构中,管线(pipeline)可以理解为处理数据的各个阶段和步骤。3D渲染流水线(render pipeline)接收描述三维场景的数据内容,经过若干阶段的处理,将其以二维图像的形式输出。
目前主流的实时3D渲染流水线有Direct 3D和OpenGL。
渲染流水线一般可以分为如下阶段:顶点处理(vertex processing)、光栅化(rasterization)、片元处理(fragment processing)和输出合并(output merging)。
顶点处理阶段对存储在顶点缓冲区(vertex buffer)中的各顶点执行各种操作,如坐标系变换等。
光栅化阶段对由顶点构成并变换到裁剪空间(clip space)的多边形进行扫描插值,将这些多边形转换成一系列的片元集合。
片元(fragment)是指一组数据值,这些数据最终用于对颜色缓冲区(color buffer)中的像素(pixel)颜色值、透明值,以及深度缓冲区(depth buffer)中的深度值进行更新。片元处理阶段对各个片元进行操作,确定每个片元的最终颜色值和透明值。输出合并阶段则是对片元与颜色缓冲区中的像素进行比较或合并操作,然后更新像素的颜色值和透明值。
提示: 以上几个阶段对理解渲染和使用Shader很重要,应该熟记。
其中,顶点处理与片元处理两个阶段是可编程的(programmable)。针对这些阶段的、由GPU执行的相关程序称为顶点着色器(vertex shader)和片元着色器(fragment shader)。顶点着色器可针对顶点执行任何转换操作;片元着色器可针对片元用各种方式决定其最终颜色值和透明值。
▲ 渲染流水线的基本阶段和流程
顶点处理阶段和片元处理阶段是可编程控制的;而光栅化阶段和输出合并阶段则由硬件以固定不可编程的方式实现。
1.2 顶点处理阶段
顶点处理阶段是3D流水线渲染的第一个阶段,它将会读取描述三维场景内容的顶点信息并进行处理。
1.2.1 顶点的组织方式
在计算机图形学中,可以使用各种建模方案提供描述三维场景的顶点信息,常用和高效的方式是使用多边形网格(polygon mesh)去组织顶点,组成网格的多边形都需要使用凸多边形,在实践中大都使用三角形网格。
▲ 利用三角形网格组织顶点
当三角形的细分程度越高时,网格就越接近原始表面,处理时间也会随之增加。
描述三角形网格的常见方法有多种,其中一个是列举顶点,即顺序读取3个顶点构成一个三角形。存储顶点的内存区域即顶点缓冲区。
在三角形网格中,一个顶点经常被多个三角形共享。因此,可以给每个顶点都分配一个整数索引值,记录三角形的方式可以从直接记录顶点本身变成记录顶点索引,以减少数据冗余,让每一个顶点在顶点缓冲区中只需要存储一份。
▲ 索引方式的三角形列表,索引缓冲区负责存储三角形用到的顶点编号信息
在实际应用中,除了位置信息外,顶点缓冲区中存储的信息还包含法线(normal)、纹理映射坐标(texture mapping coordinate);如果是用作动画模型的顶点,那么还有骨骼权重(bone weight)等。
1.2.2 坐标系统和顶点法线的确定方式
在3D渲染流水线中,使用最广泛的是笛卡儿坐标系。笛卡儿坐标系可以分为左手坐标系和右手坐标系。
▲ 笛卡儿坐标系
在光照计算中需要使用法线。法线既可以是一个顶点的法线,也可以是一个多边形的法线。在右手坐标系中定义一个三角形的法线朝向,使用右手法则定义,即右手四指围拢,按照组成三角形3个顶点在缓冲区中先后排列顺序围拢四指,此时右手拇指的朝向就是三角形的法线方向。
▲ 确定三角形法线朝向,其3个顶点p1、 p2、 p3在顶点缓冲区中以<p1,p2,p3>的顺序排列。
在给定了顶点排列顺序之后,用向量叉积运算可以计算出法向量的值。连接p1和p2形成边向量v12,连接p1和p3形成边向量v13。利用两个边向量的叉积,可以得到垂直于两个边向量的向量。用向量除以它自己的长度便得到单位化(normalized,又称为规格化)的法向量。
注意:向量的叉积运算是不满足交换律的。如果交换,得出的向量的方向是相反的。
如果把法线朝向方定义为三角形外表面,法线朝向方相反方向定义为内表面,则当把顶点数据从左(右)手导入右(左)手坐标系时,会产生内外表面相反的情况。
与三角形的法线就是该三角形所在平面垂直的向量不同,理论上一个顶点的法线可以是过该点的任意一条射线。一般情况下,某顶点的法线通常通过共享该顶点的三角形法线进行计算。
▲ 顶点的法线n共享该顶点的三角形法线
提示:Unity3D中除观察空间使用右手坐标系外,其他空间均使用左手坐标系。
1.2.3 把顶点从模型空间变换到世界空间
用于创建包含顶点数据的多边形网格的坐标系称为模型坐标系(model coordinate),坐标系所对应的空间称为模型空间。
1. 仿射变换和齐次坐标
世界变换和观察变换由缩放变换(scale transform)、旋转变换(rotation transform)和平移变换(translation transform)这3种变换组合而成。其中,缩放变换和旋转变换称为线性变换(linear transform),线性变换和平移变换统称为仿射变换(affine transform)。投影变换所用到的变换则称为射影变换。
缩放变换矩阵:
scalex、scaley和scalez表示沿着x、y、z轴方向上的缩放系数。如果全部缩放系数都相等,那么该缩放操作称为均匀缩放操作;否则,称为非均匀缩放操作。
如果是均匀缩放操作,那么Unity 3D会定义一个名为UNITY_ASSUME_UNIFORM_SCALING的着色器多样体。着色器代码将会根据此多样体是否定义了执行不同的操作。如果UNITY_ASSUME_UNIFORM_ SCALING未被启用,当把顶点从模型空间变换到世界空间中,或者从世界空间变换到观察空间中时,需要对顶点的法线做一个操作,使得它能正确地变换。
Unity 3D中使用列向量和列矩阵描述顶点信息,所以可以把顶点的坐标值右乘缩放矩阵实现缩放操作:
要定义一个三维旋转操作,需要定义对应的旋转轴。当某向量分别绕坐标系的x、y、z轴旋转θ角度时,分别有以下旋转矩阵Mrx、Mry、Mrz。
如果要对一个位置点进行平移操作,可以让位置点加上一个描述沿着每个坐标值移动多少距离的向量:
也可以使用四维齐次坐标:
2. 世界矩阵及其推导过程
在渲染流水线中,首先是要把分属在不同模型中的所有顶点整合到单一空间中。该单一空间就是世界空间。
定义旋转角度的正负规则:让旋转轴朝向观察者,如果此时的旋转方向为逆时针方向,则表示旋转的角度为负值;若为顺时针方向,则为正值。
如果把顶点的三维坐标齐次化成四维齐次坐标,那么针对此顶点所有的平移、旋转、缩放变换(仿射变换)可以通过矩阵连乘的方式变换。由于Unity 3D的顶点坐标是采用列向量的方式描述,因此对应的矩阵连乘方式是右乘,即坐标列向量写在公式的最右边,各变换矩阵按变换的先后顺序依次从右往左写。
3. 表面法线的变换
如果某三角形网格的变换矩阵为M,即网格上的所有顶点也将使用M进行变换。当M是旋转变换矩阵、平移变换矩阵和均匀缩放矩阵中的一种或者它们的组合时,顶点的法线也可以直接通过乘以M从模型空间变换到世界空间;当M为旋转变换矩阵、平移变换矩阵和非均匀缩放矩阵中的一种或者它们的组合时,则要把顶点的法线从模型空间变换到世界空间,该变换矩阵就必须为M的逆转置矩阵,即(M-1)T。
4. Unity 3D中的模型空间坐标系和世界空间坐标系
Unity 3D在各平台上,顶点的模型坐标系统一使用左手坐标系。
通过使用unity_ObjectToWorld内置变量,可以把顶点从模型空间变换到世界空间,代码如下:
float4 vInWorld = mul(unity_ObjectToWorld,vInModel);
1.2.4 把顶点从世界空间变换到观察空间
对所有的顶点进行世界变换操作完毕后,可以在世界空间内定义摄像机(camera)。当给定摄像机的状态后,则观察空间(view space)也得以确立,且世界空间中的顶点也随之变换到观察空间中。
1.观察空间
通常摄像机需要通过3个参数定义,即Eye、LookAt和Up。Eye指摄像机在世界空间中位置的坐标;LookAt指世界坐标中摄像机所观察位置的坐标;Up则指在世界空间中,近似于(注意,并不是等于)摄像机朝上的方向向量,通常定义为世界坐标系的y轴。
▲ 构造观察空间的方法和步骤
2.观察矩阵及其推导过程
Unity 3D中定义的观察空间也是右手坐标系。
假设上图中的LookAt点和Eye点在世界空间的坐标值是(0,2,10)与(0,3,20),那么在观察空间中,点LookAt则位于-n轴上,在u轴、v轴上的值为0,且LookAt点到Eye点的距离为√101。因此,在观察坐标系下,点LookAt的坐标值为-(0,0,√101)。
▲观察变换矩阵的推导和分解步骤
思路是,让模型和坐标系“锚定”,然后通过平移旋转观察坐标系,使之最终与世界坐标系重合。最终世界空间与观察空间重合时,模型顶点变换后得到的世界坐标值,实质上就是它在观察坐标系下的值。
3.Unity 3D中的观察空间坐标系
在各平台下,Unity 3D的观察坐标系统一使用右手坐标系。
通过使用unity_MatrixV内置变量,可以把顶点从基于左手坐标系的世界空间变换到基于右手坐标的观察空间,代码如下。
float4 vInViewSpace = mul(unity_MatrixV,vInWorldSpace);
1.2.5 把顶点从观察空间变换到裁剪空间
1.视截体
通常摄像机的取景范围(或者称为视野范围)是有限的。在渲染流水线中,通常使用视截体(view frustum)去框定这一取景范围。视截体是一个正棱台(regular prismoid),其两个底面平行且宽高比例相等。使用4个参数加以定义,即fovY、aspect、n和f。
▲图1-13 视截体的定义
视截体之外的物体不可见即也不会被投入渲染流水线渲染。该操作称为视截体剔除操作(view frustum culling),通常由软件完成,成熟的3D渲染引擎都有实现。
2.投影矩阵及其推导过程
通过投影变换(projection transform)可以将正棱台状的视截体转换为一个轴对齐的(axis-aligned)立方体。该轴对齐立方体所框定的空间就是裁剪空间。
▲ 投影变换
如图所示,立方体的x和y的取值范围都是[-1,1],z的取值范围是[-1,0]。
投影线相交于摄像机原点处,通常把该原点称为投影中心点(center of projection)。
在右边的轴对齐立方体中,投影变换使得投影线变为相互平行,这种相互平行的投影线称为通用投影线。
▲图1-15 计算投影矩阵
图1-15定义了一个投影平面,此平面的定义公式为
裁剪空间(轴对齐立方体)是基于右手坐标系的。在顶点处理阶段,投影变换可以视为最后一步操作,随后的顶点将进入硬件光栅化阶段。在光栅化阶段,裁剪空间采用左手坐标系,Unity 3D也遵循该规则,因此需要把右手坐标系的裁剪空间变换到左手坐标系。
式(1-34)和式(1-36)的Mprojection分别是Unity 3D在Direct3D平台和OpenGL平台上的投影矩阵值。
3. 裁剪空间中未做透视除法的顶点坐标的z分量
四维齐次坐标降维成三维笛卡儿坐标的操作,称为透视除法。透视除法是在光栅化阶段进行的。
4. Unity 3D的裁剪空间坐标系
在各平台下,Unity 3D的裁剪空间坐标系统一使用左手坐标系,并且在未经透视除法之前,是一个不等价于三维笛卡儿坐标的四维齐次坐标系。
调用UnityViewToClipPos函数,可以把顶点从观察空间变换到裁剪空间,代码如下:
float4 vInClipSpace = UnityViewToClipPos(
float3(vInViewPos.x,vInViewPos.y,vInViewPos.z));
至此,顶点的变换处理过程已经完成,在现代渲染流水线实作中,这些变换操作通常在顶点着色器中完成。
1.3 光栅化阶段
经顶点处理阶段完成后的顶点,将进入由硬件执行的光栅化阶段。
光栅化阶段包括以下几个子过程:裁剪操作、透视除法(perspective division)、背面剔除(back face culling)操作、视口变换和扫描转换(scan coversion)。目前的主流渲染流水线中,光栅化阶段在硬件电路中实现,不支持可编程操作。
1.3.1 裁剪操作
裁剪操作是在裁剪空间中,对图元(本节以三角形为例)进行剪切操作。
1.3.2 透视除法
通过使用式(1-34)中的投影矩阵Mprojection,把视截体变换为表示裁剪空间的立方体视见体。
经过透视除法的坐标称为标准化设备坐标(normalized device coordinates,NDC)。
1.3.3 背面剔除操作
就是把摄像机不可见的内容排除掉。背面剔除操作,即把背向于摄像机观察方向的多边形消除掉。
判断一个三角形T是正面还是背面,可以通过计算三角形法线向量n与摄像机位置到当前三角形法线连线向量c之间的点积,然后根据点积值与0的大小关系加以判断。点积计算式是n·c=‖n‖‖ c‖cosθ,其中θ定义为向量n和c之间的夹角。如果n和c之间的夹角为锐角,则为正值,表示当前三角形为背面;如果n和c之间的夹角为钝角,则为负值,表示当前三角形为正面;如果恰好为0,表示n和c相互垂直,当前三角形为侧向面。
▲图为在观察空间中做背面剔除操作的原理
Unity 3D ShaderLab语言提供了控制背面剔除操作的指令,代码如下:
//这些代码要放在一个pass块内
Cull Back //表示不渲染背向摄像机的多边形,这也是默认设置
Cull Front //表示不渲染正向摄像机的多边形
Cull Off //表示正向和背向摄像机的多边形都予以渲染
1.3.4 视口变换
视口(viewport)可以视为当前场景所投影的矩形区域。视口定义于当前屏幕空间中,并且不一定非得为全部屏幕,可以为当前进程窗口的部分区域。
屏幕空间采用右手坐标系。通过视口变换,可以把表示裁剪空间的视见立方体转换为三维视口。裁剪空间采用左手坐标系,屏幕空间则采用右手坐标系,且两者xz轴同向,y轴相反。
经过视口变换之后,组成图元的顶点即完全变换到二维屏幕上。接下来进行扫描转换,把顶点插值成片元。
1.3.5 扫描转换
扫描转换过程是光栅化阶段的最后一步,在该过程中定义了图元覆盖的屏幕空间像素位置,对各顶点属性进行插值计算,进而定义了各个像素点对应的片元属性。
1.4 片元处理与输出合并阶段
与顶点类似,经过光栅化阶段生成的片元通常也包含深度值、法线向量、RGB颜色值及一组纹理映射坐标。
1.4.1 纹理操作
在各种各样的纹理中,二维图像纹理是最直观、最简单的一种,即通过粘贴或者环绕方式把图像覆盖在待渲染物体的表面。
1.纹理映射坐标
纹理中最小的一个单元通常称为纹素(texel),此概念用来有效区分颜色缓冲区中的像素图素(image)。每一个纹素均包含了一个唯一地址,即二维纹素阵列的横纵索引。
▲图1-23 采用标准化纹理坐标(u,v)访问不同纹理并且获取到不同的纹素
纹理坐标标准化,即把整数纹素索引规格化到[0, 1]范围内。这些在[0, 1]范围内的坐标通常用(u, v)表示,即纹理映射坐标。
不同平台上的纹理映射坐标采用不同的纹理空间坐标系。它们的坐标系是存在差异的。在OpenGL平台上,纹理坐标系的原点在纹理图的左下角,u轴水平向右,v轴垂直向上;而在Direct3D平台上,纹理坐标系的原点在纹理图的左上角,u轴水平向右,v轴垂直向下。
2.纹理映射坐标与纹素阵列索引
在光栅化阶段,由硬件对屏幕空间内的顶点纹理映射坐标进行插值计算,该阶段对用户是透明而且是不可控的。
1.4.2 输出合并中的深度值操作
当流水线启用了Alpha测试(Alpha Test)或者Alpha混合(Alpha Blend),以及启用了对深度缓冲区进行深度测试(Z Test)操作时,渲染流水线将会对这个返回的片元的颜色值透明值和深度值与当前颜色缓冲区中的像素进行比较或者整合操作。该阶段就是输出合并阶段。目前主流的渲染流水线中,不支持对输出合并阶段的可编程操作,但是会提供一系列的Alpha测试指令、Alpha混合指令和深度测试指令来对片元透明值和深度值进行比较整合操作。
深度缓冲区和颜色缓冲区的分辨率是相同的,并且记录了存储于当前缓冲区中的深度值。当位于(x, y)处的片元从片元程序中返回时,其深度值将与位于深度缓冲区的(x, y)处的深度值进行比较。如果该片元的深度值较小,则它的颜色值、透明值和深度值将分别更新到位于(x, y)处的颜色缓冲区和深度缓冲区中;否则,该片元将视为处于当前可见像素的后方且不可见,因而被渲染流水线丢弃。
实际开发时,尤其是要处理半透明物体的绘制时,需要对图元按距离当前摄像机远近进行排序。
1.4.3 输出合并中的Alpha值操作
当两个表面片元针对某一像素位置进行比较时,一个片元可以完全遮挡住另一个片元。而某些物体的表面可以呈现半透明状态,假设当前片元和颜色缓冲区对应位置的像素相比有着较小的深度值,且该像素和当前片元之间呈现半透明效果。这种情况可以通过在片元颜色值和像素颜色值之间执行颜色混合操作得以实现。该处理过程大多数是采用了片元的Alpha值和像素的Alpha值进行操作,因此名为Alpha混合,但实质上,称其为颜色混合可能更为贴切。
当前大部分3D引擎在渲染半透明图元时,需要在全部不透明物体渲染完毕后,以从离当前摄像机最远到最近的顺序先后依次渲染。因此,半透明物体应执行排序操作。实时3D引擎若要对半透明物体进行排序,通常都是以单个物体模型为粒度。
1.4.4 Unity 3D ShaderLab中的Alpha混合指令及深度测试指令
1.Alpha混合指令
▲表1-1 常用的Alpha混合操作符及混合系数
▲表1-2列出了常用的颜色值透明值混合系数。
下面的代码演示了如何在着色器代码中声明Alpha混合所用到的颜色值透明值混合操作符和混合操作系数。
2.深度测试指令
//这些代码要放在一个pass块内
ZWrite On //表示当满足深度测试条件时,允许把当前片元的深度值写入深度缓冲区的相应位置
ZWrite Off //表示不允许把当前片元的深度值写入深度缓冲区的相应位置,如果正在绘制一个半透明物
//体,应该选中此项
ZTest Less //表示当前片元的深度值小于深度缓冲区对应点的深度值时,可把深度值写入缓冲区
ZTest Greater //表示当前片元的深度值大于深度缓冲区对应点的深度值时,可把深度值写入缓冲区
ZTest LEqual //表示当前片元的深度值小于或等于深度缓冲区对应点的深度值时,可把深度值写入缓冲区
ZTest Gequal //表示当前片元的深度值大于或等于深度缓冲区对应点的深度值时,可把深度值写入缓冲区
ZTest Equal //表示当前片元的深度值等于深度缓冲区对应点的深度值时,可把深度值写入缓冲区
ZTest NotEqual //表示当前片元的深度值不等于深度缓冲区对应点的深度值时,可把深度值写入缓冲区
ZTest Always //表示当前片元的深度值为任何值时都可以把深度值写入缓冲区