Three.js 之 11 Haunted House 恐怖鬼屋

語言: CN / TW / HK

持續創作,加速成長!這是我參與「掘金日新計劃 · 6 月更文挑戰」的第5天,點選檢視活動詳情

本系列為 Three.js journey 教程學習筆記。包含以下內容

未完待續。

本節將使用我們之前學習的內容來建立一個鬼屋。我們會建立一個房子,有門、屋頂、和一些灌木,我們也會建立一些墓碑,還有幽靈的光飄過併產生投影。

本節完成效果,線上 demo 連結

可掃碼訪問

二維碼 | 手機截圖 --- | --- |

demo 原始碼

開始之前先約定一下關於長度單位的問題。

根據不同場景,我們可以認為1代表的長度不同,例如建立比較巨集大的場景如陸地地圖可以認為1代表1km,建立房屋可以認為1代表1m,建立小場景可以認為1代表1cm。接下來就開始吧

建立房屋

地面和牆壁

使用群組的方式來新增房屋,為了後續方便整體調整房屋大小

```js // house const house = new THREE.Group() scene.add(house)

// walls const walls = new THREE.Mesh( new THREE.BoxGeometry(4, 2.5, 4), new THREE.MeshStandardMaterial({ color: '#ac8e82' }) ) walls.position.y = 1.25 house.add(walls) ```

再調整一下地面大小、光的位置和相機位置,效果和完整程式碼如下

```js import * as THREE from 'three' import './style.css' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import stats from '../common/stats' import { listenResize } from '../common/utils'

// Canvas const canvas = document.querySelector('#mainCanvas') as HTMLCanvasElement

// Scene const scene = new THREE.Scene()

/* * Objects / // Material const material = new THREE.MeshStandardMaterial() material.metalness = 0 material.roughness = 0.4

// Objects const plane = new THREE.Mesh(new THREE.PlaneGeometry(20, 20), material) plane.rotation.set(-Math.PI / 2, 0, 0) plane.position.set(0, 0, 0)

scene.add(plane)

// house const house = new THREE.Group() scene.add(house)

// walls const walls = new THREE.Mesh( new THREE.BoxGeometry(4, 2.5, 4), new THREE.MeshStandardMaterial({ color: '#ac8e82' }) ) walls.position.y = 1.25 house.add(walls)

/* * Lights / const ambientLight = new THREE.AmbientLight('#ffffff', 0.3) scene.add(ambientLight)

const directionalLight = new THREE.DirectionalLight('#ffffaa', 0.5) directionalLight.position.set(1, 0.75, 0) scene.add(directionalLight)

// Size const sizes = { width: window.innerWidth, height: window.innerHeight, }

// Camera const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100) camera.position.set(4, 2, 4)

const controls = new OrbitControls(camera, canvas) controls.enableDamping = true

// Renderer const renderer = new THREE.WebGLRenderer({ canvas, }) renderer.setSize(sizes.width, sizes.height) renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))

listenResize(sizes, camera, renderer)

// Animations const tick = () => { stats.begin()

controls.update()

// Render renderer.render(scene, camera) stats.end() requestAnimationFrame(tick) }

tick() ```

屋頂

我們使用 ConeGeometry 來做屋頂

js // roof const roof = new THREE.Mesh( new THREE.ConeGeometry(3.25, 1, 4), new THREE.MeshStandardMaterial({ color: '#b35f45' }) ) roof.rotation.y = Math.PI / 4 roof.position.y = 2.5 + 0.5 house.add(roof)

大門

增加門

js // door const door = new THREE.Mesh( new THREE.PlaneGeometry(2, 2), new THREE.MeshStandardMaterial({ color: '#FFE082', }), ) door.position.y = 1 door.position.z = 2 + 0.001 house.add(door)

可以看到 z 軸我們增加了一點點位移,這是因為如果相同的兩個平面,WebGL 可能會產生一個 z-fighting 的 bug,導致閃動。

灌木叢

接下來在新增一些灌木叢,我們將使用球體,複用幾何體和材質,只做放大和位移

```js // Bushes const bushGeometry = new THREE.SphereGeometry(1, 16, 16) const bushMaterial = new THREE.MeshStandardMaterial({ color: '#89c854' }) const bush1 = new THREE.Mesh(bushGeometry, bushMaterial) bush1.scale.set(0.5, 0.5, 0.5) bush1.position.set(0.8, 0.2, 2.2)

const bush2 = new THREE.Mesh(bushGeometry, bushMaterial) bush2.scale.set(0.25, 0.25, 0.25) bush2.position.set(1.4, 0.1, 2.1)

const bush3 = new THREE.Mesh(bushGeometry, bushMaterial) bush3.scale.set(0.4, 0.4, 0.4) bush3.position.set(-0.8, 0.1, 2.2)

const bush4 = new THREE.Mesh(bushGeometry, bushMaterial) bush4.scale.set(0.15, 0.15, 0.15) bush4.position.set(-1, 0.05, 2.6)

house.add(bush1, bush2, bush3, bush4) ```

墓碑群

我們使用程式碼實現墓碑的隨機擺放

```js // graves const graves = new THREE.Group() scene.add(graves)

const graveGeometry = new THREE.BoxGeometry(0.6, 0.8, 0.2) const graveMaterial = new THREE.MeshStandardMaterial({ color: '#b2b6b1', })

for (let i = 0; i < 50; i += 1) { const grave = new THREE.Mesh(graveGeometry, graveMaterial) const angle = Math.random() * Math.PI * 2 const radius = 3 + Math.random() * 6 const x = Math.cos(angle) * radius const z = Math.sin(angle) * radius grave.position.set(x, 0.3, z) grave.rotation.z = (Math.random() - 0.5) * 0.4 grave.rotation.y = (Math.random() - 0.5) * 0.4 graves.add(grave) } ```

我們需要一些恐怖的光線效果,修改之前環境光和平行光,並增加大門頂部的點光源

```js /* * Lights / const ambientLight = new THREE.AmbientLight('#b9d5ff', 0.12) scene.add(ambientLight)

const directionalLight = new THREE.DirectionalLight('#b9d5ff', 0.12) directionalLight.position.set(1, 0.75, 0) scene.add(directionalLight)

// Door light const doorLight = new THREE.PointLight('#ff7d46', 1, 7) doorLight.position.set(0, 2.2, 2.7) house.add(doorLight) ```

Three.js 中內建了霧的效果,參見 Fog 類

其建構函式

js Fog( color : Integer, near : Float, far : Float )

  • near 開始應用霧的最小距離。距離小於活動攝像機“near”個單位的物體將不會被霧所影響。
  • far 結束計算、應用霧的最大距離,距離大於活動攝像機“far”個單位的物體將不會被霧所影響。預設值是1000。

js const fog = new THREE.Fog('#262837', 1, 15) scene.fog = fog

添加了 fog 後的效果

可以看到已經蒙上了一層霧,但畫布的背景還是黑色的,我們需要改變畫布背景色,將 renderer 的顏色設定為與霧相同

js renderer.setClearColor('#262837')

貼圖紋理

接下來我們新增紋理貼圖,使用之前學到到 material 中的內容

js // door const door = new THREE.Mesh( new THREE.PlaneGeometry(2, 2, 100, 100), new THREE.MeshStandardMaterial({ map: doorColorTexture, transparent: true, alphaMap: doorAlphaTexture, aoMap: doorAmbientOcclusionTexture, displacementMap: doorHeightTexture, displacementScale: 0.01, normalMap: doorNormalTexture, metalnessMap: doorMetalnessTexture, roughnessMap: doorRoughnessTexture, }), ) door.geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(door.geometry.attributes.uv.array, 2)) door.position.y = 1 door.position.z = 2 + 0.001 house.add(door)

看到門的貼圖效果還不錯

我們也可以嘗試換一張貼圖,並增加一些磚塊

```js // Textures const textureLoader = new THREE.TextureLoader() const doorColorTexture = textureLoader.load('../assets/textures/door2/baseColor.jpg') const doorAmbientOcclusionTexture = textureLoader.load( '../assets/textures/door2/ambientOcclusion.jpg' ) const doorHeightTexture = textureLoader.load('../assets/textures/door2/height.png') const doorNormalTexture = textureLoader.load('../assets/textures/door2/normal.jpg') const doorMetalnessTexture = textureLoader.load('../assets/textures/door2/metalness.jpg') const doorRoughnessTexture = textureLoader.load('../assets/textures/door2/roughness.jpg')

const brickColorTexture = textureLoader.load('../assets/textures/brick/baseColor.jpg')

const brickAmbientOcclusionTexture = textureLoader.load( '../assets/textures/brick/ambientOcclusion.jpg' ) const brickHeightTexture = textureLoader.load('../assets/textures/brick/height.png') const brickNormalTexture = textureLoader.load('../assets/textures/brick/normal.jpg') const brickRoughnessTexture = textureLoader.load('../assets/textures/door2/roughness.jpg')

...

// walls const walls = new THREE.Mesh( new THREE.BoxGeometry(4, 2.5, 4, 200, 200), new THREE.MeshStandardMaterial({ map: brickColorTexture, aoMap: brickAmbientOcclusionTexture, displacementMap: brickHeightTexture, displacementScale: 0.001, normalMap: brickNormalTexture, roughnessMap: brickRoughnessTexture, }) ) walls.position.y = 1.25 house.add(walls)

// door const door = new THREE.Mesh( new THREE.PlaneGeometry(2, 2, 100, 100), new THREE.MeshStandardMaterial({ map: doorColorTexture, transparent: true, // alphaMap: doorAlphaTexture, aoMap: doorAmbientOcclusionTexture, displacementMap: doorHeightTexture, displacementScale: 0.04, normalMap: doorNormalTexture, metalnessMap: doorMetalnessTexture, roughnessMap: doorRoughnessTexture, }) ) door.geometry.setAttribute( 'uv2', new THREE.Float32BufferAttribute(door.geometry.attributes.uv.array, 2) ) door.position.y = 1 door.position.z = 2 + 0.001 house.add(door) ```

效果如下

磚塊可能太大了,我們可以將其 repeat,記得所有的紋理都要一起 repeat

```js brickColorTexture.repeat.set(3, 3) brickAmbientOcclusionTexture.repeat.set(3, 3) brickHeightTexture.repeat.set(3, 3) brickNormalTexture.repeat.set(3, 3) brickRoughnessTexture.repeat.set(3, 3)

brickColorTexture.wrapS = THREE.RepeatWrapping brickAmbientOcclusionTexture.wrapS = THREE.RepeatWrapping brickHeightTexture.wrapS = THREE.RepeatWrapping brickNormalTexture.wrapS = THREE.RepeatWrapping brickRoughnessTexture.wrapS = THREE.RepeatWrapping

brickColorTexture.wrapT = THREE.RepeatWrapping brickAmbientOcclusionTexture.wrapT = THREE.RepeatWrapping brickHeightTexture.wrapT = THREE.RepeatWrapping brickNormalTexture.wrapT = THREE.RepeatWrapping brickRoughnessTexture.wrapT = THREE.RepeatWrapping ```

增加一些地面的紋理

```js const floorColorTexture = textureLoader.load('../assets/textures/floor/baseColor.jpg') const floorAmbientOcclusionTexture = textureLoader.load( '../assets/textures/floor/ambientOcclusion.jpg', ) const floorHeightTexture = textureLoader.load('../assets/textures/floor/height.png') const floorNormalTexture = textureLoader.load('../assets/textures/floor/normal.jpg') const floorRoughnessTexture = textureLoader.load('../assets/textures/door2/roughness.jpg') floorColorTexture.repeat.set(8, 8) floorAmbientOcclusionTexture.repeat.set(8, 8) floorHeightTexture.repeat.set(8, 8) floorNormalTexture.repeat.set(8, 8) floorRoughnessTexture.repeat.set(8, 8)

floorColorTexture.wrapS = THREE.RepeatWrapping floorAmbientOcclusionTexture.wrapS = THREE.RepeatWrapping floorHeightTexture.wrapS = THREE.RepeatWrapping floorNormalTexture.wrapS = THREE.RepeatWrapping floorRoughnessTexture.wrapS = THREE.RepeatWrapping

floorColorTexture.wrapT = THREE.RepeatWrapping floorAmbientOcclusionTexture.wrapT = THREE.RepeatWrapping floorHeightTexture.wrapT = THREE.RepeatWrapping floorNormalTexture.wrapT = THREE.RepeatWrapping floorRoughnessTexture.wrapT = THREE.RepeatWrapping

// ground const plane = new THREE.Mesh( new THREE.PlaneGeometry(40, 40), new THREE.MeshStandardMaterial({ map: floorColorTexture, aoMap: floorAmbientOcclusionTexture, displacementMap: floorHeightTexture, displacementScale: 0.01, normalMap: floorNormalTexture, roughnessMap: floorRoughnessTexture, }), ) plane.rotation.set(-Math.PI / 2, 0, 0) plane.position.set(0, 0, 0) scene.add(plane) ```

新增幽靈光

使用點光源作為幽靈光

```js /* * Ghosts / const ghost1 = new THREE.PointLight('#ff00ff', 2, 3) scene.add(ghost1)

const ghost2 = new THREE.PointLight('#00ffff', 2, 3) scene.add(ghost2)

const ghost3 = new THREE.PointLight('#ffff00', 2, 3) scene.add(ghost3) ```

增加一些動畫

```js // Animations const clock = new THREE.Clock() const tick = () => { stats.begin()

const elapsedTime = clock.getElapsedTime()

// Ghosts const ghost1Angle = elapsedTime * 0.5 ghost1.position.x = Math.cos(ghost1Angle) * 4 ghost1.position.z = Math.sin(ghost1Angle) * 4 ghost1.position.y = Math.sin(elapsedTime * 3)

const ghost2Angle = -elapsedTime * 0.32 ghost2.position.x = Math.cos(ghost2Angle) * 5 ghost2.position.z = Math.sin(ghost2Angle) * 5 ghost2.position.y = Math.sin(elapsedTime * 4) + Math.sin(elapsedTime * 2.5)

const ghost3Angle = -elapsedTime * 0.18 ghost3.position.x = Math.cos(ghost3Angle) * (7 + Math.sin(elapsedTime * 0.32)) ghost3.position.z = Math.sin(ghost3Angle) * (7 + Math.sin(elapsedTime * 0.5)) ghost3.position.y = Math.sin(elapsedTime * 4) + Math.sin(elapsedTime * 2.5)

controls.update()

// Render renderer.render(scene, camera) stats.end() requestAnimationFrame(tick) } ```

開啟投影

使用上一節學到的內容開啟投影。

renderer 開啟 shadowMap

js renderer.shadowMap.enabled = true

並設定產生投影和接受投影的物體

```js directionalLight.castShadow = true doorLight.castShadow = true ghost1.castShadow = true ghost2.castShadow = true ghost3.castShadow = true

walls.castShadow = true bush1.castShadow = true bush2.castShadow = true bush3.castShadow = true bush4.castShadow = true

plane.receiveShadow = true ```

在 for 迴圈中為墓碑也開啟投影

js grave.castShadow = true

效果如下

線上 demo 連結

可掃碼訪問

demo 原始碼

小結

本節使用前面所學知識實現了一個完整的 demo,當然這個 demo 還有很多可以優化的地方,比如墓碑上的字,墓碑不重疊的演算法,增加音效等。讀者有興趣可以試著新增深入研究,比如最後我又加了些恐怖的音效。