Three.js 之 11 Haunted House 恐怖鬼屋
持續創作,加速成長!這是我參與「掘金日新計劃 · 6 月更文挑戰」的第5天,點選檢視活動詳情
本系列為 Three.js journey 教程學習筆記。包含以下內容
- Three.js 之 1 Animation 動畫
- Three.js 之 2 Camera 相機
- Three.js 之 3 畫布與全屏
- Three.js 之 4 Geometry 幾何體
- Three.js 之 5 debug UI
- Three.js 之 6 Texture 紋理
- Three.js 之 7 Materials 材質
- Three.js 之 8 炫酷的 3D Text
- Three.js 之 9 Light 光
- Three.js 之 10 Shadow 投影
- Three.js 之 11 Haunted House 恐怖鬼屋
- Three.js 之 12 Particles 粒子效果
- Three.js 之 13 Galaxy 銀河效果生成器
未完待續。
本節將使用我們之前學習的內容來建立一個鬼屋。我們會建立一個房子,有門、屋頂、和一些灌木,我們也會建立一些墓碑,還有幽靈的光飄過併產生投影。
本節完成效果,線上 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 還有很多可以優化的地方,比如墓碑上的字,墓碑不重疊的演算法,增加音效等。讀者有興趣可以試著新增深入研究,比如最後我又加了些恐怖的音效。