Last active
December 7, 2024 02:35
-
-
Save nickyvanurk/9ac33a6aff7dd7bd5cd5b8a20d4db0dc to your computer and use it in GitHub Desktop.
Camera perspective <-> orthographic toggle in r3f and vanilla three.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useEffect, useRef, useState } from 'react'; | |
import { useFrame, useThree } from '@react-three/fiber'; | |
import { MapControls, OrthographicCamera, PerspectiveCamera } from '@react-three/drei'; | |
import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib'; | |
export function Camera() { | |
const [oldType, setOldType] = useState('PerspectiveCamera'); | |
const [coords, setCoords] = useState({ x: 0, y: 0 }); | |
const gl = useThree((state) => state.gl); | |
const camera = useThree((state) => state.camera); | |
const proxyRef = useRef<THREE.Group>(null); | |
const mapControlsRef = useRef<OrbitControlsImpl>(null); | |
const orthographicRef = useRef<THREE.OrthographicCamera>(null); | |
const perspectiveRef = useRef<THREE.PerspectiveCamera>(null); | |
const { set } = useThree(({ get, set }) => ({ get, set })); | |
useEffect(() => { | |
set({ camera: perspectiveRef.current }); | |
const handleWindowMouseMove = (event: { clientX: number; clientY: number }) => { | |
setCoords({ x: event.clientX, y: event.clientY }); | |
}; | |
window.addEventListener('mousemove', handleWindowMouseMove); | |
return () => window.removeEventListener('mousemove', handleWindowMouseMove); | |
}, []); | |
useFrame(() => { | |
// HACK: Mouse capture resets when switching cameras because MapControls creates a new instance | |
// of itself. The group element and proxyRef is part of this hack in order to keep the MapControls | |
// target property from resetting. | |
if (camera.type !== oldType) { | |
gl.domElement.dispatchEvent( | |
new PointerEvent('pointerdown', { button: 2, pointerType: 'mouse', clientX: coords.x, clientY: coords.y }) | |
); | |
setOldType(camera.type); | |
} | |
if (!mapControlsRef.current || !proxyRef.current) return; | |
if (mapControlsRef.current !== proxyRef.current.userData['controls']) { | |
if (proxyRef.current.userData['controls']) { | |
mapControlsRef.current.target.copy(proxyRef.current.userData['controls'].target); | |
mapControlsRef.current.update(); | |
} | |
proxyRef.current.userData['controls'] = mapControlsRef.current; | |
} | |
const angle = mapControlsRef.current.getPolarAngle(); | |
if (+angle.toFixed(2) === 0.0) { | |
if ( | |
camera.type === 'OrthographicCamera' || | |
!orthographicRef.current || | |
!perspectiveRef.current || | |
!mapControlsRef.current | |
) | |
return; | |
orthographicRef.current.position.copy(perspectiveRef.current.position); | |
const distance = perspectiveRef.current.position.distanceTo(mapControlsRef.current.target); | |
const halfWidth = frustumWidthAtDistance(perspectiveRef.current, distance) / 2; | |
const halfHeight = frustumHeightAtDistance(perspectiveRef.current, distance) / 2; | |
orthographicRef.current.top = halfHeight; | |
orthographicRef.current.bottom = -halfHeight; | |
orthographicRef.current.left = -halfWidth; | |
orthographicRef.current.right = halfWidth; | |
orthographicRef.current.zoom = 1; | |
orthographicRef.current.lookAt(mapControlsRef.current.target); | |
orthographicRef.current.updateProjectionMatrix(); | |
set({ camera: orthographicRef.current }); | |
} else if (camera.type === 'OrthographicCamera') { | |
if (!orthographicRef.current || !perspectiveRef.current || !mapControlsRef.current) return; | |
const oldY = perspectiveRef.current.position.y; | |
perspectiveRef.current.position.copy(orthographicRef.current.position); | |
perspectiveRef.current.position.y = oldY / orthographicRef.current.zoom; | |
perspectiveRef.current.updateProjectionMatrix(); | |
set({ camera: perspectiveRef.current }); | |
} | |
}); | |
useFrame((state) => { | |
gl.render(state.scene, camera); | |
}, 1); | |
return ( | |
<> | |
<group ref={proxyRef}></group> | |
<MapControls ref={mapControlsRef} domElement={gl.domElement} /> | |
<PerspectiveCamera ref={perspectiveRef} position={[150, 1300, 1100]} fov={71} far={4000} /> | |
<OrthographicCamera ref={orthographicRef} near={1} far={4000} /> | |
</> | |
); | |
} | |
function frustumHeightAtDistance(camera: THREE.PerspectiveCamera, distance: number) { | |
const vFov = (camera.fov * Math.PI) / 180; | |
return Math.tan(vFov / 2) * distance * 2; | |
} | |
function frustumWidthAtDistance(camera: THREE.PerspectiveCamera, distance: number) { | |
return frustumHeightAtDistance(camera, distance) * camera.aspect; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { OrthographicCamera, PerspectiveCamera } from 'three'; | |
import { MapControls } from 'three/examples/jsm/controls/OrbitControls'; | |
export class Controls { | |
persCam: PerspectiveCamera; | |
orthoCam: OrthographicCamera; | |
camera: PerspectiveCamera | OrthographicCamera; | |
controls: MapControls; | |
constructor(private container: HTMLElement) { | |
this.persCam = new PerspectiveCamera(71, this.container.clientWidth / this.container.clientHeight, 0.1, 4000); | |
this.orthoCam = new OrthographicCamera(2, -2, -1, 1, 0.1, 4000); | |
this.camera = this.persCam; | |
this.camera.position.set(50, 1300, 1100); | |
this.camera.lookAt(0, 1, 0); | |
this.controls = new MapControls(this.camera, this.container); | |
this.controls.enableDamping = true; | |
this.controls.panSpeed = 1.2; | |
this.controls.dampingFactor *= 2; | |
} | |
dispose() { | |
this.controls.dispose(); | |
} | |
update() { | |
if (this.controls.getPolarAngle() <= 0.001) { | |
if (this.camera.type === 'PerspectiveCamera') this.orthographicCamera(); | |
} else if (this.camera.type === 'OrthographicCamera') this.perspectiveCamera(); | |
this.controls.update(); | |
} | |
updateFrustum() { | |
this.persCam.aspect = this.container.clientWidth / this.container.clientHeight; | |
const distance = this.orthoCam.position.distanceTo(this.controls.target); | |
const halfWidth = frustumWidthAtDistance(this.persCam, distance) / 2; | |
const halfHeight = frustumHeightAtDistance(this.persCam, distance) / 2; | |
const halfSize = { x: halfWidth, y: halfHeight }; | |
this.orthoCam.top = halfSize.y; | |
this.orthoCam.bottom = -halfSize.y; | |
this.orthoCam.left = -halfSize.x; | |
this.orthoCam.right = halfSize.x; | |
this.camera.updateProjectionMatrix(); | |
} | |
private orthographicCamera() { | |
this.orthoCam.position.copy(this.persCam.position); | |
const distance = this.persCam.position.distanceTo(this.controls.target); | |
const halfWidth = frustumWidthAtDistance(this.persCam, distance) / 2; | |
const halfHeight = frustumHeightAtDistance(this.persCam, distance) / 2; | |
this.orthoCam.top = halfHeight; | |
this.orthoCam.bottom = -halfHeight; | |
this.orthoCam.left = -halfWidth; | |
this.orthoCam.right = halfWidth; | |
this.orthoCam.zoom = 1; | |
this.orthoCam.lookAt(this.controls.target); | |
this.orthoCam.updateProjectionMatrix(); | |
this.camera = this.orthoCam; | |
this.controls.object = this.orthoCam; | |
} | |
private perspectiveCamera() { | |
const oldY = this.persCam.position.y; | |
this.persCam.position.copy(this.orthoCam.position); | |
this.persCam.position.y = oldY / this.orthoCam.zoom; | |
this.persCam.updateProjectionMatrix(); | |
this.camera = this.persCam; | |
this.controls.object = this.persCam; | |
} | |
} | |
function frustumHeightAtDistance(camera: PerspectiveCamera, distance: number) { | |
const vFov = (camera.fov * Math.PI) / 180; | |
return Math.tan(vFov / 2) * distance * 2; | |
} | |
function frustumWidthAtDistance(camera: PerspectiveCamera, distance: number) { | |
return frustumHeightAtDistance(camera, distance) * camera.aspect; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment