Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/components/raycaster.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions examples/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ <h2>Tests</h2>
<li><a href="test/raycaster/">Raycaster</a></li>
<li><a href="test/raycaster/simple.html">Raycaster (Simple)</a></li>
<li><a href="test/raycaster/sprite.html">Raycaster (Sprite)</a></li>
<li><a href="test/raycaster-batched-mesh/">Raycaster (BatchedMesh / InstancedMesh)</a></li>
<li><a href="test/shaders/">Shaders</a></li>
<li><a href="test/shadows/">Shadows</a></li>
<li><a href="test/text/">Text</a></li>
Expand Down
133 changes: 133 additions & 0 deletions examples/test/raycaster-batched-mesh/build-batches.js
Original file line number Diff line number Diff line change
@@ -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
);
});
});
50 changes: 50 additions & 0 deletions examples/test/raycaster-batched-mesh/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Raycaster — BatchedMesh / InstancedMesh per-instance intersections</title>
<meta name="description" content="BatchedMesh and InstancedMesh per-instance raycasting - A-Frame">
<script src="../../../dist/aframe-master.js"></script>
<script src="build-batches.js"></script>
</head>
<body>
<a-scene background="color: #222">
<!-- Mouse cursor: hover / click events propagate to the per-instance entity
once `build-batches` populates userData.batchIdToEl / instanceIdToEl. -->
<a-entity cursor="rayOrigin: mouse"
raycaster="objects: .batch-host"></a-entity>

<a-entity camera position="0 1.6 5" look-controls wasd-controls></a-entity>
<a-light type="ambient" intensity="0.6"></a-light>
<a-light type="directional" intensity="0.6" position="1 2 1"></a-light>

<!-- Host entities own the shared meshes. Cursor events fire on them
(A-Frame raycaster / cursor don't know about per-instance ids),
and `batch-proxy` forwards those events to the logical per-instance
entity based on `detail.intersection.batchId` / `instanceId`. -->
<a-entity id="batchHost" class="batch-host"
batch-proxy="key: batchId"></a-entity>
<a-entity id="instanceHost" class="batch-host"
batch-proxy="key: instanceId"></a-entity>

<!-- Logical per-instance a-entities. They have no mesh of their own,
but their `position` drives the matrix of the corresponding slot
in the shared mesh. `hover-tint` switches the instance color
on hover via setColorAt, `log-events` writes to devtools. -->
<a-entity id="boxA" position="-2.5 1 -2"
hover-tint log-events></a-entity>
<a-entity id="boxB" position="0 1 -2" scale="1.5 0.5 1"
hover-tint log-events></a-entity>
<a-entity id="boxC" position="2.5 1 -2" rotation="0 45 30"
hover-tint log-events></a-entity>

<a-entity id="coneA" position="-2 0 -2" hover-tint log-events></a-entity>
<a-entity id="coneB" position="0 0 -2" hover-tint log-events></a-entity>
<a-entity id="coneC" position="2 0 -2" hover-tint log-events></a-entity>

<a-text value="Hover / click the shapes. Open the console to see per-instance events."
color="#fff" position="-3 2.6 -2"
width="8" align="center"></a-text>
</a-scene>
</body>
</html>
39 changes: 31 additions & 8 deletions src/components/cursor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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; }
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand All @@ -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);
Expand Down
16 changes: 14 additions & 2 deletions src/components/raycaster.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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); }
Expand Down
Loading
Loading