Skip to content

包围盒与世界矩阵转换

这是一个可以直接在 VitePress 页面中运行的 Three.js 示例。上方显示运行效果,下方展示对应源码。

运行效果

原理

包围盒通常用来描述模型在空间中占据的范围。Three.js 中常用 Box3 表示包围盒,用 Box3Helper 将包围盒可视化。

几何体自身的 geometry.boundingBox 属于局部空间,它只描述顶点在几何体本地坐标系中的范围。模型被加入场景后,位置、旋转、缩放以及父级变换都会记录在 matrixWorld 中。要把局部包围盒转换为世界空间包围盒,可以复制局部包围盒后调用 applyMatrix4(object.matrixWorld)

这个示例加载 public/models/ironman_mk44.glb,遍历模型中的 Mesh,将每个 Mesh 的局部包围盒通过世界矩阵转换到世界空间,再用 union 合并成整个模型的世界包围盒。

源码

js
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'
import { GUI } from 'three/addons/libs/lil-gui.module.min.js'

const canvas = document.querySelector('canvas')
const demo = canvas.parentElement

const scene = new THREE.Scene()
scene.background = new THREE.Color('#111827')

const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000)
camera.position.set(0, 2.5, 6)

const renderer = new THREE.WebGLRenderer({
	canvas,
	antialias: true
})

renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.setSize(window.innerWidth, window.innerHeight)

const ambientLight = new THREE.AmbientLight('#ffffff', 0.9)
scene.add(ambientLight)

const directionalLight = new THREE.DirectionalLight('#ffffff', 2)
directionalLight.position.set(4, 6, 5)
scene.add(directionalLight)

const gridHelper = new THREE.GridHelper(8, 8)
scene.add(gridHelper)

const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true

const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('/draco/')

const gltfLoader = new GLTFLoader()
gltfLoader.setDRACOLoader(dracoLoader)

let model = null
let boxHelper = null
const boundingSphere = new THREE.Sphere()
const sphereGeometry = new THREE.SphereGeometry(1, 32, 16)
const sphereMaterial = new THREE.MeshBasicMaterial({
	color: '#38bdf8',
	wireframe: true,
	transparent: true,
	opacity: 0.45
})
const sphereHelper = new THREE.Mesh(sphereGeometry, sphereMaterial)
sphereHelper.visible = false
scene.add(sphereHelper)

const worldBox = new THREE.Box3()
const transformedBox = new THREE.Box3()
const tempBox = new THREE.Box3()

const guiState = {
	positionX: 0,
	positionY: 0,
	positionZ: 0,
	rotationY: 0,
	scale: 1,
	updateWorldMatrix: true,
	showBox: true,
	showSphere: false
}

const updateWorldBoxByMatrix = () => {
	if (!model) {
		return
	}

	model.updateMatrixWorld(true)
	worldBox.makeEmpty()

	model.traverse((object) => {
		if (!object.isMesh || !object.geometry) {
			return
		}

		if (!object.geometry.boundingBox) {
			object.geometry.computeBoundingBox()
		}

		// 几何体自己的 boundingBox 是局部空间数据。
		// copy 后应用 object.matrixWorld,就能把局部包围盒转换到世界空间。
		tempBox.copy(object.geometry.boundingBox).applyMatrix4(object.matrixWorld)
		worldBox.union(tempBox)
	})

	transformedBox.copy(worldBox)
	boxHelper.box.copy(transformedBox)
	transformedBox.getBoundingSphere(boundingSphere)
	sphereHelper.position.copy(boundingSphere.center)
	sphereHelper.scale.setScalar(boundingSphere.radius)
	sphereHelper.visible = guiState.showSphere
}

const updateModelTransform = () => {
	if (!model) {
		return
	}

	model.position.set(guiState.positionX, guiState.positionY, guiState.positionZ)
	model.rotation.y = THREE.MathUtils.degToRad(guiState.rotationY)
	model.scale.setScalar(guiState.scale)

	if (guiState.updateWorldMatrix) {
		updateWorldBoxByMatrix()
	}
}

gltfLoader.load('/models/ironman_mk44.glb', (gltf) => {
	model = gltf.scene
	scene.add(model)

	transformedBox.setFromObject(model)
	boxHelper = new THREE.Box3Helper(transformedBox, '#facc15')
	scene.add(boxHelper)
	updateWorldBoxByMatrix()
})

const gui = new GUI({
	title: '包围盒控制',
	autoPlace: false
})

gui.domElement.classList.add('three-gui')
demo.appendChild(gui.domElement)

const positionFolder = gui.addFolder('位移')
positionFolder.add(guiState, 'positionX', -3, 3, 0.05).name('X').onChange(updateModelTransform)
positionFolder.add(guiState, 'positionY', -3, 3, 0.05).name('Y').onChange(updateModelTransform)
positionFolder.add(guiState, 'positionZ', -3, 3, 0.05).name('Z').onChange(updateModelTransform)

gui.add(guiState, 'rotationY', -180, 180, 1).name('Y 旋转').onChange(updateModelTransform)
gui.add(guiState, 'scale', 0.2, 2.5, 0.05).name('缩放').onChange(updateModelTransform)
gui
	.add(guiState, 'updateWorldMatrix')
	.name('更新世界矩阵')
	.onChange((value) => {
		if (value) {
			updateWorldBoxByMatrix()
		}
	})
gui
	.add(guiState, 'showBox')
	.name('显示包围盒')
	.onChange((value) => {
		if (boxHelper) {
			boxHelper.visible = value
		}
	})
gui
	.add(guiState, 'showSphere')
	.name('显示包围球')
	.onChange((value) => {
		updateWorldBoxByMatrix()
		sphereHelper.visible = value
	})

function animate() {
	requestAnimationFrame(animate)

	controls.update()
	renderer.render(scene, camera)
}

animate()