Skip to main content

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.

Un gatto

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:

G(x,y)=12πσ2  exp(x2+y22σ2)G(x, y) = \frac{1}{2\pi \sigma^2} \; \exp \left(-\frac{x^2 + y^2}{2\sigma^2} \right)

dove:

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

The sample application
The sample application

Gli obiettivi dell’implementazione sono i seguenti:

#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:

@fragment
fn toFloat4() -> @location(0) vec4f
@fragment
fn toFloat3() -> @location(0) vec3f
@fragment
fn 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.

src/webgpu/texture_metadata.ts
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
| Float64Array
6 collapsed lines
| Int8Array
| Uint8Array
| Uint8ClampedArray
| Int16Array
| Uint16Array
| Int32Array
| Uint32Array;
export type TypedArrayConstructor = Float32ArrayConstructor
| Float64ArrayConstructor
6 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:

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:

src/filter/gauss_blur_2d.ts
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 I(x,y)I'(x, y) nella posizione (x,y)(x, y) dopo l’applicazione del filtro gaussiano è dato dalla somma dei valori di pixel pesati secondo il kernel:

I(x,y)=i=kkj=kkI(x+i,y+j)G(i,j)I'(x, y) = \sum_{i=-k}^{k} \sum_{j=-k}^{k} I(x+i, y+j) \cdot G(i, j)

dove:

La deviazione standard è determinata come un terzo del raggio del kernel (5), una scelta che sfrutta la proprietà statistica della distribuzione normale secondo cui:

P(μ3σXμ+3σ)=99.7%P(\mu - 3\sigma \leq X \leq \mu + 3\sigma) = 99.7\%

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 G(i,j)G(i, j) 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.