Symmetric Projection
Updated: Apr 6, 2026
Symmetric projection is a rendering optimization for Vulkan applications where the asymmetric field of view for each eye is replaced with a larger, symmetric field of view. The width of the render target is increased so that you get approximately the same number of pixels per degree. The GPU hardware is able to process vertices much faster when using multiview if the projection matrices are the same for both eyes.
While this increases the size of the render targets,
fixed foveated rendering can turn off all the tiles that are outside the original asymmetric field of view. This means for most developers, enabling symmetric projection will almost universally improve performance.
Typical performance gains range from 5-15% in GPU-bound scenarios, with the greatest benefit in geometry-heavy scenes with many draw calls. The visual difference is imperceptible in practice.
Symmetric projection is an application-side rendering pattern, not an OpenXR extension to enable. The application computes the symmetric field of view, allocates appropriately sized swapchains, and submits per-eye visible regions. The compositor handles the rest.
If your application uses post-processing effects, symmetric projection (like subsampled layout) can hurt performance because intermediate render passes will not have foveation applied. Test with and without symmetric projection when using post-processing pipelines.
On Quest devices, the left and right eyes have slightly different viewing frustums — they converge on objects at different angles. With multiview rendering, the GPU renders both eyes simultaneously by processing geometry once and replicating it across views. The GPU divides the render target into tiles, and each tile in the left eye is paired with the corresponding tile in the right eye.
With asymmetric projection, the content in a given tile of the left eye doesn’t overlap well with the content in the same tile of the right eye, because the two projection matrices shift the scene differently. This means each tile must process more unique geometry across the two views, reducing the effectiveness of multiview.
With symmetric projection, both eyes use the same projection matrix, so content from the left eye is much more likely to end up in the same tile as the corresponding content from the right eye. Each tile has less total geometry to handle, which is where the performance gain comes from.

With symmetric projection:
- The asymmetric per-eye fields of view are replaced with a single, wider symmetric field of view and the render target width is increased to maintain the same pixels per degree.
- Both eyes use an identical projection matrix, so tile content overlaps much better between views — each tile processes less geometry.
- FFR tile turn-off discards the tiles on the nasal edge that fall outside the original asymmetric field of view, reclaiming the GPU cost of the wider render target.
Step 1: Query per-eye field of view
Call xrLocateViews to get the asymmetric per-eye field of view values:
XrView views[2] = { {XR_TYPE_VIEW}, {XR_TYPE_VIEW} };
XrViewState viewState = {XR_TYPE_VIEW_STATE};
XrViewLocateInfo locateInfo = {
.type = XR_TYPE_VIEW_LOCATE_INFO,
.viewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,
.displayTime = predictedDisplayTime,
.space = appSpace,
};
uint32_t viewCount = 2;
xrLocateViews(session, &locateInfo, &viewState, 2, &viewCount, views);
Step 2: Compute the shared union field of view
The goal is to create a single field of view that both eyes can render with. Take the maximum tangent angle across both eyes for each direction — left, right, up, and down — so the resulting field of view encompasses both eyes’ visible areas. The result does not need to be literally symmetric within each axis; it just needs to be the same for both eyes:
float maxLeftTan = fmaxf(tanf(-views[0].fov.angleLeft), tanf(-views[1].fov.angleLeft));
float maxRightTan = fmaxf(tanf(views[0].fov.angleRight), tanf(views[1].fov.angleRight));
float maxUpTan = fmaxf(tanf(views[0].fov.angleUp), tanf(views[1].fov.angleUp));
float maxDownTan = fmaxf(tanf(-views[0].fov.angleDown), tanf(-views[1].fov.angleDown));
XrFovf sharedFov = {
.angleLeft = -atanf(maxLeftTan),
.angleRight = atanf(maxRightTan),
.angleUp = atanf(maxUpTan),
.angleDown = -atanf(maxDownTan),
};
Step 3: Allocate swapchains at the shared field of view size
Use the shared field of view to determine swapchain dimensions. A common approach is to scale the recommended eye texture resolution by the ratio of the shared field of view to the original per-eye field of view:
float totalHorizTan = maxLeftTan + maxRightTan;
float totalVertTan = maxUpTan + maxDownTan;
// Get the recommended dimensions from xrEnumerateViewConfigurationViews
XrViewConfigurationView configViews[2] = { {XR_TYPE_VIEW_CONFIGURATION_VIEW},
{XR_TYPE_VIEW_CONFIGURATION_VIEW} };
uint32_t viewCount = 2;
xrEnumerateViewConfigurationViews(instance, systemId,
XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, 2, &viewCount, configViews);
// Scale to shared FOV (wider to encompass both eyes)
float asymHorizTan = tanf(views[0].fov.angleRight) - tanf(views[0].fov.angleLeft);
float sharedWidth = (float)configViews[0].recommendedImageRectWidth
* (totalHorizTan / asymHorizTan);
float asymVertTan = tanf(views[0].fov.angleUp) - tanf(views[0].fov.angleDown);
float sharedHeight = (float)configViews[0].recommendedImageRectHeight
* (totalVertTan / asymVertTan);
// Quantize to multiples of 16 for GPU tile alignment
uint32_t swapchainWidth = ((uint32_t)sharedWidth + 15) & ~15;
uint32_t swapchainHeight = ((uint32_t)sharedHeight + 15) & ~15;
XrSwapchainCreateInfo createInfo = {
.type = XR_TYPE_SWAPCHAIN_CREATE_INFO,
.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT |
XR_SWAPCHAIN_USAGE_SAMPLED_BIT,
.format = vulkanColorFormat,
.sampleCount = 1,
.width = swapchainWidth,
.height = swapchainHeight,
.faceCount = 1,
.arraySize = 2, // multiview
.mipCount = 1,
};
XrSwapchain swapchain;
xrCreateSwapchain(session, &createInfo, &swapchain);
Step 4: Compute the visible rectangle for each eye
Each eye only uses a portion of the shared buffer. Compute the visible rectangle in pixels, rounding outward to ensure no visible content is clipped. Then derive the actual submitted field of view from the pixel-snapped rectangle so it matches exactly:
// Tangent-per-pixel scale factors
float tanPerPixelX = totalHorizTan / (float)swapchainWidth;
float tanPerPixelY = totalVertTan / (float)swapchainHeight;
XrRect2Di eyeRect[2];
XrFovf eyeSubmitFov[2];
for (int eye = 0; eye < 2; eye++) {
float eyeLeftTan = tanf(-views[eye].fov.angleLeft);
float eyeRightTan = tanf(views[eye].fov.angleRight);
float eyeUpTan = tanf(views[eye].fov.angleUp);
float eyeDownTan = tanf(-views[eye].fov.angleDown);
// Compute floating-point pixel coordinates
float xMin = (maxLeftTan - eyeLeftTan) / totalHorizTan * swapchainWidth;
float yMin = (maxUpTan - eyeUpTan) / totalVertTan * swapchainHeight;
float xMax = (maxLeftTan + eyeRightTan) / totalHorizTan * swapchainWidth;
float yMax = (maxUpTan + eyeDownTan) / totalVertTan * swapchainHeight;
// Round outward to nearest pixel
int32_t x0 = (int32_t)floorf(xMin);
int32_t y0 = (int32_t)floorf(yMin);
int32_t x1 = (int32_t)ceilf(xMax);
int32_t y1 = (int32_t)ceilf(yMax);
eyeRect[eye].offset.x = x0;
eyeRect[eye].offset.y = y0;
eyeRect[eye].extent.width = x1 - x0;
eyeRect[eye].extent.height = y1 - y0;
// Derive the submitted FOV from the pixel-snapped rect so it matches exactly
eyeSubmitFov[eye].angleLeft = -atanf(maxLeftTan - x0 * tanPerPixelX);
eyeSubmitFov[eye].angleRight = atanf(x1 * tanPerPixelX - maxLeftTan);
eyeSubmitFov[eye].angleUp = atanf(maxUpTan - y0 * tanPerPixelY);
eyeSubmitFov[eye].angleDown = -atanf(y1 * tanPerPixelY - maxUpTan);
}
Step 5: Render with shared projection matrix
Build a single projection matrix from the shared field of view and use it for both eyes in multiview rendering:
// Both eyes use the same projection matrix
XrMatrix4x4f projMatrix;
XrMatrix4x4f_CreateProjectionFov(&projMatrix, GRAPHICS_VULKAN,
sharedFov, nearZ, farZ);
There are two valid approaches for submitting the projection layer:
Option A: Submit the full buffer with the shared field of view. This is the simplest approach — submit the entire swapchain rectangle and the shared field of view. The compositor will correctly map the full buffer to each eye’s display:
XrCompositionLayerProjectionView projViews[2];
for (int eye = 0; eye < 2; eye++) {
projViews[eye] = (XrCompositionLayerProjectionView){
.type = XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW,
.pose = views[eye].pose,
.fov = sharedFov,
.subImage = {
.swapchain = swapchain,
.imageArrayIndex = eye,
.imageRect = {
.offset = {0, 0},
.extent = {swapchainWidth, swapchainHeight},
},
},
};
}
Option B: Submit per-eye visible rectangles with adjusted field of view. For pixel-perfect alignment between the projection layer and any overlay layers, submit each eye’s visible sub-rectangle with a field of view derived from the pixel-snapped coordinates (computed in Step 4):
XrCompositionLayerProjectionView projViews[2];
for (int eye = 0; eye < 2; eye++) {
projViews[eye] = (XrCompositionLayerProjectionView){
.type = XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW,
.pose = views[eye].pose,
.fov = eyeSubmitFov[eye],
.subImage = {
.swapchain = swapchain,
.imageArrayIndex = eye,
.imageRect = eyeRect[eye],
},
};
}
In both cases, complete the submission:
XrCompositionLayerProjection projLayer = {
.type = XR_TYPE_COMPOSITION_LAYER_PROJECTION,
.space = appSpace,
.viewCount = 2,
.views = projViews,
};
There is no visual difference between symmetric and asymmetric projection. The compositor maps the rendered buffer to the correct per-eye field of view at composition time, so the final output to the display is identical regardless of which projection mode was used.
Multiview per-view render areas
When using symmetric projection, each eye’s actual visible region is a subset of the full render target. The VK_QCOM_multiview_per_view_render_areas Vulkan extension allows you to specify a different render area for each view in a multiview render pass. This avoids shading pixels in regions that will be discarded, providing an additional GPU performance gain on top of FFR tile turn-off.
When using this extension, use the pixel-snapped eyeRect values from Step 4 (Option B) as the per-view render areas, and submit with the matching per-eye field of view:
VkRect2D perViewRenderAreas[2] = {
{ .offset = { eyeRect[0].offset.x, eyeRect[0].offset.y },
.extent = { eyeRect[0].extent.width, eyeRect[0].extent.height } },
{ .offset = { eyeRect[1].offset.x, eyeRect[1].offset.y },
.extent = { eyeRect[1].extent.width, eyeRect[1].extent.height } },
};
VkMultiviewPerViewRenderAreasRenderPassBeginInfoQCOM perViewInfo = {
.sType = VK_STRUCTURE_TYPE_MULTIVIEW_PER_VIEW_RENDER_AREAS_RENDER_PASS_BEGIN_INFO_QCOM,
.perViewRenderAreaCount = 2,
.pPerViewRenderAreas = perViewRenderAreas,
};
VkRenderPassBeginInfo renderPassBeginInfo = {
.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO,
.pNext = &perViewInfo,
.renderPass = renderPass,
.framebuffer = framebuffer,
.renderArea = { .offset = {0, 0}, .extent = {swapchainWidth, swapchainHeight} },
};
Combining with FFR and subsampled layout
- The fragment density map automatically turns off tiles on the nasal edge of each eye that fall outside the actual viewing frustum, reclaiming the GPU cost of the wider symmetric buffer.
- To enable both, chain the foveation and Vulkan swapchain create info structs together on the swapchain (see FFR implementation guide).
For optimal performance, combine symmetric projection with dynamic resolution. The XR_META_recommended_layer_resolution extension provides per-frame resolution recommendations based on current GPU utilization and thermal state:
// Construct the projection layer with symmetric FOV (as shown above)
// Then query the recommended resolution
XrRecommendedLayerResolutionGetInfoMETA getInfo = {
.type = XR_TYPE_RECOMMENDED_LAYER_RESOLUTION_GET_INFO_META,
.layer = (XrCompositionLayerBaseHeader*)&projLayer,
.predictedDisplayTime = predictedDisplayTime,
};
XrRecommendedLayerResolutionMETA recommendation = {
.type = XR_TYPE_RECOMMENDED_LAYER_RESOLUTION_META,
};
xrGetRecommendedLayerResolutionMETA(session, &getInfo, &recommendation);
if (recommendation.isValid) {
// Render to a sub-rect of the swapchain at the recommended dimensions
renderWidth = recommendation.recommendedImageDimensions.width;
renderHeight = recommendation.recommendedImageDimensions.height;
}
Allocate the swapchain at the maximum expected resolution, then render to a dynamically sized viewport each frame based on the recommendation.
- Enable symmetric projection for most applications. The performance gain typically outweighs any concerns, especially for geometry-heavy scenes.
- Quantize dimensions to multiples of 16 for optimal GPU tile alignment.
- Combine with FFR to automatically reclaim GPU cost from the wider symmetric buffer through tile turn-off.
- Combine with subsampled layout for the best visual quality in foveated peripheral regions.
- If your application has custom shader behavior that depends on precise per-eye asymmetry, profile both symmetric and asymmetric rendering to confirm the benefit.