(placeholder)

Wie Murgänge gemacht werden


The avatar of Eleanore Young
Eleanore Young

In unserem Projekt MurGame standen wir vor einigen technischen Herausforderung, deren Lösungen wir in diesem Artikel hervorheben möchten, um anderen Entwickler:innen bei ähnlichen Problemen Hilfe zu stellen sowie einen Einblick in unsere Arbweitsweise zu gewähren.

Das MurGame ist eine spielerische Murgang-Simulation, bei der die Spieler:innen ein malerisches Alpendorf vor riesigen Schlammlawinen schützen müssen. Voraussetzung für das Spiel ist unter anderem, dass die Preise der Schutzmassnahmen sowie der Schaden der Murgänge realistischen Werten entsprechen sollten, damit das Spiel als wahrheitsgetreue Simulation genutzt werden kann.

Als Ausgangslage erhielten wir Simulationsdaten aus der Software RAMMS, welche im Spiel visualisiert und gleichzeitig auch zur Berechnung von Schadenswerten verwendet werden. Da das Spiel im Web verfügbar sein soll, müssen diese grossen Datenmengen so heruntergebrochen werden, dass sie in eine Webseite passen.

Das Spiel wird in Zusammenarbeit mit Geo7 und dem Schweizerischen Institut für Schnee- und Lawinenforschung SLF erstellt. Die Entwicklung wurde von der Präventionsstiftung der Kantonalen Gebäudeversicherungen, der Mobiliar und dem Bundesamt für Umwelt (BAFU)finanziert.

Rapid Mass Movement Simulation

Unsere Entwicklungsppartnerin Geo7 führte die eigentlichen Berechnungen der Murgänge unter verschiedenen Ausgangsbedingungen (sog. Szenarien) durch. Dazu wurde die Software RAMMS verwendet, die aufgrund eines gegebenen Terrainmodells und vordefinierten Hindernissen eine fluiddynamische Simulation eines Murgangs aufbaut. Die Simulationsdaten enthalten einen zeitlichen Verlauf der Flusshöhe des Murgangs, wie auch die maximale Flusshöhe, Geschwindigkeit und Kraft des Murgangs über die ganze Zeit erstreckt.

Das zugrundeligende Terrain und ein Momentanabbild eines Murgangs im Ursprungsformat
Das zugrundeligende Terrain und ein Momentanabbild eines Murgangs im Ursprungsformat

Für die Verwendung in unserer Spielengine Unity 3D wurden diese Daten als eine Serie von Graustufenbildern zur Verfügung gestellt. Wir entschieden uns hier für nur einen Farbkanal, da wir für das Spiel keine weiteren Informationen als die Flusshöhe des Murgangs in Abhängigkeit von Zeit benötigten. Die Graustufendaten zu Flusshöhe sind im GeoTIFF Format als Fliesskommazahl in Meter angegeben.

Für jedes Szenario entsteht so eine Bildsequenz von ungefähr 300 bis 900 Bildern, die den Ablauf eines Murgangs darstellen. Unter einem Szenario wird eine aufgeschlüsselte Sammlung aus Hindernissen wie Häuser, Dämmen und Brücken verstanden. Insgesamt wurden ca. 1000 verschiedene Szenarien mit gleichbleibendem Terrain vorgerechnet. So entsteht eine Gesamtrohdatenmenge von ca. 640 GB.

Bild mit drei Verschiedenen Dorfvarianten
Bild mit drei Verschiedenen Dorfvarianten

MurGame

Wie bereits erwähnt, hat das Spiel MurGame zum Ziel, die Gefahren und die damit assoziierten Kosten von Murgängen im Schweizer Alpenraum zu veranschaulichen. Dabei möchten wir die Simulationsdaten aus dem RAMMS System so wahrheitsgetreu wie möglich darstellen. Das bedeutet wiederum, dass wir im Wesentlichen folgende Probleme lösen müssen:

  • Ein statisches Terrain aus den Höheninformationen berechnen
  • Ein dynamischer Ablauf des Murgangs in vielen verschiedenen Szenarien abspielen
  • Eine Berechnung des Schadensmasses für die Schadenskosten und für das visuelle Zerstören von Gebäuden durchführen
  • Das Spiel als WebGL Build übers Internet zur Verfügung stellen

Statisches Terrain

Als Ausgangslage wählte unsere Entwicklungspartnerin Geo7 ein geographisch günstiges Gebiet in den Schweizer Alpen aus. Die topographische Karte dieses Gebiets wurde von uns dann in ein statisches 3D-Modell mit ca. 50x50 Vertices umgerechnet, da alle verwendeten Szenarien am selben Ort stattfinden. Das Modell ist darum statisch, weil es die Effizienz von gewissen Berechnungen zu Spiellaufzeit beschleunigt. Dieses Modell wurde im Entwicklungsprozess dann gemäss unseres Grafikkonzeptes abstrahiert und vereinfacht.

Frühform des aus Höhendaten generierten Terrainmeshes
Frühform des aus Höhendaten generierten Terrainmeshes

Murgang Datenformate

Damit die Höhendaten aus RAMMS in unserem Projekt verwendet werden können, müssen sie vom GeoTIFF Format in ein Format umgewandelt werden, welches direkt in eine GPU geladen werden kann und zugleich stark komprimiert ist.

In einem unserer frühen Versuche bauten wir auf dem neuen Texturformat Basis-Universal auf, weil es eine hohe Kompressionsrate und einen kurzen Ladevorgang in die GPU versprach. Allerdings stiessen wir bei der Konvertierung auf Qualitätsverluste, die besonders die feinen Seitenläufe eines Murgangs betrafen.

So entschieden wir uns, die GPU-kompatiblen Texturformate direkt von Unity handhaben zu lassen, während wir auf unserer Seite die Höhendaten im simpleren Zwischenformat PNG speicherten (siehe hierzu das Paket Heightmap-Importer).

Da das Bildformat PNG jedoch nur Fliesskommawerte zwischen 0 und 1 unterstützt, mussten die Ursprungsdaten normalisiert und für jedes Szenario das zugehörige Höhenintervall in Metern als Metadaten zu den Bildern gespeichert werden. Entsprechend muss bei der Darstellung dann die Höheninformation aus den Texturen wieder skaliert werden.

Um die Darstellung des Murgangs zu verbessern, wird aus den Höhendaten im Ursprungsformat jeweils ein Graustufenbild und ein Normalmap-Bild erstellt, wobei letzteres in einem späteren Vorgang die Richtung der Flächennormalen der einzelnen Mesh-Dreiecke bestimmt. Dadurch entsteht ein glaubhafteres 3D-Abbild des Murgangs. Auf Dateienebene ist das Normalmap-Bild eine einfache Kopie der Höhendaten in PNG-Form.

Momentanbbild eines Murgangs: PNG Version, Unity Import (unterscheidet sich visuell nicht vom PNG Bild, ausser dass die Auflösung auf Zweierpotenzen normalisiert ist) und Normalmap
Momentanbbild eines Murgangs: PNG Version, Unity Import (unterscheidet sich visuell nicht vom PNG Bild, ausser dass die Auflösung auf Zweierpotenzen normalisiert ist) und Normalmap

Seitens Unity wird der Import der PNG Bilddaten über einen AssetPostprocessor (spezifisch über AssetPostprocessor.OnPreprocessTexture) gesteuert, da die standardmässig definierten Einstellungen für den Texturimport aus PNG nicht unseren Bedürfnissen entsprechen. Hierzu werden die folgenden Einstellungen verwendet.

Importeinstellungen von Höhendaten

textureType: Default
textureShape: Texture2D
sRGBTexture: false
alphaSource: None
npotScale: ToNearest
isReadable: true
mipmapEnabled: true
mipmapFilter: BoxFilter
wrapMode: Clamp
filterMode: Bilinear
anisoLevel: 1
maxTextureSize: 256
resizeAlgorithm: Bilinear
compressionQuality: 50

Importeinstellungen der Normalmaps

textureType: NormalMap
textureShape: Texture2D
convertToNormalmap: true
npotScale: ToNearest
isReadable: true
mipmapEnabled: true
mipmapFilter: BoxFilter
wrapMode: Clamp
filterMode: Trilinear
anisoLevel: 1
overridden: true
maxTextureSize: 256
resizeAlgorithm: Bilinear
compressionQuality: 50

Archivierung der Szenarien

Aufgrund der grossen Datenmenge (640 GiB Ursprungsdaten, dann 11 GiB PNG Daten), können die Szenarien und deren Bildinformationen nicht direkt im Unity-Projekt gespeichert werden. Dazu werden die ca. 1000 Szenarien einzeln in AssetBundles verpackt (siehe wiederum das Paket Heightmap-Importer) und diese werden dann vom Spiel dynamisch heruntergeladen, sobald sie gebraucht werden. Der Speicherbedarf eines einzelnen AssetBundles ist ungefähr 12 MiB, was einer Gesamtdatenmenge von wiederum 11 GiB entspricht. Es sei angemerkt, dass der Zeitaufwand des Imports von PNG in AssetBundles (ohne Reduktion des Bildmaterials) ungefähr 150 Stunden beträgt.

Zur Erhöhung der Spielbarkeit wurden drei sog. Fallback-Szenarien (d.h. Ausweichslösungen) direkt ins Spiel integriert, damit der Murgang trotzdem abgespielt werden kann, auch wenn die AssetBundles aus irgend einem Grund nicht verfügbar sind.

Abspielen des Murgangs

Die auf der Spielkarte platzierten Gebäude und Schutzmassnahmen bestimmen das Szenario und damit die abzuspielende Bildsequenz. Dazu wird das zugehörige AssetBundle in das laufende Spiel geladen. Die Metadaten des Szenarios bestimmen, in welchem Zeitabstand die einzelnen Bilder der Sequenz verarbeitet werden. Dazu wird jedes vorhandene Bild als Keyframe einer Animation aufgefasst, wobei zwischen zwei aufeinanderfolgenden Bildern linear interpoliert wird.

Animation des Murgangs aus der momentanen Version
Animation des Murgangs aus der momentanen Version

Ein Shader (siehe dessen Quellcode) deformiert ein statisches Mesh entsprechend den in den Bildern enthaltenen Höheninformationen, wobei die Normalmap Bilder nun verwendet werden, um die Auslenkung der Flächennormalen der einzehlen Dreiecke des Meshes zu bestimmen.

Schadensberechnung

Die Berechnung des Schadensmasses läuft zweistufig ab. Erst wird aufgrund der Sequenz von Murgang-Keyframes untersucht, welche Gebäude und Schutzmassnahmen beschädigt werden und schlussendlich bestimmt die maximale Flussgeschwindigkeit des Murgangs im jeweiligen Szenario, wie hoch die Schadenskosten sein werden. Das nachfolgende Stück Code erläutert im Groben, wie die Berechnung funktioniert.

using UnityEngine;

class Buildable {
    public float MapPosition;
    public float MapWidth;
    public float MapHeight;
    public float MurgangLevel;
}

struct Texture {
    public Color32[] Data;
    public int Width;
    public int Height;
}

struct ImageBounds {
    public float Min;
    public float Max;
}

void AssignLevel(Buildable bld, Texture data, ImageBounds bounds)
{
    // For every buildable, I need to have a rectangular area that
    // indicates the occupied area on the map. To be able to
    // calculate this, each buildable needs to store both its
    // center and width and height in map coordinates.

    // Map space refers to the coordinate system on the game map,
    // projected to two dimensions. In unity coordinates, this uses the
    // x, and z coordinates of the local position of each buildable,
    // normalized by the size of the terrain.

    // Determine the position, and extents of the buildable in map
    // space. The extents form a rectangle projected on the map plane.
    Vector2 positionMapSpace = bld.MapPosition;
    Vector2 minMS = new Vector2(
        positionMapSpace.x - bld.MapWidth / 2.0f,
        positionMapSpace.y - bld.MapHeight / 2.0f
    );
    Vector2 maxMS = new Vector2(
        positionMapSpace.x + bld.MapWidth / 2.0f,
        positionMapSpace.y + bld.MapHeight / 2.0f
    );

    // Transform the previous coordinates to texture space (in pixels).
    Vector2Int pTS = new Vector2Int(
        Mathf.RoundToInt(positionMapSpace.x * texWidth),
        Mathf.RoundToInt(positionMapSpace.y * texHeight)
    );
    Vector2Int minTS = new Vector2Int(
        Mathf.RoundToInt(minMS.x * texWidth),
        Mathf.RoundToInt(minMS.y * texHeight)
    );
    Vector2Int maxTS = new Vector2Int(
        Mathf.RoundToInt(maxMS.x * texWidth),
        Mathf.RoundToInt(maxMS.y * texHeight)
    );

    // Determine the width and height of the texture sample selected by
    // the extents of the buildable in pixels. This indicates how many
    // pixels we have to read from the texture for the damage calculation.
    int sampleWidth = maxTS.x - minTS.x;
    int sampleHeight = maxTS.y - minTS.y;
    int sampleCount = sampleWidth * sampleHeight;
    Color32[] samples = new Color32[sampleCount];

    // If the buildable has no collider, we cannot determine its
    // extents. Therefore, we will just take the texture value at its
    // center. This is just to make it work. It will not result in an
    // accurate damage value.
    if (sampleCount == 0)
    {
        bld.MurgangLevel = ((float) texData[pTS.x + pTS.y * texWidth].r) / 255.0f;
        return;
    }

    // Read the selected texture samples.
    for (int i = 0, y = minTS.y; y < maxTS.y; y += 1)
    {
        for (int x = minTS.x; x < maxTS.x; x += 1)
        {
            int idx = x + y * texWidth;
            samples[i] = texData[idx];
            i += 1;
        }
    }

    // Aggregate all samples and find the highest red color value which in turn
    // determines the height of the murgang.
    float maxLevel = ((float) samples.Max(c => c.r)) / 255.0f;

    // Rescale the murgang level to the actual height in meters.
    bld.MurgangLevel = maxLevel * (bounds.Max - bounds.Min) + bounds.Min;
}

Schlusswort

Die grosse Datenmenge sowie die Transferrate übers Internet Und der verfügbare Arbeitsspeicher in gängigen Browsern stellen die grössten Einschränkungen für dieses Spiel dar. Dadurch erhöht sich die Ladezeit der einzelnen Szenarien stark, während das Entladen von ungebrauchten Szenarien gleichzeitig notwendig wird. Durch Verwendung von verlustbehafteten Kompressionstechniken, bei denen Bildinformationen verloren gehen, kann die Datenmenge reduziert werden, aber leider nur mit visuellen Einbussen. Obwohl in unserer Situation ausgeschlossen, wäre eine vollständig lokal installierte Spielversion deshalb die bessere Lösung.

Die von uns verwendete Technik könnte auf Basis von Daten aus RAMMS zudem für Lawinen und Steinrutsche verwendet werden. Auch Überschwemmungen könnten mit gleichen oder ähnlichen Techniken dargestellt werden.

Weiterführende Informationen