在可视化开发中,无论是二维的 canvas 还是三维开发,线条的绘制都是非常常见的,比如绘制城市之间的迁徙图、运动轨迹图等等。不管是在三维还是二维,所有物体都是由点构成、两点构成线、三点构成面。那么在 ThreeJS 中绘制一根简单的线的背后又有哪些故事呢,本文将逐一解开。
一根线的诞生
在 ThreeJS 中,物体由几何体(Geometry) 和材质(Material) 构成,物体以何种方式(点、线、面)展示取决于渲染方式(ThreeJS 提供了不同的物体构造函数)。
翻看 ThreeJS 的 API,与线相关有这些:
简单来说,ThreeJS 提供了 LineBasicMaterial
和 LineDashedMaterial
两类材质,主要控制线的颜色,宽度等;几何体主要控制线段断点的位置等,主要使用 BufferGeometry 这个基本几何类来创建线的几何体。同时也提供了一些线生成函数来帮助生成线几何体。
直线
在 API 中提供了 Line
LineLoop
LineSegments
三类线相关的物体
Line
先使用 Line
来创建一根最简单的线:
// 创建材质
const material = new THREE.LineBasicMaterial({ color: 0xff0000 });
// 创建空几何体
const geometry = new THREE.BufferGeometry()
const points = [];
points.push(new THREE.Vector3(20, 20, 0));
points.push(new THREE.Vector3(20, -20, 0));
points.push(new THREE.Vector3(-20, -20, 0));
points.push(new THREE.Vector3(-20, 20, 0));
// 绑定顶点到空几何体
geometry.setFromPoints(points);
const line = new THREE.Line(geometry, material);
scene.add(line);
LineLoop
LineLoop
用于将一系列点绘制成一条连续的线,它和 Line
几乎一样,唯一的区别就是所有点连接之后会将第一个点和最后一个点相连接,这种线条在实际项目中用于绘制某个区域,比如在地图上用线条勾选出某一区域。使用 LineLoop
创建一个对象:
// 创建材质
const material = new THREE.LineBasicMaterial({ color: 0xff0000 });
// 创建空几何体
const geometry = new THREE.BufferGeometry()
const points = [];
points.push(new THREE.Vector3(20, 20, 0));
points.push(new THREE.Vector3(20, -20, 0));
points.push(new THREE.Vector3(-20, -20, 0));
points.push(new THREE.Vector3(-20, 20, 0));
// 绑定顶点到空几何体
geometry.setFromPoints(points);
const line = new THREE.LineLoop(geometry, material);
scene.add(line);
同样是四个点,使用 LineLoop
创建后是一个闭合的区域。
LineSegments
LineSegments
用于将两个点连接为一条线,它会将我们传递的一系列点自动分配成两个为一组,然后将分配好的两个点连接,这种先天实际项目中主要用于绘制具有相同开始点,结束点不同的线条,比如常用到的遗传图。使用 LineSegments
创建一个对象:
// 创建材质
const material = new THREE.LineBasicMaterial({ color: 0xff0000 });
// 创建空几何体
const geometry = new THREE.BufferGeometry()
const points = [];
points.push(new THREE.Vector3(20, 20, 0));
points.push(new THREE.Vector3(20, -20, 0));
points.push(new THREE.Vector3(-20, -20, 0));
points.push(new THREE.Vector3(-20, 20, 0));
// 绑定顶点到空几何体
geometry.setFromPoints(points);
const line = new THREE.LineSegments(geometry, material);
scene.add(line);
区别
上述三个线对象的区别是底层渲染的 WebGL 方式不同,假设有 p1/p2/p3/p4/p5 五个点,
Line
使用的是gl.LINE_STRIP
,画一条直线到下一个顶点,最终连线是 p1- > p2 -> p3 -> p4 -> p5LineLoop
使用的是gl.LINE_LOOP
,绘制一条直线到下一个顶点,并将最后一个顶点返回到第一个顶点,最终连线是 p1- > p2 -> p3 -> p4 -> p5 -> p1LineSegments
使用的是gl.LINES
,在一对顶点之间画一条线,最终连线是 p1- > p2 p3 -> p4
如果仅仅是绘制两个点之间的一条线段,那么上述三种实现方式都是没有什么区别的,实现效果都是一样的。
虚线
除了 LineBasicMaterial
,ThreeJS 还提供了 LineDashedMaterial
这个材质来绘制虚线:
// 虚线材质
const material = new THREE.LineDashedMaterial({
color: 0xff0000,
scale: 1,
dashSize: 3,
gapSize: 1,
});
const points = [];
points.push(new THREE.Vector3(10, 10, 0));
points.push(new THREE.Vector3(10, -10, 0));
points.push(new THREE.Vector3(-10, -10, 0));
points.push(new THREE.Vector3(-10, 10, 0));
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geometry, material);
// 计算LineDashedMaterial所需的距离的值的数组。
line.computeLineDistances();
scene.add(line);
<img src="https://img.alicdn.com/imgextra/i4/O1CN010B12zS1TwlulbyP9Y_!!6000000002447-2-tps-908-574.png" style="zoom:50%;" />
需要注意的是,绘制虚线需要计算线条之间的距离,否则不会出现虚线的效果。 对于几何体中的每一个顶点,line.computeLineDistances
这个方法计算出了当前点到线的起始点的累积长度。
炫酷的线
加点宽度
LineBasicMaterial
提供了设置线宽的 linewidth、相邻线段间的连接形状 linecap 以及端点形状 linecap,但是设置了之后却发现不生效,ThreeJS 的文档也说明了这一点:
由于底层 OpenGL 渲染的限制性,线宽的最大和最小值都只能为 1,线宽无法设置,那么线段之间的连接形状设置也就没有意义了,因此这三个设置项都是无法生效的。
ThreeJS 官方提供了一个可以设置线宽的 demo,这个 demo 使用了扩展包 jsm 中的材质 LineMaterial
、几何体 LineGeometry
和对象 Line2
。
import { Line2 } from './jsm/lines/Line2.js';
import { LineMaterial } from './jsm/lines/LineMaterial.js';
import { LineGeometry } from './jsm/lines/LineGeometry.js';
const geometry = new LineGeometry();
geometry.setPositions( positions );
const matLine = new LineMaterial({
color: 0xffffff,
linewidth: 5, // in world units with size attenuation, pixels otherwise
//resolution: // to be set by renderer, eventually
dashed: false,
alphaToCoverage: true,
});
const line = new Line2(geometry, matLine);
line.computeLineDistances();
line.scale.set(1, 1, 1);
scene.add( line );
function animate() {
renderer.render(scene, camera);
// renderer will set this eventually
matLine.resolution.set( window.innerWidth, window.innerHeight ); // resolution of the viewport
requestAnimationFrame(animate);
}
需要注意的是,在渲染循环的 loop 中,每帧都需要重新设置材质的 resolution
,否则宽度效果就无法生效;Line2
没有提供文档说明,具体参数需要通过观察源码进行探索。
加点颜色
在基本 demo 中,通过材质的 color
来统一设置线的颜色,那么如果想实现渐变效果又该如何实现呢?
在材质设置中, vertexColors
这个参数可以控制材质颜色的来源,如果设置为 true,那么颜色的计算逻辑来自于顶点颜色,通过一定的插值平滑过渡为连续的颜色变化。
// 创建材质
const material = new THREE.LineMaterial({
linewidth: 2,
vertexColors: true,
resolution: new THREE.Vector2(800, 600),
});
// 创建空几何体
const geometry = new THREE.LineGeometry();
geometry.setPositions([
10,10,0, 10,-10,0, -10,-10,0, -10,10,0
]);
// 设置顶点颜色
geometry.setColors([
1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 0
]);
const line = new THREE.Line2(geometry, material);
line.computeLineDistances();
scene.add(line);
上述代码创建了四个点,分别设置顶点颜色为红色(1,0,0)、绿色(0,1,0)、蓝色(0,0,1)、黄色(1,1,0),得到的渲染效果如下图:
这个例子只设置了四个顶点的颜色,如果颜色的插值函数间隔取得更小,我们就能创建出细节更丰富的颜色。
加点形状
两点相连可以指定一根线,如果点与点之间的间距非常小,而点又非常密集时,点点之间相连即可以生成各式各样的曲线了。
ThreeJS 提供了多种曲线生成函数,主要分为二维曲线和三维曲线:
<img src="https://img.alicdn.com/imgextra/i3/O1CN01zjHrBJ1cn00O1kmjD_!!6000000003644-2-tps-476-524.png" style="zoom:50%;" />
ArcCurve
和EllipseCurve
分别绘制圆和椭圆的,EllipseCurve
是ArcCurve
的基类;LineCurve
和LineCurve3
分别绘制二维和三维的曲线(数学曲线的定义包括直线),他们都由起始点和终止点组成;QuadraticBezierCurve
、QuadraticBezierCurve3
、CubicBezierCurve
和CubicBezierCurve3
分别是二维、三维、二阶、三阶贝塞尔曲线;SplineCurve
和CatmullRomCurve3
分别是二维和三维的样条曲线,使用 Catmull-Rom 算法,从一系列的点创建一条平滑的样条曲线。
贝塞尔曲线与 CatmullRom 曲线的区别在于,CatmullRom 曲线可以平滑的通过所有点,一般用于绘制轨迹,而贝塞尔曲线通过中间点来构造切线。
- 贝塞尔曲线
- CatmullRom 曲线
这些构造函数通过参数生成曲线,Curve
基类提供了 getPoints
方法类获取曲线上的点,参数为曲线划分段数,段数越多,划分越密,点越多,曲线越光滑。最后将这系列点并赋值到几何体中,以贝塞尔曲线为例:
// 创建几何体
const geometry = new THREE.BufferGeometry();
// 创建曲线
const curve = new THREE.CubicBezierCurve3(
new THREE.Vector3(-10, -20, -10),
new THREE.Vector3(-10, 40, -10),
new THREE.Vector3(10, 40, 10),
new THREE.Vector3(10, -20, 10)
);
// getPoints 方法从曲线中获取点
const points = curve.getPoints(100);
// 将这系列点赋值给几何体
geometry.setFromPoints(points);
// 创建材质
const material = new THREE.LineBasicMaterial({color: 0xff0000});
const line = new THREE.Line(geometry, material);
scene.add(line);
<img src="https://img.alicdn.com/imgextra/i3/O1CN01mLGaXQ1WeOsF7cHVJ_!!6000000002813-2-tps-852-859.png" style="zoom:50%;" />
我们也可以通过继承 Curve
基类,通过重写基类中 getPoint
方法来实现自定义曲线,getPoint
方法是返回在曲线中给定位置 t 的向量。
比如实现一条正弦函数的曲线:
class CustomSinCurve extends THREE.Curve {
constructor( scale = 1 ) {
super();
this.scale = scale;
}
getPoint( t, optionalTarget = new THREE.Vector3() ) {
const tx = t * 3 - 1.5;
const ty = Math.sin( 2 * Math.PI * t );
const tz = 0;
return optionalTarget.set( tx, ty, tz ).multiplyScalar( this.scale );
}
}
加点拉伸
线不管如何变化都只是二维平面,虽然上述有一些三维曲线,不过是法平面不同。如果我们想模拟一些类似管道的效果,管道是有直径的概念,那么二维线肯定无法满足要求。所以我们需要使用其他几何体来实现管道效果。
ThreeJS 封装了很多几何体供我们使用,其中就有一个 TubeGeometry
管道几何体,
它可以根据 3d 曲线往外拉伸出一条管道,它的构造函数:
class TubeGeometry(path : Curve, tubularSegments : Integer, radius : Float, radialSegments : Integer, closed : Boolean)
path 即是曲线,描述管道形状。我们使用前面自己创建的正弦函数曲线CustomSinCurve
来生成一条曲线,并使用 TubeGeometry
拉伸。
const tubeGeometry = new THREE.TubeGeometry(new CustomSinCurve(10), 20, 2, 8, false);
const tubeMaterial = new THREE.MeshStandardMaterial({ color: 0x156289, emissive: 0x072534, side: THREE.DoubleSide });
const tube = new THREE.Mesh(tubeGeometry, tubeMaterial);
scene.add(tube)
加点动画
到这个时候,我们的线已经有了宽度、颜色、形状,那么下一步该动起来了!动起来的实质是在每个渲染帧改变物体的某个属性,形成一定的连续效果,所以我们有两个思路去让线条动起来,一种是让线的几何体动起来,一种是让线的材质动起来,
流动的线
在材质动画中,使用最为频繁的是贴图流动。通过设置贴图的 repeat
属性,并不断改变贴图对象的 offset
让贴图产生流动效果。
如果要在线中实现贴图流动效果,二维的线是无法实现的,必须要在拉伸后的三维管道中才有意义。同样使用前述实现的管道体,然后对材质赋予贴图配置:
// 创建纹理
const imgUrl = 'xxx'; // 图片地址
const texture = new THREE.TextureLoader().load(imgUrl);
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
// 控制纹理重复参数
texture.repeat.x = 10;
texture.repeat.y = 1;
// 将纹理应用于材质
const tubeMaterial = new THREE.MeshStandardMaterial({
color: 0x156289,
emissive: 0x156289,
map: texture,
side: THREE.DoubleSide,
});
const tube = new THREE.Mesh(tubeGeometry, tubeMaterial);
scene.add(tube)
function renderLoop() {
const delta = clock.getDelta();
renderer.render(scene, camera);
// 在renderloop中更新纹理的offset
if (texture) {
texture.offset.x -= 0.01;
}
requestAnimationFrame(renderLoop);
}
生长的线
生长的线的实现思路很简单,先计算定义好一系列点,即线的最终形状,然后再创建一条只有前两个点的线,然后向创建好的线里面按顺序塞入其他点,再更新这条线,最终就能得到线生长的效果。
BufferGeometry 的更新
在此之前,我们再次来了解一下 ThreeJS 中的几何体。ThreeJS 中的几何体可以分为,点Points、线Line、网格Mesh。Points 模型创建的物体是由一个个点构成,每个点都有自己的位置,Line 模型创建的物体是连续的线条,这些线可以理解为是按顺序把所有点连接起来, Mesh 网格模型创建的物体是由一个个小三角形组成,这些小三角形又是由三个点确定。不管是哪一种模型,它们都有一个共同点,就是都离不开点,每一个点都有确定的 x y z,BoxGeometry、SphereGeometry 帮我们封装了对这些点的操作,我们只需要告诉它们长宽高或者半径这些信息,它就会帮我创建一个默认的几何体。而 BufferGeometry 就是完全由我们自己去操作点信息的方法,我们可以通过它去设置每一个点的位置(position)、每一个点的颜色(color)、每一个点的法向量(normal) 等。
与 Geometry 相比,BufferGeometry 将信息(例如顶点位置,面索引,法线,颜色,uv和任何自定义属性)存储在 buffer 中 —— 也就是 Typed Arrays。这使得它们通常比标准 Geometry 更快,但缺点是更难用。
在更新 BufferGeometry 时,最重要的一个点是,不能调整 buffer 的大小,这种操作开销很大,相当于创建了个新的 geometry,但可以更新 buffer 的内容。所以如果期望 BufferGeometry 的某个属性会增加,比如顶点的数量,必须预先分配足够大的 buffer 来容纳可能创建的任意新顶点数。 当然,这也意味着 BufferGeometry 将有一个最大大小,也就是无法创建一个可以高效无限扩展的 BufferGeometry。
那么,在绘制生长的线时,实际问题就是在渲染时扩展线的顶点。举个例子,我们先为 BufferGeometry 的顶点属性分配可容纳 500 个顶点的缓冲区,但最初只绘制 2 个,再通过 BufferGeometry 的 drawRange
方法来控制绘制的缓冲区范围。
const MAX_POINTS = 500;
// 创建几何体
const geometry = new THREE.BufferGeometry();
// 设置几何体的属性
const positions = new Float32Array( MAX_POINTS * 3 ); // 一个顶点向量需要3个位置描述
geometry.setAttribute( 'position', new THREE.BufferAttribute( positions, 3 ) );
// 控制绘制范围
const drawCount = 2; // 只绘制前两个点
geometry.setDrawRange( 0, drawCount );
// 创建材质
const material = new THREE.LineBasicMaterial( { color: 0xff0000 } );
// 创建线
const line = new THREE.Line( geometry, material );
scene.add(line);
然后随机添加顶点到线中:
const positions = line.geometry.attributes.position.array;
let x, y, z, index;
x = y = z = index = 0;
for ( let i = 0; i < MAX_POINTS; i ++ ) {
positions[ index ++ ] = x;
positions[ index ++ ] = y;
positions[ index ++ ] = z;
x += ( Math.random() - 0.5 ) * 30;
y += ( Math.random() - 0.5 ) * 30;
z += ( Math.random() - 0.5 ) * 30;
}
如果要更改第一次渲染后渲染的点数,执行以下操作:
line.geometry.setDrawRange(0, newValue);
如果要在第一次渲染后更改 position 数值,则需要设置 needsUpdate 标志:
line.geometry.attributes.position.needsUpdate = true; // 需要加在第一次渲染之后
画线
在三维搭建场景下的编辑器中,经常需要绘制物体与物体之间的连接,例如工业场景中绘制管道、建模场景中绘制货架等等。这个过程可以抽象为在屏幕上点击两点生成一条直线。在二维场景下,这个功能听起来没有任何难度,但是在三维场景中,又该如何实现呢?
首先要解决的是线的顶点更新,即鼠标点击一次确定线中的一个顶点,再次点击确定下一个顶点位置,其次要解决的是三维场景下点击与交互问题,如何在二维屏幕中确定三维点位置,如何保证用户点击的点就是其所理解的位置。
LineGeometry 的更新
在绘制普通的线时,几何体都使用了 BufferGeometry,我们也在上一小节介绍了如何对其进行更新。但在绘制有宽度的线这一节中,我们使用了扩展包 jsm 中的材质 LineMaterial
、几何体 LineGeometry
和对象 Line2
。LineGeometry 又该如何更新呢?
LineGeometry 提供了 setPosition
的方法,对其 BufferAttribute 进行操作,因此我们不需要关心如何更新
翻看源码可以知道,LineGeometry 的底层渲染,并不是直接通过 positions 属性来计算位置,而是通过属性 instanceStart
instanceEnd
来设置的。LineGeometry 提供了 setPositions
方法来更新线的顶点。
class LineSegmentsGeometry {
// ...
setPositions( array ) {
let lineSegments;
if ( array instanceof Float32Array ) {
lineSegments = array;
} else if ( Array.isArray( array ) ) {
lineSegments = new Float32Array( array );
}
const instanceBuffer = new InstancedInterleavedBuffer( lineSegments, 6, 1 ); // xyz, xyz
this.setAttribute( 'instanceStart', new InterleavedBufferAttribute( instanceBuffer, 3, 0 ) ); // xyz
this.setAttribute( 'instanceEnd', new InterleavedBufferAttribute( instanceBuffer, 3, 3 ) ); // xyz
this.computeBoundingBox();
this.computeBoundingSphere();
return this;
}
}
因此绘制时我们只需要调用 setPositions
方法来更新线顶点,同时需要预先定好绘制线最大可容纳的顶点数,再控制渲染范围,实现思路同上。
const MaxCount = 10;
const positions = new Float32Array(MaxCount * 3);
const points = [];
const material = new THREE.LineMaterial({
linewidth: 2,
color: 0xffffff,
resolution: new THREE.Vector2(800, 600)
});
geometry = new THREE.LineGeometry();
geometry.setPositions(positions);
geometry.instanceCount = 0;
line = new THREE.Line2(geometry, material);
line.computeLineDistances();
scene.add(line);
// 鼠标移动或点击时更新线
function updateLine() {
positions[count * 3 - 3] = mouse.x;
positions[count * 3 - 2] = mouse.y;
positions[count * 3 - 1] = mouse.z;
geometry.setPositions(positions);
geometry.instanceCount = count - 1;
}
点击与交互
在三维场景下如何实现点选交互呢?鼠标所在的屏幕是一个二维的世界,而屏幕呈现的是一个三维世界,首先先解释一下三种坐标系的关系:世界坐标系、屏幕坐标系、视点坐标系。
场景坐标系(世界坐标系)
通过 ThreeJS 构建出来的场景,都具有一个固定不变的坐标系(无论相机的位置在哪),并且放置的任何物体都要以这个坐标系来确定自己的位置,也就是
(0,0,0)
坐标。例如我们创建一个场景并添加箭头辅助。屏幕坐标
在显示屏上的坐标就是屏幕坐标系。如下图所示,其中的
clientX
和clientY
的最值由,window.innerWidth
,window.innerHeight
决定。视点坐标
视点坐标系就是以相机的中心点为原点,但是相机的位置,也是根据世界坐标系来偏移的,WebGL 会将世界坐标先变换到视点坐标,然后进行裁剪,只有在视线范围(视见体)之内的场景才会进入下一阶段的计算
如下图添加了相机辅助线.
如果想获取鼠标点击的坐标,就需要把屏幕坐标系转换为 ThreeJS 中的场景坐标系。一种是采用几何相交性计算的方式,从鼠标点击的地方,沿着视角方向发射一条射线。通过射线与三维模型的几何相交性判断来决定物体是否被拾取到。 ThreeJS 内置了一个 Raycaster
的类,为我们提供的是一个射线,然后我们可以根据不同的方向去发射射线,根据射线是否被阻挡,来判断我们是否碰到了物体。来看看如何使用 Raycaster类来实现鼠标点击物体的高亮显示效果
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
renderer.domElement.addEventListener("mousedown", (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(cubes, true);
if (intersects.length > 0) {
var obj = intersects[0].object;
obj.material.color.set("#ff0000");
obj.material.needsUpdate= true;
}
})
实例化 Raycaster
对象,以及一个记录鼠标位置的二维向量 mouse
。当监听 dom 节点mousedown
事件被触发的时候,可以在事件回调里面,获取到鼠标在当前 dom 上的位置 (event.clientX、event.clientY)
。然后把屏幕坐标转化为 场景坐标系中的屏幕坐标位置。对应关系如下图所示。
屏幕坐标系的原点为左上角,Y 轴向下,而三维坐标系的原点是屏幕中心,Y 轴向上且做了归一化处理,因此如果要讲鼠标位置 x 换算到三维坐标系中:
1.将原点转到屏幕中间即 x - 0.5*canvasWidth
2.做归一化处理 (x - 0.5*canvasWidth)/(0.5*canvasWidth)
即最终 (event.clientX / window.innerWidth) * 2 - 1;
y 轴计算同理,不过做了一次翻转。
继续调用 raycaster 的 setFromCamera
方法,可以获得一条和相机朝向一致、从鼠标点射出去的射线。然后调用射线与物体相交的检测函数 intersectObjects
。
class Raycaster {
// ...
intersectObjects(objects: Object3D[], recursive?: boolean, optionalTarget?: Intersection[]): Intersection[];
}
第一个参数 objects
是检测与射线相交的一组物体,第二个参数 recursive
默认只检测当前级别的物体,子物体不做检测。如果需要检查所有后代,需要显示设置为 true。
- 在画线中的交互限制
在画线场景下,点击两点确定一条直线,但是在二维屏幕内去看三维世界,人感受到的三维坐标并不一定是实际的三维坐标,如果画线交互需要更加精确,即保证鼠标点击的点就是用户理解的三维坐标点,那么需要加一些限制。
因为在二维屏幕内可以精确确定一个点的位置,那么如果我们把射线拾取范围限制在一个固定平面内呢?即先确定平面,再确定点的位置。进入下一个点绘制前,可以切换平面。通过限制拾取范围,保证鼠标点击的点是用户理解的三维坐标点。
简单起见,我们创建三个基础拾取平面 XY/XZ/YZ,绘制一个点时拾取平面是确定的,同时创建辅助网格线来帮助用户观察自己是在哪个平面内绘制。
const planeMaterial = new THREE.MeshBasicMaterial();
const planeGeometry = new THREE.PlaneGeometry(100, 100);
// XY 平面 即在 Z 方向上绘制
const planeXY = new THREE.Mesh(planeGeometry, planeMaterial);
planeXY.visible = false;
planeXY.name = "planeXY";
planeXY.rotation.set(0, 0, 0);
scene.add(planeXY);
// XZ 平面 即在 Y 方向上绘制
const planeXZ = new THREE.Mesh(planeGeometry, planeMaterial);
planeXZ.visible = false;
planeXZ.name = "planeXZ";
planeXZ.rotation.set(-Math.PI / 2, 0, 0);
scene.add(planeXZ);
// YZ 平面 即在 X 方向上绘制
const planeYZ = new THREE.Mesh(planeGeometry, planeMaterial);
planeYZ.visible = false;
planeYZ.name = "planeYZ";
planeYZ.rotation.set(0, Math.PI / 2, 0);
scene.add(planeYZ);
// 辅助网格
const grid = new THREE.GridHelper(10, 10);
scene.add(grid);
// 初始化设置
mode = "XZ";
grid.rotation.set(0, 0, 0);
activePlane = planeXZ;// 设置拾取平面
- 鼠标移动时 更新位置
在鼠标移动时,用射线获取鼠标点与拾取平面的坐标,作为线的下一个点位置:
function handleMouseMove(event) {
if (drawEnabled) {
const { clientX, clientY } = event;
const rect = container.getBoundingClientRect();
mouse.x = ((clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -(((clientY - rect.top) / rect.height) * 2) + 1;
raycaster.setFromCamera(mouse, camera);
// 计算射线与当前平面的交叉点
const intersects = raycaster.intersectObjects([activePlane], true);
if (intersects.length > 0) {
const intersect = intersects[0];
const { x: x0, y: y0, z: z0 } = lastPoint;
const x = Math.round(intersect.point.x);
const y = Math.round(intersect.point.y);
const z = Math.round(intersect.point.z);
const newPoint = new THREE.Vector3();
if (mode === "XY") {
newPoint.set(x, y, z0);
} else if (mode === "YZ") {
newPoint.set(x0, y, z);
} else if (mode === "XZ") {
newPoint.set(x, y0, z);
}
mouse.copy(newPoint);
updateLine();
}
}
}
- 鼠标点击时 添加点
鼠标点击后,当前点被正式添加到线中,并作为上一个顶点记录,同时更新拾取平面与辅助网格的位置。
function handleMouseClick() {
if (drawEnabled) {
const { x, y, z } = mouse;
positions[count * 3 + 0] = x;
positions[count * 3 + 1] = y;
positions[count * 3 + 2] = z;
count += 1;
grid.position.set(x, y, z);
activePlane.position.set(x, y, z);
lastPoint = mouse.clone();
}
}
- 键盘切换模式
为方便起见,监听键盘事件来控制模式,X/Y/Z 分别切换不同的拾取平面,D/S 来控制画线是否可以操作。
function handleKeydown(event) {
if (drawEnabled) {
switch (event.key) {
case "d":
drawEnabled = false;
break;
case "s":
drawEnabled = true;
break;
case "x":
mode = "YZ";
grid.rotation.set(-Math.PI / 2, 0, 0);
activePlane = planeYZ;
break;
case "y":
mode = "XZ";
grid.rotation.set(0, 0, 0);
activePlane = planeXZ;
break;
case "z":
mode = "XY";
grid.rotation.set(0, 0, Math.PI / 2);
activePlane = planeXY;
break;
default:
}
}
}
最后实现的效果
如果稍加拓展,可以对交互进行更细致的优化,也可以在生成线之后对线材质的相关属性进行编辑,可以玩的花样就非常多了。
总结
线在图形绘制中一直是一个非常有意思的话题,可延伸的技术点也很多。从 OpenGL 中基本的线连接方式,到为线加一些宽度、颜色等效果,以及在编辑场景下如何实现画线功能。上述对 ThreeJS 中线的总结如果有任何问题,都欢迎一起讨论!
作者:ES2049 | Timeless
文章可随意转载,但请保留原文链接。
非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj@alibaba-inc.com 。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。