Build a VR Exposure-Therapy Prototype with WebXR and Three.js

2026-11-10来源 dev.to NewsEnglish
Build a VR Exposure-Therapy Prototype with WebXR and Three.js


## Why this is a software problem

VR exposure therapy is one of the best-supported uses of VR in medicine. In a randomized trial at Oxford, 100 adults with a fear of heights were assigned to either an automated VR program or usual care. The VR group worked with an animated virtual coach, with no live therapist, and no adverse events were reported (Lancet Psychiatry 2018 record).
For developers, the takeaway is simple: when therapy is delivered as software, the quality of the code is part of the treatment. Frame rate, session control, data handling and safe exits all matter.
This post builds a small WebXR prototype: a platform where the ground drops away in graded steps, controlled by a clinician-style level setting. Then it covers what changes when the user is a patient. For the clinical evidence behind all of this, read the companion article on Medium: Virtual Reality in Healthcare: A New Therapeutic Tool?

This is a technical demo, not a validated medical product and not medical advice.

What we’re building

• A small platform at floor level, so the patient stands on it.
• A ground plane that sits 0.5, 2, 6, 12 or 25 metres below the platform.
• A level dial that a clinician controls, because exposure therapy is graded: intensity rises step by step, never all at once.
• Session logging with no personal data.

**

Prerequisites

**

• A recent Three.js (r150 or newer, which provides the three/addons import path).
• A secure context. WebXR works on HTTPS or localhost only.
• A headset browser such as Meta Quest Browser, or Meta’s Immersive Web Emulator browser extension for desktop testing.
npm create vite@latest vr-exposure -- --template vanilla
cd vr-exposure
npm install three
**

The scene

**
Replace main.js with this:
import * as THREE from 'three';
import { VRButton } from 'three/addons/webxr/VRButton.js';

const LEVELS_M = [0.5, 2, 6, 12, 25]; // metres from platform to ground
const params = new URLSearchParams(location.search);
let level = clamp(Number(params.get('level')) || 0, 0, LEVELS_M.length - 1);

const sessionId = crypto.randomUUID(); // random per session, not linked to a person
function log(type, data = {}) {
console.info(JSON.stringify({ sessionId, type, t: Date.now(), ...data }));
}
function clamp(v, lo, hi) {
return Math.min(Math.max(v, lo), hi);
}

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x9ec5e8);
scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 2));

const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.05, 500);
camera.position.set(0, 1.6, 0); // desktop preview only; the headset pose overrides it in VR

// The platform the patient stands on (top surface at y = 0)
const platform = new THREE.Mesh(
new THREE.BoxGeometry(2, 0.1, 2),
new THREE.MeshStandardMaterial({ color: 0x444b57 })
);
platform.position.y = -0.05;
scene.add(platform);

// Ground that sits further below the platform as the level rises
const ground = new THREE.Group();
ground.add(
new THREE.Mesh(
new THREE.PlaneGeometry(400, 400).rotateX(-Math.PI / 2),
new THREE.MeshStandardMaterial({ color: 0x5b8c5a })
)
);
const grid = new THREE.GridHelper(400, 200, 0x2f4f2f, 0x3f6b3f); // grid lines give depth cues
grid.position.y = 0.02;
ground.add(grid);
scene.add(ground);

function setLevel(next) {
if (!Number.isInteger(next)) return; // treat any incoming value as untrusted
level = clamp(next, 0, LEVELS_M.length - 1);
ground.position.y = -LEVELS_M[level];
log('level_change', { level, heightM: LEVELS_M[level] });
}

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.xr.enabled = true;
document.body.appendChild(renderer.domElement);
document.body.appendChild(VRButton.createButton(renderer)); // shows 'VR NOT SUPPORTED' where unavailable

renderer.xr.addEventListener('sessionstart', () => log('session_start'));
renderer.xr.addEventListener('sessionend', () => log('session_end'));

setLevel(level);
renderer.setAnimationLoop(() => renderer.render(scene, camera));

addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});

// Desktop testing: keys 1 to 5 set the level
addEventListener('keydown', (e) => {
if (e.key >= '1' && e.key <= '5') setLevel(Number(e.key) - 1);
});
To run it on a standalone headset without setting up HTTPS, use USB debugging and forward the port, so the headset treats localhost as a secure context:
npm run dev -- --host
adb reverse tcp:5173 tcp:5173

then open http://localhost:5173 in Quest Browser

Add ?level=2 to the URL to start at the third step.
**

Letting a clinician control the level

**
The patient should never be the one raising the intensity. A real build needs a second screen for the clinician. This is a sketch of the receiving side, assuming your own WebSocket server:
const ws = new WebSocket('wss://your-server.example/clinician');
ws.addEventListener('message', (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'set_level') setLevel(Number(msg.level));
});
Use wss://, authenticate the clinician, and keep the validation in setLevel. Never trust a message just because it arrived on your socket.
**

What changes when the user is a patient

**
1. Comfort comes first
VR sickness is real. The most cited explanation is a mismatch between what the eyes see and what the inner ear feels, although researchers still have no single accepted theory (Frontiers review). What you can control as a developer:
• Frame budget. Target the headset’s native refresh rate. At 72 Hz you have about 13.9 ms per frame, at 90 Hz about 11.1 ms, and at 120 Hz about 8.3 ms.
• Never move the camera yourself. The headset owns the viewpoint. Keep the patient stationary or use teleport-style movement.
• Change levels deliberately. In the demo, changing the level moves the ground. Do it between steps, with the patient’s knowledge, and consider a quick fade. Never animate it continuously.
• Offer a seated mode and keep sessions short, as clinical programs do.
2. A reliable way out
A patient who feels unwell must be able to stop instantly. Give them a one-gesture exit:
renderer.xr.addEventListener('sessionstart', () => {
const session = renderer.xr.getSession();
session.addEventListener('squeezestart', () => session.end());
});
Consider requiring a deliberate gesture so it cannot trigger by accident, and let the clinician end the session remotely too.
3. Plan for no headset
Clinics cannot assume every device supports immersive VR. Check first and provide a fallback:
async function supportsImmersiveVR() {
return Boolean(await navigator.xr?.isSessionSupported('immersive-vr'));
}
4. Treat movement data as sensitive
Head and hand pose streams reveal a lot about a person. Collect the minimum you need, use random session IDs as above, encrypt data in transit, and do not send raw pose data unless an outcome measure requires it. If you handle identifiable patient data, involve your compliance team early, because rules such as HIPAA and GDPR depend on your use case and region.
5. Know when software becomes a medical device
Software that claims to treat or diagnose a condition may be regulated as a medical device. The FDA authorized RelieVRx, a VR therapy for chronic low back pain, through its De Novo pathway in November 2021 (MobiHealthNews). That is the bar a real therapeutic product works toward. Your prototype is not there, and it should not make clinical claims.
6. Measure outcomes from day one
The Oxford program used roughly six 30-minute sessions over two weeks (report). If you want to show your software helps, log structured events such as level changes, session length and early exits, and pair them with validated questionnaires. Evidence is what separates a demo from a therapy.
Where to take it next
• Add a breathing pacer or calming cue the patient can trigger.
• Build a clinician dashboard with live level control and a visible session timer.
• Try hand tracking by requesting hand-tracking as an optional feature.
• Add a seated calibration step.
• Run a small usability study with clinicians before any patient sees it.
**

Wrap-up

**
WebXR makes it easy to get a scene on a headset. Healthcare makes you answer harder questions: Is it comfortable? Can the patient stop it? Who sees the data? What can you honestly claim?
If you want the clinical side, including the evidence for pain, phobia, surgical training and rehab, read Virtual Reality in Healthcare: A New Therapeutic Tool? on Medium.
What would you add first: a clinician dashboard, hand tracking, or outcome logging? Tell me in the comments.
DEV front matter

Change published: false to true when you are ready to publish.
Sources
• Oxford fear-of-heights trial, Lancet Psychiatry 2018 and trial report
• RelieVRx FDA authorization (MobiHealthNews)
• Frontiers review on reducing cybersickness in head-mounted displays

原文:https://www.a8q9.cn/en/article/build-a-vr-exposure-therapy-prototype-with-webxr-and-three-js-f5aefb.html

相关文章