# Three.js 學習筆記 ## 環境建置 安裝 `node` 與 `git` :用 Google 搜尋直接到官網下載即可。 全局安裝 `typescript` :終端機執行命令 `npm install --location=global typescript` 。 創建專案項目:在任何想要生成專案項目的位置建立一個任意名稱的資料夾,可識別即可。 初始化 `package.json` :終端機 cd 到專案項目中,執行命令 `npm init` 一路按 Enter 到底。 安裝 `three.js` :終端機 cd 到專案項目中,執行命令 `npm install three --save-dev` 。 建立專案架構:新增資料夾 `dist/client` 、 `dist/server` 、 `src/client` `src/server` 。 安裝 `typescript` 版本的 `three.js` :終端機 cd 到專案項目中,執行命令 `npm install @types/three --save-dev` 。 安裝 `webpack` :終端機 cd 到專案項目中,執行命令 `npm install webpack webpack-cli webpack-dev-server webpack-merge ts-loader --save-dev` 。 ## 檔案建立 建立檔案 `dist/client/index.html` 並於 `body` 中添加 `<script type="module" src="bundle.js"></script>` 建立檔案 `src/client/client.ts` 貼上[官網的範例程式碼](https://threejs.org/docs/#manual/en/introduction/Creating-a-scene) 建立檔案 `src/client/tsconfig.json` : ```json { "compilerOptions": { "target": "ES6", "moduleResolution": "node", "strict": true, "allowSyntheticDefaultImports": true }, "include": ["**/*.ts"] } ``` 建立檔案 `src/client/webpack.common.js` : ```javascript const path = require('path') module.exports = { entry: './src/client/client.ts', module: { rules: [ { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/, }, ], }, resolve: { extensions: ['.tsx', '.ts', '.js'], }, output: { filename: 'bundle.js', path: path.resolve(__dirname, '../../dist/client'), }, } ``` 建立檔案 `src/client/webpack.dev.js` : ```javascript const { merge } = require('webpack-merge') const common = require('./webpack.common.js') const path = require('path') module.exports = merge(common, { mode: 'development', devtool: 'eval-source-map', devServer: { static: { directory: path.join(__dirname, '../../dist/client'), }, hot: true, }, }) ``` 建立檔案 `src/client/webpack.prod.js` : ```javascript const { merge } = require('webpack-merge') const common = require('./webpack.common.js') module.exports = merge(common, { mode: 'production', performance: { hints: false, }, }) ``` ## 啟動項目 將 `package.json` 中的 `scripts` 添加啟動用的命令: ```json { // ... "scripts": { "build": "webpack --config ./src/client/webpack.prod.js", // 添加這行,用 webpack 編譯生產環境用的程式碼,載入的 js 檔案會小很多 "dev": "webpack serve --config ./src/client/webpack.dev.js", // 添加這行,用 webpack 啟動開發模式,可以快速顯示無需編譯,但載入的 js 檔案會很大 "test": "echo \"Error: no test specified\" && exit 1" }, // ... } ``` 於項目根目錄執行命令: `npm run dev` ,即可在 `http://localhost:8080/` 開啟專案的 `index.html` 檔案查看渲染後畫面。 於項目根目錄執行命令: `npm run build` ,即可編譯出 `/dist/client/bundle.js` 檔案,直接用 live server 開啟 `index.html` 即可看到渲染後畫面。 ## 基礎程式碼解析 在前面步驟中有添加檔案 `src/client/client.ts` 裡面的程式碼即為使用 Three.js 編寫的內容,在 Three.js 中主要用三大項目組成看到的結果,分別是場景(scene)、攝影機(camera)及渲染器(renderer),基礎程式碼細節如下: ```typescript // 引入 three.js import * as THREE from 'three' // 建立場景 const scene = new THREE.Scene() // 建立攝影機 const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 ) // 建立渲染器 const renderer = new THREE.WebGLRenderer() renderer.setSize(window.innerWidth, window.innerHeight) document.body.appendChild(renderer.domElement) // 指定物件的造型, BoxGeometry 是立方體 const geometry = new THREE.BoxGeometry() const material = new THREE.MeshBasicMaterial({ color: 0x00ff00, // 設置物件外觀顏色 }) // 建立方塊物件 const cube = new THREE.Mesh(geometry, material) // 往場景中添加方塊物件 scene.add(cube) // 設定動畫讓物件自動旋轉 function animate() { requestAnimationFrame(animate) cube.rotation.x += 0.01 cube.rotation.y += 0.01 // 渲染物件,指定其場景與攝影機 renderer.render(scene, camera) } animate() ``` ## Three.js 的模塊用法 在 Three.js 中提供了許多方便使用的模塊,比如 OrbitControls 可以讓物件被滑鼠操控(旋轉、移動、縮放等等),這些模塊需要額外引入才能使用: ```typescript import * as THREE from 'three' // 從 Three.js 中引入 OrbitControls 模塊 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' // 建立場景、攝影機、渲染器的部分皆與上一個例子相同 document.body.appendChild(renderer.domElement) // 在 appendChild 後添加這行,並指定其攝影機與渲染器,讓渲染的物件產生控制 new OrbitControls(camera, renderer.domElement) // 剩下內容不變 ``` 接著即可通過按住滑鼠進行任意角度的旋轉、利用滑鼠滾輪控制物件的縮放、點擊 command+按著滑鼠任意移動物件。 ## 關於 animate&render 在上述例子中的最下方有一段程式碼是用來讓物件自動旋轉的動畫: ```typescript // 創建 animate 函數用來設定動畫要執行的內容 function animate() { requestAnimationFrame(animate) // 於每次切換 Frame 時重新呼叫 animate 函數 cube.rotation.x += 0.01 // 讓物件的 x 軸方向旋轉角度持續 ++ cube.rotation.y += 0.01 // 讓物件的 y 軸方向旋轉角度持續 ++ render() // 執行 render 函數 } // 創建 render 函數用來讓渲染器進行畫面渲染 function render() { renderer.render(scene, camera) } animate() // 手動呼叫 animate 函數 ``` > 假設不執行 `render` 函數,則動畫僅在後台持續執行,前台將無法看到任何物件。 假設不需要執行任何動畫,則不需建立 `animate` 函數,僅需手動呼叫 `render` 函數: ```typescript function render() { renderer.render(scene, camera) } render() ``` 假設不需要執行任何動畫,但有引入 `OrbitControls` ,希望可以看到物件更新,則監聽 `OrbitControls` 的 `change` 事件並執行 `render` 函數: ```typescript const controls = new OrbitControls(camera, renderer.domElement) controls.addEventListener('change',render) function render() { renderer.render(scene, camera) } render() ``` ## 顯示統計器 在開發的過程中可以通過統計器來查看當前電腦可執行的每秒幀數,通過在 `animate` 中更新統計器,可在每次更新時查看當前幀數監控性能,並持續改善程式碼: ```typescript // 引入統計器 import Stats from 'three/examples/jsm/libs/stats.module' // 宣告統計器並將其添加到 body 中 const stats = new Stats(); document.body.appendChild(stats.dom) function animate() { requestAnimationFrame(animate) stats.update() // 調用 stats.update() 讓統計數值實時更新 render() } function render() { renderer.render(scene, camera) } animate() ``` ## 添加 GUI 於項目根目錄中執行以下命令以安裝 GUI 套件: ```shell npm install dat.gui --save-dev npm install @types/dat.gui --save-dev ``` 使用: ```typescript import { GUI } from 'dat.gui' // 引入 GUI const gui = new GUI() // 新增 GUI const cubeFolder = gui.addFolder('Cube') // 往 GUI 添加資料夾名為 Cube cubeFolder.open() // 預設開啟資料夾 const cubeRotationFolder = cubeFolder.addFolder('Rotation') // 往 Cube 資料夾中添加新資料夾名為 Rotation cubeRotationFolder.add(cube.rotation, 'x', 0, Math.PI * 2) // 往 Rotation 資料夾中添加 cube.rotation 名為 x 最小值 0 最大值 2PI cubeRotationFolder.add(cube.rotation, 'y', 0, Math.PI * 2) // 往 Rotation 資料夾中添加 cube.rotation 名為 y 最小值 0 最大值 2PI cubeRotationFolder.add(cube.rotation, 'z', 0, Math.PI * 2) // 往 Rotation 資料夾中添加 cube.rotation 名為 z 最小值 0 最大值 2PI cubeRotationFolder.open() // 預設開啟資料夾 const cubePositionFolder = cubeFolder.addFolder('Position') // 往 Cube 資料夾中添加新資料夾名為 Position cubePositionFolder.add(cube.position, 'x', -10, 10) // 往 Position 資料夾中添加 cube.position 名為 x 最小值 -10 最大值 10 cubePositionFolder.add(cube.position, 'y', -10, 10) // 往 Position 資料夾中添加 cube.position 名為 y 最小值 -10 最大值 10 cubePositionFolder.add(cube.position, 'z', -10, 10) // 往 Position 資料夾中添加 cube.position 名為 z 最小值 -10 最大值 10 cubePositionFolder.open() // 預設開啟資料夾 const cubeScaleFolder = cubeFolder.addFolder('Scale') // 往 Cube 資料夾中添加新資料夾名為 Scale cubeScaleFolder.add(cube.scale, 'x', 0, 5) // 往 Scale 資料夾中添加 cube.scale 名為 x 最小值 0 最大值 5 cubeScaleFolder.add(cube.scale, 'y', 0, 5) // 往 Scale 資料夾中添加 cube.scale 名為 y 最小值 0 最大值 5 cubeScaleFolder.add(cube.scale, 'z', 0, 5) // 往 Scale 資料夾中添加 cube.scale 名為 z 最小值 0 最大值 5 cubeScaleFolder.open() // 預設開啟資料夾 const cameraFolder = gui.addFolder('Camera') // 往 GUI 添加資料夾名為 Camera cameraFolder.add(camera.position, 'z', 0, 10) // 往 Camera 資料夾中添加 camera.position 名為 z 最小值 0 最大值 10 cameraFolder.open() // 預設開啟資料夾 ``` GUI 是最外層,每個 Folder 各自為一個收合區塊,在 Folder 中添加 Folder 就會產生第二層收合區塊。 ## 添加輔助線 可以通過 `scene.add(new THREE.AxesHelper(5))` 添加輔助線,紅色為 X 軸、綠色為 Y 軸、藍色為 Z 軸。 ## 關於 Object3D 的層次結構 首先要知道,在 Three.js 中幾乎所有東西都是由 Object3D 衍伸出來的, Object3D 可用的方法有 position 、 scale 等等,諸如場景、相機其實也都是 Object3D 衍生的產物。 在範例程式碼中,可以通過 `scene.add(cube)` 往場景中添加方塊物件,而 `add` 方法也是 Object3D 可用的方法之一,同理,我們也可以往 cube 物件中添加新的 cube2 物件,結構上就會變成 scene => cube => cube2 。 在產生結構的狀況下,物件本身就會有 local 與 world 兩種數值,一種是根據其父物件相依賴下所得出的數值,另一種則是根據整個基礎場景所得出的數值: ```typescript const object = new THREE.Mesh( new THREE.SphereGeometry(), new THREE.MeshPhongMaterial({ color: 0xff0000 }) ) object.position.set(4, 0, 0) scene.add(object) // 獲取 world 的 Position const objectWorldPosition = new THREE.Vector3() object.getWorldPosition(objectWorldPosition) console.log(objectWorldPosition.x) ``` ## 材質 ### 通用設定 Three.js 中提供非常多不同的材質可直接套用,通過賦予材質可以更具體地針對每種屬性做深入理解,舉例來說可用以下程式碼觀察常見的幾個屬性: ```typescript import * as THREE from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import { GUI } from 'dat.gui' // 建立場景 const scene = new THREE.Scene() // 添加輔助線 scene.add(new THREE.AxesHelper(5)) // 建立相機 const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 ) // 指定相機位置 camera.position.z = 3 // 建立渲染器 const renderer = new THREE.WebGLRenderer() renderer.setSize(window.innerWidth, window.innerHeight) document.body.appendChild(renderer.domElement) // 建立控制器 new OrbitControls(camera, renderer.domElement) // 創建三種不同物件,分別是 球狀、二十面體、平面 const sphereGeometry = new THREE.SphereGeometry() const icosahedronGeometry = new THREE.IcosahedronGeometry(1, 0) const planeGeometry = new THREE.PlaneGeometry() // 建立普通材質,會讓每一面產生不同顏色外觀 const material = new THREE.MeshNormalMaterial() // 建立基礎材質,會給每一面添加純白外觀,該材質僅用於測試 alphaTest 屬性 // const material = new THREE.MeshBasicMaterial() // 設置球的位置在靠右 const sphere = new THREE.Mesh(sphereGeometry, material) sphere.position.x = 2.5 scene.add(sphere) // 設置二十面體在正中央 const icosahedron = new THREE.Mesh(icosahedronGeometry, material) icosahedron.position.x = 0 scene.add(icosahedron) // 設置平面在靠左 const plane = new THREE.Mesh(planeGeometry, material) plane.position.x = -2.5 scene.add(plane) // 添加 GUI const gui = new GUI() const materialFolder = gui.addFolder('THREE.Material') // 添加各種屬性的設定到 GUI 中 materialFolder.add(material, 'transparent').onChange(() => updateMaterial()) // 開關透明度,打開後 opacity 才會生效 materialFolder.add(material, 'opacity', 0, 1, 0.01) // 設置透明度數值 materialFolder.add(material, 'depthTest') // 開關深度測試,如果打開則會有深度敢,關閉時物件會像幅空一下沒有立體位置的感覺 materialFolder.add(material, 'depthWrite') // 開關深度寫入,如果打開物件之間就會互相覆蓋,關閉時物件之間則可穿透,假設最外層物件有透明度,關閉該選項即可看到後面的物件 materialFolder.add(material, 'alphaTest', 0, 1, 0.01).onChange(() => updateMaterial()) // 當材質為 MeshBasicMaterial 時,如果 alphaTest > opacity 就會看不到物件 materialFolder.add(material, 'visible') // 開關可見度,打開就會看不到物件 // 設置視角(side)的選項 const options = { side: { "FrontSide": THREE.FrontSide, "BackSide": THREE.BackSide, "DoubleSide": THREE.DoubleSide, } } materialFolder.add(material, 'side', options.side).onChange(() => updateMaterial()) // 設定視角,預設是正面,另外可選背面及雙向,正面時,從背面看平面物件會消失;背面時,從正面看平面物件會消失,正面時,立體物件只能看到外觀;背面時,立體物件只能看到內部;雙向時,立體物件放大後視角會變成內部 // 預設 GUI 開啟 materialFolder.open() // 建立函數用來更新材質 function updateMaterial() { material.side = Number(material.side) as THREE.Side // 設置 side 為選中項目 material.needsUpdate = true // 指定要重新編譯材料 } function animate() { requestAnimationFrame(animate) renderer.render(scene, camera) } animate() ``` ### MeshBasicMaterial ![](https://hackmd.io/_uploads/Sy8S2_Oyp.png) 基礎材質,不會受到光照影響。上圖示例主要呈現環境紋理反射與折射效果。 ```typescript // 建立 MeshBasicMaterial const material = new THREE.MeshBasicMaterial() // 設置紋理 const texture = new THREE.TextureLoader().load("img/grid.png") material.map = texture // 設置反射紋理,反射紋理通常用六張圖所組成,分別對應方塊的六面:正x、負x、正y、負y、正z、負z const envTexture = new THREE.CubeTextureLoader().load(["img/px_50.png", "img/nx_50.png", "img/py_50.png", "img/ny_50.png", "img/pz_50.png", "img/nz_50.png"]) // 設置反射方式 envTexture.mapping = THREE.CubeReflectionMapping // envTexture.mapping = THREE.CubeRefractionMapping material.envMap = envTexture const meshBasicMaterialFolder = gui.addFolder('THREE.MeshBasicMaterial') const data = { color: material.color.getHex(), } meshBasicMaterialFolder.addColor(data, 'color').onChange(() => { material.color.setHex(Number(data.color.toString().replace('#', '0x'))) }) // 設置外觀顏色 meshBasicMaterialFolder.add(material, 'wireframe') // 設置是否為純線框 //meshBasicMaterialFolder.add(material, 'wireframeLinewidth', 0, 10) // 設置線框寬度 但實際上看不出差異 meshBasicMaterialFolder.add(material, 'combine', options.combine).onChange(() => updateMaterial()) // 設置紋理疊加效果,預設是混合 meshBasicMaterialFolder.add(material, 'reflectivity', 0, 1) // 控制反射率 meshBasicMaterialFolder.add(material, 'refractionRatio', 0, 1) // 控制折射率 ``` - combine 有以下三種: - MultiplyOperation 是默認值,會將環境紋理顏色與表面顏色相乘。 - MixOperation 使用反射率來混合兩種顏色。 - AddOperation 添加兩種顏色。 - 環境紋理反射方式有兩種: - CubeReflectionMapping 反射,類似鏡子 - CubeRefractionMapping 折射,類似玻璃和水 ### MeshNormalMaterial ![](https://hackmd.io/_uploads/BJwp_2p03.png) 普通材質用法與常見設定: ```typescript // 建立 MeshNormalMaterial const material = new THREE.MeshNormalMaterial() meshNormalMaterialFolder.add(material, 'wireframe') // 設置是否為純線框 meshNormalMaterialFolder.add(material, 'flatShading').onChange(() => updateMaterial()) // 設置是否使用平面陰影渲染,會把圓形變成一塊一塊帶有陰影的方形 ``` > 預設會有彩色外觀,然後可以設置 flatShading 讓球體等圓弧形狀變成立方塊感 ### MeshLambertMaterial ![](https://hackmd.io/_uploads/S1uWOnTA3.png) 通常用於製作木頭或石頭 ```typescript // 該材質要有燈光照明,看起來才會有立體感 const light = new THREE.PointLight(0xffffff, 1000) light.position.set(10, 10, 10) scene.add(light) // 建立 MeshLambertMaterial const material = new THREE.MeshNormalMaterial() // 設定自發光顏色,讓物體在黑暗中發光 meshLambertMaterialFolder.addColor(data, 'emissive').onChange(() => { material.emissive.setHex(Number(data.emissive.toString().replace('#', '0x'))) }) // 設定陰影顏色 ``` > 該材質也可以設置 flatShading ### MeshPhongMaterial ![](https://hackmd.io/_uploads/Bk6kP2aAh.png) 在模擬閃亮的物體(例如拋光木材)時很有用。 由於該材質比 MeshLambertMaterial、MeshNormalMaterial、MeshBasicMaterial 使用更多效能,所以請確保僅在需要時使用。 ```typescript // 該材質要有燈光照明,看起來才會有立體感 const light = new THREE.PointLight(0xffffff, 1000) light.position.set(10, 10, 10) scene.add(light) // 設置反光的顏色 meshPhongMaterialFolder.addColor(data, 'specular').onChange(() => { material.specular.setHex(Number(data.specular.toString().replace('#', '0x'))) }); // 設置反光的範圍大小 meshPhongMaterialFolder.add(material, 'shininess', 0, 1024); ``` > 該材質也可以設置 flatShading ### MeshStandardMaterial ![](https://hackmd.io/_uploads/HkSscTp03.png) 利用其金屬性與粗糙程度可輕鬆做出各種不同的材質,但與 MeshPhongMaterial 相同,使用時需耗費更多效能,所以建議僅在需要時使用。 ```typescript // 該材質要有燈光照明,看起來才會有立體感 const light = new THREE.PointLight(0xffffff, 1000) light.position.set(10, 10, 10) scene.add(light) // 建立 MeshStandardMaterial const material = new THREE.MeshStandardMaterial() meshStandardMaterialFolder.add(material, 'roughness', 0, 1) // 設置材質表面的粗糙程度,越小越平滑 meshStandardMaterialFolder.add(material, 'metalness', 0, 1) // 設置金屬程度,會改面反光範圍及整體著色程度 ``` > 該材質也可以設置 flatShading ### MeshPhysicalMaterial ![](https://hackmd.io/_uploads/HJwvIxCAh.png) 延伸至 MeshStandardMaterial ,比 MeshStandardMaterial 多提供了一些與反射率相關的選項。 ```typescript // 該材質要有燈光照明,看起來才會有立體感 const light = new THREE.PointLight(0xffffff, 1000) light.position.set(10, 10, 10) scene.add(light) // 建立 MeshStandardMaterial const material = new THREE.MeshPhysicalMaterial() meshPhysicalMaterialFolder.add(material, 'reflectivity', 0, 1) // 控制反射率 meshPhysicalMaterialFolder.add(material, 'roughness', 0, 1) // 設置材質表面的粗糙程度,越小越平滑 meshPhysicalMaterialFolder.add(material, 'metalness', 0, 1) // 設置金屬程度,會改面反光範圍及整體著色程度 meshPhysicalMaterialFolder.add(material, 'clearcoat', 0, 1, 0.01) // 設定清漆強度 meshPhysicalMaterialFolder.add(material, 'clearcoatRoughness', 0, 1, 0.01) // 控制清漆層的粗糙度 meshPhysicalMaterialFolder.add(material, 'transmission', 0, 1, 0.01) // 控制物體的透明度,做出像水滴一樣的感覺 meshPhysicalMaterialFolder.add(material, 'ior', 1.0, 2.333) // 控制物體的折射率 meshPhysicalMaterialFolder.add(material, 'thickness', 0, 10.0) // 控制物體的厚度 ``` ### MeshMatcapMaterial ![](https://hackmd.io/_uploads/Hkqcte00h.png) 不需要燈光就能看到效果,只需要查找好素材,設置為 matcap ,就可以做出類似 MeshNormalMaterial 的效果,只是其顏色被替換為我們設置的 matcap ,然後球體怎麼旋轉或增加光線都會跟引入的 matcap 圖片長一樣。 matcap 下載:https://github.com/nidorx/matcaps ```typescript // 建立 MeshMatcapMaterial const material = new THREE.MeshMatcapMaterial() // 設置外觀素材(搜尋 matcap texture 就可以找到很多) const matcapTexture = new THREE.TextureLoader().load("img/matcap-red-light.png") material.matcap = matcapTexture ``` > 該材質沒有 material.envMap 屬性 ### MeshToonMaterial ![](https://hackmd.io/_uploads/BJulCgAA3.png) 主要需設置 gradientMap 為色階圖片素材(默認是黑白三色階,渲染出來就是只有三個顏色),並將 Texture.minFilter 和 Texture.magFilter 設置為 THREE.NearestFilter ,即可看到卡通效果的塗色樣式。 ```typescript // 該材質要有燈光照明,否則不可見 const light = new THREE.PointLight(0xffffff, 500) light.position.set(10, 10, 10) scene.add(light) const fourTone = new THREE.TextureLoader().load('img/fourTone.jpg') // 黑白四色階圖,渲染出四種顏色 fourTone.minFilter = THREE.NearestFilter fourTone.magFilter = THREE.NearestFilter const fiveTone = new THREE.TextureLoader().load('img/fiveTone.jpg') // 黑白五色階圖,渲染出五種顏色 fiveTone.minFilter = THREE.NearestFilter fiveTone.magFilter = THREE.NearestFilter const material: THREE.MeshToonMaterial = new THREE.MeshToonMaterial() // 設置 gradientMap 的選項 const options = { gradientMap: { Default: null, fourTone: 'fourTone', fiveTone: 'fiveTone', }, } // 設置 gradientMap 切換成 threeTone、fourTone、fiveTone meshToonMaterialFolder.add(data, 'gradientMap', options.gradientMap).onChange(() => updateMaterial()) function updateMaterial() { material.gradientMap = eval(data.gradientMap as string) material.needsUpdate = true } ``` > 色階圖片只能自己製作,用 PS 或 GIMP ,一格色階 `1*1px` 即可,左到右對應白到黑,類似這種圖片:<https://www.color-hex.com/color-palette/37706> ## 紋理貼圖 ### SpecularMap ![](https://hackmd.io/_uploads/rkLLQY-JT.png) 高光貼圖,控制反射強度,針對 MeshLambertMaterial 跟 MeshPhongMaterial 使用。 要調整強度,可在 MeshPhongMaterial 上使用 specular 和 shininess 屬性;在 MeshPhongMaterial 上,則使用 reflectivity 屬性。 ```typescript const material = new THREE.MeshPhongMaterial() // const texture = new THREE.TextureLoader().load('img/grid.png') const texture = new THREE.TextureLoader().load("img/worldColour.5400x2700.jpg") material.map = texture // const envTexture = new THREE.CubeTextureLoader().load(["img/px_50.png", "img/nx_50.png", "img/py_50.png", "img/ny_50.png", "img/pz_50.png", "img/nz_50.png"]) const envTexture = new THREE.CubeTextureLoader().load(["img/px_eso0932a.jpg", "img/nx_eso0932a.jpg", "img/py_eso0932a.jpg", "img/ny_eso0932a.jpg", "img/pz_eso0932a.jpg", "img/nz_eso0932a.jpg"]) envTexture.mapping = THREE.CubeReflectionMapping material.envMap = envTexture // const specularTexture = new THREE.TextureLoader().load("img/grayscale-test.png") const specularTexture = new THREE.TextureLoader().load("img/earthSpecular.jpg") material.specularMap = specularTexture const data = { specular: material.specular.getHex(), } meshPhongMaterialFolder.addColor(data, 'specular').onChange(() => { material.specular.setHex( Number(data.specular.toString().replace('#', '0x')) ) }) meshPhongMaterialFolder.add(material, 'shininess', 0, 1024) ``` ### RoughnessMap and MetalnessMap ![](https://hackmd.io/_uploads/BJN1QFZJp.png) roughnessMap 和 metalnessMap 就像 SpecularMap 一樣,只是是給 MeshStandardMaterial 和 MeshPhysicalMaterial 用的,且多了更多反射相關的設定。 ```typescript const material = new THREE.MeshPhysicalMaterial({}) const texture = new THREE.TextureLoader().load('img/worldColour.5400x2700.jpg') material.map = texture const envTexture = new THREE.CubeTextureLoader().load([ 'img/px_eso0932a.jpg', 'img/nx_eso0932a.jpg', 'img/py_eso0932a.jpg', 'img/ny_eso0932a.jpg', 'img/pz_eso0932a.jpg', 'img/nz_eso0932a.jpg', ]) envTexture.mapping = THREE.CubeReflectionMapping material.envMap = envTexture // const specularTexture = new THREE.TextureLoader().load("img/grayscale-test.png") const specularTexture = new THREE.TextureLoader().load('img/earthSpecular.jpg') material.roughnessMap = specularTexture // material.metalnessMap = specularTexture meshPhysicalMaterialFolder.add(material, 'reflectivity', 0, 1) meshPhysicalMaterialFolder.add(material, 'envMapIntensity', 0, 1) meshPhysicalMaterialFolder.add(material, 'roughness', 0, 1) meshPhysicalMaterialFolder.add(material, 'metalness', 0, 1) meshPhysicalMaterialFolder.add(material, 'clearcoat', 0, 1, 0.01) meshPhysicalMaterialFolder.add(material, 'clearcoatRoughness', 0, 1, 0.01) ``` ### BumpMap ![](https://hackmd.io/_uploads/rkXgZKWJ6.png) 用來創建凹凸貼圖紋理,它並不會改變幾何物件本身,而是控制燈光。 ```typescript const planeGeometry = new THREE.PlaneGeometry(3.6, 1.8) const material = new THREE.MeshPhongMaterial() const texture = new THREE.TextureLoader().load('img/worldColour.5400x2700.jpg') material.map = texture const bumpTexture = new THREE.TextureLoader().load('img/earth_bumpmap.jpg') material.bumpMap = bumpTexture material.bumpScale = 0.015 // 設定凹凸貼圖效果的強度,越靠近 0 越平滑,越靠近 1 越能看出紋理 gui.add(material, 'bumpScale', 0, 1, 0.01) ``` ### NormalMap ![](https://hackmd.io/_uploads/BkJwOtZk6.png) 用來創建法線紋理,主要通過紋理圖片的 RGB 數值影響燈光以呈現凹凸感。 ```typescript const material = new THREE.MeshPhongMaterial() const texture = new THREE.TextureLoader().load('img/worldColour.5400x2700.jpg') material.map = texture const normalTexture = new THREE.TextureLoader().load( 'img/earth_normalmap_8192x4096.jpg' ) material.normalMap = normalTexture material.normalScale.set(2, 2) gui.add(material.normalScale, 'x', 0, 10, 0.01) gui.add(material.normalScale, 'y', 0, 10, 0.01) gui.add(light.position, 'x', -20, 20).name('Light Pos X') // 控制燈光 x 位置 ``` ### DisplacementMap ![](https://hackmd.io/_uploads/H1eUlwSJa.png) DisplacementMap 會更改幾何物體和頂點中心,與其他 map 不同,他會更改物件本身,而不是更改材質紋理的顏色、銳利度。 ```typescript const displacementMap = new THREE.TextureLoader().load( 'img/gebco_bathy.5400x2700_8bit.jpg' ) material.displacementMap = displacementMap // 設置材質的 displacementMap 為一張黑白圖片,白色海拔最高,黑色海拔最低 meshPhongMaterialFolder.add(material, 'displacementScale', 0, 1, 0.01) // 設定位移比例,越大凹凸就越大 meshPhongMaterialFolder.add(material, 'displacementBias', -1, 1, 0.01) // 設定偏離量,越大高於中心點,越小低於中心點 const planeData = { width: 3.6, height: 1.8, widthSegments: 1, heightSegments: 1 }; const planePropertiesFolder = gui.addFolder("PlaneGeometry") // 設置平面(plane)的寬度與高度分段數,越大就越多頂點,可以用 wireframe 模式瀏覽更明顯 planePropertiesFolder.add(planeData, 'widthSegments', 1, 360).onChange(regeneratePlaneGeometry) planePropertiesFolder.add(planeData, 'heightSegments', 1, 180).onChange(regeneratePlaneGeometry) planePropertiesFolder.open() function regeneratePlaneGeometry() { let newGeometry = new THREE.PlaneGeometry( planeData.width, planeData.height, planeData.widthSegments, planeData.heightSegments ) plane.geometry.dispose() plane.geometry = newGeometry } ``` #### DisplacementMap 的重複與中心 ![](https://hackmd.io/_uploads/ryvegPHJ6.png) 通過設置 UV 坐標來更改材質紋理在幾何體上的位置。 ```typescript const textureFolder = gui.addFolder('Texture') // 設置重複,實際效果有點像把整個畫面拉近,變成只能看到原圖的一部分大小,接著通過中心位置操控 xy 位置瀏覽超出畫面範圍的內容 textureFolder .add(texture.repeat, 'x', 0.1, 1, 0.1) .onChange((v) => ((material.displacementMap as THREE.Texture).repeat.x = v)) textureFolder .add(texture.repeat, 'y', 0.1, 1, 0.1) .onChange((v) => ((material.displacementMap as THREE.Texture).repeat.y = v)) // 設置中心位置,必須在 repeat 值小於 1 時才可看到變化 textureFolder .add(texture.center, 'x', 0, 1, 0.001) .onChange((v) => ((material.displacementMap as THREE.Texture).center.x = v)) textureFolder .add(texture.center, 'y', 0, 1, 0.001) .onChange((v) => ((material.displacementMap as THREE.Texture).center.y = v)) textureFolder.open() ``` ### 紋理 Mipmaps ![](https://hackmd.io/_uploads/H13a7wBk6.png) 設置紋理渲染方式, GPU 將根據距相機的距離使用不同大小版本的紋理來渲染表面。 上圖,左側使用的是默認紋理濾鏡,右側是設置為其他紋理濾鏡後的效果。 ```typescript const options = { minFilters: { NearestFilter: THREE.NearestFilter, NearestMipMapLinearFilter: THREE.NearestMipMapLinearFilter, NearestMipMapNearestFilter: THREE.NearestMipMapNearestFilter, 'LinearFilter ': THREE.LinearFilter, 'LinearMipMapLinearFilter (Default)': THREE.LinearMipMapLinearFilter, LinearMipmapNearestFilter: THREE.LinearMipmapNearestFilter, }, magFilters: { NearestFilter: THREE.NearestFilter, 'LinearFilter (Default)': THREE.LinearFilter, }, } const gui = new GUI() const textureFolder = gui.addFolder('THREE.Texture') // 設置相機距離較遠,物件看起來較小時的紋理渲染方式 textureFolder .add(texture2, 'minFilter', options.minFilters) .onChange(() => updateMinFilter()) // 設置相機距離較近,物件放大時的紋理渲染方式 textureFolder .add(texture2, 'magFilter', options.magFilters) .onChange(() => updateMagFilter()) textureFolder.open() ``` ### Custom Mipmaps ![](https://hackmd.io/_uploads/SJABwPSkT.png) 也可以通過函數自定義 mipmap 處理紋理。 上圖,左側使用的是默認紋理濾鏡,右側是設置為其他紋理濾鏡後的效果,像圖中選擇使用 LinearMipmapNearestFilter 濾鏡就無法做出漸層效果。 ```typescript const mipmap = (size: number, color: string) => { const imageCanvas = document.createElement('canvas') as HTMLCanvasElement const context = imageCanvas.getContext('2d') as CanvasRenderingContext2D imageCanvas.width = size imageCanvas.height = size context.fillStyle = '#888888' context.fillRect(0, 0, size, size) context.fillStyle = color context.fillRect(0, 0, size / 2, size / 2) context.fillRect(size / 2, size / 2, size / 2, size / 2) return imageCanvas } const blankCanvas = document.createElement('canvas') as HTMLCanvasElement blankCanvas.width = 128 blankCanvas.height = 128 const texture1 = new THREE.CanvasTexture(blankCanvas) texture1.mipmaps[0] = mipmap(128, '#ff0000') texture1.mipmaps[1] = mipmap(64, '#00ff00') texture1.mipmaps[2] = mipmap(32, '#0000ff') texture1.mipmaps[3] = mipmap(16, '#880000') texture1.mipmaps[4] = mipmap(8, '#008800') texture1.mipmaps[5] = mipmap(4, '#000088') texture1.mipmaps[6] = mipmap(2, '#008888') texture1.mipmaps[7] = mipmap(1, '#880088') texture1.repeat.set(5, 5) texture1.wrapS = THREE.RepeatWrapping texture1.wrapT = THREE.RepeatWrapping ``` ### Anisotropic Filtering ![](https://hackmd.io/_uploads/SkeF5DH1a.png) ```typescript const planeGeometry1 = new THREE.PlaneGeometry() const planeGeometry2 = new THREE.PlaneGeometry() const texture1 = new THREE.TextureLoader().load("img/grid.png") const texture2 = new THREE.TextureLoader().load("img/grid.png") // 設置異向性,越大看起來就越清晰 textureFolder .add(texture2, 'anisotropy', 1, renderer.capabilities.getMaxAnisotropy()) .onChange(() => updateAnistropy()) ``` ## 燈光 MeshBasicMaterial 、 MeshNormalMaterial 和 MeshMatcapMaterial 是自發光的,因此它們不需要光照即可在場景中可見。 其他材質皆需要光照才可看到,例如 MeshLambertMaterial 、 MeshPhongMaterial 、 MeshStandardMaterial 、 MeshPhysicalMaterial 和 MeshToonMaterial 。 以下示例從左到右分別使用這五種材質: - MeshBasicMaterial - MeshLambertMaterial - MeshPhongMaterial - MeshPhysicalMaterial - MeshToonMaterial ### Ambient Light 環境光 ![](https://hackmd.io/_uploads/B1R1TZPk6.png) 環境光,不會讓物件產生陰影,其光會在各個方向和距離上完全均勻的照射,所以就算把燈光位置設定在 0, 0, 0 以外的地方也不會對畫面產生任何影響。 ```typescript const light = new THREE.AmbientLight(0xffffff, Math.PI) scene.add(light) ``` ### Directional Light 定向光 ![](https://hackmd.io/_uploads/rkwh6ZDy6.png) 定向光,可以讓物件產生陰影,常被拿來模擬太陽光,通過控制其定位可更改照射的方向,但距離並不會影響到光的強弱。 ```typescript const light = new THREE.DirectionalLight(0xffffff, Math.PI) scene.add(light) // 添加光的輔助線,可查看光從哪個角度照射過來 const helper = new THREE.DirectionalLightHelper(light) scene.add(helper) ``` #### Directional Light Shadow 定向光的陰影效果 ![](https://hackmd.io/_uploads/Skf8X8bla.png) 定向光陰影使用 OrthographicCamera 計算陰影。正投影相機的投影模式下,無論相機的距離為何,渲染影像中的物件尺寸都保持不變。 ```typescript const light = new THREE.DirectionalLight(0xffffff, Math.PI) light.castShadow = true; // 設置 castShadow 為 true 以開啟燈光陰影 scene.add(light) // 添加相機輔助線 const helper = new THREE.CameraHelper(light.shadow.camera); scene.add(helper) // 設置燈光的陰影屬性,以下都是預設值 light.shadow.mapSize.width = 512 light.shadow.mapSize.height = 512 light.shadow.camera.near = 0.5 light.shadow.camera.far = 100 // 啟用渲染器的陰影映射功能 renderer.shadowMap.enabled = true // 設置陰影映射的類型 renderer.shadowMap.type = THREE.PCFSoftShadowMap // 建立一個平面用來接收陰影 const planeGeometry = new THREE.PlaneGeometry(100, 20) const plane = new THREE.Mesh(planeGeometry, new THREE.MeshPhongMaterial()) plane.rotateX(-Math.PI / 2) plane.position.y = -1.75 plane.receiveShadow = true // 設置 plane 接收陰影 scene.add(plane) ``` 陰影映射的類型有以下幾種: - THREE.PCFSoftShadowMap:陰影的邊緣會有輕微的模糊效果,但性能較低。 - THREE.BasicShadowMap:陰影的邊緣沒有模糊效果,看起來很像馬賽克,但性能好。 - THREE.PCFShadowMap:介於上述兩種類型之間,但性能比 PCFSoftShadowMap 好。 - THREE.VSMShadowMap:可以產生非常平滑且高品質的陰影,但計算成本較高,且會產生不明線條,需要另外設置 `light.shadow.radius` 數值,調整到看不見多餘線條。 ### Hemisphere Light 半球光 ![](https://hackmd.io/_uploads/SyqdxMwk6.png) 半球光,可以讓物件產生陰影,分別從上往下照射+從下往上照射形成一個球型的感覺,上圖是由下往上的角度看起來的樣子(color設綠色、groundColor設紅色的效果)。 設置 light 中 color 屬性可改變從上往下照射的光顏色,另外 Hemisphere Light 本身有個 groundColor 屬性則是用來設置從下往上照射的光顏色。 ```typescript // 傳入的值分別對應 color(上往下的光顏色),groundColor(下往上的光顏色),光的強度 const light = new THREE.HemisphereLight(0xffffff, 0xffffff, Math.PI) scene.add(light) // 添加光的輔助線,可查看球體的形狀,後面的 5 用來設定大小 const helper = new THREE.HemisphereLightHelper(light, 5) scene.add(helper) ``` ### Point Light 點光源 ![](https://hackmd.io/_uploads/BJkQa7Dy6.png) 點光源,可以讓物件產生陰影,從某個點往各個方向發射光芒,在上圖最右側的 MeshToonMaterial 材質,若用點光源照射,可看出其稍微有點自體發光效果。 點光源本身具備兩個屬性, distance(控制光照射的距離,如為零可照射無限遠) 與 decay(控制沿著光照距離的衰退量,值越大,距離越遠的地方光線變暗的程度就會越高)。 ```typescript const light = new THREE.PointLight(0xffffff, 2) scene.add(light) const helper = new THREE.PointLightHelper(light) scene.add(helper) pointLightFolder.add(light, 'distance', 0, 100, 0.01) // 控制距離 pointLightFolder.add(light, 'decay', 0, 4, 0.1) // 控制衰退量 ``` > 建議放置一個 PlaneGeometry 較能看出燈光效果 ### Spot light 聚光燈 ![](https://hackmd.io/_uploads/SkGK_Vw1p.png) 聚光燈,從上往下放射性照射,與點光源一樣具有 distance 與 decay 屬性,另外還有兩個屬性分別是 angle(控制光散射的最大角度,極限為 90) 與 penumbra(控制圓錐體的衰退量,設置後照射的光邊緣就會有模糊效果,如未設置則會看到很明顯的黑色邊界)。 ```typescript const light = new THREE.SpotLight(0xffffff, 2) scene.add(light) const helper = new THREE.SpotLightHelper(light) scene.add(helper) spotLightFolder.add(light, 'angle', 0, 1, 0.1) // 設定角度 spotLightFolder.add(light, 'penumbra', 0, 1, 0.1) // 設定圓錐體的衰退量 ``` > 建議放置一個 PlaneGeometry 較能看出燈光效果 #### Spot Light Shadow 聚光燈的陰影效果 ![](https://hackmd.io/_uploads/B1nb6Nbla.png) 聚光燈陰影使用 PerspectiveCamera 計算陰影。透視投影相機的投影模式會模仿人眼的觀看方式,是渲染 3D 場景最常用的投影模式。 ```typescript const light = new THREE.SpotLight( 0xffffff ); light.castShadow = true; // 設置 castShadow 為 true 以開啟燈光陰影 scene.add(light) // 添加相機輔助線 const helper = new THREE.CameraHelper(light.shadow.camera); scene.add(helper) // 設置燈光的陰影屬性,以下都是預設值 light.shadow.mapSize.width = 512; light.shadow.mapSize.height = 512; light.shadow.camera.near = 0.5; light.shadow.camera.far = 100; // 啟用渲染器的陰影映射功能 renderer.shadowMap.enabled = true // 設置陰影映射的類型 renderer.shadowMap.type = THREE.PCFSoftShadowMap // 建立一個平面用來接收陰影 const planeGeometry = new THREE.PlaneGeometry(100, 20) const plane = new THREE.Mesh(planeGeometry, new THREE.MeshPhongMaterial()) plane.rotateX(-Math.PI / 2) plane.position.y = -1.75 plane.receiveShadow = true; // 設置 plane 接收陰影 scene.add(plane) ``` ## 控制器 ### OrbitControls 預設目標中心位置在 0,0,0 ,當進行旋轉動作時會以目標中心位置為基準,假設希望以 cube 右上角的點當做中心位置旋轉,初學者可能會想改變相機位置,但如果真的使用 `camera.lookAt(0.5, 0.5, 0.5)` 設定後,雖然初始畫面看起來相機確實是對著右上角的點,但實際上通過 OrbitControls 進行旋轉時,會發現 cube 跳回 0,0,0 的位置進行旋轉,所以想實現 cube 右上角為原點進行旋轉應該要使用 `controls.target.set(.5, .5, .5)` 設定: ```typescript // 引入控制器 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' // 宣告 controls 變量為 OrbitControls 控制器 const controls = new OrbitControls(camera, renderer.domElement) // camera.lookAt(0.5, 0.5, 0.5) // 這是更改相機照射的位置 controls.target.set(0.5, 0.5, 0.5) // 這是更改控制器的中心點位置 function animate() { requestAnimationFrame(animate) // 實時更新控制器 controls.update() renderer.render(scene, camera) } ``` 另外可通過 `controls.addEventListener` 監聽事件: - change 事件:正在進行旋轉、平移或縮放動作時會觸發。 - start 事件:滑鼠按下去的那個瞬間會觸發。 - end 事件:滑鼠放開的那個瞬間會觸發。 ```typescript // 監聽事件用法 controls.addEventListener('change', () => console.log("Controls Change")) controls.addEventListener('start', () => console.log("Controls Start Event")) controls.addEventListener('end', () => console.log("Controls End Event")) // 讓控制器自動旋轉物件,需於 animate 中調用 controls.update() 才可看到效果 controls.autoRotate = true // 設定自動旋轉物件的速度,默認是 2 (60fps 時,每分鐘可以轉 2 次) controls.autoRotateSpeed = 10 // 讓控制器旋轉放開時產生阻尼(放開後還會動一下下才停止) controls.enableDamping = true // 設定阻尼強度(0~1 之間,越大會越快停止,預設是 0.05) controls.dampingFactor = .01 // 監聽鍵盤事件,需要傳入元素進行綁定,官方建議使用 window controls.listenToKeyEvents(window) // 設置上下左右使用的鍵盤按鍵,實際上是控制相機位置,所以物體並不會朝認知中的上下左右移動 controls.keys = { LEFT: "ArrowLeft", // 上下左右的左鍵 UP: "ArrowUp", // 上下左右的上鍵 RIGHT: "ArrowRight", // 上下左右的右鍵 BOTTOM: "ArrowDown" // 上下左右的下鍵 } // 控制滑鼠事件 controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, // 左鍵,旋轉 MIDDLE: THREE.MOUSE.DOLLY, // 滾輪,縮放 RIGHT: THREE.MOUSE.PAN // 右鍵,平移 } // 控制 iphone/ipad 等觸控事件 controls.touches = { ONE: THREE.TOUCH.ROTATE, // 單指,旋轉 TWO: THREE.TOUCH.DOLLY_PAN // 雙指,縮放和平移 } // 設置平移時相機位置的平移方式,在 OrbitControls 中默認為 true ,如果是 false 單擊並上下移動時會變成物件放大縮小的感覺 controls.screenSpacePanning = false // 設置水平線的最小與最大角度,以下設置 0~90 度,所以在水平旋轉時會有 0~90 度的極限 controls.minAzimuthAngle = 0 controls.maxAzimuthAngle = Math.PI / 2 // 設置垂直線的最小與最大角度,以下設置 0~90 度,所以在垂直旋轉時會有 0~90 度的極限 controls.minPolarAngle = 0 controls.maxPolarAngle = Math.PI / 2 // 設置最遠與最近距離,縮放時會以相機為基準計算 2~4 的距離為極限 controls.maxDistance = 4 controls.minDistance = 2 ``` > 鍵盤按鍵查找名稱可參考:https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code ### TrackballControls 軌跡球控制器 該控制器必須一直開著 update 事件才可看到效果 ```typescript // 引入 TrackballControls import { TrackballControls } from 'three/examples/jsm/controls/TrackballControls' // 宣告 controls 變量為 TrackballControls 控制器 const controls = new TrackballControls(camera, renderer.domElement) // 與 OrbitControls 一樣可監聽 change start end 事件 controls.addEventListener('change', () => console.log("Controls Change")) controls.addEventListener('start', () => console.log("Controls Start Event")) controls.addEventListener('end', () => console.log("Controls End Event")) // 禁用控制器 controls.enabled = false // 設置旋轉速度,預設是 1 controls.rotateSpeed = 10 // 設置縮放速度,預設是 1.2 controls.zoomSpeed = 5 // 設置平移速度,預設是 0.3 controls.panSpeed = 0.8 // 設置交互的按鍵,以此例來說,按著A時,滑鼠操作都是旋轉、按著S時,滑鼠操作都是縮放、按著D時,滑鼠操作都是平移 controls.keys = ['KeyA', 'KeyS', 'KeyD'] // 是否禁用平移 controls.noPan = true // 默認為 false // 是否禁用旋轉 controls.noRotate = true // 默認為 false // 是否禁用縮放 controls.noZoom = true // 默認為 false // 設置為靜態移動,設為 true 後就不會出現阻尼效果了 controls.staticMoving = true // 默認為 false // 設定阻尼強度(0~1 之間,越大會越快停止,預設是 0.2) controls.dynamicDampingFactor = 0.1 // 設置最遠與最近距離,縮放時會以相機為基準計算 2~4 的距離為極限 controls.maxDistance = 4 controls.minDistance = 2 ``` ### PointerLockControls 指針鎖定控制器 通常用於第一人稱 3D 遊戲,使用鼠標控制相機並可搭配鍵盤控制第一人稱的位置。該控制器本身沒有 `update` 方法。 ```typescript import { PointerLockControls } from 'three/examples/jsm/controls/PointerLockControls' // 在 HTML 中放置遮罩與按鈕 const menuPanel = document.getElementById('menuPanel') as HTMLDivElement const startButton = document.getElementById('startButton') as HTMLInputElement startButton.addEventListener( 'click', function () { controls.lock() // 點擊按鈕後通過這行啟動視角追蹤 }, false ) // 宣告控制器 const controls = new PointerLockControls(camera, renderer.domElement) // 監聽 lock 事件,以隱藏遮罩與按鈕 controls.addEventListener('lock', () => menuPanel.style.display = 'none') // 通過鍵盤 esc 按鍵可取消 lock 事件並觸發 unlock 事件,以顯示遮罩與按鈕 controls.addEventListener('unlock', () => menuPanel.style.display = 'block') // 監聽鍵盤點擊事件以改變控制器位置 const onKeyDown = function (event: KeyboardEvent) { switch (event.code) { case "KeyW": controls.moveForward(.25) break case "KeyA": controls.moveRight(-.25) break case "KeyS": controls.moveForward(-.25) break case "KeyD": controls.moveRight(.25) break } } document.addEventListener('keydown', onKeyDown, false) ``` ### DragControls 拖放控制器 該控制器讓物件可被拖移。該控制器本身沒有 `update` 方法。 ```typescript import { DragControls } from 'three/examples/jsm/controls/DragControls' const geometry = new THREE.BoxGeometry() const material = [ new THREE.MeshPhongMaterial({ color: 0xff0000, transparent: true }), // 為每個材質設置顏色與開啟透明屬性 new THREE.MeshPhongMaterial({ color: 0x00ff00, transparent: true }), new THREE.MeshPhongMaterial({ color: 0x0000ff, transparent: true }) ] const cubes = [ new THREE.Mesh(geometry, material[0]), // 為每個 BoxGeometry 設置材質 new THREE.Mesh(geometry, material[1]), new THREE.Mesh(geometry, material[2]) ] cubes[0].position.x = -2 // 為每個 BoxGeometry 設置 x 軸位置 cubes[1].position.x = 0 cubes[2].position.x = 2 cubes.forEach((c) => scene.add(c)) // 把 cube 放到場景中 // 宣告控制器, DragControls 的第一個參數需要傳入陣列,告知有哪些物件需要被拖曳 const controls = new DragControls(cubes, camera, renderer.domElement) // 監聽事件,可監聽拖曳中及拖曳結束,並通過 event.object.material 更改物件材質 controls.addEventListener('dragstart', function (event) { event.object.material.opacity = 0.33 }) controls.addEventListener('dragend', function (event) { event.object.material.opacity = 1 }) ``` > 在監聽事件中通過 event.object.material 更改物件材質時需注意,每個物件的材質必須要獨立設置,才可以在拖曳時更改拖曳中的物件的材質,若所有物件是共用同一個材質,則拖曳時會更改到所有物件的材質。 ### TransformControls 變換控制器 用來為物件添加控制小工具,讓物件可以通過小工具進行縮放、旋轉和定位物件。該控制器本身沒有 `update` 方法。 ```typescript import { TransformControls } from 'three/examples/jsm/controls/TransformControls' // 宣告控制器 const controls = new TransformControls(camera, renderer.domElement) // 添加控制器到物件上,但有限制一次只能給一個物件 controls.attach(cube) scene.add(controls) // 監聽事件 window.addEventListener('keydown', function (event) { switch (event.code) { case 'KeyG': controls.setMode('translate') // 按著 G 就只能控制 translate break case 'KeyR': controls.setMode('rotate') // 按著 R 就只能控制旋轉 break case 'KeyS': controls.setMode('scale') // 按著 S 就只能控制縮放 break } }) ``` ### 在一個場景中使用不同控制器的方式 1. 同時使用軌道控制器及拖放控制器 ```typescript const orbitControls = new OrbitControls(camera, renderer.domElement) const dragControls = new DragControls([cube], camera, renderer.domElement) // 在拖放事件中,更改 orbitControls.enabled 以啟用/禁用軌道控制器 dragControls.addEventListener('dragstart', function (event) { orbitControls.enabled = false event.object.material.opacity = 0.33 }) dragControls.addEventListener('dragend', function (event) { orbitControls.enabled = true event.object.material.opacity = 1 }) ``` 2. 同時使用軌道控制器及變換控制器 ```typescript const orbitControls = new OrbitControls(camera, renderer.domElement) const transformControls = new TransformControls(camera, renderer.domElement) transformControls.attach(cube) scene.add(transformControls) // 在變換事件中,判斷是否有拖放事件以更改 orbitControls.enabled transformControls.addEventListener('dragging-changed', function (event) { orbitControls.enabled = !event.value }) ``` ## 各種不同的模型使用方式 ### OBJ Loader 可以上網搜尋 `.obj` 檔案以進行使用。 另外也可以安裝 blender 自行建立想要的模型並匯出成 obj 檔案類型以進行使用。 使用 OBJLoader 載入模型時, Three.js 預設會將材質設置為 MeshPhongMaterial ,所以場景中必須要有燈光才可以看到物體,另外也可以通過更改 object 的 child 的 material 設置成自己想要的材質。 ```typescript import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader' const material = new THREE.MeshBasicMaterial({ color: 0x00ff00, wireframe: true }) // 宣告 OBJ加載器 const objLoader = new OBJLoader() // 調用 load 事件 objLoader.load( 'models/monkey.obj', // 傳入一個 .obj 檔案 (object) => { // 載入完成後執行的事件 object.traverse(function (child) { // 遍歷 obj 的 child ,並重新設置材質 if ((child as THREE.Mesh).isMesh) { (child as THREE.Mesh).material = material } }) // 將 obj 添加到場景中 scene.add(object) }, (xhr) => { // 載入過程執行的事件 console.log((xhr.loaded / xhr.total) * 100 + '% loaded') }, (error) => { // 載入時出現錯誤要執行的事件 console.log(error) } ) ``` ### MTL Loader 可以上網搜尋 `.mtl` 檔案以進行使用。 另外也可以安裝 blender 自行建立想要的模型並設置材質後匯出成 obj 檔案類型以進行使用(匯出後會自動生成 .mtl 檔案)。 由於預設會將材質設置為 MeshPhongMaterial ,所以可以設置的屬性會以該材質為基準,如果使用 blender 自行建置模型,則可設置其顏色、透明度、反射、放射、陰影的光滑或平坦度。 ```typescript // 通常會搭配 OBJLoader 使用 import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader' // 引入 MTLLoader import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader' // 宣告 MTL 加載器 const mtlLoader = new MTLLoader() // 調用 load 事件 mtlLoader.load( 'models/monkey.mtl', // 傳入一個 .mtl 檔案 (materials) => { materials.preload() // 預加載 const objLoader = new OBJLoader() objLoader.setMaterials(materials) // 設置材質 objLoader.load( // 載入 OBJ 'models/monkey.obj', (object) => { scene.add(object) }, (xhr) => { console.log((xhr.loaded / xhr.total) * 100 + '% loaded') }, (error) => { console.log('An error happened') } ) }, (xhr) => { console.log((xhr.loaded / xhr.total) * 100 + '% loaded') }, (error) => { console.log('An error happened') } ) ``` ### GLTF Loader 將製作好的一個或多個場景,包含燈光、相機、物件等等的內容,以 JSON (`.gltf`) 或二進位 (`.glb`) 格式進行匯出並在 Three.js 中加載使用。 `.glb` 檔案通常會比 `.gltf` 檔案來得更小。 ```typescript // 引入 GLTFLoader import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' // 開啟渲染器的陰影效果 renderer.shadowMap.enabled = true // 宣告 const loader = new GLTFLoader() // 載入 loader.load( 'models/monkey.glb', // 傳入 .glb or .gltf 檔案 function (gltf) { gltf.scene.traverse(function (child) { if ((child as THREE.Mesh).isMesh) { // 設置讓材質接收陰影與投射陰影 const m = (child as THREE.Mesh) m.receiveShadow = true m.castShadow = true } if (((child as THREE.Light)).isLight) { // 設置燈光 const l = (child as THREE.SpotLight) l.castShadow = true // 投射陰影 // 設置偏移,當物件同時接收與投射陰影時會出現條狀紋理 // 此時可通過設置偏移讓其消失,但同時偏移也會影響到陰影的位置 l.shadow.bias = -.003 l.shadow.mapSize.width = 2048 l.shadow.mapSize.height = 2048 } }) scene.add(gltf.scene) }, (xhr) => { console.log((xhr.loaded / xhr.total) * 100 + '% loaded') }, (error) => { console.log(error) } ) ``` ### DRACO Loader 將 glb or gltf 檔案進行壓縮後,於 Three.js 中進行使用。由於該功能須通過瀏覽器進行編譯以壓縮,所以渲染的時間會比直接使用未壓縮的 glb or gltf 檔案花費更久一點,但是整體的檔案大小會更輕量,可視需求使用。 ```typescript const dracoLoader = new DRACOLoader() // 設置解析器的路徑,可以從 node_modules\three\examples\jsm\libs\draco\ 拷貝並放到指定位置取用 dracoLoader.setDecoderPath('/js/draco/') // 宣告 const loader = new GLTFLoader() // 設置 DRACOLoader loader.setDRACOLoader(dracoLoader) loader.load( 'models/monkey_compressed.glb', // 傳入 glb or glft 檔案 function (gltf) { gltf.scene.traverse(function (child) { if ((child as THREE.Mesh).isMesh) { const m = child as THREE.Mesh m.receiveShadow = true m.castShadow = true } if ((child as THREE.Light).isLight) { const l = child as THREE.SpotLight l.castShadow = true l.shadow.bias = -0.003 l.shadow.mapSize.width = 2048 l.shadow.mapSize.height = 2048 } }) scene.add(gltf.scene) }, (xhr) => { console.log((xhr.loaded / xhr.total) * 100 + '% loaded') }, (error) => { console.log(error) } ) ``` ### FBX Loader 到 [Mixamo](https://www.mixamo.com/#/) 網站上,註冊後點選 Character 下載喜歡的靜態模型並引入到 Three.js 中使用。 ```typescript const fbxLoader = new FBXLoader() fbxLoader.load( 'models/Kachujin G Rosales.fbx', (object) => { object.traverse(function (child) { if ((child as THREE.Mesh).isMesh) { if ((child as THREE.Mesh).material) { // 引入的模型通常會有透明度 可通過這行將透明度關閉 ((child as THREE.Mesh).material as THREE.MeshBasicMaterial).transparent = false } // (child as THREE.Mesh).material = material // 如果不喜歡模型原本的材質,也可通過設置這行來更改所有材質 } }) object.scale.set(.01, .01, .01) // 引入的模型通常會很巨大,可通過 scale 調整到正確尺寸 scene.add(object) }, (xhr) => { console.log((xhr.loaded / xhr.total) * 100 + '% loaded') }, (error) => { console.log(error) } ) ``` ### FBX Loader 延伸 #### FBX Animations 首先要通過 FBX Loader 載入靜態模型,並使用 AnimationMixer 方法建立動畫混合器,接著從 [Mixamo](https://www.mixamo.com/#/) 網站上的 Animations 中下載喜歡的動畫效果並引入到 Three.js 中使用。 主要是通過 AnimationMixer 提供的 clipAction 方法傳入想使用的動畫效果(通過 fbxLoader.load 將 object.animations[0] 傳入 clipAction 方法中,以設置動畫效果),過程中會先宣告一個陣列保存所有 clipAction ,該案例是通過 GUI 按鈕執行我們自己建立的 setAction 方法,在 setAction 方法中通過判斷當前的動畫及傳入的動畫以更改動畫效果,主要程式碼如下: ```typescript let mixer: THREE.AnimationMixer // 宣告一個變數用來存放要使用動畫混合器的物件 let modelReady = false // 用來判斷模型是否全部載入完畢 const animationActions: THREE.AnimationAction[] = [] let activeAction: THREE.AnimationAction let lastAction: THREE.AnimationAction const fbxLoader: FBXLoader = new FBXLoader() fbxLoader.load('models/vanguard_t_choonyung.fbx', // 載入靜態的T姿勢模型 (object) => { object.scale.set(0.01, 0.01, 0.01) // 將模型縮小至合理的尺寸 mixer = new THREE.AnimationMixer(object) // 使用 AnimationMixer() 傳入模型物件以宣告動畫混合器 const animationAction = mixer.clipAction((object as THREE.Object3D).animations[0]) // 使用動畫混合器的 clipAction 方法設置物件的動畫效果 animationActions.push(animationAction) // 將該動畫添加到陣列中 animationsFolder.add(animations, 'default') // 新增 default 按鈕到 GUI 的 Animations 資料夾中,點擊以執行 default 動畫 activeAction = animationActions[0] scene.add(object) // 將模型添加到場景中 fbxLoader.load('models/vanguard@samba.fbx', // 載入森巴舞動畫模型 (object) => { const animationAction = mixer.clipAction((object as THREE.Object3D).animations[0]); // 使用動畫混合器的 clipAction 方法設置物件的動畫效果 animationActions.push(animationAction) // 將該動畫添加到陣列中 animationsFolder.add(animations, "samba") // 新增 samba 按鈕到 GUI 的 Animations 資料夾中,點擊以執行 samba 動畫 fbxLoader.load('models/vanguard@bellydance.fbx', // 載入肚皮舞動畫模型 (object) => { const animationAction = mixer.clipAction((object as THREE.Object3D).animations[0]); // 使用動畫混合器的 clipAction 方法設置物件的動畫效果 animationActions.push(animationAction) // 將該動畫添加到陣列中 animationsFolder.add(animations, "bellydance") // 新增 bellydance 按鈕到 GUI 的 Animations 資料夾中,點擊以執行 bellydance 動畫 fbxLoader.load('models/vanguard@flair.fbx', // 載入 flair 動畫模型 (object) => { const animationAction = mixer.clipAction((object as THREE.Object3D).animations[0]); // 使用動畫混合器的 clipAction 方法設置物件的動畫效果 animationActions.push(animationAction) // 將該動畫添加到陣列中 animationsFolder.add(animations, "flair") // 新增 flair 按鈕到 GUI 的 Animations 資料夾中,點擊以執行 flair 動畫 modelReady = true // 將布林值設置為模型已載入完成 }, (xhr) => { console.log((xhr.loaded / xhr.total * 100) + '% loaded') }, (error) => { console.log(error) } ) }, (xhr) => { console.log((xhr.loaded / xhr.total * 100) + '% loaded') }, (error) => { console.log(error) } ) }, (xhr) => { console.log((xhr.loaded / xhr.total * 100) + '% loaded') }, (error) => { console.log(error) } ) }, (xhr) => { console.log((xhr.loaded / xhr.total) * 100 + '% loaded') }, (error) => { console.log(error) } ) const animations = { // 設置 animations ,讓用戶點擊每個按鈕時呼叫 setAction 方法,將模型的動畫切換為對應的動畫效果 default: function () { setAction(animationActions[0]) }, samba: function () { setAction(animationActions[1]) }, bellydance: function () { setAction(animationActions[2]) }, flair: function () { setAction(animationActions[3]) }, } const setAction = (toAction: THREE.AnimationAction) => { // 設置 setAction 方法 if (toAction != activeAction) { // 判斷目前被呼叫的動畫是否為正在顯示的動畫 lastAction = activeAction // 將最後動畫設定為正在顯示的動畫 activeAction = toAction // 將當前顯示的動畫改為目前被呼叫的動畫 lastAction.stop() // 將動畫停止播放 //lastAction.fadeOut(1) activeAction.reset() // 將當前動畫重置 //activeAction.fadeIn(1) activeAction.play() // 播放當前動畫 } } ``` ### GLTFLoader 延伸 #### Textured GLTF 將 glb or gltf 檔案於設計軟體中設置好 Textured 後導出至 Three.js 中使用。 ```typescript const loader = new GLTFLoader() loader.load( 'models/monkey_textured.glb', function (gltf) { gltf.scene.traverse(function (child) { if ((child as THREE.Mesh).isMesh) { const m = child as THREE.Mesh m.receiveShadow = true m.castShadow = true } if ((child as THREE.Light).isLight) { const l = child as THREE.SpotLight l.castShadow = true l.shadow.bias = -0.003 l.shadow.mapSize.width = 2048 l.shadow.mapSize.height = 2048 } }) scene.add(gltf.scene) }, (xhr) => { console.log((xhr.loaded / xhr.total) * 100 + '% loaded') }, (error) => { console.log(error) } ) ``` #### GLTF Animations GLTF Animations 的用法與 FBX Animations 幾乎一致,只需注意幾件事: 1. 模型檔案是 `.gltf` or `.glb` 2. 模型加載器需使用 `GLTFLoader` ## Raycaster 光線投射 用來判斷滑鼠當前位置與哪些物體相交,並對相交的物件進行操作。 ```typescript const raycaster = new THREE.Raycaster() // 使用 raycaster ,預設就包含在 three.js 中不需另外引入 const sceneMeshes: THREE.Object3D[] = [] // 建立 sceneMeshes const loader = new GLTFLoader() loader.load( 'models/monkey_textured.glb', // 通過 GLTFLoader 引入 .glb 模型 function (gltf) { gltf.scene.traverse(function (child) { if ((child as THREE.Mesh).isMesh) { // 判斷是否為 mesh const m = child as THREE.Mesh (m.material as THREE.MeshStandardMaterial).flatShading = true // 將材質設置為平面 sceneMeshes.push(m) // 將 mesh 加入到 sceneMeshes 陣列中 } }) scene.add(gltf.scene) //sceneMeshes.push(gltf.scene) }, (xhr) => { console.log((xhr.loaded / xhr.total) * 100 + '% loaded') }, (error) => { console.log(error) } ) // 監聽滑鼠移動事件 renderer.domElement.addEventListener('mousemove', onMouseMove, false) // 建立箭頭並添加到場景中 const arrowHelper = new THREE.ArrowHelper( new THREE.Vector3(), new THREE.Vector3(), .25, 0xffff00) scene.add(arrowHelper) function onMouseMove(event: MouseEvent) { // 將鼠標位置的 x, y 轉換成 -1 到 1 之間的數值 const mouse = { x: (event.clientX / renderer.domElement.clientWidth) * 2 - 1, y: -(event.clientY / renderer.domElement.clientHeight) * 2 + 1 } as THREE.Vector2 // 設置要使用光線投射的相機,傳入轉換為 -1 到 1 的鼠標位置及相機 raycaster.setFromCamera(mouse, camera); // 使用 intersectObjects 方法傳入所有 mesh 並設置 false 告知不遍歷子物件,以獲取當前 mesh 中所有與滑鼠相交的 mesh const intersects = raycaster.intersectObjects(sceneMeshes, false) // 判斷是否有東西相交 if (intersects.length > 0) { // 判斷物件名稱是否為平面,不是平面才執行 if (intersects[0].object.userData.name != 'Plane') { // 建立一個 Vector3 const n = new THREE.Vector3(); // 拷貝為第一個相交物件的面向位置 n.copy((intersects[0].face as THREE.Face).normal); // 當物體本身在移動時,需添加這行,讓位置對應當前移動的物體(比如物件正在旋轉,那箭頭就要按照旋轉的物件更新方向) n.transformDirection(intersects[0].object.matrixWorld); // 設置箭頭指向的方向 arrowHelper.setDirection(n); // 設置箭頭的起始點為相交的第一個 mesh 的點 arrowHelper.position.copy(intersects[0].point); } } } ``` ### 偵測滑鼠選中物件並改變選中物件的材質 通過將原本的 mesh.material 存到 originalMaterials 中,於找到相交物件時改變相交物件本身的材質並將其餘物件的材質設置回 originalMaterials 中的原始材質。 ```javascript const pickableObjects: THREE.Mesh[] = [] let intersectedObject: THREE.Object3D | null const originalMaterials: { [id: string]: THREE.Material | THREE.Material[] } = {} const highlightedMaterial = new THREE.MeshBasicMaterial({ wireframe: true, color: 0x00ff00, }) const loader = new GLTFLoader() loader.load( 'models/simplescene.glb', // 使用 GLTFLoader 載入帶有六種不同形狀與一個平面的 glb 檔案 function (gltf) { gltf.scene.traverse(function (child) { if ((child as THREE.Mesh).isMesh) { const m = child as THREE.Mesh switch (m.name) { case 'Plane': // 設置 Plane 接收陰影 m.receiveShadow = true break case 'Sphere': // 單獨設置 Sphere 有陰影 m.castShadow = true break default: // 將其餘的物體都設置陰影 m.castShadow = true // 將其餘的物體放到 pickableObjects 中 pickableObjects.push(m) // 將其餘的物體按照名稱把材質保存到 originalMaterials 中 originalMaterials[m.name] = (m as THREE.Mesh).material } } }) scene.add(gltf.scene) }, (xhr) => { console.log((xhr.loaded / xhr.total) * 100 + '% loaded') }, (error) => { console.log(error) } ) const raycaster = new THREE.Raycaster() // 設置光線投射 let intersects: THREE.Intersection[] // 建立 intersects 用來存放相交物體 function onDocumentMouseMove(event: MouseEvent) { // 將鼠標位置換算成 -1 ~ 1 const mouse = new THREE.Vector2() mouse.set( (event.clientX / renderer.domElement.clientWidth) * 2 - 1, -(event.clientY / renderer.domElement.clientHeight) * 2 + 1 ) // 設置從相機位置發出的光線投射 raycaster.setFromCamera(mouse, camera) // 獲取相交的物件 intersects = raycaster.intersectObjects(pickableObjects, false) if (intersects.length > 0) { // 判斷是否有相交物件 // 將物件設為 intersectedObject intersectedObject = intersects[0].object } else { // 將 intersectedObject 設為 null(這樣下方遍歷 pickableObjects 時如果沒選中任何物件才可以復原物件的材質) intersectedObject = null } // 遍歷 pickableObjects 為物件設置材質 pickableObjects.forEach((o: THREE.Mesh, i) => { // 判斷是否有相交物件 將物件的材質設為高亮 if (intersectedObject && intersectedObject.name === o.name) { pickableObjects[i].material = highlightedMaterial } else { // 將其他物件都設置為原本的材質 pickableObjects[i].material = originalMaterials[o.name] } }) } ``` ### 利用光線投射功能實現簡單的碰撞檢測 藉由判斷相交位置更改相機位置,假設控制器焦點到相機之間有相交的物體,則將相機的位置改為相交物體的位置,以防止相機與焦點之間有物體擋住。 ```javascript const raycaster = new THREE.Raycaster() // 建立光線投射 const sceneMeshes: THREE.Mesh[] = [] // 建立陣列存放物件 const dir = new THREE.Vector3() // 建立 Vector3 後續用來計算向量 let intersects: THREE.Intersection[] = [] // 建立軌道控制器 const controls = new OrbitControls(camera, renderer.domElement) controls.enableDamping = true // 設置阻尼開啟,當控制器旋轉後放開不會馬上停止,會稍微動一下才停 // 監聽控制器 change 事件 controls.addEventListener('change', function () { xLine.position.copy(controls.target) // 當控制器變化時同步更改 xLine 的位置為控制器的焦點 yLine.position.copy(controls.target) // 當控制器變化時同步更改 yLine 的位置為控制器的焦點 zLine.position.copy(controls.target) // 當控制器變化時同步更改 zLine 的位置為控制器的焦點 raycaster.set( // 設置光線投射的起點與方向 controls.target, // 起點為控制器的焦點 dir.subVectors(camera.position, controls.target).normalize() // 使用 subVectors 方法,計算相機 - 控制器焦點的位置,再通過 normalize 方法轉換為 1 ) // 獲取相交的物體 intersects = raycaster.intersectObjects(sceneMeshes, false) if (intersects.length > 0) { if ( intersects[0].distance < controls.target.distanceTo(camera.position) // 判斷當前相交的物體與相機的距離,是否小於控制器到相機的距離 ) { camera.position.copy(intersects[0].point) // 重新設置相機位置為相交的物體位置 } } }) // 建立一個 Plane 當地板 const floor = new THREE.Mesh( new THREE.PlaneGeometry(10, 10), new THREE.MeshNormalMaterial({ side: THREE.DoubleSide }) ) floor.rotateX(-Math.PI / 2) floor.position.y = -1 scene.add(floor) sceneMeshes.push(floor) // 建立一個 Plane 當牆 const wall1 = new THREE.Mesh( new THREE.PlaneGeometry(2, 2), new THREE.MeshNormalMaterial({ side: THREE.DoubleSide }) ) wall1.position.x = 4 wall1.rotateY(-Math.PI / 2) scene.add(wall1) sceneMeshes.push(wall1) // 建立一個 Plane 當牆 const wall2 = new THREE.Mesh( new THREE.PlaneGeometry(2, 2), new THREE.MeshNormalMaterial({ side: THREE.DoubleSide }) ) wall2.position.z = -3 scene.add(wall2) sceneMeshes.push(wall2) // 建立一個方塊 const cube: THREE.Mesh = new THREE.Mesh( new THREE.BoxGeometry(), new THREE.MeshNormalMaterial() ) cube.position.set(-3, 0, 0) scene.add(cube) sceneMeshes.push(cube) // 建立一個 Plane 當天花板 const ceiling = new THREE.Mesh( new THREE.PlaneGeometry(10, 10), new THREE.MeshNormalMaterial({ side: THREE.DoubleSide }) ) ceiling.rotateX(Math.PI / 2) ceiling.position.y = 3 scene.add(ceiling) sceneMeshes.push(ceiling) // 建立 0.2 的 x 軸線在畫面中心 const lineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff, }) const points: THREE.Vector3[] = [] // 建立 0.2 的 x 軸線在畫面中心 points[0] = new THREE.Vector3(-0.1, 0, 0) points[1] = new THREE.Vector3(0.1, 0, 0) let lineGeometry = new THREE.BufferGeometry().setFromPoints(points) const xLine = new THREE.Line(lineGeometry, lineMaterial) scene.add(xLine) // 建立 0.2 的 y 軸線在畫面中心 points[0] = new THREE.Vector3(0, -0.1, 0) points[1] = new THREE.Vector3(0, 0.1, 0) lineGeometry = new THREE.BufferGeometry().setFromPoints(points) const yLine = new THREE.Line(lineGeometry, lineMaterial) scene.add(yLine) // 建立 0.2 的 z 軸線在畫面中心 points[0] = new THREE.Vector3(0, 0, -0.1) points[1] = new THREE.Vector3(0, 0, 0.1) lineGeometry = new THREE.BufferGeometry().setFromPoints(points) const zLine = new THREE.Line(lineGeometry, lineMaterial) scene.add(zLine) function animate() { requestAnimationFrame(animate) controls.update() // 實時更新控制器 renderer.render(scene, camera) stats.update() } animate() ``` ### 利用光線折射協助測量點與點之間的距離 ```typescript // 使用 CSS2DRenderer 建立用來放置標籤用的 CSS 渲染器 const labelRenderer = new CSS2DRenderer() // 設置大小為整個畫面寬高 labelRenderer.setSize(window.innerWidth, window.innerHeight) // 設置樣式讓它絕對定位在畫面上 labelRenderer.domElement.style.position = 'absolute' labelRenderer.domElement.style.top = '0px' // 設置 pointerEvents 為 none 讓滑鼠可以穿透這層 CSS 渲染器 labelRenderer.domElement.style.pointerEvents = 'none' // 將其添加到 body,現在除了 canvas 之外下方還會有一個 div 就是 labelRenderer (如果有開啟 stats 功能,再下面應該還有另一個 div 用來放右上角的 stats) document.body.appendChild(labelRenderer.domElement) const pickableObjects: THREE.Mesh[] = [] const loader = new GLTFLoader() loader.load( 'models/simplescene.glb', // 使用 GLTFLoader 載入 glb 檔案的模型 function (gltf) { gltf.scene.traverse(function (child) { if ((child as THREE.Mesh).isMesh) { const m = child as THREE.Mesh switch (m.name) { // 設置讓 Plane 接收陰影 case 'Plane': m.receiveShadow = true break default: // 設置讓其他物件擁有陰影 m.castShadow = true } pickableObjects.push(m) } }) scene.add(gltf.scene) }, (xhr) => { console.log((xhr.loaded / xhr.total) * 100 + '% loaded') }, (error) => { console.log(error) } ) let isKeyDown = false let lineId = 0 let line: THREE.Line let drawingLine = false const measurementLabels: { [key: number]: CSS2DObject } = {} // 監聽鍵盤點擊事件 window.addEventListener('keydown', function (event) { if (event.key === 'Shift') { // 判斷按下的是 shift 鍵 isKeyDown = true controls.enabled = false // 取消控制器 renderer.domElement.style.cursor = 'crosshair' // 設置渲染器的鼠標為 + } }) // 監聽鍵盤放開事件 window.addEventListener('keyup', function (event) { if (event.key === 'Shift') { // 判斷按下的是 shift 鍵 isKeyDown = false controls.enabled = true // 重啟控制器 renderer.domElement.style.cursor = 'pointer' // 設置渲染器的鼠標為手指 if (drawingLine) { // 判斷是否正在繪製線 scene.remove(line) // 從場景中移除線條 scene.remove(measurementLabels[lineId]) // 從場景中移除標籤 drawingLine = false // 將正在繪製線設為否 } } }) const raycaster = new THREE.Raycaster() let intersects: THREE.Intersection[] const mouse = new THREE.Vector2() // 監聽滑鼠點擊事件 renderer.domElement.addEventListener('pointerdown', onClick, false) function onClick() { if (isKeyDown) { raycaster.setFromCamera(mouse, camera) // 設置要使用光線投射的相機,傳入轉換為 -1 到 1 的鼠標位置及相機 intersects = raycaster.intersectObjects(pickableObjects, false) if (intersects.length > 0) { if (!drawingLine) { // 開始繪製線條 const points = [] // 建立一個陣列用來存放所有點 points.push(intersects[0].point) // 點 1 points.push(intersects[0].point.clone()) // 點 2 const geometry = new THREE.BufferGeometry().setFromPoints(points) // 使用 BufferGeometry 設置點 1 與點 2 line = new THREE.LineSegments( // 繪製線段 geometry, // 將點 1 與點 2 傳入 new THREE.LineBasicMaterial({ // 設置線段材質 color: 0xffffff, }) ) line.frustumCulled = false // 設置當線超過相機可見範圍時,是否要取消渲染(預設是 true,即超過畫面就不渲染) scene.add(line) // 將線段添加到場景中 const measurementDiv = document.createElement('div') as HTMLDivElement // 建立 div 用於顯示測量距離標籤 measurementDiv.className = 'measurementLabel' // 設置 div 樣式 measurementDiv.innerText = '0.0m' // 設置 div 顯示的文字 const measurementLabel = new CSS2DObject(measurementDiv) // 使用 CSS2DObject 將 div 設置為 CSS 渲染器中的物件 measurementLabel.position.copy(intersects[0].point) // 設置要放置的位置為相交的點 measurementLabels[lineId] = measurementLabel // 設置 id scene.add(measurementLabels[lineId]) // 將 CSS2DObject 添加到場景中 drawingLine = true // 設為繪製中 } else { // 結束正在繪製的線 const positions = (line.geometry.attributes.position as THREE.BufferAttribute).array // 將線段的位置轉成陣列 positions[3] = intersects[0].point.x // 更改點 2 的 x 為相交的點的 x positions[4] = intersects[0].point.y // 更改點 2 的 y 為相交的點的 y positions[5] = intersects[0].point.z // 更改點 2 的 z 為相交的點的 z line.geometry.attributes.position.needsUpdate = true // 更新線段的位置 lineId++ // 將 id +1 drawingLine = false // 取消繪製中 } } } } // 監聽滑鼠移動事件 document.addEventListener('mousemove', onDocumentMouseMove, false) function onDocumentMouseMove(event: MouseEvent) { event.preventDefault() // 將鼠標位置轉換成 -1 ~ 1 mouse.x = (event.clientX / renderer.domElement.clientWidth) * 2 - 1 mouse.y = -(event.clientY / renderer.domElement.clientHeight) * 2 + 1 if (drawingLine) { // 判斷是否正在繪製線 raycaster.setFromCamera(mouse, camera) // 設置要使用光線投射的相機,傳入轉換為 -1 到 1 的鼠標位置及相機 intersects = raycaster.intersectObjects(pickableObjects, false) // 獲取所有相交的物件 if (intersects.length > 0) { // 判斷是否有相交的物件存在 const positions = (line.geometry.attributes.position as THREE.BufferAttribute).array // 將線段的位置轉成陣列 positions[3] = intersects[0].point.x // 將點 2 的 x 位置設為相交的點的 x positions[4] = intersects[0].point.y // 將點 2 的 y 位置設為相交的點的 y positions[5] = intersects[0].point.z // 將點 2 的 z 位置設為相交的點的 z const v0 = new THREE.Vector3( // 獲取點 1 的座標 positions[0], positions[1], positions[2] ) const v1 = new THREE.Vector3( // 獲取點 2 的座標 positions[3], positions[4], positions[5] ) line.geometry.attributes.position.needsUpdate = true // 更新點的位置 const distance = v0.distanceTo(v1) // 計算點 1 到點 2 的距離 measurementLabels[lineId].element.innerText = distance.toFixed(2) + 'm' // 設置標籤顯示的數值,通過 toFixed(2) 進位到小數點後兩位 measurementLabels[lineId].position.lerpVectors(v0, v1, 0.5) // 用 lerpVectors 計算點 1 到點 2 的一半,設為標籤的位置 } } } ``` ### 使用 Tween.js 搭配光線折射作出物體彈跳效果 ```typescript // 假設沒有在 animate 中實時呼叫 render 則需手動監聽控制器的 change 事件,該做法會比實時呼叫 render 更節省效能 controls.addEventListener('change', render) const sceneMeshes: THREE.Mesh[] = [] let monkey: THREE.Mesh const loader = new GLTFLoader() loader.load( 'models/monkey_textured.glb', function (gltf) { gltf.scene.traverse(function (child) { if ((child as THREE.Mesh).isMesh) { const m = child as THREE.Mesh m.receiveShadow = true m.castShadow = true sceneMeshes.push(m) if (child.name === 'Suzanne') { monkey = m } } if ((child as THREE.Light).isLight) { const l = child as THREE.SpotLight l.castShadow = true l.shadow.bias = -0.003 l.shadow.mapSize.width = 2048 l.shadow.mapSize.height = 2048 } }) scene.add(gltf.scene) render() }, (xhr) => { console.log((xhr.loaded / xhr.total) * 100 + '% loaded') }, (error) => { console.log(error) } ) const raycaster = new THREE.Raycaster() const mouse = new THREE.Vector2() function onDoubleClick(event: MouseEvent) { mouse.set( (event.clientX / renderer.domElement.clientWidth) * 2 - 1, -(event.clientY / renderer.domElement.clientHeight) * 2 + 1 ) raycaster.setFromCamera(mouse, camera) const intersects = raycaster.intersectObjects(sceneMeshes, false) if (intersects.length > 0) { const p = intersects[0].point new TWEEN.Tween(monkey.position) .to({ x: (p.x + monkey.position.x) / 2, y: p.y + 4, z: (p.z + monkey.position.z) / 2 }, 300) .onUpdate(() => render()) // 假設沒有在 animate 中實時呼叫 render 則需添加這行,通過 onUpdate 調用 render .start() .onComplete(() => { new TWEEN.Tween(monkey.position) .to({ x: p.x, y: p.y + 1.134, z: p.z }, 300) .easing(TWEEN.Easing.Cubic.Out) .onUpdate(() => render()) // 假設沒有在 animate 中實時呼叫 render 則需添加這行,通過 onUpdate 調用 render .start() }) } } renderer.domElement.addEventListener('dblclick', onDoubleClick, false) ``` ## 結合運用 GLTF Animations、Raycaster、Tween.js、SpotLight 結合 GLTF Animations、Raycaster、Tween.js、SpotLight 製作模型移動事件,通過雙擊讓模型從原點走向目標位置,並於走路的過程中及抵達時各執行不同的動畫。 ```typescript const light1 = new THREE.SpotLight(0xffffff, 100) // 使用點光源 light1.position.set(2.5, 5, 2.5) light1.angle = Math.PI / 8 light1.penumbra = 0.5 light1.castShadow = true; light1.shadow.mapSize.width = 1024; light1.shadow.mapSize.height = 1024; light1.shadow.camera.near = 0.5; light1.shadow.camera.far = 20 scene.add(light1) const light2 = new THREE.SpotLight(0xffffff, 100) // 使用點光源 light2.position.set(-2.5, 5, 2.5) light2.angle = Math.PI / 8 light2.penumbra = 0.5 light2.castShadow = true; light2.shadow.mapSize.width = 1024; light2.shadow.mapSize.height = 1024; light2.shadow.camera.near = 0.5; light2.shadow.camera.far = 20 scene.add(light2) const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 ) camera.position.set(0.8, 1.4, 1.0) const renderer = new THREE.WebGLRenderer() renderer.setSize(window.innerWidth, window.innerHeight) renderer.shadowMap.enabled = true // 開啟陰影 document.body.appendChild(renderer.domElement) const sceneMeshes: THREE.Mesh[] = [] // 添加地板 const planeGeometry = new THREE.PlaneGeometry(25, 25) const plane = new THREE.Mesh(planeGeometry, new THREE.MeshPhongMaterial()) plane.rotateX(-Math.PI / 2) plane.receiveShadow = true // 接收陰影 scene.add(plane) sceneMeshes.push(plane) let mixer: THREE.AnimationMixer let modelReady = false let modelMesh: THREE.Object3D const animationActions: THREE.AnimationAction[] = [] let activeAction: THREE.AnimationAction let lastAction: THREE.AnimationAction const gltfLoader = new GLTFLoader() // 載入模型 gltfLoader.load( 'models/Kachujin.glb', (gltf) => { gltf.scene.traverse(function (child) { if ((child as THREE.Mesh).isMesh) { let m = child as THREE.Mesh m.castShadow = true // 開啟模型陰影 m.frustumCulled = false // 這個可以讓相機貼著模型時維持模型渲染(默認相機碰到模型時,模型會停止渲染) } }) mixer = new THREE.AnimationMixer(gltf.scene) const animationAction = mixer.clipAction((gltf as any).animations[0]) animationActions.push(animationAction) animationsFolder.add(animations, 'default') activeAction = animationActions[0] scene.add(gltf.scene) modelMesh = gltf.scene gltfLoader.load( 'models/Kachujin@kick.glb', (gltf) => { console.log('loaded kick') const animationAction = mixer.clipAction( (gltf as any).animations[0] ) animationActions.push(animationAction) animationsFolder.add(animations, 'kick') gltfLoader.load( 'models/Kachujin@walking.glb', (gltf) => { // 取消前後左右位移,讓動畫在原地執行 (gltf as any).animations[0].tracks.shift() const animationAction = mixer.clipAction( (gltf as any).animations[0] ) animationActions.push(animationAction) animationsFolder.add(animations, 'walking') modelReady = true }, (xhr) => { console.log((xhr.loaded / xhr.total) * 100 + '% loaded') }, (error) => { console.log(error) } ) }, (xhr) => { console.log((xhr.loaded / xhr.total) * 100 + '% loaded') }, (error) => { console.log(error) } ) }, (xhr) => { console.log((xhr.loaded / xhr.total) * 100 + '% loaded') }, (error) => { console.log(error) } ) window.addEventListener('resize', onWindowResize, false) function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight camera.updateProjectionMatrix() renderer.setSize(window.innerWidth, window.innerHeight) render() } const raycaster = new THREE.Raycaster(); const targetQuaternion = new THREE.Quaternion() renderer.domElement.addEventListener('dblclick', onDoubleClick, false) const mouse = new THREE.Vector2() function onDoubleClick(event: MouseEvent) { mouse.set( (event.clientX / renderer.domElement.clientWidth) * 2 - 1, -(event.clientY / renderer.domElement.clientHeight) * 2 + 1 ) raycaster.setFromCamera(mouse, camera) const intersects = raycaster.intersectObjects(sceneMeshes, false) if (intersects.length > 0) { const p = intersects[0].point // 獲取物件到點擊處的距離 const distance = modelMesh.position.distanceTo(p) // 讓角色在移動前,面向要去的地方 const rotationMatrix = new THREE.Matrix4() // 使用 lookAt 傳入目標點、起點、物件的 up 屬性,設置一個變換 rotationMatrix.lookAt(p, modelMesh.position, modelMesh.up) // 通過 setFromRotationMatrix 設定 rotationMatrix 變換的旋轉 targetQuaternion.setFromRotationMatrix(rotationMatrix) // 設定成走路動作 setAction(animationActions[2]) TWEEN.removeAll() // 避免快速重複 dblclick 時,走路的動畫跑不出來 new TWEEN.Tween(modelMesh.position) // 讓物件移動到點擊的位置 .to({ x: p.x, y: p.y, z: p.z }, 1000 / 2 * distance) // 根據距離調整動畫的速度 .onUpdate(() => { // 讓相機跟著物件移動 controls.target.set(modelMesh.position.x, modelMesh.position.y + 1, modelMesh.position.z) // 讓燈光跟著物件移動 light1.target = modelMesh light2.target = modelMesh }) .start() .onComplete(() => { setAction(animationActions[1]) // 將走路動作改成踢的動作 activeAction.clampWhenFinished = true // 讓動畫在最後一幀停止 activeAction.loop = THREE.LoopOnce // 讓動畫僅執行一次 }) } } const setAction = (toAction: THREE.AnimationAction) => { if (toAction != activeAction) { lastAction = activeAction activeAction = toAction lastAction.fadeOut(0.2) activeAction.reset() activeAction.fadeIn(0.2) activeAction.play() } } const clock = new THREE.Clock() let delta = 0 function animate() { requestAnimationFrame(animate) controls.update() if (modelReady) { delta = clock.getDelta() mixer.update(delta) if (!modelMesh.quaternion.equals(targetQuaternion)) { modelMesh.quaternion.rotateTowards(targetQuaternion, delta * 10) } } TWEEN.update() render() stats.update() } function render() { renderer.render(scene, camera) } animate() ``` ## 使用 Vector3 lerp 執行簡單的點與點之間位移動畫 ```typescript // 創建地板 const floor = new THREE.Mesh( new THREE.PlaneGeometry(20, 20, 10, 10), new THREE.MeshBasicMaterial({ color: 0xaec6cf, wireframe: true }) ) floor.rotateX(-Math.PI / 2) scene.add(floor) // 創建兩個方塊 const geometry = new THREE.BoxGeometry() const cube1 = new THREE.Mesh( geometry, new THREE.MeshBasicMaterial({ color: 0x00ff00, wireframe: true }) // 設為綠色 ) cube1.position.y = 0.5 // 上提 0.5 不然會有一半在地板下面 scene.add(cube1) const cube2 = new THREE.Mesh( geometry, new THREE.MeshBasicMaterial({ color: 0xff0000, wireframe: true }) // 設為紅色 ) cube2.position.y = 0.5 // 上提 0.5 不然會有一半在地板下面 scene.add(cube2) let v1 = new THREE.Vector3(0, 0.5, 0) // 設定 v1 為 cube1 的初始位置 let v2 = new THREE.Vector3(0, 0.5, 0) // 設定 v1 為 cube2 的初始位置 const raycaster = new THREE.Raycaster() const mouse = new THREE.Vector2() function onDoubleClick(event: THREE.Event) { mouse.set( (event.clientX / renderer.domElement.clientWidth) * 2 - 1, -(event.clientY / renderer.domElement.clientHeight) * 2 + 1 ) raycaster.setFromCamera(mouse, camera) const intersects = raycaster.intersectObject(floor, false) if (intersects.length > 0) { v1 = intersects[0].point // 將 v1 的位置改為雙擊的點 v1.y += 0.5 } } renderer.domElement.addEventListener('dblclick', onDoubleClick, false) const data = { lerpAlpha: 0.1, lerpVectorsAlpha: 1.0, } function animate() { requestAnimationFrame(animate) controls.update() cube1.position.lerp(v1, data.lerpAlpha) // 讓 cube1 逐漸往 v1 位置靠近,其 lerpAlpha 可以想像為移動的速率,如果是 1 就會瞬移過去 cube2.position.lerpVectors(v1, v2, data.lerpVectorsAlpha) // 讓 cube2 位置為 v1 到 v2 之間距離的 lerpVectorsAlpha ,如果 lerpVectorsAlpha 是 0 , cube2 會在 cube1 的位置 controls.target.copy(cube1.position) // 讓控制器跟隨 cube1 的位置 render() stats.update() } function render() { renderer.render(scene, camera) } animate() ``` ## 使用 cannon-es 套件 使用 cannon-es 套件讓 Three.js 中的物體產生物理效果(EX 重力)。 cannon-es 套件 fork 於 cannon.js 套件,由於 cannon.js 套件自2016年起已不再維護,於是產生了 cannon-es 以維護分支 > cannon-es 的官方說明文件: https://pmndrs.github.io/cannon-es/docs/ 用法: ```typescript import * as THREE from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import * as CANNON from 'cannon-es' const scene = new THREE.Scene() scene.add(new THREE.AxesHelper(5)) const light1 = new THREE.SpotLight(0xffffff, 100) light1.position.set(2.5, 5, 5) light1.angle = Math.PI / 4 light1.penumbra = 0.5 light1.castShadow = true light1.shadow.mapSize.width = 1024 light1.shadow.mapSize.height = 1024 light1.shadow.camera.near = 0.5 light1.shadow.camera.far = 20 scene.add(light1) const light2 = new THREE.SpotLight(0xffffff, 100) light2.position.set(-2.5, 5, 5) light2.angle = Math.PI / 4 light2.penumbra = 0.5 light2.castShadow = true light2.shadow.mapSize.width = 1024 light2.shadow.mapSize.height = 1024 light2.shadow.camera.near = 0.5 light2.shadow.camera.far = 20 scene.add(light2) const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 ) camera.position.set(0, 2, 4) const renderer = new THREE.WebGLRenderer() renderer.setSize(window.innerWidth, window.innerHeight) renderer.shadowMap.enabled = true // 開啟陰影 document.body.appendChild(renderer.domElement) const controls = new OrbitControls(camera, renderer.domElement) controls.enableDamping = true // controls.target.y = 0.5 // 1. 新增世界 const world = new CANNON.World() // 創建世界 world.gravity.set(0, -9.82, 0) // 設定物理重力 // 優化效能(很少用到) // world.broadphase = new CANNON.NaiveBroadphase() // ;(world.solver as CANNON.GSSolver).iterations = 10 // world.allowSleep = true // 創建材質 const normalMaterial = new THREE.MeshNormalMaterial() const phongMaterial = new THREE.MeshPhongMaterial() // 創建 three.js 的方塊 const cubeGeometry = new THREE.BoxGeometry(1, 1, 1) const cubeMesh = new THREE.Mesh(cubeGeometry, normalMaterial) // 設定位置 cubeMesh.position.x = -3 cubeMesh.position.y = 3 // 開啟陰影 cubeMesh.castShadow = true // 將方塊添加到場景中 scene.add(cubeMesh) // 2. 新增物件到世界中 // 創建 CANNON 的方塊,在 CANNON 中使用半徑設置大小,所以 three.js 的 1, 1, 1 在 CANNON 中要用 0.5, 0.5, 0.5 const cubeShape = new CANNON.Box(new CANNON.Vec3(0.5, 0.5, 0.5)) const cubeBody = new CANNON.Body({ mass: 1 }) // 設定質量為 1 讓其擁有重力效果 cubeBody.addShape(cubeShape) // 設定其位置等於 three.js 的方塊的位置 cubeBody.position.x = cubeMesh.position.x cubeBody.position.y = cubeMesh.position.y cubeBody.position.z = cubeMesh.position.z // 往世界中添加方塊 world.addBody(cubeBody) // 創建 three.js 的球體 const sphereGeometry = new THREE.SphereGeometry(1) const sphereMesh = new THREE.Mesh(sphereGeometry, normalMaterial) // 設定位置 sphereMesh.position.x = -1 sphereMesh.position.y = 3 // 開啟陰影 sphereMesh.castShadow = true // 將球體添加到場景中 scene.add(sphereMesh) // 創建 CANNON 的球體 const sphereShape = new CANNON.Sphere(1) const sphereBody = new CANNON.Body({ mass: 1 }) // 設定質量為 1 讓其擁有重力效果 sphereBody.addShape(sphereShape) // 設定其位置等於 three.js 的球體的位置 sphereBody.position.x = sphereMesh.position.x sphereBody.position.y = sphereMesh.position.y sphereBody.position.z = sphereMesh.position.z world.addBody(sphereBody) // 創建 three.js 的十二面體 const icosahedronGeometry = new THREE.IcosahedronGeometry(1, 0) const icosahedronMesh = new THREE.Mesh(icosahedronGeometry, normalMaterial) // 設定位置 icosahedronMesh.position.x = 1 icosahedronMesh.position.y = 3 // 開啟陰影 icosahedronMesh.castShadow = true // 將十二面體添加到場景中 scene.add(icosahedronMesh) // 創建函數建立 CANNON 的凸多面體 function CreateConvexPolyhedron(geometry: THREE.BufferGeometry) { // 獲取 geometry 的所有頂點 const position = geometry.attributes.position.array // 宣告 points 存放所有的轉換成 CANNON.Vec3 的頂點 const points: CANNON.Vec3[] = [] for (let i = 0; i < position.length; i += 3) { // 將轉換好的頂點 push 到 points 中 points.push(new CANNON.Vec3(position[i], position[i + 1], position[i + 2])); } // 宣告 faces 存放所有面的組合(每三個頂點組成一個面) const faces: number[][] = [] for (let i = 0; i < position.length / 3; i += 3) { // 將每三個頂點設為一個陣列 push 到 faces 中 faces.push([i, i + 1, i + 2]) } // 使用 CANNON.ConvexPolyhedron 傳入所有頂點及面創建出 CANNON 的凸多面體 return new CANNON.ConvexPolyhedron({ vertices: points, faces: faces, }) } // 根據 IcosahedronGeometry 使用 CreateConvexPolyhedron 函數創建 CANNON 的凸多面體 const icosahedronShape = CreateConvexPolyhedron(icosahedronMesh.geometry) const icosahedronBody = new CANNON.Body({ mass: 1 }) // 設定質量為 1 讓其擁有重力效果 icosahedronBody.addShape(icosahedronShape) // 設定其位置等於 three.js 的十二面體的位置 icosahedronBody.position.x = icosahedronMesh.position.x icosahedronBody.position.y = icosahedronMesh.position.y icosahedronBody.position.z = icosahedronMesh.position.z world.addBody(icosahedronBody) // 創建 three.js 的圓環結 const torusKnotGeometry = new THREE.TorusKnotGeometry() const torusKnotMesh = new THREE.Mesh(torusKnotGeometry, normalMaterial) torusKnotMesh.position.x = 4 torusKnotMesh.position.y = 3 torusKnotMesh.castShadow = true // 開啟陰影 scene.add(torusKnotMesh) // 添加到場景中 function CreateTrimesh(geometry: THREE.BufferGeometry) { // 獲取所有頂點座標 const vertices = (geometry.attributes.position as THREE.BufferAttribute).array // 獲取所有頂點座標的索引 const indices = Object.keys(vertices).map(Number) // 使用 CANNON.Trimesh 傳入所有頂點及索引創建出 CANNON 的不規則物體 return new CANNON.Trimesh(vertices as unknown as number[], indices) } // 根據 TorusKnotGeometry 使用 CreateTrimesh 函數創建 CANNON 的不規則物體 const torusKnotShape = CreateTrimesh(torusKnotMesh.geometry) const torusKnotBody = new CANNON.Body({ mass: 1 }) // 設定質量為 1 讓其擁有重力效果 torusKnotBody.addShape(torusKnotShape) // 設定其位置等於 three.js 的圓環結的位置 torusKnotBody.position.x = torusKnotMesh.position.x torusKnotBody.position.y = torusKnotMesh.position.y torusKnotBody.position.z = torusKnotMesh.position.z world.addBody(torusKnotBody) // 添加地板 const planeGeometry = new THREE.PlaneGeometry(25, 25) const planeMesh = new THREE.Mesh(planeGeometry, phongMaterial) // 設定材質為 MeshPhongMaterial // 轉為水平方向 planeMesh.rotateX(-Math.PI / 2) planeMesh.receiveShadow = true // 接收陰影 scene.add(planeMesh) // 將平面添加到場景中 // 在世界中添加 CANNON 的地板 const planeShape = new CANNON.Plane() // CANNON 的地板預設就是無限大,無法設定尺寸 const planeBody = new CANNON.Body({ mass: 0 }) // 設定重力為 0 讓平面靜止不動 planeBody.addShape(planeShape) // 等同於 planeMesh.rotateX(-Math.PI / 2) 將地板旋轉為平的 planeBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -Math.PI / 2) world.addBody(planeBody) function animate() { requestAnimationFrame(animate) controls.update() // 3. 模擬世界更新到下一個時間點,常見傳入 1/60 因為電腦每秒跑 60 幀 world.step(1 / 60) // 4. 設定世界中物件的位置與旋轉 // 設定 three.js 的方塊位置為 CANNON 的方塊位置 cubeMesh.position.set(cubeBody.position.x, cubeBody.position.y, cubeBody.position.z) // 設定 three.js 的方塊的四元數為 CANNON 的方塊的四元數,以確保獲取到方塊的旋轉 cubeMesh.quaternion.set(cubeBody.quaternion.x, cubeBody.quaternion.y, cubeBody.quaternion.z, cubeBody.quaternion.w) // 設定 three.js 的球體位置為 CANNON 的球體位置 sphereMesh.position.set(sphereBody.position.x, sphereBody.position.y, sphereBody.position.z) // 設定 three.js 的球體的四元數為 CANNON 的球體的四元數,以確保獲取到球體的旋轉 sphereMesh.quaternion.set(sphereBody.quaternion.x, sphereBody.quaternion.y, sphereBody.quaternion.z, sphereBody.quaternion.w) // 設定 three.js 的十二面體位置為 CANNON 的十二面體位置 icosahedronMesh.position.set(icosahedronBody.position.x, icosahedronBody.position.y, icosahedronBody.position.z) // 設定 three.js 的十二面體的四元數為 CANNON 的十二面體的四元數,以確保獲取到十二面體的旋轉 icosahedronMesh.quaternion.set(icosahedronBody.quaternion.x, icosahedronBody.quaternion.y, icosahedronBody.quaternion.z, icosahedronBody.quaternion.w) // 設定 three.js 的圓環結位置為 CANNON 的圓環結位置 torusKnotMesh.position.set(torusKnotBody.position.x, torusKnotBody.position.y, torusKnotBody.position.z) // 設定 three.js 的圓環結的四元數為 CANNON 的圓環結的四元數,以確保獲取到圓環結的旋轉 torusKnotMesh.quaternion.set(torusKnotBody.quaternion.x, torusKnotBody.quaternion.y, torusKnotBody.quaternion.z, torusKnotBody.quaternion.w) render() } function render() { renderer.render(scene, camera) } animate() ``` ### cannon-es-debugger 套件 安裝 cannon-es-debugger 套件以查看在 world 中建立的形狀, 安裝指令: ``` npm i cannon-es-debugger ``` 用法: ```typescript import CannonDebugger from 'cannon-es-debugger' // 引入該套件 const cannonDebugger = CannonDebugger(scene, world) // 宣告它,傳入場景及世界 function animate() { requestAnimationFrame(animate) world.step(1 / 60) cannonDebugger.update() // 呼叫 update 方法 // ... } ``` ### cannon-es 中針對特殊模型建立世界中的形狀 已知可創建方塊(CANNON.Box)、球體(CANNON.Sphere)、凸多面體(CANNON.ConvexPolyhedron)、不規則物體(CANNON.Trimesh),其中只有球體支援與其他所有形狀進行碰撞,且效能也是最好的,所以若需針對特殊模型設定形狀,建議作法是針對模型的特定錨點建立小球體、主要形狀建立大球體。 > 可以使用 Blender 編輯模型的錨點以獲取該錨點的 X/Y/Z 座標,需注意在反推回 three.js 使用時對應的座標實際是 X/Z/-Y > EX: Blender [x, y, z] = [2, 3, 4] => three.js [x, y, z] = [2, 4, -3] ```typescript // 創建世界 const world = new CANNON.World() // 設定重力 world.gravity.set(0, -9.82, 0) // 宣告材質 const normalMaterial = new THREE.MeshNormalMaterial() const phongMaterial = new THREE.MeshPhongMaterial() // 建立存放猴子用的陣列及變數 let monkeyMeshes: THREE.Object3D[] = [] let monkeyBodies: CANNON.Body[] = [] let monkeyLoaded = false // 使用 OBJLoader 載入 Blender 的蘇珊猴子模型 const objLoader = new OBJLoader() objLoader.load( 'models/monkey.obj', (object) => { const monkeyMesh = object.children[0] as THREE.Mesh monkeyMesh.material = normalMaterial // 設定猴子為普通材質 // 遍歷一百次 for (let i = 0; i < 100; i++) { const monkeyMeshClone = monkeyMesh.clone() // 拷貝猴子 // 隨機設定猴子的位置 monkeyMeshClone.position.x = Math.floor(Math.random() * 10) - 5 monkeyMeshClone.position.z = Math.floor(Math.random() * 10) - 5 monkeyMeshClone.position.y = 5 + i // 添加猴子到場景中 scene.add(monkeyMeshClone) // 添加猴子到 monkeyMeshes 陣列中 monkeyMeshes.push(monkeyMeshClone) // 建立 CANNON 猴子的 body 並設定質量為 1 讓其產生重力 const monkeyBody = new CANNON.Body({ mass: 1 }) // 為 CANNON 猴子的錨點添加球體 monkeyBody.addShape(new CANNON.Sphere(.8), new CANNON.Vec3(0, .2, 0)) // 頭用一個大的球體 monkeyBody.addShape(new CANNON.Sphere(.05), new CANNON.Vec3(0, -.97, 0.46)) // 下巴小球體 monkeyBody.addShape(new CANNON.Sphere(.05), new CANNON.Vec3(-1.36, .29, -0.5)) // 左耳小球體 monkeyBody.addShape(new CANNON.Sphere(.05), new CANNON.Vec3(1.36, .29, -0.5)) // 右耳小球體 // 設定 CANNON 猴子的位置為 three.js 的猴子位置 monkeyBody.position.x = monkeyMeshClone.position.x monkeyBody.position.y = monkeyMeshClone.position.y monkeyBody.position.z = monkeyMeshClone.position.z // 將 CANNON 猴子添加到世界中 world.addBody(monkeyBody) // 添加 CANNON 猴子到 monkeyBodies 陣列中 monkeyBodies.push(monkeyBody) } monkeyLoaded = true }, (xhr) => { console.log((xhr.loaded / xhr.total) * 100 + '% loaded') }, (error) => { console.log('An error happened') } ) // 添加地板 const planeGeometry = new THREE.PlaneGeometry(25, 25) const planeMesh = new THREE.Mesh(planeGeometry, phongMaterial) planeMesh.rotateX(-Math.PI / 2) planeMesh.receiveShadow = true // 接收陰影 scene.add(planeMesh) // 添加 CANNON 的地板 const planeShape = new CANNON.Plane() const planeBody = new CANNON.Body({ mass: 0 }) // 設定質量為零讓地板靜止不受重力影響 planeBody.addShape(planeShape) planeBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -Math.PI / 2) world.addBody(planeBody) // 宣告 cannonDebugger ,傳入場景及世界以開啟 debug 看到世界裡的物體 const cannonDebugger = CannonDebugger(scene, world) function animate() { requestAnimationFrame(animate) world.step(1 / 60) cannonDebugger.update() // 呼叫 cannonDebugger 的 update 方法 // Copy coordinates from Cannon to Three.js if (monkeyLoaded) { monkeyMeshes.forEach((m, i) => { // 設定 three.js 的猴子位置為 CANNON 的猴子位置 m.position.set( monkeyBodies[i].position.x, monkeyBodies[i].position.y, monkeyBodies[i].position.z ) // 設定 three.js 的猴子的四元數為 CANNON 的猴子的四元數,以確保獲取到猴子的旋轉 m.quaternion.set( monkeyBodies[i].quaternion.x, monkeyBodies[i].quaternion.y, monkeyBodies[i].quaternion.z, monkeyBodies[i].quaternion.w ) }) } render() } function render() { renderer.render(scene, camera) } animate() ``` ## 相機視角跟著滑鼠移動 ```typescript let mouseX = 0; let mouseY = 0; let windowHalfX = window.innerWidth / 2; let windowHalfY = window.innerHeight / 2; document.addEventListener('mousemove', onDocumentMouseMove); function onDocumentMouseMove(event: any) { mouseX = (event.clientX - windowHalfX) / 100; mouseY = (event.clientY - windowHalfY) / 100; } function render() { camera.position.x += (mouseX - camera.position.x) * .5; camera.position.y += (- mouseY - camera.position.y) * .5; renderer.render(scene, camera); } ```