前言
在逛掘金的时候看到一个多米诺骨牌的例子非常有意思,然后看到作者也贴出了源码,就尝试去复刻一版,这一版精简了一部分代码内容,可以说是丐版。想复现的朋友可以参考一下。
原始代码参考:github.com/Gaohaoyang/…
效果预览参考原创作者主页:gaohaoyang.github.io/threeJourne…
原创作者掘金主页:CS_Joe
我大致把这个任务分解成了下面这几个步骤
- 设置相机以及视角控制器
- 创建光源
- 创建物理世界
- 创建地板以及物体
- 添加GUI面板
- renderer渲染
const scene = new THREE.Scene()
addCannoWorld()// 添加物理世界
setCameraAndControl()// 设置相机和视角控制器
createPlane()// 创建地板
addLight()// 创建光源
addTriangle()// 创建物体
addGui()// 添加gui面板
// 剩下的是递归渲染部分
以下是所需要用到的所有第三方库。原版本使用的是ts,我这里是直接用js写。
import * as THREE from 'three'
import * as CANNON from 'cannon-es'
import * as dat from 'lil-gui'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { useEffect } from 'react'
import styles from './index.module.less'
设置相机和视角控制器
这里极快地麻溜地把相机和视角控制器设置好了,学过three的或者有相关图形学知识的朋友应该都知道这俩是干啥用的。相机就代表了上帝之眼,视角控制器就是玩游戏的时候鼠标旋转所要用到的东西,这两样东西可以控制屏幕的内容,也就是肉眼可见到的地方。
const sizes = {
width: window.innerWidth,
height: window.innerHeight,
}
--------------------------------------------------------------------------------------
const camera = new THREE.PerspectiveCamera(20, sizes.width / sizes.height, 0.1, 10000)
const setCameraAndControl = () => {
const canvas = document.querySelector('#dominoes-canvas')
controls = new OrbitControls(camera, canvas)
setCamera()
setControl()
function setControl(){
controls.enableDamping = true
controls.zoomSpeed = 0.3
controls.target.set(5, 10, 0)
}
function setCamera(){
camera.position.set(5, 50, 150)
}
}
创建光源
这里我基本上照抄的原版,只不过我把它抽出来封了一个函数。主要分为添加环境光和直射光源。环境光就是整个环境下自带的光照,直射光相当于灯光。
const addLight = () => {
/**
* Light
*/
const directionLight = new THREE.DirectionalLight('#ffffff', 1)
directionLight.castShadow = true
directionLight.shadow.camera.top = 50
directionLight.shadow.camera.right = 50
directionLight.shadow.camera.bottom = -50
directionLight.shadow.camera.left = -50
directionLight.shadow.camera.near = 1
directionLight.shadow.camera.far = 200// 照射距离
directionLight.shadow.mapSize.set(2048, 2048)
directionLight.position.set(-80, 20, 10)// x y z y是高
directionLight.target.position.set(0, -15, 10);
const ambientLight = new THREE.AmbientLight(new THREE.Color('#ffffff'), 3)
scene.add(directionLight,ambientLight)
const directionLightHelper = new THREE.DirectionalLightHelper(directionLight, 2)
directionLightHelper.visible = true
gui.add(directionLightHelper, 'visible').name('直射光线')// 添加直射光的gui控制面板
scene.add(directionLightHelper)
scene.add(ambientLight)
}
添加物理世界
要想让3d空间的物体拥有物理世界的属性,需要创建一个物理的世界。CANNON是一个三维的js物理属性库。它创建的物理世界可以看作是一个只有数值的世界,包含了三维物体的坐标数据,在这个世界里面,这些坐标数据都依据这个库的计算而产生相当于现实世界的效果。three里面所有生成的物体如果想要具有物理属性,需要把这些物体在three里面的坐标和canno的物理世界坐标做一个绑定。就像是灵魂拥有了肉体。
const addCannoWorld = () => {
world = new CANNON.World()// world我在App顶部已经声明,因为后续添加物体,需要使用到这个实例。所以这个方法需要放在上面一点。
world.gravity.set(0, -10, 0)// x、y、z的重力系数
world.allowSleep = true
}
创建具有物理属性的地板
这里分为两部分,一部分是向three里面添加地板,一部分是向物理世界里添加地板,在这两个世界里面,它们的坐标都是对应的。可以给地板设置一些物理属性如摩擦力,反弹力等。默认都是将这两个世界的地板的中心设置在了原点。这些api基本上都是望文生义的非常好理解。重要的是要知道想要的效果需要设置什么东西。
const createPlane = () => {
// material
const materialPlane = new THREE.MeshStandardMaterial({
metalness: 0.4,
roughness: 0.5,
color: '#E8F5E9',
})
// plane
const plane = new THREE.Mesh(new THREE.PlaneGeometry(150, 150), materialPlane)
plane.rotateX(-Math.PI / 2)
plane.receiveShadow = true//接受阴影
scene.add(plane)
const floorMaterial = new CANNON.Material('floorMaterial')
const defaultMaterial = new CANNON.Material('default')
const defaultContactMaterial = new CANNON.ContactMaterial(defaultMaterial, defaultMaterial, {
friction: 0.01,
restitution: 0.3,
})
const floorContactMaterial = new CANNON.ContactMaterial(floorMaterial, defaultMaterial, {
friction: 0.01,
restitution: 0.6,
})
world.addContactMaterial(defaultContactMaterial)
world.addContactMaterial(floorContactMaterial)
// floor
const floorShape = new CANNON.Plane()
const floorBody = new CANNON.Body({
type: CANNON.Body.STATIC,
shape: floorShape,
material: floorMaterial,
})
floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -Math.PI / 2)
world.addBody(floorBody)
}
创建具有物理属性的多米诺骨牌
和创建地板的步骤类似,也是分别创建three和canno两个世界,然后再将它们绑定在一起。
const addOneDominoe = (x,y,z) => {
// three世界
const geometry = new THREE.BoxGeometry(0.2, 3, 1.5)
const material = new THREE.MeshStandardMaterial({
metalness: 0.3,
roughness: 0.8,
color: new THREE.Color(`rgb(1,1,1)`),
})
const dominoe = new THREE.Mesh(geometry, material)
dominoe.position.set(x,y,z)
dominoe.castShadow = true
dominoe.receiveShadow = true
scene.add(dominoe)
// Cannon body
const shape = new CANNON.Box(
new CANNON.Vec3(dominoeDepth * 0.5, dominoeHeight * 0.5, dominoeWidth * 0.5)
)
// 物理世界
const defaultMaterial = new CANNON.Material('default')
defaultMaterial.friction = 0.1
const body = new CANNON.Body({
mass: 0.1,
shape,
material: defaultMaterial,
})
body.inertia.set(1, 1, 1);
body.position.copy(dominoe.position)// 这个方法非常重要,拷贝了多米诺骨牌在three世界里面的坐标
body.sleepSpeedLimit = 1
world.addBody(body)
objectsToUpdate.push({ // objectsToUpdates是一个数组,同样我在App顶部已经声明。因为后续递归渲染需要用到。
mesh: dominoe,
body,
})
}
下面是创建多个骨牌,就是设置一下骨牌的坐标。让它们能连着倒塌。
const addTriangle = () => {
for (let row = 0; row < 9; row += 1) {
for (let i = 0; i <= row; i += 1) {
addOneDominoe(
(-dominoeHeight / 2) * (9 - row),
dominoeHeight / 2,
1.5 * dominoeWidth * i + dominoeWidth * 0.8 * (9 - row)
)
}
}
for (let i = 0; i < 10; i += 1) {
addOneDominoe(
(-dominoeHeight / 2) * 10 - (i * dominoeHeight) / 2,
dominoeHeight / 2,
dominoeWidth * 0.8 * 9
)
}
添加GUI面板(推动骨牌)
这里是GUI面板的设置,源自lil-gui这个库,可以方便一些三维建设工作的调试。这里给这个按钮设置了一个回调,即对物理世界的最后一个物体,施加了30的力,方向刚好是朝向多米诺骨堆的,当施加了这个力之后,物理世界的最后一个物体坐标便会因为产生了力的作用而发生偏移,其余所有物体被碰到都会因为力的作用坐标发生变化,从而产生页面上的倒塌效果。
const addGui = () => {
let guiObj = {
start:()=>{
world.bodies[world.bodies.length - 1].applyForce(
new CANNON.Vec3(30, 0, 0),
new CANNON.Vec3(0, 0, 0)
)
}
}
gui.add(guiObj,'start').name('推')
}
开始渲染
这里按照我分解的顺序,依次执行步骤,这里面重要的代码大概就是创建一个renderer渲染器,递归渲染了。值得注意的是,我们要在递归渲染的每一帧更新物理世界里面的坐标,让它和three世界里面的坐标在每一帧里面都保持对应,其余设置阴影光照反射之类的代码可有可无,不影响多米诺骨牌的任务,但是这些仍然是三维开发任务里面非常重要的任务,但是在这篇文章中,并不需要去死磕。到此位置便完成了多米诺骨牌的任务了!
const initScene = () => {
addCannoWorld()// 添加物理世界
setCameraAndControl()// 设置相机和视角控制器
addLight()// 创建光源
createPlane()// 创建地板
addTriangle()// 创建物体
addGui()// 添加gui面板
const canvas = document.querySelector('#dominoes-canvas')
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
})
renderer.setSize(sizes.width, sizes.height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.physicallyCorrectLights = true
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
// 递归渲染
const render = () => {
controls.update()
renderer.setSize(sizes.width, sizes.height)
requestAnimationFrame(render)
renderer.render(scene, camera)
world.fixedStep()
objectsToUpdate.forEach((object) => {
object.mesh.position.copy(object.body.position)
object.mesh.quaternion.copy(object.body.quaternion)
})
}
render()
}
完整代码
import * as THREE from 'three'
import * as CANNON from 'cannon-es'
import * as dat from 'lil-gui'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { useEffect } from 'react'
import styles from './index.module.less'
// Size
const sizes = {
width: window.innerWidth,
height: window.innerHeight,
}
const App = () => {
let scene = new THREE.Scene()
const gui = new dat.GUI()
const camera = new THREE.PerspectiveCamera(20, sizes.width / sizes.height, 0.1, 10000)
let controls = null
let world = null
let objectsToUpdate = []
useEffect(()=>{
initScene()
return () => {
destroy()
}
},[])
const initScene = () => {
addCannoWorld()// 添加物理世界
setCameraAndControl()// 设置相机和视角控制器
addLight()// 创建光源
createPlane()// 创建地板
addTriangle()// 创建物体
addGui()// 添加gui面板
const canvas = document.querySelector('#dominoes-canvas')
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
})
renderer.setSize(sizes.width, sizes.height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.physicallyCorrectLights = true
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
// 递归渲染
const render = () => {
controls.update()
renderer.setSize(sizes.width, sizes.height)
requestAnimationFrame(render)
renderer.render(scene, camera)
world.fixedStep()
objectsToUpdate.forEach((object) => {
// @ts-ignore
object.mesh.position.copy(object.body.position)
// @ts-ignore
object.mesh.quaternion.copy(object.body.quaternion)
})
}
render()
}
const setCameraAndControl = () => {
const canvas = document.querySelector('#dominoes-canvas')
controls = new OrbitControls(camera, canvas)
setCamera()
setControl()
function setControl(){
controls.enableDamping = true
controls.zoomSpeed = 0.3
controls.target.set(5, 10, 0)
}
function setCamera(){
camera.position.set(5, 50, 150)
}
}
const dominoeHeight=3,dominoeDepth=0.2,dominoeWidth=1.5
const addLight = () => {
/**
* Light
*/
const directionLight = new THREE.DirectionalLight('#ffffff', 1)
directionLight.castShadow = true
directionLight.shadow.camera.top = 50
directionLight.shadow.camera.right = 50
directionLight.shadow.camera.bottom = -50
directionLight.shadow.camera.left = -50
directionLight.shadow.camera.near = 1
directionLight.shadow.camera.far = 200// 照射距离
directionLight.shadow.mapSize.set(2048, 2048)
directionLight.position.set(-80, 20, 10)// x y z y是高
directionLight.target.position.set(0, -15, 10);
const ambientLight = new THREE.AmbientLight(new THREE.Color('#ffffff'), 3)
scene.add(directionLight,ambientLight)
const directionLightHelper = new THREE.DirectionalLightHelper(directionLight, 2)
directionLightHelper.visible = true
gui.add(directionLightHelper, 'visible').name('直射光线')
scene.add(directionLightHelper)
scene.add(ambientLight)
}
const createPlane = () => {
// material
const materialPlane = new THREE.MeshStandardMaterial({
metalness: 0.4,
roughness: 0.5,
color: '#E8F5E9',
})
// plane
const plane = new THREE.Mesh(new THREE.PlaneGeometry(150, 150), materialPlane)
plane.rotateX(-Math.PI / 2)
plane.receiveShadow = true
scene.add(plane)
const floorMaterial = new CANNON.Material('floorMaterial')
const defaultMaterial = new CANNON.Material('default')
const defaultContactMaterial = new CANNON.ContactMaterial(defaultMaterial, defaultMaterial, {
friction: 0.01,
restitution: 0.3,
})
const floorContactMaterial = new CANNON.ContactMaterial(floorMaterial, defaultMaterial, {
friction: 0.01,
restitution: 0.6,
})
world.addContactMaterial(defaultContactMaterial)
world.addContactMaterial(floorContactMaterial)
// floor
const floorShape = new CANNON.Plane()
const floorBody = new CANNON.Body({
type: CANNON.Body.STATIC,
shape: floorShape,
material: floorMaterial,
})
floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -Math.PI / 2)
world.addBody(floorBody)
}
const addOneDominoe = (x,y,z) => {
const geometry = new THREE.BoxGeometry(0.2, 3, 1.5)
const material = new THREE.MeshStandardMaterial({
metalness: 0.3,
roughness: 0.8,
color: new THREE.Color(`rgb(1,1,1)`),
})
const dominoe= new THREE.Mesh(geometry, material)
dominoe.position.set(x,y,z)
dominoe.castShadow = true
dominoe.receiveShadow = true
scene.add(dominoe)
// Cannon body
const shape = new CANNON.Box(
new CANNON.Vec3(dominoeDepth * 0.5, dominoeHeight * 0.5, dominoeWidth * 0.5)
)
const defaultMaterial = new CANNON.Material('default')
defaultMaterial.friction = 0.1
const body = new CANNON.Body({
mass: 0.1,
shape,
material: defaultMaterial,
})
body.inertia.set(1, 1, 1);
// @ts-ignore
body.position.copy(dominoe.position)
body.sleepSpeedLimit = 1
world.addBody(body)
objectsToUpdate.push({
mesh: dominoe,
body,
})
// body.addEventListener('collide', playHitSound)
}
const addTriangle = () => {
for (let row = 0; row < 9; row += 1) {
for (let i = 0; i <= row; i += 1) {
addOneDominoe(
(-dominoeHeight / 2) * (9 - row),
dominoeHeight / 2,
1.5 * dominoeWidth * i + dominoeWidth * 0.8 * (9 - row)
)
}
}
// start line
for (let i = 0; i < 10; i += 1) {
addOneDominoe(
(-dominoeHeight / 2) * 10 - (i * dominoeHeight) / 2,
dominoeHeight / 2,
dominoeWidth * 0.8 * 9
)
}
}
const addCannoWorld = () => {
world = new CANNON.World()
world.gravity.set(0, -10, 0)// x、y、z的重力系数
world.allowSleep = true
}
const addGui = () => {
let guiObj = {
start:()=>{
console.log(1);
world.bodies[world.bodies.length - 1].applyForce(
new CANNON.Vec3(30, 0, 0),
new CANNON.Vec3(0, 0, 0)
)
}
}
gui.add(guiObj,'start').name('推')
}
const destroy = () => {
gui.destroy()
}
return (
<canvas id='dominoes-canvas' className={styles.scene}>
</canvas>
)
}
export default App