在視覺化開發中,無論是二維的 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 。