如何快速成长为图形学工程师


本文来自作者 姜雪伟  GitChat 上分享 「如何快速成长为图形学工程师」,阅读原文查看交流实录。

文末高能

编辑 | 哈比

目前 IT 市场出现了各路诸侯争霸局面,从大的方向说分为三类:PC 端、移动端、VR/AR,从细分领域来说有 MMO 端游、单机端游、MMO 移动手游、单机手游、VR/AR、PC 端页游、移动端页游等等。

随着硬件的提升,玩家对产品的品质要求越来越高,想提升品质就需要 GPU 渲染,换句话说就是离不开图形学渲染,涉及到的图形渲染库有 DX、OpenGL、OpenGLES、WebGL。

当然实现渲染技术就需要对 GPU 编程,也就是我们通常说的 Shader 编程。

另外,IT 市场需求也越来越大,水涨船高,薪资也比普通的程序员高很多,通过招聘网站就可以看出,目前这方面的技术人员太少了,很多客户端程序员或者独立游戏开发者也想从事图形学渲染,但是又感觉自己无处下手,不知道从何入手。

很多人都感觉图形学高深莫测,有种恐惧感。

本篇课程就为了帮助你快速入手,快速掌握图形学编程的一些知识点和一些编程技巧,这样可以在比较短的时间内成长为图形学工程师,这也需要开发者自己的努力。

市场上成熟的引擎主要是 Unity、UE4,二者都提供了强大的渲染能力,作为新手如何才能快速的掌握图形学编程呢,下面我们从几个方面来分享:

一、图像处理

Shader 编程其实就是对图像进行编程,常见的图像格式有:jpg、png、tga、tga、bmp 等等。

作为图形学开发者首要的事情是搞清楚它们的存储格式,每种图像格式它包括很多信息,当然主要还是颜色的存储:rgb 或者说 rgba,另外图像的存储是按照矩阵的方式,如下图所示:

如果有 A 通道就表明这个图像可以有透明效果,R、G、B 每个分量一般是用一个字节(8 位)来表示,所以图(1)中每个像素大小就是3*8=24位图,而图(2)中每个像素大小是4*8=32位。

图像是二维数据,数据在内存中只能一维存储,二维转一维有不同的对应方式,比较常见的只有两种方式:按像素 “行排列” 从上往下或者从下往上。

不同图形库中每个像素点中 RGBA 的排序顺序可能不一样,上面说过像素一般会有 RGB 或 RGBA 四个分量,那么在内存中 RGB 的排列就多种情况,跟排列组合类似,不过一般只会有 RGB、BGR、RGBA、RGBA、BGRA 这几种排列据, 绝大多数图形库或环境是 BGR/BGRA 排列。

如果将图像原始格式直接存储到文件中将会非常大,比如一个5000*5000 24 位图,所占文件大小为5000*5000*3字节 =71.5MB, 其大小非常可观。

如果用 zip 或 rar 之类的通用算法来压缩像素数据,得到的压缩比例通常不会太高,因为这些压缩算法没有针对图像数据结构进行特殊处理。

于是就有了 jpeg、png 等格式,同样是图像压缩算法 jpeg 和 png 也有不同的适用场景,给读者看看图像在内存中的存储,如下图所示:

jpeg、png 文件之于图像,就相当于 zip、rar 格式之于普通文件(用 zip、rar 格式对普通文件进行压缩)。

这个跟我们的 Unity 打包 Assetbundle 与 zip 类似,二者采用相同的压缩方式。另外 bmp 是无压缩的图像格式,在这里以 Bmp 为例,介绍一下 Bmp 格式的图片存储格式。

bmp 格式没有压缩像素格式,存储在文件中时先有文件头、再图像头、后面就都是像素数据了,上下颠倒存储。

用 windows 自带的 mspaint 工具保存 bmp 格式时,可以发现有四种 bmp 可供选择:

  • 单色: 一个像素只占一位,要么是 0,要么是 1,所以只能存储黑白信息;

  • 16 色位图: 一个像素 4 位,有 16 种颜色可选;

  • 256 色位图: 一个像素 8 位,有 256 种颜色可选;

  • 24 位位图: 就是图 (1) 所示的位图,颜色可有 2^24 种可选,对于人眼来说完全足够了。

这里为了简单起见,只详细讨论最常见的 24 位图的 bmp 格式。

现在来看其文件头和图片格式头的结构:

在这里为了能让读者更好的理解图像的读取方式,在此把图像处理的文件头和图片头代码展示一下,网上资源很多:

   //bmp 文件头    typedef struct tagBITMAPFILEHEADER {        unsigned short bfType;      // 19778,必须是 BM 字符串,对应的十六进制为 0x4d42, 十进制为 19778        unsigned int bfSize;        // 文件大小        unsigned short bfReserved1; // 0        unsigned short bfReserved2; // 0        unsigned int bfOffBits;     // 从文件头到像素数据的偏移,也就是这两个结构体的大小之和    } BITMAPFILEHEADER; //bmp 图像头 typedef struct tagBITMAPINFOHEADER {    unsigned int biSize;        // 此结构体的大小    int biWidth;                // 图像的宽    int biHeight;               // 图像的高    unsigned short biPlanes;    // 1    unsigned short biBitCount;  // 24    unsigned int biCompression; // 0    unsigned int biSizeImage;   // 像素数据所占大小 , 这个值应该等于上面文件头结构中 bfSize-bfOffBits    int biXPelsPerMeter;        // 0    int biYPelsPerMeter;        // 0    unsigned int biClrUsed;     // 0    unsigned int biClrImportant;// 0 } BITMAPINFOHEADER;

Bmp 结构体,作为读者了解一下即可,知道是咋回事?另外 png 是一种无损压缩格式, 压缩大概是用行程编码算法,png 可以有透明效果。

png 比较适合适量图,几何图。 比如本文中出现的这些图都是用 png 保存。

通过对图像格式的了解,可以帮助大家揭开一个谜团就是说,无论哪种压缩格式的图片,加载到内存中后,它们都会被解压展开,这也是为什么我们的图片大小几十 K,加载到内存后是几 M 的原因。

总结

之所以给读者介绍关于图像的结构和加载方式,是因为我们对 GPU 编程核心就是对图像的处理,只有掌握了它们,我们才可以根据策划的需求或者是美术的需求做出各种渲染效果,比如在材质中剔除黑色,进行反射,折射,以及高光、法线等的渲染。

即使是后处理渲染又称为滤镜的渲染也是对图片像素的处理,与材质渲染不同的是它是对整个场景的渲染,因为游戏运行也是通过一帧一帧渲染的图片播放的,后处理就是对这些图片进行再渲染。

常用的后出比如:Bloom,Blur,HDR,PSSM 等等。所以关于图片的存储结构大家一定要掌握,这样你在学习 Shader 编程时理解的就更深入了。

二、渲染流程

不论是 Unity 引擎还是 UE4 引擎,他们都有自己的渲染流程,它们都是从固定流水线的基础上发展起来的可编程流水线。

我们就先从固定流水线讲起,作为图形学开发是必须要掌握的,因为固定流水线是图形学渲染的基础,它们的核心是各个空间之间的矩阵变换。

这个我们在 Shader 编程时经常遇到,比如我们经常使用的UNITY_MATRIX_MVP。其实,它就是将固定流水线中的矩阵运算转移到了 GPU 中进行了。

游戏开发早期,在 3D 游戏开发的初级阶段,显卡功能还没有现在这么强大,3D 游戏开发都是采用的固定流水线,也可以说固定流水线是 3D 游戏引擎开发的最基本的底层核心。

现在引擎开发采用的可编程流水线也是在固定流水线的基础上发展起来的,有人可能会问,3D 固定流水线的作用是什么?

通俗的讲,固定流水线的原理就是将 3D 图形转换成屏幕上的 2D 图像显示的过程,在此过程中都是通过 CPU 处理的,以前那些比较老的 3D 游戏都是按照这个原理制作的。

实现固定流水线有几个步骤?

技术来源于生活,这句话是真理,我就用现实生活中的案例给大家介绍一下固定流水线。平时我们经常用摄像机拍照片,比如我们要拍一个人物木偶,人物木偶首先要做出来,它是在工厂通过工人的机器制做出来的。

木偶在出厂前对于外界是看不到的,工厂制作完成后,将其拿到商场里面摆出来才能看到,然后我们用摄像机拍照木偶,因为摄像机有视角和远近距离,视角外的物体拍不到的,距离远的会做模糊处理。

对准人物木偶让其在摄像机的正中位置,可以看到在摄像机的镜头上一个人物木偶就出现了,人物木偶的颜色也在镜头上显示,这整个过程简单一句话概括就是一个 3D 图形在 2D 屏幕上的成像。

说这些的主要目的是为了让大家能够用自己的语言表述固定流水线。

以前在公司招聘 3D 程序员时,固定流水线是必须要问的问题,面试的大部分人都回答不上或者回答的不完整,靠的是死记硬背,没有真正领会其精髓。

流水线前加了两个字 “固定” 就是告诉大家它是按照固定的流程实现的。将上面所说的流程转化成程序语言就是固定流水线。固定流水线的流程如下图所示:

物理空间就是我们说的模型自身的空间,比如美术制作的序列帧动画或者是 3D 模型,因为这些制作的序列帧动画或者模型也有自己的朝向,大小,这些都是它们自己拥有的与外界无关,这就是物理空间也称为局部空间。

将这些美术制作好的对象放置到游戏编辑器中比如 Unity 编辑器,编辑器所在的空间就是世界空间,比如,我们可以在编辑器中对模型进行 Transfrom 组件中的 position、scale、Rotation 进行设置,这些设置就是在世界空间中完成的。

它是相对于世界中的物体进行设置的,比如我们经常使用的 Transform.position 这个就是设置的世界空间的位置,而如果使用 Transform.localposition 就是设置的物体局部空间的位置,这种一般应用到物体的子孩子进行设置。

比如如果我们要在 Game 视图中看到场景,我们就需要设置 Camera 相机,这样我们才能看到我们在场景中摆放的物体,这个就是可视空间,如下图所示:

当然并不是我们所有摆放的物体都能看到,有些是看不到的。

看不到的物体就被相机裁剪掉了,这会涉及到对物体进行矩阵投影裁剪变换,因为我们要裁剪掉不再视口中的物体,我们需要将其投影变换,以及做遮挡剔除就是设置物体的前后关系。

如下图所示:

最后将其相机中的物体在屏幕上显示出来,效果如下图所示:

各个空间之间的变换是通过矩阵变换也就是物体与矩阵相乘得到的,3D 中的物体是由很多点组成的。

这些点是三维的,在场景中做变换就是对 3D 物体中的点做矩阵运算,但是开发者在操作时为什么没有用到矩阵变换,这是因为引擎底层已经为我们封装好了,我们不需要再进行矩阵变换而只需要对其进行传值操作。

为了方便读者理解,现把 Unity 引擎底层关于矩阵计算的部分代码给读者展示如下,希望帮助读者理解关于矩阵的换算:

   TransformType Transform::CalculateTransformMatrix (Matrix4x4f& transform) const {    //@TODO: Does this give any performance gain??    Prefetch(m_CachedTransformMatrix.GetPtr());    if (m_HasCachedTransformMatrix)    {        CopyMatrix(m_CachedTransformMatrix.GetPtr(), transform.GetPtr());        return (TransformType)m_CachedTransformType;    }    const Transform* transforms[32];    int transformCount = 1;    TransformType type = (TransformType)0;    Matrix4x4f temp;    {        // collect all transform that need CachedTransformMatrix update        transforms[0] = this;        Transform* parent = NULL;        for (parent = GetParent(); parent != NULL && !parent->m_HasCachedTransformMatrix; parent = parent->GetParent())        {            transforms[transformCount++] = parent;            // reached maximum of transforms that we can calculate - fallback to old method            if (transformCount == 31)            {                parent = parent->GetParent();                if (parent)                {                    type = parent->**CalculateTransformMatrixIterative**(temp);                    Assert(parent->m_HasCachedTransformMatrix);                }                break;            }        }        // storing parent of last transform (can be null), the transform itself won't be updated        transforms[transformCount] = parent;        Assert(transformCount <= 31);    }            // iterate transforms from lowest parent    for (int i = transformCount - 1; i >= 0; --i)    {        const Transform* t = transforms[i];        const Transform* parent = transforms[i + 1];        if (parent)        {            Assert(parent->m_HasCachedTransformMatrix);            // Build the local transform into temp            type |= t->CalculateLocalTransformMatrix(temp);                            type |= (TransformType)parent->m_CachedTransformType;            **MultiplyMatrices4x4**(&parent->m_CachedTransformMatrix, &temp, &t->m_CachedTransformMatrix);        }        else        {            // Build the local transform into m_CachedTransformMatrix            type |= t->CalculateLocalTransformMatrix(t->m_CachedTransformMatrix);        }        // store cached transform        t->m_CachedTransformType = UpdateTransformType(type, t);        t->m_HasCachedTransformMatrix = true;    }    Assert(m_HasCachedTransformMatrix);    CopyMatrix(m_CachedTransformMatrix.GetPtr(), transform.GetPtr());    return (TransformType)m_CachedTransformType; }

以上是引擎底层关于矩阵的运算实现,下面再给读者介绍关于视口变换的案例。

我们在自己实现项目时遇到的问题就是使用下面的处理方式把问题解决了,我们的需求是将相机获取到图片在屏幕上某个位置显示出来。

这就要实现从局部坐标到世界坐标,再到屏幕坐标的换算,实质上就是对图片的像素进行具体转换操作:

再解释一下,上图中 clipSpace 是相机裁剪完成后,读者如果使用过 Unity3D 引擎知道,它的视口大小是 0,0,1,1,这个是被标准化处理过了,也就是图中的 Normalized Device Space。

但是屏幕上的坐标数值不是用 0,1 表示的,也就是图中的 Window Space,我们知道屏幕的大小尺寸都是用像素表示的,比如: 640 480,720 640,1280 * 720 等,这些像素与 0,1 之间关系是一一对应的,对应公式如下所示:

公式中的参数 (xw,yw) 是屏幕坐标,(x, y, width, height) 是传入的参数,(xnd, ynd) 是投影之后经归一化之后的点,这样我们就可以计算出屏幕上的点坐标。

如果读者对矩阵变换不理解可以查看《线性代数》和《3D 数学基础:图形与游戏开发》这两本书。

费这么多笔墨给读者介绍固定流水线以及矩阵变换,主要是为我们下面介绍的可编程流水线做铺垫。

可编程流水线主要是针对 GPU 编程的,换句话说就是将固定流水线的矩阵变换放到 GPU 中进行计算,这样可以彻底解放 CPU 用于处理其他事情,提升效率。

这就涉及到 GPU 编程了,GPU 编程语言目前有 3 种主流语言:
–基于 OpenGL 的 GLSL(OpenGLShading Language,也称为 GLslang)
–基于 Direct3D 的 HLSL(High Level ShadingLanguage)语言,
–NVIDIA 公司的 Cg (C for Graphic)语言。

跨平台的 Shader 编程语言是 GLSL 和 CG,二者的语法跟 C 语言很类似,可编程流水线的执行流程图如下:

从上图可以看出在 GPU 中——图中黄色的部分,主要负责顶点坐标变换、光照、裁剪、投影以及屏幕映射,该阶段基于 GPU 进行运算。

在该阶段的末端得到了经过变换和投影之后的顶点坐标、颜色、以及纹理坐标。

当然 GPU 并不是只是简单的执行这些,因为 GPU 是多线程的它可以做很多的工作,GPU 比较擅长做的就是关于矩阵的运算。

说到 GPU 编程,不得不说顶点着色器和片段着色器,这个也是开发者要重点掌握的,再看看顶点着色器和片段着色器在 GPU 中的执行流程,如下图所示:

上图主要是实现了顶点着色程序从 GPU 前端模块(寄存器)中提取图元信息(顶点位置、法向量、纹理坐标等),并完成顶点坐标空间转换、法向量空间转换、光照计算等操作,最后将计算好的数据传送到指定寄存器中。

这就是说 GPU 也有自己的寄存器,我们在 Shader 中声明的变量它是存储在 GPU 的寄存器中。

片断着色程序从寄存器中获取需要的数据,通常为 “纹理坐标、光照信息等”,并根据这些信息以及从应用程序传递的纹理信息(如果有的话)进行每个片断的颜色计算。

这个就涉及到图片的像素计算了,也是开发者要掌握的。最后将处理后的数据送光栅操作模块完成。

另外,我们自己所写的 Shader,程序是如何使用的?换句话说,我们的 Shader 属于一种特殊的脚本,程序加载它并对它进行解释,最后通过接口将其输送到 GPU 中处理。

这个处理过程是引擎底层实现的,为了让读者清楚,在此通过一部分核心代码给读者展示引擎是通过加载读取 Shader 并将其传输给 GPU 中处理。

现在比较流行的 H5 游戏,它使用的渲染库是 WebGL,WebGL 提供了相应的接口,用于加载已有的 Shader,当然 OpenGL,DX 都提供了相应的接口。

下面我们先定义顶点着色器和片段着色器脚本,简单的举个例子:

   attribute vec3 position;   uniform   mat4 mvpMatrix;   void main(void){      gl_Position = mvpMatrix * vec4(position, 1.0);   }

这里用到了一个 attribute 变量和一个 uniform 变量,用于在 Shader 中声明变量,这个是原生态的 Shader 脚本与 Unity 的是不一样的。

变量 position 的类型是 vec3,表示的是一个 3 维向量,里面是顶点的位置,向量的三个元素分别是 X,Y,Z 坐标,另一个 uniform 声明的变量 mvpMatrix,类型是 mat4,它表示的是一个 4x4 的方阵。

它是模型,视图,投影的各个变换矩阵结合后的矩阵。这次的顶点着色器,只是利用坐标变换矩阵来变换顶点的坐标位置,使用乘法运算,顶点着色器得到的结果将传递给片段着色器。

为了让 position 和矩阵相乘,使用 vec4 先将其变成一个 4 维的向量,然后相乘,最后将计算结果代入到 gl_Position,顶点着色器的处理结束。

接着说片段着色器,这次,绘制的模型是一个简单的三角形,先不进行着色,只是使用白色来填充。

所以,片段着色器中的处理,就只是将白色信息传给 gl_FragColor 中。下面是片段着色器的代码。

   void main(void){      gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);   }

关于颜色,基本上使用 vec3 或者 vec4 的情况比较多。因为一般就是使用 RGB 或者 RGBA,需要 3~4 个元素。

这一次使用的 vec4 是所有的参数都是 1.0 的向量,颜色是白色[红,绿,蓝,不透明度的各元素都是最大=白色]。

接下来,我们看顶点着色器和片段着色器的运行过程,也就是引擎内部的实现过程,我们编写 Shader 不仅要知道咋写?还要清楚引擎内部是咋调用的,做到知其然,知其所以然。

Shader 的编译也不需要什么特别的编译器,只需要调用 WebGL 内部的接口函数就可以进行编译了。

从着色器的编译,到实际着色器的生成这一连串的流程,都在一个函数中来完成。下面是这个函数的代码:

  function create_shader(id){      // 用来保存着色器的变量      var shader;      // 根据 id 从 HTML 中获取指定的 script 标签      var scriptElement = document.getElementById(id);      // 如果指定的 script 标签不存在,则返回      if(!scriptElement){return;}      // 判断 script 标签的 type 属性      switch(scriptElement.type){          // 顶点着色器          case 'x-vertex':              shader = gl.createShader(gl.VERTEX_SHADER);              break;          // 片段着色器          case 'x-fragment':              shader = gl.createShader(gl.FRAGMENT_SHADER);              break;          default :              return;      }      // 将标签中的代码分配给生成的着色器      gl.shaderSource(shader, scriptElement.text);      // 编译着色器      gl.compileShader(shader);      // 判断一下着色器是否编译成功      if(gl.getShaderParameter(shader, gl.COMPILE_STATUS)){          // 编译成功,则返回着色器          return shader;      }else{          // 编译失败,弹出错误消息          alert(gl.getShaderInfoLog(shader));      }   }

简单的介绍一下上述代码,重点的函数都加了注释,将代码分配给生成的着色器的时候,使用的是 shaderSource 函数,参数有两个,第一个参数是着色器对象,第二个参数是着色器的代码。

这时候,只是把着色器的代码分配给了着色器,Shader 编译的时候,使用的是 compileShader 函数,将着色器对象作为参数传给这个函数,这样就可以将着色器在引擎中进行编译了。

这个自定义函数,无论是顶点着色器还是片段着色器,都可以进行编译读取。实际上,顶点着色器和片段着色器的处理不同的地方就是 createShader 函数,其他地方是完全一样的。

从顶点着色器向片段着色器中传递数据,其实,实现的就是从一个着色器向另一个着色器传递数据的,不是别的,就是程序对象。

程序对象是管理顶点着色器和片段着色器,或者 WebGL 程序和各个着色器之间进行数据的互相通信的重要的对象。

那么,生成程序对象,并把着色器传给程序对象,然后连接着色器,将这些处理函数化,关于程序对象的实现是通过调用函数接口实现的,代码如下:

   function create_program(vs, fs){      // 程序对象的生成      var program = gl.createProgram();      // 向程序对象里分配着色器      gl.attachShader(program, vs);      gl.attachShader(program, fs);      // 将着色器连接      gl.linkProgram(program);      // 判断着色器的连接是否成功      if(gl.getProgramParameter(program, gl.LINK_STATUS)){          // 成功的话,将程序对象设置为有效          gl.useProgram(program);          // 返回程序对象          return program;      }else{          // 如果失败,弹出错误信息          alert(gl.getProgramInfoLog(program));      }   }

这个函数接收顶点着色器和片段着色器两个参数,首先生成程序对象,分配着色器,生成着色器的时候,使用 WebGL 中的函 createProgram。

将着色器分配给程序对象的时候使用函数接口 attachShader,attachShader 函数的第一个参数是程序对象,第二个参数是着色器。

着色器分配结束后,根据程序对象,要连接两个着色器,这时候使用 linkProgram 函数,参数就是程序对象。再后面就是赋值了,下面语句:

   var projMat = getPerspectiveProjection(30, 16 / 9, 1, 100);    var viewMat = lookAt(6, 6, 14, 0, 0, 0, 0, 1, 0);    var mvpMat = multiMatrix44(projMat, viewMat);    var u_MvpMatrix = gl.getUniformLocation(sp, "mvpMatrix");    gl.uniformMatrix4fv(u_MvpMatrix, false, mvpMat);

该语句将世界视口投影矩阵传递给 GPU 使用,其他的跟这个类似,以上我们就实现了对 Shader 的加载编译以及参数传递。

这些对 Unity 都是封闭的,开发者是不清楚的,给读者介绍这些的目的是告诉读者 Shader 在引擎中的一个执行流程。

我们写的代码是通过以上类似的处理方式进行的,掌握了以上两点后,下面开始 Shader 实用技术编程讲解。

三、Shader 编程技巧

在告诉大家编程技巧之前,我们首先要清楚 Shader 编程使用的接口函数的定义,换句话说我们要能看懂已有 Shader 的语句。

以 Unity 为例,Unity 为我们提供了很多现成的 Shader,还有一些 Shader 库,为我们封装了很多函数,我们要做的事情就是要了解这些函数作用,在讲解语句之前,先给读者介绍几个空间概念。

虽然我们已经在上面提过,因为这几个概念非常重要,所以有必要强调一下:

在 model space 中,坐标是相对于模型网格的原点(0,0,0)定义的,这也是为什么我们要求美术在导出模型时将其设置成原点。

我们的 vertex function 需要把这些坐标转换到 clip space 中,为投影做准备。

在 tangent space 中也称为切线空间,法线纹理的计算就是在这个空间中进行的,坐标是相对于模型的正面定义的——在处理法线纹理时我们使用这个 space,它是放在 UnityCG.cginc 库中,定义如下:

   #define TANGENT_SPACE_ROTATION \      float3 binormal = cross( v.normal, v.tangent.xyz ) * v.tangent.w; \      float3x3 rotation = float3x3( v.tangent.xyz, binormal, v.normal )

可以看出 rotation 就是由三个向量构建出了的 3X3 矩阵,而这三个向量分别对应了 Object Space 中的 tangent、binormal 和 normal 的方向。

这三个方向对应了 Tangent Space 中的三个坐标轴的方向,效果如下所示:

其实就是一个坐标变换,如果我们想得到从坐标系 A 转换到坐标系 B 的一个变换矩阵,我们只需用 A 中 B 的三个坐标轴的方向、按 X、Y、Z 轴的顺序构建一个矩阵即可。

在 world space 中,坐标是相对于世界的原点(0,0,0)定义的。

在 view space 中,坐标是相对于摄像机定义的,因此在这个 space 中,摄像机的位置就是(0,0,0)。

在 clip space 中,通常图元会被裁剪,然后再通过屏幕映射投影到屏幕空间中。

在 Shader 中我们通常会将其放到一个宏里面,比如 UNITY_MATRIX_MVP 这个就是(在 UnityShaderVariables.cginc 里定义)相乘,从而把顶点位置从 model space 转换到 clip space。

我们使用了矩阵乘法操作 mul 来执行这个步骤。

下面我们通过顶点着色器和片段着色器给读者介绍关于使用的函数定义:

   v2f vert(appdata_base v) {        v2f o;      o.pos = mul (UNITY_MATRIX_MVP, v.vertex);      o.srcPos = ComputeScreenPos(o.pos);        o.w = o.pos.w;      return o;       }

顶点着色器中 ComputeScreenPos 是在 UnityCG.cginc 中定义的函数,它就作用如名字一样,计算该顶点转换到屏幕上的位置。

还有如果我们需要把法线从模型空间变换到世界空间中,可以直接使用内置函数 UnityObjectToWorldNormal 函数,常见的语句如下:

   fixed3 worldNormal = UnityObjectToWorldNormal(v.normal) 它的实现可以在它的实现可以在 UnityCG.cginc 里找到:    inline float3 UnityObjectToWorldNormal( in float3 norm )   {      // Multiply by transposed inverse matrix, actually using transpose() generates badly optimized code      return normalize(_World2Object[0].xyz * norm.x + _World2Object[1].xyz * norm.y + _World2Object[2].xyz * norm.z);   }

还有一种写法:

   o.worldNormal = normalize(mul(v.normal, (float3x3)_World2Object));

总之,读者一定要清楚重点函数的作用,这个可以查阅 Unity 自带的库。

我们在编写 Shader 时,通常会把引用的库在 Shader 前面写出来,比如 Shader 代码中 Include”UnityCG.cginc” 等等。

它们都是可以在 Unity 提供的库中找到的,读者学习 Shader 编程对于向量之间的点乘,叉乘,矩阵相乘都要搞清楚。

掌握了上面的知识点后,我们在项目开发时,怎么去满足需求呢?

首先先从 Unity 官方提供的 Shader 中匹配需求开发,Unity 为我们提供了很多现成的 Shader,可以直接拿过来用。如果单个现成的满足不了需求,看看能否将其中两个合并成一个使用,或者在已有的基础上修改,就不要自己重新写了。

举个例子,关于透明的材质我们经常会遇到渲染顺序问题,比如我们渲染的透明材质按照 Unity 提供的渲染顺序,它是在不透明的物体后面渲染,如果我们遇到需求先渲染透明材质,这种问题解决起来比较简单。

直接在 Tag 标签中的 Queue 中 减去 1000,这样它的渲染数值就小于不透明物体了,代码如下:

   Tags {"Queue"="Transparent-1000" "IgnoreProjector"="True" "RenderType"="Transparent"}

还有我们在做实时阴影渲染时,为了防止阴影重叠,我们将原有的 Unity 自带的 Shader 做了一个改动就是将不需要的颜色才剪掉,只是加了一个判断。

根据设置的参数去做裁剪,功能就实现出来了:

  half4 frag(v2f i) : SV_Target            {                half4 col = tex2D(_MainTex, i.texcoord);                **if(col.a < _Cutoff)**                {                    clip(col.a - _Cutoff);                }                else                {                    col.rgb = col.rgb * float3(0, 0,0);                    col.rgb = col.rgb + _Color;                    col.a = _Color.a;                }                return col;            }

其实,我们只是加了一个判断见加黑体部分,它就是根据材质的 Alpha 值与设定的值进行比较裁剪掉而已。这样实现的效果如下:

原来的代码是没有这个判断,这是我们根据自己的需求加上去的。另外,我们针对角色的渲染也会做一些处理,如下图所示的效果:

它对应的 Unity 中材质的渲染如下图所示:

其实我们也是在 Unity 原有 Shader 的基础上增加了一些变量设置:

   Properties {    _Color ("Main Color", Color) = (1,1,1,1)    _ReflectColor ("Reflection Color", Color) = (1,1,1,0.5)    _MainTex ("Base (RGB) RefStrength (A)", 2D) = "white" {}    _Cube ("Reflection Cubemap", Cube) = "_Skybox" { TexGen CubeReflect }    _BumpMap ("Normalmap", 2D) = "bump" {}    _AddColor ("Add Color", Color) = (0,0,0,1)    _Shininess ("Shininess", Range (0.05, 1)) = 0.9    _ShininessPath ("ShininessPath", Range (0.1, 1)) = 0.5    _IllColor ("Ill Color", Color) = (0.5, 0.5, 0.5, 1) }

当然要定义这些变量:

   sampler2D _MainTex; sampler2D _BumpMap; samplerCUBE _Cube; fixed4 _Color; fixed4 _ReflectColor; fixed4 _AddColor; half _Shininess; half _ShininessPath; fixed4 _IllColor;

注意它们的名字跟 Properties 定义的是一一对应的,最后在 Shader 中进行处理这些变量,无非是对材质进行相乘或者相加运算,最终还是对纹理的像素进行操作:

   void surf (Input IN, inout SurfaceOutput o) {    fixed4 tex = tex2D(_MainTex, IN.uv_MainTex);    fixed4 c = tex * _Color + _AddColor;    o.Albedo = c.rgb;    o.Gloss = tex.a;    o.Normal = UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap));    float3 worldRefl = WorldReflectionVector (IN, o.Normal);    fixed4 reflcol = texCUBE (_Cube, worldRefl);    reflcol *= tex.a;    o.Emission = reflcol.rgb * _ReflectColor.rgb  + tex.rgb * _IllColor.rgb;    o.Alpha = reflcol.a * _ReflectColor.a;    o.Specular *= o.Alpha * _Shininess + _ShininessPath; }

颜色的叠加或者对相应的颜色进行加强利用相乘得到,是不是很简单?

另外,如果 Unity 现有的 Shader 满足不了需求,我们可以借用 OpenGL 中的 Shader,它们也可以比较方便的修改成 Unity 的 Shader。

换句话说,如果我们用 Unity 自带的 Shader 搞不定,如果你能找到 OpenGL 中的 Shader 一样可以修改,代码如下所示,下面是 OpenGL 中的代码:

   varying vec2 v_texCoord; uniform sampler2D yTexture; uniform sampler2D uvTexture; const mat3 yuv2rgb = mat3(                        1, 0, 1.2802,                        1, -0.214821, -0.380589,                        1, 2.127982, 0                        ); void main() {        vec3 yuv = vec3(                1.1643 * (texture2D(yTexture, v_texCoord).r - 0.0627),                texture2D(uvTexture, v_texCoord).a - 0.5,                texture2D(uvTexture, v_texCoord).r - 0.5                );    vec3 rgb = yuv * yuv2rgb;    gl_FragColor = vec4(rgb, 1.0); }

我们要把以上的 Shader 代码应用到我们的 Unity 中,这就需要将上面代码改成 Unity 能识别的 Shader 代码。

下面就以修改这个为例给读者分析一下:varying vec2 v_texCoord;可以定义成输入结构体,也就是我们说的 UV 纹理,定义的结构体如下:

   struct Input    {        float2 uv_MainTex;    }

再修改下面的代码:

   uniform sampler2D yTexture; uniform sampler2D uvTexture;

可以修成成如下代码:

   Properties {    _XTex ("XTexture(RGB)", 2D) = "white" {}    _YTex ("YTexture(RGB)", 2D) = "white" {} }

接下来再将定义的矩阵修成 Unity 中的 Shader,语句如下:

   float3x3 = matrix(   1, 0, 1.2802,                        1, -0.214821, -0.380589,                        1, 2.127982, 0);

接下来再改造:

vec3 rgb = yuv * yuv2rgb; float3= mul(yuv2rgb,yuv);

最后 获取颜色:float4(rgb,1.0f);

按照这种方式就可以将 OpenGL 中的 Shader 改造成 Unity 自身的 Shader。当然使用这种方式只是为了让大家快的实现需求,实现不是目的。

我们的主要目的是在此基础上掌握 Shader 的函数运用以及实现它们的思路。

再此给读者推荐一个网站 OpenGL 的,里面的 Shader 我们很多可以使用,比如用 Shader 实现的效果如下:

左边是实时渲染效果,右边是代码和渲染通道。网址:https://www.shadertoy.com/。

四、材质渲染

Shader 的核心用法就是材质渲染,材质渲染无非涉及到材质高光,法线这些点,还有反射,折射,卡通渲染,描边等等,以及 Unity 高版本实现的 PBR 物理效果。

在此给读者介绍一个 Shader 编辑器工具 Shader Forge,如果读者使用过 UE4 虚幻蓝图,它的操作方式跟 Shader Forge 非常类似。

为此我还专门写过一篇博客介绍 Shader Forge,网上也有很多教程使用:教程一:http://blog.csdn.net/jxw167/article/details/69267236、教程二:http://blog.csdn.net/jxw167/article/details/69257559。

读者可以先看看这两篇博客在此就不重复了,只是说,在使用 Shader Forge 时要注意,我们使用时虽然实现了效果,但是要考虑到在手机端的效率问题,有时我们需要对其做一些效率优化。

比如渲染顺序问题,效率问题等等,我们用 Shader Forge 实现的效果如下:

对应的 Unity 中的 Shader 控制面板 Shader 如下所示:

在优化时,比如我们不想让其受光照,我们一般的做法是直接如下:

   //           Tags { //                "LightMode"="ForwardBase" //            }

将光照模式注释掉,同时可以加一句 Light off,关闭灯光。另外,下面这行代码也要注意,将其注释掉:

  //#pragma exclude_renderers gles3 metal d3d11_9x xbox360 xboxone ps3 ps4 psp2

因为手机系统的适配,这句可以在 Shader Forge 中设置屏蔽掉,如果不设置可以直接把这行代码注视掉,Shader Forge 操作起来非常方便,直接用线链接就可以,而且可以实时的查看效果。

下面再说说 Shader 优化,这个是比较头疼的问题,一方面要考虑到优化,一方面要考虑到品质。

五、Shader 优化处理

Shader 的优化处理,这个是一直困扰着程序的问题,想要好的品质,也要顾及到运行效率,下面就给读者分析一下。

在编写 Shader 使如果遇到需求多时,我们会在 Shader 中添加很多变量,在 Shader 如果使用变量比较多,我们通常的优化方案是从其声明的变量精度开始。

通常的定义如下:

  • float:表示的是最高精度,通常 32 位;

  • half:表示的是中等精度,通常 16 位;

  • fixed:表示的是最低精度,通常 11 位。

同样还有我们常见的 float2,half2,fixed2 精度依次降低,当然效率是逐步提升的,精度越低效率越高。

我们在使用时也是按照这种去考虑,当然也要考虑到品质,fixed2 精度肯定不如 float2 的精度,当然那品质也就不如 float2 渲染的精度,这个要酌情处理。

另外,对于 Shader 代码中使用的 if else,while,for,这些用于判断的语句和循环语句尽量少用,因为 GPU 是多线程执行的,这些容易打断它的执行。

尽量少用,不是说完全不用,因为一些特殊需求还是要特殊处理的。

对于大批量的物体,比如国战的游戏,需要大量的玩家,如果我们使用 CPU 去处理,这样容易导致产生大量的批处理,严重影响效率,也有读者会考虑到使用网格合成,但是这样就不能一一操作单体了,所以这种方法需要排除。

如果我们使用 GPU Instancing 去处理就容易的多了,但是使用 GPU Instancing 处理的条件是必须是相同的角色动作和材质,高版本的 Unity 的 Shader 都为我们提供了这个功能,在 Unity2017.2 中的 Shader 截图如下:

红线加粗的部分就是实例化,我们要勾选上,当然,我们自己的 Shader 也可以这么处理,GPU Instancing 实例化角色的 Shader 代码如下所示:

   Pass    {        CGPROGRAM        #pragma vertex vert        #pragma fragment frag        // 开启 gpu instancing        #pragma multi_compile_instancing        #include "UnityCG.cginc"        struct appdata        {            float2 uv : TEXCOORD0;            UNITY_VERTEX_INPUT_INSTANCE_ID        };        struct v2f        {            float2 uv : TEXCOORD0;            float4 vertex : SV_POSITION;            UNITY_VERTEX_INPUT_INSTANCE_ID        };        sampler2D _MainTex;        float4 _MainTex_ST;        sampler2D _AnimMap;        float4 _AnimMap_TexelSize;//x == 1/width        float _AnimLen;        v2f vert (appdata v, uint vid : SV_VertexID)        {            UNITY_SETUP_INSTANCE_ID(v);            float f = _Time.y / _AnimLen;            fmod(f, 1.0);            float animMap_x = (vid + 0.5) * _AnimMap_TexelSize.x;            float animMap_y = f;            float4 pos = tex2Dlod(_AnimMap, float4(animMap_x, animMap_y, 0, 0));            v2f o;            o.uv = TRANSFORM_TEX(v.uv, _MainTex);            o.vertex = UnityObjectToClipPos(pos);            return o;        }

看以上代码带有 INSTANCE_ID 的部分,这需要在 Shader 中事先声明定义,这样 Shader 才具有实例化功能。

只要将该 Shader 挂到需要实例化的角色身上即可,网上也有这样的案例。

以上给读者介绍了几个常用的优化方案,策划会根据不同的项目提出不同的需求,方法掌握了,其他的修改就可以了。

六、Shader 后处理

GPU 不仅能用于渲染材质,而且还能渲染场景也就是我们说的后处理,又称为滤镜。

因为 Unity 使用的是单线程渲染方式,而且它是通过相机表现的,就是说后处理的脚本要挂接到相机上,这样如果相机上的后处理脚本过多,严重影响运行效率。

而 UE4 虚幻使用的是多线程渲染,这样它的渲染效率大大提升。

所以 UE4 可以使用大量的后处理效果,当然在 PC 端是完全可以的,手机端就要酌情考虑了。如果要掌握后处理渲染,首要的是要知道其工作原理。

下面以游戏中比较经典 Bloom 的处理算法为例给读者介绍:

Bloom 又称 “全屏泛光”,在游戏场景的渲染中使用的非常多。

像 Bloom 这些后处理渲染效果,在游戏场景中是必备的渲染,它们的实现方式都是在 GPU 中实现的。要实现 Bloom 算法,首先要做的事情是明白其实现原理,下面我们就来揭秘这个 Bloom 特效的实现流程。

在流程上总共分为 4 步:

  • 提取原场景贴图中的亮色;

  • 针对提取贴图进行横向模糊;

  • 在横向模糊基础上进行纵向模糊;

  • 所得贴图与原场景贴图叠加得最终效果图。

首先展示流程的第 1 步代码如下所示:

BloomExtract.fx // 提取原始场景贴图中明亮的部分 // 这是应用全屏泛光效果的第一步 sampler TextureSampler : register(s0); float BloomThreshold; float4 ThePixelShader(float2 texCoord : TEXCOORD0) : COLOR0 {    // 提取原有像素颜色    float4 c = tex2D(TextureSampler, texCoord);    // 在 BloomThreshold 参数基础上筛选较明亮的像素    return saturate((c - BloomThreshold) / (1 - BloomThreshold)); } technique BloomExtract {    pass Pass1    {        PixelShader = compile ps_2_0 ThePixelShader();    } }

接下来是第 2,3 步的实现代码如下所示:

GaussianBlur.fx // 高斯模糊过滤 // 这个特效要应用两次,一次为横向模糊,另一次为横向模糊基础上的纵向模糊,以减少算法上的时间复杂度 // 这是应用 Bloom 特效的中间步骤 sampler TextureSampler : register(s0); #define SAMPLE_COUNT 15 // 偏移数组 float2 SampleOffsets[SAMPLE_COUNT]; // 权重数组 float SampleWeights[SAMPLE_COUNT]; float4 ThePixelShader(float2 texCoord : TEXCOORD0) : COLOR0 {    float4 c = 0;    // 按偏移及权重数组叠加周围颜色值到该像素    // 相对原理,即可理解为该像素颜色按特定权重发散到周围偏移像素    for (int i = 0; i < SAMPLE_COUNT; i++)    {        c += tex2D(TextureSampler, texCoord + SampleOffsets[i]) * SampleWeights[i];    }    return c; } technique GaussianBlur {    pass Pass1    {        PixelShader = compile ps_2_0 ThePixelShader();    } }

第 4 步实现的代码如下所示:

BloomCombine.fx // 按照特定比例混合原始场景贴图及高斯模糊贴图,产生泛光效果 // 这是全屏泛光特效的最后一步 // 模糊场景纹理采样器 sampler BloomSampler : register(s0); // 原始场景纹理及采样器定义 texture BaseTexture; sampler BaseSampler = sampler_state {    Texture   = (BaseTexture);    MinFilter = Linear;    MagFilter = Linear;    MipFilter = Point;    AddressU  = Clamp;    AddressV  = Clamp; }; float BloomIntensity; float BaseIntensity; float BloomSaturation; float BaseSaturation; // 减缓颜色的饱和度 float4 AdjustSaturation(float4 color, float saturation) {    // 人眼更喜欢绿光,因此选取 0.3, 0.59, 0.11 三个值    float grey = dot(color, float3(0.3, 0.59, 0.11));    return lerp(grey, color, saturation); } float4 ThePixelShader(float2 texCoord : TEXCOORD0) : COLOR0 {    // 提取原始场景贴图及模糊场景贴图的像素颜色    float4 bloom = tex2D(BloomSampler, texCoord);    float4 base = tex2D(BaseSampler, texCoord);    // 柔化原有像素颜色    bloom = AdjustSaturation(bloom, BloomSaturation) * BloomIntensity;    base = AdjustSaturation(base, BaseSaturation) * BaseIntensity;    // 结合模糊像素值微调原始像素值    base *= (1 - saturate(bloom));    // 叠加原始场景贴图及模糊场景贴图,即在原有像素基础上叠加模糊后的像素,实现发光效果    return base + bloom; } technique BloomCombine {    pass Pass1    {        PixelShader = compile ps_2_0 ThePixelShader();    } }

具体实现见下图所示:

下面,大家结合这个流程图来分析各个步骤:

  • 第一步:应用 BloomExtract 特效提取原始场景贴图m_pResolveTarget中较明亮的颜色绘制到m_pTexture1贴图中(m_pResolveTarget--->m_pTexture1)

  • 第二步:应用 GaussianBlur 特效,在m_pTexture1贴图基础上进行横向高斯模糊到m_pTexture2贴图(m_pTexture1--->m_pTexture2)

  • 第三步:再次应用 GaussianBlur 特效,在横向模糊之后的m_pTexture2贴图基础上进行纵向高斯模糊,得到最终的模糊贴图m_pTexture1(m_pTexture2--->m_pTexture1)
    注意:此时,m_pTexture1贴图即为最终的模糊效果贴图。

  • 第四步:应用 BloomCombine 特效,叠加原始场景贴图 m_pResolveTarget 及两次模糊之后的场景贴图 m_pTexture1,从而实现发光效果 (m_pResolveTarget+m_pTexture1)。

实现的效果如下所示:

另外,其他的后处理方式比如 Blur,HDR,PSSM 等等,也是基于图像的算法实现的。掌握了算法的原理,不论用什么引擎,它们的原理都是类似的,只是在一些细节方面做的不同罢了。

Unity 也为用户提供了大量的后处理 Shader,拿过来使用就可以了,但是也要注意其效率,我们可以在此基础上进行优化处理,比如适当的把精度降低一些。

有些函数语句在不影响效果的前提下可以简化,比如一些 exp,exp2 等等,这种类似的函数都可以简化处理,毕竟它们也是非常耗的。

七、Shader 调试

Shader 因为是一种脚本语言,面临着非常尴尬的境地是无法调试,以前我们开发端游时使用的是 Render Monkey,它是可以调试的。

Unity 中的 Shader 可以看到其错误的行数,如果语句没有错误,我们要查找问题,通常的做法就是逐步的注释掉语句进行排查,虽然麻烦但是可以解决问题。

也可以使用特殊值的方式进行测试语句。

八、总结

以上几点也是作者自己关于 Shader 学习的一点总结,希望对读者有所帮助。

有一点大家要清楚,我们使用 Shader 渲染都是基于图像的处理方式,不论是材质渲染还是后处理渲染,其实如果想成为图形学工程师,以上六点还是必须要掌握的。

在此也是抛砖引玉,里面用到了一些技巧,只是帮助你快速的入手,要想深入的学习,我们还是要把基础打好,其实图形学没有想象的那么复杂。

近期热文

谈谈 Java 内存模型

Jenkins 与 GitLab 的自动化构建之旅

通往高级 Java 开发的必经之路

谈谈源码泄露 · WEB 安全

用 LINQ 编写 C# 都有哪些一招必杀的技巧?

机器学习面试干货精讲

深入浅出 JS 异步处理技术方案


「阅读原文」看交流实录,你想知道的都在这里

  • 14
    点赞
  • 47
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值