Skip to content

法向量和法线辅助器

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

运行效果

原理

法向量是垂直于表面方向的向量,通常用于光照计算。Three.js 的标准材质会根据法向量判断表面朝向光源的角度,从而计算明暗效果。如果法向量不正确,模型可能会出现光照异常、表面发黑或明暗方向不对。

GLTF 模型通常会自带法向量,数据保存在每个 Mesh 的 geometry.attributes.normal 中。如果某个几何体缺少法向量,可以调用 geometry.computeVertexNormals() 重新计算。

VertexNormalsHelper 是一个调试辅助器,它会读取目标 Mesh 的顶点法向量,并把每个顶点的法向量画成一段线。这个示例可以在 GUI 中切换 public/models 目录里的模型,加载后会遍历模型里的 Mesh,并为每个 Mesh 创建对应的法线辅助器。

源码

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 { VertexNormalsHelper } from 'three/addons/helpers/VertexNormalsHelper.js'
import { GUI } from 'three/addons/libs/lil-gui.module.min.js'

const canvas = document.querySelector('canvas')
const demo = canvas.parentElement
const loading = document.createElement('div')
loading.className = 'model-loading'
loading.innerHTML = '<span></span><p>模型加载中...</p>'
loading.hidden = true
demo.appendChild(loading)

// 浏览器不能直接读取 public/models 目录,所以这里用数组维护可选择模型。
const modelOptions = [
	{
		name: 'ironman.glb',
		path: '/models/ironman.glb'
	},
	{
		name: 'mega.glb',
		path: '/models/mega.glb'
	},
	{
		name: 'porsche_911.glb',
		path: '/models/porsche_911.glb'
	}
]

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, 1.8, 4.8)

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)
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap

const ambientLight = new THREE.AmbientLight('#ffffff', 1.25)
const skyLight = new THREE.HemisphereLight('#e0f2fe', '#fef3c7', 1.8)
const sunLight = new THREE.DirectionalLight('#fff8dc', 5.2)
sunLight.position.set(5, 8, 4)
sunLight.castShadow = true
sunLight.shadow.mapSize.set(2048, 2048)
sunLight.shadow.camera.near = 0.5
sunLight.shadow.camera.far = 30
sunLight.shadow.camera.left = -6
sunLight.shadow.camera.right = 6
sunLight.shadow.camera.top = 6
sunLight.shadow.camera.bottom = -6
scene.add(ambientLight, skyLight, sunLight)

const shadowPlane = new THREE.Mesh(
	new THREE.PlaneGeometry(10, 10),
	new THREE.ShadowMaterial({
		color: '#475569',
		opacity: 0.18
	})
)
shadowPlane.rotation.x = -Math.PI / 2
shadowPlane.receiveShadow = true
scene.add(shadowPlane)

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

const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.target.set(0, 0.8, 0)

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

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

let model = null
let mixer = null
let currentAction = null
let animationIndex = 0
let animationClips = []
let loadId = 0
const clock = new THREE.Clock()
const meshMaterials = new Set()
const normalRecords = new Map()
const normalsHelpers = []

const guiState = {
	model: modelOptions[0].name,
	removeNormals: false,
	showNormalsHelper: true,
	normalLength: 0.035,
	ambientIntensity: 1.25,
	wireframe: false,
	autoRotate: true
}

const forEachMaterial = (callback) => {
	meshMaterials.forEach((material) => {
		if (Array.isArray(material)) {
			material.forEach(callback)
		} else {
			callback(material)
		}
	})
}

const playAnimationByIndex = (index) => {
	if (!mixer || !animationClips.length) {
		return
	}

	const clip = animationClips[index]
	const action = mixer.clipAction(clip)

	if (currentAction) {
		currentAction.stop()
	}

	action.reset()
	action.setLoop(THREE.LoopOnce, 1)
	action.clampWhenFinished = true
	action.play()
	currentAction = action
}

const resetAnimations = () => {
	if (currentAction) {
		currentAction.stop()
		currentAction = null
	}

	if (mixer && model) {
		mixer.stopAllAction()
		mixer.uncacheRoot(model)
	}

	mixer = null
	animationIndex = 0
	animationClips = []
	clock.stop()
	clock.start()
}

const clearNormalsHelpers = () => {
	normalsHelpers.forEach((helper) => {
		scene.remove(helper)
		helper.dispose()
	})
	normalsHelpers.length = 0
	normalRecords.clear()
	meshMaterials.clear()
}

const removeCurrentModel = () => {
	resetAnimations()
	clearNormalsHelpers()

	if (!model) {
		return
	}

	scene.remove(model)
	model.traverse((object) => {
		object.geometry?.dispose()

		if (Array.isArray(object.material)) {
			object.material.forEach((material) => material.dispose())
		} else {
			object.material?.dispose()
		}
	})
	model = null
}

const updateNormalsState = () => {
	normalRecords.forEach((normalAttribute, geometry) => {
		if (guiState.removeNormals) {
			geometry.deleteAttribute('normal')
		} else {
			geometry.setAttribute('normal', normalAttribute)
		}
	})

	normalsHelpers.forEach((helper) => {
		// 移除法向量后,VertexNormalsHelper 没有 normal attribute 可以读取,需要一起隐藏。
		helper.visible = !guiState.removeNormals && guiState.showNormalsHelper
	})

	forEachMaterial((material) => {
		material.needsUpdate = true
	})
}

const updateNormalsLength = (value) => {
	normalsHelpers.forEach((helper) => {
		helper.size = value
		helper.update()
	})
}

const createNormalsHelpers = (target) => {
	target.traverse((object) => {
		if (!object.isMesh || !object.geometry) {
			return
		}

		// GLTF 模型通常自带 normal attribute;如果某个 Mesh 缺失,则现场计算一份。
		if (!object.geometry.getAttribute('normal')) {
			object.geometry.computeVertexNormals()
		}

		const normalAttribute = object.geometry.getAttribute('normal')
		normalRecords.set(object.geometry, normalAttribute)
		meshMaterials.add(object.material)

		const helper = new VertexNormalsHelper(object, guiState.normalLength, 0xff4d4f)
		helper.visible = guiState.showNormalsHelper
		scene.add(helper)
		normalsHelpers.push(helper)
	})
}

const moveModelCenterToOrigin = (target) => {
	const box = new THREE.Box3().setFromObject(target)
	const center = box.getCenter(new THREE.Vector3())

	target.position.sub(center)

	const fittedBox = new THREE.Box3().setFromObject(target)
	shadowPlane.position.y = fittedBox.min.y - 0.02
}

const loadModel = (modelName) => {
	const option = modelOptions.find((item) => item.name === modelName)

	if (!option) {
		return
	}

	const currentLoadId = loadId + 1
	loadId = currentLoadId
	loading.hidden = false
	loading.querySelector('p').textContent = '模型加载中...'
	removeCurrentModel()

	gltfLoader.load(
		option.path,
		(gltf) => {
			if (currentLoadId !== loadId) {
				return
			}

			model = gltf.scene
			moveModelCenterToOrigin(model)
			model.traverse((object) => {
				if (object.isMesh) {
					object.castShadow = true
					object.receiveShadow = true
				}
			})
			scene.add(model)
			createNormalsHelpers(model)
			updateNormalsState()

			// 按顺序播放模型自带的全部动画,最后一个结束后回到第一个。
			if (gltf.animations.length) {
				mixer = new THREE.AnimationMixer(model)
				animationClips = gltf.animations

				mixer.addEventListener('finished', () => {
					animationIndex = (animationIndex + 1) % animationClips.length
					playAnimationByIndex(animationIndex)
				})

				playAnimationByIndex(animationIndex)
			}

			loading.hidden = true
		},
		(event) => {
			if (currentLoadId !== loadId) {
				return
			}

			if (event.lengthComputable) {
				const progress = Math.round((event.loaded / event.total) * 100)
				loading.querySelector('p').textContent = `模型加载中... ${progress}%`
			}
		},
		() => {
			if (currentLoadId !== loadId) {
				return
			}

			loading.querySelector('p').textContent = '模型加载失败'
		}
	)
}

const gui = new GUI({
	title: '法向量控制',
	autoPlace: false
})

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

gui
	.add(guiState, 'model', modelOptions.map((item) => item.name))
	.name('模型')
	.onChange(loadModel)

gui
	.add(guiState, 'removeNormals')
	.name('移除法向量')
	.onChange(updateNormalsState)

gui
	.add(guiState, 'showNormalsHelper')
	.name('显示辅助器')
	.onChange(updateNormalsState)

gui
	.add(guiState, 'normalLength', 0.005, 0.12, 0.005)
	.name('法线长度')
	.onChange(updateNormalsLength)

gui
	.add(guiState, 'ambientIntensity', 0, 2, 0.05)
	.name('环境光')
	.onChange((value) => {
		ambientLight.intensity = value
	})

gui
	.add(guiState, 'wireframe')
	.name('线框')
	.onChange((value) => {
		forEachMaterial((material) => {
			material.wireframe = value
		})
	})

gui.add(guiState, 'autoRotate').name('自动旋转')
loadModel(guiState.model)

function animate() {
	requestAnimationFrame(animate)

	const delta = clock.getDelta()

	if (model && guiState.autoRotate) {
		model.rotation.y += 0.006
	}

	if (mixer) {
		mixer.update(delta)
	}

	if (!guiState.removeNormals) {
		normalsHelpers.forEach((helper) => helper.update())
	}

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

animate()