three.js and raw WebXR: @realitycollective/threejs-environment
The three.js adapter binds the engine-free core to a three.js Scene and AudioListener, for apps on plain three.js and raw WebXR.
What it binds
scene.background, Fog / FogExp2, an AmbientLight and a DirectionalLight, scene.environment for image-based lighting, three.js's own built-in depth-sensing occlusion, a WebXR light probe read from the current XRFrame, Audio / PositionalAudio for sound, and the XRFrame directly for planes, meshes, anchors and hit testing.
Install
npm install @realitycollective/threejs-environment three
The peer dependency is three >= 0.170.0; Meta's super-three fork satisfies it and is what the workspace develops against.
Setup
import { createThreeAudio, createThreeEnvironment, STOCK_PRESETS } from "@realitycollective/threejs-environment";
const { director } = createThreeEnvironment(scene, {
presets: STOCK_PRESETS,
initial: STOCK_PRESETS.noon,
defaultTransition: { durationMs: 4000, easing: "easeInOut" },
});
const listener = new AudioListener();
camera.add(listener);
const { director: audio, port: audioPort } = createThreeAudio(listener, {
cues: [{ id: "hum", src: "/audio/hum.mp3", bus: "ambience", loop: true }],
});
renderer.setAnimationLoop(() => {
const deltaMs = clock.getDelta() * 1000;
director.update(deltaMs); // neither director ticks itself
audio.update(deltaMs);
renderer.render(scene, camera);
});
Call audioPort.resume() from the same gesture that enters XR; browsers refuse to start an AudioContext outside a user gesture, and a suspended context makes every voice silently succeed.
What it adds over the core
A solid sky becomes scene.background; a gradient becomes a two-pixel-wide equirectangular DataTexture, generated by a pure function and regenerated in place across a transition rather than reallocated per frame. Fog is Fog or FogExp2, mutated in place while the kind is unchanged. Lights are an AmbientLight and a DirectionalLight, positioned from the direction light travels. Image-based lighting reuses the generated sky ramp for a gradient, loads an authored image through an injectable loader, and uses three.js's RoomEnvironment for kind: "room" when you supply a prefilter, or a neutral ramp when you do not. Occlusion runs over three.js's own depth sensing, present in the renderer since r158; pass renderer in the port options, and the port decides whether the request can be served and reports the reason when it cannot. Light estimation reads a WebXR light probe from the current XRFrame and converts it to ordinary ambient and key specs, so the estimate is data the app can read, blend or ignore. For sound, Audio / PositionalAudio carry per-cue reference distance, rolloff, maximum distance, distance model and cone; a play that arrives before its buffer has decoded is held and started once the decode lands, so the first press of a session is not silent. For the room, createThreeWorldSensing(renderer) reads planes, meshes, anchors and hit-test results straight from the XRFrame, in the reference space three.js draws in, so a pose can be handed to object.position.set(...) unchanged. A hit test bound to a hand is cast from that hand's own targetRaySpace and carries the distance from where the ray started.
What it cannot do on this host, and why
There is no hand-rolled depth-priming pass: duplicating the renderer's GL state handling is how caches start lying, so the port decides whether a request can be served rather than building its own pass. kind: "room" image-based lighting is a neutral ramp, not a real room probe, unless you supply a prefilter, because prefiltering needs a renderer this port deliberately does not hold. Measured reflections need a reflection hook, two lines around XRWebGLBinding.getReflectionCubeMap; without one, ambient and key light estimation still work and the report says the reflections were not measured. The adapter creates no geometry at all: a floor is a mesh, so a floor is the app's.
Testing it
npm test at the repository root runs vitest across the workspace, including this adapter's port tests, under coverage gates, plus the shared import-surface test that fails an import of a name a dependency only re-exports from elsewhere. npm run verify:pack packs the adapter and installs it into a clean project to prove the consumer path.
Live demo
webxr-environment.pages.dev, the Environment playground, built on this adapter.