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.
let und var
Abschnitt betitelt „let und var“Mit let speichern wir einen Wert, dem wir anschließend nichts Neues mehr zuweisen können.
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.
var brightness = 1.0;brightness = brightness * 0.5;Ein Beispiel dafür kennen wir bereits aus unserem Phong-Reflexionsmodell:
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:
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:
const MAX_LIGHTS = 4;var brightness: array<f32, MAX_LIGHTS>;Bei unserer Beleuchtungsberechnung sieht das anders aus:
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:
constverwenden wir für feste Werte und Berechnungen, die ausschließlich auf anderen Konstanten beruhen.letverwenden 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.
override
Abschnitt betitelt „override“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:
override shininess: f32 = 32.0;Der Standardwert ist 32.0. Beim Erstellen unserer Render-Pipeline können wir stattdessen einen anderen Wert übergeben:
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.
Adressräume
Abschnitt betitelt „Adressräume“Bisher haben wir Variablen innerhalb unserer Shader-Funktionen deklariert. Daneben kennen wir bereits diese Schreibweise:
@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.
function
Abschnitt betitelt „function“Wenn wir innerhalb einer Funktion var verwenden, gehört die Variable automatisch zum Adressraum function.
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.
private
Abschnitt betitelt „private“Mit private können wir eine Variable außerhalb unserer Funktionen deklarieren und in mehreren Funktionen verwenden.
var<private> brightness: f32 = 1.0;
fn reduceBrightness() { brightness = brightness * 0.5;}
@fragmentfn 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.
uniform
Abschnitt betitelt „uniform“Den Adressraum uniform kennen wir bereits sehr gut.
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:
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.
storage
Abschnitt betitelt „storage“Mit storage können Shader auf Storage-Buffer zugreifen.
@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.
workgroup
Abschnitt betitelt „workgroup“Der Adressraum workgroup gehört zur Arbeit mit Compute-Shadern.
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.
@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.
Daten zwischen Shader-Stufen übergeben
Abschnitt betitelt „Daten zwischen Shader-Stufen übergeben“Nicht jede Deklaration mit einem @-Zeichen beschreibt einen Adressraum.
Aus unseren bisherigen Beispielen kennen wir auch @location und @builtin:
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.
Zusammenfassung
Abschnitt betitelt „Zusammenfassung“Für unsere nächsten Shader können wir uns an einigen einfachen Regeln orientieren:
letfür berechnete Werte, die wir anschließend nicht mehr verändern.varfür Variablen, denen wir später neue Werte zuweisen.constfür feste Konstanten wiePIoderMAX_LIGHTS.overridefü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.