平面反射通常指的是在镜子或者光滑地面的反射效果上,如下图所示,

上图是一个光滑的平面,平面上的物体在平面上有对称的投影。

一、平面反射的原理

对于光照射到物体表面然后发生完美镜面反射的示意图,如下所示,

对于平面反射,假设平面上任意一点都会发生完美的镜面反射。因此,眼睛看到物体的一点的反射信息是从反射向量处得到的,这个可以用下图来表示,

这个实际上相当于,眼睛从平面的下面看向反射向量,如下图所示,

因此,如上图所示,我们可以把摄像机根据平面对称变换到A点所示的位置,然后再渲染一遍场景到RenderTexture中。当我们渲染点O的反射信息时候,就可以到这张RT中去采样了。那么如何去采样反射信息了?使用点O的屏幕空间坐标。因为,RT是从A点看到的场景,视线和平面的交点O是当前渲染的像素点,因此用O的屏幕空间坐标去采样RT就可以得到其反射信息。

1.1 平面反射矩阵

1.1.1 平面方程的计算

我们现在来推导一下把摄像机关于平面对称的反射矩阵。
我们知道一个平面可以表示为$P*N+d=0$。P是平面上任意一点,N是平面的法向量,d是一个常数。我们首先需要求出平面方程。对于平面,其世界空间的法向量就是N。用平面的世界空间位置带入P即可求出d的值。

1
plane = new Vector4(planeNormal.x, planeNormal.y, planeNormal.z, -Vector3.Dot(planeNormal, planePosition) - Offset);

我们可以用以上的一个Vector4表示一个平面,前三个分量表示normal,第四个分量表示d。

1.1.2 平面反射矩阵的计算


如上图所示,我们需要计算点A关于平面的对称点A’。关键在于计算出点A到平面的距离AO的大小。那么$A’=A-2*n*|AO|$,负号是因为方向和法线相反。所以,关键是求出|AO|。因为AO实际上是AP在法线相反方向的投影向量,那么$|AO|=dot(AP,n)=dot(A-P,n)=dot(A,n)-dot(P,n)$。由于P满足平面方程,因此$dot(P,n)=d$,因此$|AO|=dot(A,n)+d$,因此$A’=A-2*n*(dot(A,n)+d)$。

假设n为(nx,ny,nz),已知d的值,A是(x,y,z)点作为我们要变换的点,A’为(x’,y’,z‘),那么我们可以得到:
$x’ = x - 2(x * nx + y * ny + z * nz + d)* nx = (1 - 2nx * nx)x +(-2nx * ny)y + (-2nx * nz)z + (-2dnx)$,
$y’ = y - 2(x * nx + y * ny + z * nz + d)* ny = (-2nx * ny)x + (1 - 2ny * ny)y + (-2ny * nz)z + (-2dny)$,
$z’ = z - 2(x * nx + y * ny + z * nz + d)* nz = (-2nx * nz)x + (-2ny * nz)y + (1 - 2nz * nz)z + (-2dnz)$,
改写成矩阵形式可以得到平面反射的矩阵为:

1.2 斜裁剪矩阵

上面我们已经推导出平面反射矩阵,不过还有一种特殊情况需要处理。
斜裁剪矩阵
如上图所示,我们的平面是P,将摄像机从C点对称到C’点。从C’可以看到的区域包括A和B,但是B是在平面P的下部,我们从C是无法看到的。因此,从C’点渲染场景RT的时候必须排除B区域,也就是需要将平面P作为裁剪平面,裁剪掉区域B。
这个东西叫做斜裁剪矩阵,我们可以推导出具体的斜裁剪矩阵或者使用Unity提供的接口直接计算出来。
计算斜裁剪矩阵需要两个步骤,第一步是计算出摄像机空间下的平面表示,第二步是用摄像机空间下的平面和原投影矩阵一起计算斜投影矩阵。
具体推导可以参考文章,【图形与渲染】相机平面镜反射与斜裁剪矩阵(上)-镜像矩阵
第二步也可以使用Unity的camera中的接口CalculateObliqueMatrix来计算,参数就是第一步得到的平面。

二、平面反射的实现

2.1 平面反射的脚本

这里的脚本指的是生成RenderTexture需要的脚本,脚本继承自MonoBehaviour。

2.1.1 默认管线下的实现

默认管线下需要在函数OnWillRenderObject中,基本步骤是先计算反射平面,然后计算反射矩阵和斜投影矩阵,设置反射相机的斜投影矩阵,然后将反射相机变换到平面对面,调用相机的Render函数渲染RT。需要注意的是,渲染的时候需要修改物体正反旋向,即GL.invertCulling设置为true。

2.1.2 Urp管线下的实现

Urp管线下,需要绑定 RenderPipelineManager.beginCameraRendering的回调,然后在回调中实现。回调中会接收到当前渲染的相机,反射相机就是该相机关于平面的镜像。同时,渲染RT的函数需要改成UniversalRenderPipeline.RenderSingleCamera,传入context和反射相机。其余步骤,跟默认管线的区别不大。

2.2 平面反射的Shader

平面反射的shader可以使用普通的场景shader做修改。关键在于如何采样平面反射信息和平面反射强度以及模糊等。

2.2.1 平面反射信息的采样

这个之前已经解释过用屏幕空间坐标来采样RT。

2.2.1 平面反射强度

这个可以用菲涅尔效应计算,不过关键点在于强度必须是NdotV的函数,最简单的方式是计算出NdotV,对NdotV取反或者1-NdotV,因为入射角越大反射光越强,同时提供一个最大最小值来限制强度范围。

2.2.1 模糊和Mipmap

可以采样周围多个像素然后做平均模糊或者高斯模糊。不过,最简单的方式是对RT强制生成Mipmap,采样RT的时候指定Mipmap级别。那么,mipmap级别如何计算了。我们可以根据shader的粗糙度来转行为mipmap级别,这个参考unity的urp内置shader函数PerceptualRoughnessToMipmapLevel的实现。

最终得到的反射效果如图,

三、平面反射的优化

平面反射由于需要对场景镜像渲染一遍, DrawCall会翻倍,而且由于原理限制,没有有效的优化手段,因此平面反射通常是应用在特定的场合下。
优化的手段,主要是降低生成反射RT的消耗。

3.1 控制反射层级

我们可以在反射脚本中增加层级控制,然后设置反射相机的cullingMask,指定层级的物体才会被渲染到RT中。

3.2 控制反射RT的尺寸

可以根据反射平面的大小来调整RT的尺寸,同样我们可以在脚本中开放这个尺寸设置来方便美术来调整RT大小。

3.3 降低RT的shader复杂度

我们可以使用Unity的shader replacement将生成RT的shader都替换为一个简单的shader,然后再渲染生成RT,这样可以大幅度降低shader计算复杂度。不过,DrawCall是无法降低的。

参考资料

Unity Shader-反射效果(CubeMap,Reflection Probe,Planar Reflection,Screen Space Reflection)
图形与渲染】相机平面镜反射与斜裁剪矩阵(下)-斜裁剪矩阵

需要注意的是,本文涉及的内容过多过杂,基本涉及到游戏渲染和图形管线的方方面面。内容是根据多方面的资料整理而成,比如本人的Unity和Unreal引擎相关的理解和认知,以及引擎相关官方文档等,以及DirectX和OpenGL相关官方文档等,以及网络上各种相关文章和资料等。可能有一些纰漏或者不足之处,或者有些阶段的资料来源较为单一,本人主要目的是从概念理解上对应整个游戏引擎的渲染管线,不一定和真实的游戏完全一一对应,比如应用程序阶段的知识对应到游戏引擎应该会有一些区别和取舍,几何阶段和光栅化阶段主要参考的是OpenGL和DirectX,Vulkan和Metal相关资料参考较少,可能不同的图形API会有一些出入。由于涉及内容过多,难免理解不到位,有发现比较明显错误的,请指出以尽早修正,避免造成误解。

一、渲染管线的思维导图

这是本文内容的思维导图,通过该图可以从整体上把握全文的内容,对渲染管线有整理的理解。

二、应用程序阶段

2.1 渲染数据加载

这个阶段指的是将渲染所需要的相关数据,比如模型、贴图、材质、Shader等加载到内存中,通常只发生一次,不需要每帧重复加载。比如,Unity游戏需要在运行时,将需要的场景或者人物从AssetBundle中加载出来,然后引擎才能显示加载的场景或者人物。

2.2 物体级别的裁剪

以下描述的裁剪算法是按照粒度从粗到细的裁剪,相应复杂度和代价也是在递增。最简单的是基于距离的裁剪;然后是利用空间数据结构实现的视锥体裁剪;动态的入口裁剪是一种特殊情况,可以算在视口裁剪内也可以用于预计算;然后预计算数据的裁剪;接下来才是动态的遮挡剔除。

2.2.1 基于距离的裁剪

思路是超过一定的视距即不渲染该物体,Unreal引擎支持这个特性,参考Cull Distance Volumes。对于Unity,可以使用CullingGroup实现类似的功能。即使引擎没有提供类似的支持,在游戏逻辑层面,先可以每帧或者隔帧判断物体跟摄像机的距离,来动态显示隐藏物体。

2.2.2 视锥体裁剪

用物体跟摄像机视锥体做相交测试,将完全没有相交的物体过滤掉。为了加快速度,使用的是物体的包围盒或者包围球跟视锥体做相交测试。游戏引擎内一般都会有空间数据结构来组织物体,比如BVH,那么可以直接使用BVH来搜索加速这个计算。具体过程是用视锥体和空间数据结构去做相交测试,如果当前节点没有相交,那么不需要继续,如果有相交则继续遍历子节点直到叶子节点或者没有相交,叶子节点中存储的物体即是需要渲染的物体。

基于空间数据结构的裁剪

四叉树和八叉树

四叉树对应的是二维空间,下面以八叉树为例来说明。八叉树是将三维空间平均划分为八个部分作为八个子节点,重复划分到一定的粒度为止,比如叶子节点内最多存储多少个物体,物体存储在叶子节点内。
优点:概念和实现简单。
缺点:无限空间不好划分;物体可能跨越分割面;物体分布不均匀会造成层次过深,搜索效率不高。
适用场景:四叉树适用于基于高度场的地形管理;八叉树室适用于室外分布均匀的三维场景(有高度)。

BSP

针对八叉树这种不均匀划分,如果将物体均匀划分成两部分,那么就是Binary Space Partition Tree,可以避免树的层次过深。注意,BSP的每个节点存储的是划分平面,而不是物体,划分平面将场景分为前后2个部分,分别对应左右子树;由于需要BSP树针对的多边形,因此可以针对物体的AABB包围盒做划分。

优点:物体分布均匀,不会出现树层次过深;支持任意空间。
缺点:实现复杂,构造时间长,不适合动态场景。
适用场景:紧凑并且分布均匀的室内场景;静态场景;自带物体排序,方便实现画家算法。

KD-Tree

BSP全称是K-Dimensional Tree。这是一种特殊的BSP,在BSP上进一步将划分面限制跟坐标轴垂直,但是保持从物体分布的中间划分,以尽可能得到一个物体分布均匀的树。KD-Tree不仅仅可以用来做空间划分,在其它领域经常用来组织K维度的数据来做搜索,比如K维数据查询、近邻查询。
优点:物体分布均匀,不会出现树层次过深;数据可以组织为数组形式的完全二叉树,缓存友好等。
缺点:如何确定最优或者较优的划分面?
适用场景:紧凑并且分布均匀的室内场景;辅助其它数据结构进行邻域查询。

BVH

全名是Bounding Volume Hierarchy,中文翻译层次包围盒。BSP和KD-Tree的节点代表的都是分割面,但是面有可能穿过物体。层次包围盒的思想是每个节点代表一个空间,空间计算其包含物体的最小包围盒,划分空间后重新计算子空间的包围盒。与BSP最大区别是节点代表的不再是分割平面而是包含最小包围盒的子空间。因此,这些子空间可能出现一定的重叠,但是不会出现物体出现在不同的划分里面。
优点:节点存储的是物体,方便碰撞检测等查询;构建快,动态更新方便。
缺点:如何确定最优的包围盒?
适用场景:视锥剔除;物体碰撞检测;射线检测;光线跟踪。

空间数据结构的其它应用

除了视锥体裁剪外,空间数据结构还有很多其它应用,比如
1、Ray Casting (射线检测)
2、碰撞检测
3、邻近查询 (比如查询玩家周围敌人)
4、光线追踪

Portal Culling(入口裁剪)

适用于将场景划分为格子,格子之间可能存在入口的情形,如下图所示,

从入口只能看到部分被墙壁遮挡住的物体,因此可以借助这个特性加速视锥体和格子的相交裁剪。Unity中的Occlusion Portal即是这个特性。如果预计算出Protal Culling的结果,那么可以在运行时加快物体裁剪。

2.2.3 预计算遮挡剔除

这是一种空间换时间的算法,会增大内存占用,降低Cpu的裁剪消耗。所以是否需要预计算遮挡数据,还需要具体讨论。一般如果内存消耗不大,但是Cpu占用较高的话,可以尝试开启预计算遮挡数据。

Precomputed Visibility (UE4)

参考虚幻引擎的Precomputed Visibility。思想是将场景划分为格子,计算每个格子内可以看到的可见物体集合相关的数据,用于运行时动态查询。

预计算Occlusion Culling (Unity)

参考Unity的Occlusion culling。类似于UE4的Precomputed Visibility,不过Unity的Occlusion Culling也支持动态物体,但是动态物体只能occludee(被遮挡物体)。Unity的预计算Occlusion Culling应该是入口剔除的一种预计算实现。

2.2.4 动态遮挡查询

这里讲的是在CPU上或者GPU上实现的遮挡查询。图形API已经提供了遮挡查询相关的接口,比如OpenGL的Query Object或者DirectX的Predication Queries。但是不是所有的硬件都能够支持,因此可以在软件层面即在CPU上做软渲染实现遮挡查询。Hierarchical Z-Buffer Occlusion则是在普通的硬件遮挡查询上的进一步优化,使用了层次Z-Buffer来进一步加快速度。

软件遮挡查询

软光栅化模仿硬件遮挡查询,因此不受设备类型限制,只是需要额外消耗CPU。

硬件遮挡查询

使用图形接口本身提供的遮挡查询接口。基本思想是用物体的包围盒去渲染Z-Buffer,统计通过深度测试的像素数目,如果有通过说明当前物体没有被完全挡住,保存结果用于下一帧查询。因此,硬件遮挡查询会存在两个问题:额外的渲染消耗和延迟一帧。

Hierarchical Z-Buffer Occlusion

类似硬件遮挡查询,不过使用Hierarchical Z-Buffer来加快查询速度。具体实现比较复杂,请参考相关文章。

2.2.5 LOD切换

LOD指的是Level Of Details。如果物体通过了以上的裁剪,那么说明会提交给渲染线程进行处理。LOD切换指的是这些物体的细节层次切换,比如一些不重要的或者看不清楚的物体选择更简单的模型。

基于距离的LOD切换

最常见的方式是根据摄像机距离来进行LOD切换,越远的物体选择更简略的LOD,Unity和UE4默认是这种方式。

基于渲染分级切换LOD

但是我们也可以主动切换LOD,比如检测到当前硬件较差,需要切换到更低的画质,那么可以根据游戏设置的渲染品质分级来切换低的LOD。

LOD过渡

LOD的一个常见问题是LOD的过渡问题,可能在切换LOD时候会察觉到明显的过渡。常见的方式是在切换时候混合2个LOD,比如透明度逐渐从1变化到0或者从0变化到1,避免出现明显的过渡。

2.3 物体级别的渲染排序

为了减少OverDraw或者实现半透明效果,所有通过裁剪的物体会按照一定的次序进行渲染。下面列举几个常见的渲染次序。游戏引擎实际的渲染过程还会跟引擎渲染管线的Pass定义顺序相关,比如不透明和透明物体在不同的Pass内渲染的,而且是先在一个Pass内渲染透明物体,再在另外一个Pass渲染透明物体。

从前到后渲染(不透明物体)

从前到后渲染可以利用Early Z-Test过滤掉不必要的片元处理。因此,如果先渲染近处的物体,那么后面渲染的远处物体就不会通过Early Z-Test,就不会进入片段处理阶段。不过,不是所有的硬件都需要按照从前到后的物体顺序进行渲染,这毕竟需要额外的CPU消耗来排序物体,部分支持HSV(hidden surface removal)特性的GPU,比如PowerVR是不需要做这个排序的。Unity提高了静态变量SystemInfo.hasHiddenSurfaceRemovalOnGPU来查询GPU是否支持HSV,
Urp渲染管线会根据这个来判断是否需要跳过从前到后排序物体。

从后到前渲染(半透明物体)

由于半透明物体的渲染算法要求必须从后到前渲染物体,同时关闭深度测试 ,前面的物体与后面的物体进行颜色混合。那么这个排序过程是无法省掉的,类似从前到后渲染的排序,可以采样BSP来排序物体。

渲染层级或渲染队列

Unity同时定义了这2种排序,不过SortingLayer的优先级更高,这个是定义在物体的Renderer组件上。RenderQueue是定义在Shader和材质上,优先级在渲染层级之后。理论上,就是对所有物体进行优先级排序。

最少渲染状态切换

还有一种方式是尽可能在渲染物体的时候避免渲染状态切换,这样能够尽可能减少CPU消耗。那么可以在CPU计算出来一个最优的渲染顺序来尽可能减少渲染状态切换。

2.4 渲染数据绑定和状态设置

这一个阶段讲的是在CPU上设置渲染相关数据和状态,以及为了减少渲染状态切换的渲染合批的思想。

视口设置

设置窗口的渲染区域,比如OpenGL的glViewport。通过这个设置,我们可以在一个窗口上渲染多个不同的视口,比如游戏的分屏。

FrameBuffer设置

一般游戏引擎不会直接将物体渲染到默认的渲染缓冲上,单独的RenderTarget方便进行后处理,在后处理之后再Blit到默认缓冲上。一个FrameBuffer可以包含颜色、深度、模板三个附件,也可以将深度和模板组织成一个32位的RT。

渲染合批

渲染合批指的是为了减少渲染状态切换的一种优化手段,Unity URP渲染管线的SRP技术可以大幅度优化渲染批次。这是一个在Shader变体层次的合批,与之前的材质层次的合批相比有很大的优化。

顶点输入绑定

对于OpenGL来说就是创建和绑定VAO(Vertex Array Object)。一个VAO中可以包含VBO(Vertex Buffer Object)、IBO(Index Buffer Object)。然后用glVertexAttribPointer和glEnableVertexAttribArray指定数据到Shader的输入变量。
顶点属性通常包括,位置、法线、切线、UV、顶点颜色等。

Shader绑定

渲染数据绑定好之后,需要指定当前使用的Shader,这包括Shader的编译链接和使用等(假设Shader代码已经加载进来)。

Shader编译链接使用

类似于CPU上运行的程序,Shader也需要编译链接以及开始使用的过程,不过这个过程基本上是固定。
可以参考learnopengl的着色器一节。

Uniform变量绑定

Shader中通常会有很多全局变量,比如MVP、摄像机位置、光的信息等。这些都需要在CPU上传入Shader中。

Output-Merger Stage相关设置

在渲染管线的最后(片元着色器之后),有一个Output-Merger阶段,也叫做Raster Operations。这是一个不可编程阶段,但是有很多选择可以设置。比如剪切测试、模板测试、深度测试、颜色混合因子和函数、sRGB转换等。这些都需要在应用程序阶段进行设置。

2.5 DrawCall调用

终于到了应用程序的最后一步,即DrawCall的调用了。OpenGL对应的接口是glDrawArrays或者glDrawElements

三、几何处理阶段

这是第二个大的阶段,当前阶段已经进行GPU中了。该阶段的起点和主要过程是顶点着色器。除了着色器之外,其余阶段都是硬件自动进行的,除了可选阶段之外,其余的都是固定的,应用程序无法根据配置来进行更改。

3.1 顶点着色器

顶点着色器的处理对象是应用程序阶段绑定的每个顶点,顶点着色器会获得顶点属性以及相应的Uniform变量。顶点着色器的输出是一个NDC Clip Space的顶点位置。NDC(Normalized device coordinates)是规范化设备坐标系的位置,OpenGL的范围[-1,1],DirectX的范围是[0,1]。之所以说是Clip Space,因为该阶段得到的顶点数据是一个齐次坐标,还需要进行透视除法,即x、y、z除以w分量才能得到NDC坐标系下的位置。

3.2 曲面细分着色器

曲面细分着色器是一个可选阶段,用于将一个简单模型细分成复杂的模型。其实该阶段是2个着色器和一个固定阶段的组合。在DirectX中叫做Hull Shader stage、Tessellator stage、Domain Shader stage;在OpenGL的Tessellation中叫做Tessellation Control Shader、Tessellation Primitive eneration、Tessellation Evaluation Shader。具体的介绍和使用方式请参考相关资料。

3.3 几何着色器

几何着色器也是一个可选阶段。几何着色器的输入是图元的顶点集合(比如三角形图元有三个顶点,点图元只有一个顶点),输出是一个新的图元,新的图元也要包含一个顶点集合。简单来说,几何着色器的输入和输出都是图元,输入的图元是在应用程序阶段指定的,输出的图元可以在顶点着色器中实现。

3.4 Stream Output (Transform Feedback)

这是一个可选的阶段。这个阶段在DirectX中叫做Stream Output ,在OpenGL找叫做Transform Feedback。如果该阶段开启,那么顶点数据流会输出到一个Buffer中,这个Buffer可以给顶点着色器使用也可以返回给CPU,当前渲染管线则不会进行接下来的处理。

3.5 图元组装

这一步是将之前得到的顶点数据组合成图元,比如顶点图元、线段图元、三角形图元。该阶段输出图元进行接下来的处理。

3.6 透视除法和NDC裁剪

该阶段的输入是组装好的图元,输出的是NDC裁剪之后的图元。首先对图元的顶点进行透视除法,这样得到的顶点数据都会位于NDC内,方便进行NDC裁剪。图元裁剪后可以会产生新的图元。

3.7 屏幕空间映射

该阶段是将NDC下的图元顶点坐标映射到屏幕空间。值得注意的是顶点坐标是一个齐次坐标,透视除法后得到的是NDC下的坐标;然后,通过一个缩放和平移变换将x和y映射到屏幕空间。

3.8 面剔除 (Face Culling)

这一个阶段指的是三角形的前后面剔除。前或者后的定义是根据正视三角形的时候定义三角形顶点的旋向,可以定义逆时针旋转或者顺时针旋转为前面。实际上,面剔除跟实际的摄像机位置没有关系,不管摄像机转到哪个地方,前后面不会改变,比如渲染立方体的时候,后面都是立方体内部看不到的面,无论摄像机如何旋转。因为,前后面的定义是固定视角正对三角形时候定义的。

四、光栅化阶段

该大的阶段的输入是几何处理阶段输出的图元。该阶段主要分为四个部分,首先是光栅化图元得到片元(潜在的像素信息),然后进行Early Fragment Test,通过测试后再进行片元着色器,最终进行输出合并阶段的各种测试以及颜色混合等,再输出到颜色缓冲区。

4.1 图元光栅化

该阶段是将图元的顶点信息进行线性插值,然后生成片元数据。每个片元上有顶点信息线性插值而来的片元数据。需要注意的是,这个插值是线性的,如果有一些数据是非线性的,则不能在顶点着色器中计算然后输出到片元着色器,因为线性插值的结果和在片元着色器中计算的结果是不一致的。
这里需要特别说明的是,关于深度z’的生成。屏幕空间映射后的z’是关于摄像机空间z倒数的一个线性函数。之所以使用1/z而不是z,是为了在近处获得更好的深度缓冲精度,因为1/z在近处的变化更快,可以优化Z-Fighting这种现象。由于z’不是一个关于z的线性函数,因此z’应该是在光栅化后硬件自动根据1/z计算出来的,而不是先计算z’再光栅化。

4.2 Early Fragment Test

参考OpenGL的Early Fragment Test,可以看到不仅仅通常所说的Early Z-Test还有其它好几个阶段都可以进行EarlyTest,一共是四个测试(Pixel ownership test、Scissor test、
Stencil test、Depth test)和遮挡查询更新。根据文档,Pixel ownership test和Scissor test从OpenGL4.2起会总是在EarlyTest阶段进行。那么,如果这些测试没有在EarlyTest阶段进行,则会在最终的输出合并阶段进行;如果进行了,那么输出合并阶段也不会重复处理。

4.3 Early Z-Test的限制

不要在片元着色器中改变深度,比如glsl的gl_FragDepth;也不要discard片元,通常实现AlphaTest会根据Alphadiscard片元。因为这些操作会导致硬件无法预测最终的深度,从而无法进行提前深度测试。

4.4 片段着色器

片段着色器的输入是光栅化来的各种顶点属性,输出是一个颜色值。该阶段是计算光照结果的主要阶段。通常片元着色器会有比较复杂的计算,通常的优化手段是将计算转移到顶点着色器甚至CPU(应用程序阶段,用Uniform传入)上。

4.5 Output-Merger Stage(Raster Operations)

终于进入最后的输出合并阶段,该阶段的输入是一个个的片元。片元需要进行一些列的测试和转换,最终才会将颜色输出到缓冲区上。

Pixel ownership test

根据OpenGL的文档,该阶段只对默认缓冲区生效,用于测试像素是否被其它窗口遮挡的情形。对于自定义的FrameBuffer,不存在这个测试。

Alpha Test

需要特别说明的是,Alpha测试当前是已经被废弃了,从DirectX10和OpenGL3.1开始废弃,参考Transparency Sorting文档;当前需要在片元着色器用discard实现。列在这里主要是为了完整性。

Scissor test

参考OpenGL的剪切测试文档,Scissor Test。通过在应用程序阶段设置,可以让片元只通过视口的一个小矩形区域。根据EarlyTest的文档,推测该阶段目前都在EarlyTest阶段进行了。

Multisample operations

如果启用了MSAA,那么需要进行resolve才能够输出到默认颜色缓冲中,进行屏幕显示。假如在默认缓冲中开了MSAA,那么从MSAA的后备缓冲交换到前向缓冲就需要进行resolve操作,因为前向缓冲是single-sample的。如果是自定义的FrameBuffer开启了MSAA,那么在Blit到默认缓冲区的时候也需要进行resolve操作。

模板测试

模板测试基本思想是用一个八位的模板缓冲,一个参考值,一个比较函数,一个掩码,用该参考值和片元对应的模板缓冲值使用比较进行比较(比较之前进行掩码),通过的则片元可以继续进行深度测试,否则丢弃。另外还可以定义模板成功和失败,以及深度测试成功和失败后模板缓冲如何变化。可以参考OpenGL的Stencil Test文档。
模板测试的一个常见的应用是描边或者在像素级别分类。

深度测试

深度测试是根据当前片元的深度值与深度缓冲进行比较,比较函数可以设置,通过比较的片元才会进行接下来的处理,否则丢弃当前片元。

遮挡查询更新

参考OpenGL的遮挡查询文档Query Object
该阶段会更新遮挡查询的结果,因此遮挡查询的结果只能用于下一帧渲染。

颜色混合

需要注意的是,容易误解半透明渲染才会有颜色混合,实际上颜色混合是管线的一个固定的阶段,不透明渲染也会有默认的混合方式。
理解颜色混合,首先要明白2个概念,source和dest,source指的是当前的片元,dest指的是要目标缓冲中对应的颜色。
颜色混合主要是需要设置2个函数,一个函数用于设置混合因子,一个函数用来设置混合函数。混合因子有四种,source rgb和dest rgb,source a和dest a,可以一起指定也可以分开指定。具体可以参考OpenGL的Blending文档。

sRGB转换

1、我们知道显示器或者颜色纹理的颜色空间是sRgb,sRGB空间就是Gamma校正的颜色空间,也就是已经Gamma校正过的颜色数据,这样子在显示器上才能正常显示。如果我们使用的线性工作流,也就是在线性空间中制作资源,编写Shader计算光照结果,那么片元着色器的输出需要转换到sRgb空间。这个转换部分硬件上是自动支持,对于不支持的硬件则需要在Shader里面转换。
2、如果要硬件自动转换,首先要创建的必须是srgb颜色空间的FrameBuffer,在OpenGL中可以使用glEnable(GL_FRAMEBUFFER_SRGB)开启;要保证片元输出的线性空间的颜色,也就是要采用线性工作流。
3、需要注意的是,避免将sRGB转换和ToneMaping混合起来,ToneMaping做的是将HDR映射到LDR。这只是一个带偏向性颜色范围映射,也就是算法倾向性的增强部分颜色。而sRGB转换才是将颜色从线性空间转换到sRGB空间。

Dithering

首先说明一下,颜色格式分为Float、Normalized Integer、Integer三种,默认缓冲区就是Normalized Integer格式的颜色。根据OpenGL的文档,当将一个Float颜色写入Normalized Integer缓冲区的时候,可以开启Dithering。Normalized Integer缓冲区是一个定点数缓冲来存储浮点值,比如通常我们的颜色是定义在[0,\1]的浮点值,但是颜色缓冲是[0,254]\的Int值,OpenGL会自动进行转换。

Logic operations

根据OpenGL的文档,当将颜色写入Integer(Normalized Or Not)缓冲区的时候,可以开启Logic operations。这是一些Bool操作。具体可以参考文档Logical Operation。Logical Operations在sRGB颜色空间是禁止的。

Write mask

该阶段可以分别指定Color、Depth、Stencil的写入掩码。具体参考文档Write Mask

五、RenderPass

5.1 Renderer

以上所有内容在游戏引擎只是一个RenderPass,实际情况下,每帧游戏引擎会按照一定的顺序渲染多个Pass。比如,深度Pass(或者深度法线Pass)、阴影Pass、不透明物体Pass、透明物体Pass、后处理Pass等;而且后面的Pass会利用前面的Pass渲染结果来处理,比如深度Pass渲染的深度纹理可以用在后续的Pass实现一些效果。
总而言之,真实的游戏引擎是每帧渲染多个Pass,每个Pass对应上述的内容。

5.2 CameraStack

实际上,在Unity的Urp渲染管线中,更完整的过程是渲染相机堆栈->每个相机堆栈对应一个渲染器->每个渲染器包含多个Pass。不过,Urp里面每个相机堆栈只对应一个FrameBuffer,也就是所有的相机渲染输出都是这一个FrameBuffer,避免内存和带宽浪费。如果在场景内创建多个相机堆栈,那么其它的相机堆栈的输出应该是离屏RT。

六、参考资料

1、Graphics pipeline
2、Rendering Pipeline Overview
3、Per-Sample Processing
4、Output Merger (OM) stage
5、裁剪和空间管理
6、[总结] 漫谈HDR和色彩管理(三)SDR和HDR

一、思维导图

二、模型空间

这里的模型空间指的是建模出来的空间,也就是用建模软件输出的数据所在的坐标空间。比如,3D Max用的是右手系,输出的模型数据所在的空间就叫模型空间;由于Unity的模型空间是左手系,所以通常需要旋转90度才能对应上。

三、切线空间

切线空间又可以叫做纹理空间。假如纹理坐标uv构成一个二维空间,加上垂直于这个二维空间的法线,那么就是一个三维的切线空间。

3.1 法线贴图的切线空间

切线空间有什么应用了?我们在计算光照模型的时候,通常会有更精细表示法线的数据,比如法线贴图,法线贴图通常是建模软件用高模计算出来的。不过,法线贴图是原始切线空间下的数据。因此,法线贴图中的法线数据通常是(0,0,1),所以法线贴图表现出大部分是蓝色。我们在读取这个法线数据后,需要将其变换到计算光照模型所在的空间,比如世界空间。

3.2 模型空间下的切线空间

顶点上除了位置数据外,还可以有法线、切线数据。注意,这些数据都是在模型空间的。因此,法线、切线、副切线(法线和切线叉积计算出来)自然可以构成一个模型空间下的切线空间。

3.3 切线变换

假如我们想将切线空间下的法线变换到世界空间,该如何做了?我们需要得到一个世界空间下的切线空间。首先将模型空间下的切线空间变换到世界空间,这样我们就得到了一个世界空间下的切线子空间,然后用这个切线子空间构成一个切线变换,再对切线空间下的法线数据应用这个切线变换就能变换到世界空间。
用公式来表示这个变换是,$NormalWS=TangentMatrix*NormalTS$。当然也可以将切线变换到其它的空间,比如摄像机空间,区别是构造不同的TangentMatrix。

四、关节空间

4.1 关节空间

这里的关节空间,指的是带骨骼的模型中,骨骼或者关节所定义的局部空间。
以人体手指为假设,手指会受到腕关节、肘关节、肩关节影响,对应三个骨骼。那么,手指会依次受到这三个关节的牵扯影响。我们知道,虚拟的根骨骼Root所在的是模型空间,同时每个关节也定义了自己的局部空间,比如腕关节是最终的局部空间,我们把这个关节定义的局部空间叫做关节空间。

4.2 关节姿势

所谓关节姿势,存储的是子关节到父关节的变换,包括旋转、缩放、平移,这个也可以叫做局部关节姿势。全局关节姿势是,将所有的局部关节姿势结合起来。
比如公式,$P{2\to M} = P{2\to 1} P{1\to 0} P{0\to M}$表示的是将顶点从子关节2的局部空间变换到模型空间。全局关节姿势可以表示为$P{j\to M} = \prod {i=j}^{0} P_{i\to p(i)}$,其中p(i)是关节i的父关节。

4.3 绑定关节姿势

我们知道,默认情况下,蒙皮骨骼都有一个T-Pose,即绑定姿势,也可以理解为初始姿势。模型空间的顶点乘以绑定姿势的逆变换就能得到关节空间的顶点。

4.4 蒙皮矩阵

模型空间的顶点乘以绑定姿势的逆变换就能得到关节空间的顶点。关键点来了,这个时候再乘以骨骼的当前全局姿势矩阵,就又变换回了模型空间。所谓的蒙皮矩阵,就是这两个变换的结合。可以用公式表示骨骼i的蒙皮矩阵,$K{j} = (B{j\to M})^{-1} C_{j \to M}$,B代表绑定姿势,C代表当前姿势。多个蒙皮矩阵的加权,就能得到蒙皮动画。

4.5 蒙皮动画

顶点会受到多个骨骼影响,这些骨骼的影响加权和为1,这个就是蒙皮动画。可以用公式来表示,$p’ = \sum_{i=1}^{n}W_i(p)K_ip$。其中,p是模型空间的顶点,Wi是骨骼i影响的权重,Ki是骨骼i的蒙皮矩阵。蒙皮矩阵的计算如上所示。

4.6 总结

根据以上五步的推导,蒙皮动画需要存储的数据是,

  • 绑定姿势下的的模型空间顶点
  • 绑定关节姿势的逆矩阵
  • 当前姿势数据(实时计算当前姿势矩阵)
  • 蒙皮矩阵的权重

五、世界空间

所谓世界空间,不需要解释了吧。放在游戏场景里面,指的是规定了场景坐标系的空间。模型空间,则指的是场景内的单个模型自身数据所在的空间。

5.1 模型变换

模型变换就是将模型空间的顶点数据变换到世界空间,通常包括对模型的平移、旋转和缩放。但是,一般要求的变换顺序是先缩放、再旋转、最后平移,如果反过来会造成平移受到前面的变换影响,与直观印象不符合。
所以,$ModelMatrix=TranslateMatrix*RotateMatrix*ScaleMatrix$。特别说明平移矩阵指的是,将模型从原点移动到其在世界空间的位置。

六、摄像机空间

摄像机空间也叫做观察空间。摄像机可以理解为世界空间的一个位置和朝向,比如在坐标(1,1,1)看向原点,那么射线的位置就是坐标(1,1,1),前向就是看向的方向(-1,-1,-1)。这个时候再定义一个垂直于forward的Up方向,就可以根据叉积找到垂直forward和up方向的right方向。这三个方向就可以构成一个摄像机空间。

6.1 观察变换

观察变换是将顶点从世界空间变换到新的摄像机空间。首先,需要将顶点平移,比如上述情况下的原点在观察空间下是(-1,-1,-1);然后,需要旋转顶点以匹配观察空间的坐标轴。由于观察空间的坐标轴是世界空间下的单位正交基,因此将三个轴放入矩阵即可得到旋转矩阵的逆矩阵(等于旋转矩阵的转置矩阵)。

七、裁剪空间

裁剪空间指的是观察空间下的顶点经过投影变换后所处的空间。我们知道,可视区域是摄像机前面的一个平截头体(透视投影)或者一个长方体(正交投影)。裁剪空间的用途是将可视区域外的物体裁剪,同时计算物体的二维坐标。

7.1 投影变换

投影分为透视投影和正交投影两种,透视投影会造成近大远小的效果,符合视觉效应,三维游戏一般使用透视投影,正交投影则远近一样大,通常只用于建模软件。

透视投影


从图可以看出,透视投影后w是有值的,并不是1,结果还是一个平截头体。具体的矩阵推动,请参考相关资料。

正交投影


从图可以看出,正交投影实际上就是缩放和平移的结合,得到的结果是一个-1到1范围的立方体。

7.2 透视除法和图元裁剪

由于透视投影后齐次坐标的w非1,需要进行透视除法,这个是图形硬件自动进行的。如图所示:

透视除法后,可以得到和正交投影结果一样的规范化立方体,也叫做NDC(规范化设备坐标系)。
到了NDC后,就可以方便的进行图元裁剪,毕竟坐标都是-1到1了(DirectX下Z坐标是0到1)。

7.3 顶点着色器的输出

顶点着色器必须输出裁剪空间下的坐标。由于DirectX的NDC的Z范围是0到1,与OpenGL的-1到1有一定区别。因此,这2者的投影矩阵在Z坐标上有一定的平移和缩放区别。

八、屏幕空间

投影变换后得到的顶点范围是-1到1。现在还需要将NDC下的顶点映射到屏幕空间。屏幕空间也可以叫做窗口空间,即窗口定义的坐标空间。

8.1 视口变换

假设,窗口坐标原点在左下角(OpenGL的原点在左下角,但是DirectX的在左上角),窗口大小为Width和Height。视口变换就是把-1到1的x和y坐标范围映射到(0,width)和(0,height),对应DirectX的话,y还需要取反。这就是一个平移和缩放的过程。

实际上,透视除法和视口变换(屏幕空间映射)都是硬件自动进行的。

九、参考资料

Unity Shader入门精要

一、伽马校正

所谓gamma校正,实际上是一个颜色的非线性变换。下面来解释这个变换曲线存在的原因。

1.1 人眼的非线性视觉效应

为什么要有gamma校正了。一言以蔽之,人眼的生理效应。如下图所示,

第一行是人眼感受到的线性亮度变化,第二行是真实的非线性亮度变化。可以得出结论,首尾两端是一致的,但是中间值变化不一致;真实的中间亮度值必须更大,才能得到人眼感知的线性亮度变化。我们的目的是让人眼感受到线性的亮度变化曲线,因此输入亮度必须是第二行这种非线性的亮度变化曲线。
第二行的亮度变化曲线,就是伽马校正曲线

1.2 非线性显示器

显示器为了应对人眼的这种非线性视觉效应,采用的也是类似的机制(也可能是历史原因,总之认为当今的显示器都是如此设计就行)。假设我们输入的颜色值,即输入给显示器的电压,那么这个电压对应的是1.1的第二行(Gamma校正曲线);人眼感受到的显示器的真实输出对应的是1.1的第一行(线性颜色输出),即gamma编码曲线。
如下图所示,

这里反复强调了,人眼感受到的显示器亮度,而不是显示器的输出亮度。举个例子,输入颜色值是0.732的话,那么显示器经过gamma编码后输出的亮度是0.5,人眼感受到的亮度是0.218,刚好和人眼的视觉效应匹配。

值得强调的是,gamma指数2.2是可以变化的,在不同的场景下,可以选择不同的gamma指数。

1.3 总结

总结,照片是按照gamma校正曲线编码的,显示器经过gamma编码后,输出照片的亮度是线性曲线,人眼看到线性曲线的亮度后感知到的曲线是gamma曲线。
因此,我们需要确定输入的颜色数据是在线性曲线或者gamma校正曲线上。

二、颜色空间和工作流

颜色空间可以理解为,颜色是在哪个空间下制作的。不需要特别多的数学曲线来描绘,但是这个说明又需要一点美术经验来理解。下面来具体分类解释。

2.1 伽马颜色空间和工作流

比如,我们拍摄的照片,人眼看起来是正确的,那么说明人眼感受到的是线性变化的,因此照片的数据是经过伽马校正的,也就是照片的数据变化是在gamma校正曲线上的。同样的,在电脑上使用软件制作的图片也是处于gamma校正曲线上的。
我们把这种颜色数据在gamma校正曲线上的,叫做gamma color space,也叫做sRGB。
那么,伽马工作流指的是所有的流程都在伽马颜色空间完成,比如输入数据,比如光照计算等。

2.2 线性颜色空间和工作流

类似的,线性颜色空间指的是输入数据是在线性曲线上的。那么,我们如果用一张真实的图片作为输入,首先要对其进行gamma校正,也就是需要将这张贴图设置为sRGB,引擎或者图形接口自动会将其转换。
线性工作流指的是所有的流程都在线性颜色空间完成,比如输入数据,比如光照计算等。
值得强调的是,我们现在的显示器都是gamma显示器,因此我们不能在渲染管线中不能直接输出线性数据,需要转换到sRGB空间再进行输出,某些硬件支持这个自动转换,如果检测到硬件不支持,渲染引擎会在后处理流程中用shader来转换。

2.3 工作流总结

下面用一张流程图来总结颜色空间的工作流,如下所示,

  • sRGB Texture在gamma工作流下正常显示
  • 线性工作流的输出必须进行gamma校正,否则显示会变暗
  • gamma工作流的shader计算在sRGB空间中
  • 线性工作流的shader计算在线性空间中

注意,sRGB贴图移除gamma校正和shader输出进行gamma校正,都有硬件的自动支持,比如OpenGL的sRGB纹理和 GL_FRAMEBUFFER_SRGB。如果硬件不支持,那么应用(比如游戏引擎),在线性工作流中需要自己进行变换,比如加载sRGB贴图时候手动变换到线性空间和使用shader进行gamma校正。

2.4 关于贴图设置为sRGB后变暗的说明

业界或者网上一直流传,贴图设置为sRGB后会变暗。
参考2.3的图,在线性工作流下,如果贴图设置为sRGB后,引擎会对贴图进行去gamma校正,变换为线性空间,颜色数值都会变小,参考1.2的曲线图。不管原始图片是否是sRGB空间下创建的,渲染时候得到的颜色值都变小了,因此不管输出时候是否进行gamma校正,我们看到的结果都会变暗。
如果是gamma工作流,则不会变暗,因为没有去gamma校正这个过程。

三、总结

我们讲述了人眼和显示器的视觉效应,以及两种颜色空间和对应的工作流。我们需要着重弄清楚的是,人眼的视觉效应、显示器的gamma校正、gamma颜色空间(sRGB)。

四、参考资料

Unity Color space
Gamma Correction
Gamma、Linear、sRGB 和Unity Color Space,你真懂了吗?

一、DrawCall、Batches、SetPassCalls的基本理解

我们先从图形渲染的角度对这些概念做一个基本的理解。

1.1 DrawCall

DrawCall实际上指的是一次图形渲染接口的调用,比如OpenGL的glDrawArrays或者glDrawElements的一次调用,以及DirectX的DrawPrimitive或者DrawIndexedPrimitive。因此,DrawCall可以简单理解为一次渲染指令调用。

1.2 Batches

我们知道,在调用DrawCall之前,需要设置渲染状态,比如当前使用的Shader、当前shader的参数(材质参数)、深度测试是否开启、模板测试设置等,设置完这些状态后,才会调用DrawCall。我们把设置渲染状态,加载网格数据,然后调用DrawCall这一个过程,叫做一个批次。理论上,我们可以在设置完渲染状态后,调用多个DrawCall,假如一个DrawCall的绘制数量有限制的话,但是通常一个批次也就调用一次DrawCall。
那么所谓合批,就是想办法尽量减少批次。减少批次的关键是减少场景中不同的渲染状态组合,也就是渲染状态切换尽可能少。这样子批次自然最少。批次少了,批次对应的DrawCall自然少了,每个批次需要的渲染状态切换也少了。注意,渲染状态切换类似于DrawCall都是一次渲染指令调用。

1.3 SetPassCalls

那么什么是SetPassCalls了。在Shader中有一个Pass的概念,比如一个Shader有2个Pass,那么实际上应用这个Shader的物体会按照Shader的Pass定义顺序渲染2遍,每一遍都是用对应的Pass渲染。Unity的官方文档里面解释SetPassCalls就是Shader中的Pass被切换的次数,因为每个渲染批次都会设置一个Pass,一个Pass就会对应一些渲染状态,当渲染状态变化时候就必须开始新的批次,但是新的批次下Pass可能没有变化

二、Unity的DrawCall、Batches、SetPassCalls区别和联系

我们以一个没有开启静态合批的场景运行时的统计数据为例子来说明。我们打开Unity场景的Statistics窗口,

以及Profile窗口,

FrameDebug窗口,

我们可以得出这个场景的DrawCall是584,Batches也是584,SetPassCalls是192。Statistics中是不会显示DrawCall的,只有在Profile窗口下选中Rendering才能看到。

2.1 Unity的DrawCall

根据运行数据,可以得出结论DrawCall数目基本等于Batches。为什么说基本了?因为同一个Batch下,可能分多次调用DrawCall,比如网格过于巨大,可能拆分成多个DrawCall,这个也是符合批次的定义的,因为渲染状态没有切换,这发生在静态合批和动态合批的情况下。
如果没有静态合批和动态合批,那么DC等于Batches,如果有那么DC没有变化,但是Batches等于合并之后的渲染状态切换。

2.2 Unity的Batches

Unity的批次实际上就是前面解释的Batches。不过,Batches实际上包含有三类:Static Batches、Dynamic Batches、Instancing Batches,分别对应Unity的静态合批、动态合批、实例化渲染。

2.3 Unity的SetPassCalls

根据FrameDebug窗口可以看到,一共是197+24+1+1=233个渲染事件。其中,Clear事件有14个。除去Clear事件后还生效219的事件,不过我们的SetPassCalls是192,还多了17个。我们观察到UI相机有18个DrawMesh事件,点击后发现这个事件使用的都是同样的Pass,如下图所示,

,这些Pass之间除了材质属性外的渲染状态都是一致的,因此还要减去17。
注意,FrameDebug窗口的截图中折叠的部分基本是SRP Batch。
根据这些数据我们可以得出结论,如果支持SRP Batch,一个SetPassCall等于一个SRP Batch;如果不支持SRP Batch,那么一个SetPassCall就是一次Shader的Pass切换。由于Pass切换实际上指的是Shader关键字或者ROP阶段的设置改变,那么其实这个跟SRP是一致的。SRP本质上也是Shader变体切换,而非传统的材质切换。传统的材质切换对应的是Batches。
注明:实验引擎版本是Unity2020.3.12。

2.4 总结

至此可以得出最终结论,Unity的DrawCall和Batches数目在没有静态合批和动态合批时候相等,Batches对应的是传统的材质切换,DrawCall是一次Batch内一次到多次的渲染命令调用。SetPassCalls一般会大幅度少于Batches,对应的是SRP Batch或者Pass切换,数目等于FrameDebug中的事件数目减去Clear事件、Draw Mesh事件中重复的Pass数目。

三、DrawCall相关的性能优化

3.1 为什么需要降低DrawCall

一谈起游戏优化,尤其是渲染优化,大家就说降低DrawCall,降低批次。实际上,大部分人都没法正确区分,Unity引擎下DrawCall、Batch、SetPassCall这三个概念。DrawCall或者批次高,并不是性能低下的直接原因,真正的原因是批次高,导致渲染状态切换过多。而渲染状态切换实际上是发生的渲染管线的CPU阶段,使用图形API,比如OpenGL或者DirectX来完成的。这样CPU会花费大量的时间提交渲染指令给GPU,CPU占用过高,但是GPU的渲染指令队列并没有饱和,GPU执行渲染指令的速度很快,因此GPU的负荷可能还没上来,GPU在等待CPU提交渲染指令,整个渲染流水线没有最高速的跑起来。当然如果GPU也忙不过来,那么不仅仅需要降低批次,Shader复杂度和OverDraw应该是重点关注对象。

3.2 如何降低批次

3.2.1 静态合批

静态合批实际上是引擎在打包或者烘焙时候,将同材质的物体合并成一个更大的物体,这样相同材质的物体只需要一次渲染状态设置和一次DrawCall调用,也就一个批次。由于合并生成大的模型后,会占用额外的内存空间,比如三个同材质的立方体的网格就是一个简单的立方体,合并后的网格占用是三个世界空间立方体的组合,因此有时候需要考虑静态合批带来的内存增长。

3.2.2 动态合批

动态合批是静态合批在运行时的体现。Unity对动态合批有一些限制,比如限制模型顶点属性不能超过900等,具体可以参考Dynamic batching。动态合批由于是运行是合并网格,因此不仅会增大内存,还会占用CPU时间。动态合批一般应用在一些小物体的合并上,比如小的道具或者特效等。

3.4.3 Instancing Draw

Instancing Draw实际上是图形接口支持的一种技术,可以翻译为实例化渲染,可以参考文档:实例化。这种技术通常应用在重复的物体大量出现的情况下,比如说草地、树木、星星,这种只有位置或者朝向、缩放等不一样。实例化渲染可以通过指定每物体属性(正常的每顶点属性是每个顶点不一样)来传入这种每个物体不一样的属性,从而避免使用不同的材质。在OpenGL中是使用glVertexAttribDivisor来设置属性的更新速度,从而指定每物体属性。
至于Unity的Instancing,参考文档:GPU instancing。关键点:GPU instancing不能和SRP Batcher、Static Batcher并存,SRP Batcher、Static Batcher的优先级更高;GPU instancing不支持 SkinnedMeshRenderers(蒙皮); Graphics.DrawMeshInstanced或者Graphics.DrawMeshInstancedIndirect是主动Instancing,如果不调用这2个函数,那么Unity会尝试Instancing(如果Shader支持Instancing,且没有开启SRP Batch),这会有额外的CPU消耗。

3.4.4 SRP Batcher

参考文档:Scriptable Render Pipeline (SRP) Batcher。关键点:只有可编程管线才支持,默认管线不能支持;Shader必须支持SRP Batcher;只支持Mesh和SkinMesh,不支持粒子系统;不能与 Instancing Draw兼容;如果使用了MaterialPropertyBlock,SRP Batcher无法开启。
SRP Batcher本质上是Shader变体级别的合批优化,根据前面的分析等价于一次SetPassCall。具体原理还是参考Unity 的官方文档。

3.4.6 合批方法的优先级

根据Unity优化DC的官方文档Optimizing draw calls,合批方法的优先级如下:

1.SRP Batcher and static batching
2.GPU instancing
3.Dynamic batching
其中SRP和静态合批是最高优先级,并且是可以兼容的(对于使用SRP Batcher兼容Shader的物体),因此可以同时启用静态合批和SRP Batcher。不过,经过实验发现上述实验场景在开启了SRP Batcher后,再去打开静态合批,Batches并没有多少什么变化,猜测是场景内使用同样材质的物体过少,相反使用同样Shader变体的物体较多。

3.4.5 合批总结

对于目前的可编程管线,优先使用的都是SRP,因此Shader要尽可能兼容SRP Batcher。对于特殊情况,比如渲染草地这种,才需要舍弃SRP Batcher去使用实例化渲染。对于不支持SRP Batcher的Shader,动态合批和静态合批才可能会被开启。动态合并和静态合批都要增大内存,动态合批还会占用CPU,限制条件还非常多。所以,首选SRP Batcher和Instancing。
由于SRP Batcher不能降低DrawCall和Batcher,实际上降低的是SetPassCall;但是静态合批和动态合批可以降低Batcher,但是不能降低DrawCall。所以,在一些低端机器上,Batcher过多可能引起问题的话,还是得开启传统的静态合批,不过这会需要打开网格读写,合并网格也会增大包体和内存。因此出现这种情况的话,最好的选择应该是只开启SRP Batcher,然后让美术手工合并网格和贴图。

四、参考资料

DrawCall,Batches,SetPass calls是什么?原理?【匠】
The Rendering Statistics window
Unity Profiler中常见的WaitForTargetFPS、Gfx.WaitForPresent 和 Graphics.PresentAndSync
Draw call batching
Optimizing draw calls

左手坐标系和右手坐标系是三维空间下两种不同的坐标系,而且无法通过旋转将左手坐标系转换到右手坐标系。与其相对应的,有左手定则和右手定则,主要是用来确定叉积的朝向或者说旋向。
首先,规定二维坐标,X轴朝右、Y轴朝上,推广到三维空间,需要确定的是Z轴是朝前还是朝后。

一、左手坐标系

所谓左手坐标系,指的是通过左手来确定的一个三维空间坐标系。

1.1 确定左手坐标系的方式

下面总结了三种可以确定左手坐标系的方法。

1.1.1 拇指、食指、中指相互垂直确定法


如图,伸出左手,拇指朝上代表Y轴、食指朝前代表Z轴、中指朝右代表X轴。注意,中指这个时候是只能往右边弯曲的。

1.1.2 左手定则确定法

伸出左手,手指朝着右边X轴,握向Y轴,这个时候拇指指向的方向就是Z轴(朝前)。

1.1.3 人站立的正面朝向确定法

人朝前站立着,右手伸出的朝向是X轴,头顶的方向是Y轴,面向Z轴。

1.2 左手定则

假设,叉乘计算,C=A叉乘B。如何确定在C的朝向了?如果A和B都在左手坐标系下,那么使用左手定则来确定C的朝向。
类似1.1.2,伸出左手,手指朝着A,握向B,这个时候拇指指向的方向就是C。

二、右手坐标系

2.1 确定右手坐标系的方式

2.1.1 拇指、食指、中指相互垂直确定法

参考1.1.1,伸出右手,拇指朝上代表Y轴、食指朝前代表Z轴、中指朝左代表X轴。注意,中指这个时候是只能往左边弯曲的。
但是,我们一般假定X轴朝右,因此需要握着Z轴旋转180度。这个时候,拇指朝上代表Y轴、食指朝后代表Z轴、中指朝右代表X轴。注意,左右手坐标系旋转后不会改变。

2.1.2 左手定则确定法

伸出右手,手指朝着右边X轴,握向Y轴,这个时候拇指指向的方向就是Z轴(朝后)。

2.1.3 人站立的正面朝向确定法

人朝前站立着,右手伸出的朝向是X轴,头顶的方向是Y轴,背后的是Z轴。

2.2 右手定则

类似1.1,如果A和B都在,右手坐标系下,那么使用右手定则来确定C的朝向。
类似1.1.2,伸出右手,手指朝着A,握向B,这个时候拇指指向的方向就是C。
因此,左手定则和右手定则的区别是使用左手还是右手。

三、图形API的左右手坐标系

图形管线中,存在多个坐标系,每个坐标系都可以使用左手或者右手坐标系。下面按照,物体坐标系->世界坐标系->摄像机坐标系->裁剪坐标系->窗口坐标系来说明。

3.1 OpenGL

OpenGL默认是右手坐标系。不过到了窗口坐标系,OpenGL使用的是左手坐标系。为什么了?因为OpenGL的深度范围是[0,1],而且是摄像机越远,深度越大,这就是左手坐标系啦
由于物体坐标系、世界坐标系、摄像机坐标系都是右手坐标系,但是窗口坐标系是左手坐标系,那么投影矩阵就需要乘以右手坐标系变换到左手坐标系这个变换,也就是Z变换成-Z。不过这个变换也可以放在摄像机坐标系,也就是MVP的V中。现在假定,都乘到P中了。
最终结论是:物体坐标系、世界坐标系、摄像机坐标系是右手坐标系;裁剪坐标系和窗口坐标系是左手坐标系,窗口坐标系实际上只是裁剪坐标系进行齐次除法后再平移缩放而已。

3.2 DirectX

DirectX默认是左手坐标系。
类似3.1,物体坐标系、世界坐标系、摄像机坐标系是左手坐标系。注意,DirectX的窗口坐标系是以左上角为原点的,深度是朝前的,那么跟OpenGL的反过来,是右手坐标系。
因此,裁剪坐标系和窗口坐标系是右手手坐标系。投影变化同样要乘以,右手坐标系变换到左手坐标系这个变换,也就是Z变换成-Z。

3.3 Vulkan

Vulkan的窗口坐标系和DirectX的一致,因此推测其余坐标系和DirectX的一致。

3.4 Metal

Vulkan的窗口坐标系和DirectX的一致,因此推测其余坐标系和DirectX的一致。

看来只有,历史遗留的奇葩OpenGL的窗口坐标系,原点在左下角啊。原点在哪,这个跟纹理的v坐标是否需要取反也有关系。

四、游戏引擎的左右手坐标系

游戏引擎中,物体和世界坐标系是固定的,对于所有的图形API都会一样。

4.1 Unity


根据上图,Unity的物体和世界坐标系可以推测都是左手系。

根据上图,出自Shader入门精要,Unity的窗口坐标系和OpenGL的一致,是左手系。但是摄像机空间变换到了右手系。那么,在V中需要乘以Z到-Z的变换。同时,P中再乘以-Z到Z的变换变回左手系。
为啥多次一举了,怀疑这个结论的正确性。下面做实验,用IMGizmos绘制坐标轴。代码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using UnityEngine;

namespace GYGame
{
/// <summary>
/// 出生点
/// </summary>
public class PlayerStart : MonoBehaviour
{
public float GizmosHeight { get; set; } = 2.0f;

void OnDrawGizmos()
{
IMGizmos.Line3D(transform.position, transform.position + transform.up * GizmosHeight, Color.green);
IMGizmos.Line3D(transform.position, transform.position + transform.right * GizmosHeight, Color.red);
IMGizmos.Line3D(transform.position, transform.position + transform.forward * GizmosHeight, Color.blue);
}
}
}

选中场景相机,可以得到下面结果,

可以看到右下角的场景相机画面里面有显示PlayerStart的Gizmos,Gizmos显示的坐标系是左手系,跟右上角显示的坐标系是一致的。同时,引擎自带的Gizmos显示的摄像机前向也是Z轴正向。
因此,推测我实验的Unity版本是2020,与UnityShader入门精要使用的Unity5.X版本,摄像机空间的旋向性已经发生了变化。

4.2 UnrealEngine


虚幻和Unity一样也是采用左手坐标系,不过其是Z轴朝上,Y轴朝外。沿着X轴旋转90度,可以得到Z轴朝内,Y轴朝上,那么和Unity的是一致的。
推测,其余的空间的坐标系旋向和Unity的是一致。摄像机空间的旋向也可以用类似4.1的方式绘制Gizmos,然后选中摄像机,查看摄像机的绘制结果。

五、参考资料

Shader入门精要
图形坐标系的跨平台适配

一、命名法

Pascal命名法:每个单词首字母大写。
Camel命名法:第一个单词首字母小写,其余单词首字母大写。
C++标准库命名法:全小写,单词用下划线分割。

1.1 CSharp

函数和类采用Pascal命名法,变量采用Camel命名法。
代码目录和文件采用Pascal命名法。

1.2 Lua

类采用Pascal命名法,其余采用C++标准库命名法。
代码目录和文件采用C++标准库命名法。

1.3 其它

其它目录和文件采用Pascal命名法。

二、C#代码规范

2.1 命名的基本约定

函数用动词命名,其它的用名词或者形容词命名。

避免使用拼音

原则上避免使用拼音命名代码。

尽量避免缩写

尽量不要缩写名字,名字长没关系,尽可能描述清楚。

类型前缀

类和变量前一般不要加前缀。模板类型加前缀T,接口加前缀I,枚举加前缀E。

类型后缀

特殊类型可选加后缀。
List:可选加List后缀。
Dictionary:可选加Dict后缀。
delegate:加上后缀Event。

命名空间

使用Pascal命名法。
命名空间采用GY开头,比如GYEngine、GYGame。

使用Pascal命名法。
类名要用名词。模板类开头用T。

接口

使用Pascal命名法。
接口开头用I。接口名要用名词或者形容词。

枚举

枚举类型采用Pascal命名法,需要加上前缀E,比如EMessageType。
枚举常量不需要加前缀,采用Pascal命名法,特殊情况下可以拆成两部分用下划线区分,比如Message_Start。

1
2
3
4
5
6
7
8
9
10
11
12
public Enum EWeaponType
{
Knife,
Pistol,
MachineGun,
}

public Enum EMessageType
{
Message_Start,
Message_End,
}

函数

使用Pascal命名法。
函数名最好用动词开头。

委托和事件

使用Pascal命名法。
使用动词短语命名,delegate类型的命名需要加上后缀Event。
event类型的实例需要加上On前缀,Event后缀。

1
2
3
public delegate void KillMonsterEvent();

public event KillMonsterEvent OnKillMonsterEvent = null;

属性

使用Pascal命名法。
属性是对Get和Set的语法封装,一般是public或者protected采有意义。

特性(Attribute)

使用Pascal命名法。
用名词或名词短语+Attribute方式命名特性。
比如,

1
2
3
public class ObsoleteAttribute
{
}

局部变量

采用Camel命名法。

函数参数

采用Camel命名法。

成员变量

类非公有非静态成员变量用m开头。比如mActorId。
类的公有成员变量大写开头,不需要加m前缀,尽量用属性代替公有变量。

静态变量

类的静态成员变量用s开头。
函数内的静态变量用s开头。
比如,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public Actor
{
private int mActorId = 0;

private static int sActorNumInClass = 0;

protected int mActorClassId = 0;

public string ActorName = "name";

public int ActorId
{
get
{
return mActorId;
}

set
{
mActorId = value;
}
}

public delegate void KillMonsterEvent();

public event KillMonsterEvent OnKillMonsterEvent = null;

public Actor()
{
mActorId = 0;
}

public int GetActorNum(bool isFirstTime)
{
static int sActorNumInFun;
int addNum = 1;
return sActorNumInFun = (isFirstTime ? 0 : sActorNumInFun + addNum);
}
}

常量

所有单词大写,多个单词之间用下划线隔开,比如public const int MAX_NUM = 10。

注释

原则上,尽量写可读性良好、自解释的代码,避免写冗余的注释。

文件注释

文件开头必须要有注释,如果是单个类的文件,可以将用类注释替代。

类注释

单个类的文件,必须有类注释。
类注释说明该类是做什么的,可选包含怎么实现以及为什么这么实现的原因。

函数注释

简单函数不需要注释,难以使用的函数需要加注释,想想为什么难以使用,这个时候往往需要重构或者拆分函数代码了。

语句注释

关键难以理解的代码语句,需要加上注释说明。

变量注释

关键变量加上注释,普通的不需要加注释。

2.2 代码风格

类成员排列顺序

  1. 属性:公有属性 、受保护属性
  2. 字段:受保护字段、私有字段(公有字段当作属性对待)
  3. 事件:公有事件、受保护事件、私有事件
  4. 构造函数:参数数量最少的构造函数,参数数量中等的构造函数,参数数量最多的构造函数
  5. 方法:重载方法的排列顺序与构造函数相同,从参数数量最少往下至参数最多。方法按照功能分块,尽可能按照公有、保护、私有的访问级别来分布。

变量

  1. 一行只能声明一个变量,尽量避免用var定义变量类型,除非类型写起来很冗余。
  2. 尽量在声明的同时初始化。
  3. 变量定义在开头,比如类开头或者函数开头。除非是根据条件定义的块变量。

比如,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class People
{
private string mName = "PeopleName";
private int mAge = 0;

public void ChangeAge(int newAge, bool needAddAge)
{
mAge = newAge;
if (needAddAge)
{
int tempAddAge = 1;
mAge += tempAddAge;
}
}
}

语句

  1. 一行只能有一条语句。
  2. 单行复合语句必须加大括号。原则上,即使只有一行语句,也需要加大括号包起来,防止后续修改代码破坏忘记语句范围。比如,

  3. else if等必须新起一行。比如,

    1
    2
    3
    4
    5
    6
    7
    8
    if (isWorkday)
    {
    Work();
    }
    else if (isHoliday)
    {
    Rest();
    }

缩进

代码缩进使用Tab键实现,最好不要使用空格,为保证在不同机器上使代码缩进保持一致,设置Tab键宽度为4个字符。

大括号

  1. 大括号需要占一行对齐,而不是将左大括号放在行尾。
  2. Lambda函数可以将左大括号放在同一行,不需要另起一行。

空格

  1. if、while、for、return等关键词后应有一个空格[eg. “if (a == b)”]。
  2. 运算符前后应各有一个空格[eg. “a = b + c;”]。
  3. 函数调用后不需要加空格。
  4. 左括号后面和右括号前面不需要加额外的空格。

空行

  1. 函数之间必须加空行。
  2. 较长函数的代码块直接用空行分割。
  3. 变量定义可以分块加空行分割。

行长度

每一行代码的行长度,建议不要超过110个字符或者说不超过屏幕宽度。如果超过这个长度,可以按照以下规则换行:

  1. 在逗号后换行。
  2. 在操作符前换行。
  3. 第一条优先于第二条。

函数长度

建议单个函数长度不要超过80行。越简短越好。
超过80行,可以考虑拆分函数重用代码。

类长度

单个类文件原则上不超过1000行。接近或者超过,考虑拆分类或者多个文件实现类。

2.3 示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
namespace YMGame
{
public Enum EWeaponType
{
Knife,
Pistol,
MachineGun,
}

public Actor
{
private int mActorId = 0;

private static int sActorNumInClass;

protected int mActorClassId;

public string ActorName;

public int ActorId
{
get
{
return mActorId;
}

set
{
mActorId = value;
}
}

public int GetActorNum(bool isFirstTime)
{
static int sActorNumInFun;
int addNum = 1;
return sActorNumInFun = (isFirstTime ? 0 : sActorNumInFun + addNum);
}

public void SetActorId(int classId, int actorId)
{
static int sNonClassIdActorNum = 0;

mActorClassId = classId;
mActorId = actorId;

if (mActorClassId <= 0 )
{
sNonClassIdActorNum++;
bool isNonClassIdActor = true;
actorId = 0;
}
}
}
}

三、Lua代码规范

除了以下特殊提及到的,Lua的代码规范参照C#的代码规范。

3.1 命名规则

文件(类)名

采用Pascal命名法。

函数

采用Pascal命名法。

文件的local变量

下划线开头,采用Camel命名法。比如_classType。

函数的local变量

采用Camel命名法。

函数参数

采用Camel命名法。

C#代码导出到Lua

必须增加Cs前缀以做区分,比如CsFileManager = CS.GYEngine.FileManager.Instance。

双下划线

双下划线用于一些特殊函数的前缀,比如类的初始化和销毁函数。

日志打印

使用项目规定的log函数。比如使用log.l,可以通过个人logid来过滤其他人日志;警告使用log.w;错误使用log.e,避免使用默认的error。

四、编程技巧

避免使用魔数

代码里面不要出现魔法数字,用常量来替代。

1
2
3
4
5
6
7
8
9
10
11
public double CalculateCircularArea(double radius) 
{
return (3.1415) * radius * radius;
}

// 常量替代魔法数字
public static final Double PI = 3.1415;
public double CalculateCircularArea(double radius)
{
return PI * radius * radius;
}

解释型变量

如下所示,用bool变量代替复杂的条件判断,bool变量的命名可以解释条件判断的意思。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if (date.after(SUMMER_START) && date.before(SUMMER_END))
{
// ...
}
else
{
// ...
}

// 引入解释性变量后逻辑更加清晰
bool isSummer = date.after(SUMMER_START)&&date.before(SUMMER_END);
if (isSummer)
{
// ...
}
else
{
// ...
}

避免函数参数过多

参数过多时候,可以将参数组合成一个结构体传入,方便后续对参数的修改。

避免函数参数控制函数内部逻辑

可以考虑拆分成多个函数,保证函数职责单一。

避免嵌套过深

可以考虑使用continue、break、return关键字,提前退出嵌套。

分割代码和单一职责

如果函数或者类的代码过长,考虑拆分成多个函数或者类,保证职责单一。

预计算和缓存

比如Component或者UI控件的获得等,可以在初始化的时候获取然后缓存引用,避免重复查询。

避免频繁创建字符串

由于C#中的string是独一无二的,无法修改,所以字符串操作会创建新的字符串,不像C++可以就地初始化或者重复利用对象,因此避免大量使用string的操作符构建字符串,改成使用StringBuilder。

五、安全性编程

5.1 安全性编程原则

判空

C#中的对象都是引用,使用前需要判空,空引用会造成异常。这个是良好的编程习惯。可以用空值传播操作符等,简略代码。

参数检查

对传入的参数要进行安全性检查,比如空引用,索引范围等,非法情况提前返回,然后再进行正常的逻辑处理。

尽可能使用错误处理而不是异常处理

异常有额外的性能消耗,加上异常会破坏调用链,应该尽可能用错误判断得方式处理各种可以预测的问题,而不是抛出异常。游戏引擎内一般不使用异常,比如UE4的源码内就禁用异常。

5.2 示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public IEnumerator SpawnObjectAsync(string assetPath, Vector3 position, Quaternion rotation, Vector3 scale, Transform parent = null,
string name = "", Action<GameObject> onSpawnObjectDone = null)
{
if (string.IsNullOrEmpty(assetPath))
{
GYLog.LogError("GameObjectPool SpawnObjectAsync assetPath is IsNullOrEmpty");
yield break;
}

GameObjectPooledItemList pool = null;
if (mAssetPathLookup.TryGetValue(assetPath, out pool) == false)
{
yield return WarmPoolAsync(assetPath, 1, (tempPool) => pool = tempPool);
}

if (pool == null)
{
GYLog.LogError("GameObjectPool SpawnObjectAsync Get GameObjectCollection return null");
yield break;
}

GameObject clone = pool.GetItem();

if (clone == null)
{
GYLog.LogError("GameObjectPool SpawnObject Get GameObject from GameObjectCollection return null");
yield break;
}

clone.SetActiveEx(true);

if (parent != null)
{
clone.transform.parent = parent;
}
clone.transform.position = position;
clone.transform.rotation = rotation;
clone.transform.localScale = scale;

if (name != "")
{
clone.name = name;
}

mInstanceLookup.Add(clone.GetInstanceID(), pool);
mIsDirty = true;

onSpawnObjectDone?.Invoke(clone);
}

比如示例代码,首先做了输入参数检查,然后在执行过程中做了条件检查,检查失败直接主动报错,马上返回。

六、改动权限

项目中可以通过SVN或者Git的权限限制,避免过多人改动底层或者关键代码。下面举例说明,

C#的Engine代码

原则上,Engine代码不做改动,主程或者指定的人有权限改动,其它人需要改动需要事先跟主程沟通后才能改动。

C#的Game代码

在游戏发布之前,Game代码允许改动;在游戏发布之后,改动Game层的C#代码需要热更新二进制包或者打补丁更新,有改动需求需要事先跟主程沟通。

Lua的框架代码

框架代码改动之前需要考虑清楚,客户端程序都有改动权限,改动大的部分最好同步主程或者执行主程等,并且负责跟踪和修复改动后引入的问题

Lua的业务代码

客户端程序一直有改动权限,需要遵守代码规范。

一、内存对齐

C++的对象都会进行内存对齐,所谓内存对齐,指的是对象的地址和大小都会对齐到n的倍数上。比如按照4对齐,那么对象的地址会是4的倍数,对象的大小也是4的倍数。究其原因是,机器在内存对齐的地址上访问数据更快,可以一起取出数据;如果数据存在在不对齐的地址上,需要换成2次对齐地址上的取数据,再组合出原始数据;而且,部分机器根本没有取非对齐的数据。

1.1 默认对齐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class OrdinaryClassWithMemoryPack
{
public:
int intA;

short shortB;

float floatC;
};

std::cout << "sizeof(int):" << sizeof(int) << std::endl;
std::cout << "sizeof(short):" << sizeof(short) << std::endl;
std::cout << "sizeof(float):" << sizeof(float) << std::endl;
std::cout << "sizeof(OrdinaryClassWithMemoryPack):" << sizeof(OrdinaryClassWithMemoryPack) << std::endl;
std::cout << "address of omp:" << &omp << std::endl << std::endl;

vs2019 x86的结果

vs2019 x64的结果

可以看到,默认都是按照4字节对齐,int和float都是4个字节,short是2个字节,不过强制按照4字节对齐了。对象的地址都是4的倍数,不过64位程序的地址是64位了。

1.2 Pack(n)

假如我们用pack指令强制按照2字节对齐,那么输出结果如何了?

1
2
3
4
5
6
7
8
9
10
11
12
#pragma pack(push)
#pragma pack(2)
class OrdinaryClassWithMemoryPack
{
public:
int intA;

float floatB;

short shortC;
};
#pragma pack(pop)

vs2019 x86的结果

vs2019 x64的结果


从输出结果可以看出,对象还是位于4对齐的地址上,只是对象本身的大小变成10了。short只占2个字节,那么接下来的float并没有强制在4字节的地址对齐,而是根据pack指令对齐在2字节的地址上了。

1.3 实验环境

未避免文章过于啰嗦,接下来的例子只说明vs2019 x86的输出结果。

二、普通类的对象

2.1 基类的对象

接下来的讨论为避免内存对齐的干扰,忽略内存对齐。因此,类的成员变量只有一个int。定义基类如下,

1
2
3
4
5
class OrdinaryClassA
{
public:
int intA;
};

2.2 单继承子类的对象

定义子类如下,

1
2
3
4
5
class OrdinaryClassAFirstSon : public OrdinaryClassA
{
public:
int intAFirstSon;
};

2.3 多继承子类的对象

定义多继承的子类如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class OrdinaryClassASecondSon : public OrdinaryClassA
{
public:
int intASecondSon;
};

class OrdinaryMultipleInheritClassE : public OrdinaryClassAFirstSon, public OrdinaryClassASecondSon
{
public:
int intE;
};
std::cout << "sizeof(OrdinaryClassA):" << sizeof(OrdinaryClassA) << std::endl;
std::cout << "sizeof(OrdinaryClassAFirstSon):" << sizeof(OrdinaryClassAFirstSon) << std::endl;
std::cout << "sizeof(OrdinaryMultipleInheritClassE):" << sizeof(OrdinaryMultipleInheritClassE) << std::endl;

输出结果:

根据输出结果,可以看出:基类是4个字节;子类拥有基类的对象,加上自己的成员,一起是8个字节;多重继承的子类,拥有2个基类对象,加上自己的成员,总共是8+8+4=20个字节。
OrdinaryMultipleInheritClassE的两个基类都继承同一个类OrdinaryClassA,因此E的对象中会有2份A的实例。一般的编程范式中,都要求避免多继承,改用多接口继承。C++在针对这种情况,也有一种虚拟继承的方式来避免数据冗余。

三、带虚函数的类对象

3.1 带虚函数的基类的对象

1
2
3
4
5
6
7
8
9
10
11
12
class VirtualFunClassA
{
public:
int intA;

public:
virtual int VirtualFunA()
{
return 0;
}
};

3.1 带虚函数的单继承子类的对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class VirtualFunClassAFirstSon : public VirtualFunClassA
{
public:
int intAFirstSon;

public:
virtual int VirtualFunA() override
{
return 0;
}

virtual int VirtualFunAFirstSon()
{
return 0;
}
};

VirtualFunClassA va;
VirtualFunClassAFirstSon vason;

std::cout << "sizeof(VirtualFunClassA):" << sizeof(VirtualFunClassA) << std::endl;
std::cout << "sizeof(VirtualFunClassAFirstSon):" << sizeof(VirtualFunClassAFirstSon) << std::endl << std::endl;

用vs2019调试,自动窗口中显示的va和vason的内存布局如下:

输出结果:

可以看到,类对象内多了一个vfptr(虚函数指针),其中子类的虚函数指针是放在父对象内的。

3.2 带虚函数的多继承子类的对象

现在来考虑多继承的情况,假如多个基类都有虚函数,那么内存布局如何了?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class VirtualFunClassB
{
public:
int intB;

public:
virtual int VirtualFunB()
{
return 0;
}
};

class VirtualFunMultipleInheritClassC : public VirtualFunClassA, public VirtualFunClassB
{
public:
int intC;

public:
virtual int VirtualFunA() override
{
return 0;
}

virtual int VirtualFunB() override
{
return 0;
}

virtual int VirtualFunC()
{
return 0;
}
};

VirtualFunMultipleInheritClassC vmc;

std::cout << "sizeof(VirtualFunClassA):" << sizeof(VirtualFunClassA) << std::endl;
std::cout << "sizeof(VirtualFunClassB):" << sizeof(VirtualFunClassB) << std::endl;
std::cout << "sizeof(VirtualFunMultipleInheritClassC):" << sizeof(VirtualFunMultipleInheritClassC) << std::endl << std::endl;

用vs2019调试,自动窗口中显示的vmc的内存布局如下:

输出结果:

可以得出结论:vmc中有2个基类的对象,大小分别是8,自身有一个大小为4的int,因此总共是20的大小;多继承的对象内会有多个虚函数指针,一个指针对应一个带虚函数的基类;子类如果也带非继承而来的虚函数,那么这个虚函数也会放在某个基类的虚函数表内。
因此,多重继承的子类对象,会有多个虚函数指针,对应多个虚函数表,自身虚函数会被合并到某个基类的虚函数表中,不会再多一个虚函数指针和虚函数表。对于多重继承子类的多个虚函数表,可能是分开存储,也可能是连续存储为一个表,只是虚函数指针有一定的偏移。

四、虚拟继承的类对象

下面来讨厌最变态的部分,虚拟继承的对象。

4.1 虚多继承子类的对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class OrdinaryClassAVirtualFirstSon : virtual public OrdinaryClassA
{
public:
int intAFirstSon;
};

class OrdinaryClassAVirtualSecondSon : virtual public OrdinaryClassA
{
public:
int intASecondSon;
};

class OrdinayVirtualMultipleInheritClassF : public OrdinaryClassAVirtualFirstSon, public OrdinaryClassAVirtualSecondSon
{
public:
int intF;
};

OrdinaryClassAVirtualFirstSon oavson;
OrdinayVirtualMultipleInheritClassF ovmf;

std::cout << "sizeof(OrdinaryClassAVirtualFirstSon):" << sizeof(OrdinaryClassAVirtualFirstSon) << std::endl;
std::cout << "sizeof(OrdinaryClassAVirtualSecondSon):" << sizeof(OrdinaryClassAVirtualSecondSon) << std::endl;
std::cout << "sizeof(OrdinayVirtualMultipleInheritClassF):" << sizeof(OrdinayVirtualMultipleInheritClassF) << std::endl << std::endl;

用vs2019调试,自动窗口中显示的ovmf的内存布局如下:

输出结果:

可以看到2个基类的大小都是12,子类的大小是24。如果是普通继承的话,基类的大小是8,子类的大小是20,这个可以参考2.3。那么,虚继承的对象内肯定多了什么?具体是什么了。

启用类内存布局分析

由于自动窗口无法显示虚拟继承的内存布局了,那么我们只能用其它方式来查看。
如下图,我们通过Project的属性窗口,找到C++ ->命令行,添加新的选项 /d1 reportAllClassLayout。

虚继承的基类内存布局

然后清理工程重新生成,在输出窗口会输出所有类的局部情况,然后搜索OrdinaryClassAVirtualFirstSon,如下图所示,

可以看到,对象内有三个成员,按照顺序分别是vbptr(虚表指针)、数据成员intAFirstSon、基类的数据成员intA。相比普通的继承,多了虚表指针。大小总和是4+4+4=12。

虚继承的多重继承子类内存布局


可以看到,对象的成员按照顺序分别是基类1对象、基类2对象、数据成员intF、虚继承的基类数据成员intA。
大小总和是8+8+4+4=24。基类1和基类2里面都是带1个虚表指针和1个数据成员。
相比普通的继承,多了2个虚表指针,但是减少了重复基类数据,总的大小变化是20+8-4=24。如果,重复的基类OrdinaryClassA有更多的数据成员,那么虚拟继承这种机制就更划算了。

4.2 带虚函数的虚多继承子类的对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
class VirtualFunClassASecondSon : virtual public VirtualFunClassA
{
public:
int intASecondSon;

public:
virtual int VirtualFunA() override
{
return 0;
}

virtual int VirtualFunASecondSon()
{
return 0;
}
};

class VirtualFunClassAThirdSon : virtual public VirtualFunClassA
{
public:
int AThirdSon;

public:
virtual int VirtualFunA() override
{
return 0;
}

virtual int VirtualFunAThirdSon()
{
return 0;
}
};

class VirtualFunVirtualInheritClassG : public VirtualFunClassASecondSon, public VirtualFunClassAThirdSon
{
public:
int intG;

public:
virtual int VirtualFunA() override
{
return 0;
}

virtual int VirtualFunASecondSon() override
{
return 0;
}

virtual int VirtualFunAThirdSon() override
{
return 0;
}

virtual int VirtualFunE()
{
return 0;
}
};

std::cout << "sizeof(VirtualFunClassASecondSon):" << sizeof(VirtualFunClassASecondSon) << std::endl;
std::cout << "sizeof(VirtualFunClassAThirdSon):" << sizeof(VirtualFunClassAThirdSon) << std::endl;
std::cout << "sizeof(VirtualFunVirtualInheritClassG):" << sizeof(VirtualFunVirtualInheritClassG) << std::endl << std::endl;

输出结果:

发现基类的大小变成了20,多了8个字节。子类的从24变成了36,多了12个字节。猜测是多了虚函数指针。

带虚函数的虚继承的基类内存布局


可以看到,内存布局是虚函数指针、虚表指针、数据成员、基类对象(基类的虚函数指针、基类数据成员)。相比不带虚函数的虚拟继承,是多了2个虚函数指针。相比,普通的继承,是多了1个虚表指针和1个虚函数指针。所以,最奇怪的地方是没有像普通继承那样将2个虚函数指针合并成一个。

如果注释掉当前类的虚函数VirtualFunASecondSon,得到的内存布局如下:

区别是少了当前类的虚函数指针,基类对象内的虚函数指针保留。

带虚函数的虚继承的多重继承子类内存布局

这应该是已知的最复杂的类对象布局情况了。按照顺序是基类1、基类2、数据成员、虚拟基类。基类1和基类2内部都是虚函数指针、虚表指针、数据成员,大小都是12,那么总共是24。数据成员大小是4。虚拟基类的内部是虚函数指针、数据成员,大小是8。因此,总共的大小是12+12+4+8=36。
相比不带虚函数的虚拟继承,多了3个虚函数指针,总计12个字节。相比普通的继承,多了2个虚表指针和1个虚函数指针,但是减少了虚拟基类数据的重复,那么总大小是28+12-4=36。

虚拟继承的最终结论

1、虚拟继承的对象内会多一个虚表指针。
2、带虚函数的虚继承,子类和基类的虚函数表不会合并,因此会多一个虚函数指针。
3、多重继承的基类,如果虚继承了共同的基类,那么其共同基类对象只会存在一份,包括数据成员和虚函数指针。

疑问:带虚函数的虚继承为何不合并子类和基类的虚函数指针?

猜测可能跟vs2019对应的vc++编译器实现有关。

4.3 虚表指针的用途

我们知道,虚函数指针指向的是虚函数表,虚函数表内存储的是虚函数的地址。对于采用指针或者引用来动态调用虚函数的情况,会在运行时才能确定真正的虚函数地址,这个就叫做延迟绑定。为了灵活性,失去了部分性能。
那么,虚表指针是用来做什么的?可以肯定的是用于找到共同的基类对象的。猜测虚表指针指向一张table,该table内部存储共同的基类数据在类对象内的偏移。

4.4 虚拟继承实现的编译器差异

根据深入探索C++对象模型的说明,虚拟继承在不同的编译器下有不同的实现,而且C++标准并未规定如何实现。因此,g++的内存布局跟vc++的内存布局可能会有显著差别。

NGUI介绍

NGUI是Unity中最流行的UI插件,在UGUI出现前几乎是Unity唯一的UI解决方案。
NGUI是一个提供高效事件通知框架的强大UI系统。NGUI遵循Kiss准则,其中类代码简洁,多数在200行以内。程序员可以方便的扩展其组件类代码以获得定制的功能。
NGUI官方网址
NGUI官方文档地址

NGUI下载

我们可以从unity商店购买NGUI,或者下载其免费版本。
NGUI的Unity商店
当然也可以下载网上其它人提供的版本学习研究。
NGUI 3.10.2

NGUI导入

下载NGUI后,我们得到的是一个.unitypackage文件,比如NGUI Next-Gen UI v3.6.8.unitypackage。
Unity编辑器中,打开菜单Assets->ImportPackage->CustomPackage,然后选择下载的.unitypackage文件导入编辑器。导入NGUI后,在工程的Assets目录下会出现一个NGUI文件夹,并且Unity编辑器中会多了一个NGUI主菜单。

NGUI例子

打开NGUI->Options->Reset Prefab ToolBar,会出现如下工具条:

NGUI例子
这里面有基本的NGUI控件例子,是我们学习参照的好材料。

NGUI类图

下面是我整理的NGUI类图:
NGUI类图

该类图中列出了NGUI中绝大部分的类。
类图中有两个最重要的分支,UIWidgetContainer分支和UIWidget分支。
NGUI中的大部分控件都继承自UIWidgetContainer,这说明在NGUI中,其实是把控件当作Sprite的容器而已。UIWidget的子类就是Sprite和Texture,表示NGUI中的控件都是图片化的,控件的表现都依赖图片。

NGUI常用组件

UILabel 文本

UIInput 输入框

UITextList 多文本显示框,类似聊天窗

UISprite 图片精灵

UIBotton 按钮

UIToggle 单选框/复选框

UIScrollBar 滚动条

UISlider 滑动条/进度条

UIProgressBar 进度条

UIPopupList 下拉框

UIGrid 将子控件按照单元格布局

UITable UIGrid加强版,类似Html的table

UIPanel 控件渲染器,管理和绘制其下所有的组件

UIScrollView 滚动视窗

UIKeyBinding 给控件的点击或者选中事情绑定按键

UIRoot NGUI的UI根物体

参考资料:

NGUI官网

本文主要介绍编写一个原生的WebGL程序需要哪些步骤。

WebGL程序的软件结构

默认情况下,一个动态网页程序只包括HTML和JavaScript两种语言。
而在WebGL程序中,还包括了第三种语言:GLSL ES。

enter description here

WebGL编程模型

enter description here
上图表示一个WebGL程序运行的主要流程。主要分为3个阶段,应用程序阶段、着色器阶段、片元后处理阶段。
本文接下来按照一定的规律介绍编写一个原生WebGL程序主要的步骤。

获得WebGL渲染环境

在Html中定义canvas标签

1
<canvas id="webgl" width="400" height="400"> </canvas>

在JS代码中获得canvas对象

1
var canvas = document.getElementById('webgl');

通过canvas对象获得WebGL渲染环境

1
var gl = getWebGLContext(canvas);

编写着色器

编写顶点着色器

顶点着色器是用来描述顶点属性(比如位置、颜色、纹理坐标等的程序)
enter description here

编写片元着色器

片元着色器处理光栅后的数据,可以片元将其理解为像素。
片元着色器的输出构成了最终的像素值(开启多重采样的话只构成了某个像素的一部分值)
enter description here

初始化着色器

初始化着色器基本上是一个固定的流程,主要分为以下几个步骤。

创建shader

加载shader源码

编译shader

创建程序

附加编译好的shader

链接程序

使用程序

获得顶点属性

顶点上有各种属性,比如空间坐标、纹理坐标、材质等,一个顶点就是一个属性集合。
如下图所示的立方体,顶点上有2个属性,坐标和颜色。
enter description here
顶点属性可以通过读取模型文件,比如obj文件等获得,或者简单写在代码定义中,比如上图的立方体。

创建顶点缓冲区

缓冲区存在于显存中,能够被显卡直接用来进行渲染,不需要进行数据传输。
在WebGL中,通过以下调用获得一个缓冲区对象。

1
var vertexColorBuffer = gl.createBuffer();

写入顶点数据到顶点缓冲区对象

这个步骤分为两个操作。

首先,绑定创建的缓冲区

1
gl.bindBuffer(gl.ARRAY_BUFFER, vertexColorBuffer);

然后,传输系统内存中上的顶点数据到缓冲区(显存中)

1
gl.bufferData(gl.ARRAY_BUFFER, verticesColors, gl.STATIC_DRAW);

传输数据的标志

gl.bufferData的第三个参数表示数据的使用标志,表示三种不同的应用场景。
1. gl.STATIC_DRAW :表示数据不会经常改变,通常用于静态物体,比如地形、墙体等。
2. gl.STREAM_DRAW:表示数据使用一次后就会被丢弃。
3. gl.DYNAMIC_DRAW:表示数据会被多次修改,也会被使用多次。

系统会根据usage标示符为缓冲区对象分配最佳的存储位置。
STATIC_DRAW和STREAM_DRAW分配在显存上,DYNAMIC_DRAW可能分配在AGP中。

将顶点数据传输到顶点着色器

目前,我们已经准会了WebGL渲染环境,并且数据已经从系统内存传输到显存中的缓冲区对象中。现在,我们要将缓存区对象中的数据指定给顶点着色器中对应的变量。
顶点着色器中的attribute变量对象顶点的属性。我们的顶点着色器中定义了2个变量,a_Position,a_Color。下面我们分为三步为这其指定数据。

  1. 获得着色器中attribute变量位置
    1
    var a_Position = gl.getAttribLocation(gl.program, 'a_Position');
  2. 根据变量位置传入缓冲区中的顶点属性数组

    1
    gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, FSIZE * 6, 0);
  3. 启用该attribute变量的属性数组

    1
    gl.enableVertexAttribArray(a_Position);

对于a_Color,我们在系统内存中定义在坐标的后面,因此在第2步中需要进行偏移,gl.vertexAttribPointer的最后一个参数可以指定数据的偏移位置,因此第2步修改为:

1
gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, FSIZE * 6, FSIZE * 3);

FSIZE表示float的大小。

传入uniform变量到着色器

着色器中还存在一种uniform变量,这种变量对于所有顶点来说都是一样的。
比如,mvp矩阵就应该定义为uniform变量。一般情况,我们在js代码中计算好mvp矩阵,然后传输到着色器中的uniform变量中。主要步骤如下:
1. 获取uniform变量的在着色中的位置

1
var u_MvpMatrix = gl.getUniformLocation(gl.program, 'u_MvpMatrix');

  1. 计算uniform变量(比如mvp矩阵)的值
1
2
3
var mvpMatrix = new Matrix4();
mvpMatrix.setPerspective(30, 1, 1, 100);
mvpMatrix.lookAt(3, 3, 7, 0, 0, 0, 0, 1, 0);
  1. 传入uniform变量
1
gl.uniformMatrix4fv(u_MvpMatrix, false, mvpMatrix.elements);

目前,顶点着色器已经有了每个顶点的属性,以及用uniform变量表示的mvp矩阵,因此可以变换顶点属性后传入片元着色器中进一步处理。

定义面片索引

上面我们处理的数据都是顶点属性,但是我们实际要绘制的图元是面片,比如三角面片。
通常情况下,我们会用三个顶点索引表示一个三角面片。
如下所示:

1
2
3
4
5
6
7
8
9
10
// Indices of the vertices
var indices = new Uint8Array([
0, 1, 2, 0, 2, 3, // front
0, 3, 4, 0, 4, 5, // right
0, 5, 6, 0, 6, 1, // up
1, 6, 7, 1, 7, 2, // left
7, 4, 3, 7, 3, 2, // down
4, 7, 6, 4, 6, 5 // back
]);

indices表示一个立方体的面片索引。

创建索引缓冲区,写入索引

接下来,我们要创建索引缓冲区,并将内存中的索引数据传入缓存区。
1. 创建索引缓冲区

1
var indexBuffer = gl.createBuffer();
  1. 绑定索引缓冲区
1
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
  1. 将面片索引写入缓冲区对象
1
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);

根据索引绘制图元

最后一步只需要根据面片索引绘制图元即可。
根据面片的顶点索引绘制图元节省内存,不需要存储重复的顶点数据。
我们只需要调用gl.drawElements即可。

1
gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_BYTE, 0);

其中,第二个参数n表示要绘制的图元(三角形面片)个数。最后一个参数0表示使用已经绑定好的索引缓冲区对象。

完整代码

下面给出绘制一个彩色立方体的完整代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
// Vertex shader program
var VSHADER_SOURCE =
'attribute vec4 a_Position;\n' +
'attribute vec4 a_Color;\n' +
'uniform mat4 u_MvpMatrix;\n' +
'varying vec4 v_Color;\n' +
'void main() {\n' +
' gl_Position = u_MvpMatrix * a_Position;\n' +
' v_Color = a_Color;\n' +
'}\n';

// Fragment shader program
var FSHADER_SOURCE =
'#ifdef GL_ES\n' +
'precision mediump float;\n' +
'#endif\n' +
'varying vec4 v_Color;\n' +
'void main() {\n' +
' gl_FragColor = v_Color;\n' +
'}\n';

function main() {
// Retrieve <canvas> element
var canvas = document.getElementById('webgl');

// Get the rendering context for WebGL
var gl = getWebGLContext(canvas);
if (!gl) {
console.log('Failed to get the rendering context for WebGL');
return;
}

// Initialize shaders
if (!initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE)) {
console.log('Failed to intialize shaders.');
return;
}

// Set the vertex coordinates and color
var n = initVertexBuffers(gl);
if (n < 0) {
console.log('Failed to set the vertex information');
return;
}

// Set clear color and enable hidden surface removal
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.enable(gl.DEPTH_TEST);

// Get the storage location of u_MvpMatrix
var u_MvpMatrix = gl.getUniformLocation(gl.program, 'u_MvpMatrix');
if (!u_MvpMatrix) {
console.log('Failed to get the storage location of u_MvpMatrix');
return;
}

// Set the eye point and the viewing volume
var mvpMatrix = new Matrix4();
mvpMatrix.setPerspective(30, 1, 1, 100);
mvpMatrix.lookAt(3, 3, 7, 0, 0, 0, 0, 1, 0);

// Pass the model view projection matrix to u_MvpMatrix
gl.uniformMatrix4fv(u_MvpMatrix, false, mvpMatrix.elements);

// Clear color and depth buffer
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

// Draw the cube
gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_BYTE, 0);
}

function initVertexBuffers(gl) {
// Create a cube
// v6----- v5
// /| /|
// v1------v0|
// | | | |
// | |v7---|-|v4
// |/ |/
// v2------v3
var verticesColors = new Float32Array([
// Vertex coordinates and color
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, // v0 White
-1.0, 1.0, 1.0, 1.0, 0.0, 1.0, // v1 Magenta
-1.0, -1.0, 1.0, 1.0, 0.0, 0.0, // v2 Red
1.0, -1.0, 1.0, 1.0, 1.0, 0.0, // v3 Yellow
1.0, -1.0, -1.0, 0.0, 1.0, 0.0, // v4 Green
1.0, 1.0, -1.0, 0.0, 1.0, 1.0, // v5 Cyan
-1.0, 1.0, -1.0, 0.0, 0.0, 1.0, // v6 Blue
-1.0, -1.0, -1.0, 0.0, 0.0, 0.0 // v7 Black
]);

// Indices of the vertices
var indices = new Uint8Array([
0, 1, 2, 0, 2, 3, // front
0, 3, 4, 0, 4, 5, // right
0, 5, 6, 0, 6, 1, // up
1, 6, 7, 1, 7, 2, // left
7, 4, 3, 7, 3, 2, // down
4, 7, 6, 4, 6, 5 // back
]);

// Create a buffer object
var vertexColorBuffer = gl.createBuffer();
var indexBuffer = gl.createBuffer();
if (!vertexColorBuffer || !indexBuffer) {
return -1;
}

// Write the vertex coordinates and color to the buffer object
gl.bindBuffer(gl.ARRAY_BUFFER, vertexColorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, verticesColors, gl.STATIC_DRAW);

var FSIZE = verticesColors.BYTES_PER_ELEMENT;
// Assign the buffer object to a_Position and enable the assignment
var a_Position = gl.getAttribLocation(gl.program, 'a_Position');
if(a_Position < 0) {
console.log('Failed to get the storage location of a_Position');
return -1;
}
gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, FSIZE * 6, 0);
gl.enableVertexAttribArray(a_Position);
// Assign the buffer object to a_Color and enable the assignment
var a_Color = gl.getAttribLocation(gl.program, 'a_Color');
if(a_Color < 0) {
console.log('Failed to get the storage location of a_Color');
return -1;
}
gl.vertexAttribPointer(a_Color, 3, gl.FLOAT, false, FSIZE * 6, FSIZE * 3);
gl.enableVertexAttribArray(a_Color);

// Write the indices to the buffer object
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);

return indices.length;
}

上面代码中使用到的创建WebGL渲染环境、初始化着色器、创建矩阵的操作,读者可以自行找相应的代码库替代。
或者在下面的链接中下载:
WebGL Lib, 密码:tncd。

PPT文档如下: