├── .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 | 
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 | 
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 | 
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 |