three games 之 桌球

3,027 阅读9分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 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就可以了,设置好小球的质量,材质。 物理引擎的材质是为了处理碰撞问题,像摩擦因数,反弹系数等等。

当一个物体静止时,这个物体的状态就不会发生改变,所以可以休眠,也就是不用计算它了,显然这样做可以提升性能。

建模结束之后就把小球摆好。

image.png 注意,在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];
  }

球桌

球桌分为,桌面毛毡, 球桌边缘挡板, 网洞。 下面是预定义的几何体,都是些顶点建模的细致活。可以略过。 code-Tables.png

桌面就一个长方体,但是这里可能是为了仿真,分成了三个部分. 两边的是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;
    }

image.png

image.png

挡板是用多个梯形体(原谅我凭空造词)拼凑而成的。

有时候遇到有人问怎么在几何体里挖孔?渲染上挖孔容易,只要用裁剪/模版测试, 但是在建模里挖孔,实际上不就是,要增加额外的顶点和面,还要删除被挖掉的表面。 真的是想想就觉得麻烦,挖孔真的没组合容易。

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;
    }

image.png

最后就是网洞了,实际上是用长方体围起来的,就跟箍桶一样,不用圆柱的原因就是在上面要开一个口子,圆柱做不到,但是我们可以控制每一块木板的长度。 这里直接使用上面封装的拱形。

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;
    }

image.png

建模到这里就结束了,物理世界中的模型已经有了,下面就是视觉了。 image.png

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);
    }

image.png

球桌

这一次很简单,直接加载一个模型就可以了。因为球桌没有涉及太多交互逻辑,所以不用更新,也就不用放在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 );

			}  
        );
    }

image.png

开始击球

物理世界和视觉世界都弄好了之后, 就开始我们的主要玩法了。

也很简单,这里采用的思路是不直接控制球杆的方向,而是让球杆的方向永远是朝着屏幕里面的。准确的说,就是视线在球桌面的上的投影,当然,我们这里桌面是固定的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;
        }
    }
}

image.png

image.png

击球的力度

这里用长按来蓄力,还是那套逻辑,按下的状态下力道不断增加,而不是按下就增加力道。 松开力道就归零。

用一个非常简单的进度条来表示当前力道,同样是按下才显示,松开就隐藏。 原本的逻辑是把事件监听放在了进度条的父级盒子上,用来避免触发相机轨道控制器的逻辑。我直接加上了键盘空格键的监听,不用鼠标就不会有那个问题,我真是个小机灵鬼。

//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

结语

这个游戏到这里就基本完成了,是不是相当简单。

因为这个游戏最复杂的部分交给物理引擎了。

然后就是,如果要写出一个完整的桌球游戏,那肯定是要把桌球的规则加上,教程里已经加上了,我也看不懂,就不管了。还有考虑一些边界问题,比如主球进洞和飞出桌面,要重置,这个我已经想到了,所以加上去了。

好了,游戏系列告一段落,希望能有空把魔方交互搞清楚吧。

参考链接

b站大学

老爷子的github

我的gitee