Hi everyone,
I’m working on a 3D model viewer in Three.js with animation support. I’m trying to display vertex normals using VertexNormalsHelper
, but the normals appear disconnected or floating away from the mesh geometry
I make sure to call computeVertexNormals()
if normals are missing, and the helper updates correctly in the render loop. However, the misalignment still occurs — especially when the mesh is animated or skinned.
The code:
const Model = React.memo(({ settings, modelStore }: any) => {
const context = useMyContext()
if (!context) throw new Error('Context is required')
const { url, rootPath, fileMap, fileType, rootFile } = context as ViewerProps
const { scene, camera, controls, gl }: any = useThree()
const modelRef = useRef<Object3D | null>(null!)
const innerModelRef = useRef<Object3D>(null!)
const skeletonRef = useRef<SkeletonHelper | null>(null!)
const vertexRef = useRef<VertexNormalsHelper[] | null>(null!)
const actionsRef = useRef<Map<string, AnimationAction>>(new Map())
const mixerRef = useRef<AnimationMixer>(null)
const [bakeOptions, setBakeOptions] = useState(false)
// State for animation control
const [isPlaying, setIsPlaying] = useState<boolean>(false)
const [activeClips, setActiveClips] = useState<string[]>([])
const [n, setN] = useState(0)
const model = useModelLoader({
url,
rootPath,
assetMap: fileMap,
fileType,
rootFile,
})
const clips = model?.clips || []
useLayoutEffect(() => {
if (clips.length > 0) {
setN(clips.length)
}
}, [clips.length])
// Move useControls to the top level
const [{ dilation, size }] = useControls(
() => ({
buttons: folder(
{
'Reset Position': button(() => {
if (modelRef.current) {
console.log('in reset position')
modelRef.current.position.set(0, 0, 0)
modelRef.current.updateMatrixWorld()
set({ xP: 0, yP: 0, zP: 0 })
scene.updateMatrixWorld()
}
}),
'Reset Rotation': button(() => {
if (modelRef.current) {
modelRef.current.rotation.set(0, 0, 0)
set({ xR: 0, yR: 0, zR: 0 })
modelRef.current.updateMatrixWorld()
}
}),
'Reset Scale': button(() => {
if (modelRef.current) {
modelRef.current.scale.set(1, 1, 1)
set({ scale: 1 })
modelRef.current.updateMatrixWorld()
}
}),
'Reset All': button(() => {
if (modelRef.current) {
modelRef.current.scale.set(1, 1, 1)
modelRef.current.rotation.set(0, 0, 0)
modelRef.current.position.set(0, 0, 0)
set({ xP: 0, yP: 0, zP: 0, xR: 0, yR: 0, zR: 0, scale: 1 })
modelRef.current.updateMatrixWorld()
}
}),
'Download Texture': folder({
dilation: { value: 2, min: 1, max: 10, step: 1 },
size: { value: 1024, min: 128, max: 4096, step: 128 },
'Bake & Export All': button(() => {
if (modelRef.current) {
setBakeOptions(true)
}
}),
}),
},
{ collapsed: true }
),
}),
{ store: modelStore }
)
//Leva
const [
{
xP,
yP,
zP,
xR,
yR,
zR,
scale,
skeleton,
vertexHelper,
materialChannels,
side,
},
set,
] = useControls(
() => ({
transform: folder(
{
position: folder(
{
xP: {
value: 0,
min: -10,
max: 10,
step: 0.1,
},
yP: {
value: 0,
min: -10,
max: 10,
step: 0.1,
},
zP: {
value: 0,
min: -10,
max: 10,
step: 0.1,
},
},
{ collapsed: true }
),
rotation: folder(
{
xR: {
value: 0,
min: -Math.PI,
max: Math.PI,
step: 0.1,
},
yR: {
value: 0,
min: -Math.PI,
max: Math.PI,
step: 0.1,
},
zR: {
value: 0,
min: -Math.PI,
max: Math.PI,
step: 0.1,
},
},
{ collapsed: true }
),
scale: {
value: 1,
min: 0.1,
max: 10,
step: 0.5,
},
},
{ collapsed: true }
),
material: folder(
{
materialChannels: {
options: ['Normal', 'Original'],
value: 'Original',
},
side: {
options: ['Front', 'Back', 'Double'],
value: 'Front',
},
skeleton: false,
vertexHelper: false,
wireframe: {
value: false,
onChange: (value: boolean) => {
if (innerModelRef.current) {
innerModelRef.current.traverse((node: any) => {
if (node.material) {
node.material.wireframe = value
}
})
}
},
},
},
{ collapsed: true }
),
}),
{ store: modelStore }
)
const inputs: any = {}
if (clips.length > 0 && n > 0) {
for (let i = 0; i < Math.min(n, clips.length); i++) {
// Safely access clip name with null check
const clipName = clips[i]?.name
if (clipName) {
inputs[clipName] = {
value: false,
onChange: (val: boolean) => {
if (mixerRef.current) {
const action = actionsRef.current.get(clipName)
if (action) {
if (val) {
setIsPlaying(true)
action.reset().play()
} else {
action.stop()
}
}
}
},
transient: false,
}
}
}
}
const [, setSelect] = useControls(
() => ({
animation: folder(
{
'Play All': button(
() => {
if (mixerRef.current) {
mixerRef.current?.stopAllAction()
actionsRef.current.forEach((action: any) => {
action.setEffectiveTimeScale(1)
action.clampWhenFinished = true
action.reset().play()
})
setSelect({})
setActiveClips(['all clips'])
setIsPlaying(true)
}
},
{
disabled: n === 0,
}
),
'Stop All': button(
() => {
if (mixerRef.current) {
mixerRef.current.stopAllAction()
setActiveClips([])
setIsPlaying(false)
model.clips.forEach((clip: AnimationClip) => {
const action = actionsRef.current.get(clip.name)
if (action) {
action.reset()
}
})
}
},
{
disabled: n === 0,
}
),
playbackSpeed: {
value: 1.0,
min: 0,
max: 2,
step: 0.1,
onChange: value => {
if (mixerRef.current) {
mixerRef.current.timeScale = value
}
},
render: () => n > 0,
},
animation_type: folder({ ...inputs }, { collapsed: true }),
},
{ collapsed: true }
),
}),
{ store: modelStore },
[model, n, clips]
)
useFrame((state, delta) => {
if (mixerRef.current) {
mixerRef.current.update(delta)
}
if (vertexRef.current && vertexHelper) {
vertexRef.current.forEach(helper => {
helper.update()
})
}
})
useLayoutEffect(() => {
if (vertexRef.current) {
vertexRef.current.forEach((helper: any) => {
helper.visible = vertexHelper
})
}
}, [vertexHelper])
useLayoutEffect(() => {
if (skeletonRef.current) {
skeletonRef.current.visible = skeleton
}
}, [skeleton])
useLayoutEffect(() => {
if (!innerModelRef.current) return
innerModelRef.current.traverse((obj: any) => {
if (obj instanceof Mesh) {
obj.material =
materialChannels === 'Normal'
? obj.userData.normalMaterial
: obj.userData.OriginalMaterial
if (obj.material) {
obj.material.needsUpdate = true
}
}
})
}, [materialChannels])
useLayoutEffect(() => {
if (!innerModelRef.current) return
const side_ =
side === 'Front' ? FrontSide : side === 'Back' ? BackSide : DoubleSide
innerModelRef.current.traverse((obj: any) => {
if (obj instanceof Mesh) {
obj.material.side = side_
if (obj.material) {
obj.material.needsUpdate = true
}
}
})
}, [side])
useLayoutEffect(() => {
if (model?.scene) {
console.log('model Loaded')
const vertexHelpers: VertexNormalsHelper[] = []
model.scene.updateMatrixWorld(true)
// setupModel(model.scene, model.clips)
model.scene.traverse((object: any) => {
if (object instanceof Mesh) {
object.castShadow = true
object.receiveShadow = true
if (!object.userData.OriginalMaterial) {
object.userData.OriginalMaterial = object.material
}
const normalMat = new MeshNormalMaterial()
const origMat = object.material
// Копируем важные карты из оригинального материала
if (origMat.normalMap) {
normalMat.normalMap = origMat.normalMap
normalMat.normalScale =
origMat.normalScale?.clone() || new Vector2(1, 1)
}
if (origMat.bumpMap) {
normalMat.bumpMap = origMat.bumpMap
normalMat.bumpScale = origMat.bumpScale || 1
}
if (origMat.displacementMap) {
normalMat.displacementMap = origMat.displacementMap
normalMat.displacementScale = origMat.displacementScale || 1
normalMat.displacementBias = origMat.displacementBias || 0
}
normalMat.wireframe = origMat.wireframe || false
normalMat.flatShading = origMat.flatShading || false
if (origMat.alphaTest !== undefined) {
normalMat.alphaTest =
origMat.alphaTest < 1e-4 ? 1e-4 : origMat.alphaTest
}
if (origMat.alphaHash !== undefined) {
normalMat.alphaHash = origMat.alphaHash
}
// Сохраняем подготовленный normal материал
object.userData.normalMaterial = normalMat
if (object.geometry) {
if (!object.geometry.attributes.normal) {
object.geometry.computeVertexNormals()
}
const helper = new VertexNormalsHelper(object, 0.1, 0xffff00)
helper.visible = false
scene.add(helper)
vertexHelpers.push(helper)
}
}
})
vertexRef.current = vertexHelpers
}
if (model.clips && model.clips.length > 0) {
mixerRef.current = new AnimationMixer(model.scene)
model.clips.forEach((clip: AnimationClip) => {
const action = mixerRef.current!.clipAction(clip)
actionsRef.current.set(clip.name, action)
})
setN(model.clips.length)
}
console.log('actionRef ', actionsRef.current)
if (innerModelRef.current) {
skeletonRef.current = new SkeletonHelper(innerModelRef.current)
skeletonRef.current.visible = false
scene.add(skeletonRef.current)
}
return () => {
if (skeletonRef.current) {
scene.remove(skeletonRef.current)
skeletonRef.current.dispose()
skeletonRef.current = null
}
if (vertexRef.current) {
vertexRef.current.forEach((helper: any) => {
scene.remove(helper)
helper.geometry.dispose()
helper.material.dispose()
})
}
if (mixerRef.current) {
mixerRef.current.stopAllAction()
mixerRef.current = null
}
actionsRef.current.clear()
if (modelRef.current) {
modelRef.current.traverse((node: any) => {
if (node.geometry) {
node.geometry.dispose()
}
if (node.material) {
if (Array.isArray(node.material)) {
node.material.forEach((material: any) => material.dispose())
} else {
node.material.dispose()
}
}
// Также очищаем кешированные материалы из userData
if (node.userData.normalMaterial) {
node.userData.normalMaterial.dispose()
node.userData.normalMaterial = null
}
if (node.userData.originalMaterial) {
node.userData.originalMaterial = null
}
})
}
}
}, [model])
const createAndDownloadZip = async (textures: TextureData[]) => {
const zip = new JSZip()
// Add each texture to the zip
textures.forEach((texture, index) => {
// Convert base64 data URL to binary
const base64Data = texture.dataUrl.split(',')[1]
zip.file(`texture_${index}_${texture.name}.png`, base64Data, {
base64: true,
})
})
// Generate zip file
const zipBlob = await zip.generateAsync({ type: 'blob' })
// Create download link
const downloadUrl = URL.createObjectURL(zipBlob)
const link = document.createElement('a')
link.href = downloadUrl
link.download = 'model_textures.zip'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(downloadUrl)
}
useEffect(() => {
if (!bakeOptions || !innerModelRef.current) return
const baker = new ShaderBaker()
const texturePromises: Promise<TextureData>[] = []
const texturesToProcess: Mesh[] = []
// Collect all meshes that need texture baking
innerModelRef.current.traverse((node: any) => {
if (node instanceof Mesh) {
texturesToProcess.push(node)
}
})
// Process each mesh
texturesToProcess.forEach((mesh, index) => {
const texturePromise = new Promise<TextureData>(resolve => {
const fbo = baker.bake(gl, mesh, {
scene: scene,
size: size,
dilation: dilation,
})
// Convert texture to data URL
const dataUrl = getTextureAsDataUrl(gl, fbo.texture)
resolve({
name: mesh.name || `mesh_${index}`,
dataUrl: dataUrl,
})
// Clean up FBO
fbo.dispose()
})
texturePromises.push(texturePromise)
})
// When all textures are processed, create the zip file
Promise.all(texturePromises)
.then(textures => {
createAndDownloadZip(textures)
setBakeOptions(false)
})
.catch(error => {
console.error('Error processing textures:', error)
setBakeOptions(false)
})
}, [bakeOptions, size, dilation, gl, scene])
if (!model?.scene) return null
return (
<group
ref={modelRef}
position={[xP, yP, zP]}
rotation={[xR, yR, zR]}
scale={scale}
>
<Center top>
<primitive ref={innerModelRef} object={model.scene} />
</Center>
</group>
)
})
export default Model
I asked ChatGPT about this issue, and it suggested a few possible causes:
- SkinnedMesh / bone deformation: If the mesh is animated using bones, the helper may not follow vertex transformations since it uses the static geometry state.
- Helper attached before deformation: The normals are computed before skinning or animation affects the mesh, so they appear “offset.”
- Matrix updates: The object may not be updating its matrix or world matrix properly when animated.
- Helper not linked to SkinnedMesh bone hierarchy: The
VertexNormalsHelper
does not account for GPU skinning updates done in the shader. - Use of clone() or Group wrapper: If the animated mesh is nested inside wrappers, the helper may be pointing to the original transform space.
So now I’m wondering — is this a problem with the code, or could it be a limitation of the model format / animation skinning system itself?
Any help or ideas would be greatly appreciated!