This translation is community contributed and may not be up to date. We only maintain the English version of the documentation. Read this tutorial in English
In diesem Tutorial erstellen wir einen Nachbearbeitungseffekt zur Farbkorrektur (color grading) für den gesamten Bildschirm. Die zugrunde liegende Rendertechnik lässt sich vielseitig für verschiedene Nachbearbeitungseffekte wie Unschärfe, Nachzieheffekte, Leuchten, Farbanpassungen und andere verwenden.
Wir gehen davon aus, dass du dich im Defold-Editor zurechtfindest und grundlegende Kenntnisse über GL-Shader und die Rendering-Pipeline von Defold hast. Wenn du dich in diese Themen einlesen möchtest, sieh dir unser Shader-Handbuch und das Render-Handbuch an.
Mit dem Standard-Render-Skript (render script) wird jede visuelle Komponente (component), etwa ein Sprite, eine Kachelkarte (tile map), ein Partikeleffekt oder eine GUI, direkt in den Bildpuffer (frame buffer) der Grafikkarte gerendert. Die Hardware sorgt dann dafür, dass die Grafik auf dem Bildschirm erscheint. Das eigentliche Zeichnen der Pixel einer Komponente übernimmt ein GL-Shader-Programm (shader program). Defold enthält für jeden Komponententyp ein Standard-Shader-Programm, das die Pixeldaten unverändert auf den Bildschirm zeichnet. Normalerweise ist genau dieses Verhalten erwünscht: Deine Bilder sollen so auf dem Bildschirm erscheinen, wie sie ursprünglich gestaltet wurden.
Du kannst das Shader-Programm einer Komponente durch eines ersetzen, das die Pixeldaten verändert oder völlig neue Pixelfarben programmgesteuert erzeugt. Im Shadertoy-Tutorial erfährst du, wie das geht.
Nehmen wir nun an, du möchtest dein gesamtes Spiel in Schwarz-Weiß rendern. Eine mögliche Lösung besteht darin, das jeweilige Shader-Programm für jeden Komponententyp so zu ändern, dass jeder Shader die Pixelfarben entsättigt. Derzeit enthält Defold 6 integrierte Materialien und 6 Paare aus Vertex- und Fragment-Shader-Programmen. Das ist also mit einigem Aufwand verbunden. Außerdem müssen alle späteren Änderungen oder zusätzlichen Effekte in jedem Shader-Programm vorgenommen werden.
Ein wesentlich flexiblerer Ansatz besteht darin, das Rendering in zwei getrennten Schritten durchzuführen:

Mit dieser Methode können wir die entstandenen Bilddaten auslesen und verändern, bevor sie auf dem Bildschirm erscheinen. Indem wir Schritt 2 oben um Shader-Programme ergänzen, können wir leicht Effekte für den gesamten Bildschirm erzielen. Sehen wir uns an, wie wir das in Defold einrichten.
Wir müssen das integrierte Render-Skript ändern und die neue Renderfunktion hinzufügen. Das Standard-Render-Skript ist ein guter Ausgangspunkt. Kopiere es daher zunächst:
Öffne grade.render und setze ihre Eigenschaft Script auf „/main/grade.render_script“.

Öffne game.project und setze Render auf „/main/grade.render“.

Das Spiel ist jetzt so eingerichtet, dass es eine neue Rendering-Pipeline verwendet, die wir ändern können. Um zu prüfen, ob die Engine unsere Kopie des Render-Skripts verwendet, starte dein Spiel, nimm am Render-Skript eine Änderung mit sichtbarem Ergebnis vor und lade das Skript anschließend neu. Du kannst beispielsweise das Zeichnen von Kacheln und Sprites deaktivieren und dann ⌘ + R drücken, um das „defekte“ Render-Skript per Hot Reload in das laufende Spiel zu laden:
...
render.set_projection(vmath.matrix4_orthographic(0, render.get_width(), 0, render.get_height(), -1, 1))
-- render.draw(self.tile_pred) -- <1>
render.draw(self.particle_pred)
render.draw_debug3d()
...
Wenn die Sprites und Kacheln bei diesem einfachen Test verschwinden, weißt du, dass das Spiel dein Render-Skript ausführt. Wenn alles wie erwartet funktioniert, kannst du die Änderung am Render-Skript rückgängig machen.
Ändern wir nun das Render-Skript so, dass es in das Renderziel außerhalb des Bildschirms statt in den Bildpuffer zeichnet. Zuerst müssen wir das Renderziel erstellen:
function init(self)
self.tile_pred = render.predicate({"tile"})
self.gui_pred = render.predicate({"gui"})
self.text_pred = render.predicate({"text"})
self.particle_pred = render.predicate({"particle"})
self.clear_color = vmath.vector4(0, 0, 0, 1)
self.clear_color.x = sys.get_config_number("render.clear_color_red", 0)
self.clear_color.y = sys.get_config_number("render.clear_color_green", 0)
self.clear_color.z = sys.get_config_number("render.clear_color_blue", 0)
self.clear_color.w = sys.get_config_number("render.clear_color_alpha", 1)
self.view = vmath.matrix4()
local color_params = { format = graphics.TEXTURE_FORMAT_RGBA,
width = render.get_width(),
height = render.get_height() } -- <1>
local target_params = {[graphics.BUFFER_TYPE_COLOR0_BIT] = color_params }
self.target = render.render_target("original", target_params) -- <2>
end
Jetzt müssen wir nur noch den ursprünglichen Rendercode mit Aufrufen von render.set_render_target() umschließen:
function update(self)
render.set_render_target(self.target) -- <1>
render.set_depth_mask(true)
render.set_stencil_mask(0xff)
render.clear({[graphics.BUFFER_TYPE_COLOR0_BIT] = self.clear_color, [graphics.BUFFER_TYPE_DEPTH_BIT] = 1, [graphics.BUFFER_TYPE_STENCIL_BIT] = 0})
render.set_viewport(0, 0, render.get_width(), render.get_height()) -- <2>
render.set_view(self.view)
...
render.set_render_target(render.RENDER_TARGET_DEFAULT) -- <3>
end
render.draw() in die Puffer unseres Renderziels außerhalb des Bildschirms.update() bleibt unverändert, abgesehen vom Ansichtsbereich (viewport), der auf die Auflösung des Renderziels gesetzt wird.Mehr müssen wir nicht tun. Wenn du das Spiel jetzt ausführst, zeichnet es alles in das Renderziel. Da wir aber nichts mehr in den Bildpuffer zeichnen, sehen wir nur einen schwarzen Bildschirm.
Um die Pixel im Farbpuffer des Renderziels auf den Bildschirm zu zeichnen, brauchen wir etwas, das wir mit den Pixeldaten texturieren können. Dazu verwenden wir ein flaches, quadratisches 3D-Modell.
main.collection und erstelle ein neues Spielobjekt (game object) namens „grade“.grade“ eine Modellkomponente (Model) hinzu.quad.gltf, die du unter builtins/assets/meshes findest.Lass das Spielobjekt unskaliert am Ursprung. Wenn wir das Quad später rendern, projizieren wir es so, dass es den gesamten Bildschirm ausfüllt. Zuerst brauchen wir aber ein Material und Shader-Programme für das Quad:
grade.material, indem du mit der rechten Maustaste auf main in der Ansicht Asset klickst und New ▸ Material auswählst.grade.vp und ein Fragment-Shader-Programm namens grade.fp, indem du mit der rechten Maustaste auf main in der Ansicht Asset klickst und New ▸ Vertex program sowie New ▸ Fragment program auswählst.view_proj“ vom Typ CONSTANT_TYPE_VIEWPROJ hinzu. Das ist die kombinierte Ansichts- und Projektionsmatrix, die im Vertex-Programm für die Vertices des Quads verwendet wird.original“ hinzu. Mit ihm werden Pixel aus dem Farbpuffer des Renderziels außerhalb des Bildschirms ausgelesen.Füge ein Tag namens „grade“ hinzu. Wir erstellen im Render-Skript ein neues Render-Prädikat (render predicate), das diesem Tag entspricht, um das Quad zu zeichnen.

Öffne main.collection, wähle die Modellkomponente im Spielobjekt „grade“ aus und setze ihre Eigenschaft Material auf „/main/grade.material“.

Das Vertex-Shader-Programm kann so bleiben, wie es aus der Grundvorlage erstellt wurde:
// grade.vp
uniform mediump mat4 view_proj;
// positions are in world space
attribute mediump vec4 position;
attribute mediump vec2 texcoord0;
varying mediump vec2 var_texcoord0;
void main()
{
gl_Position = view_proj * vec4(position.xyz, 1.0);
var_texcoord0 = texcoord0;
}
Im Fragment-Shader-Programm führen wir eine einfache Farbänderung durch, anstatt gl_FragColor direkt auf den ausgelesenen Farbwert zu setzen. Damit wollen wir vor allem sicherstellen, dass bisher alles wie erwartet funktioniert:
// grade.fp
varying mediump vec4 position;
varying mediump vec2 var_texcoord0;
uniform lowp sampler2D original;
void main()
{
vec4 color = texture2D(original, var_texcoord0.xy);
// Desaturate the color sampled from the original texture
float grey = color.r * 0.3 + color.g * 0.59 + color.b * 0.11;
gl_FragColor = vec4(grey, grey, grey, 1.0);
}
Jetzt ist das Quad-Modell mit seinem Material und seinen Shadern eingerichtet. Wir müssen es nur noch in den Bildpuffer des Bildschirms zeichnen.
Wir müssen dem Render-Skript ein Render-Prädikat hinzufügen, damit wir das Quad-Modell zeichnen können. Öffne grade.render_script und bearbeite die Funktion init():
function init(self)
self.tile_pred = render.predicate({"tile"})
self.gui_pred = render.predicate({"gui"})
self.text_pred = render.predicate({"text"})
self.particle_pred = render.predicate({"particle"})
self.grade_pred = render.predicate({"grade"}) -- <1>
...
end
grade.material festgelegt haben.Nachdem der Farbpuffer des Renderziels in update() gefüllt wurde, richten wir eine Ansicht und eine Projektion ein, mit denen das Quad-Modell den gesamten Bildschirm ausfüllt. Anschließend verwenden wir den Farbpuffer des Renderziels als Textur des Quads:
function update(self)
render.set_render_target(self.target)
...
render.set_render_target(render.RENDER_TARGET_DEFAULT)
render.clear({[graphics.BUFFER_TYPE_COLOR0_BIT] = self.clear_color}) -- <1>
render.set_viewport(0, 0, render.get_window_width(), render.get_window_height()) -- <2>
render.set_view(vmath.matrix4()) -- <3>
render.set_projection(vmath.matrix4())
render.enable_texture(0, self.target, graphics.BUFFER_TYPE_COLOR0_BIT) -- <4>
render.draw(self.grade_pred) -- <5>
render.disable_texture(0, self.target) -- <6>
end
render.clear() das Renderziel beeinflusst, nicht den Bildpuffer des Bildschirms.grade.material befindet sich der Sampler „original“ auf Platz 0, daher liest der Fragment-Shader aus dem Renderziel.grade.material, das dieses Tag setzt, daher wird das Quad gezeichnet.Starten wir nun das Spiel und sehen uns das Ergebnis an:

Farben werden durch drei Komponentenwerte ausgedrückt, wobei jede Komponente den Anteil von Rot, Grün oder Blau in einer Farbe angibt. Das gesamte Farbspektrum von Schwarz über Rot, Grün, Blau, Gelb und Pink bis Weiß lässt sich in einem Würfel darstellen:

Jede Farbe, die auf dem Bildschirm dargestellt werden kann, ist in diesem Farbwürfel enthalten. Die Grundidee der Farbkorrektur besteht darin, einen solchen Farbwürfel mit veränderten Farben als dreidimensionale Nachschlagetabelle (lookup table) zu verwenden.
Für jedes Pixel:
Das können wir in unserem Fragment-Shader tun:

Open GL ES 2.0 unterstützt keine 3D-Texturen, daher müssen wir einen anderen Weg finden, den 3D-Farbwürfel darzustellen. Ein gängiges Verfahren besteht darin, den Würfel entlang der Z-Achse (Blau) in Scheiben zu schneiden und diese in einem zweidimensionalen Raster nebeneinander anzuordnen. Jede der 16 Scheiben enthält ein Raster aus 16 × 16 Pixeln. Wir speichern es in einer Textur, aus der wir im Fragment-Shader mit einem Sampler lesen können:

Die entstandene Textur enthält 16 Zellen, eine für jede Intensität von Blau. Innerhalb jeder Zelle liegen 16 Rottöne entlang der X-Achse und 16 Grüntöne entlang der Y-Achse. Die Textur stellt den gesamten RGB-Farbraum mit 16 Millionen Farben in nur 4096 Farben dar, also mit gerade einmal 4 Bit Farbtiefe. Nach den meisten Maßstäben ist das dürftig, aber dank einer Funktion der GL-Grafikhardware können wir wieder eine sehr hohe Farbgenauigkeit erreichen. Sehen wir uns an, wie das geht.
Um eine Farbe nachzuschlagen, prüfen wir die Blaukomponente und ermitteln, aus welcher Zelle die Rot- und Grünwerte stammen sollen. Die Formel, mit der wir die Zelle mit dem passenden Rot-Grün-Farbsatz finden, ist einfach:
cell = \left \lfloor{B \times (N - 1)} \right \rfloor
Hier ist B der Wert der Blaukomponente zwischen 0 und 1 und N die Gesamtzahl der Zellen. In unserem Fall liegt die Zellnummer im Bereich 0–15, wobei Zelle 0 alle Farben mit der Blaukomponente 0 und Zelle 15 alle Farben mit der Blaukomponente 1 enthält.
Der RGB-Wert (0.63, 0.83, 0.4) liegt beispielsweise in der Zelle, die alle Farben mit einem Blauwert von 0.4 enthält, also in Zelle 6. Mit diesem Wissen lassen sich die endgültigen Texturkoordinaten anhand der Grün- und Rotwerte leicht ermitteln:

Beachte, dass wir die Rot- und Grünwerte (0, 0) als Position in der Mitte des Pixels unten links und die Werte (1.0, 1.0) als Position in der Mitte des Pixels oben rechts behandeln müssen.
Wir lesen von der Mitte des Pixels unten links bis zur Mitte des Pixels oben rechts, weil keine Pixel außerhalb der aktuellen Zelle den ausgelesenen Wert beeinflussen sollen. Siehe dazu die Erklärung zur Filterung weiter unten.
Wenn wir die Textur an diesen bestimmten Koordinaten abtasten, stellen wir fest, dass wir genau zwischen 4 Pixeln landen. Welchen Farbwert wird GL uns also für diesen Punkt liefern?

Die Antwort hängt davon ab, wie wir die Filterung des Samplers im Material festgelegt haben.
Wenn die Sampler-Filterung auf NEAREST gesetzt ist, gibt GL den Farbwert des nächstgelegenen Pixels zurück; dabei wird der Positionswert abgerundet. Im obigen Fall gibt GL den Farbwert an der Position (0.60, 0.80) zurück. Für unsere Nachschlagetextur mit 4 Bit bedeutet das, dass wir die Farbwerte auf insgesamt nur 4096 Farben quantisieren.
Wenn die Sampler-Filterung auf LINEAR gesetzt ist, gibt GL den interpolierten Farbwert zurück. GL mischt eine Farbe anhand der Abstände zu den Pixeln um die Abtastposition. Im obigen Fall gibt GL eine Farbe zurück, die sich zu jeweils 25 % aus den 4 Pixeln um den Abtastpunkt zusammensetzt.
Mit linearer Filterung beseitigen wir also die Farbquantisierung und erzielen mit einer recht kleinen Nachschlagetabelle eine sehr gute Farbgenauigkeit.
Implementieren wir das Nachschlagen in der Textur im Fragment-Shader:
grade.material.lut“ hinzu, kurz für lookup table, also Nachschlagetabelle.Setze die Eigenschaft Filter min auf FILTER_MODE_MIN_LINEAR und die Eigenschaft Filter mag auf FILTER_MODE_MAG_LINEAR.

Lade die folgende Textur der Nachschlagetabelle (lut16.png) herunter und füge sie deinem Projekt hinzu.

Öffne main.collection und setze die Textureigenschaft lut auf die heruntergeladene Nachschlagetextur.

Öffne schließlich grade.fp, damit wir die Unterstützung für das Nachschlagen von Farben hinzufügen können:
varying mediump vec4 position;
varying mediump vec2 var_texcoord0;
uniform lowp sampler2D original;
uniform lowp sampler2D lut; // <1>
#define MAXCOLOR 15.0 // <2>
#define COLORS 16.0
#define WIDTH 256.0
#define HEIGHT 16.0
void main()
{
vec4 px = texture2D(original, var_texcoord0.xy); // <3>
float cell = floor(px.b * MAXCOLOR); // <4>
float half_px_x = 0.5 / WIDTH; // <5>
float half_px_y = 0.5 / HEIGHT;
float x_offset = half_px_x + px.r / COLORS * (MAXCOLOR / COLORS);
float y_offset = half_px_y + px.g * (MAXCOLOR / COLORS); // <6>
vec2 lut_pos = vec2(cell / COLORS + x_offset, y_offset); // <7>
vec4 graded_color = texture2D(lut, lut_pos); // <8>
gl_FragColor = graded_color; // <9>
}
lut.px) aus der ursprünglichen Textur aus, also dem Farbpuffer des Renderziels außerhalb des Bildschirms.px, aus welcher Zelle die Farbe gelesen werden soll.px.Derzeit gibt die Textur der Nachschlagetabelle lediglich dieselben Farbwerte zurück, die wir nachschlagen. Das bedeutet, dass das Spiel mit seiner ursprünglichen Farbgebung gerendert werden sollte:

Bisher sieht es so aus, als hätten wir alles richtig gemacht. Unter der Oberfläche lauert jedoch ein Problem. Sieh dir an, was passiert, wenn wir ein Sprite mit einer Testtextur mit Farbverläufen hinzufügen:

Der blaue Farbverlauf zeigt eine wirklich unschöne Streifenbildung. Woran liegt das?
Die Streifenbildung im Blaukanal entsteht, weil GL beim Auslesen der Farbe aus der Textur keine Interpolation des Blaukanals durchführen kann. Wir wählen anhand des Blauwerts vorab eine bestimmte Zelle zum Auslesen aus, und dabei bleibt es. Wenn der Blaukanal beispielsweise einen beliebigen Wert im Bereich 0.400–0.466 enthält, spielt der genaue Wert keine Rolle: Wir lesen die endgültige Farbe immer aus Zelle 6 aus, in der der Blaukanal auf 0.400 gesetzt ist.
Um eine bessere Auflösung des Blaukanals zu erhalten, können wir die Interpolation selbst implementieren. Liegt der Blauwert zwischen den Werten zweier benachbarter Zellen, können wir beide Zellen auslesen und anschließend die Farben mischen. Wenn der Blauwert beispielsweise 0.420 ist, sollten wir Zelle 6 und Zelle 7 auslesen und anschließend die Farben mischen.
Wir sollten also aus zwei Zellen lesen:
cell_{low} = \left \lfloor{B \times (N - 1)} \right \rfloor
und:
cell_{high} = \left \lceil{B \times (N - 1)} \right \rceil
Anschließend lesen wir Farbwerte aus beiden Zellen aus und interpolieren die Farben linear nach folgender Formel:
color = color_{low} \times (1 - C_{frac}) + color_{high} \times C_{frac}
Hier ist color~low~ die aus der niedrigeren (linken) Zelle ausgelesene Farbe und color~high~ die aus der höheren (rechten) Zelle ausgelesene Farbe. Die GLSL-Funktion mix() führt diese lineare Interpolation für uns aus.
Der Wert C~frac~ oben ist der Nachkommaanteil des Blaukanalwerts, nachdem dieser auf den Farbbereich 0–15 skaliert wurde:
C_{frac} = B \times (N - 1) - \left \lfloor{B \times (N - 1)} \right \rfloor
Auch hierfür gibt es eine GLSL-Funktion, die den Nachkommaanteil eines Werts liefert. Sie heißt frac(). Die endgültige Implementierung im Fragment-Shader (grade.fp) ist recht unkompliziert:
varying mediump vec4 position;
varying mediump vec2 var_texcoord0;
uniform lowp sampler2D original;
uniform lowp sampler2D lut;
#define MAXCOLOR 15.0
#define COLORS 16.0
#define WIDTH 256.0
#define HEIGHT 16.0
void main()
{
vec4 px = texture2D(original, var_texcoord0.xy);
float cell = px.b * MAXCOLOR;
float cell_l = floor(cell); // <1>
float cell_h = ceil(cell);
float half_px_x = 0.5 / WIDTH;
float half_px_y = 0.5 / HEIGHT;
float r_offset = half_px_x + px.r / COLORS * (MAXCOLOR / COLORS);
float g_offset = half_px_y + px.g * (MAXCOLOR / COLORS);
vec2 lut_pos_l = vec2(cell_l / COLORS + r_offset, g_offset); // <2>
vec2 lut_pos_h = vec2(cell_h / COLORS + r_offset, g_offset);
vec4 graded_color_l = texture2D(lut, lut_pos_l); // <3>
vec4 graded_color_h = texture2D(lut, lut_pos_h);
// <4>
vec4 graded_color = mix(graded_color_l, graded_color_h, fract(cell));
gl_FragColor = graded_color;
}
cell, dem skalierten Blauwert.Wenn wir das Spiel erneut mit der Testtextur ausführen, erhalten wir nun deutlich bessere Ergebnisse. Die Streifenbildung im Blaukanal ist verschwunden:

Gut, das war viel Arbeit, um etwas zu zeichnen, das genauso aussieht wie die ursprüngliche Spielwelt. Aber dieser Aufbau ermöglicht uns etwas wirklich Tolles. Jetzt wird es spannend!

lut16.png) an.lut16.png durch die farblich angepasste Version.
Juhu!