diff --git a/docs/components/gltf-model.md b/docs/components/gltf-model.md new file mode 100644 index 0000000000..4185228e9a --- /dev/null +++ b/docs/components/gltf-model.md @@ -0,0 +1,81 @@ +--- +title: gltf-model +type: components +layout: docs +parent_section: components +--- + +[about-gltf]: https://www.khronos.org/gltf + +[glTF][about-gltf] (GL Transmission Format) is an open project by Khronos providing a common, extensible format for 3D assets that is both efficient and highly interoperable with modern web technologies. + +The `gltf-model` component loads a 3D model using a glTF (.gltf or .glb) file. + +## Why use glTF? + +[obj-model]: ./obj-model.md +[collada-model]: ./collada-model.md + +In comparison to the older [OBJ][obj-model] format, which supports only vertices, normals, texture coords, and basic materials, glTF provides a more powerful set of features. In addition to all of the above, glTF offers: + +- Hierarchical objects +- Scene information (light sources, cameras) +- Skeletal structure and animation +- More robust materials and shaders + +For simple models with no animation, OBJ is nevertheless a common and reliable choice. + +In comparison to [COLLADA][collada-model], the supported features are very similar. However, because glTF focuses on providing a "transmission format" rather than an editor format, it is more interoperable with web technologies. By analogy, the .PSD (Adobe Photoshop) format is helpful for editing 2D images, but images are converted to .JPG for use on the web. In the same way, glTF is a simpler way of transmitting 3D assets while rendering the same result. + +In short, expect glTF models to work with A-Frame more reliably than COLLADA models. + +## Example + +Load a glTF model by pointing to an asset that specifies the `src` for a glTF file. + +```html + + + + + + + +``` + +## Values + +| Type | Description | +|----------|--------------------------------------| +| selector | Selector to an `` | +| string | `url()`-enclosed path to a glTF file | + +## Events + +| Event Name | Description | +|--------------|--------------------------------------------| +| model-loaded | glTF model has been loaded into the scene. | + +## Loading Inline + +Alternatively, load a glTF model by specifying the path directly within `url()`. This is less performant than using the asset management system. + +```html + +``` + +## More Resources + +The glTF format is fairly new, and few editors will export a `.gltf` file directly. Instead, various converters are available or in progress: + +[fbx-converter]: http://gltf.autodesk.io/ +[collada-converter]: http://cesiumjs.org/convertmodel.html +[obj-converter]: https://github.com/AnalyticalGraphicsInc/obj2gltf + +- [FBX → glTF][fbx-converter] - coming soon. +- [COLLADA → glTF][collada-converter]. +- [OBJ → glTF][obj-converter]. + +[spec]: https://github.com/KhronosGroup/glTF + +See the [official glTF specification][spec] for available features, community resources, and ways to contribute. diff --git a/docs/primitives/a-gltf-model.md b/docs/primitives/a-gltf-model.md new file mode 100644 index 0000000000..cfdbb5e3fd --- /dev/null +++ b/docs/primitives/a-gltf-model.md @@ -0,0 +1,33 @@ +--- +title: +type: primitives +layout: docs +parent_section: primitives +--- + +The glTF model primitive displays a 3D glTF model created from a 3D +modeling program or downloaded from the web. + +## Example + +```html + + + + + + + + + + + +``` + +## Attribute + +[gltf]: ../components/gltf-model.md + +| Attribute | Component Mapping | Default Value | +|-----------|------------------------|---------------| +| src | [gltf-model][gltf].src | null | diff --git a/examples/test/model/index.html b/examples/test/model/index.html index 461cf8833b..f48336a062 100644 --- a/examples/test/model/index.html +++ b/examples/test/model/index.html @@ -2,8 +2,8 @@ - Model - + Models (glTF, OBJ, COLLADA) + @@ -11,15 +11,23 @@ + + + + + + + + diff --git a/src/components/gltf-model.js b/src/components/gltf-model.js new file mode 100644 index 0000000000..9d0433a1ca --- /dev/null +++ b/src/components/gltf-model.js @@ -0,0 +1,37 @@ +var registerComponent = require('../core/component').registerComponent; +var THREE = require('../lib/three'); + +/** + * glTF model loader. + */ +module.exports.Component = registerComponent('gltf-model', { + schema: {type: 'model'}, + + init: function () { + this.model = null; + this.loader = new THREE.GLTFLoader(); + }, + + update: function () { + var self = this; + var el = this.el; + var src = this.data; + + if (!src) { return; } + + this.remove(); + + this.loader.load(src, function gltfLoaded (gltfModel) { + self.model = gltfModel.scene; + self.system.registerModel(self.model); + el.setObject3D('mesh', self.model); + el.emit('model-loaded', {format: 'gltf', model: self.model}); + }); + }, + + remove: function () { + if (!this.model) { return; } + this.el.removeObject3D('mesh'); + this.system.unregisterModel(this.model); + } +}); diff --git a/src/components/index.js b/src/components/index.js index 988ed11b89..d9f52aeb0e 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -3,6 +3,7 @@ require('./camera'); require('./collada-model'); require('./cursor'); require('./geometry'); +require('./gltf-model'); require('./hand-controls'); require('./light'); require('./look-controls'); diff --git a/src/extras/primitives/index.js b/src/extras/primitives/index.js index 95d37975ab..3ee15aa8b4 100644 --- a/src/extras/primitives/index.js +++ b/src/extras/primitives/index.js @@ -2,6 +2,7 @@ require('./primitives/a-camera'); require('./primitives/a-collada-model'); require('./primitives/a-cursor'); require('./primitives/a-curvedimage'); +require('./primitives/a-gltf-model'); require('./primitives/a-image'); require('./primitives/a-light'); require('./primitives/a-obj-model'); diff --git a/src/extras/primitives/primitives/a-collada-model.js b/src/extras/primitives/primitives/a-collada-model.js index 7746ed6371..75c3e45d12 100644 --- a/src/extras/primitives/primitives/a-collada-model.js +++ b/src/extras/primitives/primitives/a-collada-model.js @@ -1,9 +1,7 @@ -var getMeshMixin = require('../getMeshMixin'); var registerPrimitive = require('../primitives').registerPrimitive; -var utils = require('../../../utils/'); -registerPrimitive('a-collada-model', utils.extendDeep({}, getMeshMixin(), { +registerPrimitive('a-collada-model', { mappings: { src: 'collada-model' } -})); +}); diff --git a/src/extras/primitives/primitives/a-gltf-model.js b/src/extras/primitives/primitives/a-gltf-model.js new file mode 100644 index 0000000000..986d8bf5ba --- /dev/null +++ b/src/extras/primitives/primitives/a-gltf-model.js @@ -0,0 +1,7 @@ +var registerPrimitive = require('../primitives').registerPrimitive; + +registerPrimitive('a-gltf-model', { + mappings: { + src: 'gltf-model' + } +}); diff --git a/src/lib/three.js b/src/lib/three.js index 6b0d8cd17c..2e636124bf 100644 --- a/src/lib/three.js +++ b/src/lib/three.js @@ -19,6 +19,7 @@ if (THREE.Cache) { } // TODO: Eventually include these only if they are needed by a component. +require('three/examples/js/loaders/GLTFLoader'); // THREE.GLTFLoader require('three/examples/js/loaders/OBJLoader'); // THREE.OBJLoader require('three/examples/js/loaders/MTLLoader'); // THREE.MTLLoader require('three/examples/js/BlendCharacter'); // THREE.BlendCharacter @@ -27,6 +28,7 @@ require('../../vendor/VRControls'); // THREE.VRControls require('../../vendor/VREffect'); // THREE.VREffect THREE.ColladaLoader.prototype.crossOrigin = 'anonymous'; +THREE.GLTFLoader.prototype.crossOrigin = 'anonymous'; THREE.MTLLoader.prototype.crossOrigin = 'anonymous'; THREE.OBJLoader.prototype.crossOrigin = 'anonymous'; diff --git a/src/systems/gltf-model.js b/src/systems/gltf-model.js new file mode 100644 index 0000000000..031e538f4b --- /dev/null +++ b/src/systems/gltf-model.js @@ -0,0 +1,41 @@ +var registerSystem = require('../core/system').registerSystem; +var THREE = require('../lib/three'); + +/** + * glTF model system. + */ +module.exports.System = registerSystem('gltf-model', { + init: function () { + this.models = []; + }, + + /** + * Updates shaders for all glTF models in the system. + */ + tick: function () { + var sceneEl = this.sceneEl; + if (sceneEl.hasLoaded && this.models.length) { + THREE.GLTFLoader.Shaders.update(sceneEl.object3D, sceneEl.camera); + } + }, + + /** + * Registers a glTF asset. + * @param {object} gltf Asset containing a scene and (optional) animations and cameras. + */ + registerModel: function (gltf) { + this.models.push(gltf); + }, + + /** + * Unregisters a glTF asset. + * @param {object} gltf Asset containing a scene and (optional) animations and cameras. + */ + unregisterModel: function (gltf) { + var models = this.models; + var index = models.indexOf(gltf); + if (index >= 0) { + models.splice(index, 1); + } + } +}); diff --git a/src/systems/index.js b/src/systems/index.js index b96fb23b2f..8a64b19dc2 100755 --- a/src/systems/index.js +++ b/src/systems/index.js @@ -1,5 +1,6 @@ require('./camera'); require('./geometry'); +require('./gltf-model'); require('./light'); require('./material'); require('./tracked-controls'); diff --git a/tests/assets/box/Box.bin b/tests/assets/box/Box.bin new file mode 100755 index 0000000000..29a29e1385 Binary files /dev/null and b/tests/assets/box/Box.bin differ diff --git a/tests/assets/box/Box.gltf b/tests/assets/box/Box.gltf new file mode 100755 index 0000000000..90df6f1b3d --- /dev/null +++ b/tests/assets/box/Box.gltf @@ -0,0 +1,250 @@ +{ + "accessors": { + "accessor_21": { + "bufferView": "bufferView_29", + "byteOffset": 0, + "byteStride": 0, + "componentType": 5123, + "count": 36, + "type": "SCALAR" + }, + "accessor_23": { + "bufferView": "bufferView_30", + "byteOffset": 0, + "byteStride": 12, + "componentType": 5126, + "count": 24, + "max": [ + 0.5, + 0.5, + 0.5 + ], + "min": [ + -0.5, + -0.5, + -0.5 + ], + "type": "VEC3" + }, + "accessor_25": { + "bufferView": "bufferView_30", + "byteOffset": 288, + "byteStride": 12, + "componentType": 5126, + "count": 24, + "max": [ + 1, + 1, + 1 + ], + "min": [ + -1, + -1, + -1 + ], + "type": "VEC3" + } + }, + "animations": {}, + "asset": { + "generator": "collada2gltf@027f74366341d569dea42e9a68b7104cc3892054", + "premultipliedAlpha": true, + "profile": { + "api": "WebGL", + "version": "1.0.2" + }, + "version": "1.0" + }, + "bufferViews": { + "bufferView_29": { + "buffer": "Box", + "byteLength": 72, + "byteOffset": 0, + "target": 34963 + }, + "bufferView_30": { + "buffer": "Box", + "byteLength": 576, + "byteOffset": 72, + "target": 34962 + } + }, + "buffers": { + "Box": { + "byteLength": 648, + "type": "arraybuffer", + "uri": "Box.bin" + } + }, + "materials": { + "Effect-Red": { + "name": "Red", + "technique": "technique0", + "values": { + "diffuse": [ + 0.8, + 0, + 0, + 1 + ], + "shininess": 256, + "specular": [ + 0.2, + 0.2, + 0.2, + 1 + ] + } + } + }, + "meshes": { + "Geometry-mesh002": { + "name": "Mesh", + "primitives": [ + { + "attributes": { + "NORMAL": "accessor_25", + "POSITION": "accessor_23" + }, + "indices": "accessor_21", + "material": "Effect-Red", + "mode": 4 + } + ] + } + }, + "nodes": { + "Geometry-mesh002Node": { + "children": [], + "matrix": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "meshes": [ + "Geometry-mesh002" + ], + "name": "Mesh" + }, + "node_1": { + "children": [ + "Geometry-mesh002Node" + ], + "matrix": [ + 1, + 0, + 0, + 0, + 0, + 0, + -1, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1 + ], + "name": "Y_UP_Transform" + } + }, + "programs": { + "program_0": { + "attributes": [ + "a_normal", + "a_position" + ], + "fragmentShader": "Box0FS", + "vertexShader": "Box0VS" + } + }, + "scene": "defaultScene", + "scenes": { + "defaultScene": { + "nodes": [ + "node_1" + ] + } + }, + "shaders": { + "Box0FS": { + "type": 35632, + "uri": "Box0FS.glsl" + }, + "Box0VS": { + "type": 35633, + "uri": "Box0VS.glsl" + } + }, + "skins": {}, + "techniques": { + "technique0": { + "attributes": { + "a_normal": "normal", + "a_position": "position" + }, + "parameters": { + "diffuse": { + "type": 35666 + }, + "modelViewMatrix": { + "semantic": "MODELVIEW", + "type": 35676 + }, + "normal": { + "semantic": "NORMAL", + "type": 35665 + }, + "normalMatrix": { + "semantic": "MODELVIEWINVERSETRANSPOSE", + "type": 35675 + }, + "position": { + "semantic": "POSITION", + "type": 35665 + }, + "projectionMatrix": { + "semantic": "PROJECTION", + "type": 35676 + }, + "shininess": { + "type": 5126 + }, + "specular": { + "type": 35666 + } + }, + "program": "program_0", + "states": { + "enable": [ + 2929, + 2884 + ] + }, + "uniforms": { + "u_diffuse": "diffuse", + "u_modelViewMatrix": "modelViewMatrix", + "u_normalMatrix": "normalMatrix", + "u_projectionMatrix": "projectionMatrix", + "u_shininess": "shininess", + "u_specular": "specular" + } + } + } +} \ No newline at end of file diff --git a/tests/assets/box/Box0FS.glsl b/tests/assets/box/Box0FS.glsl new file mode 100755 index 0000000000..6e928dc700 --- /dev/null +++ b/tests/assets/box/Box0FS.glsl @@ -0,0 +1,17 @@ +precision highp float; +varying vec3 v_normal; +uniform vec4 u_diffuse; +uniform vec4 u_specular; +uniform float u_shininess; +void main(void) { +vec3 normal = normalize(v_normal); +vec4 color = vec4(0., 0., 0., 0.); +vec4 diffuse = vec4(0., 0., 0., 1.); +vec4 specular; +diffuse = u_diffuse; +specular = u_specular; +diffuse.xyz *= max(dot(normal,vec3(0.,0.,1.)), 0.); +color.xyz += diffuse.xyz; +color = vec4(color.rgb * diffuse.a, diffuse.a); +gl_FragColor = color; +} diff --git a/tests/assets/box/Box0VS.glsl b/tests/assets/box/Box0VS.glsl new file mode 100755 index 0000000000..9e3592280a --- /dev/null +++ b/tests/assets/box/Box0VS.glsl @@ -0,0 +1,12 @@ +precision highp float; +attribute vec3 a_position; +attribute vec3 a_normal; +varying vec3 v_normal; +uniform mat3 u_normalMatrix; +uniform mat4 u_modelViewMatrix; +uniform mat4 u_projectionMatrix; +void main(void) { +vec4 pos = u_modelViewMatrix * vec4(a_position,1.0); +v_normal = u_normalMatrix * a_normal; +gl_Position = u_projectionMatrix * pos; +} diff --git a/tests/components/gltf-model.test.js b/tests/components/gltf-model.test.js new file mode 100644 index 0000000000..bfcd803c8d --- /dev/null +++ b/tests/components/gltf-model.test.js @@ -0,0 +1,58 @@ +/* global assert, process, setup, suite, test */ +var entityFactory = require('../helpers').entityFactory; + +var SRC = '/base/tests/assets/box/Box.gltf'; + +suite('gltf-model', function () { + setup(function (done) { + var el; + var asset = document.createElement('a-asset-item'); + asset.setAttribute('id', 'gltf'); + asset.setAttribute('src', SRC); + el = this.el = entityFactory({assets: [asset]}); + if (el.hasLoaded) { done(); } + el.addEventListener('loaded', function () { done(); }); + }); + + test('can load', function (done) { + var el = this.el; + el.addEventListener('model-loaded', function () { + assert.ok(el.components['gltf-model'].model); + done(); + }); + el.setAttribute('gltf-model', '#gltf'); + }); + + test('can load with url()', function (done) { + var el = this.el; + el.addEventListener('model-loaded', function () { + assert.ok(el.components['gltf-model'].model); + done(); + }); + el.setAttribute('gltf-model', `url(${SRC})`); + }); + + test('can load multiple models', function (done) { + var el = this.el; + var el2 = document.createElement('a-entity'); + var elPromise = new Promise(function (resolve) { + el.addEventListener('model-loaded', resolve); + }); + var el2Promise = new Promise(function (resolve) { + el2.addEventListener('model-loaded', resolve); + }); + + Promise.all([elPromise, el2Promise]).then(function () { + assert.ok(el.getObject3D('mesh')); + assert.ok(el2.getObject3D('mesh')); + assert.notEqual(el.components['gltf-model'].model, el2.components['gltf-model'].model); + done(); + }); + + el2.addEventListener('loaded', function () { + el.setAttribute('gltf-model', '#gltf'); + el2.setAttribute('gltf-model', '#gltf'); + }); + el.sceneEl.appendChild(el2); + }); +}); diff --git a/tests/systems/gltf-model.test.js b/tests/systems/gltf-model.test.js new file mode 100644 index 0000000000..2aa351c2bc --- /dev/null +++ b/tests/systems/gltf-model.test.js @@ -0,0 +1,37 @@ +/* global process, setup, suite, test, sinon, THREE */ +var entityFactory = require('../helpers').entityFactory; + +suite('glTF system', function () { + setup(function (done) { + var el = this.el = entityFactory(); + this.sinon.spy(THREE.GLTFLoader.Shaders, 'update'); + el.addEventListener('loaded', function () { + done(); + }); + }); + + suite('tick', function () { + test('does nothing when no models are present', function () { + var sceneEl = this.el.sceneEl; + var model = {cool: 'very'}; + + sceneEl.systems['gltf-model'].tick(100, 10); + sinon.assert.notCalled(THREE.GLTFLoader.Shaders.update); + + sceneEl.systems['gltf-model'].registerModel(model); + sceneEl.systems['gltf-model'].unregisterModel(model); + sceneEl.systems['gltf-model'].tick(110, 10); + sinon.assert.notCalled(THREE.GLTFLoader.Shaders.update); + }); + + test('updates shaders', function () { + var sceneEl = this.el.sceneEl; + var model = {cool: 'very'}; + + sceneEl.systems['gltf-model'].registerModel(model); + sceneEl.systems['gltf-model'].tick(110, 10); + sinon.assert.calledOnce(THREE.GLTFLoader.Shaders.update); + sinon.assert.calledWith(THREE.GLTFLoader.Shaders.update, sceneEl.object3D, sceneEl.camera); + }); + }); +});