Unsere Christmas Experience Ho-Ho Hunt begann eigentlich als etwas völlig anderes: eine persönliche Fitness-App für die 24 Tage vor Weihnachten. Die Idee: Zuerst nur kleine Schneeflächen schaufeln, später immer größere. Nettes Konzept – aber für One-Off-Events und B2B-Kampagnen nicht wirklich ideal.
Also haben wir umgeschwenkt. Die Bewegung haben wir beibehalten und alles in eine kompakte Hinterhof-Szene verlegt, in der ein einziges, klar abgrenzbares Spielfeld viel besser für kurze Sessions funktioniert – ein perfektes, kompaktes VR-Schneeschaufel-Duell für Teams, Events und Brand-Aktivierungen.
Ho-Ho Hunt in Kürze
In Ho-Ho Hunt schlüpfst du in die Stiefel des Weihnachtsmanns an einem Abend, an dem alles schiefgeht: Er stürzt auf einem Dach in einer ruhigen Nachbarschaft ab, verliert die Kontrolle über den Schlitten und sieht zu, wie Geschenke im tiefen Schnee eines kleinen Hinterhofs verschwinden.
Zum Glück liegt eine Schaufel in Reichweite, und du hast nur eine Mission: den Hinterhof freiräumen und so schnell wie möglich jedes Geschenk wieder ausbuddeln.
Jeder Schwung fräst sichtbar Schnee weg und legt weitere Geschenke frei; das Tempo zieht an, du kämpfst gegen die Uhr und die Bestenliste, während Kolleg:innen oder Besucher:innen dich anfeuern. Einfach zu verstehen, überraschend intensiv und definitiv ein Herzfrequenz-Booster.
Hinter den Kulissen:
einen Schneeschaufel-Mechanismus bauen
Das Demo-Projekt für diesen Artikel entstand in Unity 6 (genauer: 6000.0.62f1). Uns sind keine größeren Shader-Graph-Änderungen bekannt, die das Ergebnis beeinflussen würden, ältere oder neuere Versionen sollten daher genauso funktionieren.
Das Schneefeld-Mesh
Um ein „schaufelbares“ Schneefeld zu erstellen, müssen wir zwischen Komplexität, Performance und Gameplay balancieren. Wir wollen keine Iglus oder Schneemänner bauen, sondern freilegen, was unter dem Schnee liegt. Deshalb verwenden wir ein granular unterteiltes Plane-Mesh mit Vertex-Displacement über eine Heightmap, gerendert von einem dedizierten Schneeshader. Immer wenn die Heightmap durch einen Schaufelhieb verändert wird, rufen wir einen Compute Shader auf, der diese Heightmap entsprechend anpasst. So können wir nicht nur Schnee entfernen, sondern ihn auch woanders ablagern oder Fußspuren und andere Dellen erzeugen.
Für unsere Experience nutzen wir Unitys Mesh-Generierung zur Laufzeit, um flexibler mit Größe und Granularität zu experimentieren. Zum Nachbauen kannst du Blender verwenden: Plane hinzufügen, in den Edit-Modus wechseln, rechtsklicken und „Subdivide“ wählen. Unten links die gewünschte Anzahl an Subdivisions pro Kante eintragen. In unserem Beispiel verwenden wir 63, also entsteht ein Plane mit 64x64 kleinen Flächen.

Exportiere das Mesh anschließend als FBX (File -> Export -> FBX), beschränke den Export auf das ausgewählte Mesh (Kamera und Licht brauchen wir nicht) und aktiviere „Apply Transform“, um den Scale zu normalisieren. Unity kann zwar .blend-Dateien direkt importieren, aber via FBX ist es wegen der unterschiedlichen Koordinatensysteme oft leichter.
Einfache Schneefarbe
Wir haben Shader Graph genutzt, um einen Shader für den Schnee zu bauen. Rechtsklick im Projekt, Create -> Shader Graph -> URP -> Lit Shader Graph. Das erzeugt einen leeren Shader mit Eingängen für Vertex- und Fragment-Stage.

Bevor wir Höhenänderungen unterstützen, kümmern wir uns um die Basisfarbe. Eine frische Schneedecke ist meist weiß, aber mit subtilen Unregelmäßigkeiten. Wir fügen zwei Parameter hinzu: eine Color BaseColor (Standard weiß), eine Texture2D NoiseTex für das Rauschen sowie ein Float NoiseScale (Standard 1). Dann nehmen wir die Weltposition eines Fragments, skalieren x und z entsprechend der Noise Scale, sampeln unsere Noise-Textur, remappen den Wert und modifizieren die Grundfarbe leicht. Für das Remapping hat sich 0.8 bis 1.1 bewährt. Das Ergebnis geht in die Base Color der Fragment-Stage.

Jetzt speichern wir den Shader, klicken im Projekt mit der rechten Maustaste darauf und wählen Create -> Material. Das erzeugt automatisch ein Material mit diesem Shader. Zieh das Material auf das Schneemesh in der Szene.

Noch haben wir keine Noise-Textur gesetzt, also ergibt das Sampling immer 0 und das Remapping führt zu konstantem (0.8, 0.8, 0.8) gefärbtem Schnee.
Du kannst einen Online-Generator für kachelbare Perlin-Noise-Texturen verwenden, das Ergebnis ins Projekt legen und dem Material-Parameter NoiseTex zuweisen. Das führt zu folgendem Resultat:

(Der Unterschied ist je nach Kompression oder Monitor schwer zu sehen.)
Heightmap für Vertex-Displacement
Jetzt kümmern wir uns um das Vertex-Displacement. Um die Höhenwerte einzuspeisen, fügen wir dem Shader einen weiteren Texture2D-Parameter HeightTex hinzu. Ein zusätzlicher Skalierungsparameter wäre denkbar, würde aber später nur verwirren – wir gehen davon aus, dass der Wert aus der Heightmap direkt die sichtbare Höhendifferenz ist.
Um den Höhenwert anzuwenden, sampeln wir die Heightmap und addieren den Wert zur y-Position des Vertex, bevor wir das Ergebnis an den Vertex-Position-Input weitergeben.

Die finale Heightmap haben wir noch nicht – wir werden sie als Render Texture anlegen und zur Laufzeit aktualisieren. Zum Testen können wir vorübergehend unser Perlin Noise einspeisen. Das Lighting ist noch nicht schön, weil die Normalen fehlen, aber mit Wireframe sieht man, dass es funktioniert.

Dellen zur Laufzeit erzeugen
Wir erstellen eine Render Texture, um die Heightmap zur Laufzeit zu speichern und anzupassen. Im Projekt: Create -> Rendering -> Render Texture, nenne sie Heightmap und aktiviere „Random Write“. Zieh diese Render Texture anschließend in den HeightTex-Parameter des Schneematerials.
Zum Modifizieren erzeugen wir einen Compute Shader mit einem Kernel, der die Anpassung durchführt. Also Create -> Shader -> Compute Shader, nenne ihn ShovelBrush und implementiere folgendes:
#pragma kernel Shovel
// Die Heightmap, die verändert wird
RWTexture2D<float> Heightmap;
// Schaufel-Parameter, vor dem Kernel-Aufruf gesetzt
uint Resolution;
float2 CenterUV;
float Radius;
float Strength;
[numthreads(8, 8, 1)]
void Shovel(uint3 id : SV_DispatchThreadID) {
// Früh raus, wenn der Aufruf außerhalb der Auflösung liegt
if (id.x >= Resolution || id.y >= Resolution) {
return;
}
// Texel außerhalb des Schaufelradius ignorieren
float2 uv = (float2(id.xy) + 0.5) / Resolution;
float2 d = uv - CenterUV;
float dist = length(d);
if (dist > Radius) {
return;
}
// Falloff innerhalb des Radius berechnen
float t = saturate(dist / Radius);
float falloff = smoothstep(1.0, 0.0, t);
// Heightmap aktualisieren
int2 coord = int2(id.xy);
float h = Heightmap[coord];
h += Strength * falloff;
Heightmap[coord] = h;
}
Im Gegensatz zum grafischen Shader oben ist ein Compute Shader ein GPU-Programm, das bei Bedarf allgemeine Aufgaben erledigt (Physik, Bildverarbeitung, Texturen bemalen usw.). Er zeichnet nichts selbst. Die Grafik-Shader schreiben nicht in Texturen, daher übernimmt das der Compute Shader.
Wir starten 8x8 = 64 Threads parallel auf einer Texture, und jeder Aufruf prüft zuerst, ob er überhaupt etwas zu tun hat. Diese Größe passt gut zu 2D-Texturen, läuft solide auf mobilen GPUs und hält den Registerdruck gering – perfekt für unsere Zielplattform Meta Quest.
Was jetzt fehlt, ist das Bindeglied. Wir schreiben ein kleines Script, das zufällig Dellen oder Hügel erzeugt, nur um den Effekt zu zeigen. Erstelle also ein neues Script (Create -> MonoBehaviour Script), nenne es RandomShovel und implementiere Folgendes:
using System.Collections;
using UnityEngine;
public class RandomShovel : MonoBehaviour {
[SerializeField] private ComputeShader shovelShader;
[SerializeField] private RenderTexture heightmap;
[SerializeField] private Material showMaterial;
private int _shovelKernel;
private Coroutine _shovelCoroutine;
void Start() {
_shovelKernel = shovelShader.FindKernel("Shovel");
shovelShader.SetTexture(_shovelKernel, "Heightmap", heightmap);
shovelShader.SetInt("Resolution", heightmap.width);
// Heightmap mit „mittlerer Höhe“ leeren
var prev = RenderTexture.active;
RenderTexture.active = heightmap;
GL.Clear(false, true, new Color(0.5f, 0.5f, 0.5f, 1f));
RenderTexture.active = prev;
}
void OnEnable() {
_shovelCoroutine = StartCoroutine(RandomShovelCoroutine());
}
void OnDisable() {
StopCoroutine(_shovelCoroutine);
}
private IEnumerator RandomShovelCoroutine() {
while (true) {
yield return new WaitForSeconds(0.2f);
// Parameter für diesen Durchlauf vorbereiten
Vector4 center = new Vector4(Random.Range(0f, 1f), Random.Range(0f, 1f), 0, 0);
float radius = Random.Range(0.05f, 0.3f);
float strength = Random.Range(-0.5f, 0.5f);
shovelShader.SetVector("CenterUV", center);
shovelShader.SetFloat("Radius", radius);
shovelShader.SetFloat("Strength", strength);
// Kernel ausführen
shovelShader.Dispatch(_shovelKernel, heightmap.width / 8, heightmap.height / 8, 1);
}
}
}
Danach legst du in der Szene ein Empty mit demselben Namen an, hängst das Script daran und ziehst Compute Shader, Heightmap-Render-Texture und Schneematerial aus dem Project View in die jeweiligen Felder.
Das Script leert die Heightmap mit einem mittleren Farbwert, damit Dellen und Hügel sofort sichtbar sind. Es übergibt die Parameter an den Compute Shader, triggert den Kernel alle 200 ms und erstellt zufällig Dellen oder Aufschüttungen. Startest du nun das Projekt, siehst du auch die Render Texture ständig arbeiten.

Wenn du auf den Play-Button klickst, wird der YouTube-Player eingebettet und Cookies von Drittanbietern gesetzt und Daten von YouTube gesammelt.
Sauberes Lighting
Dir wird auffallen, dass der Schnee trotz Höhenänderungen noch flach wirkt. Das liegt daran, dass wir noch keine Normalinformationen in den Shader geben.
Unser erster Versuch war, die Node „Normal from Height“ zu verwenden und die Heightmap einzuspeisen:

Das Ergebnis war enttäuschend! Selbst mit einem zusätzlichen „Normal Strength“-Node (z.B. Stärke 2) blieb der Schnee flach.

Also berechnen wir die Normalen manuell, indem wir benachbarte Texel auf der Heightmap einbeziehen. Vorab zum Vergleich:

Damit das funktioniert, muss der Shader wissen, wie „groß“ ein Texel in Weltkoordinaten und als UV ist, also Meshgröße / Meshauflösung bzw. 1 / Meshauflösung.
Wir fügen zwei Shader-Parameter hinzu: HeightTexelUV und MetersPerTexel. Nutzt du die gleichen Blender-Werte wie oben, wären das HeightTexelUV = 1 / 64 = 0.015625 und MetersPerTexel = 10 / 64 = 0.15625. Wir addieren HeightTexelUV zur aktuellen u- oder v-Koordinate, sampeln das Nachbartixel, schauen, wie sich die Höhe nach rechts und vorne ändert, teilen durch Meters-per-Texel und erhalten reale Steigungen. Aus diesen beiden Steigungen bauen wir einen Normalvektor (−slopeX, 1, −slopeY) und normalisieren ihn – klassische Tangentialflächen-Mathematik. Die Minuszeichen stammen von der Kreuzprodukt-Richtung, damit die Normale entgegen der steigenden Fläche zeigt.

Wir könnten das Ergebnis weiter verbessern, indem wir auch in die Gegenrichtung sampeln, aber das hier liefert bereits sehr solide Resultate.
Extra Noise
Da wir in VR arbeiten, sieht die Spieler:in den Schnee aus nächster Nähe. Eine zusätzliche Rauschschicht hilft dabei. Sie wird aber nur in Kameranähe eingeblendet und in der Ferne ausgeblendet, damit es nicht zu unruhig wirkt. Hier ein Close-up aus einem Ho-Ho-Hunt-Build:

Dieses Extra-Rauschen ist letztlich eine weitere Noise-Textur, diesmal jedoch als Normal Map. Der gesampelte Wert fließt in die gerade gebaute Normalberechnung ein, aber mit wesentlich höherer Auflösung als das großflächige Rauschen vom Anfang. Falls du keine passende Normal-Noise hast, dupliziere die existierende Noise-Textur, stelle sie auf Typ „Normal“ um und aktiviere „Create from grayscale“, um den Effekt zu testen.
Also fügen wir eine Texture2D DetailNormalTex und einen Skalierungsparameter DetailScale hinzu, sampeln den Wert (unbedingt den Typ „Normal“ setzen!) und steuern die Intensität abhängig von der Kameradistanz, damit die Wirkung sauber ausfadet:

Der Output dieses Detailrauschens und das Ergebnis der Heightmap-Normalberechnung werden mit dem Normal-Blend-Node kombiniert und in den Fragment-Normal-Input gespeist.
Ich habe DetailScale auf 200 gesetzt und in der Normalmap-Konfiguration Bumpiness 0.2 mit Smooth Filtering gewählt. Das Ergebnis:

Wie du siehst, weicht das Resultat dieses Beispielprojekts – das speziell für diesen Beitrag gebaut wurde – leicht vom echten Spiel ab. Das zeigt, wie stark Noise-Texturen oder Skalierungen das Ergebnis beeinflussen. Experimentieren lohnt sich!
Glitzer
Wir haben versucht, eine extra Glitzer-Schicht einzubauen, um die funkelnden Schneekristalle einzufangen. Dafür wären Emission plus Baked Lighting oder Bloom-Postprocessing nötig gewesen. Ersteres passt nicht zur dynamischen Schneefläche, Letzteres ist uns zu teuer, also haben wir auf Glitzer verzichtet.
Weitere Hinweise
Das Schöne an diesem Ansatz: Alles bleibt auf der GPU – Texturen, Shader, keine ständigen Hin- und her-Kopien. Selbst das Abfragen der Heightmap für das Collision Mesh läuft asynchron via AsyncGPUReadback.Request und blockiert nichts. Ideal für mobile Devices wie die Quest!
Natürlich ist das nur ein Ausgangspunkt für Schneemechaniken. Während der Arbeit haben wir viele weitere Dinge ausprobiert und das Schaufel- und Rendering-System weiter ausgebaut.
Was man sonst noch mit Compute Shadern anstellen kann
Unser vollständiger Compute Shader in Ho-Ho Hunt bringt ein paar Extras mit. Eine Methode bildet die Form der Schaufelspitze genauer ab, eine andere verwischt nach bestimmten Aktionen harte Kanten der erzeugten Meshes (Blur), um Schneeverwehungen zu simulieren. Außerdem queryen wir die Heightmap asynchron, um nur bei wirklichen Änderungen ein Collision Mesh zu aktualisieren.
Geschwindigkeitsbasierte Schneeablagerung
Wir haben zusätzlich Schneeablagerung und partikelbasiertes Schneespray abhängig von der Schaufelbewegung eingebaut.

Wenn du auf den Play-Button klickst, wird der YouTube-Player eingebettet und Cookies von Drittanbietern gesetzt und Daten von YouTube gesammelt.
Schneeball-Mechanik
Das war ein amüsanter Abstecher: Mit dem Compute-Shader-Brush haben wir den Pfad einer Kugel in den Schnee gezeichnet. Man könnte die Kugel einfach wachsen lassen, während sie rollt, bis sie Teil eines Schneemanns wird.

Wenn du auf den Play-Button klickst, wird der YouTube-Player eingebettet und Cookies von Drittanbietern gesetzt und Daten von YouTube gesammelt.
Optimierung
Wir haben das Render Target mit Standardparametern erstellt, obwohl wir nur einen Kanal benötigen – abgefragt wird nur R. Hier lässt sich Bandbreite sparen, oder man speichert zusätzliche Infos. In unserer Umsetzung existierte außerdem eine Compaction Map, um Schnee bei Kompression härter wirken zu lassen, die wir fürs Gameplay aber deaktiviert haben.
Bei Noise ist es verlockend, Function-Nodes wie Simple Noise im Shader Graph zu verwenden. Für Prototypen reicht das, aber meist sind sie teurer als Texture Lookups. Außerdem können Designer Texturen leichter tauschen als Graphen umzubauen.
Auch die Präzision spielt eine Rolle. Shader Graph startet standardmäßig mit „Single“ Precision. Wir sind auf „Half“ gewechselt und erzwingen „Single“ nur in einem spezifischen Subgraph, um Bandbreite zu sparen.
Fazit
Damit endet unser kleiner Blick hinter die Kulissen von Ho-Ho Hunt. Wenn du Ho-Ho Hunt oder eine unserer anderen Experiences lizenzieren möchtest – oder eigene Ideen besprechen willst –, melde dich gern!

