渲染管线,以three为例

渲染管线,以three为例

五月 28, 2022

渲染管线,以three为例

参考资料

  1. https://en.wikipedia.org/wiki/Graphics_pipeline
  2. https://zhuanlan.zhihu.com/p/137780634
  3. https://zhuanlan.zhihu.com/p/415005108
  4. 《交互式计算机图形学——基于WEBGL的自顶向下方法》(第7版) 第8章 从几何到像素 p301
  5. https://zhuanlan.zhihu.com/p/79183044
  6. https://github.com/mrdoob/three.js
  7. https://www.bilibili.com/video/BV1Q54y1G7v3
  8. https://blog.csdn.net/waeceo/article/details/50580607

定义

绘图流水线(Graphics pipeline,亦称绘图管线)是计算机图形系统将三维模型渲染到二维屏幕上的过程。简单地说,在计算机即将显示电子游戏或三维动画内的三维模型时,绘图流水线就是把该模型转换成屏幕画面的过程。图形渲染管线是实时渲染的核心组件。渲染管线的功能是通过给定虚拟相机、3D场景物体以及光源等场景要素来产生或者渲染一副2D的图像。

流程

应用程序阶段

这是一个由CPU主要负责的阶段,且完全由开发人员掌控。在这个阶段,CPU将决定递给GPU什么样的数据,根据需要对场景进行更改,并且告诉GPU这些数据的渲染状态。
在这个过程中用户会决定场景中有什么物体,拥有那几种光源、位于那些位置,每个物体拥有什么材质,也包括体积碰撞、动画变形等。
据个例子就是,在常见的第三人称射击游戏中:玩家所处的环境位置决定了光源,在室内可能是电灯发出的点光源,在室外则是太阳发出的定向光;玩家操纵不同的人物,手中的枪械、装备,是否驾驶载具决定了场景中有那些模型物体;同时这些不同的物体也决定了他们各自的材质;同时,游戏的制作时的物理引擎决定了这些模型处于场景中的何种位置,人物位于载具之中,而载具位于空中、陆地或者是海洋之中。
通常在这一步中,只有那些位于相机可视内的物体数据才会被传入到gpu中,相机完全不可见的物体数据将不被传入,下面是three.js中的剔除过程

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
// three通过视锥体(frustum)判断对象是否需要渲染
// 这里只展示关键并简化过的代码,便于理解这个过程
// WebGLRenderer.js
if ( ! mesh.frustumCulled || intersectsObject( mesh ) ) {
// 只有该对象需要在相机可视范围内,或者不做相机可视判断
// 才加入渲染数组
currentRenderList.push( mesh );
}

// Frustum.js
const _sphere = new Sphere();
// 对象相交判断
function intersectsObject( mesh ) {
const geometry = mesh.geometry;
if ( geometry.boundingSphere === null ) geometry.computeBoundingSphere();
// 边界球乘以世界矩阵,得到该对象位于世界坐标系的位置
_sphere.copy( geometry.boundingSphere ).applyMatrix4( mesh.matrixWorld );
return this.intersectsSphere( _sphere );
}

// 边界球判断
function intersectsSphere( sphere ) {
// 视锥体的六个平面 Plane对象
const planes = this.planes;
// 边界球中心
const center = sphere.center;
// 边界球半径
const negRadius = - sphere.radius;
for ( let i = 0; i < 6; i ++ ) {
const distance = planes[ i ].distanceToPoint( center );
// 边界球完全在视锥体之外 返回false
if ( distance < negRadius ) {
return false;
}
}
return true;
}

当然除了视锥体剔除以外,还有遮挡剔除、层级剔除等等

几何阶段

https://upload.wikimedia.org/wikipedia/commons/thumb/f/f8/Geometry_pipeline_en.svg/825px-Geometry_pipeline_en.svg.png

世界坐标系转换

几何阶段的首要目标是将模型坐标系转化为统一的世界坐标系,参考three中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <begin_vertex>
vec3 transformed = vec3( position );

#include <worldpos_vertex>

#if defined( USE_ENVMAP ) || defined( DISTANCE ) || defined ( USE_SHADOWMAP ) || defined ( USE_TRANSMISSION )

vec4 worldPosition = vec4( transformed, 1.0 );

#ifdef USE_INSTANCING
// 这里是three的InstancedMesh
worldPosition = instanceMatrix * worldPosition;

#endif

worldPosition = modelMatrix * worldPosition;

#endif

相机变换

世界坐标系到相机坐标系的转换可以视作一系列矩阵的运算,具体参看如下图所示:

https://img-blog.csdn.net/20141102170636562?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvb250aGV3YXlzdWNjZXNz/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center1

three将这些相机坐标系与投影坐标系转换步骤放在了同一步处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <begin_vertex>
vec3 transformed = vec3( position );

#include <project_vertex>
vec4 mvPosition = vec4( transformed, 1.0 );

#ifdef USE_INSTANCING

mvPosition = instanceMatrix * mvPosition;

#endif
// 相机矩阵
mvPosition = modelViewMatrix * mvPosition;
// 投影矩阵
gl_Position = projectionMatrix * mvPosition;

照明

场景通常包含放置在不同位置的光源,以使对象的照明看起来更逼真。在这种情况下,基于光源和与相应三角形关联的材料属性为每个顶点计算纹理的增益因子。在后面的光栅化步骤中,三角形的顶点值在其表面上进行插值。普通照明(环境光)应用于所有表面。它是场景的漫反射亮度,因此与方向无关。太阳是一个定向光源,可以假设它是无限远的。太阳在表面上产生的光照是通过形成来自太阳的方向矢量和表面的法线矢量的标量积来确定的。如果值为负,则表面朝向太阳。z

投影

这一部发生在

3D投影步骤将视图体积转换为具有角点坐标(-1, -1, 0)和(1, 1, 1)的立方体;有时也使用其他目标卷。此步骤称为投影,即使它将一个体积转换为另一个体积,因为生成的 Z 坐标不存储在图像中,而仅用于稍后光栅化步骤中的Z 缓冲。在透视图中,使用了中心投影。为了限制显示对象的数量,使用了两个额外的剪切平面;因此,视觉体积是一个截断的金字塔(平截头体)。平行或正交投影例如,用于技术表示,因为它的优点是对象空间中的所有平行线在图像空间中也是平行的,并且无论与观察者的距离如何,表面和体积都是相同的大小。

裁剪

裁剪的本质为了去除那些不会出或是不想现在屏幕上的像素的操作,three其实分别在几个步骤都进行了这样的操作:

  1. 前面应用阶段介绍的对模型的整体剔除
  2. 图元装配与光栅化阶段,不可操作的背面剔除,与屏幕外裁剪
  3. 光栅化后片段shader所使用的主动剔除

下面是three中通过设置切面,主动对模型的部分的剔除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// vertex
#include <clipping_planes_pars_vertex>
#if NUM_CLIPPING_PLANES > 0

vClipPosition = - mvPosition.xyz;

#endif

#include <clipping_planes_vertex>
#if NUM_CLIPPING_PLANES > 0

vClipPosition = - mvPosition.xyz;

#endif

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
// fragment
#include <clipping_planes_pars_fragment>
#if NUM_CLIPPING_PLANES > 0

varying vec3 vClipPosition;

uniform vec4 clippingPlanes[ NUM_CLIPPING_PLANES ];

#endif

#include <clipping_planes_fragment>
#if NUM_CLIPPING_PLANES > 0

vec4 plane;

#pragma unroll_loop_start
for ( int i = 0; i < UNION_CLIPPING_PLANES; i ++ ) {

plane = clippingPlanes[ i ];
if ( dot( vClipPosition, plane.xyz ) > plane.w ) discard;

}
#pragma unroll_loop_end

#if UNION_CLIPPING_PLANES < NUM_CLIPPING_PLANES

bool clipped = true;

#pragma unroll_loop_start
for ( int i = UNION_CLIPPING_PLANES; i < NUM_CLIPPING_PLANES; i ++ ) {

plane = clippingPlanes[ i ];
clipped = ( dot( vClipPosition, plane.xyz ) > plane.w ) && clipped;

}
#pragma unroll_loop_end
// 判定为被裁剪的部分被主动剔除
if ( clipped ) discard;

#endif

#endif

窗口视口转换

为了将图像输出到屏幕的任何目标区域(视口),必须应用另一个转换,即Window-Viewport 转换。这是一个转变,然后是缩放。结果坐标是输出设备的设备坐标。视口包含 6 个值:窗口的高度和宽度(以像素为单位)、窗口左上角的窗口坐标(通常为 0、0)以及 Z 的最小值和最大值(通常为 0 和 1)。
在现代硬件上,大多数几何计算步骤都是在顶点着色器中执行的。这原则上是可自由编程的,但通常至少执行点的变换和照明计算。对于 DirectX 编程接口,从版本 10 开始就需要使用自定义顶点着色器,而旧版本仍然有标准着色器。

光栅化阶段

经过图元组装以及屏幕映射阶段后,我们将物体坐标变换到了窗口坐标。光栅化是个离散化的过程,将3D连续的物体转化为离散屏幕像素点的过程。包括三角形组装和三角形遍历两个阶段。光栅化会确定图元所覆盖的片段,利用顶点属性插值得到片段的属性信息,然后送到片段着色器进行颜色计算,我们这里需要注意到片段是像素的候选者,只有通过后续的测试,片段才会成为最终显示的像素点。

注释

1. 世界坐标系和相机坐标系,图像坐标系的关系