Unity Shader 笔记

1 渲染流水线

指的是CPU和GPU根据一系列的顶点数据和纹理等信息,最终转换成人眼可以识别的图像的过程。

1.1 渲染流程

《实时渲染》一书将渲染流程分成3个阶段:应用阶段、几何阶段、光栅化阶段。

1.1.1 应用阶段

这个阶段由CPU负责实现,应用准备好模型、光源等渲染数据,设置好材质、纹理、Shader等渲染状态,然后输出为渲染所需的点、线、三角面等几何信息,即渲染图元,这些渲染图元将被传递给下一阶段–几何阶段。

1.1.2 几何阶段

这个阶段由GPU负责实现,处理绘制的几何相关事情,其中一个重要任务就是把顶点坐标变换到屏幕空间,然后交给光栅器处理。同时输出顶点深度值、着色等信息,传递给下一阶段。

顶点着色器就是运行在几何阶段。

1.1.3 光栅化阶段

这个阶段也是在GPU上运行,使用上个阶段传递的数据来产生屏幕上的像素,并渲染出最终图像。

片元着色器运行在光栅化阶段。

1.2 GPU流水线

GPU流水线就是GPU渲染的过程,包含几何阶段和光栅化阶段。下图展示了GPU流水线的不同阶段实现。

如上图所示,颜色表示了不同阶段的可配置性或可编程性。绿色表示该流水线阶段是完全可编程控制的,黄色表示可配置但不可编程,蓝色表示由GPU固定实现,开发者不可控制。

实线表示该Shader必须由开发者编程实现,虚线表示该Shader是可选的。

1.2.1 顶点着色器

顶点着色器是GPU流水线中几何阶段的第一步,它的输入来自CPU。处理单位是顶点,每个顶点都会调用一次顶点着色器,它们之间没有联系,互相独立,GPU可以并行化处理每个顶点,因此速度很快。

顶点着色器的工作主要是:坐标变换和逐顶点光照(计算顶点颜色)。坐标变换就是对顶点的坐标(位置)进行某种变换,这在顶点动画中非常有用。例如,通过改变顶点位置来模拟水面、布料等。但无论怎样改变,一个最基本的顶点着色器必须完成的工作是,把顶点坐标从模型空间转换到齐次裁剪空间(即透视空间)。

1.2.2 片元

一个片元并不是真正意义上的像素,而是用于计算每个像素最终颜色的状态集合,这些状态包括它的屏幕坐标、深度,以及其它从几何阶段输出的顶点信息,例如法线、纹理坐标等。

1.2.3 片元着色器

用于实现逐片元的着色操作,输出一个或多个颜色值。

2 Unity Shader 基础

在Unity中Shader总是和材质配合使用。

2.1 使用流程

  1. 创建一个材质;
  2. 创建一个Unity Shader,并赋给上一步创建的材质;
  3. 把材质赋给要渲染的对象;
  4. 在材质面板中调整Unity Shader的属性,得到满意效果;

2.2 基本结构

Properties语义块包含了一系列属性,这些属性将会出现在材质面板中。

Unity Shader的基本结构如下所示。

Shader "MyShader" {
    Properties {
        // 所需的各种属性
    }
    SubShader {
        // 真正意义上的Shader代码
    }
    SubShader {
        // 和上一个SubShader类似
    }
}

2.3 Cg/HLSL语义

Unity支持的语义在不同阶段的含义如下

2.3.1 顶点着色器输入时

语义 描述
POSITION 模型空间中的顶点位置,通常是float4类型
NORMAL 顶点法线,通常是float3类型
TANGENT 顶点切线,通常是float4类型
TEXCOORD0 该顶点的纹理坐标,TEXCOORD0表示第一组,通常是float2或float4类型
COLOR 顶点颜色,通常是fixed4或float4类型

2.3.2 从顶点着色器传递数据给片元着色器时

语义 描述
SV_POSITION 裁剪空间中的顶点坐标
COLOR0 用于输出第一组顶点颜色,不是必需
COLOR1 用于输出第一组顶点颜色,不是必需
TEXCOORD0~TEXCOORD7 用于输出纹理坐标,不是必需

2.3.3 片元着色器输出时

语义 描述
SV_Target 输出值将会存储到渲染目标(render target)中

3 基础光照

渲染包括两大部分:一个像素是否可见?这个像素是什么颜色?而颜色很大程度上依赖于光照。光照模型就是用来决定在一个像素上怎样计算光照。

3.1 标准光照模型

标准光照模型只关心直接光照,也就是直接从光源发射出来照射到物体表面后,经过一次反射直接进入摄像机的光线。

基本方法是,把进入到摄像机内的光线分为4个部分,每个部分使用一种方法来计算它的贡献度。这4个部分是。

  • 自发光。描述当给定一个方向时,表面本身会向该方向发射多少辐射量。
  • 高光反射。描述当光线从光源照射到模型表面,会在完全镜面反射方向散射多少辐射量。
  • 漫反射。描述当光线从光源照射到模型表面时,会向每个方向散射多少辐射量。
  • 环境光。描述其它所有的间接光照。

3.1.1 自发光

自发光的计算很简单,只需要在片元着色器输出最后的颜色之前,添加上材质的自发光颜色即可。

3.1.2 环境光

在Shader中,只需要通过Unity的内置变量UNITY_LIGHTMODEL_AMBIENT就可以得到环境光的颜色和强度信息。

3.1.3 漫反射

在漫反射中,视角位置不重要,因为反射完全随机,可以认为在任何反射方向上的分布都一样。但是,入射光线的角度很重要。

漫反射部分的计算公式如下

C_{diffuse} = (C_{light} \cdot M_{diffuse}) max(0, \hat{n} \cdot \hat{l})

其中\hat{n}是表面法线,\hat{l}是指向光源的单位矢量,M_{diffuse}是材质的漫反射颜色,C_{light}是光源颜色。

最终颜色 = 环境光 + 漫反射光。

3.1.4 高光反射

使用Phong模型的高光反射部分的计算公式如下

C_{specular} = (C_{light} \cdot M_{specular}) max(0, \hat{v} \cdot \hat{r}) ^ {M_{glass}}

最终颜色 = 环境光 + 漫反射光 + 高光反射部分。

3.2 逐顶点和逐像素

前面给出了基本光照模型使用的数学公式,那在什么地方计算这些光照模型呢?有两种选择:在顶点着色器中计算光照,被称为逐顶点光照;
在片元着色器中计算光照,被称为逐像素光照。

在逐像素光照中,以每个像素为基础得到它的法线,然后进行光照模型的计算。与之相对的是逐顶点光照,在每个顶点上计算光照,然后在渲染图元内部进行线性插值,最后输出为像素颜色。

由于顶点数量远小于像素数量,因此逐顶点光照的计算量小于逐像素光照。但是逐顶点光照依赖于线性插值,因此有非线性计算时会出现问题,例如出现明显的棱角。

4 基础纹理

纹理映射坐标定义了该顶点在纹理对应的2D坐标,通常用一个二维变量(u, v)来表示,其中u是横向坐标,v是纵向坐标,因此,也被称为UV坐标。它的值通常被归一化到[0, 1]范围内。

4.1 纹理属性

Unity使用tex2D函数对纹理采样。

4.1.1 Wrap Mode

决定了当纹理坐标超过[0, 1]后将如何处理,有两种模式。

  • Repeat。这种模式下,如果纹理坐标超过1,坐标的整数部分将会被舍弃,而直接使用小数部分进行采样。这样的结果是纹理会不断重复。
  • Clamp。这种模式下,如果纹理坐标超过1,那么截取为1;如果小于0,那么截取为0。这样的结果是只会截取到纹理的边界。

4.1.2 Filter Mode

决定了当纹理由于变换而产生拉伸时将会采取哪种滤波模式,影响到纹理放大或缩小时得到的图片质量。有如下3种模式,它们的效果依次提升,但耗费性能也依次增大。

  • Point。采样像素数目只有一个最近邻,因此图像看起来有种像素风格的效果。
  • Bilinear。会找到4个邻近像素,进行线性插值混合后得到最终像素,因此图像看起来像被模糊了。
  • Trilinear。和Bilinear几乎一样,只是还会在多级渐远纹理之间进行混合。如果没有使用多级渐远纹理技术,则结果和Bilinear一样。

一般情况,我们会选择Bilinear滤波模式。

4.2 单张纹理

纹理的一种常见应用是用来代替物体的漫反射颜色。
环境光 = 纹理采样值 * 原环境光
最终颜色 = 环境光 + 漫反射光 + 高光反射部分。

4.3 凹凸映射

纹理的另一种应用就是凹凸映射,通过使用一张纹理来修改模型表面的法线,让模型看起来是“凹凸不平”的。有两种主要的方法。

  • 使用一张高度纹理来模拟表面位移,得到一个修改后的法线值,这种方法被称为高度映射
  • 使用一张法线纹理来直接存储表面法线,这种方法被称为法线映射

4.4 渐变纹理

纹理其实能存储任何表面属性,所以可通过渐变纹理来控制漫反射光照的结果,使模型呈现渐变效果。

漫反射部分 = 渐变纹理采样值 * 原材质颜色
最终颜色 = 环境光 + 漫反射部分 + 高光反射部分。

4.5 遮罩纹理

遮罩允许我们保护某些区域,使它免于修改。简单说,遮罩可以控制某些区域显示,某些区域不显示。甚至还可以控制如何混合纹理,达到更细腻的效果。

一般使用3张纹理,包括主纹理_MainTex、法线纹理_BumpMap和遮罩纹理_SpecularMask,通过采样得到遮罩纹理的纹素值,然后与某种表面属性相乘。这样,当该通道的值为0时,保护表面不受该属性的影响。

环境光 = 主纹理采样值 * 原环境光
高光反射部分 = 原高光反射部分 * 遮罩纹理采样值
最终颜色 = 环境光 + 漫反射部分 + 高光反射部分。

5 透明效果

Unity使用两种方法实现透明效果。一种是透明度测试,不适用于半透明效果。另一种是透明度混合

  • 透明度测试。只要一个片元的透明度不满足条件,那么该片元会被舍弃,否则按照普通的不透明物体来处理,即进行深度测试、深度写入等。所以,这种效果很极端,要么完全透明,要不完全不透明。
  • 透明度混合。它使用片元的透明度作为混合因子,与已经存储在颜色缓冲区的颜色值进行混合,得到新的颜色。这种方法可以得到真正的半透明效果,但是要关闭深度写入,且必须是正确的渲染顺序。

6 更复杂的光照

6.1 渲染路径

渲染路径决定了光照是如何应用在Shader中的。主要有3种:前向渲染路径(Forward Rendering Path)、延迟渲染路径(Deferred Rendering Path)、顶点照明渲染路径(Vertex Lit Rendering Path)。从Unity 5.0开始,顶点照明渲染路径已经被抛弃(但仍然支持使用了它的旧Shader)。

6.1.1 前向渲染路径

前向渲染路径是最常用的一种渲染路径,大致过程如下:

  1. 对片元进行深度测试,如果未通过,说明该片元不可见,直接舍弃;
  2. 如果片元可见,则进行光照计算,并更新帧缓冲。

对于每个逐像素光源,都需要进行上面一次完整的渲染流程。如果一个物体被多个逐像素光源影响,就需要执行多个Pass,每个Pass计算一个光照结果,然后在帧缓冲中把这些结果混合起来得到最终的颜色值。如果场景中有N个物体,每个物体受M个光源影响,那么渲染整个场景一共需要N*M个Pass。

6.1.2 延迟渲染路径

如前所述,每执行一个Pass都需要重新渲染一遍物体,当场景中有大量实时光源时,前向渲染的性能会急速下降,因此提出了延迟渲染。

延迟渲染主要包含两个Pass。在第一个Pass,不进行任何光照计算,仅仅计算哪些片元可见,存储到G缓冲区。然后在第二个Pass中,利用G缓冲区的各个片元信息,进行真正的光照计算。

可以看到,延迟渲染只是使用两个Pass,跟场景中有多少光源没关系,效率不依赖于场景复杂度,因此可提升性能。但也有一些缺点。

  • 不能处理半透明物体
  • 不支持真正的抗锯齿(anti-aliasing)功能
  • 显卡必须支持MRT(Multiple Render Targets)

6.2 阴影

Unity使用Shadow Map技术来实现阴影。它的原理是把摄像机放在与光源重合的位置上,那么该光源的阴影就是摄像机看不到的地方。

Unity会为光源计算它的阴影映射纹理(shadow map),本质上是一张深度图,记录了从光源位置出发、能看到的场景中距离它最近的表面位置(深度信息)。

一个物体接收来自其它物体的阴影,以及它向其他物体投射阴影是两个过程。

  • 接收来自其它物体的阴影,必须在Shader中对阴影映射纹理进行采样,把采样结果和最后光照结果相乘来产生阴影效果。
  • 向其他物体投射阴影,必须把该物体加入到光源的阴影映射纹理的计算中,从而让其他物体在对阴影映射纹理采样时可以得到该物体的相关信息。

7 高级纹理

7.1 立方体纹理

立方体纹理包含6张图像,对应一个立方体的6个面。它最常见的用处是用于环境映射,创建方法有三种:

  1. 直接由一些特殊布局的的纹理创建;
  2. 手动创建一个Cubemap资源,再把6张图赋给它;
  3. 由脚本生成,利用Camera.RenderToCubemap函数实现。

7.1.1 反射

通过入射光线的方向和表面法线方向来计算反射方向,再利用反射方向对立方体纹理采样,即可模拟反射效果。

7.2 渲染纹理

现代GPU允许把整个三维场景渲染到一个中间缓冲中,即渲染目标纹理(Render Target Texture, RTT),而不是传统的帧缓冲或后背缓冲(Back Buffer),与之相关的是多重渲染目标(Multiple Render Target, MRT),即把场景同时渲染到多个渲染目标纹理中,而不再需要为每个渲染目标纹理单独渲染完整的场景。延迟渲染就是多重渲染目标的一个应用。

我们可以对这些渲染纹理编写自定义的Pass,从而实现各种屏幕特效。

7.2.1 镜子效果

使用一个渲染纹理作为输入,并把该纹理在水平方向上翻转后显示到物体上,即可模拟镜子效果。

7.2.2 玻璃效果

首先使用一张法线纹理来修改模型的法线信息,然后使用反射方法,通过一个Cubemap来模拟玻璃的反射。而在模拟折射时,则使用GrabPass获取玻璃后面的屏幕图像,并使用切线空间下的法线对屏幕纹理坐标偏移,最后对屏幕图像采样来模拟近似的折射效果。

8 屏幕后处理特效

是指在渲染完整个场景得到屏幕图像后,再对这个图像进行一系列操作,从而实现各种屏幕特效。

8.1 后处理过程

  1. 首先在摄像机中添加一个用于屏幕后处理的脚本;
  2. 在脚本中实现OnRenderImage函数来获取当前屏幕的渲染纹理;
  3. 调用Graphics.Blit函数使用特定的Shader进行处理,再把返回的渲染纹理显示到屏幕上;

8.2 调整亮度、饱和度和对比度

首先得到原图像的采样结果,然后进行如下调整:

  1. 调整亮度。只需把原颜色乘以指定亮度系数即可;
  2. 调整饱和度。对原颜色的RGB每个分量乘以一个特定系数再相加,得到该像素对应的亮度值,然后用该亮度值创建一个饱和度为0的颜色,再用指定饱和度系数对其和上一步得到的颜色之间进行插值,即可得到期望的饱和度颜色;
  3. 调整对比度。首先创建一个对比度为0的颜色值(各分量均为0.5),再使用指定对比度系数对其和上一步得到的颜色之间进行插值,即可得到最终的颜色值。

8.3 边缘检测

原理是利用一些边缘检测算子对图像进行卷积(convolution)操作。

8.3.1 什么是卷积

卷积操作指的是使用一个卷积核对图像中的每个像素进行一系列操作。卷积核是一个四方形网格结构(例如3*3的方形区域),该区域内每个方格都有一个权重值。

如下图所示,当对某个像素进行卷积时,会把卷积核的中心放置于该像素上,依次计算核中每个元素和其覆盖的图像像素值的乘积并求和,得到的结果就是该位置的新像素值。

8.3.2 边缘如何检测

首先考虑一下,边是如何形成的?如果相邻像素之间存在差别明显的颜色、亮度、纹理等属性,就可以认为它们之间应该有一条边界。这种相邻像素之间的差值用梯度(gradient)来表示,可以知道,边缘处的梯度绝对值会比较大。

8.4 运动模糊

在真实世界中运动模糊非常常见,它可以让物体运动看起来更加真实平滑。

实现运动模糊有多种方法。一种方法是利用一块累计缓存(accumulation buffer)来混合多张连续的图像。当物体快速移动产生多张图像后,取它们之间的平均值作为最后的运动模糊图像。但这种方法对性能消耗很大,因为想要获取多张帧图像意味着要在同一帧里渲染多次场景。另一种应用广泛的方法是创建和使用速度缓存(velocity buffer),这个缓存存储了各个像素当前的运动速度,然后利用该值来决定模糊的方向和大小。

9 非真实感渲染

非真实感渲染(Non-Photorealistic Rendering, NPR)指的是用一些渲染方法,使画面达到和某些特殊绘画风格相似的效果,例如卡通、水彩风格等。

9.1 卡通风格的渲染

卡通风格的画面有一些共同特点,例如物体被黑色线条描边,以及分明的明暗变化等。高光效果也有所不同,往往是一块块分界明显的纯色区域。

实现方法除了光照模型不同外,还需要在物体边缘部分绘制轮廓。

9.2 素描风格的渲染

实现方法是直接使用6张素描纹理。

首先在顶点着色器计算逐顶点的光照,根据光照结果决定6张纹理的混合权重,并传递给片元着色器。然后在片元着色器中根据这些权重来混合6张纹理的采样结果。

10 Unity的渲染优化技术

影响性能的主要是两种计算资源:CPU和GPU。

10.1 CPU优化

  • 使用批处理减少DrawCall数量;
  • 减少复杂的脚本或者物理模拟;

10.2 GPU优化

  1. 减少需要处理的顶点数量。
    • 优化几何体
    • 使用模型的LOD(Level of Detail)技术
    • 使用遮挡剔除(Occlusion Culling)技术
  2. 减少需要处理的片元数量
    • 控制绘制顺序
    • 警惕透明物体
    • 减少实时光照
  3. 减少计算复杂度
    • 使用Shader的LOD(Level of Detail)技术
    • 代码方面的优化

10.3 节省内存带宽

  • 减少纹理大小
  • 合理利用分辨率缩放

11 基于物理的渲染(Physically Based Rendering, PBR)

PBR指的是基于物理光照的着色和渲染技术,可以更真实的表现光与物体的互动。简单来说,就是使用了更复杂的光照计算公式(漫反射和高光),包含间接照明、间接高光、粗糙度、金属度等属性。

11.1 什么是全局光照

指的是模拟光线如何传播,不仅考虑直接光照,还会计算光线被不同物体表面反射所产生的间接光照。

计算间接光照的一个传统方法是使用光线追踪,来追踪场景中每一条重要光线的传播路径,可以得到非常出色的画面效果。但计算非常耗时,不适合用在实时渲染中。

Unity采用实时+预计算相结合的方法来模拟全局光照,在不同情况下搭配使用。

11.2 什么是HDR

HDR是High Dynamic Range的缩写,即高动态范围,与之相对的是低动态范围(Low Dynamic Range, LDR)。动态范围指的是最高和最低亮度之间的比值。

真实世界中,最亮和最暗区域的范围非常大,比值可以超过几万倍。而传统的亮度用一个8位值表示,意味着最多只能有256种亮度,必然会存在精度损失。

HDR使用远高于8位的精度(如32位)来记录亮度信息,因此可以更加精确地反映真实的光照环境。即亮的物体真的非常亮,暗的物体真的非常暗,同时又可以看到两者之间的细节。

HDR的缺点是需要更大的显存空间,渲染速度会变慢,同时一些硬件并不支持HDR。另外一旦使用了HDR,就无法再利用硬件的抗锯齿功能。

欢迎关注微信公众号“楚游香”,获取更多文章和交流。

标签:

发表回复

您的电子邮箱地址不会被公开。