A research & development post from Matt, Yipikiyay's Technical Director
I had some time the other day to start building out a game mechanic I've wanted to put together. The idea is to trigger certain events based on the number of times you've been and/or looked into a room. It turned out to be really fun to write out so I wanted to share the steps taken to get it working here.
First things first; setting up a basic scene with a few walls, a floor, some light and a first person controller. (I've started using the Easy Character Movement plugin as a good starting point for character controllers.)
The scene I've made is a true work of art, it's absolutely beautiful.
This challenge can be split up into two tasks, player presence detection & line of sight detection. Presence detection is easier so I went for that first, treating it as the warm up.
This can be simply done using a trigger collider in the shape of the room. We know the character controller is going to need a collider on it so there will always be something to find collisions with.
Instead of making the script that does this specific to rooms, we may want to know about the player (or another object) being in other zones at some point too, so let's keep this script nice and generic and write one which just notifies when a configurable collider has entered/exited the trigger using Unity Events.
public class TriggerStateChangedEvent : UnityEvent<bool> { }
public TriggerStateChangedEvent TriggerStateChanged;
public string tagFilter;
private void OnTriggerEnter(Collider collider)
{
if (string.IsNullOrEmpty(tagFilter) || collider.tag == tagFilter)
{
TriggerStateChanged?.Invoke(true);
}
}
private void OnTriggerExit(Collider collider)
{
if(string.IsNullOrEmpty(tagFilter) || collider.tag == tagFilter)
{
TriggerStateChanged?.Invoke(false);
}
}
Here you can see that we let a blank tagFilter
variable act as a wildcard triggering the events for any collider, or you can set this value to filter which colliders this script looks for. We might want to change this to allow for multiple filters or to use layers going forwards, but let's try and leave it as simple as possible until we find a need to change it.
Next I created a room script which will be the place we store the current state of each room as we detect changes. For now this just adds a listener to the unity event on the detector script and toggles a bool so we can see it working in the editor. Simples
A neat little trick here is that you can add multiple colliders to one object. So we can fill an odd shaped room with multiple box colliders. However this introduces a small issue. Notice in the gif below how the HasPlayerInside
value is now changing incorrectly.
This is because now we have multiple colliders, our detection script is correctly running the OnTriggerExit
method when we exit the first one but it doesn't account for the fact we have actually entered the second collider, so it notifies that as an exit.
That's not what we want, if we have multiple colliders attached we want to treat them as one large combined collider. To solve this we can update the script to keep track of the number of enters and exits we hear, then only invoking the unity event when this count reaches 0 or it no longer equals.
public class TriggerStateChangedEvent : UnityEvent<bool> { }
public TriggerStateChangedEvent TriggerStateChanged;
public string tagFilter;
private int triggersInside = 0;
private void OnTriggerEnter(Collider collider)
{
if (string.IsNullOrEmpty(tagFilter) || collider.tag == tagFilter)
{
triggersInside++;
if (triggersInside == 1)
TriggerStateChanged?.Invoke(true);
}
}
private void OnTriggerExit(Collider collider)
{
if(string.IsNullOrEmpty(tagFilter) || collider.tag == tagFilter)
{
triggersInside--;
if (triggersInside == 0)
TriggerStateChanged?.Invoke(false);
}
}
Nice. Warm up complete, so let's move onto the next problem.
Unity Monobehaviours have special methods that get called by the engine at certain times. Two of which are:
OnBecameVisible
OnBecameInvisible
These sound like a nice quick starting point to test out, however there are a few problems:
So Unity isn't giving us any shortcuts, we're going to have to write it out ourselves.
The first question we should think about is what is line of sight into a room? It's an unobstructed straight line from our eyes (the game camera) to any of the room's entrances. We can write a script to check for that that's generic just like in part 1, so we could reuse it for other situations going forwards. It would also be neat to make it work with collider bounds instead of specific renderers, so it works completely outside of the rendering system.
I created a NotifyOnVisible
script and attached it, as well as a box collider, to a new GameObject in the scene. The box collider was setup up to match the shape of the rooms entranceway, then it's time to get to work in the script.
To check for this line of sight we're going to have to start using linecasts to the target. However linecasts are (relatively) expensive from a performance perspective so let's try to avoid doing them if we can. If our collider isn't even in the camera's frustum we don't need to linecast at all, so let's first check for that.
private void Update()
{
if (_camera != null)
{
for (int i = 0; i < colliders.Length; i++)
{
bounds[i] = colliders[i].bounds;
}
Plane[] frustumPlanes = GeometryUtility.CalculateFrustumPlanes(_camera);
List<Bounds> boundsInFrustum = new List<Bounds>();
for(int i = 0; i < bounds.Length; i++)
{
if(GeometryUtility.TestPlanesAABB(frustumPlanes, bounds[i]))
{
boundsInFrustum.Add(bounds[i]);
}
}
}
}
Assuming colliders
is a collection of the colliders we want to check against, like before in part 1 that can be multiple colliders on the same object. In this snippet of code we're getting the camera's frustum using CalculateFrustumPlanes
method then checking if our colliders bounds are within it using the GeometryUtility.TestPlanesAABB
method. If they are we pop the bounds into a new list. So by the time this has run we have a filtered list containing only the bounds within our frustum.
I hadn't used CalculateFrustumPlanes
or TestPlanesAABB
very frequently before, so ran a quick profile check to see how much of an impact they had to the execution time of the frame. TestPlanesAABB
seemed fine but grabbing the camera's frustum seemed to have a bit of a hit every time it was called. As we could have many of these detection scripts throughout the scene running this same code, and given that each one would be calculating the same result in the same frame, we could get a bit of a saving if we cached that value every frame.
This could be done either by setting up a manager for all of these detection scripts, which gets the frustum and passes it into the detection script every frame. Or through a static method with a private cached result for that frame. I opted for the latter as it's more flexible than needing an object in the scene that holds reference to all detectors. I called my version of the class GeometryUtilityExtra
and added the following:
using UnityEngine;
public class GeometryUtilityExtra
{
private static int cachedFrame;
private static Plane[] planes;
public static Plane[] CalculateFrustumPlanes(Camera _camera)
{
if (planes == null || cachedFrame != Time.frameCount)
{
cachedFrame = Time.frameCount;
planes = GeometryUtility.CalculateFrustumPlanes(_camera);
}
return planes;
}
}
As you can see, regardless of how many times this is called and no matter where from, the GeometryUtility.CalculateFrustumPlanes
method will only be called at max once per frame now. Sweet, that helps keep this thing scalable in much larger scenes.
Back to the main task, we now know all of the bounds that are possibly visible, let's now linecast to that object to check if line of sight is obstructed.
bool _isVisible = false;
foreach (Bounds bound in boundsInFrustum)
{
if(!Physics.Linecast(origin, bound.center, Physics.DefaultRaycastLayers, QueryTriggerInteraction.Ignore))
{
_isVisible = true;
break;
}
}
We can break from the loop if any of the colliders have no collisions on the linecast as we only need to be able to see to one of the rooms entrances to know we can see into the room.
Now by the end of the update method, we will know if the bounds are in our line of sight by checking the _isVisible
value. I also started adding Debug.DrawLine
for any linecasts made so we can see what's happening in the editor, changing it's colour depending on the outcome.
Not bad, but we're only currently only checking one line to the center of our collider which won't work if something partially covers the entrance but doesn't cover the edges. Let's add some additional checks to linecast to a number of points on our collider, lets go for the center point still as well as each corner.
bool _isVisible = false;
foreach (Bounds bound in boundsInFrustum)
{
Vector3[] lineCastTargets = new Vector3[5]
{
bound.center,
new Vector3(bound.center.x, bound.center.y - bound.extents.y, bound.center.z + bound.extents.z),
new Vector3(bound.center.x, bound.center.y + bound.extents.y, bound.center.z + bound.extents.z),
new Vector3(bound.center.x, bound.center.y - bound.extents.y, bound.center.z - bound.extents.z),
new Vector3(bound.center.x, bound.center.y + bound.extents.y, bound.center.z - bound.extents.z)
}
for (int i = 0; i < lineCastTargets.Length; i++)
{
if(!Physics.Linecast(origin, lineCastTargets[i], Physics.DefaultRaycastLayers, QueryTriggerInteraction.Ignore))
{
_isVisible = true;
break;
}
}
if(_isVisible) break;
}
Now is when I have to admit a bit of a brain fart while doing this. Due to how I had to collider rotated I started dealing with Y & Z and not X & Y.
It makes no difference other than our brains being trained to expect X & Y so I rolled with it. In hindsight I should have just changed the rotation of the collider and not been so special.
Now we're checking the center and each corner of the collider, that should be ok right? Well maybe. It will depend on the geometry and sorts of angles the player gets to in your scene, for my scene however it's not enough.
You can see from the scene view in that gif the depth of the doorway alone causes these issues in my setup. So let's move this up a level and allow for a configurable amount of points to be line cast within the collider bounds. That way we can control the level of fidelity this system should use per entrance.
It made sense to me the most likely line of sight was going to be straight to the middle of the entrance. So I thought if we're going to be testing loads of points, and want to break out of that as early as possible, let's start at the center and work outwards.
Based on having the center and extremities of the bounds, the maths for working this out is some good old fun GCSE maths, linear equations. Remember, we only need 1 of those lines to not be obstructed to know we have line of sight, so once we find 1 we can just break and stop processing that room.
The formula to calculate the Y coordinate for any given X (or in our case Z) coordinate on our line of one corner to another is y = mx + b
(but swap X for Z).
We need to work out m (the gradient) which we can do by dividing the change in y values by the change in x values. Assuming we're looking at the line running through the center point to the top right corner, in our code that equates to:
float m = bound.extents.y / bound.extents.z
b
can be worked if we pop the values of a known point on our line into the formula now we know m
.
Let's do that with the center point:
// y = mx + b
// bound.center.y = m * bound.center.z + b;
// rearranges to:
float b = bound.center.y - (m * bound.center.z)
Now we have m
& b
(and we can run the same process to get them for the inverse line on the other corners) we can work out any point along those lines. So we can now create a loop which uses a step value indicating how much distance we want between each point in the Z axis, then run the same linecast code written above.
I decided to also add in points along the horizontal axis too.
That's looking much more accurate - and as it's configurable based on the step value, it's much more flexible too.
One final optimisation step I added was to make sure the points within the collider bounds we've calculated were all within the camera's frustum before marking them as needing a linecast. Our original frustum check only picked up if the whole bounds were outside the frustum, but we could save a lot of linecasts if only a very small section of the bounds are in view.
For this we could use the handy TestPlanesAABB method again. The only downside to this is it only takes a bounds and we only have a Vector3 point. I wrote a method for this check which instantiates a new bounds without a size at the requested point.
private bool CheckWithinFrustum(Plane[] cameraFrustum, Vector3 vector)
{
return GeometryUtility.TestPlanesAABB(cameraFrustum, new Bounds(vector, Vector3.zero));
}
This worked and I was feeling lazy at the time so felt ok with it, however it does add extra allocation that's not actually necessary. This could be improved by using the raw maths to work this out instead of the handy helper function.
This was actually my second thought, my first involved using a method on the camera which converts a Vector3 from world space to 2d viewport space.
Vector3 pointInViewport = _camera.WorldToViewportPoint(vector);
return pointInViewport.x >= 0 && pointInViewport.x <= 1 && pointInViewport.y >= 0 && pointInViewport.y <= 1;
This however is actually quite a slow method, and it turns out using the TestPlanesAABB
method is much faster, so ended up being the approach I used.
And there we go. We now have a system which can accurately tell us if we have a clear line of sight to a shape in a dynamic scene. It's careful not to do any more processing than needed and has a controllable fidelity value.
I also added a way to toggle if it should run it's processing or not, which when combined with that fact we know which room a user is in means we can only let this code execute when you're in an adjacent room (or one we know line of sight might be possible).
I hooked this into the room script and made the room toggle it's contents everytime the user looked away to prove this all worked.
As a user I never saw the room change and the scene setup is all left 100% dynamic, which is exactly what we were after.
Now to start building the game to go with this mechanic...
Until next time. Yipikiyay!
by Matt Murton.