@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.
384 lines (324 loc) • 15.7 kB
text/typescript
import * as flatbuffers from "flatbuffers";
import { Euler, Quaternion, Vector3 } from "three";
import { InstancingUtil } from "../engine/engine_instancing.js";
import { onUpdate } from '../engine/engine_lifecycle_api.js';
import { OwnershipModel, RoomEvents } from "../engine/engine_networking.js"
import { sendDestroyed } from '../engine/engine_networking_instantiate.js';
import { setWorldEuler } from '../engine/engine_three_utils.js';
import * as utils from "../engine/engine_utils.js"
import { registerBinaryType } from '../engine-schemes/schemes.js';
import { SyncedTransformModel } from '../engine-schemes/synced-transform-model.js';
import { Transform } from '../engine-schemes/transform.js';
import { Behaviour, GameObject } from "./Component.js";
import { Rigidbody } from "./RigidBody.js";
const debug = utils.getParam("debugsync");
export const SyncedTransformIdentifier = "STRS";
registerBinaryType(SyncedTransformIdentifier, SyncedTransformModel.getRootAsSyncedTransformModel);
const builder = new flatbuffers.Builder();
/**
* Creates a flatbuffer model containing the transform data of a game object. Used by {@link SyncedTransform}
* @param guid The unique identifier of the object to sync
* @param b The behavior component containing transform data
* @param fast Whether to use fast mode synchronization (syncs more frequently)
* @returns A Uint8Array containing the serialized transform data
*/
export function createTransformModel(guid: string, b: Behaviour, fast: boolean = true): Uint8Array {
builder.clear();
const guidObj = builder.createString(guid);
SyncedTransformModel.startSyncedTransformModel(builder);
SyncedTransformModel.addGuid(builder, guidObj);
SyncedTransformModel.addFast(builder, fast);
const p = b.worldPosition;
const r = b.worldEuler;
const s = b.gameObject.scale; // todo: world scale
// console.log(p, r, s);
SyncedTransformModel.addTransform(builder, Transform.createTransform(builder, p.x, p.y, p.z, r.x, r.y, r.z, s.x, s.y, s.z));
const res = SyncedTransformModel.endSyncedTransformModel(builder);
// SyncedTransformModel.finishSyncedTransformModelBuffer(builder, res);
builder.finish(res, SyncedTransformIdentifier);
return builder.asUint8Array();
}
let FAST_ACTIVE_SYNCTRANSFORMS = 0;
let FAST_INTERVAL = 0;
onUpdate((ctx) => {
const isRunningOnGlitch = ctx.connection.currentServerUrl?.includes("glitch");
const threshold = isRunningOnGlitch ? 10 : 40;
FAST_INTERVAL = Math.floor(FAST_ACTIVE_SYNCTRANSFORMS / threshold);
FAST_ACTIVE_SYNCTRANSFORMS = 0;
if (debug && FAST_INTERVAL > 0) console.log("Sync Transform Fast Interval", FAST_INTERVAL);
})
/**
* SyncedTransform synchronizes the position and rotation of a game object over the network.
* It handles ownership transfer, interpolation, and network state updates automatically.
* @category Networking
* @group Components
*/
export class SyncedTransform extends Behaviour {
// public autoOwnership: boolean = true;
/** When true, overrides physics behavior when this object is owned by the local user */
public overridePhysics: boolean = true
/** Whether to smoothly interpolate position changes when receiving updates */
public interpolatePosition: boolean = true;
/** Whether to smoothly interpolate rotation changes when receiving updates */
public interpolateRotation: boolean = true;
/** When true, sends updates at a higher frequency, useful for fast-moving objects */
public fastMode: boolean = false;
/** When true, notifies other clients when this object is destroyed */
public syncDestroy: boolean = false;
// private _state!: SyncedTransformModel;
private _model: OwnershipModel | null = null;
private _needsUpdate: boolean = true;
private rb: Rigidbody | null = null;
private _wasKinematic: boolean | undefined = false;
private _receivedDataBefore: boolean = false;
private _targetPosition!: Vector3;
private _targetRotation!: Quaternion;
private _receivedFastUpdate: boolean = false;
private _shouldRequestOwnership: boolean = false;
/**
* Requests ownership of this object on the network.
* You need to be connected to a room for this to work.
*/
public requestOwnership() {
if (debug)
console.log("Request ownership");
if (!this._model) {
this._shouldRequestOwnership = true;
this._needsUpdate = true;
}
else
this._model.requestOwnership();
}
/**
* Free ownership of this object on the network.
* You need to be connected to a room for this to work.
* This will also be called automatically when the component is disabled.
*/
public freeOwnership() {
this._model?.freeOwnership();
}
/**
* Checks if this client has ownership of the object
* @returns true if this client has ownership, false if not, undefined if ownership state is unknown
*/
public hasOwnership(): boolean | undefined {
return this._model?.hasOwnership ?? undefined;
}
/**
* Checks if the object is owned by any client
* @returns true if the object is owned, false if not, undefined if ownership state is unknown
*/
public isOwned(): boolean | undefined {
return this._model?.isOwned;
}
private joinedRoomCallback: any = null;
private receivedDataCallback: any = null;
/** @internal */
awake() {
if (debug)
console.log("new instance", this.guid, this);
this._receivedDataBefore = false;
this._targetPosition = new Vector3();
this._targetRotation = new Quaternion();
// sync instantiate issue was because they shared the same last pos vector!
this.lastPosition = new Vector3();
this.lastRotation = new Quaternion();
this.lastScale = new Vector3();
this.rb = GameObject.getComponentInChildren(this.gameObject, Rigidbody);
if (this.rb) {
this._wasKinematic = this.rb.isKinematic;
}
this.receivedUpdate = true;
// this._state = new TransformModel(this.guid, this);
this._model = new OwnershipModel(this.context.connection, this.guid);
if (this.context.connection.isConnected) {
this.tryGetLastState();
}
this.joinedRoomCallback = this.tryGetLastState.bind(this);
this.context.connection.beginListen(RoomEvents.JoinedRoom, this.joinedRoomCallback);
this.receivedDataCallback = this.onReceivedData.bind(this);
this.context.connection.beginListenBinary(SyncedTransformIdentifier, this.receivedDataCallback);
}
/** @internal */
onDestroy(): void {
// TODO: can we add a new component for this?! do we really need this?!
if (this.syncDestroy)
sendDestroyed(this.guid, this.context.connection);
this._model = null;
this.context.connection.stopListen(RoomEvents.JoinedRoom, this.joinedRoomCallback);
this.context.connection.stopListenBinary(SyncedTransformIdentifier, this.receivedDataCallback);
}
/**
* Attempts to retrieve and apply the last known network state for this transform
*/
private tryGetLastState() {
const model = this.context.connection.tryGetState(this.guid) as unknown as SyncedTransformModel;
if (model) this.onReceivedData(model);
}
private tempEuler: Euler = new Euler();
/**
* Handles incoming network data for this transform
* @param data The model containing transform information
*/
private onReceivedData(data: SyncedTransformModel) {
if (this.destroyed) return;
if (typeof data.guid === "function" && data.guid() === this.guid) {
if (debug)
console.log("new data", this.context.connection.connectionId, this.context.time.frameCount, this.guid, data);
this.receivedUpdate = true;
this._receivedFastUpdate = data.fast();
const transform = data.transform();
if (transform) {
InstancingUtil.markDirty(this.gameObject, true);
const position = transform.position();
if (position) {
if (this.interpolatePosition)
this._targetPosition?.set(position.x(), position.y(), position.z());
if (!this.interpolatePosition || !this._receivedDataBefore)
this.setWorldPosition(position.x(), position.y(), position.z());
}
const rotation = transform.rotation();
if (rotation) {
this.tempEuler.set(rotation.x(), rotation.y(), rotation.z());
if (this.interpolateRotation) {
this._targetRotation.setFromEuler(this.tempEuler);
}
if (!this.interpolateRotation || !this._receivedDataBefore)
setWorldEuler(this.gameObject, this.tempEuler);
}
const scale = transform.scale();
if (scale) {
this.gameObject.scale.set(scale.x(), scale.y(), scale.z());
}
}
this._receivedDataBefore = true;
// if (this.rb && !this._model?.hasOwnership) {
// this.rb.setBodyFromGameObject(data.velocity)
// }
}
}
/**
* @internal
* Initializes tracking of position and rotation when component is enabled
*/
onEnable(): void {
this.lastPosition.copy(this.worldPosition);
this.lastRotation.copy(this.worldQuaternion);
this.lastScale.copy(this.gameObject.scale);
this._needsUpdate = true;
// console.log("ENABLE", this.guid, this.gameObject.guid, this.lastWorldPos);
if (this._model) {
this._model.updateIsOwned();
}
}
/**
* @internal
* Releases ownership when component is disabled
*/
onDisable(): void {
if (this._model)
this._model.freeOwnership();
}
private receivedUpdate = false;
private lastPosition!: Vector3;
private lastRotation!: Quaternion;
private lastScale!: Vector3;
/**
* @internal
* Handles transform synchronization before each render frame
* Sends updates when owner, receives and applies updates when not owner
*/
onBeforeRender() {
if (!this.activeAndEnabled || !this.context.connection.isConnected) return;
// console.log("BEFORE RENDER", this.destroyed, this.guid, this._model?.isOwned, this.name, this.gameObject);
if (!this.context.connection.isInRoom || !this._model) {
if (debug)
console.log("no model or room", this.name, this.guid, this.context.connection.isInRoom);
return;
}
if (this._shouldRequestOwnership) {
this._shouldRequestOwnership = false;
this._model.requestOwnership();
}
const pos = this.worldPosition;
const rot = this.worldQuaternion;
const scale = this.gameObject.scale;
if (this._model.isOwned && !this.receivedUpdate) {
const threshold = this._model.hasOwnership || this.fastMode ? .0001 : .001;
if (pos.distanceTo(this.lastPosition) > threshold ||
rot.angleTo(this.lastRotation) > threshold ||
scale.distanceTo(this.lastScale) > threshold) {
// console.log(worlddiff, worldRot);
if (!this._model.hasOwnership) {
if (debug)
console.log(this.guid, "reset because not owned but", this.gameObject.name, this.lastPosition);
this.worldPosition = this.lastPosition;
pos.copy(this.lastPosition);
this.worldQuaternion = this.lastRotation;
rot.copy(this.lastRotation);
this.gameObject.scale.copy(this.lastScale);
InstancingUtil.markDirty(this.gameObject, true);
this._needsUpdate = false;
}
else {
this._needsUpdate = true;
}
}
}
// else if (this._model.isOwned === false) {
// if (!this._didRequestOwnershipOnce && this.autoOwnership) {
// this._didRequestOwnershipOnce = true;
// this._model.requestOwnershipIfNotOwned();
// }
// }
if (this._model && !this._model.hasOwnership && this._model.isOwned) {
if (this._receivedDataBefore) {
const t = this._receivedFastUpdate || this.fastMode ? .5 : .3;
let requireMarkDirty = false;
if (this.interpolatePosition && this._targetPosition) {
const pos = this.worldPosition;
pos.lerp(this._targetPosition, t);
this.worldPosition = pos;
requireMarkDirty = true;
}
if (this.interpolateRotation && this._targetRotation) {
const rot = this.worldQuaternion;
rot.slerp(this._targetRotation, t);
this.worldQuaternion = rot;
requireMarkDirty = true;
}
if (requireMarkDirty)
InstancingUtil.markDirty(this.gameObject, true);
}
}
this.receivedUpdate = false;
this.lastPosition.copy(pos);
this.lastRotation.copy(rot);
this.lastScale.copy(scale);
if (!this._model) return;
if (!this._model || this._model.hasOwnership === undefined || !this._model.hasOwnership) {
// if we're not the owner of this synced transform then don't send any data
return;
}
// local user is owner:
if (this.rb && this.overridePhysics) {
if (this._wasKinematic !== undefined) {
if (debug)
console.log("reset kinematic", this.rb.name, this._wasKinematic);
this.rb.isKinematic = this._wasKinematic;
}
// TODO: if the SyncedTransform has a dynamic rigidbody we should probably synchronize the Rigidbody's properties (velocity etc)
// Or the Rigidbody should be synchronized separately in which case the SyncedTransform should be passive
}
const updateInterval = 10;
const fastUpdate = this.rb || this.fastMode;
if (this._needsUpdate && (updateInterval <= 0 || updateInterval > 0 && this.context.time.frameCount % updateInterval === 0 || fastUpdate)) {
FAST_ACTIVE_SYNCTRANSFORMS++;
if (fastUpdate && FAST_INTERVAL > 0 && this.context.time.frameCount % FAST_INTERVAL !== 0) return;
if (debug) console.debug("[SyncedTransform] Send update", this.context.connection.connectionId, this.guid, this.gameObject.name, this.gameObject.guid);
this._needsUpdate = false;
const st = createTransformModel(this.guid, this, fastUpdate ? true : false);
this.context.connection.sendBinary(st);
}
}
}