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.
663 lines (593 loc) • 24.8 kB
text/typescript
import {
IGeometry,
iGeometryCommons,
ILight,
IMaterial,
iMaterialCommons,
IMaterialEventMap,
IObject3D,
IObject3DEventMap,
iObjectCommons,
IScene,
ITexture,
LegacyPhongMaterial,
PhysicalMaterial,
UnlitLineMaterial,
UnlitMaterial,
upgradeTexture,
} from '../core'
import {IObjectExtension} from '../core/IObject'
import {Event, Event2, EventDispatcher, VideoTexture} from 'three'
import {generateUUID} from '../three'
import {object3DTextureProperties, sceneTextureProperties} from '../core/object/iObjectCommons'
import {materialTextureProperties, materialTexturePropertiesUserData} from '../core/material/iMaterialCommons'
import {safeSetProperty} from 'ts-browser-helpers'
/**
* Event map for Object3DManager events.
*/
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}
'lightAdd': {light: ILight}
'lightRemove': {light: ILight}
'dispose': object
}
/**
* Manages 3D objects, materials, geometries, textures, and videos in a scene.
*/
export class Object3DManager extends EventDispatcher<Object3DManagerEventMap> {
private _root: IObject3D | undefined
private _objects = new Map<string, IObject3D>()
private _objectExtensions: IObjectExtension[] = []
private _materials = new Map<string, IMaterial>()
private _geometries = new Map<string, IGeometry>()
private _textures = new Map<string, ITexture>()
private _videos = new Map<string, VideoTexture & ITexture>()
private _lights = new Map<string, ILight>()
// todo wait sometime before disposing to avoid disposing and creating again immediately in the same frame
autoDisposeTextures = true
autoDisposeMaterials = true
autoDisposeGeometries = true
autoDisposeObjects = false
constructor() {
super()
this._rootChanged = this._rootChanged.bind(this)
this._materialChanged = this._materialChanged.bind(this)
this._geometryChanged = this._geometryChanged.bind(this)
this._texturesChanged = this._texturesChanged.bind(this) // todo add texturesChanged to textures on objects as well like background and environment
// this._objAdded = this._objAdded.bind(this)
}
onPostFrame(timeline: {time: number, running: boolean}) {
// const delta = time.delta
for (const video of this._videos.values()) {
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 = timeline.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 (!timeline.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
const existing = this.getObject(obj.uuid)
if (existing) {
if (obj !== existing) {
console.warn('Object3DManager - Object with the same uuid already registered', obj, existing)
safeSetProperty(obj, 'uuid', generateUUID(), true, true)
} else return
// return
}
if (!obj.assetType) {
iObjectCommons.upgradeObject3D.call(obj)
}
this._objects.set(obj.uuid, obj)
obj.addEventListener('parentRootChanged', this._rootChanged)
obj.addEventListener('materialChanged', this._materialChanged)
obj.addEventListener('geometryChanged', this._geometryChanged)
obj.addEventListener('texturesChanged', this._texturesChanged)
if ((obj as IScene).isScene) {
(obj as IScene).addEventListener('backgroundChanged', this._textureChanged)
;(obj as IScene).addEventListener('environmentChanged', this._textureChanged)
}
this._registerMaterials(obj.materials, obj)
this._registerGeometry(obj.geometry, obj)
const maps: Map<string, ITexture> = iObjectCommons.getMapsForObject3D.call(obj)
if (maps) for (const tex of maps.values()) {
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})
if (obj.isLight) {
this._lights.set(obj.uuid, obj as ILight)
this.dispatchEvent({type: 'lightAdd', light: obj as ILight})
}
obj.dispatchEvent({type: '__register' as any}) // todo do same for geom and textures
}
unregisterObject(obj: IObject3D) {
if (!obj || !obj.uuid) return false
const existing = this._objects.get(obj.uuid)
if (!existing) return false
if (obj !== existing) {
console.error('Object3DManager - Object to unregister is not the same as the registered object', obj, existing)
return false
}
this._objects.delete(obj.uuid)
obj.removeEventListener('materialChanged', this._materialChanged)
obj.removeEventListener('geometryChanged', this._geometryChanged)
obj.removeEventListener('texturesChanged', this._texturesChanged)
if ((obj as IScene).isScene) {
(obj as IScene).removeEventListener('backgroundChanged', this._textureChanged)
;(obj as IScene).removeEventListener('environmentChanged', this._textureChanged)
}
// obj.removeEventListener('added', this._objAdded)
this._unregisterMaterials(obj.materials, obj)
this._unregisterGeometry(obj.geometry, obj)
const maps: Map<string, ITexture> = iObjectCommons.getMapsForObject3D.call(obj)
if (maps) for (const tex of maps.values()) {
this._unregisterTexture(tex, obj)
}
if (this.autoDisposeObjects && obj.userData?.disposeOnIdle !== false) { // todo add disposeOnIdle to types and docs
obj.dispose && obj.dispose(false)
}
this.dispatchEvent({type: 'objectRemove', object: obj})
if (obj.isLight && this._lights.has(obj.uuid)) {
this._lights.delete(obj.uuid)
this.dispatchEvent({type: 'lightRemove', light: obj as ILight})
}
obj.dispatchEvent({type: '__unregister' as any})
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)
}
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.values()) {
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)
// }
// }
}
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)
// }
private _materialChanged = (ev: Event2<'materialChanged', IObject3DEventMap, IObject3D>) => {
if (!ev.target) return
const obj = ev.target
const oldMaterials = ev.oldMaterial
if (oldMaterials) {
if (Array.isArray(oldMaterials)) {
this._unregisterMaterials(oldMaterials, obj)
} else {
this._unregisterMaterial(oldMaterials, obj)
}
}
this._registerMaterials(obj.materials, obj)
}
private _geometryChanged = (ev: Event2<'geometryChanged', IObject3DEventMap, IObject3D>) => {
if (!ev.target) return
const obj = ev.target
const oldGeometry = ev.oldGeometry
if (oldGeometry) this._unregisterGeometry(oldGeometry, obj)
this._registerGeometry(obj.geometry, obj)
}
// 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 || !mat.uuid) return
if (!mat.assetType) {
iMaterialCommons.upgradeMaterial.call(mat)
}
let meshes = mat.appliedMeshes
if (!meshes) {
meshes = new Set<IObject3D>()
mat.appliedMeshes = meshes
}
const existing = this.getMaterial(mat.uuid)
if (existing) {
if (mat !== existing) {
console.warn('Object3DManager - Material with the same uuid already registered', mat, existing)
safeSetProperty(mat, 'uuid', generateUUID(), true, true)
}
}
const isNewMaterial = !this._materials.has(mat.uuid)
meshes.add(mesh)
this._materials.set(mat.uuid, mat)
// Add texturesChanged event listener for new materials
if (isNewMaterial) {
mat.addEventListener('texturesChanged', this._texturesChanged)
}
const maps: Map<string, ITexture> = /* mat._mapRefs || */iMaterialCommons.getMapsForMaterial.call(mat)
if (maps) for (const tex of maps.values()) {
this._registerTexture(tex, mat)
}
if (isNewMaterial) {
this.dispatchEvent({type: 'materialAdd', material: mat})
mat.dispatchEvent({type: '__register' as any})
}
}
private _unregisterMaterial(mat: IMaterial, mesh: IObject3D) {
if (!mat || !mesh || !mesh.uuid) return
const meshes = mat.appliedMeshes
if (!meshes) return
meshes.delete(mesh)
const existing = this.getMaterial(mat.uuid)
if (existing && mat !== existing) {
console.error('Object3DManager - Material to unregister is not the same as the registered material', mat, existing)
return
}
if (meshes.size === 0 && existing) {
this._materials.delete(mat.uuid)
// Remove texturesChanged event listener when material is no longer used
mat.removeEventListener('texturesChanged', this._texturesChanged)
const maps: Map<string, ITexture> = /* mat._mapRefs || */iMaterialCommons.getMapsForMaterial.call(mat)
if (maps) for (const tex of maps.values()) {
this._unregisterTexture(tex, mat)
}
this.dispatchEvent({type: 'materialRemove', material: mat})
if (this.autoDisposeMaterials) {
mat.dispose(false)
}
mat.dispatchEvent({type: '__unregister' as any})
}
}
private _texturesChanged = (ev: Event2<'texturesChanged', IMaterialEventMap, IMaterial> | Event2<'texturesChanged', IObject3DEventMap, IObject3D>) => {
if (!ev.target) return
// todo check for changeKey to avoid looping through all textures?
const material = ev.target
const removedTextures = ev.removedTextures
if (removedTextures) for (const tex of removedTextures) {
this._unregisterTexture(tex, material)
}
const addedTextures = ev.textures // using textures instead of addedTextures here
if (addedTextures) for (const tex of addedTextures) {
this._registerTexture(tex, material)
}
}
private _textureChanged = (ev: {
target: IObject3D|IMaterial,
oldTexture: ITexture|null
texture: ITexture|null
}) => {
if (!ev.target) return
if (ev.oldTexture) this._unregisterTexture(ev.oldTexture, ev.target)
if (ev.texture) this._registerTexture(ev.texture, ev.target)
}
// 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 existing = this.getGeometry(geom.uuid)
if (existing) {
if (geom !== existing) {
console.warn('Object3DManager - Geometry with the same uuid already registered', geom, existing)
safeSetProperty(geom, 'uuid', generateUUID(), true, true)
}
}
const isNewGeometry = !this._geometries.has(geom.uuid)
meshes.add(mesh)
this._geometries.set(geom.uuid, geom)
if (isNewGeometry) {
this.dispatchEvent({type: 'geometryAdd', geometry: geom})
geom.dispatchEvent({type: '__register' as any})
}
}
private _unregisterGeometry(geom: IGeometry|undefined, mesh: IObject3D) {
if (!geom || !mesh || !mesh.uuid) return
const meshes = geom.appliedMeshes
if (!meshes) return
meshes.delete(mesh)
const existing = this.getGeometry(geom.uuid)
if (existing && geom !== existing) {
console.error('Object3DManager - Geometry to unregister is not the same as the registered geometry', geom, existing)
}
if (meshes.size === 0 && this._geometries.has(geom.uuid)) {
this._geometries.delete(geom.uuid)
this.dispatchEvent({type: 'geometryRemove', geometry: geom})
if (this.autoDisposeGeometries)
geom.dispose(false)
geom.dispatchEvent({type: '__unregister' as any})
}
}
// 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 existing = this.getTexture(tex.uuid)
if (existing) {
if (tex !== existing) {
console.warn('Object3DManager - Texture with the same uuid already registered', tex, existing)
safeSetProperty(tex, 'uuid', generateUUID(), true, true)
}
}
const isNewTexture = !this._textures.has(tex.uuid)
objects.add(obj)
this._textures.set(tex.uuid, tex)
if (tex.isVideoTexture) this._registerVideo(tex as VideoTexture & ITexture)
if (isNewTexture) {
this.dispatchEvent({type: 'textureAdd', texture: tex})
tex.dispatchEvent({type: '__register' as any})
}
}
private _unregisterTexture(tex: ITexture|undefined, obj: IObject3D | IMaterial) {
if (!tex || !obj || !obj.uuid) return
const objects = tex.appliedObjects
if (!objects) return
objects.delete(obj)
const existing = this.getTexture(tex.uuid)
if (existing && tex !== existing) {
console.error('Object3DManager - Texture to unregister is not the same as the registered texture', tex, existing)
return
}
if (objects.size === 0 && this._textures.has(tex.uuid)) {
this._textures.delete(tex.uuid)
if (tex.isVideoTexture) this._videos.delete(tex.uuid)
this.dispatchEvent({type: 'textureRemove', texture: tex})
if (tex.userData?.disposeOnIdle !== false && this.autoDisposeTextures && !tex.isRenderTargetTexture && tex.dispose)
tex.dispose()
tex.dispatchEvent({type: '__unregister' as any})
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.set(tex.uuid, 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.getObject(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.getMaterial(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.values()]
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> = materialTextureProperties
// todo add from plugins like custom bump map etc.
static readonly MaterialTexturePropertiesUserData: Set<string> = materialTexturePropertiesUserData
static readonly SceneTextureProperties: Set<string> = sceneTextureProperties
static readonly Object3DTextureProperties: Set<string> = object3DTextureProperties
static {
new Set([
...UnlitMaterial.MapProperties,
...UnlitLineMaterial.MapProperties,
...PhysicalMaterial.MapProperties,
...LegacyPhongMaterial.MapProperties,
]).forEach(v=>Object3DManager.MaterialTextureProperties.add(v))
}
// region getters
getObjects() {
return [...this._objects.values()]
}
getObject(uuid: string) {
return this._objects.get(uuid)
}
getObjectExtensions() {
return [...this._objectExtensions]
}
getMaterials() {
return [...this._materials.values()]
}
getMaterial(uuid: string) {
return this._materials.get(uuid)
}
getGeometries() {
return [...this._geometries.values()]
}
getGeometry(uuid: string) {
return this._geometries.get(uuid)
}
getTextures() {
return [...this._textures.values()]
}
getTexture(uuid: string) {
return this._textures.get(uuid)
}
getVideos() {
return [...this._videos.values()]
}
getVideo(uuid: string) {
return this._videos.get(uuid)
}
getLights() {
return [...this._lights.values()]
}
getLight(uuid: string) {
return this._lights.get(uuid)
}
// endregion getters
}
// 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
}
}