Skip to content

几何顶点变换

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

运行效果

原理

Three.js 中的几何体顶点数据保存在 geometry.attributes.position 中。mesh.positionmesh.rotationmesh.scale 改变的是对象自身的变换矩阵,而 geometry.translate()geometry.rotateX()geometry.scale() 改变的是几何体内部的顶点坐标。

这意味着几何顶点变换会真正改写几何体数据。示例中每次调整 GUI 都会从原始几何体复制一份顶点数据,然后依次应用位移、旋转、缩放,并重新计算法向量和顶点颜色。

示例中的黄色小球表示顶点位置,顶点颜色会根据当前顶点坐标变化,方便观察顶点数据被变换后的分布。

源码

js
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.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.8, 6)

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

renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(window.innerWidth, window.innerHeight)

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

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

const baseGeometry = new THREE.BoxGeometry(1.4, 1.4, 1.4, 2, 2, 2)
const geometry = baseGeometry.clone()

const material = new THREE.MeshStandardMaterial({
	vertexColors: true,
	roughness: 0.45,
	metalness: 0.08
})

const wireframeMaterial = new THREE.MeshBasicMaterial({
	color: '#e5e7eb',
	wireframe: true,
	transparent: true,
	opacity: 0.28
})

const mesh = new THREE.Mesh(geometry, material)
const wireframeMesh = new THREE.Mesh(geometry, wireframeMaterial)
scene.add(mesh)
scene.add(wireframeMesh)

const vertexMarkerGeometry = new THREE.SphereGeometry(0.045, 12, 8)
const vertexMarkerMaterial = new THREE.MeshBasicMaterial({ color: '#facc15' })
const vertexMarkers = []
const vertexMarkerGroup = new THREE.Group()
scene.add(vertexMarkerGroup)

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

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

const guiState = {
	translateX: 0,
	translateY: 0,
	translateZ: 0,
	rotateX: 0,
	rotateY: 0,
	rotateZ: 0,
	scaleX: 1,
	scaleY: 1,
	scaleZ: 1,
	showVertexMarkers: true,
	reset: () => {
		Object.assign(guiState, {
			translateX: 0,
			translateY: 0,
			translateZ: 0,
			rotateX: 0,
			rotateY: 0,
			rotateZ: 0,
			scaleX: 1,
			scaleY: 1,
			scaleZ: 1
		})

		gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
		updateGeometry()
	}
}

const applyVertexColors = () => {
	const position = geometry.getAttribute('position')
	const colors = []
	const color = new THREE.Color()

	for (let index = 0; index < position.count; index += 1) {
		const x = position.getX(index)
		const y = position.getY(index)
		const z = position.getZ(index)

		// 用顶点当前位置生成颜色,便于观察几何顶点数据变换后的分布。
		color.setRGB((x + 2.5) / 5, (y + 2.5) / 5, (z + 2.5) / 5)
		colors.push(color.r, color.g, color.b)
	}

	geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3))
}

const syncVertexMarkers = () => {
	const position = geometry.getAttribute('position')

	while (vertexMarkers.length < position.count) {
		const marker = new THREE.Mesh(vertexMarkerGeometry, vertexMarkerMaterial)

		vertexMarkers.push(marker)
		vertexMarkerGroup.add(marker)
	}

	vertexMarkers.forEach((marker, index) => {
		marker.visible = guiState.showVertexMarkers && index < position.count

		if (index < position.count) {
			marker.position.set(position.getX(index), position.getY(index), position.getZ(index))
		}
	})
}

const updateGeometry = () => {
	geometry.copy(baseGeometry)

	// 这里改变的是几何体顶点数据,不是 mesh.position、mesh.rotation 或 mesh.scale。
	geometry.translate(guiState.translateX, guiState.translateY, guiState.translateZ)
	geometry.rotateX(THREE.MathUtils.degToRad(guiState.rotateX))
	geometry.rotateY(THREE.MathUtils.degToRad(guiState.rotateY))
	geometry.rotateZ(THREE.MathUtils.degToRad(guiState.rotateZ))
	geometry.scale(guiState.scaleX, guiState.scaleY, guiState.scaleZ)
	geometry.computeVertexNormals()
	applyVertexColors()
	syncVertexMarkers()
}

const gui = new GUI({
	title: '顶点变换',
	autoPlace: false
})

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

const translateFolder = gui.addFolder('位移')
translateFolder.add(guiState, 'translateX', -2, 2, 0.05).name('X').onChange(updateGeometry)
translateFolder.add(guiState, 'translateY', -2, 2, 0.05).name('Y').onChange(updateGeometry)
translateFolder.add(guiState, 'translateZ', -2, 2, 0.05).name('Z').onChange(updateGeometry)

const rotateFolder = gui.addFolder('旋转')
rotateFolder.add(guiState, 'rotateX', -180, 180, 1).name('X').onChange(updateGeometry)
rotateFolder.add(guiState, 'rotateY', -180, 180, 1).name('Y').onChange(updateGeometry)
rotateFolder.add(guiState, 'rotateZ', -180, 180, 1).name('Z').onChange(updateGeometry)

const scaleFolder = gui.addFolder('缩放')
scaleFolder.add(guiState, 'scaleX', 0.2, 2.5, 0.05).name('X').onChange(updateGeometry)
scaleFolder.add(guiState, 'scaleY', 0.2, 2.5, 0.05).name('Y').onChange(updateGeometry)
scaleFolder.add(guiState, 'scaleZ', 0.2, 2.5, 0.05).name('Z').onChange(updateGeometry)

gui
	.add(guiState, 'showVertexMarkers')
	.name('显示顶点')
	.onChange(syncVertexMarkers)

gui.add(guiState, 'reset').name('重置')

updateGeometry()

function animate() {
	requestAnimationFrame(animate)

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

animate()