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;