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