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 performance deep-dive into WebXR Composition Layers â projection, quad, cylinder, and equirectangular layers, video playback via XRMediaBinding, and GPU-composited overlay techniques.
Diese Anleitung fĂŒhrt Sie durch den Aufbau einer vollstĂ€ndigen WebXR-Anwendung von Grund auf â eine VR-Szene mit Controller-Interaktion, Teleportations-Bewegung und World-Space-UI. Am Ende haben Sie eine bereitstellbare App, die die in dieser Serie behandelten APIs integriert.
Wir verwenden Vite fĂŒr eine schnelle Entwicklung und TypeScript fĂŒr die Typsicherheit:
npm create vite@latest my-xr-app -- --template vanilla-ts
cd my-xr-app
npm install three @webxr-input-profiles/motion-controllers
npm install --save-dev @types/three
Aktivieren Sie die WebXR-Typen in tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"types": ["three", "webxr"]
}
}
FĂŒgen Sie die WebXR-Typreferenz oben in Ihrer Entry-Datei hinzu:
/// <reference types="webxr" />
Eventuell mĂŒssen Sie die Types-Glob in einer *.d.ts-Datei hinzufĂŒgen:
// src/xr-types.d.ts
import "webxr";
my-xr-app/
âââ src/
â âââ main.ts # Entry point â session setup, loop
â âââ scene.ts # Scene, camera, renderer setup
â âââ controllers.ts # Controller model loading, input events
â âââ teleport.ts # Teleportation movement system
â âââ ui.ts # World-space UI
â âââ xr-types.d.ts # WebXR type declarations
âââ public/
â âââ models/ # GLTF models
âââ index.html
âââ package.json
âââ tsconfig.json
import { setupScene } from "./scene";
import { setupControllers } from "./controllers";
import { setupTeleport } from "./teleport";
async function main() {
// Check WebXR support
if (!navigator.xr) {
document.body.innerHTML = "<h1>WebXR not supported in this browser</h1>";
return;
}
// Request immersive VR session
const session = await navigator.xr.requestSession("immersive-vr", {
requiredFeatures: ["local-floor", "hand-tracking"],
});
// Set up the Three.js scene
const { renderer, scene, camera } = setupScene(session);
// Set up controllers
const controllers = setupControllers(renderer, scene);
// Set up teleportation
const teleport = setupTeleport(renderer, scene, session);
// Handle session end
session.addEventListener("end", () => {
renderer.setAnimationLoop(null);
});
// Start the render loop
renderer.setAnimationLoop((time: number, frame: XRFrame | null) => {
if (frame) {
controllers.update(frame, renderer.xr.getReferenceSpace()!);
teleport.update(frame);
}
renderer.render(scene, camera);
});
}
main().catch(console.error);
Die Methode renderer.setAnimationLoop() ersetzt requestAnimationFrame(), wenn XR aktiv ist. Der Callback erhÀlt in jedem Frame ein XRFrame-Objekt.
import * as THREE from "three";
let _session: XRSession | null = null;
export function setupScene(session: XRSession) {
_session = session;
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true;
renderer.xr.setFoveation(0.5); // Balance quality/performance
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111122);
// Lighting
const ambient = new THREE.AmbientLight(0x404060);
scene.add(ambient);
const directional = new THREE.DirectionalLight(0xffffff, 1);
directional.position.set(1, 3, 2);
scene.add(directional);
// Floor grid
const grid = new THREE.GridHelper(10, 10, 0x4444ff, 0x222244);
scene.add(grid);
// Interactive objects
const geometry = new THREE.BoxGeometry(0.2, 0.2, 0.2);
const material = new THREE.MeshStandardMaterial({ color: 0x44aaff });
for (let i = 0; i < 10; i++) {
const cube = new THREE.Mesh(geometry, material);
cube.position.set((Math.random() - 0.5) * 4, 0.1, (Math.random() - 0.5) * 4 - 1);
scene.add(cube);
}
// Camera (handled by Three.js XR manager)
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 1.6, 0);
return { renderer, scene, camera };
}
Wichtige Punkte:
renderer.xr.enabled = true â weist Three.js an, XR-Kameratransformationen anstelle der Szenenkamera zu verwendensetFoveation() reduziert die periphere Auflösung zur Performance-SteigerungThree.js stellt XRRenderer.getController() bereit, um jeden getrackten Input zu reprĂ€sentieren:
import * as THREE from "three";
export interface ControllerState {
position: THREE.Vector3;
quaternion: THREE.Quaternion;
selectPressed: boolean;
squeezePressed: boolean;
}
export function setupControllers(renderer: THREE.WebGLRenderer, scene: THREE.Scene) {
const controllers: { [key: string]: ControllerState } = {};
const controllerMeshes: THREE.Object3D[] = [];
// Create a controller visual (simple ray + tip)
function createControllerVisual(): THREE.Group {
const group = new THREE.Group();
// Ray
const rayGeometry = new THREE.BufferGeometry();
const rayVertices = new Float32Array([0, 0, 0, 0, 0, -1]);
rayGeometry.setAttribute("position", new THREE.BufferAttribute(rayVertices, 3));
const rayMaterial = new THREE.LineBasicMaterial({ color: 0x8888ff });
const ray = new THREE.Line(rayGeometry, rayMaterial);
group.add(ray);
// Tip
const tipGeometry = new THREE.SphereGeometry(0.01, 8, 8);
const tipMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
const tip = new THREE.Mesh(tipGeometry, tipMaterial);
tip.position.set(0, 0, -1);
group.add(tip);
return group;
}
// Left controller
const leftGroup = createControllerVisual();
const leftController = renderer.xr.getController(0);
leftController.add(leftGroup);
scene.add(leftController);
controllerMeshes.push(leftGroup);
// Right controller
const rightGroup = createControllerVisual();
const rightController = renderer.xr.getController(1);
rightController.add(rightGroup);
scene.add(rightController);
controllerMeshes.push(rightGroup);
// Input events
function setupInput(hand: number, state: ControllerState) {
const controller = renderer.xr.getController(hand);
controller.addEventListener("selectstart", () => {
state.selectPressed = true;
});
controller.addEventListener("selectend", () => {
state.selectPressed = false;
});
controller.addEventListener("squeezestart", () => {
state.squeezePressed = true;
});
controller.addEventListener("squeezeend", () => {
state.squeezePressed = false;
});
controllers[`hand-${hand}`] = state;
}
setupInput(0, {
position: new THREE.Vector3(),
quaternion: new THREE.Quaternion(),
selectPressed: false,
squeezePressed: false,
});
setupInput(1, {
position: new THREE.Vector3(),
quaternion: new THREE.Quaternion(),
selectPressed: false,
squeezePressed: false,
});
// Update controller state each frame
return {
controllers,
getController: (index: number) => renderer.xr.getController(index),
update: (frame: XRFrame, refSpace: XRReferenceSpace) => {
for (const inputSource of session.inputSources) {
const handIndex = inputSource.handedness === "left" ? 0 : 1;
const pose = frame.getPose(inputSource.gripSpace!, refSpace);
if (pose && controllers[`hand-${handIndex}`]) {
const state = controllers[`hand-${handIndex}`];
state.position.copy(pose.transform.position);
state.quaternion.copy(pose.transform.orientation);
}
}
},
};
}
| Event | Trigger | Anwendungsfall |
|---|---|---|
select | Trigger-Zug (vollstÀndiger Klick) | Objektauswahl, Tastendruck |
selectstart / selectend | Trigger drĂŒcken/loslassen | Drag-Operationen, Hold-Aktionen |
squeeze | Grip-Taste | Objekte greifen |
squeezestart / squeezeend | Grip drĂŒcken/loslassen | Kontinuierliches Greifen |
inputsourceschange | Verbinden/Trennen | Controller-Hot-Plugging |
Teleportation ist das Standard-VR-Lokomotionsmuster â zielen Sie auf den Ort, an den Sie gelangen möchten, und lassen Sie los, um sich zu bewegen:
import * as THREE from "three";
export function setupTeleport(
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
session: XRSession,
) {
// Arc visualization
const arcPoints = 20;
const arcGeometry = new THREE.BufferGeometry();
const arcPositions = new Float32Array((arcPoints + 1) * 3);
arcGeometry.setAttribute("position", new THREE.BufferAttribute(arcPositions, 3));
const arcMaterial = new THREE.LineBasicMaterial({
color: 0x44ff44,
transparent: true,
opacity: 0.6,
});
const arcLine = new THREE.Line(arcGeometry, arcMaterial);
arcLine.visible = false;
scene.add(arcLine);
// Target ring
const ringGeometry = new THREE.RingGeometry(0.1, 0.15, 32);
const ringMaterial = new THREE.MeshBasicMaterial({
color: 0x44ff44,
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide,
});
const targetRing = new THREE.Mesh(ringGeometry, ringMaterial);
targetRing.visible = false;
scene.add(targetRing);
const camera = renderer.xr.getCamera();
return {
update: (frame: XRFrame) => {
const refSpace = renderer.xr.getReferenceSpace();
if (!refSpace) return;
// Use right controller for aiming
const rightController = renderer.xr.getController(1);
const controllerPose = frame.getPose(rightController.targetRaySpace, refSpace);
if (!controllerPose) {
arcLine.visible = false;
targetRing.visible = false;
return;
}
// Compute parabolic arc
const start = controllerPose.transform.position;
const forward = new THREE.Vector3(0, 0, -1);
forward.applyQuaternion(controllerPose.transform.orientation);
// Physics for arc: simulate velocity with gravity
const velocity = forward.clone().multiplyScalar(2);
velocity.y += 0.5; // Upward arc
const points: THREE.Vector3[] = [];
const gravity = new THREE.Vector3(0, -9.8, 0);
const step = 0.05; // 50ms per step
for (let t = 0; t < arcPoints; t++) {
const time = t * step;
const point = start
.clone()
.add(velocity.clone().multiplyScalar(time))
.add(gravity.clone().multiplyScalar(0.5 * time * time));
if (point.y < 0) {
point.y = 0; // Hit floor level
points.push(point);
break;
}
points.push(point);
}
// Update arc visual
const positions = arcLine.geometry.attributes.position.array as Float32Array;
for (let i = 0; i < points.length && i < arcPoints + 1; i++) {
positions[i * 3] = points[i].x;
positions[i * 3 + 1] = points[i].y;
positions[i * 3 + 2] = points[i].z;
}
arcLine.geometry.attributes.position.needsUpdate = true;
arcLine.visible = points.length > 0;
// Update target ring
if (points.length > 1) {
const target = points[points.length - 1];
targetRing.position.copy(target);
targetRing.visible = true;
}
// Teleport on trigger release
// (Handled via controller 'selectend' event in main.ts)
},
teleportTo: (position: THREE.Vector3) => {
// Offset camera position
const offset = position.clone().sub(camera.position);
camera.position.add(offset);
},
};
}
Der parabolische Bogen nutzt eine einfache Physiksimulation â Gravitation + Anfangsgeschwindigkeit. Das selectend-Event löst die Teleportation aus und verschiebt die Kamera an die Zielposition.
Verwenden Sie fĂŒr einfache UIs in VR Sprite-basierte oder Plane-basierte Panels:
import * as THREE from "three";
export function createVrPanel(
scene: THREE.Scene,
text: string,
width = 0.5,
height = 0.15,
): THREE.Group {
const group = new THREE.Group();
// Background
const bgGeometry = new THREE.PlaneGeometry(width, height);
const bgMaterial = new THREE.MeshBasicMaterial({
color: 0x222244,
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide,
});
const background = new THREE.Mesh(bgGeometry, bgMaterial);
group.add(background);
// Border
const edgeGeometry = new THREE.EdgesGeometry(bgGeometry);
const edgeMaterial = new THREE.LineBasicMaterial({ color: 0x6666ff });
const border = new THREE.LineSegments(edgeGeometry, edgeMaterial);
group.add(border);
// Text using canvas texture (simple approach)
const canvas = document.createElement("canvas");
canvas.width = 256;
canvas.height = 64;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#ffffff";
ctx.font = "24px monospace";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(text, 128, 32);
const texture = new THREE.CanvasTexture(canvas);
const textGeometry = new THREE.PlaneGeometry(width * 0.9, height * 0.6);
const textMaterial = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide,
});
const textMesh = new THREE.Mesh(textGeometry, textMaterial);
textMesh.position.z = 0.001; // Slightly in front of background
group.add(textMesh);
scene.add(group);
return group;
}
FĂŒr komplexere UIs ziehen Sie Folgendes in Betracht:
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
const loader = new GLTFLoader();
loader.load("/models/my-model.glb", (gltf) => {
const model = gltf.scene;
model.scale.set(0.1, 0.1, 0.1);
scene.add(model);
});
// Adjust foveation level based on scene complexity
renderer.xr.setFoveation(complexScene ? 0.7 : 0.3);
// Enable XR multisampling (if available)
renderer.xr.setFramebufferScaleFactor(0.8); // Lower = better performance
// Use instancing for repeated objects
const matrix = new THREE.InstancedMesh(geometry, material, count);
// Limit draw calls
renderer.sortObjects = true; // Enable depth sorting
| Ziel | Frame-Budget (pro Frame) |
|---|---|
| 72 fps (Quest Standard) | 13,9 ms |
| 90 fps (Quest Refresh) | 11,1 ms |
| 120 fps (High-End) | 8,3 ms |
Profilieren Sie mit dem about://inspect-Tool des Browsers oder dem Performance-HUD-Overlay von Meta.
WebXR erfordert HTTPS, um zu funktionieren (Kamera- und Tracking-APIs).
npm run build
# Deploy to any static host
npx serve dist # Local test
# OR: upload to Vercel, Netlify, or your own server
FROM nginx:alpine
COPY dist/ /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf
Mit HTTPS ĂŒber Ihren Reverse Proxy (Caddy, Traefik, nginx + certbot):
server {
listen 443 ssl;
server_name xr.mydomain.com;
ssl_certificate /etc/letsencrypt/live/xr.mydomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/xr.mydomain.com/privkey.pem;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
add_header 'Cross-Origin-Embedder-Policy' 'require-corp';
add_header 'Cross-Origin-Opener-Policy' 'same-origin';
}
}
Die Cross-Origin-Header sind wichtig fĂŒr WebXR-Performance-Features (SharedArrayBuffer).
VerknĂŒpfen Sie in main.ts alle Komponenten:
import { setupScene } from "./scene";
import { setupControllers } from "./controllers";
import { setupTeleport } from "./teleport";
import { createVrPanel } from "./ui";
async function main() {
if (!navigator.xr) {
/* fallback */ return;
}
const session = await navigator.xr.requestSession("immersive-vr", {
requiredFeatures: ["local-floor"],
});
const { renderer, scene, camera } = setupScene(session);
const controllers = setupControllers(renderer, scene);
const teleport = setupTeleport(renderer, scene, session);
// Title panel
const titlePanel = createVrPanel(scene, "My WebXR App");
titlePanel.position.set(0, 1.8, -1);
// Teleport on trigger release
renderer.xr.getController(1).addEventListener("selectend", () => {
const refSpace = renderer.xr.getReferenceSpace();
if (refSpace) {
// teleport.teleportTo(currentTarget);
}
});
session.addEventListener("end", () => renderer.setAnimationLoop(null));
// Handle window resize
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
renderer.setAnimationLoop((time: number, frame: XRFrame | null) => {
if (frame) {
const refSpace = renderer.xr.getReferenceSpace();
if (refSpace) controllers.update(frame, refSpace);
teleport.update(frame);
}
renderer.render(scene, camera);
});
}
main();
Dies ist Ihr Fundament. Von hier aus können Sie jede der APIs aus dieser Serie hinzufĂŒgen: