Skip to content

Commit

Permalink
feat: Implement GizmoHelper, ViewportGizmo and ViewcubeGizmo (#303)
Browse files Browse the repository at this point in the history
* Implemented NavigationGizmo inspired by Blender and threejs.org/editor

* add alignment, margin and text color attributes

* separated gizmo logic (helper) from component(s) and added ViewCubeGizmo

* fixed story name

* updated docs and cleanup

* fixed storybook suspense error

* code review

* gardening

* avoid double-rendering main scene

* code review

* docs + code review

* tweaks

Co-authored-by: Jens Schmidt <jens.schmidt@gmx.net>
  • Loading branch information
w3dot0 and Jens Schmidt committed Feb 27, 2021
1 parent 6938dbf commit 54b2953
Show file tree
Hide file tree
Showing 8 changed files with 493 additions and 7 deletions.
47 changes: 47 additions & 0 deletions .storybook/stories/GizmoViewcube.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { number, select, withKnobs } from '@storybook/addon-knobs'
import * as React from 'react'
import { Vector3 } from 'three'
import { GizmoHelper, OrbitControls, useGLTF, GizmoViewcube } from '../../src'
import { Setup } from '../Setup'

export default {
title: 'Gizmos/GizmoViewcube',
component: GizmoViewcube,
decorators: [
(storyFn) => (
<Setup controls={false} cameraPosition={new Vector3(0, 0, 10)}>
{storyFn()}
</Setup>
),
withKnobs,
],
}

const GizmoViewcubeStory = () => {
const controlsRef = React.useRef<OrbitControls>()
const { scene } = useGLTF('LittlestTokyo.glb')

return (
<>
<primitive object={scene} scale={[0.01, 0.01, 0.01]} />
<GizmoHelper
alignment={select('alignment', ['top-left', 'top-right', 'bottom-right', 'bottom-left'], 'bottom-right')}
margin={[number('marginX', 80), number('marginY', 80)]}
onTarget={() => controlsRef?.current?.target as Vector3}
onUpdate={() => controlsRef.current?.update!()}
>
<GizmoViewcube />
</GizmoHelper>

<OrbitControls ref={controlsRef} />
</>
)
}

export const DefaultStory = () => (
<React.Suspense fallback={null}>
<GizmoViewcubeStory />
</React.Suspense>
)

DefaultStory.storyName = 'Default'
49 changes: 49 additions & 0 deletions .storybook/stories/GizmoViewport.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { color, number, select, withKnobs } from '@storybook/addon-knobs'
import * as React from 'react'
import { Vector3 } from 'three'
import { GizmoViewport, GizmoHelper, OrbitControls, useGLTF } from '../../src'
import { Setup } from '../Setup'

export default {
title: 'Gizmos/GizmoViewport',
component: GizmoViewport,
decorators: [
(storyFn) => (
<Setup controls={false} cameraPosition={new Vector3(0, 0, 10)}>
{storyFn()}
</Setup>
),
withKnobs,
],
}

const GizmoViewportStory = () => {
const controlsRef = React.useRef<OrbitControls>()
const { scene } = useGLTF('LittlestTokyo.glb')

return (
<>
<primitive object={scene} scale={[0.01, 0.01, 0.01]} />
<GizmoHelper
alignment={select('alignment', ['top-left', 'top-right', 'bottom-right', 'bottom-left'], 'bottom-right')}
margin={[number('marginX', 80), number('marginY', 80)]}
onTarget={() => controlsRef?.current?.target as Vector3}
onUpdate={() => controlsRef.current?.update!()}
>
<GizmoViewport
axisColors={[color('colorX', 'red'), color('colorY', 'green'), color('colorZ', 'blue')]}
labelColor={color('labelColor', 'black')}
/>
</GizmoHelper>

<OrbitControls ref={controlsRef} />
</>
)
}
export const DefaultStory = () => (
<React.Suspense fallback={null}>
<GizmoViewportStory />
</React.Suspense>
)

DefaultStory.storyName = 'Default'
11 changes: 4 additions & 7 deletions .storybook/stories/useCamera.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function UseCameraScene() {
const virtualCam = React.useRef<THREE.Camera>(null!)
const ref = React.useRef<THREE.Mesh>()

const [hover, set] = React.useState<null | number>(null)
const [hover, setHover] = React.useState<null | number>(null)

const { gl, scene, camera } = useThree()

Expand All @@ -39,16 +39,13 @@ function UseCameraScene() {
gl.render(virtualScene, virtualCam.current)
}, 1)

const handlePointerOut = () => setHover(null)
const handlePointerMove = (e: THREE.Event) => setHover(Math.floor(e.faceIndex ?? 0 / 2))
return (createPortal(
<>
<OrthographicCamera ref={virtualCam} makeDefault={false} position={[0, 0, 100]} zoom={2} />

<mesh
ref={ref}
raycast={useCamera(virtualCam)}
onPointerOut={() => set(null)}
onPointerMove={(e) => set(Math.floor(e.faceIndex ?? 0 / 2))}
>
<mesh ref={ref} raycast={useCamera(virtualCam)} onPointerOut={handlePointerOut} onPointerMove={handlePointerMove}>
{[...Array(6)].map((_, index) => (
<meshLambertMaterial attachArray="material" key={index} color="hotpink" wireframe={hover !== index} />
))}
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ The `native` route of the library **does not** export `Html` or `Loader`. The de
<li><a href="#line">Line</a></li>
<li><a href="#positionalaudio">PositionalAudio</a></li>
<li><a href="#billboard">Billboard</a></li>
<li><a href="#gizmoHelper">GizmoHelper</a></li>
<li><a href="#environment">Environment</a></li>
<li><a href="#effects">Effects</a></li>
<li><a href="#useanimations">useAnimations</a></li>
Expand Down Expand Up @@ -336,6 +337,27 @@ Adds a `<Plane />` that always faces the camera.
/>
```

#### GizmoHelper

Used by widgets that visualize and control camera position.

Two example gizmos are included: GizmoViewport and GizmoViewcube, and `useGizmoContext` makes it easy to create your own.

```jsx
<GizmoHelper
alignment="bottom-right" // widget alignment within scene
margin={[80, 80]} // widget margins (X, Y)
onUpdate={/* called during camera animation */}
onTarget={/* return current camera target (e.g. from orbit controls) to center animation */}
>
<GizmoViewport
axisColors={['red', 'green', 'blue']}
labelColor="black"
/>
{/* alternative: <GizmoViewcube /> */}
</GizmoHelper>
```

#### Environment

[![](https://img.shields.io/badge/-storybook-%23ff69b4)](https://drei.pmnd.rs/?path=/story/abstractions-environment--environment-st)
Expand Down
118 changes: 118 additions & 0 deletions src/core/GizmoHelper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import * as React from 'react'
import { createPortal, useFrame, useThree } from 'react-three-fiber'
import { Camera, Group, Intersection, Matrix4, Object3D, Quaternion, Raycaster, Scene, Vector3 } from 'three'
import { OrthographicCamera } from './OrthographicCamera'
import { useCamera } from './useCamera'

type GizmoHelperContext = {
tweenCamera: (direction: Vector3) => void
raycast: (raycaster: Raycaster, intersects: Intersection[]) => void
}

const Context = React.createContext<GizmoHelperContext>({} as GizmoHelperContext)

export const useGizmoContext = () => {
return React.useContext<GizmoHelperContext>(Context)
}

const turnRate = 2 * Math.PI // turn rate in angles per second
const dummy = new Object3D()
const matrix = new Matrix4()
const [q1, q2] = [new Quaternion(), new Quaternion()]
const target = new Vector3()
const targetPosition = new Vector3()
const targetQuaternion = new Quaternion()

export type GizmoHelperProps = JSX.IntrinsicElements['group'] & {
alignment?: 'top-left' | 'top-right' | 'bottom-right' | 'bottom-left'
margin?: [number, number]
onUpdate: () => void // update controls during animation
onTarget: () => Vector3 // return the target to rotate around
}

export const GizmoHelper = ({
alignment = 'bottom-right',
margin = [80, 80],
onUpdate,
onTarget,
children: GizmoHelperComponent,
}: GizmoHelperProps): any => {
const { gl, camera: mainCamera, size } = useThree()
const gizmoRef = React.useRef<Group>()
const virtualCam = React.useRef<Camera>(null!)
const [virtualScene] = React.useState(() => new Scene())

const animating = React.useRef(false)
const radius = React.useRef(0)
const focusPoint = React.useRef(new Vector3(0, 0, 0))

const tweenCamera = (direction: Vector3) => {
animating.current = true
focusPoint.current = onTarget()
radius.current = mainCamera.position.distanceTo(target)

// Rotate from current camera orientation
dummy.position.copy(target)
dummy.lookAt(mainCamera.position)
q1.copy(dummy.quaternion)

// To new current camera orientation
targetPosition.copy(direction).multiplyScalar(radius.current).add(target)
dummy.lookAt(targetPosition)
q2.copy(dummy.quaternion)
}

const animateStep = (delta: number) => {
if (!animating.current) return

const step = delta * turnRate

// animate position by doing a slerp and then scaling the position on the unit sphere
q1.rotateTowards(q2, step)
mainCamera.position.set(0, 0, 1).applyQuaternion(q1).multiplyScalar(radius.current).add(focusPoint.current)

// animate orientation
mainCamera.quaternion.rotateTowards(targetQuaternion, step)
mainCamera.updateProjectionMatrix()
onUpdate && onUpdate()

if (q1.angleTo(q2) < 0.01) {
animating.current = false
}
}

const beforeRender = () => {
// Sync gizmo with main camera orientation
matrix.copy(mainCamera.matrix).invert()
gizmoRef.current?.quaternion.setFromRotationMatrix(matrix)
}

useFrame((_, delta) => {
if (virtualCam.current && gizmoRef.current) {
animateStep(delta)
beforeRender()
gl.autoClear = false
gl.clearDepth()
gl.render(virtualScene, virtualCam.current)
}
})

const gizmoHelperContext = {
tweenCamera,
raycast: useCamera(virtualCam),
}

// Position gizmo component within scene
const [marginX, marginY] = margin
const x = alignment.endsWith('-left') ? -size.width / 2 + marginX : size.width / 2 - marginX
const y = alignment.startsWith('top-') ? size.height / 2 - marginY : -size.height / 2 + marginY
return createPortal(
<Context.Provider value={gizmoHelperContext}>
<OrthographicCamera ref={virtualCam} makeDefault={false} position={[0, 0, 100]} />
<group ref={gizmoRef} position={[x, y, 0]}>
{GizmoHelperComponent}
</group>
</Context.Provider>,
virtualScene
)
}
Loading

1 comment on commit 54b2953

@vercel
Copy link

@vercel vercel bot commented on 54b2953 Feb 27, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.