├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── browser.js ├── cubes_stereo.png ├── dist ├── .gitkeep ├── aframe-stereo-component.js └── aframe-stereo-component.min.js ├── examples ├── basic_image │ ├── index.html │ └── textures │ │ ├── L.jpg │ │ └── R.jpg ├── basic_video │ ├── index.html │ └── textures │ │ ├── MaryOculus.mp4 │ │ └── MaryOculus.webm ├── build.js ├── index.html ├── main.js └── two_boxes │ └── index.html ├── foto_stereo.png ├── index.js ├── package.json ├── scripts └── unboil.js ├── tests ├── __init.test.js ├── helpers.js ├── index.test.js └── karma.conf.js └── video_stereo.png /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: vincentfretin 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .sw[ponm] 2 | examples/node_modules/ 3 | gh-pages 4 | node_modules/ 5 | npm-debug.log 6 | examples/build.js 7 | package-lock.json 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Óscar Marín Miró 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## aframe-stereo-component 2 | 3 | A stereo component for [A-Frame](https://aframe.io) VR. 4 | 5 | This component builds on the ['layer' concept of THREE.js] (https://github.com/mrdoob/three.js/issues/6437) and is really two components in one: 6 | - **'stereocam' component**, with tells an aframe camera which 'eye' to render in case of monoscopic display (without 'Entering VR'). The camera will render all entities without the stereo component, but if it encounters an entity with the 'stereo' component active, it will render only those in the same eye as defined here. 7 | - **'stereo' component**, which tells aframe to include an entity in either the 'right' eye or 'left' eye (you can also specify 'both', but this has the same effect as not using the 'stereo' component. *The component also enables stereoscopic video rendering projected on spheres*, so if a sphere (see example below) has the 'stereo' component enabled, if will only project half of the video texture (which one depends on the 'eye' property), so the result is stereoscopic video rendering, if you include two spheres. The component expects videos in side-by-side equirectangular projection (see the video example below). 8 | 9 | If a video is used in a sphere with the 'stereo' component active, **the component will also enable playback in mobile devices, by attaching a 'click' event on the rendering canvas**. Thus, in mobile devices you must click on the screen (via cardboard v2.0 button or with your finger) for the video to start playing. This can be disabled by setting the `playOnClick` variable to false. 10 | 11 | **NOTE: for some reason (?) if the video element is put inside scene 'assets' tag, a cross-origin issue is raised ** 12 | 13 | You can see demos for both examples below [here] (http://oscarmarinmiro.github.io/aframe-stereo-component) 14 | 15 | ### 'stereocam' component properties (only for camera) 16 | 17 | | Property | Description | Default Value | 18 | | -------- | ----------- | ------------- | 19 | | eye | which eye is enabled in monoscopic display ('left' or 'right') | 'left | 20 | 21 | ### 'stereo' component properties (for other entities) 22 | | Property | Description | Default Value | 23 | | -------- | ----------- | ------------- | 24 | | eye | in which eye the entity is render VR mode ('left' or 'right') | 'left | 25 | | mode | this property is for spheres holding a video texture. mode can be 'full' or 'half', depending if the original video is full 360 or only spans 180 degrees horizontally (half-dome)| 'full' | 26 | | split | this property indicates whether to split the video texture horizontally (left and right hemispheres), or vertically, (top and bottom hemispheres) | 'horizontal' 27 | | playOnClick | this property indicates whether the video will automatically be played on clicking on the canvas | true 28 | 29 | ### Usage 30 | 31 | !["Stereoscopic video"](/video_stereo.png?raw=true "Stereoscopic video") 32 | 33 | #### Browser Installation. 360 stereoscopic video example 34 | 35 | Install and use by directly including the [browser files](dist): 36 | 37 | ```html 38 | 39 | 40 | My A-Frame Scene 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 53 | 54 | 55 | 56 | 57 | 59 | 60 | 61 | 62 | 63 | 64 | 70 | 71 | 72 | 73 | 74 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | ``` 90 | !["Stereoscopic images"](/foto_stereo.png?raw=true "Stereoscopic video") 91 | #### Browser Installation. Stereoscopic panoramas (images) 92 | 93 | if you have an over/under stereo panorama file, you can follow [this instructions](http://bl.ocks.org/bryik/4bf77096d3af66b11739caaf01393837) to split it 94 | 95 | Install and use by directly including the [browser files](dist): 96 | 97 | ```html 98 | 99 | 100 | My A-Frame Scene 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | <-- or alternatively --> 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | ``` 131 | 132 | !["Two cubes in a scene, each one for each eye"](/cubes_stereo.png?raw=true "Two cubes in a scene, each one for each eye") 133 | 134 | #### Browser Installation. Two cubes, each one for each eye 135 | 136 | Install and use by directly including the [browser files](dist): 137 | 138 | ```html 139 | 140 | 141 | My A-Frame Scene 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | ``` 168 | 169 | #### Stereoscopic videos that are split vertically - Top and Bottom 170 | 171 | Install and use by directly including the [browser files](dist): 172 | 173 | ```html 174 | 175 | 176 | My A-Frame Scene 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 188 | 189 | 190 | 191 | 194 | 195 | 196 | 197 | 200 | 201 | 202 | 203 | 204 | ``` 205 | 206 | 207 | #### NPM Installation 208 | 209 | Install via NPM: 210 | 211 | ```bash 212 | npm install aframe-stereo-component 213 | ``` 214 | 215 | Then register and use. 216 | 217 | ```js 218 | var AFRAME = require('aframe'); 219 | var stereoComponent = require('aframe-stereo-component').stereo_component; 220 | var stereocamComponent = require('aframe-stereo-component').stereocam_component; 221 | 222 | AFRAME.registerComponent('stereo', stereoComponent); 223 | AFRAME.registerComponent('stereocam', stereocamComponent); 224 | ``` 225 | 226 | #### Credits 227 | 228 | The video used in the examples is from http://pedrofe.com/rendering-for-oculus-rift-with-arnold/, from the project http://www.meryproject.com/ 229 | 230 | Boilerplate code from https://github.com/ngokevin/aframe-component-boilerplate 231 | 232 | Code for adjusting sphere face vertex is from https://github.com/mrdoob/three.js/blob/master/examples/webvr_video.html 233 | 234 | Stereo images from [Dougstar02](https://www.reddit.com/r/oculus/comments/4yrmm2/i_took_some_360_ansel_stereo_screenshots_from_the/) 235 | -------------------------------------------------------------------------------- /browser.js: -------------------------------------------------------------------------------- 1 | // Browser distrubution of the A-Frame component. 2 | (function () { 3 | if (!AFRAME) { 4 | console.error('Component attempted to register before AFRAME was available.'); 5 | return; 6 | } 7 | 8 | // Register all components here. 9 | var components = { 10 | stereo: require('./index').stereo_component, 11 | stereocam: require('./index').stereocam_component 12 | }; 13 | 14 | Object.keys(components).forEach(function (name) { 15 | if (AFRAME.aframeCore) { 16 | AFRAME.aframeCore.registerComponent(name, components[name]); 17 | } else { 18 | AFRAME.registerComponent(name, components[name]); 19 | } 20 | }); 21 | })(); 22 | 23 | -------------------------------------------------------------------------------- /cubes_stereo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c-frame/aframe-stereo-component/f037508ed5fbea8bf59d237d99349a66b14328ae/cubes_stereo.png -------------------------------------------------------------------------------- /dist/.gitkeep: -------------------------------------------------------------------------------- 1 | `npm run dist` to generate browser files. 2 | -------------------------------------------------------------------------------- /dist/aframe-stereo-component.js: -------------------------------------------------------------------------------- 1 | /******/ (function(modules) { // webpackBootstrap 2 | /******/ // The module cache 3 | /******/ var installedModules = {}; 4 | 5 | /******/ // The require function 6 | /******/ function __webpack_require__(moduleId) { 7 | 8 | /******/ // Check if module is in cache 9 | /******/ if(installedModules[moduleId]) 10 | /******/ return installedModules[moduleId].exports; 11 | 12 | /******/ // Create a new module (and put it into the cache) 13 | /******/ var module = installedModules[moduleId] = { 14 | /******/ exports: {}, 15 | /******/ id: moduleId, 16 | /******/ loaded: false 17 | /******/ }; 18 | 19 | /******/ // Execute the module function 20 | /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); 21 | 22 | /******/ // Flag the module as loaded 23 | /******/ module.loaded = true; 24 | 25 | /******/ // Return the exports of the module 26 | /******/ return module.exports; 27 | /******/ } 28 | 29 | 30 | /******/ // expose the modules object (__webpack_modules__) 31 | /******/ __webpack_require__.m = modules; 32 | 33 | /******/ // expose the module cache 34 | /******/ __webpack_require__.c = installedModules; 35 | 36 | /******/ // __webpack_public_path__ 37 | /******/ __webpack_require__.p = ""; 38 | 39 | /******/ // Load entry module and return exports 40 | /******/ return __webpack_require__(0); 41 | /******/ }) 42 | /************************************************************************/ 43 | /******/ ([ 44 | /* 0 */ 45 | /***/ (function(module, exports, __webpack_require__) { 46 | 47 | // Browser distrubution of the A-Frame component. 48 | (function () { 49 | if (!AFRAME) { 50 | console.error('Component attempted to register before AFRAME was available.'); 51 | return; 52 | } 53 | 54 | // Register all components here. 55 | var components = { 56 | stereo: __webpack_require__(1).stereo_component, 57 | stereocam: __webpack_require__(1).stereocam_component 58 | }; 59 | 60 | Object.keys(components).forEach(function (name) { 61 | if (AFRAME.aframeCore) { 62 | AFRAME.aframeCore.registerComponent(name, components[name]); 63 | } else { 64 | AFRAME.registerComponent(name, components[name]); 65 | } 66 | }); 67 | })(); 68 | 69 | 70 | 71 | /***/ }), 72 | /* 1 */ 73 | /***/ (function(module, exports) { 74 | 75 | module.exports = { 76 | 77 | // Put an object into left, right or both eyes. 78 | // If it's a video sphere, take care of correct stereo mapping for both eyes (if full dome) 79 | // or half the sphere (if half dome) 80 | 81 | 'stereo_component' : { 82 | schema: { 83 | eye: { type: 'string', default: "left"}, 84 | mode: { type: 'string', default: "full"}, 85 | split: { type: 'string', default: "horizontal"}, 86 | playOnClick: { type: 'boolean', default: true }, 87 | }, 88 | init: function(){ 89 | 90 | // Flag to acknowledge if 'click' on video has been attached to canvas 91 | // Keep in mind that canvas is the last thing initialized on a scene so have to wait for the event 92 | // or just check in every tick if is not undefined 93 | 94 | this.video_click_event_added = false; 95 | 96 | this.material_is_a_video = false; 97 | 98 | // Check if material is a video from html tag (object3D.material.map instanceof THREE.VideoTexture does not 99 | // always work 100 | 101 | if(this.el.getAttribute("material")!==null && 'src' in this.el.getAttribute("material") && this.el.getAttribute("material").src !== "") { 102 | var src = this.el.getAttribute("material").src; 103 | 104 | // If src is an object and its tagName is video... 105 | 106 | if (typeof src === 'object' && ('tagName' in src && src.tagName === "VIDEO")) { 107 | this.material_is_a_video = true; 108 | } 109 | } 110 | 111 | var object3D = this.el.object3D.children[0]; 112 | 113 | // In A-Frame 0.2.0, objects are all groups so sphere is the first children 114 | // Check if it's a sphere w/ video material, and if so 115 | // Note that in A-Frame 0.2.0, sphere entities are THREE.SphereBufferGeometry, while in A-Frame 0.3.0, 116 | // sphere entities are THREE.BufferGeometry. 117 | 118 | var validGeometries = [THREE.SphereGeometry, THREE.BufferGeometry]; 119 | var isValidGeometry = validGeometries.some(function(geometry) { 120 | return object3D.geometry instanceof geometry; 121 | }); 122 | 123 | if (isValidGeometry && this.material_is_a_video) { 124 | 125 | // if half-dome mode, rebuild geometry (with default 100, radius, 64 width segments and 64 height segments) 126 | 127 | if (this.data.mode === "half") { 128 | 129 | var geo_def = this.el.getAttribute("geometry"); 130 | var geometry = new THREE.SphereGeometry(geo_def.radius || 100, geo_def.segmentsWidth || 64, geo_def.segmentsHeight || 64, Math.PI / 2, Math.PI, 0, Math.PI); 131 | 132 | } 133 | else { 134 | var geo_def = this.el.getAttribute("geometry"); 135 | var geometry = new THREE.SphereGeometry(geo_def.radius || 100, geo_def.segmentsWidth || 64, geo_def.segmentsHeight || 64); 136 | } 137 | 138 | // Panorama in front 139 | 140 | object3D.rotation.y = Math.PI / 2; 141 | 142 | 143 | // Calculate texture offset and repeat and modify UV's 144 | // (cannot use in AFrame material params, since mappings are shared when pointing to the same texture, 145 | // thus, one eye overrides the other) -> https://stackoverflow.com/questions/16976365/two-meshes-same-texture-different-offset 146 | 147 | var axis = this.data.split === 'horizontal' ? 'y' : 'x'; 148 | 149 | // If left eye is set, and the split is horizontal, take the left half of the video texture. 150 | // If the split is set to vertical, take the top/upper half of the video texture. 151 | // UV texture coordinates start at the bottom left point of the texture, so y axis coordinates for left eye on vertical split 152 | // are 0.5 - 1.0, and for the right eye are 0.0 - 0.5 153 | 154 | var offset = this.data.eye === 'left' ? (axis === 'y' ? {x: 0, y: 0} : {x: 0, y: 0.5}) : (axis === 'y' ? {x: 0.5, y: 0} : {x: 0, y: 0}); 155 | 156 | var repeat = axis === 'y' ? {x: 0.5, y: 1} : {x: 1, y: 0.5}; 157 | 158 | var uvAttribute = geometry.attributes.uv; 159 | 160 | for (var i = 0; i < uvAttribute.count; i++ ) { 161 | var u = uvAttribute.getX(i)*repeat.x + offset.x; 162 | var v = uvAttribute.getY(i)*repeat.y + offset.y; 163 | 164 | uvAttribute.setXY(i, u, v); 165 | 166 | } 167 | 168 | // Needed in BufferGeometry to update UVs 169 | 170 | uvAttribute.needsUpdate = true 171 | 172 | this.originalGeometry = object3D.geometry; 173 | object3D.geometry = geometry 174 | 175 | } 176 | else{ 177 | 178 | // No need to attach video click if not a sphere and not a video, set this to true 179 | 180 | this.video_click_event_added = true; 181 | 182 | } 183 | 184 | 185 | }, 186 | 187 | remove: function(){ 188 | var object3D = this.el.object3D.children[0]; 189 | object3D.geometry.dispose(); 190 | if (this.originalGeometry) { 191 | object3D.geometry = this.originalGeometry; 192 | } 193 | }, 194 | 195 | // On element update, put in the right layer, 0:both, 1:left, 2:right (spheres or not) 196 | 197 | update: function(oldData){ 198 | 199 | var object3D = this.el.object3D.children[0]; 200 | var data = this.data; 201 | 202 | if(data.eye === "both"){ 203 | object3D.layers.set(0); 204 | } 205 | else{ 206 | object3D.layers.set(data.eye === 'left' ? 1:2); 207 | } 208 | 209 | }, 210 | 211 | tick: function(time){ 212 | 213 | // If this value is false, it means that (a) this is a video on a sphere [see init method] 214 | // and (b) of course, tick is not added 215 | 216 | if(!this.video_click_event_added && this.data.playOnClick){ 217 | if(typeof(this.el.sceneEl.canvas) !== 'undefined'){ 218 | 219 | // Get video DOM 220 | 221 | this.videoEl = this.el.object3D.children[0].material.map.image; 222 | 223 | // On canvas click, play video element. Use self to not lose track of object into event handler 224 | 225 | var self = this; 226 | 227 | this.el.sceneEl.canvas.onclick = function () { 228 | self.videoEl.play(); 229 | }; 230 | 231 | // Signal that click event is added 232 | this.video_click_event_added = true; 233 | 234 | } 235 | } 236 | 237 | } 238 | }, 239 | 240 | // Sets the 'default' eye viewed by camera in non-VR mode 241 | 242 | 'stereocam_component':{ 243 | 244 | schema: { 245 | eye: { type: 'string', default: "left"} 246 | }, 247 | 248 | // Cam is not attached on init, so use a flag to do this once at 'tick' 249 | 250 | // Use update every tick if flagged as 'not changed yet' 251 | 252 | init: function(){ 253 | // Flag to register if cam layer has already changed 254 | this.layer_changed = false; 255 | }, 256 | 257 | tick: function(time){ 258 | 259 | var originalData = this.data; 260 | 261 | // If layer never changed 262 | 263 | if(!this.layer_changed){ 264 | 265 | // because stereocam component should be attached to an a-camera element 266 | // need to get down to the root PerspectiveCamera before addressing layers 267 | 268 | // Gather the children of this a-camera and identify types 269 | 270 | var childrenTypes = []; 271 | 272 | this.el.object3D.children.forEach( function (item, index, array) { 273 | childrenTypes[index] = item.type; 274 | }); 275 | 276 | // Retrieve the PerspectiveCamera 277 | var rootIndex = childrenTypes.indexOf("PerspectiveCamera"); 278 | var rootCam = this.el.object3D.children[rootIndex]; 279 | 280 | if(originalData.eye === "both"){ 281 | rootCam.layers.enable( 1 ); 282 | rootCam.layers.enable( 2 ); 283 | } 284 | else{ 285 | rootCam.layers.enable(originalData.eye === 'left' ? 1:2); 286 | } 287 | 288 | this.layer_changed = true; 289 | } 290 | } 291 | } 292 | }; 293 | 294 | 295 | /***/ }) 296 | /******/ ]); -------------------------------------------------------------------------------- /dist/aframe-stereo-component.min.js: -------------------------------------------------------------------------------- 1 | !function(e){function t(a){if(i[a])return i[a].exports;var r=i[a]={exports:{},id:a,loaded:!1};return e[a].call(r.exports,r,r.exports,t),r.loaded=!0,r.exports}var i={};return t.m=e,t.c=i,t.p="",t(0)}([function(e,t,i){!function(){if(!AFRAME)return void console.error("Component attempted to register before AFRAME was available.");var e={stereo:i(1).stereo_component,stereocam:i(1).stereocam_component};Object.keys(e).forEach(function(t){AFRAME.aframeCore?AFRAME.aframeCore.registerComponent(t,e[t]):AFRAME.registerComponent(t,e[t])})}()},function(e,t){e.exports={stereo_component:{schema:{eye:{type:"string",default:"left"},mode:{type:"string",default:"full"},split:{type:"string",default:"horizontal"},playOnClick:{type:"boolean",default:!0}},init:function(){if(this.video_click_event_added=!1,this.material_is_a_video=!1,null!==this.el.getAttribute("material")&&"src"in this.el.getAttribute("material")&&""!==this.el.getAttribute("material").src){var e=this.el.getAttribute("material").src;"object"==typeof e&&"tagName"in e&&"VIDEO"===e.tagName&&(this.material_is_a_video=!0)}var t=this.el.object3D.children[0],i=[THREE.SphereGeometry,THREE.BufferGeometry],a=i.some(function(e){return t.geometry instanceof e});if(a&&this.material_is_a_video){if("half"===this.data.mode)var r=this.el.getAttribute("geometry"),o=new THREE.SphereGeometry(r.radius||100,r.segmentsWidth||64,r.segmentsHeight||64,Math.PI/2,Math.PI,0,Math.PI);else var r=this.el.getAttribute("geometry"),o=new THREE.SphereGeometry(r.radius||100,r.segmentsWidth||64,r.segmentsHeight||64);t.rotation.y=Math.PI/2;for(var n="horizontal"===this.data.split?"y":"x",s="left"===this.data.eye?"y"===n?{x:0,y:0}:{x:0,y:.5}:"y"===n?{x:.5,y:0}:{x:0,y:0},l="y"===n?{x:.5,y:1}:{x:1,y:.5},c=o.attributes.uv,h=0;h 2 | 3 | A-Frame Layer Component - Basic 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /examples/basic_image/textures/L.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c-frame/aframe-stereo-component/f037508ed5fbea8bf59d237d99349a66b14328ae/examples/basic_image/textures/L.jpg -------------------------------------------------------------------------------- /examples/basic_image/textures/R.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c-frame/aframe-stereo-component/f037508ed5fbea8bf59d237d99349a66b14328ae/examples/basic_image/textures/R.jpg -------------------------------------------------------------------------------- /examples/basic_video/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | A-Frame Layer Component - Basic 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /examples/basic_video/textures/MaryOculus.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c-frame/aframe-stereo-component/f037508ed5fbea8bf59d237d99349a66b14328ae/examples/basic_video/textures/MaryOculus.mp4 -------------------------------------------------------------------------------- /examples/basic_video/textures/MaryOculus.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c-frame/aframe-stereo-component/f037508ed5fbea8bf59d237d99349a66b14328ae/examples/basic_video/textures/MaryOculus.webm -------------------------------------------------------------------------------- /examples/build.js: -------------------------------------------------------------------------------- 1 | (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i https://stackoverflow.com/questions/16976365/two-meshes-same-texture-different-offset 80 | 81 | var axis = this.data.split === 'horizontal' ? 'y' : 'x'; 82 | 83 | // !!! NOTE THAT UV texture coordinates, start at the bottom left point of the texture, so y axis coordinates for left eye on horizontal split 84 | // are 0.5 - 1.0, and for the right eye are 0.0 - 0.5 (they are swapped from THREE.js 'y' axis logic) 85 | 86 | var offset = this.data.eye === 'left' ? (axis === 'y' ? {x: 0, y: 0} : {x: 0, y: 0.5}) : (axis === 'y' ? {x: 0.5, y: 0} : {x: 0, y: 0}); 87 | 88 | var repeat = axis === 'y' ? {x: 0.5, y: 1} : {x: 1, y: 0.5}; 89 | 90 | var uvAttribute = geometry.attributes.uv; 91 | 92 | for (var i = 0; i < uvAttribute.count; i++ ) { 93 | var u = uvAttribute.getX(i)*repeat.x + offset.x; 94 | var v = uvAttribute.getY(i)*repeat.y + offset.y; 95 | 96 | uvAttribute.setXY(i, u, v); 97 | 98 | } 99 | 100 | // Needed in BufferGeometry to update UVs 101 | 102 | uvAttribute.needsUpdate = true 103 | 104 | object3D.geometry = geometry 105 | 106 | } 107 | else{ 108 | 109 | // No need to attach video click if not a sphere and not a video, set this to true 110 | 111 | this.video_click_event_added = true; 112 | 113 | } 114 | 115 | 116 | }, 117 | 118 | // On element update, put in the right layer, 0:both, 1:left, 2:right (spheres or not) 119 | 120 | update: function(oldData){ 121 | 122 | var object3D = this.el.object3D.children[0]; 123 | var data = this.data; 124 | 125 | if(data.eye === "both"){ 126 | object3D.layers.set(0); 127 | } 128 | else{ 129 | object3D.layers.set(data.eye === 'left' ? 1:2); 130 | } 131 | 132 | }, 133 | 134 | tick: function(time){ 135 | 136 | // If this value is false, it means that (a) this is a video on a sphere [see init method] 137 | // and (b) of course, tick is not added 138 | 139 | if(!this.video_click_event_added && this.data.playOnClick){ 140 | if(typeof(this.el.sceneEl.canvas) !== 'undefined'){ 141 | 142 | // Get video DOM 143 | 144 | this.videoEl = this.el.object3D.children[0].material.map.image; 145 | 146 | // On canvas click, play video element. Use self to not lose track of object into event handler 147 | 148 | var self = this; 149 | 150 | this.el.sceneEl.canvas.onclick = function () { 151 | self.videoEl.play(); 152 | }; 153 | 154 | // Signal that click event is added 155 | this.video_click_event_added = true; 156 | 157 | } 158 | } 159 | 160 | } 161 | }, 162 | 163 | // Sets the 'default' eye viewed by camera in non-VR mode 164 | 165 | 'stereocam_component':{ 166 | 167 | schema: { 168 | eye: { type: 'string', default: "left"} 169 | }, 170 | 171 | // Cam is not attached on init, so use a flag to do this once at 'tick' 172 | 173 | // Use update every tick if flagged as 'not changed yet' 174 | 175 | init: function(){ 176 | // Flag to register if cam layer has already changed 177 | this.layer_changed = false; 178 | }, 179 | 180 | tick: function(time){ 181 | 182 | var originalData = this.data; 183 | 184 | // If layer never changed 185 | 186 | if(!this.layer_changed){ 187 | 188 | // because stereocam component should be attached to an a-camera element 189 | // need to get down to the root PerspectiveCamera before addressing layers 190 | 191 | // Gather the children of this a-camera and identify types 192 | 193 | var childrenTypes = []; 194 | 195 | this.el.object3D.children.forEach( function (item, index, array) { 196 | childrenTypes[index] = item.type; 197 | }); 198 | 199 | // Retrieve the PerspectiveCamera 200 | var rootIndex = childrenTypes.indexOf("PerspectiveCamera"); 201 | var rootCam = this.el.object3D.children[rootIndex]; 202 | 203 | if(originalData.eye === "both"){ 204 | rootCam.layers.enable( 1 ); 205 | rootCam.layers.enable( 2 ); 206 | } 207 | else{ 208 | rootCam.layers.enable(originalData.eye === 'left' ? 1:2); 209 | } 210 | } 211 | } 212 | 213 | } 214 | }; 215 | 216 | },{}]},{},[1]); 217 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | A-Frame Stereo Component 4 | 5 | 22 | 23 | 24 |

A-Frame Stereo Component

25 | Two boxes example, one for each eye 26 | Stereoscopic video 27 | Stereoscopic image 28 | 29 |
30 |
31 | Fork me on GitHub 32 |
33 |
34 | 35 | 36 | -------------------------------------------------------------------------------- /examples/main.js: -------------------------------------------------------------------------------- 1 | var stereoComponent = require('../index.js').stereo_component; 2 | var stereocamComponent = require('../index.js').stereocam_component; 3 | 4 | AFRAME.registerComponent('stereo', stereoComponent); 5 | AFRAME.registerComponent('stereocam', stereocamComponent); 6 | -------------------------------------------------------------------------------- /examples/two_boxes/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | My A-Frame Scene 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /foto_stereo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c-frame/aframe-stereo-component/f037508ed5fbea8bf59d237d99349a66b14328ae/foto_stereo.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | // Put an object into left, right or both eyes. 4 | // If it's a video sphere, take care of correct stereo mapping for both eyes (if full dome) 5 | // or half the sphere (if half dome) 6 | 7 | 'stereo_component' : { 8 | schema: { 9 | eye: { type: 'string', default: "left"}, 10 | mode: { type: 'string', default: "full"}, 11 | split: { type: 'string', default: "horizontal"}, 12 | playOnClick: { type: 'boolean', default: true }, 13 | }, 14 | init: function(){ 15 | 16 | // Flag to acknowledge if 'click' on video has been attached to canvas 17 | // Keep in mind that canvas is the last thing initialized on a scene so have to wait for the event 18 | // or just check in every tick if is not undefined 19 | 20 | this.video_click_event_added = false; 21 | 22 | this.material_is_a_video = false; 23 | 24 | // Check if material is a video from html tag (object3D.material.map instanceof THREE.VideoTexture does not 25 | // always work 26 | 27 | if(this.el.getAttribute("material")!==null && 'src' in this.el.getAttribute("material") && this.el.getAttribute("material").src !== "") { 28 | var src = this.el.getAttribute("material").src; 29 | 30 | // If src is an object and its tagName is video... 31 | 32 | if (typeof src === 'object' && ('tagName' in src && src.tagName === "VIDEO")) { 33 | this.material_is_a_video = true; 34 | } 35 | } 36 | 37 | var object3D = this.el.object3D.children[0]; 38 | 39 | // In A-Frame 0.2.0, objects are all groups so sphere is the first children 40 | // Check if it's a sphere w/ video material, and if so 41 | // Note that in A-Frame 0.2.0, sphere entities are THREE.SphereBufferGeometry, while in A-Frame 0.3.0, 42 | // sphere entities are THREE.BufferGeometry. 43 | 44 | var validGeometries = [THREE.SphereGeometry, THREE.BufferGeometry]; 45 | var isValidGeometry = validGeometries.some(function(geometry) { 46 | return object3D.geometry instanceof geometry; 47 | }); 48 | 49 | if (isValidGeometry && this.material_is_a_video) { 50 | 51 | // if half-dome mode, rebuild geometry (with default 100, radius, 64 width segments and 64 height segments) 52 | 53 | if (this.data.mode === "half") { 54 | 55 | var geo_def = this.el.getAttribute("geometry"); 56 | var geometry = new THREE.SphereGeometry(geo_def.radius || 100, geo_def.segmentsWidth || 64, geo_def.segmentsHeight || 64, Math.PI / 2, Math.PI, 0, Math.PI); 57 | 58 | } 59 | else { 60 | var geo_def = this.el.getAttribute("geometry"); 61 | var geometry = new THREE.SphereGeometry(geo_def.radius || 100, geo_def.segmentsWidth || 64, geo_def.segmentsHeight || 64); 62 | } 63 | 64 | // Panorama in front 65 | 66 | object3D.rotation.y = Math.PI / 2; 67 | 68 | 69 | // Calculate texture offset and repeat and modify UV's 70 | // (cannot use in AFrame material params, since mappings are shared when pointing to the same texture, 71 | // thus, one eye overrides the other) -> https://stackoverflow.com/questions/16976365/two-meshes-same-texture-different-offset 72 | 73 | var axis = this.data.split === 'horizontal' ? 'y' : 'x'; 74 | 75 | // If left eye is set, and the split is horizontal, take the left half of the video texture. 76 | // If the split is set to vertical, take the top/upper half of the video texture. 77 | // UV texture coordinates start at the bottom left point of the texture, so y axis coordinates for left eye on vertical split 78 | // are 0.5 - 1.0, and for the right eye are 0.0 - 0.5 79 | 80 | var offset = this.data.eye === 'left' ? (axis === 'y' ? {x: 0, y: 0} : {x: 0, y: 0.5}) : (axis === 'y' ? {x: 0.5, y: 0} : {x: 0, y: 0}); 81 | 82 | var repeat = axis === 'y' ? {x: 0.5, y: 1} : {x: 1, y: 0.5}; 83 | 84 | var uvAttribute = geometry.attributes.uv; 85 | 86 | for (var i = 0; i < uvAttribute.count; i++ ) { 87 | var u = uvAttribute.getX(i)*repeat.x + offset.x; 88 | var v = uvAttribute.getY(i)*repeat.y + offset.y; 89 | 90 | uvAttribute.setXY(i, u, v); 91 | 92 | } 93 | 94 | // Needed in BufferGeometry to update UVs 95 | 96 | uvAttribute.needsUpdate = true 97 | 98 | this.originalGeometry = object3D.geometry; 99 | object3D.geometry = geometry 100 | 101 | } 102 | else{ 103 | 104 | // No need to attach video click if not a sphere and not a video, set this to true 105 | 106 | this.video_click_event_added = true; 107 | 108 | } 109 | 110 | 111 | }, 112 | 113 | remove: function(){ 114 | var object3D = this.el.object3D.children[0]; 115 | object3D.geometry.dispose(); 116 | if (this.originalGeometry) { 117 | object3D.geometry = this.originalGeometry; 118 | } 119 | }, 120 | 121 | // On element update, put in the right layer, 0:both, 1:left, 2:right (spheres or not) 122 | 123 | update: function(oldData){ 124 | 125 | var object3D = this.el.object3D.children[0]; 126 | var data = this.data; 127 | 128 | if(data.eye === "both"){ 129 | object3D.layers.set(0); 130 | } 131 | else{ 132 | object3D.layers.set(data.eye === 'left' ? 1:2); 133 | } 134 | 135 | }, 136 | 137 | tick: function(time){ 138 | 139 | // If this value is false, it means that (a) this is a video on a sphere [see init method] 140 | // and (b) of course, tick is not added 141 | 142 | if(!this.video_click_event_added && this.data.playOnClick){ 143 | if(typeof(this.el.sceneEl.canvas) !== 'undefined'){ 144 | 145 | // Get video DOM 146 | 147 | this.videoEl = this.el.object3D.children[0].material.map.image; 148 | 149 | // On canvas click, play video element. Use self to not lose track of object into event handler 150 | 151 | var self = this; 152 | 153 | this.el.sceneEl.canvas.onclick = function () { 154 | self.videoEl.play(); 155 | }; 156 | 157 | // Signal that click event is added 158 | this.video_click_event_added = true; 159 | 160 | } 161 | } 162 | 163 | } 164 | }, 165 | 166 | // Sets the 'default' eye viewed by camera in non-VR mode 167 | 168 | 'stereocam_component':{ 169 | 170 | schema: { 171 | eye: { type: 'string', default: "left"} 172 | }, 173 | 174 | // Cam is not attached on init, so use a flag to do this once at 'tick' 175 | 176 | // Use update every tick if flagged as 'not changed yet' 177 | 178 | init: function(){ 179 | // Flag to register if cam layer has already changed 180 | this.layer_changed = false; 181 | }, 182 | 183 | tick: function(time){ 184 | 185 | var originalData = this.data; 186 | 187 | // If layer never changed 188 | 189 | if(!this.layer_changed){ 190 | 191 | // because stereocam component should be attached to an a-camera element 192 | // need to get down to the root PerspectiveCamera before addressing layers 193 | 194 | // Gather the children of this a-camera and identify types 195 | 196 | var childrenTypes = []; 197 | 198 | this.el.object3D.children.forEach( function (item, index, array) { 199 | childrenTypes[index] = item.type; 200 | }); 201 | 202 | // Retrieve the PerspectiveCamera 203 | var rootIndex = childrenTypes.indexOf("PerspectiveCamera"); 204 | var rootCam = this.el.object3D.children[rootIndex]; 205 | 206 | if(originalData.eye === "both"){ 207 | rootCam.layers.enable( 1 ); 208 | rootCam.layers.enable( 2 ); 209 | } 210 | else{ 211 | rootCam.layers.enable(originalData.eye === 'left' ? 1:2); 212 | } 213 | 214 | this.layer_changed = true; 215 | } 216 | } 217 | } 218 | }; 219 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aframe-stereo-component", 3 | "version": "1.4.0", 4 | "description": "Stereoscopic component for A-Frame VR.", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "browserify examples/main.js -o examples/build.js", 8 | "dev": "budo examples/main.js:build.js --ssld --dir examples --port 8000 --live --open", 9 | "dist": "webpack browser.js dist/aframe-stereo-component.js && webpack -p browser.js dist/aframe-stereo-component.min.js", 10 | "postpublish": "npm run dist", 11 | "preghpages": "npm run build && rm -rf gh-pages && cp -r examples gh-pages", 12 | "ghpages": "npm run preghpages && ghpages -p gh-pages", 13 | "test": "karma start ./tests/karma.conf.js", 14 | "unboil": "node scripts/unboil.js" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/oscarmarinmiro/aframe-stereo-component.git" 19 | }, 20 | "keywords": [ 21 | "aframe", 22 | "aframe-component", 23 | "layout", 24 | "aframe-vr", 25 | "vr", 26 | "aframe-layout", 27 | "mozvr", 28 | "webvr", 29 | "stereo", 30 | "stereoscopic", 31 | "video", 32 | "equirectangular" 33 | ], 34 | "author": "Óscar Marín Miró @oscarmarinmiro", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/oscarmarinmiro/aframe-stereo-component/issues" 38 | }, 39 | "homepage": "https://github.com/oscarmarinmiro/aframe-stereo-component#readme", 40 | "devDependencies": { 41 | "browserify": "^12.0.1", 42 | "browserify-css": "^0.8.3", 43 | "budo": "^7.1.0", 44 | "chai": "^4.3.6", 45 | "chai-shallow-deep-equal": "^1.4.0", 46 | "ghpages": "0.0.3", 47 | "inquirer": "^0.12.0", 48 | "karma": "^6.4.0", 49 | "karma-browserify": "^4.4.2", 50 | "karma-chai-shallow-deep-equal": "0.0.4", 51 | "karma-firefox-launcher": "^2.1.2", 52 | "karma-mocha": "^2.0.1", 53 | "karma-mocha-reporter": "^2.2.5", 54 | "karma-sinon-chai": "^2.0.2", 55 | "mocha": "^10.0.0", 56 | "shelljs": ">=0.8.5", 57 | "webpack": "^1.12.9" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /scripts/unboil.js: -------------------------------------------------------------------------------- 1 | require('shelljs/global'); 2 | var exec = require('child_process').exec; 3 | var inquirer = require('inquirer'); 4 | 5 | var q1 = { 6 | name: 'shortname', 7 | message: 'What is your component\'s short-name? (e.g., `rick-roll` for aframe-rick-roll-component, ``)', 8 | type: 'input' 9 | }; 10 | 11 | var q2 = { 12 | name: 'longname', 13 | message: 'What is your component\'s long-name? (e.g., `Rick Roll` for A-Frame Rick Roll Component)', 14 | type: 'input' 15 | }; 16 | 17 | var q3 = { 18 | name: 'repo', 19 | message: 'Where is your component on Github? (e.g., yourusername/aframe-rick-roll-component)', 20 | type: 'input' 21 | }; 22 | 23 | var q4 = { 24 | name: 'author', 25 | message: 'Who are you? (e.g., Jane John )', 26 | type: 'input' 27 | }; 28 | 29 | inquirer.prompt([q1, q2, q3, q4], function (ans) { 30 | ls(['index.js', 'package.json', 'README.md']).forEach(function(file) { 31 | sed('-i', 'aframe-example-component', 'aframe-' + ans.shortname + '-component', file); 32 | sed('-i', 'Example Component', ans.longname + ' Component', file); 33 | sed('-i', 'Example component', ans.longname + ' component', file); 34 | sed('-i', "'example'", "'" + ans.shortname + "'", file); 35 | }); 36 | 37 | ls('README.md').forEach(function (file) { 38 | sed('-i', 'example component', ans.longname + ' component', file); 39 | sed('-i', 'example=', ans.shortname + '=', file); 40 | }); 41 | 42 | find('examples').filter(function (file) { return file.match(/\.html/); }).forEach(function (file) { 43 | sed('-i', 'Example Component', ans.longname + ' Component', file); 44 | }); 45 | 46 | ls(['package.json', 'README.md']).forEach(function (file) { 47 | sed('-i', 'aframe-example-component', 'aframe-' + ans.shortname + '-component', file); 48 | sed('-i', 'ngokevin/aframe-component-boilerplate', ans.repo, file); 49 | sed('-i', 'Kevin Ngo ', ans.author, file); 50 | }); 51 | }); 52 | 53 | exec("sed '1,/--trim--/d' README.md | tee README.md"); 54 | -------------------------------------------------------------------------------- /tests/__init.test.js: -------------------------------------------------------------------------------- 1 | /* global sinon, setup, teardown */ 2 | 3 | /** 4 | * __init.test.js is run before every test case. 5 | */ 6 | window.debug = true; 7 | 8 | var AScene = require('aframe-core').AScene; 9 | 10 | beforeEach(function () { 11 | this.sinon = sinon.sandbox.create(); 12 | // Stub to not create a WebGL context since Travis CI runs headless. 13 | this.sinon.stub(AScene.prototype, 'attachedCallback'); 14 | }); 15 | 16 | afterEach(function () { 17 | // Clean up any attached elements. 18 | ['canvas', 'a-assets', 'a-scene'].forEach(function (tagName) { 19 | var els = document.querySelectorAll(tagName); 20 | for (var i = 0; i < els.length; i++) { 21 | els[i].parentNode.removeChild(els[i]); 22 | } 23 | }); 24 | AScene.scene = null; 25 | 26 | this.sinon.restore(); 27 | }); 28 | -------------------------------------------------------------------------------- /tests/helpers.js: -------------------------------------------------------------------------------- 1 | /* global suite */ 2 | 3 | /** 4 | * Helper method to create a scene, create an entity, add entity to scene, 5 | * add scene to document. 6 | * 7 | * @returns {object} An `` element. 8 | */ 9 | module.exports.entityFactory = function () { 10 | var scene = document.createElement('a-scene'); 11 | var entity = document.createElement('a-entity'); 12 | scene.appendChild(entity); 13 | document.body.appendChild(scene); 14 | return entity; 15 | }; 16 | 17 | /** 18 | * Creates and attaches a mixin element (and an `` element if necessary). 19 | * 20 | * @param {string} id - ID of mixin. 21 | * @param {object} obj - Map of component names to attribute values. 22 | * @returns {object} An attached `` element. 23 | */ 24 | module.exports.mixinFactory = function (id, obj) { 25 | var mixinEl = document.createElement('a-mixin'); 26 | mixinEl.setAttribute('id', id); 27 | Object.keys(obj).forEach(function (componentName) { 28 | mixinEl.setAttribute(componentName, obj[componentName]); 29 | }); 30 | 31 | var assetsEl = document.querySelector('a-assets'); 32 | if (!assetsEl) { 33 | assetsEl = document.createElement('a-assets'); 34 | document.body.appendChild(assetsEl); 35 | } 36 | assetsEl.appendChild(mixinEl); 37 | 38 | return mixinEl; 39 | }; 40 | 41 | /** 42 | * Test that is only run locally and is skipped on CI. 43 | */ 44 | module.exports.getSkipCISuite = function () { 45 | if (window.__env__.TEST_ENV === 'ci') { 46 | return suite.skip; 47 | } else { 48 | return suite; 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /tests/index.test.js: -------------------------------------------------------------------------------- 1 | var Aframe = require('aframe-core'); 2 | var example = require('../index.js').component; 3 | var entityFactory = require('./helpers').entityFactory; 4 | 5 | Aframe.registerComponent('example', example); 6 | 7 | describe('example', function () { 8 | beforeEach(function (done) { 9 | this.el = entityFactory(); 10 | this.el.addEventListener('loaded', function () { 11 | done(); 12 | }); 13 | }); 14 | 15 | describe('example property', function () { 16 | it('is good', function () { 17 | assert.equal(1, 1); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /tests/karma.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = function (config) { 3 | config.set({ 4 | basePath: '../', 5 | browserify: { 6 | paths: ['./'] 7 | }, 8 | browsers: ['firefox_latest'], 9 | customLaunchers: { 10 | firefox_latest: { 11 | base: 'FirefoxNightly', 12 | prefs: { /* empty */ } 13 | } 14 | }, 15 | client: { 16 | captureConsole: true, 17 | mocha: {ui: 'bdd'} 18 | }, 19 | envPreprocessor: [ 20 | 'TEST_ENV' 21 | ], 22 | files: [ 23 | 'tests/**/*.test.js', 24 | ], 25 | frameworks: ['mocha', 'sinon-chai', 'chai-shallow-deep-equal', 'browserify'], 26 | preprocessors: { 27 | 'tests/**/*.js': ['browserify'] 28 | }, 29 | reporters: ['mocha'] 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /video_stereo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c-frame/aframe-stereo-component/f037508ed5fbea8bf59d237d99349a66b14328ae/video_stereo.png --------------------------------------------------------------------------------