這裡是「Three.js學習日誌」的第23篇,這篇是在講解使用three.js + socket.io打造3D聊天室作品。這系列的文章假設讀者看得懂javascript,並且有Canvas 2D Context的相關知識。
今天我們要開始本次賽程的第一個創作!話不多說,就讓我們馬上開始吧!
這邊先來看一下今天我們預計要達成的進度畫面。
我們在上一回製作了three.js
的boilerplate
,也就是專案模板!
而這次我們當然就是要直接拿來使用了,不過在用之前我們先來看圖回憶一下整體模板的流程架構。
備註:由於筆者在2022/10/9有對上一回的文章做過一些優化更改,部分的內容可能跟賽事官方保留的快照有些微差異(主要是補上
Playground
類別的描述),在這邊特此註明,對評審造成不便,敬請見諒。
Github Repository
:
首先就是要先從上面的Github Repository
先clone
一份下來(當然也可以直接選擇Use this template)。
接著:
npm i
npm run dev
先跟大家說一下,這邊我其實已經先在上面的REPO裝好
gsap
,以備後續使用。
./src/pages/index.main.ejs
我們先來稍微修改一下index.main.ejs
的架構,以符合之後UI上的需求。
<!DOCTYPE html>
<html lang="en">
<%- include('../template/head.ejs',{title:'Index'})%>
<body>
<div class="wrapper" id="wrapper">
<div class="wrapper__inner">
<div class="wrapper__canvas-block" id="canvas-block">
<canvas class="wrapper__canvas"></canvas>
</div>
<div class="wrapper__chat-block chat-block " id="chat-block">
<button class="chat-block__toggler" id="chat-block-toggler"></button>
</div>
</div>
</div>
</body>
</html>
./src/ts/util/sizer.ts
這邊因為我們在調整過index.main.ejs
之後,canvas
的resize
機制會變得有點異常,所以得要修正一下sizer
的sizing
方法。
sizing() {
// 將參考的對象改為父層元素而非window
const rect = this.canvas.parentElement.getBoundingClientRect();
this.width = rect.width;
this.height = rect.height
this.trigger('resize', [this.width, this.height])
}
調整完結構接下來當然就是調整./src/scss/main.scss
的樣式。
不過老實說筆者自己也覺得在這邊把一拖拉庫的scss
搬上來照貼實在沒太大意義。
畢竟切版也不是這次的主題
所以我打算用圖片說明一下我在UI方面的規劃。
這邊我會把UI分成canvas3D聊天室
區和實體文本聊天室
區。
給讀者的題外話: 對 ~ 兩邊都會顯示聊天室的內容,因為筆者小弟我覺得這樣很Cool~,麻煩別吐槽我實用性或意義的問題,那樣很沒幽默感唷 ^.<*~
canvas3D聊天室
: 就是中間的方塊
實體文本聊天室
: 右下角的按鈕按下之後整個畫面會往左推,接著在右側顯示出來實體文本聊天室
的畫面。
如果真的想要確認樣式的部分,我們在這個作品創作的部分結束後會再提供這個專案的REPO地址。
在上面的進度畫面中我們其實可以看到畫面上會有灰色-深灰色的漸層背景,這部分我們之前是沒有提到過。
其實那就是把環境貼圖應用在背景的結果,而我是在./src/ts/class/env.ts
作的相關設置。
./src/ts/class/env.ts
import { Base } from './base';
import { AmbientLight, Clock, CubeTextureLoader, DirectionalLight } from 'three';
export class Env {
ambientLight: AmbientLight;
directionalLight: DirectionalLight;
constructor(private base: Base) {
this.setLights();
}
setLights() {
this.setAmbientLight();
this.setDirectionalLight();
this.setBackground();
}
setDirectionalLight() {
this.directionalLight = new DirectionalLight(0xffffff, 1);
this.directionalLight.castShadow = true
this.directionalLight.shadow.mapSize.set(2048, 2048)
this.directionalLight.shadow.normalBias = 0.05
this.directionalLight.position.set(3.5, 2, - 1.25)
this.base.scene.add(this.directionalLight)
}
setAmbientLight() {
this.ambientLight = new AmbientLight(0xffffff, 1);
this.base.scene.add(this.ambientLight)
}
//把`CubeTexture`施加在`this.base.scene.background`上面即可以。
setBackground() {
this.base.scene.background = this.base.resources.gradientCubeTexture
}
update(clock: Clock) {
}
}
在我們製作的three.js boilerplate
中,如果想要創建新的物件,規範上是要開一個新的文件放在./src/ts/mesh/
底下的
並且要在
./src/ts/mesh/index.ts
Export 出去。
所以這邊我們先創立一個cube.ts
在./src/ts/mesh/
底下
./src/ts/mesh/cube.ts
import { Clock, ExtrudeGeometry, Group, Mesh, MeshMatcapMaterial, Shape } from "three";
import { Base } from "../class/base";
import { MeshType } from "../interface";
export class Cube implements MeshType {
mesh: Mesh;
group: Group;
ready = false;
constructor(private base: Base) {
this.setModel();
}
// 創建帶有導角的方塊的Geometry
createRoundedBoxGeo(width: number, height: number, depth: number, radius0: number, smoothness: number) {
let shape = new Shape();
let eps = 0.00001;
let radius = radius0 - eps;
let faceRadius = 0.25;
// 開始繪製Shape路徑,absarc是用來繪製橢圓曲線用的
shape.absarc(eps, eps, faceRadius, -Math.PI / 2, -Math.PI, true);
shape.absarc(eps, height - radius * 2, faceRadius, Math.PI, Math.PI / 2, true);
shape.absarc(width - radius * 2, height - radius * 2, faceRadius, Math.PI / 2, 0, true);
shape.absarc(width - radius * 2, eps, faceRadius, 0, -Math.PI / 2, true);
// 把shape傳進去ExtrudeGeometry做extrude 和bevel
let geometry = new ExtrudeGeometry(shape, {
depth: depth - radius0,
bevelEnabled: true,
bevelSegments: smoothness * 2,
steps: 1,
bevelSize: radius,
bevelThickness: radius0,
curveSegments: smoothness
});
//以3D物件的包圍盒作為基準,相3D物件置中
//不這麼做的話ExtrudeGeometry會以左下角為基準
geometry.center();
return geometry;
}
update(clock: Clock) {
}
在這邊我們實際上是用ExtrudeGeometry
來實作導角方塊。
所謂的Extrude
(突出)其實是3D
建模的一種術語,意思是把一或多個平面沿著它自己的法向量突出,並形成一個全新的體積。
extrude前和extrude後
而大多數3D
建模軟體在Extrude
之後都有搭配一個機能,叫做Bevel
,這個機能就是用來讓我們創造導角用的。
blender的bevel看起來就像這樣
而three.js
的ExtrudeGeometry
用法是:
首先要先傳入一個 Shape
的實例,Shape
就有點像我們在2D Context
上繪製的路徑
把shape傳進去ExtrudeGeometry做extrude
和bevel
。
最後要記得發動geometry
的center
方法,不然他會以左下角為原點置中
我的構想是這個方塊在onload
的時候會有一個比較大幅度的旋轉動畫,而且本身會在這個動畫之後慢慢自旋。
所以這邊我們這樣寫:
./src/ts/mesh/cube.ts
...
setModel() {
const geo = this.createRoundedBoxGeo(3, 3, 3, 0.4, 20);
//使用Matcap紋理,這樣可以大幅減少實時光照的效能問題
const mat = new MeshMatcapMaterial({
matcap: this.base.resources.cubeMatcap
})
// 這邊我們先建立一個group
this.group = new Group();
this.mesh = new Mesh(geo, mat);
//開場的時候先把方塊縮小到看不見
this.mesh.scale.set(0, 0, 0);
//並且XYZ軸都旋轉60度
this.mesh.rotation.set(Math.PI / 3, Math.PI / 3, Math.PI / 3);
//把mesh放到group裡面
this.group.add(this.mesh);
//再把group放到scene裡面
this.base.scene.add(this.group);
this.doAnimation();
}
// 開場的時候使用gsap.to快速旋轉方塊mesh
doAnimation() {
gsap.to(this.mesh.rotation, {
x: 0,
y: 0,
z: 0,
duration: 1, // 用Tween的方式刻意的讓傳遞數值的動作產生delay
paused: true
}).play()
gsap.to(this.mesh.scale, {
x: 1,
y: 1,
z: 1,
duration: 2, // 用Tween的方式刻意的讓傳遞數值的動作產生delay
paused: true
}).play()
}
// 每次tick loop都旋轉group
update(clock: Clock) {
this.group.rotation.y = clock.getElapsedTime() / 3;
}
}
這邊比較值得一提的就是旋轉動畫的實作。
這邊我選擇去把mesh
放入一個group
中,再把group
加入場景。
mesh
會作為內層,開場的時候會被旋轉。group
會作為外層,每次tick loop
的時候會被旋轉。像這樣去實作方塊的動畫,也就是我們之前有提到過的「把旋轉軸分離在不同層級」,這樣就可以同時達成兩種旋轉方向的總和,而且也比較直觀。
如果忘記是哪裡有提到,可以看這邊
今天我們主要是作完整體外觀的一部分,明天將會繼續這部分的製作,希望各位能夠繼續追蹤~