ThreeJS 中线的那些事

在可视化开发中,无论是二维的 canvas 还是三维开发,线条的绘制都是非常常见的,比如绘制城市之间的迁徙图、运动轨迹图等等。不管是在三维还是二维,所有物体都是由点构成、两点构成线、三点构成面。那么在 ThreeJS 中绘制一根简单的线的背后又有哪些故事呢,本文将逐一解开。
一根线的诞生 在 ThreeJS 中,物体由几何体(Geometry) 和材质(Material) 构成,物体以何种方式(点、线、面)展示取决于渲染方式(ThreeJS 提供了不同的物体构造函数)。
翻看 ThreeJS 的 API,与线相关有这些:
ThreeJS 中线的那些事
文章图片

简单来说,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);

ThreeJS 中线的那些事
文章图片

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

ThreeJS 中线的那些事
文章图片

同样是四个点,使用 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);

ThreeJS 中线的那些事
文章图片

区别
上述三个线对象的区别是底层渲染的 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);

ThreeJS 中线的那些事
文章图片

需要注意的是,绘制虚线需要计算线条之间的距离,否则不会出现虚线的效果。 对于几何体中的每一个顶点,line.computeLineDistances 这个方法计算出了当前点到线的起始点的累积长度。
炫酷的线 加点宽度 LineBasicMaterial 提供了设置线宽的 linewidth、相邻线段间的连接形状 linecap 以及端点形状 linecap,但是设置了之后却发现不生效,ThreeJS 的文档也说明了这一点:
ThreeJS 中线的那些事
文章图片

由于底层 OpenGL 渲染的限制性,线宽的最大和最小值都只能为 1,线宽无法设置,那么线段之间的连接形状设置也就没有意义了,因此这三个设置项都是无法生效的。
ThreeJS 中线的那些事
文章图片

ThreeJS 官方提供了一个可以设置线宽的 demo,这个 demo 使用了扩展包 jsm 中的材质 LineMaterial、几何体 LineGeometry 和对象 Line2
ThreeJS 中线的那些事
文章图片

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 中线的那些事
文章图片

这个例子只设置了四个顶点的颜色,如果颜色的插值函数间隔取得更小,我们就能创建出细节更丰富的颜色。
加点形状 两点相连可以指定一根线,如果点与点之间的间距非常小,而点又非常密集时,点点之间相连即可以生成各式各样的曲线了。
ThreeJS 提供了多种曲线生成函数,主要分为二维曲线和三维曲线:
ThreeJS 中线的那些事
文章图片

  • ArcCurveEllipseCurve 分别绘制圆和椭圆的,EllipseCurveArcCurve 的基类;
  • LineCurveLineCurve3 分别绘制二维和三维的曲线(数学曲线的定义包括直线),他们都由起始点和终止点组成;
  • QuadraticBezierCurveQuadraticBezierCurve3CubicBezierCurveCubicBezierCurve3 分别是二维、三维、二阶、三阶贝塞尔曲线;
  • SplineCurveCatmullRomCurve3 分别是二维和三维的样条曲线,使用 Catmull-Rom 算法,从一系列的点创建一条平滑的样条曲线。
贝塞尔曲线与 CatmullRom 曲线的区别在于,CatmullRom 曲线可以平滑的通过所有点,一般用于绘制轨迹,而贝塞尔曲线通过中间点来构造切线。
  • 贝塞尔曲线
ThreeJS 中线的那些事
文章图片

  • CatmullRom 曲线
ThreeJS 中线的那些事
文章图片

这些构造函数通过参数生成曲线,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);

ThreeJS 中线的那些事
文章图片

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

ThreeJS 中线的那些事
文章图片

加点动画 到这个时候,我们的线已经有了宽度、颜色、形状,那么下一步该动起来了!动起来的实质是在每个渲染帧改变物体的某个属性,形成一定的连续效果,所以我们有两个思路去让线条动起来,一种是让线的几何体动起来,一种是让线的材质动起来,
流动的线
在材质动画中,使用最为频繁的是贴图流动。通过设置贴图的 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); }

ThreeJS 中线的那些事
文章图片

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; }

【ThreeJS 中线的那些事】如果要更改第一次渲染后渲染的点数,执行以下操作:
line.geometry.setDrawRange(0, newValue);

如果要在第一次渲染后更改 position 数值,则需要设置 needsUpdate 标志:
line.geometry.attributes.position.needsUpdate = true; // 需要加在第一次渲染之后

ThreeJS 中线的那些事
文章图片

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 ) ); // xyzthis.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) 坐标。例如我们创建一个场景并添加箭头辅助。
    ThreeJS 中线的那些事
    文章图片

  • 屏幕坐标
    在显示屏上的坐标就是屏幕坐标系。如下图所示,其中的 clientXclientY 的最值由,window.innerWidth,window.innerHeight 决定。
    ThreeJS 中线的那些事
    文章图片

  • 视点坐标
    视点坐标系就是以相机的中心点为原点,但是相机的位置,也是根据世界坐标系来偏移的,WebGL 会将世界坐标先变换到视点坐标,然后进行裁剪,只有在视线范围(视见体)之内的场景才会进入下一阶段的计算
    如下图添加了相机辅助线.
    ThreeJS 中线的那些事
    文章图片

如果想获取鼠标点击的坐标,就需要把屏幕坐标系转换为 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)。然后把屏幕坐标转化为 场景坐标系中的屏幕坐标位置。对应关系如下图所示。
ThreeJS 中线的那些事
文章图片

屏幕坐标系的原点为左上角,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: } } }

最后实现的效果
ThreeJS 中线的那些事
文章图片

Demo
如果稍加拓展,可以对交互进行更细致的优化,也可以在生成线之后对线材质的相关属性进行编辑,可以玩的花样就非常多了。
总结 线在图形绘制中一直是一个非常有意思的话题,可延伸的技术点也很多。从 OpenGL 中基本的线连接方式,到为线加一些宽度、颜色等效果,以及在编辑场景下如何实现画线功能。上述对 ThreeJS 中线的总结如果有任何问题,都欢迎一起讨论!
作者:ES2049 | Dell
文章可随意转载,但请保留原文链接。
非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj@alibaba-inc.com 。

    推荐阅读