前言
本篇继续前面文章来讲解投影矩阵相关内容。本篇标题为进入三维世界,那么我们需要用我们真实的三维世界来观察我们绘制的三维图形。
首先,我们要知道视并不存在真正的摄像机,只不过是在世界坐标系里面选择一个点,作为摄像机的位置。然后根据一些参数,在这个点构建一个坐标系。然后通过 视图矩阵将世界坐标系的坐标变换到摄像机坐标系下。
WebGL 成像采用的是虚拟相机模型。在场景中你通过模型变换,将物体放在场景中不同位置后,最终哪些部分需要成像,显示在屏幕上,主要由视变换和后面要 介绍的投影变换、视口变换等决定。其中视变换阶段,通过假想的相机来处理矩阵计算能够方便处理。对于 WebGL 来说并不存在真正的相机,所谓的相机坐标空间(camera space 或者 eye space)只是为了方便处理,而引入的坐标空间。
首先要明白的是投影变换是将摄像机坐标系下的物体变换到剪裁坐标系,投影变换是通过乘以投影矩阵实现。投影方式有两种,一种是正交投影,一种透视投影。
一、视点和视线
首先我们来研究一下如何定义三维世界的观察者:在什么地方、朝哪里看、视野有多宽、能看多远。
三维物体与二维图形的最显著的区别就是,三维物体有深度,也就是 z 轴。事实上,我们最后还是得把三维场景绘制到二维的屏幕上,即绘制观察者看到的世界,而观察者可以处在任意位置观察。为了定义一个观察者,我们需要考虑下面两点:
- 观察方向:也就是观察者自己在什么位置,朝向哪里观看;
- 可视距离:就是观察者能够看多远;
我们将观察者所处的位置定义为视点,从视点出发沿着观察方向的射线称作为视线。在之前绘制 “F” 图形的时候,默认情况下视点处于圆点 (0, 0, 0),视线为 z 轴负半轴(指向屏幕里面)。
1. 视点、观察目标点和上方向
为了确定观察者的状态,需要确定三个信息:
- 视点:观察者的位置;视线的起点;下面表述都用坐标 (eyeX, eyeY, eyeZ) 表示;
- 观察目标点:被观察目标所在的点;他可以同来确定视线。观察目标点是一个点。观察点坐标用 (atX, atY, atZ) 表示;
- 上方向:为了将观察到的景色绘制到屏幕上;它是具有三个分量的矢量,用 (upX, upY, upZ) 表示;
也就是说通过视点、视线、上方向我们可以定义一个观察坐标系。也就是说我们真实看到的物体是需要下面坐标系的转换;
观察坐标系:
- 眼睛在原点
- z 轴是视线方向
- x y 平面和 z 轴垂直
首先是模型坐标,也就是我们绘制 “F” 时定义的坐标(这里需要注意的是,也是之前一直在强调,我们定义坐标的时候是将 y 轴看做是从下往上的,我们在转换时候需要将 y 轴坐标进行取反,以为 canvas 的坐标是从上往下的方向),然后我们需要将模型坐标转换为世界坐标(世界坐标就是 webgl 显示坐标[-1, 1]范围)。然后需要将世界坐标进一步换系,转换到观察坐标。最后通过投影、剪裁空间进行显示物体。
至于将世界坐标转换到我们观察者坐标系。如下图:
观察者坐标系 (xv, yv, zv),世界坐标系(x,y,z);对于观察坐标系,其实就是首先将坐标移动到世界坐标系。反过来的过程;也就是先旋转,然后进行平移操作;然后将世界坐标系的坐标乘以该变换矩阵就可以得到在观察者坐标系下的坐标。
下面是视图矩阵的推导过程:
封装对应生成视图矩阵方法如下:
我们将设置从哪里看图形的矩阵称为视图矩阵。
/**
* 创建视图矩阵
* @param eyeX, 指定视点
* @param atX, 指定观察点
* @param upX, 指定上方向,如果上方向是 Y 轴正方向,那么传入(0, 1, 0)
* @return this
*/
Matrix4.prototype.setLookAt = function(eyeX, eyeY, eyeZ, atX, atY, atZ, upX, upY, upZ) {
var e, fx, fy, fz, rlf, sx, sy, sz, rls, ux, uy, uz;
fx = atX - eyeX;
fy = atY - eyeY;
fz = atZ - eyeZ;
// Normalize f.
rlf = 1 / Math.sqrt(fx*fx + fy*fy + fz*fz);
fx *= rlf;
fy *= rlf;
fz *= rlf;
// 计算上方向,通过叉乘得到法向量
sx = fy * upZ - fz * upY;
sy = fz * upX - fx * upZ;
sz = fx * upY - fy * upX;
// Normalize s.
rls = 1 / Math.sqrt(sx*sx + sy*sy + sz*sz);
sx *= rls;
sy *= rls;
sz *= rls;
// Calculate cross product of s and f.
ux = sy * fz - sz * fy;
uy = sz * fx - sx * fz;
uz = sx * fy - sy * fx;
e = this.elements;
e[0] = sx;
e[1] = ux;
e[2] = -fx;
e[3] = 0;
e[4] = sy;
e[5] = uy;
e[6] = -fy;
e[7] = 0;
e[8] = sz;
e[9] = uz;
e[10] = -fz;
e[11] = 0;
e[12] = 0;
e[13] = 0;
e[14] = 0;
e[15] = 1;
return this.elements
};
2. 实例:通过键盘改变视点观察立方体
整体代码如下:
import React, { useRef, useEffect } from 'react'
import { VSHADERR_SOURCE, FSHADER_SOURCE } from './glsl'
import { initShader } from '../../utils/webglFunc'
import { vertices, indices, colors } from '../HelloCube/data'
import Matrix4 from '../../utils/matrix'
import './index.css'
const LookAtF = () => {
const canvasDom = useRef<HTMLCanvasElement | null>(null)
const requestID = useRef<number>()
const initArrayBuffer = (gl: WebGLRenderingContext, data: Float32Array, num: number, type: number, attribute: string) => {
var buffer = gl.createBuffer() // 创建缓冲区对象
if (!buffer) {
console.log('Failed to create the buffer object')
return false
}
// 将数据写入缓冲区对象
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)
var a_attribute = gl.getAttribLocation((gl as any).program, attribute)
if (a_attribute < 0) {
console.log('Failed to get the storage location of ' + attribute)
return false
}
gl.vertexAttribPointer(a_attribute, num, type, false, 0, 0)
// 将缓冲区对象分配给 attribute 变量
gl.enableVertexAttribArray(a_attribute)
return true
}
const initVertexBuffers = (gl: WebGLRenderingContext) => {
// 创建缓冲区对象
const indexBuffer = gl.createBuffer()
if (!indexBuffer) {
console.log('Failed to create the vertexColorBuffer object')
return -1
}
// 将顶点坐标和颜色写入缓冲区
if (!initArrayBuffer(gl, vertices, 3, gl.FLOAT, 'a_Position')) {
return -1
}
if (!initArrayBuffer(gl, colors, 3, gl.FLOAT, 'a_Color')) {
return -1
}
// 将顶点索引写入缓冲区对象
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW)
return indices.length
}
const draw = (gl: WebGLRenderingContext, n: number, modelMatrix: any, u_ModelMatrix: any) => {
// 设置视点和视线
u_ModelMatrix.setLookAt(g_eyeX, g_eyeY, g_eyeZ, 0, 0, -1, 0, 1, 0)
gl.uniformMatrix4fv(modelMatrix, false, u_ModelMatrix.elements)
// 清空颜色和深度缓冲区
gl.clear(gl.COLOR_BUFFER_BIT)
// 绘制立方体
gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_BYTE, 0)
}
const main = () => {
if (!canvasDom.current) return
const gl = canvasDom.current.getContext('webgl')
if (!gl) {
console.log('Failed to get the rendering context for WebGL')
return
}
gl.canvas.width = (gl as any).canvas.clientWidth
gl.canvas.height = (gl as any).canvas.clientHeight
// 初始化着色器
if (!initShader(gl, VSHADERR_SOURCE, FSHADER_SOURCE)) {
console.log('Failed to intialize shaders.')
return
}
const n = initVertexBuffers(gl)
if (n < 0) {
console.log('Failed to set the vertex information')
return
}
// 指定清空 canavs 的颜色
gl.clearColor(0.0, 0.0, 0.0, 1.0)
// 开启隐藏面消除
gl.enable(gl.DEPTH_TEST)
// 创建 Matrix4 对象以进行模型变换
const u_ViewMatrix = gl.getUniformLocation((gl as any).program, 'u_ViewMatrix')
if (u_ViewMatrix && u_ViewMatrix < 0) {
console.log('Failed to get the storage location of u_MvpMatrix')
return
}
// 创建 Matrix4 对象以进行模型变换
const viewMatrix = new Matrix4()
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height)
// 清空颜色和深度缓冲区
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
if (!n || !u_ViewMatrix) return
document.onkeydown = (ev) => {
keydown(ev, gl, n, u_ViewMatrix, viewMatrix)
}
draw(gl, n, u_ViewMatrix, viewMatrix)
}
// 视点
let g_eyeX = 0.20,
g_eyeY = 0.25,
g_eyeZ = 1
const keydown = (ev: any, gl: WebGLRenderingContext, n: number, u_ViewMatrix: WebGLUniformLocation, viewMatrix: Matrix4) => {
// 按下右键
if (ev.keyCode === 39) {
g_eyeX += 0.01
} else if (ev.keyCode === 37) {
// 按下左键
g_eyeX -= 0.01
} else {
return
}
draw(gl, n, u_ViewMatrix, viewMatrix)
}
useEffect(() => {
main()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<canvas ref={canvasDom}></canvas>
)
}
export default LookAtF
着色器部分:
export const VSHADERR_SOURCE = `
attribute vec4 a_Position;
attribute vec4 a_Color;
uniform mat4 u_ViewMatrix;
varying vec4 v_Color;
void main() {
// 如果 gl_Position 最后一个分量为 1.0,那么前三个分量就可以表示一个点的三维坐标。平移不改变缩放比例,所以 u_Translation 第四个参数为 0.0,1.0 表示不缩放
gl_Position = u_ViewMatrix * a_Position;
v_Color = a_Color;
}
`
export const FSHADER_SOURCE = `
#ifdef GL_ES
precision mediump float;
#endif
varying vec4 v_Color;
void main() {
gl_FragColor = v_Color;
}
`
我们会发现显示的物体在旋转过程中会发生一个角的缺失一部分。是因为我们没有指定可视范围,也就是实际观察得到的区域边界。webgl 只显示可视范围内的区域,这样可以降低程序的开销。webgl 通过定义水平视角、垂直视角和可视深度(能够看多远)来定义可视空间。
二、可视空间
有两类常用的可视空间:
- 长方体可视空间,也称盒状空间,由正射投影产生。
- 四棱锥/金字塔可视空间,由透视投影产生。
所以对应投影方式有两种,一种是正交投影,一种透视投影。
-
正交投影:投影到近平面上相同尺寸大小的图形大小相同,不会有真实世界那种近大远小的感觉。
-
透视投影:同样尺寸的物体,近处的物体投影出来大,远处的物体投影出小,会产生与真实世界一样近大远小的感觉。
在乘以投影矩阵后,任何一个点的坐标 [x,y,z,w] 中的下x,y,z的分量将在 -w~w 内。
透视投影分两步:
- 从 Frustun 内一点投影到近裁剪平面的过程
- 由近平面到规范化设备坐标系的过程
首先将视体内的点投影到近裁剪平面,然后将近裁剪平面上的点规范化到设备坐标系。
其实 gl_Position 的第四个参数也就是缩放系数。
1. 正射投影
下面是正射投影的盒状可视空间的工作原理
正射投影:
盒状可视空间的形状如上图,可视空间由前后两个矩形表面确定,分别称为近裁剪面和远裁剪面。canvas 上显示的就是可视空间中物体在近裁剪面上的投影。如果剪裁面的宽高比跟 canvas 不一样,那么画面就会被按照 canvas 的宽高比进行压缩,物体会被扭曲。近裁剪面与远裁剪面之间的盒状空间就是可视空间,只有在此空间内的物体会被显示出来。
这也就是为什么上面我们通过键盘移动立方体有的会被隐藏。
有关在线的例子,可以看这里的运行的效果:
https://webglfundamentals.org/webgl/webgl-visualize-camera-with-orthographic.html
正交投影本质:
他其实就是我们规定的一个盒状的可视空间,然后需要将这个盒装的可视空间进行变化压缩到一个规范化的裁剪空间中,也就是正交投影矩阵的视锥体是一个长方体 [l,r][b,t][f,n]
,我们要把这个长方体转换到一个正方体[-1,1][-1,1][-1,1]
中;
首先第一步需要计算这个长方体的中心点,然后将这个中心点移动到坐标轴的原点。长方体的中心点很容易计算,[(l+r)/2,(b+t)/2,(f+n)/2]
,
所以得到此步骤需要变换的矩阵:
接下来,第二步就是需要对长方体进行缩放,例如从[l,r]
缩放到[-1,1]
,缩放系数为2/(r-l)
,所以得到缩放矩阵为:
所以正交矩阵 = 缩放矩阵 * 平移矩阵;
正交投影的推导过程:
封装对应代码如下:
export const orthographic = (left: number, right: number, bottom: number, top: number, near: number, far: number) => {
return [
2 / (right - left), 0, 0, 0,
0, 2 / (top - bottom), 0, 0,
0, 0, 2 / (near - far), 0,
(left + right) / (left - right),
(bottom + top) / (bottom - top),
(near + far) / (near - far),
1,
]
}
2. 透视投影
透视:一种绘制物体的空间位置关系的绘图技术;透视投影的观察体:
具有近大远小的特性。
透视投影更接近我们的世界,就是近大远小;而正射投影的好处是用户可以方便地比较场景中的物体的大小,因为物体看上去的大小与其所在的位置没有关系。
透视投影矩阵本质:
他其实就是将上面的锥形盒子进行压缩到规范化盒子;在这个过程中,我们需要把握以下三个原则:
- 近平面的所有点坐标不变;(也就是 (x, y, n, 1) 经过变换后仍然是他本身)
- 远平面的所有点坐标 z 值不变 都是 f;
- 远平面的中心点坐标值不变 为 (0,0,f);(可以将这个点带入方程组计算关系式)
相似三角形:Y’ = n / z * Y
所以首先,需要计算出将锥形进行缩放变换到正交投影的长方体,然后执行上面推导正交投影矩阵的步骤;也就是说,
透视投影矩阵 = 正交投影矩阵 * 变换矩阵;
对于透视投影矩阵的推导,其实我们可以找到相似三角形,得到不同比例:
考虑用双曲线描述 z 方向的剪裁坐标值;
封装对应代码:
export const perspective = (fov: number, aspect: number, zNear: number, zFar: number) => {
const f = 1 / Math.tan(fov * 0.5)
const inv = 1 / (zNear - zFar)
const m = [
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (zNear + zFar) * inv, -1,
0, 0, 2 * zNear * zFar * inv, 0
]
return m
}
3. 隐藏面设置
在现实世界中,如果我们眼睛看到的物体有前后关系,那么后面的物体遮住的话会看不到。所以这部分可以不进行绘制。在默认情况下,webgl 为了加速绘图操作,是按照顶点在缓冲区中的顺序来处理他们的;所以都会进行绘制,而且后绘制的图形覆盖先绘制的图形。
WebGL 中的三角形有正反面的概念,正面三角形的顶点顺序是逆时针方向, 反面三角形是顺时针方向。
对于WebGL而言,一个三角形是顺时针还是逆时针是根据裁剪空间中的顶点顺序判断的, 换句话说,WebGL是根据你在顶点着色器中运算后提供的结果来判定的, 这就意味着如果你把一个顺时针的三角形沿 X 轴缩放 -1 ,它将会变成逆时针, 或者将顺时针的三角形旋转180度后变成逆时针。由于我们没有开启 CULL_FACE, 所以可以同时看到顺时针(正面)和逆时针(反面)三角形。现在开启了, 任何时候正面三角形无论是缩放还是旋转的原因导致翻转了,WebGL就不会绘制它。 这件事很有用,因为通常情况下你只需要看到你正面对的面。
WebGL可以只绘制正面或反面三角形,可以这样开启:
// 隐藏面消除
gl.enable(gl.CULL_FACE);
这个功能会帮助我们消除那些被遮挡的表面。
对应的,如果开启了隐藏面消除,我们需要在绘制之前,清除深度缓冲区:
gl.clear(gl.DEPTH_BUFFER_BIT)
gl.enable(cap)方法的使用,可以去查看MDN接口文档介绍 https://developer.mozilla.org/zh-CN/docs/Web/API/WebGLRenderingContext/enable
对于为什么需要清空深度缓冲区。是因为深度缓冲区是一个隐藏对象,其作用就是帮助webgl进行隐藏面消除;webgl在颜色缓冲区中,绘制几何图形,绘制完成之后将颜色缓冲区显示到canvas上,要将隐藏面消除就必须知道几何图形的深度信息,而深度缓冲区就是用来存储深度信息的,由于深度方向通常是z轴方向,所以有时候也曾为z缓冲区;
在绘制每一帧之前,都必须清除深度缓冲区,以消除绘制上一帧时在其中留下的痕迹;
当然还需要清除颜色缓冲区,因此可以这样写:
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
至此,投影系列算是讲完了。