diff --git a/docs/components/raycaster.md b/docs/components/raycaster.md index 4a23200ca60..d964123bbb0 100644 --- a/docs/components/raycaster.md +++ b/docs/components/raycaster.md @@ -204,6 +204,27 @@ A-Frame will call `.refreshObjects()` automatically when an entity is appended or detached from the scene, but it will not get called during normal DOM mutations (e.g., some entity changes its `class`). +## Per-instance Intersections on BatchedMesh / InstancedMesh + +When multiple a-entities share a single `THREE.BatchedMesh` or +`THREE.InstancedMesh` (for draw-call reduction), the raycaster keeps +resolving every intersection to the single host entity that owns the +shared mesh. To distinguish instances, check +`intersection.batchId` / `intersection.instanceId` on the event detail. + +`raycaster-closest-entity-changed` fires whenever the closest +`(entity, batchId, instanceId)` tuple changes, including transitions +between two instances of the same shared mesh. The cursor component +treats the tuple as its hover identity, so `mouseenter` and +`mouseleave` fire on those transitions too — the event's +`intersection` detail carries the per-instance ids. + +Apps keep their own map from `batchId` / `instanceId` to logical +entities and can proxy the host's cursor events to the matching +per-instance entity. See the +[raycaster-batched-mesh example](https://github.com/aframevr/aframe/tree/master/examples/test/raycaster-batched-mesh) +for a working setup. + ## Customizing the Line If `showLine` is set to `true`, the raycaster will configure the line given the diff --git a/examples/index.html b/examples/index.html index 11b0dec522f..eea7179017d 100644 --- a/examples/index.html +++ b/examples/index.html @@ -208,6 +208,7 @@

Tests

  • Raycaster
  • Raycaster (Simple)
  • Raycaster (Sprite)
  • +
  • Raycaster (BatchedMesh / InstancedMesh)
  • Shaders
  • Shadows
  • Text
  • diff --git a/examples/test/raycaster-batched-mesh/build-batches.js b/examples/test/raycaster-batched-mesh/build-batches.js new file mode 100644 index 00000000000..26a4b0142a5 --- /dev/null +++ b/examples/test/raycaster-batched-mesh/build-batches.js @@ -0,0 +1,133 @@ +/* global AFRAME, THREE */ +// Demonstrates per-instance hover / click on THREE.BatchedMesh and THREE.InstancedMesh. +// +// A-Frame's raycaster and cursor keep treating the host a-entity as the hover +// target, but they fire `mouseenter` / `mouseleave` / `click` whenever the +// (entity, batchId, instanceId) tuple changes. The event detail includes the +// per-instance ids on `detail.intersection`. Apps hold their own map from +// ids to logical entities and proxy events to them — the mapping stays in +// app code, not in A-Frame core. + +var BOX_COLOR = new THREE.Color('#4cc3d9'); +var CONE_COLOR = new THREE.Color('#f1ea65'); +var HOVER_COLOR = new THREE.Color('#ff6b9d'); + +AFRAME.registerComponent('log-events', { + events: { + mouseenter: function () { console.log('[mouseenter]', this.el.id); }, + mouseleave: function () { console.log('[mouseleave]', this.el.id); }, + click: function () { console.log('[click]', this.el.id); } + } +}); + +AFRAME.registerComponent('hover-tint', { + events: { + mouseenter: function () { this.setColor(HOVER_COLOR); }, + mouseleave: function () { this.setColor(this.baseColor); } + }, + setColor: function (color) { + var slot = this.el.object3D.userData.batchSlot; + if (!slot || !color) { return; } + slot.mesh.setColorAt(slot.id, color); + if (slot.mesh.instanceColor) { slot.mesh.instanceColor.needsUpdate = true; } + } +}); + +// Host-level proxy: reads detail.intersection.batchId / instanceId, looks up the +// logical per-instance entity in the host's local map, and re-dispatches the +// cursor events to that entity so components like hover-tint / log-events can +// react on the per-instance side. +AFRAME.registerComponent('batch-proxy', { + schema: { key: { default: 'batchId' } }, // 'batchId' or 'instanceId' + init: function () { + this.map = []; + }, + register: function (id, memberEl) { + this.map[id] = memberEl; + }, + events: { + mouseenter: function (evt) { this.forward(evt, 'mouseenter'); }, + mouseleave: function (evt) { this.forward(evt, 'mouseleave'); }, + click: function (evt) { this.forward(evt, 'click'); } + }, + forward: function (evt, name) { + var id = evt.detail.intersection && evt.detail.intersection[this.data.key]; + var target = id !== undefined ? this.map[id] : null; + if (!target) { return; } + target.emit(name, { cursorEl: evt.detail.cursorEl, intersection: evt.detail.intersection }); + } +}); + +function stashSlot (el, mesh, id, baseColor) { + el.object3D.userData.batchSlot = { mesh: mesh, id: id }; + var tint = el.components['hover-tint']; + if (tint) { tint.baseColor = baseColor; } +} + +function buildBatchedMesh (hostEl, memberEls, baseColor) { + var geometry = new THREE.BoxGeometry(1, 1, 1); + var material = new THREE.MeshStandardMaterial(); + var batched = new THREE.BatchedMesh( + memberEls.length, + geometry.attributes.position.count, + geometry.index.count, + material + ); + var geomId = batched.addGeometry(geometry); + var proxy = hostEl.components['batch-proxy']; + + memberEls.forEach(function (el) { + var instanceId = batched.addInstance(geomId); + el.object3D.updateMatrix(); + batched.setMatrixAt(instanceId, el.object3D.matrix); + batched.setColorAt(instanceId, baseColor); + proxy.register(instanceId, el); + stashSlot(el, batched, instanceId, baseColor); + }); + + hostEl.setObject3D('mesh', batched); +} + +function buildInstancedMesh (hostEl, memberEls, baseColor) { + var geometry = new THREE.ConeGeometry(0.5, 1, 16); + var material = new THREE.MeshStandardMaterial(); + var mesh = new THREE.InstancedMesh(geometry, material, memberEls.length); + var proxy = hostEl.components['batch-proxy']; + + memberEls.forEach(function (el, i) { + el.object3D.updateMatrix(); + mesh.setMatrixAt(i, el.object3D.matrix); + mesh.setColorAt(i, baseColor); + proxy.register(i, el); + stashSlot(el, mesh, i, baseColor); + }); + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) { mesh.instanceColor.needsUpdate = true; } + + hostEl.setObject3D('mesh', mesh); +} + +document.addEventListener('DOMContentLoaded', function () { + var sceneEl = document.querySelector('a-scene'); + sceneEl.addEventListener('loaded', function () { + buildBatchedMesh( + document.getElementById('batchHost'), + [ + document.getElementById('boxA'), + document.getElementById('boxB'), + document.getElementById('boxC') + ], + BOX_COLOR + ); + + buildInstancedMesh( + document.getElementById('instanceHost'), + [ + document.getElementById('coneA'), + document.getElementById('coneB'), + document.getElementById('coneC') + ], + CONE_COLOR + ); + }); +}); diff --git a/examples/test/raycaster-batched-mesh/index.html b/examples/test/raycaster-batched-mesh/index.html new file mode 100644 index 00000000000..f1bfcfb9438 --- /dev/null +++ b/examples/test/raycaster-batched-mesh/index.html @@ -0,0 +1,50 @@ + + + + + Raycaster — BatchedMesh / InstancedMesh per-instance intersections + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/cursor.js b/src/components/cursor.js index e38bfb2d1db..3078f260fc6 100644 --- a/src/components/cursor.js +++ b/src/components/cursor.js @@ -65,6 +65,10 @@ export var Component = registerComponent('cursor', { this.fuseTimeout = undefined; this.cursorDownEl = null; this.intersectedEl = null; + // Track the current intersection (not just the entity) so that transitions between + // instances of a shared BatchedMesh / InstancedMesh — where intersectedEl stays + // the host but intersection.batchId / instanceId changes — fire mouseleave/enter. + this.intersection = null; this.canvasBounds = document.body.getBoundingClientRect(); this.isCursorDown = false; this.activeXRInput = null; @@ -436,11 +440,19 @@ export var Component = registerComponent('cursor', { // If cursor is the only intersected object, ignore the event. if (!intersectedEl) { return; } - // Already intersecting this entity. - if (this.intersectedEl === intersectedEl) { return; } + // Already intersecting — compare (entity, batchId, instanceId) so ray transitions + // within a shared BatchedMesh / InstancedMesh (same entity, different instance) + // still trigger mouseleave / mouseenter. + if (this.intersectedEl === intersectedEl && + this.intersection && + this.intersection.batchId === intersection.batchId && + this.intersection.instanceId === intersection.instanceId) { + return; + } - // Ignore events further away than active intersection. - if (this.intersectedEl) { + // Ignore events further away than active intersection (unless it's the same entity — + // a closer instance on the same host should take over). + if (this.intersectedEl && this.intersectedEl !== intersectedEl) { currentIntersection = this.el.components.raycaster.getIntersection(this.intersectedEl); if (currentIntersection && currentIntersection.distance <= intersection.distance) { return; } } @@ -474,11 +486,17 @@ export var Component = registerComponent('cursor', { var data = this.data; var self = this; - // Already intersecting. - if (this.intersectedEl === intersectedEl) { return; } + // Already intersecting this exact (entity, batchId, instanceId) tuple. + if (this.intersectedEl === intersectedEl && + this.intersection && + this.intersection.batchId === intersection.batchId && + this.intersection.instanceId === intersection.instanceId) { + return; + } // Set new intersection. this.intersectedEl = intersectedEl; + this.intersection = intersection; // Hovering. cursorEl.addState(STATES.HOVERING); @@ -520,6 +538,7 @@ export var Component = registerComponent('cursor', { // Unset intersected entity (after emitting the event). this.intersectedEl = null; + this.intersection = null; // Clear fuseTimeout. clearTimeout(this.fuseTimeout); @@ -541,7 +560,12 @@ export var Component = registerComponent('cursor', { twoWayEmit: function (evtName, originalEvent) { var el = this.el; var intersectedEl = this.intersectedEl; - var intersection; + // Use the stashed intersection so mouseleave carries the OLD tuple — important for + // BatchedMesh / InstancedMesh transitions, where a fresh getIntersection(el) would + // return the incoming instance rather than the one we're leaving. + var intersection = evtName === EVENTS.MOUSELEAVE + ? this.intersection + : el.components.raycaster.getIntersection(intersectedEl); function addOriginalEvent (detail, evt) { if (originalEvent instanceof MouseEvent) { @@ -552,7 +576,6 @@ export var Component = registerComponent('cursor', { } } - intersection = this.el.components.raycaster.getIntersection(intersectedEl); this.eventDetail.intersectedEl = intersectedEl; this.eventDetail.intersection = intersection; addOriginalEvent(this.eventDetail, originalEvent); diff --git a/src/components/raycaster.js b/src/components/raycaster.js index c936d60e754..a853d13ba0d 100644 --- a/src/components/raycaster.js +++ b/src/components/raycaster.js @@ -68,6 +68,8 @@ export var Component = registerComponent('raycaster', { this.objects = []; this.prevCheckTime = undefined; this.prevIntersectedEls = []; + this.prevClosestBatchId = undefined; + this.prevClosestInstanceId = undefined; this.rawIntersections = []; this.raycaster = new THREE.Raycaster(); this.updateOriginDirection(); @@ -281,15 +283,25 @@ export var Component = registerComponent('raycaster', { el.emit(EVENTS.INTERSECTION, this.intersectionDetail); } - // Emit event when the closest intersected entity has changed. + // Emit event when the closest intersected entity has changed. Also fires when the + // closest entity is unchanged but `intersection.batchId` / `intersection.instanceId` + // changed (ray moved between instances of a shared BatchedMesh / InstancedMesh). + var prevBatchId = this.prevClosestBatchId; + var prevInstanceId = this.prevClosestInstanceId; + var closestBatchId = intersections.length ? intersections[0].batchId : undefined; + var closestInstanceId = intersections.length ? intersections[0].instanceId : undefined; if (prevIntersectedEls.length === 0 && intersections.length > 0 || prevIntersectedEls.length > 0 && intersections.length === 0 || (prevIntersectedEls.length && intersections.length && - prevIntersectedEls[0] !== intersections[0].object.el)) { + (prevIntersectedEls[0] !== intersections[0].object.el || + prevBatchId !== closestBatchId || + prevInstanceId !== closestInstanceId))) { this.intersectionDetail.els = this.intersectedEls; this.intersectionDetail.intersections = intersections; el.emit(EVENTS.INTERSECTION_CLOSEST_ENTITY_CHANGED, this.intersectionDetail); } + this.prevClosestBatchId = closestBatchId; + this.prevClosestInstanceId = closestInstanceId; // Update line length. if (data.showLine) { setTimeout(this.updateLine); } diff --git a/tests/components/cursor.test.js b/tests/components/cursor.test.js index c6cc3a77d21..8115696f45f 100644 --- a/tests/components/cursor.test.js +++ b/tests/components/cursor.test.js @@ -160,6 +160,29 @@ suite('cursor', function () { component.onCursorUp(); }); + // The click must carry the uv where the ray currently points, not the + // uv stashed when hover began. The cursor pulls a fresh intersection from the + // raycaster for non-mouseleave events, so moving across the same entity between + // mousedown and click yields up-to-date uv in the event detail. + test('click carries the up-to-date uv, not the stale hover intersection', function (done) { + var staleIntersection = {distance: 10.5, uv: {x: 0.1, y: 0.1}}; + var freshIntersection = {distance: 10.5, uv: {x: 0.9, y: 0.9}}; + // Stored when hover began. + component.intersection = staleIntersection; + component.intersectedEl = intersectedEl; + component.cursorDownEl = intersectedEl; + // The ray has since moved; the raycaster reports the current intersection. + this.sinon.replace(el.components.raycaster, 'getIntersection', function (queryEl) { + return queryEl === intersectedEl ? freshIntersection : null; + }); + once(intersectedEl, 'click', function (evt) { + assert.shallowDeepEqual(evt.detail.intersection.uv, freshIntersection.uv); + done(); + }); + component.isCursorDown = true; + component.onCursorUp(); + }); + test('emits click event on intersectedEl when fuse and mouse cursor enabled', function (done) { el.setAttribute('cursor', 'fuse', true); el.setAttribute('cursor', 'rayOrigin', 'mouse'); @@ -331,6 +354,84 @@ suite('cursor', function () { done(); }); }); + + // When multiple a-entities share a BatchedMesh / InstancedMesh, the ray can move + // between instances without changing the resolved entity. Cursor should treat + // (entity, batchId, instanceId) as the hover identity so events fire on the + // transition and carry the current per-instance intersection detail. + test('re-emits mouseleave/mouseenter when batchId changes on same entity', function (done) { + var first = {distance: 5, batchId: 0}; + var second = {distance: 6, batchId: 1}; + var current = first; + var events = []; + // mouseenter pulls a fresh intersection from the raycaster, so reflect the + // currently-hovered instance here. + this.sinon.replace(el.components.raycaster, 'getIntersection', function (queryEl) { + return queryEl === intersectedEl ? current : null; + }); + intersectedEl.addEventListener('mouseenter', function (evt) { + events.push({type: 'enter', batchId: evt.detail.intersection.batchId}); + if (events.length === 3) { + assert.deepEqual(events, [ + {type: 'enter', batchId: 0}, + {type: 'leave', batchId: 0}, + {type: 'enter', batchId: 1} + ]); + done(); + } + }); + intersectedEl.addEventListener('mouseleave', function (evt) { + events.push({type: 'leave', batchId: evt.detail.intersection.batchId}); + }); + + el.emit('raycaster-intersection', { + intersections: [first], + els: [intersectedEl] + }); + current = second; + el.emit('raycaster-closest-entity-changed', { + intersections: [second], + els: [intersectedEl] + }); + }); + + test('does not re-emit when (entity, batchId, instanceId) is unchanged', function (done) { + var intersectionA = {distance: 5, batchId: 0}; + var enterCount = 0; + intersectedEl.addEventListener('mouseenter', function () { enterCount++; }); + + el.emit('raycaster-intersection', { + intersections: [intersectionA], + els: [intersectedEl] + }); + // Same tuple -> no new mouseenter. + el.emit('raycaster-closest-entity-changed', { + intersections: [intersectionA], + els: [intersectedEl] + }); + + setTimeout(function () { + assert.equal(enterCount, 1); + done(); + }); + }); + + test('mouseleave intersection carries the OLD tuple when batchId changes', function (done) { + var first = {distance: 5, batchId: 0}; + var second = {distance: 6, batchId: 1}; + intersectedEl.addEventListener('mouseleave', function (evt) { + assert.equal(evt.detail.intersection.batchId, 0); + done(); + }); + el.emit('raycaster-intersection', { + intersections: [first], + els: [intersectedEl] + }); + el.emit('raycaster-closest-entity-changed', { + intersections: [second], + els: [intersectedEl] + }); + }); }); suite('onIntersectionCleared', function () { diff --git a/tests/components/raycaster.test.js b/tests/components/raycaster.test.js index 36d2f2532a1..6dcd7be9763 100644 --- a/tests/components/raycaster.test.js +++ b/tests/components/raycaster.test.js @@ -435,6 +435,121 @@ suite('raycaster', function () { }); }); + suite('BatchedMesh per-instance tracking', function () { + var hostEl; + var batchedMesh; + + setup(function (done) { + el.setAttribute('position', '0 0 1'); + el.setAttribute('raycaster', {near: 0.1, far: 10}); + + hostEl = document.createElement('a-entity'); + hostEl.setAttribute('id', 'host'); + + // Two unit boxes batched into one mesh, one in front of the raycaster (-1 Z) + // and one to the side (-3 X). Default ray hits the first; after a 70deg yaw + // it hits the second — a transition between instances on the SAME host entity. + var geomA = new THREE.BoxGeometry(1, 1, 1); + var geomB = new THREE.BoxGeometry(1, 1, 1); + batchedMesh = new THREE.BatchedMesh(2, 1024, 2048, new THREE.MeshBasicMaterial()); + var idA = batchedMesh.addGeometry(geomA); + var idB = batchedMesh.addGeometry(geomB); + var inA = batchedMesh.addInstance(idA); + var inB = batchedMesh.addInstance(idB); + batchedMesh.setMatrixAt(inA, new THREE.Matrix4().makeTranslation(0, 0, -1)); + batchedMesh.setMatrixAt(inB, new THREE.Matrix4().makeTranslation(-3, 0, 0)); + + hostEl.addEventListener('loaded', function () { + hostEl.setObject3D('mesh', batchedMesh); + setTimeout(() => { done(); }); + }); + sceneEl.appendChild(hostEl); + }); + + test('intersection carries batchId for per-instance hits', function (done) { + el.addEventListener('raycaster-intersection', function (evt) { + assert.equal(component.intersectedEls[0], hostEl); + assert.equal(evt.detail.intersections[0].batchId, 0); + done(); + }); + sceneEl.object3D.updateMatrixWorld(); + component.refreshObjects(); + component.tock(); + }); + + test('emits closest-entity-changed when ray moves to a different instance on same host', function (done) { + el.addEventListener('raycaster-intersection', function onFirst () { + el.removeEventListener('raycaster-intersection', onFirst); + el.addEventListener('raycaster-closest-entity-changed', function (evt) { + // Same host entity, but the closest intersection has switched instance. + assert.equal(evt.detail.els[0], hostEl); + assert.equal(evt.detail.intersections[0].batchId, 1); + done(); + }); + el.setAttribute('rotation', '0 70 0'); + sceneEl.object3D.updateMatrixWorld(); + component.tock(); + }); + sceneEl.object3D.updateMatrixWorld(); + component.refreshObjects(); + component.tock(); + }); + }); + + suite('InstancedMesh per-instance tracking', function () { + var hostEl; + var instancedMesh; + + setup(function (done) { + el.setAttribute('position', '0 0 1'); + el.setAttribute('raycaster', {near: 0.1, far: 10}); + + hostEl = document.createElement('a-entity'); + hostEl.setAttribute('id', 'instHost'); + + var geom = new THREE.BoxGeometry(1, 1, 1); + var mat = new THREE.MeshBasicMaterial(); + instancedMesh = new THREE.InstancedMesh(geom, mat, 2); + instancedMesh.setMatrixAt(0, new THREE.Matrix4().makeTranslation(0, 0, -1)); + instancedMesh.setMatrixAt(1, new THREE.Matrix4().makeTranslation(-3, 0, 0)); + instancedMesh.instanceMatrix.needsUpdate = true; + + hostEl.addEventListener('loaded', function () { + hostEl.setObject3D('mesh', instancedMesh); + setTimeout(() => { done(); }); + }); + sceneEl.appendChild(hostEl); + }); + + test('intersection carries instanceId for per-instance hits', function (done) { + el.addEventListener('raycaster-intersection', function (evt) { + assert.equal(component.intersectedEls[0], hostEl); + assert.equal(evt.detail.intersections[0].instanceId, 0); + done(); + }); + sceneEl.object3D.updateMatrixWorld(); + component.refreshObjects(); + component.tock(); + }); + + test('emits closest-entity-changed when ray moves to a different instance on same host', function (done) { + el.addEventListener('raycaster-intersection', function onFirst () { + el.removeEventListener('raycaster-intersection', onFirst); + el.addEventListener('raycaster-closest-entity-changed', function (evt) { + assert.equal(evt.detail.els[0], hostEl); + assert.equal(evt.detail.intersections[0].instanceId, 1); + done(); + }); + el.setAttribute('rotation', '0 70 0'); + sceneEl.object3D.updateMatrixWorld(); + component.tock(); + }); + sceneEl.object3D.updateMatrixWorld(); + component.refreshObjects(); + component.tock(); + }); + }); + suite('updateOriginDirection', function () { test('updates ray origin if position changes', function () { var origin;