lume
Version:
597 lines (534 loc) • 17.6 kB
text/typescript
// TODO the interaction in here can be separated into a DragFling class, then
// this class can apply DragFling to X and Y rotations. We can use DragFling for
// implementing a scrollable area.
import {onCleanup} from 'solid-js'
import html from 'solid-js/html'
import {signal, syncSignals} from 'classy-solid'
import {element, numberAttribute, booleanAttribute, type ElementAttributes} from '@lume/element'
import {autoDefineElements} from '../LumeConfig.js'
import {Element3D, type Element3DAttributes} from '../core/Element3D.js'
import {FlingRotation, ScrollFling, PinchFling} from '../interaction/index.js'
import {defaultScenePerspective} from '../constants.js'
import type {PerspectiveCamera} from './PerspectiveCamera.js'
export type CameraRigAttributes =
| Element3DAttributes
| 'verticalAngle'
| 'minVerticalAngle'
| 'maxVerticalAngle'
| 'horizontalAngle'
| 'minHorizontalAngle'
| 'maxHorizontalAngle'
| 'distance'
| 'minDistance'
| 'maxDistance'
| 'active'
| 'dollySpeed'
| 'interactive'
| 'rotationSpeed'
| 'dynamicDolly'
| 'dynamicRotation'
| 'dollyEpsilon'
| 'dollyScrollLerp'
| 'dollyPinchSlowdown'
| 'rotationEpsilon'
| 'rotationSlowdown'
| 'initialPolarAngle' // deprecated
| 'minPolarAngle' // deprecated
| 'maxPolarAngle' // deprecated
| 'initialDistance' // deprecated
// TODO allow overriding the camera props, and make the default camera overridable via <slot>
/**
* @class CameraRig
*
* Element: `<lume-camera-rig>`
*
* The [`<lume-camera-rig>`](./CameraRig) element is much like a real-life
* camera rig that contains a camera on it: it has controls to allow the user to
* rotate and dolly the camera around in physical space more easily, in a
* particular and specific. In the following example, try draging to rotate,
* scrolling to zoom:
*
* <live-code id="example"></live-code>
*
* ## Slots
*
* - default (no name): Allows children of the camera rig to render as children
* of the camera rig, like with elements that don't have a ShadowDOM.
* - `camera-child`: Allows children of the camera rig to render relative to the
* camera rig's underlying camera.
*/
export
class CameraRig extends Element3D {
/**
* @property {true} hasShadow
*
* *override* *readonly*
*
* This is `true` because this element has a `ShadowRoot` with the mentioned
* [`slots`](#slots).
*/
override readonly hasShadow: true = true
/**
* @property {number} verticalAngle
*
* *attribute*
*
* Default: `0`
*
* The vertical angle of the camera (rotation around a horizontal axis). When the user drags up or
* down, the camera will move up and down as it rotates around the center.
* The camera is always looking at the center.
*/
verticalAngle = 0
/**
* @deprecated initialPolarAngle has been renamed to verticalAngle.
* @property {number} initialPolarAngle
*
* *deprecated*: initialPolarAngle has been renamed to verticalAngle.
*/
get initialPolarAngle() {
return this.verticalAngle
}
set initialPolarAngle(value) {
this.verticalAngle = value
}
/**
* @property {number} minVerticalAngle
*
* *attribute*
*
* Default: `-90`
*
* The lowest angle that the camera will rotate vertically.
*/
minVerticalAngle = -90
/**
* @deprecated minPolarAngle has been renamed to minVerticalAngle.
* @property {number} minPolarAngle
*
* *deprecated*: minPolarAngle has been renamed to minVerticalAngle.
*/
get minPolarAngle() {
return this.minVerticalAngle
}
set minPolarAngle(value) {
this.minVerticalAngle = value
}
/**
* @property {number} maxVerticalAngle
*
* *attribute*
*
* Default: `90`
*
* The highest angle that the camera will rotate vertically.
*
* <live-code id="verticalExample"></live-code>
*
* <script>
* example.content = cameraRigExample
* verticalExample.content = cameraRigVerticalRotationExample
* </script>
*/
maxVerticalAngle = 90
/**
* @deprecated maxPolarAngle has been renamed to maxVerticalAngle.
* @property {number} maxPolarAngle
*
* *deprecated*: maxPolarAngle has been renamed to maxVerticalAngle.
*/
get maxPolarAngle() {
return this.maxVerticalAngle
}
set maxPolarAngle(value) {
this.maxVerticalAngle = value
}
/**
* @property {number} horizontalAngle
*
* *attribute*
*
* Default: `0`
*
* The horizontal angle of the camera (rotation around a vertical axis). When the user drags left or
* right, the camera will move left or right as it rotates around the center.
* The camera is always looking at the center.
*/
horizontalAngle = 0
/**
* @property {number} minHorizontalAngle
*
* *attribute*
*
* Default: `-Infinity`
*
* The smallest angle that the camera will be allowed to rotate to
* horizontally. The default of `-Infinity` means the camera will rotate
* laterally around the focus point indefinitely.
*/
minHorizontalAngle = -Infinity
/**
* @property {number} maxHorizontalAngle
*
* *attribute*
*
* Default: `Infinity`
*
* The largest angle that the camera will be allowed to rotate to
* horizontally. The default of `Infinity` means the camera will rotate
* laterally around the focus point indefinitely.
*/
maxHorizontalAngle = Infinity
/**
* @property {number} distance
*
* *attribute*
*
* Default: `-1`
*
* The distance that the camera will be away from the center point.
* When the performing a scroll gesture, the camera will zoom by moving
* towards or away from the center point (i.e. dollying).
*
* A value of `-1` means automatic distance based on the current scene's
* [`.perspective`](../core/Scene#perspective), matching the behavior of
* [CSS `perspective`](https://developer.mozilla.org/en-US/docs/Web/CSS/perspective).
*/
distance = -1
accessor #appliedDistance = defaultScenePerspective
/**
* @deprecated initialDistance has been renamed to distance.
* @property {number} initialDistance
*
* *deprecated*: initialDistance has been renamed to distance.
*/
get initialDistance() {
return this.distance
}
set initialDistance(value) {
this.distance = value
}
/**
* @property {number} minDistance
*
* *attribute*
*
* Default: `-1`
*
* The smallest distance (a non-zero value) the camera can get to the center point when zooming
* by scrolling.
*
* A value of `-1` means the value will automatically be half of whatever
* the [`.distance`](#distance) value is.
*/
minDistance = -1
accessor #appliedMinDistance = 200
/**
* @property {number} maxDistance
*
* *attribute*
*
* Default: `-1`
*
* The largest distance (a non-zero value) the camera can get from the
* center point when zooming out by scrolling or with pinch gesture.
*
* A value of `-1` means the value will automatically be double of whatever
* the [`.distance`](#distance) value is.
*/
maxDistance = -1
accessor #appliedMaxDistance = 800
/**
* @property {boolean} active
*
* *attribute*
*
* Default: `true`
*
* When `true`, the underlying camera is set to [`active`](./PerspectiveCamera#active).
*/
active = true
/**
* @property {number} dollySpeed
*
* *attribute*
*
* Default: `1`
*/
dollySpeed = 1
/**
* @property {boolean} interactive
*
* *attribute*
*
* Default: `true`
*
* When `false`, user interaction (ability to zoom or rotate the camera) is
* disabled, but the camera rig can still be manipulated programmatically.
*/
interactive = true
/**
* @property {number} rotationSpeed
*
* *attribute*
*
* Default: `1`
*
* How much the camera rotates while dragging.
*/
rotationSpeed = 1
/**
* @property {boolean} dynamicDolly
*
* *attribute*
*
* Default: `false`
*
* When `true`, the effective dolly speed will be changed based on the
* camera's distance to `minDistance`. Getting closer to `minDistance` will
* lower the effective dolly speed towards zero. This is useful when zoomed
* into an object and having the dolly movements not be disproportionately
* huge while viewing fine details of the object.
*/
dynamicDolly = false
/**
* @property {boolean} dynamicRotation
*
* *attribute*
*
* Default: `false`
*
* When `true`, the effective rotation speed will be changed based on the
* camera's distance to `minDistance`. Getting closer to `minDistance` will
* lower the effective rotation speed to allow for finer control. This is useful
* zoomed in to see fine details of an object and having the rotation not be
* disproportionately huge, for example when zooming into a 3D globe.
*/
dynamicRotation = false
/**
* @property {number} dollyEpsilon
*
* *attribute*
*
* Default: `0.01`
*
* The threshold for when to stop dolly smoothing animation (lerp). When the
* delta between actual dolly position and target dolly position is below
* this number, animation stops. Set this to a high value to prevent
* smoothing.
*/
dollyEpsilon = 0.01
/**
* @property {number} dollyScrollLerp
*
* *attribute*
*
* Default: `0.3`
*
* The portion to lerp towards the dolly target position each frame after
* scrolling to dolly the camera. Between 0 and 1.
*/
dollyScrollLerp = 0.3
/**
* @property {number} dollyPinchSlowdown
*
* *attribute*
*
* Default: `0.05`
*
* Portion of the dolly speed to remove each frame to slow down the dolly
* animation after pinching to dolly the camera, i.e. how much to lerp
* towards zero motion. Between 0 and 1.
*/
dollyPinchSlowdown = 0.05
/**
* @property {number} rotationEpsilon
*
* *attribute*
*
* Default: `0.01`
*
* The threshold for when to stop intertial rotation slowdown animation.
* When the current frame's change in rotation goes below this number,
* animation stops. Set this to a high value to prevent inertial slowdown.
*/
rotationEpsilon = 0.01
/**
* @property {number} rotationSlowdown
*
* *attribute*
*
* Default: `0.05`
*
* Portion of the rotational speed to remove each frame to slow down the
* rotation after dragging to rotate the camera, i.e. how much to lerp
* towards zero motion. Between 0 and 1.
*/
rotationSlowdown = 0.05
// TODO really bad name, its not the underlying three camera, but the lume camera.
threeCamera?: PerspectiveCamera
/** @deprecated Use `.threeCamera` instead. */
get cam() {
return this.threeCamera
}
rotationYTarget?: Element3D
rotationXTarget?: Element3D
flingRotation = new FlingRotation()
scrollFling = new ScrollFling()
pinchFling = new PinchFling()
get #derivedInputDistance() {
return this.distance !== -1 ? this.distance : this.scene?.perspective ?? defaultScenePerspective
}
override connectedCallback() {
super.connectedCallback()
this.createEffect(() => {
// We start interaction if we have a scene (we're in the composed
// tree) and have the needed DOM nodes.
if (!(this.scene && this.rotationYTarget && this.rotationXTarget && this.threeCamera)) return
// TODO replace with @memo once that's out in classy-solid
this.createEffect(() => {
this.#appliedDistance = this.#derivedInputDistance
this.#appliedMinDistance = this.minDistance !== -1 ? this.minDistance : this.#derivedInputDistance / 2
this.#appliedMaxDistance = this.maxDistance !== -1 ? this.maxDistance : this.#derivedInputDistance * 2
})
// We set position here instead of in the template, otherwise
// pre-upgrade values from the template running before element
// upgrade (due to how Solid templates using cloneNode making them
// non-upgraded until connected) will override the initial
// appliedDistance value.
this.createEffect(() => (this.threeCamera!.position.z = this.#appliedDistance))
const {scrollFling, pinchFling, flingRotation} = this
flingRotation.interactionInitiator = this.scene
flingRotation.interactionContainer = this.scene
flingRotation.rotationYTarget = this.rotationYTarget
flingRotation.rotationXTarget = this.rotationXTarget
scrollFling.target = this.scene
pinchFling.target = this.scene
// Sync appliedDistance to scrollFling.y and vice versa
syncSignals(
() => this.#appliedDistance,
(d: number) => (this.#appliedDistance = d),
() => this.scrollFling!.y,
(y: number) => (this.scrollFling!.y = y),
)
// Sync scrollFling.y to pinchFling.x and vice versa
syncSignals(
() => this.scrollFling.y,
(y: number) => (this.scrollFling.y = y),
() => this.pinchFling.x,
(x: number) => (this.pinchFling.x = x),
)
this.createEffect(() => {
flingRotation.minFlingRotationX = this.minVerticalAngle
flingRotation.maxFlingRotationX = this.maxVerticalAngle
flingRotation.minFlingRotationY = this.minHorizontalAngle
flingRotation.maxFlingRotationY = this.maxHorizontalAngle
flingRotation.factor = this.rotationSpeed
flingRotation.epsilon = this.rotationEpsilon
flingRotation.slowdownAmount = this.rotationSlowdown
scrollFling.minY = pinchFling.minX = this.#appliedMinDistance
scrollFling.maxY = pinchFling.maxX = this.#appliedMaxDistance
scrollFling.sensitivity = pinchFling.sensitivity = this.dollySpeed
scrollFling.epsilon = pinchFling.epsilon = this.dollyEpsilon
scrollFling.lerpAmount = this.dollyScrollLerp
pinchFling.slowdownAmount = this.dollyPinchSlowdown
})
this.createEffect(() => {
if (!this.dynamicDolly) return
// Dolly speed when position is at minDistance
const minDollySpeed = 0.001
// Dolly speed when position is at maxDistance
const maxDollySpeed = 2 * this.dollySpeed
// Scroll sensitivity is linear between min/max dolly speed and min/max distance.
const sens =
((maxDollySpeed - minDollySpeed) / (this.maxDistance - this.minDistance)) *
(this.threeCamera!.position.z - this.minDistance) +
minDollySpeed
scrollFling.sensitivity = sens < minDollySpeed ? minDollySpeed : sens
})
this.createEffect(() => {
if (!this.dynamicRotation) return
// This only depends on the size of the scene and the FOV of the camera. The only
// issue is the camera's FOV is not reactive and is set by the scene at some point.
// In the case where the camera's FOV is not set yet, use the scene's perspective.
const perspective = this.threeCamera!.three.fov
? this.scene!.calculatedSize.y / 2 / Math.tan((this.threeCamera!.three.fov * Math.PI) / 360)
: this.scene!.perspective
// Plane positioned at origin facing camera with width equal to `minDistance`.
// `minDistance` is doubled because the expected `minDistance` should barely touch
// the object, whose size would be double `minDistance`.
const planeSize = (perspective * (this.minDistance * 2)) / this.threeCamera!.position.z
const degreesPerPixel = 180 / planeSize
// Counteract the FlingRotation's delta modifier to get exact angular movement.
const sens = (1 / 0.15) * degreesPerPixel * this.rotationSpeed
this.flingRotation.factor = sens <= 0 ? 1 : sens
})
this.createEffect(() => {
if (this.interactive && !this.pinchFling?.interacting) flingRotation.start()
else flingRotation.stop()
})
this.createEffect(() => {
if (this.interactive) {
scrollFling.start()
pinchFling.start()
} else {
scrollFling.stop()
pinchFling.stop()
}
})
onCleanup(() => {
this.flingRotation.stop()
this.scrollFling.stop()
this.pinchFling.stop()
})
})
}
override template = () => html`
<lume-element3d
id="cameraY"
ref=${(el: Element3D) => (this.rotationYTarget = el)}
size="1 1 1"
size-mode="proportional proportional proportional"
rotation=${() => [0, this.horizontalAngle, 0]}
>
<lume-element3d
id="cameraX"
ref=${(el: Element3D) => (this.rotationXTarget = el)}
size="1 1 1"
rotation=${() => [this.verticalAngle, 0, 0]}
size-mode="proportional proportional proportional"
>
<slot
name="camera"
TODO="determine semantics for overriding the internal camera (this slot is not documented yet)"
>
<lume-perspective-camera
ref=${(cam: PerspectiveCamera) => (this.threeCamera = cam)}
id="camera-rig-perspective-camera"
active=${() => this.active}
comment="We don't set position here because it triggers the pre-upgrade handling due to the template running before perspective-camera is upgraded (due to Solid specifics) which causes the initial value to override the initial position calculated from scene.perspective."
xposition=${() => [0, 0, this.#appliedDistance]}
align-point="0.5 0.5 0.5"
far="100000"
>
<slot name="camera-child"></slot>
</lume-perspective-camera>
</slot>
</lume-element3d>
</lume-element3d>
<slot></slot>
`
}
declare module 'solid-js' {
namespace JSX {
interface IntrinsicElements {
'lume-camera-rig': ElementAttributes<CameraRig, CameraRigAttributes>
}
}
}
declare global {
interface HTMLElementTagNameMap {
'lume-camera-rig': CameraRig
}
}