The sections we have examined earlier are composed in the ShaderLab declarative language. The true challenge in graphics programming begins here with the CGPROGRAM or HLSLPROGRAM declaration.
Shader "CustomPath/ShaderName_simpleColor"
{
// ====================================================
// 1. MATERIAL PROPERTIES - Inspector Interface
// ====================================================
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_Color ("Color", Color) = (1, 1, 1, 1)
}
// ====================================================
// 2. SUBSHADER - Render Pipeline Configuration
// ====================================================
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
// ====================================================
// 3. PASS - Contains the shader program code
// ====================================================
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fog
#include "UnityCG.cginc"
// Global Uniforms
sampler2D _MainTex;
fixed4 _Color;
// ====================================================
// 4. DATA STRUCTURES (Vertex Input / Output)
// ====================================================
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
};
// ====================================================
// 5. VERTEX SHADER STAGE
// ====================================================
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
UNITY_TRANSFER_FOG(o, o.vertex);
return o;
}
// ====================================================
// 6. FRAGMENT SHADER STAGE
// ====================================================
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv) * _Color;
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
ENDCG
}
}
// ====================================================
// 7. SHADER FALLBACK
// ====================================================
FallBack "Diffuse"
}
A Pass refers to a Render Pass literally. For those who have worked with rendering in 3D software (e.g., Maya or Blender), this concept will be easier to understand since when an image is being processed, it can generate different layers or passes separately (e.g., color Pass, light Pass, occlusion Pass, etc.) and thus obtain a separate composition in different layers.
!Each Pass renders one object at a time: That is, if we have two passes in our shader, the object will be rendered twice on the GPU and the equivalent of that would be two draw calls.
Shader will appear with the declaration of HLSLPROGRAM instead of CGPROGRAM since HLSL is currently the official graphics programming language (in fact, Shader Graph is based on it). Anyway, we can update our shader simply by replacing the word CGPROGRAM for HLSLPROGRAM and ENDCG for ENDHLSL, and then our program will compile both in Built-in RP as in Universal RP and High Definition RP.
The pragma is a directive that is used to give instructions to the compiler. In the case of shaders, it is used to indicate the vertex and fragment functions that we will use in shader. Their function is to help our shader recognize and compile certain functions that could not otherwise be recognized as such.
#pragma vertex vert — indicates the vertex function that we will use in the shader.#pragma fragment frag — indicates the fragment function that we will use in the shader.Multi-compile Pragma Variant Flags:
#pragma multi_compile_fwdbase — indicates that the shader will be compiled in the forward base pass.#pragma multi_compile_fwdadd — indicates that the shader will be compiled in the forward add pass.#pragma multi_compile_fwdaddfullshadows — indicates that the shader will be compiled in the forward add full shadows pass.#pragma multi_compile_fwdaddshadow — indicates that the shader will be compiled in the forward add shadow pass.#pragma multi_compile_fwdaddshadowcaster — indicates that the shader will be compiled in the forward add shadow caster pass.#pragma multi_compile_fwdaddshadowcasterfullshadows — indicates that the shader will be compiled in the forward add shadow caster full shadows pass.#pragma multi_compile_fog — indicates that the shader will be compiled with fog.The include is a directive that is used to include a file in the shader. This is useful when we want to use a function that is already defined in another file and we do not want to write it again in the current shader. In Unity, the most common include is “UnityCG.cginc”, which contains a series of functions that are used in the shader (e.g. lighting, texture sampling, etc.).
UNITY_FOG_COORDS(texcoordindex)UnityObjectToClipPos(inputVertex)TRANSFORM_TEX(tex, name)UNITY_TRANSFER_FOG(outputStruct, clipSpacePos)UNITY_APPLY_FOG(inputCoords, colorOutput)Another defined function that we can find in UnityCG.cginc is UNITY_PI, which equals 3.14159265359f. The latter is not included in our default shader because it is used only in specific cases (e.g. when calculating a triangle or Sphere).
Review Path:
Windows:{unity install path}/Data/CGIncludes/UnityCG.cginc
We can create our own directives using ".cginc" files, which will help us to organize our code and make it more readable. For example, we can create a file called "MyFunctions.cginc" and inside it, we can define the functions that we will use in our shader.
A data type that we will use frequently in the creation of our shaders is “struct”. For those who know the C language, a struct is a compound data type declaration, which defines a grouped list of multiple elements of the same type and allows access to different variables through a single pointer. We will use structs to define both inputs and outputs in our shader.
Its syntax is as follows:
struct name
{
vector[n] name : SEMANTIC[n];
};
A semantic is a chain connected to a shader input or output that transmits usage information of the intended use of a parameter:
POSITION[n] — Vertex position semantics.TEXCOORD[n] — allows access to the UV coordinates of our primitive and has up to four dimensions (x, y, z, w).TANGENT[n] — gives access to the tangents of our primitive. If we want to create normal maps, it will be necessary to work with a semantic that has up to four dimensions as well.NORMAL[n] — we can access the normals of our primitive, and it has up to four dimensions. We must use this semantic if we want to work with lighting within our shader.COLOR[n] — Vertex color allocation channels.BINORMAL[n] — corresponds to the binormal of the vertices.| Semantic | Description |
|---|---|
SV_POSITION |
Corresponds to the position of the vertices in screen space. |
SV_TARGET |
Corresponds to the target of the vertices (Render Target / Frame Buffer color output). |
SV_DEPTH |
Corresponds to the depth of the vertices. |
SV_COVERAGE |
Corresponds to the coverage of the vertices. |
SV_CLIPDISTANCE |
Corresponds to the clipping distance of the vertices. |
SV_STENCILREF |
Corresponds to the stencil reference of the vertices. |
SV_VERTEXID |
Corresponds to the vertex ID. |
SV_PRIMITIVEID |
Corresponds to the primitive ID. |
SV_INSTANCEID |
Corresponds to the instance ID. |
SV_ISFRONTFACE |
Corresponds to the front face of the vertices. |
SV_SAMPLEINDEX |
Corresponds to the sample index of the vertices. |
SV_DISPATCHTHREADID |
Corresponds to the dispatch thread ID. |
SV_GROUPID |
Corresponds to the group ID. |
SV_GROUPINDEX |
Corresponds to the group index. |
SV_GROUPTHREADID |
Corresponds to the group thread ID. |
SV_GROUPTHREADFLATTENED |
Corresponds to the group thread flattened. |
SV_RENDERTARGETARRAYINDEX |
Corresponds to the render target array index. |
SV_VIEWPORTARRAYINDEX |
Corresponds to the viewport array index. |
The vertex shader corresponds to a rendering pipeline’s programmable stage, where the vertices are transformed from a 3D space to a two-dimensional projection on the screen. Its smallest unit of calculation corresponds to an independent vertex.
"vert" is the name of the function that we will use to transform the vertices which corresponds to the vertex shader stage. Vertex shader was declared as such in the #pragma vertex.
This Shader in an Unlit type means it doesn't have light it includes a function for the vertex shader and another for the fragment shader #pragma fragment frag. It is essential to mention this since Unity provides a quick way to write shaders in the form of “Surface Shader” (surf) that generates Cg code automatically, exclusively for materials that are affected by lighting.
The first operation that occurs within the vertex shader stage is the transformation of the object vertices from object-space to clip-space through the “UnityObjectToClipPos” method. Let’s remember that our objects are within a three-dimensional space in the scene, and we must transform those coordinates into a two-dimensional projection of pixels on the screen. That transformation occurs precisely within the “UnityObjectToClipPos” function.
This function multiplies the matrix of the current model (unity_ObjectToWorld) by the factor of the multiplication between the current view and the projection matrix (UNITY_MATRIX_VP):
$$UnityObjectToClipPos = mul(\text{UNITY\_MATRIX\_VP}, mul(\text{unity\_ObjectToWorld}, float4(\text{pos}, 1.0)))$$Our next and last function in the Pass corresponds to the fragment shader stage that appears in our shader with the name “frag”. The reason we can tell that frag is the function of the fragment shader stage is because it has been declared as such in the #pragma fragment.
The word “fragment” refers to a pixel on the screen; to an individual fragment or to a group that together cover an object area. This means that the fragment shader stage will process every pixel on the computer screen concerning the object we are viewing.
This shader is currently written in Cg, so it will only compile in Built-in RP. If we want our shader to compile both in Universal RP and High Definition RP, we will have to change this data type for “half4 or float4”, otherwise our program could generate an error.
// Cg language architecture configuration
fixed4 frag (v2f i) : SV_Target { … }
// HLSL language architecture configuration
half4 frag (v2f i) : SV_Target { … }
Unlike the vertex shader stage, this function has an output called “SV_Target”, which allows us to render our scene in an intermediate buffer (render target) instead of sending the data to the Frame Buffer. In previous versions of Direct3D (version 9 and lower), the color output in the fragment shader appeared with the COLOR semantic. However, in modern GPUs (version 10 onwards), this semantics is updated to SV_Target, which means “system value target”. It can apply additional effects to the image before projecting them on the computer screen.
Inside the fragment shader stage we can find a fixed4 vector type called “col” which is the same as the tex2D function, where, as an argument, it receives the _MainTex texture and UV coordinate input. Basically what this operation does is store a texture within the col vector.
Note: A high-precision data type has more decimals, therefore, the GPU will take longer to calculate it, increasing times and generating heat. It is essential to use vectors and/or variables in their required data type, thus we can optimize our program, reducing the graphic load on the GPU.
“Sampler” refers to the sampling state of a texture. Within this type of data, we can store a texture and its UV coordinates. Scalar values are real number integers or floating-point numbers (float, half, fixed, etc.) and vectors are a set of scalar values (float2, half3, fixed4, etc.). Matrix types are used mainly for shearing, rotation and changes of vertex positions:
// three rows and three columns matrix execution layout
float3x3 name = float3x3(
1, 0, 0,
0, 1, 0,
0, 0, 1
);
// two rows and two columns matrix execution layout
half2x2 name = half2x2(
1, 0,
0, 1
);
// four rows and four columns matrix execution layout
fixed4x4 name = fixed4x4(
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
);
The data type “sampler” allows us to store both the texture and the sampling status in a single variable:
// declare the _MainTex texture as a global variable
Texture2D _MainTex;
// declare the _MainTex sampler as a global variable
SamplerState sampler_MainTex;
half4 frag(v2f i) : SV_Target {
// inside the col vector sample the texture in UV coordinates.
half4 col = _MainTex.Sample(sampler_MainTex, i.uv);
return col;
}
The above process can be optimized by simply using a unified legacy structural macro assignment statement:
// declare the sampler for _MainTex
sampler2D _MainTex;
half4 frag(v2f i) : SV_Target {
// sample the texture in UV coordinates using the function tex2D().
half4 col = tex2D(_MainTex, i.uv);
return col;
}
| Function | Description Evaluation |
|---|---|
abs(x) |
returns the absolute value of a scalar or vector. |
ceil(x) |
returns the smallest integer value greater than or equal to the input value. |
clamp(x, min, max) |
restricts the value of a scalar or vector to a range. |
cos(x) |
returns the cosine of a scalar or vector. |
sin(x) |
returns the sine of a scalar or vector. |
tan(x) |
returns the tangent of a scalar or vector. |
exp(x) |
returns the exponential value of a scalar or vector. |
exp2(x) |
returns the exponential value of a scalar or vector in base 2. |
floor(x) |
returns the largest integer value less than or equal to the input value. |
step(edge, x) |
returns 0.0 if the value is less than the edge, and 1.0 if the value is greater than or equal to the edge. |
smoothstep(min, max, x) |
returns a smooth interpolation between 0.0 and 1.0 when the value is between the minimum and maximum values. |
frac(x) |
returns the fractional part of a scalar or vector. |
length(v) |
returns the length of a vector. |
lerp(a, b, t) |
returns the linear interpolation between two values. |
min(a, b) |
returns the minimum value between two values. |
max(a, b) |
returns the maximum value between two values. |
pow(x, y) |
returns the value of the first parameter raised to the power of the second parameter. |
Unity utilizes three distinct rendering architectures, each tailored to different performance requirements, hardware targets, and pipeline capabilities.
CGPROGRAM blocks and includes UnityCG.cginc. Best suited for legacy projects or older mobile hardware where optimization must be manually tuned.HLSLPROGRAM blocks and utilizes modern HLSL includes (such as Core.hlsl). Features include a single-pass forward renderer, a dedicated 2D Renderer, and native Shader Graph support. Highly recommended for cross-platform deployments spanning mobile, PC, and consoles.| Feature | BIRP | URP | HDRP |
|---|---|---|---|
| Shader Language | Cg / HLSL | HLSL | HLSL |
| Base Include File | UnityCG.cginc |
Core.hlsl |
Common.hlsl |
| Lightmap Support | ✓ | ✓ | ✓ |
| Deferred Rendering | ✓ | Limited | ✓ |
| Shader Graph Support | No | ✓ | ✓ |
Writing efficient shader code requires understanding how modern GPU hardware executes logic at the silicon level. Minor syntax adjustments can drastically alter instruction latency and register pressure.
GPUs are optimized for parallel multiply-accumulate (MAC) operations, which form the structural building blocks of linear algebra:
// BAD: Division is expensive and stalls the ALU pipeline
float result = value / 2.0;
// GOOD: Use multiplication by a precalculated reciprocal literal
float result = value * 0.5;
While multiplication should always be favored, division is structurally unavoidable under the following mathematical conditions:
float t = saturate(time / duration);
float specular = energy / (distance * distance);
float3 normalized = vector / length(vector);
Choosing the correct variable precision directly influences register allocation and memory bandwidth usage on mobile and embedded architectures:
float4 position; — 32-bit high precision. Mandatory for world-space coordinates, transform matrices, and complex texture coordinates (UVs).half4 color; — 16-bit medium precision. Optimal for lighting vectors, localized directions, normal maps, and high dynamic range (HDR) colors.fixed4 light; — 11-bit low precision. Legacy data type (Cg only) restricted to simple low-magnitude color math and basic UI masks. In modern HLSL pipelines, this automatically maps to min16float or half.tex2Dlod with explicit mipmap levels for sampling distant objects, or inside loops/vertex functions where automatic derivative calculation is disabled.Dynamic branching in a fragment shader causes divergent execution paths within a warp/wavefront, forcing the GPU to execute both paths sequentially while masking off threads.
// BAD: Dynamic branch causing execution divergence in fragment stages
if (distance > threshold) {
// Code path A
} else {
// Code path B
}
// GOOD: Mathematical interpolation eliminating physical branch execution
float lerpFactor = saturate(distance / threshold);
return lerp(a, b, lerpFactor);
To leverage the Scriptable Render Pipeline (SRP) Batcher and eliminate CPU-side draw call overhead, all material properties must be grouped within a standardized constant buffer block named UnityPerMaterial.
// Proper constant buffer declaration for SRP Batcher compliance
CBUFFER_START(UnityPerMaterial)
float4 _MainTex_ST;
half4 _Color;
CBUFFER_END
CBUFFER block.Modern GPUs run vectorized instructions efficiently. Consolidate your linear algebra into parallel vector calculations rather than component-by-component scalar operations.
// GOOD: Unified parallel vector math (processed simultaneously by SIMD hardware)
float3 result = a * b + c;
// Swizzling: Reconstructing component layouts efficiently via native routing
float4 color = texture.rrrg; // Maps channels natively to create float4(r, r, r, g)
When implementing custom screen-space mappings or procedural patterns, automatic mipmap generation fails due to missing texture coordinates. Screen-space partial derivatives must be evaluated manually:
// Manual mip selection based on screen-space coordinate differentials
float2 derivatives = ddx(uv) + ddy(uv);
float mip = 0.5 * log2(max(dot(derivatives, derivatives), 1e-6));
// Sample texture with explicitly resolved mip level
color = tex2Dlod(_MainTex, float4(uv, 0, mip));
Shaders generate massive keyword combinations (permutations) that can explode compile times and memory footprints. Differentiate keyword behavior strategically:
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS — Compiles and includes all variations in the build. Required for engine-driven runtime global pipeline states.#pragma shader_feature _ALPHATEST_ON — Strips unused permutations out of the final build if no active materials utilize the keyword state, saving disk space and memory footprint.#if defined(SHADER_API_MOBILE)
// Simplified execution paths for low-bandwidth mobile units
#elif defined(SHADER_API_D3D11)
// Full high-end feature set utilizing PC hardware capability
#endif
// Simulate half-precision behavior on a 32-bit float register
float simulatedHalf = asfloat(asuint(value) & 0xFFFF0000);
// Check for unhandled NaN (Not-a-Number) values
bool isInvalid = isnan(value);
Porting legacy shaders into Scriptable Render Pipelines (SRP) requires rewriting architectural layout blocks. Modern pipelines abandon old Cg compiler abstractions in favor of direct, cross-platform HLSL structures.
Shader "URPExamples/SimpleColor"
{
Properties
{
_BaseColor("Color", Color) = (1,1,1,1)
_BaseMap("Texture", 2D) = "white" {}
}
SubShader
{
Tags
{
"RenderType"="Opaque"
"RenderPipeline"="UniversalPipeline"
}
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
// Data structure for Vertex Input
struct Attributes
{
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
};
// Data structure for Vertex Output (Rasterizer Input)
struct Varyings
{
float4 positionHCS : SV_POSITION;
float2 uv : TEXCOORD0;
};
// Textures and Samplers API macros
TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);
// SRP Batcher constant buffer configuration
CBUFFER_START(UnityPerMaterial)
float4 _BaseMap_ST;
half4 _BaseColor;
CBUFFER_END
Varyings vert(Attributes IN)
{
Varyings OUT;
// Transform positions from object space to homogenous clip space
OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz);
OUT.uv = TRANSFORM_TEX(IN.uv, _BaseMap);
return OUT;
}
half4 frag(Varyings IN) : SV_Target
{
// Core macro-based sampling
half4 color = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv);
return color * _BaseColor;
}
ENDHLSL
}
}
}
When migrating shaders across pipelines, use the following structural conversions for include files, space transformations, and sampling macros:
Modern render pipelines break up global dependencies into target-specific shader libraries to avoid bloating memory allocations:
UnityCG.cginc or AutoLight.cginc for light and shadows calculations.Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl for structural transforms, plus specialized files like Lighting.hlsl or Shadows.hlsl.Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl and Packages/com.unity.render-pipelines.high-definition/ShaderLibrary/ShaderVariables.hlsl to interface with advanced volumetric, area lighting, and physically-based rendering data buffers.Modern hardware pipelines standardize vertex operations using specific, explicitly named functions rather than combined utility macros:
UnityObjectToClipPos(v.vertex)TransformObjectToHClip(positionOS.xyz) (Object Space to Homogeneous Clip Space).float3 variable containing raw geometric position coordinates, discarding the explicit 4th component translation vector ($w$) prior to internal matrix multiplications.To preserve compile-time API compatibility across platform backends (such as Vulkan, Metal, and Direct3D), explicit texture sampling macros must replace legacy sampler2D layouts:
| Pipeline Stage | Legacy System (BIRP) | Modern SRP Architecture (URP / HDRP) |
|---|---|---|
| Global Allocation | sampler2D _BaseMap; |
TEXTURE2D(_BaseMap); |
| Fragment Sample | fixed4 col = tex2D(_BaseMap, uv); |
half4 col = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv); |
Below is the verified verification index of external engineering resources, documentation modules, and research papers utilized throughout this technical pipeline review: