diff --git a/README.md b/README.md index 503057f930..c4d6abff6d 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ This will allow the CSP checks to pass that are served up by Reticulum so you ca - `disable_telemetry` - If `true` disables Sentry telemetry. - `log_filter` - A `debug` style filter for setting the logging level. - `debug` - If `true` performs verbose logging of Janus and NAF traffic. +- `disableTunnel` - Tunnel vision is on by default. Disable the tunnel vision by this parameter. ## Additional Resources diff --git a/src/hub.js b/src/hub.js index 8957d014ff..798b412351 100644 --- a/src/hub.js +++ b/src/hub.js @@ -134,6 +134,7 @@ import "./components/cardboard-controls"; import "./components/cursor-controller"; import "./components/nav-mesh-helper"; +import "./systems/tunnel-effect"; import "./components/tools/pen"; import "./components/tools/networked-drawing"; diff --git a/src/systems/tunnel-effect.js b/src/systems/tunnel-effect.js new file mode 100644 index 0000000000..8375964060 --- /dev/null +++ b/src/systems/tunnel-effect.js @@ -0,0 +1,163 @@ +import "../utils/postprocessing/EffectComposer"; +import "../utils/postprocessing/RenderPass"; +import "../utils/postprocessing/ShaderPass"; +import "../utils/postprocessing/MaskPass"; +import "../utils/shaders/CopyShader"; +import "../utils/shaders/VignetteShader"; +import qsTruthy from "../utils/qs_truthy"; + +const disabledByQueryString = qsTruthy("disableTunnel"); +const CLAMP_SPEED = 0.01; +const CLAMP_RADIUS = 0.001; +const NO_TUNNEL_RADIUS = 10.0; +const NO_TUNNEL_SOFTNESS = 0.0; + +function lerp(start, end, t) { + return (1 - t) * start + t * end; +} + +function f(t) { + const x = t - 1; + return 1 + x * x * x * x * x; +} + +AFRAME.registerSystem("tunneleffect", { + schema: { + targetComponent: { type: "string", default: "character-controller" }, + radius: { type: "number", default: 1.0, min: 0.25 }, + minRadius: { type: "number", default: 0.25, min: 0.1 }, + maxSpeed: { type: "number", default: 0.5, min: 0.1 }, + softest: { type: "number", default: 0.1, min: 0.0 }, + opacity: { type: "number", default: 1, min: 0.0 } + }, + + init: function() { + this.scene = this.el; + this.isMoving = false; + this.isVR = false; + this.dt = 0; + this.isPostProcessingReady = false; + this.characterEl = document.querySelector(`a-entity[${this.data.targetComponent}]`); + if (this.characterEl) { + this._initPostProcessing = this._initPostProcessing.bind(this); + this.characterEl.addEventListener("componentinitialized", this._initPostProcessing); + } else { + console.warn("Could not find target component."); + } + this._enterVR = this._enterVR.bind(this); + this._exitVR = this._exitVR.bind(this); + this.scene.addEventListener("enter-vr", this._enterVR); + this.scene.addEventListener("exit-vr", this._exitVR); + }, + + pause: function() { + if (!this.characterEl) { + return; + } + this.characterEl.removeEventListener("componentinitialized", this._initPostProcessing); + this.scene.removeEventListener("enter-vr", this._enterVR); + this.scene.removeEventListener("exit-vr", this._exitVR); + }, + + play: function() { + this.scene.addEventListener("enter-vr", this._enterVR); + this.scene.addEventListener("exit-vr", this._exitVR); + }, + + tick: function(t, dt) { + this.dt = dt; + + if (disabledByQueryString || !this.isPostProcessingReady || !this.isVR) { + return; + } + + const { maxSpeed, minRadius, softest } = this.data; + const characterSpeed = this.characterComponent.velocity.length(); + const shaderRadius = this.vignettePass.uniforms["radius"].value || NO_TUNNEL_RADIUS; + if (!this.enabled && characterSpeed > CLAMP_SPEED) { + this.enabled = true; + this._bindRenderFunc(); + } else if ( + this.enabled && + characterSpeed < CLAMP_SPEED && + Math.abs(NO_TUNNEL_RADIUS - shaderRadius) < CLAMP_RADIUS + ) { + this.enabled = false; + this._exitTunnel(); + } + if (this.enabled) { + const clampedSpeed = characterSpeed > maxSpeed ? maxSpeed : characterSpeed; + const speedRatio = clampedSpeed / maxSpeed; + this.targetRadius = lerp(NO_TUNNEL_RADIUS, minRadius, f(speedRatio)); + this.targetSoftness = lerp(NO_TUNNEL_SOFTNESS, softest, f(speedRatio)); + this._updateVignettePass(this.targetRadius, this.targetSoftness, this.data.opacity); + } + }, + + _exitTunnel: function() { + this.scene.renderer.render = this.originalRenderFunc; + this.isMoving = false; + }, + + _initPostProcessing: function(event) { + if (event.detail.name === this.data.targetComponent) { + this.characterEl.removeEventListener("componentinitialized", this._initPostProcessing); + this.characterComponent = this.characterEl.components[this.data.targetComponent]; + this._initComposer(); + } + }, + + _enterVR: function() { + this.isVR = true; //TODO: This is called in 2D mode when you press "f", which is bad + }, + + _exitVR: function() { + this._exitTunnel(); + this.isVR = false; + }, + + _initComposer: function() { + this.renderer = this.scene.renderer; + this.camera = this.scene.camera; + this.originalRenderFunc = this.scene.renderer.render; + this.isDigest = false; + const render = this.scene.renderer.render; + const system = this; + this.postProcessingRenderFunc = function() { + if (system.isDigest) { + render.apply(this, arguments); + } else { + system.isDigest = true; + system.composer.render(system.dt); + system.isDigest = false; + } + }; + this.composer = new THREE.EffectComposer(this.renderer); + this.composer.resize(); + this.scenePass = new THREE.RenderPass(this.scene.object3D, this.camera); + this.vignettePass = new THREE.ShaderPass(THREE.VignetteShader); + this._updateVignettePass(this.data.radius, this.data.softness, this.data.opacity); + this.composer.addPass(this.scenePass); + this.composer.addPass(this.vignettePass); + this.isPostProcessingReady = true; + }, + + _updateVignettePass: function(radius, softness, opacity) { + const { width, height } = this.renderer.getSize(); + const pixelRatio = this.renderer.getPixelRatio(); + this.vignettePass.uniforms["radius"].value = radius; + this.vignettePass.uniforms["softness"].value = softness; + this.vignettePass.uniforms["opacity"].value = opacity; + this.vignettePass["resolution"] = new THREE.Uniform(new THREE.Vector2(width * pixelRatio, height * pixelRatio)); + if (!this.vignettePass.renderToScreen) { + this.vignettePass.renderToScreen = true; + } + }, + + /** + * use the render func of the effect composer when we need the postprocessing + */ + _bindRenderFunc: function() { + this.scene.renderer.render = this.postProcessingRenderFunc; + } +}); diff --git a/src/utils/postprocessing/EffectComposer.js b/src/utils/postprocessing/EffectComposer.js new file mode 100644 index 0000000000..11fe744781 --- /dev/null +++ b/src/utils/postprocessing/EffectComposer.js @@ -0,0 +1,167 @@ +THREE.EffectComposer = function(renderer, renderTarget) { + this.renderer = renderer; + this.delta = 0; + window.addEventListener("vrdisplaypresentchange", this.resize.bind(this)); + + if (renderTarget === undefined) { + const parameters = { + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + format: THREE.RGBAFormat, + stencilBuffer: false + }; + + const size = renderer.getDrawingBufferSize(); + renderTarget = new THREE.WebGLRenderTarget(size.width, size.height, parameters); + renderTarget.texture.name = "EffectComposer.rt1"; + } + + this.renderTarget1 = renderTarget; + this.renderTarget2 = renderTarget.clone(); + this.renderTarget2.texture.name = "EffectComposer.rt2"; + + this.writeBuffer = this.renderTarget1; + this.readBuffer = this.renderTarget2; + + this.passes = []; + this.maskActive = false; + + // dependencies + + if (THREE.CopyShader === undefined) { + console.error("THREE.EffectComposer relies on THREE.CopyShader"); + } + + if (THREE.ShaderPass === undefined) { + console.error("THREE.EffectComposer relies on THREE.ShaderPass"); + } + + this.copyPass = new THREE.ShaderPass(THREE.CopyShader); +}; + +Object.assign(THREE.EffectComposer.prototype, { + swapBuffers: function(pass) { + if (pass.needsSwap) { + if (this.maskActive) { + const context = this.renderer.context; + context.stencilFunc(context.NOTEQUAL, 1, 0xffffffff); + this.copyPass.render(this.renderer, this.writeBuffer, this.readBuffer, this.delta); + context.stencilFunc(context.EQUAL, 1, 0xffffffff); + } + + const tmp = this.readBuffer; + this.readBuffer = this.writeBuffer; + this.writeBuffer = tmp; + } + + if (THREE.MaskPass !== undefined) { + if (pass instanceof THREE.MaskPass) { + this.maskActive = true; + } else if (pass instanceof THREE.ClearMaskPass) { + this.maskActive = false; + } + } + }, + + addPass: function(pass) { + this.passes.push(pass); + const size = this.renderer.getDrawingBufferSize(); + pass.setSize(size.width, size.height); + }, + + insertPass: function(pass, index) { + this.passes.splice(index, 0, pass); + }, + + render: function(delta, starti) { + const maskActive = this.maskActive; + let pass; + let i; + const il = this.passes.length; + const scope = this; + let currentOnAfterRender; + this.delta = delta; + + for (i = starti || 0; i < il; i++) { + pass = this.passes[i]; + if (pass.enabled === false) continue; + + // If VR mode is enabled and rendering the whole scene is required. + // The pass renders the scene and and postprocessing is resumed before + // submitting the frame to the headset by using the onAfterRender callback. + if (this.renderer.vr.enabled && pass.scene) { + currentOnAfterRender = pass.scene.onAfterRender; + pass.scene.onAfterRender = function() { + // Disable stereo rendering when doing postprocessing + // on a render target. + scope.renderer.vr.enabled = false; + scope.render(delta, i + 1, maskActive); + + // Renable vr mode. + scope.renderer.vr.enabled = true; + }; + + pass.render(this.renderer, this.writeBuffer, this.readBuffer); + + // Restore onAfterRender + pass.scene.onAfterRender = currentOnAfterRender; + this.swapBuffers(pass); + return; + } + + pass.render(this.renderer, this.writeBuffer, this.readBuffer); + this.swapBuffers(pass); + } + }, + + reset: function(renderTarget) { + if (renderTarget === undefined) { + const size = this.renderer.getDrawingBufferSize(); + renderTarget = this.renderTarget1.clone(); + renderTarget.setSize(size.width, size.height); + } + + this.renderTarget1.dispose(); + this.renderTarget2.dispose(); + this.renderTarget1 = renderTarget; + this.renderTarget2 = renderTarget.clone(); + + this.writeBuffer = this.renderTarget1; + this.readBuffer = this.renderTarget2; + }, + + setSize: function(width, height) { + this.renderTarget1.setSize(width, height); + this.renderTarget2.setSize(width, height); + for (let i = 0; i < this.passes.length; i++) { + this.passes[i].setSize(width, height); + } + }, + + resize: function() { + const rendererSize = this.renderer.getDrawingBufferSize(); + this.setSize(rendererSize.width, rendererSize.height); + } +}); + +THREE.Pass = function() { + // if set to true, the pass is processed by the composer + this.enabled = true; + + // if set to true, the pass indicates to swap read and write buffer after rendering + this.needsSwap = true; + + // if set to true, the pass clears its buffer before rendering + this.clear = false; + + // if set to true, the result of the pass is rendered to screen + this.renderToScreen = false; +}; + +Object.assign(THREE.Pass.prototype, { + setSize: function() {}, + + render: function() { + console.error("THREE.Pass: .render() must be implemented in derived pass."); + } +}); diff --git a/src/utils/postprocessing/MaskPass.js b/src/utils/postprocessing/MaskPass.js new file mode 100644 index 0000000000..ddc5aa98a3 --- /dev/null +++ b/src/utils/postprocessing/MaskPass.js @@ -0,0 +1,80 @@ +/** + * @author alteredq / http://alteredqualia.com/ + */ + +THREE.MaskPass = function(scene, camera) { + THREE.Pass.call(this); + + this.scene = scene; + this.camera = camera; + + this.clear = true; + this.needsSwap = false; + + this.inverse = false; +}; + +THREE.MaskPass.prototype = Object.assign(Object.create(THREE.Pass.prototype), { + constructor: THREE.MaskPass, + + render: function(renderer, writeBuffer, readBuffer) { + const context = renderer.context; + const state = renderer.state; + + // don't update color or depth + + state.buffers.color.setMask(false); + state.buffers.depth.setMask(false); + + // lock buffers + + state.buffers.color.setLocked(true); + state.buffers.depth.setLocked(true); + + // set up stencil + + let writeValue, clearValue; + + if (this.inverse) { + writeValue = 0; + clearValue = 1; + } else { + writeValue = 1; + clearValue = 0; + } + + state.buffers.stencil.setTest(true); + state.buffers.stencil.setOp(context.REPLACE, context.REPLACE, context.REPLACE); + state.buffers.stencil.setFunc(context.ALWAYS, writeValue, 0xffffffff); + state.buffers.stencil.setClear(clearValue); + + // draw into the stencil buffer + + renderer.render(this.scene, this.camera, readBuffer, this.clear); + renderer.render(this.scene, this.camera, writeBuffer, this.clear); + + // unlock color and depth buffer for subsequent rendering + + state.buffers.color.setLocked(false); + state.buffers.depth.setLocked(false); + + // only render where stencil is set to 1 + + state.buffers.stencil.setFunc(context.EQUAL, 1, 0xffffffff); // draw if == 1 + state.buffers.stencil.setOp(context.KEEP, context.KEEP, context.KEEP); + } +}); + +THREE.ClearMaskPass = function() { + THREE.Pass.call(this); + + this.needsSwap = false; +}; + +THREE.ClearMaskPass.prototype = Object.create(THREE.Pass.prototype); + +Object.assign(THREE.ClearMaskPass.prototype, { + render: function(renderer) { + renderer.state.buffers.stencil.setTest(false); + } +}); diff --git a/src/utils/postprocessing/README.md b/src/utils/postprocessing/README.md new file mode 100644 index 0000000000..1385db1d2b --- /dev/null +++ b/src/utils/postprocessing/README.md @@ -0,0 +1,7 @@ +These files +- EffectComposer.js +- MaskPass.js +- RenderPass.js +- ShaderPass.js +are copied from https://github.com/mrdoob/three.js/blob/dev/examples/js/postprocessing/EffectComposer.js + diff --git a/src/utils/postprocessing/RenderPass.js b/src/utils/postprocessing/RenderPass.js new file mode 100644 index 0000000000..e7dd98af6f --- /dev/null +++ b/src/utils/postprocessing/RenderPass.js @@ -0,0 +1,52 @@ +/** + * @author alteredq / http://alteredqualia.com/ + */ + +THREE.RenderPass = function(scene, camera, overrideMaterial, clearColor, clearAlpha) { + THREE.Pass.call(this); + + this.scene = scene; + this.camera = camera; + + this.overrideMaterial = overrideMaterial; + + this.clearColor = clearColor; + this.clearAlpha = clearAlpha !== undefined ? clearAlpha : 0; + + this.clear = true; + this.clearDepth = false; + this.needsSwap = false; +}; + +THREE.RenderPass.prototype = Object.assign(Object.create(THREE.Pass.prototype), { + constructor: THREE.RenderPass, + + render: function(renderer, writeBuffer, readBuffer) { + const oldAutoClear = renderer.autoClear; + renderer.autoClear = false; + + this.scene.overrideMaterial = this.overrideMaterial; + + let oldClearColor, oldClearAlpha; + + if (this.clearColor) { + oldClearColor = renderer.getClearColor().getHex(); + oldClearAlpha = renderer.getClearAlpha(); + + renderer.setClearColor(this.clearColor, this.clearAlpha); + } + + if (this.clearDepth) { + renderer.clearDepth(); + } + + renderer.render(this.scene, this.camera, this.renderToScreen ? null : readBuffer, this.clear); + + if (this.clearColor) { + renderer.setClearColor(oldClearColor, oldClearAlpha); + } + + this.scene.overrideMaterial = null; + renderer.autoClear = oldAutoClear; + } +}); diff --git a/src/utils/postprocessing/ShaderPass.js b/src/utils/postprocessing/ShaderPass.js new file mode 100644 index 0000000000..d6d25800f8 --- /dev/null +++ b/src/utils/postprocessing/ShaderPass.js @@ -0,0 +1,44 @@ +/** + * @author alteredq / http://alteredqualia.com/ + */ + +THREE.ShaderPass = function(shader, textureID) { + THREE.Pass.call(this); + this.textureID = textureID !== undefined ? textureID : "tDiffuse"; + if (shader instanceof THREE.ShaderMaterial) { + this.uniforms = shader.uniforms; + this.material = shader; + } else if (shader) { + this.uniforms = THREE.UniformsUtils.clone(shader.uniforms); + this.material = new THREE.ShaderMaterial({ + defines: Object.assign({}, shader.defines), + uniforms: this.uniforms, + vertexShader: shader.vertexShader, + fragmentShader: shader.fragmentShader + }); + } + + this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + this.scene = new THREE.Scene(); + + this.quad = new THREE.Mesh(new THREE.PlaneBufferGeometry(2, 2), null); + this.quad.frustumCulled = false; // Avoid getting clipped + this.scene.add(this.quad); +}; + +THREE.ShaderPass.prototype = Object.assign(Object.create(THREE.Pass.prototype), { + constructor: THREE.ShaderPass, + render: function(renderer, writeBuffer, readBuffer) { + if (this.uniforms[this.textureID]) { + this.uniforms[this.textureID].value = readBuffer.texture; + } + + this.quad.material = this.material; + + if (this.renderToScreen) { + renderer.render(this.scene, this.camera); + } else { + renderer.render(this.scene, this.camera, writeBuffer, this.clear); + } + } +}); diff --git a/src/utils/shaders/CopyShader.js b/src/utils/shaders/CopyShader.js new file mode 100644 index 0000000000..f970d4a92d --- /dev/null +++ b/src/utils/shaders/CopyShader.js @@ -0,0 +1,38 @@ +/** + * @author alteredq / http://alteredqualia.com/ + * + * Full-screen textured quad shader + */ + +THREE.CopyShader = { + uniforms: { + tDiffuse: { value: null }, + opacity: { value: 1.0 } + }, + + vertexShader: [ + "varying vec2 vUv;", + + "void main() {", + + "vUv = uv;", + "gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );", + + "}" + ].join("\n"), + + fragmentShader: [ + "uniform float opacity;", + + "uniform sampler2D tDiffuse;", + + "varying vec2 vUv;", + + "void main() {", + + "vec4 texel = texture2D( tDiffuse, vUv );", + "gl_FragColor = opacity * texel;", + + "}" + ].join("\n") +}; diff --git a/src/utils/shaders/README.md b/src/utils/shaders/README.md new file mode 100644 index 0000000000..6d4b23f4a3 --- /dev/null +++ b/src/utils/shaders/README.md @@ -0,0 +1,5 @@ +These files +- CopyShader.js +- VignetteShader.js +were copied from https://github.com/mrdoob/three.js/blob/dev/examples/js/shaders/CopyShader.js + diff --git a/src/utils/shaders/VignetteShader.js b/src/utils/shaders/VignetteShader.js new file mode 100644 index 0000000000..9a424139bc --- /dev/null +++ b/src/utils/shaders/VignetteShader.js @@ -0,0 +1,46 @@ +THREE.VignetteShader = { + uniforms: { + tDiffuse: { value: null }, + radius: { value: 0.65 }, + opacity: { value: 0.9 }, + softness: { value: 0.2 }, + resolution: new THREE.Uniform(new THREE.Vector2(1920, 1080)) + }, + + vertexShader: [ + "varying vec2 vUv;", + "void main() {", + "vUv = uv;", + "gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );", + "}" + ].join("\n"), + + fragmentShader: [ + "uniform sampler2D tDiffuse;", + "uniform float radius;", + "uniform float opacity;", + "uniform float softness;", + "uniform vec2 resolution;", + + "varying vec2 vUv;", + + "void main() {", + "vec4 texel = texture2D( tDiffuse, vUv);", + "float ratio = resolution.x / resolution.y;", + "float centerXOffset = radius / ratio;", + "float leftX = (0.3 + centerXOffset >= 0.5) ? 0.5 - centerXOffset : 0.3;", + "float rightX = (0.7 - centerXOffset <= 0.5) ? 0.5 + centerXOffset : 0.7;", + "vec2 uvLeft = (vUv.xy) - vec2(leftX, 0.5);", + "vec2 uvRight = (vUv.xy) - vec2(rightX, 0.5);", + "uvLeft.x *= ratio;", + "uvRight.x *= ratio;", + "float lenLeft = length(uvLeft);", + "float lenRight = length(uvRight);", + "float vignetteLeft = smoothstep(radius, radius-softness, lenLeft);", + "float vignetteRight = smoothstep(radius, radius-softness, lenRight);", + "float vignette = vignetteLeft + vignetteRight;", + "vec3 final = mix (texel.rgb, texel.rgb * vignette, opacity);", + "gl_FragColor = vec4(final.rgb, 1.0);", + "}" + ].join("\n") +};