I recently started working on a new project, a video game using the Unity Engine. True to my inner nerd, I'm less interested in making a video game and more interested in learning about the software engineering behind video games. As such, I challenged myself to not rely on common external libraries, and instead attempt to write much of the code from scratch. This is quite the challenge - simply figuring out the trigonometry, physics and edge cases associated with camera control was a month-long endeavor involving lots of late nights and scribbled scratch paper.

One of the other large challenges I faced was writing a custom shader, which would allow me to get some neat lighting effects. I had a lot of trouble finding quality introductory resources for the realm of shader programming. Many of the tutorials I found used tons of jargon, and assumed basic knowledge that I clearly lacked.

I want to fill that gap, and share some of what I've learned through this fun dive into game development. Without further adieu, let's dive in!

What's A Shader?

To understand shaders (and most other things), I think it's best to look at the history of them and how they evolved. What problem were they attempting to solve, how did they do that, and how can we use them to solve our problems?

In the late 1970's and early 80's, computers were beginning to really gain traction and power. This gave rise to the possibility of computer rendered graphics. As computer animation studios started to become an entire industry, they also started introducing standards for how to describe and render three dimensional scenes. The first APIs for this task were fixed-function, where the programmer was given a discrete set of functions that mapped directly to a discrete set of renderer outputs.

Of course, as the computer graphics industry really started to take off, this fixed-function approach was no longer meeting the needs of artists and programmers. And so, in 1988, Pixar Animation Studios introduced the RenderMan Interface Specification. This novel API allowed programmers to use familiar C-like syntax to bridge the gap between modeling software and rendering software. Programmers could now do powerful, relatively high-level tasks, such as instruct the renderer to create geometric objects.

Along with geometry, the RenderMan specification also introduced the ability to program the renderer with material and lighting states. Using the same C-like language, programmers could instruct the graphics renderer to do all sorts of arbitrarily complex material and lighting effects. This marked the birth of shader programming, with later companies introducing real-time hardware pipelines and a growing evolution of features, complexities and idiosyncrasies.

Foundational Vocabulary Terms

As mentioned, one of the early struggles I encountered with shader programming was finding introductory resources that weren't stuffed with jargon. I want to avoid that same trap in this article, by instead introducing basic vocabulary terms. This is by no means inclusive, as there are a lot of 3D graphics basics such as vectors or pixels which I am not going to introduce. I've tried my best to include the terms which I think are either very foundational to shader programming, or which do not have a readily available concise definition.

  • Normal
    Imagine one of the individual triangle surfaces which compose a 3D surface. Now, imagine a vector which shoots out perpendicular to that surface. This is a so-called "normal" vector. See this Wolfram article and the associated picture for a visualization and more in-depth explanation.
  • Vertex and Fragment Shaders
    Vertices are individual points of a 3D object, and a vertex shader is one which operates on those points. Fragments are casually analogous to pixels, and operate on those inputs. In general, vertex shaders will manipulate where the vertices are rendered, and fragment shaders will manipulate the colors. There is a bit of nuance here, depending on the graphics rendering pipeline itself (e.g. OpenGL vs. DirectX) but the fundamental idea is the same.
  • Clip Space
    This is the coordinate space relative to the camera view. To explain that sentence better, imagine we have a 3D object that exists. In order to actually render it on the screen, a few things need to happen. One of things is transforming the coordinates from the game world's understanding of space into the space that the camera sees. The latter coordinates are referred to as being in clip space, because at this stage the GPU can cut out (clip) objects which are hidden from the camera's perspective.
  • HLSL / GLSL
    These are the two most common programming languages you'll use to write most shader software. The acronyms stand for either OpenGL Shader Language (GLSL) or High-Level Shader Language (HLSL). They are syntactically very similar, and based on the C programming language.
  • Shader Pass
    While defined simply in HLSL as Pass, the fundamental concept is one instance of processing. While less passes are desirable for performance reasons, you can think of these like painting a picture. You may have one pass for the basic colors, another pass for some more complex highlighting, etc.

Programming Fundamentals

At this point, there are tons of tutorials online that discuss the fundamentals of shader programming, so I won't spend a ton of time reinventing the wheel. However, this article wouldn't feel complete without at least touching on the basics, so I'll do my best to give a quick overview.

Basic Shader Example

Below, we'll walk through a very basic shader to illustrate a few concepts. This shader is specifically for the Unity engine, however the concepts (and often syntax) are nearly identical across any of the major game engines.

Here's a dump of the entire shader, which applies a single texture to an object. Feel free to skim through the code to get a whole-picture overview, and then I'll break it down to explain some of the important syntax and concepts. It should also be noted that this code is using some legacy data types and libraries which aren't reflective of modern HLSL. Nonetheless, this legacy syntax is a good intro point for understanding the basics of how shader programming works.

Shader "Custom/Basic" {

    Properties
    {
        _TintColor ("Tint", Color) = (0, 0, 0, 1)
        _MainTexture ("Texture", 2D) = "white" {}
    }

    SubShader 
    {
        Pass 
        {
            Tags
            {
                "RenderType" = "Opaque"
                "Queue" = "Geometry"
            }

            CGPROGRAM
            #pragma vertex processVertex
            #pragma fragment processFragment

            #include "UnityCG.cginc"

            struct Appdata 
            {
                float4 Vertex : POSITION;
                float2 UVCoordinates : TEXCOORD0;
            };

            struct VertexToFragment
            {
                float4 Vertex : SV_POSITION;
                float2 UVCoordinates : TEXCOORD0;
            };

            fixed4 _TintColor;
            sampler2D _MainTexture;

            VertexToFragment processVertex(Appdata input) 
            {
                VertexToFragment output;
                output.Vertex = UnityObjectToClipPos(input.Vertex);
                output.UVCoordinates = input.UVCoordinates;
                return output;
            }

            fixed4 processFragment(VertexToFragment input) : SV_TARGET
            {
                fixed4 textureData = tex2D(_MainTexture, input.UVCoordinates);
                textureData = textureData * _TintColor;
                return textureData;
            }

            ENDCG
        }
    }
}
Organizational Code

The top-level Shader block is used for high-level organizational, such as the Properties and identification ("Custom/Basic"). Shader blocks can also contain additional metadata or high-level instructions, such as configuring Fallback shaders and Category blocks for shared rendering commands.

There can be multiple SubShader blocks, which is where the majority of the actual shader logic resides. Different SubShader blocks can be used to provide implementations for certain graphics hardware or rendering pipelines. The graphics engine will determine how these are processed. For instance, Unity will process SubShader blocks top-to-bottom, stopping when it reaches the first that is fully compatible with the user's current device and graphics settings.

In the last portions of the organizational bits, we have a Pass block which informs the Unity engine that the containing code relates to a singular draw call. The Tags block which follows is also Unity-specific, informing the engine about material properties such as the transparency (Opaque) and which specific rendering queue that the associated shader execution should belong to (Geometry).

Rendering Code

The CGPROGRAM signifies the true start of the shader code. The first few definitions are standard coding setup. We forward declare the vertex shader function (processVertex) and the fragment shader function (processFragment). The next few lines import the Unity library, and declare the structs that will hold input information from the game engine. For instance, the Appdata::Vertex field holds a float4, which is a vector of four floats. This represents the homogeneous coordinates of an object, which are represented in 4D space (x, y, z and w).

Moving on, we can now discuss the actual vertex processing:

VertexToFragment processVertex(Appdata input) 
{
    VertexToFragment output;
    output.Vertex = UnityObjectToClipPos(input.Vertex);
    output.UVCoordinates = input.UVCoordinates;
    return output;
}

In this block of code, we take the engine's input and process it. We're given input data (Vertex and UVCoordinates), and it's our job to transform the 4D vertices into 2D clip space. You can imagine how this might be used to, for example, applying transformations to simulate cloth movement without manipulating an underlying asset. In this particular example, however, we don't do much of interest: simply transforming the input from the 3D space into 2D space using the Unity library's built in UnityObjectToClipPos function.

That resulting fragment will get further processed by the the graphics pipeline, but ultimately will pass through our shader again when it's time to process further:

fixed4 processFragment(VertexToFragment input) : SV_TARGET
{
    fixed4 textureData = tex2D(_MainTexture, input.UVCoordinates);
    textureData = textureData * _TintColor;
    return textureData;
}

In this code, it's our responsibility to process the fragment input. This step will be called for each fragment (e.g. pixel) and we can do all sorts of work here. In most shaders, the bulk of interesting work will be done here, such as applying lighting effects, blending, clipping pixels which don't need to be rendered, and more. In our case, we use tex2D in order to sample the input texture. We apply a tint by multiplying it by the preset _TintColor, and return the data.

Conclusion

Of course, this is only the tip of the iceberg! Shaders are extremely complex instruction sets which can quickly scale in complexity. There are a ton of tutorials and articles out there, and I'll leave it to those experts to explain the more advanced concepts.

Hopefully this simple walkthrough arms you with the vocabulary and basics suitable to set you down the path to those more complex resources.