three分解步骤实现多米诺骨牌(React 版)(侵删)

前言

在逛掘金的时候看到一个多米诺骨牌的例子非常有意思,然后看到作者也贴出了源码,就尝试去复刻一版,这一版精简了一部分代码内容,可以说是丐版。想复现的朋友可以参考一下。

原始代码参考:github.com/Gaohaoyang/...

效果预览参考原创作者主页:gaohaoyang.github.io/threeJourne...

原创作者掘金主页:CS_Joe

我大致把这个任务分解成了下面这几个步骤

  • 设置相机以及视角控制器
  • 创建光源
  • 创建物理世界
  • 创建地板以及物体
  • 添加GUI面板
  • renderer渲染
js 复制代码
const scene = new THREE.Scene()
addCannoWorld()// 添加物理世界
setCameraAndControl()// 设置相机和视角控制器
createPlane()// 创建地板
addLight()// 创建光源
addTriangle()// 创建物体
addGui()// 添加gui面板
// 剩下的是递归渲染部分

以下是所需要用到的所有第三方库。原版本使用的是ts,我这里是直接用js写。

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的或者有相关图形学知识的朋友应该都知道这俩是干啥用的。相机就代表了上帝之眼,视角控制器就是玩游戏的时候鼠标旋转所要用到的东西,这两样东西可以控制屏幕的内容,也就是肉眼可见到的地方。

js 复制代码
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)
    }
}

创建光源

这里我基本上照抄的原版,只不过我把它抽出来封了一个函数。主要分为添加环境光和直射光源。环境光就是整个环境下自带的光照,直射光相当于灯光。

js 复制代码
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的物理世界坐标做一个绑定。就像是灵魂拥有了肉体。

js 复制代码
    const addCannoWorld = () => {
        world = new CANNON.World()// world我在App顶部已经声明,因为后续添加物体,需要使用到这个实例。所以这个方法需要放在上面一点。
        world.gravity.set(0, -10, 0)// x、y、z的重力系数
        world.allowSleep = true
    }

创建具有物理属性的地板

这里分为两部分,一部分是向three里面添加地板,一部分是向物理世界里添加地板,在这两个世界里面,它们的坐标都是对应的。可以给地板设置一些物理属性如摩擦力,反弹力等。默认都是将这两个世界的地板的中心设置在了原点。这些api基本上都是望文生义的非常好理解。重要的是要知道想要的效果需要设置什么东西。

js 复制代码
    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两个世界,然后再将它们绑定在一起。

js 复制代码
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,
    })
}

下面是创建多个骨牌,就是设置一下骨牌的坐标。让它们能连着倒塌。

js 复制代码
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的力,方向刚好是朝向多米诺骨堆的,当施加了这个力之后,物理世界的最后一个物体坐标便会因为产生了力的作用而发生偏移,其余所有物体被碰到都会因为力的作用坐标发生变化,从而产生页面上的倒塌效果。

js 复制代码
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世界里面的坐标在每一帧里面都保持对应,其余设置阴影光照反射之类的代码可有可无,不影响多米诺骨牌的任务,但是这些仍然是三维开发任务里面非常重要的任务,但是在这篇文章中,并不需要去死磕。到此位置便完成了多米诺骨牌的任务了!

js 复制代码
    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()
    }

完整代码

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'
// 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
相关推荐
甜兒.34 分钟前
鸿蒙小技巧
前端·华为·typescript·harmonyos
Jiaberrr4 小时前
前端实战:使用JS和Canvas实现运算图形验证码(uniapp、微信小程序同样可用)
前端·javascript·vue.js·微信小程序·uni-app
everyStudy4 小时前
JS中判断字符串中是否包含指定字符
开发语言·前端·javascript
城南云小白4 小时前
web基础+http协议+httpd详细配置
前端·网络协议·http
前端小趴菜、4 小时前
Web Worker 简单使用
前端
web_learning_3214 小时前
信息收集常用指令
前端·搜索引擎
tabzzz5 小时前
Webpack 概念速通:从入门到掌握构建工具的精髓
前端·webpack
200不是二百5 小时前
Vuex详解
前端·javascript·vue.js
滔滔不绝tao5 小时前
自动化测试常用函数
前端·css·html5
码爸5 小时前
flink doris批量sink
java·前端·flink