| name | webxr-hit-test-pitfalls |
| description | Debug hit-test failures and placement marker issues. |
| version | 1.0.0 |
| author | Hermes Agent + Dr. Arman Khalatyan |
WebXR Hit-Test Pitfalls and Reticle Visibility
Use this skill when debugging AR placement marker issues in Three.js WebXR portals.
When to Use
- Reticle/cyan ring doesn't appear during scanning
- Hit-test returns empty results on Android Chrome
- User sees passthrough but no placement marker
matrix.decompose() throws "cannot assign to readonly property 'position'"
Quick Checklist
1. Reticle Visibility Pattern (STABILITY DEBOUNCE)
- ✅ Reticle is a Group, not a Mesh
- ✅
reticle.matrixAutoUpdate = true (never false)
- ❌ DO NOT show reticle immediately — this causes flicker during ARCore calibration
- ✅
reticle.visible = false initially, positioned far away (set(0, -100, 0))
- ✅ Use stability debounce: require ≥3 consecutive valid hit-test frames before showing:
let hitStabilityCount = 0;
const HIT_STABLE_THRESHOLD = 3;
let groundDetected = false;
if (results.length > 0 && pose && pose.transform.position) {
hitStabilityCount++;
reticle.position.set(pose.transform.position.x, ...);
if (hitStabilityCount >= HIT_STABLE_THRESHOLD && !groundDetected) {
groundDetected = true;
reticle.visible = true;
scanningOverlay.classList.remove('active');
}
} else {
hitStabilityCount = Math.max(0, hitStabilityCount - (results.length > 0 ? 1 : 2));
}
- ✅ After
groundDetected, if tracking is lost (hitStabilityCount → 0), keep reticle visible at last known position — don't hide again unless explicitly resuming scan mode
Anti-pattern: Showing/hiding reticle every frame based on single hit-test result. This flickers during calibration and makes the marker appear/disappear as user moves phone, causing "scan grid disappears when I turn head" reports.
2. Hit-test Reference Space
- ✅
referenceSpace obtained FIRST (local-floor or viewer fallback)
- ✅
requestHitTestSource({ space: referenceSpace }) uses the SAME space
- ✅
frame.getHitTestResults() pose extraction uses same referenceSpace
3. Position Updates (NOT Matrix)
- ✅
reticle.position.set(pose.transform.position.x, ...)
- ✅ NEVER
reticle.matrix.fromArray(hitPose.transform.matrix) — destroys rotation
- ✅ NEVER
matrix.decompose() — throws on Three.js r160+ Groups
4. Reading Back (NOT Matrix)
- ✅
reticle.getWorldPosition(pos) — safe
- ✅
reticle.getWorldQuaternion(quat) — safe
- ❌
matrix.decompose(pos, quat, scale) — throws exception
Correct Pattern (v2 — with stability debounce)
const reticleGroup = new THREE.Group();
const ringMesh = new THREE.Mesh(
new THREE.RingGeometry(0.35, 0.55, 48),
new THREE.MeshBasicMaterial({ color: 0x38bdf8, transparent: true, opacity: 1, side: THREE.DoubleSide })
);
ringMesh.rotation.x = -Math.PI / 2;
reticleGroup.add(ringMesh);
reticle = reticleGroup;
reticle.matrixAutoUpdate = true;
reticle.visible = false;
scene.add(reticle);
const particleCount = 120;
const positions = new Float32Array(particleCount * 3);
( i = ; i < particleCount; i++) {
r = .() * ;
theta = .() * . * ;
positions[i*] = r * .(theta);
positions[i*+] = ;
positions[i*+] = r * .(theta);
}
particleGeo = .();
particleGeo.(, .(positions, ));
scanParticles = .(particleGeo, particleMaterial);
reticle.(scanParticles);
hitStabilityCount = ;
groundDetected = ;
= ;
referenceSpace = ;
{ referenceSpace = session.(); }
(e) { referenceSpace = session.(); }
session.({ : referenceSpace }).( {
hitTestSource = source;
});
results = frame.(hitTestSource);
(results. > ) {
pose = results[].(referenceSpace);
(pose && pose..) {
hitStabilityCount++;
reticle..(pose..., pose..., pose...);
(hitStabilityCount >= && !groundDetected) {
groundDetected = ;
reticle. = ;
scanningOverlay..();
}
} {
hitStabilityCount = .(, hitStabilityCount - );
}
} {
hitStabilityCount = .(, hitStabilityCount - );
}
pos = .();
reticle.(pos);
Files Modified (webxr-portal-door)
/home/hermes/projects/webxr-portal-door/index.html
/home/hermes/projects/webxr-portal-door/Dockerfile
Mobile Cache-Busting Pitfall
Problem: cache-busted ?v= query params fail to force reload on Android Chrome despite correct nginx headers (Cache-Control: no-cache, no-store). Browser uses aggressive same-origin disk cache or socket pooling that ignores cache headers.
Fix — apply ALL three layers:
- Nginx config (in Dockerfile):
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
etag off;
add_header Last-Modified "" always;
- HTML
<meta> tag (inside <head>, before body content can cache):
<meta http-equiv="Cache-Control" content="no-cache, no-store">
- Testing instruction: Tell user to:
- Use incognito/private browser window (most reliable)
- OR clear Chrome socket pools:
chrome://net-internals/#http-cache → "Clear socket pools"
- OR open Android Settings → Apps → Chrome → Storage → Clear
Verification: Check Content-Length of both localhost and remote URL — if lengths match, new code IS served and the issue is client-side caching.
Related Skills
webxr-portal — main WebXR portal implementation