Back openDesk Edu for a sovereign, open-source education — every vote counts.
Vote nowSave products you love by clicking the heart icon.
End-to-End-Leitfaden zum Erstellen einer produktionsreifen WebXR-Anwendung mit Three.js und TypeScript — Projektsetup, Session-Lifecycle, Controller-Input, Teleportation, VR-UI und Deployment.
Ein praxisnaher Deep-Dive in die WebXR Hand Tracking API — Gelenkpositionen, Pinch-Erkennung, benutzerdefinierte Gestenerkennung und Interaktionsmuster für immersive VR-Anwendungen.
Damit sich AR-Erlebnisse echt anfühlen, müssen virtuelle Objekte mit der physischen Welt interagieren. Sie sollten auf Tischen stehen, von Wänden verdeckt werden und reagieren, wenn der Nutzer eine Oberfläche berührt. WebXR stellt hierfür zwei APIs bereit: Hit Testing (wo zeigt der Nutzer hin?) und Depth Sensing (wie sieht die Form der realen Welt aus?).
Hit Testing beantwortet eine einfache Frage: Wenn ein Strahl vom Controller oder Blick des Nutzers ausgeht, an welcher Stelle schneidet dieser eine reale Oberfläche?
const session = await navigator.xr.requestSession("immersive-ar", {
requiredFeatures: ["hit-test"],
});
Es gibt zwei Arten von Hit Test Sources:
Non-transient — an einen festen Referenzraum gebunden. Ideal für gaze-basierte Interaktionen:
const hitTestSource = await session.requestHitTestSource({
space: viewerReferenceSpace,
});
Transient — an eine Input-Quelle (Controller, Hand) gebunden. Aktualisiert sich mit der Bewegung des Inputs:
const transientSource = await session.requestHitTestSourceForTransientInput({
profile: "generic-trigger",
offsetRay: new XRRay(
{
x: 0,
y: 0,
z: 0, // origin, direction
},
{
x: 0,
y: 0,
z: -1,
w: 0, // -Z = forward
},
),
});
Über den Parameter offsetRay lässt sich anpassen, wo der Hit Test Strahl relativ zur Input-Quelle beginnt. Bei Controllern ist dies standardmäßig die Zeigerichtung. Bei Händen möchte man den Ursprung eventuell auf die Handfläche oder die Fingerspitze des Zeigefingers verschieben.
function onXRFrame(time: number, frame: XRFrame) {
// Non-transient hit results
const nonTransientResults = frame.getHitTestResults(hitTestSource);
for (const result of nonTransientResults) {
const pose = result.getPose(referenceSpace);
if (pose) {
// pose.transform.position = intersection point in world space
// pose.transform.orientation = surface normal
renderHitMarker(pose.transform.position);
}
}
// Transient hit results
const transientResults = frame.getHitTestResultsForTransientInput(transientSource);
for (const result of transientResults) {
const inputSource = result.inputSource;
const pose = result.results[0]?.getPose(referenceSpace);
if (pose) {
// Intersection tied to this specific input source
handleInputInteraction(inputSource, pose);
}
}
}
Jedes Hit Test Ergebnis enthält:
getPose(referenceSpace) — den Schnittpunkt und die Oberflächennormaleresult.inputSource — bei transienten Ergebnissen die Input-Quelle, die den Test ausgelöst hatfunction renderHitMarker(position: DOMPointReadOnly, normal?: DOMPointReadOnly) {
marker.position.set(position.x, position.y, position.z);
if (normal) {
// Orient marker to match surface normal
const up = new THREE.Vector3(0, 1, 0);
const surfaceNormal = new THREE.Vector3(normal.x, normal.y, normal.z);
const quat = new THREE.Quaternion().setFromUnitVectors(up, surfaceNormal);
marker.quaternion.copy(quat);
}
marker.visible = true;
}
Während Hit Testing Ihnen mitteilt, wo sich Oberflächen an bestimmten Punkten befinden, liefert Depth Sensing eine vollständige Tiefenkarte (Depth Map) der Umgebung.
const session = await navigator.xr.requestSession("immersive-ar", {
requiredFeatures: ["depth-sensing"],
depthSensing: {
usagePreference: ["cpu-optimized", "gpu-optimized"],
dataFormatPreference: ["luminance-alpha", "float32"],
},
});
Tiefeninformationen sind pro Ansicht (linkes und rechtes Auge) verfügbar:
function onXRFrame(time: number, frame: XRFrame) {
for (const view of frame.views) {
const depthInfo = frame.getDepthInformation(view);
if (!depthInfo) continue;
// Raw depth values in meters
const depthData = depthInfo.getDepthInMeters();
// Buffer dimensions
const width = depthInfo.width; // e.g., 256
const height = depthInfo.height; // e.g., 176
// Access a specific pixel (center of frame)
const centerU = Math.floor(width / 2);
const centerV = Math.floor(height / 2);
const centerDepth = depthData[centerV * width + centerU];
// centerDepth is distance in meters from the camera
// Convert depth pixel to world position
const worldPos = depthInfo.getDepthInWorldPosition(centerU, centerV);
if (worldPos) {
// worldPos is a DOMPointReadOnly in the reference space
}
}
}
| Methode | Zugriffsmuster | Bestens geeignet für |
|---|---|---|
getDepthInMeters() | CPU — Float32Array | Räumliche Abfragen, Physik |
getDepthInWorldPosition() | CPU — DOMPointReadOnly | Konvertierung einzelner Pixel in den Weltraum |
Depth Texture (via XRWebGLBinding) | GPU — WebGL Texture | Occlusion Rendering, Shader-Effekte |
Der rohe Depth Buffer befindet sich im Clip Space der Kamera. Die Konvertierung in den Weltraum erfordert die Projektionsmatrix:
function depthUVToWorld(
u: number,
v: number,
depthInMeters: Float32Array,
width: number,
height: number,
view: XRView,
): THREE.Vector3 | null {
const depth = depthInMeters[v * width + u];
if (depth <= 0) return null;
// Normalized device coordinates
const ndcX = (u / width) * 2 - 1;
const ndcY = (v / height) * 2 - 1;
// Using the view's projection matrix inverse
const projMatrix = new THREE.Matrix4().fromArray(view.projectionMatrix);
const invProj = projMatrix.invert();
const clipPos = new THREE.Vector4(ndcX, ndcY, -1, 1);
const worldPos4 = clipPos.applyMatrix4(invProj);
worldPos4.multiplyScalar(1 / worldPos4.w);
// Scale to actual depth
const direction = new THREE.Vector3(worldPos4.x, worldPos4.y, worldPos4.z).normalize();
const worldPosition = direction.multiplyScalar(depth);
// Transform by view transform
worldPosition.applyMatrix4(new THREE.Matrix4().fromArray(view.transform.inverse.matrix));
return worldPosition;
}
Alternativ kann die Convenience-Methode verwendet werden (in einigen Runtimes verfügbar):
const worldPos = depthInfo.getDepthInWorldPosition(u, v);
Occlusion (Verdeckung) ist das, was AR realistisch macht – virtuelle Objekte verschwinden hinter realen Oberflächen. Ohne Occlusion werden Objekte immer über der realen Welt gerendert, was die Illusion zerstört.
Der performanteste Ansatz sampelt die Depth Texture im Fragment Shader:
// Fragment shader
uniform sampler2D uDepthTexture;
uniform mat4 uProjectionMatrix;
uniform mat4 uViewMatrix;
uniform mat4 uModelMatrix;
varying vec3 vWorldPosition;
void main() {
// Transform fragment world position to clip space of the depth camera
vec4 clipPos = uProjectionMatrix * uViewMatrix * vec4(vWorldPosition, 1.0);
vec3 ndc = clipPos.xyz / clipPos.w;
// Convert to UV coordinates
vec2 uv = ndc.xy * 0.5 + 0.5;
// Sample the real-world depth
float realDepth = texture2D(uDepthTexture, uv).r;
// Compare with the fragment's depth
float fragDepth = ndc.z;
// If fragment is behind the real surface, discard it
if (fragDepth > realDepth + 0.005) {
discard;
}
// Otherwise render normally
gl_FragColor = vec4(1.0);
}
Erstellen Sie für Three.js ein benutzerdefiniertes Shader Material:
const occlusionMaterial = new THREE.ShaderMaterial({
uniforms: {
uDepthTexture: { value: depthTexture },
uProjectionMatrix: { value: new THREE.Matrix4() },
uViewMatrix: { value: new THREE.Matrix4() },
},
vertexShader: `
varying vec3 vWorldPosition;
void main() {
vec4 worldPos = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPos.xyz;
gl_Position = projectionMatrix * viewMatrix * worldPos;
}
`,
fragmentShader: `
uniform sampler2D uDepthTexture;
uniform mat4 uProjectionMatrix;
uniform mat4 uViewMatrix;
varying vec3 vWorldPosition;
void main() {
vec4 clipPos = uProjectionMatrix * uViewMatrix * vec4(vWorldPosition, 1.0);
vec3 ndc = clipPos.xyz / clipPos.w;
vec2 uv = ndc.xy * 0.5 + 0.5;
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {
gl_FragColor = vec4(1.0);
return;
}
float realDepth = texture2D(uDepthTexture, uv).r;
float fragDepth = ndc.z;
if (fragDepth > realDepth + 0.005) discard;
gl_FragColor = vec4(0.5, 0.8, 1.0, 1.0);
}
`,
transparent: true,
});
| Ansatz | Qualität | Performance | Komplexität |
|---|---|---|---|
| Depth Texture Shader | Gut | ✅ Best | Mittel |
| CPU Depth Read + Stencil | Mittel | ❌ Langsam | Niedrig |
| Mesh Reconstruction | Exzellent | ✅ Gut (nach Erstellung) | Hoch |
Der Depth Texture Ansatz ist der empfohlene Startpunkt – gute visuelle Qualität bei minimalem Overhead.
Einige Runtimes (Quest 3, ARKit) bieten Mesh Reconstruction an – ein vollständiges Dreiecksnetz der Umgebung:
// Check for mesh reconstruction support
const meshSet = frame.worldInformation?.meshSet;
if (meshSet) {
for (const mesh of meshSet) {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(mesh.positions, 3));
geometry.setIndex(mesh.indices);
// Use for occlusion, physics, or navigation
}
}
Mesh Reconstruction ist die genaueste Form des Umgebungsveständnisses, aber auch die rechenintensivste. Verwenden Sie sie sparsam und mit Geometry Level of Detail (LOD).
Die wahre Stärke ergibt sich aus der Kombination all dieser Funktionen:
class ARPlacementSystem {
private hitTestSource: XRHitTestSource;
private depthTexture: WebGLTexture | null = null;
async placeObject(frame: XRFrame): Promise<boolean> {
// 1. Find surface via hit test
const results = frame.getHitTestResults(this.hitTestSource);
if (results.length === 0) return false;
const pose = results[0].getPose(referenceSpace);
if (!pose) return false;
// 2. Place the object
const anchor = await frame.createAnchor(pose.transform, referenceSpace);
this.attachObjectToAnchor(anchor);
// 3. Enable occlusion for the placed object
this.enableOcclusion(frame);
return true;
}
private enableOcclusion(frame: XRFrame) {
for (const view of frame.views) {
const depthInfo = frame.getDepthInformation(view);
if (depthInfo) {
// Update the depth texture uniform
// ... (GPU depth texture binding via XRWebGLBinding)
}
}
}
}