By the end of this lesson, you will:
Great AR isn't just about placing objects-it's about making those interactions feel magical. Snapchat's AR lenses track faces perfectly. IKEA Place snaps furniture to floors automatically. Pokemon GO creatures react to the environment.
The difference between "cool tech demo" and "viral app" is interaction design. When users can naturally manipulate virtual objects, take perfect screenshots, and show friends, your app becomes shareable. This lesson covers the patterns that make AR feel effortless.
Show users where objects will be placed before they tap:
import * as THREE from 'three';
export const createReticle = () => {
// Create circular reticle
const geometry = new THREE.RingGeometry(0.15, 0.2, 32);
const material = new THREE.MeshBasicMaterial({
color: 0xffffff,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.7,
});
const reticle = new THREE.Mesh(geometry, material);
reticle.rotation.x = -Math.PI / 2; // Horizontal
reticle.visible = false; // Hidden until plane detected
return reticle;
};
// Update reticle position each frame
export const updateReticle = (reticle, camera, planes) => {
// Cast ray from center of screen
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(0, 0), camera);
// Find intersection with planes
const intersects = raycaster.intersectObjects(planes, true);
if (intersects.length > 0) {
const hit = intersects[0];
// Position reticle at hit point
reticle.position.copy(hit.point);
// Orient to surface normal
reticle.up.copy(hit.face.normal);
reticle.visible = true;
} else {
reticle.visible = false;
}
};
Reticle Design Patterns:
Automatically align objects to detected surfaces:
export const snapToSurface = (object, hitPoint, surfaceNormal) => {
// Position at hit point
object.position.copy(hitPoint);
// Align to surface
const up = new THREE.Vector3(0, 1, 0);
const quaternion = new THREE.Quaternion().setFromUnitVectors(up, surfaceNormal);
object.quaternion.copy(quaternion);
// Adjust height based on object bounds
const box = new THREE.Box3().setFromObject(object);
const height = box.max.y - box.min.y;
object.position.y += height / 2;
};
// Smart snapping for different surface types
export const smartSnap = (object, surface) => {
switch (surface.type) {
case 'horizontal_up':
// Floor/table - place upright
object.rotation.x = 0;
break;
case 'horizontal_down':
// Ceiling - hang upside down
object.rotation.x = Math.PI;
break;
case 'vertical':
// Wall - orient perpendicular
object.rotation.y = surface.orientation;
break;
}
};
Move objects by dragging on screen:
import { useState, useRef } from 'react';
export default function DraggableObject({ object3D, camera, planes }) {
const [isDragging, setIsDragging] = useState(false);
const dragOffset = useRef(new THREE.Vector3());
const startDrag = (event) => {
const { locationX, locationY } = event.nativeEvent;
// Check if tapped on object
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
mouse.x = (locationX / screenWidth) * 2 - 1;
mouse.y = -(locationY / screenHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(object3D, true);
if (intersects.length > 0) {
setIsDragging(true);
// Calculate offset from object center to hit point
dragOffset.current.subVectors(object3D.position, intersects[0].point);
}
};
const onDrag = (event) => {
if (!isDragging) return;
const { locationX, locationY } = event.nativeEvent;
// Cast ray from touch point
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
mouse.x = (locationX / screenWidth) * 2 - 1;
mouse.y = -(locationY / screenHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
// Find intersection with planes
const intersects = raycaster.intersectObjects(planes, true);
if (intersects.length > 0) {
// Update object position
const newPosition = intersects[0].point.clone().add(dragOffset.current);
object3D.position.copy(newPosition);
}
};
const endDrag = () => {
setIsDragging(false);
};
return {
startDrag,
onDrag,
endDrag,
isDragging,
};
}
Rotate objects around vertical axis:
import { useRef } from 'react';
export default function TwoFingerRotation({ object3D }) {
const lastAngle = useRef(0);
const onRotationGestureStart = (event) => {
// Store initial rotation
lastAngle.current = event.rotation;
};
const onRotationGestureUpdate = (event) => {
// Calculate rotation delta
const deltaRotation = event.rotation - lastAngle.current;
// Apply rotation around Y axis
object3D.rotation.y += deltaRotation;
lastAngle.current = event.rotation;
};
return {
onRotationGestureStart,
onRotationGestureUpdate,
};
}
Scale objects with pinch gesture and visual indicator:
import * as THREE from 'three';
export const createScaleIndicator = (object) => {
// Create wireframe box around object
const box = new THREE.Box3().setFromObject(object);
const size = box.getSize(new THREE.Vector3());
const geometry = new THREE.BoxGeometry(size.x, size.y, size.z);
const edges = new THREE.EdgesGeometry(geometry);
const material = new THREE.LineBasicMaterial({ color: 0x00ff00 });
const indicator = new THREE.LineSegments(edges, material);
indicator.position.copy(object.position);
indicator.visible = false;
return indicator;
};
export const scaleWithIndicator = (object, indicator, scaleFactor) => {
// Apply scale
object.scale.multiplyScalar(scaleFactor);
// Update indicator
const box = new THREE.Box3().setFromObject(object);
const size = box.getSize(new THREE.Vector3());
indicator.scale.set(
size.x / indicator.geometry.parameters.width,
size.y / indicator.geometry.parameters.height,
size.z / indicator.geometry.parameters.depth
);
indicator.position.copy(object.position);
};
Add gravity and collision detection:
export class PhysicsObject {
constructor(object3D, mass = 1) {
this.object = object3D;
this.mass = mass;
this.velocity = new THREE.Vector3(0, 0, 0);
this.acceleration = new THREE.Vector3(0, -9.8, 0); // Gravity
this.isGrounded = false;
}
update(deltaTime, planes) {
if (this.isGrounded) return;
// Apply physics
this.velocity.add(
this.acceleration.clone().multiplyScalar(deltaTime)
);
this.object.position.add(
this.velocity.clone().multiplyScalar(deltaTime)
);
// Check collision with planes
const box = new THREE.Box3().setFromObject(this.object);
const bottom = box.min.y;
for (const plane of planes) {
if (bottom <= plane.position.y) {
// Collision detected
this.object.position.y = plane.position.y + (box.max.y - box.min.y) / 2;
this.velocity.y = 0;
this.isGrounded = true;
break;
}
}
}
applyForce(force) {
this.acceleration.add(force.divideScalar(this.mass));
}
throw(direction, power) {
this.isGrounded = false;
this.velocity.copy(direction.multiplyScalar(power));
}
}
Hide virtual objects behind real objects:
export const setupOcclusion = (scene, renderer) => {
// Create occlusion material
const occlusionMaterial = new THREE.MeshBasicMaterial({
colorWrite: false, // Don't render color
depthWrite: true, // Write to depth buffer
});
// Create occlusion mesh from detected geometry
const occlusionMesh = new THREE.Mesh(detectedGeometry, occlusionMaterial);
scene.add(occlusionMesh);
// Enable depth testing
renderer.sortObjects = true;
};
// Update occlusion mesh as environment is scanned
export const updateOcclusion = (occlusionMesh, environmentMesh) => {
occlusionMesh.geometry.copy(environmentMesh.geometry);
occlusionMesh.position.copy(environmentMesh.position);
};
Measure distances in AR:
import * as THREE from 'three';
export class ARMeasurementTool {
constructor(scene) {
this.scene = scene;
this.points = [];
this.lines = [];
this.labels = [];
}
addPoint(position) {
// Create point marker
const geometry = new THREE.SphereGeometry(0.02, 16, 16);
const material = new THREE.MeshBasicMaterial({ color: 0xff0000 });
const marker = new THREE.Mesh(geometry, material);
marker.position.copy(position);
this.scene.add(marker);
this.points.push(marker);
// Create line if we have 2+ points
if (this.points.length >= 2) {
const p1 = this.points[this.points.length - 2].position;
const p2 = this.points[this.points.length - 1].position;
this.addLine(p1, p2);
}
}
addLine(start, end) {
const geometry = new THREE.BufferGeometry().setFromPoints([start, end]);
const material = new THREE.LineBasicMaterial({ color: 0xffff00 });
const line = new THREE.Line(geometry, material);
this.scene.add(line);
this.lines.push(line);
// Calculate distance
const distance = start.distanceTo(end);
this.addLabel(start.clone().lerp(end, 0.5), `${distance.toFixed(2)}m`);
}
addLabel(position, text) {
// In real app, use sprite or canvas texture for text
console.log(`Label at`, position, `: ${text}`);
}
clear() {
this.points.forEach((p) => this.scene.remove(p));
this.lines.forEach((l) => this.scene.remove(l));
this.labels.forEach((l) => this.scene.remove(l));
this.points = [];
this.lines = [];
this.labels = [];
}
}
Attach objects to tracked features:
export class AnchoredObject {
constructor(object3D, anchorPose) {
this.object = object3D;
this.anchor = anchorPose;
this.offset = new THREE.Vector3(); // Offset from anchor
}
update(newAnchorPose) {
// Update anchor
this.anchor = newAnchorPose;
// Update object position relative to anchor
this.object.position.copy(this.anchor.position).add(this.offset);
this.object.quaternion.copy(this.anchor.rotation);
}
setOffset(offset) {
this.offset.copy(offset);
}
}
Complete AR interaction system with reticle and placement:
import { useState, useRef, useEffect } from 'react';
import {
View,
Text,
TouchableWithoutFeedback,
StyleSheet,
Button,
} from 'react-native';
import { GLView } from 'expo-gl';
import { Renderer, Camera, Scene } from 'expo-three';
import * as THREE from 'three';
export default function InteractiveARScreen() {
const [placementMode, setPlacementMode] = useState(true);
const [objectCount, setObjectCount] = useState(0);
const sceneRef = useRef(null);
const cameraRef = useRef(null);
const reticleRef = useRef(null);
const floorRef = useRef(null);
const onContextCreate = async (gl) => {
const renderer = new Renderer({ gl });
renderer.setSize(gl.drawingBufferWidth, gl.drawingBufferHeight);
const scene = new Scene();
scene.background = null;
sceneRef.current = scene;
const camera = new Camera();
camera.position.set(0, 1.6, 0);
cameraRef.current = camera;
// Create floor plane
const floorGeometry = new THREE.PlaneGeometry(10, 10);
const floorMaterial = new THREE.MeshStandardMaterial({
color: 0x808080,
transparent: true,
opacity: 0.3,
});
const floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -1.6;
scene.add(floor);
floorRef.current = floor;
// Create reticle
const reticle = createReticle();
scene.add(reticle);
reticleRef.current = reticle;
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(2, 5, 2);
scene.add(directionalLight);
// Animation loop
const animate = () => {
requestAnimationFrame(animate);
// Update reticle position
if (reticleRef.current && floorRef.current && placementMode) {
updateReticlePosition(
reticleRef.current,
cameraRef.current,
floorRef.current
);
}
renderer.render(scene, camera);
gl.endFrameEXP();
};
animate();
};
const createReticle = () => {
const geometry = new THREE.RingGeometry(0.12, 0.15, 32);
const material = new THREE.MeshBasicMaterial({
color: 0x00ff00,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.8,
});
const reticle = new THREE.Mesh(geometry, material);
reticle.rotation.x = -Math.PI / 2;
return reticle;
};
const updateReticlePosition = (reticle, camera, floor) => {
// Cast ray from center of screen
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(0, 0), camera);
const intersects = raycaster.intersectObject(floor, true);
if (intersects.length > 0) {
reticle.position.copy(intersects[0].point);
reticle.visible = true;
} else {
reticle.visible = false;
}
};
const handleTap = () => {
if (!placementMode || !reticleRef.current?.visible) return;
// Place object at reticle position
const geometry = new THREE.BoxGeometry(0.2, 0.2, 0.2);
const material = new THREE.MeshStandardMaterial({
color: Math.random() * 0xffffff,
});
const cube = new THREE.Mesh(geometry, material);
cube.position.copy(reticleRef.current.position);
cube.position.y += 0.1; // Half height above floor
sceneRef.current.add(cube);
setObjectCount((prev) => prev + 1);
};
const toggleMode = () => {
setPlacementMode((prev) => !prev);
if (reticleRef.current) {
reticleRef.current.visible = !placementMode;
}
};
return (
<TouchableWithoutFeedback onPress={handleTap}>
<View style={styles.container}>
<GLView style={styles.glView} onContextCreate={onContextCreate} />
<View style={styles.overlay}>
<Text style={styles.instructions}>
{placementMode
? 'Tap to place objects'
: 'Selection mode - tap objects to edit'}
</Text>
<Text style={styles.stats}>Objects: {objectCount}</Text>
</View>
<View style={styles.controls}>
<Button
title={placementMode ? 'Switch to Select' : 'Switch to Place'}
onPress={toggleMode}
/>
</View>
</View>
</TouchableWithoutFeedback>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
glView: {
flex: 1,
},
overlay: {
position: 'absolute',
top: 50,
left: 20,
right: 20,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
padding: 16,
borderRadius: 8,
},
instructions: {
color: '#fff',
fontSize: 16,
marginBottom: 8,
},
stats: {
color: '#fff',
fontSize: 14,
},
controls: {
position: 'absolute',
bottom: 20,
alignSelf: 'center',
},
});
| Pitfall | Solution |
|---|---|
| Reticle jumps erratically | Smooth reticle movement with interpolation, limit update rate |
| Objects float above surface | Adjust position based on object bounding box |
| Gestures conflict | Implement state machine (placement -> drag -> rotate -> scale) |
| Physics too fast/slow | Use fixed timestep, calibrate gravity for mobile |
| Occlusion not working | Ensure depth testing enabled, update occlusion mesh regularly |
In the next lesson, we'll explore Health Connect Integration, diving into fitness tracking and health data to build apps that motivate users to stay active and share progress.