【可视化学习】96-从入门到放弃WebGL(二十三)
发表于:2024-12-09 |

前言

本篇文章继续跟着李伟老师学习 webgl,本篇文章的内容主要为选择三维对象与它的衍生内容

选择三维对象的基本原理

首先我们知道几个概念:

  • 从相机视点位射向鼠标点可以做一条射线。
  • 构成三角形的三个顶点可以确定一个平面。
  • 在由三角网构成的三维模型中,选中一个三角形,就是选中了三维模型。

因此,我们要判断鼠标是否选中了模型,就得按以下几步走:

  • 获取从相机视点位射向鼠标点的射线 ray。
  • 获取射线 ray 与三角形所在的平面的交点 M。
  • 判断交点 M 是否在三角形中。

平面与射线的交点

数学推理

交点

已知:

  • 平面 α
  • 点 A(ax,ay,az)为平面 α 中任一点
  • 向量 n(nx,ny,nz)为平面 α 的法向量
  • 射线 l
  • 射线 l 的原点为点 E(ex,ey,ez)
  • 射线 l 的方向为 v(vx,vy,vz)

求:射线 l 与平面 α 的交点 M

因为:

α⊥n⇒α 中的所有直线 ⊥n(因为 n 是法线,所以平面内所有直线都垂直 n)

(M-A)∈α ,MA 向量在平面 α 内部

所以,由垂直向量的关系得:

1
(M-A)·n=0

由向量的数乘得:

1
EM=λ*v

所以:

1
M-E=λ*v

因为:

向量的加减运算符合交换律

所以:

1
M=λv+E

接下来求出 λ,便可得 M 值。

对比上面求出的两个等式:

1
2
(M-A)·n=0  ①
M=λv+E ②

M 是我们最终要求的因变量
λ 是我们下一步要求的未知数
其余的射线方向 v、平面法线 n、射线原点 E,都是已知常量
所以,上面的两个等式就是一个二元一次方程式组,M、λ 就其中的二元。

用消元法把等式 ② 代入等式 ① 中 消掉 M,得到 λ:

1
2
3
(λv+E-A)·n=0
λv·n=(A-E)·n
λ=(A-E)·n/v·n

将 λ 代入等式 ② 中,得到 M:

1
M=((A-E)·n/v·n)*v+E

上面的公式便是射线与平面的交点公式了。

数学原理已通,接下来咱们用代码实现一下。

代码举例说明

这里拿一些特殊数据举例
效果图

已知:

1
2
3
4
5
6
7
8
// 三角形ABC
const A = new Vector3(-6, 0, -4);
const B = new Vector3(0, 0, 4);
const C = new Vector3(6, 0, -4);
// 视点
const E = new Vector3(0, 12, 16);
// 鼠标点
const P = new Vector3(0, 3, 4);

求:以相机视点为原点且指向鼠标位置的射线与三角形 ABC 所在的平面的交点 M

先求一下三角形 ABC 的法线
1
2
3
const AB = new Vector3().subVectors(B, A);
const BC = new Vector3().subVectors(C, B);
const n = new Vector3().crossVectors(AB, BC);
通过视点和鼠标点计算射线方向
1
const v = new Vector3().subVectors(P, E).normalize();
射线与平面的交点公式求交点
1
2
3
4
5
// M=((A-E)·n/v·n)*v+E
const M = v
.clone()
.multiplyScalar(A.clone().sub(E).dot(n) / v.clone().dot(n))
.add(E);
最后输出一下 M:
1
2
console.log("M", M);
// Vector3 {x: 0, y: -1.7763568394002505e-15, z: -3.552713678800501e-15}

上面的 y、z 分量受到了浮点数的误差影响,不好分辨其具体大小。

我们可以可用取其小数点后 5 位看看:

1
2
console.log(M.x.toFixed(5), M.y.toFixed(5), M.z.toFixed(5));
// 0.00000 -0.00000 -0.00000

由上可知交点 M 就是零点。也就是我们根据勾股定理能够推算出来的相交点。

three.js 测试

1.用不共线的三点建平面
1
const plane = new Plane().setFromCoplanarPoints(A, B, C);
2.计算射线方向
1
const v = new Vector3().subVectors(P, E).normalize();
3.用基点和射线方向建立射线
1
const ray = new Ray(E, v);
4.用射线对象的 intersectPlane() 方法求射线与平面的交点
1
2
const M = new Vector3();
ray.intersectPlane(plane, M);
5.输出 M
1
2
console.log("M", M);
//M Vector3 {x: 0, y: 0, z: 0}

其交点是零点,与之前算过的一样。

在空间中判断点是否在三角形中

之前咱们在二维平面中用叉乘判断过点是否在三角形中,在三维空间中也是要用叉乘来判断的。
只不过,三维向量的叉乘结果还是向量,无法像二维向量的叉乘那样,用一个实数结果判断其大于零还是小于零。

回顾二维平面点是否在三角形中

我们来稍微回顾一下如何在二维平面判断点是否在三角形中

如果点 M 在在三角形内部,那么从三角形的任一点出发,逆时针走过所有边的过程中,点 M 始终在边的左侧。M 始终在 AB,BC, CA 的左侧。如果点 M 在三角形的外部,则不满足这样的关系。如果是顺时针的话,那就是右侧。

效果图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 三角形
const triangle = [A, B, C];
// 是否在三角形中
function inTriangle(M, triangle) {
let bool = true;
for (let i = 0; i < 3; i++) {
const j = (i + 1) % 3;
const [a, b] = [triangle[i], triangle[j]];
const ma = a.clone().sub(M);
const ab = b.clone().sub(a);
const d = ma.clone().cross(ab);
const len = d.dot(n);
if (len < 0) {
bool = false;
break;
}
}
return bool;
}
const bool = inTriangle(M, triangle);
console.log(bool);

详细解释一下上面的 inTriangle()方法

1.for 循环遍历三角形的三条边

1
2
const j = (i + 1) % 3;
const [a, b] = [triangle[i], triangle[j]];

2.分别将点和三角形的三条边做叉乘运算

1
2
3
4
const ma = a.clone().sub(M);
const ab = b.clone().sub(a);
// ma和ab的垂直向量
const d = ma.clone().cross(ab);

上面的叉乘结果可以理解为一条垂直向量,此垂直向量垂直三角的三条边,且垂直于 M 点到三角形三条边的连线。

我们重点要知道,这条垂直向量是有方向的,它可能与三角形的法线同向,也可能与三角形的法线异向。

当 M 点在三角形中是,M 点连接三角形三边得到的垂直向量会有一个特点,要么都在三角形法线的正方向上,要么都与三角形法线的负方向上。

至于什么时候在正方向上,什么时候在负方向上,这跟鼠标点和三角形三条边的连接顺序,以及三角形的绘图顺序有关。

3.判断垂直向量在三角形的哪一侧

1
2
3
4
5
const len = d.dot(n)
if (len < 0) {
bool = false
break
}
1
2
a·b=|a|*|b|*cos<a,b>
cos<a,b>=a·b/|a|*|b|

以向量 a 为坐标基线,则:

1
2
当cos<a,b>大于0时,<a,b>∈(-90°,90°),a在向量b 的正方向上
当cos<a,b>小于0时,<a,b>∈(90°,180°)∪(-90°,-180°),a在向量b 的负方向上

因为,我们之前是按照三角形的绘图顺序让鼠标点与三角形的三条边进行的连接,然后分别求出了三条垂直向量。

所以,当三角形是逆时针画的,且鼠标点在三角形中时,三条垂直向量都在三角形法线的正方向上。

若有一条垂直向量不在三角形法线的正方向上,那就说明 M 点不在三角形中。

three.js 测试

射线对象直接有一个 intersectTriangle() 方法,用于判断射线是否穿过了一个三角形。

1
2
3
4
5
6
7
8
9
10
11
12
{
const plane = new Plane().setFromCoplanarPoints(A, B, C)
const dir = new Vector3().subVectors(P, E).normalize()
const ray = new Ray(E, dir)
const M = new Vector3()
ray.intersectTriangle(
A, B, C,
true,
M
)
}
intersectTriangle ( a : Vector3, b : Vector3, c : Vector3, backfaceCulling : Boolean, target : Vector3 )

a, b, c - 组成三角形的三个 Vector3。
backfaceCulling - 是否使用背面剔除。
target — 结果将会被复制到这一 Vector3 中。
注:使用 intersectTriangle()方法时,若射线没有穿过三角形,会返回零点。这个有点坑,万一射线和三角形的交点就是零点,那就没法判断射线有没有穿过三角形了。

接下来,咱们整体的举一个鼠标选择立方体的例子。

鼠标选择立方体

在这个案例中所涉及的知识点我们以前都说过,所以咱们直接上代码。

之前咱们用顶点索引画过一个彩色的立方体,咱们就在这基础上做选择了。

声明必备变量

先将投影视图矩阵、顶点集合和顶点索引提取出来,以备后用。

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
// 投影视图矩阵
const pvMatrix = new Matrix4();

// 旋转状态
let selected = false;

// 顶点集合
const vertices = new Float32Array([
1, 1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1, 1, -1, -1, 1, 1, -1, -1, 1, -1, -1,
-1, -1,
]);

// 顶点索引
const indexes = new Uint8Array([
0,
1,
2,
0,
2,
3, // front
0,
3,
4,
0,
4,
5, // right
0,
5,
6,
0,
6,
1, // up
1,
6,
7,
1,
7,
2, // left
7,
4,
3,
7,
3,
2, // down
4,
7,
6,
4,
6,
5, // back
]);

鼠标移动事件

在鼠标移动的时候,更新投影视图矩阵,并选择对象。

1
2
3
4
5
canvas.addEventListener("pointermove", (event) => {
orbit.pointermove(event);
pvMatrix.copy(orbit.getPvMatrix());
selectObj(event);
});

选择对象的方法

我们重点看一下选择对象的方法 selectObj(event)

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 selectObj(event) {
// 鼠标世界位
const mp = worldPos(event);
// 射线
const ray = new Ray(camera.position).lookAt(mp);
// 选择状态
selected = false;
// 遍历三维对象里的所有三角形
for (let i = 0; i < indexes.length; i += 3) {
//三角形
const triangle = [
getVector3(indexes[i]),
getVector3(indexes[i + 1]),
getVector3(indexes[i + 2]),
];
//射线与三角形的交点。若有交点,返回交点;若无交点返回null
const interPos = intersectTriangle(ray, triangle);
//只要一个三角形被选中,则三维对象被选中
if (interPos) {
selected = true;
break;
}
}
}
1.worldPos()获取鼠标世界位,这个方法咱们之前在基点变换中写过
1
2
3
4
5
6
7
function worldPos({ clientX, clientY }) {
const [hw, hh] = [canvas.width / 2, canvas.height / 2];
// 裁剪空间位
const cp = new Vector3((clientX - hw) / hw, -(clientY - hh) / hh, 0);
// 鼠标在世界坐标系中的位置
return cp.applyMatrix4(pvMatrix.clone().invert());
}
2.建立射线的 Ray 对象是 three.js 里的,其 lookAt()方法可以让射线射向某个点位,从而改变射线的方向。
1
const ray = new Ray(camera.position).lookAt(mp);
3.变量立方体中的所有三角形的时候,是先以三个点位单位遍历的顶点索引,然后再基于顶点索引从顶点集合中寻找相应的顶点数据。
1
2
3
4
5
6
7
8
9
for (let i = 0; i < indexes.length; i += 3) {
//三角形
const triangle = [
getVector3(indexes[i]),
getVector3(indexes[i + 1]),
getVector3(indexes[i + 2]),
]
……
}
getVector3() 基于顶点索引从顶点集合中寻找相应的顶点数据
1
2
3
4
function getVector3(j) {
const i = j * 3;
return new Vector3(vertices[i], vertices[i + 1], vertices[i + 2]);
}
4.获取射线与三角形的交点。若有交点,返回交点;若无交点返回 null
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
const interPos = intersectTriangle(ray, triangle)
// intersectTriangle() 方法的实现原理,咱们之前已经写过
function intersectTriangle(ray, triangle) {
const { origin: E, direction: v } = ray
const [A, B, C] = triangle
// 三角形的法线
const AB = new Vector3().subVectors(B, A)
const BC = new Vector3().subVectors(C, B)
const n = new Vector3().crossVectors(AB, BC)

// 射线和平面的交点
const M = v.clone().multiplyScalar(
A.clone().sub(E).dot(n) / v.clone().dot(n)
).add(E)

// 判断点M是否在三角形中
let bool = true
for (let i = 0; i < 3; i++) {
const j = (i + 1) % 3
const [a, b] = [triangle[i], triangle[j]]
const ma = a.clone().sub(M)
const ab = b.clone().sub(a)
const d = ma.clone().cross(ab)
const len = d.dot(n)
if (len < 0) {
bool = false
break
}
}
if (bool) {
return M
} else {
return null
}
}
5.更新选择状态,只要一个三角形被选中,则三维对象被选中
1
2
3
4
if (interPos) {
selected = true
break
}

鼠标选择三维对象的基本原理就是这样。

接下来,咱们完成一个立方体,当鼠标放上去的时候,立方体既能转动,还能变色。

6.声明必备变量
1
2
3
4
5
6
// 模型矩阵
const modelMatrix = new Matrix4();
// 插值-用于修改片元颜色
let time = 0;
// 弧度-用于旋转模型矩阵
let ang = 0;
7.在获取顶点的时候,用模型矩阵将其转换成世界位
1
2
3
4
5
6
7
8
function getVector3(j) {
const i = j * 3;
return new Vector3(
vertices[i],
vertices[i + 1],
vertices[i + 2]
).applyMatrix4(modelMatrix);
}

我们之前强调过,判断图形关系的时候,一定要统一坐标系。

之前我们获取的鼠标位是世界位,所以模型顶点也一定要是世界位。

8.在连续渲染的时候,若鼠标选中了对象,就让立方体既能旋转,也能变色。
1
2
3
4
5
6
7
8
9
10
11
12
13
!(function ani() {
scene.setUniform("u_PvMatrix", pvMatrix.elements);
if (selected) {
time += 20;
ang += 0.05;
mat.setData("u_Time", { value: time });
mat.setData("u_ModelMatrix", {
value: new Matrix4().makeRotationY(ang).elements,
});
}
scene.draw();
requestAnimationFrame(ani);
})();

结语

本篇文章就到这里,更多内容敬请期待,债见~

上一篇:
【回顾学习】::before与::after
下一篇:
微信小程序版本更新提示