博主最近利用业余时间在学习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.youkuaiyun.com/qq_43641110/article/details/128971908