@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.
508 lines • 20.4 kB
JavaScript
import { Euler, Quaternion, Scene, Vector3 } from "three";
// https://github.com/uuidjs/uuid
// v5 takes string and namespace
import { v5 } from 'uuid';
import { ContextEvent, ContextRegistry } from "../engine/engine_context_registry.js";
import { isDevEnvironment } from "./debug/index.js";
import { destroy, findByGuid, instantiate } from "./engine_gameobject.js";
import { InstantiateOptions } from "./engine_gameobject.js";
import { SendQueue } from "./engine_networking_types.js";
import { Context } from "./engine_setup.js";
import * as utils from "./engine_utils.js";
ContextRegistry.registerCallback(ContextEvent.ContextCreated, evt => {
const context = evt.context;
beginListenInstantiate(context);
beginListenDestroy(context);
});
const debug = utils.getParam("debugcomponents");
const ID_NAMESPACE = 'eff8ba80-635d-11ec-90d6-0242ac120003';
export class InstantiateIdProvider {
get seed() {
return this._seed;
}
set seed(val) {
this._seed = val;
}
_originalSeed;
_seed;
constructor(seed) {
if (typeof seed === "string") {
seed = InstantiateIdProvider.hash(seed);
}
this._originalSeed = seed;
this._seed = seed;
}
reset() {
this._seed = this._originalSeed;
}
generateUUID(str) {
if (typeof str === "string") {
return v5(str, ID_NAMESPACE);
}
const s = this._seed;
this._seed -= 1;
// console.log(s);
return v5(s.toString(), ID_NAMESPACE);
}
initialize(strOrNumber) {
if (typeof strOrNumber === "string") {
this._seed = InstantiateIdProvider.hash(strOrNumber);
}
else {
this._seed = strOrNumber;
}
}
static createFromString(str) {
return new InstantiateIdProvider(this.hash(str));
}
static hash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
}
}
export var InstantiateEvent;
(function (InstantiateEvent) {
InstantiateEvent["NewInstanceCreated"] = "new-instance-created";
InstantiateEvent["InstanceDestroyed"] = "instance-destroyed";
})(InstantiateEvent || (InstantiateEvent = {}));
class DestroyInstanceModel {
guid;
dontSave;
constructor(guid) {
this.guid = guid;
}
}
/**
* Destroy an object across the network. See also {@link syncInstantiate}.
* @param obj The object or component to be destroyed
* @param con The network connection to send the destroy event to
* @param recursive If true, all children will be destroyed as well. Default is true
* @param opts Options for the destroy operation
* @category Networking
*/
export function syncDestroy(obj, con, recursive = true, opts) {
if (!obj)
return;
const go = obj;
destroy(obj, recursive);
if (!con) {
console.warn("Can not send destroy: No networking connection provided", obj.guid);
return;
}
if (!con.isConnected) {
if (isDevEnvironment())
console.debug("Can not send destroy: not connected", obj.guid);
return;
}
let guid = obj.guid;
if (!guid && go.uuid) {
guid = go.uuid;
}
if (!guid) {
console.warn("Can not send destroy: failed to find guid", obj);
return;
}
sendDestroyed(guid, con, opts);
}
export function sendDestroyed(guid, con, opts) {
const model = new DestroyInstanceModel(guid);
if (opts?.saveInRoom === false) {
model.dontSave = true;
}
con.send(InstantiateEvent.InstanceDestroyed, model, SendQueue.Queued);
}
const _onSyncDestroyCallbacks = [];
/**
* Register a callback that fires when a remote `syncDestroy` event is received.
* The callback receives the guid and the resolved Object3D (or null if not found in the scene).
* The callback fires **before** the object is destroyed, so you can still access its state.
* @param callback Called with the guid and the Object3D about to be destroyed
* @returns An unsubscribe function
* @category Networking
* @example
* ```ts
* const unsub = onSyncDestroy((guid, obj) => {
* console.log("Remote object destroyed:", guid, obj?.name);
* });
* // later: unsub();
* ```
*/
export function onSyncDestroy(callback) {
_onSyncDestroyCallbacks.push(callback);
return () => {
const idx = _onSyncDestroyCallbacks.indexOf(callback);
if (idx >= 0)
_onSyncDestroyCallbacks.splice(idx, 1);
};
}
export function beginListenDestroy(context) {
context.connection.beginListen(InstantiateEvent.InstanceDestroyed, (data) => {
if (debug)
console.log("[Remote] Destroyed", context.scene, data);
// TODO: create global lookup table for guids
const obj = findByGuid(data.guid, context.scene);
if (obj) {
// Notify listeners before destroying
for (const cb of _onSyncDestroyCallbacks) {
try {
cb(data.guid, obj);
}
catch (err) {
console.error("Error in onSyncDestroy callback", err);
}
}
destroy(obj);
}
});
}
//
/**
* When a file is instantiated via some server (e.g. via file drop) we also want to send the info where the file can be downloaded.
* @internal
*/
export class HostData {
/** File to download */
filename;
/** Checksum to verify its the correct file */
hash;
/** Expected size of the referenced file and its dependencies */
size;
constructor(filename, hash, size) {
this.filename = filename;
this.hash = hash;
this.size = size;
}
}
export class NewInstanceModel {
guid;
originalGuid;
seed;
visible;
hostData;
dontSave;
parent;
position;
rotation;
scale;
/** Set to true to prevent this model from being instantiated */
preventCreation = undefined;
/**
* When set this will delete the server state when the user disconnects
*/
deleteStateOnDisconnect;
constructor(originalGuid, newGuid) {
this.originalGuid = originalGuid;
this.guid = newGuid;
}
}
/** Registered callbacks for remote instantiation events */
const _onSyncInstantiateCallbacks = [];
/**
* Register a callback that fires when a remote `syncInstantiate` object is created on this client.
* Use this to get references to objects spawned by other users.
* @param callback Called with the instantiated Object3D, the network model data, and the Needle Engine context in which the instantiate event was received
* @returns An unsubscribe function
* @category Networking
* @example
* ```ts
* const unsub = onSyncInstantiate((instance, model, context) => {
* console.log("Remote object created:", instance.name, model.originalGuid, context);
* });
* // later: unsub();
* ```
* @see {@link syncInstantiate} - Instantiate objects across the network
* @see {@link syncDestroy} - Destroy objects across the network
*/
export function onSyncInstantiate(callback) {
_onSyncInstantiateCallbacks.push(callback);
return () => {
const idx = _onSyncInstantiateCallbacks.indexOf(callback);
if (idx >= 0)
_onSyncInstantiateCallbacks.splice(idx, 1);
};
}
/**
* Instantiate an object across the network. The object is cloned locally and a network message
* is sent so all connected clients create the same clone. Late joiners receive the message
* via room state replay (unless `deleteOnDisconnect` is set or `save` is false).
*
* ## How it works internally
* 1. The prefab is cloned locally using a seeded {@link InstantiateIdProvider}
* 2. The seed ensures all clients generate **identical deterministic guids** for the clone
* and all its children — no need to send individual guids over the network
* 3. A {@link NewInstanceModel} message is sent containing the prefab's `originalGuid`,
* the clone's `guid`, the `seed`, and transform data
* 4. On receiving clients, the prefab is resolved via {@link registerPrefabProvider} or
* by searching the scene for an object with matching guid, then cloned with the same seed
*
* ## Runtime-created prefabs (no GLB)
* If the object has a `guid` but no prefab provider is registered for it, `syncInstantiate`
* will **auto-register** the object as a prefab provider. This means for code-only prefabs
* you just need to set a `guid` — no manual `registerPrefabProvider` call needed, as long as
* all clients run the same setup code that creates the same prefab with the same guid.
*
* @param object The object to instantiate. Must have a `guid` property (set one for runtime objects).
* @param opts Options for the instantiation, including the network context to send the instantiate event to
* @param hostData Optional data about a file to download when this object is instantiated (e.g. when instantiated via file drop)
* @param save When false, the state of this instance will not be saved in the networking backend. Default is true.
* @returns The instantiated object, or null if instantiation failed (e.g. missing guid or network context)
* @see {@link syncDestroy} - Destroy objects across the network
* @see {@link onSyncInstantiate} - Register a callback to get references to remotely instantiated objects
* @see {@link registerPrefabProvider} - Manually register a prefab provider (auto-registered by syncInstantiate)
* @see {@link unregisterPrefabProvider} - Remove a registered prefab provider
* @category Networking
*
* @example Basic usage with a runtime-created prefab
* ```ts
* const cookie = ObjectUtils.createPrimitive("Cube", { color: 0xff8c00 });
* cookie.guid = "cookie-prefab";
* // No need to call registerPrefabProvider — syncInstantiate auto-registers it
* syncInstantiate(cookie, { parent: ctx.scene, deleteOnDisconnect: false });
* ```
*
* @example With deterministic seed (advanced)
* ```ts
* const idProvider = new InstantiateIdProvider("my-seed");
* const instance = syncInstantiate(prefab, { context, idProvider });
* ```
* The seed generates deterministic guids via UUID v5, so all clients produce identical
* identifiers for the clone and its children without sending them over the network.
*/
export function syncInstantiate(object, opts, hostData, save) {
const obj = object;
if (!obj.guid) {
// Auto-assign guid from object name if available.
// The name must be the same on all clients (which it is when both run the same setup code).
if (obj.name) {
obj.guid = obj.name;
}
else {
console.error("[syncInstantiate] Can not instantiate: a 'guid' is required. For runtime-created objects, either set a name (`obj.name = 'my-prefab'`) or a guid (`obj.guid = 'my-prefab-id'`). The identifier must be the same on all clients.", obj);
return null;
}
}
// Auto-register the prefab provider if none exists for this guid.
// This allows runtime-created objects to work with syncInstantiate without
// manual Prefabs.register calls, as long as all clients create the
// same prefab with the same guid in their setup code.
if (!Prefabs.has(obj.guid)) {
Prefabs.register(obj.guid, async () => obj);
}
if (!opts.context) {
opts.context = Context.Current;
}
if (!opts.context) {
console.error("[syncInstantiate] Missing network instantiate options / reference to network connection in sync instantiate. Please pass in the Needle Engine context.");
return null;
}
const originalOpts = opts ? { ...opts } : null;
const { instance, seed } = instantiateSeeded(obj, opts);
if (instance) {
const go = instance;
// if (go.guid) {
// const listener = GameObject.addNewComponent(go, DestroyListener);
// listener.target = go;
// }
if (go.guid) {
if (debug)
console.log("[Local] new instance", "gameobject:", instance?.guid);
const model = new NewInstanceModel(obj.guid, go.guid);
model.seed = seed;
if (opts.deleteOnDisconnect === true)
model.deleteStateOnDisconnect = true;
if (originalOpts) {
if (originalOpts.position) {
if (Array.isArray(originalOpts.position)) {
model.position = { x: originalOpts.position[0], y: originalOpts.position[1], z: originalOpts.position[2] };
}
else
model.position = { x: originalOpts.position.x, y: originalOpts.position.y, z: originalOpts.position.z };
}
if (originalOpts.rotation) {
if (originalOpts.rotation instanceof Euler) {
originalOpts.rotation = new Quaternion().setFromEuler(originalOpts.rotation);
}
else if (originalOpts.rotation instanceof Array) {
originalOpts.rotation = new Quaternion().fromArray(originalOpts.rotation);
}
model.rotation = { x: originalOpts.rotation.x, y: originalOpts.rotation.y, z: originalOpts.rotation.z, w: originalOpts.rotation.w };
}
if (originalOpts.scale) {
if (Array.isArray(originalOpts.scale)) {
model.scale = { x: originalOpts.scale[0], y: originalOpts.scale[1], z: originalOpts.scale[2] };
}
else
model.scale = { x: originalOpts.scale.x, y: originalOpts.scale.y, z: originalOpts.scale.z };
}
}
if (!model.position)
model.position = { x: go.position.x, y: go.position.y, z: go.position.z };
if (!model.rotation)
model.rotation = { x: go.quaternion.x, y: go.quaternion.y, z: go.quaternion.z, w: go.quaternion.w };
if (!model.scale)
model.scale = { x: go.scale.x, y: go.scale.y, z: go.scale.z };
model.visible = obj.visible;
if (originalOpts?.parent) {
if (typeof originalOpts.parent === "string")
model.parent = originalOpts.parent;
else if (originalOpts.parent?.["guid"]) {
model.parent = originalOpts.parent["guid"];
}
else if (originalOpts.parent instanceof Scene) {
model.parent = "scene";
}
else
console.warn("Unsupported parent type in sync instantiate options: " + originalOpts.parent?.name);
}
model.hostData = hostData;
if (save === false)
model.dontSave = true;
const con = opts?.context?.connection;
if (!con && isDevEnvironment())
console.debug("Object will be instantiated but it will not be synced: not connected", obj.guid);
if (opts.context.connection.isInRoom)
syncedInstantiated.push(new WeakRef(go));
opts?.context?.connection.send(InstantiateEvent.NewInstanceCreated, model);
}
else
console.warn("Missing guid, can not send new instance event", go);
}
return instance;
}
export function generateSeed() {
return Math.random() * 9_999_999; // Number.MAX_VALUE;;
}
const syncedInstantiated = new Array();
export function beginListenInstantiate(context) {
const cb1 = context.connection.beginListen(InstantiateEvent.NewInstanceCreated, async (model) => {
const obj = await tryResolvePrefab(model.originalGuid, context.scene);
if (model.preventCreation === true) {
return;
}
if (!obj) {
console.warn("could not find object that was instantiated: " + model.guid);
return;
}
const options = new InstantiateOptions();
if (model.position)
options.position = new Vector3(model.position.x, model.position.y, model.position.z);
if (model.rotation)
options.rotation = new Quaternion(model.rotation.x, model.rotation.y, model.rotation.z, model.rotation.w);
if (model.scale)
options.scale = new Vector3(model.scale.x, model.scale.y, model.scale.z);
options.parent = model.parent;
if (model.seed)
options.idProvider = new InstantiateIdProvider(model.seed);
options.visible = model.visible;
options.context = context;
if (debug && context.alias)
console.log("[Remote] instantiate in: " + context.alias);
const inst = instantiate(obj, options);
syncedInstantiated.push(new WeakRef(inst));
if (inst) {
if (model.parent === "scene")
context.scene.add(inst);
if (debug)
console.log("[Remote] new instance", "gameobject:", inst?.guid, obj);
// Notify listeners about the remote instantiation
for (const cb of _onSyncInstantiateCallbacks) {
try {
cb(inst, model, context);
}
catch (err) {
console.error("Error in onSyncInstantiate callback", err);
}
}
}
});
const cb2 = context.connection.beginListen("left-room", () => {
if (syncedInstantiated.length > 0)
console.debug(`Left networking room, cleaning up ${syncedInstantiated.length} instantiated objects`);
for (const prev of syncedInstantiated) {
const obj = prev.deref();
if (obj)
obj.destroy();
}
syncedInstantiated.length = 0;
});
return () => {
cb1();
cb2();
};
}
function instantiateSeeded(obj, opts) {
const seed = generateSeed();
const options = opts ?? new InstantiateOptions();
options.idProvider = new InstantiateIdProvider(seed);
const instance = instantiate(obj, options);
return { seed: seed, instance: instance };
}
export { Prefabs } from "./engine_networking_prefabs.js";
import { Prefabs } from "./engine_networking_prefabs.js";
/**
* Register a prefab provider. Forwards to {@link Prefabs.register}.
* @category Networking
*/
export function registerPrefabProvider(key, fn) {
Prefabs.register(key, fn);
}
/**
* Unregister a prefab provider. Forwards to {@link Prefabs.unregister}.
* @category Networking
*/
export function unregisterPrefabProvider(key) {
Prefabs.unregister(key);
}
async function tryResolvePrefab(guid, obj) {
const res = await Prefabs.resolve(guid);
if (res)
return res;
return tryFindObjectByGuid(guid, obj);
}
function tryFindObjectByGuid(guid, obj) {
if (obj === null)
return null;
if (!guid)
return null;
if (obj["guid"] === guid) {
return obj;
}
if (obj.children) {
for (const ch of obj.children) {
const found = tryFindObjectByGuid(guid, ch);
if (found) {
return found;
}
}
}
return null;
}
// class DestroyListener extends Behaviour {
// target: GameObject | Component | null = null;
// private destroyCallback: any;
// awake(): void {
// if (!this.target) {
// console.log("Missing target to watch", this);
// return;
// }
// this.destroyCallback = this.onObjectDestroyed.bind(this);
// this.context.connection.beginListen(InstantiateEvent.InstanceDestroyed, this.destroyCallback);
// }
// onDestroy(): void {
// this.context.connection.stopListening(InstantiateEvent.InstanceDestroyed, this.destroyCallback);
// if (this.target && this.target.guid && this.gameObject.guid === this.target.guid) {
// sendDestroyed(this.target.guid, this.context.connection);
// }
// }
// private onObjectDestroyed(evt: DestroyInstanceModel) {
// if (evt.guid === this.target?.guid) {
// if (debug)
// console.log("RECEIVED destroyed event", evt.guid);
// GameObject.destroy(this.target);
// }
// }
// }
//# sourceMappingURL=engine_networking_instantiate.js.map