Pipeline di Rendering GPU: Blend Mode, Compositing Porter-Duff e Rendering Tile-Based
Il Pipeline di Rendering del Browser Dopo il Layout
La maggior parte dei diagrammi del pipeline di rendering del browser mostrano qualcosa tipo: DOM → Style → Layout → Paint → Composite. Non è sbagliato, ma è fuorviante perché suggerisce un flusso lineare pulito. In realtà, le ultime due fasi sono dove vive tutta la complessità, e interagiscono in modi non ovvi.
Ecco cosa succede realmente dopo il layout:
1. Generazione dei paint record (thread principale)
- Percorrere il layout tree in ordine di stacking
- Generare una display list di operazioni di pittura (disegna rect, testo, ecc.)
- Raggruppare operazioni in paint layer basati su trigger di compositing
2. Rasterizzazione (thread compositor + worker di raster)
- Dividere ogni paint layer in tile (tipicamente 256x256 px)
- Schedulare rasterizzazione dei tile su thread worker o GPU
- Skia converte le operazioni di pittura in comandi GPU (o rendering software CPU)
3. Compositing (thread compositor → processo GPU)
- Organizzare le texture dei tile rasterizzati secondo il layer tree
- Applicare transform, clip, opacity, filter, blend mode
- Inviare draw call alla GPU per l'output finale al framebuffer
4. Display (GPU → controller del display)
- Swap dei buffer o present
- Sincronizzazione VSync
L'insight critico: rasterizzazione e compositing sono operazioni GPU diverse. La rasterizzazione converte contenuto vettoriale (path, testo, box) in texture di pixel. Il compositing combina quelle texture nell'immagine finale. Possono avvenire su thread diversi, code GPU diverse, e persino a frame rate diversi.
Blend Mode: La Matematica Dietro la Magia
I blend mode sono definiti dalla specifica W3C Compositing and Blending, che a sua volta si basa sull'algebra di compositing Porter-Duff del 1984. Ogni operazione di blend ha due componenti:
- Funzione di blending B(Cb, Cs): Come si combinano i colori sorgente e destinazione
- Operatore di compositing: Come interagiscono i canali alpha di sorgente e destinazione
// Formula generale di compositing:
// Co = αs × Fa × Cs + αb × Fb × Cb
//
// Dove Fa e Fb dipendono dall'operatore Porter-Duff:
// source-over: Fa = 1, Fb = 1 - αs
// source-in: Fa = αb, Fb = 0
// source-out: Fa = 1 - αb, Fb = 0
// ecc.
// Per blending con compositing source-over:
// Co = αs × (1 - αb) × Cs + αs × αb × B(Cb, Cs) + (1 - αs) × αb × Cb
// Funzioni comuni dei blend mode:
// Multiply: B(Cb, Cs) = Cb × Cs
// Screen: B(Cb, Cs) = Cb + Cs - Cb × Cs
// Overlay: B(Cb, Cs) = HardLight(Cs, Cb) // sì, è invertito
// Soft-Light: B(Cb, Cs) = funzione piecewise complessa...
A livello GPU, i blend mode "semplici" che mappano all'unità di blend a funzione fissa sono essenzialmente gratis — l'hardware li esegue senza costo extra durante la rasterizzazione. Il source-over standard (compositing con alpha normale) è una singola configurazione di stato di blending:
// Equivalente OpenGL del compositing source-over
glEnable(GL_BLEND);
glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,
GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
Ma qui è dove diventa costoso. Blend mode avanzati come multiply, screen, overlay, color-dodge, ecc., non possono essere espressi come equazioni di blend a funzione fissa su GPU più vecchie. L'unità di blend hardware supporta solo un set limitato di operazioni (add, subtract, min, max) con fattori configurabili di source/destination.
Per gestire blend mode avanzati, la GPU (o il browser) ha tre opzioni:
Opzione 1: KHR_blend_equation_advanced (estensione GPU)
- Supportata sulla maggior parte delle GPU moderne (Vulkan, GL 4.x, Metal)
- Aggiunge blend mode hardware per multiply, screen, overlay, ecc.
- Richiede barriere di blending coerente tra draw call
- Veloce, ma l'overhead delle barriere può accumularsi
Opzione 2: Trucco dual-source blending (modalità limitate)
- Usare fragment shader per produrre due colori
- Configurare l'unità di blend per combinarli
- Funziona solo per modalità esprimibili con due termini lineari
Opzione 3: Readback del framebuffer nello shader
- Leggere il colore corrente del framebuffer nel fragment shader
- Calcolare il risultato del blend manualmente
- Scrivere il colore blended
- LENTO: rompe il modello di pipeline parallelo della GPU
Quell'animazione SVG che ho menzionato? Stava finendo nell'opzione 3. Il mix-blend-mode: multiply su un elemento 1920x1080 causava la lettura del framebuffer da parte del fragment shader ad ogni pixel, serializzando quella che avrebbe dovuto essere un'operazione parallela. Spostare il blend mode su un elemento più piccolo, pre-composito, ha ridotto il costo del 98%.
Tile-Based Rendering: Perché la GPU del Tuo Telefono È Strana
Le GPU desktop (NVIDIA, AMD) usano un'architettura Immediate Mode Renderer (IMR): ogni triangolo viene elaborato completamente attraverso il pipeline e i suoi frammenti vengono scritti direttamente al framebuffer nella memoria off-chip.
Le GPU mobile (Qualcomm Adreno, ARM Mali, Apple GPU) usano Tile-Based Deferred Rendering (TBDR): il viewport viene diviso in piccoli tile (tipicamente 16x16 o 32x32 pixel), tutta la geometria viene classificata nei tile, e ogni tile viene renderizzato interamente nella veloce memoria on-chip prima di essere scritto una sola volta.
IMR (Desktop):
Per ogni triangolo:
Rasterizzare → shading frammenti → scrivere al framebuffer (off-chip)
Banda: ALTA (lettura/scrittura costante del framebuffer)
Parallelismo: ALTO (i triangoli sono indipendenti)
TBDR (Mobile):
Passo 1 - Binning:
Per ogni triangolo:
Determinare quali tile tocca → aggiungere al bin del tile
Passo 2 - Rendering:
Per ogni tile:
Caricare il tile nella memoria on-chip
Per ogni triangolo nel bin di questo tile:
Rasterizzare → shading frammenti → scrivere nella memoria del tile (on-chip)
Scrivere il tile finito al framebuffer (off-chip)
Banda: BASSA (una scrittura per tile, non per frammento)
Latenza: PIÙ ALTA (passo extra di binning)
Questo conta per il rendering del browser perché i blend mode e la trasparenza si comportano diversamente su GPU tile-based. Siccome tutti i frammenti di un tile vengono elaborati insieme, la GPU può risolvere il blending nella memoria on-chip senza costosi readback del framebuffer. Per questo mix-blend-mode può essere più veloce su mobile che su desktop in alcuni casi — controintuitivo ma vero.
Tuttavia, il tile-based rendering odia una cosa: overdraw con shader complessi. Ogni frammento in un tile viene sottoposto a shading, anche se è eventualmente occluso da un frammento successivo. Il Forward Pixel Kill di Mali e l'Hidden Surface Removal di Apple cercano di mitigare questo, ma stack di layer complessi con blending sconfiggono queste ottimizzazioni perché i pixel occlusi contribuiscono effettivamente al colore finale.
Skia: Il Motore di Rendering 2D del Browser
Chrome, Android, Flutter, e Firefox (parzialmente) usano tutti Skia per il rendering 2D. Il backend GPU di Skia (Ganesh, in fase di sostituzione con Graphite) traduce comandi di disegno ad alto livello in chiamate API GPU.
Ecco una vista semplificata di come Skia elabora un draw con blend mode:
// Skia interno: elaborazione di un draw con blend mode
void GrOpsRenderPass::draw(const GrProgramInfo& programInfo,
const GrMesh& mesh) {
// 1. Controllare se il blend mode è "semplice" (può usare blend HW)
if (programInfo.pipeline().getXferProcessor().hasHWBlendEquation()) {
// Configurare stato blend a funzione fissa
this->setHWBlendState(programInfo.pipeline());
this->issueDrawCall(mesh);
}
// 2. Controllare KHR_blend_equation_advanced
else if (fGpu->caps()->advancedBlendEquationSupport()) {
this->setAdvancedBlendEquation(xferMode);
glBlendBarrierKHR(); // Barriera di coerenza richiesta
this->issueDrawCall(mesh);
}
// 3. Fallback: leggere dst nello shader
else {
// Bind del framebuffer corrente come input texture allo shader
this->bindDstTexture(programInfo);
// Fragment shader legge dst, calcola blend, scrive risultato
this->issueDrawCall(mesh);
}
}
Il backend Graphite di Skia (il successore di Ganesh) prende un approccio fondamentalmente diverso: costruisce un render graph dell'intero frame prima di inviare qualsiasi lavoro alla GPU. Questo gli permette di:
- Raggruppare draw call più aggressivamente
- Eliminare cambi ridondanti di render target
- Schedulare lavoro GPU più efficientemente su API moderne (Vulkan, Metal, Dawn)
Ho fatto benchmark di Graphite vs Ganesh su un workload di compositing complesso (50 layer sovrapposti con vari blend mode, 1080p):
Ganesh (backend GL): 8,2 ms/frame (~122 fps)
Graphite (Vulkan): 3,1 ms/frame (~322 fps)
Graphite (Metal M2): 2,4 ms/frame (~416 fps)
L'approccio render graph è particolarmente efficace per i blend mode perché può raggruppare tutti gli elementi che usano lo stesso blend mode in un singolo render pass, riducendo i cambi di stato.
Vello: Il Futuro Sono i Compute Shader
Vello (precedentemente piet-gpu) prende un approccio radicale: fare tutto in compute shader. Nessun pipeline di rasterizzazione, nessun blending a funzione fissa. L'intero pipeline di rendering 2D — appiattimento path, espansione stroke, tiling, rasterizzazione fine, compositing — gira come una serie di dispatch di compute shader.
// Stadi del pipeline di Vello (semplificato):
// 1. Encoding dei path: curve Bézier → segmenti di linea (flatten)
// 2. Binning: assegnare segmenti di path ai tile
// 3. Rasterizzazione grossa: costruire liste di comandi per tile
// 4. Rasterizzazione fine: eseguire comandi per tile, output pixel
// Il kernel di rasterizzazione fine gestisce il blending inline:
// (pseudocodice WGSL)
@compute @workgroup_size(256, 1, 1)
fn fine(@builtin(global_invocation_id) gid: vec3<u32>) {
let tile_xy = gid.xy;
var rgba = vec4<f32>(0.0); // accumulatore locale del tile
for (cmd in tile_commands[tile_xy]) {
switch cmd.tag {
CMD_FILL: {
let src = compute_coverage(cmd);
rgba = blend(src, rgba, cmd.blend_mode);
}
CMD_STROKE: { /* simile */ }
CMD_IMAGE: { /* texture sample + blend */ }
}
}
textureStore(output, tile_xy, rgba);
}
Il vantaggio: siccome Vello controlla l'intero pipeline, i blend mode sono semplicemente code path diversi nello shader di rasterizzazione fine. Non c'è "fast path" vs "slow path" — tutti i blend mode hanno le stesse caratteristiche di performance. E siccome tutto gira in compute, non c'è overhead per cambio di stato quando si passa tra blend mode tra draw call.
I miei benchmark confrontando Vello con Skia/Ganesh su contenuto SVG pesante in path (10.000 path con vari blend mode):
Skia Ganesh (GL): 12,4 ms
Skia Graphite (Vk): 5,8 ms
Vello (Vk compute): 2,1 ms
Vello è ancora sperimentale e non gestisce tutti i casi limite (rendering del testo, stack complessi di clip), ma per contenuti pesanti in path e blending è già significativamente più veloce.
Profiling GPU Pratico per Contenuti Web
Quando qualcosa renderizza lentamente, ecco il mio workflow di profiling:
Passo 1: Ispezione dei layer
// Nella Console di Chrome DevTools:
// Forzare i bordi dei layer
document.querySelectorAll('*').forEach(el => {
const style = getComputedStyle(el);
if (style.willChange !== 'auto' ||
style.transform !== 'none' ||
style.opacity !== '1' ||
style.mixBlendMode !== 'normal' ||
style.filter !== 'none') {
console.log(el.tagName, el.className, {
willChange: style.willChange,
transform: style.transform,
opacity: style.opacity,
mixBlendMode: style.mixBlendMode,
filter: style.filter
});
}
});
Passo 2: Cattura trace GPU
# Tracing GPU di Chrome
google-chrome --enable-gpu-benchmarking \
--enable-tracing=gpu,viz,cc \
--trace-startup-file=gpu_trace.json \
"https://tua-pagina.com"
# Analizzare con chrome://tracing o Perfetto
# Cercare:
# - Durate "RasterTask" > 4ms (tile lenti)
# - "DrawQuad" con blend_mode != NORMAL
# - "GLRenderer::DrawContentQuad" come indicatori di readback
Passo 3: Stima della banda del framebuffer
// Stimare il costo di compositing basato su dimensioni dei layer e blend mode
function estimateCompositingCost(layers) {
let totalPixels = 0;
let readbackPixels = 0;
layers.forEach(layer => {
const pixels = layer.width * layer.height;
totalPixels += pixels;
// Blend mode non separabili possono scatenare readback
const expensiveBlends = [
'hue', 'saturation', 'color', 'luminosity'
];
if (expensiveBlends.includes(layer.blendMode)) {
readbackPixels += pixels;
}
});
// A 4 byte/pixel, 60fps:
const bandwidthGB = (totalPixels * 4 * 60) / 1e9;
const readbackGB = (readbackPixels * 4 * 2 * 60) / 1e9; // 2x per read+write
return { bandwidthGB, readbackGB };
}
La Trappola del Compositing
Ecco cosa mi ha fregato con quell'animazione SVG originale, e quello che ora vedo ovunque: gli sviluppatori applicano blend mode senza rendersi conto del costo downstream sulla GPU. Un mix-blend-mode: multiply su un elemento grande non cambia solo come si combinano i colori — può forzare la creazione di un render target intermedio (buffer offscreen), un readback del framebuffer per pixel, e l'impossibilità di unire quel layer con layer adiacenti.
La regola empirica che uso ora:
- Blend mode separabili (multiply, screen, darken, lighten): Generalmente accelerati via hardware. Vanno bene su elementi di dimensioni ragionevoli.
- Blend mode non separabili (hue, saturation, color, luminosity): Spesso scatenano fallback software o path shader costosi. Evitare su aree grandi.
- Qualsiasi blend mode su un elemento grande: Forza un'allocazione di texture intermedia proporzionale all'area in pixel dell'elemento. Preferire applicare blend mode a elementi più piccoli, pre-compositi.
- Blend mode annidati: Ogni livello di annidamento può creare una texture intermedia aggiuntiva. Il costo di memoria si moltiplica.
Il pipeline di rendering del browser è uno dei pezzi più sofisticati di software di grafica real-time in esecuzione su hardware consumer. Capirlo a livello GPU mi ha reso uno sviluppatore web fondamentalmente migliore — non perché stia facendo programmazione GPU, ma perché ora posso prevedere cosa sarà veloce e cosa sarà dolorosamente lento. E la previsione batte il profiling quando stai prendendo decisioni di design.