Zum Inhalt springen

Variablen und Adressräume in WGSL

In unseren bisherigen Shadern haben wir bereits let, var und var<uniform> verwendet. Jetzt schauen wir uns genauer an, welche Unterschiede es gibt und wann wir welche Schreibweise verwenden.

Mit let speichern wir einen Wert, dem wir anschließend nichts Neues mehr zuweisen können.

WGSL
let normal = normalize(normals);
let brightness = max(dot(normal, lightDirection), 0.0);

Hier berechnen wir zunächst eine normalisierte Normale und anschließend die Helligkeit. Beide Ergebnisse bleiben für den Rest ihrer jeweiligen Gültigkeitsbereiche unverändert.

Mit var deklarieren wir dagegen eine Variable, deren Wert wir später verändern können.

WGSL
var brightness = 1.0;
brightness = brightness * 0.5;

Ein Beispiel dafür kennen wir bereits aus unserem Phong-Reflexionsmodell:

WGSL
var specularFactor = max(dot(reflected, uniforms.viewDirection.xyz), 0.0);
specularFactor = pow(specularFactor, uniforms.shininess);

Zuerst berechnen wir den Winkelanteil des Glanzlichts. Anschließend verändern wir das Ergebnis mit pow().

In der Praxis verwenden wir let, solange wir einem Wert nichts Neues zuweisen müssen. Wenn wir ihn später verändern möchten, verwenden wir var.

Aus JavaScript kennen wir const für Werte, denen wir nichts Neues zuweisen möchten. In WGSL haben wir dafür gerade let kennengelernt.

Warum gibt es trotzdem const?

Betrachten wir zwei Beispiele:

WGSL
const MAX_LIGHTS = 4;

Hier legen wir eine feste Zahl fest. Sie ist direkt im Shader-Code definiert und hängt nicht davon ab, welche Daten der Shader später verarbeitet.

Wir können sie beispielsweise verwenden, um die Größe eines Arrays festzulegen:

WGSL
const MAX_LIGHTS = 4;
var brightness: array<f32, MAX_LIGHTS>;

Bei unserer Beleuchtungsberechnung sieht das anders aus:

WGSL
let brightness = max(dot(normal, lightDirection), 0.0);

Hier hängt die Helligkeit von den tatsächlichen Werten der Normalen und der Lichtrichtung ab. Bei jedem Fragment kann ein anderes Ergebnis entstehen.

Dafür verwenden wir let. Mit const wäre diese Deklaration nicht möglich, weil die Berechnung Werte benötigt, die erst bei der Ausführung des Shaders vorliegen.

Wir können uns also merken:

  • const verwenden wir für feste Werte und Berechnungen, die ausschließlich auf anderen Konstanten beruhen.
  • let verwenden wir für unveränderliche Werte, die auch aus aktuellen Shader-Berechnungen stammen dürfen.

Eine feste Farbe könnten wir beispielsweise sowohl mit let als auch mit const definieren. In diesem Fall sind beide Schreibweisen erlaubt. const macht dabei deutlich, dass wir eine feste Konstante definieren möchten.

Manchmal möchten wir eine Konstante im Shader definieren, deren Standardwert wir beim Erstellen der Pipeline von JavaScript aus überschreiben können. Dafür gibt es override.

Dafür gibt es override:

WGSL
override shininess: f32 = 32.0;

Der Standardwert ist 32.0. Beim Erstellen unserer Render-Pipeline können wir stattdessen einen anderen Wert übergeben:

JavaScript
const pipeline = device.createRenderPipeline({
layout: "auto",
vertex: {
module: shaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout, normalBufferLayout],
},
fragment: {
module: shaderModule,
entryPoint: "fragmentMain",
constants: {
shininess: 64.0,
},
targets: [{ format }],
},
});

In diesem Fall verwendet der Fragment-Shader 64.0.

Ein override-Wert wird beim Erstellen der Pipeline festgelegt. Möchten wir ihn ändern, müssen wir eine neue Pipeline erstellen.

Uniforms sind flexibler: Ihre Werte können wir jederzeit über den Uniform-Buffer ändern, ohne die Pipeline neu erstellen zu müssen.

override ist vor allem dann nützlich, wenn wir verschiedene feste Varianten eines Shaders benötigen.

Bisher haben wir Variablen innerhalb unserer Shader-Funktionen deklariert. Daneben kennen wir bereits diese Schreibweise:

WGSL
@group(0) @binding(0)
var<uniform> uniforms: Uniforms;

Die Angabe <uniform> bezeichnet einen sogenannten Adressraum.

Adressräume legen fest, wo die Daten einer Variablen gespeichert werden und wie der Shader darauf zugreifen darf.

WGSL unterscheidet mehrere Adressräume.

Adressraum Verwendung
function Veränderbare lokale Variablen innerhalb einer Funktion
private Variablen, die jede Shader-Ausführung für sich besitzt
uniform Daten aus einem Uniform-Buffer
storage Daten aus einem Storage-Buffer
workgroup Gemeinsame Daten innerhalb einer Compute-Shader-Workgroup
handle Ressourcen wie Texturen und Sampler

Schauen wir uns die wichtigsten Unterschiede an.

Wenn wir innerhalb einer Funktion var verwenden, gehört die Variable automatisch zum Adressraum function.

WGSL
fn calculateBrightness() -> f32 {
var brightness = 1.0;
brightness = brightness * 0.5;
return brightness;
}

Die Variable brightness existiert innerhalb dieses Funktionsaufrufs. Andere Shader-Ausführungen greifen nicht auf dieselbe Variable zu.

Den Adressraum müssen wir hier nicht ausdrücklich angeben.

Mit private können wir eine Variable außerhalb unserer Funktionen deklarieren und in mehreren Funktionen verwenden.

WGSL
var<private> brightness: f32 = 1.0;
fn reduceBrightness() {
brightness = brightness * 0.5;
}
@fragment
fn fragmentMain() -> @location(0) vec4f {
reduceBrightness();
return vec4f(brightness, brightness, brightness, 1.0);
}

Die Variable brightness beginnt mit 1.0. Die Funktion reduceBrightness() halbiert ihren Wert. Anschließend verwendet fragmentMain() das Ergebnis und gibt ein mittleres Grau zurück.

Warum heißt dieser Adressraum private?

Aus JavaScript kennen wir Variablen, die außerhalb einer Funktion stehen. Mehrere Funktionsaufrufe können auf dieselbe Variable zugreifen und ihren Wert verändern.

Bei WGSL funktioniert das anders: Unser Fragment-Shader wird für jedes Fragment erneut ausgeführt. Dabei erhält jedes Fragment seine eigene brightness-Variable, die wieder bei 1.0 beginnt.

Die Variable ist also innerhalb einer Shader-Ausführung für mehrere Funktionen zugänglich, wird aber nicht zwischen verschiedenen Fragmenten geteilt.

Wenn wir lediglich einen Zwischenwert innerhalb einer Funktion benötigen, verwenden wir stattdessen wie gewohnt let oder var.

Den Adressraum uniform kennen wir bereits sehr gut.

WGSL
struct Uniforms {
color: vec4f,
shininess: f32,
};
@group(0) @binding(0)
var<uniform> uniforms: Uniforms;

Die Werte kommen aus einem Uniform-Buffer, den wir in JavaScript anlegen und über eine Bind Group bereitstellen.

Im Shader können wir diese Werte lesen:

WGSL
let color = uniforms.color.rgb;

Wir dürfen sie dort aber nicht überschreiben.

Wenn wir die Werte verändern möchten, aktualisieren wir den Buffer auf der JavaScript-Seite, beispielsweise mit device.queue.writeBuffer().

Das macht Uniforms besonders praktisch für Materialfarben, Beleuchtungsparameter und später auch Kameradaten.

Mit storage können Shader auf Storage-Buffer zugreifen.

WGSL
@group(0) @binding(1)
var<storage, read_write> values: array<f32>;

Storage-Buffer eignen sich unter anderem für größere Datenmengen. Je nach Deklaration und Shader-Stufe können wir ihre Inhalte lesen und auch verändern.

In unserem Beispiel erlaubt read_write beide Zugriffsarten.

Das wird besonders bei Compute-Shadern interessant, wenn wir Berechnungsergebnisse direkt in einen Buffer schreiben möchten.

Der Adressraum workgroup gehört zur Arbeit mit Compute-Shadern.

WGSL
var<workgroup> sharedValues: array<f32, 64>;

Eine Workgroup besteht aus mehreren zusammenarbeitenden Shader-Ausführungen. Sie können über diesen Adressraum auf gemeinsame Daten zugreifen.

Das ist etwas anderes als private, wo jede Ausführung ihre eigenen Werte besitzt.

Wie diese Zusammenarbeit funktioniert und wann wir dafür Synchronisation benötigen, behandeln wir später bei den Compute-Shadern.

Auch Texturen und Sampler sind Ressourcen, auf die wir in WGSL zugreifen können.

WGSL
@group(0) @binding(0)
var texture: texture_2d<f32>;
@group(0) @binding(1)
var textureSampler: sampler;

Diese Ressourcen gehören zum Adressraum handle. Anders als bei uniform oder storage schreiben wir hier aber nicht ausdrücklich var<handle>.

Mit Texturen und Samplern beschäftigen wir uns ausführlicher im Texturkapitel.

Nicht jede Deklaration mit einem @-Zeichen beschreibt einen Adressraum.

Aus unseren bisherigen Beispielen kennen wir auch @location und @builtin:

WGSL
struct VertexOutput {
@builtin(position) position: vec4f,
@location(0) normals: vec3f,
};

Damit beschreiben wir, welche Daten ein Shader ausgibt und wie sie von der nächsten Shader-Stufe empfangen werden.

@builtin(position) kennzeichnet die Position für die Rasterisierung.

Über @location(0) geben wir in diesem Beispiel die Normalen vom Vertex-Shader an den Fragment-Shader weiter. Die Werte werden dabei standardmäßig interpoliert.

In GLSL beziehungsweise GLSL ES begegnen uns für ähnliche Aufgaben Begriffe wie attribute, varying, in und out. WGSL verwendet dafür seine eigenen Attribute.

Das ist etwas anderes als ein Adressraum: @location beschreibt die Zuordnung von Ein- und Ausgaben, während beispielsweise uniform oder storage festlegen, auf welche Art von Speicher wir zugreifen.

Für unsere nächsten Shader können wir uns an einigen einfachen Regeln orientieren:

  • let für berechnete Werte, die wir anschließend nicht mehr verändern.
  • var für Variablen, denen wir später neue Werte zuweisen.
  • const für feste Konstanten wie PI oder MAX_LIGHTS.
  • override für Konstanten, die wir beim Erstellen der Pipeline festlegen möchten.
  • var<uniform> für Daten, die wir aus JavaScript an unsere Shader übergeben.

Die weiteren Adressräume werden wir kennenlernen, sobald wir sie in unseren Beispielen benötigen. Besonders storage und workgroup spielen später bei Compute-Shadern eine wichtige Rolle.