@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.
716 lines • 28.3 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Color, Object3D, Quaternion, Vector3 } from "three";
import { InputEvents } from "../engine/engine_input.js";
import { RoomEvents } from "../engine/engine_networking.js";
import { RaycastOptions } from "../engine/engine_physics.js";
import { ViewDevice } from "../engine/engine_playerview.js";
import { serializable } from "../engine/engine_serialization.js";
import { getParam } from "../engine/engine_utils.js";
import { Camera } from "./Camera.js";
import { Behaviour, GameObject } from "./Component.js";
import { OrbitControls } from "./OrbitControls.js";
import { SmoothFollow } from "./SmoothFollow.js";
import { AvatarMarker } from "./webxr/WebXRAvatar.js";
import { XRStateFlag } from "./webxr/XRFlag.js";
/**
* Defines the viewing perspective in spectator mode
*/
export var SpectatorMode;
(function (SpectatorMode) {
/** View from the perspective of the followed player */
SpectatorMode[SpectatorMode["FirstPerson"] = 0] = "FirstPerson";
/** Freely view from a third-person perspective */
SpectatorMode[SpectatorMode["ThirdPerson"] = 1] = "ThirdPerson";
})(SpectatorMode || (SpectatorMode = {}));
const debug = getParam("debugspectator");
/**
* Provides functionality to follow and spectate other users in a networked environment.
* Handles camera switching, following behavior, and network synchronization for spectator mode.
*
* Debug mode can be enabled with the URL parameter `?debugspectator`, which provides additional console output.
*
* @category Networking
* @group Components
*/
export class SpectatorCamera extends Behaviour {
/** Reference to the Camera component on this GameObject */
cam = null;
/**
* When enabled, pressing F will send a request to all connected users to follow the local player.
* Pressing ESC will stop spectating.
*/
useKeys = true;
_mode = SpectatorMode.FirstPerson;
/** Gets the current spectator perspective mode */
get mode() { return this._mode; }
/** Sets the current spectator perspective mode */
set mode(val) {
this._mode = val;
}
/** Returns whether this user is currently spectating another user */
get isSpectating() {
return this._handler?.currentTarget !== undefined;
}
/**
* Checks if this instance is spectating the user with the given ID
* @param userId The user ID to check
* @returns True if spectating the specified user, false otherwise
*/
isSpectatingUser(userId) {
return this.target?.userId === userId;
}
/**
* Checks if the user with the specified ID is following this user
* @param userId The user ID to check
* @returns True if the specified user is following this user, false otherwise
*/
isFollowedBy(userId) {
return this.followers?.includes(userId);
}
/** List of user IDs that are currently following the user */
get followers() {
return this._networking.followers;
}
/** Stops the current spectating session */
stopSpectating() {
if (this.context.isInXR) {
this.followSelf();
return;
}
this.target = undefined;
}
/** Gets the local player's connection ID */
get localId() {
return this.context.connection.connectionId ?? "local";
}
/**
* Sets the player view to follow
* @param target The PlayerView to follow, or undefined to stop spectating
*/
set target(target) {
if (this._handler) {
// if (this.target?.userId) {
// const isFollowedByThisUser = this.followers.includes(this.target.userId);
// if (isFollowedByThisUser) {
// console.warn("Can not follow follower");
// target = undefined;
// }
// }
const prev = this._handler.currentTarget?.userId;
const self = this.context.players.getPlayerView(this.localId);
// if user is in XR and sets target to self disable it
if (target === undefined || (this.context.isInXR === false && self?.currentObject === target.currentObject)) {
if (this._handler.currentTarget !== undefined) {
this._handler.disable();
GameObject.setActive(this.gameObject, false);
if (this.orbit)
this.orbit.enabled = true;
this._networking.onSpectatedObjectChanged(target, prev);
}
}
else if (this._handler.currentTarget !== target) {
this._handler.set(target);
GameObject.setActive(this.gameObject, true);
if (this.orbit)
this.orbit.enabled = false;
this._networking.onSpectatedObjectChanged(target, prev);
}
}
}
/** Gets the currently followed player view */
get target() {
return this._handler?.currentTarget;
}
/** Sends a network request for all users to follow this player */
requestAllFollowMe() {
this._networking.onRequestFollowMe();
}
/** Determines if the camera is spectating the local player */
get isSpectatingSelf() {
return this.isSpectating && this.target?.currentObject === this.context.players.getPlayerView(this.localId)?.currentObject;
}
// private currentViewport : Vector4 = new Vector4();
// private currentScissor : Vector4 = new Vector4();
// private currentScissorTest : boolean = false;
orbit = null;
_handler;
eventSub_WebXRRequestStartEvent = null;
eventSub_WebXRStartEvent = null;
eventSub_WebXREndEvent = null;
_debug;
_networking;
awake() {
this._debug = new SpectatorSelectionController(this.context, this);
this._networking = new SpectatorCamNetworking(this.context, this);
this._networking.awake();
GameObject.setActive(this.gameObject, false);
this.cam = GameObject.getComponent(this.gameObject, Camera);
if (!this.cam) {
console.warn("SpectatorCamera: Spectator camera needs camera component on the same object.", this);
return;
}
if (!this._handler && this.cam)
this._handler = new SpectatorHandler(this.context, this.cam, this);
this.orbit = GameObject.getComponent(this.context.mainCamera, OrbitControls);
}
onDestroy() {
this.stopSpectating();
this._handler?.destroy();
this._networking?.destroy();
}
/**
* Checks if the current platform supports spectator mode
* @returns True if the platform is supported, false otherwise
*/
isSupportedPlatform() {
const ua = window.navigator.userAgent;
const standalone = /Windows|MacOS/.test(ua);
const isHololens = /Windows NT/.test(ua) && /Edg/.test(ua) && !/Win64/.test(ua);
return standalone && !isHololens;
}
/**
* Called before entering WebXR mode
* @param _evt The WebXR event
*/
onBeforeXR(_evt) {
if (!this.isSupportedPlatform())
return;
GameObject.setActive(this.gameObject, true);
}
/**
* Called when entering WebXR mode
* @param _evt The WebXR event
*/
onEnterXR(_evt) {
if (!this.isSupportedPlatform())
return;
if (debug)
console.log(this.context.mainCamera);
if (this.context.mainCamera) {
this.followSelf();
}
}
/**
* Called when exiting WebXR mode
* @param _evt The WebXR event
*/
onLeaveXR(_evt) {
this.context.removeCamera(this.cam);
GameObject.setActive(this.gameObject, false);
if (this.orbit)
this.orbit.enabled = true;
this._handler?.set(undefined);
this._handler?.disable();
if (this.isSpectatingSelf)
this.stopSpectating();
}
/**
* Sets the target to follow the local player
*/
followSelf() {
this.target = this.context.players.getPlayerView(this.context.connection.connectionId);
if (!this.target) {
this.context.players.setPlayerView(this.localId, this.context.mainCamera, ViewDevice.Headset);
this.target = this.context.players.getPlayerView(this.localId);
}
if (debug)
console.log("Follow self", this.target);
}
// TODO: only show Spectator cam for DesktopVR;
// don't show for AR, don't show on Quest
// TODO: properly align cameras on enter/exit VR, seems currently spectator cam breaks alignment
/**
* Called after the main rendering pass to render the spectator view
*/
onAfterRender() {
if (!this.cam)
return;
const renderer = this.context.renderer;
const xrWasEnabled = renderer.xr.enabled;
if (!renderer.xr.isPresenting && !this._handler?.currentTarget)
return;
this._handler?.update(this._mode);
// remember XR render target so we can restore later
const previousRenderTarget = renderer.getRenderTarget();
let oldFramebuffer = null;
const webglState = renderer.state;
// seems that in some cases, renderer.getRenderTarget returns null
// even when we're rendering to a headset.
if (!previousRenderTarget) {
if (!renderer.state.bindFramebuffer || !webglState.bindXRFramebuffer)
return;
oldFramebuffer = renderer["_framebuffer"];
webglState.bindXRFramebuffer(null);
}
this.setAvatarFlagsBeforeRender();
const mainCam = this.context.mainCameraComponent;
// these should not be needed if we don't override viewport/scissor
// renderer.getViewport(this.currentViewport);
// renderer.getScissor(this.currentScissor);
// this.currentScissorTest = renderer.getScissorTest();
// for scissor rendering (e.g. just a part of the screen / viewport, multiplayer split view)
// let left = 0;
// let bottom = 100;
// let width = 300;
// let height = 300;
// renderer.setViewport(left, bottom, width, height);
// renderer.setScissor(left, bottom, width, height);
// renderer.setScissorTest(true);
if (mainCam) {
const backgroundColor = mainCam.backgroundColor;
if (backgroundColor)
renderer.setClearColor(backgroundColor, backgroundColor.alpha);
this.cam.backgroundColor = backgroundColor;
this.cam.clearFlags = mainCam.clearFlags;
this.cam.nearClipPlane = mainCam.nearClipPlane;
this.cam.farClipPlane = mainCam.farClipPlane;
}
else
renderer.setClearColor(new Color(1, 1, 1));
renderer.setRenderTarget(null); // null: direct to Canvas
renderer.xr.enabled = false;
const cam = this.cam?.threeCamera;
this.context.updateAspect(cam);
const wasPresenting = renderer.xr.isPresenting;
renderer.xr.isPresenting = false;
renderer.setSize(this.context.domWidth, this.context.domHeight);
renderer.render(this.context.scene, cam);
renderer.xr.isPresenting = wasPresenting;
// restore previous settings so we can continue to render XR
renderer.xr.enabled = xrWasEnabled;
//renderer.setViewport(this.currentViewport);
//renderer.setScissor(this.currentScissor);
//renderer.setScissorTest(this.currentScissorTest);
if (previousRenderTarget)
renderer.setRenderTarget(previousRenderTarget);
else if (webglState.bindXRFramebuffer)
webglState.bindXRFramebuffer(oldFramebuffer);
this.resetAvatarFlags();
}
/**
* Updates avatar visibility flags for rendering in spectator mode
*/
setAvatarFlagsBeforeRender() {
const isFirstPersonMode = this._mode === SpectatorMode.FirstPerson;
for (const av of AvatarMarker.instances) {
if (av.avatar && "isLocalAvatar" in av.avatar && "flags" in av.avatar) {
let mask = XRStateFlag.All;
if (this.isSpectatingSelf)
mask = isFirstPersonMode && av.avatar.isLocalAvatar ? XRStateFlag.FirstPerson : XRStateFlag.ThirdPerson;
const flags = av.avatar.flags;
if (!flags)
continue;
for (const flag of flags) {
flag.UpdateVisible(mask);
}
}
}
}
/**
* Restores avatar visibility flags after spectator rendering
*/
resetAvatarFlags() {
for (const av of AvatarMarker.instances) {
if (av.avatar && "flags" in av.avatar) {
const flags = av.avatar.flags;
if (!flags)
continue;
for (const flag of flags) {
if ("isLocalAvatar" in av.avatar && av.avatar?.isLocalAvatar) {
flag.UpdateVisible(XRStateFlag.FirstPerson);
}
else {
flag.UpdateVisible(XRStateFlag.ThirdPerson);
}
}
}
}
}
}
__decorate([
serializable()
], SpectatorCamera.prototype, "useKeys", void 0);
/**
* Handles the smooth following behavior for the spectator camera
*/
class SpectatorHandler {
context;
cam;
spectator;
follow;
target;
view;
currentObject;
/** Gets the currently targeted player view */
get currentTarget() {
return this.view;
}
constructor(context, cam, spectator) {
this.context = context;
this.cam = cam;
this.spectator = spectator;
}
/**
* Sets the target player view to follow
* @param view The PlayerView to follow
*/
set(view) {
const followObject = view?.currentObject;
if (!followObject) {
this.spectator.stopSpectating();
return;
}
if (followObject === this.currentObject)
return;
this.currentObject = followObject;
this.view = view;
if (!this.follow)
this.follow = GameObject.addComponent(this.cam.gameObject, SmoothFollow);
if (!this.target)
this.target = new Object3D();
followObject.add(this.target);
this.follow.enabled = true;
this.follow.target = this.target;
// this.context.setCurrentCamera(this.cam);
if (debug)
console.log("FOLLOW", followObject);
if (!this.context.isInXR) {
this.context.setCurrentCamera(this.cam);
}
else
this.context.removeCamera(this.cam);
}
/** Disables the spectator following behavior */
disable() {
if (debug)
console.log("STOP FOLLOW", this.currentObject);
this.view = undefined;
this.currentObject = undefined;
this.context.removeCamera(this.cam);
if (this.follow)
this.follow.enabled = false;
}
/** Cleans up resources used by the handler */
destroy() {
this.target?.removeFromParent();
if (this.follow)
GameObject.destroy(this.follow);
}
/**
* Updates the camera position and orientation based on the spectator mode
* @param mode The current spectator mode (first or third person)
*/
update(mode) {
if (this.currentTarget?.isConnected === false || this.currentTarget?.removed === true) {
if (debug)
console.log("Target disconnected or timeout", this.currentTarget);
this.spectator.stopSpectating();
return;
}
if (this.currentTarget && this.currentTarget?.currentObject !== this.currentObject) {
if (debug)
console.log("Target changed", this.currentObject, "to", this.currentTarget.currentObject);
this.set(this.currentTarget);
}
const perspectiveCamera = this.context.mainCamera;
if (perspectiveCamera) {
const cam = this.cam.threeCamera;
if (cam.near !== perspectiveCamera.near || cam.far !== perspectiveCamera.far) {
cam.near = perspectiveCamera.near;
cam.far = perspectiveCamera.far;
cam.updateProjectionMatrix();
}
}
const target = this.follow?.target;
if (!target || !this.follow)
return;
switch (mode) {
case SpectatorMode.FirstPerson:
if (this.view?.viewDevice !== ViewDevice.Browser) {
// soft follow for AR and VR
this.follow.followFactor = 5;
this.follow.rotateFactor = 5;
}
else {
// snappy follow for desktop
this.follow.followFactor = 50;
this.follow.rotateFactor = 50;
}
target.position.set(0, 0, 0);
break;
case SpectatorMode.ThirdPerson:
this.follow.followFactor = 3;
this.follow.rotateFactor = 2;
target.position.set(0, .5, 1.5);
break;
}
this.follow.flipForward = false;
// console.log(this.view);
if (this.view?.viewDevice !== ViewDevice.Browser)
target.quaternion.copy(_inverseYQuat);
else
target.quaternion.identity();
}
}
const _inverseYQuat = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI);
/**
* Handles user input for selecting targets to spectate
*/
class SpectatorSelectionController {
context;
spectator;
constructor(context, spectator) {
this.context = context;
this.spectator = spectator;
console.log("[Spectator Camera] Click other avatars or cameras to follow them. Press ESC to exit spectator mode.");
this.context.domElement.addEventListener("keydown", (evt) => {
if (!this.spectator.useKeys)
return;
const key = evt.key;
if (key === "Escape") {
this.spectator.stopSpectating();
}
});
let downTime = 0;
this.context.input.addEventListener(InputEvents.PointerDown, _ => {
downTime = this.context.time.time;
});
this.context.input.addEventListener(InputEvents.PointerUp, _ => {
const dt = this.context.time.time - downTime;
if (dt > 1) {
this.spectator.stopSpectating();
}
else if (this.context.input.getPointerClicked(0) && dt < .3)
this.trySelectObject();
});
}
/**
* Attempts to select an avatar to spectate through raycasting
*/
trySelectObject() {
const opts = new RaycastOptions();
opts.setMask(0xffffff);
// opts.cam = this.spectator.cam?.cam;
const hits = this.context.physics.raycast(opts);
if (debug)
console.log(...hits);
if (hits?.length) {
for (const hit of hits) {
if (hit.distance < .2)
continue;
const obj = hit.object;
const avatar = GameObject.getComponentInParent(obj, AvatarMarker);
const id = avatar?.connectionId;
if (id) {
const view = this.context.players.getPlayerView(id);
this.spectator.target = view;
if (debug)
console.log("spectate", id, avatar);
break;
}
}
}
}
}
/**
* Network model for communicating follower changes
*/
class SpectatorFollowerChangedEventModel {
/** The user ID that is following */
guid;
dontSave = true;
/** The user ID being followed */
targetUserId;
/** Indicates if the user stopped following */
stoppedFollowing;
constructor(connectionId, userId, stoppedFollowing) {
this.guid = connectionId;
this.targetUserId = userId;
this.stoppedFollowing = stoppedFollowing;
}
}
/**
* Network model for requesting users to follow a specific player
*/
class SpectatorFollowEventModel {
guid;
userId;
constructor(comp, userId) {
this.guid = comp.guid;
this.userId = userId;
}
}
/**
* Handles network communication for spectator functionality
*/
class SpectatorCamNetworking {
/** List of user IDs currently following this player */
followers = [];
context;
spectator;
_followerEventMethod;
_requestFollowMethod;
_joinedRoomMethod;
constructor(context, spectator) {
this.context = context;
this.spectator = spectator;
this._followerEventMethod = this.onFollowerEvent.bind(this);
this._requestFollowMethod = this.onRequestFollowEvent.bind(this);
this._joinedRoomMethod = this.onUserJoinedRoom.bind(this);
}
/**
* Initializes network event listeners
*/
awake() {
this.context.connection.beginListen("spectator-follower-changed", this._followerEventMethod);
this.context.connection.beginListen("spectator-request-follow", this._requestFollowMethod);
this.context.connection.beginListen(RoomEvents.JoinedRoom, this._joinedRoomMethod);
this.context.domElement.addEventListener("keydown", evt => {
if (!this.spectator.useKeys)
return;
if (evt.key === "f") {
this.onRequestFollowMe();
}
else if (evt.key === "Escape") {
this.onRequestFollowMe(true);
}
});
}
/**
* Removes network event listeners
*/
destroy() {
this.context.connection.stopListen("spectator-follower-changed", this._followerEventMethod);
this.context.connection.stopListen("spectator-request-follow", this._requestFollowMethod);
this.context.connection.stopListen(RoomEvents.JoinedRoom, this._joinedRoomMethod);
}
/**
* Notifies other users about spectating target changes
* @param target The new target being spectated
* @param _prevId The previous target's user ID
*/
onSpectatedObjectChanged(target, _prevId) {
if (debug)
console.log(this.context.connection.connectionId, "onSpectatedObjectChanged", target, _prevId);
if (this.context.connection.connectionId) {
const stopped = target?.userId === undefined;
const userId = stopped ? _prevId : target?.userId;
const evt = new SpectatorFollowerChangedEventModel(this.context.connection.connectionId, userId, stopped);
this.context.connection.send("spectator-follower-changed", evt);
}
}
/**
* Requests other users to follow this player or stop following
* @param stop Whether to request users to stop following
*/
onRequestFollowMe(stop = false) {
if (debug)
console.log("Request follow", this.context.connection.connectionId);
if (this.context.connection.connectionId) {
this.spectator.stopSpectating();
const id = stop ? undefined : this.context.connection.connectionId;
const model = new SpectatorFollowEventModel(this.spectator, id);
this.context.connection.send("spectator-request-follow", model);
}
}
/**
* Handles room join events
*/
onUserJoinedRoom() {
if (getParam("followme")) {
this.onRequestFollowMe();
}
}
/**
* Processes follower status change events from the network
* @param evt The follower change event data
*/
onFollowerEvent(evt) {
const userBeingFollowed = evt.targetUserId;
const userThatIsFollowing = evt.guid;
if (debug)
console.log(evt);
if (userBeingFollowed === this.context.connection.connectionId) {
if (evt.stoppedFollowing) {
const index = this.followers.indexOf(userThatIsFollowing);
if (index !== -1) {
this.followers.splice(index, 1);
this.removeDisconnectedFollowers();
console.log(userThatIsFollowing, "unfollows you", this.followers.length);
}
}
else {
if (!this.followers.includes(userThatIsFollowing)) {
this.followers.push(userThatIsFollowing);
this.removeDisconnectedFollowers();
console.log(userThatIsFollowing, "follows you", this.followers.length);
}
}
}
}
/**
* Removes followers that are no longer connected to the room
*/
removeDisconnectedFollowers() {
for (let i = this.followers.length - 1; i >= 0; i--) {
const id = this.followers[i];
if (this.context.connection.userIsInRoom(id) === false) {
this.followers.splice(i, 1);
}
}
}
_lastRequestFollowUser;
/**
* Handles follow requests from other users
* @param evt The follow request event
* @returns True if the request was handled successfully
*/
onRequestFollowEvent(evt) {
this._lastRequestFollowUser = evt;
if (evt.userId === this.context.connection.connectionId) {
this.spectator.stopSpectating();
}
else if (evt.userId === undefined) {
// this will currently also stop spectating if the user is not following you
this.spectator.stopSpectating();
}
else {
const view = this.context.players.getPlayerView(evt.userId);
if (view) {
this.spectator.target = view;
}
else {
if (debug)
console.warn("Could not find view", evt.userId);
this.enforceFollow();
return false;
}
}
return true;
}
_enforceFollowInterval;
/**
* Periodically retries following a user if the initial attempt failed
*/
enforceFollow() {
if (this._enforceFollowInterval)
return;
this._enforceFollowInterval = setInterval(() => {
if (this._lastRequestFollowUser === undefined || this._lastRequestFollowUser.userId && this.spectator.isFollowedBy(this._lastRequestFollowUser.userId)) {
clearInterval(this._enforceFollowInterval);
this._enforceFollowInterval = undefined;
}
else {
if (debug)
console.log("REQUEST FOLLOW AGAIN", this._lastRequestFollowUser.userId);
this.onRequestFollowEvent(this._lastRequestFollowUser);
}
}, 1000);
}
}
//# sourceMappingURL=SpectatorCamera.js.map