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
相关推荐
清秋9 分钟前
全网最全 ECMAScript 攻略( 更新至 ES2026)
前端·javascript·编程语言
Cao_Ron26 分钟前
用 SVG image symbol 画 ECharts graph 圆形节点
前端
hxy060131 分钟前
React-Call让你的React组件像异步函数一样调用
前端·react.js
Hilaku32 分钟前
为什么 AI 写前端时,总是优先选择 React,而不是 Vue?
前端·javascript·程序员
京东云开发者34 分钟前
GPT-5.6、ChatGPT Work 与 Codex 更新食用指南
前端
Cao_Ron1 小时前
用 ECharts graph 画出卡片式矩形节点
前端
skiyee1 小时前
换个底座的 Wot Starter 居然让 AI 更懂项目!
前端·ai编程
anyup1 小时前
这一套绝了!AI 大模型写故事,星云 SDK 驱动 3D 数字人实时演绎
前端·人工智能·aigc
耀耀切克闹灬1 小时前
短链跳转
前端
孟陬1 小时前
高质量全方位的 AI 工具 sse-stuntman — Mock SSE 接口
前端·node.js·openai