保龄球小游戏(threejs实现)

博主最近利用业余时间在学习webgl及threejs,这篇文章主要记录学习成果,利用一个小游戏的实现介绍一下threejs的主要知识点,不足之处请指正。

1. 初始化场景、相机、渲染器

场景(scene)和相机(camera)是threejs中两个非常重要的概念,相关概念官方文档介绍的很详细,简单来说,场景是装一切物体的一个对象,相机是用什么视角来展示场景的一个对象。

渲染器(renderer)用来将场景和相机渲染到html中

  scene = new Scene()
  camera = new PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
  camera.position.set(0, 10, 18)
  camera.lookAt(0, 5, 0)
  renderer = new WebGLRenderer({ antialias: true, canvas })
  // 根据设备像素比决定渲染的像素,贴图不模糊
  const { clientWidth, clientHeight } = renderer.domElement
  renderer.setSize(clientWidth * devicePixelRatio, clientHeight * devicePixelRatio, false)
  renderer.shadowMap.enabled = true
  renderer.setClearColor(0xbfd1e5)

2. 创建光源

场景中创建的物体是由不同的几何体(geometry)和材质(material)组合成的网格(mesh),有些材质需要光源(light)才能显示。

threejs中的五种基础材质:

MeshBasicMaterial(网格基础材质):基础材质,用于给几何体赋予一种简单的颜色,或者显示几何体的线框。
MeshDepthMaterial(网格深度材质): 这个材质使用从摄像机到网格的距离来决定如何给网格上色。
MeshLambertMaterial(网格 Lambert 材质): 这是一种考虑光照影响的材质,用于创建暗淡的、不光亮的物体。
MeshNormalMaterial(网格法向材质):这是一种简单的材质,根据法向向量计算物体表面的颜色。
MeshPhongMaterial(网格 Phong 式材质):这是一种考虑光照影响的材质,用于创建光亮的物体。

  // 创建点光源
  const pointLight = new PointLight(0xffffff, 200, 100)
  pointLight.position.set(0, 8, 1)
  pointLight.castShadow = true // default false 阴影
  scene.add(pointLight)

3. 初始化刚体

刚体就相当于现实生活中的物体(实体)一样 例如:桌子、板凳、大树、乒乓球等。

cannon.js是一个3d物理引擎,它能实现常见的碰撞检测,各种体形,接触,摩擦和约束功能。

cannon.js更轻量级、更小的文件大小。

在使用cannon.js时通常会与其它3d库(如threejs)同时使用,因为cannon.js就和后端差不多只负责数据,3d库则负责展示效果。

cannon-es.js是基于cannon.js并且长期维护的版本。

// 初始化刚体,刚体就相当于现实生活中的物体(实体)一样 例如:桌子、板凳、大树、乒乓球等
const initCannon = () => {
  world = new CANNON.World() //初始化一个CANNON对象
  // 设置CANNON的引力  相当与地球的引力(您可以x轴也可以设置y轴或者z轴 负数则会向下掉,正数则向上)
  world.gravity.set(0, -9.82, 0)

  /**
   * 设置两种材质相交时的效果  相当于设置两种材质碰撞时应该展示什么样的效果 例如:篮球在地板上反弹
   */
  //创建一个接触材质
  const concretePlasticMaterial = new CANNON.ContactMaterial(
    concreteMaterial, //材质1
    plasticMaterial, //材质2
    {
      friction: 0.1, //摩擦力
      restitution: 0.7 //反弹力
    }
  )
  const plasticPlasticMaterial = new CANNON.ContactMaterial(
    plasticMaterial, //材质1
    plasticMaterial, //材质1
    {
      friction: 0.1, //摩擦力
      restitution: 0.7 //反弹力
    }
  )
  //添加接触材质配置
  world.addContactMaterial(concretePlasticMaterial)
  world.addContactMaterial(plasticPlasticMaterial)
}

4. 创建地板及其刚体

// 创建地板
const initGround = () => {
  let texture = new TextureLoader().load(groundImg)
  texture.wrapS = texture.wrapT = RepeatWrapping
  // texture.repeat.set(1, 4)
  // let geometry = new BoxGeometry(groundSize.x, groundSize.y, groundSize.z)
  let geometry = new PlaneGeometry(groundSize.x, groundSize.z)
  // 需要添加光
  let material = new MeshPhongMaterial({
    map: texture
  })
  let mesh = new Mesh(geometry, material)
  mesh.rotation.x = -Math.PI / 2
  // mesh.position.y = -0.1
  mesh.receiveShadow = true
  mesh.name = 'ground'
  scene.add(mesh)
  // let groundGeom = new BoxGeometry(40, 0.2, 40)
  // let groundMate = new MeshPhongMaterial({
  //   color: 0xdddddd
  // })
  // let ground = new Mesh(groundGeom, groundMate)
  // ground.position.y = -0.1
  // ground.receiveShadow = true
  // scene.add(ground) //step 5 添加地面网格

  /**
   * 创建地板刚体
   */
  const floorBody = new CANNON.Body()
  floorBody.mass = 0 //质量  质量为0时表示该物体是一个固定的物体即不可破坏
  floorBody.addShape(new CANNON.Plane()) //设置刚体的形状为CANNON.Plane 地面形状
  floorBody.material = concreteMaterial //设置材质
  // 由于平面初始化是是竖立着的,所以需要将其旋转至跟现实中的地板一样 横着
  // 在cannon.js中,我们只能使用四元数(Quaternion)来旋转,可以通过setFromAxisAngle(…)方法,第一个参数是旋转轴,第二个参数是角度
  floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(-1, 0, 0), Math.PI * 0.5)
  world.addBody(floorBody)
}

5. 创建墙及其刚体

墙体使用用贴图(texture)。

// 创建墙
const initWall = () => {
  let texture = new TextureLoader().load(wallImg)
  texture.wrapS = texture.wrapT = RepeatWrapping
  // texture.repeat.set(1, 4)
  let geometry = new PlaneGeometry(groundSize.x, 10)
  // 需要添加光
  let material = new MeshPhongMaterial({
    map: texture
  })
  let mesh = new Mesh(geometry, material)
  mesh.position.z = -groundSize.x / 2
  mesh.position.y = 5
  mesh.receiveShadow = true
  scene.add(mesh)

  /**
   * 创建地板刚体
   */
  const floorBody = new CANNON.Body()
  floorBody.mass = 0 //质量  质量为0时表示该物体是一个固定的物体即不可破坏
  floorBody.addShape(new CANNON.Plane()) //设置刚体的形状为CANNON.Plane 地面形状
  floorBody.position.z = -groundSize.x / 2
  floorBody.material = concreteMaterial //设置材质
  world.addBody(floorBody)
}

6. 创建保龄球及其刚体

在3d编程中,对于复杂的物体需要使用建模软件创建模型,这里使用gltf/glb格式的模型,对于gltf/glb格式的模型需要gltf加载器,glb是gltf的二进制格式,如果是使用draco压缩的模型,还需要draco加载器解压模型。

// Draco

const dracoLoader = new DRACOLoader()

dracoLoader.setDecoderPath('/draco/')

dracoLoader.setDecoderConfig({ type: 'js' })

// gltf

const loader = new GLTFLoader()

loader.setDRACOLoader(dracoLoader)

// 加载模型
const loadModel = (glb, callback) => {
  loader.load(
    glb,
    function (gltf) {
      const model = gltf.scene
      // scene.add(model)
      callback(model)
    },
    undefined,
    function (error) {
      console.error(error)
    }
  )
}

加载模型并添加到场景中。

// 加载球体
const initSphere = () => {
  loadModel('/models/bowlingBall/base.glb', (model) => {
    scene.add(model)
    // model.scale.set(0.7, 0.7, 0.7)
    const box = new Box3().setFromObject(model)
    const x = box.max.x - box.min.x
    // const y = box.max.y - box.min.y
    // const z = box.max.z - box.min.z
    sphereRadius = x / 2
    spherePosition = new CANNON.Vec3(0, sphereRadius, groundSize.z / 2 - sphereRadius)
    const material = new MeshPhongMaterial({
      color: 0xffff00,
      specular: 0x7777ff // 高光颜色
    })
    model.traverse(function (child) {
      if (child.isMesh) {
        child.castShadow = true
        child.material = material
        bowlingBall = child
      }
    })
    creatSphereBody()
  })
}
/**
 *创建球体刚体
 */
const creatSphereBody = () => {
  sphereBody = new CANNON.Body({
    mass: 10, //质量
    position: spherePosition, //位置
    //刚体的形状。 CANNON.Sphere为圆球体  CANNON.Box为立方体 更多形状查看文档:https://pmndrs.github.io/cannon-es/docs/classes/Shape.html
    shape: new CANNON.Sphere(sphereRadius),
    //材质  材质决定了物体(刚体)的弹力和摩擦力,默认为null,无弹力无摩擦。 plastic为塑料材质  concrete为混泥土材质。相关文档地址:https://pmndrs.github.io/cannon-es/docs/classes/Material.html
    material: plasticMaterial
  })
  //添加外力,这有点类似于风力一样,在某个位置向某物吹风
  // 该方法接收两个参数:force:力的大小(Vec3)    relativePoint:相对于质心施加力的点(Vec3)。
  // sphereBody.applyForce(new CANNON.Vec3(100, 0, 0), sphereBody.position)
  const sounds = new Howl({
    src: [pinSound],
    loop: false
  })
  sphereBody.addEventListener('collide', (_event) => {
    sounds.play()
    // if (_event.body.mass === 0) {
    // }
  })
  world.addBody(sphereBody) //向world中添加刚体信息
}

7. 创建瓶体及其刚体

// 建在瓶体
const initCylinder = () => {
  loadModel('/models/bowlingPin/base.glb', (model) => {
    // model.scale.set(0.5, 0.5, 0.5)
    const box = new Box3().setFromObject(model)
    const x = box.max.x - box.min.x
    // const y = box.max.y - box.min.y
    const z = box.max.z - box.min.z
    // 这里需要根据模型的初始状态来设置半径和高度,然后设置对应的刚体尺寸和位置
    pinBodyHeight = z
    pinBodyRadius = x / 2
    pinBodyPosition.y = pinBodyHeight / 2
    model.position.y = z / 2 // 由于几何体要旋转一下,所以取z轴的一半

    const material = new MeshPhongMaterial({
      color: 0xffffff,
      specular: 0x7777ff // 高光颜色
    })
    const redMaterial = new MeshPhongMaterial({
      color: 'red'
    })

    model.traverse(function (child) {
      if (child.isMesh) {
        child.castShadow = true
        child.material = child.name === 'shadeRed' ? redMaterial : material
        // 设置相对位置为0,旋转起来才没有问题
        child.position.set(0, 0, 0)
        child.rotation.x = -Math.PI / 2
      }
    })
    // bowlingPin = model
    // scene.add(bowlingPin)
    // bowlingPin = model
    // creatPinBody()
    generatePin(model)
  })
}
// 批量生成瓶子和对应刚体
const generatePin = (model, widthCount = 5) => {
  const heightCount = widthCount
  for (let i = 0; i < heightCount; i++) {
    pinBodyPosition.z = i - 5
    for (let j = 0; j < widthCount; j++) {
      const newModel = model.clone()
      pinBodyPosition.x = j - widthCount / 2
      originalPositionArray.push(pinBodyPosition.clone())
      bowlingPinArray.push(newModel)
      scene.add(newModel)
      creatPinBody()
    }
    widthCount--
  }
}
/**
 * 创建瓶体刚体
 */
const creatPinBody = () => {
  pinBody = new CANNON.Body({
    mass: 0.2,
    position: pinBodyPosition,
    shape: new CANNON.Cylinder(pinBodyRadius, pinBodyRadius, pinBodyHeight, 10),
    material: plasticMaterial
  })
  pinBodyArray.push(pinBody)
  world.addBody(pinBody)
}

8. 连续渲染

利用requestAnimationFrame函数连续渲染场景,根据刚体的位置更新球体和瓶体的位置及方向。

// 连续渲染
const animate = () => {
  requestAnimationFrame(animate)
  world.fixedStep() //动态更新world
  // world.step(1 / 60) //动态更新world
  // sphere.position.copy(sphereBody.position) //设置threejs中的球体位置
  bowlingBall?.position.copy(sphereBody.position)
  bowlingBall?.quaternion.copy(sphereBody.quaternion)
  for (let index = 0; index < pinBodyArray.length; index++) {
    const pinBody = pinBodyArray[index]
    pinBody && bowlingPinArray[index]?.position.copy(pinBody.position)
    pinBody && bowlingPinArray[index]?.quaternion.copy(pinBody.quaternion)
  }
  // controls.update()
  renderer.render(scene, camera)
}

9. 鼠标控制保龄球位置并且发射

threejs中Raycaster类用于计算鼠标在3d场景中与物体的交点,找到鼠标与地板的交点,然后将保龄球刚体的位置设置为该交点,即可改变保龄球在地板上的位置,给保龄球添加力量即可发射出去。

// 鼠标选择
const raycaster = new Raycaster()
const pointer = new Vector2()

// 发射
const shoot = () => {
  sphereBody.applyForce(new CANNON.Vec3(0, 1000, 0), spherePosition)
}

let bowling = null
let mousedown = false
const hanleMouseDown = () => {
  mousedown = true
  if (bowling) {
    canvas.style.cursor = 'grabbing'
  }
}
const hanleMouseUp = () => {
  mousedown = false
  canvas.style.cursor = 'default'
  if (bowling) {
    shoot()
  }
}
const hanleMouseMove = ({ clientX, clientY }) => {
  // 消除左侧边栏的影响(如果有左侧菜单)
  const [x, y] = [clientX - canvas.getBoundingClientRect().left, clientY]
  // 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1 to +1)
  const { width, height } = renderer.domElement
  pointer.set((x / width) * 2 - 1, -(y / height) * 2 + 1)
  // 基于鼠标点的裁剪坐标位和相机设置射线投射器
  raycaster.setFromCamera(pointer, camera)
  // 存放地板与鼠标的交点
  const groundPoint = new Vector3()
  // 计算射线和物体的交点
  const intersects = raycaster.intersectObjects(scene.children)
  let selected = false
  for (let index = 0; index < intersects.length; index++) {
    const inter = intersects[index]
    if (inter.object.name === 'shadePurple001') {
      selected = true
      bowling = inter
      canvas.style.cursor = 'grab'
    } else if (inter.object.name === 'ground') {
      groundPoint.copy(inter.point)
    }
  }
  if (selected) {
    // bowling.object.material.color.set(0xffffff)
    if (mousedown) {
      // sphereBody.position = new CANNON.Vec3(
      //   groundPoint.x,
      //   sphereRadius,
      //   groundPoint.z + sphereRadius
      // )
      sphereBody.position.x = groundPoint.x
    }
  } else {
    // bowling?.object.material.color.set(0xffff00)
    bowling = null
    canvas.style.cursor = 'default'
  }
}

10. 重置游戏

让球体和瓶体恢复到初始状态。

const reset = () => {
  sphereBody.position.copy(spherePosition)
  for (let index = 0; index < originalPositionArray.length; index++) {
    const position = originalPositionArray[index]
    pinBodyArray[index]?.position.copy(position)
    pinBodyArray[index]?.quaternion.copy(new CANNON.Quaternion())
    pinBodyArray[index]?.sleep()
  }
  sphereBody.sleep()
}

游戏截图:

完整代码在我的github仓库:https://github.com/gouxiwen/web3d/blob/main/src/views/three/bowling.vue

参考文章:

https://blog.csdn.net/qq_43641110/article/details/128971908

  • 25
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值