~3 min read

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
}
Web

WebGL/WebGPU: you write the vertex and fragment shader directly (GLSL/WGSL), create the program yourself, compile it, and pass uniforms.

Unity

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 be CGPROGRAM ... 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:

  • Colorfloat4
  • Vectorfloat4
  • Float, Range(0, 1)float
  • 2D, 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
}
Variant explosion

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.

  • 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.