iT邦幫忙

2022 iThome 鐵人賽

DAY 15
0
Software Development

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

Day15: three.js 3D圖表特效開發實戰:來人!餵公子吃餅:圓餅圖

  • 分享至 

  • xImage
  •  

我們既然都釐清了線段原理,那就應該要實作線段。我們是逃不了線段的實際應用的。來人!餵公子吃餅!

https://ithelp.ithome.com.tw/upload/images/20221017/20142505AnGQiW8KEE.png

圖片來源

準備程式碼

首先,我們先透過three.js範本開一個新的專案。我們直接沿用Day3時的範本即可:

CodePen

https://ithelp.ithome.com.tw/upload/images/20220930/20142505oosaPKRSEA.png

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

原始碼

  1. 複製貼上的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();
    
  2. 我刪除掉上次parentchild的物件,這次用不到。

    
        // 我把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();
    
  3. 我新增燈光,燈光的函式從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()
    
  4. 新增OrbitControl ,方便我們控制鏡頭(非必要)

    import { OrbitControls } from 'https://unpkg.com/three@latest/examples/jsm/controls/OrbitControls.js';
    
    // 在camera, renderer宣後之後加上這行
    new OrbitControls(camera, renderer.domElement);
    
  5. 把背景改成白色(非必要)

    scene.background = new THREE.Color(0xffffff)
    

目前的畫面:

https://ithelp.ithome.com.tw/upload/images/20220930/201425055gufi5yaSo.png

只看得到空白的畫面以及幾條黃線,那是輔助開發者釐清鏡頭位置的PointLightHelper、DirectionalLightHelper。

開發線段

圓餅圖的構成

  • 由弧形產生「餅」
  • 需要有直線

餅的實作

  1. 準備原始資料

    // 假設圖表拿到這筆資料
    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,
    ]
    
  2. 實作弧形。

    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軸為起始點,以逆時針方向繪製。

        https://ithelp.ithome.com.tw/upload/images/20220930/20142505TtxlNtgJoy.png

      • 我們知道圓形的周長的2PI,所以只要填入0~2PI的數值,就能表達起始/結束的角度了

        https://ithelp.ithome.com.tw/upload/images/20220930/2014250503EVfCjLFd.png

  3. 轉成二維座標點

    getPoints會在線段中取樣。比如我代入50,它就會將線段點出50個點。

    const curvePoints = curve.getPoints(50)
    
  4. 建立一個形狀

    const shape = new THREE.Shape(curvePoints)
    
  5. 將形狀當成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,可從點而來,這樣是為什麼我們在上一個步驟必須先轉成點。

    https://ithelp.ithome.com.tw/upload/images/20220930/201425059pnpPQcorc.png

  6. 以上都完成之後,一個弧形就完成了!

    https://ithelp.ithome.com.tw/upload/images/20220930/20142505Unm9dk9Chr.png

  7. 你會發現怪怪的地方:我們只會繪製出了弧線。但作為圓餅圖,必須應該從將弧線連到原點才行。

    https://ithelp.ithome.com.tw/upload/images/20220930/20142505PQBvipWjqk.png

    所以我們可以在繪製弧線之後,畫一條線段到原點,然後只用closePath()來封閉整個路徑。

    所以,我們需要兩個函式完成:lineTo(x,y)以及closePath()

    const shape = new THREE.Shape(curvePoints)
    // 新增下面兩行
    // 從當前的.currentPoint劃線到原點
    shape.lineTo(0,0)
    // 將整個線段的頭尾相連
    shape.closePath()
    const shapeGeometry = new THREE.ShapeGeometry(shape)
    

    https://ithelp.ithome.com.tw/upload/images/20220930/20142505wzcSVmPtAG.png

  8. 我們將建構餅的程式碼包成函式,方便製作多個餅。函式有三個參數: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()
    
  9. 我們由變數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)
    
  10. 我嫌光源的輔助線段有點醜,我把它關掉(非必要)

        // 新增點光
        const addPointLight = () => {
            ...
    -       scene.add(lightHelper);
            ...
        }
    
        // 新增平行光
        const addDirectionalLight = () => {
            ...
    -       scene.add(lightHelper);
            ...
        }
    

    https://ithelp.ithome.com.tw/upload/images/20220930/201425059oUaKqH63r.png

  11. 還是有一個問題:圓餅圖是扭曲的。當我們斜斜的看著圓餅圖,你看得出來扭曲。

    Untitled

    這個問題對於圖表視覺化造成很大的問題。光看這張圖,你會覺得紫色比藍色還要大塊,但其實藍色比紫色還大塊。

    這是因為「透視」問題。我們目前使用PerspectiveCamera,它是有透視(即有消失點)的鏡頭,它使畫面扭曲。為了避免扭曲,我們使用無透視的OrthographicCamera鏡頭。下面這張圖凸顯了兩者差別:
    https://ithelp.ithome.com.tw/upload/images/20221115/20142505cmatZdO0a9.jpg

    圖中可見扭曲的差異。如果今天要比較比例大小,假如是四個立方體的大小好了,那麼使用OrthographicCamera來比較差異就方便許多。那麼,該怎麼實作呢?

  12. 使用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一樣具有透視,所以它的參數也不太一樣。

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

    OrthographicCamera提供幾個參數,這些都由鏡頭的中心點(0,0)開始計算:

    1. left: number
    2. right: number
    3. top: number
    4. bottom: number

    從結果可以看到:即使將鏡頭轉到側面,紫色與藍色的比例一致。

    Untitled

我們的圓餅圖就完成了!

小結

目前我們只是用手工的方式製作2D圓餅圖,但當我們extrude它之後,它就可以成為3D,也能成為它最大的亮點。

CodePen

https://ithelp.ithome.com.tw/upload/images/20220930/20142505KEFXruMdOZ.png

https://codepen.io/umas-sunavan/pen/xxjWJbx?editors=1010

下篇我將介紹如何實作Extrude,並且加上動畫,使得用戶在操作時,都可以看到比一般圖表更立體的效果。

而這種圖表事實上還可以加上文字,而這個技術在「Day13: three.js 3D地球特效開發實戰:飛雷神之術走跳地球!—鏡頭追蹤與浮動文字」就有提到,有興趣的話可以深入研究。

下篇完成品

Untitled


上一篇
Day14: three.js 3D圖表特效開發實戰:繪畫就跟佐為下棋一樣簡單:線段原理
下一篇
Day16: three.js 3D圖表特效開發實戰:你爹只給個爛餅,大不了還你3D爛餅:用粉圓體做立體圓餅圖
系列文
30天成為鍵盤麥可貝:前端視覺特效開發實戰31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言