three.js——物理引擎

3,286 阅读3分钟

剖析three.js案例中物理引擎Ammo.js的使用方法,其中包含运动、碰撞、检测、自由落体、碰撞器、冻结、自定义更新等功能,有大量代码通过链接方式转到gitee,可以根据代码对照使用

主要剖析的是threejs中提供的ammo案例,相对于原本案例,本篇文章精简了一些代码,提取了一些方法,新增了一些可配置参数,并结合我上一篇文章three.js——镜头跟踪 做了一个小Demo。

先看一下效果

2023-04-17 14.54.56.gif

从图片中可以看出来,小球在落地时,触碰到人物模型的头部,改变了运动方向,后面人物模型行走时,可以踢球,改变球的运动速度。

场景

基础场景

基础场景参照three.js——镜头跟踪

物理引擎

引入ammo.js

<script src="../node_modules/three/examples/jsm/libs/ammo.wasm.js"></script>

因为是demo所以直接在html入口文件通过链接方式引入的ammo.js,项目中有需要也可以通过modlue模式在ts文件import方式引用;

在使用ammo的文件中从window中提取该变量

let Ammo = (window as any).Ammo

渲染

Ammo().then(function (AmmoLib) {

     Ammo = AmmoLib;
    (window as any).Ammo = Ammo
    init();
    animate();

});

threejs的渲染要在物理引擎注册之后调用,然后在runder中更新物理引擎,渲染先放下,先从注册物理引擎开始

注册物理引擎

export let physicsWorld: any
export let dispatcher: any

// 初始化物理引擎
export function initPhysics() {
    let Ammo = (window as any).Ammo
    // 注册碰撞器
    let collisionConfiguration = new Ammo.btDefaultCollisionConfiguration();
    // 注册碰撞器内容 之后的碰撞检测会使用到
    dispatcher = new Ammo.btCollisionDispatcher(collisionConfiguration);
    let broadphase = new Ammo.btDbvtBroadphase();
    let solver = new Ammo.btSequentialImpulseConstraintSolver();
    // 注册物理世界  后续受物理作用的模型都需要放在这里,供ammp更新
    physicsWorld = new Ammo.btDiscreteDynamicsWorld(dispatcher, broadphase, solver, collisionConfiguration);
    // 设置初始化重力,重力是向下作用力,所以在这里取反,其实初始值设置成负数是同样的
    physicsWorld.setGravity(new Ammo.btVector3(0, - gravityConstant, 0));
}

除了必须的参数,setGravity这个方法是经常用到的,设置重力,上述代码设置的是物理引擎的重力(引力),可以模拟地球引力、月球引力等,参数<0为向下作用力,参数>0为向上作用力即有重力的物体在这个物理世界,是做向上运动的,例子等讲到注册物理体的时候一起看一下

更新物理引擎

// 更新物理引擎
/**
 * 
 * @param deltaTime 更新时间
 * @param rigidBodies 刚体
 * @param cb 回调 pos:在重力影响下导致的位置变换   dir:在物理引擎影响下的角度变化 objThree 当前被影响的模型
 */
export function updatePhysics(deltaTime: number, rigidBodies: Object3D[], cb?: (pos: Vector3, dir: Vector3, objThree: Object3D) => void) {
    let Ammo = (window as any).Ammo
    // 定义一个变量用来存储对象的transform
    const transformAux1 = new Ammo.btTransform();

    // Step world
    physicsWorld.stepSimulation(deltaTime, 10);

    // Update rigid bodies

    for (let i = 0, il = rigidBodies.length; i < il; i++) {

        const objThree = rigidBodies[i];

        const objPhys = objThree.userData.physicsBody;
        const PhysUpdate = objThree.userData.PhysUpdate;
        const ms = objPhys.getMotionState();
    
        ms.getWorldTransform(transformAux1);
        const p = transformAux1.getOrigin();
        const q = transformAux1.getRotation();
        objThree.position.set(p.x(), p.y(), p.z());
        objThree.quaternion.set(q.x(), q.y(), q.z(), q.w());

        objThree.userData.collided = false;
        ... 
    }

}

上述代码中const transformAux1 = new Ammo.btTransform(); 定义一个变量用来存储对象的transform属性,这属性中包含getOrigin获取对象位置,getRotation获取角度,得到的是Ammo.btVector3 对象,rigidBodies刚体是受控模型集合,在创建模型时,如果需要在物理世界更新,就可以将模型添加到这个数组,当然,每一个刚体都需要添加到initPhysics方法注册的physicsWorld方法,具体添加方法见后续代码

累了,笔者摸个鱼

十分钟后...

刚体

创建刚体

创建10个小球,作为刚体,给一个初始高度,让小球做自由落体

for (let i = 0; i < 10; i++) {
        const { object: object1, body: body1 } = createRigidBody(sphere.clone(), true, true, new Vector3(0, 1, 0), null, new Vector3(0, 13, 0))
        object1.name = 'sphere'
        object1.userData.PhysUpdate = true
        setPointerBttVec(object1, body1)
        //添加到物理世界
        physicsWorld.addRigidBody(body1);
        作为刚体收集到刚体数组
        rigidBodies.push(object1)
        scene.add(object1)
    }

效果如下:

2023-04-17 15.58.11.gif

参数及方法

createRigidBody

封装了一个注册刚体的方法,7个入参,一个返回值,下面会一一介绍

gitee代码

createRigidBody的方法参数说明

参数说明
object物体本身,刚体实体也会存储在这模型的信息userData上面
isShape是否为实体 false为静态,即不存在碰撞器,其他刚体也会穿透模型
mass是否受引力和碰撞影响如果为false,物体运动状态不设置 反之则设置为4即代表受引力碰撞等因素影响
pos起始位置,物体和刚体初始的位置
quat四元数 表示位置方向角度等 物体和刚体的初始角度
vel线性速度 物体初始运动方向和运动速度
angVel线性角度 物体初始旋转角度
pointFrom自定义碰撞器形状 默认以object生成碰撞体,如果这个值不为空,将以传来的模型生成碰撞体
returns{模型、刚体实体} 返回模型本体和模型刚体

const body = new Ammo.btRigidBody(rbInfo); 注册刚体部分,返回一个刚体对象其中也包含很多api可以设置运动、旋转、重力、摩擦力、以及目前笔者也没看明白的属性

属性入参解释返回
setLinearVelocitybtVector3设置线性速度-
getLinearVelocitybtVector3获取线性速度btVector3
setGravitybtVector3设置重力-
getGravity-获取重力number
setFrictionbtVector3设置摩擦力-
getFriction-获取摩擦力-
...

有很多参数没进行验证,感兴趣的同学可以试一试

顶点信息收集器 createConvexHullPhysicsShape

gitee代码

这里的for循环方法可以根据具体传来的数据格式改变,position.array结构是[1,1,1,2,2,2] 每三个为一组xyz,所以在遍历的时候 xyz取的是i,i+1,i+2;

更新刚体

通过上述提取的方法,将地板、角色都添加到物理世界, gitee代码

// 创建物理物体 包含地板和小球
createObjects();
// 创建主角,任人物模型
initPlayer()

下面以主角为例,介绍一下上述的方法作用和刚体的更新方法

// 是否通过物理世界更新模型
 object.userData.PhysUpdate = true
// 是否锁定旋转
object.userData.rotateLock = true
// 位置锁定
object.userData.positionLock = ['', '', '']

PhysUpdate 字段是判断是否在更新物理引擎的时候一起更新模型,如果为false,则需要在updatePhysics方法的回调参数手动更新,

cb?: (pos: Vector3, dir: Vector3, objThree: Object3D) => void,这个方法会传入位置信息,角度信息,和模型自身,后期可以根据objThree.name分类更新;

甚至也可以提前将方法绑定到模型自身 object.userData.handlePlayer = handlePlayer 类似这样。

rotateLock判断是否可以在更新物理引擎的时候旋转物体,如果PhysUpdate 为 false,则忽略旋转锁定和位置锁定,

rotateLock和positionLock都接受两种数据格式true和['x','y','z'],在updatePhysics方法进行了限制

const p = transformAux1.getOrigin();
const q = transformAux1.getRotation();

const pl = (objThree.userData.positionLock || [])
let px = p.x()
let py = p.y()
let pz = p.z()
// 锁定位置
for (let i = 0; i < pl.length; i++) {
    const dir = pl[i];
    if (dir === 'x') px = 0
    if (dir === 'y') py = 0
    if (dir === 'z') pz = 0
}

const rl = (objThree.userData.rotateLock || [])
let rx = q.x()
let ry = q.y()
let rz = q.z()
let rw = q.w()
// 锁定角度
for (let i = 0; i < rl.length; i++) {
    const dir = rl[i];
    if (dir === 'x') rx = 0
    if (dir === 'y') ry = 0
    if (dir === 'z') rz = 0
    if (dir === 'w') rw = 0
}

物理运动

three.js——镜头跟踪 中提到的人物运动,是通过 指针锁定控制器驱动人物运动,在物理引擎下,如果只是改变人物模型位置做运动,那么并不会触发碰撞的运动,所以在这里需要改一下,

if (controls?.isLocked) {
    if (moveForward) {
        const dir = new Vector3()
        // 获取控制器方向参数
        controls.getDirection(dir)
        const v3 = dir.clone().negate().multiplyScalar(PlayerParams.speed)
        // 修改运动参数
        linearVelocity.set(v3.x, v3.z)
    }
    Player.scene.userData.physicsBody.setLinearVelocity(new Ammo.btVector3(linearVelocity.x, jumpTop, linearVelocity.y))

}

linearVelocity参数是前文定义的一个2维向量,用来存储控制器数据的,通过getDirection方法获取控制器旋转角度,方向取反乘以主角速度,计算出主角当前方向(旋转角度),再通过setLinearVelocity设置线性速度,将主角往这个方向做运动,这里比较绕,参考下图实例

image.png

碰撞检测

2023-04-18 11.12.19.gif

// 碰撞检测
const rayRigidBodyCheck = () => {
    // 物理世界的物体数量,在initPhysics初始化物理引擎注册的
    for (let i = 0, il = dispatcher.getNumManifolds(); i < il; i++) {

        const contactManifold = dispatcher.getManifoldByIndexInternal(i);

        const rb0 = Ammo.castObject(contactManifold.getBody0(), Ammo.btRigidBody);
        const rb1 = Ammo.castObject(contactManifold.getBody1(), Ammo.btRigidBody);

        const threeObject0 = Ammo.castObject(rb0.getUserPointer(), Ammo.btVector3).threeObject;
        const threeObject1 = Ammo.castObject(rb1.getUserPointer(), Ammo.btVector3).threeObject;

        const n = threeObject0?.name
        const n1 = threeObject1?.name

        if (n1 === 'Player' && n === 'sphere') {
            console.log('踢球');
            
        }

    }
}

上述代码可以在render方法中调用,主要原理就是遍历physicsWorld中每一个碰撞体的点位,

dispatcher.getNumManifolds 获取碰撞数量,

dispatcher.getManifoldByIndexInternal(i); 通过碰撞索引获取到当前碰撞的是哪几个物体,

后续就是获取碰撞体后的信息,可以通过名称判断事件,甚至可以在加载某个碰撞物体的时候做一个埋点,检测到碰撞则执行一下;

人物的跳跃,按空格跳跃,给人物模型一个向上的力,如果一直按空格,人物则一直向上跳,甚至飞出天际, 所以这时候就需要碰撞检测,人物模型跳跃后,空格失效,可以用一个布尔值做开关,当检测人物模型碰撞到地板,空格生效,可以再次跳跃

包括子弹也是一样,发射子弹后,子弹向前运动,当子弹碰撞到敌人,敌人死亡,子弹消失,等等等等,碰撞检测可以做很多功能

也可以做机关,将一个物体只作为碰撞器,并不加入到场景中,当人物碰撞到机关,可以开门,奖励等操作

具体玩法可以参照unity游戏引擎,上述代码大部分逻辑和方法的抽离都是借鉴unity游戏引擎做的

这是unity的刚体和碰撞器的界面,感兴趣或者有时间的同学可以使用threejs做一个解密杀怪游戏

项目地址

笔者对很多概念也不甚了解,如果有错误的地方,还请大佬们指正,让我们在前端的路上越走越远~

历史文章

# Javascript基础之写一个好玩的点击效果

# Javascript基础之鼠标拖拉拽

# three.js 打造游戏小场景(拾取武器、领取任务、刷怪)

# threejs 打造 world.ipanda.com 同款3D首页

# three.js——物理引擎

# three.js——镜头跟踪

# threejs 笔记 03 —— 轨道控制器