diff --git a/package-lock.json b/package-lock.json index 0bbad51e84..fef46892ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -518,7 +518,9 @@ "requires": { "@tweenjs/tween.js": "^16.8.0", "browserify-css": "^0.8.2", + "debug": "github:ngokevin/debug#ef5f8e66d49ce8bc64c6f282c15f8b7164409e3a", "deep-assign": "^2.0.0", + "document-register-element": "github:dmarcos/document-register-element#8ccc532b7f3744be954574caf3072a5fd260ca90", "envify": "^3.4.1", "load-bmfont": "^1.2.3", "object-assign": "^4.0.1", @@ -532,11 +534,7 @@ "dependencies": { "debug": { "version": "github:ngokevin/debug#ef5f8e66d49ce8bc64c6f282c15f8b7164409e3a", - "from": "github:ngokevin/debug#ef5f8e66d49ce8bc64c6f282c15f8b7164409e3a" - }, - "document-register-element": { - "version": "github:dmarcos/document-register-element#8ccc532b7f3744be954574caf3072a5fd260ca90", - "from": "github:dmarcos/document-register-element#8ccc532b7f3744be954574caf3072a5fd260ca90" + "from": "github:ngokevin/debug#noTimestamp" }, "three": { "version": "0.94.0", @@ -3906,6 +3904,10 @@ "esutils": "^2.0.2" } }, + "document-register-element": { + "version": "github:dmarcos/document-register-element#8ccc532b7f3744be954574caf3072a5fd260ca90", + "from": "github:dmarcos/document-register-element#8ccc532b7" + }, "dom-converter": { "version": "0.1.4", "resolved": "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.1.4.tgz", @@ -10542,6 +10544,12 @@ } } }, + "raw-loader": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-0.5.1.tgz", + "integrity": "sha1-DD0L6u2KAclm2Xh793goElKpeao=", + "dev": true + }, "react": { "version": "16.4.1", "resolved": "https://registry.yarnpkg.com/react/-/react-16.4.1.tgz", diff --git a/package.json b/package.json index d9e3d59c08..0462a0f4e7 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "htmlhint": "^0.9.13", "node-sass": "^4.9.3", "prettier": "^1.7.0", + "raw-loader": "^0.5.1", "rimraf": "^2.6.2", "sass-loader": "^6.0.7", "selfsigned": "^1.10.2", diff --git a/src/components/hover-visuals.js b/src/components/hover-visuals.js new file mode 100644 index 0000000000..ec0111e269 --- /dev/null +++ b/src/components/hover-visuals.js @@ -0,0 +1,40 @@ +const interactorTransform = []; + +/** + * Applies effects to a hoverer based on hover state. + * @namespace interactables + * @component hover-visuals + */ +AFRAME.registerComponent("hover-visuals", { + schema: { + hand: { type: "string" }, + controller: { type: "selector" } + }, + init() { + // uniforms are set from the component responsible for loading the mesh. + this.uniforms = null; + }, + remove() { + this.uniforms = null; + }, + tick() { + if (!this.uniforms || !this.uniforms.size) return; + + this.el.object3D.matrixWorld.toArray(interactorTransform); + const hovering = this.data.controller.components["super-hands"].state.has("hover-start"); + + for (const uniform of this.uniforms.values()) { + if (this.data.hand === "left") { + uniform.hubs_HighlightInteractorOne.value = hovering; + uniform.hubs_InteractorOnePos.value[0] = interactorTransform[12]; + uniform.hubs_InteractorOnePos.value[1] = interactorTransform[13]; + uniform.hubs_InteractorOnePos.value[2] = interactorTransform[14]; + } else { + uniform.hubs_HighlightInteractorTwo.value = hovering; + uniform.hubs_InteractorTwoPos.value[0] = interactorTransform[12]; + uniform.hubs_InteractorTwoPos.value[1] = interactorTransform[13]; + uniform.hubs_InteractorTwoPos.value[2] = interactorTransform[14]; + } + } + } +}); diff --git a/src/components/hoverable-visuals.js b/src/components/hoverable-visuals.js new file mode 100644 index 0000000000..aa33cd4414 --- /dev/null +++ b/src/components/hoverable-visuals.js @@ -0,0 +1,76 @@ +const interactorOneTransform = []; +const interactorTwoTransform = []; + +/** + * Applies effects to a hoverable based on hover state. + * @namespace interactables + * @component hoverable-visuals + */ +AFRAME.registerComponent("hoverable-visuals", { + schema: { + cursorController: { type: "selector" }, + enableSweepingEffect: { type: "boolean", default: true } + }, + init() { + // uniforms and boundingSphere are set from the component responsible for loading the mesh. + this.uniforms = null; + this.boundingSphere = new THREE.Sphere(); + + this.sweepParams = [0, 0]; + }, + remove() { + this.uniforms = null; + this.boundingBox = null; + }, + tick(time) { + if (!this.uniforms || !this.uniforms.size) return; + + const { hoverers } = this.el.components["hoverable"]; + + let interactorOne, interactorTwo; + for (const hoverer of hoverers) { + if (hoverer.id === "player-left-controller") { + interactorOne = hoverer.object3D; + } else if (hoverer.id === "cursor") { + if (this.data.cursorController.components["cursor-controller"].enabled) { + interactorTwo = hoverer.object3D; + } + } else { + interactorTwo = hoverer.object3D; + } + } + + if (interactorOne) { + interactorOne.matrixWorld.toArray(interactorOneTransform); + } + if (interactorTwo) { + interactorTwo.matrixWorld.toArray(interactorTwoTransform); + } + + if (interactorOne || interactorTwo) { + const worldY = this.el.object3D.matrixWorld.elements[13]; + const scaledRadius = this.el.object3D.scale.y * this.boundingSphere.radius; + this.sweepParams[0] = worldY - scaledRadius; + this.sweepParams[1] = worldY + scaledRadius; + } + + for (const uniform of this.uniforms.values()) { + uniform.hubs_EnableSweepingEffect.value = this.data.enableSweepingEffect; + uniform.hubs_SweepParams.value = this.sweepParams; + + uniform.hubs_HighlightInteractorOne.value = !!interactorOne; + uniform.hubs_InteractorOnePos.value[0] = interactorOneTransform[12]; + uniform.hubs_InteractorOnePos.value[1] = interactorOneTransform[13]; + uniform.hubs_InteractorOnePos.value[2] = interactorOneTransform[14]; + + uniform.hubs_HighlightInteractorTwo.value = !!interactorTwo; + uniform.hubs_InteractorTwoPos.value[0] = interactorTwoTransform[12]; + uniform.hubs_InteractorTwoPos.value[1] = interactorTwoTransform[13]; + uniform.hubs_InteractorTwoPos.value[2] = interactorTwoTransform[14]; + + if (interactorOne || interactorTwo) { + uniform.hubs_Time.value = time; + } + } + } +}); diff --git a/src/components/media-loader.js b/src/components/media-loader.js index 5dcd47beef..d3f595cf22 100644 --- a/src/components/media-loader.js +++ b/src/components/media-loader.js @@ -1,9 +1,10 @@ import { getBox, getScaleCoefficient } from "../utils/auto-box-collider"; -import { guessContentType, proxiedUrlFor, resolveUrl } from "../utils/media-utils"; +import { guessContentType, proxiedUrlFor, resolveUrl, injectCustomShaderChunks } from "../utils/media-utils"; import { addAnimationComponents } from "../utils/animation"; import "three/examples/js/loaders/GLTFLoader"; import loadingObjectSrc from "../assets/LoadingObject_Atom.glb"; + const gltfLoader = new THREE.GLTFLoader(); let loadingObject; gltfLoader.load(loadingObjectSrc, gltf => { @@ -18,6 +19,8 @@ const fetchMaxContentIndex = url => { return fetch(url).then(r => parseInt(r.headers.get("x-max-content-index"))); }; +const boundingBox = new THREE.Box3(); + AFRAME.registerComponent("media-loader", { schema: { src: { type: "string" }, @@ -30,6 +33,7 @@ AFRAME.registerComponent("media-loader", { this.onError = this.onError.bind(this); this.showLoader = this.showLoader.bind(this); this.clearLoadingTimeout = this.clearLoadingTimeout.bind(this); + this.onMediaLoaded = this.onMediaLoaded.bind(this); this.shapeAdded = false; this.hasBakedShapes = false; }, @@ -100,6 +104,20 @@ AFRAME.registerComponent("media-loader", { delete this.showLoaderTimeout; }, + setupHoverableVisuals() { + const hoverableVisuals = this.el.components["hoverable-visuals"]; + if (hoverableVisuals) { + hoverableVisuals.uniforms = injectCustomShaderChunks(this.el.object3D); + boundingBox.setFromObject(this.el.object3DMap.mesh); + boundingBox.getBoundingSphere(hoverableVisuals.boundingSphere); + } + }, + + onMediaLoaded() { + this.clearLoadingTimeout(); + this.setupHoverableVisuals(); + }, + async update(oldData) { try { const { src } = this.data; @@ -135,13 +153,13 @@ AFRAME.registerComponent("media-loader", { if (contentType.startsWith("video/") || contentType.startsWith("audio/")) { this.el.removeAttribute("gltf-model-plus"); this.el.removeAttribute("media-image"); - this.el.addEventListener("video-loaded", this.clearLoadingTimeout, { once: true }); + this.el.addEventListener("video-loaded", this.onMediaLoaded, { once: true }); this.el.setAttribute("media-video", { src: accessibleUrl }); this.el.setAttribute("position-at-box-shape-border", { dirs: ["forward", "back"] }); } else if (contentType.startsWith("image/")) { this.el.removeAttribute("gltf-model-plus"); this.el.removeAttribute("media-video"); - this.el.addEventListener("image-loaded", this.clearLoadingTimeout, { once: true }); + this.el.addEventListener("image-loaded", this.onMediaLoaded, { once: true }); this.el.removeAttribute("media-pager"); this.el.setAttribute("media-image", { src: accessibleUrl, contentType }); this.el.setAttribute("position-at-box-shape-border", { dirs: ["forward", "back"] }); @@ -152,7 +170,7 @@ AFRAME.registerComponent("media-loader", { // 1. we pass the canonical URL to the pager so it can easily make subresource URLs // 2. we don't remove the media-image component -- media-pager uses that internally this.el.setAttribute("media-pager", { src: canonicalUrl }); - this.el.addEventListener("preview-loaded", this.clearLoadingTimeout, { once: true }); + this.el.addEventListener("preview-loaded", this.onMediaLoaded, { once: true }); this.el.setAttribute("position-at-box-shape-border", { dirs: ["forward", "back"] }); } else if ( contentType.includes("application/octet-stream") || @@ -168,6 +186,7 @@ AFRAME.registerComponent("media-loader", { this.clearLoadingTimeout(); this.hasBakedShapes = !!(this.el.body && this.el.body.shapes.length > (this.shapeAdded ? 1 : 0)); this.setShapeAndScale(this.data.resize); + this.setupHoverableVisuals(); addAnimationComponents(this.el); }, { once: true } diff --git a/src/components/player-info.js b/src/components/player-info.js index a7e0812f88..7c5cbd89e1 100644 --- a/src/components/player-info.js +++ b/src/components/player-info.js @@ -1,3 +1,5 @@ +import { injectCustomShaderChunks } from "../utils/media-utils"; + /** * Sets player info state, including avatar choice and display name. * @namespace avatar @@ -32,5 +34,10 @@ AFRAME.registerComponent("player-info", { if (this.data.avatarSrc && modelEl) { modelEl.setAttribute("gltf-model-plus", "src", this.data.avatarSrc); } + + const uniforms = injectCustomShaderChunks(this.el.object3D); + this.el.querySelectorAll("[hover-visuals]").forEach(el => { + el.components["hover-visuals"].uniforms = uniforms; + }); } }); diff --git a/src/components/super-spawner.js b/src/components/super-spawner.js index 7ff4e1189f..c5e557401c 100644 --- a/src/components/super-spawner.js +++ b/src/components/super-spawner.js @@ -85,6 +85,8 @@ AFRAME.registerComponent("super-spawner", { this.onSpawnEvent = this.onSpawnEvent.bind(this); this.sceneEl = document.querySelector("a-scene"); + + this.el.setAttribute("hoverable-visuals", { cursorController: "#cursor-controller", enableSweepingEffect: false }); }, play() { diff --git a/src/hub.html b/src/hub.html index fc27508c68..114e811be9 100644 --- a/src/hub.html +++ b/src/hub.html @@ -145,6 +145,7 @@ grabbable stretchable="useWorldPosition: true; usePhysics: never" hoverable + hoverable-visuals="cursorController: #cursor-controller" auto-scale-cannon-physics-body sticky-object="autoLockOnRelease: true; autoLockOnLoad: true;" position-at-box-shape-border="target:.delete-button" @@ -436,11 +437,11 @@ diff --git a/src/hub.js b/src/hub.js index f16044fa87..221c0212e2 100644 --- a/src/hub.js +++ b/src/hub.js @@ -33,6 +33,8 @@ import "./components/virtual-gamepad-controls"; import "./components/ik-controller"; import "./components/hand-controls2"; import "./components/character-controller"; +import "./components/hoverable-visuals"; +import "./components/hover-visuals"; import "./components/haptic-feedback"; import "./components/networked-video-player"; import "./components/offset-relative-to"; diff --git a/src/materials/MobileStandardMaterial.js b/src/materials/MobileStandardMaterial.js index aa9e10de10..10657605f2 100644 --- a/src/materials/MobileStandardMaterial.js +++ b/src/materials/MobileStandardMaterial.js @@ -74,6 +74,8 @@ void main() { `; export default class MobileStandardMaterial extends THREE.ShaderMaterial { + type = "MobileStandardMaterial"; + isMobileStandardMaterial = true; static fromStandardMaterial(material) { const parameters = { vertexShader: VERTEX_SHADER, @@ -107,4 +109,7 @@ export default class MobileStandardMaterial extends THREE.ShaderMaterial { return mobileMaterial; } + clone() { + return MobileStandardMaterial.fromStandardMaterial(this); + } } diff --git a/src/utils/media-highlight-frag.glsl b/src/utils/media-highlight-frag.glsl new file mode 100644 index 0000000000..82214980d5 --- /dev/null +++ b/src/utils/media-highlight-frag.glsl @@ -0,0 +1,33 @@ +if (hubs_HighlightInteractorOne || hubs_HighlightInteractorTwo) { + float ratio = 0.0; + + if (hubs_EnableSweepingEffect) { + float size = hubs_SweepParams.t - hubs_SweepParams.s; + float line = mod(hubs_Time / 3000.0 * size, size * 2.0) + hubs_SweepParams.s - size / 2.0; + + if (hubs_WorldPosition.y < line) { + // Highlight with a sweeping gradient. + ratio = max(0.0, 1.0 - (line - hubs_WorldPosition.y) / size * 3.0); + } + } + + // Highlight with a gradient falling off with distance. + float pulse = 9.0 + 3.0 * (sin(hubs_Time / 1000.0) + 1.0); + + if (hubs_HighlightInteractorOne) { + float dist1 = distance(hubs_WorldPosition, hubs_InteractorOnePos); + ratio += -min(1.0, pow(dist1 * pulse, 3.0)) + 1.0; + } + + if (hubs_HighlightInteractorTwo) { + float dist2 = distance(hubs_WorldPosition, hubs_InteractorTwoPos); + ratio += -min(1.0, pow(dist2 * pulse, 3.0)) + 1.0; + } + + ratio = min(1.0, ratio); + + // Gamma corrected highlight color + vec3 highlightColor = vec3(0.184, 0.499, 0.933); + + gl_FragColor.rgb = (gl_FragColor.rgb * (1.0 - ratio)) + (highlightColor * ratio); +} diff --git a/src/utils/media-utils.js b/src/utils/media-utils.js index d72a453fa3..4750ba6039 100644 --- a/src/utils/media-utils.js +++ b/src/utils/media-utils.js @@ -1,5 +1,7 @@ import { objectTypeForOriginAndContentType } from "../object-types"; import { getReticulumFetchUrl } from "./phoenix-utils"; +import mediaHighlightFrag from "./media-highlight-frag.glsl"; + const mediaAPIEndpoint = getReticulumFetchUrl("/api/v1/media"); const commonKnownContentTypes = { @@ -136,3 +138,64 @@ export const addMedia = (src, template, contentOrigin, resolve = false, resize = return { entity, orientation }; }; + +export function injectCustomShaderChunks(obj) { + const vertexRegex = /\bskinning_vertex\b/; + const fragRegex = /\bgl_FragColor\b/; + const validMaterials = ["MeshStandardMaterial", "MeshBasicMaterial", "MobileStandardMaterial"]; + + const shaderUniforms = new Map(); + + obj.traverse(object => { + if (!object.material || !validMaterials.includes(object.material.type)) { + return; + } + object.material = object.material.clone(); + object.material.onBeforeCompile = shader => { + if (!vertexRegex.test(shader.vertexShader)) return; + + shader.uniforms.hubs_EnableSweepingEffect = { value: false }; + shader.uniforms.hubs_SweepParams = { value: [0, 0] }; + shader.uniforms.hubs_InteractorOnePos = { value: [0, 0, 0] }; + shader.uniforms.hubs_InteractorTwoPos = { value: [0, 0, 0] }; + shader.uniforms.hubs_HighlightInteractorOne = { value: false }; + shader.uniforms.hubs_HighlightInteractorTwo = { value: false }; + shader.uniforms.hubs_Time = { value: 0 }; + + const vchunk = ` + if (hubs_HighlightInteractorOne || hubs_HighlightInteractorTwo) { + vec4 wt = modelMatrix * vec4(transformed, 1); + + // Used in the fragment shader below. + hubs_WorldPosition = wt.xyz; + } + `; + + const vlines = shader.vertexShader.split("\n"); + const vindex = vlines.findIndex(line => vertexRegex.test(line)); + vlines.splice(vindex + 1, 0, vchunk); + vlines.unshift("varying vec3 hubs_WorldPosition;"); + vlines.unshift("uniform bool hubs_HighlightInteractorOne;"); + vlines.unshift("uniform bool hubs_HighlightInteractorTwo;"); + shader.vertexShader = vlines.join("\n"); + + const flines = shader.fragmentShader.split("\n"); + const findex = flines.findIndex(line => fragRegex.test(line)); + flines.splice(findex + 1, 0, mediaHighlightFrag); + flines.unshift("varying vec3 hubs_WorldPosition;"); + flines.unshift("uniform bool hubs_EnableSweepingEffect;"); + flines.unshift("uniform vec2 hubs_SweepParams;"); + flines.unshift("uniform bool hubs_HighlightInteractorOne;"); + flines.unshift("uniform vec3 hubs_InteractorOnePos;"); + flines.unshift("uniform bool hubs_HighlightInteractorTwo;"); + flines.unshift("uniform vec3 hubs_InteractorTwoPos;"); + flines.unshift("uniform float hubs_Time;"); + shader.fragmentShader = flines.join("\n"); + + shaderUniforms.set(object.material.uuid, shader.uniforms); + }; + object.material.needsUpdate = true; + }); + + return shaderUniforms; +} diff --git a/webpack.config.js b/webpack.config.js index 55afb6708d..e7b6e4d8e3 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -77,6 +77,7 @@ module.exports = (env, argv) => ({ devServer: { https: createHTTPSConfig(), host: "0.0.0.0", + public: "hubs.local:8080", useLocalIp: true, allowedHosts: ["hubs.local"], before: function(app) { @@ -153,6 +154,10 @@ module.exports = (env, argv) => ({ context: path.join(__dirname, "src") } } + }, + { + test: /\.(glsl)$/, + use: { loader: "raw-loader" } } ] },