From cd9908d31f958eb4c67b8cfd2e3dc78fc4a2b01b Mon Sep 17 00:00:00 2001 From: Michael Estes Date: Tue, 13 Feb 2024 20:00:34 -0800 Subject: [PATCH] Resource State (#9632) * Start of Resource State * licenses * Start of resource manager hooks * Resource hook refactoring * useTexture for SkyboxComponent * Start of unloading logic * Include entity in useTexture * Seperate logic by resource type * Update Three cache * Avatar loading system to resource manager * Particle system to resource manager * Per type metadata * Include entity * Unload assets when changed in editor, onUnload callback * Image component resource manager hooks * Don't use unloaded callback when the component is being unmounted * Batch loader, loopAnimationComponent, AvatarAnimationSystem resource hooks * Spawn point component to resource hooks, make sure unload only is called when unmounted * Better typing * onStart callback for setting initial metadata per type * Update loaders to use abort controller signal * license * loader typings * Portal texture resource hook * Media component resource hooks, potential on gpu callback * EnvmapComponent resource hooks * rename abort controller function * fix ordering * Envmap and Hyperspace to resource hooks * seperate resources by type, start of tests * Update typing * Remove references to three FileLoader, Loader * Replace three loader reference * Parse function is optional * Custom loading manager with more hooks, tests * license * cleanup * tests * tests * tests * Remove materials on model unload * Model component fix wip * Track textures loaded by the GLTFLoader * Update imports * Resource manager track and manage materials, geometry * Cleanup, no need to set asset to null when resources are disposed * Track resources used by avatar preview, preliminary fixes for unloading assets race condition * unload correctly in avatar preview, envmapcomponent, model component * Update imports * Start of variant resource manager * Resource manager loads variants if exists * hookstate fix * Component typing * Avatar preview animation fixes * Mesh bvh hookstate and race condition fix, shadow system unmount fix * Mesh bvh fixes, object layer component fixes * Preliminary fix for avatar switching * Fix errors when selecting a new avatar * track textures by source uuid, remove dispose calls, mesh bvh hookstate fix, portal component reactivity fix * debug change * Update mesh bvh * remove property * test fix * Fix for multiple avtars shown in avatar selection panel * Avatar selection working * bug fixes * src ref --------- Co-authored-by: HexaField Co-authored-by: Daniel Belmes <3631206+DanielBelmes@users.noreply.github.com> --- .../common/components/AvatarPreview/index.tsx | 24 +- .../Panel3D/useRender3DPanelSystem.tsx | 35 +- packages/engine/package.json | 2 +- .../engine/src/assets/classes/AssetLoader.ts | 21 +- .../compression/ModelTransformLoader.ts | 2 +- packages/engine/src/assets/font/FontLoader.ts | 4 +- .../src/assets/functions/resourceHooks.ts | 235 +++++++++ .../src/assets/loaders/base/FileLoader.ts | 252 ++++++++++ .../engine/src/assets/loaders/base/Loader.ts | 93 ++++ .../loaders/base/ResourceLoadingManager.ts | 137 ++++++ .../src/assets/loaders/corto/CORTOLoader.js | 3 +- .../src/assets/loaders/fbx/FBXLoader.d.ts | 4 +- .../src/assets/loaders/fbx/FBXLoader.js | 6 +- .../src/assets/loaders/gltf/DRACOLoader.d.ts | 3 +- .../src/assets/loaders/gltf/DRACOLoader.js | 11 +- .../src/assets/loaders/gltf/GLTFLoader.d.ts | 6 +- .../src/assets/loaders/gltf/GLTFLoader.js | 21 +- .../src/assets/loaders/gltf/KTX2Loader.d.ts | 3 +- .../src/assets/loaders/gltf/KTX2Loader.js | 8 +- .../assets/loaders/gltf/NodeDracoLoader.js | 7 +- .../src/assets/loaders/tga/TGALoader.ts | 10 +- .../src/assets/loaders/usdz/USDZLoader.d.ts | 5 +- .../src/assets/loaders/usdz/USDZLoader.js | 8 +- .../src/assets/state/ResourceState.test.tsx | 252 ++++++++++ .../engine/src/assets/state/ResourceState.ts | 459 ++++++++++++++++++ .../components/LoopAnimationComponent.ts | 30 +- .../src/avatar/functions/avatarFunctions.ts | 27 +- .../avatar/systems/AvatarAnimationSystem.ts | 53 +- .../avatar/systems/AvatarLoadingSystem.tsx | 42 +- .../src/scene/components/EnvmapComponent.tsx | 117 ++--- .../components/HyperspaceTagComponent.ts | 22 +- .../src/scene/components/ImageComponent.ts | 57 +-- .../src/scene/components/MediaComponent.ts | 18 +- .../src/scene/components/MeshBVHComponent.ts | 70 +-- .../src/scene/components/ModelComponent.tsx | 135 +++--- .../components/ParticleSystemComponent.ts | 117 +++-- .../src/scene/components/PortalComponent.ts | 52 +- .../src/scene/components/SkyboxComponent.ts | 48 +- .../scene/components/SpawnPointComponent.ts | 34 +- .../src/scene/components/VariantComponent.tsx | 10 +- .../src/scene/functions/bvhWorkerPool.ts | 10 +- .../functions/loaders/VariantFunctions.ts | 94 ++-- .../src/scene/systems/SceneObjectSystem.tsx | 1 - .../engine/src/scene/systems/ShadowSystem.tsx | 26 +- .../engine/tests/util/loadGLTFAssetNode.ts | 2 +- .../src/assets/ModelTransformLoader.ts | 2 +- .../common/classes/GenerateMeshBVHWorker.ts | 6 + .../networking/state/EntityNetworkState.tsx | 4 +- .../components/ObjectLayerComponent.ts | 3 + 49 files changed, 2049 insertions(+), 542 deletions(-) create mode 100644 packages/engine/src/assets/functions/resourceHooks.ts create mode 100644 packages/engine/src/assets/loaders/base/FileLoader.ts create mode 100644 packages/engine/src/assets/loaders/base/Loader.ts create mode 100644 packages/engine/src/assets/loaders/base/ResourceLoadingManager.ts create mode 100644 packages/engine/src/assets/state/ResourceState.test.tsx create mode 100644 packages/engine/src/assets/state/ResourceState.ts diff --git a/packages/client-core/src/common/components/AvatarPreview/index.tsx b/packages/client-core/src/common/components/AvatarPreview/index.tsx index 03d5444899..3adbc688a3 100644 --- a/packages/client-core/src/common/components/AvatarPreview/index.tsx +++ b/packages/client-core/src/common/components/AvatarPreview/index.tsx @@ -42,7 +42,7 @@ import { SxProps, Theme } from '@mui/material/styles' import styles from './index.module.scss' import { EntityUUID } from '@etherealengine/common/src/interfaces/EntityUUID' -import { hasComponent, removeComponent, setComponent } from '@etherealengine/ecs' +import { setComponent, UndefinedEntity } from '@etherealengine/ecs' import { defaultAnimationPath, preloadedAnimations } from '@etherealengine/engine/src/avatar/animation/Util' import { LoopAnimationComponent } from '@etherealengine/engine/src/avatar/components/LoopAnimationComponent' import { AssetPreviewCameraComponent } from '@etherealengine/engine/src/camera/components/AssetPreviewCameraComponent' @@ -55,6 +55,7 @@ import { UUIDComponent } from '@etherealengine/spatial/src/common/UUIDComponent' import { ObjectLayerMaskComponent } from '@etherealengine/spatial/src/renderer/components/ObjectLayerComponent' import { VisibleComponent } from '@etherealengine/spatial/src/renderer/components/VisibleComponent' import { ObjectLayers } from '@etherealengine/spatial/src/renderer/constants/ObjectLayers' +import { EntityTreeComponent } from '@etherealengine/spatial/src/transform/components/EntityTree' import { MathUtils } from 'three' interface Props { @@ -68,16 +69,10 @@ interface Props { const AvatarPreview = ({ fill, avatarUrl, sx, onAvatarError, onAvatarLoaded }: Props) => { const { t } = useTranslation() const panelRef = useRef() as React.MutableRefObject - useRender3DPanelSystem(panelRef) - - useEffect(() => { - loadAvatarPreview() - }, [avatarUrl]) - const renderPanelState = getMutableState(PreviewPanelRendererState) - const loadAvatarPreview = () => { + useEffect(() => { if (!avatarUrl) return const renderPanelEntities = renderPanelState.entities[panelRef.current.id] @@ -86,20 +81,19 @@ const AvatarPreview = ({ fill, avatarUrl, sx, onAvatarError, onAvatarLoaded }: P setComponent(entity, UUIDComponent, uuid) setComponent(entity, NameComponent, '3D Preview Entity') - if (hasComponent(entity, LoopAnimationComponent)) removeComponent(entity, LoopAnimationComponent) - if (hasComponent(entity, ModelComponent)) removeComponent(entity, ModelComponent) - - setComponent(entity, VisibleComponent, true) - ObjectLayerMaskComponent.setLayer(entity, ObjectLayers.AssetPreview) - setComponent(entity, ModelComponent, { src: avatarUrl, convertToVRM: true }) setComponent(entity, LoopAnimationComponent, { animationPack: defaultAnimationPath + preloadedAnimations.locomotion + '.glb', activeClipIndex: 5 }) + setComponent(entity, ModelComponent, { src: avatarUrl, convertToVRM: true }) + setComponent(entity, EntityTreeComponent, { parentEntity: UndefinedEntity }) + + setComponent(entity, VisibleComponent, true) + ObjectLayerMaskComponent.setLayer(entity, ObjectLayers.AssetPreview) setComponent(entity, EnvmapComponent, { type: EnvMapSourceType.Skybox }) const cameraEntity = renderPanelEntities[PanelEntities.camera].value setComponent(cameraEntity, AssetPreviewCameraComponent, { targetModelEntity: entity }) - } + }, [avatarUrl]) return ( diff --git a/packages/client-core/src/user/components/Panel3D/useRender3DPanelSystem.tsx b/packages/client-core/src/user/components/Panel3D/useRender3DPanelSystem.tsx index 9a2a790888..0c408ec90b 100644 --- a/packages/client-core/src/user/components/Panel3D/useRender3DPanelSystem.tsx +++ b/packages/client-core/src/user/components/Panel3D/useRender3DPanelSystem.tsx @@ -27,7 +27,6 @@ import React, { useEffect } from 'react' import { Euler, Quaternion, Vector3, WebGLRenderer } from 'three' import { - Engine, Entity, PresentationSystemGroup, UndefinedEntity, @@ -35,11 +34,12 @@ import { defineQuery, defineSystem, getComponent, + getOptionalComponent, removeComponent, removeEntity, setComponent } from '@etherealengine/ecs' -import { defineState, getMutableState, none } from '@etherealengine/hyperflux' +import { NO_PROXY, defineState, getMutableState, none } from '@etherealengine/hyperflux' import { DirectionalLightComponent, TransformComponent } from '@etherealengine/spatial' import { CameraComponent } from '@etherealengine/spatial/src/camera/components/CameraComponent' import { @@ -49,6 +49,7 @@ import { import { NameComponent } from '@etherealengine/spatial/src/common/NameComponent' import { InputSourceComponent } from '@etherealengine/spatial/src/input/components/InputSourceComponent' import { addClientInputListeners } from '@etherealengine/spatial/src/input/systems/ClientInputSystem' +import { GroupComponent } from '@etherealengine/spatial/src/renderer/components/GroupComponent' import { ObjectLayerComponents, ObjectLayerMaskComponent @@ -158,6 +159,7 @@ export function useRender3DPanelSystem(panel: React.MutableRefObject value === id) rendererState.entities[id].set(none) + rendererState.renderers[id].get(NO_PROXY).dispose() rendererState.renderers[id].set(none) rendererState.ids[thisIdIndex].set(none) } @@ -193,21 +195,20 @@ export const render3DPanelSystem = defineSystem({ iterateEntityNode(previewEntity, (entity) => { setComponent(entity, ObjectLayerComponents[ObjectLayers.AssetPreview]) }) - const cameraComponent = getComponent(cameraEntity, CameraComponent) - // sync with view camera - const viewCamera = cameraComponent.cameras[0] - viewCamera.projectionMatrix.copy(cameraComponent.projectionMatrix) - viewCamera.quaternion.copy(cameraComponent.quaternion) - viewCamera.position.copy(cameraComponent.position) - viewCamera.layers.mask = getComponent(cameraEntity, ObjectLayerMaskComponent) - // hack to make the background transparent for the preview - const lastBackground = Engine.instance.scene.background - Engine.instance.scene.background = null - rendererState.renderers[id].value.render(Engine.instance.scene, viewCamera) - Engine.instance.scene.background = lastBackground - iterateEntityNode(previewEntity, (entity) => { - removeComponent(entity, ObjectLayerComponents[ObjectLayers.AssetPreview]) - }) + const group = getOptionalComponent(previewEntity, GroupComponent) + if (group && group[0]) { + const cameraComponent = getComponent(cameraEntity, CameraComponent) + // sync with view camera + const viewCamera = cameraComponent.cameras[0] + viewCamera.projectionMatrix.copy(cameraComponent.projectionMatrix) + viewCamera.quaternion.copy(cameraComponent.quaternion) + viewCamera.position.copy(cameraComponent.position) + viewCamera.layers.mask = getComponent(cameraEntity, ObjectLayerMaskComponent) + rendererState.renderers[id].value.render(group[0], viewCamera) + iterateEntityNode(previewEntity, (entity) => { + removeComponent(entity, ObjectLayerComponents[ObjectLayers.AssetPreview]) + }) + } } } } diff --git a/packages/engine/package.json b/packages/engine/package.json index 9d4d446a9d..ee32246568 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -69,7 +69,7 @@ "sift": "^17.0.1", "simplex-noise": "^4.0.1", "three": "0.158.0", - "three-mesh-bvh": "^0.6.8", + "three-mesh-bvh": "^0.7.1", "three.quarks": "0.11.1", "troika-three-text": "^0.49.0", "ts-matches": "5.3.0", diff --git a/packages/engine/src/assets/classes/AssetLoader.ts b/packages/engine/src/assets/classes/AssetLoader.ts index 7a5981cd52..10b60ce085 100644 --- a/packages/engine/src/assets/classes/AssetLoader.ts +++ b/packages/engine/src/assets/classes/AssetLoader.ts @@ -28,7 +28,6 @@ import { AudioLoader, BufferAttribute, BufferGeometry, - FileLoader, Group, LOD, Material, @@ -44,6 +43,8 @@ import { TextureLoader } from 'three' +import { FileLoader } from '../loaders/base/FileLoader' + import { getState } from '@etherealengine/hyperflux' import { isClient } from '@etherealengine/common/src/utils/getEnvironment' @@ -87,14 +88,6 @@ const onUploadDropBuffer = () => this.array = new this.array.constructor(1) } -const onTextureUploadDropSource = () => - function (this: Texture) { - // source.data can't be null because the WebGLRenderer checks for it - this.source.data = { width: this.source.data.width, height: this.source.data.height, __deleted: true } - this.mipmaps.map((b) => delete b.data) - this.mipmaps = [] - } - export const cleanupAllMeshData = (child: Mesh, args: LoadingArgs) => { if (getState(EngineState).isEditor || !child.isMesh) return const geo = child.geometry as BufferGeometry @@ -104,9 +97,6 @@ export const cleanupAllMeshData = (child: Mesh, args: LoadingArgs) => { for (const name in attributes) (attributes[name] as BufferAttribute).onUploadCallback = onUploadDropBuffer() if (geo.index) geo.index.onUploadCallback = onUploadDropBuffer() } - Object.entries(mat) - .filter(([k, v]: [keyof typeof mat, Texture]) => v?.isTexture) - .map(([_, v]) => (v.onUpdate = onTextureUploadDropSource())) } const processModelAsset = (asset: Mesh, args: LoadingArgs): void => { @@ -368,7 +358,7 @@ const assetLoadCallback = const getAbsolutePath = (url) => (isAbsolutePath(url) ? url : getState(EngineState).publicPath + url) -type LoadingArgs = { +export type LoadingArgs = { ignoreDisposeGeometry?: boolean forceAssetType?: AssetType assetRoot?: Entity @@ -379,7 +369,8 @@ const load = async ( args: LoadingArgs, onLoad = (response: any) => {}, onProgress = (request: ProgressEvent) => {}, - onError = (event: ErrorEvent | Error) => {} + onError = (event: ErrorEvent | Error) => {}, + signal?: AbortSignal ) => { if (!_url) { onError(new Error('URL is empty')) @@ -430,7 +421,7 @@ const load = async ( const callback = assetLoadCallback(url, args, assetType, onLoad) try { - return loader.load(url, callback, onProgress, onError) + return loader.load(url, callback, onProgress, onError, signal) } catch (error) { onError(error) } diff --git a/packages/engine/src/assets/compression/ModelTransformLoader.ts b/packages/engine/src/assets/compression/ModelTransformLoader.ts index 57928a98c8..b65bbc2325 100644 --- a/packages/engine/src/assets/compression/ModelTransformLoader.ts +++ b/packages/engine/src/assets/compression/ModelTransformLoader.ts @@ -42,7 +42,7 @@ import { import fetch from 'cross-fetch' import draco3d from 'draco3dgltf' import { MeshoptDecoder, MeshoptEncoder } from 'meshoptimizer' -import { FileLoader } from 'three' +import { FileLoader } from '../loaders/base/FileLoader' import { EEMaterialExtension } from './extensions/EE_MaterialTransformer' import { EEResourceIDExtension } from './extensions/EE_ResourceIDTransformer' diff --git a/packages/engine/src/assets/font/FontLoader.ts b/packages/engine/src/assets/font/FontLoader.ts index 6d0c2e55d9..86a07928a0 100644 --- a/packages/engine/src/assets/font/FontLoader.ts +++ b/packages/engine/src/assets/font/FontLoader.ts @@ -23,8 +23,10 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20 Ethereal Engine. All Rights Reserved. */ -import { FileLoader, Loader, LoadingManager, ShapePath } from 'three' +import { LoadingManager, ShapePath } from 'three' +import { FileLoader } from '../loaders/base/FileLoader' +import { Loader } from '../loaders/base/Loader' export class FontLoader extends Loader { constructor(manager?: LoadingManager) { super(manager) diff --git a/packages/engine/src/assets/functions/resourceHooks.ts b/packages/engine/src/assets/functions/resourceHooks.ts new file mode 100644 index 0000000000..5e6a4e2a03 --- /dev/null +++ b/packages/engine/src/assets/functions/resourceHooks.ts @@ -0,0 +1,235 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +import { Entity, UndefinedEntity } from '@etherealengine/ecs' +import { State, useHookstate } from '@etherealengine/hyperflux' +import { useEffect } from 'react' +import { Texture } from 'three' +import { getVariant } from '../../scene/functions/loaders/VariantFunctions' +import { LoadingArgs } from '../classes/AssetLoader' +import { GLTF } from '../loaders/gltf/GLTFLoader' +import { AssetType, ResourceManager, ResourceType } from '../state/ResourceState' + +function createAbortController(url: string, callback: () => void): AbortController { + const controller = new AbortController() + controller.signal.onabort = (event) => { + console.warn('resourceHook: Aborted resource fetch for url: ' + url, event) + callback() + } + + return controller +} + +function useLoader( + url: string, + resourceType: ResourceType, + entity: Entity = UndefinedEntity, + params: LoadingArgs = {}, + //Called when the asset url is changed, mostly useful for editor functions when changing an asset + onUnload: (url: string) => void = (url: string) => {} +): [State, () => void, State, State | null>] { + const urlState = useHookstate(url) + const value = useHookstate(null) + const error = useHookstate(null) + const progress = useHookstate | null>(null) + + const unload = () => { + if (url) ResourceManager.unload(url, entity) + } + + useEffect(() => { + if (url !== urlState.value) { + if (urlState.value) { + const oldUrl = urlState.value + ResourceManager.unload(oldUrl, entity) + value.set(null) + progress.set(null) + error.set(null) + onUnload(oldUrl) + } + urlState.set(url) + } + + if (!url) return + const controller = createAbortController(url, unload) + let completed = false + + ResourceManager.load( + url, + resourceType, + entity, + params, + (response) => { + completed = true + value.set(response) + }, + (request) => { + progress.set(request) + }, + (err) => { + completed = true + error.set(err) + }, + controller.signal + ) + + return () => { + if (!completed) controller.abort() + } + }, [url]) + + return [value, unload, error, progress] +} + +function useBatchLoader( + urls: string[], + resourceType: ResourceType, + entity: Entity = UndefinedEntity, + params: LoadingArgs = {} +): [ + State<(T | null)[]>, + () => void, + State<(ErrorEvent | Error | null)[]>, + State<(ProgressEvent | null)[]> +] { + const values = useHookstate(new Array(urls.length).fill(null)) + const errors = useHookstate<(ErrorEvent | Error)[]>(new Array(urls.length).fill(null)) + const progress = useHookstate[]>(new Array(urls.length).fill(null)) + + const unload = () => { + for (const url of urls) ResourceManager.unload(url, entity) + } + + useEffect(() => { + const completedArr = new Array(urls.length).fill(false) as boolean[] + const controller = createAbortController(urls.toString(), unload) + + for (let i = 0; i < urls.length; i++) { + const url = urls[i] + if (!url) continue + ResourceManager.load( + url, + resourceType, + entity, + params, + (response) => { + completedArr[i] = true + values[i].set(response) + }, + (request) => { + progress[i].set(request) + }, + (err) => { + completedArr[i] = true + errors[i].set(err) + }, + controller.signal + ) + } + + return () => { + for (const completed of completedArr) { + if (!completed) { + controller.abort() + return + } + } + } + }, [JSON.stringify(urls)]) + + return [values, unload, errors, progress] +} + +async function getLoader( + url: string, + resourceType: ResourceType, + entity: Entity = UndefinedEntity, + params: LoadingArgs = {} +): Promise<[T | null, () => void, ErrorEvent | Error | null]> { + const unload = () => { + ResourceManager.unload(url, entity) + } + + return new Promise((resolve) => { + const controller = createAbortController(url, unload) + ResourceManager.load( + url, + resourceType, + entity, + params, + (response) => { + resolve([response, unload, null]) + }, + (request) => {}, + (err) => { + resolve([null, unload, err]) + }, + controller.signal + ) + }) +} + +export function useGLTF( + url: string, + entity?: Entity, + params?: LoadingArgs, + onUnload?: (url: string) => void +): [State, () => void, State, State | null>] { + const variantUrl = getVariant(entity) + if (variantUrl) { + url = variantUrl + } + return useLoader(url, ResourceType.GLTF, entity, params, onUnload) +} + +export function useBatchGLTF( + urls: string[], + entity?: Entity, + params?: LoadingArgs +): [ + State<(GLTF | null)[]>, + () => void, + State<(ErrorEvent | Error | null)[]>, + State<(ProgressEvent | null)[]> +] { + return useBatchLoader(urls, ResourceType.GLTF, entity, params) +} + +export function useTexture( + url: string, + entity?: Entity, + params?: LoadingArgs, + onUnload?: (url: string) => void +): [State, () => void, State, State | null>] { + return useLoader(url, ResourceType.Texture, entity, params, onUnload) +} + +export async function getTextureAsync( + url: string, + entity?: Entity, + params?: LoadingArgs +): Promise<[Texture | null, () => void, ErrorEvent | Error | null]> { + return getLoader(url, ResourceType.Texture, entity, params) +} diff --git a/packages/engine/src/assets/loaders/base/FileLoader.ts b/packages/engine/src/assets/loaders/base/FileLoader.ts new file mode 100644 index 0000000000..0b34220408 --- /dev/null +++ b/packages/engine/src/assets/loaders/base/FileLoader.ts @@ -0,0 +1,252 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +import { Cache, LoadingManager } from 'three' +import { Loader } from './Loader' + +const loading = {} + +class HttpError extends Error { + response: any + constructor(message, response) { + super(message) + this.response = response + } +} + +class FileLoader extends Loader { + mimeType: undefined | any + responseType: undefined | string + + constructor(manager?: LoadingManager) { + super(manager) + } + + load( + url: string, + onLoad: (data: TData) => void, + onProgress?: (event: ProgressEvent) => void, + onError?: (err: unknown) => void, + signal?: AbortSignal + ) { + if (url === undefined) url = '' + + if (this.path !== undefined) url = this.path + url + + url = this.manager.resolveURL(url) + + const cached = Cache.get(url) + + if (cached !== undefined) { + this.manager.itemStart(url) + + setTimeout(() => { + if (onLoad) onLoad(cached) + + this.manager.itemEnd(url) + }, 0) + + return cached + } + + // Check if request is duplicate + + if (loading[url] !== undefined) { + loading[url].push({ + onLoad: onLoad, + onProgress: onProgress, + onError: onError + }) + + return + } + + // Initialise array for duplicate requests + loading[url] = [] + + loading[url].push({ + onLoad: onLoad, + onProgress: onProgress, + onError: onError + }) + + // create request + const req = new Request(url, { + headers: new Headers(this.requestHeader), + credentials: this.withCredentials ? 'include' : 'same-origin' + // An abort controller could be added within a future PR + }) + + // record states ( avoid data race ) + const mimeType = this.mimeType + const responseType = this.responseType + + // start the fetch + fetch(req, { signal }) + .then((response) => { + if (response.status === 200 || response.status === 0) { + // Some browsers return HTTP Status 0 when using non-http protocol + // e.g. 'file://' or 'data://'. Handle as success. + + if (response.status === 0) { + console.warn('THREE.FileLoader: HTTP Status 0 received.') + } + + // Workaround: Checking if response.body === undefined for Alipay browser #23548 + + if ( + typeof ReadableStream === 'undefined' || + response.body == undefined || + response.body.getReader == undefined + ) { + return response + } + + const callbacks = loading[url] + const reader = response.body.getReader() + + // Nginx needs X-File-Size check + // https://serverfault.com/questions/482875/why-does-nginx-remove-content-length-header-for-chunked-content + const contentLength = response.headers.get('Content-Length') || response.headers.get('X-File-Size') + const total = contentLength ? parseInt(contentLength) : 0 + const lengthComputable = total !== 0 + let loaded = 0 + + // periodically read data into the new stream tracking while download progress + const stream = new ReadableStream({ + start(controller) { + readData() + + function readData() { + reader.read().then(({ done, value }) => { + if (done) { + controller.close() + } else { + loaded += value.byteLength + + const event = new ProgressEvent('progress', { lengthComputable, loaded, total }) + for (let i = 0, il = callbacks.length; i < il; i++) { + const callback = callbacks[i] + if (callback.onProgress) callback.onProgress(event) + } + + controller.enqueue(value) + readData() + } + }) + } + } + }) + + return new Response(stream) + } else { + throw new HttpError( + `fetch for "${response.url}" responded with ${response.status}: ${response.statusText}`, + response + ) + } + }) + .then((response) => { + switch (responseType) { + case 'arraybuffer': + return response.arrayBuffer() + + case 'blob': + return response.blob() + + case 'document': + return response.text().then((text) => { + const parser = new DOMParser() + return parser.parseFromString(text, mimeType) + }) + + case 'json': + return response.json() + + default: + if (mimeType === undefined) { + return response.text() + } else { + // sniff encoding + const re = /charset="?([^;"\s]*)"?/i + const exec = re.exec(mimeType) + const label = exec && exec[1] ? exec[1].toLowerCase() : undefined + const decoder = new TextDecoder(label) + return response.arrayBuffer().then((ab) => decoder.decode(ab)) + } + } + }) + .then((data) => { + // Add to cache only on HTTP success, so that we do not cache + // error response bodies as proper responses to requests. + Cache.add(url, data) + + const callbacks = loading[url] + delete loading[url] + + for (let i = 0, il = callbacks.length; i < il; i++) { + const callback = callbacks[i] + if (callback.onLoad) callback.onLoad(data) + } + }) + .catch((err) => { + // Abort errors and other errors are handled the same + + const callbacks = loading[url] + + if (callbacks === undefined) { + // When onLoad was called and url was deleted in `loading` + this.manager.itemError(url) + throw err + } + + delete loading[url] + + for (let i = 0, il = callbacks.length; i < il; i++) { + const callback = callbacks[i] + if (callback.onError) callback.onError(err) + } + + this.manager.itemError(url) + }) + .finally(() => { + this.manager.itemEnd(url) + }) + + this.manager.itemStart(url) + } + + setResponseType(value: string): this { + this.responseType = value + return this + } + + setMimeType(value): this { + this.mimeType = value + return this + } +} + +export { FileLoader } diff --git a/packages/engine/src/assets/loaders/base/Loader.ts b/packages/engine/src/assets/loaders/base/Loader.ts new file mode 100644 index 0000000000..aa406cda6f --- /dev/null +++ b/packages/engine/src/assets/loaders/base/Loader.ts @@ -0,0 +1,93 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +import { DefaultLoadingManager, LoadingManager } from 'three' + +class Loader { + static DEFAULT_MATERIAL_NAME = '__DEFAULT' + + manager: LoadingManager + crossOrigin: string + withCredentials: boolean + path: string + resourcePath: string + requestHeader: { [header: string]: string } + + constructor(manager?: LoadingManager) { + this.manager = manager !== undefined ? manager : DefaultLoadingManager + + this.crossOrigin = 'anonymous' + this.withCredentials = false + this.path = '' + this.resourcePath = '' + this.requestHeader = {} + } + + load( + url: TUrl, + onLoad: (data: TData) => void, + onProgress?: (event: ProgressEvent) => void, + onError?: (err: unknown) => void, + signal?: AbortSignal + ) {} + + loadAsync(url: TUrl, onProgress?: (event: ProgressEvent) => void): Promise { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const scope = this + + return new Promise(function (resolve, reject) { + scope.load(url, resolve, onProgress, reject) + }) + } + + parse?(data?: TData) {} + + setCrossOrigin(crossOrigin: string): this { + this.crossOrigin = crossOrigin + return this + } + + setWithCredentials(value: boolean): this { + this.withCredentials = value + return this + } + + setPath(path: string): this { + this.path = path + return this + } + + setResourcePath(resourcePath: string): this { + this.resourcePath = resourcePath + return this + } + + setRequestHeader(requestHeader: { [header: string]: string }): this { + this.requestHeader = requestHeader + return this + } +} + +export { Loader } diff --git a/packages/engine/src/assets/loaders/base/ResourceLoadingManager.ts b/packages/engine/src/assets/loaders/base/ResourceLoadingManager.ts new file mode 100644 index 0000000000..9e4be202ad --- /dev/null +++ b/packages/engine/src/assets/loaders/base/ResourceLoadingManager.ts @@ -0,0 +1,137 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +import { Loader } from './Loader' + +class ResourceLoadingManager { + onItemStart?: (url: string) => void + onStart?: (url: string, loaded: number, total: number) => void + onLoad?: () => void + onProgress?: (url: string, loaded: number, total: number) => void + onError?: (url: string) => void + + isLoading = false + itemsLoaded = 0 + itemsTotal = 0 + urlModifier?: (url: string) => string + handlers = [] as (RegExp | Loader)[] + + constructor( + onItemStart?: (url: string) => void, + onStart?: (url: string, loaded: number, total: number) => void, + onLoad?: () => void, + onProgress?: (url: string, loaded: number, total: number) => void, + onError?: (url: string) => void + ) { + this.onItemStart = onItemStart + this.onStart = onStart + this.onLoad = onLoad + this.onProgress = onProgress + this.onError = onError + } + + itemStart = (url: string) => { + this.itemsTotal++ + + if (this.isLoading === false) { + if (this.onStart !== undefined) { + this.onStart(url, this.itemsLoaded, this.itemsTotal) + } + } + + if (this.onItemStart !== undefined) this.onItemStart(url) + + this.isLoading = true + } + + itemEnd = (url: string) => { + this.itemsLoaded++ + + if (this.onProgress !== undefined) { + this.onProgress(url, this.itemsLoaded, this.itemsTotal) + } + + if (this.itemsLoaded === this.itemsTotal) { + this.isLoading = false + + if (this.onLoad !== undefined) { + this.onLoad() + } + } + } + + itemError = (url: string) => { + if (this.onError !== undefined) { + this.onError(url) + } + } + + resolveURL = (url: string): string => { + if (this.urlModifier) { + return this.urlModifier(url) + } + + return url + } + + setURLModifier = (callback?: (url: string) => string): this => { + this.urlModifier = callback + + return this + } + + addHandler = (regex: RegExp, loader: Loader): this => { + this.handlers.push(regex, loader) + + return this + } + + removeHandler = (regex: RegExp): this => { + const index = this.handlers.indexOf(regex) + + if (index !== -1) { + this.handlers.splice(index, 2) + } + + return this + } + + getHandler = (file: string): Loader | null => { + for (let i = 0, l = this.handlers.length; i < l; i += 2) { + const regex = this.handlers[i] as RegExp + const loader = this.handlers[i + 1] as Loader + + if (regex.global) regex.lastIndex = 0 + + if (regex.test(file)) { + return loader + } + } + + return null + } +} + +export { ResourceLoadingManager } diff --git a/packages/engine/src/assets/loaders/corto/CORTOLoader.js b/packages/engine/src/assets/loaders/corto/CORTOLoader.js index 7bcfdb5236..17f36abb89 100644 --- a/packages/engine/src/assets/loaders/corto/CORTOLoader.js +++ b/packages/engine/src/assets/loaders/corto/CORTOLoader.js @@ -25,7 +25,8 @@ Ethereal Engine. All Rights Reserved. */ -import { BufferAttribute, BufferGeometry, FileLoader } from 'three' +import { BufferAttribute, BufferGeometry } from 'three' +import { FileLoader } from "../base/FileLoader" class CORTOLoader { constructor() { diff --git a/packages/engine/src/assets/loaders/fbx/FBXLoader.d.ts b/packages/engine/src/assets/loaders/fbx/FBXLoader.d.ts index ca301c200f..90836e765c 100644 --- a/packages/engine/src/assets/loaders/fbx/FBXLoader.d.ts +++ b/packages/engine/src/assets/loaders/fbx/FBXLoader.d.ts @@ -25,7 +25,8 @@ Ethereal Engine. All Rights Reserved. */ -import { Group, Loader, LoadingManager } from 'three' +import { Group, LoadingManager } from 'three' +import { Loader } from '../base/Loader' export class FBXLoader extends Loader { constructor(manager?: LoadingManager); @@ -35,6 +36,7 @@ export class FBXLoader extends Loader { onLoad: (object: Group) => void, onProgress?: (event: ProgressEvent) => void, onError?: (event: ErrorEvent) => void, + signal?: AbortSignal ): void; loadAsync(url: string, onProgress?: (event: ProgressEvent) => void): Promise; parse(FBXBuffer: ArrayBuffer | string, path: string): Group; diff --git a/packages/engine/src/assets/loaders/fbx/FBXLoader.js b/packages/engine/src/assets/loaders/fbx/FBXLoader.js index 6ce7b70b15..6d2f90e601 100755 --- a/packages/engine/src/assets/loaders/fbx/FBXLoader.js +++ b/packages/engine/src/assets/loaders/fbx/FBXLoader.js @@ -35,7 +35,6 @@ import { DirectionalLight, EquirectangularReflectionMapping, Euler, - FileLoader, Float32BufferAttribute, Group, Line, @@ -68,6 +67,7 @@ import { VectorKeyframeTrack, SRGBColorSpace } from 'three'; +import { FileLoader } from '../base/FileLoader'; import * as fflate from 'fflate'; import { NURBSCurve } from './NURBSCurve'; @@ -99,7 +99,7 @@ class FBXLoader extends Loader { } - load( url, onLoad, onProgress, onError ) { + load( url, onLoad, onProgress, onError, signal ) { const scope = this; @@ -133,7 +133,7 @@ class FBXLoader extends Loader { } - }, onProgress, onError ); + }, onProgress, onError, signal ); } diff --git a/packages/engine/src/assets/loaders/gltf/DRACOLoader.d.ts b/packages/engine/src/assets/loaders/gltf/DRACOLoader.d.ts index 7d8f6501ed..c90997b53b 100755 --- a/packages/engine/src/assets/loaders/gltf/DRACOLoader.d.ts +++ b/packages/engine/src/assets/loaders/gltf/DRACOLoader.d.ts @@ -34,7 +34,8 @@ export class DRACOLoader extends Loader { url: string, onLoad: (geometry: BufferGeometry) => void, onProgress?: (event: ProgressEvent) => void, - onError?: (event: ErrorEvent) => void + onError?: (event: ErrorEvent) => void, + signal?: AbortSignal ): void setDecoderPath(path: string): DRACOLoader setDecoderConfig(config: object): DRACOLoader diff --git a/packages/engine/src/assets/loaders/gltf/DRACOLoader.js b/packages/engine/src/assets/loaders/gltf/DRACOLoader.js index c12fec4ec6..337f1ed50c 100755 --- a/packages/engine/src/assets/loaders/gltf/DRACOLoader.js +++ b/packages/engine/src/assets/loaders/gltf/DRACOLoader.js @@ -1,4 +1,3 @@ - /* CPAL-1.0 License @@ -24,8 +23,9 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20 Ethereal Engine. All Rights Reserved. */ - -import { BufferAttribute, BufferGeometry, FileLoader, Loader } from 'three' +import { BufferAttribute, BufferGeometry } from 'three' +import { FileLoader } from '../base/FileLoader' +import { Loader } from '../base/Loader' const _taskCache = new WeakMap() @@ -75,7 +75,7 @@ class DRACOLoader extends Loader { return this } - load(url, onLoad, onProgress, onError) { + load(url, onLoad, onProgress, onError, signal) { const loader = new FileLoader(this.manager) loader.setPath(this.path) @@ -95,7 +95,8 @@ class DRACOLoader extends Loader { this.decodeGeometry(buffer, taskConfig).then(onLoad).catch(onError) }, onProgress, - onError + onError, + signal ) } diff --git a/packages/engine/src/assets/loaders/gltf/GLTFLoader.d.ts b/packages/engine/src/assets/loaders/gltf/GLTFLoader.d.ts index 515d657eba..c781801ece 100755 --- a/packages/engine/src/assets/loaders/gltf/GLTFLoader.d.ts +++ b/packages/engine/src/assets/loaders/gltf/GLTFLoader.d.ts @@ -33,7 +33,6 @@ import { ColorSpace, Group, InterleavedBufferAttribute, - Loader, LoadingManager, Material, Mesh, @@ -48,6 +47,8 @@ import { Entity } from '@etherealengine/ecs/src/Entity' import { DRACOLoader } from './DRACOLoader' import { KTX2Loader } from './KTX2Loader' +import { Loader } from '../base/Loader' + export interface GLTF { animations: AnimationClip[] scene: Scene @@ -74,7 +75,8 @@ export class GLTFLoader extends Loader { url: string, onLoad: (gltf: GLTF) => void, onProgress?: (event: ProgressEvent) => void, - onError?: (event: ErrorEvent) => void + onError?: (event: ErrorEvent) => void, + signal?: AbortSignal ): void loadAsync(url: string, onProgress?: (event: ProgressEvent) => void): Promise diff --git a/packages/engine/src/assets/loaders/gltf/GLTFLoader.js b/packages/engine/src/assets/loaders/gltf/GLTFLoader.js index 6c57276d26..c6c9dfe4b3 100755 --- a/packages/engine/src/assets/loaders/gltf/GLTFLoader.js +++ b/packages/engine/src/assets/loaders/gltf/GLTFLoader.js @@ -35,7 +35,6 @@ import { ColorManagement, DirectionalLight, DoubleSide, - FileLoader, FrontSide, Group, ImageBitmapLoader, @@ -53,7 +52,6 @@ import { LinearMipmapLinearFilter, LinearMipmapNearestFilter, LinearSRGBColorSpace, - Loader, LoaderUtils, Material, MathUtils, @@ -93,6 +91,11 @@ import { InstancedBufferAttribute } from 'three'; +import { FileLoader } from '../base/FileLoader'; +import { Loader } from '../base/Loader'; + +import { ResourceType } from "../../state/ResourceState" + /** * @param {BufferGeometry} geometry * @param {number} drawMode @@ -307,7 +310,7 @@ class GLTFLoader extends Loader { } - load( url, onLoad, onProgress, onError ) { + load( url, onLoad, onProgress, onError, signal ) { const scope = this; @@ -374,7 +377,7 @@ class GLTFLoader extends Loader { } - }, onProgress, _onError ); + }, onProgress, _onError, signal ); } @@ -3310,6 +3313,9 @@ class GLTFParser { parser.associations.set( texture, { textures: textureIndex } ); + if(parser.fileLoader.manager.itemEndFor) + parser.fileLoader.manager.itemEndFor(parser.options.url, ResourceType.Texture, texture.source.uuid, texture) + return texture; } ).catch( function (error) { @@ -3725,6 +3731,9 @@ class GLTFParser { if ( materialDef.extensions ) addUnknownExtensionsToUserData( extensions, material, materialDef ); + if(parser.fileLoader.manager.itemEndFor) + parser.fileLoader.manager.itemEndFor(parser.options.url, ResourceType.Material, material.uuid, material) + return material; } ); @@ -4796,6 +4805,10 @@ function addPrimitiveAttributes( geometry, primitiveDef, parser ) { return Promise.all( pending ).then( function () { + if(parser.fileLoader.manager.itemEndFor) + parser.fileLoader.manager.itemEndFor(parser.options.url, ResourceType.Geometry, geometry.uuid, geometry) + + return primitiveDef.targets !== undefined ? addMorphTargets( geometry, primitiveDef.targets, parser ) : geometry; diff --git a/packages/engine/src/assets/loaders/gltf/KTX2Loader.d.ts b/packages/engine/src/assets/loaders/gltf/KTX2Loader.d.ts index b2cf7d1c89..feb2f49e05 100644 --- a/packages/engine/src/assets/loaders/gltf/KTX2Loader.d.ts +++ b/packages/engine/src/assets/loaders/gltf/KTX2Loader.d.ts @@ -45,6 +45,7 @@ export class KTX2Loader extends CompressedTextureLoader { url: string, onLoad: (texture: CompressedTexture) => void, onProgress?: (requrest: ProgressEvent) => void | undefined, - onError?: ((event: ErrorEvent) => void) | undefined + onError?: ((event: ErrorEvent) => void) | undefined, + signal?: AbortSignal ): CompressedTexture } \ No newline at end of file diff --git a/packages/engine/src/assets/loaders/gltf/KTX2Loader.js b/packages/engine/src/assets/loaders/gltf/KTX2Loader.js index ff96864e1e..61d0ff2993 100644 --- a/packages/engine/src/assets/loaders/gltf/KTX2Loader.js +++ b/packages/engine/src/assets/loaders/gltf/KTX2Loader.js @@ -45,7 +45,6 @@ import { Data3DTexture, DataTexture, DisplayP3ColorSpace, - FileLoader, FloatType, HalfFloatType, NoColorSpace, @@ -53,7 +52,6 @@ import { LinearMipmapLinearFilter, LinearDisplayP3ColorSpace, LinearSRGBColorSpace, - Loader, RedFormat, RGB_ETC1_Format, RGB_ETC2_Format, @@ -98,6 +96,8 @@ import { } from './ktx-parse.module.js'; import { ZSTDDecoder } from './zstddec.module.js'; import WebWorker from 'web-worker' +import { FileLoader } from '../base/FileLoader'; +import { Loader } from '../base/Loader'; import { isClient } from '@etherealengine/common/src/utils/getEnvironment' const _taskCache = new WeakMap(); @@ -257,7 +257,7 @@ class KTX2Loader extends Loader { } - load( url, onLoad, onProgress, onError ) { + load( url, onLoad, onProgress, onError, signal ) { if ( this.workerConfig === null ) { @@ -286,7 +286,7 @@ class KTX2Loader extends Loader { .then( ( texture ) => onLoad ? onLoad( texture ) : null ) .catch( onError ); - }, onProgress, onError ); + }, onProgress, onError, signal ); } diff --git a/packages/engine/src/assets/loaders/gltf/NodeDracoLoader.js b/packages/engine/src/assets/loaders/gltf/NodeDracoLoader.js index ba2254156a..9a032f1316 100644 --- a/packages/engine/src/assets/loaders/gltf/NodeDracoLoader.js +++ b/packages/engine/src/assets/loaders/gltf/NodeDracoLoader.js @@ -15,7 +15,6 @@ // 'use strict'; import { - FileLoader, BufferGeometry, DefaultLoadingManager, Float32BufferAttribute, @@ -28,6 +27,7 @@ import { Uint32BufferAttribute, Uint8BufferAttribute } from 'three' +import { FileLoader } from '../base/FileLoader' import draco from 'draco3dgltf' @@ -66,7 +66,7 @@ export class NodeDRACOLoader { return DRACO_ENCODER } - load(url, onLoad, onProgress, onError) { + load(url, onLoad, onProgress, onError, signal) { var scope = this var loader = new FileLoader(scope.manager) loader.setPath(this.path) @@ -77,7 +77,8 @@ export class NodeDRACOLoader { scope.decodeDracoFile(blob, onLoad) }, onProgress, - onError + onError, + signal ) } diff --git a/packages/engine/src/assets/loaders/tga/TGALoader.ts b/packages/engine/src/assets/loaders/tga/TGALoader.ts index c44b9816a2..711068e883 100755 --- a/packages/engine/src/assets/loaders/tga/TGALoader.ts +++ b/packages/engine/src/assets/loaders/tga/TGALoader.ts @@ -23,7 +23,9 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20 Ethereal Engine. All Rights Reserved. */ -import { FileLoader, Loader, Texture } from 'three' +import { Texture } from 'three' +import { FileLoader } from '../base/FileLoader' +import { Loader } from '../base/Loader' declare const OffscreenCanvas: { prototype: any @@ -37,7 +39,8 @@ export class TGALoader extends Loader { url: string, onLoad?: (texture: Texture) => void, onProgress?: (event: ProgressEvent) => void, - onError?: (event: ErrorEvent) => void + onError?: (event: ErrorEvent) => void, + signal?: AbortSignal ): Texture { const texture = new Texture() @@ -56,7 +59,8 @@ export class TGALoader extends Loader { } }, onProgress, - onError + onError, + signal ) return texture diff --git a/packages/engine/src/assets/loaders/usdz/USDZLoader.d.ts b/packages/engine/src/assets/loaders/usdz/USDZLoader.d.ts index 2cc5246a43..08cd481883 100644 --- a/packages/engine/src/assets/loaders/usdz/USDZLoader.d.ts +++ b/packages/engine/src/assets/loaders/usdz/USDZLoader.d.ts @@ -25,7 +25,8 @@ Ethereal Engine. All Rights Reserved. */ -import { BufferGeometry, Group, Loader, Material } from "three"; +import { BufferGeometry, Group, Material } from "three"; +import { Loader } from "../base/Loader"; export class USDAParser { parse(text: string): object @@ -34,7 +35,7 @@ export class USDAParser { export class USDZLoader extends Loader { register(plugin: USDZLoaderPlugin): void unregister(plugin: USDZLoaderPlugin): void - load(url: string, onLoad: (result) => void, onProgress: (progress) => void, onError: (error) => void): void + load(url: string, onLoad: (result) => void, onProgress?: (progress) => void, onError?: (error) => void, signal?: AbortSignal): void parse(buffer: string, onLoad: (result) => void): object } diff --git a/packages/engine/src/assets/loaders/usdz/USDZLoader.js b/packages/engine/src/assets/loaders/usdz/USDZLoader.js index 2a48e461f7..7616b81518 100644 --- a/packages/engine/src/assets/loaders/usdz/USDZLoader.js +++ b/packages/engine/src/assets/loaders/usdz/USDZLoader.js @@ -29,10 +29,8 @@ import { BufferAttribute, BufferGeometry, ClampToEdgeWrapping, - FileLoader, Group, InstancedMesh, - Loader, Material, Matrix4, Mesh, @@ -45,6 +43,8 @@ import { TextureLoader, Vector3, } from 'three'; +import { FileLoader } from '../base/FileLoader'; +import { Loader } from '../base/Loader'; import * as fflate from 'fflate'; @@ -188,7 +188,7 @@ class USDZLoader extends Loader { this.plugins = this.plugins.filter(_plugin => plugin !== _plugin) } - load( url, onLoad, onProgress, onError ) { + load( url, onLoad, onProgress, onError, signal ) { const scope = this; @@ -217,7 +217,7 @@ class USDZLoader extends Loader { scope.manager.itemError( url ); } - }, onProgress, onError ); + }, onProgress, onError, signal ); } diff --git a/packages/engine/src/assets/state/ResourceState.test.tsx b/packages/engine/src/assets/state/ResourceState.test.tsx new file mode 100644 index 0000000000..7f145a4d1b --- /dev/null +++ b/packages/engine/src/assets/state/ResourceState.test.tsx @@ -0,0 +1,252 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +import assert from 'assert' + +import { createEntity, destroyEngine } from '@etherealengine/ecs' +import { getState } from '@etherealengine/hyperflux' +import { createEngine } from '@etherealengine/spatial/src/initializeEngine' +import { LoadingManager } from 'three' +import { loadEmptyScene } from '../../../tests/util/loadEmptyScene' +import { ResourceLoadingManager } from '../loaders/base/ResourceLoadingManager' +import { GLTF } from '../loaders/gltf/GLTFLoader' +import { ResourceManager, ResourceState, ResourceStatus, ResourceType } from './ResourceState' + +describe('ResourceState', () => { + const url = '/packages/projects/default-project/assets/collisioncube.glb' + + beforeEach(async () => { + createEngine() + loadEmptyScene() + }) + + afterEach(() => { + return destroyEngine() + }) + + it('Errors when resource is missing', (done) => { + const entity = createEntity() + const resourceState = getState(ResourceState) + const controller = new AbortController() + const nonExistingUrl = '/doesNotExist.glb' + assert.doesNotThrow(() => { + ResourceManager.load( + nonExistingUrl, + ResourceType.GLTF, + entity, + {}, + (response) => { + assert(false) + }, + (resquest) => { + assert(false) + }, + (error) => { + assert(resourceState.resources[nonExistingUrl].status === ResourceStatus.Error) + done() + }, + controller.signal + ) + }, done) + }) + + it('Loads asset', (done) => { + const entity = createEntity() + const resourceState = getState(ResourceState) + const controller = new AbortController() + assert.doesNotThrow(() => { + ResourceManager.load( + url, + ResourceType.GLTF, + entity, + {}, + (response) => { + assert(response.asset) + assert(resourceState.resources[url].status === ResourceStatus.Loaded, 'Asset not loaded') + + done() + }, + (resquest) => {}, + (error) => { + assert(false) + }, + controller.signal + ) + }, done) + }) + + it('Removes asset', (done) => { + const entity = createEntity() + const resourceState = getState(ResourceState) + const controller = new AbortController() + assert.doesNotThrow(() => { + ResourceManager.load( + url, + ResourceType.GLTF, + entity, + {}, + (response) => { + ResourceManager.unload(url, entity) + assert(resourceState.resources[url] === undefined, 'Asset not removed') + + done() + }, + (resquest) => {}, + (error) => { + assert(false) + }, + controller.signal + ) + }, done) + }) + + it('Loads asset once, but references twice', (done) => { + const entity = createEntity() + const entity2 = createEntity() + const resourceState = getState(ResourceState) + const controller = new AbortController() + assert.doesNotThrow(() => { + ResourceManager.load( + url, + ResourceType.GLTF, + entity, + {}, + (response) => { + assert(resourceState.resources[url].references.length === 1, 'References not counted') + assert(resourceState.resources[url].references.indexOf(entity) !== -1, 'Entity not referenced') + + ResourceManager.load( + url, + ResourceType.GLTF, + entity2, + {}, + (response) => { + assert(response.asset) + assert(resourceState.resources[url].references.length === 2, 'References not counted') + assert(resourceState.resources[url].references.indexOf(entity) !== -1, 'Entity not referenced') + assert(resourceState.resources[url].references.indexOf(entity) !== -1, 'Entity2 not referenced') + ResourceManager.unload(url, entity) + + assert(resourceState.resources[url].references.length.valueOf() === 1, 'Entity reference not removed') + assert(resourceState.resources[url].references.indexOf(entity) === -1) + + ResourceManager.unload(url, entity2) + assert(resourceState.resources[url] === undefined, 'Asset not removed') + + done() + }, + (resquest) => {}, + (error) => { + assert(false) + }, + controller.signal + ) + }, + (resquest) => {}, + (error) => { + assert(false) + }, + controller.signal + ) + }, done) + }) + + it('Counts references when entity is the same', (done) => { + const entity = createEntity() + const resourceState = getState(ResourceState) + const controller = new AbortController() + assert.doesNotThrow(() => { + ResourceManager.load( + url, + ResourceType.GLTF, + entity, + {}, + (response) => { + assert(resourceState.resources[url].references.length === 1, 'References not counted') + assert(resourceState.resources[url].references.indexOf(entity) !== -1, 'Entity not referenced') + + ResourceManager.load( + url, + ResourceType.GLTF, + entity, + {}, + (response) => { + assert(resourceState.resources[url].references.length === 2, 'References not counted') + assert(resourceState.resources[url].references.indexOf(entity) !== -1, 'Entity not referenced') + ResourceManager.unload(url, entity) + + assert(resourceState.resources[url].references.length.valueOf() === 1, 'Entity reference not removed') + assert(resourceState.resources[url].references.indexOf(entity) !== -1) + + ResourceManager.unload(url, entity) + assert(resourceState.resources[url] === undefined, 'Asset not removed') + + done() + }, + (resquest) => {}, + (error) => { + assert(false) + }, + controller.signal + ) + }, + (resquest) => {}, + (error) => { + assert(false) + }, + controller.signal + ) + }, done) + }) + + it('Calls loading manager', (done) => { + const entity = createEntity() + const resourceState = getState(ResourceState) + const controller = new AbortController() + assert.doesNotThrow(() => { + ResourceManager.setDefaultLoadingManager( + new ResourceLoadingManager((startUrl) => { + assert(startUrl === url) + assert(resourceState.resources[url] !== undefined, 'Asset not added to resource manager') + done() + ResourceManager.setDefaultLoadingManager() + }) as LoadingManager + ) + + ResourceManager.load( + url, + ResourceType.GLTF, + entity, + {}, + (response) => {}, + (resquest) => {}, + (error) => { + assert(false) + }, + controller.signal + ) + }, done) + }) +}) diff --git a/packages/engine/src/assets/state/ResourceState.ts b/packages/engine/src/assets/state/ResourceState.ts new file mode 100644 index 0000000000..6d9ef86f7a --- /dev/null +++ b/packages/engine/src/assets/state/ResourceState.ts @@ -0,0 +1,459 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +import { Entity } from '@etherealengine/ecs' +import { NO_PROXY, State, defineState, getMutableState, getState, none } from '@etherealengine/hyperflux' +import { EngineRenderer } from '@etherealengine/spatial/src/renderer/WebGLRendererSystem' +import { Cache, CompressedTexture, DefaultLoadingManager, LoadingManager, Material, Texture } from 'three' +import { SourceType } from '../../scene/materials/components/MaterialSource' +import { removeMaterialSource } from '../../scene/materials/functions/MaterialLibraryFunctions' +import { AssetLoader, LoadingArgs } from '../classes/AssetLoader' +import { Geometry } from '../constants/Geometry' +import { ResourceLoadingManager } from '../loaders/base/ResourceLoadingManager' +import { GLTF } from '../loaders/gltf/GLTFLoader' + +Cache.enabled = true + +export enum ResourceStatus { + Unloaded, + Loading, + Loaded, + Error +} + +export enum ResourceType { + GLTF, + Texture, + Geometry, + Material, + ECSData, + Audio, + Unknown +} + +const resourceTypeName = { + [ResourceType.GLTF]: 'GLTF', + [ResourceType.Texture]: 'Texture', + [ResourceType.Geometry]: 'Geometry', + [ResourceType.Material]: 'Material', + [ResourceType.ECSData]: 'ECSData', + [ResourceType.Audio]: 'Audio', + [ResourceType.Unknown]: 'Unknown' +} + +export type AssetType = GLTF | Texture | CompressedTexture | Geometry | Material + +type BaseMetadata = { + size?: number +} + +type GLTFMetadata = { + verts: number +} & BaseMetadata + +type TexutreMetadata = { + onGPU: boolean +} & BaseMetadata + +type Metadata = GLTFMetadata | TexutreMetadata | BaseMetadata + +type Resource = { + id: string + status: ResourceStatus + type: ResourceType + references: Entity[] + asset?: AssetType + assetRefs: string[] + metadata: Metadata +} + +const debug = false +const debugLog = debug + ? (message?: any, ...optionalParams: any[]) => { + console.log(message) + } + : (message?: any, ...optionalParams: any[]) => {} + +export const ResourceState = defineState({ + name: 'ResourceManagerState', + initial: () => ({ + resources: {} as Record, + referencedAssets: {} as Record + }) +}) + +const setDefaultLoadingManager = ( + loadingManager: LoadingManager = new ResourceLoadingManager( + onItemStart, + onStart, + onLoad, + onProgress, + onError + ) as LoadingManager +) => { + DefaultLoadingManager.itemStart = loadingManager.itemStart + DefaultLoadingManager.itemEnd = loadingManager.itemEnd + DefaultLoadingManager.itemError = loadingManager.itemError + DefaultLoadingManager.resolveURL = loadingManager.resolveURL + DefaultLoadingManager.setURLModifier = loadingManager.setURLModifier + DefaultLoadingManager.addHandler = loadingManager.addHandler + DefaultLoadingManager.removeHandler = loadingManager.removeHandler + DefaultLoadingManager.getHandler = loadingManager.getHandler + //@ts-ignore + DefaultLoadingManager.itemEndFor = onItemLoadedFor +} + +const onItemStart = (url: string) => { + const resourceState = getMutableState(ResourceState) + const resources = resourceState.nested('resources') + if (!resources[url].value) { + // console.warn('ResourceManager: asset loaded outside of the resource manager, url: ' + url) + return + } + + const resource = resources[url] + if (resource.status.value === ResourceStatus.Unloaded) { + resource.status.set(ResourceStatus.Loading) + } +} + +const onStart = (url: string, loaded: number, total: number) => {} +const onLoad = () => { + const totalSize = getCurrentSizeOfResources() + const totalVerts = getCurrentVertCountOfResources() + debugLog('Loaded: ' + totalSize + ' bytes of resources') + debugLog(totalVerts + ' Vertices') + + //@ts-ignore + if (debug) window.resources = getState(ResourceState) +} + +const onItemLoadedFor = (url: string, resourceType: ResourceType, id: string, asset: T) => { + const resourceState = getMutableState(ResourceState) + const resources = resourceState.nested('resources') + const referencedAssets = resourceState.nested('referencedAssets') + if (!resources[url].value) { + console.warn('ResourceManager:loadedFor asset loaded for asset that is not loaded: ' + url) + return + } + + debugLog( + 'ResourceManager:loadedFor loading asset of type ' + + resourceTypeName[resourceType] + + ' with ID: ' + + id + + ' for asset at url: ' + + url + ) + + if (!referencedAssets[id].value) { + referencedAssets.merge({ + [id]: [] + }) + } + + if (!resources[id].value) { + resources.merge({ + [id]: { + id: id, + status: ResourceStatus.Loaded, + type: resourceType, + references: [], + asset: asset, + assetRefs: [], + metadata: {} + } + }) + const callbacks = Callbacks[resourceType] + callbacks.onStart(resources[id]) + callbacks.onLoad(asset, resources[id]) + } + + resources[url].assetRefs.merge([id]) + referencedAssets[id].merge([url]) +} + +const onProgress = (url: string, loaded: number, total: number) => {} +const onError = (url: string) => {} + +setDefaultLoadingManager() + +const getCurrentSizeOfResources = () => { + let size = 0 + const resources = getState(ResourceState).resources + for (const key in resources) { + const resource = resources[key] + if (resource.metadata.size) size += resource.metadata.size + } + + return size +} + +const getCurrentVertCountOfResources = () => { + let verts = 0 + const resources = getState(ResourceState).resources + for (const key in resources) { + const resource = resources[key] + if ((resource.metadata as GLTFMetadata).verts) verts += (resource.metadata as GLTFMetadata).verts + } + + return verts +} + +const getRendererInfo = () => { + return { + memory: EngineRenderer.instance.renderer.info.memory, + programCount: EngineRenderer.instance.renderer.info.programs?.length + } +} + +const Callbacks = { + [ResourceType.GLTF]: { + onStart: (resource: State) => {}, + onLoad: (response: GLTF, resource: State) => {}, + onProgress: (request: ProgressEvent, resource: State) => { + resource.metadata.size.set(request.total) + }, + onError: (event: ErrorEvent | Error, resource: State) => {} + }, + [ResourceType.Texture]: { + onStart: (resource: State) => { + resource.metadata.merge({ onGPU: false }) + }, + onLoad: (response: Texture | CompressedTexture, resource: State) => { + response.onUpdate = () => { + resource.metadata.merge({ onGPU: true }) + //@ts-ignore + response.onUpdate = null + } + //Compressed texture size + if (response.mipmaps[0]) { + let size = 0 + for (const mip of response.mipmaps) { + size += mip.data.byteLength + } + resource.metadata.size.set(size) + // Non compressed texture size + } else { + const height = response.image.height + const width = response.image.width + const size = width * height * 4 + resource.metadata.size.set(size) + } + }, + onProgress: (request: ProgressEvent, resource: State) => {}, + onError: (event: ErrorEvent | Error, resource: State) => {} + }, + [ResourceType.Material]: { + onStart: (resource: State) => {}, + onLoad: (response: Material, resource: State) => {}, + onProgress: (request: ProgressEvent, resource: State) => {}, + onError: (event: ErrorEvent | Error, resource: State) => {} + }, + [ResourceType.Geometry]: { + onStart: (resource: State) => {}, + onLoad: (response: Geometry, resource: State) => { + // Estimated geometry size + let size = 0 + for (const name in response.attributes) { + const attr = response.getAttribute(name) + size += attr.count * attr.itemSize * attr.array.BYTES_PER_ELEMENT + } + + const indices = response.getIndex() + if (indices) { + resource.metadata.merge({ verts: indices.count }) + size += indices.count * indices.itemSize * indices.array.BYTES_PER_ELEMENT + } + resource.metadata.size.set(size) + }, + onProgress: (request: ProgressEvent, resource: State) => {}, + onError: (event: ErrorEvent | Error, resource: State) => {} + } +} as { + [key in ResourceType]: { + onStart: (resource: State) => void + onLoad: (response: AssetType, resource: State) => void + onProgress: (request: ProgressEvent, resource: State) => void + onError: (event: ErrorEvent | Error, resource: State) => void + } +} + +const load = ( + url: string, + resourceType: ResourceType, + entity: Entity, + args: LoadingArgs, + onLoad: (response: T) => void, + onProgress: (request: ProgressEvent) => void, + onError: (event: ErrorEvent | Error) => void, + signal: AbortSignal +) => { + const resourceState = getMutableState(ResourceState) + const resources = resourceState.nested('resources') + if (!resources[url].value) { + resources.merge({ + [url]: { + id: url, + status: ResourceStatus.Unloaded, + type: resourceType, + references: [entity], + metadata: {}, + assetRefs: [] + } + }) + } else { + resources[url].references.merge([entity]) + } + + const resource = resources[url] + const callbacks = Callbacks[resourceType] + callbacks.onStart(resource) + debugLog('ResourceManager:load Loading resource: ' + url + ' for entity: ' + entity) + AssetLoader.load( + url, + args, + (response: T) => { + resource.status.set(ResourceStatus.Loaded) + resource.asset.set(response) + callbacks.onLoad(response, resource) + onLoad(response) + }, + (request) => { + callbacks.onProgress(request, resource) + onProgress(request) + }, + (error) => { + resource.status.set(ResourceStatus.Error) + callbacks.onError(error, resource) + onError(error) + }, + signal + ) +} + +const unload = (url: string, entity: Entity) => { + const resourceState = getMutableState(ResourceState) + const resources = resourceState.nested('resources') + if (!resources[url].value) { + console.warn('ResourceManager:unload No resource exists for url: ' + url) + return + } + + debugLog('ResourceManager:unload Unloading resource: ' + url + ' for entity: ' + entity) + const resource = resources[url] + resource.references.set((entities) => { + const index = entities.indexOf(entity) + if (index > -1) { + entities.splice(index, 1) + } + return entities + }) + + if (resource.references.length == 0) { + debugLog('Before Removing Resources', debug && JSON.stringify(getRendererInfo())) + removeResource(url) + debugLog('After Removing Resources', debug && JSON.stringify(getRendererInfo())) + } +} + +const removeReferencedResources = (resource: State) => { + const resourceState = getMutableState(ResourceState) + const referencedAssets = resourceState.nested('referencedAssets') + + for (const ref of resource.assetRefs.value) { + referencedAssets[ref].set((refs) => { + const index = refs.indexOf(resource.id.value) + if (index > -1) { + refs.splice(index, 1) + } + return refs + }) + + if (referencedAssets[ref].length == 0) { + removeResource(ref) + referencedAssets[ref].set(none) + } + } +} + +const removeResource = (id: string) => { + const resourceState = getMutableState(ResourceState) + const resources = resourceState.nested('resources') + if (!resources[id].value) { + console.warn('ResourceManager:removeResource No resource exists at id: ' + id) + return + } + + const resource = resources[id] + debugLog( + 'ResourceManager:removeResource: Removing ' + resourceTypeName[resource.type.value] + ' resource with ID: ' + id + ) + Cache.remove(id) + removeReferencedResources(resource) + + const asset = resource.asset.get(NO_PROXY) + if (asset) { + switch (resource.type.value) { + case ResourceType.GLTF: + removeMaterialSource({ type: SourceType.MODEL, path: id }) + break + case ResourceType.Texture: + ;(asset as Texture).dispose() + break + case ResourceType.Geometry: + ;(asset as Geometry).dispose() + break + case ResourceType.Material: + { + const material = asset as Material + for (const [key, val] of Object.entries(material) as [string, Texture][]) { + if (val && typeof val.dispose === 'function') { + val.dispose() + } + } + material.dispose() + } + break + case ResourceType.ECSData: + break + case ResourceType.Audio: + break + case ResourceType.Unknown: + break + + default: + break + } + } + + resources[id].set(none) +} + +export const ResourceManager = { + load, + unload, + setDefaultLoadingManager +} diff --git a/packages/engine/src/avatar/components/LoopAnimationComponent.ts b/packages/engine/src/avatar/components/LoopAnimationComponent.ts index 573f4ef334..3b2700e827 100644 --- a/packages/engine/src/avatar/components/LoopAnimationComponent.ts +++ b/packages/engine/src/avatar/components/LoopAnimationComponent.ts @@ -46,7 +46,7 @@ import { useEntityContext } from '@etherealengine/ecs/src/EntityFunctions' import { NO_PROXY, useHookstate } from '@etherealengine/hyperflux' import { CallbackComponent, StandardCallbacks, setCallback } from '@etherealengine/spatial/src/common/CallbackComponent' import { VRM } from '@pixiv/three-vrm' -import { AssetLoader } from '../../assets/classes/AssetLoader' +import { useGLTF } from '../../assets/functions/resourceHooks' import { ModelComponent } from '../../scene/components/ModelComponent' import { bindAnimationClipFromMixamo, retargetAnimationClip } from '../functions/retargetMixamoRig' import { AnimationComponent } from './AnimationComponent' @@ -214,30 +214,30 @@ export const LoopAnimationComponent = defineComponent({ } }, [modelComponent?.asset]) + const [gltf, unload] = useGLTF(loopAnimationComponent.animationPack.value, entity) + + useEffect(() => { + return unload + }, []) + useEffect(() => { const asset = modelComponent?.asset.get(NO_PROXY) ?? null + const model = gltf.get(NO_PROXY) if ( - !asset?.scene || + !model || !animComponent || + !asset?.scene || !loopAnimationComponent.animationPack.value || lastAnimationPack.value === loopAnimationComponent.animationPack.value ) return - let aborted = false animComponent.mixer.time.set(0) - AssetLoader.loadAsync(loopAnimationComponent.animationPack.value).then((model) => { - if (aborted) return - const animations = model.animations ?? model.scene.animations - for (let i = 0; i < animations.length; i++) retargetAnimationClip(animations[i], model.scene) - lastAnimationPack.set(loopAnimationComponent.animationPack.get(NO_PROXY)) - animComponent.animations.set(animations) - }) - - return () => { - aborted = true - } - }, [animComponent, loopAnimationComponent.animationPack, modelComponent?.scene]) + const animations = model.animations ?? model.scene.animations + for (let i = 0; i < animations.length; i++) retargetAnimationClip(animations[i], model.scene) + lastAnimationPack.set(loopAnimationComponent.animationPack.get(NO_PROXY)) + animComponent.animations.set(animations) + }, [gltf, animComponent, loopAnimationComponent.animationPack]) return null } diff --git a/packages/engine/src/avatar/functions/avatarFunctions.ts b/packages/engine/src/avatar/functions/avatarFunctions.ts index 0f2da15c75..8ce96c0008 100644 --- a/packages/engine/src/avatar/functions/avatarFunctions.ts +++ b/packages/engine/src/avatar/functions/avatarFunctions.ts @@ -40,7 +40,6 @@ import { Entity } from '@etherealengine/ecs/src/Entity' import { setObjectLayers } from '@etherealengine/spatial/src/renderer/components/ObjectLayerComponent' import { ObjectLayers } from '@etherealengine/spatial/src/renderer/constants/ObjectLayers' import { computeTransformMatrix } from '@etherealengine/spatial/src/transform/systems/TransformSystem' -import { AssetLoader } from '../../assets/classes/AssetLoader' import { AnimationState } from '../AnimationManager' // import { retargetSkeleton, syncModelSkeletons } from '../animation/retargetSkeleton' import config from '@etherealengine/common/src/config' @@ -62,7 +61,7 @@ import { AvatarDissolveComponent } from '../components/AvatarDissolveComponent' import { AvatarPendingComponent } from '../components/AvatarPendingComponent' import { AvatarMovementSettingsState } from '../state/AvatarMovementSettingsState' import { LocalAvatarState } from '../state/AvatarState' -import { bindAnimationClipFromMixamo, retargetAnimationClip } from './retargetMixamoRig' +import { bindAnimationClipFromMixamo } from './retargetMixamoRig' declare module '@pixiv/three-vrm/types/VRM' { export interface VRM { @@ -208,30 +207,6 @@ export const retargetAvatarAnimations = (entity: Entity) => { }) } -/**loads animation bundles. assumes the bundle is a glb */ -export const loadBundledAnimations = (animationFiles: string[]) => { - const manager = getMutableState(AnimationState) - - //preload animations - for (const animationFile of animationFiles) { - AssetLoader.loadAsync( - `${config.client.fileServer}/projects/default-project/assets/animations/${animationFile}.glb` - ).then((asset: GLTF) => { - // delete unneeded geometry data to save memory - asset.scene.traverse((node) => { - delete (node as any).geometry - delete (node as any).material - }) - for (let i = 0; i < asset.animations.length; i++) { - retargetAnimationClip(asset.animations[i], asset.scene) - } - //ensure animations are always placed in the scene - asset.scene.animations = asset.animations - manager.loadedAnimations[animationFile].set(asset) - }) - } -} - /** * @todo: stop using global state for avatar speed * in future this will be derrived from the actual root motion of a diff --git a/packages/engine/src/avatar/systems/AvatarAnimationSystem.ts b/packages/engine/src/avatar/systems/AvatarAnimationSystem.ts index a7faca154d..fe6a975516 100644 --- a/packages/engine/src/avatar/systems/AvatarAnimationSystem.ts +++ b/packages/engine/src/avatar/systems/AvatarAnimationSystem.ts @@ -26,10 +26,18 @@ Ethereal Engine. All Rights Reserved. import { useEffect } from 'react' import { MathUtils, Matrix4, Quaternion, Vector3 } from 'three' -import { defineState, getMutableState, getState, none, useHookstate } from '@etherealengine/hyperflux' - +import { + NO_PROXY, + defineState, + getMutableState, + getState, + none, + useHookstate, + useMutableState +} from '@etherealengine/hyperflux' + +import config from '@etherealengine/common/src/config' import { EntityUUID } from '@etherealengine/common/src/interfaces/EntityUUID' -import { isClient } from '@etherealengine/common/src/utils/getEnvironment' import { getComponent, getOptionalComponent, @@ -55,9 +63,11 @@ import { TransformComponent } from '@etherealengine/spatial/src/transform/compon import { XRLeftHandComponent, XRRightHandComponent } from '@etherealengine/spatial/src/xr/XRComponents' import { XRControlsState, XRState } from '@etherealengine/spatial/src/xr/XRState' import { VRMHumanBoneList, VRMHumanBoneName } from '@pixiv/three-vrm' +import { useBatchGLTF } from '../../assets/functions/resourceHooks' import { AnimationComponent } from '.././components/AnimationComponent' import { AvatarAnimationComponent, AvatarRigComponent } from '.././components/AvatarAnimationComponent' import { AvatarHeadDecapComponent, AvatarIKTargetComponent } from '.././components/AvatarIKComponents' +import { AnimationState } from '../AnimationManager' import { IKSerialization } from '../IKSerialization' import { updateAnimationGraph } from '../animation/AvatarAnimationGraph' import { solveTwoBoneIK } from '../animation/TwoBoneIKSolver' @@ -66,7 +76,7 @@ import { applyHandRotationFK } from '../animation/applyHandRotationFK' import { getArmIKHint } from '../animation/getArmIKHint' import { AvatarComponent } from '../components/AvatarComponent' import { SkinnedMeshComponent } from '../components/SkinnedMeshComponent' -import { loadBundledAnimations } from '../functions/avatarFunctions' +import { retargetAnimationClip } from '../functions/retargetMixamoRig' import { updateVRMRetargeting } from '../functions/updateVRMRetargeting' import { LocalAvatarState } from '../state/AvatarState' import { AnimationSystem } from './AnimationSystem' @@ -312,11 +322,42 @@ const execute = () => { } const reactor = () => { + /**loads animation bundles. assumes the bundle is a glb */ + const animations = [preloadedAnimations.locomotion, preloadedAnimations.emotes] + const [gltfs, unload] = useBatchGLTF( + animations.map((animationFile) => { + return `${config.client.fileServer}/projects/default-project/assets/animations/${animationFile}.glb` + }) + ) + const manager = useMutableState(AnimationState) + + useEffect(() => { + return unload + }, []) + useEffect(() => { - if (isClient) { - loadBundledAnimations([preloadedAnimations.locomotion, preloadedAnimations.emotes]) + const assets = gltfs.get(NO_PROXY) + if (assets.length !== animations.length) return + + for (let i = 0; i < assets.length; i++) { + const asset = assets[i] + if (asset) { + // delete unneeded geometry data to save memory + asset.scene.traverse((node) => { + delete (node as any).geometry + delete (node as any).material + }) + for (let i = 0; i < asset.animations.length; i++) { + retargetAnimationClip(asset.animations[i], asset.scene) + } + //ensure animations are always placed in the scene + asset.scene.animations = asset.animations + manager.loadedAnimations[animations[i]].set(asset) + } } + }, [gltfs]) + useEffect(() => { const networkState = getMutableState(NetworkState) networkState.networkSchema[IKSerialization.ID].set({ diff --git a/packages/engine/src/avatar/systems/AvatarLoadingSystem.tsx b/packages/engine/src/avatar/systems/AvatarLoadingSystem.tsx index 13638ec94d..6f67ee214f 100644 --- a/packages/engine/src/avatar/systems/AvatarLoadingSystem.tsx +++ b/packages/engine/src/avatar/systems/AvatarLoadingSystem.tsx @@ -36,7 +36,7 @@ import { getMutableState, getState, useHookstate } from '@etherealengine/hyperfl import { GroupComponent } from '@etherealengine/spatial/src/renderer/components/GroupComponent' import { TransformComponent } from '@etherealengine/spatial/src/transform/components/TransformComponent' import React from 'react' -import { AssetLoader } from '../../assets/classes/AssetLoader' +import { useTexture } from '../../assets/functions/resourceHooks' import { AnimationState } from '../AnimationManager' import { AvatarDissolveComponent } from '../components/AvatarDissolveComponent' import { AvatarPendingComponent } from '../components/AvatarPendingComponent' @@ -110,24 +110,38 @@ const reactor = () => { const assetsReady = useHookstate(false) + const [itemLight, lightUnload] = useTexture('/static/itemLight.png') + const [itemPlate, plateUnload] = useTexture('/static/itemPlate.png') + + useEffect(() => { + const texture = itemLight.value + if (!texture) return + + texture.colorSpace = SRGBColorSpace + texture.needsUpdate = true + SpawnEffectComponent.lightMesh.material.map = texture + return lightUnload + }, [itemLight]) + + useEffect(() => { + const texture = itemPlate.value + if (!texture) return + + texture.colorSpace = SRGBColorSpace + texture.needsUpdate = true + SpawnEffectComponent.plateMesh.material.map = texture + return plateUnload + }, [itemPlate]) + + useEffect(() => { + if (itemLight.value && itemPlate.value) assetsReady.set(true) + }, [itemLight, itemPlate]) + useEffect(() => { SpawnEffectComponent.lightMesh.geometry.computeBoundingSphere() SpawnEffectComponent.plateMesh.geometry.computeBoundingSphere() SpawnEffectComponent.lightMesh.name = 'light_obj' SpawnEffectComponent.plateMesh.name = 'plate_obj' - - AssetLoader.loadAsync('/static/itemLight.png').then((texture) => { - texture.colorSpace = SRGBColorSpace - texture.needsUpdate = true - SpawnEffectComponent.lightMesh.material.map = texture - }) - - AssetLoader.loadAsync('/static/itemPlate.png').then((texture) => { - texture.colorSpace = SRGBColorSpace - texture.needsUpdate = true - SpawnEffectComponent.plateMesh.material.map = texture - assetsReady.set(true) - }) }, []) const loadingEffect = useHookstate(getMutableState(AnimationState).avatarLoadingEffect) diff --git a/packages/engine/src/scene/components/EnvmapComponent.tsx b/packages/engine/src/scene/components/EnvmapComponent.tsx index d3ae866f5e..afceb49c8f 100644 --- a/packages/engine/src/scene/components/EnvmapComponent.tsx +++ b/packages/engine/src/scene/components/EnvmapComponent.tsx @@ -39,7 +39,7 @@ import { } from 'three' import { EntityUUID } from '@etherealengine/common/src/interfaces/EntityUUID' -import { getMutableState, getState, useHookstate } from '@etherealengine/hyperflux' +import { NO_PROXY, getMutableState, getState, useHookstate } from '@etherealengine/hyperflux' import { isClient } from '@etherealengine/common/src/utils/getEnvironment' import { @@ -56,7 +56,7 @@ import { UUIDComponent } from '@etherealengine/spatial/src/common/UUIDComponent' import { RendererState } from '@etherealengine/spatial/src/renderer/RendererState' import { GroupComponent } from '@etherealengine/spatial/src/renderer/components/GroupComponent' import { MeshComponent } from '@etherealengine/spatial/src/renderer/components/MeshComponent' -import { AssetLoader } from '../../assets/classes/AssetLoader' +import { useTexture } from '../../assets/functions/resourceHooks' import { EnvMapSourceType, EnvMapTextureType } from '../constants/EnvMapEnum' import { getRGBArray, loadCubeMapTexture } from '../constants/Util' import { addError, removeError } from '../functions/ErrorFunctions' @@ -121,6 +121,10 @@ export const EnvmapComponent = defineComponent({ const component = useComponent(entity, EnvmapComponent) const background = useHookstate(getMutableState(SceneState).background) const mesh = useOptionalComponent(entity, MeshComponent)?.value as Mesh | null + const [envMapTexture, unload, error] = useTexture( + component.envMapTextureType.value === EnvMapTextureType.Equirectangular ? component.envMapSourceURL.value : '', + entity + ) useEffect(() => { updateEnvMapIntensity(mesh, component.envMapIntensity.value) @@ -142,55 +146,51 @@ export const EnvmapComponent = defineComponent({ texture.needsUpdate = true texture.colorSpace = SRGBColorSpace texture.mapping = EquirectangularReflectionMapping - component.envmap.set(texture) + SceneAssetPendingTagComponent.removeResource(entity, EnvmapComponent.jsonID) }, [component.type, component.envMapSourceColor]) + useEffect(() => { + const texture = envMapTexture.get(NO_PROXY) + if (!texture) return + + texture.mapping = EquirectangularReflectionMapping + component.envmap.set(texture) + SceneAssetPendingTagComponent.removeResource(entity, EnvmapComponent.jsonID) + + return unload + }, [envMapTexture]) + + useEffect(() => { + if (!error.value) return + + component.envmap.set(null) + addError(entity, EnvmapComponent, 'MISSING_FILE', 'Skybox texture could not be found!') + SceneAssetPendingTagComponent.removeResource(entity, EnvmapComponent.jsonID) + }, [error]) + useEffect(() => { if (component.type.value !== EnvMapSourceType.Texture) return - switch (component.envMapTextureType.value) { - case EnvMapTextureType.Cubemap: - loadCubeMapTexture( - component.envMapSourceURL.value, - (texture: CubeTexture | undefined) => { - SceneAssetPendingTagComponent.removeResource(entity, EnvmapComponent.jsonID) - if (!texture) return + if (component.envMapTextureType.value == EnvMapTextureType.Cubemap) { + loadCubeMapTexture( + component.envMapSourceURL.value, + (texture: CubeTexture | undefined) => { + SceneAssetPendingTagComponent.removeResource(entity, EnvmapComponent.jsonID) + if (texture) { texture.mapping = CubeReflectionMapping texture.colorSpace = SRGBColorSpace component.envmap.set(texture) removeError(entity, EnvmapComponent, 'MISSING_FILE') - }, - undefined, - (_) => { - component.envmap.set(null) - addError(entity, EnvmapComponent, 'MISSING_FILE', 'Skybox texture could not be found!') - SceneAssetPendingTagComponent.removeResource(entity, EnvmapComponent.jsonID) } - ) - break - - case EnvMapTextureType.Equirectangular: - AssetLoader.loadAsync(component.envMapSourceURL.value, {}) - .then((texture) => { - if (texture) { - texture.mapping = EquirectangularReflectionMapping - component.envmap.set(texture) - removeError(entity, EnvmapComponent, 'MISSING_FILE') - } else { - component.envmap.set(null) - addError(entity, EnvmapComponent, 'MISSING_FILE', 'Skybox texture could not be found!') - } - }) - .catch((e) => { - component.envmap.set(null) - addError(entity, EnvmapComponent, 'MISSING_FILE', 'Skybox texture could not be found!') - }) - .finally(() => { - SceneAssetPendingTagComponent.removeResource(entity, EnvmapComponent.jsonID) - }) - default: - SceneAssetPendingTagComponent.removeResource(entity, EnvmapComponent.jsonID) + }, + undefined, + (_) => { + component.envmap.set(null) + addError(entity, EnvmapComponent, 'MISSING_FILE', 'Skybox texture could not be found!') + SceneAssetPendingTagComponent.removeResource(entity, EnvmapComponent.jsonID) + } + ) } }, [component.type, component.envMapSourceURL]) @@ -224,27 +224,28 @@ const EnvBakeComponentReactor = (props: { envmapEntity: Entity; bakeEntity: Enti const bakeComponent = useComponent(bakeEntity, EnvMapBakeComponent) const group = useComponent(envmapEntity, GroupComponent) const renderState = useHookstate(getMutableState(RendererState)) + const [envMaptexture, unload, error] = useTexture(bakeComponent.envMapOrigin.value, envmapEntity) + + useEffect(() => { + return unload + }, []) /** @todo add an unmount cleanup for applyBoxprojection */ useEffect(() => { - AssetLoader.loadAsync(bakeComponent.envMapOrigin.value, {}) - .then((texture) => { - if (texture) { - texture.mapping = EquirectangularReflectionMapping - getMutableComponent(envmapEntity, EnvmapComponent).envmap.set(texture) - if (bakeComponent.boxProjection.value) applyBoxProjection(bakeEntity, group.value) - removeError(envmapEntity, EnvmapComponent, 'MISSING_FILE') - } else { - addError(envmapEntity, EnvmapComponent, 'MISSING_FILE', 'Skybox texture could not be found!') - } - }) - .catch((e) => { - addError(envmapEntity, EnvmapComponent, 'MISSING_FILE', 'Skybox texture could not be found!') - }) - .finally(() => { - SceneAssetPendingTagComponent.removeResource(props.envmapEntity, EnvmapComponent.jsonID) - }) - }, [renderState.forceBasicMaterials, bakeComponent.envMapOrigin]) + const texture = envMaptexture.get(NO_PROXY) + if (!texture) return + + texture.mapping = EquirectangularReflectionMapping + getMutableComponent(envmapEntity, EnvmapComponent).envmap.set(texture) + if (bakeComponent.boxProjection.value) applyBoxProjection(bakeEntity, group.value) + SceneAssetPendingTagComponent.removeResource(props.envmapEntity, EnvmapComponent.jsonID) + }, [envMaptexture]) + + useEffect(() => { + if (!error.value) return + addError(envmapEntity, EnvmapComponent, 'MISSING_FILE', 'Skybox texture could not be found!') + SceneAssetPendingTagComponent.removeResource(props.envmapEntity, EnvmapComponent.jsonID) + }, [error]) return null } diff --git a/packages/engine/src/scene/components/HyperspaceTagComponent.ts b/packages/engine/src/scene/components/HyperspaceTagComponent.ts index 51b542f6c1..cda2777db3 100644 --- a/packages/engine/src/scene/components/HyperspaceTagComponent.ts +++ b/packages/engine/src/scene/components/HyperspaceTagComponent.ts @@ -38,7 +38,7 @@ import { Entity, UndefinedEntity } from '@etherealengine/ecs/src/Entity' import { createEntity, removeEntity, useEntityContext } from '@etherealengine/ecs/src/EntityFunctions' import { useExecute } from '@etherealengine/ecs/src/SystemFunctions' import { SceneState } from '@etherealengine/engine/src/scene/Scene' -import { getMutableState, getState } from '@etherealengine/hyperflux' +import { NO_PROXY, getMutableState, getState } from '@etherealengine/hyperflux' import { CameraComponent } from '@etherealengine/spatial/src/camera/components/CameraComponent' import { NameComponent } from '@etherealengine/spatial/src/common/NameComponent' import { ObjectDirection } from '@etherealengine/spatial/src/common/constants/Axis3D' @@ -66,7 +66,7 @@ import { TubeGeometry, Vector3 } from 'three' -import { AssetLoader } from '../../assets/classes/AssetLoader' +import { useTexture } from '../../assets/functions/resourceHooks' import { teleportAvatar } from '../../avatar/functions/moveAvatar' import { SceneLoadingSystem } from '../SceneModule' import { PortalComponent, PortalEffects, PortalState } from './PortalComponent' @@ -172,12 +172,6 @@ export const HyperspaceTagComponent = defineComponent({ addObjectToGroup(hyperspaceEffectEntity, hyperspaceEffect) setObjectLayers(hyperspaceEffect, ObjectLayers.Portal) - AssetLoader.loadAsync(`${config.client.fileServer}/projects/default-project/assets/galaxyTexture.jpg`).then( - (texture) => { - hyperspaceEffect.texture = texture - } - ) - getComponent(hyperspaceEffectEntity, TransformComponent).scale.set(10, 10, 10) setComponent(hyperspaceEffectEntity, EntityTreeComponent, { parentEntity: entity }) setComponent(hyperspaceEffectEntity, VisibleComponent) @@ -210,6 +204,18 @@ export const HyperspaceTagComponent = defineComponent({ const hyperspaceEffect = getComponent(hyperspaceEffectEntity, GroupComponent)[0] as any as PortalEffect const cameraTransform = getComponent(Engine.instance.cameraEntity, TransformComponent) const camera = getComponent(Engine.instance.cameraEntity, CameraComponent) + const [galaxyTexture, unload] = useTexture( + `${config.client.fileServer}/projects/default-project/assets/galaxyTexture.jpg`, + entity + ) + + useEffect(() => { + const texture = galaxyTexture.get(NO_PROXY) + if (!texture) return + + hyperspaceEffect.texture = texture + return unload + }, [galaxyTexture]) useEffect(() => { // TODO: add BPCEM of old and new scenes and fade them in and out too diff --git a/packages/engine/src/scene/components/ImageComponent.ts b/packages/engine/src/scene/components/ImageComponent.ts index c3ab834f9f..7a166d7a82 100644 --- a/packages/engine/src/scene/components/ImageComponent.ts +++ b/packages/engine/src/scene/components/ImageComponent.ts @@ -41,7 +41,7 @@ import { } from 'three' import { EntityUUID } from '@etherealengine/common/src/interfaces/EntityUUID' -import { getState, useHookstate } from '@etherealengine/hyperflux' +import { getState, NO_PROXY, useHookstate } from '@etherealengine/hyperflux' import config from '@etherealengine/common/src/config' import { StaticResourceType } from '@etherealengine/common/src/schema.type.module' @@ -51,6 +51,7 @@ import { addObjectToGroup, removeObjectFromGroup } from '@etherealengine/spatial import { EngineRenderer } from '@etherealengine/spatial/src/renderer/WebGLRendererSystem' import { AssetLoader } from '../../assets/classes/AssetLoader' import { AssetClass } from '../../assets/enum/AssetClass' +import { useTexture } from '../../assets/functions/resourceHooks' import { ImageAlphaMode, ImageAlphaModeType, ImageProjection, ImageProjectionType } from '../classes/ImageUtils' import { addError, clearErrors } from '../functions/ErrorFunctions' import { SceneState } from '../Scene' @@ -161,34 +162,36 @@ export function ImageReactor() { const image = useComponent(entity, ImageComponent) const texture = useHookstate(null as Texture | null) - useEffect( - function updateTextureSource() { - if (!image.source.value) { - return addError(entity, ImageComponent, `MISSING_TEXTURE_SOURCE`) - } - - const assetType = AssetLoader.getAssetClass(image.source.value) - if (assetType !== AssetClass.Image) { - return addError(entity, ImageComponent, `UNSUPPORTED_ASSET_CLASS`) - } + const [textureState, unload, error] = useTexture(image.source.value, entity) - AssetLoader.loadAsync(image.source.value) - .then((_texture) => { - texture.set(_texture) - }) - .catch((e) => { - addError(entity, ImageComponent, `LOADING_ERROR`, e.message) - }) - .finally(() => { - SceneAssetPendingTagComponent.removeResource(entity, ImageComponent.jsonID) - }) + useEffect(() => { + const _texture = textureState.get(NO_PROXY) + if (_texture) { + texture.set(_texture) + SceneAssetPendingTagComponent.removeResource(entity, ImageComponent.jsonID) + return unload + } + }, [textureState]) + + useEffect(() => { + if (!error.value) return + addError(entity, ImageComponent, `LOADING_ERROR`, error.value.message) + SceneAssetPendingTagComponent.removeResource(entity, ImageComponent.jsonID) + }, [error]) + + useEffect(() => { + if (!image.source.value) { + addError(entity, ImageComponent, `MISSING_TEXTURE_SOURCE`) + SceneAssetPendingTagComponent.removeResource(entity, ImageComponent.jsonID) + return + } - return () => { - // TODO: abort load request, pending https://github.com/mrdoob/three.js/pull/23070 - } - }, - [image.source] - ) + const assetType = AssetLoader.getAssetClass(image.source.value) + if (assetType !== AssetClass.Image) { + addError(entity, ImageComponent, `UNSUPPORTED_ASSET_CLASS`) + SceneAssetPendingTagComponent.removeResource(entity, ImageComponent.jsonID) + } + }, [image.source]) useEffect( function updateTexture() { diff --git a/packages/engine/src/scene/components/MediaComponent.ts b/packages/engine/src/scene/components/MediaComponent.ts index 5c48f67702..5212c0f57c 100644 --- a/packages/engine/src/scene/components/MediaComponent.ts +++ b/packages/engine/src/scene/components/MediaComponent.ts @@ -27,7 +27,7 @@ import Hls from 'hls.js' import { startTransition, useEffect } from 'react' import { DoubleSide, Mesh, MeshBasicMaterial, PlaneGeometry } from 'three' -import { State, getMutableState, getState, none, useHookstate } from '@etherealengine/hyperflux' +import { NO_PROXY, State, getMutableState, getState, none, useHookstate } from '@etherealengine/hyperflux' import { isClient } from '@etherealengine/common/src/utils/getEnvironment' import { @@ -52,6 +52,7 @@ import { setVisibleComponent } from '@etherealengine/spatial/src/renderer/compon import { ObjectLayers } from '@etherealengine/spatial/src/renderer/constants/ObjectLayers' import { EntityTreeComponent } from '@etherealengine/spatial/src/transform/components/EntityTree' import { AssetLoader } from '../../assets/classes/AssetLoader' +import { useTexture } from '../../assets/functions/resourceHooks' import { AudioState } from '../../audio/AudioState' import { removePannerNode } from '../../audio/PositionalAudioFunctions' import { PlayMode } from '../constants/PlayMode' @@ -481,15 +482,22 @@ export function MediaReactor() { ) const debugEnabled = useHookstate(getMutableState(RendererState).nodeHelperVisibility) + const [audioHelperTexture, unload] = useTexture(debugEnabled.value ? AUDIO_TEXTURE_PATH : '', entity) + + useEffect(() => { + if (!audioHelperTexture.value) return + return unload + }, [audioHelperTexture]) useEffect(() => { if (!debugEnabled.value) return const helper = new Mesh(new PlaneGeometry(), new MeshBasicMaterial({ transparent: true, side: DoubleSide })) helper.name = `audio-helper-${entity}` - AssetLoader.loadAsync(AUDIO_TEXTURE_PATH).then((AUDIO_HELPER_TEXTURE) => { - helper.material.map = AUDIO_HELPER_TEXTURE - }) + if (audioHelperTexture.value) { + const texture = audioHelperTexture.get(NO_PROXY) + helper.material.map = texture + } const helperEntity = createEntity() addObjectToGroup(helperEntity, helper) @@ -503,7 +511,7 @@ export function MediaReactor() { removeEntity(helperEntity) media.helperEntity.set(none) } - }, [debugEnabled]) + }, [debugEnabled, audioHelperTexture]) return null } diff --git a/packages/engine/src/scene/components/MeshBVHComponent.ts b/packages/engine/src/scene/components/MeshBVHComponent.ts index 38ba0d5138..b54b88287c 100644 --- a/packages/engine/src/scene/components/MeshBVHComponent.ts +++ b/packages/engine/src/scene/components/MeshBVHComponent.ts @@ -30,7 +30,7 @@ import { useOptionalComponent } from '@etherealengine/ecs/src/ComponentFunctions' import { useEntityContext } from '@etherealengine/ecs/src/EntityFunctions' -import { getMutableState, useHookstate } from '@etherealengine/hyperflux' +import { NO_PROXY, getMutableState, useHookstate } from '@etherealengine/hyperflux' import { RendererState } from '@etherealengine/spatial/src/renderer/RendererState' import { addObjectToGroup, removeObjectFromGroup } from '@etherealengine/spatial/src/renderer/components/GroupComponent' import { MeshComponent } from '@etherealengine/spatial/src/renderer/components/MeshComponent' @@ -39,7 +39,7 @@ import { VisibleComponent } from '@etherealengine/spatial/src/renderer/component import { ObjectLayers } from '@etherealengine/spatial/src/renderer/constants/ObjectLayers' import { useEffect } from 'react' import { BufferGeometry, InstancedMesh, LineBasicMaterial, Mesh, Object3D, SkinnedMesh } from 'three' -import { MeshBVHVisualizer, acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' +import { MeshBVHHelper, acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' import { generateMeshBVH } from '../functions/bvhWorkerPool' import { ModelComponent } from './ModelComponent' @@ -54,8 +54,13 @@ const edgeMaterial = new LineBasicMaterial({ depthWrite: false }) -function ValidMeshForBVH(mesh: Mesh): boolean { - return mesh && mesh.isMesh && !(mesh as InstancedMesh).isInstancedMesh && !(mesh as SkinnedMesh).isSkinnedMesh +function ValidMeshForBVH(mesh: Mesh | undefined): boolean { + return ( + mesh !== undefined && + mesh.isMesh && + !(mesh as InstancedMesh).isInstancedMesh && + !(mesh as SkinnedMesh).isSkinnedMesh + ) } export const MeshBVHComponent = defineComponent({ @@ -63,21 +68,17 @@ export const MeshBVHComponent = defineComponent({ onInit(entity) { return { - generated: false, - visualizers: null as Object3D[] | null + generated: false } }, onSet(entity, component, json) { if (!json) return - - if (json.visualizers) component.visualizers.set(json.visualizers) }, toJSON(entity, component) { return { - generated: component.generated.value, - visualizers: component.visualizers.value + generated: component.generated.value } }, @@ -88,23 +89,33 @@ export const MeshBVHComponent = defineComponent({ const visible = useOptionalComponent(entity, VisibleComponent) const model = useComponent(entity, ModelComponent) const childEntities = useHookstate(ModelComponent.entitiesInModelHierarchyState[entity]) + const abortControllerState = useHookstate(new AbortController()) + + useEffect(() => { + const abortController = abortControllerState.get(NO_PROXY) + + return () => { + abortController.abort() + } + }, []) useEffect(() => { - let aborted = false if (!component.generated.value && visible?.value) { + const abortController = abortControllerState.get(NO_PROXY) const entities = childEntities.value + const cameraOcclusion = model.cameraOcclusion.get(NO_PROXY) let toGenerate = 0 for (const currEntity of entities) { const mesh = getOptionalComponent(currEntity, MeshComponent) - if (ValidMeshForBVH(mesh!)) { + if (ValidMeshForBVH(mesh)) { toGenerate += 1 - generateMeshBVH(mesh!).then(() => { - if (!aborted) { + generateMeshBVH(mesh!, abortController.signal).then(() => { + if (!abortController.signal.aborted) { toGenerate -= 1 if (toGenerate == 0) { component.generated.set(true) } - if (model.cameraOcclusion) { + if (cameraOcclusion) { ObjectLayerMaskComponent.enableLayers(currEntity, ObjectLayers.Camera) } } @@ -112,46 +123,35 @@ export const MeshBVHComponent = defineComponent({ } } } - - return () => { - aborted = true - } }, [visible, childEntities]) useEffect(() => { if (!component.generated.value) return - const remove = () => { - if (component.visualizers.value) { - for (const visualizer of component.visualizers.value) { - removeObjectFromGroup(visualizer.entity, visualizer) - } - } - component.visualizers.set(null) - } - - if (debug.value && !component.visualizers.value) { - component.visualizers.set([]) + const visualizers = [] as Object3D[] + if (debug.value) { const entities = childEntities.value for (const currEntity of entities) { const mesh = getOptionalComponent(currEntity, MeshComponent) if (ValidMeshForBVH(mesh!)) { - const meshBVHVisualizer = new MeshBVHVisualizer(mesh!) + const meshBVHVisualizer = new MeshBVHHelper(mesh!) meshBVHVisualizer.edgeMaterial = edgeMaterial meshBVHVisualizer.depth = 20 meshBVHVisualizer.displayParents = false meshBVHVisualizer.update() addObjectToGroup(currEntity, meshBVHVisualizer) - component.visualizers.merge([meshBVHVisualizer]) + visualizers.push(meshBVHVisualizer) } } - } else if (!debug.value) { - remove() } return () => { - remove() + if (visualizers) { + for (const visualizer of visualizers) { + removeObjectFromGroup(visualizer.entity, visualizer) + } + } } }, [component.generated, debug]) diff --git a/packages/engine/src/scene/components/ModelComponent.tsx b/packages/engine/src/scene/components/ModelComponent.tsx index 508c8a077b..2bf1cc24f5 100644 --- a/packages/engine/src/scene/components/ModelComponent.tsx +++ b/packages/engine/src/scene/components/ModelComponent.tsx @@ -54,14 +54,12 @@ import { GroupComponent, addObjectToGroup } from '@etherealengine/spatial/src/re import { MeshComponent } from '@etherealengine/spatial/src/renderer/components/MeshComponent' import { VRM } from '@pixiv/three-vrm' import React from 'react' -import { AssetLoader } from '../../assets/classes/AssetLoader' import { AssetType } from '../../assets/enum/AssetType' +import { useGLTF } from '../../assets/functions/resourceHooks' import { GLTF } from '../../assets/loaders/gltf/GLTFLoader' import { AnimationComponent } from '../../avatar/components/AnimationComponent' import { AvatarRigComponent } from '../../avatar/components/AvatarAnimationComponent' import { autoconvertMixamoAvatar, isAvaturn } from '../../avatar/functions/avatarFunctions' -import { SourceType } from '../../scene/materials/components/MaterialSource' -import { removeMaterialSource } from '../../scene/materials/functions/MaterialLibraryFunctions' import { addError, removeError } from '../functions/ErrorFunctions' import { parseGLTFModel, proxifyParentChildRelationships } from '../functions/loadGLTFModel' import { getModelSceneID } from '../functions/loaders/ModelFunctions' @@ -71,19 +69,6 @@ import { SceneAssetPendingTagComponent } from './SceneAssetPendingTagComponent' import { SceneObjectComponent } from './SceneObjectComponent' import { ShadowComponent } from './ShadowComponent' import { SourceComponent } from './SourceComponent' -import { VariantComponent } from './VariantComponent' - -function clearMaterials(src: string) { - try { - removeMaterialSource({ type: SourceType.MODEL, path: src ?? '' }) - } catch (e) { - if (e?.name === 'MaterialNotFound') { - console.warn('could not find material in source ' + src) - } else { - throw e - } - } -} const entitiesInModelHierarchy = {} as Record @@ -143,20 +128,61 @@ export const ModelComponent = defineComponent({ function ModelReactor(): JSX.Element { const entity = useEntityContext() const modelComponent = useComponent(entity, ModelComponent) - const uuidComponent = useComponent(entity, UUIDComponent) - const variantComponent = useOptionalComponent(entity, VariantComponent) + + /** @todo this is a hack */ + const override = !isAvaturn(modelComponent.src.value) ? undefined : AssetType.glB + const [gltf, unload, error, progress] = useGLTF(modelComponent.src.value, entity, { + forceAssetType: override, + ignoreDisposeGeometry: modelComponent.cameraOcclusion.value + }) useEffect(() => { - let aborted = false - if (variantComponent && !variantComponent.calculated.value) return - const model = modelComponent.value - const src = model.src - if (!src) { - modelComponent.scene.set(null) - modelComponent.asset.set(null) + /* unload should only be called when the component is unmounted + the useGLTF hook will handle unloading if the model source is changed ie. the user changes their avatar model */ + return unload + }, []) + + useEffect(() => { + const onprogress = progress.value + if (!onprogress) return + if (hasComponent(entity, SceneAssetPendingTagComponent)) + SceneAssetPendingTagComponent.loadingProgress.merge({ + [entity]: { + loadedAmount: onprogress.loaded, + totalAmount: onprogress.total + } + }) + }, [progress]) + + useEffect(() => { + const err = error.value + if (!err) return + + console.error(err) + addError(entity, ModelComponent, 'INVALID_SOURCE', err.message) + SceneAssetPendingTagComponent.removeResource(entity, modelComponent.src.value) + }, [error]) + + useEffect(() => { + const loadedAsset = gltf.get(NO_PROXY) + if (!loadedAsset) return + + if (typeof loadedAsset !== 'object') { + addError(entity, ModelComponent, 'INVALID_SOURCE', 'Invalid URL') return } + const boneMatchedAsset = modelComponent.convertToVRM.value + ? (autoconvertMixamoAvatar(loadedAsset) as GLTF) + : loadedAsset + + /**if we've loaded or converted to vrm, create animation component whose mixer's root is the normalized rig */ + if (boneMatchedAsset instanceof VRM) + setComponent(entity, AnimationComponent, { + animations: loadedAsset.animations, + mixer: new AnimationMixer(boneMatchedAsset.humanoid.normalizedHumanBonesRoot) + }) + if (!hasComponent(entity, GroupComponent)) { const obj3d = new Group() obj3d.entity = entity @@ -164,62 +190,17 @@ function ModelReactor(): JSX.Element { proxifyParentChildRelationships(obj3d) } - /** @todo this is a hack */ - const override = !isAvaturn(src) ? undefined : AssetType.glB - - AssetLoader.load( - src, - { - forceAssetType: override, - ignoreDisposeGeometry: modelComponent.cameraOcclusion.value - }, - (loadedAsset) => { - if (variantComponent && !variantComponent.calculated.value) return - if (aborted) return - if (typeof loadedAsset !== 'object') { - addError(entity, ModelComponent, 'INVALID_SOURCE', 'Invalid URL') - return - } - const boneMatchedAsset = modelComponent.convertToVRM.value - ? (autoconvertMixamoAvatar(loadedAsset) as GLTF) - : loadedAsset - /**if we've loaded or converted to vrm, create animation component whose mixer's root is the normalized rig */ - if (boneMatchedAsset instanceof VRM) - setComponent(entity, AnimationComponent, { - animations: loadedAsset.animations, - mixer: new AnimationMixer(boneMatchedAsset.humanoid.normalizedHumanBonesRoot) - }) - modelComponent.asset.set(boneMatchedAsset) - }, - (onprogress) => { - if (aborted) return - if (getOptionalComponent(entity, SceneAssetPendingTagComponent)?.includes(src)) - SceneAssetPendingTagComponent.loadingProgress.merge({ - [entity]: { - loadedAmount: onprogress.loaded, - totalAmount: onprogress.total - } - }) - }, - (err: Error) => { - if (aborted) return - console.error(err) - addError(entity, ModelComponent, 'INVALID_SOURCE', err.message) - SceneAssetPendingTagComponent.removeResource(entity, src) - } - ) - return () => { - aborted = true - SceneAssetPendingTagComponent.removeResource(entity, src) - } - }, [modelComponent.src, modelComponent.convertToVRM, variantComponent?.calculated]) + modelComponent.asset.set(boneMatchedAsset) + }, [gltf]) useEffect(() => { const model = modelComponent.get(NO_PROXY)! const asset = model.asset as GLTF | null if (!asset) return + const group = getOptionalComponent(entity, GroupComponent) if (!group) return + removeError(entity, ModelComponent, 'INVALID_SOURCE') removeError(entity, ModelComponent, 'LOADING_ERROR') const sceneObj = group[0] as Scene @@ -232,8 +213,7 @@ function ModelReactor(): JSX.Element { // update scene useEffect(() => { - const scene = getComponent(entity, ModelComponent).scene - const asset = getComponent(entity, ModelComponent).asset + const { scene, asset, src } = getComponent(entity, ModelComponent) if (!scene || !asset) return @@ -254,7 +234,7 @@ function ModelReactor(): JSX.Element { project: '', thumbnailUrl: '' }) - const src = modelComponent.src.value + if (!hasComponent(entity, AvatarRigComponent)) { //if this is not an avatar, add bbox snap setComponent(entity, ObjectGridSnapComponent) @@ -271,7 +251,6 @@ function ModelReactor(): JSX.Element { }) return () => { - if (!(asset instanceof VRM)) clearMaterials(src) /** @todo Replace with hooks and refrence counting */ getMutableState(SceneState).scenes[uuid].set(none) } }, [modelComponent.scene]) diff --git a/packages/engine/src/scene/components/ParticleSystemComponent.ts b/packages/engine/src/scene/components/ParticleSystemComponent.ts index 7cafcb274b..1e96b3f389 100644 --- a/packages/engine/src/scene/components/ParticleSystemComponent.ts +++ b/packages/engine/src/scene/components/ParticleSystemComponent.ts @@ -55,7 +55,7 @@ import { VisibleComponent } from '@etherealengine/spatial/src/renderer/component import { TransformComponent } from '@etherealengine/spatial/src/transform/components/TransformComponent' import { AssetLoader } from '../../assets/classes/AssetLoader' import { AssetClass } from '../../assets/enum/AssetClass' -import { GLTF } from '../../assets/loaders/gltf/GLTFLoader' +import { useGLTF, useTexture } from '../../assets/functions/resourceHooks' import getFirstMesh from '../util/meshUtils' export const ParticleState = defineState({ @@ -802,6 +802,61 @@ export const ParticleSystemComponent = defineComponent({ const component = componentState.value const batchRenderer = useHookstate(getMutableState(ParticleState).batchRenderer) + const [geoDependency, unloadGeo] = useGLTF(component.systemParameters.instancingGeometry!, entity, {}, (url) => { + metadata.geometries.nested(url).set(none) + }) + const [shapeMesh, unloadMesh] = useGLTF(component.systemParameters.shape.mesh!, entity, {}, (url) => { + metadata.geometries.nested(url).set(none) + }) + const [textureState, unloadTexture] = useTexture(component.systemParameters.texture!, entity, {}, (url) => { + metadata.textures.nested(url).set(none) + dudMaterial.map.set(none) + }) + + const metadata = useHookstate({ textures: {}, geometries: {}, materials: {} } as ParticleSystemMetadata) + const dudMaterial = useHookstate( + new MeshBasicMaterial({ + color: 0xffffff, + transparent: component.systemParameters.transparent ?? true, + blending: component.systemParameters.blending as Blending + }) + ) + + useEffect(() => { + //add dud material + componentState.systemParameters.material.set('dud') + metadata.materials.nested('dud').set(dudMaterial.get(NO_PROXY)) + + return () => { + unloadGeo() + unloadMesh() + unloadTexture() + } + }, []) + + useEffect(() => { + if (!geoDependency.value || !geoDependency.value.scene) return + + const scene = geoDependency.value.scene + const geo = getFirstMesh(scene)?.geometry + !!geo && metadata.geometries.nested(component.systemParameters.instancingGeometry!).set(geo) + }, [geoDependency]) + + useEffect(() => { + if (!shapeMesh.value || !shapeMesh.value.scene) return + + const scene = shapeMesh.value.scene + const mesh = getFirstMesh(scene) + mesh && metadata.geometries.nested(component.systemParameters.shape.mesh!).set(mesh.geometry) + }, [shapeMesh]) + + useEffect(() => { + const texture = textureState.get(NO_PROXY) + if (!texture) return + metadata.textures.nested(component.systemParameters.texture!).set(texture) + dudMaterial.map.set(texture) + }, [textureState]) + useEffect(() => { if (component.system) { const emitterAsObj3D = component.system.emitter as unknown as Object3D @@ -842,59 +897,18 @@ export const ParticleSystemComponent = defineComponent({ component.systemParameters.texture && AssetLoader.getAssetClass(component.systemParameters.texture) === AssetClass.Image - const loadDependencies: Promise[] = [] - const metadata: ParticleSystemMetadata = { textures: {}, geometries: {}, materials: {} } - - //add dud material - componentState.systemParameters.material.set('dud') - const dudMaterial = new MeshBasicMaterial({ - color: 0xffffff, - transparent: component.systemParameters.transparent ?? true, - blending: component.systemParameters.blending as Blending - }) - metadata.materials['dud'] = dudMaterial + const loadedEmissionGeo = (doLoadEmissionGeo && shapeMesh.value) || !doLoadEmissionGeo + const loadedInstanceGeo = (doLoadInstancingGeo && geoDependency.value) || !doLoadInstancingGeo + const loadedTexture = (doLoadTexture && textureState.value) || !doLoadTexture - const processedParms = JSON.parse(JSON.stringify(component.systemParameters)) as ExpandedSystemJSON + if (loadedEmissionGeo && loadedInstanceGeo && loadedTexture) { + const processedParms = JSON.parse(JSON.stringify(component.systemParameters)) as ExpandedSystemJSON - function loadGeoDependency(src: string) { - return new Promise((resolve) => { - AssetLoader.load(src, {}, ({ scene }: GLTF) => { - const geo = getFirstMesh(scene)?.geometry - !!geo && (metadata.geometries[src] = geo) - resolve(null) - }) - }) + componentState._loadIndex.set(componentState._loadIndex.value + 1) + const currentIndex = componentState._loadIndex.value + currentIndex === componentState._loadIndex.value && initParticleSystem(processedParms, metadata.value) } - doLoadEmissionGeo && - loadDependencies.push( - new Promise((resolve) => { - AssetLoader.load(component.systemParameters.shape.mesh!, {}, ({ scene }: GLTF) => { - const mesh = getFirstMesh(scene) - mesh && (metadata.geometries[component.systemParameters.shape.mesh!] = mesh.geometry) - resolve(null) - }) - }) - ) - - doLoadInstancingGeo && loadDependencies.push(loadGeoDependency(component.systemParameters.instancingGeometry!)) - - doLoadTexture && - loadDependencies.push( - new Promise((resolve) => { - AssetLoader.load(component.systemParameters.texture!, {}, (texture: Texture) => { - metadata.textures[component.systemParameters.texture!] = texture - dudMaterial.map = texture - resolve(null) - }) - }) - ) - - componentState._loadIndex.set(componentState._loadIndex.value + 1) - const currentIndex = componentState._loadIndex.value - Promise.all(loadDependencies).then(() => { - currentIndex === componentState._loadIndex.value && initParticleSystem(processedParms, metadata) - }) return () => { if (component.system) { const index = batchRenderer.value.systemToBatchIndex.get(component.system) @@ -908,7 +922,8 @@ export const ParticleSystemComponent = defineComponent({ batch.dispose() } } - }, [componentState._refresh]) + }, [geoDependency, shapeMesh, textureState, componentState._refresh]) + return null } }) diff --git a/packages/engine/src/scene/components/PortalComponent.ts b/packages/engine/src/scene/components/PortalComponent.ts index 26c453a1f0..bb169db6c1 100644 --- a/packages/engine/src/scene/components/PortalComponent.ts +++ b/packages/engine/src/scene/components/PortalComponent.ts @@ -23,21 +23,10 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20 Ethereal Engine. All Rights Reserved. */ -import { RigidBodyDesc } from '@dimforge/rapier3d-compat' import { useEffect } from 'react' -import { - ArrowHelper, - BackSide, - Euler, - Mesh, - MeshBasicMaterial, - Quaternion, - SphereGeometry, - Texture, - Vector3 -} from 'three' - -import { defineState, getMutableState, getState, none, useHookstate } from '@etherealengine/hyperflux' +import { ArrowHelper, BackSide, Euler, Mesh, MeshBasicMaterial, Quaternion, SphereGeometry, Vector3 } from 'three' + +import { NO_PROXY, defineState, getMutableState, getState, none, useHookstate } from '@etherealengine/hyperflux' import { EntityUUID } from '@etherealengine/common/src/interfaces/EntityUUID' import { portalPath } from '@etherealengine/common/src/schema.type.module' @@ -58,12 +47,10 @@ import { NameComponent } from '@etherealengine/spatial/src/common/NameComponent' import { UUIDComponent } from '@etherealengine/spatial/src/common/UUIDComponent' import { V_100 } from '@etherealengine/spatial/src/common/constants/MathConstants' import { matches } from '@etherealengine/spatial/src/common/functions/MatchesUtils' -import { Physics } from '@etherealengine/spatial/src/physics/classes/Physics' import { ColliderComponent } from '@etherealengine/spatial/src/physics/components/ColliderComponent' import { RigidBodyComponent } from '@etherealengine/spatial/src/physics/components/RigidBodyComponent' import { TriggerComponent } from '@etherealengine/spatial/src/physics/components/TriggerComponent' import { CollisionGroups } from '@etherealengine/spatial/src/physics/enums/CollisionGroups' -import { PhysicsState } from '@etherealengine/spatial/src/physics/state/PhysicsState' import { RendererState } from '@etherealengine/spatial/src/renderer/RendererState' import { addObjectToGroup, removeObjectFromGroup } from '@etherealengine/spatial/src/renderer/components/GroupComponent' import { @@ -74,7 +61,7 @@ import { VisibleComponent, setVisibleComponent } from '@etherealengine/spatial/s import { ObjectLayers } from '@etherealengine/spatial/src/renderer/constants/ObjectLayers' import { EntityTreeComponent } from '@etherealengine/spatial/src/transform/components/EntityTree' import { TransformComponent } from '@etherealengine/spatial/src/transform/components/TransformComponent' -import { AssetLoader } from '../../assets/classes/AssetLoader' +import { useTexture } from '../../assets/functions/resourceHooks' export const PortalPreviewTypeSimple = 'Simple' as const export const PortalPreviewTypeSpherical = 'Spherical' as const @@ -173,10 +160,7 @@ export const PortalComponent = defineComponent({ /** Allow scene data populating rigidbody component too */ if (hasComponent(entity, RigidBodyComponent)) return - setComponent(entity, RigidBodyComponent, { - type: 'fixed', - body: Physics.createRigidBody(entity, getState(PhysicsState).physicsWorld, RigidBodyDesc.fixed()) - }) + setComponent(entity, RigidBodyComponent, { type: 'fixed' }) setComponent(entity, ColliderComponent, { shape: 'box', collisionLayer: CollisionGroups.Trigger, @@ -228,7 +212,6 @@ export const PortalComponent = defineComponent({ return () => { removeObjectFromGroup(entity, portalMesh) - portalComponent.mesh.set(null) } }, [portalComponent.previewType]) @@ -238,20 +221,25 @@ export const PortalComponent = defineComponent({ previewImageURL: string }>(null) + const [textureState, unload] = useTexture(portalDetails.value?.previewImageURL || '', entity) + + useEffect(() => { + return unload + }, []) + + useEffect(() => { + const texture = textureState.get(NO_PROXY) + if (!texture || !portalComponent.mesh.value) return + + portalComponent.mesh.value.material.map = texture + portalComponent.mesh.value.material.needsUpdate = true + }, [textureState, portalComponent.mesh]) + useEffect(() => { if (!portalDetails.value?.previewImageURL) return portalComponent.remoteSpawnPosition.value.copy(portalDetails.value.spawnPosition) portalComponent.remoteSpawnRotation.value.copy(portalDetails.value.spawnRotation) - AssetLoader.loadAsync(portalDetails.value.previewImageURL).then((texture: Texture) => { - if (!portalComponent.mesh.value || aborted) return - portalComponent.mesh.value.material.map = texture - portalComponent.mesh.value.material.needsUpdate = true - }) - let aborted = false - return () => { - aborted = true - } - }, [portalDetails, portalComponent.mesh]) + }, [portalDetails]) useEffect(() => { if (!isClient) return diff --git a/packages/engine/src/scene/components/SkyboxComponent.ts b/packages/engine/src/scene/components/SkyboxComponent.ts index 8ea4f98e9f..56ff3ec049 100755 --- a/packages/engine/src/scene/components/SkyboxComponent.ts +++ b/packages/engine/src/scene/components/SkyboxComponent.ts @@ -24,24 +24,17 @@ Ethereal Engine. All Rights Reserved. */ import { useEffect } from 'react' -import { - Color, - CubeReflectionMapping, - CubeTexture, - EquirectangularReflectionMapping, - SRGBColorSpace, - Texture -} from 'three' +import { Color, CubeReflectionMapping, CubeTexture, EquirectangularReflectionMapping, SRGBColorSpace } from 'three' import { config } from '@etherealengine/common/src/config' -import { getMutableState, useHookstate } from '@etherealengine/hyperflux' +import { NO_PROXY, getMutableState, useHookstate } from '@etherealengine/hyperflux' import { isClient } from '@etherealengine/common/src/utils/getEnvironment' import { defineComponent, useComponent } from '@etherealengine/ecs/src/ComponentFunctions' import { useEntityContext } from '@etherealengine/ecs/src/EntityFunctions' import { SceneState } from '@etherealengine/engine/src/scene/Scene' import { EngineRenderer } from '@etherealengine/spatial/src/renderer/WebGLRendererSystem' -import { AssetLoader } from '../../assets/classes/AssetLoader' +import { useTexture } from '../../assets/functions/resourceHooks' import { Sky } from '../classes/Sky' import { SkyTypeEnum } from '../constants/SkyTypeEnum' import { loadCubeMapTexture } from '../constants/Util' @@ -100,6 +93,23 @@ export const SkyboxComponent = defineComponent({ const skyboxState = useComponent(entity, SkyboxComponent) const background = useHookstate(getMutableState(SceneState).background) + const [texture, unload, error] = useTexture(skyboxState.equirectangularPath.value, entity) + + useEffect(() => { + if (skyboxState.backgroundType.value !== SkyTypeEnum.equirectangular) return + + const textureValue = texture.get(NO_PROXY) + if (textureValue) { + textureValue.colorSpace = SRGBColorSpace + textureValue.mapping = EquirectangularReflectionMapping + background.set(textureValue) + removeError(entity, SkyboxComponent, 'FILE_ERROR') + return unload + } else if (error.value) { + addError(entity, SkyboxComponent, 'FILE_ERROR', error.value.message) + } + }, [texture, error, skyboxState.backgroundType, skyboxState.equirectangularPath]) + useEffect(() => { if (skyboxState.backgroundType.value !== SkyTypeEnum.color) return background.set(skyboxState.backgroundColor.value) @@ -127,24 +137,6 @@ export const SkyboxComponent = defineComponent({ loadCubeMapTexture(...loadArgs) }, [skyboxState.backgroundType, skyboxState.cubemapPath]) - useEffect(() => { - if (skyboxState.backgroundType.value !== SkyTypeEnum.equirectangular) return - AssetLoader.load( - skyboxState.equirectangularPath.value, - {}, - (texture: Texture) => { - texture.colorSpace = SRGBColorSpace - texture.mapping = EquirectangularReflectionMapping - background.set(texture) - removeError(entity, SkyboxComponent, 'FILE_ERROR') - }, - undefined, - (error) => { - addError(entity, SkyboxComponent, 'FILE_ERROR', error.message) - } - ) - }, [skyboxState.backgroundType, skyboxState.equirectangularPath]) - useEffect(() => { if (skyboxState.backgroundType.value !== SkyTypeEnum.skybox) { if (skyboxState.sky.value) skyboxState.sky.set(null) diff --git a/packages/engine/src/scene/components/SpawnPointComponent.ts b/packages/engine/src/scene/components/SpawnPointComponent.ts index c650d681ae..017a2597b1 100755 --- a/packages/engine/src/scene/components/SpawnPointComponent.ts +++ b/packages/engine/src/scene/components/SpawnPointComponent.ts @@ -34,12 +34,12 @@ import { createEntity, removeEntity, useEntityContext } from '@etherealengine/ec import { NameComponent } from '@etherealengine/spatial/src/common/NameComponent' import { matches } from '@etherealengine/spatial/src/common/functions/MatchesUtils' import { RendererState } from '@etherealengine/spatial/src/renderer/RendererState' -import { addObjectToGroup } from '@etherealengine/spatial/src/renderer/components/GroupComponent' +import { addObjectToGroup, removeObjectFromGroup } from '@etherealengine/spatial/src/renderer/components/GroupComponent' import { setObjectLayers } from '@etherealengine/spatial/src/renderer/components/ObjectLayerComponent' import { setVisibleComponent } from '@etherealengine/spatial/src/renderer/components/VisibleComponent' import { ObjectLayers } from '@etherealengine/spatial/src/renderer/constants/ObjectLayers' import { EntityTreeComponent } from '@etherealengine/spatial/src/transform/components/EntityTree' -import { AssetLoader } from '../../assets/classes/AssetLoader' +import { useGLTF } from '../../assets/functions/resourceHooks' const GLTF_PATH = '/static/editor/spawn-point.glb' @@ -70,30 +70,34 @@ export const SpawnPointComponent = defineComponent({ const debugEnabled = useHookstate(getMutableState(RendererState).nodeHelperVisibility) const spawnPoint = useComponent(entity, SpawnPointComponent) + const [gltf, unload] = useGLTF(debugEnabled.value ? GLTF_PATH : '', entity) + + // Only call unload when unmounted + useEffect(() => { + return unload + }, []) + useEffect(() => { - if (!debugEnabled.value) return + const scene = gltf.get(NO_PROXY)?.scene + if (!scene || !debugEnabled.value) return const helperEntity = createEntity() setComponent(helperEntity, EntityTreeComponent, { parentEntity: entity }) - setVisibleComponent(helperEntity, true) - spawnPoint.helperEntity.set(helperEntity) - let active = true - AssetLoader.loadAsync(GLTF_PATH).then(({ scene: helper }) => { - if (!active) return - helper.name = `spawn-point-helper-${entity}` - addObjectToGroup(helperEntity, helper) - setObjectLayers(helper, ObjectLayers.NodeHelper) - setComponent(helperEntity, NameComponent, helper.name) - }) + scene.name = `spawn-point-helper-${entity}` + addObjectToGroup(helperEntity, scene) + setObjectLayers(scene, ObjectLayers.NodeHelper) + setComponent(helperEntity, NameComponent, scene.name) + + setVisibleComponent(spawnPoint.helperEntity.value!, true) return () => { - active = false + removeObjectFromGroup(helperEntity, scene) removeEntity(helperEntity) spawnPoint.helperEntity.set(none) } - }, [debugEnabled]) + }, [gltf, debugEnabled]) return null } diff --git a/packages/engine/src/scene/components/VariantComponent.tsx b/packages/engine/src/scene/components/VariantComponent.tsx index 512bdba6ce..5d9c578234 100644 --- a/packages/engine/src/scene/components/VariantComponent.tsx +++ b/packages/engine/src/scene/components/VariantComponent.tsx @@ -39,9 +39,8 @@ import { Entity } from '@etherealengine/ecs/src/Entity' import { useEntityContext } from '@etherealengine/ecs/src/EntityFunctions' import { MeshComponent } from '@etherealengine/spatial/src/renderer/components/MeshComponent' import { DistanceFromCameraComponent } from '@etherealengine/spatial/src/transform/components/DistanceComponents' -import { setInstancedMeshVariant, setMeshVariant, setModelVariant } from '../functions/loaders/VariantFunctions' +import { setInstancedMeshVariant } from '../functions/loaders/VariantFunctions' import { InstancingComponent } from './InstancingComponent' -import { ModelComponent } from './ModelComponent' export type VariantLevel = { src: string @@ -117,8 +116,6 @@ const VariantLevelReactor = React.memo(({ entity, level }: { level: number; enti const variantComponent = useComponent(entity, VariantComponent) const variantLevel = variantComponent.levels[level] - const modelComponent = useOptionalComponent(entity, ModelComponent) - useEffect(() => { //if the variant heuristic is set to Distance, add the DistanceFromCameraComponent if (variantComponent.heuristic.value === 'DISTANCE') { @@ -131,15 +128,10 @@ const VariantLevelReactor = React.memo(({ entity, level }: { level: number; enti } }, [variantComponent.heuristic]) - useEffect(() => { - modelComponent && setModelVariant(entity) - }, [variantLevel.src, variantLevel.metadata, modelComponent]) - const meshComponent = useOptionalComponent(entity, MeshComponent) const instancingComponent = getOptionalComponent(entity, InstancingComponent) useEffect(() => { - meshComponent && !instancingComponent && setMeshVariant(entity) meshComponent && instancingComponent && setInstancedMeshVariant(entity) }, [variantLevel.src, variantLevel.metadata, meshComponent]) diff --git a/packages/engine/src/scene/functions/bvhWorkerPool.ts b/packages/engine/src/scene/functions/bvhWorkerPool.ts index 728ce27eb6..e393b638a2 100644 --- a/packages/engine/src/scene/functions/bvhWorkerPool.ts +++ b/packages/engine/src/scene/functions/bvhWorkerPool.ts @@ -32,7 +32,7 @@ const poolSize = 1 const bvhWorkers: GenerateMeshBVHWorker[] = [] const meshQueue: Mesh[] = [] -export function generateMeshBVH(mesh: Mesh | InstancedMesh) { +export function generateMeshBVH(mesh: Mesh | InstancedMesh, signal: AbortSignal) { if ( !mesh.isMesh || (mesh as InstancedMesh).isInstancedMesh || @@ -48,14 +48,16 @@ export function generateMeshBVH(mesh: Mesh | InstancedMesh) { } meshQueue.push(mesh) - runBVHGenerator() + runBVHGenerator(signal) return new Promise((resolve) => { ;(mesh as any).resolvePromiseBVH = resolve }) } -function runBVHGenerator() { +function runBVHGenerator(signal: AbortSignal) { + if (signal.aborted) return + for (const worker of bvhWorkers) { if (meshQueue.length < 1) { break @@ -69,7 +71,7 @@ function runBVHGenerator() { worker.generate(mesh.geometry).then((bvh) => { mesh.geometry.boundsTree = bvh - runBVHGenerator() + runBVHGenerator(signal) ;(mesh as any).resolvePromiseBVH && (mesh as any).resolvePromiseBVH() }) } diff --git a/packages/engine/src/scene/functions/loaders/VariantFunctions.ts b/packages/engine/src/scene/functions/loaders/VariantFunctions.ts index a8ce512feb..41929d8936 100644 --- a/packages/engine/src/scene/functions/loaders/VariantFunctions.ts +++ b/packages/engine/src/scene/functions/loaders/VariantFunctions.ts @@ -2,7 +2,12 @@ import { InstancedMesh, Material, Object3D, Vector3 } from 'three' import { DistanceFromCameraComponent } from '@etherealengine/spatial/src/transform/components/DistanceComponents' -import { getComponent, getMutableComponent } from '@etherealengine/ecs/src/ComponentFunctions' +import { + ComponentType, + getComponent, + getMutableComponent, + getOptionalComponent +} from '@etherealengine/ecs/src/ComponentFunctions' import { Engine } from '@etherealengine/ecs/src/Engine' import { Entity } from '@etherealengine/ecs/src/Entity' import { addOBCPlugin } from '@etherealengine/spatial/src/common/functions/OnBeforeCompilePlugin' @@ -19,7 +24,6 @@ import { AssetLoader } from '../../../assets/classes/AssetLoader' import { pathResolver } from '../../../assets/functions/pathResolver' import { InstancingComponent } from '../../components/InstancingComponent' import { ModelComponent } from '../../components/ModelComponent' -import { SceneAssetPendingTagComponent } from '../../components/SceneAssetPendingTagComponent' import { VariantComponent, VariantLevel } from '../../components/VariantComponent' import getFirstMesh from '../../util/meshUtils' @@ -48,39 +52,68 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20 Ethereal Engine. All Rights Reserved. */ -/** - * Handles setting model src for model component based on variant component - * @param entity - */ -export function setModelVariant(entity: Entity) { - const variantComponent = getMutableComponent(entity, VariantComponent) - const modelComponent = getMutableComponent(entity, ModelComponent) - - if (variantComponent.heuristic.value === 'DEVICE') { +function getModelVariant( + entity: Entity, + variantComponent: ComponentType, + modelComponent: ComponentType +): string | null { + if (variantComponent.heuristic === 'DEVICE') { const targetDevice = isMobile || isMobileXRHeadset ? 'MOBILE' : 'DESKTOP' - //set model src to mobile variant src - const deviceVariant = variantComponent.levels.find((level) => level.value.metadata['device'] === targetDevice) - const modelRelativePath = pathResolver().exec(modelComponent.src.value)?.[2] - const deviceRelativePath = deviceVariant ? pathResolver().exec(deviceVariant.src.value)?.[2] : '' - if (deviceVariant && modelRelativePath !== deviceRelativePath) { - SceneAssetPendingTagComponent.removeResource(entity, modelComponent.src.value) - modelComponent.src.set(deviceVariant.src.value) - } - } else if (variantComponent.heuristic.value === 'DISTANCE') { + //get model src to mobile variant src + const deviceVariant = variantComponent.levels.find((level) => level.metadata['device'] === targetDevice) + const modelRelativePath = pathResolver().exec(modelComponent.src)?.[2] + const deviceRelativePath = deviceVariant ? pathResolver().exec(deviceVariant.src)?.[2] : '' + if (deviceVariant && modelRelativePath !== deviceRelativePath) return deviceVariant.src + } else if (variantComponent.heuristic === 'DISTANCE') { const distance = DistanceFromCameraComponent.squaredDistance[entity] for (let i = 0; i < variantComponent.levels.length; i++) { - const level = variantComponent.levels[i].value + const level = variantComponent.levels[i] if ([level.metadata['minDistance'], level.metadata['maxDistance']].includes(undefined)) continue const minDistance = Math.pow(level.metadata['minDistance'], 2) const maxDistance = Math.pow(level.metadata['maxDistance'], 2) const useLevel = minDistance <= distance && distance <= maxDistance - if (useLevel && modelComponent.src.value !== level.src) { - SceneAssetPendingTagComponent.removeResource(entity, modelComponent.src.value) - modelComponent.src.set(level.src) - } - if (useLevel) break + if (useLevel && level.src) return level.src } } + + return null +} + +function getMeshVariant(entity: Entity, variantComponent: ComponentType): string | null { + if (variantComponent.heuristic === 'DEVICE') { + const targetDevice = isMobileXRHeadset ? 'XR' : isMobile ? 'MOBILE' : 'DESKTOP' + //get model src to mobile variant src + const deviceVariant = variantComponent.levels.find((level) => level.metadata['device'] === targetDevice) + if (deviceVariant) return deviceVariant.src + } + + return null +} + +export function getVariant(entity?: Entity): string | null { + if (!entity) return null + const variantComponent = getOptionalComponent(entity, VariantComponent) + if (!variantComponent) return null + + const modelComponent = getOptionalComponent(entity, ModelComponent) + const meshComponent = getOptionalComponent(entity, MeshComponent) + + if (modelComponent) return getModelVariant(entity, variantComponent, modelComponent) + else if (meshComponent) return getMeshVariant(entity, variantComponent) + else return null +} + +/** + * Handles setting model src for model component based on variant component + * @param entity + */ +export function setModelVariant(entity: Entity) { + const variantComponent = getMutableComponent(entity, VariantComponent) + const modelComponent = getMutableComponent(entity, ModelComponent) + + const src = getModelVariant(entity, variantComponent.value, modelComponent.value) + if (src && modelComponent.src.value !== src) modelComponent.src.set(src) + variantComponent.calculated.set(true) } @@ -88,12 +121,9 @@ export function setMeshVariant(entity: Entity) { const variantComponent = getComponent(entity, VariantComponent) const meshComponent = getComponent(entity, MeshComponent) - if (variantComponent.heuristic === 'DEVICE') { - const targetDevice = isMobileXRHeadset ? 'XR' : isMobile ? 'MOBILE' : 'DESKTOP' - //set model src to mobile variant src - const deviceVariant = variantComponent.levels.find((level) => level.metadata['device'] === targetDevice) - if (!deviceVariant) return - AssetLoader.load(deviceVariant.src, {}, (gltf) => { + const src = getMeshVariant(entity, variantComponent) + if (src) { + AssetLoader.load(src, {}, (gltf) => { const mesh = getFirstMesh(gltf.scene) if (!mesh) return meshComponent.geometry = mesh.geometry diff --git a/packages/engine/src/scene/systems/SceneObjectSystem.tsx b/packages/engine/src/scene/systems/SceneObjectSystem.tsx index 4c733eb24b..21582fd75e 100644 --- a/packages/engine/src/scene/systems/SceneObjectSystem.tsx +++ b/packages/engine/src/scene/systems/SceneObjectSystem.tsx @@ -83,7 +83,6 @@ export const disposeMaterial = (material: Material) => { export const disposeObject3D = (obj: Object3D) => { const mesh = obj as Mesh - if (mesh.material) { if (Array.isArray(mesh.material)) { mesh.material.forEach(disposeMaterial) diff --git a/packages/engine/src/scene/systems/ShadowSystem.tsx b/packages/engine/src/scene/systems/ShadowSystem.tsx index 40a79c1a8f..a00e58227e 100644 --- a/packages/engine/src/scene/systems/ShadowSystem.tsx +++ b/packages/engine/src/scene/systems/ShadowSystem.tsx @@ -34,12 +34,11 @@ import { Quaternion, Raycaster, Sphere, - Texture, Vector3 } from 'three' import config from '@etherealengine/common/src/config' -import { defineState, getMutableState, getState, hookstate, useHookstate } from '@etherealengine/hyperflux' +import { NO_PROXY, defineState, getMutableState, getState, hookstate, useHookstate } from '@etherealengine/hyperflux' import { isClient } from '@etherealengine/common/src/utils/getEnvironment' import { @@ -85,7 +84,7 @@ import { EntityTreeComponent, iterateEntityNode } from '@etherealengine/spatial/ import { TransformComponent } from '@etherealengine/spatial/src/transform/components/TransformComponent' import { XRLightProbeState } from '@etherealengine/spatial/src/xr/XRLightProbeSystem' import { isMobileXRHeadset } from '@etherealengine/spatial/src/xr/XRState' -import { AssetLoader } from '../../assets/classes/AssetLoader' +import { useTexture } from '../../assets/functions/resourceHooks' import { DropShadowComponent } from '../components/DropShadowComponent' import { useMeshOrModel } from '../components/ModelComponent' import { ShadowComponent } from '../components/ShadowComponent' @@ -414,16 +413,23 @@ const reactor = () => { const useShadows = useShadowsEnabled() + const [shadowTexture, unload] = useTexture( + `${config.client.fileServer}/projects/default-project/assets/drop-shadow.png` + ) + useEffect(() => { - AssetLoader.loadAsync(`${config.client.fileServer}/projects/default-project/assets/drop-shadow.png`).then( - (texture: Texture) => { - shadowMaterial.map = texture - shadowMaterial.needsUpdate = true - shadowState.set(shadowMaterial) - } - ) + return unload }, []) + useEffect(() => { + const texture = shadowTexture.get(NO_PROXY) + if (!texture) return + + shadowMaterial.map = texture + shadowMaterial.needsUpdate = true + shadowState.set(shadowMaterial) + }, [shadowTexture]) + EngineRenderer.instance.renderer.shadowMap.enabled = EngineRenderer.instance.renderer.shadowMap.autoUpdate = useShadows diff --git a/packages/engine/tests/util/loadGLTFAssetNode.ts b/packages/engine/tests/util/loadGLTFAssetNode.ts index 6ea5b7a9c0..d14818040b 100644 --- a/packages/engine/tests/util/loadGLTFAssetNode.ts +++ b/packages/engine/tests/util/loadGLTFAssetNode.ts @@ -26,7 +26,7 @@ Ethereal Engine. All Rights Reserved. import appRootPath from 'app-root-path' import fs from 'fs' import path from 'path' -import { FileLoader } from 'three' +import { FileLoader } from '../../src/assets/loaders/base/FileLoader' const toArrayBuffer = (buf) => { const arrayBuffer = new ArrayBuffer(buf.length) diff --git a/packages/server-core/src/assets/ModelTransformLoader.ts b/packages/server-core/src/assets/ModelTransformLoader.ts index 95447ee9b5..9ca40f4a21 100644 --- a/packages/server-core/src/assets/ModelTransformLoader.ts +++ b/packages/server-core/src/assets/ModelTransformLoader.ts @@ -23,6 +23,7 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20 Ethereal Engine. All Rights Reserved. */ +import { FileLoader } from '@etherealengine/engine/src/assets/loaders/base/FileLoader' import { NodeIO } from '@gltf-transform/core' import { EXTMeshGPUInstancing, @@ -42,7 +43,6 @@ import { import fetch from 'cross-fetch' import draco3d from 'draco3dgltf' import { MeshoptDecoder, MeshoptEncoder } from 'meshoptimizer' -import { FileLoader } from 'three' import { EEMaterialExtension } from './extensions/EE_MaterialTransformer' import { MOZLightmapExtension } from './extensions/MOZ_LightmapTransformer' diff --git a/packages/spatial/src/common/classes/GenerateMeshBVHWorker.ts b/packages/spatial/src/common/classes/GenerateMeshBVHWorker.ts index 05e450acb8..f36126df1c 100644 --- a/packages/spatial/src/common/classes/GenerateMeshBVHWorker.ts +++ b/packages/spatial/src/common/classes/GenerateMeshBVHWorker.ts @@ -56,6 +56,12 @@ export class GenerateMeshBVHWorker { throw new Error('GenerateMeshBVHWorker: Already running job.') } + // If geometry has been disposed in the time that the last mesh bvh was generated + if (!geometry.attributes.position) + return new Promise((_, reject) => { + reject() + }) + const { worker } = this this.running = true diff --git a/packages/spatial/src/networking/state/EntityNetworkState.tsx b/packages/spatial/src/networking/state/EntityNetworkState.tsx index 53575acfb3..b67d791496 100644 --- a/packages/spatial/src/networking/state/EntityNetworkState.tsx +++ b/packages/spatial/src/networking/state/EntityNetworkState.tsx @@ -74,8 +74,8 @@ export const EntityNetworkState = defineState({ networkId: action.networkId, authorityPeerId: action.authorityPeerId ?? action.$peer, ownerPeer: action.$peer, - spawnPosition: action.position ?? new Vector3(), - spawnRotation: action.rotation ?? new Quaternion() + spawnPosition: action.position ? new Vector3().copy(action.position) : new Vector3(), + spawnRotation: action.rotation ? new Quaternion().copy(action.rotation) : new Quaternion() }) }), diff --git a/packages/spatial/src/renderer/components/ObjectLayerComponent.ts b/packages/spatial/src/renderer/components/ObjectLayerComponent.ts index 230f0aa416..2417154b5c 100644 --- a/packages/spatial/src/renderer/components/ObjectLayerComponent.ts +++ b/packages/spatial/src/renderer/components/ObjectLayerComponent.ts @@ -23,6 +23,7 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20 Ethereal Engine. All Rights Reserved. */ +import { entityExists } from '@etherealengine/ecs' import { defineComponent, hasComponent, @@ -86,6 +87,7 @@ export const ObjectLayerMaskComponent = defineComponent({ }, enableLayer(entity: Entity, layer: number) { + if (!entityExists(entity)) return if (!hasComponent(entity, ObjectLayerMaskComponent)) setComponent(entity, ObjectLayerMaskComponent) setComponent(entity, ObjectLayerComponents[layer]) }, @@ -98,6 +100,7 @@ export const ObjectLayerMaskComponent = defineComponent({ }, disableLayer(entity: Entity, layer: number) { + if (!entityExists(entity)) return if (!hasComponent(entity, ObjectLayerMaskComponent)) setComponent(entity, ObjectLayerMaskComponent) removeComponent(entity, ObjectLayerComponents[layer]) },