diff --git a/src/assets/avatars/BotDom_Avatar.glb b/src/assets/avatars/BotDom_Avatar.glb new file mode 100644 index 0000000000000000000000000000000000000000..9c72e3d2fc170d6b0782d741eca441a62780daad Binary files /dev/null and b/src/assets/avatars/BotDom_Avatar.glb differ diff --git a/src/components/debug.js b/src/components/debug.js new file mode 100644 index 0000000000000000000000000000000000000000..77202f03295be677b9156e1ff16f11100ab19351 --- /dev/null +++ b/src/components/debug.js @@ -0,0 +1,25 @@ +AFRAME.registerComponent("lifecycle-checker", { + schema: { + tick: { default: false } + }, + init: function() { + console.log("init", this.el); + }, + update: function() { + console.log("update", this.el); + }, + tick: function() { + if (this.data.tick) { + console.log("tick", this.el); + } + }, + remove: function() { + console.log("remove", this.el); + }, + pause: function() { + console.log("pause", this.el); + }, + play: function() { + console.log("play", this.el); + } +}); diff --git a/src/components/ik-controller.js b/src/components/ik-controller.js index 3b4f323df72d83cfbe0d03ab00094318485eed22..0a1990bd8ede46f4e5bbdde5cfced182ed4f5a3d 100644 --- a/src/components/ik-controller.js +++ b/src/components/ik-controller.js @@ -7,29 +7,27 @@ AFRAME.registerComponent("ik-root", { rightController: { type: "string", default: ".right-controller" } }, update(oldData) { - let updated = false; - if (this.data.camera !== oldData.camera) { this.camera = this.el.querySelector(this.data.camera); - updated = true; } if (this.data.leftController !== oldData.leftController) { this.leftController = this.el.querySelector(this.data.leftController); - updated = true; } if (this.data.rightController !== oldData.rightController) { this.rightController = this.el.querySelector(this.data.rightController); - updated = true; - } - - if (updated) { - this.el.querySelector("[ik-controller]").components["ik-controller"].updateIkRoot(this); } } }); +function findIKRoot(entity) { + while (entity && !(entity.components && entity.components["ik-root"])) { + entity = entity.parentNode; + } + return entity && entity.components["ik-root"]; +} + AFRAME.registerComponent("ik-controller", { schema: { leftEye: { type: "string", default: ".LeftEye" }, @@ -65,6 +63,8 @@ AFRAME.registerComponent("ik-controller", { this.rootToChest = new Matrix4(); this.invRootToChest = new Matrix4(); + this.ikRoot = findIKRoot(this.el); + this.hands = { left: { lastVisible: true, @@ -124,10 +124,6 @@ AFRAME.registerComponent("ik-controller", { .negate(); }, - updateIkRoot(ikRoot) { - this.ikRoot = ikRoot; - }, - tick(time, dt) { if (!this.ikRoot) { return; diff --git a/src/components/player-info.js b/src/components/player-info.js new file mode 100644 index 0000000000000000000000000000000000000000..8cb265a67b9c67ac26510d6b8a03efb59703c931 --- /dev/null +++ b/src/components/player-info.js @@ -0,0 +1,31 @@ +AFRAME.registerComponent("player-info", { + schema: { + displayName: { type: "string" }, + avatar: { type: "string" } + }, + init() { + this.applyProperties = this.applyProperties.bind(this); + }, + play() { + this.el.addEventListener("model-loaded", this.applyProperties); + }, + pause() { + this.el.removeEventListener("model-loaded", this.applyProperties); + }, + update(oldProps) { + this.applyProperties(); + }, + applyProperties() { + const nametagEl = this.el.querySelector(".nametag"); + if (this.data.displayName && nametagEl) { + nametagEl.setAttribute("text", { + value: this.data.displayName + }); + } + + const modelEl = this.el.querySelector(".model"); + if (this.data.avatar && modelEl) { + modelEl.setAttribute("src", this.data.avatar); + } + } +}); diff --git a/src/elements/a-gltf-entity.js b/src/elements/a-gltf-entity.js index 3c9c07031eecbf21069569b0f2738fe48afcea25..e88f7a76bf6ee4218f37997d9497e576b3bed55a 100644 --- a/src/elements/a-gltf-entity.js +++ b/src/elements/a-gltf-entity.js @@ -22,19 +22,21 @@ AFRAME.AGLTFEntity = { // From https://gist.github.com/cdata/f2d7a6ccdec071839bc1954c32595e87 // Tracking glTF cloning here: https://github.com/mrdoob/three.js/issues/11573 function cloneGltf(gltf) { - const clone = { - animations: gltf.animations, - scene: gltf.scene.clone(true) - }; - const skinnedMeshes = {}; - gltf.scene.traverse(node => { + if (!node.name) { + node.name = node.uuid; + } if (node.isSkinnedMesh) { skinnedMeshes[node.name] = node; } }); + const clone = { + animations: gltf.animations, + scene: gltf.scene.clone(true) + }; + const cloneBones = {}; const cloneSkinnedMeshes = {}; @@ -68,18 +70,24 @@ function cloneGltf(gltf) { return clone; } -const inflateEntities = function(classPrefix, parentEl, node) { +const inflateEntities = function(parentEl, node) { // setObject3D mutates the node's parent, so we have to copy const children = node.children.slice(0); const el = document.createElement("a-entity"); // Remove invalid CSS class name characters. - const className = node.name.replace(/[^\w-]/g, ""); - el.classList.add(classPrefix + className); + const className = (node.name || node.uuid).replace(/[^\w-]/g, ""); + el.classList.add(className); parentEl.appendChild(el); - // Copy over transform to the THREE.Group and reset the actual transform of the Object3D + // AFRAME rotation component expects rotations in YXZ, convert it + if (node.rotation.order !== "YXZ") { + node.rotation.setFromQuaternion(node.quaternion, "YXZ"); + } + + // Copy over the object's transform to the THREE.Group and reset the actual transform of the Object3D + // all updates to the object should be done through the THREE.Group wrapper el.setAttribute("position", { x: node.position.x, y: node.position.y, @@ -95,10 +103,8 @@ const inflateEntities = function(classPrefix, parentEl, node) { y: node.scale.y, z: node.scale.z }); - - node.position.set(0, 0, 0); - node.rotation.set(0, 0, 0); - node.scale.set(1, 1, 1); + node.matrixAutoUpdate = false; + node.matrix.identity(); el.setObject3D(node.type.toLowerCase(), node); @@ -116,7 +122,6 @@ const inflateEntities = function(classPrefix, parentEl, node) { } const entityComponents = node.userData.components; - if (entityComponents) { for (const prop in entityComponents) { if (entityComponents.hasOwnProperty(prop)) { @@ -130,116 +135,184 @@ const inflateEntities = function(classPrefix, parentEl, node) { } children.forEach(childNode => { - inflateEntities(classPrefix, el, childNode); + inflateEntities(el, childNode); }); -}; -function attachTemplate(templateEl) { - const selector = templateEl.getAttribute("data-selector"); - const targetEls = templateEl.parentNode.querySelectorAll(selector); - const clone = document.importNode(templateEl.content, true); - const templateRoot = clone.firstElementChild; + return el; +}; +function attachTemplate(root, { selector, templateRoot }) { + const targetEls = root.querySelectorAll(selector); for (const el of targetEls) { + const root = templateRoot.cloneNode(true); // Merge root element attributes with the target element - for (const { name, value } of templateRoot.attributes) { + for (const { name, value } of root.attributes) { el.setAttribute(name, value); } // Append all child elements - for (const child of templateRoot.children) { - el.appendChild(document.importNode(child, true)); + for (const child of root.children) { + el.appendChild(child); } } } +function nextTick() { + return new Promise(resolve => { + setTimeout(resolve, 0); + }); +} + +function cachedLoadGLTF(src, onProgress) { + return new Promise((resolve, reject) => { + // Load the gltf model from the cache if it exists. + if (GLTFCache[src]) { + // Use a cloned copy of the cached model. + resolve(cloneGltf(GLTFCache[src])); + } else { + // Otherwise load the new gltf model. + new THREE.GLTFLoader().load( + src, + model => { + if (!GLTFCache[src]) { + // Store a cloned copy of the gltf model. + GLTFCache[src] = cloneGltf(model); + } + resolve(model); + }, + onProgress, + reject + ); + } + }); +} + AFRAME.registerElement("a-gltf-entity", { prototype: Object.create(AFRAME.AEntity.prototype, { load: { - value() { + async value() { if (this.hasLoaded || !this.parentEl) { return; } - // Get the src url. - let src = this.getAttribute("src"); - - // If the src attribute is a selector, get the url from the asset item. - if (src.charAt(0) === "#") { - const assetEl = document.getElementById(src.substring(1)); - - const fallbackSrc = assetEl.getAttribute("src"); - const highSrc = assetEl.getAttribute("high-src"); - const lowSrc = assetEl.getAttribute("low-src"); + // The code above and below this are from AEntity.prototype.load, we need to monkeypatch in gltf loading mid function + this.loadTemplates(); + await this.applySrc(this.getAttribute("src")); + // - if (highSrc && window.APP.quality === "high") { - src = highSrc; - } else if (lowSrc && window.APP.quality === "low") { - src = lowSrc; - } else { - src = fallbackSrc; + AFRAME.ANode.prototype.load.call(this, () => { + // Check if entity was detached while it was waiting to load. + if (!this.parentEl) { + return; } - } - const onLoad = gltfModel => { - if (!GLTFCache[src]) { - // Store a cloned copy of the gltf model. - GLTFCache[src] = cloneGltf(gltfModel); + this.updateComponents(); + if (this.isScene || this.parentEl.isPlaying) { + this.play(); } + }); + } + }, - this.model = gltfModel.scene || gltfModel.scenes[0]; - this.model.animations = gltfModel.animations; + loadTemplates: { + value() { + this.templates = []; + this.querySelectorAll(":scope > template").forEach(templateEl => + this.templates.push({ + selector: templateEl.getAttribute("data-selector"), + templateRoot: document.importNode(templateEl.content.firstElementChild, true) + }) + ); + } + }, + + applySrc: { + async value(src) { + try { + // If the src attribute is a selector, get the url from the asset item. + if (src && src.charAt(0) === "#") { + const assetEl = document.getElementById(src.substring(1)); + + const fallbackSrc = assetEl.getAttribute("src"); + const highSrc = assetEl.getAttribute("high-src"); + const lowSrc = assetEl.getAttribute("low-src"); + + if (highSrc && window.APP.quality === "high") { + src = highSrc; + } else if (lowSrc && window.APP.quality === "low") { + src = lowSrc; + } else { + src = fallbackSrc; + } + } - this.setObject3D("mesh", this.model); - this.emit("model-loaded", { format: "gltf", model: this.model }); + if (src === this.lastSrc) return; + this.lastSrc = src; - if (this.getAttribute("inflate")) { - inflate(this.model, finalizeLoad); - } else { - finalizeLoad(); + if (!src) { + if (this.inflatedEl) { + console.warn("gltf-entity set to an empty source, unloading inflated model."); + this.removeInflatedEl(); + } + return; } - }; - const inflate = (model, callback) => { - inflateEntities("", this, model); - this.querySelectorAll(":scope > template").forEach(attachTemplate); + const model = await cachedLoadGLTF(src); - // Wait one tick for the appended custom elements to be connected before calling finalizeLoad - setTimeout(callback, 0); - }; + // If we started loading something else already + // TODO: there should be a way to cancel loading instead + if (src != this.lastSrc) return; - const finalizeLoad = () => { - AFRAME.ANode.prototype.load.call(this, () => { - // Check if entity was detached while it was waiting to load. - if (!this.parentEl) { - return; - } + // If we had inflated something already before, clean that up + this.removeInflatedEl(); - this.updateComponents(); - if (this.isScene || this.parentEl.isPlaying) { - this.play(); - } - }); - }; + this.model = model.scene || model.scenes[0]; + this.model.animations = model.animations; + + this.setObject3D("mesh", this.model); - // Load the gltf model from the cache if it exists. - const gltf = GLTFCache[src]; + if (this.getAttribute("inflate")) { + this.inflatedEl = inflateEntities(this, this.model); + // TODO: Still don't fully understand the lifecycle here and how it differs between browsers, we should dig in more + // Wait one tick for the appended custom elements to be connected before attaching templates + await nextTick(); + if (src != this.lastSrc) return; // TODO: there must be a nicer pattern for this + this.templates.forEach(attachTemplate.bind(null, this)); + } - if (gltf) { - // Use a cloned copy of the cached model. - const clonedGltf = cloneGltf(gltf); - onLoad(clonedGltf); - return; + this.emit("model-loaded", { format: "gltf", model: this.model }); + } catch (e) { + const message = (e && e.message) || "Failed to load glTF model"; + console.error(message); + this.emit("model-error", { format: "gltf", src }); + } + } + }, + + removeInflatedEl: { + value() { + if (this.inflatedEl) { + this.inflatedEl.parentNode.removeChild(this.inflatedEl); + delete this.inflatedEl; } + } + }, - // Otherwise load the new gltf model. - new THREE.GLTFLoader().load(src, onLoad, undefined /* onProgress */, error => { - // On glTF load error + attributeChangedCallback: { + value(attr, oldVal, newVal) { + if (attr === "src") { + this.applySrc(newVal); + } + AFRAME.AEntity.prototype.attributeChangedCallback.call(this, attr, oldVal, newVal); + } + }, - const message = error && error.message ? error.message : "Failed to load glTF model"; - console.warn(message); - this.emit("model-error", { format: "gltf", src }); - }); + setAttribute: { + value(attr, arg1, arg2) { + if (attr === "src") { + this.applySrc(arg1); + } + AFRAME.AEntity.prototype.setAttribute.call(this, attr, arg1, arg2); } } }) diff --git a/src/network-schemas.js b/src/network-schemas.js index 4632a5933807b1e97132c726fcc335afa6b68964..54cc0b63ba40efed17baf43c0d3554b1ea2fa4bc 100644 --- a/src/network-schemas.js +++ b/src/network-schemas.js @@ -5,6 +5,7 @@ function registerNetworkSchemas() { "position", "rotation", "scale", + "player-info", { selector: ".camera", component: "position" @@ -36,11 +37,6 @@ function registerNetworkSchemas() { { selector: ".right-controller", component: "visible" - }, - { - selector: ".nametag", - component: "text", - property: "value" } ] }); diff --git a/src/room.html b/src/room.html index 52fd9ddd2a9ab2cdc4b54dba0f0093c55dee0d7f..0bc4d32f480927adc71b3d6fee8f06ed1891a60b 100644 --- a/src/room.html +++ b/src/room.html @@ -30,7 +30,7 @@ high-src="./assets/avatars/BotDefault_Avatar.glb" low-src="./assets/avatars/BotDefault_Avatar_Unlit.glb" ></a-progressive-asset> - + <a-asset-item id="bot-dom-mesh" response-type="arraybuffer" src="./assets/avatars/BotDom_Avatar.glb"></a-asset-item> <a-asset-item id="watch-model" response-type="arraybuffer" src="./assets/hud/watch.glb"></a-asset-item> <a-asset-item id="meeting-space1-mesh" response-type="arraybuffer" src="./assets/environments/MeetingSpace1_mesh.glb"></a-asset-item> @@ -48,16 +48,16 @@ </template> <template id="remote-avatar-template"> - <a-entity ik-root> + <a-entity ik-root player-info> <a-entity class="camera"></a-entity> <a-entity class="left-controller"></a-entity> <a-entity class="right-controller"></a-entity> - <a-gltf-entity src="#bot-skinned-mesh" inflate="true" ik-controller > + <a-gltf-entity class="model" inflate="true"> <template data-selector=".RootScene"> - <a-entity animation-mixer ></a-entity> + <a-entity ik-controller animation-mixer></a-entity> </template> <template data-selector=".Neck"> @@ -81,7 +81,7 @@ </a-entity> </template> - <template selector=".LeftHand"> + <template data-selector=".LeftHand"> <a-entity personal-space-invader ></a-entity> </template> @@ -147,6 +147,7 @@ wasd-to-analog2d character-controller="pivot: #player-camera" ik-root + player-info > <a-entity id="player-camera" @@ -181,9 +182,13 @@ haptic-feedback ></a-entity> - <a-gltf-entity src="#bot-skinned-mesh" inflate="true" ik-controller > + <a-gltf-entity class="model" inflate="true"> <template data-selector=".RootScene"> - <a-entity animation-mixer animated-robot-hands ></a-entity> + <a-entity + ik-controller + animated-robot-hands + animation-mixer + ></a-entity> </template> <template data-selector=".Neck"> diff --git a/src/room.js b/src/room.js index b09ff896a09145e54bb76ea45f9f659606eaa022..b8e18315139946b4afb258c256f753466ffe79b8 100644 --- a/src/room.js +++ b/src/room.js @@ -37,6 +37,8 @@ import "./components/layers"; import "./components/spawn-controller"; import "./components/animated-robot-hands"; import "./components/hide-when-quality"; +import "./components/player-info"; +import "./components/debug"; import "./components/animation-mixer"; import "./components/loop-animation"; @@ -94,7 +96,7 @@ const concurrentLoadDetector = new ConcurrentLoadDetector(); concurrentLoadDetector.start(); // Always layer in any new default profile bits -store.update({ profile: { ...generateDefaultProfile(), ...(store.state.profile || {}) }}) +store.update({ profile: { ...generateDefaultProfile(), ...(store.state.profile || {}) } }); async function shareMedia(audio, video) { const constraints = { @@ -128,15 +130,21 @@ async function exitScene() { document.body.removeChild(scene); } -function setNameTagFromStore() { - const myNametag = document.querySelector("#player-rig .nametag"); - myNametag.setAttribute("text", "value", store.state.profile.display_name); +function updatePlayerInfoFromStore() { + const qs = queryString.parse(location.search); + const playerRig = document.querySelector("#player-rig"); + playerRig.setAttribute("player-info", { + displayName: store.state.profile.display_name, + avatar: qs.avatar || "#bot-skinned-mesh" + }); } async function enterScene(mediaStream, enterInVR) { const scene = document.querySelector("a-scene"); - document.querySelector("a-scene canvas").classList.remove("blurred") - scene.setAttribute("networked-scene", "adapter: janus; audio: true; debug: true; connectOnLoad: false;"); + const playerRig = document.querySelector("#player-rig"); + const qs = queryString.parse(location.search); + + document.querySelector("a-scene canvas").classList.remove("blurred"); registerNetworkSchemas(); if (enterInVR) { @@ -144,12 +152,13 @@ async function enterScene(mediaStream, enterInVR) { } AFRAME.registerInputActions(inGameActions, "default"); - document.querySelector("#player-camera").setAttribute("look-controls", "pointerLockEnabled: true;"); - const qs = queryString.parse(location.search); - scene.setAttribute("networked-scene", { + adapter: "janus", + audio: true, + debug: true, + connectOnLoad: false, room: qs.room && !isNaN(parseInt(qs.room)) ? parseInt(qs.room) : 1, serverURL: process.env.JANUS_SERVER }); @@ -159,12 +168,11 @@ async function enterScene(mediaStream, enterInVR) { } if (isMobile || qs.mobile) { - const playerRig = document.querySelector("#player-rig"); playerRig.setAttribute("virtual-gamepad-controls", {}); } - setNameTagFromStore(); - store.addEventListener('statechanged', setNameTagFromStore); + updatePlayerInfoFromStore(); + store.addEventListener("statechanged", updatePlayerInfoFromStore); const avatarScale = parseInt(qs.avatarScale, 10); @@ -212,27 +220,31 @@ async function enterScene(mediaStream, enterInVR) { } } -function onConnect() { -} +function onConnect() {} function mountUI(scene) { const qs = queryString.parse(location.search); - const disableAutoExitOnConcurrentLoad = qs.allow_multi === "true" + const disableAutoExitOnConcurrentLoad = qs.allow_multi === "true"; let forcedVREntryType = null; if (qs.vr_entry_type) { forcedVREntryType = qs.vr_entry_type; } - const uiRoot = ReactDOM.render(<UIRoot {...{ - scene, - enterScene, - exitScene, - concurrentLoadDetector, - disableAutoExitOnConcurrentLoad, - forcedVREntryType, - store - }} />, document.getElementById("ui-root")); + const uiRoot = ReactDOM.render( + <UIRoot + {...{ + scene, + enterScene, + exitScene, + concurrentLoadDetector, + disableAutoExitOnConcurrentLoad, + forcedVREntryType, + store + }} + />, + document.getElementById("ui-root") + ); getAvailableVREntryTypes().then(availableVREntryTypes => { uiRoot.setState({ availableVREntryTypes });