技术栈
- vite
- threejs
- ammojs
- typescript
前言
此项目以vitejs
作为基础框架,以typescript
为编程语言,结合threejs
3D渲染库和ammojs
物理引擎工具开发,实现一个可以使用键盘操作汽车行驶,并可以跨越障碍物。下面我们一起来看一下吧
源码
相关源码和模型的下载链接地址点击链接进行跳转
物理世界
import { initPhysics, updatePhysics, physicsWorld } from '../utils/physics';
import { createRigidBody, setPointerBttVec, } from '../utils/rigidBody';
physics.ts
和rigidBody.ts
是文中封装的方法,initPhysics
是初始化物理引擎,updatePhysics
更新物理引擎,physicsWorld
为物理世界,可模拟重力。
createRigidBody
可以创建刚体,以下是这方法的接受参数
创建刚体
/**
*
* @param object 物体本身
* @param isShape 是否为实体 false为静态
* @param mass 是否受引力影响
* @param pos 起始位置
* @param quat 四元数 表示位置方向角度等
* @param vel 线性速度
* @param angVel 线性角度
* @param pointFrom 自定义碰撞器形状
* @returns {模型、刚体实体}
*/
除了基础参数,这里需要讲一下object
和pointFrom
,这两个是碰撞检测点位的来源,
// 如果不是组对象,则直接提取
if (!shape.isGroup) {
physicsShape = createConvexHullPhysicsShape(shape.geometry.attributes.position.array);
}
/**
*
* @param coords 物体所有顶点信息
* @returns
*/
// 按照外围点创建碰撞器
export function createConvexHullPhysicsShape(coords: number[]) {
let Ammo = (window as any).Ammo
// 设置一个收集器
const shape = new Ammo.btConvexHullShape();
// 定点偏移,也可通过动态传参的方式自定义,目前为不偏移
const tempBtVec3_1 = new Ammo.btVector3(0, 0, 0);
for (let i = 0, il = coords.length; i < il; i += 3) {
tempBtVec3_1.setValue(coords[i], coords[i + 1], coords[i + 2]);
const lastOne = (i >= (il - 3));
shape.addPoint(tempBtVec3_1, lastOne);
}
return shape;
}
用自定义模型取碰撞点有一个好处就是可以优化页面,比如有一个点位很多的模型,而碰撞检测又不需要那么精准,就可以用包围盒来做检测,
更新物理引擎
updatePhysics
方法需要在renderer.setAnimationLoop(render);
的render回调函数中调用,除了创建刚体,还需要在刚体的usedata信息中设置object.userData.PhysUpdate = true
,因为在updatePhysics
中判定当前刚体是否支持更新,如果不支持更新的话,可以通过callback自行开发。
...
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();
...
if (PhysUpdate) {
objThree.position.set(px, py, pz);
// 如果方向锁定传的是true 则不进行方向修改
if (rl !== true) {
objThree.quaternion.set(rx, ry, rz, rw);
}
} else {
cb && cb(new Vector3(px, py, pz), new Vector3(rx, ry, rz), objThree);
}
...
setPointerBttVec
设置刚体的物体指针。将创建出的刚体和模型绑定起来
// 设置物体指针
export const setPointerBttVec = (object: Object3D, body: any) => {
let Ammo = (window as any).Ammo
const btVecUserData = new Ammo.btVector3(0, 0, 0);
btVecUserData.threeObject = object;
body.setUserPointer(btVecUserData);
}
源码中这些都是封装好的方法,开箱即用
背景板
// 设置背景板尺寸
const PlaneSize = 400
// 实际是使用阴影材质作为底板
// var shadowMaterial = new THREE.ShadowMaterial();
var shadowMaterial = new THREE.MeshBasicMaterial({});
const plane = new THREE.Mesh(new THREE.BoxGeometry(PlaneSize, 0.5, PlaneSize, 1, 1, 1), shadowMaterial);
// 接受阴影
plane.receiveShadow = true;
实际中的背景板使用的是 阴影材质(ShadowMaterial
),为了在文章中体现的明显一些,使用的是基础网格材质(MeshBasicMaterial
),主要是为了将页面的元素都放在这个平台上,当然,汽车移动到平面的外围,还是会掉下去的。
加入物理世界
接下来将底板加入物理世界
const { object, body } = createRigidBody(plane, true, false, new THREE.Vector3(0, -0, 0), null)
// 将刚体添加到物理引擎
physicsWorld.addRigidBody(body);
// 将刚体body和object绑定 object就是plane
setPointerBttVec(object, body)
// 将对象添加到刚体合集,会在updatePhysics方法更新
rigidBodies.push(object)
scene.add(object)
通过封装的createRigidBody
方法,创建一个底板的刚体,第三个参数为false,表示支持物理碰撞,不支持重力影响,所以在底板上面的刚体可以保持在底板上。
在render中更新物理引擎
const render = () => {
const dt = playerClock.getDelta();
// 更新物理世界
updatePhysics(dt, rigidBodies, (pos: THREE.Vector3, dir: THREE.Vector3, objectThree: THREE.Object3D) => { })
...
}
添加汽车
createVehicle
是封装小汽车的方法,接受和返回参数如下
/**
* @param pos 起始位置
* @param quat 四元数
* @param physicsWorld 物理世界
* @returns 汽车模型,更新方法和汽车刚体
*/
// 基于ammojs封装的创建基础汽车的方法
const { group, chassisMesh } = createVehicle(new THREE.Vector3(0, 4, 0), new THREE.Quaternion(0, 0, 0, 1), physicsWorld)
carMesh = chassisMesh
// 光源追踪
light.target = chassisMesh;
carGroup.add(group)
lodaCarModel()
设置light.target
为汽车开动时候始终有光打到汽车上
汽车换皮
这个小汽车基础控制方向的元素有了,接下来就是换皮,你可以换成跑车,也可以换成F1赛车,下面就是换皮的方法
轮子的映射关系
const wheelNameMap: any = {
'front_wheel_left_RGB_texture_0': 'FRONT_LEFT',
'front_wheel_right_RGB_texture_0': 'FRONT_RIGHT',
'rear_wheel_left_RGB_texture_0': 'BACK_LEFT',
'rear_wheel_right_RGB_texture_0': 'BACK_RIGHT'
}
// 加载汽车模型
const lodaCarModel = async () => {
const car = await loadGltf('../../src/assets/models/vehicle/scene.gltf') as any
// 换皮轮子
car.scene.traverse((mesh: any) => {
if (mesh.isMesh) {
castShadow(mesh)
const wheelName = wheelNameMap[mesh.name];
if (wheelName) {
const wheel = scene.getObjectByName(wheelName);
if (wheel) {
const scale = computedScale(wheel, mesh);
mesh.scale.copy(scale.clone())
wheel?.add(mesh)
}
} else {
vehicleBodyGroup.add(mesh)
}
}
})
// 换皮车身
const vehicleBody = scene.getObjectByName('vehicleBody');
if (vehicleBody) {
vehicleBodyGroup.rotation.y = Math.PI;
vehicleBodyGroup.scale.set(0.013, 0.013, 0.0111)
vehicleBodyGroup.position.setY(-0.5)
vehicleBody.add(vehicleBodyGroup)
}
}
障碍物
字母
创建自己需要的字母,并作为刚体,加入场景中。createLetter
是封装的创建字母的方法,下面是方法的参数
/**
*
* @param l 内容
* @param size 大小
* @param color 颜色
* @param height 高度
* @param change
* @returns THREE.Mesh
*/
返回的是一个单独的模型,如果传入一段字母,则创建隶属于同一个模型的字母,所以要创建多个独立字母,需要一些其他处理createLetters
方法就是用来创建多个独立字母的。接收参数如下
interface createLitterInterface {
litters: string[]; // 字母数组
size: number, // 尺寸
position: THREE.Vector3, // 初始位置
color: THREE.Color,// 颜色
height: number,// 高度
change?: any // 修改方向
weight?: number // 模拟重力
}
创建 helloWorld
const params = {
litters: 'helloWorld'.toUpperCase().split(''),
size: 4,
position: new THREE.Vector3(-16, 2, 14),
color: new THREE.Color('0xffffff'),
height: 2,
change: {
rotateY: Math.PI * 0.5
},
}
// 字母
await createLetters(params)
fontLoader
加载字体文件使用const loader = new FontLoader();
,结合# 文本缓冲几何体(TextGeometry)
创建一个文字实例
loader.load(import.meta.env.VITE_TYPEFACE_URL, function (font) {
const geometry = new TextGeometry(l, {
font: font,
size: size || 3,
height: height,
});
change?.rotateX && geometry.rotateX(change?.rotateX);
change?.rotateY && geometry.rotateY(change?.rotateY);
const material = new THREE.MeshStandardMaterial({
color
});
const objectToCurve = new THREE.Mesh(geometry, material);
resove(objectToCurve)
})
创建石堆
// 创建石堆object
const size = new THREE.Vector3(1.5, 0.8, 1)
const position = new THREE.Vector3(-5, 0.5, 10)
createStone(6, 6, size, position, 'x')
// 创建石头
const createStone = (low: number, hig: number, size: THREE.Vector3, pos: THREE.Vector3, d?: 'x' | 'z') => {
const dir = d || 'x'
const { x, y, z } = size
const material = new THREE.MeshStandardMaterial({ color: new THREE.Color(0xffffff) });
// low底层砖数量,hig是层数,从最上层开始摆 i=0,一直摆到对下层i=hig
for (let j = 0; j < hig; j++) {
const v3 = pos.clone()
v3.y = y * (j + 0.1) + 0.1
for (let i = 0; i < low - j; i++) {
v3[dir] = i * (size[dir] + 0.1)
if (j > 0) v3[dir] = v3[dir] + size[dir] / 2 * j
v3[dir] = v3[dir] + pos[dir]
const stone = createMesh(size, undefined, material)
stone.position.copy(v3)
const { object, body } = createRigidBody(stone, true, true, null, null, undefined, undefined)
castShadow(object)
physicsWorld.addRigidBody(body);
object.userData.PhysUpdate = true
setPointerBttVec(object, body)
body.setGravity(new AmmoLib.btVector3(0, - 17.8, 0));
body.setFriction(200)
rigidBodies.push(object);
scene.add(object)
}
}
}
加载其他元素
石头和树使用const gltfLoader = new GLTFLoader();
加载一个gltf模型,再通过遍历模型内的对象,继续将各对象添加到物理世界中,属性和底板相同,支持检测,但位置锁定。
源码
相关源码和模型的下载链接地址点击链接进行跳转