学习ThreeJS的捷径
本段内容会写在0篇以外所有的,本人所编写的Threejs教程中
对,学习ThreeJS有捷径
当你有哪个函数不懂的时候,第一时间去翻一翻文档
当你有哪个效果不会做的时候,第一时间去翻一翻所有的案例,也许就能找到你想要的效果
最重要的一点,就是,绝对不要怕问问题,越怕找找别人问题,你的问题就会被拖的越久
如果你确定要走WebGL/ThreeJS的开发者路线的话,以下行为可以让你更快的学习ThreeJS
- 没事就把所有的文档翻一遍,哪怕看不懂,也要留个印象,至少要知道Threejs有什么
- 没事多看看案例效果,当你记忆的案例效果足够多时,下次再遇到相似问题时,你就有可能第一时间来找对应的案例,能更快解决你自己的问题
- 上述案例不只是官网的案例,郭隆邦技术博客,跃焱邵隼,暮志未晚等站点均有不少优质案例,记得一并收藏
http://www.yanhuangxueyuan.com/ 郭隆邦技术博客
https://www.wellyyss.cn/ 跃焱邵隼
http://www.wjceo.com/ 暮志未晚
这三个站点是我最常逛的站点,推荐各位有事没事逛一下,看看他们的案例和写法思路,绝对没坏处
射线介绍
如果你玩过激光笔的话,对射线的理解就再容易不过了
射线是由一个点,一个方向构成的线,由顶点向当前方向无限延伸的一条线,就叫射线
但是,我们一般不会直接用到THREE.Ray,因为射线平时最主要的用途,就是拾取物体,所以我们直接用Raycaster即可,对数学概念的射线有兴趣的同学可以自行查阅射线Ray的官方文档
射线官方文档
射线用途
射线最主要的用途就是拾取物体,简单说,我们在屏幕上添加一个点击事件,然后以屏幕的位置发射一条射线,执行拾取所有场景的物体,并拿到拾取到的物体,做对应的交互事件,这样我们一套点击事件就完成了
射线拾取操作过程
- 绑定点击事件
- 获取点击时的位置并创建映射
- 根据点击时的位置映射的顶点创建射线,threejs会自动执行射线穿过物体的算法,并将穿过的物体以数组形式返回
- 根据逻辑,让整个数组的物体发生交互事件,或者指定拾取到的第一个,让其发生交互事件
射线拾取Raycaster
以笔者的经验之谈,学习Threejs的新手90%以上会栽到射线拾取的问题上
我们先来简单介绍一下射线拾取
我们以透视相机为例,本质上,透视相机的近端面,你可以理解为已经映射到了屏幕上,实际的相机位置,在四条红线的交点处
我们的射线,就是从相机位置出发,以屏幕上点击的位置发射一条射线,然后做射线与物体的交叉计算,计算过程threejs已经解决了,所以不用考虑这个问题
我们以上述的四个步骤,来写射线拾取的代码
0. 创建射线
//创建射线对象
let raycaster = new THREE.Raycaster();
//创建映射用,用于保存映射结果的顶点
let mouse = new THREE.Vector2();
1. 绑定点击事件
这里我们用画布<canvas>
来绑定事件
//创建射线对象
let raycaster = new THREE.Raycaster();
//创建映射用,用于保存映射结果的顶点
let mouse = new THREE.Vector2();
renderer.domElement.addEventListener('click',e=>{
//获取鼠标点击的位置
let x = e.clientX;
let y = e.clientY;
})
2. 获取点击时的位置并创建映射顶点
//创建射线对象
let raycaster = new THREE.Raycaster();
//创建映射用,用于保存映射结果的顶点
let mouse = new THREE.Vector2();
renderer.domElement.addEventListener('click',e=>{
//获取鼠标点击的位置
let x = e.clientX;
let y = e.clientY;
//我们最终点击的位置,要用映射的方式传给射线,射线根据计算的比例,计算出实际发射射线的方向,再发出射线
mouse.x = ( e.clientX / window.innerWidth ) * 2 - 1;
mouse.y = - ( e.clientY / window.innerHeight ) * 2 + 1;
})
官方这里使用的方式是映射,而我们鼠标点击的位置,一般不是空间中的实际位置
所以我们要将屏幕的坐标的坐标系做一下转换,并计算当前点中的顶点在上图坐标系中表示的二维顶点 ,然后保存在 mouse
对象上
新手常见错误1:完全不理解计算公式导致的映射坐标计算错误
pointer.x = ( event.clientX / window.innerWidth ) * 2 - 1;
pointer.y = - ( event.clientY / window.innerHeight ) * 2 + 1;
上述写法为官方写法,但是有人直接无脑复制粘贴,结果自己的画布并不是分布在全屏的,发现射线拾取的计算偏了
上述写法是建立在:你的 < canvas > 覆盖满全屏的情况下
== 如果你的< canvas > 没有覆盖全屏,我们需要使用另一个公式来计算映射 ==
全屏的映射公式计算图解:
如果当前Canvas覆盖全屏,我们在Canvas上点击了P点
第一步:计算比例值,先获取A-PW/A-PH的距离,然后除以全屏宽度AB/全屏高度AC,得到横竖向的比例
第二步:转换坐标系,因为在前端的概念中,坐标系是右边为x轴正方向,下边为y轴正方向,原点在左上角,那么,我们要把坐标系移动了,然后做一下方向变换,这就是为什么y的计算是负值
第三步:处理比例差异,我们实际坐标系的最大长宽都是2,所以我们要给比例 * 2,由于坐标系移动了,我们的坐标也要向回移动坐标系一半的距离,这样才能保证拿到的是最终的映射坐标,y轴同理
非全屏的映射公式计算图解
第一步依然是先计算比例值:我们依然可以用e.clientX和e.clientY来拿点击的位置,但是这次我们计算的就不一样了,我们需要拿到canvas的左边的位置,我们可以用 dom.getBoundingClientRect().left 来获取H所在竖线的x值,然后用p.clientX 减掉这个值,得到PH的长度,然后再除以当前dom宽度,即可计算到比例值
第二第三步几乎没有什么大的变化,依然是坐标系转换还有比例值差异处理
非全屏下的射线拾取映射公式
mouse.x = ((clientX - dom.getBoundingClientRect().left) / dom.offsetWidth) * 2 - 1
mouse.y = -((clientY - dom.getBoundingClientRect().top) / dom.offsetHeight) * 2 + 1
3. 根据点击时的位置创建射线
//创建射线对象
let raycaster = new THREE.Raycaster();
//创建映射用,用于保存映射结果的顶点
let mouse = new THREE.Vector2();
renderer.domElement.addEventListener('click',e=>{
//获取鼠标点击的位置
let x = e.clientX;
let y = e.clientY;
//我们最终点击的位置,要用映射的方式传给射线,射线根据计算的比例,计算出实际发射射线的方向,再发出射线
mouse.x = ( x / window.innerWidth ) * 2 - 1;
mouse.y = - ( y / window.innerHeight ) * 2 + 1;
//使用当前相机和映射点修改当前射线属性
raycaster.setFromCamera(mouse,camera);
})
setFromCamera,根据相机和映射后的坐标设置射线
4.获取拾取的物体并做指定的操作
//创建射线对象
let raycaster = new THREE.Raycaster();
//创建映射用,用于保存映射结果的顶点
let mouse = new THREE.Vector2();
renderer.domElement.addEventListener('click',e=>{
//获取鼠标点击的位置
let x = e.clientX;
let y = e.clientY;
//我们最终点击的位置,要用映射的方式传给射线,射线根据计算的比例,计算出实际发射射线的方向,再发出射线
mouse.x = (x / window.innerWidth ) * 2 - 1;
mouse.y = - ( y / window.innerHeight ) * 2 + 1;
//使用当前相机和映射点修改当前射线属性
raycaster.setFromCamera(mouse,camera);
// 计算物体和射线的交点
let intersects = raycaster.intersectObjects( scene.children );
console.log(intersects);
})
这样我们就拿到了射线拾取到的所有物体,至于怎么用,完全看你的需求,我们可以自行查看一下intersects下的所有元素
新手常见错误2,使用intersectObjects函数时只认识scene.children,使用拾取结果时,只用数组第一个元素
- 很多新手都问过这样的问题,在做射线拾取的时候,总是被什么东西挡住,拿不到我想要拾取的东西
在早期threejs官方文档中,官方是默认这样写的
let intersects = raycaster.intertscetObjects(scene.children);
if(intersects.length > 0){
let object = intersects[0].object;
//对object的处理
}
有些人就特别喜欢直接复制粘贴,连代码都不分析,后面的业务逻辑就全写在if里面了,东西被挡住了就完全不知道怎么操作了,也有聪明的,反应过来intersects是个数组,然后就遍历循环数组,从数组中拿到想要的东西。。。
还有的人,用着巨大的模型,几千个小元素,然后用mousemove事件去拾取整个场景的元素,让场景卡到离谱,还总是在抱怨threejs太卡之类的问题。。。
上述这些问题有一个统一的解决办法,就是先理解 intersectObjects
这个函数
intersectObjects ( array,recursive , optionalTarget)
array: 一个装满了Object3D对象的数组,可以是mesh,可以是line,可以是camera,任何Object3D及其继承类都可以,而官方的案例中偷了懒,因为scene.children,本身就是一个只能容纳Object3D类型元素的数组,所以这里特别容易混淆概念
recursive : 这个设置为true时,若你当前拾取的元素的children中还有子元素,那么这个函数就会检索你下面的子元素
optionalTarget :一个数组,用于接收拾取结果
返回值:一个数组,用于保存拾取计算的结果
上面三个问题,我们一个一个解决:
- 拾取时,东西被遮挡的问题,我们只需要单独let一个数组,然后将需要被拾取的东西添加进来即可
- 一次拾取的东西太多,要从中筛选,单独let一个数组,将你需要被拾取的东西添加进来即可
- 用拾取时特别卡,这个从三方面解决,一方面是优化你模型的总大小,以及你元素的总数,另一方面,也可以单独的创建一个数组,将需要被拾取的元素添加到数组中做拾取,这样可以极大的避免做过多无用的计算,
第三种就是让你老板给你上4090 + 线程撕裂者,你的客户也要求必须用这样的机子
let array = [];
array.push(mesh1);
array.push(mesh2);
// 这样写的话,射线就只会针对mesh1和mesh2做射线拾取算法,而且拾取到的结果不会是这两个元素以外的元素
let intersects = raycaster.intersectObjects( array );
console.log(intersects);
射线练习Demo
创建无数随机的方块
let geometry = new THREE.BoxGeometry(1,1,1);
let canSelectedMesh = [];
for(let i = 0;i< 200;i++){
let material = new THREE.MeshStandardMaterial({color:0xffffff * Math.random()});
let mesh = new THREE.Mesh(geometry,material)
mesh.position.x = Math.random() * 20 - 10;
mesh.position.y = Math.random() * 20 - 10;
mesh.position.z = Math.random() * 20 - 10;
canSelectedMesh.push(mesh);
scene.add(mesh)
}
创建射线并绑定事件
//创建射线对象
let raycaster = new THREE.Raycaster();
//创建映射用,用于保存映射结果的顶点
let mouse = new THREE.Vector2();
renderer.domElement.addEventListener('click',e=>{
//获取鼠标点击的位置
let x = e.clientX;
let y = e.clientY;
//我们最终点击的位置,要用映射的方式传给射线,射线根据计算的比例,计算出实际发射射线的方向,再发出射线
mouse.x = ( x / window.innerWidth ) * 2 - 1;
mouse.y = - ( y / window.innerHeight ) * 2 + 1;
//使用当前相机和映射点修改当前射线属性
raycaster.setFromCamera(mouse,camera);
// 计算物体和射线的焦点
let intersects = raycaster.intersectObjects( canSelectedMesh );
})
给方块变色
//取第一个元素
if(intersects.length > 0){
let object = intersects[0].object;
object.material.color = new THREE.Color("#000000");
}
案例效果
案例完整代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>射线拾取</title>
<style>
canvas{
display: block;
}
body {
margin: 0;
overscroll-behavior: none;
}
#btns{
position: absolute;
top:10%;
width: 500px;
height: 100px;
left: 50%;
transform:translateX(-50%);
}
</style>
</head>
<body>
<div id="btns"></div>
<!-- Import maps polyfill -->
<!-- Remove this when import maps will be widely supported -->
<script async src="https://unpkg.com/es-module-shims@1.3.6/dist/es-module-shims.js"></script>
<script type="importmap">
{
"imports": {
"three": "../three.js-master/build/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from '../three.js-master/build/three.module.js';
import {OrbitControls} from "../three.js-master/examples/jsm/controls/OrbitControls.js";
let scene,renderer,camera,orbitControls;
let mesh;
function init(){
scene = new THREE.Scene();
renderer = new THREE.WebGLRenderer({
alpha:true,
antialias:true
});
renderer.setSize(window.innerWidth,window.innerHeight);
document.body.appendChild(renderer.domElement);
camera = new THREE.PerspectiveCamera(60,window.innerWidth/window.innerHeight,1,1000);
camera.position.set(10,10,10);
orbitControls = new OrbitControls(camera,renderer.domElement);
let light = new THREE.PointLight();
camera.add(light);
scene.add(camera);
let helper = new THREE.AxesHelper(5);
scene.add(helper);
}
function addMesh(){
let geometry = new THREE.BoxGeometry(1,1,1);
let canSelectedMesh = [];
for(let i = 0;i< 200;i++){
let material = new THREE.MeshStandardMaterial({color:0xffffff * Math.random()});
let mesh = new THREE.Mesh(geometry,material)
mesh.position.x = Math.random() * 20 - 10;
mesh.position.y = Math.random() * 20 - 10;
mesh.position.z = Math.random() * 20 - 10;
canSelectedMesh.push(mesh);
scene.add(mesh)
}
//创建射线对象
let raycaster = new THREE.Raycaster();
//创建映射用,用于保存映射结果的顶点
let mouse = new THREE.Vector2();
renderer.domElement.addEventListener('click',e=>{
//获取鼠标点击的位置
let x = e.clientX;
let y = e.clientY;
//我们最终点击的位置,要用映射的方式传给射线,射线根据计算的比例,计算出实际发射射线的方向,再发出射线
mouse.x = ( x / window.innerWidth ) * 2 - 1;
mouse.y = - ( y / window.innerHeight ) * 2 + 1;
//使用当前相机和映射点修改当前射线属性
raycaster.setFromCamera(mouse,camera);
// 计算物体和射线的焦点
let intersects = raycaster.intersectObjects( canSelectedMesh );
//取第一个元素
if(intersects.length > 0){
let object = intersects[0].object;
object.material.color = new THREE.Color("#000000");
}
})
}
function render(){
renderer.render(scene,camera);
requestAnimationFrame(render);
}
init();
addMesh();
render();
</script>
</body>
</html>
Raycaster常用函数
**set( origin,direction ) **
设置射线的起点和方向
origin:一个Vector3对象,射线起点,我们做射线拾取也不是一定要从相机出发,用映射来拾取,我们还可以从空间中创建射线,比如说我们玩的游戏中,有激光的场景,如果人物碰到激光了,就会判定掉血,这个就是在空间中任意选取点创建的射线
direction,一个Vector3对象,用于保存射线方向
setFromCamera(coords,camera)
通过相机设置射线
coords:映射坐标,映射坐标的取值范围应在-1~1之间
camera:射线起点将会放到传入的相机上
intersectObject(Object,recursive,optionalTarget)
上面我们讲了intersectObjects,这个是object,仅一个字母之差,这个函数用来拾取指定物体而非数组,如果你只想拾取那一个目标物体,这个是比较好用的函数
object:一个Object3D对象
recursive : 这个设置为true时,若你当前拾取的元素的children中还有子元素,那么这个函数就会检索你下面的子元素
optionalTarget :一个数组,用于接收拾取结果
返回值:一个数组,用于保存拾取结果
这里要介绍一下返回值数组下面的元素
distance:射线起点 到 射线与物体交点 的距离
face:与射线相交的平面
faceIndex:与射线相交的平面的index,这个在之前bufferGeometry中介绍过
object:与射线相交的物体,一般是Mesh,Point,Sprite,Line等
point:射线与物体相交的第一个顶点
uv:射线的交点在物体的纹理的uv的位置
intersectObject和intersectObjects这两个函数,最终返回的结果,都是上述形式的对象构成的数组,所以,我们要拿到点中的物体时,可以拿 res.object,我们需要拿点中的顶点时,就获取point即可