By the end of this lesson, you will:
Placing virtual objects in the real world is the core AR interaction. IKEA Place lets you visualize furniture in your home. Snapchat's AR lenses put virtual objects on your face. Pokemon GO places creatures in your neighborhood.
The key to viral AR is making placement feel natural and fun. Users should be able to position, rotate, and scale objects with intuitive gestures. When placement feels effortless, users experiment, create scenes, and share screenshots.
Use GLB/GLTF format for 3D models in AR:
import { useEffect, useState } from 'react';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { Asset } from 'expo-asset';
export default function ModelLoader() {
const [model, setModel] = useState(null);
useEffect(() => {
loadModel();
}, []);
const loadModel = async () => {
try {
// Load model asset
const asset = Asset.fromModule(require('./assets/chair.glb'));
await asset.downloadAsync();
// Load with GLTFLoader
const loader = new GLTFLoader();
loader.load(
asset.localUri,
(gltf) => {
const loadedModel = gltf.scene;
// Scale model appropriately
loadedModel.scale.set(0.5, 0.5, 0.5);
// Position at origin
loadedModel.position.set(0, 0, 0);
setModel(loadedModel);
console.log('Model loaded successfully');
},
(progress) => {
const percent = (progress.loaded / progress.total) * 100;
console.log(`Loading: ${percent.toFixed(0)}%`);
},
(error) => {
console.error('Model load error:', error);
}
);
} catch (error) {
console.error('Asset load error:', error);
}
};
return model;
}
Supported Formats:
💡 Tip: Use GLB format for best performance. It bundles textures and geometry into one file.
Prepare models for mobile devices:
const optimizeModel = (model) => {
// Reduce polygon count
// Target: <10,000 triangles for mobile
// Traverse model and optimize materials
model.traverse((child) => {
if (child.isMesh) {
// Enable shadows only if needed
child.castShadow = false;
child.receiveShadow = false;
// Optimize material
if (child.material) {
child.material.precision = 'lowp'; // Low precision shaders
child.material.flatShading = true; // Faster rendering
}
// Enable frustum culling
child.frustumCulled = true;
}
});
return model;
};
Optimization Checklist:
<10K triangles≤1024x1024 pixels<5MB per modelPlace objects where users tap:
import { useState } from 'react';
import { TouchableWithoutFeedback } from 'react-native';
import * as THREE from 'three';
export default function TapToPlace({ scene, camera, model }) {
const [placedObjects, setPlacedObjects] = useState([]);
const handleTap = (event) => {
const { locationX, locationY } = event.nativeEvent;
// Perform raycast from tap point
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
// Convert screen coordinates to normalized device coordinates
mouse.x = (locationX / screenWidth) * 2 - 1;
mouse.y = -(locationY / screenHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
// Cast ray to find intersection with planes
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
const hit = intersects[0];
placeModelAtPoint(hit.point, model);
}
};
const placeModelAtPoint = (point, originalModel) => {
// Clone model for new instance
const newModel = originalModel.clone();
// Position at hit point
newModel.position.copy(point);
// Align to surface normal (optional)
// newModel.up.copy(hit.face.normal);
// Add to scene
scene.add(newModel);
// Track placed object
setPlacedObjects((prev) => [...prev, newModel]);
console.log('Placed object at:', point);
};
return (
<TouchableWithoutFeedback onPress={handleTap}>
{/* AR View */}
</TouchableWithoutFeedback>
);
}
Rotate objects with pan gestures:
import { PanGestureHandler } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedGestureHandler,
} from 'react-native-reanimated';
export default function RotatableObject({ object3D }) {
const rotation = useSharedValue(0);
const gestureHandler = useAnimatedGestureHandler({
onStart: (_, ctx) => {
ctx.startRotation = rotation.value;
},
onActive: (event, ctx) => {
// Convert pan distance to rotation
rotation.value = ctx.startRotation + event.translationX * 0.01;
// Apply rotation to 3D object
if (object3D) {
object3D.rotation.y = rotation.value;
}
},
});
return (
<PanGestureHandler onGestureEvent={gestureHandler}>
{/* AR View */}
</PanGestureHandler>
);
}
Scale objects with pinch gestures:
import { PinchGestureHandler } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedGestureHandler,
} from 'react-native-reanimated';
export default function ScalableObject({ object3D }) {
const scale = useSharedValue(1);
const MIN_SCALE = 0.5;
const MAX_SCALE = 3.0;
const pinchHandler = useAnimatedGestureHandler({
onStart: (_, ctx) => {
ctx.startScale = scale.value;
},
onActive: (event, ctx) => {
// Calculate new scale
const newScale = ctx.startScale * event.scale;
// Clamp to min/max
scale.value = Math.max(MIN_SCALE, Math.min(MAX_SCALE, newScale));
// Apply to 3D object
if (object3D) {
object3D.scale.set(scale.value, scale.value, scale.value);
}
},
});
return (
<PinchGestureHandler onGestureEvent={pinchHandler}>
{/* AR View */}
</PinchGestureHandler>
);
}
Select and highlight placed objects:
import { useState } from 'react';
import * as THREE from 'three';
export default function ObjectSelector({ scene, camera }) {
const [selectedObject, setSelectedObject] = useState(null);
const [outline, setOutline] = useState(null);
const selectObject = (event) => {
const { locationX, locationY } = event.nativeEvent;
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);
// Only check placed objects
const placedObjects = scene.children.filter((obj) => obj.userData.isPlaced);
const intersects = raycaster.intersectObjects(placedObjects, true);
if (intersects.length > 0) {
const object = intersects[0].object;
highlightObject(object);
setSelectedObject(object);
} else {
clearSelection();
}
};
const highlightObject = (object) => {
// Remove previous outline
if (outline) {
scene.remove(outline);
}
// Create outline
const outlineGeometry = object.geometry.clone();
const outlineMaterial = new THREE.MeshBasicMaterial({
color: 0xffff00,
side: THREE.BackSide,
});
const newOutline = new THREE.Mesh(outlineGeometry, outlineMaterial);
newOutline.position.copy(object.position);
newOutline.scale.multiplyScalar(1.05); // Slightly larger
scene.add(newOutline);
setOutline(newOutline);
};
const clearSelection = () => {
if (outline) {
scene.remove(outline);
setOutline(null);
}
setSelectedObject(null);
};
const deleteSelectedObject = () => {
if (selectedObject) {
scene.remove(selectedObject);
clearSelection();
}
};
return {
selectObject,
deleteSelectedObject,
selectedObject,
};
}
Save and restore placed objects:
import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEY = '@ar_objects';
// Save placed objects
export const saveObjects = async (objects) => {
const objectData = objects.map((obj) => ({
id: obj.userData.id,
modelName: obj.userData.modelName,
position: {
x: obj.position.x,
y: obj.position.y,
z: obj.position.z,
},
rotation: {
x: obj.rotation.x,
y: obj.rotation.y,
z: obj.rotation.z,
},
scale: {
x: obj.scale.x,
y: obj.scale.y,
z: obj.scale.z,
},
}));
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(objectData));
console.log('Objects saved:', objectData.length);
} catch (error) {
console.error('Save error:', error);
}
};
// Load saved objects
export const loadObjects = async (scene, modelLibrary) => {
try {
const data = await AsyncStorage.getItem(STORAGE_KEY);
if (data) {
const objectData = JSON.parse(data);
for (const objData of objectData) {
// Get model from library
const model = modelLibrary[objData.modelName];
if (!model) continue;
// Clone and configure
const object = model.clone();
object.position.set(
objData.position.x,
objData.position.y,
objData.position.z
);
object.rotation.set(
objData.rotation.x,
objData.rotation.y,
objData.rotation.z
);
object.scale.set(objData.scale.x, objData.scale.y, objData.scale.z);
object.userData = {
id: objData.id,
modelName: objData.modelName,
isPlaced: true,
};
scene.add(object);
}
console.log('Objects restored:', objectData.length);
}
} catch (error) {
console.error('Load error:', error);
}
};
Let users choose from multiple models:
import { useState, useEffect } from 'react';
import { View, ScrollView, TouchableOpacity, Image, StyleSheet } from 'react-native';
const MODEL_CATALOG = [
{
id: 'chair',
name: 'Modern Chair',
thumbnail: require('./assets/chair-thumb.png'),
model: require('./assets/chair.glb'),
},
{
id: 'table',
name: 'Coffee Table',
thumbnail: require('./assets/table-thumb.png'),
model: require('./assets/table.glb'),
},
{
id: 'lamp',
name: 'Floor Lamp',
thumbnail: require('./assets/lamp-thumb.png'),
model: require('./assets/lamp.glb'),
},
];
export default function ModelCatalog({ onModelSelect }) {
const [selectedModel, setSelectedModel] = useState(null);
const handleSelect = (model) => {
setSelectedModel(model.id);
onModelSelect(model);
};
return (
<View style={styles.catalog}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{MODEL_CATALOG.map((model) => (
<TouchableOpacity
key={model.id}
style={[
styles.catalogItem,
selectedModel === model.id && styles.catalogItemSelected,
]}
onPress={() => handleSelect(model)}
>
<Image source={model.thumbnail} style={styles.thumbnail} />
</TouchableOpacity>
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
catalog: {
position: 'absolute',
bottom: 20,
left: 0,
right: 0,
},
catalogItem: {
width: 80,
height: 80,
marginHorizontal: 8,
borderRadius: 12,
borderWidth: 3,
borderColor: 'transparent',
overflow: 'hidden',
},
catalogItemSelected: {
borderColor: '#007AFF',
},
thumbnail: {
width: '100%',
height: '100%',
resizeMode: 'cover',
},
});
Complete AR furniture placement app:
import { useState, useRef, useEffect } from 'react';
import {
View,
Text,
TouchableWithoutFeedback,
TouchableOpacity,
StyleSheet,
} from 'react-native';
import { GLView } from 'expo-gl';
import { Renderer, Camera, Scene } from 'expo-three';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
export default function ARFurniturePlacementScreen() {
const [objectsPlaced, setObjectsPlaced] = useState(0);
const [selectedModel, setSelectedModel] = useState(null);
const sceneRef = useRef(null);
const cameraRef = useRef(null);
const modelsRef = useRef({});
const onContextCreate = async (gl) => {
const renderer = new Renderer({ gl });
renderer.setSize(gl.drawingBufferWidth, gl.drawingBufferHeight);
renderer.shadowMap.enabled = true;
const scene = new Scene();
scene.background = null;
sceneRef.current = scene;
const camera = new Camera();
camera.position.set(0, 1.6, 0);
cameraRef.current = camera;
// Floor plane for placement
const floorGeometry = new THREE.PlaneGeometry(10, 10);
const floorMaterial = new THREE.MeshStandardMaterial({
color: 0x808080,
transparent: true,
opacity: 0.5,
});
const floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -1.6;
floor.receiveShadow = true;
scene.add(floor);
// 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);
directionalLight.castShadow = true;
scene.add(directionalLight);
// Load model
await loadFurnitureModel();
// Animation loop
const animate = () => {
requestAnimationFrame(animate);
renderer.render(scene, camera);
gl.endFrameEXP();
};
animate();
};
const loadFurnitureModel = async () => {
// In real app, load actual GLB file
// For demo, create simple geometry
const geometry = new THREE.BoxGeometry(0.4, 0.4, 0.4);
const material = new THREE.MeshStandardMaterial({ color: 0x8B4513 });
const model = new THREE.Mesh(geometry, material);
model.castShadow = true;
modelsRef.current.chair = model;
setSelectedModel(model);
};
const handleScreenTap = (event) => {
if (!sceneRef.current || !cameraRef.current || !selectedModel) return;
const { locationX, locationY } = event.nativeEvent;
// Simplified placement: place in front of camera
const newObject = selectedModel.clone();
// Random position for demo
const angle = Math.random() * Math.PI * 2;
const distance = 1.5;
newObject.position.set(
Math.cos(angle) * distance,
-1.4, // Just above floor
Math.sin(angle) * distance
);
newObject.userData = {
id: `object_${Date.now()}`,
isPlaced: true,
};
sceneRef.current.add(newObject);
setObjectsPlaced((prev) => prev + 1);
};
const clearAllObjects = () => {
if (!sceneRef.current) return;
const objectsToRemove = sceneRef.current.children.filter(
(obj) => obj.userData.isPlaced
);
objectsToRemove.forEach((obj) => {
sceneRef.current.remove(obj);
});
setObjectsPlaced(0);
};
return (
<TouchableWithoutFeedback onPress={handleScreenTap}>
<View style={styles.container}>
<GLView style={styles.glView} onContextCreate={onContextCreate} />
<View style={styles.overlay}>
<Text style={styles.instructions}>Tap to place furniture</Text>
<Text style={styles.stats}>Objects placed: {objectsPlaced}</Text>
</View>
<View style={styles.controls}>
<TouchableOpacity style={styles.button} onPress={clearAllObjects}>
<Text style={styles.buttonText}>Clear All</Text>
</TouchableOpacity>
</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',
},
button: {
backgroundColor: '#007AFF',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: 'bold',
},
});
| Pitfall | Solution |
|---|---|
| Models too large/small | Scale models in Blender before export, typical AR scale: 0.5-1m |
| Poor performance | Optimize polygon count (<10K), compress textures, disable shadows |
| Objects sink into floor | Ensure model pivot point is at bottom, adjust Y position |
| Gestures not working | Use react-native-gesture-handler, ensure proper handler setup |
| Models not loading | Check file paths, ensure GLB format, handle loading errors |
<10K polygons, compressed texturesIn the next lesson, we'll explore AR Interaction Patterns, learning advanced techniques like surface snapping, object physics, and multi-user AR experiences.