ThreeJS 中线的那些事

4,094 阅读15分钟

在可视化开发中,无论是二维的 canvas 还是三维开发,线条的绘制都是非常常见的,比如绘制城市之间的迁徙图、运动轨迹图等等。不管是在三维还是二维,所有物体都是由点构成、两点构成线、三点构成面。那么在 ThreeJS 中绘制一根简单的线的背后又有哪些故事呢,本文将逐一解开。

一根线的诞生

在 ThreeJS 中,物体由几何体(Geometry) 和材质(Material) 构成,物体以何种方式(点、线、面)展示取决于渲染方式(ThreeJS 提供了不同的物体构造函数)。

翻看 ThreeJS 的 API,与线相关有这些:

Line API

简单来说,ThreeJS 提供了 LineBasicMaterialLineDashedMaterial 两类材质,主要控制线的颜色,宽度等;几何体主要控制线段断点的位置等,主要使用 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);

img

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 -> p5
  • LineLoop 使用的是 gl.LINE_LOOP,绘制一条直线到下一个顶点,并将最后一个顶点返回到第一个顶点,最终连线是 p1- > p2 -> p3 -> p4 -> p5 -> p1
  • LineSegments 使用的是 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);

需要注意的是,绘制虚线需要计算线条之间的距离,否则不会出现虚线的效果。 对于几何体中的每一个顶点,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 提供了多种曲线生成函数,主要分为二维曲线和三维曲线:

  • ArcCurveEllipseCurve 分别绘制圆和椭圆的,EllipseCurveArcCurve 的基类;
  • LineCurveLineCurve3 分别绘制二维和三维的曲线(数学曲线的定义包括直线),他们都由起始点和终止点组成;
  • QuadraticBezierCurveQuadraticBezierCurve3CubicBezierCurveCubicBezierCurve3 分别是二维、三维、二阶、三阶贝塞尔曲线
  • SplineCurveCatmullRomCurve3 分别是二维和三维的样条曲线,使用 Catmull-Rom 算法,从一系列的点创建一条平滑的样条曲线。

贝塞尔曲线与 CatmullRom 曲线的区别在于,CatmullRom 曲线可以平滑的通过所有点,一般用于绘制轨迹,而贝塞尔曲线通过中间点来构造切线。

  • 贝塞尔曲线

img

  • 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);

我们也可以通过继承 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)
image-20220302203508781

加点动画

到这个时候,我们的线已经有了宽度、颜色、形状,那么下一步该动起来了!动起来的实质是在每个渲染帧改变物体的某个属性,形成一定的连续效果,所以我们有两个思路去让线条动起来,一种是让线的几何体动起来,一种是让线的材质动起来,

流动的线

在材质动画中,使用最为频繁的是贴图流动。通过设置贴图的 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);
}

demo

生长的线

生长的线的实现思路很简单,先计算定义好一系列点,即线的最终形状,然后再创建一条只有前两个点的线,然后向创建好的线里面按顺序塞入其他点,再更新这条线,最终就能得到线生长的效果。

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; // 需要加在第一次渲染之后

demo

画线

在三维搭建场景下的编辑器中,经常需要绘制物体与物体之间的连接,例如工业场景中绘制管道、建模场景中绘制货架等等。这个过程可以抽象为在屏幕上点击两点生成一条直线。在二维场景下,这个功能听起来没有任何难度,但是在三维场景中,又该如何实现呢?

首先要解决的是线的顶点更新,即鼠标点击一次确定线中的一个顶点,再次点击确定下一个顶点位置,其次要解决的是三维场景下点击与交互问题,如何在二维屏幕中确定三维点位置,如何保证用户点击的点就是其所理解的位置。

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) 坐标。例如我们创建一个场景并添加箭头辅助。

  • 屏幕坐标

    在显示屏上的坐标就是屏幕坐标系。如下图所示,其中的 clientXclientY 的最值由,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:
    }
  }
}

最后实现的效果

Demo

如果稍加拓展,可以对交互进行更细致的优化,也可以在生成线之后对线材质的相关属性进行编辑,可以玩的花样就非常多了。

总结

线在图形绘制中一直是一个非常有意思的话题,可延伸的技术点也很多。从 OpenGL 中基本的线连接方式,到为线加一些宽度、颜色等效果,以及在编辑场景下如何实现画线功能。上述对 ThreeJS 中线的总结如果有任何问题,都欢迎一起讨论!

作者:ES2049 | Timeless

文章可随意转载,但请保留原文链接

非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj@alibaba-inc.com