APSchmidt 发表于 2022-10-3 11:47

《Unity主程手记》摘要记录(9-10)

作者:陆泽西-《Unity3D高级编程:主程手记》
My Blog:主页 - S0nrEir's Blog
My Git:https://github.com/S0nrEir
第九章-渲染管线与图形

9.1数学

向量的意义

点乘
a·b=‖a‖×‖b‖×cosβ,结果为正数,两者方向一致;0:垂直;负数方向不一致。当负数最小,两者方向完全相反。
β=arcos((a·b)/(|a|×|b|))角度计算
//点乘
public static float Dot(Vector3 lhs,Vector3 rhs)
{
    return lhs.x*rhs.x+lhs.y*rhs.y+lhs.z*rhs.z;
}
//用Mafhf.Acos(float)取反三角函数,就有了:β = Mathf.Acos(Vector3.Dot(lhs,rhs)/(lhs.magnitude * rhs.magnitude));
方向;角度;攻击范围;投影面积;cos;a在b上的投影长度占比;夹角
叉乘
求得垂直于两个向量的另一条向量。
c=a×b=(a1, a2, a3)×(b1, b2, b3)=(a2×b3-a3×b3, a3×b1-a1×b3, a1×b2-a2×b1)
|c|=|a×b|=|a|×|b|×sinβ
//叉乘
public static Vector3 Cross(Vector3 lhs,Vector3 rhs)
{
    return new Vector3
      (
            lhs.y*rhs.z - lhs.z*rhs.y,
            lhs.z*rhs.x - lhs.x*rhs.z,
            lhs.x*rhs.y - lhs.y*rhs.x,
      );
}
顶点坐标;切线空间;TBN矩阵;右手定则(垂直结果方向)平面法向量;左右;攻击范围;
向量之间的投影





向量C就是A乘以某个系数得到的,该系数可以认为是C的模除以A的模。
c=a×(|c|/|a|) =>
|c|=|b|×cosβ(C和B的家教β) =>
c=a×(|b|×cosβ/|a|)(计算投影向量C) =>
c=a×(|b|×cosβ/|a|)(除号的两边乘以A的模) =>
c=a×(a·b)/(|a|^2)(|b|×cosβ×|a|组合起来等于a向量与b向量点积的结果,于是就有了这样的的简化共识)
//将一个Vector投影到另一个Vector
//Unity中的投影函数
public static Vector3 Project(Vector3 vector, Vector3 onNormal)
{
    float sqrMag = Dot(onNormal, onNormal);
    if (sqrMag < Mathf.Epsilon)
      return zero;
    else
      return onNormal * Dot(vector, onNormal) / sqrMag;
}
矩阵

常用矩阵:2X2 / 3X3 / 4X4
齐次坐标:将一个原本N维的向量用一个N+1的向量表示。
旋转:




在x、y、z轴上分别旋转20°、30°、15°。C向量一次乘以三个旋转矩阵。c×Mx×My×Mz=c′




任意轴上的旋转,n为缩放方向,k为缩放系数。
齐次坐标的平移矩阵

齐次矩阵增加一个维度表示平移操作




用xyz上的切变表示在xyz轴上的偏移。
四元数

欧拉角:欧拉角的表示形式是,分别沿x、y、z轴旋转度数。可能会导致万向锁(一个轴和另一个轴重叠)
几何意义:四元数有三个虚部i、j、k。q=w+i × X + j × Y + k × Z,满足i×i=j×j=k×k=-1i×j=k,ji=-kj×k=i,k×j=-ik×i=j,i×k=-j
“四元数可解释为角位移的轴一角方式。什么是轴一角?绕某个单一轴旋转一个角位移就能表达旋转的方式称为轴一角。角位移就是一个与向量类似的表达方式,即(x,y,z),只不过四元组是用4个元素来表达。四元组可以理解为绕某个轴N旋转的角位移,与欧拉角用x、y、z表达绕标准坐标轴旋转的道理相同,只是这个轴不再是标准轴,而是任意轴。”
A′=
B′=
C′=
这表示的是三个供旋转的四元数,在X轴上旋转A度,在Y轴上旋转B度,在Z轴上旋转C度。
9.2 渲染管线

OpenGL;DirectX
CPU->渲染引擎或引擎的渲染组件->OpenGL/DX->显卡驱动->GPU(模板、深度、顶点缓存、纹理等)
DrawCall:CPU传送一个渲染指令的过程
渲染流水线的几个阶段

[*]应用阶段(包围盒裁切、剔除、裁剪算法):简单可以理解为引擎阶段,在这个阶段实例化模型、UI等、在这个阶段引擎会知道需要渲染哪些模型,场景内有哪些光源,相机位置,做一些裁切算法(比如视锥剔除)等。这个阶段是准备渲染数据的阶段,为下一阶段提供渲染数据依据,在这之后,向GPU提交渲染数据,它们会被复制到显存中(对于移动平台,是内存)
[*]几何阶段(顶点着色器、细分着色器、几何着色器等、图元装配):将需要绘制的图元(三角形,点线面)转换到屏幕空间。该阶段会有如下几个流程:

[*]顶点着色器,可编程部分,也可以在此阶段做一些shader效果和计算。
[*]细分着色器,非必要,有些移动平台GPU不具备该功能。它包括曲面细分着色器和细分计算着色器,增加顶点和面片数量来让模型更加平滑(比如汽车在雪地行驶后留下的车轮痕迹)。几何着色器允许增加和创建新的图元。
[*]图元装配,将顶点和相关图元组织(关联)起来,为下一步裁切做准备。
[*]顶点变换,顶点坐标从模型空间变换到投影空间(projection space)(模型空间->世界空间->视口空间->投影空间),然后经过透视除法变换到NDC空间, 这是为了帮助裁切、深度缓冲和深度测试。
[*]裁切,在范围内的的三角形会继续传递到下一阶段,不在的丢弃,部分在的做切割处理,将范围外的丢弃。


[*]光栅化(片元着色器):三角形设置将所有的三角形都铺在屏幕上,这样知道了三角面在屏幕上的位置和颜色情况(一个三角面占了多少像素)。三角形遍历通过计算三角三条边的位置得到覆盖的像素点,再通过三个顶点中信息做插值得到每个像素的信息,比如颜色、深度、坐标等。片元着色器,可编程阶段,可以将这个阶段的图元信息看成每个像素点,但它包含的信息要比像素点多,这是通过三角形遍历得到的。
[*]逐片元操作:输出合并阶段(DX),包含剪切测试,多重采样,模板测试,深度测试,混合等。他们都是以片元为基础进行的操作。如果片元没有通过之前的测试,那么管线会在这时丢弃它们,如果通过就进入frame buffer等待画到屏幕上。大部分这些测试和操作都是以开关的形式存在的,在混合阶段片元会与当前frame buffer中的内容进行混合,一个典型场景就是半透明物体。
[*]输出
第十章-渲染原理和知识

10.1 渲染队列

深度测试的最大好处在于,可以尽早法线不需要渲染的片元,及时抛弃以节省GPU开销。通常情况下使用LEqual做深度测试的检查条件(离相机越近的物体越会遮挡前面的物体),那么离相近越近的物体放在前面渲染,是不是可以最大程度的在该阶段节省性能呢?
Unity对所有不透明物体在渲染的时候都做了排序,离相机越近越先渲染(RQ中位置靠前),但是这样没有办法处理半透明物体
因为半透明物体需要做混合,混合需要不透明物体先渲染完成,因此半透物体被安排在RQ的最后,半透明物体通常不写入深度(ZWrite Off),否则半透明物体将在深度测试时抛弃比它深度高的像素。
半透明物体RQ排序方法和不透明物体一样,只不过离相机越远的物体越先渲染。
Unity中的渲染队列层次

[*]Background:1000
[*]Geometry:2000(不透明)
[*]AlphaTest:2450
[*]Transparent:3000(半透明)
[*]Overlay:4000
SubShader
{
    Tags{"Queue" = "Transparent"}//在shader中指定顺序
    Pass
    {
      //...
    }
}在Unity中2500以下的从近到远,2500以上的,由远到近。
10.2 透明测试

AlphTest使用纹理中的alpha值来决定该片元是否会被clip
Pass
{
    //片元着色器中对片元进行clip
    half4 frag(v2f i) : SV_Target
    {
      half4 color = Tex2D(_MainTex,i.uv);
      clip(color.a - _CutOff);
      return color;
    }
}10.3 Early-Z

在流水线中,深度测试是在片元着色器之后的阶段,因此一些本来不会通过深度测试的片元也经历了片元着色器的计算,如果折叠片元比较多,就会造成计算性能浪费。Early-Z就是为了解决这种情况。
它会在光栅化之后,片元着色器之前做一次深度测试,如果失败就认为是被遮挡的像素,直接跳过片元着色器,节省计算性能。
但无论是否失败,片元都会进入深度测试,在这个阶段决定片元去留。换句话说,Early-Z只是决定了片元是否跳过片元着色器。
Early-Z是通过GPU自动调用实现的。这里涉及到两个Pass,第一个pass写入深度缓冲(不写入像素缓存),第二个pass关闭深度写入开启深度测试。
“由于Alpha Test的做法让我们在片元着色器中可以自主抛弃片元,因此问题又出现了。片元在着色器中被主动抛弃后,Early-Z前置深度测试的结果就会出现问题,因为测试通过的可见片元被抛弃后,被它遮挡的片元就成为可见片元,导致前置深度测试的结果出现问题。因此GPU在优化算法中,对片元着色器抛弃片元和修改深度值的操作做了检测,如果检查到片元着色器中存在抛弃片元和改写片元深度的操作,则Early-Z将被放弃使用。”
10.4 MipMap

根据相机距离使用不同分辨率的贴图以节省性能。
当相机离目标贴图非常远时,像素和纹理大小的比率变小,采样点偏移增大,导致采样渲染出来的图像有瑕疵。(因为物体呈现的像素点数量变少)
为了解决这个问题,MipMap提前将纹理贴图存储成不同大小的层级。在渲染时传入图形接口。除此以外,在相机远离贴图时,MipMap还能提高采样效率,因为这些物体使用了更小分辨率的贴图,缓解了内存和GPU的压力。实际的流程是,图形接口负责计算应该选择的MipMap等级,然后将他给到shader,也可以调用textureLoad(OpenGL)函数来自行决定采样等级。
图形接口是如何计算采样等级的呢?
在fragment shader中,每个屏幕空间上的XY像素和贴图纹素都有一个对应关系,这是一个片元的屏幕XY空间映射到UV的过程,可以对XY求偏导函数来实现。




这是一张划分为七个级别的MipMap
//计算mipmap层级
float MipMapLevel(float2 uv,float2 textureSize)
{
    //求出x轴和y轴的覆盖率
    float dx = ddx(uv * textureSize.x);
    float dy = ddx(uv * textureSize.y);
    //取得dx和dy的最大值
    float d = max(dot(dx,dx),dot(dy,dy));
    //计算层级
    return 0.5 * lg2(d);
}10.5 显存原理

因为架构的原因,移动平台上没有显存的概念,手机中GPU和系统公用一块内存,但移动平台的GPU仍然有自己的缓存。
一般显存中存放纹理贴图,网格等数据,这些数据要从系统内存中复制过来。而缓存中存放顶点缓存,深度缓存,模板缓存等。
在调用渲染前,应用程序将调用图形接口将需要的数据从内存复制到显存(此过程只存在于PC和主机)。而在移动平台虽然没有独立显存,但仍然会将数据复制到GPU缓存中。这个复制过程每帧都在进行,也存在缓存命中的情况。但重复复制,过大的贴图,网格仍然可能导致性能问题。
10.6 Filter滤波

问题:一张贴图纹素因为大小、位置、距离等原因,基本无法与屏幕上的像素一一对应起来。当相机离贴图很近时,一个像素可能对应贴图纹素的一小部分,而当相机离远时,一个像素可能对应很多个纹素。
为了解决这个问题,可以对纹素进行插值,OpenGL提供了多种插值算法,不同的算法(滤波方式)在速度和表现上都会有所不同。
有三种滤波方式:

[*]Nearest:最近采样。在Unity中Point就是最近采样。当纹素和像素大小不一致时,它取位置最接近的纹素,但这无法保证表现上的平滑和连续性,即使使用了MipMap,因此此种采样类型让纹理在表现上显得有些尖锐。
[*]Linear:线性采样。又分为双线性和三线性。
OpenGL会找到纹理坐标最近的两个采样点,然后根据这两个采样点和纹理坐标之间的距离计算权重,加权平均后得到最终结果。双线性取最近四个像素,将采样得到的纹素加权平均后得到最终颜色。相比最近采样,在表现上线性采样要更加平滑,但有一个问题:双线性采样只选取一个纹素和像素大小最接近一层的MipMapLevel,当像素大小匹配在两层MipMap中间时,效果仍然不太立项。
三线性采样可以解决该问题,它会在双线性采样的基础上对当前计算到的MipMapLevel上下两层的MipMap分别进行一次双线性过滤然后对采样结果做插值。
[*]各向异性:除了上述步骤外,各向异性还会考虑到贴图的三维表面与屏幕存在角度的情况。它会计算像素在贴图空间上的UV方向比例关系,如果不是1:1,就按比例选取各方向上不定数量的点参与计算。Unity中Aniso Level可以设置各向异性的级别。
多重采样

他是反走样(AntiAliasing,抗锯齿)的实现方法之一。
简单来讲,多重采样的思路是采样像素周围的图元,并且记录样本值,然后和当前像素点的颜色做混合,让表现更加平滑。采样数量越多,线条和周围像素点的融合月平滑,当然也要消耗更多的GPU计算量。
在Unity中可以通过Quality Settting->AntiAliasing来设置抗锯齿,这将开启图形接口中的多边形反走样算法并且开启多重采样。
10.7 实时阴影

将相机放在光源位置,输出到ShadowMap,可以理解为深度图。至于接收阴影的方式,就是将顶点变换到光源空间下,根据XY映射到shadowMap的纹理位置进行采样,得到shadowMap中顶点基于shadowMap的深度值。如果shadowMap的深度值小于顶点的深度值(Z分量)那么就说明顶点处于阴影中,那么就混合顶点颜色和阴影颜色,否则就说明顶点不在阴影内。
屏幕空间阴影投影技术(Multiple Render Targets)

从光源照射的深度图与相机的深度图做对比,将相机深度图中的点转换到光源空间与shadowMap中的位置做对比,如果深度比shadowMap中点的深度大,则说明处于阴影中。
Unity中使用LightMode为ShadowCaster的Pass参与阴影计算,如果没有就在FallBack里继续找,Unity每次会调用所有的ShadowCaster的Pass参与shadowMap的计算,来实现实时阴影。
SubShader
{
    Tags{"Queue" = "Geometry"}
    Pass
    {
      Name "ShadowCaster"
      //将LightMode标记为ShadowCaster
      Tags{"LightMode" = "ShadowCaster"}
      
      ...
      ...
      ...
      
      v2f vert(appdata_base v)
      {
            v2f o;
            ...
            //计算顶点在shadowMap中的纹理坐标
            TRANSFER_SHADOW_CASTER_NORMALOFFSET(o);
            return o;
      }
      
      half4 frag(v2f i) : SV_Target
      {
            SHADOW_CASTER_FRAGMENT(i);
      }
      
      ...
      ...
      ...
    }
}10.8 光照纹理烘培

全局光照(Global Illumination):真实自然中,光线照射到物体上,经过数次的反射和折射,使照到物体的光叠加了许多颜色。但实时全局光照的计算量太大,一般无法做到动态/运行时进行,除非高端显卡。
可以通过离线计算的方式来实现全局光照,但因为是静态的原因,因此也只能用于场景内的静态物体。
烘培:预先计算物体光照的明暗信息,然后保存到一张纹理上,所以不再需要适时计算光照,只需要在光照纹理中进行采样即可。
光照纹理使用顶点的UV坐标取得纹素,再混合片元颜色。
一个模型中的顶点中的UV数据可以不止一个,UV0是为了映射模型贴图,UV1就是为了光照纹理而准备,UV2则为实时全局光照准备。UV3及之后才是程序的自定义数据。使用mesh.uv4即可取得相应的UV数据。
光照纹理上存储了三种信息:间接光照信息;直接光照颜色;阴影系数。
另外场景烘培还会产生一张阴影纹理,用于记录阴影信息,如果开启了Directional Model.Directional,则会产生一张记录光照方向的纹理。
“烘培器会对场景内的所有静态的吗网格进行扫描。按大小和角度拆分对应的UV块,形成UV Chart。这是静态物体在光照纹理上的某块网格对应的UV块。可以将UV Chart理解为一段连续的UV片段 。”






10.9GPU Instancing

GPU Instancing不通过合并网格来减少DrawCall。它只向GPU提交一个模型网格。但在不同的位置、角度、缩放。
GPU Instancing的实现是底层图形接口的多实例渲染。
//OpenGL中的多实例渲染函数,这三个函数会分别将模型在一个管线中渲染多次,它们会通知shader开启一个InstancingID,表示当前的渲染实例ID
//无顶点网格集合多实例渲染
void glDrawArraysInstanced(GLenum mode, GLint first, GLsizei count, Glsizei primCount);
//带索引的网格集合多实例渲染
void glDrawElementsInstanced(GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei primCount);
//基于偏移索引的网格集合多实例渲染
void glDrawElementsInstancedBaseVertex(GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instanceCount, GLuint baseVertex);
InstancingID可以在多实例渲染中识别当前渲染的模型使用了哪些参数属性。顶点和片元着色器可以通过这个ID获取相应的参数。
//shader中开启GPU Instance(内置管线)
Pass
{
    #pragma multi_compile_intancing //开启多实例变量编译,告知shader将要使用多实例变量
    ...
    ...
    ...
   
    struct appdata
    {
      ...
      UNITY_VERTEX_INPUT_INSTANCE_ID;//vertex shader的instanceID,告知shader顶点着色器的输入参数多了一个变量,
    };

    struct v2f
    {
      ...
      UNITY_VERTEX_INPUT_INSTANCE_ID;//frag shader的instanceID,告知shader片元着色器的输入参数多了一个变量
    };
        //↑↑↑顶点和片元数据中都保存了instanceID↑↑↑
      
   //定义多实例变量数组
    UNITY_INSTANCING_BUFFER_START(Props)//开始
    UNITY_DEFINE_INSTANCED_PROP(float4, _Color)//定义多实例的属性参数_Color
    UNITY_INSTANCING_BUFFER_END(Props)//结束
      
    v2f vert(appdata v)
    {
      v2f o;
      ...
      UNITY_SETUP_INSTANCE_ID(v); //设置InstancingID
      UNITY_TRANSFER_INSTANCE_ID(v, o); //instancingID传给片元着色器
      return o;
    }

    fixed4 frag(v2f i) : SV_Target
    {
      UNITY_SETUP_INSTANCE_ID(v); //设置InstancingID
      ...
      //设置instanceID后,就可以通过instanceID索引到相应的数据
      return UNITY_ACCESS_INTANCED_PROP(Props,_Color);//获取当前渲染实例的Color属性
    }
}可以简单理解为:在集合中保存了不同位置要渲染的模型位置、缩放和角度,根据intanceID索引集合中的位置,用同一个模型根据索引到的参数进行渲染。
那么这些数据是如何给到GPU的呢?这里还是摘取书里的代码。
//获得各个属性的索引,他们将用于后面对具体的属性进行索引
int position_loc = glGetAttribLocation(prog, "position");
int normal_loc = glGetAttribLocation(prog, "normal");
int color_loc = glGetAttribLocation(prog, "color");
int matrix_loc = glGetAttribLocation(prog, "model_matrix");

//配置顶点和法线
//将数据绑定到OpenGL缓存以注入OpenGl
glBindBuffer(GL_ARRAY_BUFFER, position_buffer); //绑定顶点数组
//告诉OpenGL每个数据对应的格式
glVertexAttribPointer(position_loc, 4, GL_FLOAT, GL_FALSE, 0, NULL); //定义顶点数据规范
glEnableVertexAttribArray(position_loc); //按上述规范,将坐标数组应用到顶点属性中去

glBindBuffer(GL_ARRAY_BUFFER, normal_buffer); //绑定法线数组
glBertexAttribPointer(normal_loc, 3, GL_FLOAT, GL_FALSE, 0, NULL); //定义发现数据规范
glEnableVertexAttribArray(normal_loc); //按上述规范,将法线数组应用到顶点属性中去

//开始多实例化配置
//设置颜色的数组。我们希望几何体的每个实例都有一个不同的颜色,
//将颜色值置入缓存对象中,然后设置一个实例化的顶点属性
glBindBuffer(GL_ARRAY_BUFFER, color_buffer); //绑定颜色数组
glVertexAttribPointer(color_loc, 4, GL_FLOAT, GL_FALSE, 0, NULL); //定义颜色数据在color_loc索引位置的数据规范
glEnableVertexAttribArray(color_loc); //按照上述的规范,将color_loc数据应用到顶点属性上去

glVertexattribDivisor(color_loc, 1); //开启颜色属性的多实例化,1表示每隔1个实例时共用一个数据

glBindBuffer(GL_ARRAY_BUFFER, model_matrix_buffer); //绑定矩阵数组
for(int i = 0 ; i<4 ; i++)
{
        //设置矩阵第一行的数据规范
        glVertexAttribPointer(matrix_loc + i, 4, GL_FLOAT, GL_FALSE, sizeof(mat4), (void *)(sizeof(vec4)*i));

        //将第一行的矩阵数据应用到顶点属性上去
        glEnableVertexAttribArray(matrix_loc + i);

        //开启第一行矩阵数据的多实例化,1表示每隔1个实例时共用一个数据
        glVertexattribDivisor(matrix_loc + i, 1);
}
10.10 着色器编译过程

shader会在引擎需要的时候,调用图形接口读取代码,然后进行编译,shader代码只需要编译一次就可以重复利用。
在编译shader时,引擎会调用glCreateShader为shader创建一个shader对象。然后读shader代码,将其和创建的shader对象关联,然后调用glCompileShader进行编译。glGetShaderInfoLog可以获取编译信息来检查编译是否成功。
但这个shader对象并不是用于描述整个shader的,它可能是一个顶点着色器,片元着色器或细分着色器或其他。这时会调用glCreateProgram创建一个着色器程序,然后调用glAttachShader来绑定他们。调用glLinkProgram来生成可执行程序。

[*]glGetProgramiv:查询链接操作结果
[*]glGetProgramInfoLog:获取程序连接日志,检查是否成功
[*]glUseProgram:运行着色器程序
Unity会为每个Pass生成一个着色器程序,然后按照先后顺序调用他们。
shader变体:导致shader编译卡顿的原因。因为Unity自身带有很多很多宏编译指令,他们会导致生成多种不同变体的shader文件。或者说Unity把不同编译版本的shader拆分成了多个shader源文件,运行时挑选合适的。
“实时编译着色器是非常耗时的操作,如果没有提前编译着色器而在场景中使用着色器,就会不断地有不同的着色器被实时编译,这是导致游戏卡顿的重要原因之一。”
可以使用shader_feature设置预编译宏,这不会讲没有用到的shader变体打进包内。
“因此为了应对变体在运行时的编译消耗,我们通常会在运行时提前将所有着色器变体编译一下,使得运行中不再有着色器编译的CPU消耗。”
10.11 Projector投影原理

根据Projector组件自身的范围,检查进入他范围内的物体,取得他们的模型数据,计算他们的投影矩阵(Projetor组件视体空间的投影矩阵),传入shader中,根据投影矩阵渲染这些物体。
//一个简单的投影shader
sampler2D _MainTex;
//Projector组件的投影矩阵
float4x4 unity_Projector;
struct v2f
{
        float4 pos:SV_POSITION;
        float4 texc:TEXCOORD0;
}

v2f vert(appdata_base v)
{
        v2f o;
        o.pos = mul(UNITY_MAXTRIX_MVP, v.vertex);
        o.texc = mul(unity_Projector, v.vertex);
        return o;
}

float4 frag(v2f i) : COLOR
{
        float4 c = tex2Dproj(_MainTex, i.texc);
        return c;
}这篇系列终于完结了,但仍然有很多不明白的地方,也有很多地方加入了自己的理解,难免有错,仍需斟酌,但对于这部分内容,等到理解加深,还是需要进行修改。
页: [1]
查看完整版本: 《Unity主程手记》摘要记录(9-10)