显示 / 隐藏 文章目录 ]

Unity Shader入门精要

上次更新: 2024-03-15 14:44:57

代码基于c#,书籍 Unity Shader入门精要

什么是 OpenGL、DirectX

用于渲染二维或三维图形。可以说,这些接口架起了上层应用程序和底层 GPU 的沟通桥梁。一个应用程序向这些接口发送渲染命令,而这些接口会依次向显卡驱动(Graphics Driver)发送渲染命令,这些显卡驱动是真正知道如何和 GPU 通信的角色,正是它们把 OpenGL 或者 DirectX 的函数调用翻译成了 GPU 能够听懂的语言,同时它们也负责把纹理等数据转换成 GPU 所支持的格式。一个比喻是,显卡驱动就是显卡的操作系统。

shader_7

什么是 HLSL、GLSL、CG

如顶点着色器、片元着色器等。这些着色器的可编程性在于,我们可以使用一种特定的语言来编写程序,就好比我们可以用 C#来写游戏逻辑一样。

着色语言是专门用于编写着色器的,常见的着色语言有

  • DirectX 的 HLSL(High Level Shading Language)

  • OpenGL 的 GLSL(OpenGL Shading Language)

  • NVIDIA 的 CG(C for Graphic)。

HLSL、GLSL、CG 都是“高级(High-Level)”语言,但这种高级是相对于汇编语言来说的,而不是像 C#相对于 C 的高级那样。这些语言会被编译成与机器无关的汇编语言,也被称为中间语言(Intermediate Language, IL)。这些中间语言再交给显卡驱动来翻译成真正的机器语言,即 GPU 可以理解的语言。

GPU 流水线

当 GPU 从 CPU 那里得到渲染命令后,就会进行一系列流水线操作,最终把图元渲染到屏幕上。

shader_1

  • 顶点着色器(Vertex Shader)是完全可编程的,它通常用于实现顶点的空间变换、顶点着色等功能

  • 曲面细分着色器(Tessellation Shader)是一个可选的着色器,它用于细分图元

  • 几何着色器(Geometry Shader)同样是一个可选的着色器,它可以被用于执行逐图元(Per-Primitive)的着色操作,或者被用于产生更多的图元

  • 裁剪(Clipping),这一阶段的目的是将那些不在摄像机视野内的顶点裁剪掉,并剔除某些三角图元的面片。例如,我们可以使用自定义的裁剪平面来配置裁剪区域,也可以通过指令控制裁剪三角图元的正面还是背面。

  • 片元着色器(Fragment Shader),则是完全可编程的,它用于实现逐片元(Per-Fragment)的着色操作

顶点着色器 Vertex Shader

CPU 输入进来的每个顶点都会调用一次顶点着色器。顶点着色器本身不可以创建或者销毁任何顶点,而且无法得到顶点与顶点之间的关系。例如,我们无法得知两个顶点是否属于同一个三角网格。但正是因为这样的相互独立性,GPU 可以利用本身的特性并行化处理每一个顶点,这意味着这一阶段的处理速度会很快。

顶点着色器需要完成的工作主要有:

  • 坐标变换:把顶点坐标转换到齐次裁剪坐标系下,接着通常再由硬件做透视除法后,最终得到归一化的设备坐标(NDC)

shader_2

在 DirectX 中,NDC 的 z 方向取值范围是[0,1],在 OpenGL 环境下是-1.0,DirectX 中是 0.0

  • 逐顶点光照

裁剪

只有在单位立方体的图元才需要被继续处

shader_3

屏幕映射

OpenGL 和 DirectX 之间的差异问题。OpenGL 把屏幕的左下角当成最小的窗口坐标值,而 DirectX 则定义了屏幕的左上角为最小的窗口坐标值

shader_4

光栅阶段

从上一个阶段输出的信息:屏幕坐标系下的顶点位置以及和它们相关的额外信息,如深度值(z 坐标)、法线方向、视角方向等。光栅化阶段有两个最重要的目标:计算每个图元覆盖了哪些像素,以及为这些像素计算它们的颜色。

三角形设置

如果要得到整个三角网格对像素的覆盖情况,我们就必须计算每条边上的像素坐标。为了能够计算边界像素的坐标信息,我们就需要得到三角形边界的表示方式。

三角形遍历()

  1. 扫描变换:三角形遍历(Triangle Traversal)阶段将会检查每个像素是否被一个三角网格所覆盖。如果被覆盖的话,就会生成一个片元(fragment)、

  2. 使用三角网格 3 个顶点的顶点信息对整个覆盖区域的像素进行插值

shader_5

这一步的输出就是得到一个片元序列。需要注意的是,一个片元并不是真正意义上的像素,而是包含了很多状态的集合,这些状态用于计算每个像素的最终颜色。这些状态包括了(但不限于)它的屏幕坐标、深度信息,以及其他从几何阶段输出的顶点信息,例如法线、纹理坐标等。

片元着色器 Fragment Shader

前面的光栅化阶段实际上并不会影响屏幕上每个像素的颜色值,而是会产生一系列的数据信息,用来表述一个三角网格是怎样覆盖每个像素的。而每个片元就负责存储这样一系列数据。真正会对像素产生影响的阶段是下一个流水线阶段——逐片元操作

在 DirectX 中,片元着色器被称为像素着色器(Pixel Shader)但片元着色器是一个更合适的名字,因为此时的片元并不是一个真正意义上的像素。

shader_6

逐片元操作

这一阶段有几个主要任务:

  • 决定每个片元的可见性。这涉及了很多测试工作,例如深度测试、模板测试等。

  • 如果一个片元通过了所有的测试,就需要把这个片元的颜色值和已经存储在颜色缓冲区中的颜色进行合并,或者说是混合。

!! 对于不透明物体,开发者可以关闭混合(Blend)操作。这样片元着色器计算得到的颜色值就会直接覆盖掉颜色缓冲区中的像素值。但对于半透明物体,我们就需要使用混合操作来让这个物体看起来是透明的。

Draw Call

CPU 调用图像编程接口,如 OpenGL 中的 glDrawElements 命令或者 DirectX 中的 DrawIndexedPrimitive命令,以命令 GPU 进行渲染的操作。

CPU 和 GPU 是如何实现并行工作的?

如果没有流水线化,那么 CPU 需要等到 GPU 完成上一个渲染任务才能再次发送渲染命令。但这种方法显然会造成效率低下。解决方法就是使用一个命令缓冲区(Command Buffer)。

由 CPU 向其中添加命令,而由 GPU 从中读取命令,添加和读取的过程是互相独立的。

为什么 Draw Call 多了会影响帧率?

每一个复制动作需要很多额外的操作,例如分配内存、创建各种元数据等。如你所见,这些操作将造成很多额外的性能开销,如果我们复制了很多小文件,那么这个开销将会很大

如何减少 Draw Call?

尽管减少 Draw Call 的方法有很多,但我们这里仅讨论使用批处理(Batching)的方法。我们讲过,提交大量很小的 Draw Call 会造成 CPU 的性能瓶颈,即 CPU 把时间都花费在准备 Draw Call 的工作上了。那么,一个很显然的优化想法就是把很多小的 DrawCall 合并成一个大的 Draw Call,这就是批处理的思想。

在游戏开发过程中,为了减少 Draw Call 的开销,有两点需要注意。

  • 避免使用大量很小的网格。当不可避免地需要使用很小的网格结构时,考虑是否可以合并它们。

  • 避免使用过多的材质。尽量在不同的网格之间共用同一个材质。

你明白什么是 Shader

  • GPU 流水线上一些可高度编程的阶段,而由着色器编译出来的最终代码是会在 GPU 上运行的(对于固定管线的渲染来说,着色器有时等同于一些特定的渲染设置)

  • 有一些特定类型的着色器,如顶点着色器、片元着色器等

  • 依靠着色器我们可以控制流水线中的渲染细节,例如用顶点着色器来进行顶点变换以及传递数据,用片元着色器来进行逐像素的渲染。

Unity Shader

!! Unity Shader ! = 真正的 Shader

Unity Shader 实际上指的就是一个 ShaderLab 文件——硬盘上以.shader 作为文件后缀的一种文件,提供了一种让开发者同时控制渲染流水线中多个阶段的一种方式,不仅仅是提供 Shader 代码。

作为开发者而言,我们绝大部分时候只需要和 Unity Shader 打交道,而不需要关心渲染引擎底层的实现细节

Unity 编辑器会把这些 CG 片段编译成低级语言,如汇编语言等。通常,Unity 会自动把这些 CG 片段编译到所有相关平台(这里的平台是指不同的渲染平台,例如 Direct3D 9、OpenGL、Direct3D 11、OpenGL ES 等)上

基础

在没有 Unity 这类编辑器的情况下,如果我们想要对某个模型设置渲染状态,可能需要类似下面的代码:

上述伪代码仅仅是简化后的版本, 当渲染的模型数目、需要调整的着色器属性不断增多时,上述过程将变得更加复杂和冗长。

而且,当涉及透明物体等多物体的渲染时,如果没有编辑器的帮助,我们要非常小心如渲染顺序等问题。

// 初始化渲染设置
void  Initialization()  {
    // 从硬盘上加载顶点着色器的代码
    string  vertexShaderCode  =  LoadShaderFromFile(VertexShader.shader);
    // 从硬盘上加载片元着色器的代码
    string  fragmentShaderCode  =  LoadShaderFromFile(FragmentShader.shader);
    // 把顶点着色器加载到GPU中
    LoadVertexShaderFromString(vertexShaderCode);
    // 把片元着色器加载到GPU中
    LoadFragmentShaderFromString(fragmentShaderCode);
    // 设置名为"vertexPosition"的属性的输入,即模型顶点坐标
    SetVertexShaderProperty("vertexPosition",  vertices);
    // 设置名为"MainTex"的属性的输入,someTexture是某张已加载的纹理
    SetVertexShaderProperty("MainTex",  someTexture);
    // 设置名为"MVP"的属性的输入,MVP是之前由开发者计算好的变换矩阵
    SetVertexShaderProperty("MVP",  MVP);
    // 关闭混合
    Disable(Blend);
    // 设置深度测试
    Enable(ZText);
    SetZTestFunction(LessOrEqual);
    // 其他设置}
// 每一帧迚行渲染
void  OnRendering()  {
    // 调用渲染命令
    DrawCall();
    // 当涉及多种渲染设置时,我们可能还需要在这里改变各种渲染设置
    ...
}

VertexShader.shader:

// 输入:顶点位置、纹理、MVP变换矩阵
in  float3  vertexPosition;
in  sampler2D  MainTex;
in  Matrix4x4  MVP;
// 输出:顶点经过MVP变换后的位置
out  float4  position;
void  main()  {
    // 使用MVP对模型顶点坐标迚行变换
    position  =  MVP  *  vertexPosition;
}

FragmentShader.shader:

// 输入:VertexShader输出的position、经过光栅化程序插值后的该片元对应的position
in  float4  position;
// 输出:该片元的颜色值
out  float4  fragColor;
void  main()  {
    // 将片元颜色设为白色
    fragColor  =  float4(1.0,  1.0,  1.0,  1.0);
}

材质和 Unity Shader

总体来说,在 Unity 中我们需要配合使用材质(Material)和 Unity Shader 才能达到需要的效果。一个最常见的流程是:

  • 创建一个材质

  • 创建一个 Unity Shader,并把它赋给上一步中创建的材质

  • 把材质赋给要渲染的对象

  • 在材质面板中调整 Unity Shader 的属性,如使用的纹理、漫反射系数

Unity 表面着色器

表面着色器(Surface Shader)是 Unity 自己创造的一种着色器代码类型。它需要的代码量很少,Unity 在背后做了很多工作,但渲染的代价比较大。它在本质上和下面要讲到的顶点/片元着色器是一样的。也就是说,当给 Unity 提供一个表面着色器的时候,它在背后仍旧把它转换成对应的顶点/片元着色器。我们可以理解成,表面着色器是 Unity 对顶点/片元着色器的更高一层的抽象。它存在的价值在于,Unity 为我们处理了很多光照细节,使得我们不需要再操心这些“烦人的事情”。

Shader  "Custom/Simple  Surface  Shader"  {
    SubShader  {
      Tags  {  "RenderType"  =  "Opaque"  }
      CGPROGRAM
      #pragma  surface  surf  Lambert
      struct  Input  {
          float4  color  :  COLOR;
      };
      void  surf  (Input  IN,  inout  SurfaceOutput  o)  {
          o.Albedo  =  1;
      }
      ENDCG
    }
    Fallback  "Diffuse"
}

顶点/片元着色器

Shader  "Custom/Simple  VertexFragment  Shader"  {
    SubShader  {
      Pass  {
          CGPROGRAM
          #pragma  vertex  vert
          #pragma  fragment  frag
          float4  vert(float4  v  :  POSITION)  :  SV_POSITION  {
              return  mul  (UNITY_MATRIX_MVP,  v);
          }
          fixed4  frag()  :  SV_Target  {
              return  fixed4(1.0,0.0,0.0,1.0);
          }
          ENDCG
      }
    }
}

坐标系

Unity 使用的是左手坐标系

左右手

shader_8

shader_9

向量(矢量)

矢量的模是一个标量,可以理解为是矢量在空间中的长度。它的表示符号通常是在矢量两旁分别加上一条垂直线

shader_10

单位矢量

在很多情况下,我们只关心矢量的方向而不是模。例如,在计算光照模型时,我们往往需要得到顶点的法线方向和光源方向,此时我们不关心这些矢量有多长。在这些情况下,我们就需要计算单位矢量(unit vector)

单位矢量指的是那些模为 1 的矢量。单位矢量也被称为被归一化的矢量(normalized vector)。对任何给定的非零矢量,把它转换成单位矢量的过程就被称为归一化

shader_11

点积

一个向量在另一个向量方向上投影的长度,是一个标量

公式:a·b = |a||b|cosθ

向量积 ∧ ×

向量积,数学中又称外积、叉积,物理中称矢积、叉乘

几何意义,一个和已有两个向量都垂直的向量,法向量

shader_16

向量 a 和向量 b:

shader_12

叉乘公式为:

shader_13

其中:

shader_14

根据 i、j、k 间关系,有:

shader_15

例如

(1,2,3)×(-2,-1,4) = (2 × 4 - 3 × -1, 3 × -2 - 1 × 4, 1 × -1 - 2 × -2) = (11,10,3)

矩阵变换 Vec4

由于 3×3 矩阵不能表示平移操作,我们就把其扩展到了 4×4 的矩阵

平移矩阵

点的 x、y、z 分量分别增加了一个位置偏移。在 3D 中的可视化效果是,把点(x, y, z)在空间中平移了(tx, ty, tz)个单位

shader_17

缩放矩阵

旋转矩阵

旋转操作需要指定一个旋转轴,这个旋转轴不一定是空间中的坐标轴,但本节所讲的旋转就是指绕着空间中的 x 轴、y 轴或 z 轴进行旋转

如果我们需要把点绕着 x 轴旋转 θ 度

shader_18

绕 y 轴的旋转

shader_19

绕 z 轴的旋转

shader_20

复合变换

先进行大小为(2, 2, 2)的缩放,再绕 y 轴旋转 30°,最后向 z 轴平移 4 个单位。

矩阵乘法注意顺序

shader_21

?? 如果我们需要同时绕着 3 个轴进行旋转,是先绕 x 轴、再绕 y 轴最后绕 z 轴旋转还是按其他的旋转顺序呢?

根据坐标系,需要调整轴的顺序

shader_22

法线变换

在游戏中,模型的一个顶点往往会携带额外的信息,而顶点法线就是其中一种信息。当我们变换一个模型的时候,不仅需要变换它的顶点,还需要变换顶点法线,以便在后续处理(如片元着色器)中计算光照等。

坐标空间

事实上,在我们的生活中,我们也总是使用不同的坐标空间来交流。现在正在读这本书的你,很可能正坐在办公室或书房中。如果问你:“办公室的饮水机在哪里?”你大概会回答:“在办公室门的左方 3 米处。”这里,你很自然地使用了以门为原点的坐标空间。

要想定义一个坐标空间,必须指明其原点位置和 3 个坐标轴的方向。而这些数值实际上是相对于另一个坐标空间的(读者需要记住,所有的都是相对的)。也就是说,坐标空间会形成一个层次结构——每个坐标空间都是另一个坐标空间的子空间,反过来说,每个空间都有一个父(parent)坐标空间。对坐标空间的变换实际上就是在父空间和子空间之间对点和矢量进行变换。

模型空间

每个模型都有自己独立的坐标空间,当它移动或旋转的时候,模型空间也会跟着它移动和旋转。

Unity 在模型空间中使用的是左手坐标系,因此在模型空间中,+x 轴、+y 轴、+z 轴分别对应的是模型的右、上和前向。

shader_24

世界空间

世界空间(world space)是一个特殊的坐标系,因为它建立了我们所关心的最大的空间。一些读者可能会指出,空间可以是无限大的,怎么会有“最大”这一说呢?这里说的最大指的是一个宏观的概念,也就是说它是我们所关心的最外层的坐标空间。

摄像机空间

摄像机决定了我们渲染游戏所使用的视角。在观察空间中,摄像机位于原点

Unity 中观察空间的坐标轴选择是:+x 轴指向右方,+y 轴指向上方,而+z 轴指向的是摄像机的后方

Q:模型空间和世界空间中+z 轴指的都是物体的前方,为什么这里不一样了呢?

A:Unity 在模型空间和世界空间中选用的都是左手坐标系,而在观察空间中使用的是右手坐标系。这是符合 OpenGL 传统的,在这样的观察空间中,摄像机的正前方指向的是-z 轴方向。

裁剪空间

用于变换的矩阵叫做裁剪矩阵(clip matrix),也被称为投影矩阵(projection matrix)。位于这块空间内部的图元将会被保留,否则他剔除。由视锥体(view frustum)来决定。

shader_26

视锥体:指的是空间中的一块区域,这块区域决定了摄像机可以看到的空间。视锥体由六个平面包围而成,这些平面也被称为裁剪平面(clip planes)。视锥体有两种类型,这涉及两种投影类型:

  • 正交投影(orthographic projection):所有的网格大小都一样,而且平行线会一直保持平行
  • 透视投影(perspective projection):地板上的平行线并不会保持平行,离摄像机越近网格越大,离摄像机越远网格越小。

shader_25

追求真实感的 3D 游戏中我们往往会使用透视投影,而在一些 2D 游戏或渲染小地图等其他 HUD 元素时,我们会使用正交投影

透视摄像机视锥体模型

在 Unity 中,一个摄像机的横纵比由 Game 视图的横纵比和 Viewport Rect 中的 WH 属性共同决定(实际上,Unity 允许我们在脚本里通过 Camera.aspect 进行更改,但这里不做讨论)

shader_27

正交摄像机视锥体模型

通过 Camera 组件的 Size 属性来改变视锥体竖直方向上高度的一半,而 Clipping Planes 中的 Near 和 Far 参数可以控制视锥体的近裁剪平面和远裁剪平面距离摄像机的远近。

shader_28

屏幕空间

在 Unity 中,从裁剪空间到屏幕空间的转换是由 Unity 帮我们完成的。

屏幕空间是一个二维空间,因此,我们必须把顶点从裁剪空间投影到屏幕空间中,来生成对应的 2D 坐标。这个过程可以理解成有两个步骤:

  1. 标准齐次除法(透视除法):用齐次坐标系的 w 分量去除以 x、y、z 分量,在 OpenGL 中,得到的坐标叫做归一化的设备坐标(Normalized Device Coordinates, NDC)

经过透视投影变换后的裁剪空间,经过齐次除法后会变换到一个立方体内。按照 OpenGL 的传统,这个立方体的 x、y、z 分量的范围都是[-1, 1]。但在 DirectX 这样的 API 中,z 分量的范围会是[0, 1]。而 Unity 选择了 OpenGL 这样的齐次裁剪空间,如图所示

shader_29

而对于正交投影来说,它的裁剪空间实际已经是一个立方体了,而且由于经过正交投影矩阵变换后的顶点的 w 分量是 1,因此齐次除法并不会对顶点的 x、y、z 坐标产生影响,如图所示

shader_30

  1. 映射输出窗口的对应像素坐标

Unity 内置变换矩阵

变量名 描述
UNITY MATRIX MVP 当前的模型·观察·投影矩阵,用于将顶点/方向矢量从模型空间变换到裁剪空间
UNITY MATRIX MV 当前的模型·观察矩阵,用于将顶点/方向矢量从模型空间变换到观察空间
UNITY MATRIX V 当前的观察矩阵,用于将顶点/方向矢量从世界空间变换到观察空间
UNITY MATRIX P 当前的投影矩阵,用于将顶点/方向矢量从观察空间变换到裁剪空间
UNITY MATRIX VP 当前的观察投影矩阵,用于将顶点/方向矢量从世界空间变换到裁剪空间
UNITY MATRIX T MV UNITYMATRIX MV 的转置矩阵
UNITY MATRIX IT MV UNITYMATRIX MV 的逆转置矩阵,用于将法线从模型空间变换到观察空间,也可用于得到 UNITYMATRIXMV 的逆矩阵
Object2World 当前的模型矩阵,用于将顶点/方向矢量从模型空间变换到世界空间
World2Object Object2World 的逆矩阵,用于将顶点/方向矢量从世界空间变换到模型空间

重拾纯粹的写作