loading

Is there a device?

IsSupported / IsSessionSupported

IsSupported only says the API exists. IsSessionSupported is the question worth asking before showing an 'enter VR' button - and it needs no user gesture, so it is safe on page load. Inline sessions work without any headset at all, which makes them the sensible fallback.

C#
@inject Bit.Butil.WebXr webXr

var isSupported = await webXr.IsSupported();

var canVr = await webXr.IsSessionSupported(XrSessionMode.ImmersiveVr);
var canAr = await webXr.IsSessionSupported(XrSessionMode.ImmersiveAr);
var canInline = await webXr.IsSessionSupported(XrSessionMode.Inline);
Live sample
support output
Results will appear here when you interact with the samples.

Start a session

RequestSession / AttachCanvas

An immersive session needs a user gesture and refuses outright when a required feature is missing; an inline one needs neither. The handle comes back with the reference space the runtime actually granted, which may be a fallback from the one that was asked for - poses mean different things in different spaces, so it is worth checking.

C#
var session = await webXr.RequestSession(XrSessionMode.ImmersiveVr,
    new XrSessionOptions
    {
        RequiredFeatures = ["local-floor"],
        OptionalFeatures = ["hand-tracking"],
        ReferenceSpaceType = XrReferenceSpaceType.LocalFloor,
        PoseIntervalMs = 250          // 0 (the default) pushes nothing; poll GetViewerPose instead
    },
    onEnd: () => InvokeAsync(StateHasChanged),
    onInput: e => Console.WriteLine($"{e.Type} from the {e.Handedness} hand"),
    onPose: pose => Console.WriteLine($"head at {pose.Transform.Y:0.00}m"));

await session!.AttachCanvas(canvasElement);   // an immersive session with no layer shows black
Live sample
Mode
Reference space
Pose interval (250 ms, 0 = poll only)
session output
Results will appear here when you interact with the samples.

Where is the user looking?

GetViewerPose

Butil runs the session's frame loop, so a pose is always available to read - a pose otherwise exists only inside an XR frame callback. This is a snapshot for logic that runs at UI speed; drawing at headset frame rates is not what an interop boundary is for.

Razor
@code {
    private XrSessionHandle? session;   // from RequestSession

    private async Task ReadPose()
    {
        // Null between frames, and while tracking is lost - a headset that cannot see the room has
        // no pose to report rather than a stale one.
        var pose = await session!.GetViewerPose();
        if (pose is not null)
        {
            var head = pose.Transform;                 // metres, and a quaternion
            var eyes = pose.Views.Length;              // 2 on a headset, 1 inline
            var projection = pose.Views[0].ProjectionMatrix;   // 16 numbers, column-major
        }
    }
}
Live sample
pose output
Results will appear here when you interact with the samples.

Controllers, hands and gaze

GetInputSources / onInput

The input list changes as the user picks controllers up and puts them down, so read it when it matters rather than caching it. Select is 'the main button' and every device has one; squeeze is grabbing, and many devices never report it.

Razor
@code {
    private XrSessionHandle? session;   // from RequestSession

    private async Task ReadInputs()
    {
        foreach (var source in await session!.GetInputSources())
        {
            // "left"/"right"/"none", "gaze"/"tracked-pointer"/"screen", and the profile names
            // a renderer looks up to draw the right controller model
            Console.WriteLine($"{source.Handedness} {source.TargetRayMode} {string.Join(",", source.Profiles)}");
        }
    }
}
Live sample
input output
Results will appear here when you interact with the samples.
Note:
Butil stops at the rendering Session lifecycle, poses and input cross into .NET; drawing does not. A headset runs at 90 Hz or more, and marshalling every frame would cost more than the frames are worth. AttachCanvas gives the session an XRWebGLLayer to present, and what is drawn into it is WebGL code of your own.
Warning:
An immersive session needs a gesture, a device and a secure context RequestSession returns null when any of those is missing, or when a required feature is unavailable. On a machine with no headset, use Inline: it renders into a canvas in the page using the device's own sensors, and needs none of the above.

API reference

Member
Signature
Description
IsSupported
ValueTask<bool> IsSupported()
True when the runtime exposes navigator.xr.
IsSessionSupported
ValueTask<bool> IsSessionSupported(XrSessionMode mode)
Whether a session of this mode could start on this device right now. No gesture needed.
RequestSession
ValueTask<XrSessionHandle?> RequestSession(XrSessionMode mode, XrSessionOptions? options = null, Action? onEnd = null, Action<XrInputEvent>? onInput = null, Action<XrPose>? onPose = null)
Starts a session and its reference space, and begins the frame loop that keeps poses current.
XrSessionOptions
RequiredFeatures, OptionalFeatures, ReferenceSpaceType, PoseIntervalMs
What the session needs, what it would like, and how often to push poses back to .NET.
XrSessionHandle.ReferenceSpaceType
XrReferenceSpaceType ReferenceSpaceType
The reference space the runtime actually granted, which may be a fallback.
XrSessionHandle.AttachCanvas
ValueTask<bool> AttachCanvas(ElementReference canvas)
Builds an XRWebGLLayer over a fresh canvas so the session has something to present.
XrSessionHandle.GetViewerPose
ValueTask<XrPose?> GetViewerPose()
Where the user's head is, and what each eye sees. Null when tracking is lost.
XrSessionHandle.GetInputSources
ValueTask<XrInputSource[]> GetInputSources()
The controllers, hands and gaze sources the session knows about.
XrSessionHandle.DisposeAsync
ValueTask DisposeAsync()
Ends the session and gives the display back.
XrPose
Transform, EmulatedPosition, Views
The viewer's transform, whether its position is inferred, and one view per eye.
XrView
Eye, Transform, ProjectionMatrix
One eye's position and its 4x4 projection matrix, column-major.
XrInputSource
Handedness, TargetRayMode, Profiles, HasGamepad, HasGripSpace
One controller, hand or gaze source.
XrInputEvent
Type, Handedness, TargetRayMode
A select or squeeze, and which source it came from.
An unhandled error has occurred. Reload 🗙