WebXr
VR and AR sessions from C#: ask whether a headset is there, start a session, read where the user's head and controllers are, and hear about every trigger pull - while the rendering stays in WebGL where it belongs.
@inject Bit.Butil.WebXr webXrMDN reference
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.
@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);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.
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 blackButil 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.
{
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
}
}
}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.
{
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)}");
}
}
}API reference
ValueTask<bool> IsSupported()ValueTask<bool> IsSessionSupported(XrSessionMode mode)ValueTask<XrSessionHandle?> RequestSession(XrSessionMode mode, XrSessionOptions? options = null, Action? onEnd = null, Action<XrInputEvent>? onInput = null, Action<XrPose>? onPose = null)RequiredFeatures, OptionalFeatures, ReferenceSpaceType, PoseIntervalMsXrReferenceSpaceType ReferenceSpaceTypeValueTask<bool> AttachCanvas(ElementReference canvas)ValueTask<XrPose?> GetViewerPose()ValueTask<XrInputSource[]> GetInputSources()ValueTask DisposeAsync()Transform, EmulatedPosition, ViewsEye, Transform, ProjectionMatrixHandedness, TargetRayMode, Profiles, HasGamepad, HasGripSpaceType, Handedness, TargetRayMode