Skip to main content

@realitycollective/threejs-environment

The three.js adapter for the Reality Collective WebXR Environment Extensions. Re-exports the engine-free @realitycollective/webxr-environment core, so this is the only package you install.

npm install @realitycollective/threejs-environment three

Use

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);
});

director.transition("dusk");

What the adapter does

  • Sky - a solid colour becomes scene.background; a gradient becomes a two-pixel-wide equirectangular DataTexture, regenerated in place across a transition rather than reallocated per frame. No shader, no canvas, and the ramp itself is a pure function (skyMix, gradientPixels) so it is unit tested with no renderer involved.
  • Fog - Fog and FogExp2, mutated in place while the kind is unchanged.
  • Lights - an AmbientLight and a DirectionalLight, positioned from the direction light travels ([0, -1, 0] is overhead).
  • Image-based lighting - scene.environment, environmentIntensity and environmentRotation. A gradient reuses the generated ramp; an image is loaded through the injectable loader (pass an RGBELoader for HDR); kind: "room" uses three.js's RoomEnvironment when you supply a prefilter, and a neutral ramp when you do not, because prefiltering needs a renderer this port deliberately does not hold.
  • Depth occlusion - three.js's OWN depth sensing, which has drawn an occlusion mesh before the scene since r158. Pass renderer in the options; the port then decides whether the request can be served (a session, depth-sensing enabled, gpu-optimized depth, a scope three.js can honour) and reports the reason when it cannot. There is no hand-rolled depth pass here on purpose: duplicating the renderer's GL state handling is how caches start lying.
  • Light estimation - a WebXR light probe, read from the current XRFrame and converted to ordinary ambient and key specs. Not XREstimatedLight, so the estimate is data the app can read, blend or ignore rather than two lights in a Group only the renderer can see. Pass a reflection hook - two lines around XRWebGLBinding.getReflectionCubeMap - and the measured cube map arrives as ibl: { kind: "estimated" } too; without one, ambient and key still work and the report says the reflections were not measured.
  • 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 mesh is measured once per lastChangedTime, because the alternative is walking a room scan every frame. A hit test with space: "left" or "right" is cast from that hand's own targetRaySpace - it waits while there is no such input source and unbinds when the hand goes - and every hit carries the distance from where the ray started.
  • Audio - Audio / PositionalAudio over the listener's Web Audio context, with per-cue reference distance, rolloff, maximum distance, distance model and cone. A cue with a cone and a play with a facing become setDirectionalCone plus a turned holder: radians in the contract, degrees at the panner, +Z pointed the way the sound travels.

Everything the port creates is parented under one named Group (port.root), and dispose() gives all of it back. It creates no geometry - every one of those is a three.js facility with no app-side equivalent. A floor is a mesh, so a floor is yours; demos/playground builds its own in four lines.

Two things worth knowing

The first press is not silent. A play that arrives before its buffer has decoded is held and started when the decode lands, unless it was stopped in the meantime. Dropping it instead is the reason the first press of every button in a session so often makes no sound.

Autoplay. Browsers refuse to start an AudioContext outside a user gesture. Call audioPort.resume() from the same handler that enters XR (or from your Enter-VR button) - a suspended context makes every voice silently succeed.

Peer dependency

three >= 0.170.0. Meta's super-three fork satisfies this and is what the workspace develops against.

Licence

MIT.

Classes

ClassDescription
AudioDirector-
EnvironmentDirector-
ThreeAudioPortPlays sounds.
ThreeEnvironmentPortApplies an environment to a scene.
ThreeWorldSensingPortWhat an adapter implements. Everything except observe is optional, because every host has a different subset and a missing method is reported rather than thrown.
WorldSensingDirector-

Interfaces

InterfaceDescription
AmbientLightSpecUniform illumination from every direction.
AudioConeA directional sound, in this package's own terms.
AudioCueA sound the app knows how to make. Registered once, played by id.
AudioDirectorOptions-
AudioPortPlays sounds.
AudioSpatialHow a positional voice gets quieter with distance.
AudioVoiceA sounding voice. Opaque to the app apart from the fields shown.
AudioVoiceRequestA play request with every decision already made: which file, how loud in absolute terms, looping or not, and where from. An adapter implementing AudioPort has no mixing left to do.
EnvironmentDirectorOptions-
EnvironmentPortApplies an environment to a scene.
EnvironmentPortHostWhat a port is handed so it can talk back. Implemented by the director.
EnvironmentSpecA PARTIAL description of the environment.
EquirectLoaderAnything that can turn a URL into a texture: TextureLoader, RGBELoader.
EstimatedLightingLighting the host measured from the real world.
FogExponentialFog that thickens exponentially with distance.
FogLinearFog that ramps between two distances.
HitTestRequestA standing question: "where does this ray meet the room".
IblEstimatedThe reflections the HOST measured from the real room.
IblGradientImage-based lighting: what the world reflects and how it lights everything that is not lit by the key light.
IblRoomThe host's built-in neutral room probe. Neither authored nor measured.
IblTexture@realitycollective/webxr-environment - the engine-free core.
KeyLightSpecThe one directional light most scenes need: a sun, a moon, a work lamp.
LightEstimationSpecWhich slots a live estimate may take over.
OcclusionSourceHow the app would like the depth sensor configured.
OcclusionSpec-
PlayOptionsPer-play overrides. Everything here beats the cue's own value.
ResolvedEnvironmentEvery slot decided. This is what the director holds and adapters receive.
ResolvedLightEstimationspec with every default filled in, which is what a port receives.
ResolvedWorldDetection-
SensingReport-
SkyGradientA vertical gradient sky. top sits at the zenith and bottom at the nadir; horizon is where they meet, as a fraction of the way up the sphere.
SkySolidA single flat colour behind everything. The cheapest sky there is.
SkyTextureAn authored sky: an equirectangular image wrapped around the world.
ThreeAudioPortOptions-
ThreeAudioSetup-
ThreeEnvironmentPortOptions-
ThreeEnvironmentSetup-
ThreeWorldSensingPortOptions-
ThreeWorldSensingSetup-
TransitionOptions-
WorldAnchorA point in the real world the runtime promises to keep track of.
WorldChangeWhat moved since the last flush, per feature. Ids only; read the lists.
WorldDetectionSpecWhich of them to turn on. Everything defaults OFF: each one costs.
WorldHitWhere a ray met the world.
WorldMeshScanned geometry: furniture, walls, whatever the room-scan produced.
WorldPlaneA flat surface the host found: a floor, a wall, a table top.
WorldPoseWhere something is, in whatever reference space the app is using.
WorldSensingDirectorOptions-
WorldSensingPortWhat an adapter implements. Everything except observe is optional, because every host has a different subset and a missing method is reported rather than thrown.
WorldSensingPortHostWhat a port is handed so it can report what the room looks like.
XrAnchorLikeOne XRAnchor.
XrFrameLike-
XrHitTestResultLike-
XrLightEstimateLikeOne XRLightEstimate. The three products WebXR's light estimation makes.
XrManagerLikeThe part of renderer.xr this port uses.
XrMeshLikeOne XRMesh. The buffers are the runtime's and are never copied here.
XrPlaneLikeOne XRPlane. polygon points are in the plane's own space, metres.
XrPoseLikeWhat frame.getPose hands back: a transform, or null when tracking is lost.
XrRendererLikeA WebGLRenderer, seen through the keyhole this port looks through.
XrSessionLike-

Type Aliases

Type AliasDescription
AudioBusA mix group. Any string works; the conventional set is below.
CuePolicyWhat a second play request does while a cue is already sounding.
EasingFunctiont in 0..1 -> eased t in 0..1.
EasingNameThe named easing curves. A transition may also carry its own function.
EnvironmentBlendModeHow the host composites what is rendered over the real world.
EnvironmentListenerFired whenever what the port holds, or what the app asked for, changes.
EnvironmentSlot@realitycollective/webxr-environment - the engine-free core.
FogSpec@realitycollective/webxr-environment - the engine-free core.
IblSpec@realitycollective/webxr-environment - the engine-free core.
OcclusionModeHow hard the edge between real and virtual is drawn.
OcclusionScopeWhich content the real world is allowed to hide.
QuatA rotation as [x, y, z, w]. Normalised; the adapters guarantee that.
RampGradientA gradient with or without a horizon stop, from either slot.
RgbAn sRGB colour, one channel per component, each in 0..1.
SensingFeature-
SensingListener-
SensingStateHow a feature is doing, in the order things usually go wrong:
SkySpec@realitycollective/webxr-environment - the engine-free core.
Vec3A direction or position in world space, metres. Need not be normalised.
WorldChangeListener-
WorldFeature-
WorldPlaneOrientationRoughly which way a surface faces.
XrLightProbeLikeAn XRLightProbe. Opaque: it is only ever handed back to getLightEstimate.

Variables

VariableDescription
ALL_WORLD_DETECTIONEverything on: what setDetection(true) means.
DAWN-
DEFAULT_BUSThe bus a cue lands on when it does not name one.
DEFAULT_BUSESThe buses a director starts with, each at unity gain. Any other name works too - a bus springs into existence at unity the first time it is named. master is NOT in here: it is a separate scalar over all buses, so that "duck everything" and "turn the music down" never fight over one number.
DEFAULT_LIGHT_ESTIMATION-
DEFAULT_OCCLUSIONSoft edges over everything, from a GPU texture.
DUSK-
EMPTY_ENVIRONMENTAn environment with nothing in it: a black void with no light.
ENVIRONMENT_SLOTSThe slot names, in the order the director pushes them to the port.
NIGHT-
NOON-
OVERCAST-
SENSING_FEATURESThe sensor-backed features an adapter can report on.
SH_DC_TO_IRRADIANCEThe L0 band of a spherical-harmonic probe, scaled to irradiance.
SKY_TEXTURE_HEIGHTDefault rows. Sixty-four is smooth to the eye and 512 bytes of texture.
SKY_TEXTURE_WIDTHColumns in the generated texture. Two, because a 1px-wide equirect filters badly.
STOCK_PRESETSAll of the above, ready to hand to EnvironmentDirector's presets.
VOIDNothing at all: black, unlit, empty. The starting point of a void scene.
WORLD_FEATURESThe world features an app can ask for. They are named separately from the environment's because a host can have any subset: Quest has planes, meshes and anchors; a phone AR runtime may have only planes and hit test.

Functions

FunctionDescription
ambientFromSphericalHarmonicsThe ambient term of an estimate, or null when the runtime sent no probe.
clampClamp to an arbitrary range.
clamp01Clamp to the unit interval.
clearedFogThe same fog, pushed far enough away to be invisible.
createSkyTextureBuild the equirectangular background texture for a gradient sky.
createThreeAudioWire an audio director to a three.js AudioListener (the one on your camera). Remember port.resume() from a user gesture.
createThreeEnvironmentWire an environment director to a three.js scene.
createThreeWorldSensingWire a world-sensing director to a three.js renderer.
deepEqualsStructural equality for the plain-data specs.
gradientColourAtThe colour at a height up the sphere.
gradientPixelsRGBA bytes for the gradient, nadir row first. Pure - no three.js, no DOM.
interpolateEnvironmentBlend two resolved environments. t is the EASED progress in 0..1; the director has already applied the curve, so this stays linear per channel.
keyFromEstimateThe primary light of an estimate, or null when there is not one.
lerpLinear interpolation. t is NOT clamped - the callers already clamp it.
lerpRgbComponent-wise colour interpolation.
resolveEasingResolve an easing name or function to a function. Unknown names are linear.
resolveLightEstimation-
resolveWorldDetection-
rgbEqualsTrue when two colours are identical channel for channel.
rgbFromHex0xff8800 -> [1, 0.533…, 0]. Convenience for authoring presets.
rgbToHex[1, 0.533…, 0] -> 0xff8800. The inverse, for adapters that want a hex.
skyMixThe blend weight for bottom -> top at a given height up the sphere.
toEstimatedLightingOne estimate, as the slots it is allowed to take over.
unsupportedThe report a feature has before any adapter has said otherwise.
worldEntryChangedHas this entry changed since the last one with the same id?