浏览代码

Add CameraView, CameraViewPlugin, some refactor in ICamera and PerspectiveCamera2. Add animateCamera in PopmotionPlugin. Add some utils for animation, cameras.

master
Palash Bansal 2 年前
父节点
当前提交
10dd8553ab
没有帐户链接到提交者的电子邮件

+ 70
- 0
README.md 查看文件

@@ -94,6 +94,7 @@ To make changes and run the example, click on the CodePen button on the top righ
- [PickingPlugin](#pickingplugin) - Adds support for selecting objects in the viewer with user interactions and selection widgets
- [GLTFAnimationPlugin](#gltfanimationplugin) - Add support for playing and seeking gltf animations
- [PopmotionPlugin](#popmotionplugin) - Integrates with popmotion.io library for animation/tweening
- [CameraViewPlugin](#cameraviewplugin) - Add support for saving, loading, animating, looping between camera views
- [RenderTargetPreviewPlugin](#rendertargetpreviewplugin) - Preview any render target in a UI panel over the canvas
- [GeometryUVPreviewPlugin](#geometryuvpreviewplugin) - Preview UVs of any geometry in a UI panel over the canvas
- [FrameFadePlugin](#framefadeplugin) - Post-render pass to smoothly fade to a new rendered frame over time
@@ -1749,6 +1750,8 @@ camera.deactivateMain()

[`camera.deactivateMain`](https://threepipe.org/docs/classes/PerspectiveCamera2.html#deactivateMain) - Deactivate the camera as the main camera.

See also [CameraViewPlugin](#cameraviewplugin) for camera focus animation.

## AssetManager

Source Code: [src/assetmanager/AssetManager.ts](./src/assetmanager/AssetManager.ts)
@@ -2234,6 +2237,73 @@ await popmotion.animateAsync({ // Also await for the animation.

Note: The animation is started when the animate or animateAsync function is called.

## CameraViewPlugin

[//]: # (todo: image)

Example: https://threepipe.org/examples/#camera-view-plugin/

Source Code: [src/plugins/animation/CameraViewPlugin.ts](./src/plugins/ui/RenderTargetPreviewPlugin.ts)

API Reference: [CameraViewPlugin](https://threepipe.org/docs/classes/CameraViewPlugin.html)

CameraViewPlugin adds support to save and load camera views, which can then be animated to.
It uses PopmotionPlugin internally to animate any camera to a saved view or to loop through all the saved views.

It also provides a UI to manage the views.

```typescript
import {CameraViewPlugin, ThreeViewer, CameraView, Vector3, Quaternion, EasingFunctions, timeout} from 'threepipe'

const viewer = new ThreeViewer({...})

const cameraViewPlugin = viewer.addPluginSync(new CameraViewPlugin())

const intialView = cameraViewPlugin.getView()
// or = viewer.scene.mainCamera.getView()

// create a new view
const view = new CameraView(
'My View', // name
new Vector3(0, 0, 10), // position
new Vector3(0, 0, 0), // target
new Quaternion(0, 0, 0, 1), // quaternion rotation
1 // zoom
)

// or clone a view
const view2 = intialView.clone()
view2.position.add(new Vector3(0, 5, 0)) // move up 5 units

// animate the main camera to a view
await cameraViewPlugin.animateToView(
view,
2000, // in ms, = 2sec
EasingFunctions.easeInOut,
).catch(()=>console.log('Animation stopped'))

// stop any/all animations
cameraViewPlugin.stopAllAnimations()

// add views to the plugin
cameraViewPlugin.addView(view)
cameraViewPlugin.addView(view2)
cameraViewPlugin.addView(intialView)
cameraViewPlugin.addCurrentView() // adds the current view of the main camera

// loop through all the views once
cameraViewPlugin.animDuration = 2000 // default duration
cameraViewPlugin.animEase = EasingFunctions.easeInOutSine // default easing
await cameraViewPlugin.animateAllViews()

// loop through all the views forever
cameraViewPlugin.viewLooping = true
await timeout(10000) // wait for some time
// stop looping
cameraViewPlugin.viewLooping = false

```


## RenderTargetPreviewPlugin


+ 36
- 0
examples/camera-view-plugin/index.html 查看文件

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Camera View Plugin</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Import maps polyfill -->
<!-- Remove this when import maps will be widely supported -->
<script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script>

<script type="importmap">
{
"imports": {
"threepipe": "./../../dist/index.mjs",
"@threepipe/plugin-tweakpane": "./../../plugins/tweakpane/dist/index.mjs"
}
}

</script>
<style id="example-style">
html, body, #canvas-container, #mcanvas {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
</style>
<script type="module" src="../examples-utils/simple-code-preview.mjs"></script>
<script id="example-script" type="module" src="./script.js" data-scripts="./script.ts;./script.js"></script>
</head>
<body>
<div id="canvas-container">
<canvas id="mcanvas"></canvas>
</div>

</body>

+ 79
- 0
examples/camera-view-plugin/script.ts 查看文件

@@ -0,0 +1,79 @@
import {_testFinish, CameraView, CameraViewPlugin, EasingFunctions, ThreeViewer, Vector3} from 'threepipe'
import {TweakpaneUiPlugin} from '@threepipe/plugin-tweakpane'
import {createSimpleButtons} from '../examples-utils/simple-bottom-buttons.js'

async function init() {

const viewer = new ThreeViewer({
canvas: document.getElementById('mcanvas') as HTMLCanvasElement,
})
const cameraViewPlugin = viewer.addPluginSync(CameraViewPlugin)
console.log(cameraViewPlugin)

await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr')

await viewer.load('https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf', {
autoCenter: true,
autoScale: true,
})

// Get the current camera view and save it in a variable
const initialView = cameraViewPlugin.getView()

const topView = new CameraView(
'topView',
new Vector3(0, 6, 0),
initialView.target,
)

const leftView = new CameraView(
'leftView',
new Vector3(-6, 0, 0),
initialView.target,
)

const rightView = new CameraView(
'leftView',
new Vector3(6, 0, 0),
initialView.target,
)

createSimpleButtons({
['Top View']: async() => cameraViewPlugin.animateToView(topView, 1000, EasingFunctions.easeInOutSine),
['Left View']: async() => cameraViewPlugin.animateToView(leftView, 1000, EasingFunctions.easeInOutSine),
['Right View']: async() => cameraViewPlugin.animateToView(rightView, 1000, EasingFunctions.easeInOutSine),

['Pan right/left']: async(btn) => {
btn.disabled = true
const currentView = cameraViewPlugin.getView()
await cameraViewPlugin.animateToView(new CameraView(
'view',
currentView.position,
new Vector3(4, 0, 0).sub(currentView.target),
))
btn.disabled = false
},
['Move up/down']: async(btn) => {
btn.disabled = true
const currentView = cameraViewPlugin.getView()
await cameraViewPlugin.animateToView(new CameraView(
'view',
new Vector3(currentView.position.x, 5 - currentView.position.y, currentView.position.z),
currentView.target,
))
btn.disabled = false
},

['Reset']: async() => cameraViewPlugin.animateToView(initialView, 1000, EasingFunctions.easeInOutSine),
})

const ui = viewer.addPluginSync(TweakpaneUiPlugin, true)
ui.appendChild(viewer.scene.mainCamera.uiConfig)

const uiC = ui.setupPluginUi(CameraViewPlugin)!
uiC.expanded = true
uiC.uiRefresh?.()

}

init().then(_testFinish)

+ 4
- 1
examples/tweakpane-editor/script.ts 查看文件

@@ -1,5 +1,6 @@
import {
_testFinish,
CameraViewPlugin,
DepthBufferPlugin,
DropzonePlugin,
FrameFadePlugin,
@@ -45,6 +46,7 @@ async function init() {
await viewer.addPlugins([
new ProgressivePlugin(),
new GLTFAnimationPlugin(),
new CameraViewPlugin(),
new ViewerUiConfigPlugin(),
// new SceneUiConfigPlugin(), // this is already in ViewerUiPlugin
new DepthBufferPlugin(HalfFloatType, true, true),
@@ -68,10 +70,11 @@ async function init() {
['Viewer']: [ViewerUiConfigPlugin, SceneUiConfigPlugin, DropzonePlugin, FullScreenPlugin],
['GBuffer']: [DepthBufferPlugin, NormalBufferPlugin],
['Post-processing']: [TonemapPlugin, ProgressivePlugin, FrameFadePlugin],
['Animation']: [GLTFAnimationPlugin],
['Animation']: [GLTFAnimationPlugin, CameraViewPlugin],
['Debug']: [RenderTargetPreviewPlugin],
})

viewer.scene.addObject(new HemisphereLight(0xffffff, 0x444444, 5))
await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr')

// const result = await viewer.load<IObject3D>('https://cdn.jsdelivr.net/gh/KhronosGroup/glTF-Blender-Exporter@master/polly/project_polly.gltf', {

+ 38
- 6
src/core/ICamera.ts 查看文件

@@ -2,6 +2,7 @@ import {Camera, Vector3} from 'three'
import {IObject3D, IObject3DEvent, IObject3DEventTypes, IObject3DUserData, IObjectSetDirtyOptions} from './IObject'
import {IShaderPropertiesUpdater} from '../materials'
import {ICameraControls, TControlsCtor} from './camera/ICameraControls'
import {CameraView, ICameraView} from './camera/CameraView'

/**
* Available modes for {@link ICamera.controlsMode} property.
@@ -27,10 +28,10 @@ export interface ICamera<E extends ICameraEvent = ICameraEvent, ET extends ICame
readonly isCamera: true
setDirty(options?: ICameraSetDirtyOptions): void;

near: number;
far: number;

readonly isMainCamera: boolean;
readonly isPerspectiveCamera?: boolean;
readonly isOrthographicCamera?: boolean;

activateMain(options?: Partial<ICameraEvent>, _internal?: boolean, _refresh?: boolean): void;
deactivateMain(options?: Partial<ICameraEvent>, _internal?: boolean, _refresh?: boolean): void;

@@ -53,9 +54,15 @@ export interface ICamera<E extends ICameraEvent = ICameraEvent, ET extends ICame
*/
position: Vector3,

// todo: make disable/enable functions with key like in FrameFadePlugin
interactionsEnabled: boolean;
readonly canUserInteract: boolean;

/**
* Check whether user can interact with this camera.
* Interactions can be enabled/disabled in a variety of ways,
* like {@link interactionsEnabled}, {@link controlsMode}, {@link isMainCamera} property
*/
readonly canUserInteract: boolean;

zoom: number;
/**
@@ -72,6 +79,16 @@ export interface ICamera<E extends ICameraEvent = ICameraEvent, ET extends ICame
controlsMode?: TCameraControlsMode; // todo add more.
// controlsEnabled: boolean; // use controlsMode = '' instead


/**
* Automatically managed when {@link autoNearFar} is `true`. See also {@link minNearPlane}
*/
near: number;
/**
* Automatically managed when {@link autoNearFar} is `true`. See also {@link maxFarPlane}
*/
far: number;

// also in userData
autoNearFar: boolean // default = true
minNearPlane: number // default = 0.2
@@ -85,7 +102,6 @@ export interface ICamera<E extends ICameraEvent = ICameraEvent, ET extends ICame
*/
isActiveCamera: boolean;


setControlsCtor(key: string, ctor: TControlsCtor, replace?: boolean): void;
removeControlsCtor(key: string): void;
refreshCameraControls(setDirty?: boolean): void
@@ -93,6 +109,22 @@ export interface ICamera<E extends ICameraEvent = ICameraEvent, ET extends ICame
updateProjectionMatrix(): void
fov?: number

getView<T extends ICameraView = CameraView>(worldSpace?: boolean, cameraView?: T): T
setView(view: ICameraView): void

/**
* Set camera view from another camera.
* @param camera
* @param distanceFromTarget - default = 4
* @param worldSpace - default = true
*/
setViewFromCamera(camera: ICamera|Camera, distanceFromTarget?: number, worldSpace?: boolean): void

/**
* Dispatches the `setView` event which triggers the main camera to set its view to this camera's view.
* @param eventOptions
*/
setViewToMain(eventOptions: Partial<ICameraEvent>): void
// region inherited type fixes
// re-declaring from IObject3D because: https://github.com/microsoft/TypeScript/issues/16936

@@ -102,7 +134,7 @@ export interface ICamera<E extends ICameraEvent = ICameraEvent, ET extends ICame
getObjectById<T extends IObject3D = IObject3D>(id: number): T | undefined
getObjectByName<T extends IObject3D = IObject3D>(name: string): T | undefined
getObjectByProperty<T extends IObject3D = IObject3D>(name: string, value: string): T | undefined
copy(source: this, recursive?: boolean, distanceFromTarget?: number, ...args: any[]): this
copy(source: this, recursive?: boolean, distanceFromTarget?: number, worldSpace?: boolean, ...args: any[]): this
clone(recursive?: boolean): this
add(...object: IObject3D[]): this
remove(...object: IObject3D[]): this

+ 52
- 0
src/core/camera/CameraView.ts 查看文件

@@ -0,0 +1,52 @@
import {Event, EventDispatcher, Quaternion, Vector3} from 'three'
import {onChange, serializable, serialize} from 'ts-browser-helpers'
import {IUiConfigContainer, uiButton, uiInput, uiNumber, UiObjectConfig, uiPanelContainer, uiVector} from 'uiconfig.js'
import {ICamera} from '../ICamera'

export interface ICameraView{
name: string
position: Vector3
target: Vector3
quaternion: Quaternion
zoom: number
animate(camera?: ICamera, duration?: number): void
set(camera?: ICamera): void
}

@serializable('CameraView')
@uiPanelContainer('Camera View')
export class CameraView extends EventDispatcher<Event, 'setView'|'animateView'> implements ICameraView, IUiConfigContainer {
@onChange(CameraView.prototype._nameChanged)
@serialize() @uiInput() name = 'Camera View'

@serialize() @uiVector() position = new Vector3()
@serialize() @uiVector() target = new Vector3()
@serialize() @uiVector() quaternion = new Quaternion()
@serialize() @uiNumber() zoom = 1

@uiButton() set = (camera?: ICamera) => this.dispatchEvent({type: 'setView', camera, view: this})
@uiButton() animate = (camera?: ICamera, duration?: number) => this.dispatchEvent({type: 'animateView', camera, duration, view: this})

constructor(name?: string, position?: Vector3, target?: Vector3, quaternion?: Quaternion, zoom?: number) {
super()
if (name !== undefined) this.name = name
if (position) this.position.copy(position)
if (target) this.target.copy(target)
if (quaternion) this.quaternion.copy(quaternion)
if (zoom !== undefined) this.zoom = zoom
}

private _nameChanged() {
if (this.uiConfig) {
this.uiConfig.label = this.name
this.uiConfig.uiRefresh?.()
}
}

clone() {
return new CameraView(this.name, this.position, this.target, this.quaternion, this.zoom)
}

uiConfig?: UiObjectConfig
// uiConfig = generateUiFolder(this.name, this)
}

+ 56
- 5
src/core/camera/PerspectiveCamera2.ts 查看文件

@@ -10,6 +10,7 @@ import {ThreeSerialization} from '../../utils'
import {iCameraCommons} from '../object/iCameraCommons'
import {bindToValue} from '../../three/utils/decorators'
import {makeICameraCommonUiConfig} from '../object/IObjectUi'
import {CameraView, ICameraView} from './CameraView'

// todo: maybe change domElement to some wrapper/base class of viewer
export class PerspectiveCamera2 extends PerspectiveCamera implements ICamera {
@@ -45,7 +46,7 @@ export class PerspectiveCamera2 extends PerspectiveCamera implements ICamera {
@serialize() focus: number

@onChange3(PerspectiveCamera2.prototype.setDirty)
// @uiSlider('Zoom', [0.001, 20], 0.001)
@uiSlider('FoV Zoom', [0.001, 10], 0.001)
@serialize() zoom: number

@uiVector('Position')
@@ -162,7 +163,7 @@ export class PerspectiveCamera2 extends PerspectiveCamera implements ICamera {
setDirty(options?: ICameraSetDirtyOptions|Event): void {
if (!this._positionWorld) return // class not initialized

if (options?.key === 'fov') this.updateProjectionMatrix()
if (options?.key === 'fov' || options?.key === 'zoom') this.updateProjectionMatrix()

this.getWorldPosition(this._positionWorld)

@@ -316,6 +317,57 @@ export class PerspectiveCamera2 extends PerspectiveCamera implements ICamera {

// endregion

// region camera views

getView<T extends ICameraView = CameraView>(worldSpace = true, _view?: T) {
const up = new Vector3()
this.updateWorldMatrix(true, false)
const matrix = this.matrixWorld
up.x = matrix.elements[4]
up.y = matrix.elements[5]
up.z = matrix.elements[6]
up.normalize()
const view = _view || new CameraView()
view.name = this.name
view.position.copy(this.position)
view.target.copy(this.target)
view.quaternion.copy(this.quaternion)
view.zoom = this.zoom
// view.up.copy(up)
const parent = this.parent
if (parent) {
if (worldSpace) {
view.position.applyMatrix4(parent.matrixWorld)
this.getWorldQuaternion(view.quaternion)
// target, up is already in world space
} else {
up.transformDirection(parent.matrixWorld.clone().invert())
// pos is already in local space
// target should always be in world space
}
}
return view as T
}

setView(view: ICameraView) {
this.position.copy(view.position)
this.target.copy(view.target)
// this.up.copy(view.up)
this.quaternion.copy(view.quaternion)
this.zoom = view.zoom
this.setDirty()
}

setViewFromCamera(camera: Camera|ICamera, distanceFromTarget?: number, worldSpace = true) {
// todo: getView, setView can also be used, do we need copy? as that will copy all the properties
this.copy(camera, undefined, distanceFromTarget, worldSpace)
}

setViewToMain(eventOptions: Partial<ICameraEvent>) {
this.dispatchEvent({type: 'setView', ...eventOptions, camera: this, bubbleToParent: true})
}

// endregion
// region utils/others

// for shader prop updater
@@ -420,14 +472,13 @@ export class PerspectiveCamera2 extends PerspectiveCamera implements ICamera {
}

/**
* @deprecated
* @deprecated - use setDirty directly
* @param setDirty
*/
targetUpdated(setDirty = true): void {
if (setDirty) this.setDirty()
}


// setCameraOptions<T extends Partial<IPerspectiveCameraOptions | IOrthographicCameraOptions>>(value: T, setDirty = true): void {
// const ops: any = {...value}
//
@@ -502,7 +553,7 @@ export class PerspectiveCamera2 extends PerspectiveCamera implements ICamera {
getObjectById: <T extends IObject3D = IObject3D>(id: number) => T | undefined
getObjectByName: <T extends IObject3D = IObject3D>(name: string) => T | undefined
getObjectByProperty: <T extends IObject3D = IObject3D>(name: string, value: string) => T | undefined
copy: (source: ICamera|Camera, recursive?: boolean, distanceFromTarget?: number) => this
copy: (source: ICamera|Camera, recursive?: boolean, distanceFromTarget?: number, worldSpace?: boolean) => this
clone: (recursive?: boolean) => this
add: (...object: IObject3D[]) => this
remove: (...object: IObject3D[]) => this

+ 1
- 0
src/core/index.ts 查看文件

@@ -1,4 +1,5 @@
export {PerspectiveCamera2} from './camera/PerspectiveCamera2'
export {CameraView, type ICameraView} from './camera/CameraView'
export {ExtendedShaderMaterial} from './material/ExtendedShaderMaterial'
export {PhysicalMaterial, type PhysicalMaterialEventTypes, MeshStandardMaterial2} from './material/PhysicalMaterial'
export {ShaderMaterial2} from './material/ShaderMaterial2'

+ 9
- 13
src/core/object/IObjectUi.ts 查看文件

@@ -3,44 +3,40 @@ import {IUiConfigContainer, UiObjectConfig} from 'uiconfig.js'
import {ICamera} from '../ICamera'
import {Vector3} from 'three'

export function makeICameraCommonUiConfig(this: IObject3D, config: UiObjectConfig): UiObjectConfig[] {
export function makeICameraCommonUiConfig(this: ICamera, config: UiObjectConfig): UiObjectConfig[] {
return [
{
type: 'button',
label: 'Set View',
value: ()=>{
// todo: call setView on the camera, which will dispatch the event
(this as ICamera).dispatchEvent({type: 'setView', ui: true, camera: this as ICamera})
config.uiRefresh?.(true, 'postFrame')
console.log('set view', this)
this.setViewToMain({ui: true})
config.uiRefresh?.(true, 'postFrame') // config is parent config
},
},
{
type: 'button',
label: 'Activate main',
hidden: ()=>(this as ICamera)?.isMainCamera,
hidden: ()=>this?.isMainCamera,
value: ()=>{
// todo: call activateMain on the camera, which will dispatch the event
(this as ICamera).dispatchEvent({type: 'activateMain', ui: true, camera: this as ICamera})
this.activateMain({ui: true})
config.uiRefresh?.(true, 'postFrame')
},
},
{
type: 'button',
label: 'Deactivate main',
hidden: ()=>!(this as ICamera)?.isMainCamera,
hidden: ()=>!this?.isMainCamera,
value: ()=>{
// todo: call activateMain on the camera, which will dispatch the event
(this as ICamera).dispatchEvent({type: 'activateMain', ui: true, camera: undefined})
this.deactivateMain({ui: true})
config.uiRefresh?.(true, 'postFrame')
},
},
{
type: 'checkbox',
label: 'Auto LookAt Target',
getValue: ()=>(this as ICamera).userData.autoLookAtTarget ?? false,
getValue: ()=>this.userData.autoLookAtTarget ?? false,
setValue: (v)=>{
(this as ICamera).userData.autoLookAtTarget = v
this.userData.autoLookAtTarget = v
config.uiRefresh?.(true, 'postFrame')
},
},

+ 23
- 7
src/core/object/iCameraCommons.ts 查看文件

@@ -19,11 +19,14 @@ export const iCameraCommons = {
iObjectCommons.setDirty.call(this, {refreshScene: false, ...options})
},
activateMain: function(this: ICamera, options: Partial<ICameraEvent> = {}, _internal = false, _refresh = true): void {
if (!_internal) return this.dispatchEvent({
type: 'activateMain', ...options,
camera: this,
bubbleToParent: true,
}) // this will be used by RootScene to deactivate other cameras and activate this one
if (!_internal) {
if (options.camera === null) return this.deactivateMain(options, _internal, _refresh)
return this.dispatchEvent({
type: 'activateMain', ...options,
camera: this,
bubbleToParent: true,
})
} // this will be used by RootScene to deactivate other cameras and activate this one
if (this.userData.__isMainCamera) return
this.userData.__isMainCamera = true
this.userData.__lastScale = this.scale.clone()
@@ -71,7 +74,7 @@ export const iCameraCommons = {
upgradeCamera: upgradeCamera,

copy: (superCopy: ICamera['copy']): ICamera['copy'] =>
function(this: ICamera, camera: ICamera | Camera, recursive?, distanceFromTarget?, ...args): ICamera {
function(this: ICamera, camera: ICamera | Camera, recursive?, distanceFromTarget?, worldSpace?, ...args): ICamera {
if (!camera.isCamera) {
console.error('ICamera.copy: camera is not a Camera', camera)
return this
@@ -89,9 +92,22 @@ export const iCameraCommons = {
const minDistance = (this.controls as any)?.minDistance ?? distanceFromTarget ?? 4
camera.getWorldDirection(this.target).multiplyScalar(minDistance).add(this.getWorldPosition(new Vector3()))
}


if (worldSpace) { // default = false
const worldPos = camera.getWorldPosition(this.position)
// this.getWorldQuaternion(this.quaternion) // todo: do if autoLookAtTarget is false
// todo up vector
if (this.parent) {
this.position.copy(this.parent.worldToLocal(worldPos))
// this.quaternion.premultiply(this.parent.quaternion.clone().invert())
}
}

this.updateMatrixWorld(true)
this.updateProjectionMatrix()
this.refreshAspect(true)
this.refreshAspect(false)
this.setDirty()
return this
},


+ 0
- 1
src/core/object/iObjectCommons.ts 查看文件

@@ -339,7 +339,6 @@ function upgradeObject3D(this: IObject3D, parent?: IObject3D|undefined, objectPr

if (!this.userData.__autoBubbleToParentEvents) this.userData.__autoBubbleToParentEvents = ['select']
// Event bubbling. todo: set bubbleToParent in these events when dispatched from child and remove from here?
if (this.isCamera) this.userData.__autoBubbleToParentEvents.push('activateMain', 'setView')

if (this.isLight) this.assetType = 'light'
else if (this.isCamera) this.assetType = 'camera'

+ 553
- 0
src/plugins/animation/CameraViewPlugin.ts 查看文件

@@ -0,0 +1,553 @@
import {Object3D, Vector3} from 'three'
import {Easing} from 'popmotion'
import {AViewerPluginSync, ThreeViewer} from '../../viewer'
import {Box3B} from '../../three'
import {onChange, serialize, timeout} from 'ts-browser-helpers'
import {generateUiConfig, uiButton, uiDropdown, uiInput, UiObjectConfig, uiSlider, uiToggle} from 'uiconfig.js'
import {EasingFunctions, EasingFunctionType} from '../../utils'
import {CameraView, ICamera, ICameraView, PerspectiveCamera2} from '../../core'
import {AnimationResult, PopmotionPlugin} from './PopmotionPlugin'

/**
* Camera View Plugin
*
* Provides API to save, interact and animate and loop between with multiple camera states/views using the {@link PopmotionPlugin}.
*
*/
export class CameraViewPlugin extends AViewerPluginSync<'viewChange'|'startViewChange'|'viewAdd'|'viewDelete'> {
static readonly PluginType = 'CameraViews'

enabled = true

// get dirty() { // todo: issue with recorder convergeMode?
// return this._animating
// }

constructor() {
super()
this.addCurrentView = this.addCurrentView.bind(this)
this.resetToFirstView = this.resetToFirstView.bind(this)
this.animateAllViews = this.animateAllViews.bind(this)
// this.recordAllViews = this.recordAllViews.bind(this)
// this._wheel = this._wheel.bind(this)
// this._pointerMove = this._pointerMove.bind(this)
// this._postFrame = this._postFrame.bind(this)
}

@serialize('cameraViews')
private _cameraViews: CameraView[] = []
get cameraViews(): CameraView[] {
return this._cameraViews
}
get camViews(): CameraView[] {
return this._cameraViews
}

@onChange(CameraViewPlugin.prototype._animationLoop)
/**
* Loop all views indefinitely.
*/
@serialize() @uiToggle('Loop All Views') viewLooping = false
/**
* Pauses time between view changes when animating all views or looping.
*/
@serialize() @uiInput('View Pause Time') viewPauseTime = 200

/**
* {@link EasingFunctions}
*/
@serialize() @uiDropdown('Ease', Object.keys(EasingFunctions).map((label:string)=>({label}))) animEase: EasingFunctionType = 'easeInOutSine' // ms
@serialize() @uiSlider('Duration', [10, 10000], 10) animDuration = 1000 // ms
@serialize() @uiSlider('RotationOffset', [0.2, 0.75], 0.01) rotationOffset = 0.25
@serialize() @uiDropdown('Interpolation', ['spherical', 'linear'].map((label:string)=>({label})))
interpolateMode: 'spherical'|'linear' = 'spherical'

private _animating = false
get animating(): boolean {
return this._animating
}

dependencies = [PopmotionPlugin]

// private _updaters: {u: ((timestamp: number) => void), time: number}[] = []
// private _lastFrameTime = 0 // for post frame

onAdded(viewer: ThreeViewer): void {
super.onAdded(viewer)

let interactionsDisabled = false // we need this because interactionsEnabled is also set in PickingPlugin

// todo: move to PopmotionPlugin
// todo: remove event listener
viewer.addEventListener('preFrame', (_: any)=>{
if (/* this.seekOnScroll || */ this._animating) {
if (this._viewer!.scene.mainCamera.interactionsEnabled) {
this._viewer!.scene.mainCamera.interactionsEnabled = false
interactionsDisabled = true
// console.log(interactionsDisabled)
}
} else if (interactionsDisabled) {
this._viewer!.scene.mainCamera.interactionsEnabled = true
interactionsDisabled = false
// console.log(interactionsDisabled)
}

// console.log(ev.deltaTime)

// this._updaters.forEach(u=>{
// let dt = ev.deltaTime
// if (u.time + dt < 0) dt = -u.time
// u.time += dt
// if (Math.abs(dt) > 0.001)
// u.u(dt)
// })

})

// viewer.addEventListener('postFrame', this._postFrame)
// window.addEventListener('wheel', this._wheel)
// window.addEventListener('pointermove', this._pointerMove)

}

onRemove(viewer: ThreeViewer): void {

// viewer.removeEventListener('postFrame', this._postFrame)
// window.removeEventListener('wheel', this._wheel)
// window.removeEventListener('pointermove', this._pointerMove)

return super.onRemove(viewer)
}

@uiButton('Reset To First View')
public async resetToFirstView(duration = 100) {
if (!this.enabled) return
this._currentView = undefined
await this.animateToView(0, duration)
await timeout(2)
}

@uiButton('Add Current View')
async addCurrentView() {
if (!this.enabled) return
const camera = this._viewer?.scene.mainCamera
if (!camera) return
const view = this.getView(camera)
this.addView(view)
view.name = 'View ' + this._cameraViews.length
return view
}

addView(view: CameraView) {
this._cameraViews.push(view)
view.addEventListener('setView', this._viewSetView)
view.addEventListener('animateView', this._viewAnimateView)
this.uiConfig.uiRefresh?.()
this.dispatchEvent({type: 'viewAdd', view})
}

protected _viewSetView = ({view, camera}: {view: CameraView, camera?: ICamera}&any) => {
if (!view) {
this._viewer?.console.warn('Invalid view', view)
return
}
this.setView(view, camera)
}

protected _viewAnimateView = async({view, camera, duration, easing, throwOnStop}: {view: CameraView, camera?: ICamera, duration?: number, easing?: Easing|EasingFunctionType, throwOnStop?: boolean}&any) => {
if (!view) {
this._viewer?.console.warn('Invalid view', view)
return
}
return this.animateToView(view, duration || this.animDuration, easing || this.animEase, camera, throwOnStop)
}

deleteView(view: CameraView) {
const i = this._cameraViews.indexOf(view)
if (i >= 0)
this._cameraViews.splice(i, 1)
this.uiConfig.uiRefresh?.()
this.dispatchEvent({type: 'viewDelete', view})
}

getView(camera?: ICamera, worldSpace = true) {
camera = camera || this._viewer?.scene.mainCamera
if (!camera) return new CameraView()
return camera.getView(worldSpace)
}

setView(view: ICameraView, camera?: ICamera) {
camera = camera || this._viewer?.scene.mainCamera
if (!camera) return
camera.setView(view)
}

private _currentView: CameraView | undefined

@uiButton('Focus Next') focusNext = (wrap = true)=>{
if (this._animating) return
if (this._cameraViews.length < 2) return
let index = this._cameraViews.findIndex(v=>v === this._currentView)
if (index < 0) index = -1 // first view
index = index + 1
if (!wrap) index = Math.min(index, this._cameraViews.length - 1)
else index = index % this._cameraViews.length
this.animateToView(index)
}
@uiButton('Focus Previous') focusPrevious = (wrap = true)=> {
if (this._animating) return
if (this._cameraViews.length < 2 || !this._currentView) return
let index = this._cameraViews.findIndex(v=>v === this._currentView)
if (index < 0) index = 0 // last view
index = index - 1
if (!wrap) index = Math.max(index, 0)
else index = (index + this._cameraViews.length) % this._cameraViews.length
this.animateToView(index)
}

private _popAnimations: AnimationResult[] = []

async animateToView(_view: CameraView|number, duration?: number, easing?: Easing|EasingFunctionType, camera?: ICamera, throwOnStop = false) {
camera = camera || this._viewer?.scene.mainCamera
if (!camera) return
// if (this._currentView === view) return // todo: also check if the camera is at the correct position and orientation, till then use resetToFirstView to reset current view
if (this._animating) {
this._popAnimations.forEach(a=>a?.stop && a.stop()) // don't call stopAllAnimations here, as it sets viewLooping to false and changes config.
this._popAnimations = []
let i = 0
while (this._animating) {
await timeout(100)
if (i++ > 20) { // 2s timeout
break
}
}
if (this._animating) {
console.warn('Unable to stop all animations, maybe because of viewLooping?')
return
}
}
const view = typeof _view === 'number' ? this._cameraViews[_view] : _view

this._currentView = view
this._animating = true
this.dispatchEvent({type: 'startViewChange', view})

const popmotion = this._viewer?.getPlugin(PopmotionPlugin)
if (!popmotion) throw new Error('PopmotionPlugin not found')

if (duration === undefined) duration = this.animDuration
const ease: any = (typeof easing === 'function' ? easing : EasingFunctions[easing || this.animEase]) as (x: number) => number
// const ease = (x:number)=>x
// const driver = this._driver
this._popAnimations = []
await popmotion.animateCameraAsync(camera, view, this.interpolateMode === 'spherical', {ease, duration}, this._popAnimations)
.catch((e)=>{
// console.error(e)
if (throwOnStop) throw e
})

this._animating = false
this.dispatchEvent({type: 'viewChange', view})

await timeout(10)
}

@uiButton('Animate All Views')
async animateAllViews() {
if (!this.enabled) return
if (this.viewLooping || this._cameraViews.length < 2) return
while (this._viewQueue.length > 0) this._viewQueue.pop()
this._viewQueue.push(...this._cameraViews)
this._viewQueue.push(this._viewQueue.shift()!)
this._infiniteLooping = false
await this._animationLoop()
this._infiniteLooping = true
}

@uiButton('Stop All Animations')
async stopAllAnimations() {
this.viewLooping = false
this._popAnimations.forEach(a => a?.stop?.())
this._popAnimations = []
while (this._animating || this._animationLooping) {
await timeout(100)
}
}

fromJSON(data: any, meta?: any): this | null {
this._cameraViews.forEach(v=>this.deleteView(v)) // deserialize pushes to the existing array
if (super.fromJSON(data, meta)) {
this.uiConfig.uiRefresh?.()
return this
}
return null
}

public async animateToObject(selected?: Object3D, distanceMultiplier = 4, duration?: number, ease?: Easing|EasingFunctionType, distanceBounds = {min: 0.5, max: 5.0}) {
if (!this._viewer) return
const bbox = new Box3B().expandByObject(selected || this._viewer.scene.modelRoot.modelObject, false, true)
const center = bbox.getCenter(new Vector3())
const size = bbox.getSize(new Vector3())
const radius = size.length() / 2
await this.animateToTarget(Math.min(distanceBounds.max, Math.max(distanceBounds.min, radius * distanceMultiplier)), center, duration, ease)
}

public async animateToFitObject(selected?: Object3D, distanceMultiplier = 1.5, duration = 1000, ease?: Easing|EasingFunctionType, distanceBounds = {min: 0.5, max: 50.0}) {
if (!this._viewer) return
const bbox = new Box3B().expandByObject(selected || this._viewer.scene.modelRoot, false, true)
const center = bbox.getCenter(new Vector3()) // world position
const size = bbox.getSize(new Vector3())

const cam = this._viewer.scene.mainCamera
let cameraZ = 1
if (cam.isPerspectiveCamera) {
// get the max side of the bounding box (fits to width OR height as needed )
const fov = (cam as PerspectiveCamera2).fov * (Math.PI / 180)
const fovh = 2 * Math.atan(Math.tan(fov / 2) * cam.aspect)
const dx = size.z / 2 + Math.abs(size.x / 2 / Math.tan(fovh / 2))
const dy = size.z / 2 + Math.abs(size.y / 2 / Math.tan(fov / 2))
cameraZ = Math.max(dx, dy)

}

await this.animateToTarget(Math.min(distanceBounds.max, Math.max(distanceBounds.min, cameraZ * distanceMultiplier)), center, duration, ease)
}

/**
*
* @param distanceFromTarget - in world units
* @param center - target (center) of the view in world coordinates
* @param duration - in milliseconds
* @param ease
*/
public async animateToTarget(distanceFromTarget: number, center: Vector3, duration?: number, ease?: Easing|EasingFunctionType) {
const view = this.getView() // world space
view.target.copy(center)
const direction = new Vector3().subVectors(view.target, view.position).normalize()
view.position.copy(direction.multiplyScalar(-distanceFromTarget).add(view.target))
await this.animateToView(view, duration, ease)
}

uiConfig: UiObjectConfig = {
type: 'folder',
label: 'Camera Views',
// expanded: true,
children: [
()=>[...this._cameraViews.map(view => view.uiConfig)],
...generateUiConfig(this),
],
}

get animationLooping(): boolean {
return this._animationLooping
}
private _viewQueue: CameraView[] = []
private _animationLooping = false
private _infiniteLooping = true
private async _animationLoop() {
if (this._animationLooping) return
this._animationLooping = true
while (this.viewLooping || !this._infiniteLooping) {
if (!this.enabled) break
if (this._cameraViews.length < 1) break
if (this._viewQueue.length === 0) {
if (this._infiniteLooping) this._viewQueue.push(...this._cameraViews)
else break
}
await this.animateToView(this._viewQueue.shift()!)
await timeout(2 + this.viewPauseTime) // ms delay
}
this._animationLooping = false
}

// region deprecated

/**
* @deprecated - renamed to {@link getView} or {@link ICamera.getView}
* @param camera
* @param worldSpace
*/
getCurrentCameraView(camera?: ICamera, worldSpace = true) {
return this.getView(camera, worldSpace)
}

/**
* @deprecated - renamed to {@link setView} or {@link ICamera.setView}
* @param view
*/
setCurrentCameraView(view: CameraView) {
return this.setView(view)
}


/**
* @deprecated - use {@link animateToView} instead
* @param view
*/
async focusView(view: CameraView) {
return this.animateToView(view)
}

// endregion

// region to be ported to other plugins

// /**
// * For slight rotation of camera when seekOnScroll is enabled
// */
// private _pointerMove(ev: PointerEvent) {
// if (!this.enabled) return
// if (!this._animating && this.seekOnScroll) {
// const cam = this._viewer?.scene.mainCamera
// if (!cam) return
// const s = new Spherical()
// const p = cam.position
// const t = cam.target
// const q = new Quaternion().setFromUnitVectors(cam.cameraObject.up, new Vector3(0, 1, 0))
// const qi = q.clone().invert()
// const offset = p.clone().sub(t)
// offset.applyQuaternion(q)
// s.setFromVector3(offset)
// s.theta += this.rotationOffset * ev.movementX / this._viewer!.canvas!.clientWidth
// s.phi += this.rotationOffset * ev.movementY / this._viewer!.canvas!.clientHeight
// s.makeSafe()
// offset.setFromSpherical(s)
// offset.applyQuaternion(qi)
// p.copy(t).add(offset)
// cam.setDirty()
// }
// }

// // @uiToggle() @serialize()
// animateOnScroll = false // buggy
//
// @uiToggle() @serialize()
// seekOnScroll = false

// private _scrollAnimationState = 0
// scrollAnimationDamping = 0.1
// private _wheel(ev: any | WheelEvent) {
// if (!this.enabled) return
// if (this.seekOnScroll && !this._animating) {
// // if (ev.deltaY > 0) this.focusNext(false)
// // else this.focusPrevious(false)
// } else if (Math.abs(ev.deltaY) > 0.001) {
// this._scrollAnimationState = -1. * Math.sign(ev.deltaY)
// }
// }


// private _driver: Driver = (update)=>{
// return {
// start: ()=>this._updaters.push({u:update, time:0}),
// stop: ()=> this._updaters.splice(this._updaters.findIndex(u=>u.u === update), 1),
// }
// }

// private _fadeDisabled = false

// todo: same code used in PopmotionPlugin, merge somehow
// private _postFrame() {
// if (!this._viewer) return
// if (!this.enabled || !this._animating) {
// this._lastFrameTime = 0
// if (this._fadeDisabled) {
// this._viewer.getPluginByType<FrameFadePlugin>('FrameFade')?.enable(CameraViewPlugin.PluginType)
// this._fadeDisabled = false
// }
// // console.log('not anim')
// return
// }
// const time = now() / 1000.0
// if (this._lastFrameTime < 1) this._lastFrameTime = time - 1.0 / 60.0
// let delta = time - this._lastFrameTime
// this._lastFrameTime = time
// delta = delta * (this.animateOnScroll ? this._scrollAnimationState : 1)
//
// const d = this._viewer.getPluginByType<ProgressivePlugin>('Progressive')?.postFrameConvergedRecordingDelta()
// if (d && d > 0) delta = d
// if (d === 0) return // not converged yet.
// // if d < 0: not recording, do nothing
//
// delta *= 1000
//
// // delta = 16.666
//
// // console.log(delta)
// // console.log(dt)
// //
//
// if (delta <= 0) return
//
// this._updaters.forEach(u=>{
// let dt = delta
// if (u.time + dt < 0) dt = -u.time
// u.time += dt
// if (Math.abs(dt) > 0.001)
// u.u(dt)
// })
// if (this._scrollAnimationState < 0.001) this._scrollAnimationState = 0
// else this._scrollAnimationState *= 1.0 - this.scrollAnimationDamping
//
// if (!this._fadeDisabled) {
// const ff = this._viewer.getPluginByType<FrameFadePlugin>('FrameFade')
// if (ff) {
// ff.disable(CameraViewPlugin.PluginType)
// this._fadeDisabled = true
// }
// }
// }

// @uiButton('Record All Views')
// public async recordAllViews(onStart?: ()=>void, downloadOnEnd = true) {
// if (!this.enabled) return
// const recorder = this._viewer?.getPluginByType<CanvasRecorderPlugin>('CanvasRecorder')
// if (!recorder || !recorder.enabled) return
// if (this._cameraViews.length < 1) return
// await this.resetToFirstView()
// if (recorder.isRecording()) {
// console.error('CanvasRecorderPlugin is already recording')
// return
// }
// return new Promise<Blob|undefined>((resolve, reject) => {
// const listener2 = ()=>{
// recorder.removeEventListener('start', listenerStart)
// recorder.removeEventListener('stop', listener2)
// recorder.removeEventListener('error', listenerError)
// }
// const listenerStart = async() => {
// listener2()
// onStart?.()
// await this.animateAllViews()
// const blob = await recorder.stopRecording()
// if (downloadOnEnd) {
// const name = await this._viewer?.prompt('Canvas Recorder: Save file as', 'recording.mp4')
// if (name !== null && blob) await this._downloadBlob(blob, name || 'recording.mp4')
// }
// resolve(blob)
// }
// const listenerError = async() => {
// listener2()
// reject()
// }
// recorder.addEventListener('start', listenerStart)
// recorder.addEventListener('stop', listener2)
// recorder.addEventListener('error', listenerError)
// if (!recorder.startRecording()) {
// console.error('cannot start recording')
// return
// }
// })
// }

// private async _downloadBlob(blob: Blob, name: string) {
// const tr = this._viewer?.getPluginByType<FileTransferPlugin>('FileTransferPlugin')
// if (!tr) {
// this._viewer?.console.error('FileTransferPlugin required to export/download file')
// return
// }
// await tr.exportFile(blob, name)
// }

// endregion
}

+ 26
- 6
src/plugins/animation/PopmotionPlugin.ts 查看文件

@@ -5,7 +5,8 @@ import {AViewerPluginSync, ThreeViewer} from '../../viewer'
import type {FrameFadePlugin} from '../pipeline/FrameFadePlugin'
import type {ProgressivePlugin} from '../pipeline/ProgressivePlugin'
import {generateUUID} from '../../three'
import {makeSetterFor} from '../../utils'
import {animateCameraToViewLinear, animateCameraToViewSpherical, EasingFunctions, makeSetterFor} from '../../utils'
import {ICamera, ICameraView} from '../../core'

export interface AnimationResult{
id: string
@@ -47,6 +48,7 @@ export class PopmotionPlugin extends AViewerPluginSync<''> {
dependencies = []

private _fadeDisabled = false

/**
* Disable the frame fade plugin while animation is running
*/
@@ -196,13 +198,31 @@ export class PopmotionPlugin extends AViewerPluginSync<''> {
return this.animations[uuid]
}

async animateAsync<V>(options: AnimationOptions<V>& {target?: any, key?: string}): Promise<string> {
return this.animate(options).promise
async animateAsync<V>(options: AnimationOptions<V>& {target?: any, key?: string}, animations?: AnimationResult[]): Promise<string> {
const anim = this.animate(options)
if (animations) animations.push(anim)
return anim.promise
}

async animateTargetAsync<T>(target: T, key: keyof T, options: AnimationOptions<T[keyof T]>): Promise<string> {
return this.animate({...options, target, key: key as string}).promise
async animateTargetAsync<T>(target: T, key: keyof T, options: AnimationOptions<T[keyof T]>, animations?: AnimationResult[]): Promise<string> {
const anim = this.animate({...options, target, key: key as string})
if (animations) animations.push(anim)
return anim.promise
}

// todo : animateObject/animateTarget
animateCamera(camera: ICamera, view: ICameraView, spherical = true, options?: Partial<AnimationOptions<any>>) {
const anim = spherical ?
animateCameraToViewSpherical(camera, view) :
animateCameraToViewLinear(camera, view)
return this.animate({
ease: EasingFunctions.linear,
duration: 1000,
...anim, ...options,
})
}
async animateCameraAsync(camera: ICamera, view: ICameraView, spherical = true, options?: Partial<AnimationOptions<any>>, animations?: AnimationResult[]) {
const anim = this.animateCamera(camera, view, spherical, options)
if (animations) animations.push(anim)
return anim.promise
}
}

+ 1
- 0
src/plugins/index.ts 查看文件

@@ -36,3 +36,4 @@ export {TonemapPlugin} from './postprocessing/TonemapPlugin'
// animation
export {GLTFAnimationPlugin} from './animation/GLTFAnimationPlugin'
export {PopmotionPlugin} from './animation/PopmotionPlugin'
export {CameraViewPlugin} from './animation/CameraViewPlugin'

+ 1
- 1
src/three/utils/index.ts 查看文件

@@ -3,7 +3,7 @@ export {overrideThreeCache} from './cache'
export {dataTextureFromColor, dataTextureFromVec4, halfFloatToRgbe} from './conversion'
export {uniform, matDefine} from './decorators'
export {getEncodingComponents, getTexelEncoding, getTexelDecoding, getTexelDecoding2, getTexelDecodingFunction, getTexelEncodingFunction, getTextureColorSpaceFromMap} from './encoding'
export {generateUUID, toIndexedGeometry, isInScene} from './misc'
export {generateUUID, toIndexedGeometry, isInScene, localToWorldQuaternion, worldToLocalQuaternion} from './misc'
export {getTextureDataType, textureToCanvas, textureDataToImageData, textureToDataUrl, texImageToCanvas} from './texture'
export {threeConstMappings} from './const-mappings'
export {ObjectPicker} from './ObjectPicker'

+ 23
- 1
src/three/utils/misc.ts 查看文件

@@ -1,4 +1,4 @@
import {BufferGeometry, MathUtils} from 'three'
import {BufferGeometry, MathUtils, Object3D, Quaternion} from 'three'
import {mergeVertices} from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import {IGeometry, IMaterial, IObject3D, IScene, ITexture} from '../../core'

@@ -33,3 +33,25 @@ export function isInScene(...sceneObj: (IGeometry|IMaterial|IObject3D|ITexture)[
}
return false
}

/**
* Convert a world-space quaternion to local-space quaternion.
* https://github.com/mrdoob/three.js/pull/20243
* @param object
* @param quaternion
* @param _q
*/
export function worldToLocalQuaternion(object: Object3D, quaternion: Quaternion, _q = new Quaternion()) {
return quaternion.premultiply(object.getWorldQuaternion(_q).invert())
}

/**
* Convert a local-space quaternion to world-space quaternion.
* https://github.com/mrdoob/three.js/pull/20243
* @param object
* @param quaternion
* @param _q
*/
export function localToWorldQuaternion(object: Object3D, quaternion: Quaternion, _q = new Quaternion()) {
return quaternion.premultiply(object.getWorldQuaternion(_q))
}

+ 13
- 0
src/utils/animation.ts 查看文件

@@ -19,6 +19,7 @@ import {
linear,
} from 'popmotion'
import {timeout} from 'ts-browser-helpers'
import {MathUtils} from 'three'

export {animate}
export type {AnimationOptions, KeyframeOptions, Easing}
@@ -124,3 +125,15 @@ export async function animateAsync<V=number>(options: AnimationOptions<V>, anima
})
}

export function lerpAngle(a: number, b: number, t: number) {
const d = b - a
if (d >= Math.PI) {
return a + (d - Math.PI * 2) * t
} else if (d <= -Math.PI) {
return a + (d + Math.PI * 2) * t
} else {
return a + d * t
}
}

export const lerp = MathUtils.lerp

+ 120
- 0
src/utils/camera-anim.ts 查看文件

@@ -0,0 +1,120 @@
import {Quaternion, Spherical, Vector3} from 'three'
import {worldToLocalQuaternion} from '../three'
import {CameraView, ICamera, ICameraView} from '../core'
import {AnimationOptions} from 'popmotion'
import {lerp, lerpAngle} from './animation'

export function sphericalFromCameraView(view: Pick<CameraView, 'position'|'target'>): Spherical {
const pos = view.position.clone()
pos.sub(view.target)
const spherical = new Spherical().setFromVector3(pos)
spherical.makeSafe() // todo: is it needed?
return spherical
}

export function animateCameraToViewSpherical(camera: ICamera, view: ICameraView): AnimationOptions<number> {
// similar to orbit controls
const parent = camera.parent

const target = camera.target.clone()
const position = camera.getWorldPosition(new Vector3())
const init = {
position, target, zoom: camera.zoom,
spherical: sphericalFromCameraView({position, target}),
}
const current = {
position: new Vector3(),
target: new Vector3(),
zoom: 1,
spherical: new Spherical(),
}
const final = {
position: view.position,
target: view.target,
zoom: view.zoom,
spherical: sphericalFromCameraView(view),
}

function setter() {
camera.position.copy(parent ? parent.worldToLocal(current.position) : current.position)
camera.target.copy(current.target) // always in world space
camera.zoom = current.zoom
// lookAt in setDirty updates the quaternion
camera.setDirty() // because it has min change distance in setter
}

return {
from: 0,
to: 1,
onUpdate: (v) => {
current.spherical.phi = lerpAngle(init.spherical.phi, final.spherical.phi, v)
current.spherical.theta = lerpAngle(init.spherical.theta, final.spherical.theta, v)
current.spherical.radius = lerp(init.spherical.radius, final.spherical.radius, v)
current.target.copy(init.target).lerp(final.target, v)
current.position.setFromSpherical(current.spherical)
current.position.add(current.target)
current.zoom = lerp(init.zoom, final.zoom, v)
setter()
},
onComplete: () => {
current.position.copy(final.position)
current.target.copy(final.target)
current.zoom = final.zoom
setter()
},
onStop: () => {
throw new Error('Animation Stopped')
},
}
}

export function animateCameraToViewLinear(camera: ICamera, view: ICameraView): AnimationOptions<number> {
// similar to orbit controls
// so camera.up is the orbit axis
const parent = camera.parent

const target = camera.target.clone()
const position = camera.getWorldPosition(new Vector3())
const quaternion = camera.getWorldQuaternion(new Quaternion())
const init = {
position, target, quaternion, zoom: camera.zoom,
}
const current = {
position: new Vector3(),
target: new Vector3(),
quaternion: new Quaternion(),
zoom: 1,
}
const final = view

function setter() {
camera.position.copy(parent ? parent.worldToLocal(current.position) : current.position)
camera.target.copy(current.target) // always in world space
camera.quaternion.copy(parent ? worldToLocalQuaternion(parent, current.quaternion, camera.quaternion) : current.quaternion)
camera.zoom = current.zoom
camera.setDirty() // because it has min change distance in setter
}

return {
from: 0,
to: 1,
onUpdate: (v) => {
current.position.lerpVectors(init.position, final.position, v)
current.target.lerpVectors(init.target, final.target, v)
current.quaternion.slerpQuaternions(init.quaternion, final.quaternion, v)
current.zoom = lerp(init.zoom, final.zoom, v)
setter()
},
onComplete: () => {
current.position.copy(final.position)
current.target.copy(final.target)
current.quaternion.copy(final.quaternion)
current.zoom = final.zoom
setter()
},
onStop: () => {
throw new Error('Animation Stopped')
},
}
}


+ 2
- 1
src/utils/index.ts 查看文件

@@ -6,6 +6,7 @@ export {Dropzone, type DropFile, type ListenerCallback, type DropEventType} from
export {ThreeSerialization, type SerializationMetaType, type SerializationResourcesType, MetaImporter, metaToResources, getEmptyMeta, metaFromResources, convertArrayBufferToStringsInMeta, convertStringsToArrayBuffersInMeta, copyMaterialUserData, copyObject3DUserData, copyUserData, copyTextureUserData, jsonToBlob, serializeTextureInExtras} from './serialization'
export {shaderReplaceString} from './shader-helpers'
export {makeGLBFile} from './gltf'
export {animateAsync, animateTarget, EasingFunctions, makeSetterFor, animate} from './animation'
export {animateCameraToViewLinear, animateCameraToViewSpherical, sphericalFromCameraView} from './camera-anim'
export {animateAsync, animateTarget, EasingFunctions, makeSetterFor, animate, lerp, lerpAngle} from './animation'
export type {Easing, KeyframeOptions, AnimationOptions, EasingFunctionType, AnimateResult} from './animation'


+ 7
- 13
src/viewer/ThreeViewer.ts 查看文件

@@ -52,6 +52,7 @@ import {IViewerPlugin, IViewerPluginSync} from './IViewerPlugin'
import {DropzonePlugin, DropzonePluginOptions} from '../plugins/interaction/DropzonePlugin'
import {uiConfig, uiFolderContainer, UiObjectConfig} from 'uiconfig.js'
import {IRenderTarget} from '../rendering'
import type {ProgressivePlugin} from '../plugins'
import {TonemapPlugin} from '../plugins'
import {VERSION} from './version'

@@ -299,11 +300,10 @@ export class ThreeViewer extends EventDispatcher<IViewerEvent, IViewerEventTypes
this.addEventListener('postFrame', () => { // todo: move inside RootScene.
const cam = this._scene.mainCamera
if (cam && cam.canUserInteract) {
// todo
// const d = this.getPluginByType<ProgressivePlugin>('Progressive')?.postFrameConvergedRecordingDelta()
// // if (d && d > 0) delta = d
// if (d !== undefined && d === 0) return // not converged yet.
// // if d < 0 or undefined: not recording, do nothing
const d = this.getPlugin<ProgressivePlugin>('ProgressivePlugin')?.postFrameConvergedRecordingDelta()
// if (d && d > 0) delta = d
if (d !== undefined && d === 0) return // not converged yet.
// if d < 0 or undefined: not recording, do nothing

cam.controls?.update()
}
@@ -1019,14 +1019,8 @@ export class ThreeViewer extends EventDispatcher<IViewerEvent, IViewerEventTypes
this.console.warn('Cannot find camera', event)
return
}
this._scene.mainCamera.copy(event.camera)
const worldPos = event.camera.getWorldPosition(this._scene.mainCamera.position)
// camera.getWorldQuaternion(this.quaternion) // todo: do if autoLookAtTarget is false
if (this._scene.mainCamera.parent) {
this._scene.mainCamera.position.copy(this._scene.mainCamera.parent.worldToLocal(worldPos))
// this.quaternion.premultiply(this.parent.quaternion.clone().invert())
}
this._scene.mainCamera.setDirty()
const camera = this._scene.mainCamera
camera.setViewFromCamera(event.camera) // default is worldSpace
} else if (event.type === 'activateMain')
this._scene.mainCamera = event.camera || undefined // event.camera should have been upgraded when added to the scene.
}

正在加载...
取消
保存