Implementazione di base
La sfocatura gaussiana è un filtro che applica una media ponderata ai pixel vicini di un’immagine o texture, utilizzando una funzione gaussiana per determinare i pesi.

A destra, l’immagine dopo l’applicazione della sfocatura gaussiana. Immagine generata con DALL·E.
L’algoritmo si basa su una matrice chiamata kernel gaussiano, che rappresenta una distribuzione normale, e viene applicata su ogni pixel dell’immagine. Il kernel gaussiano in 2D ha la seguente forma:
dove:
- e sono le coordinate dei pixel rispetto al pixel centrale.
- è la deviazione standard della distribuzione gaussiana, che determina l’intensità della sfocatura. Più grande è , più sfocata sarà l’immagine.
- La funzione esponenziale calcola il peso per ogni pixel in base alla sua distanza dal centro.
Una demo di questo filtro è disponibile nella pagina dedicata.
#Struttura del progetto
Il repository è un’applicazione sviluppata con Vite in TypeScript, corredata da test scritti con QUnit e Jest.
La pagina HTML fornisce un’interfaccia che permette di scegliere un’immagine e applicare in tempo reale la sfocatura gaussiana, variando il raggio del kernel .

Gli obiettivi dell’implementazione sono i seguenti:
- Supporto a diversi formati di texture (RGB, RGBA, interi, float e così via).
- Gestione efficiente delle risorse sulla GPU.
- Correttezza del risultato ed velocità di esecuzione.
#Gestione degli shader
Invece di scrivere gli shader in file di testo separati e caricarli successivamente in JavaScript, utilizzo funzioni che restituiscono il codice sotto forma di stringhe interpolate:
function gauss2dBlurShader( format: GPUTextureFormat): string { // language=WGSL return ` .... shader code .... `;}
Questo approccio consente di sfruttare appieno gli strumenti di JavaScript per generare dinamicamente il codice e centralizzare le parti comuni tra diversi shader. L’idea richiama le soluzioni CSS-in-JS, offrendo vantaggi simili in termini di modularità e riutilizzabilità.
#Supporto formati texture multipli
I diversi formati di texture, sia in input sia come target del rendering, introducono variazioni nel codice dello shader. Un primo esempio riguarda la dichiarazione stessa delle texture, che dipende dal tipo di campione (Sample Type, ST):
@group(0) @binding(0)var floatTexture: texture_2d<f32>;
@group(0) @binding(1)var uintTexture: texture_2d<u32>;
@group(0) @binding(2)var sintTexture: texture_2d<i32>;
Un altro esempio è il tipo di ritorno del fragment shader, che varia in base al formato del render target:
@fragmentfn toFloat4() -> @location(0) vec4f
@fragmentfn toFloat3() -> @location(0) vec3f
@fragmentfn toFloat() -> @location(0) f32
// ... other variants for signed and unsigned sample types ...
Infine, il modo in cui viene letta una texture dipende dal tipo di accesso, con o senza sampler:
textureSample(inputTexture, sampler, floatCoords)
textureLoad(inputTexture, integerCoords, 0)
In particolare, le texture intere non supportano l’uso di textureSample()
.
Anche la scelta tra compute shader e fragment shader può dipendere dal formato della texture da processare.
I compute shader, infatti, non supportano l’uso di texture_2d
, ma richiedono texture_storage_2d
, che è compatibile solo con
un sottoinsieme specifico di formati.
Per risolvere questi problemi, è utile definire una mappa che raccolga le informazioni specifiche di ciascun formato. Queste informazioni possono essere utilizzate per generare dinamicamente le sezioni dello shader che dipendono dal formato.
export type SampleType = 'f32' | 'u32' | 'i32';
export type TexelType = 'f32' | 'u32' | 'i32' | 'vec2f' | 'vec2u' | 'vec2i' | 'vec3f' | 'vec3u' | 'vec3i' | 'vec4f' | 'vec4u' | 'vec4i';
export type TypedArray = Float32Array | Float64Array6 collapsed lines
| Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array;
export type TypedArrayConstructor = Float32ArrayConstructor | Float64ArrayConstructor6 collapsed lines
| Int8ArrayConstructor | Uint8ArrayConstructor | Uint8ClampedArrayConstructor | Int16ArrayConstructor | Uint16ArrayConstructor | Int32ArrayConstructor | Uint32ArrayConstructor;
export interface TextureFormatInfo { bytesPerTexel: number; channelsCount: 1 | 2 | 3 | 4; sampleType: SampleType; texelType: TexelType; textureSamplerType: GPUTextureSampleType; typedArrayConstructor: TypedArrayConstructor;}
export const TEXTURE_FORMAT_INFO: { [ texelFormat in GPUTextureFormat ]?: TextureFormatInfo } = { "r32float": { bytesPerTexel: 4, channelsCount: 1, sampleType: 'f32', texelType: 'f32', textureSamplerType: 'float', typedArrayConstructor: Float32Array },16 collapsed lines
"rgba32float": { bytesPerTexel: 16, channelsCount: 4, sampleType: 'f32', texelType: 'vec4f', textureSamplerType: 'float', typedArrayConstructor: Float32Array }, "rgba8uint": { bytesPerTexel: 4, channelsCount: 4, sampleType: 'u32', texelType: 'vec4u', textureSamplerType: 'uint', typedArrayConstructor: Uint8ClampedArray }, "r8uint": { bytesPerTexel: 1, channelsCount: 1, sampleType: 'u32', texelType: 'u32', textureSamplerType: 'uint', typedArrayConstructor: Uint8Array },16 collapsed lines
"rgba8unorm": { bytesPerTexel: 4, channelsCount: 4, sampleType: 'f32', texelType: 'vec4f', textureSamplerType: 'float', typedArrayConstructor: Uint8ClampedArray }, "bgra8unorm": { bytesPerTexel: 4, channelsCount: 4, sampleType: 'f32', texelType: 'vec4f', textureSamplerType: 'float', typedArrayConstructor: Uint8ClampedArray }};
export function castToFloat( texelType: TexelType): TexelType { switch (texelType) { case "f32": return "f32";10 collapsed lines
case "u32": return "f32"; case "i32": return "f32"; case "vec2f": return "vec2f"; case "vec2u": return "vec2f"; case "vec2i": return "vec2f"; case "vec3f": return "vec3f"; case "vec3u": return "vec3f"; case "vec3i": return "vec3f"; case "vec4f": return "vec4f"; case "vec4u": return "vec4f"; case "vec4i": return "vec4f"; }}
export function channelMask( channelCount: 1 | 2 | 3 | 4): string { switch (channelCount) { case 1: return "r"; case 2: return "rg"; case 3: return "rgb"; case 4: return "rgba"; }}
La mappa TEXTURE_FORMAT_INFO
associa a ciascun formato di texture un oggetto di tipo TextureFormatInfo
, che contiene
una serie di informazioni utili:
bytesPerTexel
: il numero di byte per ogni texel.channelsCount
: il numero di canali per texel (ad esempio, 3 per il formato RGB).sampleType
: il tipo di sample per un singolo canale (utilizzato nel tipizzaretexture_2d<>
).texelType
: il tipo di sample di un texel (ad esempio,vec4f
per il formato RGBA float).textureSamplerType
: il tipo di campionamento per la texture (utilizzato nella definizione dei binding).typedArrayConstructor
: il costruttore di un array tipizzato adatto al formato texture.
Le funzioni castToFloat()
e channelMask()
sono progettate per gestire specifici dettagli relativi ai formati e verranno
approfondite in seguito.
#Gestione e riciclo delle risorse
Per invocare uno shader è necessario creare e collegare diversi oggetti, come buffer, bind groups e render pipeline. Inoltre, la sfocatura gaussiana non può essere calcolata in-place, ossia sovrascrivendo direttamente la texture di input, ma richiede la creazione di una texture separata per l’output. Se l’effetto deve essere applicato ripetutamente, ad esempio quando l’utente modifica il raggio del kernel dallo slider, è utile riutilizzare gli oggetti esistenti anziché ricrearli ogni volta.
Un altro aspetto importante riguarda la gestione della memoria occupata da buffer e texture, che deve essere esplicitamente
rilasciata chiamando il metodo destroy()
sugli oggetti GPUBuffer
e GPUTexture
.
Per gestire questi aspetti in modo efficiente, l’implementazione della sfocatura gaussiana è organizzata in una classe
Gauss2dBlur
, anziché in una semplice funzione. Le risorse che possono essere riutilizzate tra un’invocazione e l’altra
vengono mantenute come membri di classe:
export class Gauss2dBlur { private readonly device: GPUDevice; private readonly uniformBuffer: GPUBuffer; private readonly renderPipeline: GPURenderPipeline;}
La classe viene creata tramite un metodo factory statico e asincrono, create()
, che prende in input tutti i parametri che non
possono essere cambiati senza dover ricreare le risorse riutilizzabili:
export class Gauss2dBlur { static async create( device: GPUDevice, inputFormat: GPUTextureFormat ) { // Create shader module device.pushErrorScope('validation'); const shaderModule = device.createShaderModule({ code: gauss2dBlurShader(inputFormat) }); const errors = await device.popErrorScope(); if (errors) { throw new Error('Could not compile shader!'); }
// Allocate resources const inputInfo = TEXTURE_FORMAT_INFO[inputFormat]!; const uniformBuffer; const renderPipeline;
/* ...code... */
// Instance return new Gauss2dBlur( device, uniformBuffer, renderPipeline ); }
private constructor( device: GPUDevice, uniformBuffer: GPUBuffer, renderPipeline: GPURenderPipeline ) { // ...set members... }}
Lo shader viene generato dinamicamente in base al formato di input della texture attraverso la funzione gauss2dBlurShader()
.
La creazione del shaderModule
non solleva eccezioni in caso di errori, pertanto è necessario eseguire un controllo manuale.
Successivamente, il metodo crea uniformBuffer
per il passaggio dei parametri allo shader e la renderPipeline
, per poi
istanziare la classe tramite il costruttore privato, passando le risorse appena create.
Per applicare l’effetto, è presente un metodo asincrono blur()
che prende in input parametri che possono variare senza
dover ricreare tutte le risorse, come la texture (purché dello stesso formato passato durante la creazione), il raggio
del kernel e una texture di output opzionale:
export class GaussBlur2d { async blur( inputTexture: GPUTexture, kernelRadius: number, outputTexture?: GPUTexture ): Promise<GPUTexture> { if (outputTexture) { if (outputTexture.width !== inputTexture.width || outputTexture.height !== inputTexture.height) { throw new Error('Output texture size does not match input texture!'); } if (outputTexture.format !== inputTexture.format) { throw new Error('Output format does not match input format!'); } } else { outputTexture = this.device.createTexture({ size: { width: inputTexture.width, height: inputTexture.height }, format: inputTexture.format, usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC }); }
/* ...code... */
return outputTexture; }
Il metodo inizia eseguendo alcune validazioni sui parametri di input. Se viene fornita una texture di output, questa deve avere lo stesso formato e le stesse dimensioni della texture di input; altrimenti, il metodo ne crea una internamente, lasciando al chiamante la responsabilità di distruggerla.
Completata questa fase, il metodo effettua il rendering disegnando due triangoli (6 vertici), utilizzando la outputTexture
come render target.
Il metodo destroy()
può essere chiamato dall’esterno per rilasciare tutte le risorse allocate dalla classe GaussBlur2d
:
export class GaussBlur2d { destroy() { this.uniformBuffer?.destroy(); }}
Infine, è disponibile una funzione di utility ad uso immediato:
export async function gauss2dBlur( device: GPUDevice, inputTexture: GPUTexture, kernelRadius: number, outputTexture?: GPUTexture): Promise<GPUTexture> { const gauss2d = await Gauss2dBlur.create( device, inputTexture.format );
try { return gauss2d.blur( inputTexture, kernelRadius, outputTexture ); } finally { gauss2d.destroy(); }}
#Implementazione del filtro
Lo shader per la sfocatura gaussiana utilizza le metainformazioni sulle texture, calcola la sfocatura tramite un kernel gaussiano, e normalizza i risultati. I numeri tra parentesi fanno riferimento ai marcatori nel codice riportato.
function gauss2dBlurShader( format: GPUTextureFormat): string { const formatInfo = TEXTURE_FORMAT_INFO[format]!;
// language=WGSL return ` ```wgsl struct VertexOutput { @builtin(position) position: vec4f };
struct Params { kernelRadius: i32 }
@group(0) @binding(0) var inputTexture: texture_2d<${formatInfo.sampleType}>;
@group(0) @binding(1) var<uniform> params: Params;
@vertex fn vs(@builtin(vertex_index) index: u32) -> VertexOutput { let pos = array<vec2f, 6>( vec2f(-1, 1), vec2f(1, 1), vec2f(1, -1), vec2f(1, -1), vec2f(-1, -1), vec2f(-1, 1) );
var output: VertexOutput; output.position = vec4f(pos[index], 0.0, 1.0); return output; }
const PI: f32 = 3.141592;
@fragment fn fs(@builtin(position) coord: vec4f) -> @location(0) ${formatInfo.texelType} { // 99% of Gaussian values fall within 3 * stdDev // P(mu - 3s <= X <= mu + 3s) = 0.9973 let stdDev = f32(params.kernelRadius) / 3.0;
// Gaussian blur kernel generation let pixelCoords = vec2i(coord.xy - 0.5); let norm = 1.0 / (2.0 * PI * stdDev * stdDev);
// Gaussian blur kernel generation var blur = ${castToFloat(formatInfo.texelType)}(0);
// Since we are discretizing the Gaussian kernel, the sum of the samples won't add up perfectly to 1 var weightSum = 0.0f;
for (var i = -params.kernelRadius; i <= params.kernelRadius; i++) { for (var j = -params.kernelRadius; j <= params.kernelRadius; j++) { let offset = vec2f(f32(i), f32(j)); let weight = exp(-(dot(offset, offset) / (2.0 * stdDev * stdDev))); let I = textureLoad(inputTexture, pixelCoords + vec2i(i, j), 0).${channelMask(formatInfo.channelsCount)}; let gij = norm * weight;
blur += ${castToFloat(formatInfo.texelType)}(I) * gij; weightSum += gij; } }
// Normalize the result by dividing by the sum of the weights blur /= weightSum; ${formatInfo.channelsCount === 4 ? "blur.a = 1.0f;" : "" }
return ${formatInfo.texelType}(blur); } ````;}
#Gestione formato di input
Il metodo utilizza la mappa TEXTURE_FORMAT_INFO
(1) per adattare dinamicamente il codice dello shader ai diversi formati di texture.
Questa mappa fornisce informazioni sul tipo di campione della texture, utilizzato per definire il tipo dinamico di inputTexture
(2),
e sul tipo di texel, che determina il tipo di ritorno del fragment shader (4).
Il vertex shader genera un quadrato che copre l’intero render target utilizzando sei vertici (3).
#Calcolo della sfocatura
La sfocatura gaussiana è calcolata utilizzando un kernel discreto.
Il valore del pixel nella posizione dopo l’applicazione del filtro gaussiano è dato dalla somma dei valori di pixel pesati secondo il kernel:
dove:
- è il valore del pixel nella posizione nell’immagine originale.
- è il valore del kernel gaussiano per la posizione .
- è metà della dimensione del kernel (ad esempio, se il kernel è , allora ).
La deviazione standard è determinata come un terzo del raggio del kernel (5), una scelta che sfrutta la proprietà statistica della distribuzione normale secondo cui:
Questo parametro viene utilizzato per precalcolare il coefficiente di normalizzazione (6), che è costante.
Per il calcolo del blur, tutti i valori vengono trattati come numeri a virgola mobile.
I dati letti dalla texture vengono convertiti in float tramite l’utility castToFloat()
(7, 10). Questo passaggio è necessario in quanto
la texture di input potrebbe essere intera anzichè in virgola mobile.
Inoltre, la funzione textureLoad()
restituisce sempre un vettore di 4 componenti, indipendentemente dal numero effettivo di
canali della texture. Questo può causare un errore di compilazione quando la texture di input (e, di conseguenza, il tipo di blur
)
non è a 4 canali.
Per evitare tale mismatch, i canali richiesti vengono estratti selettivamente con la funzione channelMask()
(8).
Questa funzione permette di selezionare solo i canali corrispondenti al formato di input, adattando il numero fisso di componenti
di textureLoad()
a quelli della variabile blur
.
Durante l’iterazione sui pixel circostanti, viene calcolato il peso di ogni campione in base alla distanza dal centro del kernel (9). Viene tenuta inoltre traccia della somma complessiva dei pesi (11), necessaria per normalizzare il risultato finale.
#Normalizzazione e output
Poiché il kernel gaussiano è campionato in modo discreto, la somma dei pesi non è esattamente pari a 1. Per questo motivo, il risultato della sfocatura viene normalizzato dividendo per la somma dei pesi calcolata (12). Per le texture che includono un canale alpha, il valore di questo canale viene fissato a 1 (13), preservando così la trasparenza.
Infine, il valore risultante a virgola mobile viene riconvertito nel tipo originale della texture (14), al fine di prevenire errori di compilazione.
#Conclusioni e passi successivi
In questo articolo abbiamo visto come implementare il filtro gaussiano, supportando diversi formati di input e gestendo correttamente le risorse. Nella prossima parte, ci concentreremo su un’importante ottimizzazione del calcolo della convoluzione e su come misurare accuratamente i tempi di esecuzione dello shader.