- 计算机图形领域,渲染目标是现代图形处理单元(GPU)的一个特性,它允许将3D场景渲染的结果存储到中间存储缓冲区或者是一张纹理上(RTT),而不是帧前缓冲区或者帧后缓冲区,然后通过像素着色器(片元着色器)对该结果操作,以便在显示最终图像之前将其他效果应用于最终图像 ——————维基百科
- 也就是说,渲染目标是一个缓冲(区别于 screen frame buffer 和 back frame buffer之外的缓冲区),是用来记录暂存渲染结果,而不是直接绘制到屏幕,而是使用在其他地方。离屏渲染就可以通过render target来实现。
- 需要注意,纹理不是渲染缓冲区,渲染缓冲区也不是纹理。帧缓冲区可以使用他们中的任何一个作为渲染目标。
将FrameBufferObject 连接到一个 server-side(GPU端)的texture对象。
- server-side Texture
纹理Texture 最开始是在存在CPU(client-side)的内存上,最终被送到 GPU(server-side) 存储中进行处理,texture 在 CPU 和 GPU之间的copy 通常比较关系带宽瓶颈。 (https://gwb.tencent.com/community/detail/114085) - FrameBufferObject 帧缓存对象,也就是渲染目标,GPU中渲染结果输出的目的地,绘制的所有结果(color,depth,stencil)等。存在一个默认的FBO,直接与显示设备窗口相连,将绘制结果显示到窗口。现代GPU允许创建多个FBO,这些FBO不是直接与显示器窗口相连,而是将绘制的结果存放在GPU中,在后续需要时使用。
绘制结果渲染到FBO后,通常以纹理Texture的方式去获取这些绘制结果:
- 将FBO的绘制结果传回CPU(client-side)的texture,一般使用 ReadPixel()这样的函数读取像素,将FBO的绘制结果copy到CPU侧的buffer,从GPU存储中copy到CPU内存。
- 将FBO的绘制结果copy到GPU中的另一个texture,一般使用 CopyTexture()这样的函数,将可读FBO的绘制结果copy到GPU上的另一个texture对象,直接 copy 到 GPU(server-side),意味着GPU马上就可以使用,没有从CPU再传递到GPU的过程
- 将这个FBO直接关联到GPU(server-side)的texture对象上,这样绘制时就直接回知道texture对象上,省去copy时间,一般使用 FrameBufferTexture2D() 接口。
- 场景在渲染过程中,为了避免在绘制过程中出现闪烁,通常处理是将一帧的绘制过程在屏幕外进行,屏幕上显示已经绘制完成的完整帧图像,通过这种方式,观察者看不到帧绘制过程,只能看到之前已经绘制完成的帧。为此,硬件设置了两个缓存,一个是前置缓存(front/screen frame buffer),一个是后置缓存(back frame buffer)。
- 前置缓存执行当前显示在屏幕上的图形数据,与此同时,下一帧在后置缓存中计算。
- 绘制完成后,两个缓存关系翻转,前置变后置,后置变前置,循环操作,该过程称为呈现(presenting/swap)。
- 呈现过程是高效的,只需要前置缓存和后置缓存指针来回交换。
- 除了双缓存设计,还有三缓存设计方式。
- 前向渲染是大多数引擎使用的标准渲染路径,是一个线性渲染过程,每次将几何图形通过着色器流水线处理,生成最终图像。每一帧进行渲染时,每个顶点/片元都要执行着色器,并计算所有的光照信息,即使光源离像素对应的世界空间中的距离很远,依然会考虑进去。例如一个物体受N个光源影响,在进行渲染着色计算时,必须把这N个光源都传入着色器当中进行光照计算,如果场景中有M个物体,则这M个物体都要进行在着色器计算过程中将受到影响的N个光源进行光照计算。
- 前向渲染缺点
场景中如果存在模型较多,光源数量较多,则必须为屏幕每个多边形的可见片段执行昂贵的光照计算,无论是否被重叠或被另一个多边形遮挡(深度测试在片元着色器之后),许多遮挡没有通过深度测试的片元中的光照计算实际上被浪费掉。因此,有部分引擎通过切掉远处的灯光,组合灯光,或者使用光照贴图来提高性能。 - 前向渲染优点
可渲染透明对象
支持多个光照Pass
支持自定义光照计算
延迟渲染是主要解决大量光照渲染的方案,其实质是先不做大量三角形的光照计算,而是先找到最终可见的像素,在进行光照计算。延迟渲染将渲染分成两个过程,一个是几何处理过程,一个是光照处理过程。
- 几何处理过程
首先将整个场景渲染一遍(不处理光照计算),获取到的待渲染对象的各种几何信息存储到G-Buffer缓冲区中,这些缓冲区中的数据用来后续做光照计算处理后输出,由于在进入缓冲区之前有深度测试,因此最终进入到缓冲区的像素都是最终在屏幕上可见的像素。 - 光照处理过程
该过程中,光照计算pass会遍历G-buffer中的位置,颜色,法线等参数,进行光照计算。 - 延迟渲染缺点
此过程需要支持多渲染目标的显卡,较旧的显卡不支持
需要更高的带宽,G-buffer需要更大的缓冲区存储像素信息
不能使用透明对象(可结合前向渲染解决该问题)
对MSAA不友好
需要大带宽支持 - 延迟渲染优点
处理大量光照场景有优势
只对最终可见像素进行光照计算
对后处理支持更好,G-buffer 保存有后处理需要的多种数据
使用更少的 pass ,DrawCall 调用更少,对应于前向渲染的 O(m*n)复杂度,延迟渲染复杂度为 O(m+n),假设场景中有m个物体,n个光源,并且每个物体都受到光源影响。
Ambient Occlusion: 通过场景中的某个点来产生一个标量值,用来表示从该点向各个方向发出的射线被遮挡的概率。该标量值用来产生全局的遮挡效果,为用户提供关于场景中物体位置关系和表面起伏情况的视觉信息。AO的计算通过半球面上可见性积分得到,具体计算方式:AO计算原理
SSAO是一种基于屏幕空间的AO算法
AO的半球积分计算复杂度高,一般应用于离线渲染,在实时渲染中需要计算效率更高的算法,而SSAO是实时渲染中常用的算法。使用当前视点的深度缓存当成场景中物体遮挡关系的粗略近似代替光线来求AO。
- 与场景复杂度不相关,与场景中的顶点数和三角形没有关系,仅和投影后的像素有关
- 相比于传统AO,无需预处理,无需加载时间,和分配内存,适用于动态场景
- 对每个像素的处理流程相同,可以由GPU进行并行处理
- 可以集成到现代渲染管线中
对屏幕空间里的像素计算在三维空间的位置点 p :
- 以 p 为球心,R为半径的球体内随机产生三维采样点
- 估算每个采样点的AO:取得采样点在深度缓存中的投影点的AO遮蔽近似代替采样点的遮蔽
- 投影点遮蔽的计算方法,不同的算法计算方式不同,最简单直接的是以投影点的深度值与p的深度值差异作为近似遮蔽,这样会有自身遮蔽走样问题,一种改进方法是引入法向缓存作为判断,使所有的采样点都在P点的上方,并利用采样点与投影点的深度值差异作为遮蔽近似
- 平均所有采样点的遮蔽值
SSAO所有计算都发生在屏幕空间,其计算效率较高,但在其精度上比不上基于顶点的AO计算结果,因此SSAO是牺牲了一定精度换取实时渲染的速度
IBL基于图像的光照是实现丰富环境光照效果的一种方式,一般是通过一张环境贴图(EnvironmentMap)来保存周围环境信息,通过采样处理得到另一张环境贴图实现丰富的环境光照效果
- 获得环境贴图
- 预计算辐射光照贴图
输入为环境纹理,输出也是一张环境纹理,计算过程中使用球形坐标系简化计算方程,使用黎曼和求解积分 - IBL光照计算
片元法线采样来计算采样
参考: 基于物理的渲染PBR之基于图像的光照IBL
直接光照下的反射:光源经过物体表面直接反射进入眼睛的光照
间接光照下的反射:当物体表面比较光滑,像镜面或光滑金属,其表面会反射周围环境,反射的光线进入人眼。
间接光照下的反射,严格计算的话比较复杂:
- 需要对表面像素在其法线方向做半球积分
- 光线反射会持续传递,直至能量衰减为0
实时渲染中,严格计算间接光照反射比较困难,因此通过模拟手段来近似反射效果。常用的技术方案包括:
- 环境贴图反射
- IBL反射
- 平面反射
- 屏幕空间反射
环境贴图反射
环境题图反射(CubeMap反射)是最简单的反射效果模拟方案,通过计算反射的向量方向从环境贴图中采样,从而实现反射效果
IBL反射
环境贴图反射采样得到的是完全的镜面反射效果,而没有考虑到反射表面的粗糙度,IBL反射则是考虑到反射面的粗糙度问题。在 PBR 的IBL环境光计算中,考虑到粗糙度,根据表面的粗糙度来计算环境纹理的平均值。由于它需要计算一个区域内的像素,而发射多条光线进行采样计算平均值比较慢,因此采用CubeMap的mipmap,将环境贴图通过计算不同粗糙度映射下的mipmap等级,储存在mipmap纹理中,这样在实际计算间接光照的反射时,根据粗糙度的映射取到不同级别的CubeMap纹理值。
平面反射
对于完美的平面反射,可以以当前相机视线相对于平面求的反射方向,再由相对于平面对称的位置以反射方向进行场景渲染,并将渲染结果存储在纹理中,然后将该纹理应用于反射平面,得到完美的镜面反射,同时若是粗糙平面,可以将该纹理进行模糊后处理。
屏幕空间反射(Screen Space Reflection)
屏幕空间反射(Screen Space Reflection SSR) 是一种基于屏幕空间的技术,算法原理如下:
- 对于当前屏幕空间上的每个物体的像素,根据该像素的法线和视线方向,求得反射方向
- 当前点沿着反射方向在屏幕空间进行光线步进,判断步进后的坐标深度与深度缓存中存储的的物体深度值是否相交
- 若相交,则取交点处的物体颜色作为最终的反射颜色
SSR 的优缺点:
优点:
- 针对任何面都可以进行实时反射,不需要平面
- 不需要额外的DrawCall,没有Planar Reflection 翻倍DC问题,计算在GPU上
- 只需要额外的后处理Pass, 无需大规模改动引擎管线,容易集成
- 可以与Reflection Probe结合使用
缺点:
- 需要全屏深度和全屏法线,延迟渲染管线中可以从G-Buffer直接拿到,对于前向渲染,需要额外渲染一遍 DepthNormalMap
- Shader中需要进行 raymarching ,对于GPU的负载较大,同时步进存在一定步长,精度上不能保证非常精确
- 技术本身局限,只有屏幕内的可见物体信息,不在屏幕内的,无法计算反射信息
Q&A:
为什么采用屏幕空间进行步进而不是世界空间进行光线步进计算?
世界空间中进行等步长距离的光线步进后,通过空间变换最终映射到2D屏幕空间上时,步进点之间的距离在变换后不再均匀,会出现某一像素点重复采样或者会跳过部分像素造成欠采样,而使用屏幕空间步进可以保证采样的像素点是连续的,同时也不会出现重复采样和欠采样问题
图形渲染过程中,三角形通过顶点着色器->光栅化->像素(片元)着色器的顺序进行屏幕像素点的着色计算。当在光栅化处理三角形边缘像素时,像素并没有被完整覆盖,此时会进行判断,当像素中心点处于三角形内,该像素会被着色,若像素中心点处于三角形之外,该像素就不会被着色,由于不是按照三角形覆盖边缘像素的面积与整个像素的面积比来进行像素颜色插值,锯齿由此产生。
抗锯齿最早使用的理论方法是,将目标渲染图形按照 xN 倍进行渲染,再将整个图像均值采样后缩小。例如,要渲染800x400像素的图像,先渲染一张1600x800的图像,再将图像采样后缩小,图像缩小的处理方式是直接将原始图像对应的4个像素颜色进行均值。这种方法就是 超采样抗锯齿 SSAA(Super Sampling Anti-Aliasing),将图形先进行放大渲染,再采样缩小,这种方式产生的性能消耗较大,一般不用于实时渲染。
常用的思路及对应抗锯齿方式
- 在每个像素中进行多次采样,综合计算像素色值
使用这种思路抗锯齿方式有 MSAA(空域) TAA(时域)
-
MSAA是最早出现的抗锯齿技术,主要通过硬件实现,在以前是比较流行的抗锯齿方式。虽然也是通过多重采样,与SSAA不同的是,MSAA不会对采样像素的次级4个像素都进行单独的着色计算,而是只进行一次着色计算,4个次级像素共享采样像素的着色计算结果,这样能减少内存的占用,同时对于4个次级像素的颜色结果的存储可以做进一步优化,不采用单独的RGBA格式来存储每一个次级像素的颜色,而是将不同的颜色储存,次级像素只储存颜色索引,这样能减少同样颜色所占用的空间,进一步节省空间。
MSAA使用简单方便,抗锯齿效果好,但同时会消耗大量内存,PC端会消耗大量带宽,即便是在基于TBR的移动端,MSAA从Tile中采样,复杂场景仍然会产生大量的消耗。 -
TAA是通过对历史帧进行每个像素点的多次采样,结合来自过去帧和当前帧的信息以去除当前帧中的锯齿,每个像素点的采样分摊到多个帧的计算中,相对开销就要小很多。
对于静态场景,在每帧采样时,通过改变投影矩阵中位置的偏移,来将采样点进行偏移,和MSAA中次像素采样点的位置一样,使用低差异化的采样序列,获得更好的抗锯齿效果。在对历史帧进行混合时,为了计算方便,不会保留多个历史帧的结果,而是当前帧和上一帧得到的历史混合结果做混合。需要说明的是,由于如果直接使用HDR的颜色来做TAA的输入效果不佳,会影响到光晕等特效,因此TAA在进行最终的ToneMapping之前,但这样会影响到后处理中Bloom等特效计算,因此在TAA之前执行一遍Tone Mapping,然后反转,在进行后处理之后在进行一次 ToneMapping,得到最终渲染结果。
对于动态场景,物体本身移动比较复杂,包含旋转平移缩放,再加上摄像机本身的移动,直接在混合时进行计算的话,计算会非常复杂。
对历史数据混合,要能够还原出当前物体在上一帧中屏幕中投影的位置,为了精确记录物体在屏幕空间中的移动,使用 Motion Vector贴图来记录物体在屏幕空间中的变化距离,表示当前帧和上一帧中,物体在屏幕空间投影坐标的变化值,使用高精度的贴图格式存储,对该纹理采样后,使用当前的投影坐标减去变化值,就得到上一帧中物体对应的屏幕投影坐标,利用该坐标在历史帧数据中进行采样。同时Motion Vector除了可用于TAA,还可以用于运动模糊/Motion Blur的效果。
另外需要考虑一些基于UV变化的动画效果,需要将这种偏移转化为屏幕空间中的移动,平面反射效果,需要推导反射时使用的投影矩阵,抖动,以及反射的位置信息。
历史结果处理。由于像素抖动,模型变化,渲染光照变化导致场景渲染结果变化时,会导致历史帧得到的像素值失效,这是会产生 鬼影/Ghosting 和 闪烁/Flicking 问题,为了解决这些问题,需要对采样的历史帧和当前帧数据进行对比,将历史帧数据截断/Clamp到合理范围内。
- 通过后处理找出像素块边界,两侧插值
获取渲染图像和对应的亮度数据,使用亮度数据进行高通滤波查找对比度高的像素,即可能是边缘像素,以相关像素为中心,计算周围像素的混合因子来进行像素混合。这种基于后处理的方式,检测图像边缘的抗锯齿方式也叫做 形变抗锯齿/Morphological antialising。
目前较为主流的形变抗锯齿是 FXAA 和 SMAA
- FXAA (Fast Approximate Anti-Alising) 快速近似抗锯齿
FXAA是一种比较注重性能的形变抗锯齿,只需要一次PASS就能得结果,FXAA注重快速的视觉抗锯齿效果,而并非追求完美的真实抗锯齿效果。FXAA 有两个版本,一个是注重抗锯齿质量的 FXAA Quality,另一个是注重抗锯齿速度的 FXAA Console。
FXAA Quality。通过计算处理像素和周围像素的亮度对比度,FXAA通过确定水平和垂直方向上像素点的亮度差来计算对比值,对比值较大,认为需要进行抗锯齿处理。通过计算目标像素和周围像素点的平均亮度差值,来确定进行颜色混合的权重系数。接着确定进行混合的方向,锯齿边界不会刚好是水平或者垂直的,如果水平方向亮度变化大,锯齿边界看作靠近垂直,按照水平方向进行混合,如果垂直方向亮度变化大,锯齿边界看作靠近水平,按照垂直方向进行混合。
仅通过亮度差值得到的抗锯齿效果并不是最佳,特别是在出现斜向的锯齿边缘时,因此 FXAA Quality 还会考虑斜向锯齿边缘的情况。为了得到正确的混合系数,像素采样范围需要扩展到目标像素3X3之外,找出锯齿边缘倾斜角度。该过程首先计算出当前像素点和待混合像素点之间的亮度差值,作为锯齿边界两端亮度变化的梯度值,然后向边界两侧的方向搜索,直到找到锯齿边界,判断的依据就是两侧亮度值的差值是否符合当前亮度变化的梯度值,超过既定值,则认为已经找到边界,最后按照与目标像素的距离来估算混合系数。
FXAA Console可以看成是FXAA Quality的简化版,FXAA Quality在考虑斜向锯齿的情况下,采样的点是会超过 3X3 的,Console版每次只进行5次采样,Console 寻找当前像素点亮度变化最大的梯度值,作为锯齿的法线方向,进一步得到切线方向,这种方式对与斜向锯齿的效果较好,对于水平方向的锯齿会造成效果不佳,因此会扩大采样范围向切线方向延伸,Console版本最多的采样次数不超过9次,比Quality版本少很多,得到的效果整体比较模糊,不及Quality版。
FXAA 集成比较方便,只需要一个Pass来实现抗锯齿,是目前手机上最常用的抗锯齿。FXAA缺点就是画面略有模糊,FXAA是基于后处理判断边界实现,没有次像素的特性,在光照高频(颜色变化快)的区域会不稳定,在移动摄像机时会出现一些闪烁。 - SMAA
SMAA原理和FXAA Quality 原理近似,但是整体效果更加精细。SMAA 是 MLAA(Morphological Antialiasing)的加强版。
MLAA 处理流程:
- 更加准确的边界判断,减少MLAA中误判
- 转角保留,避免某些几何体的棱角被错误的判断为需要AA处理
- 对角线模式,加强对斜向边界查找
- 更精确的边界搜索
SMAA 对锯齿的处理非常精细,得到的效果也很好,是基于后处理的抗锯齿中的极限,SMAA和FXAA一样,由于是基于后处理,并没有次像素特性,在亮度高频变化的区域,移动摄像机可能会出现闪烁。SMAA 需要三次PASS,比FXAA 的性能消耗更大,对PC来说相对轻松,而在手机上,RenderTarget的切换会有比较大的开销,因此FXAA在手机上使用较多,虽然画面会模糊一点,但在接受范围内。
文章来源:GPU 渲染管线和硬件架构浅谈
桌面端GPU渲染管线及架构
桌面端常见GPU架构,每个drawcall中,GPU按照顺序处理图元
-
应用阶段:粗粒度剔除(遮挡剔除),渲染状态设置,顶点着色数据准备 (CPU内完成)
-
几何阶段(顶点处理阶段):顶点着色器,曲面细分,几何着色器,顶点裁剪,屏幕映射,该过程会做背面剔除裁剪
-
光栅化阶段:三角形设置,三角形遍历,片元着色器
-
逐片元操作:裁剪测试,深度测试,模板测试,混合操作
-
优势:
渲染管线连续,有利于提高GPU最大吞吐量,最大化利用GPU性能,从vertex到raster处理都可以在GPU内部on-chip buffer上进行,所以只需少量带宽,存取处理过程中图元数据。 -
劣势:
IMR是全屏绘制,当前绘制图元可能是屏幕的任意位置,因此需要全屏buffer,该buffer内存很大,只能放在系统内存中,这样在与系统内存buffer的数据进行交互时,需要消耗大量带宽,对于移动端来说过于昂贵。
移动端渲染管线及架构
对于移动端,控制功耗很重要,功耗高带来发热降频,会是画面出现严重卡顿和掉帧。带宽的大量消耗会造成明显耗电和发热。因此移动端对于功耗控制尤为谨慎,普遍使用 TBR/TBDR
- TBR:Tile-based Rendering
TBR不绘制整个屏幕,而是将屏幕分成不同的Tile,GPU每次只绘制一个Tile,绘制完后再将绘制结果写入系统内存的frameBuffer,处理过程:
- 处理所有的顶点,生成tile list中间数据,该数据保存顶点归属于屏幕的哪一个Tile
- 针对每一个Tile执行光栅化,片元着色器过程,每个Tile处理完毕之后,写入系统内存中
-
优势
TBR减少了对系统内存的访问,减少了带宽开销,Tile足够小,可以在GPU on-chip-memory 上,访问速度快,后续的片元着色器阶段同样可以在 on-chip-memory上处理,depth buffer 和 stencil buffer 都只在Tile中处理才会用到,不用写入系统内存,进一步节省带宽 -
劣势
TBR需要先对所有顶点进行处理,生成tile list ,这一步会产生明显的延迟,顶点越多消耗就越大,对于曲面细分在TBR下会显得昂贵,顶点量过大会带来性能严重下降 -
TBDR:Tile-based Deferred Rendering
TBDR 和 TBR 架构很类似,区别在于,TBDR多了一个隐面剔除,即 HSR&Depth Test这个步骤,通过HSR,最终只有对屏幕产生贡献的像素会执行片元着色器,被遮挡的片元会被丢弃,避免执行无效的片元着色器
- CPU 核心少(计算单元少,内部结构复杂),每个核心有控制单元和独立缓存,设计上是大缓存,要求低延时
- GPU 计算单元多,多个计算单元共享控制单元和缓存,内存设计上是追求高带宽,可以接受高延时
- CPU经常用来执行串行和计算复杂度高的任务,GPU则用来处理高并行,相互独立,复杂度低的任务,通过多核心来抵消任务处理过程中的任务排队时间
CPU的缓存体系和指令执行过程
内存硬件类型
- SRAM (Static Random Access Memory,静态随机存取内存) 静止存取数据作用,无需刷新电路即可保存数据,断电后数据消失,速度较DRAM快,一般用作片内缓存(on-chip-cache),如L1,L2 Cache
- DRAM(Dynamic Random Access Memory,动态随机存取内存)需要不断刷新电路,否则数据会消失。常用于做内存,容量比SRAM大,一般用作系统内存,桌面端目前都是DDR SDRAM(Double Data Rate Synchronous Dynamic Random-Access Memory)
- GDDR (Graphic DDR) 用作显存,时钟频率更高,耗电量更少
- LPDDR SDRAM (Low Power Double Date Rate),是移动设备常用低功耗SDRAM,以低功耗小体积著称,Frame Buffer存放于此
- UFS 通用闪存存储,移动磁盘设备
CPU缓存体系
CPU有L1/L2/L3 三级缓存,L1与L2缓存在核心内部,L3为所有核心共享,缓存是SRAM,比系统内存DRAM速度快很多
- L1/L2 缓存是片上缓存,读取速度快,容量小。L1通常在32Kb - 256Kb,而L3缓存可以达到8Mb - 32Mb级别
- L1级别缓存分为指令缓存(I-Cache)和数据缓存(D-Cache),CPU针对指令和数据有不同缓存策略
- L1缓存不能设计太大,增大L1缓存,会增加访问的时钟周期,对于低延时来讲,降低了L1 Cache性能
- CPU的L1/L2缓存需要处理缓存一致性问题,不同核心之间L1缓存之间数据一致,一个核心中的L1缓存数据发生变化,其他核心中相应数据标记为无效,而GPU的缓存无需处理该问题(GPU缓存主要用来提高线程服务,而不是存储数据)
- CPU的数据查找按照 L1->L2->L3->DRAM的顺序进行,当数据不在缓存中时,需要从系统内存中加载,会造成较大延时
- 缓存对于CPU性能非常重要,很多时候缓存会占据芯片一半以上面积和晶体管。苹果A14/A5/M1 芯片性能的强劲与超大缓存密不可分
CPU指令执行过程
-
指令流水线五个阶段
- 取指令(Instruction Fetch)从指令缓存(I-Cache)中取指令放入指令寄存器中
- 解码指令(Instruction Decode),这里还通过寄存器文件(Register File)取到指令的源操作数
- 执行指令(Execute)
- 访问数据(Memory Access) 如果需要存储器取数据,通过数据缓存(D-Cache)取数据
- 结果写入(Register Write Back)将指令执行结果写入寄存器
-
访问主存,CPU和GPU通常会出现较大延迟,面对延迟,CPU和GPU处理策略不同。CPU通过大容量高速缓存,分支预测,乱序执行遮掩延迟,CPU缓存容量大于GPU,GPU没有L3级缓存,且CPU缓存是以低延迟为目标设计。GPU则是通过大量的计算单元切换线程Wrap来规避延迟。
-
分支,CPU通过分支预测,提高指令流水线的执行性能,GPU无分支预测单元,不擅长执行分支(在Shader中执行分支操作耗性能)。
-
超标量设计,CPU可以同时发射多条指令,并使指令并行计算,充分利用计算单元。
-
乱序执行,若按照原有指令顺序执行,可能指令之间有依赖无法并行执行,或者频繁出现高延迟指令。CPU会保证执行结果正确基础上,调整指令执行顺序,使指令更加高效执行,减少执行等待,提高管线性能。
-
线程切换,CPU线程切换会有上下文切换的性能开销(线程切换需要保存寄存器和程序计数器,切换回来时需要恢复寄存器和程序计数器)CPU尽量避免频繁线程切换。GPU寄存器数量多,线程切换时不需要保存上下文,所以可以通过零成本切换线程来遮掩延迟。
桌面端 GPU 硬件架构
不同的GPU,架构差异较大,一般包含以下核心组件:
- SM,SMX,SMM(Streaming Multiprocessor)。GPU核心,执行 Shader 指令的地方,一个GPU由多个SM组成。Mali种类似单元叫 Shader Core,PowerVR 中为 Unified Shading Cluster(USC)。
- Core 真正执行指令的地方,NVIDIA 中为CUDA Core
- Raster Engine 光栅化引擎
- ROP(Raster Operation) depth testing blending 等操作在这里完成
- Register File ,L1 Cache L2 Cache 寄存器和各级缓存
Shader Core 主要构成单元
- 32个 运算核心 (CUDA Core,也叫 流处理器 Stream processor)
- 16个 LD/ST (Load/Store) 模块来加载和存储数据
- 4个 SFU(Special function units) 执行特殊数学计算(sin,cos,log)等
- 128K 寄存器 (Register File) 3万个 32-Bit 寄存器,大寄存器设计
- 64K L1缓存 (on-chip Memory)
- 纹理读取单元 (Texture Unit)
- 指令缓存 (Instruction Cache)
- Wrap Schedules:这个模块负责 Wrap 调度,一个wrap由32个线程组成,wrap调度器的指令Dispatch Unit 送到 Core 执行
GPU 内存结构
- UMA(Unified Memory Architeture)
- 左图为桌面端独显的分离式架构,CPU和GPU使用独立的物理内存,右侧为移动端的统一内存架构,CPU与GPU共用一个物理内存
- UMA 并不是指CPU,GPU内存合并在一起,实际上他们使用的内存区域不一样,物理内存中有一块区域由GPU自己管理。CPU和GPU的数据通信依然有拷贝过程。
- 移动芯片都是Soc (System on Chip),CPU和GPU等元件在一个芯片上,芯片面积不能做的很大,不能像桌面端为显卡配置GDDR显存,并且通过独立的北桥 PCI-e 通信。移动端CPU和GPU使用同一个物理内存,更加灵活,操作系统可以决定分配给GPU的显存大小,但是会造成CPU和GPU抢占带宽,会进一步限制GPU能使用的带宽
- GPU使用独立的显存空间可以对Buffer 或者 Texture 做进一步优化
GPU 缓存分类
-
- L1缓存是片上缓存(on-chip),每个 shader 核心都有自己独立的 L1 缓存,访问速度快。移动GPU会有Tile Memory,也就是片上内存(on-chip memory)
- L2缓存是所有shader核心共享,属于片外缓存,距离shader核心比L1缓存远,访问速度较L1缓存慢
- DRAM 是主存(系统内存,System Memory),访问速度最慢,Frame Buffer放在主存上。
-
- 寄存器访问速度是最快的,GPU的寄存器数量很多
- Shared Memory和L1 Cache是同一个硬件单元,Shad red Memory 是可以由开发者控制的片上内存,L1缓存是GPU控制的,开发者无法访问,部分移动端芯片没有Shared Memory
- Local Memory和Texture/Const Memory 都是主存上的内存区域,访问速度比较慢
NVIDIA 的内存分类
NVIDIA 的内存分类主要是针对 CUDA 开发的,与游戏开发或者移动GPU的分类叫法存在部分差异
- 全局内存(Global memory),即主存
- 本地内存(LOcal memory),是Global中的一部分,每个线程私有,主要用于处理寄存器溢出,或者超大uniform数组,访问速度很慢
- 共享内存(Shared memory),Shared memory 是片上内存,访问速度较快,是一个shader核心内所有线程共享
- 寄存器内存(Register memory),访问速度最快
- 常量内存(Const memory),Const memory和Local memory类似,是Global memory 中的一块区域,访问速度较慢
- 纹理内存(Texture memory),与 Const memory一样,也在主存上
Cache line
- GPU 和缓存之间的内存交换不是以字节为单位,而是以 Cache line为单位,Cache line是固定的大小,比如CPU的Cache line 是64字节,GPU是128字节
- Cache Line的数据内容为:标记位+地址偏移+实际数据,标记位是为了确定缓存是否命中,是否写入主存
Memory Bank 和 Bank Conflict
- 为提高内存的访问性能,获得更高的带宽,Shared Memory/L1 Cache 被设计成更小块的bank区域(bank可以理解为Memory 的对外窗口,多个窗口可访问比只有一个窗口要高效),bank 数量一般与wrap大小或者CUDA core的数量对应,如32个core就把SMEM划分为32个bank,每个bank包含多个cache line
- 如果一个wrap中的不同线程访问的是不同的bank,那么可以并行执行,最大化利用带宽,提高性能
- 如果访问的是同一个bank中的同一个cache line,通过广播机制同步到其他线程,这样一次访问,其他线程都能获取到数据,不会存在性能问题
- 如果访问的是同一个bank上的不同cache line,会形成阻塞,必须阻塞等待,串行访问,这种情况会阻碍GPU的并行性,性能会下降,被称为 bank conflict
- 如果不同线程对同一个cache line 产生写入操作,也必须阻塞等待,串行写入
GPU 运算系统
-
SIMD(single instruction multiple data)和 SIMT(single instruction multiple thread)
- 游戏引擎内,常使用SSE加速计算(如视锥体裁剪计算) ,利用的是SIMD,单指令计算多个数据
- GPU的设计为了满足大规模并行计算,因此GPU是典型的SIMD/SIMT 执行模式,GPU内部将若干相同的输入打包成一组并行执行
- Vector processor和Scalar processor 概念。早期GPU是Vector processor(对应SIMD),因为早期处理的是颜色数据,处理rgba四个分量,编译器尽可能将数据打包成vec4来进行计算,随着图形渲染及GPU发展,处理的数据越来多样复杂,数据不一定有必要打包成vec4,从而可能导致性能浪费。因此现代GPU改进为Scalar processor(对应SIMT模式)
上图左侧为vector处理器,右侧为scalar处理器。对于vector处理器而言,是在一个cycle内计算(x,y,z)值,如果没有被被填满,将会造成空间浪费,而Scalar处理器是在3个时钟周期内分别计算x,y,z的值,可以同时支持4线程计算,不会造成处理器性能浪费。 - 现代GPU为SIMT执行架构。传统SIMD是一个线程调用向量处理单元(Vector ALU)操作向量寄存器完成运算,而SIMT是由一组标量处理单元(Scalar ALU)构成,每个处理单元对应一个像素线程,所有ALU共享控制单元,如取指令/译码模块,他们接收同一个指令共同完成运算,每个线程有自己的寄存器,独立的内存访问地址以及执行分支。
- 传统的SIMD是数据级并行,DLP(Data Level Parallelism),SIMT是线程级并行 TLP(Thread Level Parallelism),更进一步的超标量(Super Scalar)是指令级并行,ILP(Instruction Level Parallelism)
- PowerVR、Adreno的GPU,以及Mali最新的Valhall架构的GPU都支持Super Scalar,可以同时发射多个指令,由空闲ALU执行,即同一个pipeline内的多个ALU元件可以并行执行指令序列中的指令
- 无论哪种架构,GPU的计算单元都是并行处理大量的数据,有时会直接把GPU的计算单元称作SIMD引擎
-
Wrap线程束
- Wrap是典型的单指令多线程(SIMT)的实现。32个线程为一组线程束,32个线程同时执行一样的指令,只是线程数据不一样,这就是锁步(lock step)执行,这样一个Wrap只需要一套控制单元对指令进行解码和执行,芯片可以做的更小更快。
- Wrap Scheduler 线程束调度器会将数据存入寄存器,然后将着色器代码存入指令缓存,要求指令分派单元(DispatchUnit)从指令缓存读取指令分派给计算核心(ALU)执行
- Wrap中所有线程执行的都是相同的指令,如果遇到分支,就可能出现线程不激活执行的情况(如当前指令是true的分支,但是当前线程数据条件是false),此时线程会被遮掩(mask out),其执行的结果会被丢弃。shader 中出现分支会显著增加时间消耗,在一个Wrap中的分支除非32个线程都走到if或者else里面,否则相当于所有的分支都会走一遍,然后根据条件丢弃执行结果
- 一个Wrap中的像素线程可以是不同的图元,其shader指令是一致的
- Wrap中的线程数量和SM中的CUDA core的数量并不一定是一致的,可以是Wrap为32,但是CUDA core 只有16个,这种情况下,每个core,两个cycle完成一个warp计算,power VR为类似设计
-
Stall 和 Latency Hiding(延迟隐藏)
- 指令从开始到结束消耗的 clock cycle 称为指令的 latency。延迟通常是由对主存的访问产生的,比如纹理采样、读取顶点数据,读取 Varying 等。对于纹理采样,如果cache missing 的情况可能需要消耗几百个时钟周期。
- CPU 通过分支预测、乱序执行、大容量缓存技术来隐藏延迟,GPU通过wrap切换来隐藏延迟。
- 对 CPU而言,上下文切换具有明显开销,所以CPU尽可能避免频繁线程切换。而GPU在wrap之间切换几乎无性能开销,所以当一个wrap stall了,GPU切换到下一个Wrap,之前的Wrap获得所需数据后,切换回来继续执行。这种机制是基于GPU大寄存器设计,GPU中的寄存器数量远超CPU。
- 每个Shader Core会被同时分配多个Wrap来执行,Wrap一旦进入Shader Core 中就不会离开,直到执行完毕。每个线程会在一开始分配好所需寄存器和Local Memory。当一个wrap产生Stall,GPU的core会直接切换到另外的wrap来执行,由于不需要保存和恢复寄存器状态,这个切换几乎没有成本,可以在一个cycle 内完成。
- Shader Core中的wrap调度器每个cycle会挑选Active wrap 执行,被选中的为 Selected wrap,没被选中,但是已经做好准备执行的称为 Eligible wrap,没准备好执行的称为 Stall wrap。wrap 准备好执行需要满足的两个条件:CUDA core 有空闲,所有当前指令的参数准备就绪。
- 如果Shader中使用的变量越多,占用的寄存器数量就越多,留给 wrap 切换的寄存器就会变少,从而 分配给 Shader Core 的Wrap 数量就减少了,Active wrap 就会降低,这样会降低GPU延迟隐藏的能力,降低利用率。
-
Wrap Divergence
其他概念
- Pixel Quad
- 光栅化阶段,栅格离散化的粒度最终虽然是像素级,但是离散化模块的输出单位并不是单个像素,而是 Pixel Quad (2x2 像素),因为单个的像素无法计算 ddx,ddy ,进行Early Z 判定的最小单位也是 Pixel Quad。
- 上图所示中,像素点网格被划分了 2x2 的组,这个划分组为Quad。一个三角形,即使只覆盖了一个Quad中的一个像素,整个Quad中的四个像素都需要执行像素着色器。Quad中未被覆盖的像素称为"辅助像素"。比较小的三角形在渲染时,辅助像素的比例会更高,从而造成性能浪费。
- 辅助像素依然在管线内参与整个像素着色器运算,只是计算结果会被丢弃,由于GPU与内存之间的cache line 的存在,一次交换数据的量固定,这些被丢弃的像素很多时候也不会节省带宽,会原样读入,原样输出,带宽的消耗还是在,因此尽量避免大量小图元的绘制,能更加有效利用Wrap。
- 光栅化阶段,栅格离散化的粒度最终虽然是像素级,但是离散化模块的输出单位并不是单个像素,而是 Pixel Quad (2x2 像素),因为单个的像素无法计算 ddx,ddy ,进行Early Z 判定的最小单位也是 Pixel Quad。
- EarlyZS
- 深度测试 DepthTest 和 模版测试 StencilTest 是一个硬件单元 (ROP中的硬件单元)。Early Depth Test 阶段同样可以做 Early Stencil Test,因此该过程也被叫做 Early ZS。Early-Z 可以将无效像素提前剔除,避免进入耗时严重的像素着色器,Early-Z的提最小剔除单位不是1像素,而是1 pixel quad(4像素)。
- 传统管线渲染中,depth Test会在像素着色器之后进行。depth Test 时,当发现自身被遮挡,会被丢弃。之前的计算就会造成性能浪费。现代GPU中使用了 Early- Z 的技术,在像素着色器之前,先进行一次深度测试,如果深度测试失败,就不用再进行像素着色器,因此这样在性能上就会有较大提升。
- Alpha Test 会影响 Early-Z 的执行。一方面自身不能执行 Early-Z Write 操作,因为只有当像素着色器执行完毕之后才会知道会不会被丢弃,如果通过Early- Z提前写入深度会有错误的执行结果,另一方面只有自己执行完像素着色器,写入深度之后,相同位置的后续片元才能继续执行,否则就会阻塞等待返回结果。
- 对于在像素着色器中手动修改深度插值结果,使用clip,discard等操作,都会影响Early-Z的执行。
- Hierarchical-z 和 Tile-based Rasteration
这是由硬件提供的优化- Hierarchical Z-Culling,也叫做Z-Cull。是NVIDIA硬件支持的粗粒度裁剪方案。类似于 Adreno 的 LRZ 技术,通过低分辨率的Z-Buffer来做剔除,只能精确到8X8像素块,而不是LRZ的精确到 Quad(2x2),移动平台有其他技术做裁剪剔除,同时,它和Early-Z不同。
- Tile-based Rasteration技术,光栅化也能Tile- Based,这同样是硬件厂商的优化技术。光栅化阶段通通常不会成为性能瓶颈。原神曾对树叶使用Stencil,期望通过抠图实现半透明效果来提高性能,但是因为影响到Tile- based Rastoration 的优化,反而导致性能下降。在PC平台原本一些优化技术,比如通过 discard 或者其他手段剔除掉像素,避免进入像素着色器或者ROP(减少访问主存)阶段,以此来提高性能,这些优化手段在移动平台通常是负优化。
- GPU对于大量小三角形绘制非常不擅长。GPU对顶点着色器和光栅化的优化手段有限,同时光栅化的输出最小单元是PixelQuad,大量像素级的小三角形就会必然导致wrap中的有效像素大大减少。UE5的Nanite会使用ComputeShader自己实现软光栅,来代替硬件光栅化处理这些像素级的小三角形,来获得几倍的性能提升。
- Register Spilling 和 Active Wrap
- GPU的寄存器虽然很多,数量还是有限。GPU的核心在执行一个Wrap是,会在开始把寄存器分配给每个线程,如果shader占用过多寄存器,留给GPU核心用来执行Wrap线程的寄存器数量就会减少,从而Active Wrap降低。这样会降低GPU隐藏延迟的能力,从而影响GPU的性能。如,原本在一个Wrap加载纹理产生Stall时,会切换到下个Wrap,如果Active Wrap太少,就有可能所有的Wrap都在等纹理加载,此时GPU核心产生真正的Stall,只能等待结果返回。
- 寄存器文件使用量在shader编译完就可以确定。每个变量,临时变量,部分符合条件的 uniform 变量,都会占用寄存器文件。如果shader占用的寄存器过多,比如超过64或者128,会产生更加严重的性能问题,就是 Register Spilling (寄存器溢出)。GPU会将寄存器文件存储到 Local Memory 上,Local Memory 是主存的一块区域,访问速度很慢,Register Spilling 会大大降低shader 的执行性能。(shader 变量过多会影响自身的执行性能)。
- Mipmap
- 传递给 GPU 一个带 Mipmap的纹理,GPU在运行时通过(ddx,ddy)偏导选取合适的Mipmap Level 的纹理。
- Mipmap 有利于节省带宽,并不是指 传递的数据量变小(实际上是变多了1/3),而是最终渲染的时候,相邻的像素更有可能在一个cache line 里面,提高了Texture cache 的命中率,减少与主存的交互,因此减少带宽。
- 当需要访问主存时,需要消耗几百个时钟周期,会产生严重的Stall。而提升texture cache 的命中率就可以减少这种情况的发生。在通过性能分析工具优化时,texture L1/L2 cache missing 是一个非常重要的指标,通常要控制在较低值才是合理的。
- Mipmap本身会多占用1/3的内存(低级别的mipmap图),开发者可以决定upload给GPU的最高mipmap level 。通过引擎动态控制纹理的最高 mipmap level ,反而可以有效控制纹理占用的内存用量,在许多3D游戏场景中,贴图由模糊变清晰,可能就是在动态改变mipmap纹理的level。
- 移动平台的GPU内存和CPU内存是共用一块物理内存,但其内存空间是分离的,因此纹理提交给GPU是需要upload,当纹理upload到GPU后,CPU端的纹理内存会被释放掉,这是,若将显存的纹理内存释放掉,也就相当于释放了纹理内存。
- Unity中,一部分纹理是需要CPU端读写数据的,或者编辑器下某个纹理导入选项中勾选了Read/Write Enabled。对这些纹理而言,CPU端的内存不会被释放掉,此时该纹理就占用了两份内存,CPU一份,GPU一份。
- 纹理采样和纹理过滤
图形渲染时,纹理是要贴到三维图形上的,纹理与三维图形尺寸可能会不一致。若纹理大于三维图形,那么三维图形上的单个像素将被映射到许多纹素(texels);相反,就会导致许多个像素都映射到纹理上的同一个纹素。纹理过滤(texture filter)就是要解决不一致时,纹理的采样计算。- 纹理过滤的几种模式:
- 临近点采样(Nearest Point Sampling),计算离采样中心点最近的像素,只进行一次采样。
- 双线性插值(Bilinear Interpolation),对采样中心点周围四个像素进行采样后加权平均。虽然原理上线性过滤需要4个纹理的采样,但是现代的GPU硬件已经支持单个周期内完成线性过滤的采样,换句话说它是一个原子操作,并不会拆分为多次采样再加权计算,它消耗是与邻近过滤相当的。
- 三线性插值(Trilinear Interpolation),三线性过滤方式就是在线性过滤的基础上,再从mipmap上选择最近的两层纹理,做一次加权采样。
- 各向异性插值(Anisotropic Filter),各向异性过滤是纹理过滤的终极形态,它比三线性滤波效果更好,也更复杂。三线性过滤将uv等价的考虑,最多只加权两层纹理;但是各向异性过滤,分别考虑uv两个方向的缩放,然后在多层纹理之间加权计算出最后结果。
- 现代GPU支持一个周期内完成一个双线性插值的采样。
- 三线性插值在双线性插值的基础上,再采样两层mipmap插值,消耗是双线性的两倍。
- 各项异性是采样多层mipmap,N倍各向异性就是N倍性能开销。
- 纹理过滤的几种模式:
硬件角度理解GPU执行逻辑
- GPU中可编程元件和固定管线元件
- 顶点和像素着色器为可编程,在 Shader Core执行着色器指令。
- 光栅化不可编程,由光栅引擎负责
- EarlyZ、LateZ、Blend,是固定管线,由ROP单元负责
- 固定管线单元负责特定工作,硬件制作更加简单,性能更好,功耗低。
- 硬件层面的 EarlyZ
- 在游戏引擎角度看,每个 drawcall 有它对应的 RenderState,来决定是否要 AlphaBlend ,深度写入,是否 Alpha Test 等。对于硬件而言,每个图元并不知道自己是否是Alpha Blend。当前Render State是 Alpha Blend的话,图元就按照 Alpha Blend进行绘制。当前的ZWrite是Off的,LateZ就不写深度。
- 执行EarlyZ的是硬件单元(ROP),因此不能用代码思维理解EarlyZ的执行过程,更恰当的比喻是 流水线上的阀门,它可以控制片元是否通过。因此像 discard/clip 操作会使 EarlyZ 失效是因为 只有到了片元着色器才能决定片元是否丢弃,EarlyZ阶段控制的片元是否通过并不是最终的结果。
- GPU 核心的乱序和保序
- GPU的计算核心是乱序执行的,不同的Wrap执行耗时不一样。受分支,cache missing 因素影响,GPU尽可能填充任务到计算核心。
- 同一个像素的写入顺序是有序的。先执行 drawcall 的像素一定先写入到 frame buffer 中,不同像素的写入顺序通常也是有序的。
- GPU的阶段输出结果是有序的。不同阶段之间,通过FIFO队列,保证顺序。
- 顺序与乱序机制意义。对于半透明物体,如果RO P是乱序,那么最终得到的结果是错误的。但是对于不透明物体,由于Depth Test机制,即使乱序也能保证正确的结果。
Power VR架构
-
- A10之前(iPhone7),都是使用 Imagnination Power VR 的GPU,A11(iPhone8/iPhoneXR)开始使用的是苹果自研的GPU,保留HSR特性。
- TBDR 的第一步 Tiling 结果存放于 Parameter BUffer 中,Parameter Buffer 是 System memory 上的一块数据区,大小有限。所以有可能会出现PB已经被填满,但是还有 drawcall 未执行完毕的情况,这时,会保证渲染继续进行,硬件会进行Flush,这样会带来问题,Flush前和Flush后的对象是两次绘制,两次HSR处理,这样其中如果有遮挡,无法进行合理剔除,就会造成 overdraw 增加。当PB被填满时,会导致性能急剧增加。所以应简化场景,避免出现被填满的情况。
- GPU 通过ISP 单元进行HSR(Hidden Surface removal 隐面剔除)处理。ISP也会处理深度回读。HSR是EarlyZ的完全替代品,可以像素级剔除遮挡片元。HSR的处理结果存放在TagBuffer中,Tag Buffer在片上缓存,通过Tag Buffer可以得到需要绘制的片元,只有最终对屏幕产生贡献的像素才会被绘制。
- discard/clip Alpha Test操作会卡关管线,影响性能。
-
- PowerVR Rouge架构GPU包含N个 Unified Shading Cluster,这个 USC 是GPU核心,每个USC包含16条Pipeline,每个PipeLine包含N个ALU,ALU是真正执行指令的地方,ALU的数量影响到GPU的性能。
- 上图中,两个USC共用一个Texture Unit
- MCU是L2缓存。每个USC和Texture Unit都配置独立的L1缓存,每个USC中都有一块Tile Memory,也就是片上内存。
- Rouge架构中,每个PipeLine 包含4个 FP16 的ALU,2个 FP32 的ALU 和 1 个 SFU。FP16 和 FP32 的ALU 是分离的,虽然会占用更多的芯片面积,但可以大幅减少功耗。FP16 速度更快,占用带宽更小,功耗更小。
- Power VR 是 Scalar ALU。支持超标量(Super Scalar),可以同时发射多条指令,让空闲的ALU来执行。所以一个PipeLine内部的ALU可以被充分利用起来。
- 每个Pipeline内多个ALU的设计和其他GPU不同,NVIDIA 的 CUDA Core 就只有两个ALU,一个FP32 ALU 和一个 INT ALU。所以虽然 Power VR 的GPU核心 USC 数量虽然不多,但是ALU 的数量会比同档次的GPU要多。
- PowerVR 的 Wrap 是32大小,但是他的Pipe Line是16,因此是两个cycle处理一个Wrap。
-
- Mali GPU中有两条并行管线,Non-fragment(处理 Vertex Shader、Computer Shader) 和 Fragment(处理 Fragment Shader)。
- FPK (Mali 隐面剔除) 在 Early Z 之后
- Execution Core 中,WrapManager 负责 Wrap 调度。指令执行单元有FMA(fused-multiply-accummulate,混合乘加,基础浮点计算)、CVT(convert 类型转换)、SFU(special functions unit,特殊计算函数)三个计算元件。在 Valhall 架构下支持 Super Scalar,这三个元件是可以并行执行指令的。
Adreno 架构
- Adreno 3xx 4xx 5xx 6xx 是市面上常见的型号,都是 Scalar 架构
- 3xx 目前使用在部分低配手机
- 5xx 一般见于中低端手机
- 6xx 近几年新出的型号。630-660是高配,骁龙888配备的是 Adreno660 芯片。
- Adreno 核心数量很少,每个核心配备非常大的GMEM,这个 GMEM 是 On-chip 的,大小可以达到256K~1M。如 Adreno630 只有两个核,GMEM 大小为 1MB。
- Adreno 上的 Bin (也就是Tile) 并不是固定大小,而是根据GMEM大小和Render Target格式决定,如果渲染目标开启HDR+MSAA,Bin Size 会小很多,这样需要与主存进行更多交互,功耗就会增加。
- Flexable Render
Adreno GPU 同时支持 IMR 和 TBR 两种模式,可以根据画面复杂度,在两者之间动态切换,这就是 Flexable Redner 技术。在移动平台 TBR 是更加高效的方式。 - Low Resolution Z
LRZ 是 Adreno 5xx 系列开始加入的隐面剔除技术。通过先跑一遍 Vertex Shader 的 position 部分,生成低精度的深度图,进行裁剪剔除,相当于硬件级别的 Hi-Z,这个剔除可以精确到 Pixel Quad。
总结
- Mali 的 Wrap 是16 ,PowerVR 是 32,Adreno 大概率是32。
- Power VR、Adreno 和 Mali最新的 Valhall 都是 Scalar 架构,支持 SuperScalar ,可以更好的利用ALU并行计算。
- 隐面剔除技术,Power VR 是 HSR,Adreno是 L RZ,Mali 是 FPK 。
- 将Mesh的position单独拆成一个 stream ,有利于节省带宽。
- Shader 中使用 mediump 半精度浮点数,可以更好的利用 FP16 的ALU,性能更好。
- Tile 的大小与 RenderTarget 的格式有关。Adreno 的 GMEM 很大,Tile 要比 Mali 和 PowerVR 大很多。
- GPU 硬件工作在内核空间(Kernel Space),只能通过驱动和硬件打交道。应用层设置一个渲染命令或者给GPU传输数据,需要经过图形API和驱动的中转,才能最终到达GPU。驱动调用 会有用户空间(User Space)到内核空间的转换,当 DrawCall 非常大的时候,over head就会很高。
- 以 DX 为例,程序提交一个DrawCall,数据需要经过 App->DX runtime->User mode Driver->dxgkrnl->GPU,然后才能到达GPU。到达GPU之前,全部在CPU上执行。
- 主机平台的硬件是固定的,可以对其硬件和驱动做专门的优化。没有驱动的层层中转,CPU 和 GPU 交互的开销比较低。因此即便主机的硬件性能不如PC,实际游戏性能远超PC。
- 单纯 DrawCall 命令(如绘制图元 DrawPrimitive)开销也不会很大,更大的开销在于DrawCall 附带的绑定数据 (buffer、texture、shader),设置渲染状态的开销。在RenderDoc可以看到一个 DrawCall 会有十几条命令。
- Unity中,绑定 Vertex buffer 记做一个Batch。CPU 和 GPU 的交互模式,更加擅长一次传输大量数据,而不是多次传输少量数据。Unity 的 staticBatch 和 DynamicBatch 的目的就在于此。现在Unity以Batches数目代指DrawCall数目。
- 材质切换记做一个 SetPassCalls,材质切换会面临大量的属性同步,shader 的编译和绑定、纹理绑定等,无论是在引擎层面还是GPU交互层面都是巨大的开销。现代引擎通过排序,让相同渲染状态的物体连续绘制,目的就是减少这一部分的开销。
- 如果绘制大量小物体,很有可能大量时间消耗在CPU和GPU的交互上,而实际GPU本身的负载可能并不高。通常认为 DrawCall 过高可能会导致CPU出现瓶颈。
AlphaTest 和 AlphaBlend 对性能的影响
- 桌面平台
- 桌面平台的IMR架构上,Alpha Blend操作的是DRAM (读+写),如果使用过多,会有明显的 overdraw 和带宽消耗。相比之下,Alpha Test 如果 discard ,PS 中不会有后续计算,避免对FrameBuffer 的写操作。如果绘制顺序合理,从前往后绘制,未被discard的部分可以有效遮挡后续部分,可以减轻 overdraw,所以桌面平台,很多时候使用AlphaTest 代替 AlphaBlend ,可能会带来性能提升。
- 移动平台
- 以PowerVR为例。不透明物体片元在HSR检测通过时就写入深度,AlphaTest 片元在ISP中做HSR检测时不能写入深度。只有在像素着色器执行完毕后才知道自己会不会被丢弃,如果被丢弃则不能写入深度,如果没有被丢弃,会将深度信息回写到 ISP 的 on-chip depth buffer中。深度回写完毕之前,相同像素位置的后续片元不能被处理,导致了线程的等待阻塞。
- Early Z具有类似问题。早期 EarlyZ 和 LateZ 是共用硬件单元,读写必须是原子操作。Alpha Test 导致不能进行 EarlyZ Write,也就不能进行读取,不能进行EarlyZ Test,所以早期一些文档会描述为Alpha Test导致EarlyZ 失效,直到Flush。现代GPU不存在这个问题,Alpha Test 物体不能做EarlyZ write,但是依然可以做 EarlyZ Test,当然因为深度回传写入还是会造成线程等待。
- 单独一个AlphaTest 和 AlphaBlend 比较,Alpha Blend可能会比较快,因为它不存在深度回传的问题,就没有线程等待了。这个影响只可能在特定的情况下才会比较明显,比较常见的情况是多层半透明叠加的情况,这是深度写入关闭,无法做剔除,从而导致 overDraw 很高,移动平台上很容易出现性能问题。而Alpha Test虽然会因为深度回传阻塞线程,但是可以进行剔除,在这种情况下 AlphaTest 的性能会更好一些,如果加入 PreZ ,AlphaTest 的性能优势会更明显。草地的渲染使用PreZ + AlphaTest+(alpha to converage)通常比 Alpha Blend 性能表现更好。
- 不同的测试的结果不一样,关于 Alpha Test 与Alpha Blend 总结:
- 不论是AlphaTest 还是 Alpha Blend 都不会影响其自身被不透明物体遮挡剔除,Alpha Test也不会导致后面不透明物体之间的遮挡剔除。
- 对于比较小特效,不要尝试使用 Alpha Test 替代 Alpha Blend,这样很可能是负优化,会阻塞线程。
- 对于草地、树叶等穿插遮挡严重的场景,使用 Alpha Blend 性能很低,应该使用 PreZ + AlphaTest。
- Opaque-->AlphaTest-->Transparent 是合理的渲染顺序,打乱这个顺序可能会造成明显性能问题。
不透明物体是否需要排序
- Opaque-->AlphaTest-->Transparent 是合理的渲染顺序,其中 Opaque 和 AlphaTest 是不透明物体队列,Transparent 是半透明队列。需要注意的是,AlphaTest物体不能频繁和Opaque物体穿插绘制(这里指的是 渲染顺序上的穿插),否则会严重阻塞管线(因为有深度回传的操作)。半透明物体也不能穿插到Opaque 物体绘制,同样会导致严重的性能问题,例如写深度的半透明物体如果在不透明物体前绘制,会使LRZ(Adreno的隐面剔除)整体失效。
- 对于半透明的物体,因为要进行混合,所以需要从远及近来绘制(画家算法),否则会导致错误的渲染结果。
- 对于不透明物体而言,在没有隐面剔除功能的芯片上(Adreno 3xx),需要保证物体是从近及远进行绘制,可以更好的利用 Early Z 优化,也就是需要排序。而具有隐面剔除功能的芯片(PowerVR、Adreno5xx、Mali大部分芯片),不关心物体的绘制顺序,不需要排序,不透明物体不会有 overdraw。
- 这里的排序对于半透明物体而言,就是根据物体和相机的距离来排序的,这样能得到正确的排序结果。即便基于物体排序,还是会出现半透明物体渲染顺序错误导致冲突的情况,比如较大物体相互穿插,或者物体自身之间相互穿插等。
- 对于不透明物体,是分区块排序。在一个区块内部,物体的绘制顺序与相机的距离无关。这么做是因为 严格按照距离排序,不利于合批,合批需要优先考虑的是 材质、模型是否一致,而不是与相机的距离远近。
PreZ pass /Depth prepass 是否有必要
PreZ pass 是预先使用非常简单的 shader (开启深度写入,关闭颜色写入)画一遍场景,得到最终的Depth Buffer。然后再使用正常 shader(关闭 ZWrite,ZTest 修改为 EQUAL,不执行 clip)来显示(只会显示出与 Depth Buffer 中深度相同的像素),这样只有最终显示在最上面的像素会绘制出来,其他像素会因为Early Z 被剔除掉。
- PreZ 的好处是降低了 overdraw,坏处是多画了一遍场景(虽然使用的是最简单的 shader),drawCall 翻倍,顶点翻倍(顶点着色器执行了两遍),通常性能的瓶颈在像素着色器,大多数情况下PreZ还是有优化效果的。
- PC平台使用 PreZ pass 会更加合适。一是 PC 没有移动平台的各种隐面剔除技术,另外基于IMR的渲染,对于 drawCall 和顶点数量没有移动端的限制多,因此 PC 使用 PreZ 是不错的优化选择。
- 移动平台相反,移动平台有各种隐面剔除技术,不透明物体本身不会产生 overDraw ,而 TBR 架构对顶点数量非常敏感,大量的顶点会导致更多的主存访问,甚至会出现 Parameter Buffer 放不下而 Flush 的情况,因此移动平台不需要 PreZ pass 。
- 另外如果性能测试的瓶颈在 drawCall 和顶点着色器,就不要使用PreZ,会增加更多的压力。
- 对于草地的渲染是需要进行PreZ的。草地是 AlphaTest,且有大量的穿插,不可避免会有大量的 overDraw,PreZ可以很好降低草地的 overDraw,同时由于 PreZ 最后一次绘制草地的时候是不写入深度的,也没有 Clip,就可以当不透明物体来处理,不会像AlphaTest一样因为深度回传,影响管线的执行。
- 有些情况下,使用AlphaTest 替代 Alpha Blend 代替绘制草地,或者2D游戏使用Alpha Test绘制角色,性能都能得到提升,其原因就是当 overDraw 很重,或者ps是瓶颈时,使用Alpha Test可以利用EarlyZ做深度剔除(AlphaTest是有深度回传的),从而提升性能。这里前提是要保证 不透明,alphaTest、半透明的绘制顺序。
shader 分支对性能的影响
- 同一个Warp内执行的是相同的指令。当出现分支(if-else)时,如果所有线程走的都是分支的同一侧,对分支影响的性能很小;如果有的线程走的if,有的线程走的 else 分支,GPU的处理方式是两个分支都走一遍,然后通过执行掩码(execution mask)丢弃不需要的执行结果。这样会产生无意义的开销。这种情况就是 Warp Divergence:
- 常量做分支判断,编译器会做优化,几乎不影响性能。
- uniform 做判定条件,多数时间不会出现 Warp Divergence,对性能影响不会太大,但还是存在影响。
- 动态分支,如使用纹理采样的值做判断条件,大概率会产生Warp Divergence,会严重影响性能,尽可能避免。
编译器对Shader的优化
Unity 会使用glsl optimizer 对 shader 做优化,多数情况下会提高shader 的执行性能。对于 if-else,默认处理方式是将分支展开,全部计算一遍,根据判断条件取其中一个结果。除非分支中指令很复杂,或者有大量的纹理采样,才会保留分支代码。该设置可以通过 branch 和 flatten 关键字来控制。Unity 中的 UNITY_BRANCH 和 UNITY_FLATTEN 对应关键字:
- branch shader会根据判断语句只执行当前情况的代码。
- flatten shader会执行全部情况的的分支代码,然后再根据判断条件获得结果。
- unroll for循环是展开的,直到循环条件终止
- loop for循环不展开。
分支的性能隐患
- 大量的 if-else 会导致 shader 指令数比较多,占用的寄存器就更多。这会导致 GPU 的 Active warp 的数量降低,降低 GPU 隐藏延迟的能力。同时,也会因为寄存器占用过多,产生 Register spilling (将寄存器写入主存),从而影响性能。
- 还有可能因为 shader 指令比较多,导致编译后 bin 文件比较大,不利于缓存。
- 动态分支(包括 uniform 分支),不利于驱动进行优化。具体驱动实现过程过于黑盒,而且随着驱动的迭代更新可能会不断改进。某些驱动 (常见于低端机) ,可能会在驱动级别对分支做"优化",如果分支指令少,就将分支强制展开,但是分支的另一个隐形开销是参数传递导致带宽增加。
multi_compile 的副作用
如果不使用 if-else 那么另一个选择就是 multi_compile,但是用 multi_compile 同样会有明显副作用:
- 增加了 keyword 关键字,keyword 数量本身有限
- 增加变体数量,不同变体是不同 shader,会导致 SetPassCalls 增加,影响运行时性能
- 变体增多会产生更多的内存占用。而且Shader 实际的内存占用可能比在 UnityProfiler 中看到的 ShaderLab 的内存占用更多。因为驱动会消耗两三倍的内存去管理 ShaderProgram。相比增加变体,选择 if-else ,并使用 const 或者 uniform 作为其判定条件更加省性能。
关于分支的建议
- 尽量不要使用分支,如果必须使用的话,优先选择常量,其次是 uniform 的判定条件。
- 最糟糕的情况是使用 shader 内部计算的值作为判定条件,尽可能避免。
- 尽可能避免在常用的shader,或者低端机使用的 shader 中使用分支。
- 如果最终确定使用分支,确保两条分支不会存在大量重复代码。大量重复带哦嘛会导致shader占用寄存器文件明显增多,减少 Active warp 的数量,最终导致性能下降。
- 如果分支指令较多,可以加上 branch 关键字,shader 会根据判断条件,只执行当前情况的代码。
Load / Store Action 和 MemoryLess
- 从 SystemMemory 拷贝数据到 TileMemory 是 Load Action
- 从 TileMemory 拷贝数据到 SystemMemory 是Store Action,也叫 Resolve。
- Open GLES 中可以通过 glInvalidateFramebuffer 来规避上述 Load 和 Stote
- Metal 中可以通过 RenderPass 的 loadAction和 storeAction的设置来控制 Load/Store
- loadAction 有三种,dontCare,load,clear
- storeAction 有四种,dontCare, store,multisampleResolve, storeAndMultisampleResolve。
- 比如后处理执行完毕之后,深度就没有用了,就可以设置 depthTexture 的RT 的 storeAction 为 dontCare,这样可以避免深度写回主存的带宽开销。
- Xcode 中可以看到渲染的 L/S bandwidth 的开销。通过抓帧可以清除看到每个RenderPass的 load/store action,可以方便优化管线性能。
Memoryless
- 像 Depth/Stencil buffer,只在Tile绘制中有用,不需要存到主存中,所以其 storeAction 可以声明为 dontCare,这样可以节省带宽。
- Metal 下,这样不需要 Resolve 的RT,可以设置为 Memoryless,这样可以降低显存开销。
- RenderTarget 的切换是非常耗时的,尽量避免频繁的切换 RenderTarget
- 由于移动平台TBDR的特性,切换RT在移动平台会有更大的开销,会严重阻塞渲染流水线执行。每次切换RT时,需要等待前面的指令执行完毕,把数据写入主存。切换到新的RT之后,需要把数据从主存 Load 到 Tile Memory中,频繁与主存交互不仅很慢,而且会消耗大量的带宽,类似后处理这样必须要使用RT的,应当把多个Pass尽可能合成一个Pass。
- 当使用 RenderTexture 时,要慎重,一方面消耗过多的内存,另外 RenderTexture的更新绘制不可避免会有RT切换。如果过多使用,或者过于频繁使用,会出现明显性能问题,特别是在低端机,当确定使用RenderTexture时,要严格控制大小。
避免 CPU 回读 GPU 数据
- CPU回读GPU数据(比如 glReadPixels) 会严重阻碍CPU和GPU的并行,当CPU要读取 FrameBuffer 中的数据,必须保证 GPU 已经全部写入完毕。
- 部分解决方案是,在下一帧时读取上一帧的数据,这样可以避开等待时间,但两帧数据结果之间会有一定偏差。
Pixel local storage
- Mali和Adreno提供了API来获取 TileMemory 中的数据,这样可以高效实现某些效果,比如软粒子或后处理效果。
- iOS平台由于使用 MemoryLess ,framebuffer_fetch 无法获取深度,但可以通过其他方式记录深度,比如 MRT(Multiple Render Targets),或者将深度写入到color的alpha通道,来实现深度存储。
MSAA 对性能影响
- MSAA
移动平台的 MSAA 可以在 TileMemory 上实现 Multi sampling,不会带来大量的访问主存的开销,也不会大幅增加显存的占用,因此移动平台 MSAA 会更加高效。但并不意味着MSAA在移动平台就是免费的,依然会有一定开销,因此只能在高配机型中开启MSAA:- 在Adreno GMEM大小固定(256K~1M),而Tile大小和Render Target设置有关,如果开启 MSAA,Tile 会对应缩小,这样会导致更多与主存的交互。
- Mali 和 PowerVR 由于 Tile Memory有限,打开HDR 与 MSAA 需要更多空间来保存渲染结果,GPU 只能够通过缩小 Tile 的尺寸来适应 On-Chip-Memeory 的固定大小,进行渲染的 Tile 数量因此会增加。比如 PowerVR 原本的Tile 是32x32,如果开启MSAA可能就变为32X16 或者 16X16。
- Alpha to coverage
- Alpha to coverage 简单理解就是基于MSA A,使用多重采样时决定最终color的coverage 值,使用 AlphaTest 来模拟 Alpha Blend的效果 ,原本的AlphaTest不可避免的会有较硬的边缘,通过 Alpha-to-coverage ,像素的透明度是由4个像素插值计算得到的,边缘会因此柔和。
- 使用Alpha-to-coverage 的好处是,AlphaTest 本质是不透明物体,因此不会出现渲染半透明物体那样容易出现深度错误的情况。
- 使用 Alpha-to-coverage 也会影响 Early- Z执行(无论是AlphaTest影响还是MSAA影响),在移动平台性能并不高,只建议在必要的地方使用。
- 由于 Alpha Test 是基于MSAA来时实现的,因此在不使用 MSAA 是,启用Alpha-to-coverage 最终的结果是不可预测的(没有正确的coverage值)。
shader 优化建议
- MAD(乘加)是一条指令,将计算转换为 (axb+c) 形式,可以节省指令。
- saturate,negation,abs是免费的,clamp,min,max 不是,不要进行负优化。
- sin,cos,log,sqrt,pow,atan,atan2 使用 SFU 进行计算,通常需要花费几个ALU甚至几十个ALU,尽可能避免。
- CVT(类型转换,比如 half->float,vect3->vect4)并不一定是免费的,可能需要占用一个 cycle,减少无意义的类型转换。
- 先计算scalar,后计算 vector,性能更好,在保证代码清晰的前提下,尽可能使用高效代码。
- 优先使用半精度浮点数half,速度会更快。lowp 和 mediump 是FP16,highp 是 FP32,一般坐标需要FP32,其他的例如颜色使用 FP16 就足够了。
- 不要单纯为了性能将标量合并为矢量,尤其是以降低代码可读性为代价。现代的GPU通常已经是标量和超标量设计,无需手工合并。
- 在PC平台,使用 discard 剔除像素或许有优化效果,但在移动平台不要这么做,会影响HSR和EarlyZ,是负优化。
- 尽可能精简 Shader 代码,减少 uniform 变量,临时变量的数量,从而减少寄存器使用,避免出现 Register Spilling (寄存器超过限制写入主存)。