这个案例不仅会巩固大家对图形树的理解,还会告诉大家如何实现路径跟随和相机切换。
先看一下这个案例的图形树:
图形树
Scene 场景
-
camera 主相机
-
tank 坦克
-
tankCamera 坦克相机,随坦克而动
-
bodyMesh 车身模型
-
wheelMeshes 车轮模型
-
barrel 炮筒
- barrelMesh 炮筒模型
- barrelCamera 炮筒相机,随炮筒变换
-
-
target 目标点
-
targetBob 浮动节点-负责目标的上下浮动
-
targetMesh 目标模型
-
targetCameraPivot 目标相机旋转轴-带动目标相机的旋转
- targetCamera 目标相机
-
-
-
splineObject 坦克移动路径
-
groundMesh 地面
-
light 灯光
当前这个场景是简约而不简单的,其内部有许多图形嵌套,而且很多节点都是虚拟的Group对象。
Group对象就是方便模型管理和变换的,其具体用法我们后面详说。
这个场景的图形树中还嵌套着多个相机,里面有许多打镜头的套路,是非常值得我们学习的。
代码实现
1.利用之前封装的Stage 对象快速搭建场景。
const stage = new Stage()
const { scene, renderer } = stage
//背景色
renderer.setClearColor(0xaaaaaa)
//开启投影
renderer.shadowMap.enabled = true
2.建立一个静止不动的主相机,把stage的相机变成这个主相机。
const camera = makeCamera()
camera.position.set(16, 8, 20)
camera.lookAt(0, 0, 0)
stage.camera = camera
- makeCamera() 是我自定义的实例化透视相机的方法。
function makeCamera(fov = 40) {
const aspect = 2
const zNear = 0.1
const zFar = 1000
return new PerspectiveCamera(fov, aspect, zNear, zFar)
}
3.建立两道光。
{
// 平行光1
const light = new DirectionalLight(0xffffff, 1)
light.position.set(0, 20, 0)
scene.add(light)
light.castShadow = true
light.shadow.mapSize.width = 2048
light.shadow.mapSize.height = 2048
const d = 50
light.shadow.camera.left = -d
light.shadow.camera.right = d
light.shadow.camera.top = d
light.shadow.camera.bottom = -d
light.shadow.camera.near = 1
light.shadow.camera.far = 50
}
{
// 平行光2
const light = new DirectionalLight(0xffffff, 1)
light.position.set(1, 2, 4)
scene.add(light)
}
4.地面
const groundGeometry = new PlaneGeometry(50, 50)
const groundMaterial = new MeshPhongMaterial({ color: 0xcc8866 })
const groundMesh = new Mesh(groundGeometry, groundMaterial)
groundMesh.rotation.x = Math.PI * -0.5
groundMesh.receiveShadow = true
scene.add(groundMesh)
5.建立坦克Group,在其中放一个随坦克而动的相机。
// 坦克
const tank = new Group()
scene.add(tank)
// 坦克相机
const carRadius = 1
const bodyCenterY = (carRadius * 3) / 2
const tankCameraFov = 75
const tankCamera = makeCamera(tankCameraFov)
tankCamera.position.y = 5
tankCamera.position.z = -10
tankCamera.lookAt(0, bodyCenterY, 0)
tank.add(tankCamera)
6.建立坦克车身,放坦克Group里,这就是一个球。
const bodyGeometry = new SphereGeometry(carRadius)
const bodyMaterial = new MeshPhongMaterial({ color: 0x6688aa })
const bodyMesh = new Mesh(bodyGeometry, bodyMaterial)
bodyMesh.position.y = bodyCenterY
bodyMesh.castShadow = true
tank.add(bodyMesh)
7.建立两个车轱辘,放坦克Group里。
const wheelRadius = 0.6
const wheelThickness = 0.5
const wheelSegments = 8
const wheelGeometry = new CylinderGeometry(
wheelRadius,
wheelRadius,
wheelThickness,
wheelSegments
)
const wheelMaterial = new MeshPhongMaterial({ color: 0x888888 })
const cx = carRadius + wheelThickness / 2
const wheelMeshes = [-cx, cx].map((x) => {
const mesh = new Mesh(wheelGeometry, wheelMaterial)
mesh.rotation.z = Math.PI * 0.5
mesh.position.set(x, wheelRadius, 0)
mesh.castShadow = true
tank.add(mesh)
return mesh
})
8.建立炮筒Group,把炮筒模型和炮筒相机放进去,炮筒相机会随炮筒变换。
// 炮筒
const barrel = new Group()
barrel.position.y = bodyCenterY + 0.3
tank.add(barrel)
// 炮筒模型
const barrelSize = 0.3
const barrelLength = 5
const barrelGeometry = new BoxGeometry(barrelSize, barrelSize, barrelLength)
const barrelMesh = new Mesh(barrelGeometry, bodyMaterial)
barrelMesh.position.z = barrelLength / 2
barrelMesh.castShadow = true
barrel.add(barrelMesh)
// 炮管相机
const barrelCamera = makeCamera()
barrelCamera.position.y = 1.4
barrel.add(barrelCamera)
9.建立目标点Group,把目标点模型和目标相机放进去。
// 目标-负责目标的整体高度
const target = new Group()
target.position.z = 2
target.position.y = 4
scene.add(target)
// 浮动节点-负责目标的上下浮动
const targetBob = new Group()
target.add(targetBob)
// 目标模型
const targetGeometry = new SphereGeometry(0.5, 6, 3)
const targetMaterial = new MeshPhongMaterial({
color: 0x00ff00,
flatShading: true,
})
const targetMesh = new Mesh(targetGeometry, targetMaterial)
targetMesh.castShadow = true
targetBob.add(targetMesh)
// 目标相机
const targetCamera = makeCamera()
targetCamera.position.y = 1
targetCamera.position.z = -2
targetCamera.rotation.y = Math.PI
// 目标相机旋转轴-带动目标相机的旋转
const targetCameraPivot = new Group()
targetBob.add(targetCameraPivot)
targetCameraPivot.add(targetCamera)
之后在连续渲染的时候,目标点会通过targetBob 对象上下浮动。
targetBob.position.y = Math.sin(time * 2) * 2
在连续渲染方法里,目标相机会通过targetCameraPivot 对象进行旋转,看向坦克。
// 获取目标点的世界位
targetMesh.getWorldPosition(targetPosition)
// 炮筒指向目标点
barrel.lookAt(targetPosition)
重点解释一下,我们为什么不直接让目标相机targetCamera看向坦克,而是让一个包裹了相机的targetCameraPivot 看向坦克。
首先,我想要的效果是让目标相机站在目标点后面看向坦克,这样我既能看见一部分目标点,也能看见坦克,效果如下:
这样我就需要把相机视点放在目标点后面:
const targetCamera = makeCamera()
//设置目标点位置
targetCamera.position.y = 1
targetCamera.position.z = -2
//让相机视线朝向z轴正方向,默认相机视线朝向-z
targetCamera.rotation.y = Math.PI
来个俯视的示意图:
接下来,我们若直接让相机看向坦克,那目标点就会移出相机视口:
所以我需要在相机外面再包裹一个Group对象,用Group对象的旋转,带动相机绕目标点旋转:
10.建立一条路径,之后会让坦克沿此路径移动。
//坦克移动路径
const curve = new SplineCurve([
new Vector2(-6, 5),
new Vector2(-6, -4),
new Vector2(8, 0),
new Vector2(-6, 12),
new Vector2(-6, 5),
])
const points = curve.getPoints(50)
const geometry = new BufferGeometry().setFromPoints(points)
const material = new LineBasicMaterial({ color: 0xff0000 })
const splineObject = new Line(geometry, material)
splineObject.rotation.x = Math.PI * 0.5
splineObject.position.y = 0.05
scene.add(splineObject)
11.提前声明好三个向量。
// 坦克位置
const tankPosition = new Vector2()
// 坦克朝向
const tankTarget = new Vector2()
// 目标位
const targetPosition = new Vector3()
- tankPosition和tankTarget之后会从curve路径中获取。
- targetPosition 是暂存目标对象的世界位的。
12.用GUI切换相机。
const gui = new GUI({ autoPlace: false })
const cameras: Map<string, PerspectiveCamera> = new Map([
["camera", camera],
["barrelCamera", barrelCamera],
["targetCamera", targetCamera],
["tankCamera", tankCamera],
])
const curCamera = { name: "camera" }
gui.add(curCamera, "name", [...cameras.keys()]).onChange((key: string) => {
const {
domElement: { clientWidth, clientHeight },
} = renderer
const cam = cameras.get(key)
if (cam) {
stage.camera = cam
stage.camera.aspect = clientWidth / clientHeight
stage.camera.updateProjectionMatrix()
}
})
13.在连续渲染的时候,让坦克、目标对象和相机动起来。
// 渲染之前
stage.beforeRender = (time = 0) => {
time *= 0.001
// 坦克移动插值
const tankTime = time * 0.1
// 坦克位置
curve.getPointAt(tankTime % 1, tankPosition)
// 坦克朝向
curve.getPointAt((tankTime + 0.01) % 1, tankTarget)
// 设置坦克位置
tank.position.set(tankPosition.x, 0, tankPosition.y)
// 设置坦克朝向
tank.lookAt(tankTarget.x, 0, tankTarget.y)
// 车轱辘的滚动
wheelMeshes.forEach((obj) => {
obj.rotation.x = time * 3
})
// 目标对象的上下浮动
targetBob.position.y = Math.sin(time * 2) * 2
// 获取目标点的世界位
targetMesh.getWorldPosition(targetPosition)
// 炮筒指向目标点
barrel.lookAt(targetPosition)
if (curCamera.name === "barrelCamera") {
// 炮筒相机看向目标点
barrelCamera.lookAt(targetPosition)
} else if (curCamera.name === "targetCamera") {
// 目标相机看向坦克
tank.getWorldPosition(targetPosition)
targetCameraPivot.lookAt(targetPosition)
}
}
上面的坦克在做移动的时候,是基于一个取值范围在[0,1]间的插值 ,用curve 的getPointAt() 方法获取的点位。
关于坦克案例,咱们就说到这,下节课我们会说一下材质。