import { createLoaders } from "@needle-tools/gltf-progressive"; import { CompressedCubeTexture, CubeRefractionMapping, CubeTexture, EquirectangularRefractionMapping, SRGBColorSpace, Texture, TextureLoader } from "three" import { EXRLoader } from "three/examples/jsm/loaders/EXRLoader.js"; import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js"; import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js'; import { disposeObjectResources, setDisposable } from "../engine/engine_assetdatabase.js"; import { ContextEvent, ContextRegistry } from "../engine/engine_context_registry.js"; import { syncField } from "../engine/engine_networking_auto.js"; import { serializable } from "../engine/engine_serialization_decorator.js"; import { type IContext } from "../engine/engine_types.js"; import { addAttributeChangeCallback, getParam, PromiseAllWithErrors, removeAttributeChangeCallback } from "../engine/engine_utils.js"; import { registerObservableAttribute } from "../engine/webcomponents/needle-engine.extras.js"; import { Camera, ClearFlags } from "./Camera.js"; import { Behaviour, GameObject } from "./Component.js"; const debug = getParam("debugskybox"); registerObservableAttribute("skybox-image"); registerObservableAttribute("environment-image"); function createRemoteSkyboxComponent(context: IContext, url: string, skybox: boolean, environment: boolean, attribute: "skybox-image" | "environment-image") { const remote = new RemoteSkybox(); remote.allowDrop = false; remote.allowNetworking = false; remote.background = skybox; remote.environment = environment; GameObject.addComponent(context.scene, remote); const urlChanged = newValue => { if (typeof newValue !== "string") return; if (debug) console.log(attribute, "CHANGED TO", newValue) remote.setSkybox(newValue); }; addAttributeChangeCallback(context.domElement, attribute, urlChanged); remote.addEventListener("destroy", () => { if (debug) console.log("Destroyed attribute remote skybox", attribute); removeAttributeChangeCallback(context.domElement, attribute, urlChanged); }); return remote.setSkybox(url); } const promises = new Array>(); ContextRegistry.registerCallback(ContextEvent.ContextCreationStart, (args) => { const context = args.context; const skyboxImage = context.domElement.getAttribute("skybox-image") || context.domElement.getAttribute("background-image"); const environmentImage = context.domElement.getAttribute("environment-image"); if (skyboxImage) { if (debug) console.log("Creating remote skybox to load " + skyboxImage); // if the user is loading a GLB without a camera then the CameraUtils (which creates the default camera) // checks if we have this attribute set and then sets the skybox clearflags accordingly // if the user has a GLB with a camera but set to solid color then the skybox image is not visible -> we will just warn then and not override the camera settings if (context.mainCameraComponent?.clearFlags !== ClearFlags.Skybox) console.warn("\"skybox-image\"/\"background-image\" attribute has no effect: camera clearflags are not set to \"Skybox\""); const promise = createRemoteSkyboxComponent(context, skyboxImage, true, false, "skybox-image"); promises.push(promise); } if (environmentImage) { if (debug) console.log("Creating remote environment to load " + environmentImage); const promise = createRemoteSkyboxComponent(context, environmentImage, false, true, "environment-image"); promises.push(promise); } }); ContextRegistry.registerCallback(ContextEvent.ContextCreationStart, () => { return Promise.all(promises).finally(() => { promises.length = 0; }) }); declare type SkyboxCacheEntry = { src: string, texture: Promise }; function ensureGlobalCache() { if (!globalThis["NEEDLE_ENGINE_SKYBOX_TEXTURES"]) globalThis["NEEDLE_ENGINE_SKYBOX_TEXTURES"] = new Array(); return globalThis["NEEDLE_ENGINE_SKYBOX_TEXTURES"] as Array; } function tryGetPreviouslyLoadedTexture(src: string) { const cache = ensureGlobalCache(); const found = cache.find(x => x.src === src); if (found) { if (debug) console.log("Skybox: Found previously loaded texture for " + src); return found.texture; } return null; } async function disposeCachedTexture(tex: Promise) { const texture = await tex; setDisposable(texture, true); disposeObjectResources(texture); } function registerLoadedTexture(src: string, texture: Promise) { const cache = ensureGlobalCache(); // Make sure the cache doesnt get too big while (cache.length > 5) { const entry = cache.shift(); if (entry) { disposeCachedTexture(entry.texture); } } texture.then(t => { return setDisposable(t, false) }); cache.push({ src, texture }); } /** * RemoteSkybox is a component that allows you to set the skybox of a scene from a URL or a local file. * It supports .hdr, .exr, .jpg, .png files. * * ### Events * - `dropped-unknown-url`: Emitted when a file is dropped on the scene. The event detail contains the sender, the url and a function to apply the url. * * @example adding a skybox * ```ts * GameObject.addComponent(gameObject, Skybox, { url: "https://example.com/skybox.hdr", background: true, environment: true }); * ``` * * @example handle custom url * ```ts * const skybox = GameObject.addComponent(gameObject, Skybox); * skybox.addEventListener("dropped-unknown-url", (evt) => { * let url = evt.detail.url; * console.log("User dropped file", url); * // change url or resolve it differently * url = "https://example.com/skybox.hdr"; * // apply the url * evt.detail.apply(url); * }); * ``` */ export class RemoteSkybox extends Behaviour { /** * URL to a remote skybox. This value can also use a magic skybox name. Options are "quicklook", "quicklook-ar", "studio", "blurred-skybox". * @example * ```ts * skybox.url = "https://example.com/skybox.hdr"; * ``` */ @syncField(RemoteSkybox.prototype.urlChangedSyncField) @serializable(URL) url?: string; /** * When enabled a user can drop a link to a skybox image on the scene to set the skybox. * @default true */ @serializable() allowDrop: boolean = true; /** * When enabled the skybox will be set as the background of the scene. * @default true */ @serializable() background: boolean = true; /** * When enabled the skybox will be set as the environment of the scene (to be used as environment map for reflections and lighting) * @default true */ @serializable() environment: boolean = true; /** * When enabled dropped skybox urls (or assigned skybox urls) will be networked to other users in the same networked room. * @default true */ @serializable() allowNetworking: boolean = true; private _loader?: RGBELoader | EXRLoader | TextureLoader | KTX2Loader; private _prevUrl?: string; private _prevLoadedEnvironment?: Texture; private _prevEnvironment: Texture | null = null; private _prevBackground: any = null; /** @internal */ onEnable() { this.setSkybox(this.url); this.registerDropEvents(); } /** @internal */ onDisable() { if (this.context.scene.environment === this._prevLoadedEnvironment) { this.context.scene.environment = this._prevEnvironment; if (!Camera.backgroundShouldBeTransparent(this.context)) this.context.scene.background = this._prevBackground; this._prevLoadedEnvironment = undefined; } this.unregisterDropEvents(); // Re-apply the skybox/background settings of the main camera this.context.mainCameraComponent?.applyClearFlags(); } private urlChangedSyncField() { if (this.allowNetworking && this.url) { // omit local dragged files from being handled if (this.isRemoteTexture(this.url)) { this.setSkybox(this.url); } } } /** * Set the skybox from a given url * @param url The url of the skybox image * @param name Define name of the file with extension if it isn't apart of the url * @returns Whether the skybox was successfully set */ async setSkybox(url: string | undefined | null, name?: string) { if (!this.activeAndEnabled) return false; url = tryParseMagicSkyboxName(url, this.environment, this.background); if (!url) return false; name ??= url; if (!this.isValidTextureType(name)) { console.warn("Potentially invalid skybox url", name, "on", this.name); } if (debug) console.log("Set remote skybox url: " + url); if (this._prevUrl === url && this._prevLoadedEnvironment) { this.applySkybox(); return true; } else { this._prevLoadedEnvironment?.dispose(); this._prevLoadedEnvironment = undefined; } this._prevUrl = url; const envMap = await this.loadTexture(url, name); if (!envMap) return false; // Check if we're still enabled if (!this.enabled) return false; // Update the current url this.url = url; const nameIndex = url.lastIndexOf("/"); envMap.name = url.substring(nameIndex >= 0 ? nameIndex + 1 : 0); if (this._loader instanceof TextureLoader) { envMap.colorSpace = SRGBColorSpace; } this._prevLoadedEnvironment = envMap; this.applySkybox(); return true; } private async loadTexture(url: string, name?: string) { if (!url) return Promise.resolve(null); name ??= url; const cached = tryGetPreviouslyLoadedTexture(name); if (cached) { const res = await cached; if (res.source?.data?.length > 0 || res.source?.data?.data?.length) return res; } const isEXR = name.endsWith(".exr"); const isHdr = name.endsWith(".hdr"); const isKtx2 = name.endsWith(".ktx2"); if (isEXR) { if (!(this._loader instanceof EXRLoader)) this._loader = new EXRLoader(); } else if (isHdr) { if (!(this._loader instanceof RGBELoader)) this._loader = new RGBELoader(); } else if (isKtx2) { if (!(this._loader instanceof KTX2Loader)) { const { ktx2Loader } = createLoaders(this.context.renderer); this._loader = ktx2Loader; } } else { if (!(this._loader instanceof TextureLoader)) this._loader = new TextureLoader(); } if (debug) console.log("Loading skybox: " + url); const loadingTask = this._loader.loadAsync(url); registerLoadedTexture(name, loadingTask); const envMap = await loadingTask; return envMap; } private applySkybox() { const envMap = this._prevLoadedEnvironment; if (!envMap) return; if ((envMap instanceof CubeTexture || envMap instanceof CompressedCubeTexture)) { // Nothing to do } else { envMap.mapping = EquirectangularRefractionMapping; envMap.needsUpdate = true; } // capture state if (this.context.scene.background !== envMap) this._prevBackground = this.context.scene.background; if (this.context.scene.environment !== envMap) this._prevEnvironment = this.context.scene.environment; if (debug) console.log("Set remote skybox", this.url, !Camera.backgroundShouldBeTransparent(this.context)); if (this.environment) this.context.scene.environment = envMap; if (this.background && !Camera.backgroundShouldBeTransparent(this.context)) this.context.scene.background = envMap; if (this.context.mainCameraComponent?.backgroundBlurriness !== undefined) this.context.scene.backgroundBlurriness = this.context.mainCameraComponent.backgroundBlurriness; } private readonly validTextureTypes = [".ktx2", ".hdr", ".exr", ".jpg", ".jpeg", ".png"]; private isRemoteTexture(url: string): boolean { return url.startsWith("http://") || url.startsWith("https://"); } private isValidTextureType(url: string): boolean { for (const type of this.validTextureTypes) { if (url.endsWith(type)) return true; } return false; } private registerDropEvents() { this.unregisterDropEvents(); this.context.domElement.addEventListener("dragover", this.onDragOverEvent); this.context.domElement.addEventListener("drop", this.onDrop); } private unregisterDropEvents() { this.context.domElement.removeEventListener("dragover", this.onDragOverEvent); this.context.domElement.removeEventListener("drop", this.onDrop); } private onDragOverEvent = (e: DragEvent) => { if (!this.allowDrop) return; if (!e.dataTransfer) return; for (const type of e.dataTransfer.types) { // in ondragover we dont get access to the content // but if we have a uri list we can assume // someone is maybe dragging a image file // so we want to capture this if (type === "text/uri-list" || type === "Files") { e.preventDefault(); } } }; private onDrop = (e: DragEvent) => { if (!this.allowDrop) return; if (!e.dataTransfer) return; for (const type of e.dataTransfer.types) { if (debug) console.log(type); if (type === "text/uri-list") { const url = e.dataTransfer.getData(type); if (debug) console.log(type, url); let name = new RegExp(/polyhaven.com\/asset_img\/.+?\/(?.+)\.png/).exec(url)?.groups?.name; if (!name) { name = new RegExp(/polyhaven\.com\/a\/(?.+)/).exec(url)?.groups?.name; } if (debug) console.log(name); if (name) { const skyboxurl = "https://dl.polyhaven.org/file/ph-assets/HDRIs/exr/1k/" + name + "_1k.exr"; console.log(`[Remote Skybox] Setting skybox from url: ${skyboxurl}`); e.preventDefault(); this.setSkybox(skyboxurl); break; } else if (this.isValidTextureType(url)) { console.log("[Remote Skybox] Setting skybox from url: " + url); e.preventDefault(); this.setSkybox(url); break; } else { console.warn(`[RemoteSkybox] Unknown url ${url}. If you want to load a skybox from a url, make sure it is a valid image url. Url must end with${this.validTextureTypes.join(", ")}.`); // emit custom event - users can listen to this event and handle the url themselves const evt = new CustomEvent("dropped-unknown-url", { detail: { sender: this, event: e, url, apply: (url: string) => { e.preventDefault(); this.setSkybox(url); } } }); this.dispatchEvent(evt); } } else if (type == "Files") { const file = e.dataTransfer.files.item(0); if (debug) console.log(type, file); if (!file) continue; if (!this.isValidTextureType(file.name)) { console.warn(`[RemoteSkybox]: File \"${file.name}\" is not supported. Supported files are ${this.validTextureTypes.join(", ")}`); return; } if (tryGetPreviouslyLoadedTexture(file.name) === null) { const blob = new Blob([file]); const url = URL.createObjectURL(blob); e.preventDefault(); this.setSkybox(url, file.name); } else { e.preventDefault(); this.setSkybox(file.name); } break; } } }; } function tryParseMagicSkyboxName(str: string | null | undefined, environment: boolean, background: boolean): string | null { const useLowRes = environment && !background; switch (str?.toLowerCase()) { case "studio": if (useLowRes) { return "https://cdn.needle.tools/static/skybox/modelviewer-Neutral-small.hdr"; } else return "https://cdn.needle.tools/static/skybox/modelviewer-Neutral.hdr"; case "blurred-skybox": if (useLowRes) { return "https://cdn.needle.tools/static/skybox/blurred-skybox-small.exr"; } return "https://cdn.needle.tools/static/skybox/blurred-skybox.exr"; case "quicklook-ar": if (useLowRes) { return "https://cdn.needle.tools/static/skybox/QuickLook-ARMode-small.exr"; } return "https://cdn.needle.tools/static/skybox/QuickLook-ARMode.exr"; case "quicklook": if (useLowRes) { return "https://cdn.needle.tools/static/skybox/QuickLook-ObjectMode-small.exr"; } return "https://cdn.needle.tools/static/skybox/QuickLook-ObjectMode.exr"; } if (str === undefined) return null; return str; }