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