Skip to content
Go back

Three.js Webgl动画,物理碰撞引擎(三)

Updated:  at  11:44 AM

浏览器事件触发webgl动画

页面骨架及样式

<style>
    html
    {
        background: #1e1a20;
    }

    .webgl
    {
        position: fixed;
        top: 0;
        left: 0;
        outline: none;
    }

    .section
    {
        display: flex;
        align-items: center;
        height: 100vh;
        position: relative;
        font-family: 'Cabin', sans-serif;
        color: #ffeded;
        text-transform: uppercase;
        font-size: 7vmin;
        padding-left: 10%;
        padding-right: 10%;
    }

    section:nth-child(odd)
    {
        justify-content: flex-end;
    }
</style>

<section>第一块</section>
<section>第二块</section>
<section>第三块</section>

渲染器设置

// 为保证体验,将页面的背景颜色设置为与渲染器的clearColor相同的颜色,或者使clearColor透明并仅设置页面上的背景颜色
const renderer = new THREE.WebGLRenderer({
    canvas: canvas,
    alpha: true
})
// 设置渲染器canvas的透明度
renderer.setClearAlpha(0)

const parameters = {
    materialColor: '#ffeded'
}


// 卡通材质,并为其增加更多渐变
const textureLoader = new THREE.TextureLoader()
const gradientTexture = textureLoader.load('textures/gradients/3.jpg')
const material = new THREE.MeshToonMaterial({
    color: parameters.materialColor,
    gradientMap: gradientTexture
})

// 默认情况下,webGL在使用材质贴图,在贴图像素小于屏幕像素时,会自动对中间像素进行插值,这会出现比较柔和的过度效果
// 但是,这里希望展现的是硬边的卡通效果,所以要改变滤波器NearestFilter,返回与指定纹理坐标最接近的纹理元素的值,而不再进行插值
gradientTexture.magFilter = THREE.NearestFilter

// 创建几何体
const mesh1 = new THREE.Mesh(
    new THREE.TorusGeometry(1, 0.4, 16, 60),
    material
)
const mesh2 = new THREE.Mesh(
    new THREE.ConeGeometry(1, 2, 32),
    material
)
const mesh3 = new THREE.Mesh(
    new THREE.TorusKnotGeometry(0.8, 0.35, 100, 16),
    material
)

scene.add(mesh1, mesh2, mesh3)

const sectionMeshes = [mesh1, mesh2, mesh3]

// 卡通材质默认不显示,需要增加灯光
const directionalLight = new THREE.DirectionalLight('#ffffff', 1)
directionalLight.position.set(1, 1, 0)
scene.add(directionalLight)

// debug材质颜色
gui.addColor(parameters, 'materialColor').onChange(() => material.color.set(parameters.materialColor))

// 调整模型位置和大小
const objectsDistance = 4

mesh1.position.x = 2
mesh2.position.x = - 2
mesh3.position.x = 2
mesh1.position.y = - objectsDistance * 0
mesh2.position.y = - objectsDistance * 1
mesh3.position.y = - objectsDistance * 2


// 创建光点场景
const particlesCount = 200
const positions = new Float32Array(particlesCount * 3)
for (let i = 0; i < particlesCount; i++) {
    positions[i * 3 + 0] = (Math.random() - 0.5) * 10
    // 保证粒子在三个模型的背景中都均匀分布例子,下式约等于:(-0.5, 2.5)区间值 * 模型间距
    positions[i * 3 + 1] = (0.5 - Math.random() * sectionMeshes.length) * objectsDistance
    positions[i * 3 + 2] = (Math.random() - 0.5) * 10
}

const particlesGeometry = new THREE.BufferGeometry()
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))

const particles = new THREE.Points(
    particlesGeometry, 
    new THREE.PointsMaterial({
        color: parameters.materialColor,
        sizeAttenuation: true,
        size: 0.03
    })
)
scene.add(particles)

// 窗口尺寸
const sizes = {
    width: window.innerWidth,
    height: window.innerHeight
}

window.addEventListener('resize', () => {
    // Update sizes
    sizes.width = window.innerWidth
    sizes.height = window.innerHeight

    // Update camera
    camera.aspect = sizes.width / sizes.height
    camera.updateProjectionMatrix()

    // Update renderer
    renderer.setSize(sizes.width, sizes.height)
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
})

// 小技巧,创建分组是为了方便分离对摄像机position属性的两种动画,因为在requestAnimationFrame中,同时对position进行更新,会导致覆盖
const cameraGroup = new THREE.Group()
const camera = new THREE.PerspectiveCamera(35, sizes.width / sizes.height, 0.1, 100)
camera.position.z = 6
cameraGroup.add(camera)
scene.add(cameraGroup)

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

/**
 * Scroll
 */
let scrollY = window.scrollY
let currentSection = 0

window.addEventListener('scroll', () => {
    scrollY = window.scrollY
    const newSection = Math.round(scrollY / sizes.height)

    if (newSection != currentSection) {
        currentSection = newSection

        // 滚动到某个模型,使其做旋转动画
        gsap.to(
            sectionMeshes[currentSection].rotation,
            {
                duration: 1.5,
                ease: 'power2.inOut',
                x: '+=6',
                y: '+=3',
                z: '+=1.5'
            }
        )
    }
})

/**
 * Cursor
 */
const cursor = {}
cursor.x = 0
cursor.y = 0

window.addEventListener('mousemove', (event) => {
    cursor.x = event.clientX / sizes.width - 0.5
    cursor.y = event.clientY / sizes.height - 0.5
})

const clock = new THREE.Clock()
let previousTime = 0

const tick = () => {
    const elapsedTime = clock.getElapsedTime()
    const deltaTime = elapsedTime - previousTime
    previousTime = elapsedTime

    // 移动摄像机
    // scrollY 包含已滚动的像素数量。如果我们滚动 1000 个像素(这并不算多),那么场景中的相机将下降 1000 个单位(这很多)
    camera.position.y = - scrollY / sizes.height * objectsDistance

    // 使用deltaTime来控制相机的移动,保证在不同帧率的屏幕下,运动距离一致
    const parallaxX = cursor.x * 0.5
    const parallaxY = - cursor.y * 0.5
    cameraGroup.position.x += (parallaxX - cameraGroup.position.x) * 5 * deltaTime
    cameraGroup.position.y += (parallaxY - cameraGroup.position.y) * 5 * deltaTime

    // 让几何体自转
    for (const mesh of sectionMeshes) {
        mesh.rotation.x += deltaTime * 0.1
        mesh.rotation.y += deltaTime * 0.12
    }

    renderer.render(scene, camera)
    window.requestAnimationFrame(tick)
}

tick()

基于物理引擎来实现webgl的物理碰撞

webgl是3d的,但也可以呈现出2d的物理效果,意思是使用2d引擎来模拟碰撞及弹跳,将其映射到3d空间的某个平面,类似例子有 (http://letsplay.ouigo.com/)

3d的物理引擎

  1. Ammo.js
  1. Cannon.js
  1. Oimo.js

2D Physics

  1. Matter.js Website: https://brm.io/matter-js/ Git repository: https://github.com/liabru/matter-js Documentation: https://brm.io/matter-js/docs/ Mostly maintained by one developer Still kind of updated

  2. P2.js Website: https://schteppe.github.io/p2.js/ Git repository: https://github.com/schteppe/p2.js Documentation: http://schteppe.github.io/p2.js/docs/ Mostly maintained by one developer (Same as Cannon.js) Hasn’t been update for 2 years

  3. Planck.js Website: https://piqnt.com/planck.js/ Git repository: https://github.com/shakiba/planck.js Documentation: https://github.com/shakiba/planck.js/tree/master/docs Mostly maintained by one developer Still updated nowadays

随机生成大小不一的球和长方体,来让他们产生碰撞

这个例子使用的cannonjs来模拟碰撞,需要知道的是,要提前去看一下,不同形状的物体之间的碰撞是否支持 这是cannonjs的文档链接

预置物理世界的材质、物理世界的地面等相关内容

import CANNON from 'cannon'


// 创建3d物理的世界
const world = new CANNON.World()

// Cannon.js为了避免无所谓的碰撞计算, 选择用3种可用的宽相算法:
// NaiveBroadphase:将每个物体与其他物体进行测试
// GridBroadphase:四分格世界只测试同一方格内或相邻方格内的其他物体
// SAPBroadphase(扫描和修剪 Broadphase):在多个步骤中测试任意轴上的实体。
// 默认的 Broadphase 是 NaiveBroadphase,建议切换到SAPBroadphase。使用这种宽相最终可能会产生不发生碰撞的错误,但这种情况很少见,而且它涉及诸如快速移动物体之类的事情。
// 要切换到SAPBroadphase,只需在world.broadphase属性中实例化它,并使用相同的世界作为参数
world.broadphase = new CANNON.SAPBroadphase(world)

// 即使使用改进的宽相算法,也要对所有 "body"进行测试,即使是那些不再移动的 "body"。我们可以使用一种名为 "sleep"的功能。
// 当 "body"的速度变得非常慢时(慢到你看不到它在移动),"body"就会进入睡眠状态,除非代码对它施加了足够的力,或者其他"body"撞到了它,否则不会对它进行测试。
// 要激活此功能,只需将 "world"上的 allowSleep 属性设置为 true
// 还可以使用 sleepSpeedLimit 和 sleepTimeLimit 属性来控制 "body"入睡的可能性
world.allowSleep = true

// 设置重力
world.gravity.set(0, - 9.82, 0)

// 在物理世界中创建材质
const defaultMaterial = new CANNON.Material('default')
// const concreteMaterial = new CANNON.Material('concrete')
// const plasticMaterial = new CANNON.Material('plastic')
// 创建一个 ContactMaterial。它是两种材质的组合,包含对象碰撞时的属性。
// 前两个参数是材料。第三个参数是一个对象 {},它包含两个重要属性:friction 摩擦系数(摩擦的程度)和 restitution 弹跳系数(弹跳的程度)——两者的默认值都是 0.3
const defaultMaterial = new CANNON.ContactMaterial(
    defaultMaterial,
    defaultMaterial,
    {
        friction: 0.1,      // 摩擦力
        restitution: 0.7    // 弹跳系数
    }
)
// 在物理世界中增添材质关系
// world.addContactMaterial(defaultMaterial)
// 设置物理世界的物体所使用的默认材质,可以不用为每个物体重新上材质
world.defaultContactMaterial = defaultMaterial

// 在物理世界创建一个用来放置物体的平面,平面不要有重力,默认平面是垂直于地面的,还要将其进行旋转,与地面平行
const floorShape = new CANNON.Plane()
const floorBody = new CANNON.Body()
floorBody.mass = 0  // 质量设为0
floorBody.addShape(floorShape)
// 使用 Cannon.js 进行旋转比使用 Three.js 稍微困难一些,因为必须使用四元数。旋转 Body 的方法有多种,但必须使用其四元数属性。
// 我们将使用 setFromAxisAngle(...),第一个参数是轴。你可以把它想象成一根钉子穿过身体。第二个参数是角度。这是你围绕那个尖峰旋转身体的程度。
floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(- 1, 0, 0), Math.PI * 0.5)
world.addBody(floorBody)

// 每次碰撞都产生碰撞的声音
const hitSound = new Audio('/sounds/hit.mp3')
const playHitSound = (collision) =>{
    // 一个立方体与另一个立方体轻微接触,不想听到太多撞击声。我们需要知道撞击的力度,如果力度不够,就不要播放任何声音。
    // 要获得撞击强度,我们首先需要获得有关碰撞的信息
    // 可以通过调用接触属性上的 getImpactVelocityAlongNormal() 方法来找到冲击强度:
    const impactStrength = collision.contact.getImpactVelocityAlongNormal()
    if( impactStrength > 1.5 ) {
        // 随机控制音量的大小
        hitSound.volume = Math.random()
        // 每次碰撞音量都要从0开始播放
        hitSound.currentTime = 0
        hitSound.play()
    }
}

预置threejs的对应内容

// 环境盒
const cubeTextureLoader = new THREE.CubeTextureLoader()
const environmentMapTexture = cubeTextureLoader.load([
    '/textures/environmentMaps/0/px.png',
    '/textures/environmentMaps/0/nx.png',
    '/textures/environmentMaps/0/py.png',
    '/textures/environmentMaps/0/ny.png',
    '/textures/environmentMaps/0/pz.png',
    '/textures/environmentMaps/0/nz.png'
])

// 地面
const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(10, 10),
    new THREE.MeshStandardMaterial({
        color: '#777777',
        metalness: 0.3,
        roughness: 0.4,
        envMap: environmentMapTexture,
        envMapIntensity: 0.5
    })
)
floor.receiveShadow = true
floor.rotation.x = - Math.PI * 0.5
scene.add(floor)


// 环境光和直射光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7)
scene.add(ambientLight)

const directionalLight = new THREE.DirectionalLight(0xffffff, 0.2)
directionalLight.castShadow = true
directionalLight.shadow.mapSize.set(1024, 1024)
directionalLight.shadow.camera.far = 15
directionalLight.shadow.camera.left = - 7
directionalLight.shadow.camera.top = 7
directionalLight.shadow.camera.right = 7
directionalLight.shadow.camera.bottom = - 7
directionalLight.position.set(5, 5, 5)
scene.add(directionalLight)

// 窗口resize...
// 摄像机...
// OrbitControls控件...
// 渲染相关函数...

随机的创建球和长方体

const objectsToUpdate = []

// 在threejs中创建球
const sphereGeometry = new THREE.SphereGeometry(1, 20, 20)
const sphereMaterial = new THREE.MeshStandardMaterial({
    metalness: 0.3,
    roughness: 0.4,
    envMap: environmentMapTexture,
    envMapIntensity: 0.5
})
// 在cannonjs中创建球
const createSphere = (radius, position) =>
{
    // Three.js mesh
    const mesh = new THREE.Mesh(sphereGeometry, sphereMaterial)
    mesh.castShadow = true
    mesh.scale.set(radius, radius, radius)
    mesh.position.copy(position)
    scene.add(mesh)

    // Cannon.js body
    const shape = new CANNON.Sphere(radius)
    const sphereBody = new CANNON.Body({
        mass: 1,
        position: new CANNON.Vec3(0, 3, 0),
        shape: shape,
        material: defaultMaterial
    })
    sphereBody.position.copy(position)
    // body可以监听的事件有'colide', 'sleep' or 'wakeup'
    sphereBody.addEventListener('collide', playHitSound)
    world.addBody(sphereBody)

    // 保存 Three.js 和 Cannon.js 的物体,方便后续更新他们的位置和角度
    objectsToUpdate.push({ mesh, body: sphereBody })
}

// 在threejs中创建box
const boxGeometry = new THREE.BoxGeometry(1, 1, 1)
const boxMaterial = new THREE.MeshStandardMaterial({
    metalness: 0.3,
    roughness: 0.4,
    envMap: environmentMapTexture,
    envMapIntensity: 0.5
})

// 在cannonjs中创建box
const createBox = (width, height, depth, position) =>
{
    // Three.js mesh
    const mesh = new THREE.Mesh(boxGeometry, boxMaterial)
    mesh.scale.set(width, height, depth)
    mesh.castShadow = true
    mesh.position.copy(position)
    scene.add(mesh)

    // Cannon.js body
    const shape = new CANNON.Box(new CANNON.Vec3(width * 0.5, height * 0.5, depth * 0.5))

    const body = new CANNON.Body({
        mass: 1,
        position: new CANNON.Vec3(0, 3, 0),
        shape: shape,
        material: defaultMaterial
    })
    body.position.copy(position)
    body.addEventListener('collide', playHitSound)
    world.addBody(body)

    // Save in objects
    objectsToUpdate.push({ mesh, body })
}

// 随机的创建球和长方体
createSphere(
    Math.random() * 0.5,
    {
        x: (Math.random() - 0.5) * 3,
        y: 3,
        z: (Math.random() - 0.5) * 3
    }
)
createBox(
    Math.random(),
    Math.random(),
    Math.random(),
    {
        x: (Math.random() - 0.5) * 3,
        y: 3,
        z: (Math.random() - 0.5) * 3
    }
)

// 删除所有产生的几何体

// for(const object of objectsToUpdate)
// {
//     // 移除监听事件
//     object.body.removeEventListener('collide', playHitSound)
//     world.removeBody(object.body)

//     // Remove mesh
//     scene.remove(object.mesh)
// }

// 根据时钟来更新物理
const clock = new THREE.Clock()
let oldElapsedTime = 0

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()
    const deltaTime = elapsedTime - oldElapsedTime
    oldElapsedTime = elapsedTime

    // 根据时钟tick更新物理世界,将物理世界的时间向前推进。
    // 有两种模式。简单模式是不插值的固定时间步进。在这种情况下,只需使用第一个参数。
    // 第二种模式使用插值。在这种情况下,你还需要提供函数上次使用的时间,以及最大固定时间步长
    // step ( dt  [timeSinceLastCalled]  [maxSubSteps=10] )
    // dt                   要使用的固定时间步长
    // timeSinceLastCalled  自上次调用该函数以来所经过的时间
    // maxSubSteps          每次函数调用的最大固定步数
    world.step(1 / 60, deltaTime, 3)
    
    // 同步物理世界物体的位置和旋转
    for(const object of objectsToUpdate)
    {
        object.mesh.position.copy(object.body.position)
        object.mesh.quaternion.copy(object.body.quaternion)
    }

    // Update controls
    controls.update()

    // Render
    renderer.render(scene, camera)

    // Call tick again on the next frame
    window.requestAnimationFrame(tick)
}

tick()

解释deltaTime的作用原理

屏幕的物理刷新率在不同设备上是不一样的,比如普通显示器是60Hz,高刷屏可以到120Hz或144Hz。为了让游戏或应用在不同刷新率的设备上运动能够同步,就需要使用 deltaTime 和 fixed timestep 的方法。 原理是:

  1. 每一帧渲染前,程序记录本帧和上一帧的时间间隔,这就是 deltaTime。
  2. 用一个固定的时间步长(timestep),比如1/60秒,和deltaTime进行比较。
  3. 如果deltaTime大于timestep,就多更新几步模拟,以便把模拟赶上渲染。
  4. 如果小于,就省掉一些更新步骤,放慢模拟速度。
  5. 这样每次模拟的时间进度就能和屏幕刷新保持一致。 例如60Hz屏幕,timestep是1/60秒:

在cannonjs中给物体添加力或者速度

有多种方法可以对物体施加力:

// 给球施加一个x轴正向的力
sphereBody.applyLocalForce(new CANNON.Vec3(150, 0, 0), new CANNON.Vec3(0, 0, 0))

const tick = () =>{
    // ...

    // 给球再施加一个x轴负向的力,类似与吹风
    sphereBody.applyForce(new CANNON.Vec3(- 0.5, 0, 0), sphereBody.position)

    world.step(1 / 60, deltaTime, 3)

    // ...
}

cannonjs的约束

HingeConstraint(铰链约束):作用类似于门的铰链。 DistanceConstraint(距离约束):强制两个体之间保持一定距离。 LockConstraint(锁定约束):将物体合并,就像它们是一个整体。 PointToPointConstraint(点对点约束):将主体粘合到一个特定的点上。

ammo.js

Ammo.js 是 Bullet 的移植版,Bullet 是一个用 C++ 编写的著名且运行良好的物理引擎。 支持 WebAssembly (wasm)。WebAssembly 是一种低级语言,大多数最新浏览器都支持它。因为是低级语言,所以性能更好。 它更受欢迎,你可以找到更多 Three.js 的示例。它支持更多功能。 如果你需要最好的性能,或者你的项目有特殊功能,你可能应该选择 Ammo.js



Previous Post
Three.js 3D字体,星系制作(二)
Next Post
Three.js GLTF模型,模型导入(四)