本文将从 demo 切入,阐述 WebGL 到 Threejs 的演进过程。
WebGL
一. WebGL 渲染管线
本文假定读者对 3d 渲染有一些了解,所以只对 webgl 的渲染管线做简单回顾。
如上图所示,一次 webgl 渲染流程大体包括三步:
- 写入数据(例如顶点和纹理等数据)
- 图元装配
- 光栅化及片元上色 (感谢 bruce 提供的图片)
1. 写入数据: 我们需要把顶点数据写入到显存中,给下一步的图元装配使用:
2. 图元装配: webgl 支持的图元类型包括点,线和三角形。一般使用三角形。所谓图元装配其实就是确认图元的顶点信息,即三角形三个顶点的位置。 再复杂的 3D 模型,也是通过绘制出的一个个三角形组成的:
同时,webgl 给我们提供了一个插槽 - 顶点着色器,用来自由控制顶点的位置: 示例代码:
attribute vec4 position;
uniform mat4 matrix;
void main() {
gl_Position = matrix * position;
}
代码中的 matrix 代表一个复合矩阵,用于将三维世界坐标转换成投影坐标:
matrix = 投影矩阵 x 视图矩阵 x 模型矩阵
投影后的顶点 webgl 会帮我们自动裁剪到视口上。
**3. 光栅化及片元上色 ** 光栅化是 webgl 自动帮我们完成的,用来将图元拆解成一个个片元(像素)。我们能做的就是给拆解完的片元上色。具体的做法就是提供一个片元着色器,告知 webgl 如何对当前片元上色。 示例代码:
precision mediump float;
void main(void) {
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
}
二. 用 WebGL 绘制一个三角形
1. 绘制流程
2. 相关代码
const VSHADER_SOURCE = `
attribute vec4 a_Position;
void main() {
gl_Position = a_Position;
}
`;
const FSHADER_SOURCE = `
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`;
function main() {
const canvas = document.getElementById('webgl');
const gl = getWebGLContext(canvas);
if (!gl) {
return console.log('Failed to get the rendering context for WebGL');
}
const program = createProgram(gl, VSHADER_SOURCE, FSHADER_SOURCE);
gl.useProgram(program);
gl.program = program;
const n = initVertexBuffers(gl); // 将顶点数据写入缓冲区
if (n < 0) {
return console.log('Failed to set the positions of the vertices');
}
gl.clearColor(0.0, 0.0, 0.0, 1.0); // 设置清除颜色缓冲区后,画布使用的颜色
const draw = function() {
// 清空颜色缓冲区(画布)
gl.clear(gl.COLOR_BUFFER_BIT);
// 绘制
gl.drawArrays(gl.TRIANGLES, 0, n);
requestAnimationFrame(draw);
};
draw();
}
function initVertexBuffers(gl) {
const vertices = new Float32Array ([
0, 0.5, -0.5, -0.5, 0.5, -0.5
]);
const n = 3;
const vertexBuffer = gl.createBuffer();
if (!vertexBuffer) {
console.log('Failed to create the buffer object');
return -1;
}
// gl.ARRAY_BUFFER 指针指向新申请的内存
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
// 写入数据
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
const 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;
}
// a_Position -> gl.ARRAY_BUFFER 指向的内存
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);
// 启用遍历
gl.enableVertexAttribArray(a_Position);
// 清除指针指向
gl.bindBuffer(gl.ARRAY_BUFFER, null);
return n;
}
3. 绘制结果
三. WebGL 编程劣势
1. 繁琐
(1) 生成顶点数据繁琐: 我们不可能每定义一个几何体,都要把各个顶点数据搞出来。而是应该定义一个几何体的长宽高,自动生成相对的顶点数据才合理。 (2) 数据写入显存繁琐: 每当我们需要与显存交互的时候,流程也是十分繁琐。以下是写入顶点数据与写入纹理数据两个代码段,可以感受下:
function initVertexBuffers(gl) {
const vertices = new Float32Array ([
0, 0.5, -0.5, -0.5, 0.5, -0.5
]);
const n = 3;
const vertexBuffer = gl.createBuffer();
if (!vertexBuffer) {
console.log('Failed to create the buffer object');
return -1;
}
// gl.ARRAY_BUFFER 指针指向新申请的内存
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
// 写入数据
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
const 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;
}
// a_Position -> gl.ARRAY_BUFFER 指向的内存
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);
// 启用遍历
gl.enableVertexAttribArray(a_Position);
// 清除指针指向
gl.bindBuffer(gl.ARRAY_BUFFER, null);
return n;
}
function initTextures(gl, n) {
const texture = gl.createTexture();
if (!texture) {
console.log('Failed to create the texture object');
return false;
}
// u_Sampler 取样器,通过 纹理坐标 得到 颜色值
const u_Sampler = gl.getUniformLocation(gl.program, 'u_Sampler');
if (!u_Sampler) {
console.log('Failed to get the storage location of u_Sampler');
return false;
}
const image = new Image();
if (!image) {
console.log('Failed to create the image object');
return false;
}
image.onload = function(){ loadTexture(gl, n, texture, u_Sampler, image); };
image.src = './assets/sky.jpg';
return true;
}
function loadTexture(gl, n, texture, u_Sampler, image) {
// 反转y轴,因为图片的坐标系统y轴朝下
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
// 激活纹理单元,一个纹理单元管理一个纹理对象
gl.activeTexture(gl.TEXTURE0);
// 绑定 纹理对象 到 纹理单元 上
gl.bindTexture(gl.TEXTURE_2D, texture);
// 配置纹理对象参数,设置纹理图像映射到图形上的方式
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
// 将 纹理图像 分配到 纹理对象 上
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, image);
// 将零号纹理单元指定到片元着色器的取样器参数上
gl.uniform1i(u_Sampler, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, n);
}
2. 成本太高
(1) 着色器的学习成本: 我们 demo 中给的着色器是十分基础的,也只能搞出简单的效果。如果我们需要实现更加复杂的效果,就需要开发同学去深入学习着色器,包括各种光学和线性代数等知识。 (2) 渲染管线的学习成本: 对于大部分的前端同学,是不了解 webgl 的渲染管线的。如果要让他们也能绘制出 3d 场景,就需要提供一个学习成本更低,更易使用的框架。
Threejs
Threejs 基于 webgl 做了一层封装,让前端同学可以更低成本,更高效地绘制 3d 场景。
一. 用 threejs 绘制一个正方形
1. 绘制流程
2. 相关代码
function init() {
container = document.getElementById( 'container' );
scene = new THREE.Scene();
scene.background = new THREE.Color( 0x8fbcd4 );
camera = new THREE.PerspectiveCamera(
45,
window.innerWidth / window.innerHeight,
1,
20
);
camera.position.z = 10;
scene.add( camera );
const geometry = new THREE.PlaneGeometry( 2, 2 );
const material = new THREE.MeshBasicMaterial( {
color: 0xff0000,
} );
mesh = new THREE.Mesh( geometry, material );
scene.add( mesh );
renderer = new THREE.WebGLRenderer( { antialias: true } );
renderer.setSize( window.innerWidth, window.innerHeight );
container.appendChild( renderer.domElement );
renderer.render( scene, camera );
}
3. 绘制结果
可以发现,整个绘制流程更加高效:
- 创建顶点更加简单,我们只需定义正方形的长宽,threejs 自动帮我们计算出顶点。
- threejs 代替我们与显存交互,使用者无需关心。
- threejs 通过材质帮我们封装好了各种着色器,普调使用者无需关心,高玩也可以通过自定义材质来自实现着色器。
二. 从 threejs 源码的角度看 demo
从源码的角度看一下 threejs 是如何绘制出一个正方形的。
1. Scene
Scene 继承自 Object3d。 Object3d 是大部分对象的基类,它提供了一系列的属性和方法来对三维空间中的物体进行操纵。而 Scene 用来组织各个对象。
2. Camera
Camera 同样继承自 Object3d,另外包含了投影矩阵,用来将世界坐标系中的顶点转成相机坐标系。
3. Mesh
Mesh 是 threejs 中可以绘制出的基本对象。由几何体和材质两部分组成。
- BufferGeometry: threejs 中所有几何体的基类,包括顶点位置,面片索引、法相量、颜色值、UV 坐标和自定义缓存属性值。
- Material: threejs 中所有材质的基类,每个类型的材质都对应了一个包含了相关着色器的 program。
4. WebGLRenderer
WebGLRenderer 用于构造出一个 webgl 渲染器。 主要是帮我们初始化一个 webgl 绘制上下文,以及一系列方便我们操作 webgl 的对象。
5. renderer.render()
依次渲染出 scene 下所有的 mesh。
threejs 渲染 mesh 总共分为三步:
- 写入几何体对应的顶点数据。
- 切换到材质对应的 webgl program。
- 调用一次 drawcall。