Problem of primitives

I’m sorry if this question seems silly to you, but I wouldn’t have asked if I hadn’t searched for it before
I’m experiencing an issue where clicking on my 3D model causes a noticeable FPS drop, which indicates that some click-related processing (like raycasting) is happening. However, despite this, I’m not seeing any console.log output that I’ve placed in my click handlers.

I’v tried to do it like in exmp. - Shoe conf - codesandbox

My observations so far:

  • The FPS drop suggests that the click event is being detected, but the expected logging isn’t appearing.
  • I have assigned click handlers both on a parent and on the element representing the model, yet nothing is printed to the console.
  • I’m wondering if the OrbitControls or TransformControls might be intercepting or overriding these click events, or if there’s an issue with event propagation.

To make it clear, i paste my code.Unfortunately, I can’t remove some parts of the code, so I’ll just leave it so it’s clear.

Viewer.tsx - that’s where my canvas is.

'use client'
import { Cache, Color, Light, PCFSoftShadowMap } from 'three'
import StatsPanel from './ModelSettings/StatsPanel'
import {
	AccumulativeShadows,
	GizmoHelper,
	GizmoViewport,
	Grid,
	RandomizedLight,
} from '@react-three/drei'
import Lights from './Lights/Lights'
import ModelHandler from './ModelSettings/ModelHandler'
//import Stats from 'three/examples/jsm/libs/stats.module.js'
import {
	OrbitControls,
} from '@react-three/drei'

import React, {
	useEffect,
	useRef,
	useState,
	Suspense,
} from 'react'

import Env from './Environment'
import { memo } from 'react'
import {
	Leva,
	useControls,
	LevaPanel,
	useCreateStore,
	folder,
} from 'leva'

import { Canvas, render, useFrame, useThree } from '@react-three/fiber'

interface ViewerProps {
	url: string
	rootPath: string
	fileMap: Map<string, File>
	options?: {
		kiosk?: boolean
		preset?: string
		cameraPosition?: number[] | null
	}
	rootFile: File | string
	fileType: string
}

const Shadows = memo(() => (
	<AccumulativeShadows
		temporal
		frames={100}
		color='#9d4b4b'
		colorBlend={0.5}
		alphaTest={0.9}
		scale={20}
	>
		<RandomizedLight amount={8} radius={4} position={[5, 5, -10]} />
	</AccumulativeShadows>
))

function SceneBackground({ backgroundStore }: any) {
	const { scene, gl } = useThree()
	const { ColorType } = useControls(
		{
			ColorType: '#191919',
			exposure: {
				value: 1,
				min: 0,
				max: 2,
				step: 0.1,
				onChange: value => {
					gl.toneMappingExposure = value
				},
			},
		},

		{ store: backgroundStore }
	)

	useEffect(() => {
		scene.background = new Color(ColorType)
	}, [ColorType])

	return null
}

const GridChange = ({ circleSize, segments }: any) => {
	// const { gridSize, ...gridConfig } = useControls({
	// 	gridSize: [10.5, 10.5],
	// 	cellSize: { value: 0.6, min: 0, max: 10, step: 0.1 },
	// 	cellThickness: { value: 1, min: 0, max: 5, step: 0.1 },
	// 	cellColor: '#6f6f6f',
	// 	sectionSize: { value: 3.3, min: 0, max: 10, step: 0.1 },
	// 	sectionThickness: { value: 1.5, min: 0, max: 5, step: 0.1 },
	// 	sectionColor: '#9d4b4b',
	// 	fadeDistance: { value: 25, min: 0, max: 100, step: 1 },
	// 	fadeStrength: { value: 1, min: 0, max: 1, step: 0.1 },
	// 	followCamera: false,
	// 	infiniteGrid: true,
	// })

	// return <Grid position={[0, -0.01, 0]} args={gridSize} {...gridConfig} />
	return (
		<mesh rotation-x={-Math.PI / 2} receiveShadow>
			<circleGeometry args={[circleSize, segments]} />
			<meshStandardMaterial />
		</mesh>
	)
}

const StatsHelper = ({ canvasRef, storeType }: any) => {
	const { statsType, showStats } = useControls(
		{
			showStats: true,
			statsType: {
				value: 'statsThree',
				options: ['statsThree', 'statsGL'],
			},
		},
		{ store: storeType }
	)

	return showStats ? (
		<StatsPanel
			canvasRef={canvasRef}
			type={statsType as 'statsGL' | 'statsThree'}
		/>
	) : null
}

const GridHelperChange = ({ gridSize }: any) => {
	return <gridHelper args={[gridSize]} position={[0, -0.01, 0]} />
}

const GridOrHelper = ({ gridChangeStore }: any) => {
	const { Change } = useControls({ Change: true }, { store: gridChangeStore })

	const GridVal = useControls(
		{
			'Grid Helper': folder(
				{
					gridSize: 10,
				},
				{ render: get => !get('Change') }
			),
		},
		{ store: gridChangeStore }
	)

	// Standard grid controls - only rendered when Change is true
	const CircleVal = useControls(
		'',
		{
			'Circle Settings': folder(
				{
					circleSize: 10,
					segment: 10,
					cellColor: '#6f6f6f',
					// Add other grid controls
				},
				{ render: get => get('Change') }
			),
		},
		{ store: gridChangeStore }
	)

	return Change ? (
		<GridChange
			circleSize={CircleVal.circleSize}
			segments={CircleVal.segment}
		/>
	) : (
		<GridHelperChange gridSize={GridVal.gridSize} />
	)
}

interface PanelConfig {
	id: string
	title: string
	Component?: React.FC<{ store: any }>
	store: any
	isCollapsed: boolean
}

interface Panel {
	id: string
	store: any
	isCollapsed: boolean
}

const Viewer = () => {
	const canvasRef = useRef<HTMLElement>(null!)

	const panelConfigs: PanelConfig[] = [
		{
			id: 'grid',
			title: 'Grid Controls',
			store: useCreateStore(),
			isCollapsed: true,
			Component: GridOrHelper,
		},
		{
			id: 'background',
			title: 'Scene Settings',
			store: useCreateStore(),
			isCollapsed: true,
			Component: SceneBackground,
		},
		{
			id: 'transform',
			title: 'Transform Controls',
			store: useCreateStore(),
			isCollapsed: true,
			Component: ModelHandler,
		},
		{
			id: 'model',
			title: 'Model Settings',
			isCollapsed: true,
			store: useCreateStore(),
		},
		{
			id: 'performance',
			title: 'Performance Settings',
			isCollapsed: true,
			store: useCreateStore(),
		},
	]

	// Initialize panels with stores and collapse state
	const [panels, setPanels] = useState<PanelConfig[]>(panelConfigs)


	const updatePanelState = (panelId: string, isCollapsed: boolean) => {
		setPanels(prevPanels =>
			prevPanels.map(panel =>
				panel.id === panelId ? { ...panel, isCollapsed } : panel
			)
		)
	}

	const getStoreById = (id: string) =>
		panels.find(panel => panel.id === id)?.store

	const renderPanel = ({ id, title }: PanelConfig) => {
		const panel = panels.find(p => p.id === id)
		if (!panel) return null

		return (
			<LevaPanel
				key={id}
				store={panel.store}
				titleBar={{
					drag: false,
					title: title,
				}}
				fill
				flat
				collapsed={{
					collapsed: panel.isCollapsed,
					onChange: state => updatePanelState(id, state),
				}}
			/>
		)
	}

	return (
		<div ref={canvasRef as any} className='h-full w-full relative'>
			<Canvas
				shadows
				gl={{
					antialias: false,
				}}
			>
				<StatsHelper
					canvasRef={canvasRef}
					storeType={getStoreById('performance')}
				/>
				<SceneBackground backgroundStore={getStoreById('background')} />

				<ModelHandler
					transformStore={getStoreById('transform')}
					modelStore={getStoreById('model')}
				/>
				<Suspense fallback={null}>
					<Env envStore={getStoreById('background')} />
				</Suspense>
				<Lights lightStore={getStoreById('background')} />
				<OrbitControls makeDefault />
				<GridOrHelper gridChangeStore={getStoreById('grid')} />
				<GizmoHelper alignment='bottom-center' margin={[80, 80]}>
					<GizmoViewport
						axisColors={['red', 'green', 'blue']}
						labelColor='white'
					/>
				</GizmoHelper>
			</Canvas>

			<div
				className='absolute top-0 right-0 w-80 h-full bg-black/20 backdrop-blur-sm'
				style={{
					maxHeight: 'calc(100vh - 5rem)',
					overflowY: 'auto',
					overflowX: 'hidden',
				}}
			>
				<div className='flex flex-col gap-2 p-2'>
					{panelConfigs.map(renderPanel)}
				</div>
			</div>
		</div>
	)
}

export default Viewer

ModelHandler - just a TransformControl’s wrapper over the model

import Model from './Model'
import { useControls } from 'leva'
import { TransformControls } from '@react-three/drei'
import React, { useRef, } from 'react'

const ModelHandler = ({ transformStore, modelStore }: any) => {
	const myObj = useRef<any>(null!)
	const transformRef = useRef<any>(null!)

	const [{ enabled }] = useControls(
		() => ({
			mode: {
				options: ['translate', 'rotate', 'scale'],
				value: 'translate',
				onChange: v => {
					if (transformRef.current) {
						transformRef.current.mode = v
					}
				},
			},
			space: {
				options: ['world', 'local'],
				value: 'local',
				onChange: v => {
					if (transformRef.current) {
						transformRef.current.space = v
					}
				},
			},
			enabled: true,
			snap: { value: false },
		}),
		{ store: transformStore }
	)

	return (
		<TransformControls ref={transformRef} enabled={enabled} object={myObj}>
			<group
				onClick={(e: any) => {
					e.stopPropagation()
					console.log('hovered', e.object.name)
				}}
				ref={myObj}
			>
				<Model settings={transformRef} modelStore={modelStore} />
			</group>
		</TransformControls>
	)
}

export default ModelHandler

Model - my model XD

//....
//not a full 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, gl }: any = useThree()
	const modelRef = useRef<Object3D | null>(null!)
	const innerModelRef = useRef<Object3D>(null!)
	const skeletonRef = useRef<SkeletonHelper | 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)


	//Function for getting the model object
	const model = useModelLoader({
		url,
		rootPath,
		assetMap: fileMap,
		fileType,
		rootFile,
	})
//.....
useFrame((state, delta) => {
		if (mixerRef.current) {
			mixerRef.current.update(delta)
		}
	})
//....
useLayoutEffect(() => {
		if (model?.scene) {
			console.log('model Loaded')
		
			model.scene.traverse((object: any) => {
				if (object instanceof Mesh) {
					object.castShadow = true
					object.receiveShadow = true
				}
			})
		}

		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 (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()
						}
					}
				})
			}
		}
	}, [model])
//....

return (
		<group
			ref={modelRef}
			position={[xP, yP, zP]}
			rotation={[xR, yR, zR]}
			scale={scale}
			onClick={e => {
				console.log('Model clicked!', e.object)
				// You can also get more info about the clicked point
				console.log('Click position:', e.point)
				console.log('Click distance:', e.distance)
			}}
		>
			<Center top>
				<primitive
					onPointerOver={(e: any) => {
						e.stopPropagation()
						console.log('hovered', e.object.name)
					}}
					ref={innerModelRef}
					object={model.scene}
					onClick={() => {
						console.log('click!')
					}}
				/>
			</Center>
		</group>
	)
})

export default Model

ModelLoader - maybe it will be necessary for understanding.In this file everything in general is simple, if the model has custom materials, it is downloaded through gltf-transform WebIO, and if not - (an exception is thrown in WebIO and it redirects to the normal download function)

import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
import { LoaderUtils, Cache } from 'three'
import {
	GLTFLoader,
	GLTFParser,
} from 'three/examples/jsm/loaders/GLTFLoader.js'
import { FBXLoader, TGALoader } from 'three/examples/jsm/Addons.js'
import { DRACOLoader } from 'three/examples/jsm/Addons.js'
import { KTX2Loader } from 'three/examples/jsm/Addons.js'
import { LoadingManager, REVISION } from 'three'
import { useLoader, useThree } from '@react-three/fiber'
import { useMyContext } from '../../MyContext'
import { WebIO } from '@gltf-transform/core'
import {
	ALL_EXTENSIONS,
	EXTMeshoptCompression,
	KHRONOS_EXTENSIONS,
} from '@gltf-transform/extensions'
import { metalRough } from '@gltf-transform/functions'
import {
	MeshoptDecoder,
	MeshoptEncoder,
	MeshoptSimplifier,
} from 'meshoptimizer'
import draco3d from 'draco3dgltf'
//Function of creating a modal window for model with KTX2 texture error(just UI with 2 buttons OK-true and Cancel-false)
import createDialog from './ModalDialog'

interface LoaderProps {
	url: string
	rootPath: string
	assetMap: Map<string, File>
	fileType: string
	rootFile: File | string
}

const useModelLoader = ({
	url,
	rootPath,
	assetMap,
	fileType,
	rootFile,
}: LoaderProps): any => {
	const { gl, scene } = useThree()
	// Function for traverse materials(like in viewer.js file (DonMcCurdy))
	const traverseMaterials = useCallback(
		(object: any, callback: (mat: any) => void) => {
			object.traverse((node: any) => {
				if (!node.geometry) return
				const materials = Array.isArray(node.material)
					? node.material
					: [node.material]
				materials.forEach(callback)
			})
		},
		[]
	)

	const cleanup = useCallback(
		(model: any) => {
			if (!model) return

			scene.remove(model)

			model.traverse((node: any) => {
				if (node.geometry) {
					node.geometry.dispose()
				}
			})

			if (model.animations) {
				model.animations.forEach((animation: any) => {
					if (animation.clip) {
						animation.clip.dispose()
					}
				})
			}

			traverseMaterials(model, (material: any) => {
				if (material.dispose) {
					material.dispose()
				}
				for (const key in material) {
					if (
						key !== 'envMap' &&
						material[key] &&
						material[key].isTexture &&
						material[key].dispose
					) {
						material[key].dispose()
					}
				}
			})
		},
		[scene, traverseMaterials]
	)

	const { setIsViewerVisible } = useMyContext() as any
	const [content, setContent] = useState<any>({
		scene: null,
		clips: null,
	})
	const [errorOccurred, setErrorOccurred] = useState(false)

	// Create LoadingManager and loaders 1 time
	const MANAGER = useMemo(() => new LoadingManager(), [])
	const THREE_PATH = `https://unpkg.com/three@0.${REVISION}.x`

	const DRACO_LOADER = useMemo(
		() =>
			new DRACOLoader(MANAGER).setDecoderPath(
				`${THREE_PATH}/examples/jsm/libs/draco/gltf/`
			),
		[MANAGER]
	)

	const KTX2_LOADER = useMemo(
		() =>
			new KTX2Loader(MANAGER).setTranscoderPath(
				`${THREE_PATH}/examples/jsm/libs/basis/`
			),
		[MANAGER]
	)

	// Function for load model with KTX2 texture
	const loadModel = useCallback(
		async (url: string, loader: any, blobURLs: string[]) => {
			try {
				// Создаем экземпляр WebIO с необходимыми расширениями
				const io = new WebIO({ credentials: 'include' })
					.registerExtensions(ALL_EXTENSIONS)
					.registerDependencies({
						'meshopt.decoder': MeshoptDecoder,
						'meshopt.encoder': MeshoptEncoder,
					})

				console.log('Initializing WebIO:', io)

				const gltf_trans = await io.read(url)

				// Show dialog and wait for user's decision(Ti load custom KTX2 textures or not)
				const continueLoading = await createDialog()

				if (!continueLoading) {
					setIsViewerVisible(false)
					blobURLs.forEach((url: any) => URL.revokeObjectURL(url))
					return null // Return null to indicate that loading was canceled
				}

				// like in example=============================
				await gltf_trans.transform(metalRough())

				const glb: any = await io.writeBinary(gltf_trans)

				if (loader) {
					return new Promise((resolve, reject) => {
						loader.parse(
							glb.buffer,
							'',
							(gltf: any) => {
								resolve({
									scene: gltf.scene,
									clips: gltf.animations || [],
								})
							},
							(error: any) => {
								reject(error)
							}
						)
					})
				}
				//==============================================
			} catch (error) {
				console.log('Ошибка загрузки модели:', error)
				setErrorOccurred(true)
			}
		},
		[setIsViewerVisible]
	)

	// Function for load model with fallback loader(like in viewer.js file (DonMcCurdy))
	const loadWithFallbackLoader = useCallback(
		async (url: string, otherLoader: any, fileType: string) => {
			return new Promise((resolve, reject) => {
				otherLoader.load(
					url,
					(object: any) => {
						console.log('Loaded model with fallback loader:', object)
						let loadedScene = null
						let clips = null

						if (fileType === 'gltf/glb') {
							loadedScene = object.scene || object.scenes[0]
							clips = object.animations || []
						} else if (fileType === 'fbx') {
							loadedScene = object
							clips = object.animations || []
						}

						if (!loadedScene) {
							reject(new Error('No scene found in the model'))
							return
						}

						resolve({ scene: loadedScene, clips })
					},
					undefined,
					(error: any) => {
						console.error('Error loading model with fallback:', error)
						reject(error)
					}
				)
			})
		},
		[]
	)
	const isLoadingStarted = useRef(false)
	useEffect(() => {
		if (!url) return

		const blobURLs: string[] = []

		MANAGER.setURLModifier((someUrl: string) => {
			const baseURL = LoaderUtils.extractUrlBase(url)
			const normalizedURL =
				rootPath +
				decodeURI(someUrl)
					.replace(baseURL, '')
					.replace(/^(\.?\/)/, '')

			if (assetMap.has(normalizedURL)) {
				const blob = assetMap.get(normalizedURL)
				const blobURL = URL.createObjectURL(blob!)
				blobURLs.push(blobURL)
				return blobURL
			}
			return someUrl
		})

		let loader: GLTFLoader | FBXLoader | null = null
		let otherLoader: GLTFLoader | FBXLoader | null = null

		if (fileType === 'gltf/glb') {
			loader = new GLTFLoader(MANAGER).setMeshoptDecoder(MeshoptDecoder)
			otherLoader = new GLTFLoader(MANAGER)
				.setCrossOrigin('anonymous')
				.setDRACOLoader(DRACO_LOADER)
				.setKTX2Loader(KTX2_LOADER.detectSupport(gl))
				.setMeshoptDecoder(MeshoptDecoder)

			MANAGER.onLoad = () => {
				console.log('All textures loaded successfully')
			}

			MANAGER.onError = url => {
				console.error('Error loading texture:', url)
			}
		} else if (fileType === 'fbx') {
			otherLoader = new FBXLoader(MANAGER).setCrossOrigin('anonymous')
		}

		const loadModelAsync = async () => {
			try {
				//First try to load model with KTX2 textures
				const result = await loadModel(url, loader, blobURLs)
				if (result) {
					setContent(result)
				} else {
					try {
						const fallbackResult = await loadWithFallbackLoader(
							url,
							otherLoader,
							fileType
						)
						setContent(fallbackResult)
					} catch (fallbackError) {
						console.error('All loaders failed:', fallbackError)
					}
				}
			} catch (error) {
				console.error('Primary loader failed, trying fallback:', error)
			} finally {
				// Clean up resources
				blobURLs.forEach(url => URL.revokeObjectURL(url))
			}
		}

		loadModelAsync().finally(() => {
			isLoadingStarted.current = false
		})

		return () => {
			// Clean up resources(once again XD for sure)
			blobURLs.forEach(url => URL.revokeObjectURL(url))
			DRACO_LOADER.dispose()
			KTX2_LOADER.dispose()
			useLoader.clear(GLTFLoader, url)
			if (content.scene) cleanup(content.scene)
			Cache.clear()
		}
	}, [url])

	return content
}

export default useModelLoader

I hope someone can help me with this problem :smiling_face_with_tear: