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.

408 lines (355 loc) 17.2 kB
import type { Color, Euler, Matrix2, Matrix3, Matrix4, Object3D, Quaternion, Vector2, Vector3, Vector4 } from "three"; import { InstantiateIdProvider } from "./engine_networking_instantiate.js"; import { isSerializable } from "./engine_serialization_core.js"; import { type GuidsMap, type IComponent, type UIDProvider, isComponent } from "./engine_types.js"; import { getParam } from "./engine_utils.js"; const debug = getParam("debuginstantiate"); // ———————————————————————————————————————————————————————— // Types // ———————————————————————————————————————————————————————— export type ObjectCloneReference = { readonly original: object; readonly clone: object; } /** Maps uuid/guid → { original, clone } for Object3D and Component instances */ export type InstantiateReferenceMap = Record<string, ObjectCloneReference>; /** * Provides access to the instantiated object map (used by EventList etc.) */ export type InstantiateContext = Readonly<InstantiateReferenceMap>; // ———————————————————————————————————————————————————————— // ID Provider Cache (moved from engine_gltf_builtin_components.ts) // ———————————————————————————————————————————————————————— /** * Cache of id providers per component/object guid. * Ensures deterministic guid generation regardless of scene order. */ const idProviderCache = new Map<string, InstantiateIdProvider>(); /** Clear the id provider cache (e.g. when reloading a context) */ export function clearIdProviderCache() { idProviderCache.clear(); } // ———————————————————————————————————————————————————————— // Guid Generation (moved from engine_gltf_builtin_components.ts) // ———————————————————————————————————————————————————————— export const originalComponentNameKey = Symbol("original-component-name"); // #region hierarchy guids /** * Recursively generates new deterministic guids for all objects and components in a hierarchy. * Uses the idProviderCache so that the same source guid always produces the same output guid * (needed for networking: all clients must agree on the guids of instantiated objects). * Populates guidsMap (oldGuid → newGuid) so string references can be remapped afterwards. */ export function generateGuidsForHierarchy( obj: Object3D, idProvider: UIDProvider | null, guidsMap: GuidsMap, ): void { if (idProvider === null) return; if (!obj) return; const prev = (obj as any).guid; // Use a cached id provider per object to ensure stable guids regardless of hierarchy order const idProviderKey = (obj as any).guid; if (idProviderKey?.length) { if (!idProviderCache.has(idProviderKey)) { if (debug) console.log("Creating InstanceIdProvider with key \"" + idProviderKey + "\" for object " + obj.name); idProviderCache.set(idProviderKey, new InstantiateIdProvider(idProviderKey)); } } const objectIdProvider = idProviderKey && idProviderCache.get(idProviderKey) || idProvider; (obj as any).guid = objectIdProvider.generateUUID(); if (prev && prev !== "invalid") guidsMap[prev] = (obj as any).guid; if (obj && obj.userData && obj.userData.components) { for (const comp of obj.userData.components) { if (comp === null) continue; const compIdProviderKey = comp.guid; if (compIdProviderKey) { if (!idProviderCache.has(compIdProviderKey)) { if (debug) console.log("Creating InstanceIdProvider with key \"" + compIdProviderKey + "\" for component " + comp[originalComponentNameKey]); idProviderCache.set(compIdProviderKey, new InstantiateIdProvider(compIdProviderKey)); } } else if (debug) console.warn("Can not create IdProvider: component " + comp[originalComponentNameKey] + " has no guid", comp.guid); const componentIdProvider = idProviderCache.get(compIdProviderKey) || idProvider; const compPrev = comp.guid; comp.guid = componentIdProvider.generateUUID(); if (compPrev && compPrev !== "invalid") guidsMap[compPrev] = comp.guid; } } if (obj.children) { for (const child of obj.children) { generateGuidsForHierarchy(child as Object3D, idProvider, guidsMap); } } } // ———————————————————————————————————————————————————————— // #region reference resolution // ———————————————————————————————————————————————————————— /** * The unified reference resolution function. * Iterates all cloned components in the objectMap and remaps their properties * to point at cloned counterparts where appropriate. * * Handles: Component, Object3D, Array, Map, Set, Record/plain objects, * EventList, Vector/Color/Quaternion, and @serializable nested objects. */ export function resolveInstanceReferences(objectMap: InstantiateReferenceMap): void { for (const key in objectMap) { const val = objectMap[key]; const clone = val.clone as Object3D | null; if (!clone?.isObject3D || !clone?.userData?.components) continue; for (let i = 0; i < clone.userData.components.length; i++) { const component = clone.userData.components[i]; const entries = Object.entries(component); for (const [propKey, propValue] of entries) { if (propValue === null || propValue === undefined) continue; // Skip primitives that can't be remapped, but allow strings for guid resolution if (typeof propValue !== "object" && typeof propValue !== "string") continue; const resolved = resolveValue(propKey, propValue, objectMap); if (resolved !== undefined) { component[propKey] = resolved; } } } } } /** * Resolves string-based guid references in all components of a hierarchy using a GuidsMap. * Used by the glTF loading path where objects get new guids assigned and string references * (e.g. PlayableDirector track.outputs) need to be updated. */ export function resolveStringGuidsInHierarchy(root: Object3D, guidsMap: GuidsMap): void { resolveStringGuidsRecursive(root, guidsMap); } function resolveStringGuidsRecursive(obj: Object3D, guidsMap: GuidsMap): void { if (obj.userData?.components) { for (const component of obj.userData.components) { if (component === null) continue; resolveStringGuidsInObject(component, guidsMap); } } if (obj.children) { for (const child of obj.children) { resolveStringGuidsRecursive(child as Object3D, guidsMap); } } } function resolveStringGuidsInObject(obj: any, guidsMap: GuidsMap, visited?: WeakSet<object>): void { if (!visited) visited = new WeakSet(); if (visited.has(obj)) return; visited.add(obj); for (const key of Object.keys(obj)) { const value = obj[key]; if (value === null || value === undefined) continue; if (typeof value === "string") { if (guidsMap[value]) { obj[key] = guidsMap[value]; } } else if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { if (typeof value[i] === "string" && guidsMap[value[i]]) { value[i] = guidsMap[value[i]]; } else if (typeof value[i] === "object" && value[i] !== null) { resolveStringGuidsInObject(value[i], guidsMap, visited); } } } else if (typeof value === "object") { // Skip known non-data objects if (value.isObject3D || value.isComponent) continue; resolveStringGuidsInObject(value, guidsMap, visited); } } } // #region resolveValue /** * Resolve a single value, returning the remapped value or undefined if no remap needed. * This is the core remapping logic called recursively for nested structures. */ export function resolveValue(key: string, value: unknown, objectMap: InstantiateReferenceMap): any | undefined { // Handle null/undefined early to avoid unnecessary processing if (value === undefined) return undefined; if (value === null) return null; // String guid resolution: if this string is a known guid/uuid in the objectMap, // resolve it directly to the clone object. This handles e.g. PlayableDirector track.outputs. if (typeof value === "string") { const ref = objectMap[value]; if (ref) { return ref.clone; } return undefined; } // Primitives: no remapping needed if (typeof value !== "object") return undefined; // 1. Component → find cloned counterpart by gameObject.uuid + component index if (isComponent(value)) { return resolveComponentReference(value, objectMap); } // 2. Object3D → uuid lookup, return clone if found (otherwise external, keep as-is) if ((value as Object3D).isObject3D === true) { if (key === "gameObject") return undefined; const id = (value as Object3D).uuid; const cloneRef = objectMap[id]?.clone; if (cloneRef) { if (debug) console.log(key, "old", value, "new", cloneRef); return cloneRef; } return undefined; } // 3. Cloneable value types (Vector3, Quaternion, Euler, Color) if (isCloneableValueType(value)) { return value.clone(); } // 4. Array → create new array, recursively resolve each element if (Array.isArray(value)) { return resolveArray(key, value, objectMap); } // 5. Map → create new Map, resolve keys and values if (value instanceof Map) { return resolveMap(value, objectMap); } // 6. Set → create new Set, resolve values if (value instanceof Set) { return resolveSet(value, objectMap); } // 7. WeakMap / WeakSet → NOT iterable, cannot remap. Keep as-is. if (value instanceof WeakMap || value instanceof WeakSet) { return undefined; } // 8. @serializable objects (incl. EventList, CallInfo) → shallow clone + recursively resolve $serializedTypes fields if (isSerializable(value) && value.$serializedTypes) { return resolveSerializableObject(value, objectMap); } // 9. Plain objects / Records → shallow clone, resolve each value if (isPlainObject(value)) { return resolvePlainObject(key, value, objectMap); } return undefined; } // ———————————————————————————————————————————————————————— // Internal Helpers // ———————————————————————————————————————————————————————— function resolveComponentReference(value: IComponent, objectMap: InstantiateReferenceMap): object | undefined { const originalGameObject = value["gameObject"] as Object3D | undefined; if (!originalGameObject) return undefined; const id = originalGameObject.uuid; const newGameObject = objectMap[id]?.clone as Object3D | undefined; if (!newGameObject) { // Reference points to an object not in the cloned hierarchy (external) if (debug) console.log("Component reference did not change (external)", value); return undefined; } const index = originalGameObject.userData.components.indexOf(value); if (index >= 0 && newGameObject.isObject3D) { if (debug) console.log("Resolved component", id, "at index", index); return newGameObject.userData.components[index]; } else { console.warn("Could not find component at expected index", value); } return undefined; } function resolveArray(key: string, arr: unknown[], objectMap: InstantiateReferenceMap): unknown[] { const result: unknown[] = []; for (let i = 0; i < arr.length; i++) { const entry = arr[i]; if (entry === null || entry === undefined) { result.push(entry); continue; } // Skip primitives that can't be remapped (numbers, booleans) // but allow strings through for guid resolution if (typeof entry !== "object" && typeof entry !== "string") { result.push(entry); continue; } const resolved = resolveValue(key, entry, objectMap); result.push(resolved !== undefined ? resolved : entry); } return result; } function resolveMap(map: Map<unknown, unknown>, objectMap: InstantiateReferenceMap): Map<any, any> { const result = new Map(); let didChange = false; for (const [mapKey, mapValue] of map) { let resolvedKey = mapKey; let resolvedValue = mapValue; if (typeof mapKey === "object" && mapKey !== null) { const rk = resolveValue("", mapKey, objectMap); if (rk !== undefined) { resolvedKey = rk; didChange = true; } } if (typeof mapValue === "object" && mapValue !== null) { const rv = resolveValue("", mapValue, objectMap); if (rv !== undefined) { resolvedValue = rv; didChange = true; } } result.set(resolvedKey, resolvedValue); } return didChange ? result : result; // always return new Map to prevent shared mutation } function resolveSet(set: Set<unknown>, objectMap: InstantiateReferenceMap): Set<any> { const result = new Set(); for (const entry of set) { if (typeof entry === "object" && entry !== null) { const resolved = resolveValue("", entry, objectMap); result.add(resolved !== undefined ? resolved : entry); } else { result.add(entry); } } return result; } function resolveSerializableObject(value: unknown, objectMap: InstantiateReferenceMap): any | undefined { // Clone the serializable object to avoid mutating the original (which may be shared with source) const cloned = Object.assign(Object.create(Object.getPrototypeOf(value)), value); let didChange = false; for (const key in cloned.$serializedTypes) { const val = cloned[key]; if (val === null || val === undefined) continue; if (typeof val === "object") { if (debug) console.log("Recursively resolve references for", key, val); const resolved = resolveValue(key, val, objectMap); if (resolved !== undefined) { cloned[key] = resolved; didChange = true; } } } return didChange ? cloned : undefined; } function resolvePlainObject(_parentKey: string, obj: Record<string, unknown>, objectMap: InstantiateReferenceMap): Record<string, unknown> | undefined { let didChange = false; const clone = { ...obj }; for (const key of Object.keys(clone)) { const val = clone[key]; if (val === null || val === undefined) continue; // Skip primitives that can't be remapped, but allow strings for guid resolution if (typeof val !== "object" && typeof val !== "string") continue; const resolved = resolveValue(key, val, objectMap); if (resolved !== undefined) { clone[key] = resolved; didChange = true; } } return didChange ? clone : undefined; } function isPlainObject(obj: unknown): obj is Record<string, unknown> { if (typeof obj !== "object" || obj === null) return false; const proto = Object.getPrototypeOf(obj); return proto === Object.prototype || proto === null; } /** Returns true if the object is a three.js value type that should be cloned (not remapped) */ function isCloneableValueType(value: object): value is { clone(): object } { return (value as Vector2).isVector2 === true || (value as Vector3).isVector3 === true || (value as Vector4).isVector4 === true || (value as Quaternion).isQuaternion === true || (value as Euler).isEuler === true || (value as Color).isColor === true || (value as Matrix2).isMatrix2 === true || (value as Matrix3).isMatrix3 === true || (value as Matrix4).isMatrix4 === true; }