Custom Shader Graphs for 2D
Learning Objectives: By the end of this lesson, you will be able to:
- Create reusable shader nodes for efficient 2D graphics development
- Build complex materials using visual node-based workflows
- Integrate custom shader graphs seamlessly into modern game engines
- Design modular shader systems for team collaboration and asset reuse
1. Welcome and Introduction (5 minutes)
Icebreaker Activity
Have you ever spent hours writing the same shader code over and over again, just with slight variations? Or perhaps you’ve wanted to create complex visual effects but found the barrier between your creative vision and the technical implementation too steep? Imagine if you could build shaders like assembling LEGO blocks - connecting pre-made, reliable components to create infinite combinations. That’s the power of shader graphs! Today we’ll learn how to create visual, node-based shader systems that make complex 2D effects accessible to artists and programmers alike.
Lesson Overview
- Duration: 90 minutes
- Prerequisites: Solid understanding of shader programming, familiarity with node-based editors
- Materials Needed: Shader graph editor (Unity Shader Graph, Unreal Material Editor, or custom tool), various texture samples
2. Learning Objectives and Session Outline (5 minutes)
What We’ll Cover Today:
- Shader Graph Fundamentals - Understanding node-based shader creation
- Custom Node Development - Building reusable shader components
- Complex Material Assembly - Combining nodes for sophisticated effects
- Engine Integration - Connecting shader graphs to game engines
- Team Workflow Optimization - Organizing and sharing shader assets
Success Criteria:
By the end of this session, you should be able to create custom shader nodes, build complex 2D materials using visual workflows, and understand how to integrate these systems into professional development pipelines.
3. Foundation Knowledge (15-20 minutes)
Core Concepts
What Are Shader Graphs?
Shader graphs are visual, node-based tools that allow developers and artists to create shaders by connecting functional blocks rather than writing code directly. Each node represents a specific operation or calculation that can be combined with others to create complex effects.
Real-World Analogy: Think of shader graphs like a visual synthesizer for graphics. Just as musicians can create complex sounds by connecting oscillators, filters, and effects modules, shader artists can create stunning visuals by connecting texture samples, math operations, and effect nodes. The underlying complexity is hidden behind intuitive visual connections.
Why Shader Graphs Matter:
- Accessibility: Artists can create shaders without deep programming knowledge
- Rapid Iteration: Visual feedback allows for quick experimentation
- Modularity: Reusable nodes reduce development time
- Team Collaboration: Visual representation improves communication between programmers and artists
Node-Based Architecture
Shader graphs operate on a data-flow paradigm where information flows from input nodes through processing nodes to output nodes.
Node Types:
- Input Nodes: Provide data (textures, colors, time, UV coordinates)
- Processing Nodes: Transform data (math operations, color adjustments, noise generation)
- Utility Nodes: Combine or route data (multiply, add, mix, split)
- Output Nodes: Define final shader properties (base color, normal, emission)
Graph Execution Model
Unlike traditional programming, shader graphs execute in parallel across all pixels simultaneously. Understanding this parallel execution is crucial for creating efficient graphs.
Key Terminology
- Socket/Port: Connection points on nodes for data flow
- Data Types: Vector2, Vector3, Vector4, Float, Boolean, Texture2D
- Sub-graph: A collection of nodes grouped into a reusable component
- Master Node: The final output node that defines shader properties
- Node Library: Collection of available nodes for graph construction
- Custom Function Node: User-defined node containing custom HLSL/GLSL code
4. Detailed Explanation and Examples (25-30 minutes)
Building Custom Node Libraries
Custom Math Nodes
// Custom gradient mapping node
void GradientMap_float(float Input, Texture2D GradientTexture, float4 Output)
{
// Sample the gradient texture using input value as UV.x
float2 uv = float2(Input, 0.5);
Output = SAMPLE_TEXTURE2D(GradientTexture, sampler_GradientTexture, uv);
}
Procedural Pattern Nodes
// Hexagonal tiling pattern node
void HexTile_float(float2 UV, float Scale, out float2 HexUV, out float2 HexID)
{
UV *= Scale;
// Hexagonal grid transformation
float2 s = float2(1.732, 1.0) * 0.5;
float2 h = float2(0.5, 1.0) / s;
float2 a = fmod(UV, s);
float2 b = fmod(UV + s * 0.5, s) - s * 0.5;
HexUV = dot(a, a) < dot(b, b) ? a : b;
HexID = UV - HexUV;
}
Animation Utility Nodes
// Smooth pulse animation node
void SmoothPulse_float(float Time, float Frequency, float Sharpness, out float Output)
{
float phase = Time * Frequency;
float pulse = sin(phase);
// Apply sharpness using power function
pulse = sign(pulse) * pow(abs(pulse), 1.0 / max(Sharpness, 0.1));
// Remap from [-1,1] to [0,1]
Output = pulse * 0.5 + 0.5;
}
Complex Material Composition
Layered Material System
A sophisticated approach to 2D materials involves creating layers that can be combined using various blending modes:
// Material layer blending node
void LayerBlend_float(float4 Base, float4 Overlay, float Opacity, int BlendMode, out float4 Result)
{
float4 blended;
switch(BlendMode)
{
case 0: // Normal
blended = Overlay;
break;
case 1: // Multiply
blended = Base * Overlay;
break;
case 2: // Screen
blended = 1.0 - (1.0 - Base) * (1.0 - Overlay);
break;
case 3: // Overlay
blended = Base < 0.5 ? 2.0 * Base * Overlay : 1.0 - 2.0 * (1.0 - Base) * (1.0 - Overlay);
break;
case 4: // Soft Light
blended = (1.0 - 2.0 * Overlay) * Base * Base + 2.0 * Overlay * Base;
break;
default:
blended = Overlay;
break;
}
Result = lerp(Base, blended, Opacity);
}
Parameter Management System
// Material parameter controller node
void MaterialController_float(
float4 BaseColor,
float Metallic,
float Smoothness,
float NormalStrength,
float EmissionIntensity,
float2 Tiling,
float2 Offset,
out float4 OutColor,
out float OutMetallic,
out float OutSmoothness,
out float OutNormal,
out float OutEmission,
out float2 OutUV)
{
OutColor = BaseColor;
OutMetallic = saturate(Metallic);
OutSmoothness = saturate(Smoothness);
OutNormal = NormalStrength;
OutEmission = max(0, EmissionIntensity);
OutUV = UV * Tiling + Offset;
}
Engine Integration Patterns
Unity Shader Graph Integration
// Unity script for dynamic shader graph parameters
public class DynamicMaterial : MonoBehaviour
{
[SerializeField] private Material materialInstance;
[SerializeField] private string colorPropertyName = "_BaseColor";
[SerializeField] private string intensityPropertyName = "_Intensity";
private MaterialPropertyBlock propertyBlock;
void Start()
{
propertyBlock = new MaterialPropertyBlock();
}
public void UpdateMaterialProperties(Color newColor, float intensity)
{
propertyBlock.SetColor(colorPropertyName, newColor);
propertyBlock.SetFloat(intensityPropertyName, intensity);
GetComponent<Renderer>().SetPropertyBlock(propertyBlock);
}
}
Custom Node Definition System
{
"nodeName": "WaveDistortion",
"category": "Animation",
"description": "Creates wave-based UV distortion",
"inputs": [
{"name": "UV", "type": "Vector2"},
{"name": "Frequency", "type": "Float", "default": 5.0},
{"name": "Amplitude", "type": "Float", "default": 0.1},
{"name": "Speed", "type": "Float", "default": 1.0},
{"name": "Time", "type": "Float"}
],
"outputs": [
{"name": "DistortedUV", "type": "Vector2"}
],
"implementation": "WaveDistortion.hlsl"
}
Practical Applications
Game Development Workflows:
- Character customization systems with material variants
- Environmental storytelling through dynamic materials
- UI effects with consistent visual language
- Procedural content generation for backgrounds
Team Collaboration Benefits:
- Artists can iterate without programmer intervention
- Consistent naming conventions and node organization
- Version control-friendly asset management
- Documentation embedded in visual graphs
Performance Optimization:
- Automatic shader optimization through graph analysis
- LOD generation for different performance tiers
- Conditional compilation based on platform capabilities
Common Misconceptions
-
Misconception: “Shader graphs are slower than hand-written code” Reality: Modern shader graph compilers generate optimized code comparable to hand-written shaders
-
Misconception: “Visual tools limit creative possibilities” Reality: Custom nodes allow unlimited extension while maintaining visual workflow benefits
-
Misconception: “Shader graphs are only for simple effects” Reality: Complex AAA games use sophisticated shader graph systems for production-quality effects
5. Live Coding Demonstration (20-25 minutes)
Example 1: Building a Reusable Noise Node
// Improved multi-octave noise node
void ImprovedNoise_float(
float2 UV,
float Scale,
int Octaves,
float Persistence,
float Lacunarity,
float2 Offset,
out float NoiseValue)
{
float2 p = (UV + Offset) * Scale;
float noise = 0.0;
float amplitude = 1.0;
float frequency = 1.0;
float maxValue = 0.0;
for(int i = 0; i < Octaves; i++)
{
// Simple noise function (in practice, use more sophisticated noise)
float n = frac(sin(dot(p * frequency, float2(12.9898, 78.233))) * 43758.5453);
noise += n * amplitude;
maxValue += amplitude;
amplitude *= Persistence;
frequency *= Lacunarity;
}
NoiseValue = noise / maxValue; // Normalize
}
Graph Structure:
- Input Parameters: UV, Scale, Octaves, Persistence, Lacunarity, Offset
- Processing: Multi-octave noise calculation with configurable parameters
- Output: Normalized noise value ready for further processing
Example 2: Dynamic Water Material Graph
// Water surface animation node
void WaterSurface_float(
float2 UV,
float Time,
Texture2D NormalMap,
float WaveSpeed,
float WaveScale,
float DistortionStrength,
out float2 AnimatedUV,
out float3 SurfaceNormal,
out float FoamMask)
{
// Create flowing water animation
float2 flow1 = float2(sin(Time * WaveSpeed), cos(Time * WaveSpeed * 0.7)) * 0.1;
float2 flow2 = float2(cos(Time * WaveSpeed * 1.3), sin(Time * WaveSpeed * 0.9)) * 0.08;
// Sample normal map with different flows for complexity
float2 uv1 = UV * WaveScale + flow1;
float2 uv2 = UV * WaveScale * 1.3 + flow2;
float3 normal1 = UnpackNormal(SAMPLE_TEXTURE2D(NormalMap, sampler_NormalMap, uv1));
float3 normal2 = UnpackNormal(SAMPLE_TEXTURE2D(NormalMap, sampler_NormalMap, uv2));
// Blend normals
SurfaceNormal = normalize(normal1 + normal2);
// Create animated UV for refraction
AnimatedUV = UV + SurfaceNormal.xy * DistortionStrength;
// Generate foam based on normal variance
float normalVariance = length(normal1.xy - normal2.xy);
FoamMask = smoothstep(0.3, 0.8, normalVariance);
}
Example 3: Modular Character Material System
// Character material composer node
void CharacterMaterial_float(
Texture2D BaseTexture,
Texture2D DetailTexture,
Texture2D MaskTexture,
float4 PrimaryColor,
float4 SecondaryColor,
float4 AccentColor,
float2 UV,
float DetailIntensity,
float WearAmount,
out float4 FinalColor,
out float3 FinalNormal,
out float Roughness)
{
// Sample base textures
float4 baseColor = SAMPLE_TEXTURE2D(BaseTexture, sampler_BaseTexture, UV);
float4 detail = SAMPLE_TEXTURE2D(DetailTexture, sampler_DetailTexture, UV * 4.0);
float4 mask = SAMPLE_TEXTURE2D(MaskTexture, sampler_MaskTexture, UV);
// Color customization based on mask channels
float4 customColor = baseColor;
customColor.rgb = lerp(customColor.rgb, PrimaryColor.rgb, mask.r);
customColor.rgb = lerp(customColor.rgb, SecondaryColor.rgb, mask.g);
customColor.rgb = lerp(customColor.rgb, AccentColor.rgb, mask.b);
// Apply detail
customColor.rgb = lerp(customColor.rgb, customColor.rgb * detail.rgb, DetailIntensity);
// Apply wear effect
float wearMask = smoothstep(0.4, 0.6, detail.a + WearAmount);
customColor.rgb = lerp(customColor.rgb, customColor.rgb * 0.7, wearMask);
FinalColor = customColor;
FinalNormal = float3(0, 0, 1); // Simplified for 2D
Roughness = lerp(0.8, 0.2, mask.a); // Material roughness based on alpha
}
Example 4: Performance-Optimized Effect Graph
// LOD-aware particle material node
void ParticleLOD_float(
float2 UV,
float4 BaseColor,
Texture2D ParticleTexture,
float DistanceFromCamera,
float LODThreshold1,
float LODThreshold2,
out float4 FinalColor,
out float Alpha)
{
float4 texColor = float4(1, 1, 1, 1);
// LOD 0: Full detail
if(DistanceFromCamera < LODThreshold1)
{
texColor = SAMPLE_TEXTURE2D(ParticleTexture, sampler_ParticleTexture, UV);
}
// LOD 1: Reduced sampling
else if(DistanceFromCamera < LODThreshold2)
{
// Use lower mip level for distant particles
texColor = SAMPLE_TEXTURE2D_LOD(ParticleTexture, sampler_ParticleTexture, UV, 2);
}
// LOD 2: Solid color only
else
{
texColor = float4(1, 1, 1, 1); // No texture sampling
}
FinalColor = BaseColor * texColor;
Alpha = FinalColor.a;
}
Debugging and Validation Tools
// Shader graph debug visualization node
void DebugVisualization_float(
float4 InputValue,
int DebugMode,
out float4 DebugOutput)
{
switch(DebugMode)
{
case 0: // Normal output
DebugOutput = InputValue;
break;
case 1: // Red channel only
DebugOutput = float4(InputValue.r, 0, 0, 1);
break;
case 2: // Green channel only
DebugOutput = float4(0, InputValue.g, 0, 1);
break;
case 3: // Blue channel only
DebugOutput = float4(0, 0, InputValue.b, 1);
break;
case 4: // Alpha as grayscale
DebugOutput = float4(InputValue.a, InputValue.a, InputValue.a, 1);
break;
case 5: // UV coordinates as color
DebugOutput = float4(UV, 0, 1);
break;
default:
DebugOutput = float4(1, 0, 1, 1); // Magenta for invalid mode
break;
}
}
6. Hands-On Practice (30-40 minutes)
Exercise 1: Custom Utility Node Library (15 minutes)
Objective: Create a set of reusable utility nodes for common 2D operations
Instructions:
- Design a “Color Adjustment” node with brightness, contrast, saturation controls
- Create a “UV Animator” node for scrolling, rotating, and scaling UV coordinates
- Build a “Blend Modes” node supporting multiple Photoshop-style blend operations
- Test nodes in combination to create complex effects
Starter Template:
// Color Adjustment Node Template
void ColorAdjust_float(
float4 InputColor,
float Brightness,
float Contrast,
float Saturation,
out float4 OutputColor)
{
// Implement brightness adjustment
// Implement contrast adjustment
// Implement saturation adjustment
OutputColor = InputColor; // Replace with actual implementation
}
Success Criteria:
- Nodes work independently and in combination
- Parameters provide intuitive control ranges
- Visual results match expectations from image editing software
- Nodes can be easily integrated into larger graphs
Exercise 2: Dynamic Material System (15 minutes)
Objective: Build a shader graph that creates a customizable 2D sprite material
Requirements:
- Support base color texture with color tinting
- Include normal mapping for lighting effects
- Add emission areas for glowing details
- Implement damage/wear overlays
- Allow real-time parameter adjustment
Challenges:
- Create smooth transitions between undamaged and damaged states
- Implement multiple tint zones using mask textures
- Add animated elements (pulsing glow, flowing energy)
- Optimize for mobile performance
Exercise 3: Effect Composition Graph (10 minutes)
Objective: Design a complex visual effect using multiple custom nodes
Advanced Challenge:
- Choose an effect type (magical aura, elemental damage, environmental atmosphere)
- Combine at least 4 different custom nodes
- Include time-based animation and interactive parameters
- Create variations of the effect through parameter changes
Professional Extension:
- Implement LOD system for performance scaling
- Add debug visualization modes
- Create presets for different quality levels
- Document the graph for team use
7. Common Pitfalls and Troubleshooting (10 minutes)
Frequent Mistakes
-
Problem: Node graphs become visually cluttered and hard to follow Cause: Poor organization and lack of grouping/commenting Solution: Use sub-graphs, clear naming conventions, and visual organization
-
Problem: Performance issues with complex node networks Cause: Unnecessary calculations or inefficient node connections Solution: Profile generated shaders and optimize critical paths
-
Problem: Inconsistent behavior between different platforms Cause: Platform-specific shader compilation differences Solution: Test on target platforms and use platform-appropriate node implementations
Debugging Node Networks
// Node network performance profiler
void ProfileNode_float(
float4 Input,
float ProfilerEnabled,
float NodeID,
out float4 Output,
out float PerformanceCost)
{
float startTime = _Time.y;
// Your node implementation here
Output = Input; // Placeholder
float endTime = _Time.y;
PerformanceCost = ProfilerEnabled > 0.5 ? (endTime - startTime) : 0.0;
}
Best Practices for Node Design
- Single Responsibility: Each node should have one clear purpose
- Consistent Naming: Use descriptive names following team conventions
- Parameter Validation: Ensure inputs are within expected ranges
- Documentation: Include descriptions and usage examples
- Version Control: Track node changes and maintain backward compatibility
8. Best Practices and Professional Tips (5 minutes)
Node Library Organization
// Standard node header template
/*
* Node Name: GradientMapping
* Category: Color/Utility
* Description: Maps input values to colors using a gradient texture
* Version: 1.2
* Author: [Your Name]
*
* Inputs:
* - Input (Float): Value to map (0.0 to 1.0)
* - GradientTexture (Texture2D): Gradient lookup texture
* - Intensity (Float): Effect strength (0.0 to 2.0)
*
* Outputs:
* - Color (Vector4): Mapped color value
*
* Usage Notes:
* - Gradient texture should be horizontal (width > height)
* - Input values outside 0-1 range will be clamped
*/
Team Workflow Strategies
{
"nodeLibraryStructure": {
"Core": ["Math", "UV", "Color", "Texture"],
"Effects": ["Distortion", "Animation", "Lighting"],
"Utility": ["Debug", "Conversion", "Validation"],
"Project": ["Character", "Environment", "UI"]
},
"namingConvention": {
"nodes": "PascalCase_CategorySuffix",
"parameters": "camelCase",
"outputs": "PascalCase"
},
"versionControl": {
"nodeVersioning": "semantic",
"backwardCompatibility": "required",
"deprecationProcess": "gradual"
}
}
Performance Optimization Guidelines
- Node Complexity: Keep individual nodes simple and focused
- Graph Depth: Minimize deep cascading calculations
- Texture Usage: Cache texture samples when used multiple times
- Conditional Logic: Use mathematical blending instead of branches
- Platform Scaling: Implement quality tiers for different hardware
Industry Integration Patterns
// Example integration with game systems
public class MaterialGraphController : MonoBehaviour
{
[SerializeField] private MaterialGraphAsset materialGraph;
[SerializeField] private Dictionary<string, float> floatParameters;
[SerializeField] private Dictionary<string, Color> colorParameters;
public void UpdateGraphParameter(string parameterName, object value)
{
materialGraph.SetParameter(parameterName, value);
ApplyMaterialChanges();
}
private void ApplyMaterialChanges()
{
// Update renderer materials with new parameters
var renderers = GetComponentsInChildren<Renderer>();
foreach(var renderer in renderers)
{
materialGraph.ApplyToMaterial(renderer.material);
}
}
}
9. Summary and Review (5 minutes)
Key Takeaways
- Shader graphs democratize shader creation by making complex effects accessible through visual tools
- Custom nodes enable unlimited extension while maintaining workflow benefits
- Modular design principles improve team collaboration and asset reuse
- Professional integration requires careful planning for performance and maintainability
Quick Review Questions
- What are the main advantages of node-based shader creation over traditional code-based approaches?
- How do custom nodes extend the capabilities of shader graph systems?
- What considerations are important when designing nodes for team use?
- How can shader graphs be optimized for different performance targets?
Answers:
- Visual feedback, accessibility to artists, rapid iteration, modular reuse, better team collaboration
- Custom nodes allow unlimited functionality extension while maintaining visual workflow benefits
- Clear documentation, consistent naming, single responsibility, version control, platform compatibility
- LOD systems, conditional compilation, performance profiling, platform-specific optimizations
For Instructors
Teaching Tips
- Start with simple examples: Build complexity gradually from basic node connections
- Emphasize visual organization: Teach good graph layout and documentation practices
- Show real-world applications: Demonstrate how professional teams use shader graphs
- Encourage experimentation: Let students discover creative combinations of nodes
Common Student Questions
- “Are shader graphs as powerful as hand-written code?” Yes, especially with custom nodes
- “How do I organize complex graphs?” Use sub-graphs, clear naming, and visual grouping
- “Can I convert existing shaders to graphs?” Usually yes, but may require custom nodes for complex functions
Assessment Ideas
- Node library project: Students create a themed collection of reusable nodes
- Complex effect recreation: Build shader graph versions of existing code-based effects
- Team collaboration exercise: Work together on shared node library
Adaptation Notes
- For programmers: Focus on custom node development and system architecture
- For artists: Emphasize visual workflow and creative possibilities
- Online delivery: Ensure students have access to appropriate shader graph tools
- Time management: Theory (20 min), demonstrations (25 min), hands-on practice (35 min), review (10 min)