diff --git a/admin/package-lock.json b/admin/package-lock.json index 704009ff96..5e1bd39f79 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -1504,7 +1504,7 @@ "dev": true }, "aframe": { - "version": "github:mozillareality/aframe#deba2d4eedda664bb7d87bf4b3c9fcf25f5298e1", + "version": "github:mozillareality/aframe#3301e4b276c8516b4e74afe45412c984ed6d6cac", "from": "github:mozillareality/aframe#hubs/master", "requires": { "custom-event-polyfill": "^1.0.6", @@ -18705,10 +18705,10 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "networked-aframe": { - "version": "github:mozillareality/networked-aframe#50184f495ff1f77d0e6fe3777d73092784e3aee3", + "version": "github:mozillareality/networked-aframe#25198c32d618adc5f887e70b1c4e8cb5f3d3e047", "from": "github:mozillareality/networked-aframe#master", "requires": { - "buffered-interpolation": "^0.2.5" + "buffered-interpolation": "github:Infinitelee/buffered-interpolation#5bb18421ebf2bf11664645cdc7a15bd77ee2156b" } }, "new-array": { diff --git a/package-lock.json b/package-lock.json index a4dcc3472e..3422229275 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15829,7 +15829,7 @@ "dev": true }, "aframe": { - "version": "github:mozillareality/aframe#2cd12e184bbf7e1ff7d2100d66064f50403405c1", + "version": "github:mozillareality/aframe#3301e4b276c8516b4e74afe45412c984ed6d6cac", "from": "github:mozillareality/aframe#hubs/master", "requires": { "custom-event-polyfill": "^1.0.6", @@ -19021,6 +19021,11 @@ "integrity": "sha1-6C5D6OsXBkaB5D+cjbxzHjF9GJI=", "dev": true }, + "bitecs": { + "version": "0.3.38", + "resolved": "https://registry.npmjs.org/bitecs/-/bitecs-0.3.38.tgz", + "integrity": "sha512-vfz6wjPElg/0O7c06Ttb/BeWMInBspSuekRvNoLqPyEZV87tLWipxqpjtGPKZBhin8jv4OuQkrkZZfZpaqOndQ==" + }, "bluebird": { "version": "3.5.1", "resolved": "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz", @@ -27689,7 +27694,7 @@ "dev": true }, "networked-aframe": { - "version": "github:mozillareality/networked-aframe#2c3c394e3f12a7d2cc68b193c0f7c94d709a46e8", + "version": "github:mozillareality/networked-aframe#25198c32d618adc5f887e70b1c4e8cb5f3d3e047", "from": "github:mozillareality/networked-aframe#master", "requires": { "buffered-interpolation": "github:Infinitelee/buffered-interpolation#5bb18421ebf2bf11664645cdc7a15bd77ee2156b" diff --git a/package.json b/package.json index be494e3494..dc6200c193 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "ammo-debug-drawer": "github:infinitelee/ammo-debug-drawer", "ammo.js": "github:mozillareality/ammo.js#hubs/master", "animejs": "github:mozillareality/anime#hubs/master", + "bitecs": "^0.3.38", "buffered-interpolation": "github:Infinitelee/buffered-interpolation", "classnames": "^2.2.5", "color": "^3.1.2", diff --git a/src/App.js b/src/App.js index de56893bcc..004a3ae2b6 100644 --- a/src/App.js +++ b/src/App.js @@ -1,7 +1,24 @@ -import Store from "./storage/store"; +import * as bitecs from "bitecs"; +import { addEntity, createWorld } from "bitecs"; +import "./aframe-to-bit-components"; +import { AEntity, Networked, Object3DTag, Owned } from "./bit-components"; import MediaSearchStore from "./storage/media-search-store"; +import Store from "./storage/store"; import qsTruthy from "./utils/qs_truthy"; +window.$B = bitecs; + +const timeSystem = world => { + const { time } = world; + const now = performance.now(); + const delta = now - time.then; + time.delta = delta; + time.elapsed += delta; + time.then = now; + time.tick++; + return world; +}; + export class App { constructor() { this.scene = null; @@ -23,6 +40,50 @@ export class App { this.clippingState = new Set(); this.mutedState = new Set(); this.isAudioPaused = new Set(); + + this.world = createWorld(); + + // TODO: Create accessor / update methods for these maps / set + this.world.eid2obj = new Map(); // eid -> Object3D + + this.world.nid2eid = new Map(); + this.world.deletedNids = new Set(); + this.world.ignoredNids = new Set(); + + this.str2sid = new Map([[null, 0]]); + this.sid2str = new Map([[0, null]]); + this.nextSid = 1; + + window.$o = eid => { + this.world.eid2obj.get(eid); + }; + + // reserve entity 0 to avoid needing to check for undefined everywhere eid is checked for existance + addEntity(this.world); + + // used in aframe and networked aframe to avoid imports + this.world.nameToComponent = { + object3d: Object3DTag, + networked: Networked, + owned: Owned, + AEntity + }; + } + + // TODO nothing ever cleans these up + getSid(str) { + if (!this.str2sid.has(str)) { + const sid = this.nextSid; + this.nextSid = this.nextSid + 1; + this.str2sid.set(str, sid); + this.sid2str.set(sid, str); + return sid; + } + return this.str2sid.get(str); + } + + getString(sid) { + return this.sid2str.get(sid); } // This gets called by a-scene to setup the renderer, camera, and audio listener @@ -66,6 +127,7 @@ export class App { const camera = new THREE.PerspectiveCamera(80, window.innerWidth / window.innerHeight, 0.05, 10000); const audioListener = new THREE.AudioListener(); + APP.audioListener = audioListener; camera.add(audioListener); const renderClock = new THREE.Clock(); @@ -73,6 +135,16 @@ export class App { // TODO NAF currently depends on this, it should not sceneEl.clock = renderClock; + // TODO we should have 1 source of truth for time + APP.world.time = { + delta: 0, + elapsed: 0, + then: performance.now(), + tick: 0 + }; + + APP.world.scene = sceneEl.object3D; + // Main RAF loop function mainTick(_rafTime, xrFrame) { // TODO we should probably be using time from the raf loop itself @@ -82,6 +154,8 @@ export class App { // TODO pass this into systems that care about it (like input) once they are moved into this loop sceneEl.frame = xrFrame; + timeSystem(APP.world); + // Tick AFrame systems and components if (sceneEl.isPlaying) { sceneEl.tick(time, delta); diff --git a/src/aframe-to-bit-components.js b/src/aframe-to-bit-components.js new file mode 100644 index 0000000000..92ba10decb --- /dev/null +++ b/src/aframe-to-bit-components.js @@ -0,0 +1,31 @@ +import { addComponent, removeComponent } from "bitecs"; +import { + RemoteRight, + RemoteLeft, + HandRight, + HandLeft, + RemoteHoverTarget, + NotRemoteHoverTarget, + RemoveNetworkedEntityButton, + DestroyAtExtremeDistance +} from "./bit-components"; + +[ + ["remote-right", RemoteRight], + ["remote-left", RemoteLeft], + ["hand-right", HandRight], + ["hand-left", HandLeft], + ["is-remote-hover-target", RemoteHoverTarget], + ["is-not-remote-hover-target", NotRemoteHoverTarget], + ["remove-networked-object-button", RemoveNetworkedEntityButton], + ["destroy-at-extreme-distances", DestroyAtExtremeDistance] +].forEach(([aframeComponentName, bitecsComponent]) => { + AFRAME.registerComponent(aframeComponentName, { + init: function() { + addComponent(APP.world, bitecsComponent, this.el.object3D.eid); + }, + remove: function() { + removeComponent(APP.world, bitecsComponent, this.el.object3D.eid); + } + }); +}); diff --git a/src/bit-components.js b/src/bit-components.js new file mode 100644 index 0000000000..30b703c039 --- /dev/null +++ b/src/bit-components.js @@ -0,0 +1,121 @@ +import { defineComponent, setDefaultSize, Types } from "bitecs"; + +// TODO this has to happen before all components are defined. Is there a better spot to be doing this? +setDefaultSize(10000); + +export const $isStringType = Symbol("isStringType"); + +export const Networked = defineComponent({ + id: Types.ui32, + creator: Types.ui32, + owner: Types.ui32, + + lastOwnerTime: Types.ui32 +}); +Networked.id[$isStringType] = true; +Networked.creator[$isStringType] = true; +Networked.owner[$isStringType] = true; + +export const Owned = defineComponent(); +export const NetworkedMediaFrame = defineComponent({ + capturedNid: Types.ui32, + scale: [Types.f32, 3] +}); +NetworkedMediaFrame.capturedNid[$isStringType] = true; + +export const MediaFrame = defineComponent({ + capturedNid: Types.ui32, + scale: [Types.f32, 3], + mediaType: Types.ui8, + bounds: [Types.f32, 3], + preview: Types.eid, + previewingNid: Types.eid +}); +export const Text = defineComponent(); +export const Slice9 = defineComponent({ + insets: [Types.ui32, 4], + size: [Types.f32, 2] +}); + +export const NetworkedTransform = defineComponent({ + position: [Types.f32, 3], + rotation: [Types.f32, 4], + scale: [Types.f32, 3] +}); + +export const AEntity = defineComponent(); +export const Object3DTag = defineComponent(); +export const GLTFModel = defineComponent(); +export const CursorRaycastable = defineComponent(); +export const RemoteHoverTarget = defineComponent(); +export const NotRemoteHoverTarget = defineComponent(); +export const Holdable = defineComponent(); +export const RemoveNetworkedEntityButton = defineComponent(); +export const Interacted = defineComponent(); + +export const HandRight = defineComponent(); +export const HandLeft = defineComponent(); +export const RemoteRight = defineComponent(); +export const RemoteLeft = defineComponent(); +export const HoveredHandRight = defineComponent(); +export const HoveredHandLeft = defineComponent(); +export const HoveredRemoteRight = defineComponent(); +export const HoveredRemoteLeft = defineComponent(); +export const HeldHandRight = defineComponent(); +export const HeldHandLeft = defineComponent(); +export const HeldRemoteRight = defineComponent(); +export const HeldRemoteLeft = defineComponent(); +export const Held = defineComponent(); +export const OffersRemoteConstraint = defineComponent(); +export const HandCollisionTarget = defineComponent(); +export const OffersHandConstraint = defineComponent(); +export const TogglesHoveredActionSet = defineComponent(); + +export const HoverButton = defineComponent({ type: Types.ui8 }); +export const TextButton = defineComponent({ labelRef: Types.eid }); +export const HoldableButton = defineComponent(); +export const SingleActionButton = defineComponent(); + +export const Pen = defineComponent(); +export const HoverMenuChild = defineComponent(); +export const Static = defineComponent(); +export const Inspectable = defineComponent(); +export const PreventAudioBoost = defineComponent(); +export const IgnoreSpaceBubble = defineComponent(); +export const Rigidbody = defineComponent({ + bodyId: Types.ui16, + collisionGroup: Types.ui32, + collisionMask: Types.ui32, + flags: Types.ui8, + gravity: Types.f32 +}); +export const PhysicsShape = defineComponent({ bodyId: Types.ui16, shapeId: Types.ui16, halfExtents: [Types.f32, 3] }); +export const Pinnable = defineComponent(); +export const Pinned = defineComponent(); +export const DestroyAtExtremeDistance = defineComponent(); + +export const MediaLoading = defineComponent(); + +export const FloatyObject = defineComponent({ flags: Types.ui8, releaseGravity: Types.f32 }); +export const MakeKinematicOnRelease = defineComponent(); + +export const CameraTool = defineComponent({ + snapTime: Types.f32, + state: Types.ui8, + captureDurIdx: Types.ui8, + trackTarget: Types.eid, + + snapMenuRef: Types.eid, + button_next: Types.eid, + button_prev: Types.eid, + snapRef: Types.eid, + cancelRef: Types.eid, + recVideoRef: Types.eid, + screenRef: Types.eid, + selfieScreenRef: Types.eid, + cameraRef: Types.eid, + countdownLblRef: Types.eid, + captureDurLblRef: Types.eid, + sndToggleRef: Types.eid +}); +export const MyCameraTool = defineComponent(); diff --git a/src/bit-systems/camera-tool.js b/src/bit-systems/camera-tool.js new file mode 100644 index 0000000000..2c8a84edf8 --- /dev/null +++ b/src/bit-systems/camera-tool.js @@ -0,0 +1,432 @@ +import { defineQuery, enterQuery, entityExists, exitQuery, hasComponent } from "bitecs"; +import { + CameraTool, + Held, + HeldHandLeft, + HeldHandRight, + HeldRemoteLeft, + HeldRemoteRight, + HoveredRemoteRight, + Interacted, + RemoteRight, + Rigidbody, + TextButton +} from "../bit-components"; +import { addMedia } from "../utils/media-utils"; +import { pixelsToPNG, RenderTargetRecorder } from "../utils/render-target-recorder"; +import { isFacingCamera } from "../utils/three-utils"; +import { SOUND_CAMERA_TOOL_COUNTDOWN, SOUND_CAMERA_TOOL_TOOK_SNAPSHOT } from "../systems/sound-effects-system"; +import { paths } from "../systems/userinput/paths"; +import { ObjectTypes } from "../object-types"; +import { anyEntityWith } from "../utils/bit-utils"; + +// Prefer h264 if available due to faster decoding speec on most platforms +const videoCodec = ["h264", "vp9,opus", "vp8,opus", "vp9", "vp8"].find( + codec => window.MediaRecorder && MediaRecorder.isTypeSupported(`video/webm; codecs=${codec}`) +); +const videoMimeType = videoCodec ? `video/webm; codecs=${videoCodec}` : null; +const hasWebGL2 = !!document.createElement("canvas").getContext("webgl2"); +const allowVideo = !!videoMimeType && hasWebGL2; + +const RENDER_WIDTH = 1280; +const RENDER_HEIGHT = 720; + +const isMobileVR = AFRAME.utils.device.isMobileVR(); +const isOculusBrowser = navigator.userAgent.match(/Oculus/); +// TODO ported from old camera system. Do we still want these restrictions? +const CAPTURE_WIDTH = isMobileVR && !isOculusBrowser ? 640 : 1280; +const CAPTURE_HEIGHT = isMobileVR && !isOculusBrowser ? 360 : 720; + +const VIDEO_FPS = 25; +const VIEWFINDER_UPDATE_RATE = 1000 / 6; +const VIDEO_UPDATE_RATE = 1000 / VIDEO_FPS; + +const CAMERA_STATE = { + IDLE: 0, + COUNTDOWN_PHOTO: 1, + COUNTDOWN_VIDEO: 2, + SNAP_PHOTO: 3, + RECORDING_VIDEO: 4 +}; + +const CAPTURE_DURATIONS = [3, 7, 15, 30, 60]; + +const renderTargets = new Map(); +const videoRecorders = new Map(); +let captureAudio = false; + +const tmpVec3 = new THREE.Vector3(); + +function clicked(eid) { + return hasComponent(APP.world, Interacted, eid); +} + +function grabberPressedSnapAction(world, camera) { + const userinput = AFRAME.scenes[0].systems.userinput; + return ( + (hasComponent(world, HeldRemoteLeft, camera) && userinput.get(paths.actions.cursor.left.takeSnapshot)) || + (hasComponent(world, HeldRemoteRight, camera) && userinput.get(paths.actions.cursor.right.takeSnapshot)) || + (hasComponent(world, HeldHandLeft, camera) && userinput.get(paths.actions.leftHand.takeSnapshot)) || + (hasComponent(world, HeldHandRight, camera) && userinput.get(paths.actions.rightHand.takeSnapshot)) + // userinput.get(paths.actions.takeSnapshot) + ); +} + +function spawnCameraFile(cameraObj, file, type) { + const opts = type === "video" ? { videoPaused: true } : {}; + const { entity } = addMedia(file, "#interactable-media", undefined, `${type}-camera`, false, false, true, opts); + entity.addEventListener( + "media_resolved", + () => { + AFRAME.scenes[0].systems["hubs-systems"].soundEffectsSystem.playSoundOneShot(SOUND_CAMERA_TOOL_TOOK_SNAPSHOT); + // TODO animate and orient around camera + entity.object3D.position.copy(cameraObj.localToWorld(new THREE.Vector3(0, -0.5, 0))); + entity.object3D.quaternion.copy(cameraObj.quaternion); + entity.object3D.matrixNeedsUpdate = true; + APP.hubChannel.sendMessage({ src: entity.components["media-loader"].data.src }, type); + APP.hubChannel.sendObjectSpawnedEvent(ObjectTypes.CAMERA); + }, + { once: true } + ); +} + +function createRecorder(captureAudio) { + let srcAudioTrack; + // NOTE: if we don't have a self audio track, we can end up generating an empty video (browser bug?) + // if no audio comes through on the listener source. (Eg the room is otherwise silent.) + // So for now, if we don't have a track, just disable audio capture. + if (captureAudio && APP.dialog._micProducer?.track) { + const context = THREE.AudioContext.getContext(); + const destination = context.createMediaStreamDestination(); + if (APP.audioListener) { + // NOTE audio is not captured from camera vantage point for now. + APP.audioListener.getInput().connect(destination); + } + context.createMediaStreamSource(new MediaStream([APP.dialog._micProducer?.track])).connect(destination); + srcAudioTrack = destination.stream.getAudioTracks()[0]; + } + + return new RenderTargetRecorder( + AFRAME.scenes[0].renderer, + videoMimeType, + CAPTURE_WIDTH, + CAPTURE_HEIGHT, + VIDEO_FPS, + srcAudioTrack + ); +} + +function endRecording(world, camera, cancel) { + const recorder = videoRecorders.get(camera); + if (cancel) { + recorder.cancel(); + } else { + recorder.save().then(file => { + spawnCameraFile(world.eid2obj.get(camera), file, "video"); + }); + } + videoRecorders.delete(camera); + APP.hubChannel.endRecording(); +} + +function updateRenderTarget(world, camera) { + const sceneEl = AFRAME.scenes[0]; + const renderer = AFRAME.scenes[0].renderer; + + const tmpVRFlag = renderer.xr.enabled; + renderer.xr.enabled = false; + + // TODO we are doing this because aframe uses this hook for tock. + // Namely to capture what camera was rendering. We don't actually use that in any of our tocks. + // Also tock can likely go away as a concept since we can just direclty order things after render in raf if we want to. + const tmpOnAfterRender = sceneEl.object3D.onAfterRender; + delete sceneEl.object3D.onAfterRender; + + // TODO this assumption is now not true since we are not running after render. We should probably just permentently turn of autoUpdate and run matrix updates at a point we wnat to. + // The entire scene graph matrices should already be updated + // in tick(). They don't need to be recomputed again in tock(). + const tmpAutoUpdate = sceneEl.object3D.autoUpdate; + sceneEl.object3D.autoUpdate = false; + + const bubbleSystem = AFRAME.scenes[0].systems["personal-space-bubble"]; + const boneVisibilitySystem = AFRAME.scenes[0].systems["hubs-systems"].boneVisibilitySystem; + + if (bubbleSystem) { + for (let i = 0, l = bubbleSystem.invaders.length; i < l; i++) { + bubbleSystem.invaders[i].disable(); + } + // HACK, bone visibility typically takes a tick to update, but since we want to be able + // to have enable() and disable() be reflected this frame, we need to do it immediately. + boneVisibilitySystem.tick(); + // scene.autoUpdate will be false so explicitly update the world matrices + boneVisibilitySystem.updateMatrices(); + } + + const renderTarget = renderTargets.get(camera); + renderTarget.needsUpdate = false; + renderTarget.lastUpdated = world.time.elapsed; + + renderer.setRenderTarget(renderTarget); + renderer.render(sceneEl.object3D, world.eid2obj.get(CameraTool.cameraRef[camera])); + renderer.setRenderTarget(null); + + renderer.xr.enabled = tmpVRFlag; + sceneEl.object3D.onAfterRender = tmpOnAfterRender; + sceneEl.object3D.autoUpdate = tmpAutoUpdate; + + if (bubbleSystem) { + for (let i = 0, l = bubbleSystem.invaders.length; i < l; i++) { + bubbleSystem.invaders[i].enable(); + } + // HACK, bone visibility typically takes a tick to update, but since we want to be able + // to have enable() and disable() be reflected this frame, we need to do it immediately. + boneVisibilitySystem.tick(); + boneVisibilitySystem.updateMatrices(); + } +} + +function updateUI(world, camera) { + const snapMenuObj = world.eid2obj.get(CameraTool.snapMenuRef[camera]); + const snapBtnObj = world.eid2obj.get(CameraTool.snapRef[camera]); + const recBtnObj = world.eid2obj.get(CameraTool.recVideoRef[camera]); + const cancelBtnObj = world.eid2obj.get(CameraTool.cancelRef[camera]); + const nextBtnObj = world.eid2obj.get(CameraTool.button_next[camera]); + const prevBtnObj = world.eid2obj.get(CameraTool.button_prev[camera]); + const countdownLblObj = world.eid2obj.get(CameraTool.countdownLblRef[camera]); + const captureDurLblObj = world.eid2obj.get(CameraTool.captureDurLblRef[camera]); + const screenObj = world.eid2obj.get(CameraTool.screenRef[camera]); + const selfieScreenObj = world.eid2obj.get(CameraTool.selfieScreenRef[camera]); + + const sndToggleBtnObj = world.eid2obj.get(CameraTool.sndToggleRef[camera]); + const sndToggleLblObj = world.eid2obj.get(TextButton.labelRef[CameraTool.sndToggleRef[camera]]); + + const isIdle = CameraTool.state[camera] === CAMERA_STATE.IDLE; + const isCounting = + CameraTool.state[camera] === CAMERA_STATE.COUNTDOWN_PHOTO || + CameraTool.state[camera] === CAMERA_STATE.COUNTDOWN_VIDEO || + CameraTool.state[camera] === CAMERA_STATE.RECORDING_VIDEO; + + const inVR = AFRAME.scenes[0].is("vr-mode"); + const showViewfinder = !inVR || (hasComponent(world, Held, camera) || !isIdle); + screenObj.visible = showViewfinder; + selfieScreenObj.visible = showViewfinder; + + const playerInFrontOfCamera = isFacingCamera(world.eid2obj.get(camera)); + + const yRot = playerInFrontOfCamera ? 0 : Math.PI; + if (snapMenuObj.rotation.y !== yRot) { + snapMenuObj.rotation.y = yRot; + snapMenuObj.matrixNeedsUpdate = true; + } + + snapBtnObj.visible = isIdle; + + recBtnObj.visible = allowVideo && isIdle; + captureDurLblObj.visible = allowVideo && isIdle; + nextBtnObj.visible = allowVideo && isIdle; + prevBtnObj.visible = allowVideo && isIdle; + sndToggleBtnObj.visible = allowVideo && isIdle; + + cancelBtnObj.visible = isCounting; + countdownLblObj.visible = isCounting; + + if (countdownLblObj.visible) { + const timeLeftSec = Math.ceil((CameraTool.snapTime[camera] - world.time.elapsed) / 1000); + countdownLblObj.text = timeLeftSec; + countdownLblObj.sync(); // TODO this should probably happen in 1 spot per frame for all Texts + } + + if (captureDurLblObj.visible) { + captureDurLblObj.text = CAPTURE_DURATIONS[CameraTool.captureDurIdx[camera]]; + captureDurLblObj.sync(); // TODO this should probably happen in 1 spot per frame for all Texts + } + + if (sndToggleBtnObj.visible) { + sndToggleLblObj.text = captureAudio ? "Sound ON" : "Sound OFF"; + sndToggleLblObj.sync(); + } + + // TODO HACK hidden objects are still not having their matricies updated correctly + // Seems like a regression of #5421 + snapMenuObj.matrixNeedsUpdate = true; +} + +let snapPixels; +function captureSnapshot(world, camera) { + if (!snapPixels) { + snapPixels = new Uint8Array(RENDER_WIDTH * RENDER_HEIGHT * 4); + } + const renderer = AFRAME.scenes[0].renderer; + renderer.readRenderTargetPixels(renderTargets.get(camera), 0, 0, RENDER_WIDTH, RENDER_HEIGHT, snapPixels); + pixelsToPNG(snapPixels, RENDER_WIDTH, RENDER_HEIGHT).then(file => { + spawnCameraFile(world.eid2obj.get(camera), file, "photo"); + }); +} + +// TODO this should be its own thing, not hardcoded to camera tools or mouse bindings +function rotateWithRightClick(world, camera) { + const userinput = AFRAME.scenes[0].systems.userinput; + const transformSystem = AFRAME.scenes[0].systems["transform-selected-object"]; + const physicsSystem = AFRAME.scenes[0].systems["hubs-systems"].physicsSystem; + if ( + !transformSystem.transforming && + hasComponent(world, HoveredRemoteRight, camera) && + userinput.get(paths.device.mouse.buttonRight) + ) { + const rightCursor = anyEntityWith(world, RemoteRight); + physicsSystem.updateBodyOptions(Rigidbody.bodyId[camera], { type: "kinematic" }); + transformSystem.startTransform(world.eid2obj.get(camera), world.eid2obj.get(rightCursor), { + mode: "cursor" + }); + } else if (transformSystem.target?.eid === camera && !userinput.get(paths.device.mouse.buttonRight)) { + transformSystem.stopTransform(); + } +} + +const cameraToolQuery = defineQuery([CameraTool]); +const cameraToolEnterQuery = enterQuery(cameraToolQuery); +const cameraToolExitQuery = exitQuery(cameraToolQuery); + +export function cameraToolSystem(world) { + cameraToolEnterQuery(world).forEach(function(eid) { + const renderTarget = new THREE.WebGLRenderTarget(RENDER_WIDTH, RENDER_HEIGHT, { + format: THREE.RGBAFormat, + minFilter: THREE.LinearFilter, + magFilter: THREE.NearestFilter, + encoding: THREE.sRGBEncoding, + depth: false, + stencil: false + }); + renderTarget.lastUpdated = 0; + renderTarget.needsUpdate = true; + + // Only update the renderTarget when the screens are in view + function setRendertargetDirty() { + renderTarget.needsUpdate = true; + } + + const screenObj = world.eid2obj.get(CameraTool.screenRef[eid]); + screenObj.material.map = renderTarget.texture; + screenObj.onBeforeRender = setRendertargetDirty; + + const selfieScreenObj = world.eid2obj.get(CameraTool.selfieScreenRef[eid]); + selfieScreenObj.material.map = renderTarget.texture; + selfieScreenObj.onBeforeRender = setRendertargetDirty; + + renderTargets.set(eid, renderTarget); + }); + + cameraToolExitQuery(world).forEach(function(eid) { + const renderTarget = renderTargets.get(eid); + renderTarget.dispose(); + renderTargets.delete(eid); + + const screenObj = world.eid2obj.get(CameraTool.screenRef[eid]); + screenObj.geometry.dispose(); + screenObj.material.dispose(); + }); + + cameraToolQuery(world).forEach((camera, i, allCameras) => { + rotateWithRightClick(world, camera); + + if (CameraTool.trackTarget[camera]) { + if (entityExists(world, CameraTool.trackTarget[camera])) { + world.eid2obj.get(CameraTool.trackTarget[camera]).getWorldPosition(tmpVec3); + world.eid2obj.get(camera).lookAt(tmpVec3); + } else { + CameraTool.trackTarget[camera] = 0; + } + } + + if (CameraTool.state[camera] === CAMERA_STATE.IDLE) { + if (clicked(CameraTool.snapRef[camera]) || grabberPressedSnapAction(world, camera)) { + CameraTool.state[camera] = CAMERA_STATE.COUNTDOWN_PHOTO; + CameraTool.snapTime[camera] = world.time.elapsed + 3000; + } + + if (clicked(CameraTool.recVideoRef[camera])) { + CameraTool.state[camera] = CAMERA_STATE.COUNTDOWN_VIDEO; + CameraTool.snapTime[camera] = world.time.elapsed + 3000; + } + + if (clicked(CameraTool.button_next[camera])) { + CameraTool.captureDurIdx[camera] = (CameraTool.captureDurIdx[camera] + 1) % CAPTURE_DURATIONS.length; + } + + if (clicked(CameraTool.button_prev[camera])) { + CameraTool.captureDurIdx[camera] = + CameraTool.captureDurIdx[camera] === 0 ? CAPTURE_DURATIONS.length - 1 : CameraTool.captureDurIdx[camera] - 1; + } + + if (clicked(CameraTool.sndToggleRef[camera])) { + captureAudio = !captureAudio; + } + } + + if ( + CameraTool.state[camera] === CAMERA_STATE.COUNTDOWN_PHOTO || + CameraTool.state[camera] === CAMERA_STATE.COUNTDOWN_VIDEO + ) { + if (clicked(CameraTool.cancelRef[camera])) { + CameraTool.state[camera] = CAMERA_STATE.IDLE; + } else if (world.time.elapsed >= CameraTool.snapTime[camera]) { + if (CameraTool.state[camera] === CAMERA_STATE.COUNTDOWN_VIDEO) { + CameraTool.snapTime[camera] = world.time.elapsed + CAPTURE_DURATIONS[CameraTool.captureDurIdx[camera]] * 1000; + CameraTool.state[camera] = CAMERA_STATE.RECORDING_VIDEO; + + const recorder = createRecorder(captureAudio); + videoRecorders.set(camera, recorder); + recorder.start(); + + APP.hubChannel.beginRecording(); + } else { + CameraTool.state[camera] = CAMERA_STATE.SNAP_PHOTO; + } + } else { + // Floating point imprecision: Add epsilon to elapsed time so that the countdown sound always plays on the same frame that we click + const elapsed = world.time.elapsed + 0.01; + const msRemaining = CameraTool.snapTime[camera] - elapsed; + const msRemainingLastFrame = CameraTool.snapTime[camera] - (elapsed - world.time.delta); + if (Math.floor(msRemaining / 1000) !== Math.floor(msRemainingLastFrame / 1000)) { + AFRAME.scenes[0].systems["hubs-systems"].soundEffectsSystem.playSoundOneShot(SOUND_CAMERA_TOOL_COUNTDOWN); + } + } + } + + // TODO we previously did this in tock() since we wanted to run it late in the frame + // We actually want to run this before the normal scene render otherwise the camera view is a frame behind. + // This is not really a big deal since we also run the camera at a lower FPS anyway + const renderTarget = renderTargets.get(camera); + const elapsed = world.time.elapsed; + + // Update render target when taking a photo, recording a video, or round robbin for cameras in view + if ( + CameraTool.state[camera] === CAMERA_STATE.SNAP_PHOTO || + (CameraTool.state[camera] === CAMERA_STATE.RECORDING_VIDEO && + elapsed > renderTarget.lastUpdated + VIDEO_UPDATE_RATE) || + (renderTarget.needsUpdate && + world.time.tick % allCameras.length === i && + elapsed > renderTarget.lastUpdated + VIEWFINDER_UPDATE_RATE) + ) { + updateRenderTarget(world, camera); + if (CameraTool.state[camera] === CAMERA_STATE.RECORDING_VIDEO) { + videoRecorders.get(camera).captureFrame(renderTargets.get(camera)); + } + } + + if (CameraTool.state[camera] === CAMERA_STATE.SNAP_PHOTO) { + captureSnapshot(world, camera); + CameraTool.state[camera] = CAMERA_STATE.IDLE; + } else if (CameraTool.state[camera] === CAMERA_STATE.RECORDING_VIDEO) { + if (clicked(CameraTool.cancelRef[camera])) { + endRecording(world, camera, true); + CameraTool.state[camera] = CAMERA_STATE.IDLE; + } else if (world.time.elapsed >= CameraTool.snapTime[camera]) { + endRecording(world, camera, false); + CameraTool.state[camera] = CAMERA_STATE.IDLE; + } + } + + updateUI(world, camera); + }); +} diff --git a/src/components/avatar-inspect-collider.js b/src/components/avatar-inspect-collider.js index 49728e8838..d24fe48fa6 100644 --- a/src/components/avatar-inspect-collider.js +++ b/src/components/avatar-inspect-collider.js @@ -5,11 +5,7 @@ AFRAME.registerComponent("avatar-inspect-collider", { new THREE.Mesh( new THREE.BoxGeometry(0.3, 0.8, 0.3), new THREE.MeshBasicMaterial({ - depthWrite: false, - opacity: 0, - transparent: true, - color: 0x020202, - side: THREE.BackSide + visible: false }) ) ); diff --git a/src/components/billboard.js b/src/components/billboard.js index a03be5df36..fcfaee7787 100644 --- a/src/components/billboard.js +++ b/src/components/billboard.js @@ -1,6 +1,7 @@ const isMobileVR = AFRAME.utils.device.isMobileVR(); // Billboard component that only updates visible objects and only those in the camera view on mobile VR. +// TODO billboarding assumes a single camera viewpoint but with video-texture-source, mirrors, and camera tools this is no longer valid AFRAME.registerComponent("billboard", { schema: { onlyY: { type: "boolean" } @@ -65,17 +66,6 @@ AFRAME.registerComponent("billboard", { if (!this.playerCamera) return; this.isInView = this.el.sceneEl.is("vr-mode") ? true : isInViewOfCamera(this.el.object3D, this.playerCamera); - - if (!this.isInView) { - // Check in-game camera if rendering to viewfinder and owned - const cameraTools = this.el.sceneEl.systems["camera-tools"]; - - if (cameraTools) { - cameraTools.ifMyCameraRenderingViewfinder(cameraTool => { - this.isInView = this.isInView || isInViewOfCamera(this.el.object3D, cameraTool.camera); - }); - } - } }; })(), diff --git a/src/components/body-helper.js b/src/components/body-helper.js index 50c988553d..c1c53f387e 100644 --- a/src/components/body-helper.js +++ b/src/components/body-helper.js @@ -1,8 +1,10 @@ +import { addComponent, removeComponent } from "bitecs"; import { CONSTANTS } from "three-ammo"; +import { Rigidbody } from "../bit-components"; const ACTIVATION_STATE = CONSTANTS.ACTIVATION_STATE, TYPE = CONSTANTS.TYPE; -const ACTIVATION_STATES = [ +export const ACTIVATION_STATES = [ ACTIVATION_STATE.ACTIVE_TAG, ACTIVATION_STATE.ISLAND_SLEEPING, ACTIVATION_STATE.WANTS_DEACTIVATION, @@ -42,6 +44,9 @@ AFRAME.registerComponent("body-helper", { init2: function() { this.el.object3D.updateMatrices(); this.uuid = this.system.addBody(this.el.object3D, this.data); + const eid = this.el.object3D.eid; + addComponent(APP.world, Rigidbody, eid); + Rigidbody.bodyId[eid] = this.uuid; //uuid is a lie, it's actually an int }, update: function(prevData) { @@ -53,6 +58,8 @@ AFRAME.registerComponent("body-helper", { remove: function() { if (this.uuid !== -1) { this.system.removeBody(this.uuid); + const eid = this.el.object3D.eid; + removeComponent(APP.world, Rigidbody, eid); } this.alive = false; } diff --git a/src/components/camera-focus-button.js b/src/components/camera-focus-button.js index 1d0d3c9d33..3b58d63208 100644 --- a/src/components/camera-focus-button.js +++ b/src/components/camera-focus-button.js @@ -1,4 +1,8 @@ +import { CameraTool, MyCameraTool } from "../bit-components"; +import { anyEntityWith } from "../utils/bit-utils"; import { findComponentsInNearestAncestor } from "../utils/scene-graph"; + +const tmpPos = new THREE.Vector3(); AFRAME.registerComponent("camera-focus-button", { schema: { track: { default: false }, @@ -6,8 +10,6 @@ AFRAME.registerComponent("camera-focus-button", { }, init() { - this.cameraSystem = this.el.sceneEl.systems["camera-tools"]; - NAF.utils.getNetworkedEntity(this.el).then(networkedEl => { if (this.data.selector) { this.targetEl = networkedEl.querySelector(this.data.selector); @@ -17,10 +19,16 @@ AFRAME.registerComponent("camera-focus-button", { }); this.onClick = () => { - const myCamera = this.cameraSystem.getMyCamera(); - if (!myCamera) return; + const myCam = anyEntityWith(APP.world, MyCameraTool); + if (!myCam) return; - myCamera.components["camera-tool"].focus(this.targetEl, this.data.track); + if (this.data.track) { + const tracking = CameraTool.trackTarget[myCam]; + CameraTool.trackTarget[myCam] = tracking === this.targetEl.eid ? 0 : this.targetEl.eid; + } else { + this.targetEl.object3D.getWorldPosition(tmpPos); + APP.world.eid2obj.get(myCam).lookAt(tmpPos); + } }; this.menuPlacementRoots = findComponentsInNearestAncestor(this.el, "position-at-border"); @@ -28,10 +36,9 @@ AFRAME.registerComponent("camera-focus-button", { tick() { const isVisible = this.el.object3D.visible; - const shouldBeVisible = !!(this.cameraSystem && this.cameraSystem.getMyCamera()); - + const shouldBeVisible = !!anyEntityWith(APP.world, MyCameraTool); if (isVisible !== shouldBeVisible) { - this.el.setAttribute("visible", shouldBeVisible); + this.el.object3D.visible = shouldBeVisible; for (let i = 0; i < this.menuPlacementRoots.length; i++) { this.menuPlacementRoots[i].markDirty(); } diff --git a/src/components/camera-tool.js b/src/components/camera-tool.js deleted file mode 100644 index 4a09727adb..0000000000 --- a/src/components/camera-tool.js +++ /dev/null @@ -1,819 +0,0 @@ -import { addAndArrangeMedia } from "../utils/media-utils"; -import { createImageBitmap } from "../utils/image-bitmap-utils"; -import { ObjectTypes } from "../object-types"; -import { paths } from "../systems/userinput/paths"; -import { SOUND_CAMERA_TOOL_TOOK_SNAPSHOT, SOUND_CAMERA_TOOL_COUNTDOWN } from "../systems/sound-effects-system"; -import { cloneObject3D } from "../utils/three-utils"; -import { loadModel } from "./gltf-model-plus"; -import { waitForDOMContentLoaded } from "../utils/async-utils"; -import cameraModelSrc from "../assets/camera_tool.glb"; -import anime from "animejs"; -import { Layers } from "./layers"; -const { detect } = require("detect-browser"); - -const browser = detect(); - -const isFirefox = browser.name === "firefox"; - -const cameraModelPromise = waitForDOMContentLoaded().then(() => loadModel(cameraModelSrc)); - -const pathsMap = { - "player-right-controller": { - takeSnapshot: paths.actions.rightHand.takeSnapshot - }, - "player-left-controller": { - takeSnapshot: paths.actions.leftHand.takeSnapshot - }, - "right-cursor": { - takeSnapshot: paths.actions.cursor.right.takeSnapshot - }, - "left-cursor": { - takeSnapshot: paths.actions.cursor.left.takeSnapshot - } -}; - -const isMobileVR = AFRAME.utils.device.isMobileVR(); - -const VIEWFINDER_FPS = 6; -const VIDEO_FPS = 25; -// Prefer h264 if available due to faster decoding speec on most platforms -const videoCodec = ["h264", "vp9,opus", "vp8,opus", "vp9", "vp8"].find( - codec => window.MediaRecorder && MediaRecorder.isTypeSupported(`video/webm; codecs=${codec}`) -); -const videoMimeType = videoCodec ? `video/webm; codecs=${videoCodec}` : null; -const hasWebGL2 = !!document.createElement("canvas").getContext("webgl2"); -const allowVideo = !!videoMimeType && hasWebGL2; - -const isOculusBrowser = navigator.userAgent.match(/Oculus/); -const CAPTURE_WIDTH = isMobileVR && !isOculusBrowser ? 640 : 1280; -const CAPTURE_HEIGHT = isMobileVR && !isOculusBrowser ? 360 : 720; -const RENDER_WIDTH = 1280; -const RENDER_HEIGHT = 720; -const CAPTURE_DURATIONS = [3, 7, 15, 30, 60, Infinity]; -const DEFAULT_CAPTURE_DURATION = 7; -const COUNTDOWN_DURATION = 3; -const VIDEO_LOOPS = 3; // Number of times to loop the videos we spawn before stopping them (for perf) -const MAX_DURATION_TO_LIMIT_LOOPS = 31; // Max duration for which we limit loops (eg GIFs vs long form videos) - -const snapCanvas = document.createElement("canvas"); - -async function pixelsToPNG(pixels, width, height) { - snapCanvas.width = width; - snapCanvas.height = height; - const context = snapCanvas.getContext("2d"); - - const imageData = context.createImageData(width, height); - imageData.data.set(pixels); - const bitmap = await createImageBitmap(imageData); - context.scale(1, -1); - context.drawImage(bitmap, 0, -height); - const blob = await new Promise(resolve => snapCanvas.toBlob(resolve)); - return new File([blob], "snap.png", { type: "image/png" }); -} - -function blitFramebuffer(renderer, src, srcX0, srcY0, srcX1, srcY1, dest, dstX0, dstY0, dstX1, dstY1) { - const gl = renderer.getContext(); - - // Copies from one framebuffer to another. Note that at the end of this function, you need to restore - // the original framebuffer via setRenderTarget - const srcFramebuffer = renderer.properties.get(src).__webglFramebuffer; - const destFramebuffer = renderer.properties.get(dest).__webglFramebuffer; - - if (srcFramebuffer && destFramebuffer) { - gl.bindFramebuffer(gl.READ_FRAMEBUFFER, srcFramebuffer); - gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, destFramebuffer); - - if (gl.checkFramebufferStatus(gl.READ_FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE) { - gl.blitFramebuffer(srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, gl.COLOR_BUFFER_BIT, gl.LINEAR); - } - } -} - -AFRAME.registerComponent("camera-tool", { - schema: { - captureDuration: { default: DEFAULT_CAPTURE_DURATION }, - captureAudio: { default: false }, - isSnapping: { default: false }, - isRecording: { default: false }, - label: { default: "" } - }, - - init() { - this.el.object3D.visible = false; // Make invisible until model ready - this.lastUpdate = performance.now(); - this.localSnapCount = 0; // Counter that is used to arrange photos/videos - - this.showCameraViewfinder = !isMobileVR; - - this.renderTarget = new THREE.WebGLRenderTarget(RENDER_WIDTH, RENDER_HEIGHT, { - format: THREE.RGBAFormat, - minFilter: THREE.LinearFilter, - magFilter: THREE.NearestFilter, - encoding: THREE.sRGBEncoding, - depth: false, - stencil: false - }); - - this.camera = new THREE.PerspectiveCamera(50, RENDER_WIDTH / RENDER_HEIGHT, 0.1, 30000); - this.camera.layers.enable(Layers.CAMERA_LAYER_VIDEO_TEXTURE_TARGET); - this.camera.layers.enable(Layers.CAMERA_LAYER_THIRD_PERSON_ONLY); - this.camera.rotation.set(0, Math.PI, 0); - this.camera.position.set(0, 0, 0.05); - this.camera.matrixNeedsUpdate = true; - this.el.setObject3D("camera", this.camera); - - const material = new THREE.MeshBasicMaterial({ - map: this.renderTarget.texture - }); - material.toneMapped = false; - - // Only update the renderTarget when the screens are in view - const onBeforeRender = () => { - if (this.showCameraViewfinder) { - this.viewfinderInViewThisFrame = true; - } - }; - - this.el.sceneEl.addEventListener("stateadded", () => this.updateUI()); - this.el.sceneEl.addEventListener("stateremoved", () => this.updateUI()); - - cameraModelPromise.then(model => { - const mesh = cloneObject3D(model.scene); - mesh.scale.set(2, 2, 2); - mesh.matrixNeedsUpdate = true; - this.el.setObject3D("mesh", mesh); - - this.el.object3D.visible = true; - this.el.object3D.scale.set(0.5, 0.5, 0.5); - this.el.object3D.matrixNeedsUpdate = true; - - const obj = this.el.object3D; - - const step = (function() { - const lastValue = {}; - return function(anim) { - const value = anim.animatables[0].target; - - value.x = Math.max(Number.MIN_VALUE, value.x); - value.y = Math.max(Number.MIN_VALUE, value.y); - value.z = Math.max(Number.MIN_VALUE, value.z); - - // For animation timeline. - if (value.x === lastValue.x && value.y === lastValue.y && value.z === lastValue.z) { - return; - } - - lastValue.x = value.x; - lastValue.y = value.y; - lastValue.z = value.z; - - obj.scale.set(value.x, value.y, value.z); - obj.matrixNeedsUpdate = true; - }; - })(); - - const config = { - duration: 200, - easing: "easeOutQuad", - elasticity: 400, - loop: 0, - round: false, - x: 1, - y: 1, - z: 1, - targets: [{ x: 0.5, y: 0.5, z: 0.5 }], - update: anim => step(anim), - complete: anim => step(anim) - }; - - anime(config); - - const width = 0.28; - const geometry = new THREE.PlaneBufferGeometry(width, width / this.camera.aspect); - - this.screen = new THREE.Mesh(geometry, material); - this.screen.rotation.set(0, Math.PI, 0); - this.screen.position.set(0, 0, -0.042); - this.screen.onBeforeRender = onBeforeRender; - this.screen.matrixNeedsUpdate = true; - this.el.setObject3D("screen", this.screen); - - this.selfieScreen = new THREE.Mesh(geometry, material); - this.selfieScreen.position.set(0, 0.4, 0); - this.selfieScreen.scale.set(-2, 2, 2); - this.selfieScreen.onBeforeRender = onBeforeRender; - this.selfieScreen.matrixNeedsUpdate = true; - this.el.setObject3D("selfieScreen", this.selfieScreen); - - this.label = this.el.querySelector(".label"); - this.labelActionBackground = this.el.querySelector(".label-action-background"); - this.labelBackground = this.el.querySelector(".label-background"); - this.durationLabel = this.el.querySelector(".duration"); - - this.recordIcon = this.el.querySelector(".record-icon"); - this.recordAlphaIcon = this.el.querySelector(".record-alpha-icon"); - - this.label.object3D.visible = false; - this.durationLabel.object3D.visible = false; - - this.snapMenu = this.el.querySelector(".camera-snap-menu"); - this.snapButton = this.el.querySelector(".snap-button"); - this.recordButton = this.el.querySelector(".record-button"); - - this.cancelButton = this.el.querySelector(".cancel-button"); - this.nextDurationButton = this.el.querySelector(".next-duration"); - this.prevDurationButton = this.el.querySelector(".prev-duration"); - this.snapButton.object3D.addEventListener("interact", () => this.snapClicked(true)); - this.recordButton.object3D.addEventListener("interact", () => this.snapClicked(false)); - this.cancelButton.object3D.addEventListener("interact", () => this.cancelSnapping()); - this.nextDurationButton.object3D.addEventListener("interact", () => this.changeDuration(1)); - this.prevDurationButton.object3D.addEventListener("interact", () => this.changeDuration(-1)); - this.stopButton = this.el.querySelector(".stop-button"); - this.stopButton.object3D.addEventListener("interact", () => this.stopRecording()); - this.captureAudioButton = this.el.querySelector(".capture-audio"); - this.captureAudioIcon = this.el.querySelector(".capture-audio-icon"); - this.captureAudioButton.object3D.addEventListener("interact", () => - this.el.setAttribute("camera-tool", "captureAudio", !this.data.captureAudio) - ); - - this.updateUI(); - - this.updateRenderTargetNextTick = true; - - this.cameraSystem = this.el.sceneEl.systems["camera-tools"]; - this.cameraSystem.register(this.el); - - waitForDOMContentLoaded().then(() => { - this.playerCamera = document.getElementById("viewing-camera").getObject3D("camera"); - }); - }); - }, - - remove() { - this.cameraSystem.deregister(this.el); - this.stopRecording(); - }, - - updateViewfinder() { - this.updateRenderTargetNextTick = true; - }, - - changeDuration(delta) { - const idx = CAPTURE_DURATIONS.findIndex(d => this.data.captureDuration == d); - const newIdx = idx === 0 && delta === -1 ? CAPTURE_DURATIONS.length - 1 : (idx + delta) % CAPTURE_DURATIONS.length; - this.el.setAttribute("camera-tool", "captureDuration", CAPTURE_DURATIONS[newIdx]); - }, - - focus(el, track) { - if (track) { - this.trackTarget = el; - } else { - this.trackTarget = null; - } - - this.lookAt(el); - }, - - lookAt: (function() { - const targetPos = new THREE.Vector3(); - return function(el) { - targetPos.setFromMatrixPosition(el.object3D.matrixWorld); - this.el.object3D.lookAt(targetPos); - this.el.object3D.matrixNeedsUpdate = true; - }; - })(), - - snapClicked(isPhoto) { - if (this.data.isSnapping) return; - if (!NAF.utils.isMine(this.el) && !NAF.utils.takeOwnership(this.el)) return; - - this.el.setAttribute("camera-tool", "isSnapping", true); - this.el.sceneEl.systems["hubs-systems"].soundEffectsSystem.playSoundOneShot(SOUND_CAMERA_TOOL_COUNTDOWN); - - this.snapCountdown = COUNTDOWN_DURATION; - this.el.setAttribute("camera-tool", "label", `${this.snapCountdown}`); - - this.countdownInterval = setInterval(async () => { - if (!NAF.utils.isMine(this.el) && !NAF.utils.takeOwnership(this.el)) { - clearInterval(this.countdownInterval); - this.countdownInterval = null; - return; - } - - this.snapCountdown--; - - if (this.snapCountdown === 0) { - clearInterval(this.countdownInterval); - this.countdownInterval = null; - - if (isPhoto) { - this.el.setAttribute("camera-tool", { label: "", isSnapping: false }); - this.takeSnapshotNextTick = true; - } else { - this.beginRecording(this.data.captureDuration); - } - } else { - this.el.sceneEl.systems["hubs-systems"].soundEffectsSystem.playSoundOneShot(SOUND_CAMERA_TOOL_COUNTDOWN); - this.el.setAttribute("camera-tool", "label", `${this.snapCountdown}`); - } - }, 1000); - }, - - cancelSnapping() { - clearInterval(this.countdownInterval); - this.stopRecording(true); - this.updateUI(); - }, - - update() { - this.updateUI(); - }, - - updateUI() { - if (!this.label) return; - - const label = this.data.label; - const isFrozen = this.el.sceneEl.is("frozen"); - const hasDuration = this.data.captureDuration !== Infinity; - - const isRecordingUnbound = !hasDuration && this.data.isRecording && this.videoRecorder; - this.label.object3D.visible = !!label && !isRecordingUnbound; - const showActionLabelBackground = this.label.object3D.visible && this.data.isRecording && !isRecordingUnbound; - - this.label.setAttribute("text", { value: label, color: showActionLabelBackground ? "#fafafa" : "#ff3464" }); - - this.labelActionBackground.object3D.visible = showActionLabelBackground; - this.labelBackground.object3D.visible = this.label.object3D.visible && !showActionLabelBackground; - - this.stopButton.object3D.visible = isRecordingUnbound; - - if (hasDuration) { - this.durationLabel.setAttribute("text", "value", `${this.data.captureDuration}`); - } - - this.durationLabel.object3D.visible = hasDuration && !this.data.isSnapping && !isFrozen && allowVideo; - this.recordIcon.object3D.visible = !hasDuration && !isFrozen && allowVideo; - this.recordAlphaIcon.object3D.visible = hasDuration && !isFrozen && allowVideo; - this.snapButton.object3D.visible = !this.data.isSnapping && !isFrozen; - this.recordButton.object3D.visible = !this.data.isSnapping && !isFrozen && allowVideo; - this.cancelButton.object3D.visible = this.data.isSnapping && !isFrozen; - this.prevDurationButton.object3D.visible = this.nextDurationButton.object3D.visible = - !this.data.isSnapping && allowVideo && !isFrozen && allowVideo; - - this.captureAudioIcon.setAttribute("icon-button", "active", this.data.captureAudio); - }, - - async beginRecording(duration) { - if (!this.videoContext) { - this.videoCanvas = document.createElement("canvas"); - this.videoCanvas.width = CAPTURE_WIDTH; - this.videoCanvas.height = CAPTURE_HEIGHT; - this.videoContext = this.videoCanvas.getContext("2d"); - this.videoImageData = this.videoContext.createImageData(CAPTURE_WIDTH, CAPTURE_HEIGHT); - this.videoPixels = new Uint8Array(CAPTURE_WIDTH * CAPTURE_HEIGHT * 4); - this.videoImageData.data.set(this.videoPixels); - } - - const stream = new MediaStream(); - const track = this.videoCanvas.captureStream(VIDEO_FPS).getVideoTracks()[0]; - - // This adds hacks for current browser issues with media recordings when audio tracks are muted or missing. - const attachBlankAudio = () => { - // Chrome has issues when the audio tracks are silent so we only do this for FF. - // https://bugs.chromium.org/p/chromium/issues/detail?id=1223382 - if (isFirefox) { - // FF 73+ seems to fail to decode videos with no audio track, so we always include a silent track. - const context = THREE.AudioContext.getContext(); - const oscillator = context.createOscillator(); - const gain = context.createGain(); - const destination = context.createMediaStreamDestination(); - gain.gain.setValueAtTime(0.0001, context.currentTime); - oscillator.connect(destination); - gain.connect(destination); - stream.addTrack(destination.stream.getAudioTracks()[0]); - } - }; - - if (this.data.captureAudio) { - const selfAudio = await APP.dialog.getMediaStream(NAF.clientId, "audio"); - - // NOTE: if we don't have a self audio track, we can end up generating an empty video (browser bug?) - // if no audio comes through on the listener source. (Eg the room is otherwise silent.) - // So for now, if we don't have a track, just disable audio capture. - if (selfAudio && selfAudio.getAudioTracks().length > 0) { - const context = THREE.AudioContext.getContext(); - const destination = context.createMediaStreamDestination(); - - const listener = this.el.sceneEl.audioListener; - if (listener) { - // NOTE audio is not captured from camera vantage point for now. - listener.getInput().connect(destination); - } - context.createMediaStreamSource(selfAudio).connect(destination); - - const audio = destination.stream.getAudioTracks()[0]; - stream.addTrack(audio); - } else { - attachBlankAudio(); - } - } else { - attachBlankAudio(); - } - - stream.addTrack(track); - this.videoRecorder = new MediaRecorder(stream, { mimeType: videoMimeType }); - const chunks = []; - const recordingStartTime = performance.now(); - - this.videoRecorder.ondataavailable = e => chunks.push(e.data); - - this.updateRenderTargetNextTick = true; - - this.videoRecorder.start(); - this.el.setAttribute("camera-tool", { isRecording: true, label: " " }); - this.el.sceneEl.emit("action_camera_recording_started"); - - if (duration !== Infinity) { - this.videoCountdown = this.data.captureDuration; - this.el.setAttribute("camera-tool", "label", `${this.videoCountdown}`); - - this.videoCountdownInterval = setInterval(() => { - this.videoCountdown--; - - if (this.videoCountdown === 0) { - this.stopRecording(); - this.videoCountdownInterval = null; - } else { - this.el.setAttribute("camera-tool", "label", `${this.videoCountdown}`); - } - }, 1000); - } - - this.videoRecorder._free = () => (chunks.length = 0); // Used for cancelling - this.videoRecorder.onstop = async () => { - this.el.sceneEl.emit("action_camera_recording_ended"); - - if (chunks.length === 0) return; - const mimeType = chunks[0].type; - const recordingDuration = performance.now() - recordingStartTime; - const blob = new Blob(chunks, { type: mimeType }); - - chunks.length = 0; - - const { entity, orientation } = addAndArrangeMedia( - this.el, - new File([blob], "capture", { type: mimeType.split(";")[0] }), // Drop codec - "video-camera", - this.localSnapCount, - !!this.playerIsBehindCamera - ); - - entity.addEventListener( - "video-loaded", - () => { - // If we were recording audio, then pause the video immediately after starting. - // - // Or, to limit the # of concurrent videos playing, if it was a short clip, let it loop - // a few times and then pause it. - if (this.data.captureAudio || recordingDuration <= MAX_DURATION_TO_LIMIT_LOOPS * 1000) { - setTimeout(() => { - if (!NAF.utils.isMine(entity) && !NAF.utils.takeOwnership(entity)) return; - entity.components["media-video"].tryUpdateVideoPlaybackState(true); - }, this.data.captureAudio ? 0 : recordingDuration * VIDEO_LOOPS + 100); - } - }, - { once: true } - ); - - this.localSnapCount++; - - orientation.then(() => this.el.sceneEl.emit("object_spawned", { objectType: ObjectTypes.CAMERA })); - }; - }, - - stopRecording(cancel) { - if (this.videoRecorder) { - if (cancel) { - this.videoRecorder.onstop = () => {}; - this.videoRecorder._free(); - this.el.sceneEl.emit("action_camera_recording_ended"); - } - - this.videoRecorder.stop(); - this.videoRecorder = null; - - if (!cancel) { - this.el.sceneEl.systems["hubs-systems"].soundEffectsSystem.playSoundOneShot(SOUND_CAMERA_TOOL_TOOK_SNAPSHOT); - } - } - clearInterval(this.videoCountdownInterval); - - this.videoCountdownInterval = null; - - this.el.setAttribute("camera-tool", { label: " ", isRecording: false, isSnapping: false }); - }, - - tick() { - const interaction = AFRAME.scenes[0].systems.interaction; - const userinput = AFRAME.scenes[0].systems.userinput; - const heldLeftHand = interaction.state.leftHand.held === this.el; - const heldRightHand = interaction.state.rightHand.held === this.el; - const heldRightRemote = interaction.state.rightRemote.held === this.el; - - const heldThisFrame = - (heldLeftHand && userinput.get(interaction.options.leftHand.grabPath)) || - (heldRightHand && userinput.get(interaction.options.rightHand.grabPath)) || - (heldRightRemote && userinput.get(interaction.options.rightRemote.grabPath)); - - const isHolding = heldLeftHand || heldRightHand || heldRightRemote; - - this.updateSnapMenuOrientation(); - - if (heldThisFrame) { - this.localSnapCount = 0; - } - - this.showCameraViewfinder = isHolding || !isMobileVR || this.data.isSnapping || this.videoRecorder; - - if (this.screen && this.selfieScreen) { - this.screen.visible = this.selfieScreen.visible = !!this.showCameraViewfinder; - } - - // Always draw held, snapping, or recording camera viewfinders with a decent framerate - if ( - (isHolding || this.data.isSnapping || this.videoRecorder) && - performance.now() - this.lastUpdate >= 1000 / (this.videoRecorder ? VIDEO_FPS : VIEWFINDER_FPS) - ) { - this.updateRenderTargetNextTick = true; - } - - const isHoldingTrigger = this.isHoldingSnapshotTrigger(); - const isPermittedToUse = window.APP.hubChannel.can("spawn_camera"); - - if (isPermittedToUse) { - // If the user lets go of the trigger before 500ms, take a picture, otherwise record until they let go. - if (isHoldingTrigger && !this.data.isSnapping && !this.snapTriggerTimeout) { - this.snapTriggerTimeout = setTimeout(() => { - if (this.isHoldingSnapshotTrigger()) { - this.el.setAttribute("camera-tool", "isSnapping", true); - this.beginRecording(Infinity); - - const releaseInterval = setInterval(() => { - if (this.isHoldingSnapshotTrigger()) return; - this.stopRecording(); - clearInterval(releaseInterval); - }, 500); - } - - this.snapTriggerTimeout = null; - }, 500); - } else if (!isHoldingTrigger && !this.data.isSnapping && this.snapTriggerTimeout) { - clearTimeout(this.snapTriggerTimeout); - this.snapTriggerTimeout = null; - this.takeSnapshotNextTick = true; - } - } - }, - - tock: (function() { - return function tock() { - const sceneEl = this.el.sceneEl; - const renderer = this.renderer || sceneEl.renderer; - const now = performance.now(); - - // Perform lookAt in tock so it will re-orient after grabs, etc. - if (this.trackTarget) { - if (this.trackTarget.parentNode) { - this.lookAt(this.trackTarget); - // scene.autoUpdate will be false so explicitly update the world matrices - this.object3D.updateMatrixWorld(); - } else { - this.trackTarget = null; // Target removed - } - } - - if (!this.playerHud) { - const hudEl = document.getElementById("player-hud"); - this.playerHud = hudEl && hudEl.object3D; - } - - if ( - this.takeSnapshotNextTick || - (this.updateRenderTargetNextTick && (this.viewfinderInViewThisFrame || this.videoRecorder)) - ) { - let playerHudWasVisible = false; - - if (this.playerHud) { - playerHudWasVisible = this.playerHud.visible; - this.playerHud.visible = false; - if (this.el.sceneEl.systems["hubs-systems"]) { - for (const mesh of Object.values(this.el.sceneEl.systems["hubs-systems"].spriteSystem.meshes)) { - mesh.visible = false; - } - } - } - - const bubbleSystem = this.el.sceneEl.systems["personal-space-bubble"]; - const boneVisibilitySystem = this.el.sceneEl.systems["hubs-systems"].boneVisibilitySystem; - - if (bubbleSystem) { - for (let i = 0, l = bubbleSystem.invaders.length; i < l; i++) { - bubbleSystem.invaders[i].disable(); - } - // HACK, bone visibility typically takes a tick to update, but since we want to be able - // to have enable() and disable() be reflected this frame, we need to do it immediately. - boneVisibilitySystem.tick(); - // scene.autoUpdate will be false so explicitly update the world matrices - boneVisibilitySystem.updateMatrices(); - } - - const tmpVRFlag = renderer.xr.enabled; - const tmpOnAfterRender = sceneEl.object3D.onAfterRender; - const tmpAutoUpdate = sceneEl.object3D.autoUpdate; - delete sceneEl.object3D.onAfterRender; - renderer.xr.enabled = false; - - // The entire scene graph matrices should already be updated - // in tick(). They don't need to be recomputed again in tock(). - sceneEl.object3D.autoUpdate = false; - - if (allowVideo && this.videoRecorder && !this.videoRenderTarget) { - // Create a separate render target for video because we need to flip and (sometimes) downscale it before - // encoding it to video. - this.videoRenderTarget = new THREE.WebGLRenderTarget(CAPTURE_WIDTH, CAPTURE_HEIGHT, { - format: THREE.RGBAFormat, - minFilter: THREE.LinearFilter, - magFilter: THREE.NearestFilter, - encoding: THREE.sRGBEncoding, - depth: false, - stencil: false - }); - - // Used to set up framebuffer in three.js as a side effect - renderer.setRenderTarget(this.videoRenderTarget); - } - - renderer.setRenderTarget(this.renderTarget); - renderer.render(sceneEl.object3D, this.camera); - renderer.setRenderTarget(null); - - renderer.xr.enabled = tmpVRFlag; - sceneEl.object3D.onAfterRender = tmpOnAfterRender; - sceneEl.object3D.autoUpdate = tmpAutoUpdate; - - if (this.playerHud) { - this.playerHud.visible = playerHudWasVisible; - if (this.el.sceneEl.systems["hubs-systems"]) { - for (const mesh of Object.values(this.el.sceneEl.systems["hubs-systems"].spriteSystem.meshes)) { - mesh.visible = true; - } - } - } - - if (bubbleSystem) { - for (let i = 0, l = bubbleSystem.invaders.length; i < l; i++) { - bubbleSystem.invaders[i].enable(); - } - // HACK, bone visibility typically takes a tick to update, but since we want to be able - // to have enable() and disable() be reflected this frame, we need to do it immediately. - boneVisibilitySystem.tick(); - boneVisibilitySystem.updateMatrices(); - } - - this.lastUpdate = now; - - if (this.videoRecorder) { - // https://chromium.googlesource.com/chromium/src/gpu/+/master/command_buffer/service/gles2_cmd_decoder.cc#8899 - // We avoid using blitting and flip the render target pixels for OB. - if (!isOculusBrowser) { - // This blit operation will (if necessary) scale/resample the view finder render target and, importantly, - // flip the texture on Y - blitFramebuffer( - renderer, - this.renderTarget, - 0, - 0, - RENDER_WIDTH, - RENDER_HEIGHT, - this.videoRenderTarget, - 0, - CAPTURE_HEIGHT, - CAPTURE_WIDTH, - 0 - ); - } - renderer.readRenderTargetPixels( - !isOculusBrowser ? this.videoRenderTarget : this.renderTarget, - 0, - 0, - CAPTURE_WIDTH, - CAPTURE_HEIGHT, - this.videoPixels - ); - if (isOculusBrowser) { - this.flipPixelsY(this.videoPixels, CAPTURE_WIDTH, CAPTURE_HEIGHT); - } - this.videoImageData.data.set(this.videoPixels); - this.videoContext.putImageData(this.videoImageData, 0, 0); - } - - this.updateRenderTargetNextTick = false; - this.viewfinderInViewThisFrame = false; - } - - if (this.takeSnapshotNextTick) { - if (!this.snapPixels) { - this.snapPixels = new Uint8Array(RENDER_WIDTH * RENDER_HEIGHT * 4); - } - renderer.readRenderTargetPixels(this.renderTarget, 0, 0, RENDER_WIDTH, RENDER_HEIGHT, this.snapPixels); - - pixelsToPNG(this.snapPixels, RENDER_WIDTH, RENDER_HEIGHT).then(file => { - const { orientation } = addAndArrangeMedia( - this.el, - file, - "photo-camera", - this.localSnapCount, - !!this.playerIsBehindCamera - ); - - orientation.then(() => { - this.el.sceneEl.emit("object_spawned", { objectType: ObjectTypes.CAMERA }); - }); - }); - sceneEl.systems["hubs-systems"].soundEffectsSystem.playSoundOneShot(SOUND_CAMERA_TOOL_TOOK_SNAPSHOT); - this.takeSnapshotNextTick = false; - this.localSnapCount++; - } - }; - })(), - - flipPixelsY(pixels, width, height) { - const halfHeight = (height / 2) | 0; - const bytesPerRow = width * 4; - - const temp = new Uint8Array(width * 4); - for (let y = 0; y < halfHeight; ++y) { - const topOffset = y * bytesPerRow; - const bottomOffset = (height - y - 1) * bytesPerRow; - temp.set(pixels.subarray(topOffset, topOffset + bytesPerRow)); - pixels.copyWithin(topOffset, bottomOffset, bottomOffset + bytesPerRow); - pixels.set(temp, bottomOffset); - } - }, - - isHoldingSnapshotTrigger: function() { - const interaction = AFRAME.scenes[0].systems.interaction; - const userinput = AFRAME.scenes[0].systems.userinput; - const heldLeftHand = interaction.state.leftHand.held === this.el; - const heldRightHand = interaction.state.rightHand.held === this.el; - const heldRightRemote = interaction.state.rightRemote.held === this.el; - const heldLeftRemote = interaction.state.leftRemote.held === this.el; - - let grabberId; - if (heldRightHand) { - grabberId = "player-right-controller"; - } else if (heldLeftHand) { - grabberId = "player-left-controller"; - } else if (heldRightRemote) { - grabberId = "right-cursor"; - } else if (heldLeftRemote) { - grabberId = "left-cursor"; - } - - if (grabberId) { - const grabberPaths = pathsMap[grabberId]; - if (userinput.get(grabberPaths.takeSnapshot)) { - return true; - } - } - - return !!userinput.get(paths.actions.takeSnapshot); - }, - - updateSnapMenuOrientation: (function() { - const playerWorld = new THREE.Vector3(); - const cameraWorld = new THREE.Vector3(); - const playerToCamera = new THREE.Vector3(); - const cameraForwardPoint = new THREE.Vector3(); - const cameraForwardWorld = new THREE.Vector3(); - return function() { - if (!this.playerCamera) return; - this.el.object3D.getWorldPosition(cameraWorld); - this.playerCamera.getWorldPosition(playerWorld); - playerToCamera.subVectors(playerWorld, cameraWorld); - cameraForwardPoint.set(0, 0, 1); - this.el.object3D.localToWorld(cameraForwardPoint); - cameraForwardWorld.subVectors(cameraForwardPoint, cameraWorld); - cameraForwardWorld.normalize(); - playerToCamera.normalize(); - - const playerIsBehindCamera = cameraForwardWorld.dot(playerToCamera) < 0; - - if (this.playerIsBehindCamera !== playerIsBehindCamera) { - this.playerIsBehindCamera = playerIsBehindCamera; - this.snapMenu.object3D.rotation.set(0, this.playerIsBehindCamera ? Math.PI : 0, 0); - this.snapMenu.object3D.matrixNeedsUpdate = true; - } - }; - })() -}); diff --git a/src/components/cursor-controller.js b/src/components/cursor-controller.js index ae8896b05c..3f33483d8f 100644 --- a/src/components/cursor-controller.js +++ b/src/components/cursor-controller.js @@ -1,6 +1,28 @@ +import { addComponent, defineQuery, hasComponent, removeComponent } from "bitecs"; +import { anyEntityWith } from "../utils/bit-utils"; +import { + HeldRemoteLeft, + HeldRemoteRight, + HoveredRemoteLeft, + HoveredRemoteRight, + NotRemoteHoverTarget, + RemoteHoverTarget +} from "../bit-components"; import { paths } from "../systems/userinput/paths"; import { sets } from "../systems/userinput/sets"; import { getLastWorldPosition } from "../utils/three-utils"; +import { Layers } from "./layers"; + +export function findRemoteHoverTarget(world, object3D) { + if (!object3D) return null; + if (!object3D.eid) return findRemoteHoverTarget(world, object3D.parent); + if (hasComponent(world, NotRemoteHoverTarget, object3D.eid)) return null; + if (hasComponent(world, RemoteHoverTarget, object3D.eid)) return object3D.eid; + return findRemoteHoverTarget(world, object3D.parent); +} + +const hoveredRightRemoteQuery = defineQuery([HoveredRemoteRight]); +const hoveredLeftRemoteQuery = defineQuery([HoveredRemoteLeft]); const HIGHLIGHT = new THREE.Color(23 / 255, 64 / 255, 118 / 255); const NO_HIGHLIGHT = new THREE.Color(190 / 255, 190 / 255, 190 / 255); @@ -76,6 +98,7 @@ AFRAME.registerComponent("cursor-controller", { this.cursorVisual.renderOrder = window.APP.RENDER_ORDER.CURSOR; this.cursorVisual.material.transparent = true; + this.cursorVisual.layers.set(Layers.CAMERA_LAYER_UI); this.data.cursor.object3D.add(this.cursorVisual); this.intersection = null; @@ -133,8 +156,8 @@ AFRAME.registerComponent("cursor-controller", { this.raycaster.far = this.data.far * playerScale; this.raycaster.near = this.data.near * playerScale; - const interaction = AFRAME.scenes[0].systems.interaction; - const isGrabbing = left ? !!interaction.state.leftRemote.held : !!interaction.state.rightRemote.held; + const isGrabbing = left ? anyEntityWith(APP.world, HeldRemoteLeft) : anyEntityWith(APP.world, HeldRemoteRight); + let isHoveringSomething = false; if (!isGrabbing) { rawIntersections.length = 0; this.raycaster.ray.origin = cursorPose.position; @@ -145,8 +168,20 @@ AFRAME.registerComponent("cursor-controller", { rawIntersections ); this.intersection = rawIntersections[0]; - this.intersectionIsValid = !!interaction.updateCursorIntersection(this.intersection, left); - this.distance = this.intersectionIsValid ? this.intersection.distance : this.data.defaultDistance * playerScale; + + const remoteHoverTarget = this.intersection && findRemoteHoverTarget(APP.world, this.intersection.object); + isHoveringSomething = !!remoteHoverTarget; + if (remoteHoverTarget) { + addComponent(APP.world, left ? HoveredRemoteLeft : HoveredRemoteRight, remoteHoverTarget); + } + const hovered = left ? hoveredLeftRemoteQuery(APP.world) : hoveredRightRemoteQuery(APP.world); + for (let i = 0; i < hovered.length; i++) { + // Unhover anything that should no longer be hovered + if (remoteHoverTarget !== hovered[i]) { + removeComponent(APP.world, left ? HoveredRemoteLeft : HoveredRemoteRight, hovered[i]); + } + } + this.distance = remoteHoverTarget ? this.intersection.distance : this.data.defaultDistance * playerScale; } const { cursor, minDistance, far, camera } = this.data; @@ -171,7 +206,7 @@ AFRAME.registerComponent("cursor-controller", { (!left && transformObjectSystem.hand.el.id === "player-right-controller")) ) { this.color.copy(TRANSFORM_COLOR_1).lerpHSL(TRANSFORM_COLOR_2, 0.5 + 0.5 * Math.sin(t / 1000.0)); - } else if (this.intersectionIsValid || isGrabbing) { + } else if (isGrabbing || isHoveringSomething) { this.color.copy(HIGHLIGHT); } else { this.color.copy(NO_HIGHLIGHT); diff --git a/src/components/destroy-at-extreme-distances.js b/src/components/destroy-at-extreme-distances.js deleted file mode 100644 index 34c53e4cac..0000000000 --- a/src/components/destroy-at-extreme-distances.js +++ /dev/null @@ -1,35 +0,0 @@ -import { getLastWorldPosition } from "../utils/three-utils"; - -AFRAME.registerComponent("destroy-at-extreme-distances", { - schema: { - xMin: { default: -1000 }, - xMax: { default: 1000 }, - yMin: { default: -1000 }, - yMax: { default: 1000 }, - zMin: { default: -1000 }, - zMax: { default: 1000 } - }, - - init() { - this._checkForDestroy = this._checkForDestroy.bind(this); - this.el.sceneEl.systems["frame-scheduler"].schedule(this._checkForDestroy, "media-components"); - }, - - remove() { - this.el.sceneEl.systems["frame-scheduler"].unschedule(this._checkForDestroy, "media-components"); - }, - - _checkForDestroy: (function() { - const pos = new THREE.Vector3(); - return function() { - const { xMin, xMax, yMin, yMax, zMin, zMax } = this.data; - getLastWorldPosition(this.el.object3D, pos); - - if (pos.x < xMin || pos.x > xMax || pos.y < yMin || pos.y > yMax || pos.z < zMin || pos.z > zMax) { - if (!this.el.components.networked || NAF.utils.isMine(this.el)) { - this.el.parentNode.removeChild(this.el); - } - } - }; - })() -}); diff --git a/src/components/emoji-hud.js b/src/components/emoji-hud.js index bca0b904b6..9d3a0d8f95 100644 --- a/src/components/emoji-hud.js +++ b/src/components/emoji-hud.js @@ -1,4 +1,6 @@ +import { addComponent, removeComponent } from "bitecs"; import { TYPE } from "three-ammo/constants"; +import { HandCollisionTarget } from "../bit-components"; import { emojis } from "./emoji"; const COLLISION_LAYERS = require("../constants").COLLISION_LAYERS; @@ -114,7 +116,7 @@ AFRAME.registerComponent("emoji-hud", { if (e.detail === "frozen") { this._updateOffset(); for (let i = 0; i < this.spawnerEntities.length; i++) { - this.spawnerEntities[i].components.tags.data.isHandCollisionTarget = true; + addComponent(APP.world, HandCollisionTarget, this.spawnerEntities[i].eid); } } }, @@ -122,7 +124,7 @@ AFRAME.registerComponent("emoji-hud", { _onThaw(e) { if (e.detail === "frozen") { for (let i = 0; i < this.spawnerEntities.length; i++) { - this.spawnerEntities[i].components.tags.data.isHandCollisionTarget = false; + removeComponent(APP.world, HandCollisionTarget, this.spawnerEntities[i].eid); } } }, diff --git a/src/components/floaty-object.js b/src/components/floaty-object.js index f37f6afcec..54c8aa0e4f 100644 --- a/src/components/floaty-object.js +++ b/src/components/floaty-object.js @@ -1,14 +1,10 @@ +import { addComponent } from "bitecs"; +import { FloatyObject } from "../bit-components"; +import { FLOATY_OBJECT_FLAGS } from "../systems/floaty-object-system"; /* global AFRAME */ -const COLLISION_LAYERS = require("../constants").COLLISION_LAYERS; AFRAME.registerComponent("floaty-object", { schema: { - // Make the object locked/kinematic upon load - autoLockOnLoad: { default: false }, - - // Make the object kinematic immediately upon release - autoLockOnRelease: { default: false }, - // On release, modify the gravity based upon gravitySpeedLimit. If less than this, let the object float // otherwise apply releaseGravity. modifyGravityOnRelease: { default: false }, @@ -19,124 +15,19 @@ AFRAME.registerComponent("floaty-object", { // If true, the degree to which angular rotation is allowed when floating is reduced (useful for 2d media) reduceAngularFloat: { default: false }, - // Velocity speed limit under which gravity will not be added if modifyGravityOnRelease is true - gravitySpeedLimit: { default: 1.85 } // Set to 0 to never apply gravity + // If true, the object will behave the same regardless of how fast it was moving when released + unthrowable: { default: false } }, init() { - this.onGrab = this.onGrab.bind(this); - this.onRelease = this.onRelease.bind(this); - }, - - tick() { - if (!this.bodyHelper) { - this.bodyHelper = this.el.components["body-helper"]; - } - - const interaction = AFRAME.scenes[0].systems.interaction; - const isHeld = interaction && interaction.isHeld(this.el); - if (isHeld && !this.wasHeld) { - this.onGrab(); - } - if (this.wasHeld && !isHeld) { - this.onRelease(); - } - - if (!isHeld && this._makeStaticWhenAtRest) { - const physicsSystem = this.el.sceneEl.systems["hubs-systems"].physicsSystem; - const isMine = this.el.components.networked && NAF.utils.isMine(this.el); - const linearThreshold = this.bodyHelper.data.linearSleepingThreshold; - const angularThreshold = this.bodyHelper.data.angularSleepingThreshold; - const uuid = this.bodyHelper.uuid; - const isAtRest = - physicsSystem.bodyInitialized(uuid) && - physicsSystem.getLinearVelocity(uuid) < linearThreshold && - physicsSystem.getAngularVelocity(uuid) < angularThreshold; - - if (isAtRest && isMine) { - this.el.setAttribute("body-helper", { type: "kinematic" }); - } - - if (isAtRest || !isMine) { - this._makeStaticWhenAtRest = false; - } - } - - this.wasHeld = isHeld; - }, - - play() { - // We do this in play instead of in init because otherwise NAF.utils.isMine fails - if (this.hasBeenHereBefore) return; - this.hasBeenHereBefore = true; - if (this.data.autoLockOnLoad) { - this.el.setAttribute("body-helper", { - gravity: { x: 0, y: 0, z: 0 } - }); - this.setLocked(true); - } - }, - - setLocked(locked) { - if (this.el.components.networked && !NAF.utils.isMine(this.el)) return; - - this.locked = locked; - this.el.setAttribute("body-helper", { type: locked ? "kinematic" : "dynamic" }); - }, - - onRelease() { - if (this.data.modifyGravityOnRelease) { - const uuid = this.bodyHelper.uuid; - const physicsSystem = this.el.sceneEl.systems["hubs-systems"].physicsSystem; - if ( - this.data.gravitySpeedLimit === 0 || - (physicsSystem.bodyInitialized(uuid) && physicsSystem.getLinearVelocity(uuid) < this.data.gravitySpeedLimit) - ) { - this.el.setAttribute("body-helper", { - gravity: { x: 0, y: 0, z: 0 }, - angularDamping: this.data.reduceAngularFloat ? 0.98 : 0.5, - linearDamping: 0.95, - linearSleepingThreshold: 0.1, - angularSleepingThreshold: 0.1, - collisionFilterMask: COLLISION_LAYERS.HANDS | COLLISION_LAYERS.MEDIA_FRAMES - }); - - this._makeStaticWhenAtRest = true; - } else { - this.el.setAttribute("body-helper", { - gravity: { x: 0, y: this.data.releaseGravity, z: 0 }, - angularDamping: 0.01, - linearDamping: 0.01, - linearSleepingThreshold: 1.6, - angularSleepingThreshold: 2.5, - collisionFilterMask: COLLISION_LAYERS.DEFAULT_INTERACTABLE - }); - } - } else { - this.el.setAttribute("body-helper", { - collisionFilterMask: COLLISION_LAYERS.DEFAULT_INTERACTABLE, - gravity: { x: 0, y: -9.8, z: 0 } - }); - } - - if (this.data.autoLockOnRelease) { - this.setLocked(true); - } - }, - - onGrab() { - this.el.setAttribute("body-helper", { - gravity: { x: 0, y: 0, z: 0 }, - collisionFilterMask: COLLISION_LAYERS.HANDS | COLLISION_LAYERS.MEDIA_FRAMES - }); - this.setLocked(false); + addComponent(APP.world, FloatyObject, this.el.object3D.eid); }, - remove() { - if (this.stuckTo) { - const stuckTo = this.stuckTo; - delete this.stuckTo; - stuckTo._unstickObject(); - } + update() { + FloatyObject.flags[this.el.eid] = + (this.data.modifyGravityOnRelease && FLOATY_OBJECT_FLAGS.MODIFY_GRAVITY_ON_RELEASE) | + (this.data.reduceAngularFloat && FLOATY_OBJECT_FLAGS.REDUCE_ANGULAR_FLOAT) | + (this.data.unthrowable && FLOATY_OBJECT_FLAGS.UNTHROWABLE); + FloatyObject.releaseGravity[this.el.eid] = this.data.releaseGravity; } }); diff --git a/src/components/gltf-model-plus.js b/src/components/gltf-model-plus.js index 5ef87c08be..9b968e609f 100644 --- a/src/components/gltf-model-plus.js +++ b/src/components/gltf-model-plus.js @@ -11,6 +11,11 @@ import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader"; import { BasisTextureLoader } from "three/examples/jsm/loaders/BasisTextureLoader"; import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader"; +// It seems we need to use require to import modules +// under the three/examples/js to avoid tree shaking +// in webpack production mode. +require("three/examples/js/loaders/GLTFLoader"); + THREE.Mesh.prototype.raycast = acceleratedRaycast; class GLTFCache { @@ -53,7 +58,7 @@ class GLTFCache { } } } -const gltfCache = new GLTFCache(); +export const gltfCache = new GLTFCache(); const inflightGltfs = new Map(); const extractZipFile = promisifyWorker(new SketchfabZipWorker()); diff --git a/src/components/ik-controller.js b/src/components/ik-controller.js index d92d13f76b..fb8cf96f13 100644 --- a/src/components/ik-controller.js +++ b/src/components/ik-controller.js @@ -1,3 +1,5 @@ +import { defineQuery } from "bitecs"; +import { CameraTool } from "../bit-components"; import { waitForDOMContentLoaded } from "../utils/async-utils"; const { Vector3, Quaternion, Matrix4, Euler } = THREE; @@ -15,6 +17,8 @@ function quaternionAlmostEquals(epsilon, u, v) { ); } +const cameraToolsQuery = defineQuery([CameraTool]); + /** * Provides access to the end effectors for IK. * @namespace avatar @@ -321,7 +325,7 @@ AFRAME.registerComponent("ik-controller", { _updateIsInView: (function() { const frustum = new THREE.Frustum(); const frustumMatrix = new THREE.Matrix4(); - const cameraWorld = new THREE.Vector3(); + const tmpPos = new THREE.Vector3(); const isInViewOfCamera = (screenCamera, pos) => { frustumMatrix.multiplyMatrices(screenCamera.projectionMatrix, screenCamera.matrixWorldInverse); frustum.setFromProjectionMatrix(frustumMatrix); @@ -329,22 +333,23 @@ AFRAME.registerComponent("ik-controller", { }; return function() { - if (!this.playerCamera) return; + if (!this.playerCamera || this.data.alwaysUpdate) return; const camera = this.ikRoot.camera.object3D; - camera.getWorldPosition(cameraWorld); + camera.getWorldPosition(tmpPos); // Check player camera - this.isInView = isInViewOfCamera(this.playerCamera, cameraWorld); + this.isInView = isInViewOfCamera(this.playerCamera, tmpPos); if (!this.isInView) { - // Check in-game camera if rendering to viewfinder and owned - const cameraTools = this.el.sceneEl.systems["camera-tools"]; - - if (cameraTools) { - cameraTools.ifMyCameraRenderingViewfinder(cameraTool => { - this.isInView = this.isInView || isInViewOfCamera(cameraTool.camera, cameraWorld); - }); + const world = APP.world; + // Check camera tools if they are rendering to viewfinder + const cameraTools = cameraToolsQuery(world); + for (const eid of cameraTools) { + const screenObj = world.eid2obj.get(CameraTool.screenRef[eid]); + const cameraObj = world.eid2obj.get(CameraTool.cameraRef[eid]); + this.isInView = screenObj.visible && isInViewOfCamera(cameraObj, tmpPos); + if (this.isInView) break; } } }; diff --git a/src/components/layers.js b/src/components/layers.js index 27929fa946..f60434bee1 100644 --- a/src/components/layers.js +++ b/src/components/layers.js @@ -1,3 +1,4 @@ +// NOTE when changing be sure to check hardcoded mask values in hub.html export const Layers = { // These 3 layers are hardcoded in THREE CAMERA_LAYER_DEFAULT: 0, @@ -9,7 +10,8 @@ export const Layers = { CAMERA_LAYER_VIDEO_TEXTURE_TARGET: 5, CAMERA_LAYER_THIRD_PERSON_ONLY: 6, - CAMERA_LAYER_FIRST_PERSON_ONLY: 7 + CAMERA_LAYER_FIRST_PERSON_ONLY: 7, + CAMERA_LAYER_UI: 8 }; /** @@ -19,35 +21,22 @@ export const Layers = { */ AFRAME.registerComponent("layers", { schema: { - reflection: { type: "boolean", default: false }, - inWorldHud: { type: "boolean", default: false }, - exclusive: { type: "boolean", default: false } // if true, only these layers will be set + mask: { default: Layers.CAMERA_LAYER_DEFAULT }, + recursive: { default: false } }, init() { this.update = this.update.bind(this); - this.el.addEventListener("model-loaded", this.update); + this.el.addEventListener("object3dset", this.update); }, - update(oldData) { + update() { const obj = this.el.object3D; - - if (this.data.exclusive) { - obj.traverse(o => (o.layers.mask = 0)); - } - - for (const [name, layer] of Object.entries(Layers)) { - const oldValue = oldData[name]; - const newValue = this.data[name]; - - if (oldValue !== newValue) { - if (newValue) { - obj.traverse(o => o.layers.enable(layer)); - } else { - obj.traverse(o => o.layers.disable(layer)); - } - } + if (this.data.recursive) { + obj.traverse(o => (o.layers.mask = this.data.mask)); + } else { + obj.layers.mask = this.data.mask; } }, remove() { - this.el.removeEventListener("model-loaded", this.update); + this.el.removeEventListener("object3dset", this.update); } }); diff --git a/src/components/media-loader.js b/src/components/media-loader.js index 21241befa7..73f3e4e962 100644 --- a/src/components/media-loader.js +++ b/src/components/media-loader.js @@ -23,6 +23,8 @@ import { cloneObject3D, setMatrixWorld } from "../utils/three-utils"; import { waitForDOMContentLoaded } from "../utils/async-utils"; import { SHAPE } from "three-ammo/constants"; +import { addComponent, entityExists, removeComponent } from "bitecs"; +import { MediaLoading } from "../bit-components"; let loadingObject; @@ -283,7 +285,13 @@ AFRAME.registerComponent("media-loader", { this.data.linkedEl.addEventListener("componentremoved", this.handleLinkedElRemoved); } + // TODO this does duplicate work in some cases, but finish() is the only consistent place to do it + this.contentBounds = getBox(this.el, this.el.getObject3D("mesh")).getSize(new THREE.Vector3()); + el.emit("media-loaded"); + if (el.eid && entityExists(APP.world, el.eid)) { + removeComponent(APP.world, MediaLoading, el.eid); + } }; if (this.data.animate) { @@ -342,6 +350,7 @@ AFRAME.registerComponent("media-loader", { try { if ((forceLocalRefresh || srcChanged) && !this.showLoaderTimeout) { this.showLoaderTimeout = setTimeout(this.showLoader, 100); + addComponent(APP.world, MediaLoading, this.el.eid); } //check if url is an anchor hash e.g. #Spawn_Point_1 diff --git a/src/components/pinnable.js b/src/components/pinnable.js index ded6194643..aa1533a746 100644 --- a/src/components/pinnable.js +++ b/src/components/pinnable.js @@ -1,3 +1,6 @@ +import { addComponent, removeComponent } from "bitecs"; +import { Pinnable, Pinned } from "../bit-components"; + AFRAME.registerComponent("pinnable", { schema: { pinned: { default: false } @@ -13,10 +16,18 @@ AFRAME.registerComponent("pinnable", { this.el.addEventListener("owned-pager-page-changed", this._persist); this.el.addEventListener("owned-video-state-changed", this._persistAndAnimate); + + addComponent(APP.world, Pinnable, this.el.object3D.eid); }, update() { this._animate(); + + if (this.data.pinned) { + addComponent(APP.world, Pinned, this.el.object3D.eid); + } else { + removeComponent(APP.world, Pinned, this.el.object3D.eid); + } }, _persistAndAnimate() { diff --git a/src/components/remove-networked-object-button.js b/src/components/remove-networked-object-button.js deleted file mode 100644 index f4210fba8b..0000000000 --- a/src/components/remove-networked-object-button.js +++ /dev/null @@ -1,23 +0,0 @@ -import { removeNetworkedObject } from "../utils/removeNetworkedObject"; - -AFRAME.registerComponent("remove-networked-object-button", { - init() { - this.onClick = () => { - removeNetworkedObject(this.el.sceneEl, this.targetEl); - this.el.parentNode.removeAttribute("visibility-while-frozen"); - this.el.parentNode.setAttribute("visible", false); - }; - - NAF.utils.getNetworkedEntity(this.el).then(networkedEl => { - this.targetEl = networkedEl; - }); - }, - - play() { - this.el.object3D.addEventListener("interact", this.onClick); - }, - - pause() { - this.el.object3D.removeEventListener("interact", this.onClick); - } -}); diff --git a/src/components/set-unowned-body-kinematic.js b/src/components/set-unowned-body-kinematic.js index e9640dc93d..6cfc00cc69 100644 --- a/src/components/set-unowned-body-kinematic.js +++ b/src/components/set-unowned-body-kinematic.js @@ -25,8 +25,5 @@ AFRAME.registerComponent("set-unowned-body-kinematic", { type: "kinematic", collisionFilterMask: COLLISION_LAYERS.UNOWNED_INTERACTABLE }); - if (this.el.components["floaty-object"]) { - this.el.components["floaty-object"].locked = true; - } } }); diff --git a/src/components/shape-helper.js b/src/components/shape-helper.js index 9cc4a11f43..d1c3d02ca0 100644 --- a/src/components/shape-helper.js +++ b/src/components/shape-helper.js @@ -68,7 +68,8 @@ AFRAME.registerComponent("shape-helper", { }, remove: function() { - if (this.uuid !== -1) { + // Removing a body already cleans up it's shapes + if (this.uuid !== -1 && this.bodyHelper.alive) { this.system.removeShapes(this.bodyHelper.uuid, this.uuid); } this.alive = false; diff --git a/src/components/slice9.js b/src/components/slice9.js index ea6828bc0a..86f7d33e50 100644 --- a/src/components/slice9.js +++ b/src/components/slice9.js @@ -92,6 +92,7 @@ AFRAME.registerComponent("slice9", { this.el.setObject3D("mesh", this.plane); }, + // TODO use updateSlice9Geometry regenerateMesh: function() { const data = this.data; let height; @@ -257,5 +258,9 @@ AFRAME.registerComponent("slice9", { } this.setMap(null); + }, + + remove() { + this.geometry?.dispose(); } }); diff --git a/src/components/stats-plus.css b/src/components/stats-plus.css index ec873cf8c1..2dbb12816d 100644 --- a/src/components/stats-plus.css +++ b/src/components/stats-plus.css @@ -25,6 +25,9 @@ -ms-user-select: none; user-select: none; } +:global(.rs-counter-id) { + width: 80px; +} :global(.rs-base) { right: 10px; @@ -34,4 +37,5 @@ -webkit-user-select: none; -ms-user-select: none; user-select: none; + width: 350px; } diff --git a/src/components/stats-plus.js b/src/components/stats-plus.js index e1c8e0c0bf..2aa710df02 100644 --- a/src/components/stats-plus.js +++ b/src/components/stats-plus.js @@ -1,5 +1,7 @@ import "./stats-plus.css"; import qsTruthy from "../utils/qs_truthy"; +import { defineQuery } from "bitecs"; +import { AEntity } from "../bit-components"; function ThreeStats(renderer) { let _rS = null; @@ -75,13 +77,21 @@ function createStats(scene) { values: { fps: { caption: "fps", below: 30 } }, - groups: [{ caption: "Framerate", values: ["fps", "raf", "physics"] }], + groups: [ + { caption: "Framerate", values: ["fps", "raf", "physics"] }, + { caption: "BitECS", values: ["entities", "a-entities", "queries"] } + ], plugins: plugins }); } const HIDDEN_CLASS = "a-hidden"; +let $queries; + +const allEntitiesQuery = defineQuery(); +const aEntityQuery = defineQuery([AEntity]); + AFRAME.registerComponent("stats-plus", { // Whether or not the stats panel is expanded. // Shows FPS counter when collapsed. @@ -139,6 +149,8 @@ AFRAME.registerComponent("stats-plus", { this.initVRStats(); } this.lastUpdate = 0; + + $queries = Object.getOwnPropertySymbols(APP.world).find(s => s.description == "queries"); }, initVRStats() { this.vrPanel = document.createElement("a-entity"); @@ -198,6 +210,10 @@ AFRAME.registerComponent("stats-plus", { stats("FPS").frame(); stats("physics").set(this.el.sceneEl.systems["hubs-systems"].physicsSystem.stepDuration); + stats("queries").set(APP.world[$queries].size); + stats("entities").set(allEntitiesQuery(APP.world).length); + stats("a-entities").set(aEntityQuery(APP.world).length); + stats().update(); } else if (!this.inVR) { // Update the fps counter diff --git a/src/components/super-spawner.js b/src/components/super-spawner.js index af17427d0a..fe08de1fa1 100644 --- a/src/components/super-spawner.js +++ b/src/components/super-spawner.js @@ -151,15 +151,6 @@ AFRAME.registerComponent("super-spawner", { const willAnimateFromCursor = this.data.animateFromCursor && (userinput.get(paths.actions.rightHand.matrix) || userinput.get(paths.actions.leftHand.matrix)); - if (!willAnimateFromCursor) { - if (left) { - interaction.state.leftRemote.held = spawnedEntity; - interaction.state.leftRemote.spawning = true; - } else { - interaction.state.rightRemote.held = spawnedEntity; - interaction.state.rightRemote.spawning = true; - } - } this.activateCooldown(); await waitForEvent("model-loaded", spawnedEntity); @@ -181,12 +172,6 @@ AFRAME.registerComponent("super-spawner", { to: { x: this.handPosition.x, y: this.handPosition.y, z: this.handPosition.z }, easing: "easeInOutBack" }); - } else { - if (left) { - interaction.state.leftRemote.spawning = false; - } else { - interaction.state.rightRemote.spawning = false; - } } // We skip this in the scene preview because diff --git a/src/components/tags.js b/src/components/tags.js index 5d9d14f7d8..7c133488ca 100644 --- a/src/components/tags.js +++ b/src/components/tags.js @@ -1,9 +1,40 @@ -export function isTagged(el, tag) { - return el && el.components && el.components.tags && el.components.tags.data[tag]; -} +import { addComponent, hasComponent } from "bitecs"; +import { + Holdable, + OffersRemoteConstraint, + HandCollisionTarget, + OffersHandConstraint, + TogglesHoveredActionSet, + SingleActionButton, + HoldableButton, + Pen, + HoverMenuChild, + Static, + Inspectable, + PreventAudioBoost, + IgnoreSpaceBubble +} from "../bit-components"; + +const tag2ecs = { + isHoldable: Holdable, + offersRemoteConstraint: OffersRemoteConstraint, + isHandCollisionTarget: HandCollisionTarget, + offersHandConstraint: OffersHandConstraint, + togglesHoveredActionSet: TogglesHoveredActionSet, + singleActionButton: SingleActionButton, + holdableButton: HoldableButton, + isPen: Pen, + isHoverMenuChild: HoverMenuChild, + isStatic: Static, + inspectable: Inspectable, + preventAudioBoost: PreventAudioBoost, + ignoreSpaceBubble: IgnoreSpaceBubble +}; -export function setTag(el, tag, value = true) { - return (el.components.tags.data[tag] = !!value); +// TODO usages of this should be replaced with direct hasComponent calls +// but this also has additional logic to check for non existant object +export function isTagged(elOrObject3D, tag) { + return elOrObject3D && hasComponent(APP.world, tag2ecs[tag], elOrObject3D.eid); } AFRAME.registerComponent("tags", { @@ -27,13 +58,8 @@ AFRAME.registerComponent("tags", { console.warn("Do not edit tags with .setAttribute"); } this.didUpdateOnce = true; - }, - - remove() { - const interaction = this.el.sceneEl.systems.interaction; - if (interaction.isHeld(this.el)) { - interaction.release(this.el); - this.el.sceneEl.systems["hubs-systems"].constraintsSystem.release(this.el); - } + Object.entries(this.data).forEach(([tagName, isSet]) => { + if (isSet) addComponent(APP.world, tag2ecs[tagName], this.el.eid); + }); } }); diff --git a/src/components/transform-object-button.js b/src/components/transform-object-button.js index 320c740f98..08e11ded67 100644 --- a/src/components/transform-object-button.js +++ b/src/components/transform-object-button.js @@ -279,7 +279,7 @@ AFRAME.registerSystem("transform-selected-object", { .applyQuaternion(q.copy(plane.quaternion).invert()) .multiplyScalar(SENSITIVITY / cameraToPlaneDistance); if (this.mode === TRANSFORM_MODE.CURSOR) { - const modify = AFRAME.scenes[0].systems.userinput.get(paths.actions.transformModifier); + const modify = !AFRAME.scenes[0].systems.userinput.get(paths.actions.transformModifier); this.dyAll = this.dyStore + finalProjectedVec.y; this.dyApplied = modify ? this.dyAll : Math.round(this.dyAll / STEP_LENGTH) * STEP_LENGTH; diff --git a/src/constants.js b/src/constants.js index 3e9f7da389..d4242db63c 100644 --- a/src/constants.js +++ b/src/constants.js @@ -13,7 +13,7 @@ const CL = { // @TODO we should split these "sets" off into something other than COLLISION_LAYERS or at least name // them differently to indicate they are a combination of multiple bits CL.DEFAULT_INTERACTABLE = CL.INTERACTABLES | CL.ENVIRONMENT | CL.AVATAR | CL.HANDS | CL.MEDIA_FRAMES; -CL.UNOWNED_INTERACTABLE = CL.INTERACTABLES | CL.HANDS; +CL.UNOWNED_INTERACTABLE = CL.INTERACTABLES | CL.HANDS | CL.MEDIA_FRAMES; CL.DEFAULT_SPAWNER = CL.INTERACTABLES | CL.HANDS; const PRIVACY = "https://www.mozilla.org/en-US/privacy/hubs/"; diff --git a/src/gltf-component-mappings.js b/src/gltf-component-mappings.js index 2cdda43587..5643fd46b3 100644 --- a/src/gltf-component-mappings.js +++ b/src/gltf-component-mappings.js @@ -5,6 +5,9 @@ import { TYPE, SHAPE, FIT } from "three-ammo/constants"; const COLLISION_LAYERS = require("./constants").COLLISION_LAYERS; import { AudioType, DistanceModelType, SourceType } from "./components/audio-params"; import { updateAudioSettings } from "./update-audio-settings"; +import { renderAsEntity } from "./utils/jsx-entity"; +import { Networked } from "./bit-components"; +import { addComponent } from "bitecs"; AFRAME.GLTFModelPlus.registerComponent("duck", "duck", el => { el.setAttribute("duck", ""); @@ -121,23 +124,21 @@ AFRAME.GLTFModelPlus.registerComponent("waypoint", "waypoint", (el, componentNam el.setAttribute("waypoint", componentData); }); -AFRAME.GLTFModelPlus.registerComponent("media-frame", "media-frame", (el, componentName, componentData, components) => { - el.setAttribute("networked", { - template: "#interactable-media-frame", - owner: "scene", - persistent: true, - networkId: components.networked.id - }); - el.setAttribute("shape-helper", { - type: "box", - fit: "manual", - halfExtents: { - x: componentData.bounds.x / 2, - y: componentData.bounds.y / 2, - z: componentData.bounds.z / 2 - } - }); - el.setAttribute("media-frame", componentData); +import { findAncestorWithComponent } from "./utils/scene-graph"; +import { createElementEntity } from "./utils/jsx-entity"; +/** @jsx createElementEntity */ createElementEntity; + +AFRAME.GLTFModelPlus.registerComponent("media-frame", "media-frame", (el, _componentName, componentData) => { + const eid = renderAsEntity(APP.world, ); + + addComponent(APP.world, Networked, eid); + + const networkedEl = findAncestorWithComponent(el, "networked"); + const rootNid = (networkedEl && networkedEl.components.networked.data.networkId) || "scene"; + Networked.id[eid] = APP.getSid(`${rootNid}.${el.object3D.children[0].userData.gltfIndex}`); + APP.world.nid2eid.set(Networked.id[eid], eid); + + el.object3D.add(APP.world.eid2obj.get(eid)); }); AFRAME.GLTFModelPlus.registerComponent("media", "media", (el, componentName, componentData) => { diff --git a/src/hub.html b/src/hub.html index d85b036b40..38217b1572 100644 --- a/src/hub.html +++ b/src/hub.html @@ -109,7 +109,7 @@ - + @@ -153,7 +153,7 @@ - + @@ -218,6 +218,7 @@ @@ -231,7 +232,7 @@ tags="isStatic: true; togglesHoveredActionSet: true; inspectable: true;" listed-media > - + - + - + - - - - -