前面文章介绍了如何通过多点来绘制图形,通过建立缓冲区对象,将多个数据传入到缓冲区中;然后 webgl 进行读取缓冲区中的数据进行渲染。上个例子绘制 “F” 的坐标点不是很多;但是如果我们绘制一个立方体。如果还跟之前一样的绘制方式;立方体的每一个面由两个三角形组成,每个三角形有三个顶点,所以每个月需要有六个顶点,那么顶点数据需要有 36 个,我们会发现其实我们只需要用 8 个顶点来进行绘制立方体,但是如何进行组织绘制立方体的各个面呢?这时候我们需要一个索引。通过这个索引来记录顶点然后,我们在构造顶点数据的时候传入索引数据。webgl 通过索引去挨个读取对应的顶点数据来绘制三维图形。本篇文章也会讲到如何进行立方体的渲染以及每个面不同颜色的着色等等。
首先还是先上代码,如下:
import React, { useRef, useEffect } from 'react'
import { VSHADERR_SOURCE, FSHADER_SOURCE } from './glsl'
import { initShader } from '../../utils/webglFunc'
import { vertices, indices, colors } from './data'
import Matrix4 from '../../utils/matrix'
import './index.css'
const HelloCube = () => {
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, currentAngle: number, modelMatrix: any, u_ModelMatrix: any) => {
console.log(currentAngle, 'currentAngle')
// 设置旋转矩阵
modelMatrix.setRotate(currentAngle, 1, 0, 0)
// 将旋转矩阵传递给顶点着色器
gl.uniformMatrix4fv(u_ModelMatrix, false, 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_MvpMatrix = gl.getUniformLocation((gl as any).program, 'u_MvpMatrix')
if (u_MvpMatrix && u_MvpMatrix < 0) {
console.log('Failed to get the storage location of u_MvpMatrix')
return
}
// 创建 Matrix4 对象以进行模型变换
const mvpMatrix = new Matrix4()
mvpMatrix.setPerspective(30, 1, 1, 100)
mvpMatrix.lookAt(3, 3, 7, 0, 0, 0, 0, 1, 0)
gl.uniformMatrix4fv(u_MvpMatrix, false, mvpMatrix.elements)
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height)
// 清空颜色和深度缓冲区
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
if (!n) return
// 当前旋转角度
let currentAngle = 0.0
// 创建 Matrix4 对象以进行模型变换
const u_ModelMatrix = gl.getUniformLocation((gl as any).program, 'u_ModelMatrix')
if (u_ModelMatrix && u_ModelMatrix < 0) {
console.log('Failed to get the storage location of u_ModelMatrix')
return
}
// 创建 Matrix4 对象以进行模型变换
const modelMatrix = new Matrix4()
// 开始绘制三角形
const trick = () => {
let currentAngles = animate(currentAngle) // 更新角度
if (currentAngle !== currentAngles) {
currentAngle = currentAngles
draw(gl, n, currentAngle, modelMatrix, u_ModelMatrix)
requestID.current = requestAnimationFrame(trick) // 请求浏览器调用 trick
}
// currentAngle += 0.01
// draw(gl, n, currentAngle, modelMatrix, u_ModelMatrix)
// requestID.current = requestAnimationFrame(trick) // 请求浏览器调用 trick
}
trick()
}
// 记录上一次调用函数的时刻
let g_last = Date.now()
// 记录上一次调用函数的时刻
const animate = (angle: number) => {
// 计算距离上次调用经过多长时间
const now = Date.now()
const elapased = now - g_last // 毫秒
console.log(g_last, 'g_last')
g_last = now
// 根据距离上次调用的时间,更新当前旋转角度
let newAngle = angle + (45.0 * elapased) / 1000.0
return newAngle %= 360
}
useEffect(() => {
main()
return () => {
requestID.current && window.cancelAnimationFrame(requestID.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<canvas ref={canvasDom}></canvas>
)
}
export default HelloCube
着色器部分:
export const VSHADERR_SOURCE = `
attribute vec4 a_Position;
attribute vec4 a_Color;
uniform mat4 u_ModelMatrix;
uniform mat4 u_MvpMatrix;
// varying 变量只能是 float
varying vec4 v_Color;
void main() {
// 如果 gl_Position 最后一个分量为 1.0,那么前三个分量就可以表示一个点的三维坐标。平移不改变缩放比例,所以 u_Translation 第四个参数为 0.0,1.0 表示不缩放
gl_Position = u_MvpMatrix * u_ModelMatrix * 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;
}
`
实现的效果:
如下图:
我们构建一个立方体如上图,我们需要八个索引来表示立方体的八个点:
// v6----- v5
// /| /|
// v1------v0|
// | | | |
// | |v7---|-|v4
// |/ |/
// v2------v3
// 类型化数组 -- 所有数据类型一致,处理更高效
export const vertices = new Float32Array([
// 顶点坐标和颜色
1.0, 1.0, 1.0, -1.0, 1.0, 1.0, -1.0,-1.0, 1.0, 1.0,-1.0, 1.0, // v0-v1-v2-v3 front
1.0, 1.0, 1.0, 1.0,-1.0, 1.0, 1.0,-1.0,-1.0, 1.0, 1.0,-1.0, // v0-v3-v4-v5 right
1.0, 1.0, 1.0, 1.0, 1.0,-1.0, -1.0, 1.0,-1.0, -1.0, 1.0, 1.0, // v0-v5-v6-v1 up
-1.0, 1.0, 1.0, -1.0, 1.0,-1.0, -1.0,-1.0,-1.0, -1.0,-1.0, 1.0, // v1-v6-v7-v2 left
-1.0,-1.0,-1.0, 1.0,-1.0,-1.0, 1.0,-1.0, 1.0, -1.0,-1.0, 1.0, // v7-v4-v3-v2 down
1.0,-1.0,-1.0, -1.0,-1.0,-1.0, -1.0, 1.0,-1.0, 1.0, 1.0,-1.0 // v4-v7-v6-v5 back
])
export const colors = new Float32Array([
0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, // v0-v1-v2-v3 front(blue)
0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, // v0-v3-v4-v5 right(green)
1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, // v0-v5-v6-v1 up(red)
1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4, // v1-v6-v7-v2 left
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, // v7-v4-v3-v2 down
0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0 // v4-v7-v6-v5 back
])
// 顶点索引
export const indices = new Uint8Array([
0, 1, 2, 0, 2, 3, // front
4, 5, 6, 4, 6, 7, // right
8, 9,10, 8,10,11, // up
12,13,14, 12,14,15, // left
16,17,18, 16,18,19, // down
20,21,22, 20,22,23 // back
])
indices 数组以索引的形式存储了绘制顶点的顺序。索引值是整型数,所以数组的类型是 Uint8Array(无符号 8 位整型数);indices 中每三个索引值为 1 组,指向三个点,由这个 3 个顶点组成 1 个三角形。通常我们不需要手动创建这些顶点和索引数据,因为三维建模工具会帮助我们创建他们。
我们之前绘制图形时,一直使用 gl.drawArrays() 方法进行绘制;而通过索引绘制时,需要使用 gl.drawElements() 方法,我们需要在 gl.ELEMENT_ARRAY_BUFFER (不是之前的 gl.ARRAY_BUFFER) 中指定顶点的索引值,所以 gl.drawArrays() 和 gl.drawElements() 的区别就在于 gl.ELEMENT_ARRAY_BUFFER,它管理者具有索引结构的三维模型数据。gl.drawElements() 方法说明如下:
我们需要将顶点和颜色数据写入到 target 为 gl.ARRAY_BUFFER 的缓冲区中,将索引数据写入到 target 为 gl.ELEMENT_ARRAY_BUFFER 的缓冲区对象中。然后通过 gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_BYTE, 0) 进行读取数据进行绘制。
// 执行着色器,按照 mode 参数指定的方式,根据绑定到 gl.ELEMENT_ARRAY_BUFFER 的缓冲区中的顶点索引绘制图形。
gl.drawElements(mode, count, type, offset)
参数:
1. mode:指定绘图的方式,可以接收以下常量符号:
- gl.POINTS: 画单独的点。
- gl.LINE_STRIP: 画一条直线到下一个顶点。
- gl.LINE_LOOP: 绘制一条直线到下一个顶点,并将最后一个顶点返回到第一个顶点.
- gl.LINES: 在一对顶点之间画一条线.
- gl.TRIANGLE_STRIP
- gl.TRIANGLE_FAN
- gl.TRIANGLES: 为一组三个顶点绘制一个三角形.
2. count:整数型 指定要渲染的元素数量。
3. type:枚举类型 指定元素数组缓冲区中的值的类型。可能的值是:
- gl.UNSIGNED_BYTE
- gl.UNSIGNED_SHORT
- 当使用 OES_element_index_uint 扩展时: gl.UNSIGNED_INT
4. offset: 字节单位 指定元素数组缓冲区中的偏移量。必须是给定类型大小的有效倍数.
我们需要将顶点索引(也就是三角形列表中的内容)写入到缓冲区,并绑定到 gl.ELEMENT_ARRAY_BUFFER 上,调用 gl.drawElements() 时,webgl 首先从绑定到 gl.ELEMENT_ARRAY_BUFFER 的缓冲区中获取顶点的索引值,然后根据索引值,从绑定到 gl.ARRAY_BUFFER 的缓冲区中获取顶点的坐标、颜色等信息,然后传递给 attribute 变量并执行顶点着色器。
在调用 gl.drawElements() 时,webgl 首先从绑定到 gl.ELEMENT_ARRAY_BUFFER 的缓冲区中获取顶点的索引值,然后根据该索引值,从绑定到 gl.ARRAY_BUFFER 的缓冲区中获取顶点的坐标、颜色等信息,然后传递给 attribute 变量并执行顶点着色器。对每个索引值都这样做,最后就绘制出了整个立方体。而此时你只调用一次 gl.drawElement()。这种方式通过索引来访问顶点数据,从而循环利用顶点信息,控制内存的开销,但代价是你需要通过索引来间接地访问顶点,在某种程度上使程序复杂化了。所以,gl.drawElement() 和 gl.drawArrays() 各有优劣,具体用哪一个取决于具体的系统需求。
为了让一个立方体转动起来,你需要做的是:不断擦除和重绘立方体,并且在每次重绘时轻微地改变其角度。
首先我们需要在绘制之前进行设定请求背景色,设置好的背景色在重设之前一直有效。然后我们通过 requestAnimationFrame 实现反复调用绘制绘制方法。
对于 requestAnimationFrame API,大家可以在 mdn 查看其用法,他是对浏览器发出一次请求,请求在未来某个适当的时机调用 tick 函数方法。
代码如下(示例):
// 开始绘制三角形
const trick = () => {
let currentAngles = animate(currentAngle) // 更新角度
if (currentAngle !== currentAngles) {
currentAngle = currentAngles
draw(gl, n, currentAngle, modelMatrix, u_MvpMatrix)
requestID.current = requestAnimationFrame(trick) // 请求浏览器调用 trick
}
}
trick()
}
其他需要注意的就是我们在着色器中使用了之前没有用到过的 varying 数据类型;之前提到过,他是顶点着色器跟片元着色器之间用来传递变量的数据类型。
事实上,我们把顶点的颜色赋值给了顶点着色器中的 varying 变量 v_Color,他的值被传给片元着色器中的同名、同类型变量。更准确地说,顶点着色器中的 varying 变量在传入片元着色器之前经过了内插过程。所以,片元着色器中的 v_Color 变量和顶点着色器中的 v_Color 变量实际上并不是一回事,这也是为啥这种变量称为 varying 变量的原因。我们只是为每个顶点定义了颜色,但是顶点所在的面上的每个点都渲染成了那种颜色。其实这就是根据顶点的颜色进行内插得到的。后续我们会讲到纹理相关,会继续深入理解这块。
还有一个就是用到的 Matrix4 类。他是用生成模型矩阵等变换矩阵的一个 js 库,这里先不展开说明,下一节会讲述如何进行平移旋转缩放等。
这里还有一些相机朝向、以及投影矩阵相关的没有讲到;后续文章会一一介绍:
// 创建 Matrix4 对象以进行模型变换
const modelMatrix = new Matrix4()
modelMatrix.setPerspective(30, 1, 1, 100)
modelMatrix.lookAt(3, 3, 7, 0, 0, 0, 0, 1, 0)
至此,绘制一个旋转的彩色立方体已完成;本节我们学到了如何将大量的点传入到着色器中,以及如何简化点的重复利用。通过索引可以简化复杂的绘制。