Back openDesk Edu for a sovereign, open-source education â every vote counts.
Vote nowSave products you love by clicking the heart icon.
A performance deep-dive into WebXR Composition Layers â projection, quad, cylinder, and equirectangular layers, video playback via XRMediaBinding, and GPU-composited overlay techniques.
A practical guide to world-locked content with WebXR Anchors and plane detection â anchor lifecycle, persistent anchors, plane semantics, and combining with hit testing for robust placement UX.
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)
}
}
}
}