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" }
}
]
},