diff --git a/docs/components/attached.md b/docs/components/attached.md new file mode 100644 index 00000000000..342df30228e --- /dev/null +++ b/docs/components/attached.md @@ -0,0 +1,54 @@ +--- +title: attached +type: components +layout: docs +parent_section: components +source_code: src/components/attached.js +examples: [] +--- + +[visible]: ./visible.md +[pool]: ./pool.md + +The attached component determines whether an entity is attached to the THREE.js scene graph at all. + +All entities are attached by default. Detaching an entity from the scene means that the entity and its descendants will have no interactions in the 3D scene at all: it is not rendered, it will not interact with raycasters. + +This is similar to the [visible][visible] component, but places even more limitations on the entity. + +Invisible entities are not rendered, but their position in space is still updated every frame, and they can interact with raycasters, be checked for collisions etc. + +In contrast, when an entity is detached from the scene, by setting `attached="false"`, even these interactions do not occur, which will improve performance even further vs. just making an entity invisible. For example, entity pools implemented by the [pool][pool] component detach entities in the pool when they are not in use. + +It's a common pattern to create container entities that contain an entire group of entities that you can flip on an off with `attached`. + + +## Example + +```html + +``` + +## Value + +| Value | Description | +|-------|----------------------------------------------------------------------------------------| +| true | The entity will be rendered and visible; the default value. | +| false | The entity will be detached from the THREE.js scene. It will not be rendered nor + visible, and will not interact with anything else in the scene. | + +## Updating Attachment + +It is slightly faster to control attachment to the THREE.js scene using direct calls to [`attachToScene()`](../core/entity.md#attachtoscene-) and [`detachFromScene()`](../core/entity.md#detachfromscene-): + +```js +// direct use of entity interface +el.detachFromScene() + +// with setAttribute. +e.setAttribute('attached', false) + +``` + +Updates at the three.js level will still be reflected when doing +`entityEl.getAttribute('attached');`. diff --git a/docs/components/pool.md b/docs/components/pool.md index f9405fb16a3..e19cddaffb9 100644 --- a/docs/components/pool.md +++ b/docs/components/pool.md @@ -7,6 +7,8 @@ source_code: src/components/scene/pool.js examples: [] --- +[attached]: ./attached.md + The pool component allows for [object pooling](https://en.wikipedia.org/wiki/Object_pool_pattern). This gives us a reusable pool of entities to avoid creating and destroying the same kind of @@ -15,6 +17,8 @@ entities in dynamic scenes. Object pooling helps reduce garbage collection pause Note that entities requested from the pool are paused by default and you need to call `.play()` in order to activate their components' tick functions. +For performance reasons, unused entities in the pool are [detached from the THREE.js scene][attached]. + ## Example For example, we may have a game with enemy entities that we want to reuse. diff --git a/docs/components/visible.md b/docs/components/visible.md index ffd7ae61eb8..3830b64a9f3 100644 --- a/docs/components/visible.md +++ b/docs/components/visible.md @@ -6,14 +6,20 @@ parent_section: components source_code: src/components/visible.js examples: [] --- +[attached]: ./attached.md The visible component determines whether to render an entity. If set to `false`, then the entity will not be visible nor drawn. Visibility effectively applies to all children. If an entity's parent or ancestor entity has visibility set to false, then the entity will also not be -visible nor draw. It's a common pattern to create container entities that -contain an entire group of entities that you can flip on an off with `visible`. +visible nor draw. However, the entity's position in space is still maintained, +so it can still iuinteract with raycasters, be checked for collisions etc. + +When you want an entity or group of entities to have no interactions at all, it +is usually preferable (for performance reasons) to detach them from the THREE.js scene +entirely by toggling the [`attached`][attached] component. + ## Example @@ -30,7 +36,7 @@ contain an entire group of entities that you can flip on an off with `visible`. ## Updating Visibility -[update]: ../introduction/javascript-events-and-dom-apis.md#updating-a-component-with-setattribute +[update]: ../introduction/javascript-events-dom-apis.md#updating-a-component-with-setattribute- It is slightly faster to update visibility at the three.js level versus [via `.setAttribute`][update]. diff --git a/docs/core/entity.md b/docs/core/entity.md index 5f7d439e7ba..1d2aaf56121 100644 --- a/docs/core/entity.md +++ b/docs/core/entity.md @@ -168,10 +168,38 @@ entity.addState('selected'); entity.is('selected'); // >> true ``` +[attach]: #attachtoscene- +[detach]: #detachfromscene- + +### `attachToScene ()` + +`attachToScene` can be used to re-attach an entity to the THREE.js scene that has been detached +using `detachFromScene`. + +See [`detachFromScene`][detach] for more explanation. + ### `destroy ()` Clean up memory related to the entity such as clearing all components and their data. +### `detachFromScene ()` + +[pool]: ../components/pool.md +[attached]: ../components/attached.md + +`detachFromScene` will detach the element from the THREE.js scene, typically for performance reasons. +Detaching an entity from the scene means that the entity and its descendants will have no interactions +in the 3D scene at all: it is not rendered, it will not interact with raycasters. + +The main reason for detaching an entity from the scene, rather than destroying it are: +- to maintain state on the entity +- for performance reasons, as it is typically faster to re-attach an entity to the scene, than to recreate it and all its descendants. + +For performance reasons, entity pools implemented by the [pool][pool] component detach entities in the pool when they are not in use. + +Attachment to the THREE.js scene can also be controlled through the [attached][attached] component. + + ### `emit (name, detail, bubbles)` [animation]: ../components/animation.md @@ -452,6 +480,8 @@ entity.is('selected'); // >> false |----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | child-attached | A child entity was attached to the entity. | | child-detached | A child entity was detached from the entity. | +| attached-to-scene | The entity was attached to the THREE.js scene. This occurs on initial entity creation, and following subsequent calls to [`attachToScene()`][attach] | +| detached-from-scene | The entity was detached from the THREE.js scene. This occurs a call to to [`detachFromScene()`][detach] (unless the call is made durign entity creation, prior to the entity being attached to the THREE.js scene). | | componentchanged | One of the entity's components was modified. This event is throttled. Do not use this for reading position and rotation changes, rather [use a tick handler](../camera.md#reading-position-or-rotation-of-the-camera). | | componentinitialized | One of the entity's components was initialized. | | componentremoved | One of the entity's components was removed. | diff --git a/src/components/attached.js b/src/components/attached.js new file mode 100644 index 00000000000..7974a2a4e51 --- /dev/null +++ b/src/components/attached.js @@ -0,0 +1,16 @@ +var registerComponent = require('../core/component').registerComponent; + +/** + * Attached component. + */ +module.exports.Component = registerComponent('attached', { + schema: {default: true}, + + update: function () { + if (this.data) { + this.el.attachToScene(); + } else { + this.el.detachFromScene(); + } + } +}); diff --git a/src/components/index.js b/src/components/index.js index 4266f71dd14..24edcbe0dc5 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -1,4 +1,5 @@ require('./animation'); +require('./attached'); require('./camera'); require('./cursor'); require('./daydream-controls'); diff --git a/src/components/raycaster.js b/src/components/raycaster.js index b23652f0b3a..c1bdf2400e6 100644 --- a/src/components/raycaster.js +++ b/src/components/raycaster.js @@ -154,12 +154,16 @@ module.exports.Component = registerComponent('raycaster', { this.observer.observe(this.el.sceneEl, OBSERVER_CONFIG); this.el.sceneEl.addEventListener('object3dset', this.setDirty); this.el.sceneEl.addEventListener('object3dremove', this.setDirty); + this.el.sceneEl.addEventListener('attached-to-scene', this.setDirty); + this.el.sceneEl.addEventListener('detached-from-scene', this.setDirty); }, removeEventListeners: function () { this.observer.disconnect(); this.el.sceneEl.removeEventListener('object3dset', this.setDirty); this.el.sceneEl.removeEventListener('object3dremove', this.setDirty); + this.el.sceneEl.removeEventListener('attached-to-scene', this.setDirty); + this.el.sceneEl.removeEventListener('detached-from-scene', this.setDirty); }, /** @@ -409,13 +413,23 @@ module.exports.Component = registerComponent('raycaster', { var key; var i; var objects = this.objects; + var scene = this.el.sceneEl.object3D; + + function isAttachedToScene (object) { + if (object.parent) { + return isAttachedToScene(object.parent); + } else { + return (object === scene); + } + } // Push meshes and other attachments onto list of objects to intersect. objects.length = 0; for (i = 0; i < els.length; i++) { - if (els[i].isEntity && els[i].object3D) { - for (key in els[i].object3DMap) { - objects.push(els[i].getObject3D(key)); + var el = els[i]; + if (el.isEntity && el.object3D && isAttachedToScene(el.object3D)) { + for (key in el.object3DMap) { + objects.push(el.getObject3D(key)); } } } diff --git a/src/components/scene/pool.js b/src/components/scene/pool.js index 618b2c76b1a..f9584e43e74 100644 --- a/src/components/scene/pool.js +++ b/src/components/scene/pool.js @@ -61,6 +61,7 @@ module.exports.Component = registerComponent('pool', { el.setAttribute('mixin', this.data.mixin); el.object3D.visible = false; el.pause(); + el.detachFromScene(); this.container.appendChild(el); this.availableEls.push(el); }, @@ -93,6 +94,7 @@ module.exports.Component = registerComponent('pool', { this.createEntity(); } el = this.availableEls.shift(); + el.attachToScene(); this.usedEls.push(el); el.object3D.visible = true; return el; @@ -110,6 +112,7 @@ module.exports.Component = registerComponent('pool', { this.usedEls.splice(index, 1); this.availableEls.push(el); el.object3D.visible = false; + el.detachFromScene(); el.pause(); return el; } diff --git a/src/core/a-entity.js b/src/core/a-entity.js index 6b39e4e0562..8330befdbde 100644 --- a/src/core/a-entity.js +++ b/src/core/a-entity.js @@ -37,6 +37,11 @@ class AEntity extends ANode { this.parentEl = null; this.rotationObj = {}; this.states = []; + this.attachedToParent = false; + // tracks attach to / detach from THREE.js Scene graph (independent of DOM attachment) + this.attachedToScene = true; + // Used to preserve object3D's parent when detached from THREE.js scene graph + this.object3DParent = null; } /** @@ -203,7 +208,14 @@ class AEntity extends ANode { if (!el.object3D) { throw new Error("Trying to add an element that doesn't have an `object3D`"); } - this.object3D.add(el.object3D); + if (el.attachedToScene) { + this.object3D.add(el.object3D); + el.emit('attached-to-scene'); + } else { + // store off parent, for a later call to 'attachToScene()' + el.object3DParent = this.object3D; + } + this.emit('child-attached', {el: el}); } @@ -253,11 +265,39 @@ class AEntity extends ANode { remove (el) { if (el) { this.object3D.remove(el.object3D); + el.object3DParent = null; } else { this.parentNode.removeChild(this); } } + /** + * Attach el to THREE.js scene graph + */ + attachToScene () { + this.attachedToScene = true; + + if (this.attachedToParent) { + if (!this.object3DParent) { + this.object3DParent = this.parentNode.object3D; + } + this.object3DParent.add(this.object3D); + this.emit('attached-to-scene'); + } + } + + /** + * Detach el from THREE.js scene graph + */ + detachFromScene () { + this.attachedToScene = false; + this.object3DParent = this.object3D.parent; + if (this.object3DParent) { + this.object3DParent.remove(this.object3D); + this.emit('detached-from-scene'); + } + } + /** * @returns {array} Direct children that are entities. */ @@ -732,6 +772,7 @@ class AEntity extends ANode { if (attr === 'rotation') { return getRotation(this); } if (attr === 'scale') { return this.object3D.scale; } if (attr === 'visible') { return this.object3D.visible; } + if (attr === 'attached') { return this.attachedToScene; } component = this.components[attr]; if (component) { return component.data; } return window.HTMLElement.prototype.getAttribute.call(this, attr); diff --git a/tests/components/attached.test.js b/tests/components/attached.test.js new file mode 100644 index 00000000000..1dcfa6de118 --- /dev/null +++ b/tests/components/attached.test.js @@ -0,0 +1,53 @@ +/* global assert, process, setup, suite, test, THREE */ +var elFactory = require('../helpers').elFactory; + +suite('attached', function () { + var el; + + setup(function (done) { + elFactory().then(_el => { + el = _el; + done(); + }); + }); + + suite('update', function () { + test('treats empty as true', function () { + el.setAttribute('attached', ''); + assert.equal(el.object3D.parent, el.parentNode.object3D); + assert.ok(el.attachedToScene); + }); + + test('can set to attached', function () { + el.setAttribute('attached', true); + assert.equal(el.object3D.parent, el.parentNode.object3D); + assert.ok(el.attachedToScene); + }); + + test('can set to not attached', function () { + el.setAttribute('attached', false); + assert.equal(el.object3D.parent, null); + assert.notOk(el.attachedToScene); + }); + + test('Non-default object3D parent maintained when detached & re-attached', function () { + var alternateParent = new THREE.Group(); + alternateParent.add(el.object3D); + el.setAttribute('attached', false); + assert.equal(el.object3D.parent, null); + assert.notOk(el.attachedToScene); + el.setAttribute('attached', true); + assert.equal(el.object3D.parent, alternateParent); + assert.ok(el.attachedToScene); + }); + + test('getAttribute is affected by changes made direct to entity', function () { + el.setAttribute('attached', true); + assert.ok(el.getAttribute('attached')); + el.detachFromScene(); + assert.notOk(el.getAttribute('attached')); + el.attachToScene(); + assert.ok(el.getAttribute('attached')); + }); + }); +}); diff --git a/tests/components/raycaster.test.js b/tests/components/raycaster.test.js index f2a1324c8a3..89425d57956 100644 --- a/tests/components/raycaster.test.js +++ b/tests/components/raycaster.test.js @@ -118,6 +118,44 @@ suite('raycaster', function () { sceneEl.appendChild(el2); sceneEl.appendChild(el3); }); + + test('Objects not attached to scene are not whitelisted', function (done) { + var el2 = document.createElement('a-entity'); + var el3 = document.createElement('a-entity'); + el2.setAttribute('class', 'clickable'); + el2.setAttribute('geometry', 'primitive: box'); + el3.setAttribute('class', 'clickable'); + el3.setAttribute('geometry', 'primitive: box'); + el3.detachFromScene(); + el3.addEventListener('loaded', function () { + el.setAttribute('raycaster', 'objects', '.clickable'); + component.tock(); + assert.equal(component.objects.length, 1); + assert.equal(component.objects[0], el2.object3D.children[0]); + assert.equal(el2, el2.object3D.children[0].el); + done(); + }); + sceneEl.appendChild(el2); + sceneEl.appendChild(el3); + }); + + test('Objects with parent not attached to scene are not whitelisted', function (done) { + var el2 = document.createElement('a-entity'); + var el3 = document.createElement('a-entity'); + el2.setAttribute('class', 'clickable'); + el2.setAttribute('geometry', 'primitive: box'); + el3.setAttribute('class', 'clickable'); + el3.setAttribute('geometry', 'primitive: box'); + el2.detachFromScene(); + el3.addEventListener('loaded', function () { + el.setAttribute('raycaster', 'objects', '.clickable'); + component.tock(); + assert.equal(component.objects.length, 0); + done(); + }); + sceneEl.appendChild(el2); + el2.appendChild(el3); + }); }); suite('tock', function () { @@ -215,6 +253,18 @@ suite('raycaster', function () { sceneEl.emit('object3dremove'); assert.equal(component.dirty, true); }); + + test('refresh objects when attachToScene() or detachFromScene() is called', function () { + el.setAttribute('raycaster', {objects: '[ray-target]'}); + component.tock(); + assert.equal(component.dirty, false); + sceneEl.emit('attached-to-scene'); + assert.equal(component.dirty, true); + component.tock(); + assert.equal(component.dirty, false); + sceneEl.emit('detached-from-scene'); + assert.equal(component.dirty, true); + }); }); suite('raycaster', function () { diff --git a/tests/components/scene/pool.test.js b/tests/components/scene/pool.test.js index d24d64a7b70..db97e856991 100644 --- a/tests/components/scene/pool.test.js +++ b/tests/components/scene/pool.test.js @@ -99,6 +99,23 @@ suite('pool', function () { }); }); + suite('attachmentToThreeScene', function () { + test('Pool entity is not initially attached to scene', function () { + var sceneEl = this.sceneEl; + var poolComponent = sceneEl.components.pool; + assert.equal(poolComponent.availableEls[0].object3D.parent, null); + }); + + test('Pool entity is attached to scene when requested, and detached when released', function () { + var sceneEl = this.sceneEl; + var poolComponent = sceneEl.components.pool; + var el = poolComponent.requestEntity(); + assert.equal(el.object3D.parent, sceneEl.object3D); + poolComponent.returnEntity(el); + assert.equal(el.object3D.parent, null); + }); + }); + suite('wrapPlay', function () { test('cannot play an entity that is not in use', function () { var sceneEl = this.sceneEl; diff --git a/tests/components/visible.test.js b/tests/components/visible.test.js index 96d356824bb..b2c78dc4fec 100644 --- a/tests/components/visible.test.js +++ b/tests/components/visible.test.js @@ -27,5 +27,12 @@ suite('visible', function () { el.setAttribute('visible', false); assert.notOk(el.object3D.visible); }); + + test('getAttribute is affected by changes to Object3D.visible', function () { + el.setAttribute('visible', true); + assert.ok(el.getAttribute('visible')); + el.object3D.visible = false; + assert.notOk(el.getAttribute('visible')); + }); }); }); diff --git a/tests/core/a-entity.test.js b/tests/core/a-entity.test.js index deaa5585c63..807cf944f93 100644 --- a/tests/core/a-entity.test.js +++ b/tests/core/a-entity.test.js @@ -1585,6 +1585,73 @@ suite('a-entity component lifecycle management', function () { assert.ok(destroySpy.callCount); }); }); + + suite('attach / detach to / from scene', function () { + test('emits attached-to-scene event when attached to THREE.js scene', function (done) { + const el2 = document.createElement('a-entity'); + el2.object3D = new THREE.Mesh(); + el2.addEventListener('attached-to-scene', function (event) { + assert.equal(event.target, el2); + el2.addEventListener('loaded', function () { + done(); + }); + }); + el.appendChild(el2); + }); + + test('attached-to-scene event is delayed when entity detached from scene during creation', function (done) { + const el2 = document.createElement('a-entity'); + var detachEventReceived = false; + el2.object3D = new THREE.Mesh(); + + const detachListener = function (event) { + detachEventReceived = true; + }; + el2.addEventListener('detached-from-scene', detachListener); + el2.addEventListener('loaded', function () { + el2.addEventListener('attached-to-scene', function (event) { + // no detached-from-scene event received, as entity was never attached. + assert.notOk(detachEventReceived); + assert.equal(event.target, el2); + el2.removeEventListener('detached-from-scene', detachListener); + done(); + }); + el2.attachToScene(); + }); + el2.detachFromScene(); + el.appendChild(el2); + }); + + test('emits detached-from-scene event when detached from THREE.js scene', function (done) { + const el2 = document.createElement('a-entity'); + el2.object3D = new THREE.Mesh(); + el2.addEventListener('attached-to-scene', function (event) { + assert.equal(event.target, el2); + el2.addEventListener('detached-from-scene', function (event) { + assert.equal(event.target, el2); + done(); + }); + el2.detachFromScene(); + }); + el.appendChild(el2); + }); + + test('detachment from scene after entity loaded', function (done) { + const el2 = document.createElement('a-entity'); + el2.object3D = new THREE.Mesh(); + el2.addEventListener('attached-to-scene', function (event) { + assert.equal(event.target, el2); + el2.addEventListener('loaded', function (event) { + el2.addEventListener('detached-from-scene', function (event) { + assert.equal(event.target, el2); + done(); + }); + el2.detachFromScene(); + }); + }); + el.appendChild(el2); + }); + }); }); suite('a-entity component dependency management', function () {