Introduction
HLSL is Microsoft's shader language used in Unity through the Built-in or HDRP pipelines. At an intermediate level, it enables custom visual effects such as advanced lighting or distortions. Understanding the vertex/fragment separation and semantic usage is essential for optimal GPU performance. This tutorial guides you step by step with concrete, directly compilable examples in Unity 2023+.
Prerequisites
- Unity 2023.3 or higher
- Basic knowledge of C# and shaders
- Visual Studio or Rider for editing
- A 3D project with a simple model
Basic Shader Structure
Shader "Custom/IntermediateLighting" {
Properties {
_MainTex ("Texture", 2D) = "white" {}
_Color ("Color", Color) = (1,1,1,1)
}
SubShader {
Tags { "RenderType"="Opaque" }
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; };
sampler2D _MainTex; float4 _Color;
v2f vert (appdata v) {
v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o;
}
fixed4 frag (v2f i) : SV_Target { return tex2D(_MainTex, i.uv) * _Color; }
ENDCG
}
}
}This basic shader defines properties, the vertex shader, and the fragment shader. The POSITION and SV_POSITION semantics are required for the graphics pipeline.
Adding Normals and Simple Lighting
struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; };
struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 normal : TEXCOORD1; };
v2f vert (appdata v) {
v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv;
o.normal = UnityObjectToWorldNormal(v.normal); return o;
}
fixed4 frag (v2f i) : SV_Target {
float3 lightDir = _WorldSpaceLightPos0.xyz;
float diff = max(0, dot(i.normal, lightDir));
fixed4 col = tex2D(_MainTex, i.uv) * _Color;
return col * diff;
}Normals are added to compute basic diffuse lighting. This demonstrates transforming normals from object space to world space.
Shader with Texture and Attenuation
fixed4 frag (v2f i) : SV_Target {
float3 lightDir = _WorldSpaceLightPos0.xyz;
float diff = max(0, dot(normalize(i.normal), normalize(lightDir)));
fixed4 tex = tex2D(_MainTex, i.uv);
float atten = 1.0 - length(_WorldSpaceLightPos0.xyz - i.vertex.xyz) * 0.1;
return tex * _Color * diff * atten;
}Linear attenuation based on distance to the light is added. Avoid expensive calculations in the fragment shader when possible.
Multiple Passes for Outline
Pass { /* Main lighting pass */ }
Pass {
Cull Front
CGPROGRAM
#pragma vertex vertOutline
#pragma fragment fragOutline
v2f vertOutline(appdata v) {
v2f o; float3 norm = normalize(v.normal);
v.vertex.xyz += norm * 0.02; o.vertex = UnityObjectToClipPos(v.vertex); return o;
}
fixed4 fragOutline(v2f i) : SV_Target { return fixed4(0,0,0,1); }
ENDCG
}A second pass with Cull Front creates a simple outline by offsetting vertices along the normals.
Including a Custom .cginc File
#ifndef LIGHTING_UTILS_INCLUDED
#define LIGHTING_UTILS_INCLUDED
float3 CalculateDiffuse(float3 normal, float3 lightDir) {
return max(0, dot(normalize(normal), normalize(lightDir)));
}
#endifCreating a reusable .cginc file allows sharing code across multiple shaders and improves maintainability.
Best Practices
- Prefer calculations in the vertex shader when possible to save GPU resources
- Use correct semantics (SV_Target, TEXCOORDn)
- Always test on multiple platforms (DirectX, Vulkan)
- Avoid dynamic branches in the fragment shader
- Profile with Unity's Frame Debugger
Common Errors
- Forgetting to normalize vectors before the dot product
- Incorrect normal transformation (use UnityObjectToWorldNormal)
- Exceeding the limit of 8 TEXCOORD interpolators without optimization
- Not handling shader variants for different graphics quality levels
Going Further
Deepen your skills with our advanced shader courses.