Files
2026-07-14 00:03:40 +01:00

347 lines
18 KiB
Markdown

# Shader Architecture Patterns for Node-Based Image Compositing
Research conducted 2026-07-13.
---
## 1. Single Shader vs Multiple Shaders
### How Professional Compositors Handle It
**Blender Compositor (GPU backend)** — The most relevant case study:
- Blender's GPU compositor collapses multiple connected nodes into a **"compile unit"** and generates a **single compute shader** per unit.
- The `ShaderOperation` class iterates through a `compile_unit_` (a set of nodes) and links their GLSL logic into one shader: `source/blender/compositor/intern/shader_operation.cc:122-135`.
- Simple per-pixel operations (Math, Color Mix, Invert, etc.) are fused into a single pass. Operations that can't be expressed as shaders fall back to `MultiFunctionProcedureOperation` on CPU.
- **Key insight**: Blender uses a **hybrid approach** — fuse what you can into single shaders, fall back to separate passes for complex operations (blur, glare, convolution).
**Natron** — CPU-based compositor using OpenFX plugins:
- Each node is a separate processing unit (separate plugin call).
- Multi-threaded tile-based processing per node.
- Not GPU-accelerated; no shader fusion.
**DaVinci Resolve / Fusion** — Proprietary:
- Uses a node graph where each node can have internal multi-pass processing.
- Fusion's "Flow Region" system groups nodes for optimization.
- Effectively separate shaders per node, with internal optimization.
### Recommended Approach for Prism
**Use separate shaders per node, with optional fusion of simple nodes.** Rationale:
- Nodes in a compositing graph have diverse operations (blur vs. blend vs. color grade). An uber-shader would have massive register pressure and poor occupancy.
- Simple per-pixel operations (math, color mix, gamma) can be fused into chains as an optimization.
- Complex operations (blur, convolutions, warps) need their own shader passes anyway.
---
## 2. Texture Ping-Ponging
### The Pattern
Texture ping-ponging is the fundamental technique for chaining GPU image operations:
1. Allocate two textures (A and B) at the target resolution.
2. Bind texture A as input, render to texture B.
3. Swap: bind texture B as input, render to texture A.
4. Repeat for as many passes as needed.
```
Pass 1: Read(A) → Write(B) [e.g., blur]
Pass 2: Read(B) → Write(A) [e.g., color grade]
Pass 3: Read(A) → Write(B) [e.g., blend]
Final: Display(B)
```
### How It Works in Practice
**WebGL/Fragment Shader approach** (from multiple sources):
- Create Framebuffer Objects (FBOs) with texture attachments.
- Bind FBO → render fullscreen quad → output goes to texture.
- Bind different FBO or default framebuffer → read from that texture.
**Vulkan approach**:
- Use `VkImage` objects as both sampler inputs and render targets.
- Between passes, issue a pipeline barrier (`VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT → VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT`).
- Manage image layouts: `SHADER_READ_ONLY_OPTIMAL``COLOR_ATTACHMENT_OPTIMAL``SHADER_READ_ONLY_OPTIMAL`.
**Metal approach** (from Kosikowski's article):
- Compute shaders read from `inTexture` and write to `outTexture`.
- Swap the texture references between passes.
### Important Considerations
- **Image layout transitions** are critical in Vulkan. Each pass requires the texture to be in the correct layout.
- **Load/store ops**: For intermediate textures, use `VK_ATTACHMENT_LOAD_OP_DONT_CARE` and `VK_ATTACHMENT_STORE_OP_DONT_CARE` when contents aren't needed — saves bandwidth.
- **Resolution management**: Different nodes may operate at different resolutions. The compositor must manage a texture pool and handle up/downsampling.
- **On tile-based GPUs (mobile)**: Multiple passes that write/read intermediate textures to external memory is expensive. Use Vulkan subpasses or `VK_KHR_dynamic_rendering_local_read` to keep data on-chip.
---
## 3. Shader Composition Strategies
### 3a. Runtime Shader Generation
**Blender's approach** (most relevant):
- The compositor has a `gpu_shader_compositor_code_generation.glsl` library.
- `ShaderOperation` generates GLSL code by iterating through a compile unit's nodes and concatenating their shader code contributions.
- The generated code is compiled via Blender's `GPUMaterial` system.
- Node settings are passed as UBOs; images are bound as `image2D`/`sampler2D`.
**Godot's compositor approach**:
- Uses a **template + injection** pattern:
```
const template_shader = """
#version 450
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
layout(rgba16f, set = 0, binding = 0) uniform image2D color_image;
void main() {
// ... boilerplate ...
vec4 color = imageLoad(color_image, uv);
#COMPUTE_CODE
imageStore(color_image, uv, color);
}
"""
```
- User shader code replaces `#COMPUTE_CODE` at runtime.
- Compiled via `rd.shader_create_from_spirv()` at runtime.
**OGRE's RTSS (Run Time Shader System)**:
- Not an uber-shader. Manages a set of opaque `SubRenderState` components.
- Each component implements a specific effect.
- Components are composed and code-generated at runtime.
- Avoids the "exploding `#ifdef`" problem of uber-shaders.
### 3b. Shader Permutations vs Branching
**The permutation problem** (from MJP's detailed analysis):
- Each feature combination = separate compiled shader.
- Exponential growth: N binary features = 2^N permutations.
- Costs: compilation time, memory, PSO creation, binding overhead, instruction cache pressure.
- **Register pressure**: Uber-shaders with many features need more registers, reducing occupancy even for materials that don't use all features.
**Branching rules for GPUs**:
- **Uniform branches** (same path for all pixels in a warp): Essentially free. The driver compiles both paths and selects one.
- **Divergent branches** (different paths within a warp): Both paths execute serially, wasting cycles.
- **Branches on uniforms/constant data**: OK and performant.
- **Branches based on per-pixel data**: Expensive when pixels in the same warp diverge.
**Best practice**: Use **Vulkan specialization constants** for compile-time branching (uber-shader with static branching). This gives you permutation-like performance with fewer actual shader binaries. The driver can optimize away dead code paths.
### 3c. Compute Shaders vs Fragment Shaders
**Fragment shaders are generally faster for simple image processing:**
- Fragment shaders benefit from hardware texture prefetch and caching optimized for 2D spatial locality.
- For simple per-pixel operations (passthrough, basic color transforms): fragment shaders ~30% faster than compute (Leadwerks benchmarks: 770 FPS vs 600 FPS).
- For multi-pass chained operations: fragment shaders maintain advantage (670 FPS vs 180 FPS at 10 passes).
**Compute shaders are better when:**
- You need **shared memory** access within workgroups (e.g., local convolution, shared reductions).
- You need **read-write access** to the same texture (e.g., iterative algorithms like Jump Flood).
- You're doing operations that aren't naturally per-pixel (histogram, reduction, sorting).
- You want explicit control over workgroup dispatch.
**On tile-based GPUs (mobile)**: Arm documentation explicitly warns: "Compute shaders can be slower and less energy-efficient than fragment shaders for simple post-processing workloads."
**For compositing**: Use fragment shaders for per-pixel operations (blend, color grade, transform). Use compute for multi-pass algorithms that need shared memory (blur separable passes, glare FFT, flood fill).
### 3d. Bindless Textures and Descriptor Arrays
**The concept**: Instead of binding one texture per descriptor set, bind a large array of descriptors once. Access textures by integer index in shaders.
**Vulkan implementation** (from `VK_EXT_descriptor_indexing`, core since Vulkan 1.2):
```glsl
// GLSL
#extension GL_EXT_nonuniform_qualifier : enable
layout(set = 1, binding = 10) uniform sampler2D textures[];
vec4 color = texture(textures[albedo_id], uv);
```
**Key features**:
- `VK_DESCRIPTOR_BINDING_UPDATE_AFTER_BIND_BIT`: Update descriptors after binding.
- `VK_DESCRIPTOR_BINDING_PARTIALLY_BOUND_BIT`: Not all slots need valid descriptors.
- `NonUniformResourceIndex`: For divergent indexing within a warp.
**For a compositor**: Bindless is extremely useful. All input textures from the graph can live in one descriptor set. Each node shader indexes into the set by texture ID. This avoids re-binding descriptor sets per node.
**Trade-off**: Indirect memory loads can be slower on some mobile GPUs. Desktop GPUs handle this well.
---
## 4. Slang-Specific Patterns
### Overview
Slang is a Khronos-hosted, open-source shading language. HLSL-like syntax with modern features:
- Targets: SPIR-V (Vulkan), DXIL (D3D12), Metal, CUDA, WGSL, CPU.
- Hosted by Khronos with broad industry governance.
- Based on years of NVIDIA/CMU/Stanford/MIT research.
### Key Features Relevant to Compositing
**Modules**: Slang supports `module` and `import` for separate compilation. Modules compile to a custom IR and can be linked at runtime to produce SPIR-V or DXIL. This is **exactly what a node compositor needs** — each node type can be a module, and compositions are linked at runtime.
**Generics and Interfaces**: Instead of #ifdef permutations, use generics:
```slang
interface IImageOp {
float4 evaluate(float4 input, PixelContext ctx);
}
struct BlendOp : IImageOp {
float4 evaluate(float4 input, PixelContext ctx) { ... }
}
// Generic function specialized at compile time
T evaluateGraph<T : IImageOp>(T op, float4 input) {
return op.evaluate(input, ctx);
}
```
**Runtime code generation**: Slang supports **runtime compilation and linking**. From the docs: "Slang modules can be independently compiled offline to a custom IR and then linked at runtime to generate code in formats such as DXIL or SPIR-V." This means you can:
1. Compile each node's shader as a Slang module.
2. At graph edit time, link modules together.
3. Generate the final SPIR-V/DXIL for the composed graph.
**Reflection API**: `TypeReflection`, `VariableReflection`, `getLayout()` allow querying shader structure at runtime — useful for automatically creating descriptor layouts.
**Automatic Differentiation**: `fwd_diff` and `bwd_diff` for gradient-based operations (relevant for differentiable compositing or learned operations).
### Slang vs GLSL/HLSL for Compositing
| Feature | Slang | GLSL | HLSL |
|---------|-------|------|------|
| Separate compilation | ✅ Modules | ❌ Single TU | ⚠️ Limited |
| Runtime linking | ✅ | ❌ | ❌ |
| Generics/interfaces | ✅ | ❌ | ⚠️ Templates (limited) |
| Cross-platform | ✅ (Vulkan/Metal/DX/CUDA) | ⚠️ (OpenGL/Vulkan) | ⚠️ (DX only) |
| Vulkan SPIR-V | ✅ First-class | ✅ via glslc | ⚠️ via dxc |
| Runtime compilation | ✅ | ❌ | ❌ |
| HLSL compatibility | ✅ Most HLSL compiles out-of-box | ❌ | ✅ |
### Recommendation
**Slang is the ideal choice for a Vulkan-based compositor.** Its module system directly solves the "runtime shader composition" problem. Each node type = a Slang module. Graph composition = module linking. No need for runtime string-based code generation.
---
## 5. Vulkan-Specific Considerations
### Multi-Pass Image Processing
**Render Pass approach** (traditional):
```c
// Pass 1: Blur
VkRenderPassBeginInfo rp1 = { .renderPass = blurPass, .framebuffer = blurFBO };
vkCmdBeginRenderPass(cmd, &rp1, VK_SUBPASS_CONTENTS_INLINE);
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, blurPipeline);
vkCmdDraw(cmd, 4, 1, 0, 0); // fullscreen quad
vkCmdEndRenderPass(cmd);
// Barrier between passes
VkImageMemoryBarrier barrier = {
.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
.dstAccessMask = VK_ACCESS_SHADER_READ_BIT,
.oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
};
vkCmdPipelineBarrier(cmd, ...);
// Pass 2: Color grade
VkRenderPassBeginInfo rp2 = { .renderPass = gradePass, .framebuffer = gradeFBO };
vkCmdBeginRenderPass(cmd, &rp2, VK_SUBPASS_CONTENTS_INLINE);
vkCmdBindDescriptorSets(cmd, ..., gradeDescriptorSet); // binds blur result as texture
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, gradePipeline);
vkCmdDraw(cmd, 4, 1, 0, 0);
vkCmdEndRenderPass(cmd);
```
**Dynamic Rendering approach** (Vulkan 1.3 / `VK_KHR_dynamic_rendering`):
- Skip `VkRenderPass` and `VkFramebuffer` objects entirely.
- Use `vkCmdBeginRendering` with `VkRenderingInfo` specifying attachments directly.
- Simpler API, fewer objects to manage.
### Descriptor Management Best Practices
From ARM and NVIDIA guidelines:
- **Don't allocate descriptor sets on hot paths.** Pre-allocate pools.
- **Use `VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC` / `VK_DESCRIPTOR_TYPE_STORAGE_BUFFER_DYNAMIC`** for per-draw offsets instead of new descriptor sets.
- **Pack descriptor bindings** as tightly as possible. No holes.
- **Reuse descriptor sets** — update them rather than reallocating.
- For a compositor with bindless: create ONE large descriptor set with all textures. Bind once, index by ID.
### Pipeline Layout Optimization
- Keep pipeline layouts consistent across similar shaders to reduce pipeline switches.
- Use **push constants** for small, per-pass data (resolution, time, parameters) — cheaper than UBOs for small data.
- Pre-create pipeline cache and use `VkPipelineCache` to speed up PSO creation.
### Synchronization for Multi-Pass
- Use **pipeline barriers** between passes that read/write the same images.
- For independent passes (operating on different textures), no barrier needed — can even record in parallel.
- Use **events** for fine-grained synchronization within a command buffer.
- **Timeline semaphores** (Vulkan 1.2+) for more flexible GPU-GPU synchronization.
### Tile-Based GPU Optimization (Mobile)
- Use **subpasses** to keep intermediate data in tile memory (on-chip).
- `VK_KHR_dynamic_rendering_local_read` allows subpass-like behavior with dynamic rendering.
- Set `loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE` and `storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE` for transient intermediates.
- Merge subpasses when they share attachments (ARM: ≤8 unique attachments).
---
## 6. Industry Best Practices
### The Render Graph Pattern
Modern engines use a **frame graph** / **render graph** (DAG) for multi-pass rendering:
1. **Declare passes** and their resource inputs/outputs.
2. **Analyze dependencies** — build execution order automatically.
3. **Infer synchronization** — barriers are generated from resource usage.
4. **Alias resources** — textures with non-overlapping lifetimes can share memory.
5. **Cull unused passes** — if an output isn't used, skip the pass.
This is the most mature pattern for managing multi-pass image processing. Referenced in:
- Vulkan Tutorial: "Engine Architecture: Rendering Pipeline"
- Cat Game's "Advanced Vulkan Rendering: Building a Modern Frame Graph"
- Frostbite's "FrameGraph" (EA/DICE)
### Fusing Operations
From TFLite GPU and Blender compositor:
- **Fuse element-wise operations** with computationally expensive ones (activations + convolution, color transforms + blend).
- **Inline parameters** directly into shader code instead of passing via uniforms (bakes constants, reduces memory I/O).
- **Bake uniforms into source code** when they don't change per-pixel.
### Texture Pool Management
For a compositor with potentially many intermediate textures:
- Pre-allocate a pool of textures at common resolutions.
- Reference-count or track lifetime of each texture.
- Reuse textures with matching format/resolution once their producer is done.
- On mobile, prefer smaller intermediate formats (RGBA16F over RGBA32F when precision allows).
### Papers and References
1. **"Performance Implications of Node Graph Complexity in Real-Time Compositing"** (IEEE, 2024) — Studies Blender EEVEE's node graph rendering performance vs. structural complexity.
2. **"Compute Shader in Image Processing Development"** (CEUR Workshop, 2020) — Compares CPU, fragment, compute, and Vulkan fragment for image processing. Found compute shader overhead makes it slower for simple operations.
3. **Blender Real-time Compositor** (code.blender.org, 2022) — GPU-accelerated compositor architecture with operation graph, domain system, and shader-based execution.
4. **"The Shader Permutation Problem"** (MJP, 2021) — Comprehensive analysis of uber-shader vs. permutation trade-offs.
5. **"GPU Rendering Pipeline: Blend Modes, Porter-Duff Compositing"** (Lucio Durán, 2025) — Browser rendering pipeline compositing patterns.
6. **"High-Performance Software Rasterization on GPUs"** (NVIDIA Research, 2011) — Software GPU pipeline, relevant for understanding GPU architecture.
7. **Vulkan Samples** (Khronos) — Descriptor management, subpasses, async compute, tile-based rendering best practices.
### Recommended Architecture for Prism
Based on all research:
1. **DAG-based execution**: Topological sort the node graph. Execute in dependency order.
2. **Separate shaders per node type**: Each node type (Blend, ColorGrade, Blur, etc.) has a dedicated Slang shader module.
3. **Runtime composition via Slang modules**: Simple chains of per-pixel operations can be fused into single compute/fragment passes by linking their Slang modules.
4. **Texture pool**: Pre-allocated RGBA16F textures. Reference-counted. Reuse when possible.
5. **Ping-pong for chains**: Two textures alternating for sequential per-pixel chains.
6. **Fragment shaders for per-pixel ops**, compute shaders for operations needing shared memory (blur, convolution, reduction).
7. **Bindless descriptors**: One large descriptor set with all input textures. Node shaders index by texture ID.
8. **Push constants** for per-pass uniforms (resolution, parameters).
9. **Pipeline barriers** between passes on the same texture. No barriers for independent passes.
10. **Render graph** for automatic dependency tracking and synchronization.