webgl坐标系统及深度
WebGL没有摄像头
每次我们移动视角,实际上相当于更新了“摄像头”的位置。为此,我们需要相应地对每个vertex做一次变换。同样的,我们也必须保证在每次摄像头移动后法线和光线保持既有的状态。总而言之,我们需要考虑两种不同的变换:vertex和normal。
Vertex transformations
在我们看到场景中的物体前其实它们已经经过了很多次矩阵变换,每次变换都是通过一个4*4的矩阵来实现的。但是向量是三维的,我们不可能将它与4元矩阵相乘,因此我们需要给向量增加一维。这样每个点都会有一个第四维(齐次坐标homogenous coordinate)。
齐次坐标(Homogeneous coordinates)
在计算机图形学中齐次坐标是非常重要的。它让我们可以使用4*4的矩阵实现仿射变换(如rotation, scaling, shear, and translation)和坐标投影(projective transformation)。 在齐次坐标中,vertices由4部分组成:x,y,z和w。前三个值对应欧几里得空间(笛卡尔坐标系),第四个值代表投影(perspective component),它们组成了投影空间(projective space)。
维基上对于Homogeneous coordinates的介绍。 两个坐标系的转换非常简单,下面公式参上:
正如我们之前代码中所做的:
attribute vec3 aVertexPosition;
attribute vec3 aVertexNormal;
uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
uniform mat4 uNMatrix;
varying vec3 vNormal;
varying vec3 vEyeVec;
void main(void) {
vec4 vertex = uMVMatrix * vec4(aVertexPosition, 1.0);
vNormal = vec3(uNMatrix * vec4(aVertexNormal, 0.0));
vEyeVec = -vec3(vertex.xyz)
gl_Position = uPMatrix * vertex;}
需要注意的是,对于vertices我们的w值为1,而对于vectors我们的w值为0(0作为除数表示无限远)。
Model transform
我们从物体坐标系统开始,这是vertex坐标定义的地方。如果我们想移动这些物体,我们需要使用矩阵进行变换。这些矩阵就是模型矩阵(model matrix)。当它与vertices相乘后,我们就能得到新的vertex coordinates。
经过model transform后,物体坐标被转换为世界坐标,它决定了物体在场景中的位置。
View transform
接下来的变换,view transform将世界原点转换为了视图原点。它根据你的眼睛或是摄像头的位置定位。进行这个变换的矩阵被称为视图矩阵(view matrix)。
Projection transform
接下来是projection transform,它决定了视图空间如何被渲染以及它如何被投影到屏幕上。这个区域被称为frustum,它包含了六个面板(近处,远处,上面,下面,左面和右面):
这六个面板的信息被包含在视角矩阵(perspective matrix)中。任何在这个区域外的vertex都会被抛弃。经过投影变换后,我们可以得到剪切坐标(clipping coordinates)。
frustum的形状和深度决定了从3D投影到2D的投影类型。如果远近面板相同,那就是正投影,否则就是视角投影,如下图所示:
Perspective division
一旦可视空间决定了,frustum就会被投射到近面板上创建2D图像。由于操作系统和显示设备的差异,为了保持健壮性,WebGL提供了独立于硬件的中间间坐标系统(intermediate coordinate system)。它也被叫做规格化设备坐标(Normalized Device Coordinates(NDC))。 用w除以剪切坐标我们就能获得NDC,这也是这一步叫做perspective division的原因。同时,正如我们前面讲到的,我们从齐次坐标返回到了笛卡尔坐标系。x,y代表物体在2D图像上的位置,z代表深度,如下图所示:
从裁剪空间到 ndc 的处理过程中,深度的变换是非线性的,所以最终的结果是靠近相机的部分深度精度较高,而远离相机的部分精度较低。这也比较符合我们一般的图像逻辑。即远离当前视线的物体对视线的贡献较低无需特别高的精度。但是,如果我们在做深度可视化的时候要特别注意着点,因为有可能颜色集中在靠近相机的部分,而远离相机的部分可能没有任何颜色。正是由于这种非线性的变换,在 camera 的 near 和 far 的相差非常大的时候,更加容易引起 z-fighting问题。
深度的原理
深度纹理实际就是一张渲染纹理,只不过它里面存储的像素值不是颜色值,而是一个高精度的深度值。由于被存储在一张纹理中,深度纹理里面的深度值范围是[0,1]。
模型空间中的顶点,经过MVP变换后,变换到了裁剪空间。在裁剪空间的最后,所以的可见的点都在标准设备坐标系(NDC)中,即坐标坐落在范围[−1,1]3内。在得到NDC坐标后,深度纹理中的像素值就可以很方便地得到了,这些深度值就对应了NDC坐标中顶点坐标的Z分量的值。由于NDC中Z分量的范围为[−1,1],为了让这些值能存储在一张纹理中,我们需要使用下面的公式对其进行映射:
d=0.5∗zndc+0.5
其中,d对应了深度纹理中的像素值,zndc对应了NDC坐标中Z分量的值。
Viewport transform
最后,NDC被转换为视图坐标。它将坐标投影到屏幕上。在WebGL中,通常是canvas提供的空间,如下图所示:
和上面的转换不同,这个转换没有对应的矩阵。我们通常使用WebGL中的viewport方法,后面将会介绍。
Normal transformations
当vertices变换后,法线向量也相应的需要变换。如果你对前面说到的东西有点映像的话,或许你能想到是使用Model-view matrix(MVMatrix)进行变换。但是MVMatrix会有一些问题:
在只对一个轴收缩或是剪切变换(shear transformation)时,就如上图一样,可能会导致法线失准。那么如何解决呢?
计算法线向量
本节有大量空间几何知识,不感兴趣的可以跳过,否则就看看吧,回想下高中的美好~
首先从垂直的定义开始,如果两个向量的点积是0那么它们就是垂直的:
N*S = 0
这里S代表物体表面向量,令M代表MVMatrix。我们用M变换S得到:
S' = MS
我们期望得到一个矩阵K让我们得到变换后的向量:
N' = KN
我们期望S'和N'应该垂直,因此有:
N'*S' = 0
等式替换,我们有:
(KN)*(MS) = 0
一个点积可以写作将第一个向量置换后与第二个相乘的方式:
置换的小图标搞不定,用<T>代替
(KN)<T>(MS) = 0
展开置换有:
N<T>K<T>MS = 0
优先计算第二个和第三个矩阵:
N<T>(K<T>M)S = 0
因为N.S = 0, 因此NS = 0, 要使N(KM)S = 0 ,必须有(KM) = I, 因此有:
K<T>M = I
接着把K的值找出来:
逆矩阵的符号用<-1>代替
K<T>MM<-1> = IM<-1> = M<-1>
K<T>(I) = M<-1>
(K<T>)<T> = (M<-1>)<T>
K = (M<-1>)<T>
最后,我们得到了向量矩阵K = (M<-1>)
WebGL implementation
现在让我们来看看WebGL的实现:
在WebGL中,我们使用3个矩阵以及一个WebGL方法来实现前面说到的5个变换:
- Model-View矩阵包括了model和view变换。
- Normal矩阵通过先对Model-View矩阵求逆再置换的方式获得。
- Perspective矩阵用于projection transformation和perspective division,转化后我们将得到NDC。
- 最后,我们使用
gl.viewport来将NDC映射到视图坐标上,其原型为gl.viewport(minX, minY, width, height)。
z-fighting 的问题
深度冲突又叫(z-fighting)是图形渲染中一个非常常见的现象。造成深度冲突的主要原因是两个三角面片靠的非常近,在渲染的时候 gpu 很难分清到底哪个面在前,哪个面在后,从而形成闪烁的现象。
1.简单直接的把两个靠近的面挪开一点
想要避免深度冲突,最简单直接的办法就是手动将两个靠的非常近的面挪开一点距离,这样他们就不会争抢渲染,从而避免闪烁。但是这个办法比较局限,不是所有模型都能手动更改,且工作量比较大。
2.调整 camera 的 near、far 参数
调整 camera 的 near、far 属性的值也能一定程度的缓解深度冲突,其原理我们可以在上面的深度原理中窥探出一些端倪。当我们把裁剪空间转到 ndc 空间的时候,会归一化 z 值,z 值的范围越大,就会导致归一化后的位置相对值越靠近,就更加容易产生 z-fighting 的现象。
3.使用对数缓冲
在 threejs 的 renderer 中可以启用 logarithmicDepthBuffer 属性,从而使用对数深度缓冲。虽然可以在一定程度上避免 z-fighting 的现象,但是它会使 early-z 的测试失效,从而造成一定程度的性能浪费,使用时应慎重。
4.使用 polygonoffset
polygonoffset 是一个比较常见的消除 z-fighting 的设置项。在 threejs 中我们可以设置 material 的 polygonoffset 属性来达到启用的目的。其原理是在渲染的时候,将模型的订单稍微向靠近或远离相机的方向做一定的偏移,从而错开两个靠近的面的目的。
深度精度问题
对于一般情况,我们可以将深度渲染到贴图使用rgb的某个分量来存储深度的值。但是float的精度问题,可能会导致某个分量无法存储整个深度数值,从而导致精度丢失。这也是深度计算常见的一个问题。所以,我们一般可以使用pack/unpack的方式将一个深度的depth数值打包成一个rbga的vec4。
贴出一个threejs中的实现
(src\renderers\shaders\ShaderChunk\packing.glsl.js)
const float PackUpscale = 256. / 255.; // fraction -> 0..1 (including 1)
const float UnpackDownscale = 255. / 256.; // 0..1 -> fraction (excluding 1)
const vec3 PackFactors = vec3( 256. * 256. * 256., 256. * 256., 256. );
const vec4 UnpackFactors = UnpackDownscale / vec4( PackFactors, 1. );
const float ShiftRight8 = 1. / 256.;
vec4 packDepthToRGBA( const in float v ) {
vec4 r = vec4( fract( v * PackFactors ), v );
r.yzw -= r.xyz * ShiftRight8; // tidy overflow
return r * PackUpscale;
}
float unpackRGBAToDepth( const in vec4 v ) {
return dot( v, UnpackFactors );
}
另外,社区最近也讨论了webgl2 中对WEBGL_depth_texture的高精度支持。具体讨论可以看看这篇