Outbounds Shader-Clip-Ansatz: Präzises Laub-Discarding für Echtzeitumgebungen

TONY FIAL AND MICHIEL PROCÉ / SQUARE GLADE GAMESGuest Blog
Dec 2, 2025|6:30 Min.
Key-Art für Outbound von Square Glade Games, erstellt mit Unity. Ein orangefarbener Wohnwagen mit Solarpanelen vor einem blauen Hintergrund. Rechts von dem Wohnwagen in der Ecke befindet sich ein kleiner Hund mit Taschen, die an seinem Körper befestigt sind.
Diese Website wurde aus praktischen Gründen für Sie maschinell übersetzt. Die Richtigkeit und Zuverlässigkeit des übersetzten Inhalts kann von uns nicht gewährleistet werden. Sollten Sie Zweifel an der Richtigkeit des übersetzten Inhalts haben, schauen Sie sich bitte die offizielle englische Version der Website an.

Wie verhindert man, dass Gras durch den Boden in deinem Open-World-Van-Life-Spiel schneidet? In diesem Gastbeitrag bieten die Programmierer von Square Glade Games, Tony Fial und Michiel Procé, einen detaillierten Einblick, wie sie dieses Problem in Outbound mit einer benutzerdefinierten Shader-Clipping-Lösung angegangen und letztendlich gelöst haben.

Wir sind Tony Fial und Michiel Procé, Teil des Square Glade Games-Teams, und wir arbeiten derzeit an dem neuesten Titel des Studios, Outbound, der ein Open-World-Erkundungsspiel in einer utopischen nahen Zukunft ist. Der Spieler beginnt mit einem leeren Wohnmobil und kann es in das mobile Zuhause seiner Träume verwandeln, indem er es genau so gestaltet, wie er es möchte.

Das Fahrzeug ist ein großer Mittelpunkt des Spiels, ebenso wie das Fahren durch die Natur. Die Welt in Outbound ist handgefertigt und umfasst viel Laub und Gras, das üppig, hoch und reichlich ist. Obwohl wir eine schöne Welt mit diesen Assets schaffen konnten, führte die Kombination mit einem Fahrzeug, das durch solche Umgebungen fährt, zu einigen visuellen Problemen.

Das Problem

Der Spieler kann sein Wohnmobil durch praktisch jeden offenen Bereich fahren. Büsche und Gras sind dafür keine Hindernisse. Da das Wohnmobil ziemlich nah am Boden ist, führte dies oft dazu, dass Gras vom Terrain durch die Unterseite oder die Seiten des Fahrzeugs schnitt.

Es gibt auch Stellen, an denen das Wohnmobil die höheren Pflanzen wie Blumen und Büsche erreichen kann. Um das vorliegende Problem zu zeigen, zeigt der Screenshot unten einen Fall, in dem sowohl Gras als auch Büsche stark im Fahrzeug schneiden. Das ist nicht nur visuell unattraktiv, sondern verursacht auch verschiedene Gameplay-Probleme, wie das visuelle Blockieren von Interaktionen oder wichtigen Informationen.

Wohnmobil mit geöffneter Seitentür, das Gras und Büsche zeigt, die durch die Karosserie des Fahrzeugs schneiden
Wohnmobil mit geöffneter Seitentür, das Gras und Büsche zeigt, die durch die Karosserie des Fahrzeugs schneiden

Um unser Kernproblem zusammenzufassen, gibt es verschiedene Arten von Laub und Gras, die durch das Wohnmobil schneiden, was aus visueller und Gameplay-Perspektive unerwünscht ist.

Nun, kommen wir zur Lösung, oder?

Brainstorming möglicher Lösungen

Bei Square Glade Games finden wir es persönlich hilfreich, eine Liste optimaler Anforderungen zu erstellen, bevor wir aktiv an einer Lösung arbeiten.

In diesem speziellen Fall mussten wir unsere Lösung so gestalten, dass sie:

• Se performant. Es gibt viel Gras in Outbound, daher könnte eine nicht optimierte Lösung in den Bereichen mit viel mehr Gras und Pflanzen sehr teuer sein.

• Behalte den ursprünglichen Stil bei. Derzeit befinden wir uns in einem Entwicklungszustand, in dem wir das Aussehen wichtiger Elemente in Outbound nicht ändern können, daher sollte die Lösung idealerweise so viel wie möglich von der ursprünglichen Vegetation nutzen.

• Sei plattformübergreifend kompatibel.Da der Titel für mehrere Plattformen geplant ist, muss die Lösung auf Windows, Nintendo Switch™, Xbox und PlayStation® funktionieren.

• Sei intuitiv zu bedienen.Die Lösung sollte idealerweise sowohl für die Designer als auch für die Programmierer im Team intuitiv sein.

• Sei auf mehrere Formen anwendbar. Idealerweise würden wir das Laub in einer genauen Form des Fahrzeugs zuschneiden, möglicherweise unter Verwendung mehrerer Formen.

Jetzt darüber nachdenken, welche Lösungen diese Liste von Anforderungen erfüllen könnten. Unsere ersten Gedanken gingen zu einem Element, das alle Grashalme gemeinsam haben... dem Shader.

Fast alle Pflanzen in Outbound sind mit den Terrain-Tools auf dem Unity-Terrain platziert. Ein erheblicher Teil davon ist das Gras, das den Standard-Grass-Shader verwendet. Dieser Shader nutzt die GPU, um die Grasflächen auf sehr performante Weise zu platzieren und zu beschriften. Andere Elemente, wie die größeren Büsche, die im obigen Screenshot gezeigt werden, sind als Detail-Meshes platziert, wobei ihr eigenes zugewiesenes Material und Shader verwendet werden.

Dies stellte ein weiteres wichtiges Detail dar, nämlich dass die vorgeschlagene Lösung in der Lage sein sollte, mit mehreren völlig unterschiedlichen Shadern auf die gleiche Weise gleichzeitig zu arbeiten.

Vorgeschlagene Lösungen

Alle unten vorgeschlagenen Lösungen teilen auch ein wichtiges 'Eingangselement': Die Position des Wohnmobils oder genauer gesagt, der Bereich, in dem das Laub entfernt werden soll.

Angesichts der genannten Anforderungen wollten wir, dass unsere Lösung intuitiv für den Rest des Square Glade-Teams zu verwenden ist. Nach unserer Erfahrung werden Editor-Tools nur von Teammitgliedern verwendet, wenn sie intuitiv und leicht zu erlernen sind. Mit diesem Gedanken im Hinterkopf haben wir uns entschieden, einen visuellen 3D-Würfel zu erstellen, der skaliert, rotiert und manipuliert werden kann, um genau genug von der Karosserie des Fahrzeugs abzuschneiden und sie so zu verändern, dass sie genau richtig ist. Alle Pflanzen innerhalb des Würfels würden abgeschnitten, während alles außerhalb gleich aussehen würde.

Stencil-Shader

Das erste, was wir ausprobiert haben, war die Verwendung eines Shader-Elements namens 'Stencil-Puffer'.

Dieser Teil der Shader-Programmierung ist sehr faszinierend, aber auch ein wenig schwierig zu verstehen. Für unseren Zweck bedeutet das, dass wir dem 'Clip-Element', in diesem Fall unserem Würfel, sagen, dass es einige Informationen in den Stencil-Puffer eines gerenderten Frames schreiben soll. Das bedeutet, dass überall auf dem Bildschirm, wo der Würfel ist, ein Wert von 1 geschrieben wird. Das 'abgeschnittene' Objekt (in unserem Fall das Gras) kann aus diesem Puffer lesen und verwirft alle Pixel, die genau den Wert 1 haben.

Im Shader-Code würde das etwa so aussehen:

Clipping object 'Cube'
Stencil
{
    Ref 1
    Comp always
    Pass replace
}

Clipped object 'Grass'
Stencil
{
    Ref 1
    Comp equal
}

Das Clipping-Objekt schreibt einen Wert von 1 in den Puffer, wie in der Zeile Ref 1 angegeben, und wird dies Immer tun. Wenn ein später gerendeter Stencil-Wert übereinstimmt oder besteht den Stencil-Vergleich, wird er ersetzen es mit den Informationen dieses Shaders.Das Gras hat eine ähnliche Implementierung: Es wird auch nach dem Wert von Ref 1 suchen und wird den Test nur bestehen, wenn der Vergleich gleich diesem Referenzwert ist.

Diese Implementierung funktionierte gut, um das Gras abzuschneiden, und sie war sehr effizient, da sie auf den Pixeln des gerenderten Frames arbeitet und nicht von der Menge an Gras in einer bestimmten Szene beeinflusst wird. Es gab jedoch einen fatalen Fehler in dieser Lösung. Da diese Implementierung kein Gefühl für Tiefe hat, wird sie auch alles hinter dem Würfel abschneiden. Praktisch bedeutete dies, dass, wenn der Spieler im Fahrzeug saß und aus der Ich-Perspektive schaute, der gesamte Bildschirm als 'abgeschnitten' markiert wurde, sodass der Spieler kein Gras irgendwo sehen konnte. Deshalb mussten wir einige andere Methoden ausprobieren, die auch funktionieren würden, wenn sich die Spieler-Kamera im 'Clipper'-Objekt befand.

Manuelle Clipping

Eine Lösung, die wir kurz besprochen haben, war, das Gras an der Position unseres Fahrzeugs manuell zu entfernen und es vom Terrain selbst wegzunehmen. Das hatten wir bereits für andere Teile im Spiel getan, indem wir die Funktion 'TerrainData.SetDetailLayer' verwendet haben, die Unity für das Terrain bereitstellt. Dies würde die Graustufenfarbe der Detailebene auf 0 für die Pixel direkt unter dem Van setzen und das Terrain anweisen, alle Detail-Meshes oder Gras an diesen Positionen zu entfernen.

Da die Karten von Outbound ziemlich groß sind, bedeutet dies, dass die Auflösung der Detailebene auf der niedrigeren Seite liegt, was sie etwas 'zackig' macht. Das ist für die normale Platzierung von Gras und anderen Meshes vollkommen in Ordnung, aber beim manuellen Entfernen von Teilen wird die niedrigere Auflösung zu einer Form führen, die nicht nah genug an der Größe des Vans ist, entweder zu klein oder zu groß.

Diese Lösung würde auch zu Flimmern von Details führen, wenn sich das Fahrzeug gerade an der Grenze von zwei Terrain-Detail-Pixeln befand. Aus diesen Gründen haben wir nicht mit der Implementierung dieser Lösung fortgefahren. Unsere Reise geht weiter!

Clip-Shader

Mit dem Stencil-Buffer-Shader dachten wir, wir wären fast da, da wir die Pixel, wo nötig, mit der Präzision des äußeren Körpers des Vans unsichtbar gemacht haben. Wenn es nur einen anderen Weg gäbe, dies zu tun, während wir tatsächlich die Tiefe des Würfels nutzen, wobei die Lösung im Grunde nur die Pixel innerhalb seiner Begrenzungsbox clippen sollte.

Wie sich herausstellt, gibt es eine Methode, die genau das tut! HLSL-Shader bieten die bescheidene clip()-Funktion, die einfach das Pixel verwirft, wenn der angegebene Wert kleiner als 0 ist. Vielleicht hast du das schon einmal in einem zufälligen Shader gesehen, wo es oft für Alpha-Clipping verwendet wird.

Um ein Beispiel zu geben, sieht das Gras von Outbound aus wie echte Grasbüschel und nicht wie quadratische Quads mit einem Bild von Gras darauf, weil wir überall dort 'clippen', wo der Alphakanal unserer Grastextur schwarz ist.

Als wir einen schnellen ersten Prototypen/Check für diese Lösung gemacht haben, hatten wir große Hoffnungen, dass diese Implementierung funktionieren würde, da wir in der Lage waren, Pixel über einer bestimmten Weltposition unsichtbar zu machen. In Pseudocode sah die Funktion wie folgt aus:

// Return -1 when the Y position is above 0, and return 1 when it is not.
clip( worldPos.y > 0 ? -1 : 1 );

Die Lösung: Ein Clip-Shader

Bis zu diesem Punkt hatten wir ein einfaches Beispiel, das eine vielversprechende Lösung zeigte, nämlich die Verwendung eines Clip-Shaders. Der nächste Schritt bestand darin, eine Funktion zu erstellen, die den Shader mit den Informationen versorgt, die benötigt werden, um genau dort zu clippen, wo wir es wollten. Dies umfasste zwei Teile:

• Der Teil, in dem wir im Wesentlichen die 'Form' berechnen, einschließlich ihrer Dimensionen und Transformationen, und diese Daten an den Shader übergeben.

• Der Teil, in dem der Shader diese Daten verwendet, überprüft, ob ein gegebener Punkt innerhalb der Form liegt, und verwirft die Pixel, wo es nötig ist.

Für den ersten Schritt unserer Lösung haben wir ein Skript 'GrassClipperShape' erstellt, ein MonoBehaviour, das wir einem Objekt in der Szene anhängen konnten, das bestimmen würde, wo sich ein Clipping-Bereich befindet. Ein Beispiel dafür ist unten gezeigt, wo der Bereich der Form mit OnDrawGizmos in der Editoransicht angezeigt wird.

Campervan überlagert mit einem gelben Drahtgitterkasten, der den Clipping-Bereich zeigt
Der Campervan von Outbound überlagert mit einem gelben Drahtgitterkasten, der den Clipping-Bereich zeigt.

Da wir idealerweise mehrere dieser Clipper verwenden möchten, benötigen wir ein übergeordnetes Skript (d.h. einen "Manager"), um alle verfügbaren Clipper zu verwalten. Jeder Clipper würde die folgenden Eigenschaften an dieses übergeordnete Skript, genannt 'GrassClipperManager', übergeben:

• Form: der Typ der Form, wir wollten, dass diese Version sowohl mit Würfeln als auch mit Kugeln funktioniert, daher ist dies ein einfaches Enum, das entweder 'Würfel' oder 'Kugel' gesetzt ist

• Vector3: die Größe des Objekts in der Szene

• Matrix4x4: das berechnete rotierte Objekt im Weltkoordinatensystem

Der GrassClipperManager, von dem es in der Szene nur einen gibt, wird diese Informationen von den Clippern in jedem Frame abrufen und sie wie folgt an den Shader senden:

Shader.SetGlobalInteger("_ShapeCount", count);  
Shader.SetGlobalMatrixArray("_ShapeInvMatrix", inv);  
Shader.SetGlobalVectorArray("_ShapeParams", size);  
Shader.SetGlobalFloatArray("_ShapeType", type);

Die obigen Zeilen setzen globale Shader-Werte. Kurz gesagt bedeutet dies, dass Sie Shader-Werte mit diesen genauen Namen und Typen verwenden und sie in jedem Shader verwenden können.

Da wir möchten, dass unser Clipping auf mehreren verschiedenen Shadern erfolgt, haben wir ein separates HLSL-Skript erstellt, das in jeden Shader aufgenommen werden kann, der von unserem Clipper betroffen sein soll. Dieses Skript stellt eine benutzerdefinierte Funktion namens 'ApplyClipVolumeSDF' zur Verfügung. Es verwendet die Informationen aus den jetzt gefüllten globalen Shader-Werten und berechnet, ob ein Pixel innerhalb eines der Grenzen liegt.

inline void ApplyClipVolumeSDF(float3 worldPos)  
{  
    float clipVal = GetClipFade(worldPos);  
    if (clipVal  <= 0.0)  
        clip(-1);  
}

Wie Sie oben sehen können, wird, wenn das Pixel verworfen werden soll, die Funktion 'clip(-1)' aufgerufen, die ein verworfenes Pixel zurückgibt. Andernfalls wird es einfach normal durch den Rest des Shaders fortfahren.

Clip-Shader-Implementierung

Mit der jetzt erstellten Clipping-Funktion, die mit den erforderlichen Daten versorgt wurde, war es an der Zeit, sie in unsere Shader zu implementieren.

Lassen Sie uns zunächst besprechen, wie wir dies für die Detail-Meshes tun können, wo wir eine Kopie des Originals erstellen und bearbeiten könnten. Ganz oben im Shader müssen wir das benutzerdefinierte Skript wie folgt referenzieren:

#include "Assets/Shaders/ClipVolume.hlsl"

Und dann, wenn wir die Funktion tatsächlich verwenden möchten, rufen wir sie einfach im Fragmentteil des Shaders wie folgt auf:

float3 worldPos = mul(unity_ObjectToWorld, float4(input.positionOS, 1.0)).xyz;
ApplyClipVolumeSDF(worldPos);

In unserem Fall mussten nur zwei Shader dies einbeziehen, nämlich der Standard-Shader, den das Unity-Gras verwendet, und ein benutzerdefinierter Shader, der für all das andere Laub verwendet wird, das als Detail-Meshes gerendert wird. Jetzt, da wir dies haben, kann es bei Bedarf einfach in jeden anderen Shader implementiert werden.

Aber unsere Reise war noch nicht zu Ende – ein letztes Hindernis stellte sich uns in den Weg. Wie konnten wir jetzt den Standard-Gras-Shader bearbeiten und tatsächlich die vorgenommenen Änderungen beibehalten? Unity verwendet einige spezifische integrierte Shader zum Rendern von Gras, in unserem Fall den 'WavingGrassBillboard.shader'. Dieser Shader wird automatisch auf allen Grasflächen angewendet, ohne die Möglichkeit, benutzerdefinierte Varianten bereitzustellen. Das war entscheidend, um unsere Lösung zum Laufen zu bringen, da sie in diesen Shader eingreifen musste, um die benutzerdefinierte Funktion 'ApplyClip' aufrufen und die unerwünschten Pixel verwerfen zu können.

Nachdem er einige Lösungen ausprobiert hatte, fand unser Teamkollege Michiel Procé einen Weg, den Standard-Gras-Shader zuverlässig zu bearbeiten und die Änderungen tatsächlich beizubehalten. Durch das Ausführen des folgenden Codes während der Builds und im Editor ersetzt unser benutzerdefinierter Shader den Standard-URP-Shader:

string replacementShaderName = "Hidden/TerrainEngine/Details/UniversalPipeline/BillboardWavingDoublePass_Clipped";

if (GraphicsSettings.TryGetRenderPipelineSettings<UniversalRenderPipelineRuntimeShaders>(out var shadersResources))
{
    if (shadersResources.terrainDetailGrassBillboardShader.name != replacementShaderName)
    {
        Shader replacementShader = Shader.Find(replacementShaderName);
        shadersResources.terrainDetailGrassBillboardShader = replacementShader;
    }
}

Beachten Sie, dass dies nur den WavingGrassBillboard-Shader ersetzt, aber die Implementierung für andere Shader ähnlich wäre.

Abschließende Gedanken

Unsere endgültige Lösung mit einem Clip-Shader funktioniert gut für unsere Zwecke und wir sind sehr zufrieden mit den Ergebnissen, die sie liefert. Siehe den Screenshot unten für eine Visualisierung der Lösung, bei der ein rechteckiger Würfel das Gras darin abschneidet. Beachten Sie, dass die Box von oben gesehen wird und durch das Terrain platziert ist, um eine optimale Sicht auf das Abgeschnittene zu bieten.

Ansicht eines grasbewachsenen Feldes, das eine unsichtbare Box zeigt, die das Laub darin abschneidet.
Ansicht eines grasbewachsenen Feldes in Outbound, das eine unsichtbare Box zeigt, die das Laub darin abschneidet.

Als wir auf unsere Liste der Anforderungen für unsere Grasabschneidelösung zurückblickten, waren wir froh zu sehen, dass sie allen entspricht!

• Die Lösung ist leistungsfähig, da die Funktionen, die zur Berechnung des Abgleichs verwendet werden, sehr kostengünstig sind. Und weil sie das Pixel direkt verwirft, wird unsere Implementierung keine weiteren unnötigen Verarbeitungen durchführen.

• Es bewahrt Outbounds<2> ursprünglichen Stil intakt, da es auf den Shadern basiert, die wir bereits verwendet haben.

• Die Implementierung ist plattformunabhängig, da die clip() Funktion selbst es ist.

• Die Lösung ist intuitiv zu verwenden für den Rest des Teams. Designer können mehrere Formen erstellen und verwenden und sogar deren Schnittpunkte haben.

Wir glauben, dass Funktionen wie die oben genannten extrem wichtig sind, nicht nur aus Gründen der Kreativität, sondern auch um zu verhindern, dass später seltsame Fehler auftreten.

Beispielprojekt

Um diese Lösung mit der Community zu teilen, haben wir ein Beispielprojekt erstellt, das diese oben beschriebenen Techniken verwendet, damit Sie es selbst ausprobieren können – sehen Sie es sich hier auf GitHub an.

Danke, dass Sie unseren Gastbeitrag gelesen haben. Hoffentlich hilft dies vielen anderen Entwicklern, die dasselbe Problem haben wie wir!

Outbound befindet sich derzeit in der geschlossenen Beta-Phase; folgen Sie dem Spiel auf Steam für Updates. Entdecken Sie weitere Spiele, die mit Unity erstellt wurden, auf unserer Steam-Kuratorseite und sehen Sie sich weitere Geschichten von Unity-Entwicklern auf unserem Ressourcen-Hub an.

Nintendo Switch™ ist eine Marke von Nintendo.