UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in

366 lines (321 loc) 14.8 kB
import { Cache, Camera, Loader, Material, Mesh, Object3D } from "three"; import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js' import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'; import { USDZLoader } from 'three/examples/jsm/loaders/USDZLoader.js'; import { showBalloonMessage } from "./debug/index.js"; import { getLoader, type INeedleGltfLoader, registerLoader } from "./engine_gltf.js"; import { createBuiltinComponents, writeBuiltinComponentData } from "./engine_gltf_builtin_components.js"; // import * as object from "./engine_gltf_builtin_components.js"; import * as loaders from "./engine_loaders.js" import { registerPrewarmObject } from "./engine_mainloop_utils.js"; import { SerializationContext } from "./engine_serialization_core.js"; import { Context } from "./engine_setup.js" import { postprocessFBXMaterials } from "./engine_three_utils.js"; import { Model, type UIDProvider } from "./engine_types.js"; import * as utils from "./engine_utils.js"; import { tryDetermineFileTypeFromURL } from "./engine_utils_format.js" import { invokeAfterImportPluginHooks, registerComponentExtension, registerExtensions } from "./extensions/extensions.js"; import { NEEDLE_components } from "./extensions/NEEDLE_components.js"; /** @internal */ export class NeedleLoader implements INeedleGltfLoader { createBuiltinComponents(context: Context, gltfId: string, gltf: any, seed: number | UIDProvider | null, extension?: NEEDLE_components | undefined) { return createBuiltinComponents(context, gltfId, gltf, seed, extension); } writeBuiltinComponentData(comp: any, context: SerializationContext) { return writeBuiltinComponentData(comp, context); } parseSync(context: Context, data: string | ArrayBuffer, path: string, seed: number | UIDProvider | null): Promise<Model | undefined> { return parseSync(context, data, path, seed); } loadSync(context: Context, url: string, sourceId: string, seed: number | UIDProvider | null, prog?: ((ProgressEvent: any) => void) | undefined): Promise<Model | undefined> { return loadSync(context, url, sourceId, seed, prog); } } registerLoader(NeedleLoader); // Register the loader const printGltf = utils.getParam("printGltf") || utils.getParam("printgltf"); const downloadGltf = utils.getParam("downloadgltf"); const debugFileTypes = utils.getParam("debugfileformat"); // const loader = new GLTFLoader(); // registerExtensions(loader); export enum GltfLoadEventType { BeforeLoad = 0, AfterLoaded = 1, FinishedSetup = 10, } export class GltfLoadEvent { context: Context loader: GLTFLoader; path: string; gltf?: GLTF; constructor(context: Context, path: string, loader: GLTFLoader, gltf?: GLTF) { this.context = context; this.path = path; this.loader = loader; this.gltf = gltf; } } export type GltfLoadEventCallback = (event: GltfLoadEvent) => void; const eventListeners: { [key: string]: GltfLoadEventCallback[] } = {}; export function addGltfLoadEventListener(type: GltfLoadEventType, listener: GltfLoadEventCallback) { eventListeners[type] = eventListeners[type] || []; eventListeners[type].push(listener); } export function removeGltfLoadEventListener(type: GltfLoadEventType, listener: GltfLoadEventCallback) { if (eventListeners[type]) { const index = eventListeners[type].indexOf(listener); if (index >= 0) { eventListeners[type].splice(index, 1); } } } function invokeEvents(type: GltfLoadEventType, event: GltfLoadEvent) { if (eventListeners[type]) { for (const listener of eventListeners[type]) { listener(event); } } } async function handleLoadedGltf(context: Context, gltfId: string, gltf, seed: number | null | UIDProvider, componentsExtension) { if (printGltf) console.warn("glTF", gltfId, gltf); // Remove query parameters from gltfId if (gltfId.includes("?")) { gltfId = gltfId.split("?")[0]; } await getLoader().createBuiltinComponents(context, gltfId, gltf, seed, componentsExtension); } export async function createLoader(url: string, context: Context): Promise<GLTFLoader | FBXLoader | USDZLoader | OBJLoader | null> { const type = await tryDetermineFileTypeFromURL(url) || "unknown"; if (debugFileTypes) console.debug("Determined file type: " + type + " for url", url); switch (type) { case "unknown": { console.warn("Unknown file type. Assuming glTF:", url); const loader = new GLTFLoader(); await registerExtensions(loader, context, url); return loader; } case "fbx": return new FBXLoader(); case "obj": return new OBJLoader(); case "usd": case "usda": case "usdz": console.warn(type.toUpperCase() + " files are not supported.") return null; // return new USDZLoader(); default: console.warn("Unknown file type:", type); case "gltf": case "glb": case "vrm": { const loader = new GLTFLoader(); await registerExtensions(loader, context, url); return loader; } } } /** Load a gltf file from a url. This is the core method used by Needle Engine to load gltf files. All known extensions are registered here. * @param context The current context * @param data The gltf data as string or ArrayBuffer * @param path The path to the gltf file * @param seed The seed for generating unique ids * @returns The loaded gltf object */ export async function parseSync(context: Context, data: string | ArrayBuffer, path: string, seed: number | UIDProvider | null): Promise<Model | undefined> { if (typeof path !== "string") { console.warn("Parse gltf binary without path, this might lead to errors in resolving extensions. Please provide the source path of the gltf/glb file", path, typeof path); path = ""; } if (printGltf) console.log("Parse glTF", path) const loader = await createLoader(path, context); if (!loader) { return undefined; } // Handle OBJ Loader if (loader instanceof OBJLoader) { if (typeof data !== "string") { data = new TextDecoder().decode(data); } const res = loader.parse(data); return { animations: res.animations, scene: res, scenes: [res] } as GLTF; } // Handle any other loader that is not a GLTFLoader const isNotGLTF = !(loader instanceof GLTFLoader); if (isNotGLTF) { const res = loader.parse(data, path); postprocessLoadedFile(loader, res); return { animations: res.animations, scene: res, scenes: [res] } as GLTF; } const componentsExtension = registerComponentExtension(loader); return new Promise((resolve, reject) => { try { // GltfLoader expects a base path for resolving referenced assets // https://threejs.org/docs/#examples/en/loaders/GLTFLoader.parse // so we make sure that "path" is never a file path let gltfLoaderPath = path.split("?")[0].trimEnd(); // This assumes that the path is a FILE path and not already a directory // (it does not end with "/") – see https://linear.app/needle/issue/NE-6075 { // strip file from path const parts = gltfLoaderPath.split("/"); // check if the last part is a /, otherwise remove it if (parts.length > 0 && parts[parts.length - 1] !== "") parts.pop(); gltfLoaderPath = parts.join("/"); if (!gltfLoaderPath.endsWith("/")) gltfLoaderPath += "/"; } loader.resourcePath = gltfLoaderPath; loaders.addDracoAndKTX2Loaders(loader, context); invokeEvents(GltfLoadEventType.BeforeLoad, new GltfLoadEvent(context, path, loader)); const camera = context.mainCamera; loader.parse(data, "", async res => { invokeAfterImportPluginHooks(path, res, context); invokeEvents(GltfLoadEventType.AfterLoaded, new GltfLoadEvent(context, path, loader, res)); await handleLoadedGltf(context, path, res, seed, componentsExtension); await compileAsync(res.scene, context, camera); invokeEvents(GltfLoadEventType.FinishedSetup, new GltfLoadEvent(context, path, loader, res)); resolve(res); if (downloadGltf) { _downloadGltf(data) } }, err => { console.error("Loading asset at \"" + path + "\" failed\n", err); resolve(undefined); }); } catch (err) { console.error(err); reject(err); } }); } /** * Load a gltf file from a url. This is the core method used by Needle Engine to load gltf files. All known extensions are registered here. * @param context The current context * @param url The url to the gltf file * @param sourceId The source id of the gltf file - this is usually the url * @param seed The seed for generating unique ids * @param prog A progress callback * @returns The loaded gltf object */ export async function loadSync(context: Context, url: string, sourceId: string, seed: number | UIDProvider | null, prog?: (ProgressEvent) => void): Promise<Model | undefined> { // better to create new loaders every time // (maybe we can cache them...) // but due to the async nature and potentially triggering multiple loads at the same time // we need to make sure the extensions dont override each other // creating new loaders should not be expensive as well checkIfUserAttemptedToLoadALocalFile(url) const loader = await createLoader(url, context); if (!loader) { return undefined; } // Handle any loader that is not a GLTFLoader if (!(loader instanceof GLTFLoader)) { const res = await loader.loadAsync(url, prog); postprocessLoadedFile(loader, res); return { animations: res.animations, scene: res, scenes: [res] } as GLTF; } const componentsExtension = registerComponentExtension(loader); return new Promise((resolve, reject) => { try { loaders.addDracoAndKTX2Loaders(loader, context); invokeEvents(GltfLoadEventType.BeforeLoad, new GltfLoadEvent(context, url, loader)); const camera = context.mainCamera; loader.load(url, async res => { invokeAfterImportPluginHooks(url, res, context); invokeEvents(GltfLoadEventType.AfterLoaded, new GltfLoadEvent(context, url, loader, res)); await handleLoadedGltf(context, sourceId, res, seed, componentsExtension); await compileAsync(res.scene, context, camera); invokeEvents(GltfLoadEventType.FinishedSetup, new GltfLoadEvent(context, url, loader, res)); resolve(res); if (downloadGltf) { _downloadGltf(url) } }, evt => { prog?.call(loader, evt); }, err => { console.error("Loading asset at \"" + url + "\" failed\n", err); resolve(undefined); }); } catch (err) { console.error(err); reject(err); } }); } async function compileAsync(scene: Object3D, context: Context, camera?: Camera | null) { if (!camera) camera = context.mainCamera; try { if (camera) { await context.renderer.compileAsync(scene, camera, context.scene) .catch(err => { console.warn(err.message); }); } else registerPrewarmObject(scene, context); } catch (err: Error | any) { console.warn(err?.message || err); } } function checkIfUserAttemptedToLoadALocalFile(url: string) { const fullurl = new URL(url, window.location.href).href; if (fullurl.startsWith("file://")) { const msg = "Hi - it looks like you are trying to load a local file which will not work. You need to use a webserver to serve your files.\nPlease refer to the documentation on <a href=\"https://fwd.needle.tools/needle-engine/docs/local-server\">https://docs.needle.tools</a> or ask for help in our <a href=\"https://discord.needle.tools\">discord community</a>"; showBalloonMessage(msg); console.warn(msg) } } function _downloadGltf(data: string | ArrayBuffer) { if (typeof data === "string") { const a = document.createElement("a") as HTMLAnchorElement; a.href = data; a.download = data.split("/").pop()!; a.click(); } else { const blob = new Blob([data], { type: "application/octet-stream" }); const url = window.URL.createObjectURL(blob); const a = document.createElement("a") as HTMLAnchorElement; a.href = url; a.download = "download.glb"; a.click(); } } function postprocessLoadedFile(loader: Loader, result: Object3D | GLTF) { if ((result as Object3D)?.isObject3D) { const obj = result as Object3D; if (loader instanceof FBXLoader || loader instanceof OBJLoader) { obj.traverse((child) => { const mesh = child as Mesh; // See https://github.com/needle-tools/three.js/blob/b8df3843ff123ac9dc0ed0d3ccc5b568f840c804/examples/webgl_loader_multiple.html#L377 if (mesh?.isMesh) { postprocessFBXMaterials(mesh, mesh.material as Material); } }); } // else if (loader instanceof OBJLoader) { // obj.traverse(_child => { // // TODO: Needs testing // // if (!(child instanceof Mesh)) return; // // child.material = new MeshStandardMaterial(); // }); // } } }