iT邦幫忙

2022 iThome 鐵人賽

DAY 16
0
Software Development

30天成為鍵盤麥可貝:前端視覺特效開發實戰系列 第 16

Day16: three.js 3D圖表特效開發實戰:你爹只給個爛餅,大不了還你3D爛餅:用粉圓體做立體圓餅圖

  • 分享至 

  • xImage
  •  

前一篇我們透過線段,實作出面來,然而光這樣不夠。畢竟我們在學3D視覺特效,不能只給出了個2D,否則就像當年包龍星他爹給的爛餅一樣。不過沒關係,本篇將實作出3D立體圓餅加以奉還!

為什麼要製作圓餅圖?

對於嘗試開發3D特效來說,圓餅圖的製作可以由數值產生弧度,由弧度產生線段,由線段產生面,再由面產生3D物件。整條流程使得我們接觸每一個層面的製作。對於實戰線段來說再適合不過。不僅如此,其所用到的弧度、三角函數、Extrude等製作方法,都能夠深度的解釋。

不僅如此,在實際應用上,圓餅圖不僅能夠增加高級感、獨特性,其增加的第三個維度「高度」也能呈現另一個維度的資料,使得圓餅圖不再只是呈現比例,還能有其他用途。

本篇內容

本篇,我將示例如何將平面的圓餅圖轉成立體,並加上文字。事實上,除了立體化以外,圓餅圖還能點擊、Hover以及更多的互動。但我這邊就先聚焦在立體化以及文字上。

完成品

Untitled

加上貼圖、光影、旋轉後的圓餅圖

Untitled

Untitled

準備開發

我們由上一篇開發完的程式碼繼續,這邊附上CodePen可以直接複製程式碼。

CodePen

https://codepen.io/umas-sunavan/pen/xxjWJbx

我們也可以使用下面的程式碼開始:

import * as THREE from 'three';
import { OrbitControls } from 'https://unpkg.com/three@latest/examples/jsm/controls/OrbitControls.js';

const scene = new THREE.Scene();

const windowRatio = window.innerWidth / window.innerHeight
const camera = new THREE.OrthographicCamera(-windowRatio * 10, windowRatio * 10, 10, -10, 0.1,1000)
camera.position.set(0, 3, 15)

// 假設圖表拿到這筆資料
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 createPie = (startAngle, endAngle, color, depth) => {
	const curve = new THREE.EllipseCurve(
		0,0,
		5,5,
		startAngle, endAngle,
		false,
		0
	)
	console.log(startAngle, endAngle);
	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: color})
	const mesh = new THREE.Mesh(shapeGeometry, shapeMaterial)
	scene.add(mesh)
	return mesh
}

const dataToPie = (data) => {
	let sum = 0
	data.forEach( (datium,i) => {
		const radian = datium.rate/100 * (Math.PI * 2)
		createPie(sum, radian+sum, colorSet[i], radian)
		sum+=radian
	})
}

dataToPie(data)

// 新增環境光
const addAmbientLight = () => {
	const light = new THREE.AmbientLight(0xffffff, 0.6)
	scene.add(light)
}

// 新增點光
const addPointLight = () => {
	const pointLight = new THREE.PointLight(0xffffff, 0.2)
	scene.add(pointLight);
	pointLight.position.set(3, 3, 3)
	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, 20)
	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()

const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild( renderer.domElement );

// 在camera, renderer宣告後之後加上這行
new OrbitControls(camera, renderer.domElement);

scene.background = new THREE.Color(0xffffff)
function animate() {
	requestAnimationFrame( animate );
	renderer.render( scene, camera );
}
animate();

開發立體圓餅圖

Extrude圓餅圖的方法

上一篇我們提到,線段本身並沒有Mesh。

渲染的底層原理說起

我這邊再重新補充一次:我們之所以可以在畫面中看到一個物件,是因為我們在渲染一個物件時,給定three.js一個Geometry以及一個Material來得到一個Mesh。那為什麼three.js可以透過這兩個東西去渲染物件?那是因為在底層的WebGL由vertexShader以及fragmentShader所組成。前者透過Geometry抓到錨點在螢幕上的位置,後者得到前者的錨點位置再指定每一個像素的顏色。

https://ithelp.ithome.com.tw/upload/images/20221001/20142505CG2Dwkpbc0.png

所以說,如果沒有錨點資料能夠提供給three.js作為Geometry,那three.js就沒辦法把錨點資料傳送給WebGL裡面的vertexShader,那麼fragmentShader也沒辦法依據錨點給定顏色。

線段要渲染的方式

由上面可以得知,線段終究要組成Geometry才能渲染在像素上。而要組成Geometry有很多種方式,下面是一張我整理的圖表,箭頭代表物件可以轉換的對象。

https://ithelp.ithome.com.tw/upload/images/20221001/20142505dH7owappor.png

藍色表示線段可以轉成的Geometry。你可以看到主要有四個:ShapeGeometryExtrudeGeometryBufferGeometryTubeGeometry 四種。

這四個Geometry 的差異如下:

  • ShapeGeometry :產生一個具有面的形狀
  • ExtrudeGeometry:產生一個具有體積的物體
  • BufferGeometry:由用戶代入錨點位置而不指定任何作用。所以它有可能是三角面位置資訊,也可能是三角面Normal資訊,有可能是其他資訊。
  • TubeGeometry:沿著線段產生一條「水管」

以目前圓餅圖專案來說:

在上一篇,由於只需要做出平面的圓餅圖,所以我們使用ShapeGeometry。而今要做出3D的圓餅圖,那麼是用ExtrudeGeometry最為合適。

-   const shapeGeometry = new THREE.ShapeGeometry(shape)
+   const shapeGeometry = new THREE.ExtrudeGeometry(shape, {
+       depth: depth*2, // 隆起高度
+       steps: 1, // 在隆起的3D物件中間要切幾刀線
+       bevelEnabled: false, // 倒角(隆起向外擴展)
+     })

ExtrudeGeometry 提供很多參數,先講三個:

  • Depth:隆起高度
  • Step:在隆起的3D物件中間要切幾刀線
  • bevelEnabled:在隆起的Extrude面上是否要再向外擴展

可見下圖:

https://ithelp.ithome.com.tw/upload/images/20221001/20142505bmTEPPFfcP.png

最後完成會長這樣:

https://ithelp.ithome.com.tw/upload/images/20221001/20142505DHCZiJNrGF.png

我們的形狀長出來了,然而沒有陰影使人看不清楚圖表。

為什麼會沒有陰影?那是因為我們所選用的材質為MeshBasicMaterial

Material可以比擬為物件所穿著的衣服,MeshBasicMaterial 使得顏色都一致。但如果要製作一個有亮暗面的材質,使用其它種Material即可。我都使用MeshStandardMaterial,原因是因為在3D建模軟體輸出時(例如在Maya使用Verge3D輸出GLTF格式模型),MeshStandardMaterial為常見的材質輸出結果。由此可以獲得GLTF格式的最大相容性。

修改材質

-    const shapeMaterial = new THREE.MeshBasicMaterial({color: color})
+    const shapeMaterial = new THREE.MeshStandardMaterial({color: color})

修改後,就可以看到效果:

https://ithelp.ithome.com.tw/upload/images/20221001/20142505JWm5bH01v7.png

高度不一致問題

雖然有了立體感,但看過去不是那麼清楚。主要問題是高度不一。高高低低的,很難讓人比較各個餅之間的差異。

為了解決這個問題,我們幫餅排序即可。

// 在data進入forEach之前加上sort即可
data = data.sort((a,b) => b.rate - a.rate)

https://ithelp.ithome.com.tw/upload/images/20221001/20142505u28Nrr4D7M.png

可以看到餅已經清楚很多。

邊界銳利問題

接著你會發現,邊緣太銳利了,很沒有質感。

要做到最好看的特效,當然不能放著這個不管,畢竟銳利的邊緣使人感到東西廉價。

前面我們在extrude時,有一個參數叫做bevel,打開它即可。

const shapeGeometry = new THREE.ExtrudeGeometry(shape, {
    ...
	bevelEnabled: false, // 倒角(隆起向外擴展)
  })

https://ithelp.ithome.com.tw/upload/images/20221001/201425053PYM7op46O.png

在以前學校的Maya老師稱它倒角。倒角有四個參數,下面我用圖片解釋:

  • bevelSize: extrude方向如果是向上,那這參數調整左右向外擴張的程度
  • bevelThickness: extrude方向如果是向上,那這參數調整倒角向上增厚的程度
  • bevelOffset: 製作倒角之前的位移
  • bevelSegments: 倒角的細緻度

https://ithelp.ithome.com.tw/upload/images/20221115/20142505fUMTbg4g6I.jpg

必須注意跟Maya的不同

在Maya使用Bevel時,並沒有新增度厚度,而是直接向內切出倒角,這個概念跟three.js非常不同。如果是建模師在認識three.js的Bevel時,必須注意。

Untitled

我們釐清原理之後,參數設定上更加方便。直接加上參數就能夠完成Bevel,使得我們的模型更有高級感。

https://ithelp.ithome.com.tw/upload/images/20221001/20142505LOdqGaiYvt.png

Bevel重疊問題

可以發覺不預期的顏色跑出來了。

https://ithelp.ithome.com.tw/upload/images/20221001/20142505AqAcnU5Jo9.png

這是因為Bevel互相重疊導致。

https://ithelp.ithome.com.tw/upload/images/20221001/20142505Tziuv03c7O.png

為了解決這個問題,我們只要把餅從原點向外位移即可。

https://ithelp.ithome.com.tw/upload/images/20221001/201425055aUwOUCCKe.png

要做這樣的,首先需要知道箭頭方向。

箭頭的方向,就使弧線的起始角度與終點角度的中間值。

https://ithelp.ithome.com.tw/upload/images/20221002/2014250507qwj5W6Wf.png

const middleAngle = (startAngle + endAngle) / 2

取得中間的角度之後,我們透過三角函數,算出其角度所指的單位向量,並乘上倒角的大小(0.2)

https://ithelp.ithome.com.tw/upload/images/20221001/20142505gtBy3wEQOr.png

由上圖可知,中間的角度可以拆成X,Y兩軸的長度,而X數值為向量的斜邊分之對邊,Y數值為向量的斜邊分之鄰邊。

const x = Math.cos(middleAngle)
const y = Math.sin(middleAngle)

接著只要再乘上倒角大小即可。

shapeGeometry.translate(x*0.2, y*0.2, 0)

https://ithelp.ithome.com.tw/upload/images/20221001/20142505iZGAW0dfU4.png

這樣就完成了。

你看得到還是有一點瑕疵,那是因為我們向外移動的不夠多。然而一旦我們向外,圓餅圖的中心又會中空。

https://ithelp.ithome.com.tw/upload/images/20221001/20142505vJqZnu1Zuu.png

要解決這個問題,就是在實例化餅之前,先預留Bevel的角度。但由於篇幅關係我就暫時不討論這個問題的解決解法,我先繼續專注在餅的開發。因為我們的餅目前為止只有顏色跟比例,如果沒有加上圖例(Legend)的話,沒有人知道這些餅的意義。

加上圖例

匯入粉圓體:FontLoader

如同在「Day13: three.js 3D地球特效開發實戰:飛雷神之術走跳地球!—鏡頭追蹤與浮動文字」所提到的浮動文字製作方法一樣,我們需要準備字體。字體選用開源字體粉圓體

import { FontLoader } from 'https://unpkg.com/three@latest/examples/jsm/loaders/FontLoader.js';
import { TextGeometry } from 'https://unpkg.com/three@latest/examples/jsm/geometries/TextGeometry.js';

const loader = new FontLoader();
loader.load( 'https://storage.googleapis.com/umas_public_assets/michaelBay/day13/jf-openhuninn-1.1_Regular_cities.json', function ( font ) {
	//所有網頁邏輯
})

我們所有的邏輯都等到字體載入之後才會執行。

字體方面使用粉圓體,下載之後透過facetype.js將字體檔轉成json格式,再匯入到專案中即可。

https://ithelp.ithome.com.tw/upload/images/20221001/20142505vAljLuwIgQ.png

建立字體函式

文字Mesh如同其它種類的3D物件一樣,都需要形狀跟材質才能實例化。這邊使用TextGeometry以由字體產生錨點,最終能夠做成形狀。

// 該函式新增文字Mesh
const addText = (text, color) => {
	// 文字geometry
	const textGeometry = new TextGeometry( text, {
		font: font, //字體
		size: 2,//大小
		height: 0.01,//文字厚度
		curveSegments: 2, // 文字中曲線解析度
		bevelEnabled: false, // 是否用bevel
	} );
	const textMaterial = new THREE.MeshBasicMaterial({color: color})
	const textMesh = new THREE.Mesh(textGeometry, textMaterial)
	scene.add(textMesh)
	return textMesh
}
// 執行函式測試一下
const text =addText('openhuninn', 0xfff000)
// 防止文字被圓餅圖擋住
text.position.z = 8

新增後可以看到文字照常呈現。

Untitled

每個餅都加上文字

createPie()裡面執行addText(),使得每個餅都可以加上文字

const createPie = (startAngle, endAngle, color, depth) => {
	...
	const text = addText('openhuninn', color)
	...
}

雖然每個餅都有文字了,但還有兩個問題:

  1. 文字內容不正確,必須得知每個圓餅圖的內容標題才能生成文字。解決方法:目前文字內容在函式createPie()外層,只要傳入內層即可。
  2. 文字需要位在餅旁邊,目前所有文字的位置都是原點。解決方法:我們必須知道餅的角度及邊長,才可以透過三角函數產生文字位置。幸虧前面已經有透過三角函數來將餅向外移動了,我們只要拿來用就好。

先處理文字問題。將文字傳入函式中:

// 加上參數legned
+   const createPie = (startAngle, endAngle, color, depth, legend) => {
-   const createPie = (startAngle, endAngle, color, depth) => {
        ...
    // 使用legend作為文字內容
+       const text = addText(legend, color)
-       const text = addText('openhuninn', color)
        ...
    })
    // 執行函式的地方也必須把參數補齊
    createPie(..., ..., ..., ..., datium.name)

https://ithelp.ithome.com.tw/upload/images/20221001/20142505Kml6dFL2mZ.png

接下來安排文字的位置。透過已經算好的三角函數,再乘上半徑即可。

const middleAngle = (startAngle + endAngle) / 2
const x = Math.cos(middleAngle)
const y = Math.sin(middleAngle)
// 由於圓餅圖半徑為5,所以我設比它高一點,8.5
const textDistance = 8.5
text.geometry.translate(x*textDistance,y*textDistance,0)
// 修正文字置左時的偏移
text.geometry.translate(x-([...legend].length)*0.2,y,0)

我順便修改了文字大小

const textGeometry = new TextGeometry( text, {
		...
		// 我修改了文字大小
-		size: 2,
+		size: 0.5,
		...
	} );

https://ithelp.ithome.com.tw/upload/images/20221001/20142505EBnjr0HDuF.png

使文字面向鏡頭

這招我們也有在「Day13: three.js 3D地球特效開發實戰:飛雷神之術走跳地球!—鏡頭追蹤與浮動文字」使用過,我這邊就不詳述細節。

function animate() {
	// 遞回每一個3D文字物件
	texts.forEach( text => {
		// 使3D文字「幾乎」看向鏡頭,同時仍被方向影響,以增加視覺豐富度
		text.lookAt(...new THREE.Vector3(0,0,1).lerp(camera.position, 0.05).toArray())
	})
	...
}

完成品

Untitled

CodePen

https://codepen.io/umas-sunavan/pen/LYmmbZM

小結

事實上,圓餅圖特效還可以加上貼圖、動畫等等先前介紹過的元素,使得畫面更加豐富。

圓餅圖可以運用的特效非常多,而且不只圓餅圖,其他種類的圖表也能加以發揮。

Untitled

Untitled

圓餅圖介紹到這邊,希望可以藉此使更多人熟悉線段的開發。


上一篇
Day15: three.js 3D圖表特效開發實戰:來人!餵公子吃餅:圓餅圖
下一篇
Day17: three.js GIS系統開發實戰:鄉鎮市區GIS系統:SVG、GeoJson的應用
系列文
30天成為鍵盤麥可貝:前端視覺特效開發實戰31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言