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:

  1. Shader Graph Fundamentals - Understanding node-based shader creation
  2. Custom Node Development - Building reusable shader components
  3. Complex Material Assembly - Combining nodes for sophisticated effects
  4. Engine Integration - Connecting shader graphs to game engines
  5. 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

  1. Misconception: “Shader graphs are slower than hand-written code” Reality: Modern shader graph compilers generate optimized code comparable to hand-written shaders

  2. Misconception: “Visual tools limit creative possibilities” Reality: Custom nodes allow unlimited extension while maintaining visual workflow benefits

  3. 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:

  1. Input Parameters: UV, Scale, Octaves, Persistence, Lacunarity, Offset
  2. Processing: Multi-octave noise calculation with configurable parameters
  3. 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:

  1. Design a “Color Adjustment” node with brightness, contrast, saturation controls
  2. Create a “UV Animator” node for scrolling, rotating, and scaling UV coordinates
  3. Build a “Blend Modes” node supporting multiple Photoshop-style blend operations
  4. 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

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

  2. Problem: Performance issues with complex node networks Cause: Unnecessary calculations or inefficient node connections Solution: Profile generated shaders and optimize critical paths

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

  1. Shader graphs democratize shader creation by making complex effects accessible through visual tools
  2. Custom nodes enable unlimited extension while maintaining workflow benefits
  3. Modular design principles improve team collaboration and asset reuse
  4. Professional integration requires careful planning for performance and maintainability

Quick Review Questions

  1. What are the main advantages of node-based shader creation over traditional code-based approaches?
  2. How do custom nodes extend the capabilities of shader graph systems?
  3. What considerations are important when designing nodes for team use?
  4. How can shader graphs be optimized for different performance targets?

Answers:

  1. Visual feedback, accessibility to artists, rapid iteration, modular reuse, better team collaboration
  2. Custom nodes allow unlimited functionality extension while maintaining visual workflow benefits
  3. Clear documentation, consistent naming, single responsibility, version control, platform compatibility
  4. 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)