开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第6天,点击查看活动详情
桌球必然涉及模拟多个物体之间的碰撞,这对我来说当然很难。但是,物理引擎帮我们解决了这个问题。
首先,要明确物理引擎的作用。 物理引擎制定了一套世界的物体运动规则,暂且不涉及形变。也就是说,给定重力加速度、摩擦因子、以及物体的形状和位置,物体的初速度, 然后让时间流动起来,它帮我们计算出某一时刻,某一物体的状态(位置和角度)。
也就是说,物理引擎是用来计算物体的当前position 和 rotation的,功能很纯粹,不涉及其他。
使用的物理引擎是 cannon-es
开始构建物理世界
构建物理世界就是要设置好一些常数,重力 -9.82。求解器的迭代次数 10, 公差0 。迭代次数越大, 模拟的结果越准确。当达到公差时,认为系统是收敛的
这个求解器会重复多次,然后取平均值。 这个公差可以理解为,多次结果之间的差值,0是最精确的了。
//Game.js
initWorld() {
const world = new CANNON.World();
world.gravity.set(0, -9.82, 0);
world.solver.iterations =10 ;
world.solver.tolerance =0 ;
world.allowSleep = true;
world.fixedTimeStep = 1 / 60
this.helper = new CannonHelper(this.scene, world);
this.world = world;
this.setCollisionBehaviour(world)
// keypress 只有字符才能触发
window.addEventListener('keydown', (e) => {
// if(e.key !== ' ')return
// console.log(e);
switch (e.key) {
case ' ':
// e.repeat || this.cueball.hit(Math.random()*.5 + .5)
break;
case 'Escape':
this.balls.forEach((ball) => ball.reset())
default:
break;
}
})
}
建模
这一次的建模不是为了练手,而是为了构建物理世界。 虽然,cannon-es有个shape类ConvexPolyhedron确实可以直接传入模型的顶点生成凸多边形,但是模型也是多部件组成的,不可能直接放进去。
我认为最重要的是,使用其提供的平面,球等基础几何体建模,会大大减少顶点数,会简化物理引擎的计算,这个游戏就不需要那么精细的模型。
这里使用了一个辅助库CannonHelper,让我们可以轻易在cannons和three之间转换。 就是说,只要在cannon里面建好的模型,可以很方便的在three中渲染出来。大致用法如下, World是物理世界 ,scene是渲染的世界
this.helper = new CannonHelper(this.scene, world);
this.world.addBody(groundBody);
this.helper.addVisual(groundBody);
小球
先来最简单的。直接使用内置shape就可以了,设置好小球的质量,材质。 物理引擎的材质是为了处理碰撞问题,像摩擦因数,反弹系数等等。
当一个物体静止时,这个物体的状态就不会发生改变,所以可以休眠,也就是不用计算它了,显然这样做可以提升性能。
建模结束之后就把小球摆好。
注意,在cannon中,当一个物体的质量为0时,表示这个物体的状态不会改变,你可以理解为质量无穷大。
class Ball {
static RADIUS = .05715 /2;
static MASS = .17
static MATERIAL = new CANNON.Material('ballMaterial')
constructor(game, x,z, id =0) {
this.id = id ;
this.startPosiiton = new THREE.Vector3(x,Ball.RADIUS,z) ;
this.world = game.world ;
this.game = game ;
this.geo = new THREE.SphereGeometry(Ball.RADIUS, 16,16) ;
this.textureLoader = new THREE.TextureLoader() ;
this.createBody(x,Ball.RADIUS, z)// 高度就是半径
this.createMesh(game.scene)
this.name = 'ball'+ id
this.forward = new THREE.Vector3(0,0,-1) ;
this.up = new THREE.Vector3(0,1,0) ;
this.temVec = new THREE.Vector3();
this.temQuat = new THREE.Quaternion();
}
createBody(x,y,z){
const body = new CANNON.Body({
mass:Ball.MASS,
position: new CANNON.Vec3(x,y,z), //不知为何用 这误差 好像是因为忘记把桌面加进物理世界,导致小球往下掉了一段距离 ,但是是怎么停住的呢
shape: new CANNON.Sphere(Ball.RADIUS), // 这个可以复用吧
material: Ball.MATERIAL
})
body.linearDamping = body.angularDamping = .5 ;// 阻尼
body.allowSleep = true;
body.sleepSpeedLimit = 2 ;//速率小于1就会休眠
body.sleepTimeLimit = .1 ;// 触发休眠后 .1s休眠
this.body = body
this.world.addBody(body);
return body
}
}
createMesh(scene) {
this.mesh = new THREE.Mesh(this.geo, new THREE.MeshPhysicalMaterial({ roughness:.1, metalness:0}));
this.mesh.name =this.name
this.mesh.castShadow = true ;
this.mesh.receiveShadow = true ;
// 这里我直接用 id判断的时候出了点问题
this.id > 0 && this.textureLoader.load(urls[`ball${this.id}_png`],(texture)=> {
this.mesh.material.map = texture
this.mesh.material.needsUpdate =true// 这个必须加上,除非是初始化材质就设置好了
})
scene.add(this.mesh)
return this.mesh
}
createBalls() {
this.balls = [new WhiteBall(this, -Table.LENGTH / 4, 0)];// 用于撞击的球 桌子中线 1/4处
const rowInc = 1.74 * Ball.RADIUS; // 第一行放5个 下一行就放4个
let row = { x: Table.LENGTH / 4 + rowInc, count: 6, total: 6 };
const ids = [4, 3, 14, 2, 15, 13, 7, 12, 5, 6, 8, 9, 10, 11, 1];
for (let i = 0; i < 15; i++) {
if (row.total === row.count) { // 开始新行
row.total = 0;
row.count--; // 下一行少放一个
row.x -= rowInc;// 两行间隔 √3/2 * r
row.z = (row.count - 1) * (Ball.RADIUS + .002);// 起点 就是全部球的长度的一半 减 一个半径
}
this.balls.push(new Ball(this, row.x, row.z, ids[i]));
row.z -= 2 * (Ball.RADIUS + .002);
row.total++;
}
this.cueball = this.balls[0];
}
球桌
球桌分为,桌面毛毡, 球桌边缘挡板, 网洞。 下面是预定义的几何体,都是些顶点建模的细致活。可以略过。
桌面就一个长方体,但是这里可能是为了仿真,分成了三个部分. 两边的是floorboxsmall ,主要部分是floorbox
//Table.js
class Table {
.......
createFelt(){
const narrowStripWidth = 0.02;
const narrowStripLength = Table.WIDTH / 2 - 0.05;
const floorThickness = 0.01;
const mainAreaX = Table.LENGTH / 2 - 2 * narrowStripWidth;
const floorBox = new CANNON.Box(new CANNON.Vec3(mainAreaX, floorThickness, Table.WIDTH / 2));
const floorBoxSmall = new CANNON.Box(new CANNON.Vec3(narrowStripWidth, floorThickness, narrowStripLength));
const body = new CANNON.Body({
mass: 0, // mass == 0 makes the body static
material: Table.floorContactMaterial
});
body.addShape(floorBox, new CANNON.Vec3(0, -floorThickness, 0));
body.addShape(floorBoxSmall, new CANNON.Vec3(-mainAreaX - narrowStripWidth, -floorThickness, 0));
body.addShape(floorBoxSmall, new CANNON.Vec3( mainAreaX + narrowStripWidth, -floorThickness, 0));
if (debug) {
addCannonVisual(body, 0x00FF00, false, true);
}
world.addBody(body);
return body;
}
挡板是用多个梯形体(原谅我凭空造词)拼凑而成的。
有时候遇到有人问怎么在几何体里挖孔?渲染上挖孔容易,只要用裁剪/模版测试, 但是在建模里挖孔,实际上不就是,要增加额外的顶点和面,还要删除被挖掉的表面。 真的是想想就觉得麻烦,挖孔真的没组合容易。
class Table {
.......
createWalls(){
const pos = { x:Table.LENGTH/4 - 0.008, y:0.02, z:Table.WIDTH/2}
//walls of -z
const wall1 = new LongWall( pos.x, pos.y, -pos.z, 0.61);
const wall2 = new LongWall(-pos.x, pos.y, -pos.z, 0.61);
wall2.body.quaternion.setFromAxisAngle(new CANNON.Vec3(0, 0, 1), Math.PI);
//walls of +z
const wall3 = new LongWall( pos.x, pos.y, pos.z, 0.61);
const wall4 = new LongWall(-pos.x, pos.y, pos.z, 0.61);
wall3.body.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), Math.PI);
wall4.body.quaternion.setFromAxisAngle(new CANNON.Vec3(0, 1, 0), -Math.PI);
//wall of +x
pos.x = Table.LENGTH/2;
const wall5 = new ShortWall(pos.x, pos.y, 0, 0.605);
//wall of -x
const wall6 = new ShortWall(-pos.x, pos.y, 0, 0.605);
wall6.body.quaternion.setFromAxisAngle(new CANNON.Vec3(0, 1, 0), -1.5 * Math.PI);
const walls = [wall1, wall2, wall3, wall4, wall5, wall6];
walls.forEach( wall => {
world.addBody(wall.body);
if (debug) {
// addCannonVisual(wall.body, 0x00DD00, false, false);
}
});
return walls;
}
最后就是网洞了,实际上是用长方体围起来的,就跟箍桶一样,不用圆柱的原因就是在上面要开一个口子,圆柱做不到,但是我们可以控制每一块木板的长度。 这里直接使用上面封装的拱形。
class Table{
createHoles(){
const corner = { x: Table.LENGTH/2 + 0.015, z: Table.WIDTH/2 + 0.015, PIby4: Math.PI/4 }
const middleZ = Table.WIDTH/2 + 0.048;
const holes = [
//corners of -z table side
new Hole(corner.x, 0, -corner.z, corner.PIby4),
new Hole(-corner.x, 0, -corner.z, -corner.PIby4),
//middle holes
new Hole(0, 0, -middleZ, 0),
new Hole(0, 0, middleZ, Math.PI),
//corners of +z table side
new Hole( corner.x, 0, corner.z, 3 * corner.PIby4 ),
new Hole(-corner.x, 0, corner.z, -3 * corner.PIby4 )
];
return holes;
}
建模到这里就结束了,物理世界中的模型已经有了,下面就是视觉了。
three世界
虽然,那个用cannon-helper直接渲染出来的也能用,但是,太粗糙了点。所以,还是得靠模型和贴图。
还是常规的初始化,略过,来看和上面一一对应的three 世界。
小球
小球就不用模型,不过要适当增加细分数,使用贴图。
在之前的Ball 类中添加 createMesh方法用以创建three的小球,并且在更新方法中,用物理世界的小球的参数去更新three的小球。
class Ball
createMesh (scene) {
const geometry = new THREE.SphereBufferGeometry(Ball.RADIUS, 16, 16);
const material = new THREE.MeshStandardMaterial({
metalness: 0.0,
roughness: 0.1,
envMap: scene.environment
});
if (this.id>0){
const textureLoader = new THREE.TextureLoader().setPath('../../assets/pool-table/').load(`${this.id}ball.png`, tex => {
material.map = tex;
material.needsUpdate = true;
});
}
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
return mesh;
};
update(){
this.mesh.position.copy(this.rigidBody.position);
this.mesh.quaternion.copy(this.rigidBody.quaternion);
}
球桌
这一次很简单,直接加载一个模型就可以了。因为球桌没有涉及太多交互逻辑,所以不用更新,也就不用放在Table类了。 不过后续的逻辑里需要检测球桌的挡板边缘,所以这里要先捡出来。除了模型之外,还添加了环境贴图,如果(标准及其衍生类)材质本身没有设置环境贴图就会使用场景的环境贴图
//Game.js
setEnvironment(){
const loader = new RGBELoader();
const pmremGenerator = new THREE.PMREMGenerator( this.renderer );
pmremGenerator.compileEquirectangularShader();
loader.load( '../../assets/hdr/living_room.hdr',
texture => {
const envMap = pmremGenerator.fromEquirectangular( texture ).texture;
pmremGenerator.dispose();
this.scene.environment = envMap;
},
undefined,
err => console.error( err )
);
}
loadGLTF(){
const loader = new GLTFLoader( ).setPath('../../assets/pool-table/');
// Load a glTF resource
loader.load(
// resource URL
'pool-table.glb',
// called when the resource is loaded
gltf => {
this.table = gltf.scene;
this.table.position.set( -Table.LENGTH/2, 0, Table.WIDTH/2)
this.table.traverse( child => {
if (child.name == 'Cue'){
this.cue = child;
child.visible = false;
}
if (child.name == 'Felt'){
this.edges = child;
}
if (child.isMesh){
child.material.metalness = 0.0;
child.material.roughness = 0.3;
}
if (child.parent !== null && child.parent.name !== null && child.parent.name == 'Felt'){
child.material.roughness = 0.8;
child.receiveShadow = true;
}
})
this.scene.add( gltf.scene );
this.loadingBar.visible = false;
this.renderer.setAnimationLoop( this.render.bind(this));
},
// called while loading is progressing
xhr => {
this.loadingBar.progress = (xhr.loaded / xhr.total);
},
// called when loading has errors
err => {
console.error( err );
}
);
}
开始击球
物理世界和视觉世界都弄好了之后, 就开始我们的主要玩法了。
也很简单,这里采用的思路是不直接控制球杆的方向,而是让球杆的方向永远是朝着屏幕里面的。准确的说,就是视线在球桌面的上的投影,当然,我们这里桌面是固定的XOZ平面,也就是说可以直接取视线的xz分量,或者说只改变小球的x z分量。
击球的逻辑就是控制主球的逻辑,所以就是主球的功能
击球的方向
这里不使用上面那种思路。直接用相机轨道控制器的方位角去旋转初始方向。cannon 还可以指定力的大小和作用点,力的三要素嘛。这里直接作用在重心上,大小由外部传入。
球坐标的方位角为0的时候就是z轴,那么击球的方向就是-z轴。
之前设置了休眠,所以这里击球要先唤醒,可以说小球受到外力,被打醒了。
//whiteBall.js
hit(strength){
this.body.wakeUp() ;
const theta = this.game.controls.getAzimuthalAngle()// 这就是球坐标的方位角
this.temQuat.setFromAxisAngle(this.up, theta) ;
// const forward = this.forward.clone().applyQuaternion(this.temQuat);// 所以forward初始化的时候是 -1
const force = new CANNON.Vec3() ;
force.copy(this.forward) ;
force.scale(strength,force) ;// scale taget
this.body.applyImpulse(force, new CANNON.Vec3()); // 作用力 作用点
}
击球辅助
虽然,我们知道这个方向是朝屏幕里的,但是玩家不知道,而且也太不具体了。加了之后,感觉就跟开挂了似的。
这个辅助线的方向很容易确定,就是按上面击球的方向。 我们希望这个线,在碰到其他小球或者球桌边缘的时候停止延伸。 所以,我们需要一条方向完全相同的射线来做检测。
更新的时候,需要实时更新射线,先检测是否能撞击到小球。如果不能,再检测和球桌的碰撞,肯定会和球桌相撞。
通过 insects[0].distance我们可以很方便的拿到起点到第一个碰撞点的距离,把这个距离设置到指示线的缩放系数上即可。
//whiteBall.js
createGuideLine(){
const points = [] ;
points.push( new Vector3(), new Vector3(0,0,-1)) ;
const geo = new BufferGeometry().setFromPoints(points) ;
const mat = new LineDashedMaterial({ color: 0xffffff,dashSize: .05, gapSize:.03}) ;
const line = new Line(geo,mat ) ;
line.getWorldScale.z = 3 ;
line.visible =false ;
// this.game.scene.add(line) ;
line.computeLineDistances() // 会计算每个点到起点的距离
return line
}
// 指示线更新
updateGuideLine(){
if(!this.balls) {
this.balls = this.game.balls.map(b=> b.mesh) ;
this.balls.shift()
}
const angle = this.game.controls.getAzimuthalAngle() ;
this.guideLine.position.copy(this.mesh.position) ;
this.guideLine.rotation.y= angle ;
this.guideLine.visible =true ;
// forward 初始值是 (0,0, -1) 下面这个操作就没把初始值当回事 小球本来就没有父级,所以position本身就是世界位 ,guideline 也是 所以下面没必要
// this.guideLine.getWorldPosition(this.forward) ; 我就说嘛,搞错方法了
this.guideLine.getWorldDirection(this.forward)
// this.forward.set()
this.forward.negate() // 取反 是因为默认方向是z轴,我们摆放的前面正好就是 -z轴
this.raycaster.set(this.mesh.position, this.forward) // 预判球的撞击点
let insects = this.raycaster.intersectObjects(this.balls) ;
// 先检测会不会撞球,如果不,就检测撞墙
if(insects.length){
this.guideLine.scale.z = insects[0].distance ; // 这个距离属性真方便啊
this.dot.position.copy(insects[0].point) ;
this.dot.visible =true ;
}else {
insects =this.raycaster.intersectObjects(this.game?.edges?.children ||[]) ;
if(insects.length) {
this.guideLine.scale.z = insects[0].distance ; // 这个距离属性真方便啊
// 这里可以搞一个弹射追踪 递归。不递归了,至多弹射三次吧
}
this.dot.visible =false;
}
}
}
击球的力度
这里用长按来蓄力,还是那套逻辑,按下的状态下力道不断增加,而不是按下就增加力道。 松开力道就归零。
用一个非常简单的进度条来表示当前力道,同样是按下才显示,松开就隐藏。 原本的逻辑是把事件监听放在了进度条的父级盒子上,用来避免触发相机轨道控制器的逻辑。我直接加上了键盘空格键的监听,不用鼠标就不会有那个问题,我真是个小机灵鬼。
//Game.js
keydown(e) {
if (this.state !== 'turn') return
if (e.key === ' ') { this.ui.strengthBar.visible = true }
}
keyup(e) {
if (this.state !== 'turn') return
if (e.key === ' ') {
this.ui.strengthBar.visible = false;
this.hit(this.ui.strengthBar.strength)
}
}
// strengthBar.js
update() {
let vis = this.visible;
console.log(vis);
if(vis){
this.power+=.01 ;
this.strength = this.power;
}else {
this.power=0 ;
this.strength= 0
}
}
get strength(){
return this.power
}
set strength(val){
this.power = Math.max(0, Math.min(val, 1)) ;
const percent = this.power * 100 ;
console.log(percent);
this.strengthBar.style.width = percent+'%' ;
}
set visible(val){
this.domElement.style.display = val?'flex': 'none' ;
}
get visible(){ return !(this.domElement.style.display === 'none')}
完善碰撞规则
这个游戏写到这里,基本上就能玩了。不过,之前在设置物理参数的时候并没有设置碰撞相关的系数,因为无意义。
下面就来完善这个。既然是碰撞,那至少是两个物体之间的事,所以定义碰撞规则也是两个材质之间的事。
要定义两个材质的碰撞,就要使用new CANNON.ContactMaterial(matA ,matB, option),然后把这个材质添加到World里。 这样碰撞规则就会生效,如果碰撞的两个材质(可以是同种)未定义规则,则使用默认规则。
所以下面先定义了默认的规则,然后定义了小球和桌面,小球和球桌边缘的碰撞规则。小球之间就是默认规则。
// 这里是定义两个材质直接相互作用性质的地方
setCollisionBehaviour(world) {
this.bottom = new CANNON.Body({shape: new CANNON.Plane(), material: new CANNON.Material({})})
// Defines what happens when two materials meet. todo Refactor materials to materialA and materialB
world.defaultContactMaterial.friction = .2; // 摩擦力
world.defaultContactMaterial.restitution = .8; // 还原 应该就是说弹性损失 。2
const ball_Floor = new CANNON.ContactMaterial(Ball.MATERIAL, Table.FLOOR_MATERIAL, { friction: .7, restitution: .1 })
const ball_bottom = new CANNON.ContactMaterial(Ball.MATERIAL, Table.WALL_MATERIAL, { friction: .8, restitution: .0 })
world.addContactMaterial(ball_Floor);
world.addContactMaterial(ball_bottom);
}
相机的问题
这个游戏里直接使用了orbitConrtol,给了玩家极大的自由,但是这同样也会使得玩家难以维持一个良好的视角。
所以这里的处理也很简单,那就是对这个自由进行缩减。 限制旋转的方位角和极角,限制相机到目标点的最近和最远距离。
禁用平移。
//Game.js
const controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls = controls
// controls.enableRotate =true
// controls.enablePan =false
controls.maxDistance = 2
controls.minDistance = .35 ;
controls.maxPolarAngle = .7 ;// 限制极角 居然不是按照相机位置,而是反过来的 它的极角是y轴正向是 -PI/2 z这里就限制了观察底面
controls.minPolarAngle =-.7
结语
这个游戏到这里就基本完成了,是不是相当简单。
因为这个游戏最复杂的部分交给物理引擎了。
然后就是,如果要写出一个完整的桌球游戏,那肯定是要把桌球的规则加上,教程里已经加上了,我也看不懂,就不管了。还有考虑一些边界问题,比如主球进洞和飞出桌面,要重置,这个我已经想到了,所以加上去了。
好了,游戏系列告一段落,希望能有空把魔方交互搞清楚吧。
参考链接