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