VertexNormalsHelper not aligned with mesh after animation / model load

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

- this model without animation inside

- this model with animations inside and at the beggining of the loading it looks good,but when model moves vertexHelper didnt follow it, it’s ok and it might be the second questio after the first one.

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:

  1. SkinnedMesh / bone deformation: If the mesh is animated using bones, the helper may not follow vertex transformations since it uses the static geometry state.
  2. Helper attached before deformation: The normals are computed before skinning or animation affects the mesh, so they appear “offset.”
  3. Matrix updates: The object may not be updating its matrix or world matrix properly when animated.
  4. Helper not linked to SkinnedMesh bone hierarchy: The VertexNormalsHelper does not account for GPU skinning updates done in the shader.
  5. 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!