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.

474 lines (430 loc) • 21.7 kB
import { Color, CompressedTexture, Euler, LinearSRGBColorSpace, Object3D, RGBAFormat, Texture, WebGLRenderTarget } from "three"; import { isDevEnvironment, showBalloonMessage, showBalloonWarning } from "../engine/debug/index.js"; import { Behaviour, Component, GameObject } from "../engine-components/Component.js"; import { CallInfo, EventList } from "../engine-components/EventList.js"; import { AssetReference } from "./engine_addressables.js"; import { debugExtension } from "./engine_default_parameters.js"; import { SerializationContext, TypeSerializer } from "./engine_serialization_core.js"; import { RenderTexture } from "./engine_texture.js"; import { IComponent } from "./engine_types.js"; import { resolveUrl } from "./engine_utils.js"; import { RGBAColor } from "./js-extensions/index.js"; // export class SourcePath { // src?:string // }; // class SourcePathSerializer extends TypeSerializer{ // constructor(){ // super(SourcePath); // } // onDeserialize(data: any, _context: SerializationContext) { // if(data.src && typeof data.src === "string"){ // return data.src; // } // } // onSerialize(_data: any, _context: SerializationContext) { // } // } // new SourcePathSerializer(); class ColorSerializer extends TypeSerializer { constructor() { super([Color, RGBAColor], "ColorSerializer") } onDeserialize(data: any): Color | RGBAColor | void { if (data === undefined || data === null) return; if (data.a !== undefined) { return new RGBAColor(data.r, data.g, data.b, data.a); } else if (data.alpha !== undefined) { return new RGBAColor(data.r, data.g, data.b, data.alpha); } return new Color(data.r, data.g, data.b); } onSerialize(data: any): any | void { if (data === undefined || data === null) return; if (data.a !== undefined) return { r: data.r, g: data.g, b: data.b, a: data.a } else return { r: data.r, g: data.g, b: data.b } } } export const colorSerializer = new ColorSerializer(); class EulerSerializer extends TypeSerializer { constructor() { super([Euler], "EulerSerializer"); } onDeserialize(data: any, _context: SerializationContext) { if (data === undefined || data === null) return undefined; if (data.order) { return new Euler(data.x, data.y, data.z, data.order); } else if (data.x != undefined) { return new Euler(data.x, data.y, data.z); } return undefined; } onSerialize(data: any, _context: SerializationContext) { return { x: data.x, y: data.y, z: data.z, order: data.order }; } } export const euler = new EulerSerializer(); declare type ObjectData = { node?: number; guid?: string; } class ObjectSerializer extends TypeSerializer { constructor() { super(Object3D, "ObjectSerializer"); } onSerialize(data: any, context: SerializationContext) { if (context.objectToNode !== undefined && data.uuid) { const node = context.objectToNode[data.uuid]; if (debugExtension) console.log(node, data.name, data.uuid); return { node: node } } return undefined; } onDeserialize(data: ObjectData | string | null, context: SerializationContext) { if (typeof data === "string") { if (data.endsWith(".glb") || data.endsWith(".gltf")) { // If the @serializable([Object3D, AssetReference]) looks like this we don't need to warn here. This is the case e.g. with SyncedCamera referencing a scene if (context.serializable instanceof Array) { if (context.serializable.includes(AssetReference)) return undefined; } if (isDevEnvironment()) showBalloonWarning("Detected wrong usage of @serializable with Object3D or GameObject. Instead you should use AssetReference here! Please see the console for details."); const scriptname = context.target?.constructor?.name; console.warn(`Wrong usage of @serializable detected in your script \"${scriptname}\"\n\nIt looks like you used @serializable(Object3D) or @serializable(GameObject) for a prefab or scene reference which is exported to a separate glTF file.\n\nTo fix this please change your code to:\n\n@serializable(AssetReference)\n${context.path}! : AssetReference;\n\0`); } // ACTUALLY: this is already handled by the extension_utils where we resolve json pointers recursively // if(data.startsWith("/nodes/")){ // const node = parseInt(data.substring("/nodes/".length)); // if (context.nodeToObject) { // const res = context.nodeToObject[node]; // if (debugExtension) // console.log("Deserialized object reference?", data, res, context?.nodeToObject); // if (!res) console.warn("Did not find node: " + data, context.nodeToObject, context.object); // return res; // } // } return undefined; } if (data) { if (data.node !== undefined && context.nodeToObject) { const res = context.nodeToObject[data.node]; if (debugExtension) console.log("Deserialized object reference?", data, res, context?.nodeToObject); if (!res) console.warn("Did not find node: " + data.node, context.nodeToObject, context.object); return res; } else if (data.guid) { if (!context.context) { console.error("Missing context"); return undefined; } // it is possible that the object is not yet loaded // e.g. if we have a scene with multiple gltf files and the first gltf references something in the second gltf // we need a way to wait for all components to be created before we can resolve those references // independent of order of loading let res: GameObject | Behaviour | undefined | null = undefined; // first try to search in the current gltf scene (if any) const gltfScene = context.gltf?.scene; if (gltfScene) { res = GameObject.findByGuid(data.guid, gltfScene); } // if not found, search in the whole scene if (!res) { res = GameObject.findByGuid(data.guid, context.context.scene); } if (!res) { if (isDevEnvironment() || debugExtension) console.warn("Could not resolve object reference", context.path, data, context.target, context.context.scene); data["could_not_resolve"] = true; } else { if (res && (res as IComponent).isComponent === true) { if(debugExtension) console.warn("Deserialized object reference is a component"); res = (res as IComponent).gameObject; } if (debugExtension) console.log("Deserialized object reference?", data, res, context?.nodeToObject); } return res; } } return undefined; } } export const objectSerializer = new ObjectSerializer(); class ComponentSerializer extends TypeSerializer { constructor() { super([Component, Behaviour], "ComponentSerializer"); } onSerialize(data: any, _context: SerializationContext) { if (data?.guid) { return { guid: data.guid } } return undefined; } onDeserialize(data: any, context: SerializationContext) { if (data?.guid) { // it's a workaround for VolumeParameter having a guid as well. Generally we will probably never have to resolve a component in the scene when it's coming from the persistent asset extension (like in the case for postprocessing volume parameters) if (data.___persistentAsset) { if(debugExtension) console.log("Skipping component deserialization because it's a persistent asset", data); return undefined; } const currentPath = context.path; // TODO: need to serialize some identifier for referenced components as well, maybe just guid? // because here the components are created but dont have their former guid assigned // and will later in the stack just get a newly generated guid if (debugExtension) console.log(data.guid, context.root, context.object, context.target); // first search within the gltf (e.g. necessary when using AssetReference and loading a gltf without adding it to the scene) // if we would search JUST the scene those references would NEVER be resolved let res = this.findObjectForGuid(data.guid, context.root); if (res) { return res; } if (context.context) { // if not found within the gltf use the provided context scene // to find references outside res = this.findObjectForGuid(data.guid, context.context?.scene); if (res) return res; } if (isDevEnvironment() || debugExtension) { console.warn("Could not resolve component reference: \"" + currentPath + "\" using guid " + data.guid, context.target); } data["could_not_resolve"] = true; return undefined; } // if (data?.node !== undefined && context.nodeToObject) { // return context.nodeToObject[data.node]; // } return undefined; } findObjectForGuid(guid: string, root: Object3D): any { // recursively search root // need to check the root object too if (root["guid"] === guid) return root; const res = GameObject.foreachComponent(root, (c) => { if (c.guid === guid) return c; return undefined; }, false); if (res !== undefined) return res; // if not found, search in children for (let i = 0; i < root.children.length; i++) { const child = root.children[i]; const res = this.findObjectForGuid(guid, child); if (res) return res; } } } export const componentSerializer = new ComponentSerializer(); declare class EventListData { type: string; calls: Array<EventListCall>; } declare type EventListCall = { method: string, target: string, argument?: any, arguments?: Array<any>, enabled?: boolean, } const $eventListDebugInfo = Symbol("eventListDebugInfo"); class EventListSerializer extends TypeSerializer { constructor() { super([EventList]); } onSerialize(_data: EventList<any>, _context: SerializationContext): EventListData | undefined { console.log("TODO: SERIALIZE EVENT"); return undefined; } onDeserialize(data: EventListData, context: SerializationContext): EventList<any> | undefined | null { // TODO: check that we dont accidentally deserialize methods to EventList objects. This is here to make is easy for react-three-fiber to just add props as { () => { } } if (typeof data === "function") { const evtList = new EventList([new CallInfo(data, null, [], true)]); return evtList; } else if (data && data.type === "EventList") { if (debugExtension) console.log("DESERIALIZE EVENT", data); const fns = new Array<CallInfo>(); if (data.calls && Array.isArray(data.calls)) { for (const call of data.calls) { if (debugExtension) console.log(call); let target = componentSerializer.findObjectForGuid(call.target, context.root); // if the object is not found in the current glb try find it in the whole scene if (!target && context.context?.scene) { target = componentSerializer.findObjectForGuid(call.target, context.context?.scene); } const hasMethod = call.method?.length > 0; if (target && hasMethod) { const printWarningMethodNotFound = () => { const uppercaseMethodName = call.method[0].toUpperCase() + call.method.slice(1); if (typeof target[uppercaseMethodName] === "function") { console.warn(`EventList method:\nCould not find method ${call.method} on object ${target.name}. Please rename ${call.method} to ${uppercaseMethodName}?\n`, target[uppercaseMethodName], "\n in script: ", target); showBalloonWarning("EventList methods must start with lowercase letter, see console for details"); return; } else { console.warn(`EventList method:\nCould not find method ${call.method} on object ${target.name}`, target, typeof target[call.method]); } } const method = target[call.method]; if (typeof method !== "function") { let foundMethod = false; let currentPrototype = target; // test if the target method is actually a property setter while (currentPrototype) { const desc = Object.getOwnPropertyDescriptor(currentPrototype, call.method); if (desc && (desc.writable === true || desc.set)) { foundMethod = true; break; } currentPrototype = Object.getPrototypeOf(currentPrototype); } if (!foundMethod && (isDevEnvironment() || debugExtension)) printWarningMethodNotFound(); } } function deserializeArgument(arg: any) { if (typeof arg === "object") { // Try to deserialize the call argument to either a object or a component reference let argRes = objectSerializer.onDeserialize(arg, context); if (!argRes) argRes = componentSerializer.onDeserialize(arg, context); if (argRes) return argRes; } return arg; } if (target) { let args = call.argument; if (args !== undefined) { args = deserializeArgument(args); } else if (call.arguments !== undefined) { args = call.arguments.map(deserializeArgument); } const method = target[call.method]; if (!method) { console.warn(`EventList method not found: \"${call.method}\" on ${target?.name}`); } else { if (args !== undefined && !Array.isArray(args)) { args = [args]; } // This is the final method we pass to the call info (or undefined if the method couldnt be resolved) // const eventMethod = hasMethod ? this.createEventMethod(target, call.method, args) : undefined; const fn = new CallInfo(target, call.method, args, call.enabled); fns.push(fn); } } else if (isDevEnvironment()) { console.warn("[Debug] EventList: Could not find event listener in scene", call, context.object, data); } } } const evt = new EventList(fns); if (debugExtension) console.log(evt); const eventListOwner = context.target; if (eventListOwner !== undefined && context.path !== undefined) { evt.setEventTarget(context.path, eventListOwner); } return evt; } return undefined; } // private createEventMethod(target: object, methodName: string, args?: any): Function | undefined { // return function (...forwardedArgs: any[]) { // const method = target[methodName]; // if (typeof method === "function") { // if (args !== undefined) { // // we now have support for creating event methods with multiple arguments // // an argument can not be an array right now - so if we receive an array we assume it's the array of arguments that we want to call the method with // // this means ["test", true] will invoke the method like this: myFunction("test", true) // if (Array.isArray(args)) // method?.call(target, ...args); // // in any other case (when we just have one argument) we just call the method with the argument // // we can not use ...args by default becaue that would break string arguments (it would then just use the first character) // else // method?.call(target, args); // } // else // support invoking EventList with any number of arguments (if none were declared in unity) // method?.call(target, ...forwardedArgs); // } // else // the target "method" can be a property too // { // target[methodName] = args; // } // }; // } } export const eventListSerializer = new EventListSerializer(); /** Map<Clone, Original> texture. This is used for compressed textures (or when the GLTFLoader is cloning RenderTextures) * It's a weak map so we don't have to worry about memory leaks */ const cloneOriginalMap = new WeakMap<Texture, Texture>(); const textureClone = Texture.prototype.clone; Texture.prototype.clone = function () { const clone = textureClone.call(this); if (!cloneOriginalMap.has(clone)) { cloneOriginalMap.set(clone, this); } return clone; } export class RenderTextureSerializer extends TypeSerializer { constructor() { super([RenderTexture, WebGLRenderTarget]); } onSerialize(_data: any, _context: SerializationContext) { } onDeserialize(data: any, context: SerializationContext) { if (data instanceof Texture && context.type === RenderTexture) { let tex = data as Texture; // If this is a cloned render texture we want to map it back to the original texture // See https://linear.app/needle/issue/NE-5530 if (cloneOriginalMap.has(tex)) { const original = cloneOriginalMap.get(tex)!; tex = original; } tex.isRenderTargetTexture = true; tex.flipY = true; tex.offset.y = 1; tex.repeat.y = -1; tex.needsUpdate = true; // when we have a compressed texture using mipmaps causes error in threejs because the bindframebuffer call will then try to set an array of framebuffers https://linear.app/needle/issue/NE-4294 tex.mipmaps = []; if (tex instanceof CompressedTexture) { //@ts-ignore tex["isCompressedTexture"] = false; //@ts-ignore tex.format = RGBAFormat; } const rt = new RenderTexture(tex.image.width, tex.image.height, { colorSpace: LinearSRGBColorSpace, }); rt.texture = tex; return rt; } return undefined; } } new RenderTextureSerializer(); export class UriSerializer extends TypeSerializer { constructor() { super([URL]); } onSerialize(_data: string, _context: SerializationContext) { return null; } onDeserialize(data: string, _context: SerializationContext) { if (typeof data === "string" && data.length > 0) { return resolveUrl(_context.gltfId, data); } return undefined; } } new UriSerializer();