threepipe
Version:
A modern 3D viewer framework built on top of three.js, written in TypeScript, designed to make creating high-quality, modular, and extensible 3D experiences on the web simple and enjoyable.
631 lines (576 loc) • 23.3 kB
text/typescript
import {deepAccessObject, getOrCall, onChange, serializable, serialize, ValOrFunc} from 'ts-browser-helpers'
import {
generateUiConfig,
generateValueConfig,
IUiConfigContainer,
uiButton,
uiDropdown,
UiObjectConfig,
UiObjectType,
uiSlider,
uiToggle,
} from 'uiconfig.js'
import {generateUUID} from '../three'
import {AnimationOptions, Easing} from '@repalash/popmotion'
import {EasingFunctions, EasingFunctionType} from './animation'
import type {ThreeViewer} from '../viewer'
import {ThreeSerialization} from './serialization'
import type {AnimationResult, PopmotionPlugin} from '../plugins'
import {EventDispatcher} from 'three'
const viewerOptions = {
'None': '',
['Background Color']: 'scene.backgroundColor',
['Environment Rotation']: 'scene.environmentRotation.y',
['Environment Intensity']: 'scene.environmentIntensity',
// '[Fixed Env Map Direction']: 'scene.fixedEnvMapDirection',
['Camera Position']: 'scene.mainCamera.position',
['Camera Rotation']: 'scene.mainCamera.rotation',
['Camera Zoom']: 'scene.mainCamera.zoom',
['Camera FOV']: 'scene.mainCamera.fov',
// '[Directional Light Color']: 'plugins.RandomizedDirectionalLight.light.color',
// - todo dosent update shadows every frame
// '[Directional Light Direction']: 'plugins.RandomizedDirectionalLight.light.randomParams.direction',
// '[Diamond Env Map Rotation']: 'plugins.Diamond.envMap.rotation',
['Tonemap Exposure']: 'plugins.Tonemap.exposure',
['Tonemap Saturation']: 'plugins.Tonemap.saturation',
['Tonemap Contrast']: 'plugins.Tonemap.contrast',
['Tonemap Tone Mapping']: 'plugins.Tonemap.toneMapping',
['SSR Intensity']: 'plugins.SSReflection.passes.ssr.passObject.intensity',
['SSR Boost']: 'plugins.SSReflection.passes.ssr.passObject.boost',
['Chromatic Aberration Intensity']: 'plugins.ChromaticAberration.intensity',
['Film Grain Intensity']: 'plugins.FilmicGrain.intensity',
['Vignette Color']: 'plugins.Vignette.color',
['Vignette Power']: 'plugins.Vignette.power',
['Depth of Field Focal Point']: 'plugins.DepthOfField._focalPointHit',
['Depth of Field Near Far Blur Scale X']: 'plugins.DepthOfField.pass.nearFarBlurScale.x',
['Depth of Field Near Far Blur Scale Y']: 'plugins.DepthOfField.pass.nearFarBlurScale.y',
['Depth of Field Focal Depth Range Y']: 'plugins.DepthOfField.pass.focalDepthRange.y',
['Bloom Intensity']: 'plugins.Bloom.pass.intensity',
['Bloom Radius']: 'plugins.Bloom.pass.radius',
['Bloom Power']: 'plugins.Bloom.pass.power',
}
export type TUpdaterType = (()=>void)
export interface IAnimationObject<V> {
access?: string, // dot separated target accessor. 'model.rotation' will give this.model.rotation
duration?: number,
delay?: number,
ease?: Easing|EasingFunctionType;
updater?: (TUpdaterType)[]; // dispatch update, default none
animSet?: IAnimSet,
animSetParallel?: boolean,
name?: string,
options: AnimationOptions<V>,
// to?: V | ((fromVal:V, target: any)=>V),
// from?: V,
values: V[]
offsets?: number[],
animate?: (delay?: number, canComplete?: boolean)=>AnimationResult,
result?: AnimationResult
uiRef?: UiObjectConfig,
uiObjectType?: UiObjectType,
targetObject?: Record<string, any>,
}
export type IAnimSet = (IAnimationObject<any>)[]
export function extractAnimationKey(o: IAnimationObject<any>, extraGetters?: Record<string, (key: string, acc: string[])=>{tar: any, acc: string[], onChange?: ()=>void}|undefined>) {
let acc = Array.from((o.access ?? '').split(/(?<!\\)\./)) // split by dot, but not escaped dots
let tar: any = o.targetObject
let onChange1: undefined | (()=>void) = undefined
const key = acc.pop()?.replace(/\\\./g, '.') // deep access till the last element, then bind
if (!key || key.length === 0) return {key: undefined, tar}
extraGetters = extraGetters ?? tar?._animGetters // _animGetters are set in AnimationObjectPlugin
const getterType = acc.length >= 1 ? acc[0] : undefined
const getterName = acc.length >= 2 ? acc[1]?.replace(/\\\./g, '.') : undefined
if (extraGetters && getterType && getterType in extraGetters && getterName) {
acc = acc.slice(2)
const res = extraGetters[getterType](getterName, acc)
if (!res) tar = res
else {
tar = res.tar
// acc = acc.slice(res.i + 1)
acc = res.acc
onChange1 = res.onChange ?? onChange1
}
}
tar = deepAccessObject(acc, tar)
return {key, tar, onChange: onChange1}
}
export interface AnimationObjectEventMap {
'animationAdd': {animation: AnimationObject}
'animationRemove': {animation: AnimationObject, fromChild: boolean}
'update': object
}
/**
* AnimationObject - An object for containing keyframe-based animation for properties
*
* AnimationObject extends popmotion and interfaces with the {@link ThreeViewer} to provide a keyframe animation system that can animate any accessible property
* on objects, materials, or the viewer itself. It supports complex timing, easing, and serialization.
*
* It is used in {@link AnimationObjectPlugin}.
*
* Key Features:
* - **Property Access**: Uses dot-notation strings to access nested properties (e.g., 'position.x', 'material.roughness')
* - **Keyframe System**: Define multiple keyframes with custom timing and values
* - **Easing Support**: Built-in easing functions or custom easing functions
* - **Timeline Integration**: Seamlessly works with viewer's global timeline
* - **Serialization**: Automatically saves/loads with scene data
* - **UI Integration**: Generates UI controls and supports interactive editing
* - **Hierarchical**: Can contain child animations for complex choreography
*
* @example Basic Animation
* ```typescript
* const anim = new AnimationObject(myObject)
* anim.access = 'position.y'
* anim.values = [0, 5, 0]
* anim.offsets = [0, 0.5, 1]
* anim.duration = 2000
* anim.ease = (x: number) => 1 - Math.cos(x * Math.PI / 2) // Custom easeOutSine
* anim.updateTarget = true
* ```
*
* @example Complex Animation with Multiple Keyframes
* ```typescript
* const colorAnim = new AnimationObject(material)
* colorAnim.access = 'color'
* colorAnim.values = ['#ff0000', '#00ff00', '#0000ff', '#ff0000']
* colorAnim.offsets = [0, 0.33, 0.66, 1]
* colorAnim.duration = 4000
* anim.ease = 'easeInOutSine'
* colorAnim.delay = 500
* ```
*
*/
export class AnimationObject<V = any> extends EventDispatcher<AnimationObjectEventMap> implements IAnimationObject<V>, IUiConfigContainer {
uuid = generateUUID()
setDirty = () => {
// console.log('update')
this.updater = []
if (this.options) {
this.options.repeatType = this.repeatType
this.options.repeat = this.repeat
}
if (!this._upfn) return
if (this.updateScene) this.updater.push(this._upfn.scene)
if (this.updateCamera) this.updater.push(this._upfn.camera)
if (this.updateViewer) this.updater.push(this._upfn.viewer)
if (this.updateTarget) this.updater.push(this._upfn.target)
this.dispatchEvent({type: 'update'})
}
// @uiInput()
name = ''
// @uiInput()
// @uiDropdown('Property', Object.entries(options).map(([label, value])=>({label, value})))
access = '' // dot separated target accessor. 'scene.modelRoot.rotation' will give this.model.rotation
// @uiConfig(undefined, {params: (t: AnimationObject)=>({onChange: t.setDirty})})
// @serialize() from?: V
//
// @uiConfig(undefined, {params: (t: AnimationObject)=>({onChange: t.setDirty})})
// @serialize()
// to?: V // | ((fromVal: V, target: any) => V)
values: V[] = []
offsets: number[] = []
// @uiConfig()
options: AnimationOptions<V> = { // extra options
// onUpdate: (v: V)=>{
// console.log(v)
// },
// onPlay: ()=>{
// if (this.updateCamera) getOrCall(this.target)?.scene.mainCamera.setInteractions(false, this.uuid)
// },
// onStop: ()=>{
// if (this.updateCamera) getOrCall(this.target)?.scene.mainCamera.setInteractions(true, this.uuid)
// },
// onComplete: ()=>{
// if (this.updateCamera) getOrCall(this.target)?.scene.mainCamera.setInteractions(true, this.uuid)
// },
}
duration = 1000 // ms
delay = 0
/**
* Number of times to repeat the animation.
* Doesn't work right now
*/
// @uiSlider(undefined, [0, 10], 1, (t: AnimationObject)=>({hidden: ()=>!t.access}))
repeat = 0
/**
* Delay between repeats in milliseconds.
* Doesn't work right now
*/
// @uiSlider(undefined, [0, 10], 1, (t: AnimationObject)=>({hidden: ()=>!t.access}))
repeatDelay = 0
/**
* Type of repeat behavior.
* - 'loop': repeats the animation from the beginning.
* - 'reverse': plays the animation in reverse after it completes.
* - 'mirror': plays the animation in reverse after it completes. todo only mirrors the time, not values?
*
* Doesn't work right now
*/
// @uiDropdown('repeatType', ['loop', 'reverse'/* , 'mirror'*/].map((label:string)=>({label})), (t: AnimationObject)=>({hidden: ()=>!t.access}))
repeatType: 'loop' | 'reverse' | 'mirror' = 'reverse'
ease: EasingFunctionType = 'easeInOutSine'
updater: TUpdaterType[] = []
uiObjectType?: UiObjectType
// targetObject?: Record<string, any>
get targetObject(): Record<string, any>|undefined {
return getOrCall(this.target) ?? this.parent?.targetObject
}
updateScene = false
updateCamera = false
updateViewer = false
// @uiToggle(undefined, (t: AnimationObject)=>({hidden: ()=>!t.access}))
updateTarget = false
// uiConfig!: UiObjectConfig
target?: Record<string, any>
readonly viewer?: ValOrFunc<ThreeViewer|undefined>
getViewer(): ThreeViewer|undefined {
return this.viewer ? getOrCall(this.viewer) : this.parent?.getViewer()
}
constructor(target?: object|undefined, viewer?: ValOrFunc<ThreeViewer|undefined>, name = '') {
super()
this.target = target
this.viewer = viewer
this.name = name
this.dispatchEvent = this.dispatchEvent.bind(this)
}
fromJSON(data1: any, meta?: any): this {
let data = {...data1}
if (data.access !== undefined) { // first set access so values are initialized
this.access = data.access
delete data.access
}
if (data.from !== undefined) { // old files with to/from
data = {...data}
data.values = [data.from, data.to]
data.offsets = [0, 1]
delete data.from
delete data.to
}
ThreeSerialization.Deserialize(data, this, meta, true)
this.animSet.map(i=>{
i.parent = this
})
return this
}
private _lastAccess = ''
private _lastTarget: any = undefined
protected _onAccessChanged() {
const tar = this.targetObject
if (tar && tar === this.getViewer() && !Object.values(viewerOptions).includes(this.access)) {
this.access = ''
return
}
if (tar && tar === this.getViewer() && this.access === 'scene.environment.rotation') {
this.access = 'scene.environmentRotation.y'
return
}
if (tar && tar === this.getViewer() && this.access === 'scene.envMapIntensity') {
this.access = 'scene.environmentIntensity'
return
}
if (this.access !== this._lastAccess || !this.values.length || this._lastTarget !== tar && tar && this._lastTarget) {
this._lastAccess = this.access
const lastValues = this.values
this.values = []
this.offsets = []
const clone = this._thisValueCloner()
if (!clone) {
this.refreshUi()
return
}
this.values = [clone(), clone()]
// todo improve merge. like it wont work with vectors right now. For that we need to check if primitive type is the same and/or call the .copy() function
if (lastValues.length >= 2 && (
typeof lastValues[0] === typeof this.values[0] && (typeof lastValues[0] !== 'object' || (lastValues[0] as any).type && (lastValues[0] as any).type === (this.values[0] as any)?.type)
)) this.values = lastValues
this.offsets = this.offsets.length === this.values.length ? this.offsets : [0, 1]
this.refreshUi()
}
}
private _thisValueCloner() {
const {key, tar} = extractAnimationKey(this)
const val = tar && key !== undefined ? tar[key] : null
return val === undefined || val === null ? null : () => {
if (!val) return val
if (val.isColor) return '#' + val.getHexString()
const res = typeof val.clone === 'function' ? val.clone() : typeof val === 'object' ? {...val} : val
return res
}
}
addKeyframe(time: number) {
if (this.values.length < 2) {
console.warn('AnimationObject: Values not initialized, cannot add keyframe', this)
return
}
const value = this._thisValueCloner()
if (!value) {
console.warn('AnimationObject: No value to add keyframe for', this)
return
}
const offsetTime = time - this.delay
const duration = this.duration
const delay = this.delay
const offsets = [...this.offsets]
const values = [...this.values]
let offset = offsetTime / this.duration
let index: number
let newDuration = duration
let newDelay = delay
const newValues = [...this.values]
const newOffsets = [...this.offsets]
if (offset < 0) {
const o = -offset
offset = 0
for (let i = 0; i < offsets.length; i++) {
newOffsets[i] = (offsets[i] + o) / (1 + o)
}
newDuration = duration - offsetTime
newDelay = delay + offsetTime
index = 0
} else if (offset > 1) {
const o = offset - 1
offset = 1
for (let i = 0; i < offsets.length; i++) {
newOffsets[i] = offsets[i] / (1 + o)
}
newDuration = offsetTime
index = offsets.length
} else {
index = offsets.findIndex(o => o >= offset)
if (index < 0) {
index = this.offsets.length
} else if (this.offsets[index] === offset) {
console.warn('AnimationObject: Keyframe already exists at offset', offset, this)
return
}
}
const val = value()
newValues.splice(index, 0, val)
newOffsets.splice(index, 0, offset)
const redo = ()=>{
this.duration = newDuration
this.delay = newDelay
this.values = newValues
this.offsets = newOffsets
this.setDirty()
}
const undo = ()=>{
this.duration = duration
this.delay = delay
this.values = values
this.offsets = offsets
this.setDirty()
}
redo()
return {undo, redo}
}
updateKeyframe(index: number) {
if (index < 0 || index >= this.values.length) {
console.warn('AnimationObject: Invalid keyframe index', index, this)
return
}
const value = this._thisValueCloner()
if (!value) {
console.warn('AnimationObject: No value to update keyframe for', this)
return
}
const oldValue = this.values[index]
const newValue = value()
const redo = ()=>{
this.values[index] = newValue
this.setDirty()
}
const undo = ()=>{
this.values[index] = oldValue
this.setDirty()
}
redo()
return {undo, redo}
}
isValueSame(index: number) {
if (index < 0 || index >= this.values.length) {
console.warn('AnimationObject: Invalid keyframe index', index, this)
return false
}
const value = this._thisValueCloner()
if (!value) {
console.warn('AnimationObject: No value to update keyframe for', this)
return false
}
const oldValue = this.values[index]
const newValue = value()
if (oldValue === newValue) return true
if (typeof oldValue !== typeof newValue) return false
if (typeof oldValue === 'object' && typeof newValue === 'object') {
if ((oldValue as any)?.equals) {
return (oldValue as any).equals(newValue)
}
if (newValue?.equals) {
return newValue.equals(oldValue)
}
}
return false
}
refreshUi() {
this.setDirty()
this.uiConfig?.uiRefresh?.(true, 'postFrame', 1)
}
parent?: AnimationObject
add(o: AnimationObject) {
this.animSet.push(o)
o.parent = this
this.dispatchEvent({type: 'animationAdd', animation: o})
o.addEventListener('update', this.dispatchEvent)
o.addEventListener('animationAdd', this.dispatchEvent)
o.addEventListener('animationRemove', this.dispatchEvent)
this.refreshUi()
}
remove(o: AnimationObject, fromChild = false) {
const idx = this.animSet.indexOf(o)
if (idx >= 0) {
this.animSet.splice(idx, 1)
o.parent = undefined
this.dispatchEvent({type: 'animationRemove', animation: o, fromChild})
o.removeEventListener('update', this.dispatchEvent)
o.removeEventListener('animationAdd', this.dispatchEvent)
o.removeEventListener('animationRemove', this.dispatchEvent)
this.refreshUi()
}
}
private _upfn = {
viewer: () => this.getViewer()?.setDirty(),
renderer: () => this.getViewer()?.renderManager.reset(),
scene: () => {
this.getViewer()?.scene.setDirty()
},
camera: () => this.getViewer()?.scene.mainCamera.setDirty(),
target: () => {
const t = this.targetObject
if (t && typeof t.setDirty === 'function') {
t.setDirty({frameFade: false, refreshScene: false, source: 'AnimationObject', key: this.access})
}
},
}
animate(delay = 0, canComplete = true): AnimationResult {
// console.log('animate', this)
if (typeof delay !== 'number' || isNaN(delay)) { // called from ui
delay = 0
}
if (canComplete && this.result) {
console.warn('AnimationObject: Already animating, stopping previous animation')
this.stop()
}
const viewer = this.getViewer()
const pop = viewer?.getPlugin<PopmotionPlugin>('PopmotionPlugin')
if (!pop) {
console.error(`AnimationObject: No ${!viewer ? 'viewer' : 'PopmotionPlugin'}`)
const id = generateUUID()
return {
id,
options: this.options,
stop: () => {return},
promise: Promise.resolve(id),
anims: [],
// completed: true,
}
}
return pop.animateObject(this, 0, canComplete, undefined, delay)
}
result: AnimationResult|undefined
// todo during reverse delay should be time - duration
// @uiButton('Animate Reverse')
// async animateReverse() {
// await this.animate(true)
// }
stop() {
if (!this.result) return
this.result.stop()
this.result = undefined
}
async removeFromParent2() {
const viewer = this.getViewer()
if (this.parent && viewer) {
const confirm = await viewer.dialog.confirm(`Delete: Are you sure you want to delete the animation ${this.name}?`)
if (confirm) this.removeFromParent()
}
}
removeFromParent() {
if (this.parent) this.parent.remove(this, true)
}
// @uiToggle()
animSetParallel = false
// @uiConfig()
animSet: AnimationObject[] = []
// @uiButton('Add Animation')
addAnimation() {
const o = new AnimationObject(this.target)
this.add(o)
return o
}
uiConfig: UiObjectConfig = {
type: 'folder',
label: ()=>this.name || this.access || 'Animation',
children: [
()=>this.target ? null : {
type: 'input',
label: 'Property',
property: [this, 'access'],
children: Object.entries(viewerOptions).map(([label, value])=>({label, value})),
},
()=> this.values.flatMap((val, i)=>[
{
...generateValueConfig(this.values, i + '', undefined, val),
label: i === 0 ? 'From' : i === this.values.length - 1 ? 'To' : 'Key ' + i,
onChange: ()=>this.setDirty(),
},
i > 0 && i < this.values.length - 1 ? {
type: 'number',
label: 'Offset ' + i,
property: [this.offsets, i + ''],
bounds: [0, 1],
onChange: ()=>this.setDirty(),
} : null,
]),
generateUiConfig(this),
],
uuid: generateUUID(),
}
}