我們既然都釐清了線段原理,那就應該要實作線段。我們是逃不了線段的實際應用的。來人!餵公子吃餅!
首先,我們先透過three.js範本開一個新的專案。我們直接沿用Day3時的範本即可:
https://codepen.io/umas-sunavan/pen/YzLZvpM
複製貼上的index.js,然後開一個html來引用即可。可以參考Codepen。
import * as THREE from 'three';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 3, 15)
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild( renderer.domElement );
const geometry = new THREE.BoxGeometry(1,1,1)
const material = new THREE.MeshNormalMaterial({color: 0x0000ff})
const parent = new THREE.Mesh(geometry, material);
const child = new THREE.Mesh(geometry, material);
scene.add(parent);
parent.add(child);
parent.position.x = 10
child.position.x = 5
function animate() {
parent.rotation.y += 0.01;
requestAnimationFrame( animate );
renderer.render( scene, camera );
}
animate();
我刪除掉上次parent
跟child
的物件,這次用不到。
// 我把parent跟child相關程式刪除
- const geometry = new THREE.BoxGeometry(1,1,1)
- const material = new THREE.MeshNormalMaterial({color: 0x0000ff})
- const parent = new THREE.Mesh(geometry, material);
- const child = new THREE.Mesh(geometry, material);
- scene.add(parent);
- parent.add(child);
- parent.position.x = 10
- child.position.x = 5
function animate() {
// 我把parent跟child相關程式刪除
- parent.rotation.y += 0.01;
}
animate();
我新增燈光,燈光的函式從Day9的程式碼而來
CodePen
https://codepen.io/umas-sunavan/pen/xxjXggj
所新增的程式碼:
// 新增環境光
const addAmbientLight = () => {
const light = new THREE.AmbientLight(0xffffff, 0.5)
scene.add(light)
}
// 新增點光
const addPointLight = () => {
const pointLight = new THREE.PointLight(0xffffff, 1)
scene.add(pointLight);
pointLight.position.set(3, 3, 0)
pointLight.castShadow = true
// 新增Helper
const lightHelper = new THREE.PointLightHelper(pointLight, 20, 0xffff00)
scene.add(lightHelper);
// 更新Helper
lightHelper.update();
}
// 新增平行光
const addDirectionalLight = () => {
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
directionalLight.position.set(20, 20, 0)
scene.add(directionalLight);
directionalLight.castShadow = true
const d = 10;
directionalLight.shadow.camera.left = - d;
directionalLight.shadow.camera.right = d;
directionalLight.shadow.camera.top = d;
directionalLight.shadow.camera.bottom = - d;
// 新增Helper
const lightHelper = new THREE.DirectionalLightHelper(directionalLight, 20, 0xffff00)
scene.add(lightHelper);
// 更新位置
directionalLight.target.position.set(0, 0, 0);
directionalLight.target.updateMatrixWorld();
// 更新Helper
lightHelper.update();
}
addAmbientLight()
addDirectionalLight()
addPointLight()
新增OrbitControl
,方便我們控制鏡頭(非必要)
import { OrbitControls } from 'https://unpkg.com/three@latest/examples/jsm/controls/OrbitControls.js';
// 在camera, renderer宣後之後加上這行
new OrbitControls(camera, renderer.domElement);
把背景改成白色(非必要)
scene.background = new THREE.Color(0xffffff)
目前的畫面:
只看得到空白的畫面以及幾條黃線,那是輔助開發者釐清鏡頭位置的PointLightHelper、DirectionalLightHelper。
準備原始資料
// 假設圖表拿到這筆資料
const data = [
{rate: 14.2, name: '動力控制IC'},
{rate: 32.5, name: '電源管理IC'},
{rate: 9.6, name: '智慧型功率IC'},
{rate: 18.7, name: '二極體Diode'},
{rate: 21.6, name: '功率電晶體Power Transistor'},
{rate: 3.4, name: '閘流體Thyristor'},
]
// 我準備了簡單的色票,作為圓餅圖顯示用的顏色
const colorSet = [
0x729ECB,
0xA9ECD5,
0xA881CB,
0xF3A39E,
0xFFD2A1,
0xBBB5AE,
0xE659AB,
0x88D9E2,
0xA77968,
]
實作弧形。
const curve = new THREE.EllipseCurve(
0,0, // 橢圓形的原點
5,5, // X軸的邊長、Y軸的邊長
0, Math.PI * 0.5, // 起始的角度、結束的角度(90度)
false,// 是否以順時鐘旋轉
0//旋轉橢圓
)
你會發現:弧形怎麼不是三維的?
弧形不是三維的,但在待會轉成形狀時,就可以從二維變成三維的。在three.js裡面,橢圓形並不提供三維,然而CubicBezierCurve、QuadraticBezierCurve則提供三維的曲線物件。只要有提供三維的曲線物件,就以3結尾;如果是二維的,就沒有後綴詞。舉例來說,二維的有:
CubicBezierCurve
LineCurve
QuadraticBezierCurve
EllipseCurve
ArcCurve
⇒ 乃EllipseCurve
的別名三維的以3結尾:
CubicBezierCurve3
LineCurve3
QuadraticBezierCurve3
CatmullRomCurve3
起始的角度跟結束的角度分別要填入什麼?
起始的角度以正X軸為起始點,以逆時針方向繪製。
我們知道圓形的周長的2PI,所以只要填入0~2PI的數值,就能表達起始/結束的角度了
轉成二維座標點
getPoints
會在線段中取樣。比如我代入50,它就會將線段點出50個點。
const curvePoints = curve.getPoints(50)
建立一個形狀
const shape = new THREE.Shape(curvePoints)
將形狀當成Geometry
const shapeGeometry = new THREE.ShapeGeometry(shape)
const shapeMaterial = new THREE.MeshBasicMaterial({color: colorSet[0]})
const mesh = new THREE.Mesh(shapeGeometry, shapeMaterial)
scene.add(mesh)
為什麼要將曲線轉成點,再由點轉成形狀?
如果要將物件渲染到畫面上,它必須要有形狀Geometry。從上一篇的圖表可以看到,如果要製作成ExdtrudeGeometry或是ShapeGeometry,都必須由Shape而來,而要製作Shaper,可從點而來,這樣是為什麼我們在上一個步驟必須先轉成點。
以上都完成之後,一個弧形就完成了!
你會發現怪怪的地方:我們只會繪製出了弧線。但作為圓餅圖,必須應該從將弧線連到原點才行。
所以我們可以在繪製弧線之後,畫一條線段到原點,然後只用closePath()來封閉整個路徑。
所以,我們需要兩個函式完成:lineTo(x,y)
以及closePath()
const shape = new THREE.Shape(curvePoints)
// 新增下面兩行
// 從當前的.currentPoint劃線到原點
shape.lineTo(0,0)
// 將整個線段的頭尾相連
shape.closePath()
const shapeGeometry = new THREE.ShapeGeometry(shape)
我們將建構餅的程式碼包成函式,方便製作多個餅。函式有三個參數:startAngle
, endAngle
, color
分別代表起始與結束的弧度,以及顏色。
const createPie = (startAngle, endAngle, color) => {
const curve = new THREE.EllipseCurve(
0,0, // 橢圓形的原點
5,5, // X軸的邊長、Y軸的邊長
0, Math.PI * 0.5, // 起始的角度、結束的角度(90度)
false,// 是否以順時鐘旋轉
0//旋轉橢圓
)
const curvePoints = curve.getPoints(50)
const shape = new THREE.Shape(curvePoints)
shape.lineTo(0,0)
shape.closePath()
const shapeGeometry = new THREE.ShapeGeometry(shape)
const shapeMaterial = new THREE.MeshBasicMaterial({color: colorSet[0]})
const mesh = new THREE.Mesh(shapeGeometry, shapeMaterial)
scene.add(mesh)
return mesh
}
createPie()
我們由變數data來生成圓餅。
const dataToPie = (data) => {
// 我用sum來記憶上一個餅的結束位置,使得每個餅都從上一個結束位置開始繪製。
let sum = 0
data.forEach( (datium,i) => {
// 將百分比轉換成0~2PI的弧度
const radian = datium.rate/100 * (Math.PI * 2)
createPie(sum, radian+sum, colorSet[i])
sum+=radian
})
}
dataToPie(data)
我嫌光源的輔助線段有點醜,我把它關掉(非必要)
// 新增點光
const addPointLight = () => {
...
- scene.add(lightHelper);
...
}
// 新增平行光
const addDirectionalLight = () => {
...
- scene.add(lightHelper);
...
}
還是有一個問題:圓餅圖是扭曲的。當我們斜斜的看著圓餅圖,你看得出來扭曲。
這個問題對於圖表視覺化造成很大的問題。光看這張圖,你會覺得紫色比藍色還要大塊,但其實藍色比紫色還大塊。
這是因為「透視」問題。我們目前使用PerspectiveCamera
,它是有透視(即有消失點)的鏡頭,它使畫面扭曲。為了避免扭曲,我們使用無透視的OrthographicCamera
鏡頭。下面這張圖凸顯了兩者差別:
圖中可見扭曲的差異。如果今天要比較比例大小,假如是四個立方體的大小好了,那麼使用OrthographicCamera
來比較差異就方便許多。那麼,該怎麼實作呢?
使用OrthographicCamera
取代PerspectiveCamera
+ const windowRatio = window.innerWidth / window.innerHeight
+ const camera = new THREE.OrthographicCamera(-windowRatio * 10, windowRatio * 10, 10, -10, 0.1,1000)
- const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
由於OrthographicCamera
不像PerspectiveCamera
一樣具有透視,所以它的參數也不太一樣。
OrthographicCamera
提供幾個參數,這些都由鏡頭的中心點(0,0)開始計算:
left
: numberright
: numbertop
: numberbottom
: number從結果可以看到:即使將鏡頭轉到側面,紫色與藍色的比例一致。
我們的圓餅圖就完成了!
目前我們只是用手工的方式製作2D圓餅圖,但當我們extrude它之後,它就可以成為3D,也能成為它最大的亮點。
https://codepen.io/umas-sunavan/pen/xxjWJbx?editors=1010
下篇我將介紹如何實作Extrude,並且加上動畫,使得用戶在操作時,都可以看到比一般圖表更立體的效果。
而這種圖表事實上還可以加上文字,而這個技術在「Day13: three.js 3D地球特效開發實戰:飛雷神之術走跳地球!—鏡頭追蹤與浮動文字」就有提到,有興趣的話可以深入研究。