A Quick Distraction

This video popped into my YouTube feed the other day, it describes the Wolfenstein 3D renderer in exquisite detail, and by its own merit, you should watch:

Engrossed, I decided to replicate this rendering behaviour in Unity. The idea was to avoid the mired distance calculations in the above video and cheat with Raycasts – the idea being to Raycast into a 2D scene and rebuild the scene completely in a single post-effect shader.

The first thing to do was to set up a simple C# script that would take a simple material as an input and during OnRenderImage(), apply that material to the rendered screen.

Alongside this, I need some logic to Raycast into the scene and write the depth information to a 1-pixel high render texture, which can then be passed into the shader. As I’m also simulating a low-resolution screen, this doesn’t need to be particular high resolution, so not especially taxing on the CPU

Next, this can be attached to the camera (although it would work on any object, as it discards everything rendered by the camera anyway, and some simple movement controls attached using a RigidBody2D.

In terms of scene setup, I simply tag anything I want visible with the tag “Walls” and this is what the Raycasts test against. I also calculate the world normals of each surface, in-case I want to implement lighting or anything fancy later.

Here’s a dump of all the code – as I don’t have time for a step-by-step, it should be fairly understandable:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SteinRenderer : MonoBehaviour
{

    // Public vars
    public LayerMask wallLayerMask;
    public float cameraFOV = 90.0f;
    public int coreResolution = 256;
    public RenderTexture normalsTexture;
    public RenderTexture depthTexture;
    public Material rendererRGB;

    // Re-used vars
    List<Color> Distances;
    List<Color> Normals;
    int previousResolution = 1;
    float distance = 0f;
    Vector2 direction = Vector2.zero;
    Vector2 origin = Vector2.zero;
    [HideInInspector]
    public Texture2D writeTex;
    [HideInInspector]
    public Texture2D writeTexNormal;
    Vector2 normalAdd;
    Color[] arr;
    Vector2 MouseDelta = Vector2.zero;
    public float RotateSpeed = 10.0f;
    public float MoveSpeed = 10.0f;
    Rigidbody2D rb;

    public Vector2 Rotate(Vector2 v, float degrees)
    {
        return Quaternion.Euler(0f, 0f, degrees) * v;
    }

    float Map(float s, float a1, float a2, float b1, float b2)
    {
        return b1 + (s - a1) * (b2 - b1) / (a2 - a1);
    }

    void ReinitializeDistanceList()
    {
        if (previousResolution != coreResolution)
        {
            previousResolution = coreResolution;
            Distances = new List<Color>();
            Normals = new List<Color>();
            rendererRGB.SetInt("_Resolution", previousResolution);
                
        }
    }

    Color FloatToColor(float value)
    {
        Color newColor = new Color(value, value, value, value);

        return newColor;
    }

    Color NormalsToColor(Vector2 value)
    {
        value.Normalize();
        value *= 0.5f;
        value += normalAdd;
        Color newColor = new Color(value.x, value.y, 0f, 1.0f);

        return newColor;
    }

    void AddDistance(float dist, int i)
    {
        if (Distances.Count <= i)
        {
            Distances.Add(FloatToColor(distance));
        }
        else
        {
            Distances[i] = FloatToColor(distance);
        }
    }

    void AddNormal(Vector2 normal, int i)
    {
        if (Normals.Count <= i)
        {
            Normals.Add(NormalsToColor(normal));
        }
        else
        {
            Normals[i] = NormalsToColor(normal);
        }
    }

    void Start()
    {
        
        normalAdd = new Vector2(0.5f, 0.5f);
        Distances = new List<Color>();
        ReinitializeDistanceList();
        writeTex = new Texture2D(previousResolution, 1, TextureFormat.RFloat, false, true);
        rendererRGB.SetTexture("_DepthTex", writeTex);
        arr = new Color[previousResolution];
        writeTexNormal = new Texture2D(previousResolution, 1, TextureFormat.RGBA32, false, true);
        rendererRGB.SetTexture("_NormalTex", writeTexNormal);
    }

    void Update()
    {
        MouseDelta = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"));

        origin = transform.position;

        float fovMultiplier = cameraFOV / previousResolution;

        for (int i = 0; i < previousResolution; i++)
        {
            // Render Loop
            float fovAngle = Map(fovMultiplier * i, 0f, cameraFOV, -(cameraFOV / 2f), cameraFOV / 2f);

            direction = Rotate(transform.forward, fovAngle);
            RaycastHit2D hit = Physics2D.Raycast(
                origin,
                direction,
                Mathf.Infinity,
                wallLayerMask
            );
            
            if (hit.collider != null)
            {
                distance = Vector2.Distance(origin, hit.point);
                direction = direction.normalized - (Vector2) transform.forward;
                distance = distance * Mathf.Cos(direction.magnitude);
                AddDistance(distance, i);
                AddNormal(hit.normal, i);
            }
            else
            {
                Debug.LogWarning("WARNING: RAY ESCAPED SCENE!");
                AddDistance(1000f, i);
                AddNormal(normalAdd, i);
                Debug.DrawRay(origin, direction.normalized * 1000f, Color.red);
            }
        }
        arr = Distances.ToArray();
        writeTex.SetPixels(arr);
        writeTex.Apply();

        arr = Normals.ToArray();
        writeTexNormal.SetPixels(arr);
        writeTexNormal.Apply();
    }

    void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
         Graphics.Blit(source, destination, rendererRGB);
    }
  
    void FixedUpdate()
    {
        // move camera controls
        if (rb == null)
        {
            rb = GetComponent<Rigidbody2D>();
            if (rb == null) return;
        }


        if (MouseDelta.x != 0.0f || MouseDelta.y != 0.0f)
        {
            rb.rotation += (Time.fixedDeltaTime * -RotateSpeed * MouseDelta.x);
            rb.position += ((Vector2) rb.transform.forward) * (Time.fixedDeltaTime * MoveSpeed * MouseDelta.y);
        }
    }
       
}

What I’m doing is figuring out the number of Raycasts, and their direction by dividing the intended field of view by the screen resolution (which I check for changes each frame), see line 116.

When it hits a ‘Wall’, we return the distance perpendicular to the viewing plane (line 135: distance = distance * Mathf.Cos(direction.magnitude); ). We then write that depth to the Resolution*1px render texture (i.e. 480*1px), where each pixel is an index of the Raycasts going from left-to-right.

We also grab the normal of the hit and store that in another render texture.

Then we move on to the shader!

We simple draw the top half of the screen one colour, the bottom half a different colour, then, based on the depth of the current sampled UV.x value, we know whether to the draw a wall or not (lines 111-144).

I also fake AO by making distant areas and areas near floor/ceiling intersections darker – you can take this out if you care to.

I’ve also left in the normals extraction code.

Shader "Post Process/RenderWalls"
{
    Properties
    {
		_MainTex("Main Texture", 2D) = "white" {}
		_DepthTex("_DepthTex Texture", 2D) = "white" {}
		_Resolution("Resolution", Int) = 1
		_PConstant("P", Float) = 1.0
		_WallColor("Wall Color", Color) = (1,0,0,1)
		_FloorColor("Floor Color", Color) = (0.25,0.25,0.25,1)
		_RoofColor("Roof Color", Color) = (0.75,0.75,0.75,1)
		_DistanceDarkenAmount("Darken Amount", Float) = 10.0
		_NormalTex("World Normals", 2D) = "bump" {}
		_FakeAOExp("Ao Exponent", Float) = 2.0
		_FloorReflections("Floor Reflections", Float) = 0
		_RoofReflections("Roof Reflections", Float) = 0
		[Header(Textures)]
		_TextureAtlas("Texture Atlas (RGB)", 2D) = "grey" {}
		_TextureCount("Texture Count", Int) = 4
		_Index("Tex Index", Int) = 0
    }
    SubShader
    {
        // No culling or depth
        Cull Off ZWrite Off ZTest Always

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

			sampler2D_float _DepthTex;
			sampler2D _MainTex, _NormalTex,_TextureAtlas;
			int _Resolution, _TextureCount, _Index;
			float _PConstant, _DistanceDarkenAmount, _FakeAOExp, _RoofReflections, _FloorReflections;
			float3 _WallColor, _FloorColor, _RoofColor;

			float3 ExtractAlbedoTexture(int index, float2 uv) {
				float Udivider = 1.0 / (_TextureCount / 2.0);
				float Vdivider = 1.0 / _TextureCount;
				Udivider *= 2 * index;
				Vdivider *= index;

				uv *= float2(Udivider, Vdivider);

				return tex2D(_TextureAtlas, uv).rgb;

			}

			float2 ExtractNormalTexture(int index, float2 uv) {
				float Udivider = 1.0 / (_TextureCount / 2.0);
				Udivider += Udivider;
				float Vdivider = 1.0 / _TextureCount;
				Udivider *= 2 * index;
				Vdivider *= index;

				uv *= float2(Udivider, Vdivider);

				return tex2D(_TextureAtlas, uv).rg;
			}

			float Map(float s, float a1, float a2, float b1, float b2)
			{
				return b1 + (s - a1) * (b2 - b1) / (a2 - a1);
			}

            fixed4 frag (v2f i) : SV_Target
            {
				int TextureCount = (int)sqrt(_TextureCount);
				float4 normals, albedo;
				float3 bg = tex2D(_MainTex, i.uv);

				i.uv.x = 1 - i.uv.x;

				float distance = tex2D(_DepthTex, i.uv).r *  _PConstant;


				float maxY = 1 / distance;
				float y = i.uv.y;
				y = Map(y, 0.0, 1.0, -0.5, 0.5);
				if (y < 0.0) {
					y = -y;
				}

				float fakeAO = saturate(1 - lerp(0, 1, pow(y, _FakeAOExp) ) );
				//float refFloor = saturate(1 - pow(lerp(0, 1, maxY), _FloorReflections));
				//float refRoof = saturate(1 - pow(lerp(0, 1, maxY), _RoofReflections));
				if(y > maxY)
				{
					//return float4(bg, 1);
					float lerpValue = lerp(0, 1.0, i.uv.y * 2.0 - 1.0);
					if (i.uv.y < 0.5) {
						
						// Gives reflective surfaces
						//return float4(_FloorColor * (1 - (distance / _DistanceDarkenAmount * .2)) , 1);
						
						// floor normals
						normals = float4(0.5, 1.0, 0.5, 1);
						albedo = float4(_FloorColor * (1 - lerpValue) , 1);
					}
					else
					{
						// Gives reflective surfaces
						//return float4(_RoofColor * (1 - (distance / _DistanceDarkenAmount * .2)), 1);

						// Normals
						normals = float4(0.5, 0.0, 0.5, 1);
						albedo = float4(_RoofColor * (lerpValue), 1);
					}
					
				}
				else
				{
					float3 extractNormals = tex2D(_NormalTex, i.uv);
					extractNormals.z = extractNormals.y;
					extractNormals.y = 0.5;

					// Normals
					normals = float4(extractNormals, 1);
					albedo = float4(_WallColor * (1 - (distance / _DistanceDarkenAmount)) * fakeAO, 1);
				}

				

				return albedo;// or normals;
            }
            ENDCG
        }
    }				
}

Next I’ll look at using a texture atlas and projecting this on to the surfaces so I can get textures and normal maps going (then lighting)!


Normals, too!

Leave a Reply

Your email address will not be published. Required fields are marked *