法向量和法线辅助器
这是一个可以直接在 VitePress 页面中运行的 Three.js 示例。上方显示运行效果,下方展示对应源码。
运行效果
原理
法向量是垂直于表面方向的向量,通常用于光照计算。Three.js 的标准材质会根据法向量判断表面朝向光源的角度,从而计算明暗效果。如果法向量不正确,模型可能会出现光照异常、表面发黑或明暗方向不对。
GLTF 模型通常会自带法向量,数据保存在每个 Mesh 的 geometry.attributes.normal 中。如果某个几何体缺少法向量,可以调用 geometry.computeVertexNormals() 重新计算。
VertexNormalsHelper 是一个调试辅助器,它会读取目标 Mesh 的顶点法向量,并把每个顶点的法向量画成一段线。这个示例加载 public/models/ironman_mk44.glb,遍历模型里的 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 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)
// 环境光提供整体基础亮度,避免背光面完全变黑。
const ambientLight = new THREE.AmbientLight('#ffffff', 0.9)
scene.add(ambientLight)
const directionalLight = new THREE.DirectionalLight('#ffffff', 2.2)
directionalLight.position.set(4, 5, 5)
scene.add(directionalLight)
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
const meshMaterials = new Set()
const normalRecords = new Map()
const normalsHelpers = []
const guiState = {
removeNormals: false,
showNormalsHelper: true,
normalLength: 0.035,
ambientIntensity: 0.9,
wireframe: false,
autoRotate: true
}
const forEachMaterial = (callback) => {
meshMaterials.forEach((material) => {
if (Array.isArray(material)) {
material.forEach(callback)
} else {
callback(material)
}
})
}
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)
})
}
gltfLoader.load('/models/ironman_mk44.glb', (gltf) => {
model = gltf.scene
scene.add(model)
createNormalsHelpers(model)
updateNormalsState()
})
const gui = new GUI({
title: '法向量控制',
autoPlace: false
})
gui.domElement.classList.add('three-gui')
demo.appendChild(gui.domElement)
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('自动旋转')
function animate() {
requestAnimationFrame(animate)
if (model && guiState.autoRotate) {
model.rotation.y += 0.006
}
if (!guiState.removeNormals) {
normalsHelpers.forEach((helper) => helper.update())
}
controls.update()
renderer.render(scene, camera)
}
animate()