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); } } }