Abordagem do shader de clip de Outbound: Descarte preciso de folhagem para ambientes em tempo real

TONY FIAL AND MICHIEL PROCÉ / SQUARE GLADE GAMESGuest Blog
Dec 2, 2025|6:30 Min
Arte chave para Outbound por Square Glade Games, feito com Unity. Uma van camper laranja com painéis solares contra um fundo azul. À direita da van, no canto, está um pequeno cachorro com bolsas amarradas ao seu corpo.
Esta página da Web foi automaticamente traduzida para sua conveniência. Não podemos garantir a precisão ou a confiabilidade do conteúdo traduzido. Se tiver dúvidas sobre a precisão do conteúdo traduzido, consulte a versão oficial em inglês da página da Web.

Como você impede que a grama atravesse o chão no seu jogo de vida em van de mundo aberto? Neste post de convidado, os programadores da Square Glade Games, Tony Fial e Michiel Procé, oferecem uma visão detalhada de como abordaram e, por fim, resolveram esse problema em Outbound com uma solução de clip de shader personalizada.

Nós somos Tony Fial e Michiel Procé, parte da equipe da Square Glade Games, e atualmente estamos trabalhando no título mais recente do estúdio, Outbound, que é um jogo de exploração em mundo aberto ambientado em um futuro utópico próximo. O jogador começa com uma van de camping vazia e pode transformá-la na casa móvel dos seus sonhos, construindo-a exatamente como deseja.

O veículo é um grande ponto focal para o jogo, assim como dirigi-lo pela natureza. O mundo em Outbound é feito à mão e inclui muita folhagem e grama, que é exuberante, alta e abundante. Embora pudéssemos criar um mundo bonito com esses ativos, combiná-los com um veículo que dirige por tais ambientes causou alguns problemas visuais.

O problema

O jogador pode dirigir sua van de camping por basicamente qualquer área aberta. Arbustos e grama não são obstáculos para isso. Com a van estando bastante próxima do chão, isso frequentemente resultou em grama do terreno atravessando a parte inferior ou os lados do veículo.

Há também lugares onde a van pode alcançar a folhagem mais alta, como flores e arbustos. Para mostrar o problema em questão, a captura de tela abaixo mostra um caso onde tanto a grama quanto os arbustos estão fortemente atravessando o veículo. Isso não é apenas visualmente desagradável, mas também causa vários problemas de jogabilidade, como bloquear visualmente interações ou informações importantes.

Van de camping com a porta lateral aberta, mostrando grama e arbustos atravessando a carroceria do veículo
Van de camping com a porta lateral aberta, mostrando grama e arbustos atravessando a carroceria do veículo

Para resumir nosso problema central, existem diferentes tipos de folhagem e grama que atravessam a van de camping, o que é indesejado do ponto de vista visual e de jogabilidade.

Agora, vamos resolver isso, certo?

Brainstorming de possíveis soluções

Na Square Glade Games, antes de começarmos a trabalhar ativamente em uma solução, achamos útil compilar uma lista de requisitos ideais.

Neste caso específico, precisávamos que nossa solução:

• Seja performático. Há muita grama em Outbound, então uma solução não otimizada pode ser muito cara nas áreas com muito mais grama e plantas.

• Mantenha o estilo original intacto. Atualmente estamos em um estado de desenvolvimento onde não podemos alterar a aparência de elementos principais em Outbound, então idealmente a solução utiliza o máximo possível da folhagem original.

• Seja compatível com várias plataformas.Como o título está planejado para ser lançado em várias plataformas, a solução precisa funcionar no Windows, Nintendo Switch™, Xbox e PlayStation®.

• Seja intuitivo de usar.A solução deve ser idealmente intuitiva tanto para os designers quanto para os programadores da equipe.

• Seja aplicado a múltiplas formas. Idealmente, cortaríamos a folhagem em uma forma exata do veículo, possivelmente usando múltiplas formas.

Agora para pensar em soluções que poderiam satisfazer esta lista de requisitos. Nossos primeiros pensamentos foram para um elemento que todas as lâminas de grama compartilham... o shader.

Quase toda a flora em Outbound é colocada no terreno do Unity usando as ferramentas de terreno. Uma parte considerável disso é a grama, que usa o shader de Grama padrão. Este shader usa a GPU para colocar e exibir os planos de grama de uma maneira muito performática. Outros elementos, como os arbustos maiores mostrados na captura de tela acima, são colocados como malhas de detalhe, usando seu próprio material e shader designados.

Isso apresentou outro detalhe importante, ou seja, que a solução proposta deve ser capaz de funcionar em múltiplos shaders totalmente diferentes, da mesma maneira, ao mesmo tempo.

Soluções propostas

Todas as soluções propostas abaixo compartilham um 'input' principal em comum também: A posição da van de camping, ou para ser mais preciso, a área onde a folhagem deve ser cortada.

Olhando para os requisitos estabelecidos, queríamos que nossa solução fosse intuitiva para o restante da equipe do Square Glade usar. Em nossa experiência, as ferramentas de Editor só serão usadas pelos membros da equipe quando forem intuitivas e fáceis de entender. Com isso em mente, decidimos construir um cubo visual 3D que pudesse ser escalado, rotacionado e manipulado para recortar apenas o suficiente da carroceria do veículo e ajustá-lo para que fique perfeito. Qualquer folhagem dentro do cubo seria recortada, enquanto tudo fora dele pareceria o mesmo.

Shader de estêncil

A primeira coisa que tentamos foi usar um elemento de shader chamado 'buffer de estêncil.'

Essa parte da programação de shaders é muito fascinante, mas também um pouco difícil de entender. O que isso significa para o nosso propósito é que dizemos ao 'elemento de recorte', neste caso, nosso cubo, para escrever algumas informações no buffer de estêncil de um quadro renderizado. Isso significa que em qualquer lugar da tela onde o cubo estiver, ele escreverá um valor de 1. O objeto 'recortado' (no nosso caso, a grama) pode ler desse buffer e descartar quaisquer pixels que tenham um valor definido exatamente como 1.

No código do shader, isso pareceria algo assim:

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

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

O objeto de recorte escreve um valor de 1 no buffer, conforme indicado pela linha Ref 1, e fará isso Sempre. Se um valor de estêncil renderizado posteriormente corresponder ou Passar a comparação de estêncil, ele substituirá isso com as informações deste shader.A grama tem uma implementação semelhante: Ela também procurará o valor de Ref 1 e só passará na verificação se a Comparação for Igual a esse valor de referência.

Essa implementação funcionou para recortar a grama, e foi muito eficiente, pois funciona nos pixels do quadro renderizado e não é afetada pela quantidade de grama em uma cena dada. No entanto, havia uma falha fatal nesta solução. Porque essa implementação não tem noção de profundidade, ela recortará qualquer coisa atrás do cubo também. Praticamente, isso significava que quando o jogador estava sentado dentro do veículo, enquanto olhava de uma visão em primeira pessoa, toda a tela seria marcada como 'recortada', então o jogador não veria grama em lugar nenhum. Por causa disso, tivemos que tentar alguns outros métodos que também funcionariam quando a câmera do jogador estivesse dentro do objeto 'clipper'.

Recorte manual

Uma solução que discutimos brevemente foi remover manualmente a grama na posição do nosso veículo, tirando-a do terreno em si. Já havíamos feito isso para outras partes do jogo, usando a função 'TerrainData.SetDetailLayer' que a Unity fornece no terreno. Isso definiria a cor em escala de cinza da camada de detalhes para 0 nos pixels logo abaixo da van, instruindo o terreno a remover quaisquer malhas de detalhes ou grama naquele conjunto de locais.

Como os mapas de Outbound são bastante grandes, isso significa que a resolução da camada de detalhes está no lado mais baixo, tornando-a um pouco 'irregular'. Isso é perfeitamente aceitável para a colocação normal de detalhes de grama e outras malhas, mas ao recortar partes manualmente, a resolução mais baixa resultará em uma forma que não estaria próxima o suficiente do tamanho da van, sendo muito pequena ou muito grande.

Essa solução também resultaria em detalhes piscando quando o veículo estava apenas na borda de dois pixels de detalhes do terreno. Por essas razões, não seguimos em frente com a implementação dessa solução. Nossa jornada continua!

Shader de recorte

Com o shader de buffer de stencil, achamos que estávamos quase lá, pois tornamos os pixels invisíveis onde necessário com a precisão da carroceria externa da van. Se ao menos houvesse outra maneira de fazer isso, enquanto realmente usasse a profundidade do cubo, sabendo que a solução deveria basicamente apenas recortar os pixels dentro de sua caixa delimitadora.

Acontece que existe um método que faz exatamente isso! Os shaders HLSL fornecem a humilde função clip(), que simplesmente descarta o pixel se o valor especificado for menor que 0. Você pode ter visto isso antes em algum shader aleatório onde é frequentemente usado para recorte alfa.

Para fornecer um exemplo, a grama de Outbound parece tufos reais de grama e não quadrados quadrados com uma imagem de grama sobre eles, porque 'recortamos' onde o canal alfa de nossa textura de grama é preto.

Quando fizemos um protótipo/verificação rápida para essa solução, tínhamos grandes esperanças de que essa implementação funcionaria, pois conseguimos renderizar pixels invisíveis acima de uma certa posição no mundo. Em pseudocódigo, a função parecia com o seguinte:

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

A solução: Um shader de recorte

Até este ponto, tínhamos um exemplo simples que mostrava uma solução promissora, a saber, usar um shader de recorte. O próximo passo foi criar uma função para fornecer ao shader as informações necessárias para recortar exatamente onde queríamos. Isso envolveu duas partes:

• A parte onde calculamos, em essência, a 'forma', incluindo suas dimensões e transformações, e fornecemos esses dados ao shader.

• A parte onde o shader usa esses dados, verifica se um determinado ponto está dentro da forma e descarta seus pixels onde necessário.

Para o primeiro passo de nossa solução, criamos um script 'GrassClipperShape', um MonoBehaviour que poderíamos anexar a um objeto na cena, que ditaria onde uma área de recorte estaria. Um exemplo disso é mostrado abaixo, onde a área da forma usando OnDrawGizmos na visualização do Editor é exibida.

Furgão de camping sobreposto com uma caixa de malha amarela mostrando a área de recorte
Furgão de camping da Outbound sobreposto com uma caixa de malha amarela mostrando a área de recorte.

Como idealmente gostaríamos de usar múltiplos desses recortadores, precisamos de um script abrangente (ou seja, um "gerente") para gerenciar todos os recortadores disponíveis. Cada recortador forneceria as seguintes propriedades a este script abrangente, chamado 'GrassClipperManager':

• Forma: o tipo de forma, queríamos que esta versão funcionasse tanto com cubos quanto com esferas, então este é um enum simples definido como ‘cubo’ ou ‘esfera’

• Vector3: o tamanho do objeto na cena

• Matrix4x4: o objeto rotacionado calculado no espaço mundial

O GrassClipperManager, do qual sempre há apenas um na cena, buscará essas informações dos recortadores a cada quadro e as enviará ao shader assim:

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

As linhas acima definirão valores de shader globais. Para explicar de forma resumida, isso significa que você pode usar valores de shader com esses nomes e tipos exatos, e usá-los em qualquer shader.

Porque queremos que nosso recorte aconteça em múltiplos shaders diferentes, criamos um script HLSL separado para ser incluído em qualquer shader que precise ser afetado pelo nosso recortador. Este script expõe uma função personalizada chamada 'ApplyClipVolumeSDF'. Ele usa as informações dos valores de shader globais agora preenchidos e calculará se um pixel está dentro de qualquer um dos limites.

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

Como você pode ver acima, se o pixel deve ser descartado, ele chamará a função 'clip(-1)', retornando um pixel descartado. Caso contrário, ele apenas prosseguirá normalmente pelo resto do shader.

Implementação do shader de recorte

Com a função de recorte agora criada e fornecida com os dados necessários, era hora de implementá-la em nossos shaders.

Vamos primeiro discutir como fazer isso para as malhas de detalhe, onde poderíamos criar uma cópia do original e editá-la. No topo do shader, devemos referenciar o script personalizado assim:

#include "Assets/Shaders/ClipVolume.hlsl"

E então, quando quisermos realmente usar a função, simplesmente a chamamos dentro da parte de fragmento do shader assim:

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

No nosso caso, apenas dois shaders precisavam incluir isso, a saber, o shader padrão que a grama do Unity usa e um shader personalizado usado para toda a outra folhagem renderizada como malhas de detalhe. Agora que temos isso, pode ser implementado facilmente em qualquer outro shader, se precisarmos.

Mas nossa jornada não havia terminado - um último obstáculo se apresentou. Como poderíamos agora editar e realmente reter as alterações feitas no shader de grama padrão? Unity usa alguns shaders embutidos específicos para renderizar grama, no nosso caso o 'WavingGrassBillboard.shader'. Esse shader é aplicado automaticamente a toda a grama, sem opção de fornecer variantes personalizadas. Isso foi crucial para fazer nossa solução funcionar, pois precisava se conectar a esse shader para poder chamar a função personalizada 'ApplyClip' e descartar os pixels indesejados.

Depois de tentar algumas soluções, o colega de equipe Michiel Procé descobriu uma maneira de editar e realmente reter as alterações no shader de grama padrão de forma confiável. Executando o seguinte código durante as compilações e no editor, nosso shader personalizado substitui o shader URP padrão:

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;
    }
}

Observe que isso apenas substitui o shader WavingGrassBillboard, mas implementar isso para outros shaders seria semelhante.

Considerações finais

Nossa solução final de usar um shader de clipe funciona bem para nossos propósitos e estamos muito felizes com os resultados que fornece. Veja a captura de tela abaixo para uma visualização da solução, onde um cubo retangular corta a grama dentro. Observe que a caixa é vista de cima e está colocada através do terreno para uma visão ideal do que está sendo cortado.

Vista de um campo gramado mostrando uma caixa invisível cortando a folhagem dentro
Vista de um campo gramado em Outbound, mostrando uma caixa invisível cortando a folhagem dentro

Olhando para nossa lista de requisitos para nossa solução de corte de grama, ficamos felizes em ver que ela atende a todos eles!

• A solução é eficiente, pois as funções usadas para calcular o corte são muito baratas. E porque descarta diretamente o pixel, nossa implementação não fará processamento desnecessário adicional.

• Ela mantém o estilo original de Outbound's intacto porque é construída sobre os shaders que já estávamos usando.

• A implementação é agnóstica em relação à plataforma, porque a função clip() em si é.

• A solução é intuitiva de usar para o resto da equipe. Os designers podem criar e usar múltiplas formas e até mesmo fazê-las se intersectar.

Acreditamos que recursos como os acima são extremamente importantes, não apenas por questões de criatividade, mas também para evitar que bugs estranhos surjam mais tarde.

Projeto de amostra

Para compartilhar esta solução com a comunidade, criamos um projeto de amostra usando as técnicas detalhadas acima, para que você possa experimentar por si mesmo – veja aqui no GitHub.

Obrigado por ler nosso post de convidado. Esperamos que isso ajude muitos outros desenvolvedores que estão enfrentando o mesmo problema que nós enfrentamos!

Outbound está atualmente em testes beta fechados; acompanhe o jogo no Steam para atualizações. Explore mais jogos feitos com Unity em nossa página de Curador do Steam, e confira mais histórias de desenvolvedores da Unity em nosso hub de Recursos.

A Nintendo Switch™ é uma marca registrada da Nintendo.