Unity's Universal Render Pipeline ships with capable built-in lit shaders, but the moment you need rim lighting that responds to a local light probe, a dissolve effect driven by a vertex color channel, or a surface that switches between two material layers based on a painted mask, the built-in options hit a wall. Writing your own lit shader in URP is the answer — and with Shader Graph plus a HLSL custom function node, you can build exactly what you need without forking the entire render pipeline.
This guide covers the complete workflow: setting up a URP Shader Graph, wiring the core PBR node network, injecting custom HLSL for specialized lighting, connecting texture inputs with exposed material properties, and trimming GPU cost for mobile and console targets.
---
Setting Up Your URP Shader Graph Workspace
Before writing a single node, confirm your project is running URP, not the Built-in Render Pipeline or HDRP. In Edit → Project Settings → Graphics, the Scriptable Render Pipeline Settings field must point to a `UniversalRenderPipelineAsset`. If it's blank or points to an HDRP asset, shader graphs built for URP will silently fail at runtime.
Create a new shader graph via Assets → Create → Shader Graph → URP → Lit Shader Graph. Name it clearly — `M_Character_CustomLit` is more maintainable than `NewShaderGraph2`. Open it and immediately set the Surface Type to Opaque (or Transparent if needed) and Render Face to Front in the Graph Inspector. These settings cannot be changed at the material level later without creating a new graph.
Organize your graph from the start. Add a Sticky Note for each logical group: Input Textures, PBR Calculations, Custom Functions, Output. Group nodes (select → right-click → Group Selection) as you build. A 40-node graph without grouping becomes unreadable within a week.
The Blackboard panel on the left is where all exposed properties live. Create your base set now: `BaseColorMap` (Texture2D), `NormalMap` (Texture2D), `MaskMap` (Texture2D for packed ORM), `Tiling` (Vector2, default 1,1), `Smoothness` (Float, 0–1), `Metallic` (Float, 0–1). Exposing these here is what makes them appear in the Material Inspector and accessible from C# via `material.SetFloat()` and `material.SetTexture()`.
---
Building the Core PBR Node Network
![]()
The URP Lit Shader Graph exposes a Fragment output block with slots for Base Color, Normal (Tangent Space), Metallic, Smoothness, Emission, Occlusion, and Alpha. Feeding these correctly is the difference between a physically accurate material and one that looks wrong in changing light conditions.
Base Color path:
1. Drag `BaseColorMap` from the Blackboard → Sample Texture 2D node
2. Wire a Tiling And Offset node between the UV input and the Sample node (using the `Tiling` property)
3. Connect the RGBA output → Split → R, G, B channels → Combine → Fragment Base Color
For the Normal Map, use a dedicated Normal Map sample node (not a plain Sample Texture 2D) — it handles the tangent-space conversion automatically. Connect directly to Fragment Normal (Tangent Space).
For the ORM mask map (Occlusion in R, Roughness in G, Metallic in B — Unity HDRP convention; invert if using Unreal-packed textures):
```hlsl
// Inside a Custom Function node (string mode)
void UnpackORM_float(float4 maskSample, out float occlusion, out float roughness, out float metallic)
{
occlusion = maskSample.r;
roughness = maskSample.g; // Smoothness = 1 - roughness
metallic = maskSample.b;
}
```
Wire the Custom Function node's outputs to Fragment Occlusion, Smoothness (with a One Minus node in between for roughness→smoothness), and Metallic.
Emission is often overlooked but critical for emissive props. Use a Multiply node: `EmissionMap * EmissionColor * EmissionIntensity`. Connect to Fragment Emission. Without the intensity scalar, artists can't animate glow pulses from a script.
---
Writing Custom HLSL Functions for URP
The Custom Function node is where URP Shader Graph becomes genuinely powerful. You can write arbitrary HLSL that has access to world-space position, view direction, light data, and any value you pass in.
A common case: height-blended triplanar projection for world-aligned textures on terrain chunks or rocks.
```hlsl
// File: Assets/Shaders/HLSL/Triplanar.hlsl
void TriplanarSample_float(
float3 worldPos,
float3 worldNormal,
float tilingScale,
TEXTURE2D_PARAM(Tex, Samp),
out float4 outColor)
{
float3 blend = abs(worldNormal);
blend = pow(blend, 4.0);
blend /= (blend.x + blend.y + blend.z + 1e-5);
float2 uvX = worldPos.zy * tilingScale;
float2 uvY = worldPos.xz * tilingScale;
float2 uvZ = worldPos.xy * tilingScale;
float4 cx = SAMPLE_TEXTURE2D(Tex, Samp, uvX);
float4 cy = SAMPLE_TEXTURE2D(Tex, Samp, uvY);
float4 cz = SAMPLE_TEXTURE2D(Tex, Samp, uvZ);
outColor = cx * blend.x + cy * blend.y + cz * blend.z;
}
```
In Shader Graph, create a Custom Function node, set Source to File, point it at `Assets/Shaders/HLSL/Triplanar.hlsl`, and set the Function Name to `TriplanarSample`. Wire World Space Position and World Space Normal nodes as inputs, along with your texture and tiling property. The output feeds directly into the Base Color path.
Keep HLSL files under version control alongside the `.shadergraph` assets. Reference them by relative path from the project root (`Assets/...`), not absolute paths, or the build will break on other machines.
---
Connecting Textures and Controlling Material Properties
![]()
Exposed Blackboard properties appear in the Material Inspector automatically, but default values and ranges matter. In the Graph Inspector with a property selected, set Min and Max for floats so sliders appear instead of raw number fields — artists thank you. Set sensible defaults: Smoothness at 0.5, Metallic at 0.0 for most non-metal surfaces.
For keyword-driven variants (e.g., toggling a detail normal map on/off), use Boolean Keyword properties with the Global scope. This generates two shader variants compiled separately, which keeps runtime cost down versus branching on a float.
```
// Blackboard keyword: USE_DETAIL_NORMAL (Boolean, Global)
// In graph: Branch node — True path adds detail normal, False path passes through base normal
```
The `material.EnableKeyword("USE_DETAIL_NORMAL")` / `material.DisableKeyword("USE_DETAIL_NORMAL")` calls in C# then flip the variant at runtime with zero shader overhead.
Instancing works automatically with Shader Graph if you keep properties in the standard Blackboard. Properties NOT in the Blackboard (hardcoded constants in nodes) are baked per-variant — fine for art direction, but break instancing if they differ between instances. Always use Blackboard properties for any value that might vary between instances of the same material.
| Property type | Blackboard exposed | Instancing compatible | C# accessible |
|---|---|---|---|
| Texture | Yes | Per-instance (SRP Batcher) | `SetTexture()` |
| Float/Vector | Yes | Yes | `SetFloat()` / `SetVector()` |
| Color | Yes | Yes | `SetColor()` |
| Hardcoded constant | No | No | No |
| Keyword boolean | Yes (Global) | Yes | `EnableKeyword()` |
---
Optimizing Custom Shaders for Mobile and Console
A custom shader that looks perfect in the editor can tank frame rate on a Mali or Adreno GPU. The main culprits are texture sample count, overdraw, and ALU complexity in the fragment stage.
Reduce texture samples. Pack channels aggressively. Occlusion, Roughness, and Metallic in one RGBA texture (ORM) cuts three samples to one. If your detail layer only needs a normal, store the detail albedo in the alpha channel of the base normal map.
Use `half` precision in HLSL where possible. In the Custom Function node, declare outputs as `half4` or `half3` instead of `float4` on mobile targets. On desktop this makes no difference; on Adreno and Mali it halves ALU cost for those ops.
```hlsl
// Desktop: float precision everywhere (fine)
void RimLight_float(float3 normal, float3 viewDir, float rimPower, out float rimAmount)
{
rimAmount = pow(1.0 - saturate(dot(normal, viewDir)), rimPower);
}
// Mobile: use half precision for the per-fragment calculation
void RimLight_half(half3 normal, half3 viewDir, half rimPower, out half rimAmount)
{
rimAmount = pow(1.0h - saturate(dot(normal, viewDir)), rimPower);
}
```
Shader Graph generates both `_float` and `_half` variants if you provide both — URP automatically selects based on the target platform's precision capabilities.
Profile with Frame Debugger and GPU-based profilers (Snapdragon Profiler, Mali Graphics Debugger, or Xcode GPU Frame Capture). The Unity Profiler's GPU Usage module shows per-pass cost but not per-object shader cost — you need platform tools for that level of detail.
For LOD scenarios, create simplified shader graph variants (fewer texture samples, no detail layer, simplified normal) and assign them to lower LOD levels via scripted material swaps, or use Material LODs if your pipeline supports it.
---
Take Your Materials Further with Ready-Made Assets
Building custom shaders from scratch is the right call for hero assets and unique surface types. For background props, modular kits, and fill assets, starting from professionally made game-ready materials saves significant time. Browse https://bitsoulhosting.com/marketplace for a growing library of GLB, FBX, and texture-ready assets built to PBR standards — compatible with the URP workflows covered in this guide.
Whether you're extending an existing material with a custom HLSL function or building a shader network from scratch, the BitSoul marketplace gives you production-quality base assets to test against so you can validate your shader behaves correctly on real geometry under real lighting conditions.