HLSL Shaders in Unity in Depth
ShaderLab + HLSL structure, vertex/fragment, custom URP shader.
Shader Graph covers most tasks, but sooner or later you need either something non-standard or to understand what the graph generates under the hood. This chapter is a short introduction to “hand-written” shaders for URP.
What a shader is in Unity
A shader in Unity is a .shader file with a ShaderLab structure, inside which there are code blocks
in HLSL (or, previously, CG — the same syntax but with deprecated macros).
ShaderLab is the “wrapper”: declaration of properties, sub-shaders, passes, render states. HLSL is the actual vertex and fragment code that runs on the GPU.
Shader "Name/In/Menu"
{
Properties { ... } // what is visible in the material Inspector
SubShader { ... } // one or more implementations
Fallback "Standard" // if no SubShader matched
}
WebGL/WebGPU: you write the vertex and fragment shader directly (GLSL/WGSL), create the program yourself, compile it, and pass uniforms.
Unity does most of the work: it compiles your HLSL into the needed target (DX11/12, Vulkan, Metal, OpenGL ES, WebGL/WebGPU) and manages uniform binding through Material and Renderer.
A minimal URP shader
Here is a template for an Unlit shader for URP that tints the object with a color from the material:
Shader "Custom/URPUnlitSimple"
{
Properties
{
_BaseColor ("Base Color", Color) = (1, 1, 1, 1)
_BaseMap ("Base Map", 2D) = "white" {}
}
SubShader
{
Tags
{
"RenderType" = "Opaque"
"RenderPipeline" = "UniversalPipeline"
"Queue" = "Geometry"
}
Pass
{
Name "ForwardUnlit"
Tags { "LightMode" = "UniversalForward" }
HLSLPROGRAM
#pragma vertex Vert
#pragma fragment Frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
// Vertex input structure
struct Attributes
{
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
};
// Vertex output → fragment input structure
struct Varyings
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
};
TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);
CBUFFER_START(UnityPerMaterial)
float4 _BaseColor;
float4 _BaseMap_ST; // tiling/offset
CBUFFER_END
Varyings Vert(Attributes IN)
{
Varyings OUT;
OUT.positionCS = TransformObjectToHClip(IN.positionOS.xyz);
OUT.uv = TRANSFORM_TEX(IN.uv, _BaseMap);
return OUT;
}
half4 Frag(Varyings IN) : SV_Target
{
half4 tex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv);
return tex * _BaseColor;
}
ENDHLSL
}
}
}
What matters here:
Tags { "RenderPipeline" = "UniversalPipeline" }— tells Unity that this SubShader is for URP. Without it, URP ignores the shader.HLSLPROGRAM ... ENDHLSL— the boundaries of the HLSL block. (It used to beCGPROGRAM ... ENDCG, which is deprecated for URP/HDRP.)CBUFFER_START(UnityPerMaterial)— required for SRP Batcher compatibility. Without a CBUFFER, the batcher will not merge this shader’s draw calls.TransformObjectToHClip— converts an object-space position into clip-space, ready for rasterization.TRANSFORM_TEX— applies the tiling/offset from_BaseMap_ST.SAMPLE_TEXTURE2D— a macro for sampling a texture (cross-platform).
What properties are and why they must match the CBUFFER
Properties
{
_BaseColor ("Base Color", Color) = (1, 1, 1, 1)
}
In Properties you describe what the user wants to edit through the Inspector. In the CBUFFER you declare the same fields in HLSL to use them in code. The names must match.
Property types:
Color—float4Vector—float4Float,Range(0, 1)—float2D,3D,Cube— textures
Lighting in URP
The Unlit shader above ignores light. To get lighting, you need to include the URP lighting helpers and extend Attributes/Varyings with world-space data (normal + position are needed for the PBR calculation):
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
struct Attributes
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
float2 uv : TEXCOORD0;
};
struct Varyings
{
float4 positionCS : SV_POSITION;
float3 positionWS : TEXCOORD1; // ← needed for lighting
float3 normalWS : TEXCOORD2;
float2 uv : TEXCOORD0;
};
Varyings Vert(Attributes IN)
{
Varyings OUT;
VertexPositionInputs vp = GetVertexPositionInputs(IN.positionOS.xyz);
VertexNormalInputs vn = GetVertexNormalInputs(IN.normalOS);
OUT.positionCS = vp.positionCS;
OUT.positionWS = vp.positionWS;
OUT.normalWS = vn.normalWS;
OUT.uv = TRANSFORM_TEX(IN.uv, _BaseMap);
return OUT;
}
half4 Frag(Varyings IN) : SV_Target
{
half4 baseColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv) * _BaseColor;
InputData inputData = (InputData)0;
inputData.positionWS = IN.positionWS;
inputData.normalWS = normalize(IN.normalWS);
inputData.viewDirectionWS = GetWorldSpaceNormalizeViewDir(IN.positionWS);
SurfaceData surfaceData = (SurfaceData)0;
surfaceData.albedo = baseColor.rgb;
surfaceData.alpha = baseColor.a;
surfaceData.metallic = 0;
surfaceData.smoothness = 0.5;
surfaceData.normalTS = float3(0, 0, 1);
return UniversalFragmentPBR(inputData, surfaceData);
}
UniversalFragmentPBR is a built-in URP function that computes physically correct lighting for all
visible light sources, shadows, and GI. It is a full analog of the “Lit” shader, but controlled by you.
GetVertexPositionInputs / GetVertexNormalInputs are URP helpers that compute all coordinate
variants (object/world/clip/view) in a single call.
Render states
In a Pass you can set:
Cull Back/Cull Front/Cull Off— culling polygons by normal direction.ZWrite On/Off— whether to write to the Z-buffer.ZTest LEqual— which Z-test to use.Blend SrcAlpha OneMinusSrcAlpha— the blending mode (for transparency).ColorMask RGBA— which channels to write.
A typical set for a transparent object:
Tags { "Queue" = "Transparent" }
Blend SrcAlpha OneMinusSrcAlpha
ZWrite Off
Variants — #pragma multi_compile and shader_feature
A single shader can have many variants (with/without normal map, with/without alpha cutout, …). They are controlled through keywords:
#pragma multi_compile _ MY_FEATURE_ON— always generates both versions (for runtime switching).#pragma shader_feature _ MY_FEATURE_ON— generates only the versions actually used by the material (reduces the build).
#pragma shader_feature_local _NORMALMAP
#ifdef _NORMALMAP
TEXTURE2D(_NormalMap);
SAMPLER(sampler_NormalMap);
#endif
half3 GetNormal(float2 uv)
{
#ifdef _NORMALMAP
return UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uv));
#else
return float3(0, 0, 1);
#endif
}
Each keyword multiplies the number of variants. 5 features → 32 variants. 10 → 1024. This
slows down compilation and bloats the build and shader memory. Use shader_feature_local
(the variant exists only for a specific material, not for all of them).
Custom shaders in Shader Graph
In Shader Graph you can insert a Custom Function block that runs as an HLSL snippet:
void RGBtoHSV_float(float3 rgb, out float3 hsv)
{
float Cmax = max(max(rgb.r, rgb.g), rgb.b);
float Cmin = min(min(rgb.r, rgb.g), rgb.b);
// ... the classic algorithm
hsv = float3(h, s, v);
}
This provides a bridge between the visual graph and non-standard math functions for which there are no ready-made nodes.
What to read next
- Catlike Coding tutorials — the best practical material on URP/HDRP shaders.
- Unity URP Shader Library in
Packages/com.unity.render-pipelines.universal/ShaderLibrary/— these are the sources of the “Lit”, “Unlit”, and “SimpleLit” shaders, which you can learn from. - Frame Debugger — see exactly which constants and textures go into your shader on each draw call.
This concludes the main section on 3D development. Next come the glossary and general links for going deeper.