UNPKG

@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

590 lines • 24 kB
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"; export var SpectatorMode; (function (SpectatorMode) { SpectatorMode[SpectatorMode["FirstPerson"] = 0] = "FirstPerson"; SpectatorMode[SpectatorMode["ThirdPerson"] = 1] = "ThirdPerson"; })(SpectatorMode || (SpectatorMode = {})); const debug = getParam("debugspectator"); /** * @category Networking * @group Components */ export class SpectatorCamera extends Behaviour { cam = null; /** when enabled pressing F will send a request to all connected users to follow me, ESC to stop */ useKeys = true; _mode = SpectatorMode.FirstPerson; get mode() { return this._mode; } set mode(val) { this._mode = val; } /** if this user is currently spectating someone else */ get isSpectating() { return this._handler?.currentTarget !== undefined; } isSpectatingUser(userId) { return this.target?.userId === userId; } isFollowedBy(userId) { return this.followers?.includes(userId); } /** list of other users that are following me */ get followers() { return this._networking.followers; } stopSpectating() { if (this.context.isInXR) { this.followSelf(); return; } this.target = undefined; } get localId() { return this.context.connection.connectionId ?? "local"; } /** player view to follow */ 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); } } } get target() { return this._handler?.currentTarget; } requestAllFollowMe() { this._networking.onRequestFollowMe(); } 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(); } 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; } onBeforeXR(_evt) { if (!this.isSupportedPlatform()) return; GameObject.setActive(this.gameObject, true); } onEnterXR(_evt) { if (!this.isSupportedPlatform()) return; if (debug) console.log(this.context.mainCamera); if (this.context.mainCamera) { this.followSelf(); } } 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(); } 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 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(); } 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); } } } } 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); class SpectatorHandler { context; cam; spectator; follow; target; view; currentObject; get currentTarget() { return this.view; } constructor(context, cam, spectator) { this.context = context; this.cam = cam; this.spectator = spectator; } 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); } 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; } destroy() { this.target?.removeFromParent(); if (this.follow) GameObject.destroy(this.follow); } 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); 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(); }); } 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; } } } } } class SpectatorFollowerChangedEventModel { /** the user that is following */ guid; dontSave = true; /** the user being followed */ targetUserId; stoppedFollowing; constructor(connectionId, userId, stoppedFollowing) { this.guid = connectionId; this.targetUserId = userId; this.stoppedFollowing = stoppedFollowing; } } class SpectatorFollowEventModel { guid; userId; constructor(comp, userId) { this.guid = comp.guid; this.userId = userId; } } class SpectatorCamNetworking { 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); } 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); } }); } 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); } 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); } } 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); } } onUserJoinedRoom() { if (getParam("followme")) { this.onRequestFollowMe(); } } 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); } } } } 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; 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; 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