
In this edition of How’d They Do That? I wanted to know how to replicate a classic retro 3D effect.
Hardware limitations of the early 90’s made it hard to create real 3D skyboxes. to get around this, developers instead would use a flat image that could be panned around to match the rotation of the camera, giving the illusion of a full world in the far distance.
game engines today are easily able to create stunning skyboxes that can warp with the perspective of the camera- but I feel that, if making a retro style game, it would be missing a certain charm to miss out on this nostalgic effect
so, How did they do that?
or, really, how can we do that?
We’ll break this down into 2 steps- a panning only shader, and a shader that includes roll- if you don’t need roll, no need to over complicate it!
Step 0: Setting up the project
…Okay, three steps. I’m using the Unity Shader Graph system in Unity version 2019.4.25f1, but the theory should hold up between versions and engines. First things first, we’ll set up a Unity scene to test this out.
Make sure you’re set up with URP, then create a new scene. create a Canvas with a child image. Set the Canvas’ render mode to “Screen Space – Camera”, and set the camera to the Main Camera. On the image, set the anchor preset to stretch and the margins all to 0
Add a child camera to the Main Camera, set it to an overlay camera, and set it’s culling mask to everything but UI. On the Main Camera, set the culling mask to only UI and add the child camera to the overlay camera stack
create a new shader graph based on the sprite unlit shader, create a new material based on that shader, and apply the material to the Image on the Canvas.
Then, I set up an environment to test how well the background movements fit- Now we’re all set!

Step 1: Panning Shader
First thing we need is a centered background texture. the StarFox background I’ll be using for this test was grabbed from The Spriters Resource, ripped by user SmithyGCN. Thanks, Smithy!
it’s a little off center, which we want to adjust for our shader- this kills off a couple levels of blue for the sky box, but it’s a negligible loss
Open up the sky shader, and add a new texture variable called MainTex. we’ll set the default as our adjusted background image for our preview, but we still need to populate this in the material, which we can do straight from the settings on the Image on the Canvas. You’ll now be able to see a static image for your sky – first step done!
we’ll want the ability to pan the background, so we’ll take the UVs and add a Vector2 offset.

you’ll notice that moving up and down, the green and blue stretch indefinitely- this is desired. however, we also see stretching when moving left and right, which we do not want- we want the mountains to loop when moving left and right.

to handle this, we just need to use the modulo operator on the UV’s to make sure the value is always between 0 and 1 on the horizontal axis. This will loop the mountains for us. Now, we can’t just use the modulo operator by itself, because the default modulo operator does not do well with negative values. To fix that, we’ll follow the following equation to make a modulo operation that’s consistent in both positive and negative directions:
BetterMod(a, b) = ((a%b)+b)%b

with that, we now have a proper basis for shifting our skybox around.
To get the horizontal offset, we need to know the facing direction of the camera- luckily, there’s a camera node that handles this for us. we can take the Direction output (the camera’s world forward vector) and take out just the X and Z values (which in Unity make up the flat 2D floor plane), and use the Arctangent2 function to spit out the camera’s world yaw angle in radians.

For the vertical offset, we can take the dot product between the world up and the forward to figure out the pitch. take the Arcsine (since it starts at 0 when the angle is 0), which will give us our vertical offset.
NOTE: The dot product will give us a mirrored rotation, which will be better than using Atan2 for a full angle if we want to implement rolling- and for a normal gameplay camera, we don’t need a full angle anyways since the camera should never be upside down.

Once we plug our offsets into the offset vector and save the shader, we can rotate the camera in the scene and see our 2D panning already working


But there’s an issue- there’s a bit of drift when rotating along that doesn’t seem to feel very believable. This is because we haven’t taken the actual screen size into account, which means any offset we’re doing is being stretched out
To fix this, we just need to finagle our UV’s a bit. Create a Screen node, and divide the Width by the Height. this will create a horizontal scalar value we can apply to the UV’s width, to correct the stretch and remove the drift

Furthermore, we’ll want to recenter this scaling- all we need to do is take the net difference in size in the horizontal direction, half it, and subtract that from the UVs, this will make sure that stretching or squishing the screen keeps the image in one place

We can plug this in as a replacement for the plain UV node we had- save the shader and rotate the camera again- the rotation now appears more stable than before- the little apparent drift left has more to do with the fact that the world is doing perspective warping while the background is not


This works pretty good! unless, you decide to spin the camera all the way around:

The issue here is that the value we get for our displacement is a multiple of PI- so at 180 degress, or PI radians, we have looped the image 3.14 times. when crossing the threshold, we which to -3.14 times, which aren’t going to be perfectly lined up. We can fix this by losing a bit of drift fidelity to ensure integer seams. we can normalize the angle we got for the horizontal offset by dividing by PI (putting the value between -1 and 1) and multiplying by 3. This makes it so we are CLOSE to PI to keep away drift, but still on an integer so we get better looping.

save that out and you should see the pop is no more:

If you’re making something that doesn’t need to turn all the way around of course, you can skip doing this adjustment- doesn’t hurt to keep it in!
It would be nice as well to control the scale of our image. Create a _Scale variable, save, and set the variable to whatever you want on your material as well. you’ll immediately notice an issue- our image got pulled down!
All we need to do to fix this is adjust the UV’s by another offset that takes our scale into account and recenters the image.

To calculate our recentering, we’ll find the offset as follows:

…Then add it to our UV’s before scaling the image up

Saving that, you should see that when we change the scale value, our background scales while staying centered on the horizon

This should be everything you need to finish out the Panning version of the shader, but we can add one more thing to really send it over the top: positional parallax. when moving the camera, if we don’t have a truly infinitely distant background, it can really add something to have the image move side to side or up and down with the camera.
To do this, we’ll need to expand how we’re getting our camera vectors. Make a Transform Matrix node set to Model. using this transform will give us the ability to out any vector in and out of the local space of our camera, which means we can get more than just the forward. split the matrix, and re-construct it as a 3×3 matrix- it helps to do this since all our vectors are also only 3×3, and we care about rotation and not position
Multiply the 3×3 Matrix by all 3 basic directions, giving us the camera’s transform vectors all in works space.

We can first use the World Fwd vector to replace the Direction vector we gout out of the camera node
(using the camera node for the forward, however, might be more performant than making it by hand here – this just helps keep things consistent)

To get the positional parallax, we’ll need to break it up into a vertical and horizontal component. we also need to adjust the shift based on the way the camera is facing, making sure any panning is down within movement local to the camera X and Y. we can do that by taking our world vectors and multiplying them by the camera’s position, which will give us weighted offsets relative to the camera’s facing direction.
the values we get will be HUGE in terms of UV offsets, so we’ll want to temper that by dividing the resulting offset vector down very very far. like, really very far. I have my divisor set to about 5000/10000, which gives me a good range of panning.
We can then turn this into a 2D offset by adding the X and Z values together (since they are in the same plane) and making a Vector2 with their sum and the Y axis offset.

Doing this with the camera’s world forward as well and adding the results together, we have our full positional parallax offset

we can add that into our offset calculations here

and with both together, we now have a working retro style 2D sky box!
If this is all you needed, you can stop here- but if you want the full power of a 2D sky box, we still need to allow the camera to roll, which we’ll do in the next section.
Step 2: Shader with Roll
Before we get to the implementation, we need to knowhow we can even get the roll value. as we’ve already seen, getting rotations in the shader graph is a matter of calculating them yourself – no localRotation here to just steal euler angles from.
and even then, we’d still need to apply the rotation to the UV’s and also add the correct offsets. after a LOT of trial and error, I settled on the proper way to handle this. it works like this:
- Calculate the Horizontal and Vertical Offsets, create an offset vector
- rotate the UVs, with the pivot center at the offset location
- rotate the offset vector, and add the resulting vector to the rotated UVs
Visually, that looks a little something like this:

But first, we need to actually compute the rotation.
To do this, we need to get a little creative. The forward direction of the camera should always return the same pivot point, no matter what the roll of the camera- this will make sure the image has a consistent visual no matter where the camera is pointed. with this knowledge, we know that all we need to do is get the rotation of the Y axis of the camera around it’s forward- but relative to what?
since the forward should be consistent, we can create a reference Y vector that is also consistent that we can compare the actual Y against. And to do that, we need to play around with cross products!
to get a consistent Y world Y for our camera, we’ll take the forward of the camera and cross it with the world up. no matter how the camera is rolled, this will return the same orthogonal vector between the camera forward and the world up, which will give us a right vector. we can then take the camera forward and this right vector, and cross them to get a consistent Y vector for the camera, no matter the roll. we can then put that vector into the camera’s local space, and get use arctangent2 with it’s X and Y components to get the roll angle we desire- we’ll negate is since we’re actually wanting to rotate in a way that counteracts the camera rotation

we can’t just add this to the UV’s like we had been, so we’ll need to re-adjust how we get the UVs. We’ll want to use a Rotate node, with a default pivot at (0.5, 0.5) and the screen space adjusted UVs as our input to get our rotated image. we’ll add our panning offset to the origin to get our pivot location, and rotate by the camera roll.
Then, we’ll use another Rotate node, centered at (0.0, 0.0) with our panning offset as the input and the roll as our rotation.
We’ll add the rotated offset to the rotated image, giving us our final base UVs before scaling and recentering

the final resulting graph should look something like this:

The resulting effect should look like this:

And that’s it! We’ve created a full fledged retro 2D skybox, perfect for your Doom or Starfox remake. Have fun!














