Constrain a GameObject's movement to an orthographic camera's viewport bounds

(I’m new here – and to Unity itself – please be gentle.)

I’ve got a simple orthographic camera projecting at a quad (the “ground”) and a cube (my character). I’d like to have the character move randomly, but only within the bounds of the camera’s projection.

Here’s how I’m doing it now:

void Start()
{
	Ray topRightRay = Camera.main.ViewportPointToRay(Vector3.one);
	Ray bottomLeftRay = Camera.main.ViewportPointToRay(Vector3.zero);

	int groundLayerMask = 1 << LayerMask.NameToLayer("Ground");

	RaycastHit topRightRayHit;
	RaycastHit bottomLeftRayHit;

	if(Physics.Raycast(topRightRay, out topRightRayHit, Mathf.Infinity, groundLayerMask)
			&& Physics.Raycast(bottomLeftRay, out bottomLeftRayHit, Mathf.Infinity, groundLayerMask))
	{
		cameraRect = Rect.MinMaxRect(bottomLeftRayHit.point.x, topRightRayHit.point.z, topRightRayHit.point.x, bottomLeftRayHit.point.z);
	}
}

void Update()
{
	Debug.DrawLine (new Vector3(cameraRect.xMin, 0.5f, cameraRect.yMin), new Vector3(cameraRect.xMin, 0.5f, cameraRect.yMax), Color.red);
	Debug.DrawLine (new Vector3(cameraRect.xMax, 0.5f, cameraRect.yMin), new Vector3(cameraRect.xMax, 0.5f, cameraRect.yMax), Color.yellow);
	Debug.DrawLine (new Vector3(cameraRect.xMin, 0.5f, cameraRect.yMin), new Vector3(cameraRect.xMax, 0.5f, cameraRect.yMin), Color.blue);
	Debug.DrawLine (new Vector3(cameraRect.xMin, 0.5f, cameraRect.yMax), new Vector3(cameraRect.xMax, 0.5f, cameraRect.yMax), Color.green);
}

This seems to work just fine when the camera is not rotated around the Y axis (e.g., if its rotation is set at (30, 0, 0)), creating colorful lines that represent the camera (and, hence, game world) boundaries for my character, projected against the “Ground” quad. It looks like this:

However, once I change the camera’s Y axis rotation to (30, 45, 0), my bounds get very small. It looks like this:

(ugh, apologies for the green-on-green, but it’s a very thin rectangle, in case it’s hard to tell)

I tried to do a rotation calculation (multiplied against each line) against a 45-degree rotation of the Y axis, but that seemed to just rotate the very small box:

mCameraRotation = Quaternion.AngleAxis(45, Vector3.up);
mBottomLeft = mCameraRotation * (new Vector3(cameraRect.xMin, 0.5f, cameraRect.yMin));
// ...

Is there any hope for me?

I see you are using Viewport points to construct the ray for your rect. To make your code work, you’d need to re-initialize your cameraRect with a new Raycast each time the camera is moved or rotated. You don’t really need to do the Raycast. You can just do something like this in Update():

Vector3 pos = Camera.main.WorldToViewportPoint(transform.position);
pos.x = Mathf.Clamp01(pos.x);
pos.y = Mathf.Clamp01(pos.y);
transform.position = Camera.main.ViewportToWorldPoint(pos);

Note this clamps the pivot point, so 1/2 of your cube can leave the screen. If you want the entire cube on the screen, you’ll have to figure a more restrictive clamping:

Vector3 pos = Camera.main.WorldToViewportPoint(transform.position);
pos.x = Mathf.Clamp(pos.x, 0.07f, 0.93f);
pos.y = Mathf.Clamp01(pos.y, , 0.07f, 0.93f);
transform.position = Camera.main.ViewportToWorldPoint(pos);

I’m going to answer my own question, in case it helps other people.

It turned out that I was on the right track, but just didn’t go far enough. I needed to raycast to the other two screen edges (i.e., the top left and bottom right) to get the full viewport dimensions. Then I created a new class called GameBounds that simply encapsulates 4 Vector objects representing these corners (I tried doing this with both a Rect and a Bounds object, but found it difficult since a Rect doesn’t allow you to define verticies and there was a bit less math involved with defining these verticies statically versus deriving them from a Bounds object).

Anyhow… here’s my code (VisibleArea is a C# property of my GameBounds class):

Ray bottomLeftRay = Camera.main.ViewportPointToRay(Vector3.zero);
Ray topLeftRay = Camera.main.ViewportPointToRay(new Vector3(0, 1, 0));
Ray bottomRightRay = Camera.main.ViewportPointToRay(new Vector3(1, 0, 0));
Ray topRightRay = Camera.main.ViewportPointToRay(Vector3.one);

int groundLayerMask = 1 << LayerMask.NameToLayer(GROUND_LAYER);

RaycastHit bottomLeftRayHit;
RaycastHit topLeftRayHit;
RaycastHit bottomRightRayHit;
RaycastHit topRightRayHit;

if(Physics.Raycast(bottomLeftRay, out bottomLeftRayHit, Mathf.Infinity, groundLayerMask)
		&& Physics.Raycast(topLeftRay, out topLeftRayHit, Mathf.Infinity, groundLayerMask)
		&& Physics.Raycast(bottomRightRay, out bottomRightRayHit, Mathf.Infinity, groundLayerMask)
		&& Physics.Raycast(topRightRay, out topRightRayHit, Mathf.Infinity, groundLayerMask))
{
	VisibleArea = new GameBounds(bottomLeftRayHit.point, topLeftRayHit.point, bottomRightRayHit.point, 
			topRightRayHit.point);
}

To get my cube wandering around, randomly, within the bounds of the game world, I just take half the bounds in the X and Z axes, and keep my Y axis as-is (so my cube always sits on the ground):

float xPos = Random.Range(VisibleArea.BottomLeft.x / 2.0f, VisibleArea.TopRight.x / 2.0f);
float zPos = Random.Range(VisibleArea.BottomRight.z / 2.0f, VisibleArea.TopLeft.z / 2.0f);

It’s probably not the most efficient way to go about this, but it’s working for me!

I had a very similar problem recently. I would recommend setting up colliders (on empty objects) outside of the camera view and adding a force (with a z and x components) when the level loads(this can be done with the awake function. Of course, you would put a material with a bounciness of 1 on the colliders and eliminate drag and friction

static public Vector2 ConstrainRect(Bounds screen, Bounds map)
{
return ConstrainRect(screen.min, screen.max, map.min, map.max);
}
static public Vector2 ConstrainRect(Vector2 minScreen, Vector2 maxScreen, Vector2 minMap, Vector2 maxMap)
{
var offset = Vector2.zero;
var screenWidth = maxScreen.x - minScreen.x;
var screenHeight = maxScreen.y - minScreen.y;
var mapWidth = maxMap.x - minMap.x;
var mapHeight = maxMap.y - minMap.y;
if (screenWidth > mapWidth)
{
var diff = screenWidth - mapWidth;
minMap.x -= diff;
maxMap.x += diff;
}
if (screenHeight > mapHeight)
{
var diff = screenHeight - mapHeight;
minMap.y -= diff;
maxMap.y += diff;
}
if (minScreen.x < minMap.x) offset.x += minMap.x - minScreen.x;
if (maxScreen.x > maxMap.x) offset.x -= maxScreen.x - maxMap.x;
if (minScreen.y < minMap.y) offset.y += minMap.y - minScreen.y;
if (maxScreen.y > maxMap.y) offset.y -= maxScreen.y - maxMap.y;
return offset;
}