@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
text/typescript
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();
// });
// }
}
}