OpenXR VR

MGDF gives a module D3D12 eye targets, headset poses, controller input, and optional VR features. Players need an active OpenXR runtime, such as Meta Horizon Link or SteamVR. Modules use MGDF types and calls. They need no OpenXR SDK.

First implement the D3D12 frame contract. A VR module must render both eyes when requested. It must also draw the game window on every frame, including while VR is off or paused.

Enable and disable VR

Opt in through MGDFGraphicsRequirements::VR. A zero-initialized requirements structure keeps VR unavailable. Set VR to TRUE only when the module can render both eyes.

extern "C" __declspec(dllexport) HRESULT
GetGraphicsRequirements(MGDFGraphicsRequirements *requirements) {
  if (!requirements) return E_POINTER;
  *requirements = {};
  requirements->MinFeatureLevel = D3D_FEATURE_LEVEL_11_0;
  requirements->MinShaderModel = D3D_SHADER_MODEL_6_0;
  requirements->VR = TRUE;
  return S_OK;
}

Read GetVRSupport() on IMGDFRenderSettingsManager before showing a VR option. It returns FALSE if the module did not opt in or no active OpenXR runtime supports D3D12. TRUE does not mean that a headset is connected.

Create an IMGDFPendingRenderSettingsChange, call SetVR(TRUE) or SetVR(FALSE), then release the change. SetVR returns TRUE when it accepts the request. It returns FALSE if VR is unavailable. Acceptance does not guarantee that a headset session can start.

BOOL RequestVR(IMGDFCommonHost *host, BOOL enabled) {
  MGDF::ComObject<IMGDFRenderSettingsManager> settings;
  host->GetRenderSettings(settings.Assign());
  MGDF::ComObject<IMGDFPendingRenderSettingsChange> change;
  settings->CreatePendingSettingsChange(change.Assign());
  return change->SetVR(enabled);
} // Releasing change applies the request.

A VR change calls RTBeforeBackBufferChange and RTBackBufferChange. Release eye resources before the change and size them again after it. If the graphics adapter changes, release device resources in RTBeforeDeviceReset, then call QueueDeviceReset when all old device references are released.

The host.vr preference defaults to 0. Start core.exe -vr to request VR for one run without changing the saved value. A module that did not opt in ignores both the preference and the flag.

GetVR() stays TRUE while a headset session exists, including while the runtime pauses it. During a pause, draw the window. GetVRFrame returns S_FALSE, Focused is FALSE, and EyeCount is zero. Eye rendering resumes with the same targets and no back buffer change. Pause headset input while Focused is FALSE, but continue to render requested frames.

If the headset is unavailable, startup fails, or the runtime exits or loses the session, VR ends and the game continues in the window. The saved preference stays unchanged. Call SetVR(TRUE) again to retry when the headset is available.

Render each frame

  1. During RTDraw, call IMGDFRenderHost::GetVRFrame(&frame). Render both eyes only when the result is S_OK. S_FALSE is a success code but has no eye frame.
  2. Read MGDFVRFrame. Eyes[0] is the left eye. Eyes[1] is the right eye. Use each eye's pose, field of view, dimensions, and format.
  3. Call IMGDFRenderHost::GetVREyeTarget(index, &target) for each eye. It returns an owned ID3D12Resource reference. Create a render target view with the eye's Format. Write linear color to an sRGB target.
  4. Render the eyes. Each eye target starts in D3D12_RESOURCE_STATE_RENDER_TARGET. Leave it in that state when the commands finish.
  5. Execute eye commands on the direct queue from GetDirectQueue before RTDraw returns. Join any eye work on other queues to that queue.
  6. Draw the window on every frame. Its back buffer must finish in D3D12_RESOURCE_STATE_PRESENT.

Release each target reference when it is no longer needed. Eye images rotate until the next back buffer change. You can cache descriptors by resource. Release all eye references and cached descriptors in RTBeforeBackBufferChange.

Call GetVRFrame in RTBackBufferChange to size resources. With a session, it returns S_FALSE and supplies the eye target sizes and formats. Outside an eye frame, EyeCount is zero and poses and FOV angles are zero.

ResultGetVRFrameGetVREyeTarget
S_OKTwo eyes need rendering in this RTDraw.An owned eye target reference is available.
S_FALSEA session exists but no eye frame is available.Not returned.
E_INVALIDARGThe output pointer is null.The eye index is invalid or the output pointer is null.
E_ILLEGAL_METHOD_CALLNo session exists or the call is on the wrong thread.No session exists, no eye frame is active, or the call is on the wrong thread.
E_FAILA runtime failure occurred.

GetVRFrame zeroes its output on error. GetVREyeTarget sets its output to null on error.

Poses, floor height, and history

Eye poses use LOCAL space at this frame's predicted display time. Positions are in metres, with +X right, +Y up, and +Z forward. Quaternions use x, y, z, w order. FOV angles are in radians. Left and down angles are negative.

Use MGDFVRFrame::FloorHeight only when FloorHeightValid is TRUE. It gives floor Y in LOCAL space at the eye display time. Subtract it from a LOCAL head Y to get height above the floor. The flag is FALSE without LOCAL_FLOOR or STAGE support, or when the floor pose is invalid.

Compare PoseEpoch with the last rendered eye frame. Reset temporal history for both eyes when it changes. The counter advances when a session starts and on the first frame at or after a LOCAL or floor space change, such as recentring. It also advances on frames with no eye output. Keep the comparison with the last rendered eye frame when you skip frames.

Grip, Aim, and Gaze contain poses at the eye display time. Index Grip and Aim with MGDFVRHand. Check PositionValid and OrientationValid separately. Invalid components are zero. Aim and gaze point along the pose's rotated +Z axis.

Optional features

MGDFVRFrame::Features is a bit set of MGDFVRFeature values: MGDF_VR_FEATURE_MASK, MGDF_VR_FEATURE_DEPTH, MGDF_VR_FEATURE_PANEL, and MGDF_VR_FEATURE_GAZE. Check these flags before using optional data. Individual calls can still fail. Gaze is invalid without eye tracking or a current sample.

Hidden-area masks

Read MaskVersion on the render thread. Reload both masks when it changes, including after session or eye target recreation. It is zero without mask support.

Call GetVREyeMask(index, &vertexCount, nullptr, &indexCount, nullptr) to get the required counts. Allocate two floats per vertex and one UINT32 per index. Pass those capacities and buffers to a second call. The index buffer is a triangle list.

The mesh covers the hidden area. Use it to exclude that area from scene rendering. Its triangles are counter-clockwise in coordinates with x right and y up. Vertices are view-angle tangents at z=+1. Map them to normalized device coordinates with the eye's FOV angles:

ndcX = (2 * x - (tan(eye.AngleRight) + tan(eye.AngleLeft))) /
       (tan(eye.AngleRight) - tan(eye.AngleLeft));
ndcY = (2 * y - (tan(eye.AngleUp) + tan(eye.AngleDown))) /
       (tan(eye.AngleUp) - tan(eye.AngleDown));

S_OK returns a mesh. S_FALSE returns an empty mesh and zero counts. E_NOTIMPL means mask support is unavailable. E_INVALIDARG means an invalid index, null count pointer, or insufficient capacity. With insufficient capacity, the counts still report the required sizes. Other errors zero the counts. No session or the wrong thread returns E_ILLEGAL_METHOD_CALL. Runtime failure returns E_FAIL.

Reprojection depth

MGDFVREye::DepthFormat describes the optional depth target. DXGI_FORMAT_UNKNOWN means there is no depth target. During RTDraw, call GetVREyeDepthTarget to get an owned reference with the eye's dimensions. Use DepthFormat to create the depth view.

The image starts in D3D12_RESOURCE_STATE_DEPTH_WRITE. Leave it in that state. Submit depth work on the direct queue before RTDraw returns. Release depth references and cached descriptors in RTBeforeBackBufferChange.

After writing depth, call SetVREyeDepthRange(index, nearZ, farZ) for both eyes in that frame. No depth is submitted unless both calls succeed. The distances are in metres at depth values 0 and 1. For reverse-Z, pass the distant plane as nearZ and the close plane as farZ. Both must be positive and different. Either can be positive infinity.

Depth calls return S_OK, E_NOTIMPL without depth support, or E_INVALIDARG for an invalid index, range, or target output pointer. They return E_ILLEGAL_METHOD_CALL without a session, outside an eye frame, or on the wrong thread. Runtime failure returns E_FAIL; the target getter also uses it for resource failure. Target outputs are null on error.

Headset panel

Call SetVRPanel(&panel) on the render thread with one MGDFVRPanel. Supply pixel dimensions, a pose, size in metres, and MGDF_VR_SPACE_LOCAL or MGDF_VR_SPACE_VIEW. LOCAL stays in the room. VIEW follows the head. Positive z puts the panel in front of the user. The pose uses the eye coordinate and quaternion conventions.

The call copies the description. Changes apply on the next eye frame. SetVRPanel(nullptr) hides the panel immediately. A size change invalidates old panel images at the next RTDraw. Release their references and cached descriptors by the current RTAfterPresent. Release them also in RTBeforeBackBufferChange.

Call GetVRPanelTarget during RTDraw to get an owned reference to the current image. Use MGDFVREye::Format and write linear color with premultiplied alpha. Transparent pixels must have zero color. The image starts and must finish in D3D12_RESOURCE_STATE_RENDER_TARGET. Submit panel work on the direct queue before RTDraw returns. The panel displays over the eye projection.

Panel calls return S_OK, E_NOTIMPL without panel support, E_INVALIDARG for invalid inputs, E_ILLEGAL_METHOD_CALL without a session or on the wrong thread, or E_FAIL on runtime failure. GetVRPanelTarget also requires an eye frame. It returns S_FALSE while hidden or before the first panel frame, and E_FAIL if deferred image creation failed. Its output is null except on S_OK.

Controllers, vibration, and gaze

On the simulation thread, use IMGDFInputManager. GetVRControllerCount() always returns 2, including while VR is off. GetVRControllers(controllers) fills two owned IMGDFVRController references in left and right hand order. Release them when done. You can keep these objects across session changes.

  • GetHand identifies the hand. IsConnected requires a connection and headset input focus. GetProfile reports Touch, Index, Vive, WMR, simple, other, or none.
  • IsButtonDown and IsButtonUp read button state. IsButtonPress returns TRUE once after a button is pressed and released. A press observed between sim updates is kept for the next update. IsTouched uses the separate MGDFVRTouch enum.
  • GetTrigger and GetSqueeze return 0 to 1. GetThumbX and GetThumbY return -1 to 1. Positive X is right. Positive Y is up, which differs from IMGDFGamepad. Vive uses its trackpad.
  • GetGripPose and GetAimPose return LOCAL poses for the current sim sample. Check both validity flags.

Missing controls are zero. Without input focus, a connection, or a session, buttons are up, analog values are zero, the profile is none, and poses are invalid. All controller calls are safe while VR is off.

SetVibration(amplitude) stores a level until changed. Values are clamped to 0 to 1. NaN means zero. Set zero to stop. Output stops when focus, connection, or the session is lost. It resumes at the stored level when they return. Set zero while inactive to prevent vibration from resuming.

IMGDFInputManager::GetVRGaze(&pose) returns TRUE only for a valid gaze sample in the current sim update. Without eye tracking, focus, a session, or a current sample, it returns FALSE and zeroes the output. It is safe while VR is off. On the render thread, use MGDFVRFrame::Grip, Aim, and Gaze for poses at the eye display time.

Upscaling, frame generation, and low latency

For DLSS, set View to the eye index in both MGDFFrameConstants and MGDFUpscaleDesc. Call SetFrameConstants once for each used view in each frame. Then call Upscale for that view. Views 0 and 1 have separate histories and work with or without VR. Either view can be omitted from a frame. Set ResetHistory when PoseEpoch changes.

Frame generation is off in VR, and GetMaxGeneratedFrames() returns 0. Low latency sleep and frame markers have no effect in VR. Saved frame generation and low latency preferences stay unchanged and apply again when VR ends. See Upscaling and frame generation for input resources and command list rules.