Skip to content

feat: CameraControls support for 1:1 events onX callback props #2451

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
May 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 48 additions & 10 deletions .storybook/stories/CameraControls.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,17 @@ import { Box, CameraControls, PerspectiveCamera, Plane, useFBO } from '../../src
export default {
title: 'Controls/CameraControls',
component: CameraControls,
decorators: [
(Story) => (
<Setup controls={false}>
<Story />
</Setup>
),
],
} satisfies Meta<typeof CameraControls>

type Story = StoryObj<typeof CameraControls>

//

function CameraControlsScene1(props: ComponentProps<typeof CameraControls>) {
const cameraControlRef = useRef<CameraControls>(null)

return (
<>
<Setup controls={false}>
<CameraControls ref={cameraControlRef} {...props} />
<Box
onClick={() => {
Expand All @@ -33,7 +28,7 @@ function CameraControlsScene1(props: ComponentProps<typeof CameraControls>) {
>
<meshBasicMaterial wireframe />
</Box>
</>
</Setup>
)
}

Expand All @@ -42,6 +37,8 @@ export const CameraControlsSt1 = {
name: 'Default',
} satisfies Story

//

const CameraControlsScene2 = (props: ComponentProps<typeof CameraControls>) => {
/**
* we will render our scene in a render target and use it as a map.
Expand Down Expand Up @@ -90,6 +87,47 @@ const CameraControlsScene2 = (props: ComponentProps<typeof CameraControls>) => {
}

export const CameraControlsSt2 = {
render: (args) => <CameraControlsScene2 {...args} />,
render: (args) => (
<Setup controls={false}>
<CameraControlsScene2 {...args} />
</Setup>
),
name: 'Custom Camera',
} satisfies Story

//

function CameraControlsScene3(props: ComponentProps<typeof CameraControls>) {
const cameraControlRef = useRef<CameraControls>(null)

return (
<>
<CameraControls
ref={cameraControlRef}
// {...props}
// onWake={() => console.log('wake')}
// onSleep={() => console.log('sleep')}
/>
<Box
onClick={() => {
cameraControlRef.current?.rotate(Math.PI / 4, 0, true)
}}
>
<meshBasicMaterial wireframe />
</Box>
</>
)
}

export const CameraControlsSt3 = {
render: (args) => (
<Setup
controls={false}
frameloop="demand"
//
>
<CameraControlsScene3 {...args} />
</Setup>
),
name: 'frameloop="demand"',
} satisfies Story
11 changes: 8 additions & 3 deletions docs/controls/camera-controls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@ type CameraControlsProps = {
/** Reference this CameraControls instance as state's `controls` */
makeDefault?: boolean
/** Events callbacks, see: https://github.com/yomotsu/camera-controls#events */
onStart?: (e?: { type: 'controlstart' }) => void
onEnd?: (e?: { type: 'controlend' }) => void
onChange?: (e?: { type: 'update' }) => void
onControlStart?: (e? { type: 'controlstart' }) => void
onControl?: (e? { type: 'control' }) => void
onControlEnd?: (e? { type: 'controlend' }) => void
onTransitionStart?: (e? { type: 'transitionstart' }) => void
onUpdate?: (e? { type: 'update' }) => void
onWake?: (e? { type: 'wake' }) => void
onRest?: (e? { type: 'rest' }) => void
onSleep?: (e? { type: 'sleep' }) => void
}
```

Expand Down
134 changes: 113 additions & 21 deletions src/core/CameraControls.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint react-hooks/exhaustive-deps: 1 */
import {
Box3,
EventDispatcher,
Expand Down Expand Up @@ -28,9 +29,23 @@ export type CameraControlsProps = Omit<
camera?: PerspectiveCamera | OrthographicCamera
domElement?: HTMLElement
makeDefault?: boolean

onControlStart?: (e?: { type: 'controlstart' }) => void
onControl?: (e?: { type: 'control' }) => void
onControlEnd?: (e?: { type: 'controlend' }) => void
onTransitionStart?: (e?: { type: 'transitionstart' }) => void
onUpdate?: (e?: { type: 'update' }) => void
onWake?: (e?: { type: 'wake' }) => void
onRest?: (e?: { type: 'rest' }) => void
onSleep?: (e?: { type: 'sleep' }) => void

/** @deprecated for OrbitControls compatibility: use `onControlStart` instead */
onStart?: (e?: { type: 'controlstart' }) => void
/** @deprecated for OrbitControls compatibility: use `onControlEnd` instead */
onEnd?: (e?: { type: 'controlend' }) => void
onChange?: (e?: { type: 'update' }) => void
/** @deprecated for OrbitControls compatibility */
onChange?: (e?: { type: string }) => void

events?: boolean // Wether to enable events during controls interaction
regress?: boolean
}
Expand Down Expand Up @@ -65,7 +80,24 @@ export const CameraControls: ForwardRefComponent<CameraControlsProps, CameraCont
extend({ CameraControlsImpl })
}, [])

const { camera, domElement, makeDefault, onStart, onEnd, onChange, regress, ...restProps } = props
const {
camera,
domElement,
makeDefault,
onControlStart,
onControl,
onControlEnd,
onTransitionStart,
onUpdate,
onWake,
onRest,
onSleep,
onStart,
onEnd,
onChange,
regress,
...restProps
} = props

const defaultCamera = useThree((state) => state.camera)
const gl = useThree((state) => state.gl)
Expand All @@ -91,43 +123,103 @@ export const CameraControls: ForwardRefComponent<CameraControlsProps, CameraCont
}, [explDomElement, controls])

useEffect(() => {
const callback = (e) => {
function invalidateAndRegress() {
invalidate()
if (regress) performance.regress()
if (onChange) onChange(e)
}

const onStartCb: CameraControlsProps['onStart'] = (e) => {
if (onStart) onStart(e)
const handleControlStart = (e: { type: 'controlstart' }) => {
invalidateAndRegress()
onControlStart?.(e)
onStart?.(e) // backwards compatibility
}

const handleControl = (e: { type: 'control' }) => {
invalidateAndRegress()
onControl?.(e)
onChange?.(e) // backwards compatibility
}

const onEndCb: CameraControlsProps['onEnd'] = (e) => {
if (onEnd) onEnd(e)
const handleControlEnd = (e: { type: 'controlend' }) => {
onControlEnd?.(e)
onEnd?.(e) // backwards compatibility
}

controls.addEventListener('update', callback)
controls.addEventListener('controlstart', onStartCb)
controls.addEventListener('controlend', onEndCb)
controls.addEventListener('control', callback)
controls.addEventListener('transitionstart', callback)
controls.addEventListener('wake', callback)
const handleTransitionStart = (e: { type: 'transitionstart' }) => {
invalidateAndRegress()
onTransitionStart?.(e)
onChange?.(e) // backwards compatibility
}

const handleUpdate = (e: { type: 'update' }) => {
invalidateAndRegress()
onUpdate?.(e)
onChange?.(e) // backwards compatibility
}

const handleWake = (e: { type: 'wake' }) => {
invalidateAndRegress()
onWake?.(e)
onChange?.(e) // backwards compatibility
}

const handleRest = (e: { type: 'rest' }) => {
onRest?.(e)
}

const handleSleep = (e: { type: 'sleep' }) => {
onSleep?.(e)
}

controls.addEventListener('controlstart', handleControlStart)
controls.addEventListener('control', handleControl)
controls.addEventListener('controlend', handleControlEnd)
controls.addEventListener('transitionstart', handleTransitionStart)
controls.addEventListener('update', handleUpdate)
controls.addEventListener('wake', handleWake)
controls.addEventListener('rest', handleRest)
controls.addEventListener('sleep', handleSleep)

return () => {
controls.removeEventListener('update', callback)
controls.removeEventListener('controlstart', onStartCb)
controls.removeEventListener('controlend', onEndCb)
controls.removeEventListener('control', callback)
controls.removeEventListener('transitionstart', callback)
controls.removeEventListener('wake', callback)
controls.removeEventListener('controlstart', handleControlStart)
controls.removeEventListener('control', handleControl)
controls.removeEventListener('controlend', handleControlEnd)
controls.removeEventListener('transitionstart', handleTransitionStart)
controls.removeEventListener('update', handleUpdate)
controls.removeEventListener('wake', handleWake)
controls.removeEventListener('rest', handleRest)
controls.removeEventListener('sleep', handleSleep)
}
}, [controls, onStart, onEnd, invalidate, setEvents, regress, onChange])
}, [
controls,

invalidate,
setEvents,
regress,

performance,

onControlStart,
onControl,
onControlEnd,
onTransitionStart,
onUpdate,
onWake,
onRest,
onSleep,

onChange,
onStart,
onEnd,
])

useEffect(() => {
if (makeDefault) {
const old = get().controls
set({ controls: controls as unknown as EventDispatcher })
return () => set({ controls: old })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [makeDefault, controls])

return <primitive ref={ref} object={controls} {...restProps} />
Expand Down
Loading