ThreeJS 中線的那些事

ES2049發表於2022-03-28

在視覺化開發中,無論是二維的 canvas 還是三維開發,線條的繪製都是非常常見的,比如繪製城市之間的遷徙圖、運動軌跡圖等等。不管是在三維還是二維,所有物體都是由點構成、兩點構成線、三點構成面。那麼在 ThreeJS 中繪製一根簡單的線的背後又有哪些故事呢,本文將逐一解開。

一根線的誕生

在 ThreeJS 中,物體由幾何體(Geometry) 和材質(Material) 構成,物體以何種方式(點、線、面)展示取決於渲染方式(ThreeJS 提供了不同的物體建構函式)。

翻看 ThreeJS 的 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);

<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,簡歷請傳送至 caijun.hcj@alibaba-inc.com

相關文章