从 WebGL 到 Threejs

1,617 阅读5分钟

本文将从 demo 切入,阐述 WebGL 到 Threejs 的演进过程。

WebGL

一. WebGL 渲染管线

  本文假定读者对 3d 渲染有一些了解,所以只对 webgl 的渲染管线做简单回顾。

image.png

  如上图所示,一次 webgl 渲染流程大体包括三步:

  • 写入数据(例如顶点和纹理等数据)
  • 图元装配
  • 光栅化及片元上色 (感谢 bruce 提供的图片) image.png

1. 写入数据:   我们需要把顶点数据写入到显存中,给下一步的图元装配使用: image.png

2. 图元装配:   webgl 支持的图元类型包括点,线和三角形。一般使用三角形。所谓图元装配其实就是确认图元的顶点信息,即三角形三个顶点的位置。   再复杂的 3D 模型,也是通过绘制出的一个个三角形组成的: image.png

  同时,webgl 给我们提供了一个插槽 - 顶点着色器,用来自由控制顶点的位置: image.png   示例代码:

attribute vec4 position;
uniform mat4 matrix;
void main() {
  gl_Position = matrix * position;  
}

  代码中的 matrix 代表一个复合矩阵,用于将三维世界坐标转换成投影坐标:

matrix = 投影矩阵 x 视图矩阵 x 模型矩阵

  投影后的顶点 webgl 会帮我们自动裁剪到视口上。 image.png

**3. 光栅化及片元上色 **   光栅化是 webgl 自动帮我们完成的,用来将图元拆解成一个个片元(像素)。我们能做的就是给拆解完的片元上色。具体的做法就是提供一个片元着色器,告知 webgl 如何对当前片元上色。 image.png   示例代码:

precision mediump float;  
void main(void) { 
    gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0); 
}

二. 用 WebGL 绘制一个三角形

1. 绘制流程

image.png

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. 绘制结果

image.png

三. 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. 绘制流程

image.png

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. 绘制结果

image.png

  可以发现,整个绘制流程更加高效:

  • 创建顶点更加简单,我们只需定义正方形的长宽,threejs 自动帮我们计算出顶点。
  • threejs 代替我们与显存交互,使用者无需关心。
  • threejs 通过材质帮我们封装好了各种着色器,普调使用者无需关心,高玩也可以通过自定义材质来自实现着色器。

二. 从 threejs 源码的角度看 demo

  从源码的角度看一下 threejs 是如何绘制出一个正方形的。

1. Scene

image.png

  Scene 继承自 Object3d。 Object3d 是大部分对象的基类,它提供了一系列的属性和方法来对三维空间中的物体进行操纵。而 Scene 用来组织各个对象。

2. Camera

image.png

  Camera 同样继承自 Object3d,另外包含了投影矩阵,用来将世界坐标系中的顶点转成相机坐标系。

3. Mesh

image.png

  Mesh 是 threejs 中可以绘制出的基本对象。由几何体和材质两部分组成。

  • BufferGeometry: threejs 中所有几何体的基类,包括顶点位置,面片索引、法相量、颜色值、UV 坐标和自定义缓存属性值。
  • Material: threejs 中所有材质的基类,每个类型的材质都对应了一个包含了相关着色器的 program。

4. WebGLRenderer

image.png

  WebGLRenderer 用于构造出一个 webgl 渲染器。   主要是帮我们初始化一个 webgl 绘制上下文,以及一系列方便我们操作 webgl 的对象。 image.png

5. renderer.render()

image.png

  依次渲染出 scene 下所有的 mesh。 image.png image.png

  threejs 渲染 mesh 总共分为三步:

  • 写入几何体对应的顶点数据。
  • 切换到材质对应的 webgl program。
  • 调用一次 drawcall。

附录