Skip to content

球碰撞地面反弹---使用 oimo.js

fps: 0
点击运行
使用Oimo
<template>
  <div>
    <div class="flex space-between">
      <div>fps: {{ fps }}</div>
      <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    </div>
    <div>使用Oimo</div>
    <canvas v-if="isRunning" id="collisionBall" class="stage"></canvas>
  </div>
</template>

<script lang="ts" setup>
import { onMounted, ref, nextTick, onUnmounted } from 'vue'
import {
  Engine,
  Scene,
  ArcRotateCamera,
  Vector3,
  Color3,
  Color4,
  HemisphericLight,
  MeshBuilder,
  StandardMaterial,
  Texture,
  OimoJSPlugin,
  PhysicsImpostor
} from 'babylonjs'
import {
  AdvancedDynamicTexture,
  StackPanel,
  Control,
  TextBlock,
} from 'babylonjs-gui'

let sceneResources, adt
const max = 10
const y = 20

const fps = ref(0)
const isRunning = ref(false)

const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    sceneResources = await initScene()
  } else {
    isRunning.value = false
    destroy()
  }
}

const initScene = async () => {
  const ele = document.getElementById("collisionBall") as any

  ele.addEventListener('wheel', function(event) {
    // 根据需要处理滚动
    // 例如,可以修改相机的半径或角度
    event.preventDefault() // 阻止默认滚动行为
  })

  const engine: any = new Engine(ele, true, {
    preserveDrawingBuffer: true,
    stencil: true,
    disableWebGL2Support: false
  })

  const scene = new Scene(engine)
  scene.useRightHandedSystem = true
  scene.enablePhysics(
    new Vector3(0, -9.81, 0),
    new OimoJSPlugin()
  )

  const camera = new ArcRotateCamera('camera', -Math.PI / 1.5, Math.PI / 2.2, 15, new Vector3(0, 0, 0), scene)
  camera.upperBetaLimit = Math.PI / 2.2
  camera.wheelPrecision = 30
  camera.panningSensibility = 10
  camera.attachControl(ele, true)
  camera.setPosition(new Vector3(120, 50, 120))

  const createLight = () => {
    const light = new HemisphericLight('light',new Vector3(1, 1, 0), scene)
    return light
  }

  const createAxis = () => {
    const axisX = MeshBuilder.CreateLines(
      'axisX', {
        colors: [new Color4(1, 0, 0, 1), new Color4(1, 0, 0, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(80, 0, 0)]
      },
      scene
    )

    const axisY = MeshBuilder.CreateLines(
      'axisY', {
        colors: [new Color4(0, 1, 0, 1),  new Color4(0, 1, 0, 1)  ],
        points: [new Vector3(0, 0, 0), new Vector3(0, 80, 0) ]
      },
      scene
    )

    const axisZ = MeshBuilder.CreateLines(
      'axisZ', {
        colors: [new Color4(0, 0, 1, 1), new Color4(0, 0, 1, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(0, 0, 80)]
      },
      scene
    )

    return [axisX, axisY, axisZ]
  }

  const createGui = async () => {
    adt = AdvancedDynamicTexture.CreateFullscreenUI('UI')

    const xBox = MeshBuilder.CreateBox('x', { size: 1 }, scene)
    xBox.position = new Vector3(80, 0, 0)
    const xPanel = new StackPanel()
    xPanel.width = '20px'
    xPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    xPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const x = new TextBlock()
    x.text = 'X'
    x.height = '30px'
    x.color = 'red'
    adt.addControl(xPanel)
    xPanel.addControl(x)
    xPanel.linkWithMesh(xBox)

    const yBox = MeshBuilder.CreateBox('y', { size: 1 }, scene)
    yBox.position = new Vector3(0, 80, 0)
    const yPanel = new StackPanel()
    yPanel.width = '20px'
    yPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    yPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const y = new TextBlock()
    y.text = 'Y'
    y.height = '30px'
    y.color = 'green'
    adt.addControl(yPanel)
    yPanel.addControl(y)
    yPanel.linkWithMesh(yBox)

    const zBox = MeshBuilder.CreateBox('z', { size: 1 }, scene)
    zBox.position = new Vector3(0, 0, 80)
    const zPanel = new StackPanel()
    zPanel.width = '20px'
    zPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    zPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const z = new TextBlock()
    z.text = 'Z'
    z.height = '30px'
    z.color = 'blue'
    adt.addControl(zPanel)
    zPanel.addControl(z)
    zPanel.linkWithMesh(zBox)
  }

  const createGround = () => {
    const mat = new StandardMaterial('ground', scene)
    const wood = new Texture('/images/wood.jpg', scene)
    mat.diffuseTexture = wood
    mat.specularColor = Color3.Black()

    const ground = MeshBuilder.CreateBox('box', { size: 200 }, scene)
    ground.position.y = 0
    ground.position.x = 0
    ground.scaling.y = 0.01
    ground.material = mat
    ground.physicsImpostor = new PhysicsImpostor(
      ground,
      PhysicsImpostor.BoxImpostor, // 使用盒子形物理代理
      {
        mass: 0, // 物体的质量设置为 0
        restitution: 0.7 // 弹性(或称为恢复系数),范围从 0(无弹性,即完全非弹性碰撞)到 1(完全弹性碰撞)
      },
      scene
    )
  }

  const randomNumber = (min, max) => {
    if (min === max) {
      return min
    }
    const random = Math.random()
    return random * (max - min) + min
  }

  const getPosition = (y) => {
    return new Vector3(randomNumber(-50, 50), y, randomNumber(-50, 50))
  }

  const createSpheres = () => {
    const allSphere: any = []
    const sphere = MeshBuilder.CreateSphere('s', { diameter: 8 }, scene)
    sphere.isVisible = false
    for (let index = 0; index < max; index ++) {
      const clone = sphere.clone(`index_${index}`)
      clone.isVisible = true
      const ran = Math.random()
      const startY = Math.random() * 10
      const dis = y + (ran > 0.5 ? startY : startY * -1)
      clone.position = getPosition(dis)

      clone.physicsImpostor = new PhysicsImpostor(
        clone,
        PhysicsImpostor.SphereImpostor, // 使用球形物理代理
        {
          mass: 50, // 物体的质量设置为 1
          restitution: 0.9 // 弹性(或称为恢复系数),范围从 0(无弹性,即完全非弹性碰撞)到 1(完全弹性碰撞)
        },
        scene
      )

      // applyImpulse 方法对物理代理对象施加一个冲量
      // 这个方法接受两个 Vector3 参数:
      // 第一个是冲量的方向和大小
      // 第二个是施加冲量的点

      // 假设forceVector是 (1, 2, -1)
      // contactPoint是 (1, 2, 0),这指定了在 clone 网格对象的局部空间中施加冲量的具体位置
      // 冲量大小是根号6 ---> 1^2+2^2+(-1)^2 再开根号
      // 冲量的方向是通过反余弦公式得出(arccos(1 / 根号6), arccos(2 / 根号6), arccos(-1 / 根号6))
      
      clone.physicsImpostor.applyImpulse(
        new Vector3(0, -20, 0), // 冲量的方向和大小
        new Vector3(0, 0, 0) // 在物体上的受力点位置
      )

      const mat = new StandardMaterial('mat', scene)
      mat.diffuseColor = new Color3(0.4, 0.4, 0.4)
      mat.specularColor = new Color3(0.4, 0.4, 0.4)
      mat.emissiveColor = Color3.Red()
      clone.material = mat 

      allSphere.push(clone)
    }

    return allSphere
  }


  const runAnimate = () => {
    engine.runRenderLoop(function() {
      if (scene && scene.activeCamera) {
        scene.render()

        fps.value = engine.getFps().toFixed(2)
      }
    })
  }

  createGround()
  const allSphere = createSpheres()
  createAxis()
  createGui()
  createLight()
  runAnimate()

  scene.registerBeforeRender(function() {
    allSphere.forEach(function(obj) {
      if (obj.position.y < -10) {
        const ran = Math.random()
        const startY = Math.random() * 10
        const dis = y + (ran > 0.5 ? startY : startY * -1)
        obj.position = getPosition(dis)
        // 重置球体的速度
        obj.physicsImpostor.setLinearVelocity(new Vector3(0, 0, 0))
      }
    })
  })

  return {
    scene,
    engine
  }
}

const destroy = () => {
  if (sceneResources) {
    sceneResources.engine.stopRenderLoop() 
    sceneResources.engine.dispose()
    sceneResources.scene.dispose()
    sceneResources = null
  }
  if (adt) {
    adt.dispose()
    adt = null
  }
}

onMounted(async() => {
  await nextTick()
})

onUnmounted(() => {
  destroy()
})
</script>

初使用 Havok 物理引擎

fps: 0
点击运行
使用Havok
<template>
  <div>
    <div class="flex space-between">
      <div>fps: {{ fps }}</div>
      <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    </div>
    <div>使用Havok</div>
    <canvas v-if="isRunning" id="collisionHavok" class="stage"></canvas>
  </div>
</template>

<script lang="ts" setup>
import { onMounted, ref, nextTick, onUnmounted } from 'vue'
import {
  Engine,
  Scene,
  FreeCamera,
  Vector3,
  Color3,
  HemisphericLight,
  MeshBuilder,
  StandardMaterial,
  Texture,
  HavokPlugin,
  PhysicsAggregate
} from 'babylonjs'

let sceneResources

const fps = ref(0)
const isRunning = ref(false)

const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    sceneResources = await initScene()
  } else {
    isRunning.value = false
    destroy()
  }
}


const initScene = async () => {

  const ele = document.getElementById("collisionHavok") as any

  ele.addEventListener('wheel', function(event) {
    // 根据需要处理滚动
    // 例如,可以修改相机的半径或角度
    event.preventDefault() // 阻止默认滚动行为
  })

  const engine: any = new Engine(ele, true, {
    preserveDrawingBuffer: true,
    stencil: true,
    disableWebGL2Support: false
  })

  const scene = new Scene(engine)

  const camera = new FreeCamera('camera', new Vector3(0, 5, -10), scene)
  camera.setTarget(Vector3.Zero())
  camera.attachControl(ele, true)

  const havokInstance = await (window as any).HavokPhysics()
  const havokPlugin = new HavokPlugin(true, havokInstance)
  scene.enablePhysics(new Vector3(0, -9.8, 0), havokPlugin)

  const createLight = () => {
    const light = new HemisphericLight('light',new Vector3(0, 1, 0), scene)
    light.intensity = 0.7
    return light
  }

  const createGround = () => {
    const mat = new StandardMaterial('ground', scene)
    const wood = new Texture('/images/wood.jpg', scene)
    mat.diffuseTexture = wood
    mat.specularColor = Color3.Black()

    const ground = MeshBuilder.CreateGround('ground', { width: 10, height: 10	}, scene)
    ground.material = mat
    new PhysicsAggregate(ground, BABYLON.PhysicsShapeType.BOX, {
      mass: 0
    }, scene)
  }

  const createSphere = () => {
    const sphere = MeshBuilder.CreateSphere('s', { diameter: 2, segments: 32 }, scene)
    sphere.position.y = 4
    new PhysicsAggregate(sphere, BABYLON.PhysicsShapeType.SPHERE, {
      mass: 100,
      restitution: 0.75
    }, scene)
  }


  const runAnimate = () => {
    engine.runRenderLoop(function() {
      if (scene && scene.activeCamera) {
        scene.render()

        fps.value = engine.getFps().toFixed(2)
      }
    })
  }

  createLight()
  createGround()
  createSphere()
  runAnimate()

  return {
    scene,
    engine, 
  }
}

const destroy = () => {
  if (sceneResources) {
    sceneResources.engine.stopRenderLoop() 
    sceneResources.engine.dispose()
    sceneResources.scene.dispose()
    sceneResources = null
  }
}

onMounted(async() => {
  await nextTick()
})

onUnmounted(() => {
  destroy()
})
</script>

粒子的重力下降与球发生碰撞

fps: 0
点击运行
使用sphere.intersectsMesh(particle, true)检测碰撞
<template>
  <div>
    <div class="flex space-between">
      <div>fps: {{ fps }}</div>
      <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    </div>
    <div>使用sphere.intersectsMesh(particle, true)检测碰撞</div>
    <canvas v-if="isRunning" id="collisionParticle" class="stage"></canvas>
  </div>
</template>

<script lang="ts" setup>
import { onMounted, ref, nextTick, onUnmounted } from 'vue'
import 'babylonjs-loaders'
import {
  Engine,
  Scene,
  ArcRotateCamera,
  Vector3,
  Color3,
  HemisphericLight,
  MeshBuilder,
  StandardMaterial,
  Texture,
  SolidParticleSystem
} from 'babylonjs'

let sceneResources

const fps = ref(0)
const isRunning = ref(false)

const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    sceneResources = await initScene()
  } else {
    isRunning.value = false
    destroy()
  }
}

const initScene = async () => {
  const ele = document.getElementById("collisionParticle") as any

  ele.addEventListener('wheel', function(event) {
    // 根据需要处理滚动
    // 例如,可以修改相机的半径或角度
    event.preventDefault() // 阻止默认滚动行为
  })

  const engine: any = new Engine(ele, true, {
    preserveDrawingBuffer: true,
    stencil: true,
    disableWebGL2Support: false
  })

  const scene = new Scene(engine)
  scene.useRightHandedSystem = true

  const camera = new ArcRotateCamera('camera', Math.PI / 4, 0, 150, new Vector3(0, 0, 0), scene)
  camera.upperBetaLimit = Math.PI / 2.2
  camera.wheelPrecision = 30
  camera.panningSensibility = 10
  camera.attachControl(ele, true)
  camera.setPosition(new Vector3(50, 300, 20))

  let k = 0.0
  let tempPos = Vector3.Zero()
  let tempNormal = Vector3.Zero()
  let tempDot = 0.0
  let sign = 0
  let sphereAltitude = 40.0

  const speed = 1.9
  const cone = 0.3
  const gravity = -speed / 100
  const restitution = 0.88
 

  const createLight = () => {
    const light = new HemisphericLight('light',new Vector3(1, 1, 0), scene)
    return light
  }

  const createGround = () => {
    const groundMaterial = new StandardMaterial("groundMaterial", scene)
    groundMaterial.diffuseTexture = new Texture('/images/wood.jpg')
    const ground = MeshBuilder.CreateGround(
      'gd', {
        width: 1000.0,
        height: 1000.0
      },
      scene
    )
    ground.material = groundMaterial
    return ground
  }

  const createSphere = () => {
    const matSphere = new StandardMaterial('ms', scene)
    matSphere.diffuseColor = Color3.Blue()
    const sphere = MeshBuilder.CreateSphere(
     's', { diameter: 8 }, scene
    )
    sphere.material = matSphere
    sphere.position.y = 35
    return sphere
  }

  const createBox = () => {
    const matBox = new StandardMaterial('ms', scene)
    matBox.diffuseColor = Color3.Red()
    const box = MeshBuilder.CreateBox(
      'b', {
        size: 2
      },
      scene
    )
    box.material = matBox
    return box
  }

  const createSps = (ground, sphere) => {
    /** SolidParticleSystem允许创建由一个或多个基础网格模型组成的粒子云 */
    const sps: any = new SolidParticleSystem('sps', scene, {
      /** 启用了 particleIntersection,就可以使用任何固体粒子的 intersectsMesh() 方法来检查该粒子是否与目标网格相交 */
      particleIntersection: true
    })

    const box = createBox()
    sps.addShape(box, 200) // 添加200个box作为粒子
    box.dispose()
  
    const mesh = sps.buildMesh() // 调用 buildMesh() 方法来构建 SPS 网格
    mesh.position.y = 80.0
    mesh.position.x = -70.0
    sphereAltitude = mesh.position.y / 2.0
    sphere.position.y = sphereAltitude

    sps.isAlwaysVisible = true

    sps.initParticles = () => {
      for (let p = 0; p < sps.nbParticles; p++) {
        sps.recycleParticle(sps.particles[p])
      }
    }

    // recycleParticle方法允许设置一个粒子以便于回收利用
    // 可以帮助提高性能,尤其是在处理大量粒子时
    // 在updateParticle方法中定义粒子的行为,并在适当的时候调用recycleParticle来回收粒子
    sps.recycleParticle = (particle: any) => {
      particle.position.x = 0
      particle.position.y = 0
      particle.position.z = 0
      particle.velocity.x = Math.random() * speed
      particle.velocity.y = (Math.random() - 0.3) * cone * speed
      particle.velocity.z = (Math.random() - 0.5) * cone * speed

      particle.rotation.x = Math.random() * Math.PI
      particle.rotation.y = Math.random() * Math.PI
      particle.rotation.z = Math.random() * Math.PI

      particle.color.r = 0.0
      particle.color.g = 1.0
      particle.color.b = 0.0
      particle.color.a = 1.0
    }

    sps.updateParticle = (particle: any) => {
      if (particle.position.y + mesh.position.y < ground.position.y) {
        particle.color.r = 0.0
        particle.color.g = 1.0
        particle.color.b = 0.0
        sps.recycleParticle(particle)
      }

      particle.velocity.y += gravity
      particle.position.addInPlace(particle.velocity) // 更新粒子新位置

      // 使得每个粒子在三个轴(x、y、z)上以不同的速度和方向旋转,从而创造出更加复杂和动态的视觉效果。
      // 通过交替改变旋转方向,粒子的旋转看起来更加自然和随机。
      sign = particle.idx % 2 === 0 ? 1 : -1
      particle.rotation.z += 0.1 * sign
      particle.rotation.x += 0.05 * sign
      particle.rotation.y += 0.008 * sign

      // 判断发生碰撞
      if (particle.intersectsMesh(sphere)) {
        // 将粒子的位置向量与网格(mesh)的位置向量相加,结果存储在 tempPos 变量中,这样 tempPos 就包含了粒子的世界位置
        particle.position.addToRef(mesh.position, tempPos)
        // 从 tempPos 中减去球体的位置向量,结果存储在 tempNormal 变量中,这样 tempNormal 就包含了从球体中心指向粒子的向量,即球体的法线
        tempPos.subtractToRef(sphere.position, tempNormal)
        // 将 tempNormal 向量标准化(即长度变为1),这样它就成为了一个单位法线向量
        tempNormal.normalize()
        // 计算粒子速度向量与球体法线向量的点积,结果存储在 tempDot 变量中
        tempDot = Vector3.Dot(particle.velocity, tempNormal)


        /**
        在物理学中,当处理两个物体之间的碰撞时,特别是涉及到法线分量的速度时,一个常见的方法是使用分离公式。
        这个公式是从动量守恒和能量守恒原理推导出来的。
        给定:
        v 是粒子的原始速度向量。
        n 是碰撞物体的单位法线向量。
        v_new 是碰撞后粒子的速度向量。

        速度向量 v 可以分解为两个分量:

        平行于法线 n 的分量:v∥ = (v⋅n)n -------- v⋅n 是点积
        垂直于法线 n 的分量:v⊥ =  v − v∥

        当粒子与物体碰撞时,垂直于法线的分量保持不变,而平行于法线的分量被反转并根据恢复系数 e 进行缩放。
        恢复系数 e 描述了碰撞的弹性程度,其中 e=1 表示完全弹性碰撞,而 e=0 表示完全非弹性碰撞。

        碰撞后的速度向量 v_new  由下式给出:
        v_new = v⊥ - e(v⋅n)n
        */

        /**
        1、法线向量的计算:
        当粒子与球体碰撞时,首先计算从球体中心指向粒子的向量(tempNormal),并将其标准化为单位法线向量。
        这个向量表示了碰撞点的法线方向。

        2、速度在法线方向上的分量:
        接下来,计算粒子速度向量(particle.velocity)与单位法线向量(tempNormal)的点积(tempDot)。
        点积的结果给出了速度向量在法线方向上的分量的大小(正值或负值,取决于速度向量与法线向量的夹角是锐角还是钝角)。

        3、速度向量的更新:
        根据弹性碰撞的原理,速度向量在法线方向上的分量会完全反向,并且大小可能保持不变(在完全弹性碰撞中)。
        因此,从原始速度向量中减去其在法线方向上的分量(乘以2并反向,以符合反射定律),然后加上这个反向的分量乘以单位法线向量的相应分量。
        这样做实际上是将速度向量在其法线分量上进行了反射。
        数学上,这可以表示为:
        new_velocity_component=−original_velocity_component+2×dot_product×normal_component
        original_velocity_component 是速度向量在x、y或z方向上的原始分量
        dot_product 是速度向量与法线向量的点积
        normal_component 是单位法线向量在x、y或z方向上的分量

        4、恢复系数的应用:
        最后,将更新后的速度向量乘以恢复系数(restitution),以模拟碰撞后的能量损失。
        恢复系数是一个介于0和1之间的数,值越接近1表示碰撞越弹性。
        */

        /**
        假设一个图中
        有一个粒子(P)和一个球体(S)。粒子的速度向量(V)与球体的表面相交于点C,形成一个入射角θ。
        从球体中心S到粒子P的向量(SC)被标准化为单位法线向量(N)。
        速度向量V与法线向量N的点积给出了速度在法线方向上的分量。
        新的速度向量(V')是通过将速度向量V在其法线分量上进行反射得到的。注意,这里的反射是基于二维平面的,但在三维中原理相同。
        最后,新的速度向量V'被乘以恢复系数,以模拟碰撞后的能量损失。
        */

        particle.velocity.x = -particle.velocity.x + 2.0 * tempDot * tempNormal.x
        particle.velocity.y = -particle.velocity.y + 2.0 * tempDot * tempNormal.y
        particle.velocity.z = -particle.velocity.z + 2.0 * tempDot * tempNormal.z

        // 将粒子速度向量乘以恢复系数(restitution),这是弹性碰撞的一个参数,用于模拟碰撞后的反弹效果
        particle.velocity.scaleInPlace(restitution)

        particle.color.r = 1.0
        particle.color.g = 0.0
        particle.color.b = 0.0
      }
    }
    
    return sps
  }


  const runAnimate = () => {
    engine.runRenderLoop(function() {
      if (scene && scene.activeCamera) {
        scene.render()
        
        fps.value = engine.getFps().toFixed(2)
      }
    })
  }
  
  createLight()
  const ground = createGround()
  const sphere = createSphere()
  const sps = createSps(ground, sphere)
  sps.initParticles()

  runAnimate()

  scene.registerBeforeRender(function() {
    sps.particles.forEach(particle => {
      if (sphere.intersectsMesh(particle, true)) {
        console.log('collided')
      }
    })
    sps.setParticles()
    sphere.position.x = 20.0 * Math.sin(k)
    sphere.position.z = 10.0 * Math.sin(k * 6.0)
    sphere.position.y = 5.0 * Math.sin(k * 10) + sphereAltitude
    k += 0.01
  })

  return {
    scene,
    engine, 
  }
}

const destroy = () => {
  if (sceneResources) {
    sceneResources.engine.stopRenderLoop() 
    sceneResources.engine.dispose()
    sceneResources.scene.dispose()
    sceneResources = null
  }
}

onMounted(async() => {
  await nextTick()
})

onUnmounted(() => {
  destroy()
})
</script>

推倒积木 -1

fps: 0
点击运行
使用Havok
<template>
  <div>
    <div class="flex space-between">
      <div>fps: {{ fps }}</div>
      <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    </div>
    <div>使用Havok</div>
    <canvas v-if="isRunning" id="buildingBlock1" class="stage"></canvas>
  </div>
</template>

<script lang="ts" setup>
import { onMounted, ref, nextTick, onUnmounted } from 'vue'
import {
  Engine,
  Scene,
  ArcRotateCamera,
  Vector3,
  Color3,
  Color4,
  HemisphericLight,
  MeshBuilder,
  StandardMaterial,
  Texture,
  HavokPlugin,
  Matrix,
  PhysicsAggregate,
  PhysicsShapeType,
  PointerEventTypes
} from 'babylonjs'
import {
  AdvancedDynamicTexture,
  StackPanel,
  Control,
  TextBlock,
} from 'babylonjs-gui'

let sceneResources, adt

const fps = ref(0)
const isRunning = ref(false)

const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    sceneResources = await initScene()
  } else {
    isRunning.value = false
    destroy()
  }
}


const initScene = async () => {

  const ele = document.getElementById("buildingBlock1") as any

  ele.addEventListener('wheel', function(event) {
    // 根据需要处理滚动
    // 例如,可以修改相机的半径或角度
    event.preventDefault() // 阻止默认滚动行为
  })

  const engine: any = new Engine(ele, true, {
    preserveDrawingBuffer: true,
    stencil: true,
    disableWebGL2Support: false
  })

  const scene = new Scene(engine)
  scene.useRightHandedSystem = true

  const camera = new ArcRotateCamera('camera', 0, 0, 0, new Vector3(0, 0, 0), scene)
  camera.upperBetaLimit = Math.PI / 2.2
  camera.wheelPrecision = 30
  camera.panningSensibility = 10
  camera.attachControl(ele, true)
  camera.setPosition(new Vector3(7, 7, 0))

  const havokInstance = await (window as any).HavokPhysics()
  const havokPlugin = new HavokPlugin(true, havokInstance)
  scene.enablePhysics(new Vector3(0, -9.8, 0), havokPlugin)

  const createAxis = () => {
    const axisX = MeshBuilder.CreateLines(
      'axisX', {
        colors: [new Color4(1, 0, 0, 1), new Color4(1, 0, 0, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(80, 0, 0)]
      },
      scene
    )

    const axisY = MeshBuilder.CreateLines(
      'axisY', {
        colors: [new Color4(0, 1, 0, 1),  new Color4(0, 1, 0, 1)  ],
        points: [new Vector3(0, 0, 0), new Vector3(0, 80, 0) ]
      },
      scene
    )

    const axisZ = MeshBuilder.CreateLines(
      'axisZ', {
        colors: [new Color4(0, 0, 1, 1), new Color4(0, 0, 1, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(0, 0, 80)]
      },
      scene
    )

    return [axisX, axisY, axisZ]
  }

  const createGui = async () => {
    adt = AdvancedDynamicTexture.CreateFullscreenUI('UI')

    const xBox = MeshBuilder.CreateBox('x', { size: 1 }, scene)
    xBox.position = new Vector3(80, 0, 0)
    const xPanel = new StackPanel()
    xPanel.width = '20px'
    xPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    xPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const x = new TextBlock()
    x.text = 'X'
    x.height = '30px'
    x.color = 'red'
    adt.addControl(xPanel)
    xPanel.addControl(x)
    xPanel.linkWithMesh(xBox)

    const yBox = MeshBuilder.CreateBox('y', { size: 1 }, scene)
    yBox.position = new Vector3(0, 80, 0)
    const yPanel = new StackPanel()
    yPanel.width = '20px'
    yPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    yPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const y = new TextBlock()
    y.text = 'Y'
    y.height = '30px'
    y.color = 'green'
    adt.addControl(yPanel)
    yPanel.addControl(y)
    yPanel.linkWithMesh(yBox)

    const zBox = MeshBuilder.CreateBox('z', { size: 1 }, scene)
    zBox.position = new Vector3(0, 0, 80)
    const zPanel = new StackPanel()
    zPanel.width = '20px'
    zPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    zPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const z = new TextBlock()
    z.text = 'Z'
    z.height = '30px'
    z.color = 'blue'
    adt.addControl(zPanel)
    zPanel.addControl(z)
    zPanel.linkWithMesh(zBox)
  }

  const createLight = () => {
    const light = new HemisphericLight('light',new Vector3(0, 1, 0), scene)
    light.intensity = 0.7
    return light
  }

  const createGround = () => {
    const mat = new StandardMaterial('ground', scene)
    const wood = new Texture('/images/wood.jpg', scene)
    mat.diffuseTexture = wood
    mat.specularColor = Color3.Black()

    const ground = MeshBuilder.CreateGround('ground', { width: 10, height: 10	}, scene)
    ground.material = mat
    new PhysicsAggregate(ground, PhysicsShapeType.BOX, {
      mass: 0
    }, scene)
  }

  const assetsBody: any = []

  const createBlock = (x, y, z, i, color) => {
    const block: any = MeshBuilder.CreateBox('block_' + i, { width: 0.2, height: 1.8, depth: 0.2 }, scene)
    block.position.set(x, y, z)
    block.checkCollisions = true
    block.material = new StandardMaterial('blockMat', scene)
    block.material.diffuseColor = color
    const aggregate = new PhysicsAggregate(block, PhysicsShapeType.BOX, {
      mass: 10,
      restitution: 0.25
    }, scene)
    aggregate.body.disablePreStep = false
    return {
      asset: block,
      aggregate: aggregate
    }
  }

  const createSphere = () => {
    const sphere = MeshBuilder.CreateSphere('sphere', { diameter: 1 }, scene)
    sphere.position.set(2, 0.5, 0)
    sphere.checkCollisions = true
    const aggregate = new PhysicsAggregate(sphere, PhysicsShapeType.SPHERE, {
      mass: 10,
      restitution: 0.25, // 弹性系数
      friction: 0.5 // 摩擦力
    }, scene)
    aggregate.body.disablePreStep = false
    return {
      asset: sphere,
      aggregate: aggregate
    }
  }
  
  

  const runAnimate = () => {
    engine.runRenderLoop(function() {
      if (scene && scene.activeCamera) {
        scene.render()

        fps.value = engine.getFps().toFixed(2)
      }
    })
  }

  createAxis()
  createGui()
  createLight()
  createGround()
  runAnimate()

  const sphere: any = createSphere()
  assetsBody.push(sphere)

  for (let i = 0; i < 5; i++) {
    const block = createBlock(i * -1, 0.9, 0, i, Color3.Red())
    assetsBody.push(block)
  }

  scene.onPointerObservable.add((pointerInfo) => {
    if (pointerInfo.type === PointerEventTypes.POINTERDOWN) {
      // 使用 scene.pickWithRay 获取鼠标点击的射线与球体的交点
      // 如果点击到球体,pickResult.hit 为 true,pickResult.pickedPoint 是点击位置
      const pickResult: any = scene.pickWithRay(scene.createPickingRay(scene.pointerX, scene.pointerY, Matrix.Identity(), camera))
      if (pickResult.hit && pickResult.pickedMesh === sphere.asset) {
        // 计算施加力的方向:从球体中心到点击位置的方向
        const impulseDirection = pickResult.pickedPoint.subtract(sphere.asset.position).normalize()
        // 力的大小
        const impulse = impulseDirection.scale(-20) 
        sphere.aggregate.body.applyImpulse(impulse, pickResult.pickedPoint)
      }
    }
  })

  return {
    scene,
    engine, 
  }
}

const destroy = () => {
  if (sceneResources) {
    sceneResources.engine.stopRenderLoop() 
    sceneResources.engine.dispose()
    sceneResources.scene.dispose()
    sceneResources = null
  }
  if (adt) {
    adt.dispose()
    adt = null
  }
}

onMounted(async() => {
  await nextTick()
})

onUnmounted(() => {
  destroy()
})
</script>

模拟台球 -1

fps: 0
点击运行
使用Havok
力度:0
<template>
  <div>
    <div class="flex space-between">
      <div>fps: {{ fps }}</div>
      <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    </div>
    <div>使用Havok</div>
    <div class="flex space-between">
      <div>力度:{{ impulse }}</div>
      <div>{{ gameOver }}</div>
    </div>
    <canvas v-if="isRunning" id="billiardBall1" class="stage"></canvas>
  </div>
</template>


<script lang="ts" setup>
import { onMounted, ref, nextTick, onUnmounted } from 'vue'
import {
  Engine,
  Scene,
  ArcRotateCamera,
  Vector3,
  Color3,
  Color4,
  StandardMaterial,
  HemisphericLight,
  MeshBuilder,
  HavokPlugin,
  PhysicsAggregate,
  PhysicsShapeType,
  PointerEventTypes,
  Matrix,
  Animation,
} from 'babylonjs'
import {
  AdvancedDynamicTexture,
  StackPanel,
  Control,
  TextBlock,
} from 'babylonjs-gui'

let sceneResources, adt
let cueAnimation: any = null
let timer: any = null
let invert = 1
let isAiming = false
let balls: any = []
let pockets: any = []
let clickBall: any = null
const impulse = ref(0)

const gameOver = ref('')

const fps = ref(0)
const isRunning = ref(false)

const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    sceneResources = await initScene()
    gameOver.value = ''
  } else {
    isRunning.value = false
    destroy()
  }
}


const initScene = async () => {

  const ele = document.getElementById("billiardBall1") as any

  ele.addEventListener('wheel', function(event) {
    // 根据需要处理滚动
    // 例如,可以修改相机的半径或角度
    event.preventDefault() // 阻止默认滚动行为
  })

  const engine: any = new Engine(ele, true, {
    preserveDrawingBuffer: true,
    stencil: true,
    disableWebGL2Support: false
  })

  const scene = new Scene(engine)
  scene.useRightHandedSystem = true

  const camera = new ArcRotateCamera('camera', 0, 0, 0, new Vector3(0, 0, 0), scene)
  camera.upperBetaLimit = Math.PI / 2.2
  camera.wheelPrecision = 30
  camera.panningSensibility = 10
  camera.attachControl(ele, true)
  camera.setPosition(new Vector3(0, 0, 10))

  const havokInstance = await (window as any).HavokPhysics()
  const havokPlugin = new HavokPlugin(true, havokInstance)
  scene.enablePhysics(new Vector3(0, -9.8, 0), havokPlugin)

  const createAxis = () => {
    const axisX = MeshBuilder.CreateLines(
      'axisX', {
        colors: [new Color4(1, 0, 0, 1), new Color4(1, 0, 0, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(80, 0, 0)]
      },
      scene
    )

    const axisY = MeshBuilder.CreateLines(
      'axisY', {
        colors: [new Color4(0, 1, 0, 1),  new Color4(0, 1, 0, 1)  ],
        points: [new Vector3(0, 0, 0), new Vector3(0, 80, 0) ]
      },
      scene
    )

    const axisZ = MeshBuilder.CreateLines(
      'axisZ', {
        colors: [new Color4(0, 0, 1, 1), new Color4(0, 0, 1, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(0, 0, 80)]
      },
      scene
    )

    return [axisX, axisY, axisZ]
  }

  const createGui = async () => {
    adt = AdvancedDynamicTexture.CreateFullscreenUI('UI')

    const xBox = MeshBuilder.CreateBox('x', { size: 1 }, scene)
    xBox.position = new Vector3(80, 0, 0)
    const xPanel = new StackPanel()
    xPanel.width = '20px'
    xPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    xPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const x = new TextBlock()
    x.text = 'X'
    x.height = '30px'
    x.color = 'red'
    adt.addControl(xPanel)
    xPanel.addControl(x)
    xPanel.linkWithMesh(xBox)

    const yBox = MeshBuilder.CreateBox('y', { size: 1 }, scene)
    yBox.position = new Vector3(0, 80, 0)
    const yPanel = new StackPanel()
    yPanel.width = '20px'
    yPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    yPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const y = new TextBlock()
    y.text = 'Y'
    y.height = '30px'
    y.color = 'green'
    adt.addControl(yPanel)
    yPanel.addControl(y)
    yPanel.linkWithMesh(yBox)

    const zBox = MeshBuilder.CreateBox('z', { size: 1 }, scene)
    zBox.position = new Vector3(0, 0, 80)
    const zPanel = new StackPanel()
    zPanel.width = '20px'
    zPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    zPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const z = new TextBlock()
    z.text = 'Z'
    z.height = '30px'
    z.color = 'blue'
    adt.addControl(zPanel)
    zPanel.addControl(z)
    zPanel.linkWithMesh(zBox)
  }

  const createLight = () => {
    const light = new HemisphericLight('light',new Vector3(0, 1, 0), scene)
    light.intensity = 0.7
    return light
  }

  // 创建台球桌
  const createTable = () => {
    const table = MeshBuilder.CreateBox('table', {
      width: 10,
      height: 0.5,
      depth: 5
    }, scene)
    table.position.y = -0.25
    new PhysicsAggregate(table, PhysicsShapeType.BOX, {
      mass: 0,
      friction: 0.5,
      restitution: 0.1
    }, scene)
  }

  // 创建台球桌墙
  const createTableWall = () => {
    const wallPositions = [
      { width: 0.2, height: 0.2, depth: 4.2, x: -5, y: 0.1, z: 0 }, // 左墙
      { width: 0.2, height: 0.2, depth: 4.2, x: 5, y: 0.1, z: 0 }, // 右墙
      { width: 4.3, height: 0.2, depth: 0.2, x: -2.45, y: 0.1, z: 2.5 }, // 下墙-左
      { width: 4.3, height: 0.2, depth: 0.2, x: 2.45, y: 0.1, z: 2.5 }, // 下墙-右
      { width: 4.3, height: 0.2, depth: 0.2, x: -2.45, y: 0.1, z: -2.5 }, // 上墙-左
      { width: 4.3, height: 0.2, depth: 0.2, x: 2.45, y: 0.1, z: -2.5 }, // 上墙-右
    ]

    for (let i = 0; i < wallPositions.length; i++) {
      const info = wallPositions[i]
      const wall: any = MeshBuilder.CreateBox('wall' + i, {
        width: info.width,
        height: info.height,
        depth: info.depth
      }, scene)
      wall.position = new Vector3(info.x, info.y, info.z)
      wall.material = new StandardMaterial('wallMaterial' + i, scene)
      wall.material.diffuseColor = new Color3(0.6, 0.2, 0.4)

      new PhysicsAggregate(wall, PhysicsShapeType.BOX, {
        mass: 0,
        friction: 0.5,
        restitution: 0.1
      }, scene)
    }
  }
  
  // 创建球袋
  const createPocket = () => {
    const pocketPositions = [
      new Vector3(-4.9, 0, -2.4),
      new Vector3(4.9, 0, -2.4),
      new Vector3(-4.9, 0, 2.4),
      new Vector3(4.9, 0, 2.4),
      new Vector3(0, 0, -2.4),
      new Vector3(0, 0, 2.4)
    ]

    for (let i = 0; i < pocketPositions.length; i++) {
      const pocket: any = MeshBuilder.CreateCylinder('pocket' + i, {
        height: 0.5,
        diameter: 0.7,
        tessellation: 64
      }, scene)
      pocket.position = pocketPositions[i]
      pocket.position.y = -0.23
      pocket.material = new StandardMaterial('pocketMaterial' + i, scene)
      pocket.material.diffuseColor = new Color3(0.1, 0.1, 0.1)
      pockets.push(pocket)
    }
  }

  // 创建台球
  const createBall = () => {
    const ballColors = [
      new Color3(1, 1, 1), // 白球
      new Color3(0.8, 0.1, 0.1), // 红球
      new Color3(0.1, 0.8, 0.1), // 绿球
      new Color3(0.1, 0.1, 0.8), // 蓝球
      new Color3(0.8, 0.8, 0.1), // 黄球
      new Color3(0.8, 0.1, 0.8), // 紫球
      new Color3(0.1, 0.8, 0.8), // 青球
      new Color3(0.5, 0.5, 0.5) // 黑球
    ]

    for (let i = 0; i < 8; i++) {
      const ball: any = MeshBuilder.CreateSphere('ball' + i, {
        diameter: 0.3
      }, scene)
      ball.position.x = Math.cos(i * Math.PI / 4) * 1.5
      ball.position.z = Math.sin(i * Math.PI / 4) * 1.5
      ball.position.y = 0.25
      ball.material = new StandardMaterial('ballMaterial' + i, scene)
      ball.material.diffuseColor = ballColors[i]
      ball.customAggregate = new PhysicsAggregate(ball, PhysicsShapeType.SPHERE, {
        mass: 0.5,
        friction: 0.1,
        restitution: 0.8
      }, scene)
      balls.push(ball)
    }
  }

  // 创建球杆
  const createCue = () => {
    const cue: any = MeshBuilder.CreateCylinder('cue', {
      height: 6,
      diameterTop: 0.1,
      diameterBottom: 0.2,
      tessellation: 64
    }, scene)
    cue.rotation = new Vector3(Math.PI / 2, 0, 0)
    cue.material = new StandardMaterial('cueMaterial', scene)
    cue.material.diffuseColor = new Color3(0.8, 0.6, 0.4)

    const cueParent = MeshBuilder.CreateBox('box', {
      width: 0.5,
      height: 0.5,
      depth: 0.5
    }, scene)
    cueParent.position.set(0, 2, 0)

    cue.parent = cueParent
    cue.isVisible = false // 初始隐藏
    cueParent.isVisible = false // 初始隐藏

    return {
      cueParent,
      cue
    }
  }

  // 创建动画,90 / 60 = 1.5s,90帧,60帧每秒,1.5秒完成动画
  const createCueAnimation = (cueTemp, direction) => {
    const keys: any = []
    keys.push({
      frame: 0,
      value: cueTemp.position.clone()
    })
    keys.push({
      frame: 45,
      value: cueTemp.position.add(direction.scale(1)) // 沿着direction方向前进 1 单位
    })
    keys.push({
      frame: 90,
      value: cueTemp.position // 后退 1 单位(即返回原位置)
    })

    if(!cueAnimation) { // 防止重复new
      cueAnimation = new Animation(
        'boxAnimation',
        'position',
        60, // 帧率
        Animation.ANIMATIONTYPE_VECTOR3,
        Animation.ANIMATIONLOOPMODE_CYCLE
      )
      cueTemp.animations.push(cueAnimation)
    }

    cueAnimation.setKeys(keys)
   
    scene.beginAnimation(cueTemp, 0, 90, true) // 设置true为循环播放
  }

  const addImpulse = () => {
    timer = requestAnimationFrame(addImpulse)
    if (impulse.value >= 2.5) {
      invert = -1
    } else if (impulse.value <= 0) {
      invert = 1
    }

    impulse.value += 0.05 * invert
    impulse.value = Number(impulse.value.toFixed(2))
  }


  const runAnimate = () => {
    engine.runRenderLoop(function() {
      if (scene && scene.activeCamera) {
        scene.render()

        fps.value = engine.getFps().toFixed(2)
      }
    })
  }

  createAxis()
  createGui()
  createLight()
  createTable()
  createBall()
  createPocket()
  createTableWall()
  const { cue, cueParent } = createCue()
  runAnimate()


  scene.onPointerObservable.add(pointerInfo => {
    if (pointerInfo.type === PointerEventTypes.POINTERDOWN) {
      const pickedMesh = pointerInfo.pickInfo?.pickedMesh
      const hit = pointerInfo.pickInfo?.hit
      if (hit && pickedMesh?.name?.includes('ball')) {
        isAiming = true

        clickBall = pickedMesh

        // 禁止camera转动
        camera.detachControl()

        // 点击到球体的xyz位置
        const clickPosition = pickedMesh.position

        // 相机位置
        const cameraPosition = camera.position.clone()

        // 设置球杆的位置
        const middlePosition = cameraPosition.add(clickPosition).scale(0.5)
        cueParent.position = middlePosition

        // lookAt使得球杆对着球体
        cueParent.lookAt(clickPosition)

        // 计算看向球体的方向向量,click减去camera,方向指向click
        const direction = clickPosition.subtract(cameraPosition).normalize()
        
        cue.isVisible = true

        createCueAnimation(cueParent, direction)

        impulse.value = 0
        addImpulse()
      }
    }

    if (pointerInfo.type === PointerEventTypes.POINTERUP) {
      const pick: any = pointerInfo.pickInfo?.pickedMesh
      const hit = pointerInfo.pickInfo?.hit
      if (isAiming && hit && pick?.name === 'cue') {

        isAiming = false

        // 使用 scene.pickWithRay 获取鼠标点击的射线与球体的交点
        const x = scene.pointerX
        const y = scene.pointerY
        const m =  Matrix.Identity()
        const pickResult = scene.pickWithRay(scene.createPickingRay(x, y, m, camera))
        // 计算施加冲量的方向:从球体中心到点击位置的方向
        const impulseDirection = pickResult?.pickedPoint?.subtract(clickBall.position).normalize()
        const resultImpulse = impulseDirection?.scale(-impulse.value)
        clickBall.customAggregate.body.applyImpulse(resultImpulse, pickResult?.pickedPoint)
      }

      // 开启camera转动
      camera.attachControl()

      cue.isVisible = false

      cancelAnimationFrame(timer)
      timer = null
    }
  })

  // 游戏逻辑
  scene.registerBeforeRender(function() {
  	// 检查球是否落入球袋
  	for (let i = 0; i < pockets.length; i++) {
  		for (let j = 0; j < balls.length; j++) {
  			const distance = BABYLON.Vector3.Distance(pockets[i].position, balls[j].position)
  			if (distance < 0.5) {
  				balls[j].dispose()
  				balls.splice(j, 1)
  				j--
  			}
  		}
  	}

  	// 检查游戏是否结束
  	if (balls.length === 0) {
      gameOver.value = '游戏结束'
  	}
  })

  return {
    scene,
    engine, 
  }
}

const destroy = () => {
  if (sceneResources) {
    sceneResources.engine.stopRenderLoop() 
    sceneResources.engine.dispose()
    sceneResources.scene.dispose()
    sceneResources = null
  }
  if (adt) {
    adt.dispose()
    adt = null
  }
  balls = []
  pockets = []
  cueAnimation = null
  clickBall = null
  impulse.value = 0
}

onMounted(async() => {
  await nextTick()
})

onUnmounted(() => {
  destroy()
})
</script>