前言
接上篇,我们实现了一个可旋转的彩色立方体。我们并没有详细说明里面的一个模型矩阵变换;本篇文章将解决如何对一个图形进行平移、旋转、缩放;并实战一个似 threeJS 的可以利用鼠标滚轮以及鼠标事件来对图形操作。
一、移动、旋转、缩放
首先需要将世界坐标(我们以自己的视角定义的物体的坐标,如前面例子绘制一个 “F”)转换为 webgl 中的坐标 [-1, 1] 的范围内,这会用到缩放、然后在 webgl 中如何放置,放在哪里;我们需要用到平移、旋转等。这样的操作称为变换或仿射变换。
1.平移
首先我们可以想象一下平移的操作,无非就是对一个物体的 x、y 坐标进行添加一定的偏移量来实现:
如上图,我们将 p (x, y, z) 点移动到 p’(x’, y’, z’);对于 p’ 的坐标我们可以通过下面表达式计算:
x' = x + Tx
y' = y + Ty
我们只需要着色器中为顶点坐标的每个分量加上一个常量就可以实现上面的等式。显然,这是一个逐顶点操作而非逐片元操作,上面的操作发生在顶点着色器,而不是片元着色器。
拿我们之前绘制的 “F” 代码为例(文章)我们修改着色器部分代码:
export const VSHADERR_SOURCE = `
attribute vec2 a_Position;
uniform vec2 u_resolution;
void main() {
vec2 translate = vec2(a_Position.x + 18.0, a_Position.y + 15.0);
//[0, 1]
vec2 zeroToOne = translate / u_resolution;
//[0, 2]
vec2 zeroToTwo = zeroToOne * 2.0;
//[-1, 1]
vec2 clipSpace = zeroToTwo - 1.0;
gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
}
`
export const FSHADER_SOURCE = `
precision mediump float;
uniform vec4 u_color;
void main() {
gl_FragColor = u_color;
}
`
主要看:vec2 translate = vec2(a_Position.x + 18.0, a_Position.y + 15.0);
将它的 x,y 坐标分别加了一个偏移量(注意声明一个 vec2 类型,通过vec2() 方法进行定义);我们会看到 F 图形进行了平移。
学过矩阵的童鞋都知道,上面的表达式可以通过一个变换矩阵相乘来解决;变换矩阵非常适合操作计算机图形。我们的坐标可以看做一个 1 × 3 的一个矩阵;将上面的表达式写成矩阵相乘如下:
x' = x + Tx
y' = y + Ty
1 0 Tx x x + Tx
0 1 Ty × y = y + Ty
0 0 1 1 1
这里矩阵上升到了三维矩阵,因为二维矩阵无法实现我们要实现的表达式;这种增加一个维度的做法,我们叫做齐次坐标。
对于矩阵的相乘,这里就不做说明,可以看我这里的数学基础这篇文章。
这里只是变换了 x,y 坐标进行平移,如果进行 x、y、z 进行平移;那么我们需要一个 4 * 4 的矩阵进行相乘。
我们现在要做的是封装一个方法然后去生成一个平移的矩阵来实现,代码如下:
// webgl 会对矩阵进行一次转置
export const translate2d = (x: number, y: number) => {
return [
1, 0, 0,
0, 1, 0,
x, y, 1
]
}
我们修改之前的着色器代码,如下:
export const VSHADERR_SOURCE = `
attribute vec2 a_Position;
// 世界坐标
uniform mat3 u_world;
uniform mat3 u_unit;
void main() {
vec3 p = u_world * u_unit * vec3(a_Position, 1);
gl_Position = vec4(p.xy, 0, 1);
}
`
这里有两个变量 u_world 跟 u_unit;u_world 是世界坐标的转换矩阵;我们在写 “F” 例子的时候说过通过归一化将坐标转换到 webgl 坐标系统内,如下图:
所以我们需要先缩放、然后平移操作;
该矩阵如下:
// 将我们的世界坐标转换到 webgl 坐标系统
// 同样注意 y 轴;我们世界坐标是 [0, height];在 webgl 绘制中从上往下。
const worldMatrix = [
2/gl.canvas.width, 0, 0,
0, -2/gl.canvas.height, 0,
-1, +1, 1
]
然后我们将值传递到着色器,代码如下:
import React, { useRef, useEffect } from 'react'
import { VSHADERR_SOURCE, FSHADER_SOURCE } from './glsl'
import { initShader } from '../../utils/webglFunc'
import { d2_f as shape } from '../DrawF/df2'
import { translate2d } from '../../utils/translate'
import './index.css'
const ThreeJS = () => {
const canvasDom = useRef<HTMLCanvasElement | null>(null)
const initVertexBuffers = (gl: WebGLRenderingContext) => {
// 定义顶点
const verticies = shape(100, 100, 100, 150, 30)
// 创建缓冲区对象
const vertexBuffer = gl.createBuffer()
// 将缓冲区对象绑定到目标
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer)
// 向缓冲区对象中写入数据,也就是描述buffer 被绑定在哪里。 gl.ARRAY_BUFFER 存顶点属性,gl.ELEMENT_ARRAY_BUFFER 存储顶点索引的类型
// 最后一个参数是提示 webgl 数据将如何被使用 gl.STATIC_DRAW 表示数据通常不会发生变化,表示 gl.DYNAMIC_DRAW 会发生变化的数据
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verticies), gl.STATIC_DRAW)
// 在顶点着色器中获取 a_Position 的地址
// gl.program 为包含顶点着色器和片元着色器的着色器程序对象
const a_Position = gl.getAttribLocation((gl as any).program, 'a_Position')
// 获取存储位置其实是从 0 开始根据定义的位置依次计数的结果
if (a_Position < 0) {
console.log('Failed to get the storage location of a_Position')
return
}
// 将缓冲区对象分配给 a_Position 变量
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0)
// 连接 a_Position 变量与分配给他的缓冲区对象
gl.enableVertexAttribArray(a_Position)
return verticies.length
}
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)
const u_Color = gl.getUniformLocation((gl as any).program, 'u_color')
const u_unit = gl.getUniformLocation((gl as any).program, 'u_unit')
const u_world = gl.getUniformLocation((gl as any).program, 'u_world')
gl.uniform4fv(u_Color, [Math.random(), Math.random(), Math.random(), 1.0])
// 将我们的世界坐标转换到 webgl 坐标系统
// 同样注意 y 轴;我们世界坐标是 [0, height];在 webgl 绘制中从上往下。
const worldMatrix = [
2/gl.canvas.width, 0, 0,
0, -2/gl.canvas.height, 0,
-1, +1, 1
]
const matTranslate = translate2d(300.0, 12.0)
gl.uniformMatrix3fv(u_unit, false, matTranslate)
gl.uniformMatrix3fv(u_world, false, worldMatrix)
// 指定清空 canavs 的颜色
gl.clearColor(0.0, 0.0, 0.0, 1.0)
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height)
// 清空 canvas
gl.clear(gl.COLOR_BUFFER_BIT)
// 第一个参数为绘制的类型,第二个参数是指定从哪个顶点开始绘制(整数型,从第一个点开始就是 0),
// 第三个参数是指定绘制需要用到多少个顶点(整数型)
// 当程序调用 l.drawArrays 方法时,顶点着色器将被执行 n 次,每次处理一个顶点。执行顶点着色器代码中的 main 函数。
// 一旦顶点着色器执行完之后,片元着色器开始执行。
if (!n) return
gl.drawArrays(gl.TRIANGLES, 0, n / 2)
}
useEffect(() => {
main()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<canvas ref={canvasDom}></canvas>
)
}
export default ThreeJS
2. 旋转
我们先说简单的旋转,相对于原点进行旋转,如下图:
相对于原点旋转的功能可以看做一个生成矩阵的函数 R(θ):
封装对应函数如下:
/**
* 相对于原点旋转
* @param a 旋转角度(弧度制)
* @returns
*/
export const rotate2d = (a: number) => {
return [
Math.cos(a), Math.sin(a), 0,
-Math.sin(a), Math.cos(a), 0,
0, 0, 1
]
}
如果想实现根据任意一点进行旋转,可以先将图形移动到原点,然后进行旋转,然后又移动到之前的位置。这样一个复合的操作,来实现任意一点的旋转。
提到复合操作也就是对矩阵进行相乘,实现任意一点旋转就是:
平移矩阵 * 旋转矩阵 * 平移矩阵
这样的操作;我们需要封装一个矩阵相乘的方法,代码如下:
import { identity3d } from './identity'
export const multiply = (a: number[], b: number[], m: number, p: number) => {
const n = a.length / m
const q = b.length / p
if (n !== p) {
// eslint-disable-next-line no-throw-literal
throw 'cannot apply multiplication:matrix shape not match'
}
const r = [m * q]
for (let j = 0; j < q; j ++) {
for (let i = 0; i < m; i++) {
let s = 0
for (let k = 0; k < n; k++) {
s += a[k + i * n] * b[j + k * q]
}
r[i * q + j] = s
}
}
return r
}
export const multiply3d = (...matriexs: number[][]) => {
return matriexs.reduce((a, b) => multiply(a, b, 3, 3), identity3d())
}
然后我们执行矩阵相乘得到旋转矩阵,代码如下:
const main = () => {
// 将我们的世界坐标转换到 webgl 坐标系统
// 同样注意 y 轴;我们世界坐标是 [0, height];在 webgl 绘制中从上往下。
const worldMatrix = [
2/gl.canvas.width, 0, 0,
0, -2/gl.canvas.height, 0,
-1, +1, 1
]
let uMatrix = identity3d()
const matTranslate = translate2d(300.0, 12.0)
// 相对于原点旋转
const matRotate = rotate2d(20)
// 任意一点的旋转,首先先移动到原点,然后进行旋转,然后移回去之前的位置
const matBeforeRotate = translate2d(-50, -75)
const matAfterRotate = translate2d(50, 75)
uMatrix = multiply3d(uMatrix, matBeforeRotate, matRotate, matAfterRotate)
gl.uniformMatrix3fv(u_unit, false, uMatrix )
gl.uniformMatrix3fv(u_world, false, worldMatrix)
}
3. 缩放
缩放就很简单了,直接对 x,y 坐标进行乘以不同的缩放比例即可,矩阵形式如下:
封装生成的对应代码:
export const scale2d = (sx: number, sy: number) => {
return [
sx, 0, 0,
0, sy, 0,
0, 0, 1
]
}
二、封装鼠标相关事件
代码如下(示例):
import React, { useRef, useEffect } from 'react'
import { VSHADERR_SOURCE, FSHADER_SOURCE } from './glsl'
import { initShader } from '../../utils/webglFunc'
import { d2_f as shape } from '../DrawF/df2'
import { multiply3d } from '../../utils/multiply'
import { identity3d } from '../../utils/identity'
import { translate2d } from '../../utils/translate'
import { scale2d } from '../../utils/scale'
import { rotate2d } from '../../utils/rotate'
import './index.css'
const ThreeJS = () => {
const canvasDom = useRef<HTMLCanvasElement | null>(null)
const glContext = useRef<WebGLRenderingContext | null>()
const drawNum = useRef<number>()
const initVertexBuffers = (gl: WebGLRenderingContext) => {
// 定义顶点
const verticies = shape(100, 100, 100, 150, 30)
// 创建缓冲区对象
const vertexBuffer = gl.createBuffer()
// 将缓冲区对象绑定到目标
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer)
// 向缓冲区对象中写入数据,也就是描述buffer 被绑定在哪里。 gl.ARRAY_BUFFER 存顶点属性,gl.ELEMENT_ARRAY_BUFFER 存储顶点索引的类型
// 最后一个参数是提示 webgl 数据将如何被使用 gl.STATIC_DRAW 表示数据通常不会发生变化,表示 gl.DYNAMIC_DRAW 会发生变化的数据
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verticies), gl.STATIC_DRAW)
// 在顶点着色器中获取 a_Position 的地址
// gl.program 为包含顶点着色器和片元着色器的着色器程序对象
const a_Position = gl.getAttribLocation((gl as any).program, 'a_Position')
// 获取存储位置其实是从 0 开始根据定义的位置依次计数的结果
if (a_Position < 0) {
console.log('Failed to get the storage location of a_Position')
return
}
// 将缓冲区对象分配给 a_Position 变量
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0)
// 连接 a_Position 变量与分配给他的缓冲区对象
gl.enableVertexAttribArray(a_Position)
return verticies.length
}
const main = () => {
if (!canvasDom.current) return
const gl = canvasDom.current.getContext('webgl')
glContext.current = gl
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)
const u_Color = gl.getUniformLocation((gl as any).program, 'u_color')
const u_unit = gl.getUniformLocation((gl as any).program, 'u_unit')
const u_world = gl.getUniformLocation((gl as any).program, 'u_world')
gl.uniform4fv(u_Color, [Math.random(), Math.random(), Math.random(), 1.0])
// 将我们的世界坐标转换到 webgl 坐标系统
// 同样注意 y 轴;我们世界坐标是 [0, height];在 webgl 绘制中从上往下。
const worldMatrix = [
2/gl.canvas.width, 0, 0,
0, -2/gl.canvas.height, 0,
-1, +1, 1
]
let uMatrix = identity3d()
// const matTranslate = translate2d(300.0, 12.0)
// 相对于原点旋转
// const matRotate = rotate2d(20)
uMatrix = multiply3d(uMatrix)
gl.uniformMatrix3fv(u_unit, false, uMatrix)
gl.uniformMatrix3fv(u_world, false, worldMatrix)
// 指定清空 canavs 的颜色
gl.clearColor(0.0, 0.0, 0.0, 1.0)
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height)
// 清空 canvas
gl.clear(gl.COLOR_BUFFER_BIT)
if (!n || !u_unit) return
drawNum.current = n
draw(gl, n, u_unit, uMatrix)
}
const draw = (gl: WebGLRenderingContext, n: number, u_unit: WebGLUniformLocation, uMatrix: number[]) => {
// 清空 canvas
gl.clear(gl.COLOR_BUFFER_BIT)
gl.uniformMatrix3fv(u_unit, false, uMatrix)
// gl.uniformMatrix3fv(u_world, false, worldMatrix)
gl.drawArrays(gl.TRIANGLES, 0, n / 2)
}
let scale = 1
const handleScroll = (event: any) => {
if (!glContext.current || !drawNum.current) return
// 拉伸
const matScale = scale2d(scale, scale)
let uMatrix = identity3d()
uMatrix = multiply3d(uMatrix, matScale)
const u_unit = glContext.current.getUniformLocation((glContext.current as any).program, 'u_unit')
if (!u_unit) return
//判断鼠标滚轮的上下滑动
let deta = event.deltaY
if(deta > 0){
scale += 1
draw(glContext.current, drawNum.current, u_unit, uMatrix)
}
if(deta < 0){
scale -= 0.5
if (scale < 0) return
draw(glContext.current, drawNum.current, u_unit, uMatrix)
}
}
let timmerHandle: any = null
let isDrag = false
let drag = {
x: 0,
y: 0
}
const down = (event: any) => {
console.log("mouse down.", event, drag)
drag.x = event.clientX
drag.y = event.clientY
isDrag = false
// 延迟100ms
timmerHandle = setTimeout(setDragTrue,200)
}
const setDragTrue = () => {
isDrag = true
}
const up = (event: any) => {
if (!isDrag) {
//先把doMouseDownTimmer清除,不然200毫秒后setGragTrue方法还是会被调用的
clearTimeout(timmerHandle)
} else {
isDrag = false
if (!glContext.current || !drawNum.current) return
const matTranslate = translate2d(event.clientX - drag.x, event.clientY - drag.y)
let uMatrix = identity3d()
uMatrix = multiply3d(uMatrix, matTranslate)
const u_unit = glContext.current.getUniformLocation((glContext.current as any).program, 'u_unit')
if (!u_unit) return
draw(glContext.current, drawNum.current, u_unit, uMatrix)
}
}
useEffect(() => {
main()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<canvas ref={canvasDom} onWheel={handleScroll} onMouseDown={down} onMouseUp={up}></canvas>
)
}
export default ThreeJS
类似的功能,还是存在一些小bug,后续完善。
总结
至此,对图形的缩放基本讲完;下面我们就需要说一下投影相关的问题。