Ein weiterer Schatteneroberer in Phaser oder die Verwendung von Fahrrädern

Vor zwei Jahren habe ich bereits in Phaser 2D mit Schattensubstanzen experimentiert. Beim letzten Ludum Dare beschlossen wir plötzlich, einen Horror zu machen, und was für ein Horror ohne Schatten und Lichter! Ich habe meine Knöchel geknackt ...

... und keine verdammte Sache rechtzeitig für LD. Im Spiel gibt es natürlich ein bisschen Licht und Schatten, aber das ist ein miserabler Anschein dessen, was eigentlich sein sollte.

Nachdem ich nach dem Absenden des Spiels zum Wettbewerb nach Hause zurückgekehrt war, beschloss ich, „die Gestalt zu schließen“ und diese unglücklichen Schatten zu beenden. Was passiert ist - Sie können sich im Spiel fühlen , in der Demo spielen , sich das Bild ansehen und den Artikel lesen.

Wie immer in solchen Fällen macht es keinen Sinn, eine allgemeine Lösung zu schreiben. Sie müssen sich auf eine bestimmte Situation konzentrieren. Die Welt des Spiels kann in Form von Segmenten dargestellt werden - zumindest jene Einheiten, die Schatten werfen. Wände sind Rechtecke, Menschen sind Rechtecke, nur gedreht, der höllische Spoiler ist ein Kreis, aber im Cut-Off-Modell kann er auf eine Länge eines Durchmessers vereinfacht werden, der immer senkrecht zu einem Lichtstrahl steht.

Es gibt mehrere Lichtquellen (20-30), und alle sind kreisförmig (Scheinwerfer) und befinden sich bedingt niedriger als die beleuchteten Objekte (so dass die Schatten unendlich sein können).

Ich sah in meinem Kopf die folgenden Möglichkeiten, um das Problem zu lösen:

  1. Für jede Lichtquelle erstellen wir eine Textur in der Größe eines Bildschirms (gut oder 2-4 mal kleiner). Auf diese Textur zeichnen wir einfach das Trapez BCC'D ', wobei A die Lichtquelle ist, BC das Segment ist, B'C' die Projektion des Segments zum Rand der Textur ist. Danach werden diese Texturen an den Shader gesendet, wo sie zu einem einzigen Bild gemischt werden.

    Der Autor des Celeste-Plattformspielers hat so etwas getan, was in seinem Artikel auf Medium gut geschrieben ist: medium.com/@NoelFB/remaking-celestes-lighting-3478d6f10bf

    Probleme: 20-30 texturgroße Texturen, die fast in jedem Frame neu gezeichnet und in die GPU geladen werden müssen. Ich erinnere mich, dass dies ein sehr, sehr nicht schneller Prozess war.

  2. Die in einem Beitrag auf einem habr - habr.com/post/272233 beschriebene Methode. Für jede Lichtquelle erstellen wir eine "Tiefenkarte", d.h. eine solche Textur, wobei x = der Winkel des "Strahls" von der Lichtquelle, y = die Nummer der Lichtquelle und Farbe == Abstand von der Quelle zum nächsten Hindernis. Wenn wir einen Schritt von 0,7 Grad (360/512) und 32 Lichtquellen machen, erhalten wir eine 512x32-Textur, die so lange nicht aktualisiert wurde.
    (Beispiel Textur für einen Schritt von 45 Grad)
  3. Der geheime Weg, den ich ganz am Ende beschreiben werde

Am Ende habe ich mich für Methode 2 entschieden. Das im Artikel beschriebene hat mir jedoch nicht bis zum Ende gepasst. Dort wurde die Textur auch mithilfe eines Rakecasts im Shader erstellt - der Shader im Zyklus ging von der Lichtquelle in Richtung des Strahls und suchte nach einem Hindernis. In meinen früheren Experimenten habe ich auch Rakecast im Shader gemacht, und es war sehr teuer, wenn auch universell.

„Wir haben nur Segmente im Modell“, dachte ich, „und 10 bis 20 Segmente fallen in den Radius jeder Lichtquelle. Kann ich darauf nicht schnell eine Entfernungskarte berechnen? "

Also habe ich mich dazu entschlossen.

Zunächst habe ich einfach die Wände, die bedingte „Hauptfigur“ und die Lichtquellen auf dem Bildschirm angezeigt. Um die Lichtquellen herum schnitt in der Dunkelheit ein Kreis aus reinem, klarem Licht aus. Um dies zu bekommen:

( Demo )

Ich fing sofort an, mit dem Shader zu arbeiten, um mich nicht zu entspannen. Es war notwendig, für jede Lichtquelle ihre Koordinaten und ihren Wirkungsradius (über den das Licht nicht hinausreicht) einzugeben, dies geschieht einfach durch eine einheitliche Anordnung. Und dann blieb im Shader (der fragmentarisch ist und für jedes Pixel auf dem Bildschirm ausgeführt wird) zu verstehen, ob sich das aktuelle Pixel im beleuchteten Kreis befindet oder nicht.
class SimpleLightShader extends Phaser.Filter { constructor(game) { super(game); let lightsArray = new Array(MAX_LIGHTS*4); lightsArray.fill(0, 0, lightsArray.length); this.uniforms.lightsCount = {type: '1i', value: 0}; this.uniforms.lights = {type: '4fv', value: lightsArray}; this.fragmentSrc = ` precision highp float; uniform int lightsCount; uniform vec4 lights[${MAX_LIGHTS}]; void main() { float lightness = 0.; for (int i = 0; i < ${MAX_LIGHTS}; i++) { if (i >= lightsCount) break; vec4 light = lights[i]; lightness += step(length(light.xy - gl_FragCoord.xy), light.z); } lightness = clamp(0., 1., lightness); gl_FragColor = mix(vec4(0,0,0,0.5), vec4(0,0,0,0), lightness); } `; } updateLights(lightSources) { this.uniforms.lightsCount.value = lightSources.length; let i = 0; let array = this.uniforms.lights.value; for (let light of lightSources) { array[i++] = light.x; array[i++] = game.world.height - light.y; array[i++] = light.radius; i++; } } } 

Jetzt müssen wir für jede Lichtquelle verstehen, welche Segmente einen Schatten werfen. Vielmehr, welche Teile der Segmente - in der Abbildung unten interessieren uns die „roten“ Teile des Segments nicht, weil das Licht erreicht sie immer noch nicht.

Hinweis: Die Schnittpunktdefinition ist eine Art vorläufige Optimierung. Es wird benötigt, um die Zeit der weiteren Verarbeitung zu verkürzen und große Segmentstücke außerhalb des Radius der Lichtquelle zu eliminieren. Dies ist sinnvoll, wenn wir viele Segmente haben, deren Länge viel größer ist als der Radius des „Glühens“. Wenn dies nicht der Fall ist und wir viele kurze Segmente haben, kann es richtig sein, keine Zeit damit zu verschwenden, den Schnittpunkt zu bestimmen und die gesamten Segmente zu verarbeiten, weil Zeit sparen funktioniert immer noch nicht.

Dazu habe ich die bekannte Formel verwendet, um den Schnittpunkt einer geraden Linie und eines Kreises zu finden, an die sich jeder auswendig aus einem Schulkurs in Geometrie erinnert ... in der imaginären Welt eines Menschen. Ich konnte mich einfach nicht an sie erinnern, also musste ich es googeln .

Wir verschlüsseln, schauen, was passiert ist.
( Demo )
Es scheint die Norm zu sein. Jetzt wissen wir, welche Segmente einen Schatten werfen und Rakecast ausführen können.

Hier haben wir auch Optionen:

  1. Wir gehen einfach in einem Kreis in einem Kreis, werfen Strahlen und suchen nach Kreuzungen. Der Abstand zur nächsten Kreuzung ist der Wert, den wir benötigen
  2. Sie können nur zu den Ecken gehen, die in Segmente fallen. Schließlich kennen wir die Punkte bereits, es ist nicht schwierig, die Winkel zu berechnen.
  3. Wenn wir entlang eines Segments gehen, müssen wir keine Strahlen werfen und Schnittpunkte berechnen - wir können uns mit dem gewünschten Schritt entlang des Segments bewegen. So funktioniert es:


Hier AB- Segment (Wand), CIst das Zentrum der Lichtquelle, Cd- senkrecht zum Segment.

Lass x- der Winkel zur Normalen, für den Sie den Abstand von der Quelle zum Segment ermitteln müssen, X1- Zeigen Sie auf das Segment ABwo der Strahl fällt. Dreieck CDX1- rechteckig Cd- ein Bein, dessen Länge für dieses Segment bekannt und konstant ist, CX1- gewünschte Länge. CX1= fracCDcos(x). Wenn Sie den Schritt im Voraus kennen (und wir wissen es), können Sie die Tabelle der inversen Kosinusse vorberechnen und sehr schnell nach Entfernungen suchen.

Ich werde ein Beispiel für Code für eine solche Tabelle geben. Fast alle Arbeiten mit Ecken werden durch Arbeiten mit Indizes ersetzt, d. H. ganze Zahlen von 0 bis N, wobei N = die Anzahl der Schritte im Kreis ist (d. h. Schrittwinkel =  frac2 piN)

 class HypTable { constructor(steps = 512, stepAngle = 2*Math.PI/steps) { this.perAngleStep = [1]; for (let i = 1; i < steps/4; i++) { //   pi/2 let ang = i*stepAngle; this.perAngleStep[i] = 1/Math.cos(ang); } this.stepAngle = stepAngle; } /** * @param distancesMap -  ,    * @param angle1 -           * @param angle2 -           * @param normalFromLight - ,      */ fillDistancesForArc(distancesMap, angle1, angle2, normalFromLight) { const D = Math.hypot(normalFromLight.x, normalFromLight.y); const normalAngle = Phaser.Math.normalizeAngle(Math.atan2(normalFromLight.y, normalFromLight.x)); const normalAngleIndex = (normalAngle / this.stepAngle)|0; const index1 = (angle1 / this.stepAngle)|0; const index2 = (angle2 / this.stepAngle)|0; for (let angleIndex = index1; angleIndex <= index2; angleIndex++) { let distanceForAngle = D * this.perAngleStep[normalize(angleIndex - normalAngleIndex)]; distancesMap.set(angleIndex, distanceForAngle); } } } 

Natürlich führt diese Methode einen Fehler für Fälle ein, in denen der Anfangswinkel ACD kein Vielfaches eines Schritts ist. Aber für 512 Schritte sehe ich visuell keinen Unterschied.

Also, was wir bereits wissen, wie es geht:
  1. Suchen Sie Segmente im Bereich der Lichtquelle, die einen Schatten werfen können
  2. Erstellen Sie für Schritt t eine dist (Winkel) -Tabelle, die durch jedes Segment verläuft und die Abstände berechnet.


So sieht diese Tabelle aus, wenn Sie sie in Strahlen zeichnen.

( Demo )

Und so sieht es für 10 Lichtquellen aus, wenn sie in einer Textur geschrieben sind.

Hier entspricht jedes horizontale Pixel einem Winkel und die Farbe dem Abstand in Pixel.
Es wird in js wie diesem mit imageData geschrieben
  fillBitmap(data, index) { let total = index + this.steps*4; let d1, d2; let i = 0; //data[index] = Red //data[index+1] = Green //data[index+2] = Blue //data[index+3] = Alpha for (; index < total; index+=4, i++) { //  512,    R     2. d1 = (this.distances[i]/2)|0; data[index] = d1; d1 = this.distances[i] - d1*2; d2 = (d1*128)|0; //   G -     2. data[index+1] = d2; //  B  A  255,     . data[index+2] = 255; data[index+3] = 255; } } 


Jetzt übergeben wir die Textur an unseren Shader, der bereits die Koordinaten und Radien der Lichtquellen hat. Und verarbeite es so:

 //      uniform sampler2D iChannel0; #define STRENGTH 0.3 #define MAX_DARK 0.7 #define M_PI 3.141592653589793 #define M_PI2 6.283185307179586 //       float decodeDist(vec4 color) { return color.r*255.*2. + color.g*2.; } float getShadow(int i, float angle, float distance) { //   x   ==  float u = angle/M_PI2; //   y   ==     float v = float(i)/${MAX_LIGHTS}.; float shadowAfterDistance = decodeDist(texture2D(iChannel0, vec2(u, v))); //  1   ,  0  . return step(shadowAfterDistance, distance); } void main() { float lightness = 0.; for (int i = 0; i < ${MAX_LIGHTS}; i++) { if (i >= lightsCount) break; vec4 light = lights[i]; //       vec2 light2point = gl_FragCoord.xy - light.xy; float radius = light.z; float distance = length(light2point); float inLight = step(distance, radius); //      ,       //  . //      , //    ,          //           //     ,    if (inLight == 0.) continue; float angle = mod(-atan(light2point.y, light2point.x), M_PI2); // 1     0   float thisLightness = (1. - getShadow(i, angle, distance)); //,   “”  ,   ,  //    lightness += thisLightness*STRENGTH; } lightness = clamp(0., 1., lightness); gl_FragColor = mix(vec4(0,0,0,MAX_DARK), vec4(0,0,0,0), lightness); } 


Ergebnis:
( Demo )
Jetzt können Sie ein wenig Schönheit bringen. Lassen Sie das Licht mit der Entfernung verblassen, und die Schatten werden verschwommen.

Für Unschärfe schaue ich auf benachbarte Ecken, + - Schritt, wie folgt:

 thisLightness = (1. - getShadow(i, angle, distance)) * 0.4 + (1. - getShadow(i, angle-SMOOTH_STEP, distance)) * 0.2 + (1. - getShadow(i, angle+SMOOTH_STEP, distance)) * 0.2 + (1. - getShadow(i, angle-SMOOTH_STEP*2., distance)) * 0.1 + (1. - getShadow(i, angle+SMOOTH_STEP*2., distance)) * 0.1; 


Wenn Sie alles zusammenfügen und die FPS messen, stellt sich Folgendes heraus:

  • Bei eingebauten Grafikkarten ist alles schlecht (<30-40), auch für einfache Beispiele
  • Alles andere ist in Ordnung, solange die Lichtquellen nicht sehr stark sind. Das heißt, die Anzahl der Lichtquellen pro Pixel ist wichtig, nicht die Gesamtzahl.


Dieses Ergebnis hat mir sehr gut gefallen. Sie konnten immer noch mit der Farbe der Beleuchtung spielen, aber ich tat es nicht. Nachdem ich ein wenig gedreht und einige normale Karten hinzugefügt hatte, lud ich eine aktualisierte Version von NOPE hoch. Sie sah jetzt so aus:


Dann fing er an, einen Artikel vorzubereiten. Ich sah mir so ein GIF an und dachte nach.

„Es ist also fast ein Pseudo-3D-Look wie in Wolfenstein“, rief ich aus (ja, ich habe eine gute Vorstellungskraft). Und tatsächlich - wenn wir davon ausgehen, dass alle Wände gleich hoch sind, reichen die Entfernungskarten aus, um die Szene zu erstellen. Warum nicht versuchen?

Die Szene sollte ungefähr so ​​aussehen.


Also unsere Aufgabe:

  1. Ermitteln Sie an einem Punkt auf dem Bildschirm die Weltkoordinaten für den Fall, dass keine Wände vorhanden sind.

    Wir werden dies berücksichtigen:
    • Zuerst normalisieren wir die Koordinaten eines Punktes auf dem Bildschirm so, dass sich in der Mitte des Bildschirms und an den Ecken (-1, -1) bzw. (1,1) ein Punkt (0,0) befindet
    • Die x-Koordinate wird zum Winkel aus der Blickrichtung. Sie müssen sie nur mit A / 2 multiplizieren, wobei A der Betrachtungswinkel ist
    • Die y-Koordinate bestimmt den Abstand vom Beobachter zum Punkt, im allgemeinen Fall d ~ 1 / y. Für einen Punkt am unteren Rand des Bildschirms ist Abstand = 1, für einen Punkt in der Mitte des Bildschirms Abstand = unendlich.
    • Wenn Sie also die Wände nicht berücksichtigen, befinden sich für jeden sichtbaren Punkt auf der Welt zwei Punkte auf dem Bildschirm - einer über der Mitte (an der „Decke“) und der andere unter (auf dem „Boden“).
  2. Jetzt können wir uns die Tabelle der Entfernungen ansehen. Wenn es eine Wand gibt, die näher als unser Punkt ist, müssen Sie eine Wand zeichnen. Wenn nicht, bedeutet dies Boden oder Decke

Wir bekommen wie bestellt:
( Demo )
Fügen Sie Beleuchtung hinzu - durchlaufen Sie auf die gleiche Weise die Lichtquellen und überprüfen Sie die Weltkoordinaten. Und - der letzte Schliff - fügen Sie Texturen hinzu. Dazu müssen Sie in einer Textur mit Abständen an dieser Stelle auch den Versatz u für die Wandtextur schreiben. Hier hat sich Kanal b als nützlich erwiesen.
( Demo )
Perfekt.

Nur ein Scherz.

Natürlich unvollkommen. Aber zum Teufel, ich habe immer noch gelesen, wie ich meinen Wolfenstein vor ungefähr 15 Jahren durch Rakecast machen kann, und ich wollte alles tun, und hier ist eine solche Gelegenheit!

Anstelle einer Schlussfolgerung


Am Anfang des Artikels erwähnte ich eine andere geheime Methode. Da ist er:

Nehmen Sie einfach den Motor, der bereits weiß wie.

In der Tat, wenn Sie ein Spiel machen müssen, dann ist dies der korrekteste und schnellste Weg. Warum müssen Sie Ihre Fahrräder umzäunen und langjährige Probleme lösen?

Aber warum.

In der 10. Klasse zog ich in eine andere Schule und stieß auf Probleme in Mathe. Ich erinnere mich nicht an das genaue Beispiel, aber es war eine Gleichung mit Graden, die in jeder Hinsicht vereinfacht werden musste, aber es gelang einfach nicht. Verzweifelt beriet ich mich mit meiner Schwester und sie sagte: "Also füge x 2 auf beiden Seiten hinzu, und alles wird sich zersetzen." Und das war die Lösung: Fügen Sie hinzu, was nicht da war.

Als ich viel später meinem Freund beim Bau meines Hauses half, musste ich einen Block auf die Schwelle setzen - um eine Nische zu füllen. Und hier stehe ich und sortiere die Verkleidung der Stangen. Man scheint zu passen, aber nicht ganz. Andere sind viel kleiner. Ich denke darüber nach, wie ich das Wort Glück hier sammeln kann, und ein Freund sagt: "Also haben sie die Rillen an einer kreisförmigen Stelle getrunken, an der es stört." Und jetzt steht die große Bar schon still.

Diese Geschichten sind durch einen solchen Effekt verbunden, den ich als „Inventareffekt“ bezeichnen werde. Wenn Sie versuchen, eine Entscheidung aus vorhandenen Teilen zu treffen, ohne Material zu sehen, das in diesen Teilen verarbeitet und verfeinert werden kann. Zahlen sind Holz, Geld oder Code.

Oft habe ich den gleichen Effekt bei Kollegen in der Programmierung beobachtet. Sie fühlen sich dem Material nicht sicher und geben manchmal nach, wenn es beispielsweise erforderlich ist, nicht standardmäßige Kontrollen durchzuführen. Oder fügen Sie Unit-Tests hinzu, wo sie nicht waren. Oder sie versuchen, beim Entwerfen einer Klasse für alles zu sorgen, und dann erhalten wir einen Dialog wie:
- Das ist jetzt nicht nötig
- Was ist, wenn es notwendig wird?
- Dann werden wir hinzufügen. Lassen Sie die Erweiterungspunkte, das ist alles. Code ist kein Granit, sondern Plastilin.

Und um zu lernen, das Material, mit dem wir arbeiten, zu sehen und zu fühlen, brauchen wir auch Fahrräder.

Dies ist nicht nur ein Training für den Geist oder das Training. Dies ist ein Weg, um ein qualitativ anderes Arbeitsniveau mit Code zu erreichen.

Vielen Dank für das Lesen.

Links, falls Sie vergessen haben, irgendwo zu klicken:

Source: https://habr.com/ru/post/de434370/


All Articles