1

ThreeJS 中线的那些事

 2 years ago
source link: https://segmentfault.com/a/1190000041607875
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

一根线的诞生

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

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

简单来说,ThreeJS 提供了 LineBasicMaterialLineDashedMaterial 两类材质,主要控制线的颜色,宽度等;几何体主要控制线段断点的位置等,主要使用 BufferGeometry 这个基本几何类来创建线的几何体。同时也提供了一些线生成函数来帮助生成线几何体。

在 API 中提供了 Line LineLoop LineSegments 三类线相关的物体

先使用 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);

<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%;" />

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

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

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,简历请发送至 [email protected]


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK