The UI of Omnibullet is fairly standard. There is a single camera view overlooking the level with various UI panels on sides, most notably the “shop” panel on the left, which is transparent. Since the beginning, the camera origin has been centered at the center of the screen (this is the default), so centering the level object in the world has put it right into the center of the screen. But it turns out that that is not actually what is most visually pleasing, because the actual perceived play area is inset from the whole game window by the shop panel. And indeed, we have observed that the first thing people do after loading into a new level is to move the level to the right.

There are two ways to fix this.

  1. Load the camera already shifted to center the level
  2. Change the camera projection so that coordinate 0,0 actually appears off-center

The second option is superior in many ways:

  1. It works well, even when the game window is resized
  2. Zooming out fully would return us back to the original centering, fixing this would necessitate that the camera control component knows about UI
  3. The unchanged projection would still be centered differently, so the level would look uneven
Screenshot of empty Omnibullet level, level is centered between the left edge of shop UI on a left and right edge, but the camera is still in the middle of the screen, so the perspective is not centered on the level.
Level centered manually, notice the uneven gaps between railing and level floor (green), which are twice as large on the left due to off-center projection.

If you are not familiar with how perspective projection works at all, check blog post by Gabriel Felipe or video by Brendan Galea or by Pikuma. These guides often make an assumption about a symmetrical projective plane. And this is exactly the assumption we are going to break to change where the camera is centered at.

Symmetric projection (old) on the left, asymmetric projection that visually centers the level on the right

Step 1 — asymmetry, but by how much?

First, we need to know how wide the shop panel actually is. In the editor, its size is set to be exactly 188 pixels wide. But that is in reference to the ideal canvas size. How large is the canvas?

The size of the Unity Canvas game object is governed by the CanvasScaler, in our case set to “Scale With Screen Size” to reference resolution of 1920x1080 and to match width.

So the shop panel will always take up 188/1920 = 9.79% of the screen width, case closed. But that is only because very specific settings we have here and will cease to be true as soon as we break any of the assumptions. Let’s do this generally.

To get the actual UI size, we can use RectTransform.rect.width/height, no surprises there, but the size is still relative to the canvas size. How large is the canvas (which in our case fills the whole game screen)? We have multiple ways to determine that and some dead ends:

  • Screen.currentResolution returns the resolution of the actual screen, not our game window
  • Screen.width/height is the size of our game window, but in we need to know the size of the canvas
  • Similarly, nothing in Camera knows about the actual size of the Canvas
  • CanvasScaler resizes the canvas, but does not store that information
  • We have to ask the Canvas itself, through ((RectTransform)canvas.transform).rect

Now we have:

  • Game takes up 1439x657 pixels on screen
  • The shop panel is 141x657 pixels on screen and measures 188x876.6088 in the game
  • Screen.width/height reports 1439x657
  • ((RectTransform)canvas.transform).rect.width/height is 1920x876.6088
  • canvas.transform.scale is 0.749478996, which is the factor between measured pixels and values reported in game

So there are multiple ways to get the information that we need, but since we don’t actually need the exact size in pixels, only ratios, we can comfortably work with canvas coordinates: screen size is 1920x876.6088 units, and we need to center the camera ignoring the left 188 units.

Step 2 — the projection matrix

Thankfully, we don’t need to build the whole matrix, because Unity gives us a handy function for that: Matrix4x4.Frustum(left, right, bottom, top, near, far). The near and far arguments can be taken right from the Camera itself. Now we need to compute the rest.

We know the resolution and, to generalize, the insets from each side of the screen that we want to ignore for the centering and field-of-view calculation. We also know the vertical field of view, in degrees, from the Camera settings.

And some light math later, we arrive at:

public struct Inset {
	public float top, bottom, left, right;
}


private static Matrix4x4 InsetPerspectiveMatrix(
	float near,
	float far,
	float fovVertDeg,
	Vector2 resolution,
	Inset inset
	) {

	// Dimensions of the play area (screen - insets)
	float areaWidth = resolution.x - inset.left - inset.right;
	float areaHeight = resolution.y - inset.top - inset.bottom;

	// top and right as if we were drawing only the play area
	float areaTop = near * Mathf.Tan(fovVertDeg * Mathf.Deg2Rad * 0.5f);
	float areaRight = areaTop * areaWidth / areaHeight;

	// We need to rescale the insets to be able to extend areaTop/Right with them proportionally
	float insetScale = areaTop * 2f / areaHeight;

	return Matrix4x4.Frustum(
		-areaRight - inset.left * insetScale,
		areaRight + inset.right * insetScale,
		-areaTop - inset.bottom * insetScale,
		areaTop + inset.top * insetScale,
		near, far);
}

We derive the t, l, b, r values like usual (areaTop and areaRight, in symmetrical case we would be done here), but only for the “play area” (the screen size - insets). This takes care of the target FOV. Then we expand the FOV by adding appropriately scaled insets, which also fixes the aspect ratio. Now we can use the new matrix like so:

Camera camera;
Canvas canvas;

Vector2 canvasSize = ((RectTransform)canvas.transform).rect.size;

float near = camera.nearClipPlane;
float far = camera.farClipPlane;
float fovVertDeg = camera.fieldOfView;
Matrix4x4 matrix = InsetPerspectiveMatrix(near, far, fovVertDeg, canvasSize, currentInset);
camera.projectionMatrix = matrix;

And that is it!

Screenshot of a level that is right in the middle of the play area
Perfectly centered, as all levels should be.