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.
505 lines (454 loc) • 18.1 kB
text/typescript
import {
IGeometry,
iGeometryCommons,
IMaterial,
iMaterialCommons,
IObject3D,
iObjectCommons,
ITexture,
LegacyPhongMaterial,
PhysicalMaterial,
UnlitLineMaterial,
UnlitMaterial,
upgradeTexture,
} from '../core'
import {IObjectExtension} from '../core/IObject'
import {Event, EventDispatcher, VideoTexture} from 'three'
import {generateUUID} from '../three'
import {deepAccessObject} from 'ts-browser-helpers'
import {ViewerTimeline} from '../utils/ViewerTimeline'
export interface Object3DManagerEventMap {
'videoAdd': {video: VideoTexture & ITexture}
'videoRemove': {video: VideoTexture & ITexture}
'objectAdd': {object: IObject3D}
'objectRemove': {object: IObject3D}
'materialAdd': {material: IMaterial}
'materialRemove': {material: IMaterial}
'geometryAdd': {geometry: IGeometry}
'geometryRemove': {geometry: IGeometry}
'textureAdd': {texture: ITexture}
'textureRemove': {texture: ITexture}
'dispose': object
}
export class Object3DManager extends EventDispatcher<Object3DManagerEventMap> {
private _root: IObject3D | undefined
private _objects = new Set<IObject3D>()
private _objectExtensions: IObjectExtension[] = []
private _materials = new Set<IMaterial>()
private _geometries = new Set<IGeometry>()
private _textures = new Set<ITexture>()
private _videos = new Set<VideoTexture & ITexture>()
getVideos() {
return [...this._videos]
}
autoDisposeTextures = true
autoDisposeMaterials = true
autoDisposeGeometries = true
autoDisposeObjects = false
constructor() {
super()
this._rootChanged = this._rootChanged.bind(this)
// this._objAdded = this._objAdded.bind(this)
}
onPostFrame(time: ViewerTimeline) {
// const delta = time.delta
for (const video of this._videos) {
const data = video.userData.timeline
if (data) {
if (!data.enabled) continue
}
const elem = video.image as HTMLVideoElement
const delay = data?.delay || 0
const scale = data?.scale || 1
const start = data?.start || 0
const duration = elem.duration || 1
const end = duration - (data?.end || 0)
// elem.pause()
let t = time.time
t -= delay
t *= scale
if (t < start) t = start
if (t > end) t = end
if (t < 0) t = 0
if (t > duration) t = duration
const d1 = Math.abs(t - elem.currentTime)
if (/* d1 > delta && */d1 > 1 / 60) { // todo determine fps?
// console.log(t)
elem.currentTime = t
if (elem.paused) {
const i1 = (video._playid || 0) + 1 // increment play id to avoid playing the video multiple times
video._playid = i1
elem.play().then(() => {
if (video._playid !== i1) return // if play id changed, do not play the video
if (!elem.paused) {
elem.pause()
}
delete video._playid
})
}
}
if (!time.running) {
// if the timeline is not running, pause the video
if (!elem.paused && !video._playid) {
elem.pause()
}
}
}
}
setRoot(root: IObject3D) {
this._root = root
}
registerObject(obj: IObject3D) {
if (!obj || !obj.uuid || !obj.isObject3D) return
if (this._objects.has(obj)) return
const existing = [...this._objects].find(o => o.uuid === obj.uuid)
if (existing) {
if (existing && obj !== existing) {
console.error('AssetManager - Object with the same uuid already registered', obj, existing)
}
return
}
if (!obj.assetType) {
iObjectCommons.upgradeObject3D.call(obj)
}
this._objects.add(obj)
obj.addEventListener('parentRootChanged', this._rootChanged)
this._registerMaterials(obj.materials, obj)
this._registerGeometry(obj.geometry, obj)
const maps = Object3DManager.GetMapsForObject3D(obj)
if (maps) for (const tex of maps) {
this._registerTexture(tex, obj)
}
if (!obj.objectExtensions) obj.objectExtensions = []
const exts = obj.objectExtensions
for (const ext of this._objectExtensions) {
if (exts.includes(ext)) continue
const compatible = ext.isCompatible ? ext.isCompatible(obj) : true
if (compatible) {
exts.push(ext)
ext.onRegister && ext.onRegister(obj)
}
}
this.dispatchEvent({type: 'objectAdd', object: obj})
}
unregisterObject(obj: IObject3D) {
if (!obj || !obj.uuid || !this._objects.has(obj)) return false
this._objects.delete(obj)
// obj.removeEventListener('added', this._objAdded)
this._unregisterMaterials(obj.materials, obj)
this._unregisterGeometry(obj.geometry, obj)
const maps = Object3DManager.GetMapsForObject3D(obj)
if (maps) for (const tex of maps) {
this._unregisterTexture(tex, obj)
}
if (this.autoDisposeObjects && obj.userData?.disposeOnIdle !== false) { // todo add disposeOnIdle to types and docs
obj.dispose(false)
}
this.dispatchEvent({type: 'objectRemove', object: obj})
return true
// todo - extensions are not removed from the object, so they can be reused later
// if (obj.objectExtensions) {
// for (const ext of this._objectExtensions) {
// const ind1 = obj.objectExtensions.indexOf(ext)
// if (ind1 >= 0) obj.objectExtensions.splice(ind1, 1)
// }
// }
// listener is not removed, it will be used to know when its added back to root. todo - because of this reference to the manager is kept even after dispose, if the object is removed from the scene before dispose. but it would be empty.
// obj.removeEventListener('parentRootChanged', this._rootChanged)
}
private _rootChanged = (ev: Event<'parentRootChanged', IObject3D>) => {
if (!ev.target || !this._root) return
const parent = ev.target.parentRoot
let inRoot = false
if (parent === this._root) inRoot = true
else {
ev.target.traverseAncestors(a => {
if (a === this._root) inRoot = true
})
}
if (inRoot) {
this.registerObject(ev.target)
} else {
this.unregisterObject(ev.target)
}
}
// private _objAdded = (ev: Event<'added', IObject3D>) => {
// if (!ev.target) return
// let inRoot = false
// ev.target.traverseAncestors(a => {
// if (a === this._root) inRoot = true
// })
// if (!inRoot) return
// this.registerObject(ev.target)
// }
registerObjectExtension(ext: IObjectExtension) {
if (!ext) return
if (!ext.uuid) ext.uuid = generateUUID()
const ind = this._objectExtensions.includes(ext)
if (ind) return
this._objectExtensions.push(ext)
for (const obj of this._objects) {
if (obj.objectExtensions && !obj.objectExtensions.includes(ext)) {
const compatible = ext.isCompatible ? ext.isCompatible(obj) : true
if (compatible) {
obj.objectExtensions.push(ext)
}
}
}
}
unregisterObjectExtension(ext: IObjectExtension) {
if (!ext) return
const ind = this._objectExtensions.indexOf(ext)
if (ind < 0) return
this._objectExtensions.splice(ind, 1)
// todo - extensions are not removed from objects at the moment, so they can be reused later
// for (const obj of this._objects) {
// if (obj.objectExtensions && obj.objectExtensions.includes(ext)) {
// const ind1 = obj.objectExtensions.indexOf(ext)
// if (ind1 >= 0) obj.objectExtensions.splice(ind1, 1)
// }
// }
}
// region materials
private _registerMaterials(mat: IMaterial[]|undefined, mesh: IObject3D) {
return mat && mat.forEach(m => this._registerMaterial(m, mesh))
}
private _unregisterMaterials(mat: IMaterial[]|undefined, mesh: IObject3D) {
return mat && mat.forEach(m => this._unregisterMaterial(m, mesh))
}
private _registerMaterial(mat: IMaterial, mesh: IObject3D) {
if (!mat || !mat.isMaterial || !mesh || !mesh.uuid) return
if (!mat.assetType) {
iMaterialCommons.upgradeMaterial.call(mat)
}
let meshes = mat.appliedMeshes
if (!meshes) {
meshes = new Set<IObject3D>()
mat.appliedMeshes = meshes
}
const isNewMaterial = !this._materials.has(mat)
meshes.add(mesh)
this._materials.add(mat)
const maps = Object3DManager.GetMapsForMaterial(mat)
if (maps) for (const tex of maps) {
this._registerTexture(tex, mat)
}
if (isNewMaterial) {
this.dispatchEvent({type: 'materialAdd', material: mat})
}
}
private _unregisterMaterial(mat: IMaterial, mesh: IObject3D) {
if (!mat || !mesh || !mesh.uuid) return
const meshes = mat.appliedMeshes
if (!meshes) return
meshes.delete(mesh)
if (meshes.size === 0 && this._materials.has(mat)) {
this._materials.delete(mat)
const maps = Object3DManager.GetMapsForMaterial(mat)
if (maps) for (const tex of maps) {
this._unregisterTexture(tex, mat)
}
this.dispatchEvent({type: 'materialRemove', material: mat})
if (this.autoDisposeMaterials)
mat.dispose(false)
}
}
// endregion
// region geometry
private _registerGeometry(geom: IGeometry|undefined, mesh: IObject3D) {
if (!geom || !geom.isBufferGeometry || !mesh || !mesh.uuid) return
if (!geom.assetType) {
iGeometryCommons.upgradeGeometry.call(geom)
}
let meshes = geom.appliedMeshes
if (!meshes) {
meshes = new Set<IObject3D>()
geom.appliedMeshes = meshes
}
const isNewGeometry = !this._geometries.has(geom)
meshes.add(mesh)
this._geometries.add(geom)
if (isNewGeometry) {
this.dispatchEvent({type: 'geometryAdd', geometry: geom})
}
}
private _unregisterGeometry(geom: IGeometry|undefined, mesh: IObject3D) {
if (!geom || !mesh || !mesh.uuid) return
const meshes = geom.appliedMeshes
if (!meshes) return
meshes.delete(mesh)
if (meshes.size === 0 && this._geometries.has(geom)) {
this._geometries.delete(geom)
this.dispatchEvent({type: 'geometryRemove', geometry: geom})
if (this.autoDisposeGeometries)
geom.dispose(false)
}
}
// endregion
// region textures
private _registerTexture(tex: ITexture|undefined, obj: IObject3D | IMaterial) {
if (!tex || !tex.isTexture || !obj || !obj.uuid) return
if (!tex.assetType) {
upgradeTexture.call(tex)
}
let objects = tex.appliedObjects
if (!objects) {
objects = new Set<IObject3D|IMaterial>()
tex.appliedObjects = objects
}
const isNewTexture = !this._textures.has(tex)
objects.add(obj)
this._textures.add(tex)
if (tex.isVideoTexture) this._registerVideo(tex as VideoTexture & ITexture)
if (isNewTexture) {
this.dispatchEvent({type: 'textureAdd', texture: tex})
}
}
private _unregisterTexture(tex: ITexture|undefined, obj: IObject3D | IMaterial) {
if (!tex || !obj || !obj.uuid) return
const objects = tex.appliedObjects
if (!objects) return
objects.delete(obj)
if (objects.size === 0 && this._textures.has(tex)) {
this._textures.delete(tex)
if (tex.isVideoTexture) this._videos.delete(tex as VideoTexture & ITexture)
this.dispatchEvent({type: 'textureRemove', texture: tex})
if (tex.userData?.disposeOnIdle !== false && this.autoDisposeTextures && !tex.isRenderTargetTexture && tex.dispose)
tex.dispose()
if (tex.isVideoTexture) {
const elem = tex.image as HTMLVideoElement
if (elem) {
// elem.pause() // stop the video, todo required?
}
this.dispatchEvent({type: 'videoRemove', video: tex as VideoTexture & ITexture})
}
}
}
private _registerVideo(tex: VideoTexture & ITexture) {
this._videos.add(tex)
const elem = tex.image as HTMLVideoElement
elem.preload = 'auto'
elem.autoplay = true
// elem.play().then(() => {
// console.log('video started playing', elem)
// elem.pause()
// })
elem.loop = true
elem.muted = true // to avoid autoplay issues in browsers
this.dispatchEvent({type: 'videoAdd', video: tex})
}
// endregion textures
// region utils
findObject(nameOrUuid: string): IObject3D|undefined {
if (!nameOrUuid) return undefined
const obj = this._objects.values().find(o => o.uuid === nameOrUuid)
if (obj) return obj
const obj1 = this.findObjectsByName(nameOrUuid)
if (obj1.length > 1) {
console.warn('Multiple objects found with name:', nameOrUuid, obj1)
return undefined
}
return obj1[0]
}
findObjectsByName(name: string): IObject3D[] {
const objs: IObject3D[] = []
this._objects.forEach(o=>{
if (o.name === name) {
objs.push(o)
}
})
return objs
}
findMaterial(nameOrUuid: string): IMaterial|undefined {
if (!nameOrUuid) return undefined
const mat = this._materials.values().find(m => m.uuid === nameOrUuid)
if (mat) return mat
const mats = this.findMaterialsByName(nameOrUuid)
if (mats.length > 1) {
console.warn('Multiple materials found with name:', nameOrUuid, mats)
return undefined
}
return mats[0]
}
findMaterialsByName(name: string): IMaterial[] {
const mats: IMaterial[] = []
this._materials.forEach(m=>{
if (m.name === name) {
mats.push(m)
}
})
return mats
}
// endregion utils
dispose() {
const objects = [...this._objects]
for (const o of objects) {
this.unregisterObject(o)
o.removeEventListener('parentRootChanged', this._rootChanged)
// o.removeEventListener('added', this._objAdded)
}
this._objectExtensions = []
this._objects.clear() // todo should this dispatch objectRemove events?
this._materials.clear() // todo should this dispatch materialRemove events?
this._geometries.clear() // todo should this dispatch geometryRemove events?
// this._root = undefined
this.dispatchEvent({type: 'dispose'})
}
static readonly MaterialTextureProperties: Set<string> = new Set<string>([
...UnlitMaterial.MapProperties,
...UnlitLineMaterial.MapProperties,
...PhysicalMaterial.MapProperties,
...LegacyPhongMaterial.MapProperties,
])
// todo add from plugins like custom bump map etc.
static readonly MaterialTexturePropertiesUserData: Set<string> = new Set<string>([])
static GetMapsForMaterial(material: IMaterial) {
const maps = new Set<ITexture>()
for (const prop of material.constructor?.MapProperties || Object3DManager.MaterialTextureProperties) {
const val = prop in material ? (material as any)[prop] : undefined
if (val && val.isTexture) {
maps.add(val)
}
Object3DManager._addMap(prop, material, maps)
}
if (material.userData)
for (const prop of Object3DManager.MaterialTexturePropertiesUserData) {
Object3DManager._addMap(prop, material.userData, maps, true)
}
return maps
}
static readonly SceneTextureProperties: Set<string> = new Set<string>([
'environmentMap',
'background',
])
static readonly Object3DTextureProperties: Set<string> = new Set<string>([])
static GetMapsForObject3D(object: IObject3D) {
const maps = new Set<ITexture>()
for (const prop of Object3DManager.Object3DTextureProperties) {
Object3DManager._addMap(prop, object, maps)
}
if (object.isScene) {
for (const prop of Object3DManager.SceneTextureProperties) {
Object3DManager._addMap(prop, object, maps)
}
}
return maps
}
private static _addMap(prop: string, object: any, maps: Set<ITexture>, deep = false) {
const val = deep ?
deepAccessObject(prop, object, false) :
prop in object ? (object as any)[prop] : undefined
if (val && val.isTexture) {
maps.add(val)
}
}
}
// add _playid to VideoTexture types
declare module 'three' {
interface VideoTexture {
// used to avoid playing the video multiple times when the currentTime is set
// and the video is already playing. this is used in Object3DManager to control video playback
// based on timeline events.
_playid?: number
}
}