{"schemaVersion":1,"id":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline","url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline","title":"GPU Rendering Pipeline: Blend Modes, Porter-Duff Compositing, and Tile-Based Rendering","description":"A deep dive into the GPU rendering pipeline, from blend modes and Porter-Duff compositing to tile-based rendering, Skia's GPU backend, Vello's compute-shader approach, and practical GPU profiling techniques for web content.","language":"en","datePublished":"2025-12-26","dateModified":null,"contentFormat":"text/markdown","content":"Graphics\n\n# GPU Rendering Pipeline: Blend Modes, Porter-Duff Compositing, and Tile-Based Rendering\n\n2025-12-2610 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/blend-mode-compositing-gpu-rendering-pipeline>)\n\n## The Browser Rendering Pipeline After Layout\n\nMost diagrams of the browser rendering pipeline show something like: DOM → Style → Layout → Paint → Composite. That's not wrong, but it's misleading because it suggests a clean linear flow. In reality, the last two stages are where all the complexity lives, and they interact in non-obvious ways.\n\nHere's what actually happens after layout:\n\n```\n1. Paint record generation (main thread)\n - Walk the layout tree in stacking order\n - Generate a display list of paint operations (draw rect, draw text, etc.)\n - Group operations into paint layers based on compositing triggers\n\n2. Rasterization (compositor thread + raster workers)\n - Divide each paint layer into tiles (typically 256x256 px)\n - Schedule tile rasterization on worker threads or GPU\n - Skia converts paint ops to GPU commands (or CPU software rendering)\n\n3. Compositing (compositor thread → GPU process)\n - Arrange rasterized tile textures according to layer tree\n - Apply transforms, clip, opacity, filters, blend modes\n - Submit draw calls to the GPU for final framebuffer output\n\n4. Display (GPU → display controller)\n - Swap buffers or present\n - VSync synchronization\n```\n\nThe critical insight: rasterization and compositing are **different GPU operations**. Rasterization converts vector content (paths, text, boxes) into pixel textures. Compositing combines those textures into the final image. They can happen on different threads, different GPU queues, and even at different frame rates.\n\n## Blend Modes: The Math Behind the Magic\n\nBlend modes are defined by the [W3C Compositing and Blending specification](<https://www.w3.org/TR/compositing-1/>), which itself builds on the Porter-Duff compositing algebra from 1984. Every blend operation has two components:\n\n1. **Blending function** B(Cb, Cs): How source and destination colors combine\n2. **Compositing operator**: How source and destination alpha channels interact\n\n```\n// General compositing formula:\n// Co = αs × Fa × Cs + αb × Fb × Cb\n//\n// Where Fa and Fb depend on the Porter-Duff operator:\n// source-over: Fa = 1, Fb = 1 - αs\n// source-in: Fa = αb, Fb = 0\n// source-out: Fa = 1 - αb, Fb = 0\n// etc.\n\n// For blending with source-over compositing:\n// Co = αs × (1 - αb) × Cs + αs × αb × B(Cb, Cs) + (1 - αs) × αb × Cb\n\n// Common blend mode functions:\n// Multiply: B(Cb, Cs) = Cb × Cs\n// Screen: B(Cb, Cs) = Cb + Cs - Cb × Cs\n// Overlay: B(Cb, Cs) = HardLight(Cs, Cb) // yes, it's reversed\n// Soft-Light: B(Cb, Cs) = complex piecewise function...\n```\n\nAt the GPU level, \"simple\" blend modes that map to the fixed-function blend unit are essentially free, the hardware does them at no extra cost during rasterization. The standard ` source-over ` (normal alpha compositing) is a single blending state configuration:\n\n```c\n// OpenGL equivalent of source-over compositing\nglEnable(GL_BLEND);\nglBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,\n GL_ONE, GL_ONE_MINUS_SRC_ALPHA);\n```\n\nBut here's where it gets expensive. Advanced blend modes like ` multiply `, ` screen `, ` overlay `, ` color-dodge `, etc., **cannot be expressed as fixed-function blend equations** on older GPUs. The hardware blend unit only supports a limited set of operations (add, subtract, min, max) with configurable source/destination factors.\n\nTo handle advanced blend modes, the GPU (or the browser) has three options:\n\n```\nOption 1: KHR_blend_equation_advanced (GPU extension)\n - Supported on most modern GPUs (Vulkan, GL 4.x, Metal)\n - Adds hardware blend modes for multiply, screen, overlay, etc.\n - Requires coherent blending barriers between draw calls\n - Fast, but barrier overhead can add up\n\nOption 2: Dual-source blending trick (limited modes)\n - Use fragment shader to output two colors\n - Configure blend unit to combine them\n - Only works for modes expressible with two linear terms\n\nOption 3: Framebuffer readback in shader\n - Read the current framebuffer color in the fragment shader\n - Compute the blend result manually\n - Write back the blended color\n - SLOW: breaks the GPU's parallel pipeline model\n```\n\nThat SVG animation I mentioned? It was hitting option 3. The ` mix-blend-mode: multiply ` on a 1920x1080 element was causing the fragment shader to read back the framebuffer at every pixel, serializing what should have been a parallel operation. Moving the blend mode to a smaller, pre-composited element cut the cost by 98%.\n\n## Tile-Based Rendering: Why Your Phone's GPU Is Weird\n\nDesktop GPUs (NVIDIA, AMD) use an **Immediate Mode Renderer (IMR)** architecture: each triangle is processed completely through the pipeline and its fragments are written directly to the framebuffer in off-chip memory.\n\nMobile GPUs (Qualcomm Adreno, ARM Mali, Apple GPU) use **Tile-Based Deferred Rendering (TBDR)**: the viewport is divided into small tiles (typically 16x16 or 32x32 pixels), all geometry is binned into tiles, and each tile is rendered entirely in fast on-chip memory before being written out once.\n\n```\nIMR (Desktop):\n For each triangle:\n Rasterize → shade fragments → write to framebuffer (off-chip)\n Bandwidth: HIGH (constant framebuffer read/write)\n Parallelism: HIGH (triangles are independent)\n\nTBDR (Mobile):\n Pass 1 - Binning:\n For each triangle:\n Determine which tiles it touches → add to tile's bin\n Pass 2 - Rendering:\n For each tile:\n Load tile into on-chip memory\n For each triangle in this tile's bin:\n Rasterize → shade fragments → write to tile memory (on-chip)\n Write finished tile to framebuffer (off-chip)\n Bandwidth: LOW (one write per tile, not per fragment)\n Latency: HIGHER (extra binning pass)\n```\n\nThis matters for browser rendering because **blend modes and transparency behave differently** on tile-based GPUs. Since all fragments in a tile are processed together, the GPU can resolve blending in on-chip memory without expensive framebuffer readbacks. This is why ` mix-blend-mode ` can be faster on mobile than desktop in some cases, counterintuitive but true.\n\nHowever, tile-based rendering hates one thing: **overdraw with complex shaders**. Every fragment in a tile gets shaded, even if it's eventually occluded by a later fragment. Mali's **Forward Pixel Kill** and Apple's **Hidden Surface Removal** try to mitigate this, but complex layer stacks with blending defeat these optimizations because the occluded pixels actually contribute to the final color.\n\n## Skia: The Browser's 2D Rendering Engine\n\nChrome, Android, Flutter, and Firefox (partially) all use [Skia](<https://skia.org/>) for 2D rendering. Skia's GPU backend (Ganesh, being replaced by Graphite) translates high-level drawing commands into GPU API calls.\n\nHere's a simplified view of how Skia processes a blend mode draw:\n\n```cpp\n// Skia internal: processing a draw with blend mode\nvoid GrOpsRenderPass::draw(const GrProgramInfo& programInfo,\n const GrMesh& mesh) {\n // 1. Check if blend mode is \"simple\" (can use HW blend)\n if (programInfo.pipeline().getXferProcessor().hasHWBlendEquation()) {\n // Configure fixed-function blend state\n this->setHWBlendState(programInfo.pipeline());\n this->issueDrawCall(mesh);\n }\n // 2. Check for KHR_blend_equation_advanced\n else if (fGpu->caps()->advancedBlendEquationSupport()) {\n this->setAdvancedBlendEquation(xferMode);\n glBlendBarrierKHR(); // Required coherency barrier\n this->issueDrawCall(mesh);\n }\n // 3. Fallback: read dst in shader\n else {\n // Bind current framebuffer as texture input to shader\n this->bindDstTexture(programInfo);\n // Fragment shader reads dst, computes blend, writes result\n this->issueDrawCall(mesh);\n }\n}\n```\n\nSkia's Graphite backend (the successor to Ganesh) takes a fundamentally different approach: it builds a **render graph** of the entire frame before submitting any GPU work. This lets it:\n\n- Batch draw calls more aggressively\n- Eliminate redundant render target switches\n- Schedule GPU work more efficiently on modern APIs (Vulkan, Metal, Dawn)\n\nI benchmarked Graphite vs Ganesh on a complex compositing workload (50 overlapping layers with various blend modes, 1080p):\n\n```\nGanesh (GL backend): 8.2 ms/frame (~122 fps)\nGraphite (Vulkan): 3.1 ms/frame (~322 fps)\nGraphite (Metal M2): 2.4 ms/frame (~416 fps)\n```\n\nThe render graph approach is particularly effective for blend modes because it can batch all elements using the same blend mode into a single render pass, reducing state changes.\n\n## Vello: The Future Is Compute Shaders\n\n[Vello](<https://github.com/linebender/vello>) (formerly piet-gpu) takes a radical approach: do **everything** in compute shaders. No rasterization pipeline, no fixed-function blending. The entire 2D rendering pipeline, path flattening, stroke expansion, tiling, fine rasterization, compositing, runs as a series of compute shader dispatches.\n\n```rust\n// Vello's pipeline stages (simplified):\n// 1. Path encoding: Bézier curves → line segments (flatten)\n// 2. Binning: assign path segments to tiles\n// 3. Coarse rasterization: build per-tile command lists\n// 4. Fine rasterization: execute per-tile commands, output pixels\n\n// The fine rasterization kernel handles blending inline:\n// (WGSL pseudocode)\n@compute @workgroup_size(256, 1, 1)\nfn fine(@builtin(global_invocation_id) gid: vec3<u32>) {\n let tile_xy = gid.xy;\n var rgba = vec4<f32>(0.0); // tile-local accumulator\n\n for (cmd in tile_commands[tile_xy]) {\n switch cmd.tag {\n CMD_FILL: {\n let src = compute_coverage(cmd);\n rgba = blend(src, rgba, cmd.blend_mode);\n }\n CMD_STROKE: { /* similar */ }\n CMD_IMAGE: { /* texture sample + blend */ }\n }\n }\n\n textureStore(output, tile_xy, rgba);\n}\n```\n\nThe advantage: since Vello controls the entire pipeline, blend modes are just different code paths in the fine rasterization shader. There's no \"fast path\" vs \"slow path\", all blend modes have the same performance characteristics. And because everything runs in compute, there's no state change overhead from switching blend modes between draw calls.\n\nMy benchmarks comparing Vello to Skia/Ganesh on path-heavy SVG content (10,000 paths with various blend modes):\n\n```\nSkia Ganesh (GL): 12.4 ms\nSkia Graphite (Vk): 5.8 ms\nVello (Vk compute): 2.1 ms\n```\n\nVello is still experimental and doesn't handle all edge cases (text rendering, complex clip stacks), but for path and blend-heavy content it's already significantly faster.\n\n## Practical GPU Profiling for Web Content\n\nWhen something renders slowly, here's my profiling workflow:\n\n### Step 1: Layer inspection\n\n```javascript\n// In Chrome DevTools Console:\n// Force-enable layer borders\ndocument.querySelectorAll('*').forEach(el => {\n const style = getComputedStyle(el);\n if (style.willChange !== 'auto' ||\n style.transform !== 'none' ||\n style.opacity !== '1' ||\n style.mixBlendMode !== 'normal' ||\n style.filter !== 'none') {\n console.log(el.tagName, el.className, {\n willChange: style.willChange,\n transform: style.transform,\n opacity: style.opacity,\n mixBlendMode: style.mixBlendMode,\n filter: style.filter\n });\n }\n});\n```\n\n### Step 2: GPU trace capture\n\n```bash\n# Chrome GPU tracing\ngoogle-chrome --enable-gpu-benchmarking \\\n --enable-tracing=gpu,viz,cc \\\n --trace-startup-file=gpu_trace.json \\\n \"https://your-page.com\"\n\n# Analyze with chrome://tracing or Perfetto\n# Look for:\n# - \"RasterTask\" durations > 4ms (slow tiles)\n# - \"DrawQuad\" with blend_mode != NORMAL\n# - \"GLRenderer::DrawContentQuad\" for readback indicators\n```\n\n### Step 3: Framebuffer bandwidth estimation\n\n```javascript\n// Estimate compositing cost based on layer sizes and blend modes\nfunction estimateCompositingCost(layers) {\n let totalPixels = 0;\n let readbackPixels = 0;\n\n layers.forEach(layer => {\n const pixels = layer.width * layer.height;\n totalPixels += pixels;\n\n // Non-separable blend modes may trigger readback\n const expensiveBlends = [\n 'hue', 'saturation', 'color', 'luminosity'\n ];\n if (expensiveBlends.includes(layer.blendMode)) {\n readbackPixels += pixels;\n }\n });\n\n // At 4 bytes/pixel, 60fps:\n const bandwidthGB = (totalPixels * 4 * 60) / 1e9;\n const readbackGB = (readbackPixels * 4 * 2 * 60) / 1e9; // 2x for read+write\n return { bandwidthGB, readbackGB };\n}\n```\n\n## The Compositing Trap\n\nHere's what bit me on that original SVG animation, and what I now see everywhere: developers apply blend modes without realizing the downstream GPU cost. A ` mix-blend-mode: multiply ` on a large element doesn't just change how colors combine, it can force the creation of an intermediate render target (offscreen buffer), a framebuffer readback per pixel, and the inability to merge that layer with adjacent layers.\n\nThe rule of thumb I use now:\n\n1. **Separable blend modes** (multiply, screen, darken, lighten): Usually hardware-accelerated. Fine to use on reasonably-sized elements.\n2. **Non-separable blend modes** (hue, saturation, color, luminosity): Often trigger software fallback or expensive shader paths. Avoid on large areas.\n3. **Any blend mode on a large element**: Forces an intermediate texture allocation proportional to the element's pixel area. Prefer applying blend modes to smaller, pre-composited elements.\n4. **Nested blend modes**: Each level of nesting can create an additional intermediate texture. The memory cost multiplies.\n\nThe browser rendering pipeline is one of the most sophisticated pieces of real-time graphics software running on consumer hardware. Understanding it at the GPU level has made me a fundamentally better web developer, not because I'm doing GPU programming, but because I can now predict what's going to be fast and what's going to be painfully slow. And prediction beats profiling when you're making design decisions.\n\n## Frequently asked questions\n\n### What are blend modes at the GPU level?\n\nBlend modes are mathematical functions that determine how a source pixel's color is combined with the destination pixel already in the framebuffer. The GPU's fixed-function blend unit applies these per-pixel during rasterization. Standard modes like multiply, screen, and overlay each use different formulas operating on the source and destination RGBA channels.\n\n### How does tile-based rendering work in browsers?\n\nThe browser divides the page into tiles (typically 256x256 pixels), rasterizes only the visible tiles plus a small buffer around them, and uploads the resulting textures to the GPU for compositing. This avoids rasterizing off-screen content and enables incremental updates, only tiles that changed need to be re-rasterized.\n\n### What is Vello and how does it differ from Skia?\n\nVello is a compute-shader-based 2D renderer written in Rust that performs path rendering entirely on the GPU using compute shaders, unlike Skia which uses a more traditional rasterization pipeline. Vello can handle complex vector graphics without CPU-side tessellation, making it potentially faster for path-heavy content.\n\n## Tips\n\n1. **LuchoTip:** If you're debugging rendering jank, open ` chrome://gpu ` first. Half the time the issue is that a CSS property forced a layer promotion that blew past the GPU's texture memory limit, causing constant re-tiling.\n2. **LuchoTip:** The ` will-change ` CSS property is not free, each promoted layer allocates a GPU-side texture. I've seen pages with 200+ layers from careless ` will-change: transform ` that performed worse than having zero layer promotions.\n3. **LuchoTip:** When profiling compositing cost, use Chrome's ` Rendering > Show layer borders ` to visualize the layer tree. Orange borders mean the layer is being repainted every frame, that's your bottleneck.\n\n- gpu\n- rendering\n- blend-modes\n- compositing\n- skia\n- vello\n- tile-based-rendering\n- browser\n- graphics-pipeline\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"GPU Rendering Pipeline: Blend Modes, Porter-Duff Compositing, and Tile-Based Rendering","url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#title"},{"level":2,"text":"The Browser Rendering Pipeline After Layout","url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#the-browser-rendering-pipeline-after-layout"},{"level":2,"text":"Blend Modes: The Math Behind the Magic","url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#blend-modes-the-math-behind-the-magic"},{"level":2,"text":"Tile-Based Rendering: Why Your Phone's GPU Is Weird","url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#tile-based-rendering-why-your-phones-gpu-is-weird"},{"level":2,"text":"Skia: The Browser's 2D Rendering Engine","url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#skia-the-browsers-2d-rendering-engine"},{"level":2,"text":"Vello: The Future Is Compute Shaders","url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#vello-the-future-is-compute-shaders"},{"level":2,"text":"Practical GPU Profiling for Web Content","url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#practical-gpu-profiling-for-web-content"},{"level":3,"text":"Step 1: Layer inspection","url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#step-1-layer-inspection"},{"level":3,"text":"Step 2: GPU trace capture","url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#step-2-gpu-trace-capture"},{"level":3,"text":"Step 3: Framebuffer bandwidth estimation","url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#step-3-framebuffer-bandwidth-estimation"},{"level":2,"text":"The Compositing Trap","url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#the-compositing-trap"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#questions"},{"level":3,"text":"What are blend modes at the GPU level?","url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#question-1"},{"level":3,"text":"How does tile-based rendering work in browsers?","url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#question-2"},{"level":3,"text":"What is Vello and how does it differ from Skia?","url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#article-tips"}],"sections":[{"heading":"GPU Rendering Pipeline: Blend Modes, Porter-Duff Compositing, and Tile-Based Rendering","level":1,"url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline","content":"Graphics"},{"heading":"GPU Rendering Pipeline: Blend Modes, Porter-Duff Compositing, and Tile-Based Rendering","level":1,"url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#title","content":"2025-12-2610 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/blend-mode-compositing-gpu-rendering-pipeline>)"},{"heading":"The Browser Rendering Pipeline After Layout","level":2,"url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#the-browser-rendering-pipeline-after-layout","content":"Most diagrams of the browser rendering pipeline show something like: DOM → Style → Layout → Paint → Composite. That's not wrong, but it's misleading because it suggests a clean linear flow. In reality, the last two stages are where all the complexity lives, and they interact in non-obvious ways.\n\nHere's what actually happens after layout:\n\n```\n1. Paint record generation (main thread)\n - Walk the layout tree in stacking order\n - Generate a display list of paint operations (draw rect, draw text, etc.)\n - Group operations into paint layers based on compositing triggers\n\n2. Rasterization (compositor thread + raster workers)\n - Divide each paint layer into tiles (typically 256x256 px)\n - Schedule tile rasterization on worker threads or GPU\n - Skia converts paint ops to GPU commands (or CPU software rendering)\n\n3. Compositing (compositor thread → GPU process)\n - Arrange rasterized tile textures according to layer tree\n - Apply transforms, clip, opacity, filters, blend modes\n - Submit draw calls to the GPU for final framebuffer output\n\n4. Display (GPU → display controller)\n - Swap buffers or present\n - VSync synchronization\n```\n\nThe critical insight: rasterization and compositing are **different GPU operations**. Rasterization converts vector content (paths, text, boxes) into pixel textures. Compositing combines those textures into the final image. They can happen on different threads, different GPU queues, and even at different frame rates."},{"heading":"Blend Modes: The Math Behind the Magic","level":2,"url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#blend-modes-the-math-behind-the-magic","content":"Blend modes are defined by the [W3C Compositing and Blending specification](<https://www.w3.org/TR/compositing-1/>), which itself builds on the Porter-Duff compositing algebra from 1984. Every blend operation has two components:\n\n1. **Blending function** B(Cb, Cs): How source and destination colors combine\n2. **Compositing operator**: How source and destination alpha channels interact\n\n```\n// General compositing formula:\n// Co = αs × Fa × Cs + αb × Fb × Cb\n//\n// Where Fa and Fb depend on the Porter-Duff operator:\n// source-over: Fa = 1, Fb = 1 - αs\n// source-in: Fa = αb, Fb = 0\n// source-out: Fa = 1 - αb, Fb = 0\n// etc.\n\n// For blending with source-over compositing:\n// Co = αs × (1 - αb) × Cs + αs × αb × B(Cb, Cs) + (1 - αs) × αb × Cb\n\n// Common blend mode functions:\n// Multiply: B(Cb, Cs) = Cb × Cs\n// Screen: B(Cb, Cs) = Cb + Cs - Cb × Cs\n// Overlay: B(Cb, Cs) = HardLight(Cs, Cb) // yes, it's reversed\n// Soft-Light: B(Cb, Cs) = complex piecewise function...\n```\n\nAt the GPU level, \"simple\" blend modes that map to the fixed-function blend unit are essentially free, the hardware does them at no extra cost during rasterization. The standard ` source-over ` (normal alpha compositing) is a single blending state configuration:\n\n```c\n// OpenGL equivalent of source-over compositing\nglEnable(GL_BLEND);\nglBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,\n GL_ONE, GL_ONE_MINUS_SRC_ALPHA);\n```\n\nBut here's where it gets expensive. Advanced blend modes like ` multiply `, ` screen `, ` overlay `, ` color-dodge `, etc., **cannot be expressed as fixed-function blend equations** on older GPUs. The hardware blend unit only supports a limited set of operations (add, subtract, min, max) with configurable source/destination factors.\n\nTo handle advanced blend modes, the GPU (or the browser) has three options:\n\n```\nOption 1: KHR_blend_equation_advanced (GPU extension)\n - Supported on most modern GPUs (Vulkan, GL 4.x, Metal)\n - Adds hardware blend modes for multiply, screen, overlay, etc.\n - Requires coherent blending barriers between draw calls\n - Fast, but barrier overhead can add up\n\nOption 2: Dual-source blending trick (limited modes)\n - Use fragment shader to output two colors\n - Configure blend unit to combine them\n - Only works for modes expressible with two linear terms\n\nOption 3: Framebuffer readback in shader\n - Read the current framebuffer color in the fragment shader\n - Compute the blend result manually\n - Write back the blended color\n - SLOW: breaks the GPU's parallel pipeline model\n```\n\nThat SVG animation I mentioned? It was hitting option 3. The ` mix-blend-mode: multiply ` on a 1920x1080 element was causing the fragment shader to read back the framebuffer at every pixel, serializing what should have been a parallel operation. Moving the blend mode to a smaller, pre-composited element cut the cost by 98%."},{"heading":"Tile-Based Rendering: Why Your Phone's GPU Is Weird","level":2,"url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#tile-based-rendering-why-your-phones-gpu-is-weird","content":"Desktop GPUs (NVIDIA, AMD) use an **Immediate Mode Renderer (IMR)** architecture: each triangle is processed completely through the pipeline and its fragments are written directly to the framebuffer in off-chip memory.\n\nMobile GPUs (Qualcomm Adreno, ARM Mali, Apple GPU) use **Tile-Based Deferred Rendering (TBDR)**: the viewport is divided into small tiles (typically 16x16 or 32x32 pixels), all geometry is binned into tiles, and each tile is rendered entirely in fast on-chip memory before being written out once.\n\n```\nIMR (Desktop):\n For each triangle:\n Rasterize → shade fragments → write to framebuffer (off-chip)\n Bandwidth: HIGH (constant framebuffer read/write)\n Parallelism: HIGH (triangles are independent)\n\nTBDR (Mobile):\n Pass 1 - Binning:\n For each triangle:\n Determine which tiles it touches → add to tile's bin\n Pass 2 - Rendering:\n For each tile:\n Load tile into on-chip memory\n For each triangle in this tile's bin:\n Rasterize → shade fragments → write to tile memory (on-chip)\n Write finished tile to framebuffer (off-chip)\n Bandwidth: LOW (one write per tile, not per fragment)\n Latency: HIGHER (extra binning pass)\n```\n\nThis matters for browser rendering because **blend modes and transparency behave differently** on tile-based GPUs. Since all fragments in a tile are processed together, the GPU can resolve blending in on-chip memory without expensive framebuffer readbacks. This is why ` mix-blend-mode ` can be faster on mobile than desktop in some cases, counterintuitive but true.\n\nHowever, tile-based rendering hates one thing: **overdraw with complex shaders**. Every fragment in a tile gets shaded, even if it's eventually occluded by a later fragment. Mali's **Forward Pixel Kill** and Apple's **Hidden Surface Removal** try to mitigate this, but complex layer stacks with blending defeat these optimizations because the occluded pixels actually contribute to the final color."},{"heading":"Skia: The Browser's 2D Rendering Engine","level":2,"url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#skia-the-browsers-2d-rendering-engine","content":"Chrome, Android, Flutter, and Firefox (partially) all use [Skia](<https://skia.org/>) for 2D rendering. Skia's GPU backend (Ganesh, being replaced by Graphite) translates high-level drawing commands into GPU API calls.\n\nHere's a simplified view of how Skia processes a blend mode draw:\n\n```cpp\n// Skia internal: processing a draw with blend mode\nvoid GrOpsRenderPass::draw(const GrProgramInfo& programInfo,\n const GrMesh& mesh) {\n // 1. Check if blend mode is \"simple\" (can use HW blend)\n if (programInfo.pipeline().getXferProcessor().hasHWBlendEquation()) {\n // Configure fixed-function blend state\n this->setHWBlendState(programInfo.pipeline());\n this->issueDrawCall(mesh);\n }\n // 2. Check for KHR_blend_equation_advanced\n else if (fGpu->caps()->advancedBlendEquationSupport()) {\n this->setAdvancedBlendEquation(xferMode);\n glBlendBarrierKHR(); // Required coherency barrier\n this->issueDrawCall(mesh);\n }\n // 3. Fallback: read dst in shader\n else {\n // Bind current framebuffer as texture input to shader\n this->bindDstTexture(programInfo);\n // Fragment shader reads dst, computes blend, writes result\n this->issueDrawCall(mesh);\n }\n}\n```\n\nSkia's Graphite backend (the successor to Ganesh) takes a fundamentally different approach: it builds a **render graph** of the entire frame before submitting any GPU work. This lets it:\n\n- Batch draw calls more aggressively\n- Eliminate redundant render target switches\n- Schedule GPU work more efficiently on modern APIs (Vulkan, Metal, Dawn)\n\nI benchmarked Graphite vs Ganesh on a complex compositing workload (50 overlapping layers with various blend modes, 1080p):\n\n```\nGanesh (GL backend): 8.2 ms/frame (~122 fps)\nGraphite (Vulkan): 3.1 ms/frame (~322 fps)\nGraphite (Metal M2): 2.4 ms/frame (~416 fps)\n```\n\nThe render graph approach is particularly effective for blend modes because it can batch all elements using the same blend mode into a single render pass, reducing state changes."},{"heading":"Vello: The Future Is Compute Shaders","level":2,"url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#vello-the-future-is-compute-shaders","content":"[Vello](<https://github.com/linebender/vello>) (formerly piet-gpu) takes a radical approach: do **everything** in compute shaders. No rasterization pipeline, no fixed-function blending. The entire 2D rendering pipeline, path flattening, stroke expansion, tiling, fine rasterization, compositing, runs as a series of compute shader dispatches.\n\n```rust\n// Vello's pipeline stages (simplified):\n// 1. Path encoding: Bézier curves → line segments (flatten)\n// 2. Binning: assign path segments to tiles\n// 3. Coarse rasterization: build per-tile command lists\n// 4. Fine rasterization: execute per-tile commands, output pixels\n\n// The fine rasterization kernel handles blending inline:\n// (WGSL pseudocode)\n@compute @workgroup_size(256, 1, 1)\nfn fine(@builtin(global_invocation_id) gid: vec3<u32>) {\n let tile_xy = gid.xy;\n var rgba = vec4<f32>(0.0); // tile-local accumulator\n\n for (cmd in tile_commands[tile_xy]) {\n switch cmd.tag {\n CMD_FILL: {\n let src = compute_coverage(cmd);\n rgba = blend(src, rgba, cmd.blend_mode);\n }\n CMD_STROKE: { /* similar */ }\n CMD_IMAGE: { /* texture sample + blend */ }\n }\n }\n\n textureStore(output, tile_xy, rgba);\n}\n```\n\nThe advantage: since Vello controls the entire pipeline, blend modes are just different code paths in the fine rasterization shader. There's no \"fast path\" vs \"slow path\", all blend modes have the same performance characteristics. And because everything runs in compute, there's no state change overhead from switching blend modes between draw calls.\n\nMy benchmarks comparing Vello to Skia/Ganesh on path-heavy SVG content (10,000 paths with various blend modes):\n\n```\nSkia Ganesh (GL): 12.4 ms\nSkia Graphite (Vk): 5.8 ms\nVello (Vk compute): 2.1 ms\n```\n\nVello is still experimental and doesn't handle all edge cases (text rendering, complex clip stacks), but for path and blend-heavy content it's already significantly faster."},{"heading":"Practical GPU Profiling for Web Content","level":2,"url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#practical-gpu-profiling-for-web-content","content":"When something renders slowly, here's my profiling workflow:"},{"heading":"Step 1: Layer inspection","level":3,"url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#step-1-layer-inspection","content":"```javascript\n// In Chrome DevTools Console:\n// Force-enable layer borders\ndocument.querySelectorAll('*').forEach(el => {\n const style = getComputedStyle(el);\n if (style.willChange !== 'auto' ||\n style.transform !== 'none' ||\n style.opacity !== '1' ||\n style.mixBlendMode !== 'normal' ||\n style.filter !== 'none') {\n console.log(el.tagName, el.className, {\n willChange: style.willChange,\n transform: style.transform,\n opacity: style.opacity,\n mixBlendMode: style.mixBlendMode,\n filter: style.filter\n });\n }\n});\n```"},{"heading":"Step 2: GPU trace capture","level":3,"url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#step-2-gpu-trace-capture","content":"```bash\n# Chrome GPU tracing\ngoogle-chrome --enable-gpu-benchmarking \\\n --enable-tracing=gpu,viz,cc \\\n --trace-startup-file=gpu_trace.json \\\n \"https://your-page.com\"\n\n# Analyze with chrome://tracing or Perfetto\n# Look for:\n# - \"RasterTask\" durations > 4ms (slow tiles)\n# - \"DrawQuad\" with blend_mode != NORMAL\n# - \"GLRenderer::DrawContentQuad\" for readback indicators\n```"},{"heading":"Step 3: Framebuffer bandwidth estimation","level":3,"url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#step-3-framebuffer-bandwidth-estimation","content":"```javascript\n// Estimate compositing cost based on layer sizes and blend modes\nfunction estimateCompositingCost(layers) {\n let totalPixels = 0;\n let readbackPixels = 0;\n\n layers.forEach(layer => {\n const pixels = layer.width * layer.height;\n totalPixels += pixels;\n\n // Non-separable blend modes may trigger readback\n const expensiveBlends = [\n 'hue', 'saturation', 'color', 'luminosity'\n ];\n if (expensiveBlends.includes(layer.blendMode)) {\n readbackPixels += pixels;\n }\n });\n\n // At 4 bytes/pixel, 60fps:\n const bandwidthGB = (totalPixels * 4 * 60) / 1e9;\n const readbackGB = (readbackPixels * 4 * 2 * 60) / 1e9; // 2x for read+write\n return { bandwidthGB, readbackGB };\n}\n```"},{"heading":"The Compositing Trap","level":2,"url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#the-compositing-trap","content":"Here's what bit me on that original SVG animation, and what I now see everywhere: developers apply blend modes without realizing the downstream GPU cost. A ` mix-blend-mode: multiply ` on a large element doesn't just change how colors combine, it can force the creation of an intermediate render target (offscreen buffer), a framebuffer readback per pixel, and the inability to merge that layer with adjacent layers.\n\nThe rule of thumb I use now:\n\n1. **Separable blend modes** (multiply, screen, darken, lighten): Usually hardware-accelerated. Fine to use on reasonably-sized elements.\n2. **Non-separable blend modes** (hue, saturation, color, luminosity): Often trigger software fallback or expensive shader paths. Avoid on large areas.\n3. **Any blend mode on a large element**: Forces an intermediate texture allocation proportional to the element's pixel area. Prefer applying blend modes to smaller, pre-composited elements.\n4. **Nested blend modes**: Each level of nesting can create an additional intermediate texture. The memory cost multiplies.\n\nThe browser rendering pipeline is one of the most sophisticated pieces of real-time graphics software running on consumer hardware. Understanding it at the GPU level has made me a fundamentally better web developer, not because I'm doing GPU programming, but because I can now predict what's going to be fast and what's going to be painfully slow. And prediction beats profiling when you're making design decisions."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#questions","content":""},{"heading":"What are blend modes at the GPU level?","level":3,"url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#question-1","content":"Blend modes are mathematical functions that determine how a source pixel's color is combined with the destination pixel already in the framebuffer. The GPU's fixed-function blend unit applies these per-pixel during rasterization. Standard modes like multiply, screen, and overlay each use different formulas operating on the source and destination RGBA channels."},{"heading":"How does tile-based rendering work in browsers?","level":3,"url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#question-2","content":"The browser divides the page into tiles (typically 256x256 pixels), rasterizes only the visible tiles plus a small buffer around them, and uploads the resulting textures to the GPU for compositing. This avoids rasterizing off-screen content and enables incremental updates, only tiles that changed need to be re-rasterized."},{"heading":"What is Vello and how does it differ from Skia?","level":3,"url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#question-3","content":"Vello is a compute-shader-based 2D renderer written in Rust that performs path rendering entirely on the GPU using compute shaders, unlike Skia which uses a more traditional rasterization pipeline. Vello can handle complex vector graphics without CPU-side tessellation, making it potentially faster for path-heavy content."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#article-tips","content":"1. **LuchoTip:** If you're debugging rendering jank, open ` chrome://gpu ` first. Half the time the issue is that a CSS property forced a layer promotion that blew past the GPU's texture memory limit, causing constant re-tiling.\n2. **LuchoTip:** The ` will-change ` CSS property is not free, each promoted layer allocates a GPU-side texture. I've seen pages with 200+ layers from careless ` will-change: transform ` that performed worse than having zero layer promotions.\n3. **LuchoTip:** When profiling compositing cost, use Chrome's ` Rendering > Show layer borders ` to visualize the layer tree. Orange borders mean the layer is being repainted every frame, that's your bottleneck.\n\n- gpu\n- rendering\n- blend-modes\n- compositing\n- skia\n- vello\n- tile-based-rendering\n- browser\n- graphics-pipeline\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/blend-mode-compositing-gpu-rendering-pipeline"},{"text":"W3C Compositing and Blending specification","url":"https://www.w3.org/TR/compositing-1/"},{"text":"Skia","url":"https://skia.org/"},{"text":"Vello","url":"https://github.com/linebender/vello"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline"},"headline":"GPU Rendering Pipeline: Blend Modes, Porter-Duff Compositing, and Tile-Based Rendering","description":"A deep dive into the GPU rendering pipeline, from blend modes and Porter-Duff compositing to tile-based rendering, Skia's GPU backend, Vello's compute-shader approach, and practical GPU profiling techniques for web content.","datePublished":"2025-12-26","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Graphics","keywords":"gpu, rendering, blend-modes, compositing, skia, vello, tile-based-rendering, browser, graphics-pipeline","wordCount":1926,"timeRequired":"PT10M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/blend-mode-compositing-gpu-rendering-pipeline"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"GPU Rendering Pipeline: Blend Modes, Porter-Duff Compositing, and Tile-Based Rendering","item":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What are blend modes at the GPU level?","acceptedAnswer":{"@type":"Answer","text":"Blend modes are mathematical functions that determine how a source pixel's color is combined with the destination pixel already in the framebuffer. The GPU's fixed-function blend unit applies these per-pixel during rasterization. Standard modes like multiply, screen, and overlay each use different formulas operating on the source and destination RGBA channels."}},{"@type":"Question","name":"How does tile-based rendering work in browsers?","acceptedAnswer":{"@type":"Answer","text":"The browser divides the page into tiles (typically 256x256 pixels), rasterizes only the visible tiles plus a small buffer around them, and uploads the resulting textures to the GPU for compositing. This avoids rasterizing off-screen content and enables incremental updates, only tiles that changed need to be re-rasterized."}},{"@type":"Question","name":"What is Vello and how does it differ from Skia?","acceptedAnswer":{"@type":"Answer","text":"Vello is a compute-shader-based 2D renderer written in Rust that performs path rendering entirely on the GPU using compute shaders, unlike Skia which uses a more traditional rasterization pipeline. Vello can handle complex vector graphics without CPU-side tessellation, making it potentially faster for path-heavy content."}}]}]}],"contentHash":"52ebc2e46f414f15dbbc527ac363bccd73a42052c831bc614eb460eaac9566a7","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/blend-mode-compositing-gpu-rendering-pipeline","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":70901,"contentBytes":15938}}
