Shaders. The black box for many Unity developers that spits out the magic we need to make our graphics and assets. They have a steep learning curve for many who are new to Unity, mostly because there is so little material to learn from. This tutorial is meant to be an introduction into shader development and to break down the basics of what goes in to a shader as well as some of the things it’s possible to get out of one. It will most likely contain some inaccuracies and mangled terminology but with any luck it will also contain the information needed for you to get started creating shaders of your own. Instead of beginning with copying shader examples formulaicly let’s take a look at what a shader is and why we need to write them in the first place.
It’s easy to take the things we see on our device’s screens for granted since we see them so often, but to get to the heart of a shader we need to dig beneath what is displayed on the screen and see how it’s done. So to do that let’s start at the beginning, when an application is first launched. The program knows nothing about graphics and neither does the operating system. So to get it ready for something pretty a window has to be made, and it’s different for each OS - a fact that makes Unity’s cross-platform trait extraordinarily useful. Once that window is up one of two graphics APIs, DirectX or OpenGL, have to be used to make a rendering context, basically just a spot they can draw on. Then it’s ready to create the ‘rendering pipeline’ or graphics pipeline, a series of operations that happens every frame to output the content seen on screen.
The first operation is the Vertex Transformation stage where each vertex and normal are transformed from several coordinate spaces into screen position. They go from object space, as in the axes the model was made in, to world space, like Unity world space coordinates, to eye space, how the camera sees it (moves the camera to the origin), then to clip-space, or -1 to 1 on the screen. In addition, UV coordinates are made and any vertex lighting is done. Then in the Primitive Assembly and Rasterization stage the primitives (points, lines, and polygons) are actually assembled from their vertices and clipping and culling is done. Rasterization is when the pixels covered by each primitive (poly) is calculated which then produces something called fragments. A fragment is related to a pixel but instead of containing just color data it contains things like color, secondary color for lighting, depth, and texture coordinates. These fragments are attached to a pixel, are responsible for updating it’s color based on any instructions received, and are in charge of rendering it. The next stage is the Interpolation, Texturing, and Coloring stage, a lengthy name for the time when things get finally colored. All the mathematics and instructions are executed and sent to the fragments and some might be culled at this point as well if their depth changes. Last but not least, the Raster Operations stage finishes off some calculations like blending, alpha testing, depth testing, and stencil testing and finally, the pixels are assigned their final values and written on what is basically a texture the size of the rendering context - stored on the back buffer. With the front buffer the one currently being displayed, the back buffer then switches with the front and the process is repeated at the device’s framerate.
So why did we go over this process? Well, the answer lies with the advent of a “programmable pipeline” and of it now including a Vertex Processor to handle the Vertex Transformation stage and a Fragment Processor to take over the Texturing and Coloring part of the Texturing, Coloring, and Interpolation stage. Not too long ago commands for rendering were written in the pipeline itself, along with all the other commands to initialize and operate the pipeline, and they still can be for that matter. But as computer graphics grew more demanding, a new version of the language was made that was designed to run on the vertex and fragment processors, one that could even be run from an external file. The combination of a vertex and fragment function is what resides in a typical shader program. These functions give us direct control over the processes where vertices are manipulated and fragments are colored, which means there’s not a whole lot they can’t do when it comes to displaying stuff on a screen. Shaders enable us to do a lot that would otherwise leave us bogged down in that mess described above and it also let’s us communicate with the GPU from the CPU directly. So, now that we know exactly what a shader is and what it does, let’s move on to making the basis of one. To start off with let’s look at the bare minimum of a shader, pick it apart, and then build up from there.
Shader "Custom/Basics"
{
SubShader {
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
float4 vert (float4 vertPos : POSITION) : SV_POSITION
{
return mul(UNITY_MATRIX_MVP, vertPos);
}
fixed4 frag (void) : SV_TARGET
{
return fixed4 (0,0,1,1);
}
ENDCG
}
}
}
At the very top next to the Shader tag is the label our shader can be found under in the dropdown menu in the inspector window of materials, and the backslashes represent the folder structure. Next up is the SubShader tag which basically encompasses a whole shader. When Unity runs a shader it picks the first SubShader the hardware is capable of running, if any, so it’s there to enable development for a range of platforms while also opening up the possiblity of working with the latest and greatest. The Pass tag is where the magic happens, it causes an object to be rendered one time and the instructions it receives affects how it’s rendered. CGPROGRAM, big bold words to hold everything that drives what we do in a shader. Cg is a specialized language for the GPU, it stands for “C for graphics”,and it’s designed to run solely on graphics hardware. So it has no frills or advanced language features. No classes. But it doesn’t really need them. Programming for the GPU takes a different mindset than it does for the CPU. Forget everything about flow control and object-oriented coding picked up in C#, and the reason is this: GPUs are built differently than CPUs. They are designed to do the same or similar processes over and over and have been optimized to work best that way, with lots of computational power running in parallel. The problem for anyone coming from traditional programming is the urge to write an if-else statement. Innocuous, basic, what could the harm possibly be in that? Well in GPUs branching, or going down varying flows of logic, basically causes the parallel processes to hold up for that logic to be evalutated before resuming. This can cause a decrease in a shader’s performance if it’s not watched out for, although on high end machines it may not be an issue if kept within reason. It’s not something that traditional programming would even consider, to limit flow control, but in writing shaders it’s a fine art. Alright, well that was a long line, let’s move on to the next one.
The #pragma lines are inherited from C and they stand for pragmatic. They are pre-processor directives which tell the shader which processor each function should run on. Here we’ve got two functions, vert and frag. The vert function returns a float4 but at the tail end of the function declaration theres SV_POSITION which is what’s called a semantic. Semantics are the means by which the vert function gets the information from the model, by specifying what type of information it is and mapping it to a variable. In this case the vert function will send that float4 as the final position data of the vertex before rasterization. The SV stands for system value and semantics with that attached are new in Direct3D 10. In the parameter of the vert function there’s a float4 with the semantic of POSITION, which is the object space coordinates of the vertex. The function only has one line and that’s to return the mul(tiplication) of the MVP matrix and the object space coordinate vertex. Matrices suck. Let’s just get that out of the way. This particular one is going to crop up all the time, however, and it’s not too hard to pick up if we remember the graphics pipeline. To go from object space to world space there’s the Model Matrix. To go from world space to eye space there’s the View Matrix. To go from eye space to clip space there’s the Projection Matrix. The keen observer will note that those three matrices happen to start with the letters of the one used in the function. When multiplied together they form the ModelViewProjection matrix which can be used to transform a vertex to clip space in a single operation. Which is exactly what the function does before returning it as a float4, ready for rasterization.
The frag function is considerably simpler, it doesn’t take anything in and returns a fixed4, a type smaller than a float, used to save space when the precision isn’t needed. The fixed4 has the semantic SV_TARGET which means it’s going to a render-target, the earlier version of that semantic is COLOR. In the return the type in front of the parenthesis is the declaration of a vector, in this case a Vector4. And the vector represents the color blue since the color format is R,G,B,A. Then there’s ENDCG which… Ok, moving on.
That’s the whole of a basic shader. But there’s still a lot to cover before we can start making anything interesting. So far we’ve seen how to instruct the vertex shader to operate on it’s processor and the fragment shader on it’s processor but not how to have them interact with each other. We also haven’t seen any way to give the shader instructions from outside the program, which would be handy. So let’s take a look at something that does all that: Unity’s default Unlit shader.
Shader "Unlit/CustomUnlit"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
v2f vert (appdata v)
{
v2f o;
o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
// apply fog
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
ENDCG
}
}
}
The Properties block let’s us define variables which can be edited and initialized in the inspector window of a material. The first part of a property is the name of the variable, by convention it starts with an underscore. The quotes hold the name shown in the inspector window of the material and next to that is the type of the property. In this case the 2D texture is set to one of Unity’s default values: “white”. The other defaults for a 2D texture are “black”, “gray”, “bump”, and to leave it blank it’s just empty quotes “”. And the brackets are there because they used to have a purpose before Unity 5 and apparently the devs are nostalgic. There are other types of properties as well, such as float, int, range, Color, and Vector. The numbers are initialized with, well a number, and the Color and Vector with a Vector4.
The reason properties are so important is not only because they let us change the properties of a shader in the material window in the editor, but also because they let us change it with code during runtime. This is one of the methods for direct communication with a shader. Let’s move on to the Tags label just outside the Pass block with the “RenderType”=“Opaque” tag in it. These tags are essentially instructions regarding how to display the shader as a whole. There’s two that are probably the most commonly used: Queue and RenderType. Queue refers to the order in which an object is drawn. The default is “Queue”=“Geometry” which is implied if it’s not used. A tag that is rendered after that, in other words on top of that, is Transparent, which makes sense since there needs to be stuff behind something if it’s see-through. The other tag is RenderType, used for replacement shaders which are essentially a way to substitute the shaders in a scene with something else. If there is no RenderType tag on the shader it isn’t rendered but if there is then the replacement shader can substitute it with it’s own version. Unless replacement shaders are on the menu this isn’t terribly helpful but every default Unity shader has this tag so it’s probably good practice to stick it on anyways. The one to watch for is “Queue”=“Transparent” which is what’s needed for shaders with alpha effects.
LOD stands for Level Of Detail and it’s used to give the option to cull out expensive shaders if necessary. The LOD value starts at infinite and goes down, removing any shader with a higher LOD. Unlit shaders are 100, the lowest default, because they’re the cheapest kind of shader with very little overhead. A Diffuse shader is 200 and with some lighting and normal mapping 300-400. LOD isn’t actually necessary at all but it’s one of those things that might as well get included if only to be consistent with Unity’s system, unless it’s required that the shader never be removed.
As for the new pragma and include, one makes fog work because of magic and the other includes a file with some Unity provided utility functions. The .cginc file type is often just full of inline functions and could be thought of as a using statement of static methods. And now we come to structs, a key component in shader development, they allow complex communication from the vert function to the frag function. Here we’ve got the vertex POSITION alongside the uv TEXCOORD0, which is a semantic that has the first UV coordinates. There is TEXCOORD1(2,…,7) as well and while these can hold the other UV sets they don’t necessarily have to. Since semantics are basically just mapping out what information will be stored in a variable they don’t actually determine how that variable can be used. For example, if we wanted to get a Vector3 to the fragment shader we could declare a variable and use the semantic of a texcoord not being used. Just because we told the pipeline we wanted to fill our variable with the 3rd UV map or something, which probably doesn’t exist, doesn’t mean we can’t just then assign whatever we want to it after that. The texcoord semantic may technically mean texture coordinates but it’s sometimes easier to think of it as general storage space.
But back to structs, appdata is the input to the vert function and v2f is the output. It also happens to be the input to the frag function and that’s where the communcation between functions happens. Notice that the variable names between the structs are the same, it’s not a requirement but an indication of how the first struct is being transferred to the second. Besides the uvs transferring the verts do as well, although they go from their untransformed position in model space to their new position in clip space. Then there’s the Unity fog coords, which are magic. Or somewhere in UnityCG, maybe.
Anyways, let’s take a quick look at the two declared variables with underscores. The keen observer will note how they match the property names and that’s because they are the variables in the cg program that coincide with those properties. The _ST variable (stands for Scale Translate) is there for UnityCG to put the uv scaling and offset info in when using TRANSFORM_TEX. Easy enough, on to the vert function. First off the v2f struct that will be sent to the frag function is declared. Then the vertex is transformed into clip space with the ModelViewProjection matrix. Unity has these matrices already calculated so rather than having to derive them we can just use the shortcuts. Next is the uv coordinates getting scale and offset applied with some help from UnityCG, as mentioned before. After that there’s more magic which may or may not involve the transformation of distance based lighting effects aka fog, and then the struct is sent off to the frag function. In the frag function a variable is assigned the color at a uv coordinate (from v2f) on the texture _MainTex. Then fog is applied to the color and finally the color is returned to the render target.
With that we’ve covered the first of Unity’s default shaders and we’ve gone over all of the concepts and tools necessary to start to create custom shaders. There are a million and one different things that can be done in a shader limited only by your creativity. If you are interested in learning more about the specific functions to use in a vert frag shader try searching for “glsl” functions and checking out the Nvidia docs. Hopefully this tutorial has been of some interest, good luck in your Unity development.