前言
本篇文章继续跟着李伟老师学习 webgl,本篇文章的内容主要为基点变换与它的衍生内容
基点变换原理
就比如这样一张图片,求:让 img 基于其左上角点 P 点进行旋转和缩放的方法
我们设置图片的本地矩阵为 mi,当前 mi 和世界坐标系重合。
让 mi 的位置减去点 P
如果我们现在旋转 mi 的话,会基于 mi 的坐标原点旋转,这明显不是我们想要的。
新建本地矩阵 mb
当前 mb 和世界坐标系重合。
然后让 mb 的位置加上点 P
此时 mi 又和世界坐标系重合了
此时的 img 和最初始的 img 重合。
此时 img 的左上角点 P 和 mb 的坐标原点重合
此时我们旋转 mb,mi 会基于 mb 的坐标原点旋转,同时也带动了图片基于点 P 旋转。
计算模型矩阵,变换图片顶点的初始点位,从而得到图片顶点基于点 P 变换后的位置。
模型矩阵=mb*mi
图片顶点基于点 P 变换后的位置=模型矩阵*图片顶点的初始点位
mi 包含了图片的初始点位,可以理解为 three.js 中 Object3D。
mb 包含了 mi,可以理解为 three.js 中的 Group。
代码实现
着色器
这里的参数大家应该都比较熟了
- a_Position:顶点位置
- u_ModelMatrix:模型矩阵
- u_PvMatrix:视图矩阵
- v_Pin:通过顶点着色器传递给片元着色器的纹理(铆钉)
- a_Pin:纹理(铆钉)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| <script id="textureVertexShader" type="x-shader/x-vertex"> attribute vec4 a_Position; attribute vec2 a_Pin; uniform mat4 u_PvMatrix; uniform mat4 u_ModelMatrix; varying vec2 v_Pin; void main(){ gl_Position = u_PvMatrix*u_ModelMatrix*a_Position; v_Pin=a_Pin; } </script> <script id="textureFragmentShader" type="x-shader/x-fragment"> precision mediump float; uniform sampler2D u_Sampler; varying vec2 v_Pin; void main(){ gl_FragColor=texture2D(u_Sampler,v_Pin); } </script>
|
导入
1 2 3 4 5 6 7 8 9 10 11 12
| import { createProgram } from "../jsm/Utils.js"; import { Matrix4, OrthographicCamera, Vector3, Vector2, } from "https://unpkg.com/three/build/three.module.js"; import OrbitControls from "./jsm/OrbitControls.js"; import Mat from "./jsm/Mat.js"; import Geo from "./jsm/Geo.js"; import Obj3D from "./jsm/Obj3D.js"; import Scene from "./jsm/Scene.js";
|
初始化 canvas 并设置正交相机
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| const canvas = document.getElementById("canvas"); canvas.width = window.innerWidth; canvas.height = window.innerHeight; const gl = canvas.getContext("webgl"); gl.clearColor(0.0, 0.0, 0.0, 1.0);
const halfH = 1; const ratio = canvas.width / canvas.height; const halfW = halfH * ratio; const [left, right, top, bottom, near, far] = [ -halfW, halfW, halfH, -halfH, 1, 8, ]; const eye = new Vector3(0, 0, 2); const target = new Vector3(0, 0, 0); const camera = new OrthographicCamera(left, right, top, bottom, near, far); camera.position.copy(eye); camera.lookAt(target); camera.updateMatrixWorld(); const scence = new Scene({ gl });
|
投影视图矩阵
让相机的投影矩阵乘以视图矩阵
1 2 3
| const pvMatrix = camera.projectionMatrix .clone() .multiply(camera.matrixWorldInverse);
|
定义顶点位置
1 2 3 4 5
| const [w, h] = [0.6, 0.6]; const [hw, hh] = [w / 2, h / 2];
const vertices = new Float32Array([-hw, hh, -hw, -hh, hw, hh, hw, -hh]);
|
等待图片加载完成添加到场景中
这里用了前面文章封装的内容,这里的 a_Pin 你现在可以理解为固定的,因为它以左下角的点作为原点,因此按照三角带的绘制方式,左上角的点相当于(0,1),左下角的点相当于(0,0),右上角的点是(1,1),右下角的点是(1,0)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| const image = new Image(); image.src = "./images/erha.jpg"; let mat = null; image.onload = function () { const vs = document.getElementById("textureVertexShader").innerText; const fs = document.getElementById("textureFragmentShader").innerText; const program = createProgram(gl, vs, fs); mat = new Mat({ program, data: { u_PvMatrix: { value: pvMatrix.elements, type: "uniformMatrix4fv", }, u_ModelMatrix: { value: new Matrix4().elements, type: "uniformMatrix4fv", }, }, maps: { u_Sampler: { image, }, }, mode: "TRIANGLE_STRIP", }); const geo = new Geo({ data: { a_Position: { array: vertices, size: 2, }, a_Pin: { array: new Float32Array([0, 1, 0, 0, 1, 1, 1, 0]), size: 2, }, }, }); const obj = new Obj3D({ geo, mat }); scence.add(obj); scence.draw(); };
|
声明本地矩阵
1 2 3 4 5 6 7 8
| const mi = new Matrix4();
const mb = new Matrix4();
const mm = new Matrix4();
let orignInd = 0;
|
上面的模型矩阵 mm 之后会在渲染时,由 mb*mi 得到。
基于图片的变换基点布阵
这里的 orignInd 就是我们需要按照哪个点处理,比如是 0,那么得到的点位就是左上角的点,如果是 2,那么就是左下角的点,因为我们俩个点作为一个 xy 坐标, 依次类推,右上角的点就是 4,右下角就是 6,我们之前说过,mi 是减去 P 的本地矩阵,mb 是加上 P 的本地矩阵,所以 mi 设置为(-x,-y,0),mb 设置为(x,y,0)
1 2 3 4 5 6
| setOrign(orignInd); function setOrign(i) { const [x, y] = [vertices[i], vertices[i + 1]]; mi.setPosition(-x, -y, 0); mb.setPosition(x, y, 0); }
|
渲染变化
然后我们对模型矩阵进行沿 Z 轴旋转和 sin 函数缩放即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| let ang = 0; function render() { ang += 0.02; const s = (Math.sin(ang) + 1) / 2; mm.copy( mb .clone() .multiply(new Matrix4().makeRotationZ(ang)) .scale(new Vector3(s, s, 1)) .multiply(mi) ); mat.setData("u_ModelMatrix", { value: mm.elements, }); scence.draw(); requestAnimationFrame(render); }
|
这里的基点变化其实就是利用了基变化的原理,这里给大家推荐个视频
https://www.bilibili.com/video/av6731067/?p=13
二次基点变换
假如我们需要每旋转 45 度就变换我们的基点,那么该如何实现呢,其实就是将 orignInd 加上 2,超过 6 就变成 0 即可,让他在 0,2,4,6 依次循环,然后为了让我们变化顺畅,我们需要加上 mm 的模型矩阵,在当前矩阵基础上进行变化,实现代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| function render() { ang += 0.005; if (ang > Math.PI / 4) { ang = 0; formatVertices(); orignInd = (orignInd + 2) % 8; setOrign(orignInd); } const s = (Math.sin(ang * 8 + Math.PI / 2) + 1) / 2; mm.copy( mb .clone() .multiply(new Matrix4().makeRotationZ(ang)) .scale(new Vector3(s, s, 1)) .multiply(mi) ); mat.setData("u_ModelMatrix", { value: mm.elements, }); scence.draw(); requestAnimationFrame(render); }
function formatVertices() { for (let i = 0; i < vertices.length; i += 2) { const p = new Vector3(vertices[i], vertices[i + 1], 0).applyMatrix4(mm); vertices[i] = p.x; vertices[i + 1] = p.y; } geo.setData("a_Position", { array: vertices, }); }
|
用鼠标变换图片
功能描述
变换节点
变换节点就是图片的四个角点+描边。
变换节点没啥实际功能,就是整个视觉样式,让用户知道此图可变换。
位移
当鼠标在图片中的时候,按住鼠标可以拖拽图片。
缩放
当鼠标到图片节点的距离小于 15 像素时,开启鼠标对图片的缩放功能。
默认:居中+等比缩放
alt 键:以鼠标对面的点为基点进行缩放
shift 键:自由缩放
旋转
当鼠标到图片节点的距离小于 40 像素时,开启鼠标对图片的旋转功能。
默认:居中+按照特定弧度(15°)旋转
alt 键:以鼠标对面的点为基点进行旋转
shift 键:自由旋转
前期准备-图片+外框
准备俩套着色器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| <script id="solidVertexShader" type="x-shader/x-vertex"> attribute vec4 a_Position; uniform mat4 u_PvMatrix; uniform mat4 u_ModelMatrix; void main(){ gl_Position = u_PvMatrix*u_ModelMatrix*a_Position; gl_PointSize=10.0; } </script> <script id="solidFragmentShader" type="x-shader/x-fragment"> precision mediump float; void main(){ gl_FragColor=vec4(1.0,1.0,1.0,1.0); } </script>
<script id="textureVertexShader" type="x-shader/x-vertex"> attribute vec4 a_Position; attribute vec2 a_Pin; uniform mat4 u_PvMatrix; uniform mat4 u_ModelMatrix; varying vec2 v_Pin; void main(){ gl_Position = u_PvMatrix*u_ModelMatrix*a_Position; v_Pin=a_Pin; } </script> <script id="textureFragmentShader" type="x-shader/x-fragment"> precision mediump float; uniform sampler2D u_Sampler; varying vec2 v_Pin; void main(){ gl_FragColor=texture2D(u_Sampler,v_Pin); } </script>
|
通过图片尺寸,获取图片和图片外框的顶点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| const [w, h] = [0.6, 0.6]; const [hw, hh] = [w / 2, h / 2];
const vertices = new Float32Array([-hw, hh, -hw, -hh, hw, hh, hw, -hh]);
let verticesOut = getVerticesOut();
function getVerticesOut() { return new Float32Array([ vertices[0], vertices[1], vertices[2], vertices[3], vertices[6], vertices[7], vertices[4], vertices[5], ]); }
|
图片是用 TRIANGLE_STRIP 三角带画的;
图片外框是用 LINE_LOOP 闭合线条和点 POINTS 画的。
图片和图片外框的顶点排序是不同的。
绘制图片外框和图片
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80
| const scence = new Scene({ gl });
let matOut = null; let geoOut = null; { const vs = document.getElementById("solidVertexShader").innerText; const fs = document.getElementById("solidFragmentShader").innerText; const program = createProgram(gl, vs, fs); matOut = new Mat({ program, data: { u_PvMatrix: { value: pvMatrix.elements, type: "uniformMatrix4fv", }, u_ModelMatrix: { value: new Matrix4().elements, type: "uniformMatrix4fv", }, }, mode: ["LINE_LOOP", "POINTS"], }); geoOut = new Geo({ data: { a_Position: { array: verticesOut, size: 2, }, }, }); const obj = new Obj3D({ geo: geoOut, mat: matOut }); scence.add(obj); }
const image = new Image(); image.src = "./images/erha.jpg"; let mat = null; let geo = null; image.onload = function () { const vs = document.getElementById("textureVertexShader").innerText; const fs = document.getElementById("textureFragmentShader").innerText; const program = createProgram(gl, vs, fs); mat = new Mat({ program, data: { u_PvMatrix: { value: pvMatrix.elements, type: "uniformMatrix4fv", }, u_ModelMatrix: { value: new Matrix4().elements, type: "uniformMatrix4fv", }, }, maps: { u_Sampler: { image, }, }, mode: "TRIANGLE_STRIP", }); geo = new Geo({ data: { a_Position: { array: vertices, size: 2, }, a_Pin: { array: new Float32Array([0, 1, 0, 0, 1, 1, 1, 0]), size: 2, }, }, }); const obj = new Obj3D({ geo, mat }); scence.unshift(obj); scence.draw(); };
|
scence.unshift(obj) 以前置的方式添加三维对象。
1 2 3 4 5 6 7 8
| unshift(...objs) { const { children, gl } = this objs.forEach(obj => { children.unshift(obj) obj.parent=this obj.init(gl) }) }
|
之所以这么做是为了将图片放在图框下面,也就是先渲染图片,再渲染图框。
实现拖拽
声明一些必备变量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| let state = "none";
let change = false;
const cursorMap = new Map([ ["drag", "move"], ["rotate", "alias"], ["scale", "pointer"], ["none", "default"], ]);
const dragStart = new Vector2(); const dragEnd = new Vector2();
let offset = new Vector2();
const mi = new Matrix4();
const mb = new Matrix4();
const mm = new Matrix4();
|
监听 canvas 的鼠标按下事件
1 2 3 4 5 6 7 8 9
| canvas.addEventListener("mousedown", (event) => { const mp = worldPos(event); if (isInImg(mp)) { state = "drag"; dragStart.copy(mp); } });
|
鼠标按下时,主要做了 2 件事情:
- 鼠标在 canvas 中的位置转世界位,以便于判断鼠标和图片顶点的关系。
1 2 3 4 5 6 7 8 9
| const mp = worldPos(event); function worldPos({ clientX, clientY }) { const [hw, hh] = [canvas.width / 2, canvas.height / 2]; const cp = new Vector3((clientX - hw) / hw, -(clientY - hh) / hh, 0); const p = cp.applyMatrix4(pvMatrix.clone().invert()); return new Vector2(p.x, p.y); }
|
回顾一下我们之前所学的矩阵知识:
裁剪空间位 = 投影视图矩阵 * 模型矩阵 * 初始顶点位
由上式可得:
初始顶点位=(投影视图矩阵*模型矩阵)的逆矩阵*裁剪空间位
因为图片顶点就是基于世界坐标系定位的,世界坐标系是单位矩阵,任何矩阵与单位矩阵相乘都不会发生改变,所以模型矩阵可以忽略。
最终,鼠标的世界点位就是这样的:
1
| const p = cp.applyMatrix4(pvMatrix.clone().invert());
|
- 获取变换状态,如果鼠标在图像里,那么变换状态就是’drag’。
1 2 3 4
| if (isInImg(mp)) { state = "drag"; dragStart.copy(mp); }
|
isInImg(dragStart) 是判断鼠标是否在图像中的方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| function isInImg(p) { return ( inTriangle(p, [ { x: vertices[0], y: vertices[1] }, { x: vertices[2], y: vertices[3] }, { x: vertices[4], y: vertices[5] }, ]) || inTriangle(p, [ { x: vertices[4], y: vertices[5] }, { x: vertices[2], y: vertices[3] }, { x: vertices[6], y: vertices[7] }, ]) ); }
|
inTriangle():判断顶点是否在三角形中,返回布尔值。
因为图片由两个三角形组成,所以我做了两次判断。
只要点位在任意三角形中,就说明点位在图片中。
至于判断点位是否在三角形中的方法,我们之前说过。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| function inTriangle(p0, triangle) { let bool = true; for (let i = 0; i < 3; i++) { const j = (i + 1) % 3; const [p1, p2] = [triangle[i], triangle[j]]; if (cross([p0, p1, p2]) < 0) { bool = false; break; } } return bool; } function cross([p0, p1, p2]) { const [ax, ay, bx, by] = [p1.x - p0.x, p1.y - p0.y, p2.x - p0.x, p2.y - p0.y]; return ax * by - bx * ay; }
|
监听 canvas 的鼠标移动事件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| canvas.addEventListener("mousemove", (event) => { const mp = worldPos(event); if (state === "none") { let cursorState = "none"; if (isInImg(mp)) { cursorState = "drag"; } canvas.style.cursor = cursorMap.get(cursorState); return; } dragEnd.copy(mp); change = true; switch (state) { case "drag": drag(); break; } render(); });
|
鼠标移动时,主要做了 4 件事情:
获取鼠标世界位,以便于判断鼠标和图片顶点的关系。
设置鼠标样式,此操作是在图片不处于任何变换状态时执行的。
变换图片
drag():通过拖拽结束位减拖拽起始位得到图片的偏移量。
1 2 3
| function drag() { offset.copy(dragEnd.clone().sub(dragStart)); }
|
渲染 render()
1 2 3 4 5 6 7 8 9 10
| function render() { const { elements } = mm.copy(getModelMatrix()); mat.setData("u_ModelMatrix", { value: elements, }); matOut.setData("u_ModelMatrix", { value: elements, }); scence.draw(); }
|
计算模型矩阵的方法 getModelMatrix()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| function getModelMatrix() { const { x: px, y: py } = offset; const moveMatrix = new Matrix4().set( 1, 0, 0, px, 0, 1, 0, py, 0, 0, 1, 0, 0, 0, 0, 1 ); return mb.clone().multiply(moveMatrix).multiply(mi); }
|
监听 canvas 的鼠标抬起事件
1 2 3 4 5 6 7 8 9 10 11
| canvas.addEventListener("mouseup", () => { if (state !== "none") { state = "none"; if (change) { change = false; offset.set(0, 0); canvas.style.cursor = "default"; formatVertices(); } } });
|
鼠标抬起时,主要做了以下事情:
- 清理 state 状态
- 清空图片的变换数据
- 恢复鼠标样式
- 格式化顶点数据,并更新几何体的顶点集合
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| function formatVertices() { for (let i = 0; i < vertices.length; i += 2) { const p = new Vector3(vertices[i], vertices[i + 1], 0).applyMatrix4(mm); vertices[i] = p.x; vertices[i + 1] = p.y; } verticesOut = getVerticesOut(); geo.setData("a_Position", { array: vertices, }); geoOut.setData("a_Position", { array: verticesOut, }); }
|
实现旋转
声明必备变量
1 2 3 4 5 6 7 8 9
| let orign = new Vector2();
const start2Orign = new Vector2(); const end2Orign = new Vector2();
let startAng = 0;
let angle = 0;
|
鼠标按下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| canvas.addEventListener("mousedown", (event) => { const mp = worldPos(event); if (isInImg(mp)) { state = "drag"; dragStart.copy(mp); } else { const node = selectNode(mp); if (node) { dragStart.copy(mp); state = node.state; setOrign(); start2Orign.subVectors(dragStart, orign); startAng = Math.atan2(start2Orign.y, start2Orign.x); } } });
|
鼠标按下时,只要不是在图片里,就会做以下事情:
选择节点 selectNode(dragStart),返回节点索引和变换状态
1 2 3 4 5 6 7 8 9 10 11 12
| function selectNode(m) { let node = null; for (let i = 0; i < vertices.length; i += 2) { const v = new Vector2(vertices[i], vertices[i + 1]); const len = (m.clone().sub(v).length() * canvas.height) / 2; if (len < 40) { node = { index: i, state: "rotate" }; break; } } return node; }
|
若选中节点,则更新变换状态、基点、拖拽起点相对于基点的位置、起始弧度。
详细看一下设置基点的方法:
1 2 3 4 5 6 7 8 9 10 11
| function setOrign() { const { x, y } = getCenter(); orign.set(x, y); mi.makeTranslation(-x, -y, 0); mb.makeTranslation(x, y, 0); } function getCenter() { let [x1, y1] = [vertices[0], vertices[1]]; let [x2, y2] = [vertices[6], vertices[7]]; return new Vector2(x1 + (x2 - x1) / 2, y1 + (y2 - y1) / 2); }
|
鼠标移动
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| canvas.addEventListener('mousemove', event => { const mp = worldPos(event) if (state === 'none') { let cursorState = 'none' if (isInImg(mp)) { cursorState = 'drag' } else { const node = selectNode(mp) cursorState = node ? node.state : 'none' } canvas.style.cursor = cursorMap.get(cursorState) return } dragEnd.copy(mp) end2Orign.subVectors(mp, orign) change = true switch (state) { case 'drag': drag() break case 'rotate': rotate() break } render() })
|
rotate() 旋转方法
1 2 3 4
| function rotate() { const endAng = Math.atan2(end2Orign.y, end2Orign.x) angle = endAng - startAng }
|
render() 方法无需改变,只是其中获取模型矩阵getModelMatrix() 的方法里,需要把旋转量算进去。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| function getModelMatrix() { const { x: px, y: py } = offset const moveMatrix = new Matrix4().set( 1, 0, 0, px, 0, 1, 0, py, 0, 0, 1, 0, 0, 0, 0, 1, ) const [s, c] = [Math.sin(angle), Math.cos(angle),] const rotateMatrix = new Matrix4().set( c, -s, 0, 0, s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, ) return mb.clone() .multiply(moveMatrix) .multiply(rotateMatrix) .multiply(mi) }
|
鼠标抬起
1 2 3 4 5 6 7 8 9 10 11 12
| canvas.addEventListener('mouseup', () => { if (state !== 'none') { state = 'none' if (change) { change = false offset.set(0, 0) angle = 0 canvas.style.cursor = 'default' formatVertices() } } })
|
图片旋转的整体逻辑就是这样,当前图片默认是基于图片中心自由旋转。
我们可以再为其做一下优化:按住alt键,基于鼠标对面的节点变换。
alt 键改变基点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
| let keys = new Set()
let nodeInd = 0
const opposite = new Map([[0, 6], [2, 4], [6, 0], [4, 2]])
canvas.addEventListener('mousedown', event => { const mp = worldPos(event) if (isInImg(mp)) { state = 'drag' dragStart.copy(mp) } else { const node = selectNode(mp) if (node) { dragStart.copy(mp) state = node.state nodeInd = node.index setOrign() start2Orign.subVectors(dragStart, orign) startAng = Math.atan2(start2Orign.y, start2Orign.x) } } })
window.addEventListener('keydown', ({ keyCode }) => { keys.add(keyCode) setOrign() }) window.addEventListener('keyup', ({ keyCode }) => { keys.delete(keyCode) setOrign() })
function setOrign() { const { x, y } = keys.has(18) ? getOppo() : getCenter() orign.set(x, y) mi.makeTranslation(-x, -y, 0) mb.makeTranslation(x, y, 0) }
function getOppo() { const i2 = opposite.get(nodeInd) return new Vector2(vertices[i2], vertices[i2 + 1]) }
function getCenter() { let [x1, y1] = [vertices[0], vertices[1]] let [x2, y2] = [vertices[6], vertices[7]] return new Vector2( x1 + (x2 - x1) / 2, y1 + (y2 - y1) / 2 ) }
|
默认按照特定弧度旋转。按住shift键时,再自由旋转。
1 2 3 4 5 6 7 8 9
| let angSpace = Math.PI / 12 function rotate() { const endAng = Math.atan2(end2Orign.y, end2Orign.x) angle = endAng - startAng if (!keys.has(16)) { angle = Math.round(angle / angSpace) * angSpace } }
|
实现缩放
建立必备变量
1 2
| let zoom = new Vector2(1, 1)
|
鼠标移动
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| canvas.addEventListener('mousemove', event => { …… switch (state) { …… case 'scale': scale() break } render() })
function scale() { const sx = end2Orign.x / start2Orign.x const sy = end2Orign.y / start2Orign.y if (keys.has(16)) { zoom.set(sx, sy) } else { const ratio = end2Orign.length() / start2Orign.length() zoom.set( ratio * sx / Math.abs(sx), ratio * sy / Math.abs(sy), ) } }
|
选择节点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| function selectNode(m) { let node = null for (let i = 0; i < vertices.length; i += 2) { const v = new Vector2(vertices[i], vertices[i + 1]) const len = m.clone().sub(v).length() * canvas.height / 2 if (len < 15) { node = { index: i, state: 'scale' } break } else if (len < 40) { node = { index: i, state: 'rotate' } break } } return node }
|
鼠标抬起
1 2 3 4 5
| canvas.addEventListener('mouseup', event => { …… zoom = new Vector2(1, 1) …… })
|
模型矩阵
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| function getModelMatrix() { const { x: px, y: py } = offset const moveMatrix = new Matrix4().set( 1, 0, 0, px, 0, 1, 0, py, 0, 0, 1, 0, 0, 0, 0, 1, ) const [s, c] = [Math.sin(angle), Math.cos(angle),] const rotateMatrix = new Matrix4().set( c, -s, 0, 0, s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, ) const { x: sx, y: sy } = zoom const scaleMatrix = new Matrix4().set( sx, 0, 0, 0, 0, sy, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, ) return mb.clone() .multiply(moveMatrix) .multiply(rotateMatrix) .multiply(scaleMatrix) .multiply(mi) }
|
其实我们也可以使用Matrix4 对象内置的方法进行变换。
1 2 3 4 5 6 7 8 9 10 11 12 13
| function getModelMatrix() { return mb.clone() .multiply( new Matrix4().makeTranslation( position.x, position.y, 0 ) ) .multiply( new Matrix4().makeRotationZ(angle) ) .scale(new Vector3(zoom.x, zoom.y, 1)) .multiply(mi) }
|
到目前为止,缩放的基本流程就搞定了。
然而,镜像缩放时,还会带来一个坑,此坑会影响图片的选择。
我们之前是通过下面的方法判断点位是否在三角形中的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| function inTriangle(p0, triangle) { let bool = true; for (let i = 0; i < 3; i++) { const j = (i + 1) % 3; const [p1, p2] = [triangle[i], triangle[j]]; if (cross([p0, p1, p2]) < 0) { bool = false; break } } return bool; } function cross([p0, p1, p2]) { const [ax, ay, bx, by] = [ p1.x - p0.x, p1.y - p0.y, p2.x - p0.x, p2.y - p0.y, ]; return ax * by - bx * ay; }
|
上面的 cross([p0, p1, p2]) < 0 是针对逆时针绘图的情况来判断的。
若是顺时针绘图,点位在三角形中需要满足的条件就应该是cross([p0, p1, p2]) > 0
因此我们需要判断一下,这个图片是逆时针画的,还是顺时针画的。
之前我们说过一个原理:叉乘是有方向的。
通过上面的原理可以知道:在二维多边形中,通过叉乘求出的多边形的面积是有正负之分的。
通过这个多边形的面积的正负,便可以判断图片是逆时针画的,还是顺时针画的。
接下来咱们通过代码说一下其具体实现过程。
先封装一个按逆时针获取图片中两个三角形的方法,以便复用
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| function getTriangles() { return [ [ { x: vertices[0], y: vertices[1] }, { x: vertices[2], y: vertices[3] }, { x: vertices[4], y: vertices[5] }, ], [ { x: vertices[4], y: vertices[5] }, { x: vertices[2], y: vertices[3] }, { x: vertices[6], y: vertices[7] }, ] ] }
|
再封装一个获取面积的方法
1 2 3 4
| function getArea() { const [t1, t2] = getTriangles() return cross(t1) + cross(t2) }
|
上面所求的面积实际上是图片面积的两倍,不过这都无所谓,我只需要面积的正负。
声明面积变量
在判断点位是否在三角形中的时候,乘上面积
1 2 3 4 5 6 7 8 9 10 11 12
| function inTriangle(p0, triangle) { let bool = true; for (let i = 0; i < 3; i++) { const j = (i + 1) % 3; const [p1, p2] = [triangle[i], triangle[j]]; if (area * cross([p0, p1, p2]) < 0) { bool = false; break } } return bool; }
|
面积需要随顶点数据的格式化同步更新
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| function formatVertices() { for (let i = 0; i < vertices.length; i += 2) { const p = new Vector3(vertices[i], vertices[i + 1], 0) .applyMatrix4(mm) vertices[i] = p.x vertices[i + 1] = p.y } area = getArea() geo.setData('a_Position', { array: vertices }) geoOut.setData('a_Position', { array: getVerticesOut() }) }
|
保留初始点位
我之前在变换图片时,其实还有一处瑕疵,那就是无法保留初始点位。
初始点位与浮点数进行多次运算,容易引起数据失真。
当然,若变换次数不多,肉眼是很难发现失真的。
对于上面的问题,我们是可以通过保留初始点位来解决的。
接下来咱们说一下其实现思路。
实现思路
已知:图片img
求:基于图片的左上角点P变换图片,且保留图片初始点位的方法
解:
搭建矩阵
把图片装画框里,画框的本地矩阵是mh
把画框装盒子里,盒子的本地矩阵是mi
把盒子装抽屉里,抽屉的本地矩阵是mb
设置变换基点:
变换抽屉的本地矩阵mb
在第二次变换时,将所有变换数据合入画框mh中
渲染时的模型矩阵:
代码实现
在之前代码的基础上进行修改。
声明必备数据
1 2 3 4 5 6 7 8 9 10 11
| const verticesBasic = new Float32Array([ -hw, hh, -hw, -hh, hw, hh, hw, -hh, ])
const vertices = Float32Array.from(verticesBasic)
const mh = new Matrix4()
|
将图片和图片外框的顶点数据写死,之后在变换的时候无需修改初始点位,直接修改模型矩阵即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| geoOut = new Geo({ data: { a_Position: { array: new Float32Array([ verticesBasic[0], verticesBasic[1], verticesBasic[2], verticesBasic[3], verticesBasic[6], verticesBasic[7], verticesBasic[4], verticesBasic[5], ]), size: 2 }, } }) …… geo = new Geo({ data: { a_Position: { array: verticesBasic, size: 2 }, a_Pin: { array: new Float32Array([0, 1,0, 0,1, 1,1, 0,]), size: 2 } } })
|
在格式化顶点数据的方法里,已经无需更新几何体的顶点数据,只需要获取图片顶点的世界位即可。
1 2 3 4 5 6 7 8 9 10
| function formatVertices() { mh.copy(mm.clone().multiply(mh)) for (let i = 0; i < verticesBasic.length; i += 2) { const p = new Vector3(verticesBasic[i], verticesBasic[i + 1], 0) .applyMatrix4(mh) vertices[i] = p.x vertices[i + 1] = p.y } area = getArea() }
|
在渲染方法里计算模型矩阵的时候,再乘上画框的本地矩阵
1 2 3 4 5 6 7 8 9 10 11
| function render() { mm.copy(getModelMatrix()) const { elements } = mm.clone().multiply(mh) mat.setData('u_ModelMatrix', { value: elements }) matOut.setData('u_ModelMatrix', { value: elements }) scence.draw() }
|
关于鼠标对图像的变换我们就说到这。
虽然我们在这里是拿二维图片举的例子,然而其实现思想都是按三维走的,就比如模型矩阵的运算。
通过上面的例子,大家可以知道修改模型的两种方式:
- 直接修改构成模型的初始点位
- 修改模型矩阵
当然,上面的操作最终都是在移动模型的初始点位,只是其应用场景会有所不同。
前者适合对模型的局部顶点进行修改,就比如对图片的某一个顶点进行拉扯。
后者适合对模型的所有顶点统一变换。
大家理解了这两种方式的差异,也就知道了在three.js 里什么时候要修改BufferGeometry对象里的顶点,什么时候修改Object3D对象的本地矩阵。
结语
本篇文章就到这里了,更多内容敬请期待,债见~