Skip to content

Instantly share code, notes, and snippets.

@nickyvanurk
Last active December 7, 2024 02:35
Show Gist options
  • Save nickyvanurk/9ac33a6aff7dd7bd5cd5b8a20d4db0dc to your computer and use it in GitHub Desktop.
Save nickyvanurk/9ac33a6aff7dd7bd5cd5b8a20d4db0dc to your computer and use it in GitHub Desktop.
Camera perspective <-> orthographic toggle in r3f and vanilla three.js
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;
}
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