Dynamische Lichter und Schatten in meinem 2D-Spiel

Ursprünglicher Autor: Matt Greer
  • Übersetzung
Ich arbeite an einem Stealth-Action-Spiel, bei dem Schatten eine große Rolle im Gameplay spielen. Daher habe ich mit WebGL-Shadern dynamisches Beleuchten / Schattieren erstellt.


Erster Teil: Dynamische Beleuchtung


Ich wurde inspiriert, es durch einen Beitrag auf reddit zu erstellen , in dem aionskull normale Karten in Unity verwendete, um ihre Sprites dynamisch abzudecken. Ein Benutzer mit dem Spitznamen gpillow hat in den Kommentaren geschrieben, dass er in Love2D etwas Ähnliches getan hat. Hier ist ein 8-MB-GIF mit Ergebnissen. Danke an jusksmit für sie.

Was ist dynamische Beleuchtung? Dies ist eine Technik in 3D-Grafiken, bei der eine Lichtquelle Objekte auf der Bühne beleuchtet. Dynamisch, da es in Echtzeit aktualisiert wird, wenn sich die Quelle bewegt. Ziemlich Standard in der 3D-Welt und leicht auf 2D anwendbar, es sei denn, Sie können natürlich die Shader nutzen.

Sie können eine dynamische Beleuchtung erstellen, indem Sie wissen, dass der Einfallswinkel des Lichts auf die Ebene die Beleuchtung bestimmt, und Sie können die Beleuchtung bestimmen, indem Sie den Normalenvektor erkennen, der zeigt, wohin die Ebene „schaut“.



In der Abbildung oben ist dies ein Pfeil, der aus der Mitte des Bedienfelds herausragt. Sie können sehen, dass das Panel viel schlechter beleuchtet ist, wenn die Lichtstrahlen in einem großen Winkel (zur Normalen) verlaufen. Letztendlich ist der Algorithmus also recht einfach: Je größer der Winkel, desto weniger Licht empfängt das Panel. Der einfachste Weg, die Beleuchtung zu berechnen, besteht darin, das Skalarprodukt zwischen dem Vektor von der Lichtquelle und dem Normalenvektor zu berechnen.

Ok, alles ist sehr cool, aber wie bekommt man normale Vektoren in einem 2D-Spiel? Hier gibt es tatsächlich keine dreidimensionalen Objekte ... Hier können uns jedoch zusätzliche Texturen (die ganz normalen Karten) helfen, in denen die notwendigen Informationen aufgezeichnet werden. Ich habe im obigen Video zwei solcher Karten für zwei Häuser erstellt und sie zur Berechnung der Beleuchtung verwendet. Hier ein Beispiel:

Bild

Am Anfang sehen Sie ein normales Haus-Sprite ohne Schattierung. Im zweiten Teil des Bildes befindet sich die normale Karte, die den normalen Vektor in der Farbe der Textur codiert. Der Vektor hat (x, y, z) -Koordinaten und das Texturpixel hat r-, g- und b-Komponenten. Daher ist es wirklich einfach, die Normalen zu codieren: Nehmen Sie die nach Süden ausgerichtete Fassade des Hauses. Seine Normalität ist ein Vektor mit Koordinaten [x: 0, y: 0,5, z: 0].(In guter Weise sollte die Normale gleich (0, 1, 0) sein, aber da wir den Vektor von -1 bis +1 bestimmen und im Bereich von 0 bis 1 codieren müssen, hat der Autor anscheinend beschlossen, nicht zu dämpfen und sofort zu berücksichtigen Normalen von -0,5 bis +0,5 (ca. Übersetzung)

RGB-Werte können nicht negativ sein, daher verschieben wir alle Werte um 0,5: [x: 0,5, y: 1, z: 0,5]. Nun, RGB wird normalerweise auch in einer Zahl von 0 bis 255 dargestellt, also multiplizieren wir mit 255 und erhalten [x: 128, y: 255, z: 128], oder mit anderen Worten, der Vektor „Süden“ ist dieses Licht grün auf der normalen Karte.

Jetzt, wo wir die Normalen haben, können wir die Grafikkarte ihre Magie entfalten lassen.
Ich benutze ImpactJS , es hat eine gute Kompatibilität mit WebGL2D .(Es ist kostenpflichtig, ich empfehle pixi.js oder eine andere Grafikbibliothek mit einem Webgl-Renderer. Wenn Sie mehr Analoga kennen, schreiben Sie einen Kommentar! Ca. Transl.) Mit WebGL2D können wir ganz einfach einen Pixel-Shader für die Beleuchtung hinzufügen:

#ifdef GL_ES
  precision highp float;
#endif
varying vec2 vTextureCoord;
uniform sampler2D uSampler;
uniform vec3 lightDirection;
uniform vec4 lightColor;
void main(void) {
  // Берем вектор нормали из текстуры
  vec4 rawNormal = texture2D(uSampler, vTextureCoord);
  // Если альфа-канал равен нулю, то ничего не делаем: 
  if(rawNormal.a == 0.0) {
    gl_FragColor = vec4(0, 0, 0, 0);
  } else {
    // Транслируем из RGB в вектор, а именно из 0..1 в -0.5..+0.5
    rawNormal -= 0.5;
    // Вычисляем уровень освещенности
    float lightWeight = 
      dot(normalize(rawNormal.xyz), normalize(lightDirection));
    lightWeight = max(lightWeight, 0.0);
    // И записываем в пиксель
    gl_FragColor = lightColor * lightWeight;
  }
}


Ein paar Anmerkungen: Wir erhalten eine pixelweise Beleuchtung, die sich geringfügig von der Scheitelpunktbeleuchtung unterscheidet (normal in 3D). Es gibt keine besondere Wahl, da die Eckpunkte in 2d bedeutungslos sind (es gibt nur 4 davon, um eine Ebene auf der Bühne anzuzeigen). Tatsächlich ist dies jedoch kein Problem. Die pixelweise Beleuchtung ist viel genauer. Es sollte auch beachtet werden, dass der Shader nur Beleuchtung ohne das Haupt-Sprite rendert. Ich muss zugeben, ich betrüge ein bisschen, weil ich mein Sprite nicht anzünde , sondern schattiere und in lightColor eine dunkelgraue Farbe übertrage. Die tatsächliche Beleuchtung der Pixel, nämlich die Erhöhung der Helligkeit, sieht schlechter aus, die Pixel scheinen "abgenutzt" zu sein. Es gibt Lösungen für dieses Problem, aber im Moment ist es nicht prinzipiell.

Bild

Teil zwei: Schatten zeichnen.


Das Werfen von Schatten in 3D ist ein gut untersuchtes Problem mit bekannten Lösungen wie Raytracing oder Shadow Mapping . Ich fand es jedoch schwierig, eine akzeptable vorgefertigte Lösung für 2d zu finden. Ich musste es selbst tun. Ich denke, es hat sich als gut herausgestellt, obwohl es auch einige Nachteile hat.

Kurz gesagt, wir werden eine Linie von einem Pixel auf der Bühne zur Sonne ziehen und prüfen, ob es ein Hindernis gibt. Wenn es - dann ist das Pixel im Schatten, wenn nicht - in der Sonne ist, also im Prinzip nichts Kompliziertes.

Der Shader akzeptiert xyAngle und zAngle , die dafür verantwortlich sind, wo sich die Sonne befindet. Da es sehr weit entfernt ist, sind die Lichtstrahlen parallel, und dementsprechend sind diese beiden Winkel für alle Pixel gleich. Auch der Shader akzeptierteine Karte der Höhen der Welt. Es wird die Höhe aller Objekte, Gebäude, Bäume usw. angezeigt. Wenn das Pixel zum Gebäude gehört, beträgt der Pixelwert ungefähr 10 und bedeutet, dass die Höhe des Gebäudes an diesem Punkt 10 Pixel beträgt.

Der Shader startet also in dem Pixel, das beleuchtet werden muss, und bewegt sich mithilfe des xyAngle- Vektors in kleinen Schritten in Richtung Sonne. Auf jedem von ihnen werden wir prüfen, ob sich etwas in dem gegebenen Pixel der Höhenkarte befindet.
Bild
Sobald wir ein Hindernis finden, bestimmen wir seine Höhe und wie hoch es an einem bestimmten Punkt sein sollte, um die Sonne zu blockieren (mit zAngle ).
Bild
Wenn der Wert in der Höhenkarte größer ist, dann alles, ein Pixel im Schatten. Wenn nicht, werden wir weiter suchen. Aber früher oder später werden wir uns ergeben und verkünden, dass das Pixel von der Sonne beleuchtet wird. Im Beispiel habe ich 100 Schritte fest codiert, bisher funktioniert es perfekt.

Hier ist der Shader-Code in vereinfachter / Pseudo-Form:

void main(void) {
  float alpha = 0.0;
  if(isInShadow()) {
    alpha = 0.5;
  }
  gl_FragColor = vec4(0, 0, 0, alpha);
}
bool isInShadow() {
  float height = getHeight(currentPixel);
  float distance = 0;
  for(int i = 0; i < 100; ++i) {
    distance += moveALittle();
    vec2 otherPixel = getPixelAt(distance);
    float otherHeight = getHeight(otherPixel);
    if(otherHeight > height) {
      float traceHeight = getTraceHeightAt(distance);
      if(traceHeight <= otherHeight) {
        return true;
      }
    }
  }
  return false;
}


Und hier ist der ganze Code:

#ifdef GL_ES
  precision highp float;
#endif
vec2 extrude(vec2 other, float angle, float length) {
  float x = length * cos(angle);
  float y = length * sin(angle);
  return vec2(other.x + x, other.y + y);
}
float getHeightAt(vec2 texCoord, float xyAngle, float distance,
    sampler2D heightMap) {
  vec2 newTexCoord = extrude(texCoord, xyAngle, distance);
  return texture2D(heightMap, newTexCoord).r;
}
float getTraceHeight(float height, float zAngle, float distance) {
  return distance * tan(zAngle) + height;
}
bool isInShadow(float xyAngle, float zAngle, sampler2D heightMap,
    vec2 texCoord, float step) {
  float distance;
  float height;
  float otherHeight;
  float traceHeight;
  height = texture2D(heightMap, texCoord).r;
  for(int i = 0; i < 100; ++i) {
    distance = step * float(i);
    otherHeight = getHeightAt(texCoord, xyAngle, distance, heightMap);
    if(otherHeight > height) {
      traceHeight = getTraceHeight(height, zAngle, distance);
      if(traceHeight <= otherHeight) {
        return true;
      }
    }
  }
  return false;
}
varying vec2 vTextureCoord;
uniform sampler2D uHeightMap;
uniform float uXYAngle;
uniform float uZAngle;
uniform int uMaxShadowSteps;
uniform float uTexStep;
void main(void) {
  float alpha = 0.0;
  if(isInShadow(uXYAngle, uZAngle, uHeightMap, uMaxShadowSteps,
     vTextureCoord, uTexStep)) {
    alpha = 0.5;
  }
  gl_FragColor = vec4(0, 0, 0, alpha);
}


UTexStep speichert die Schrittlänge zum Überprüfen von Pixeln. Normalerweise reicht 1 / heightMap.width oder 1 / heightMap.height aus, da in OpenGL-Texturkoordinaten Werte von 0 bis 1 vorliegen, sodass 1 / Auflösung nur die Größe eines Pixels ergibt.

Fazit


In Wahrheit gibt es ein paar kleine Details, die ich im obigen Code weggelassen habe, aber die Grundidee sollte klar sein. (Zum Beispiel ist mir gerade die Idee gekommen, dass die Höhenkarte! = Normale Karte. Anmerkung übersetzt.) Diese Methode hat einen großen Nachteil, da jedes Pixel in der Szene nur eine Höhe haben kann. So treten beispielsweise bei Bäumen Schwierigkeiten auf. Der Motor kann den Schatten von ihnen nicht korrekt in Form eines dünnen Rumpfes und einer üppigen Krone anzeigen - es gibt entweder dicke zylindrische Schatten oder dünne Stöcke von den Stämmen, da der Hohlraum zwischen den Blättern und dem Boden nicht in der Höhenkarte aufgezeichnet ist.

Bild

Jetzt auch beliebt: