Achter de schermen: Sneeuw scheppen in Ho-Ho Hunt

Achter de schermen: Sneeuw scheppen in Ho-Ho Hunt

Matthias GallMatthias Gall
·27 november 2025·15 min. leestijd
Let op: dit bericht is automatisch vertaald.

Onze kerstbeleving Ho-Ho Hunt begon eigenlijk als iets totaal anders: een persoonlijke fitness-app voor de 24 dagen tot Kerstmis. Het plan was om eerst kleine sneeuwveldjes te scheppen en gaandeweg grotere oppervlakken aan te pakken. Leuk idee, maar niet ideaal voor eenmalige events en B2B-campagnes.

Dus we zijn gepivot, hebben de bevredigende beweging gehouden en alles verplaatst naar een compacte achtertuin, waar één helder speelveld veel beter werkt voor korte sessies – een perfecte, compacte VR-sneeuwschepchallenge voor teams, events en merkactivaties.

Ho-Ho Hunt in een notendop

In Ho-Ho Hunt stap je in de laarzen van de Kerstman tijdens een avond waarop alles misgaat: hij crasht op een dak in een stille wijk, verliest de controle over de slee en ziet cadeaus verdwijnen in de diepe sneeuw van een kleine achtertuin.

Gelukkig ligt er een schep in de buurt en heb je één missie: de tuin leegscheppen en alle cadeaus opgraven voordat de timer nul bereikt.

Elke haal schraapt zichtbaar sneeuw weg en onthult meer cadeaus; het tempo gaat omhoog, je racet tegen de klok en tegen het leaderboard terwijl collega’s of bezoekers je aanmoedigen. Simpel om te begrijpen, verrassend intens en gegarandeerd goed voor je hartslag.

Achter de schermen:
een sneeuwschepmechaniek bouwen

Het demoproject voor dit artikel is gebouwd met Unity 6 (specifiek 6000.0.62f1). We kennen geen Shader Graph-wijzigingen die de functionaliteit zouden breken, dus oudere of nieuwere versies moeten ook werken.

De sneeuwveld-mesh

Voor een “schepbaar” sneeuwveld moeten we balanceren tussen complexiteit, performance en gameplay. We willen geen iglo’s maken, maar juist tonen wat onder de sneeuw ligt. Daarom kiezen we voor een fijnmazig plane-mesh met vertexdisplacement op basis van een heightmap, gerenderd door een speciale sneeuwshader. Telkens wanneer de heightmap verandert door een schepbeweging, roepen we een compute shader aan om die heightmap te updaten. Daarmee kunnen we niet alleen sneeuw weghalen, maar ook elders deponeren of voetstappen en andere deukjes maken.

Voor onze ervaring maakten we gebruik van Unity’s runtime mesh generation om flexibel te blijven qua afmetingen en granulariteit. Om dit zelf te proberen kun je Blender gebruiken: voeg een plane toe, ga naar edit mode, klik met rechts en kies Subdivide. Vul linksonder het gewenste aantal subdivides per rand in. In dit voorbeeld gebruiken we 63, wat resulteert in 64x64 kleine vlakjes.

Een gesubdivide plane-mesh in Blender
Een gesubdivide plane-mesh in Blender

Exporteer het mesh vervolgens als FBX (File -> Export -> FBX), beperk de export tot het geselecteerde mesh (de standaardcamera en -lamp hebben we niet nodig) en vink “Apply Transform” aan om de schaal te normaliseren. Unity kan .blend-bestanden direct importeren, maar via FBX is vaak makkelijker vanwege de verschillende coördinatensystemen.

Simpele sneeuwkleur

We hebben Shader Graph gebruikt om een shader voor de sneeuw te bouwen. Rechtsklik in het project, Create -> Shader Graph -> URP -> Lit Shader Graph. Dat levert een lege shader met inputs voor de vertex- en fragmentfase.

De initiale Shader Graph waarmee we beginnen
De initiale Shader Graph waarmee we beginnen

Voordat we hoogtevariaties aanpakken, regelen we de basiskleur. Een verse sneeuwlaag is meestal wit met subtiele ruis. We voegen twee parameters toe: een Color BaseColor (standaard wit), een Texture2D NoiseTex voor de ruis en een Float NoiseScale (standaard 1). Daarna nemen we de wereldpositie van een fragment, schalen de x- en z-waarden volgens de noise scale, sampelen de ruis, remappen de waarde en passen de basis kleur licht aan. Een range van 0,8 tot 1,1 werkt goed. De output gaat naar de Base Color van de fragmentstage.

Een basiskleur voor sneeuw met subtiele variatie
Een basiskleur voor sneeuw met subtiele variatie

Sla de shader op, klik er met rechts op en kies Create -> Material. Dat maakt automatisch een materiaal met deze shader. Sleep het materiaal op het sneeuwmesh in de scene.

Het mesh (midden) met materiaal en de bijbehorende parameters
Het mesh (midden) met materiaal en de bijbehorende parameters

We hebben nog geen noisemap ingesteld, dus het sampelen geeft 0 en na het remappen blijft het sneeuwoppervlak constant (0.8, 0.8, 0.8).

Je kunt een online generator voor tiling perlin noise gebruiken, het resultaat in het project opslaan en koppelen aan NoiseTex. Dat levert bijvoorbeeld dit op:

De sneeuwkleur varieert nu afhankelijk van de noise en scaling.
De sneeuwkleur varieert nu afhankelijk van de noise en scaling.

(Het verschil is subtiel en hangt af van compressie en monitor.)

Heightmap voor vertexdisplacement

Tijd voor vertexdisplacement. Om de hoogtewaarden door te geven, voegen we een Texture2D-parameter HeightTex toe aan de shader. We zouden een extra schaalparameter kunnen maken, maar om verwarring te voorkomen gaan we ervan uit dat de heightmapwaarde direct de renderhoogte is.

Om de hoogte toe te passen, sampelen we de heightmap en tellen de waarde op bij y van de vertexpositie voordat we die doorgeven aan de vertex stage.

Vertexdisplacement aangestuurd door een heightmap.
Vertexdisplacement aangestuurd door een heightmap.

De echte heightmap hebben we nog niet – die maken we als render texture die we runtime bijwerken. Voor testdoeleinden kunnen we voorlopig de perlin noise gebruiken. De belichting is nog niet mooi omdat we geen normal berekenen, maar met wireframe zie je dat de displacement klopt.

Testen met de noise-tekstuur als heightmap toont duidelijk de displacement.
Testen met de noise-tekstuur als heightmap toont duidelijk de displacement.

Dellen runtime genereren

We maken een render texture om de heightmap runtime op te slaan en te wijzigen. Create -> Rendering -> Render Texture, noem hem Heightmap en vink “Random Write” aan. Sleep de render texture daarna naar de HeightTex-parameter van het sneeuwmateriaal.

Voor het aanpassen schrijven we een compute shader met een kernel die de heightmap wijzigt. Create -> Shader -> Compute Shader, noem hem ShovelBrush en implementeer:

#pragma kernel Shovel

// De heightmap die aangepast wordt
RWTexture2D<float> Heightmap;

// Schepparameters, vooraf ingesteld
uint Resolution;
float2 CenterUV;
float Radius;
float Strength;

[numthreads(8, 8, 1)]
void Shovel(uint3 id : SV_DispatchThreadID) {
    // Stop als de call buiten de resolutie valt
    if (id.x >= Resolution || id.y >= Resolution) {
        return;
    }

    // Texels buiten de schepradius negeren
    float2 uv = (float2(id.xy) + 0.5) / Resolution;
    float2 d = uv - CenterUV;
    float dist = length(d);
    if (dist > Radius) {
        return;
    }

    // Falloff binnen de radius
    float t = saturate(dist / Radius);
    float falloff = smoothstep(1.0, 0.0, t);

    // Heightmap updaten
    int2 coord = int2(id.xy);
    float h = Heightmap[coord];
    h += Strength * falloff;
    Heightmap[coord] = h;
}

In tegenstelling tot de graphics shader is een compute shader een GPU-programma dat je on demand draait voor algemene taken (physics, image processing, teksturen beschilderen, …). Hij tekent niks zelf. Omdat graphics shaders niet in textures kunnen schrijven, laat je dat door de compute shader doen.

We starten 8x8 = 64 threads parallel op een texture, en elke call checkt eerst of er werk is. Deze blokgrootte past goed bij 2D-textures, draait prima op mobiele GPU’s en houdt de registerdruk laag – ideaal voor Meta Quest.

Wat nu nog ontbreekt, is een script dat alles bij elkaar brengt. We bouwen een simpele component die willekeurig dellen of hoopjes genereert, puur om het effect te laten zien. Maak een nieuw script (Create -> MonoBehaviour Script), noem het RandomShovel en implementeer:

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 vullen met "medium height"
        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);

            // Parameters voor deze run
            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 uitvoeren
            shovelShader.Dispatch(_shovelKernel, heightmap.width / 8, heightmap.height / 8, 1);
        }
    }

}

Maak vervolgens een empty game object in de scene met dezelfde naam, voeg het script toe en sleep de compute shader, heightmap render texture en het sneeuwmateriaal naar de juiste velden.

Het script wist de heightmap met een middelgrijze waarde zodat dellen en ophopingen direct zichtbaar zijn. Het pusht de parameters naar de compute shader, triggert de kernel elke 200 ms en genereert willekeurig dellen of hoopjes. Start je nu het project, dan zie je de render texture continu veranderen.

Video Voorvertoning

Als je op de afspeelknop klikt, wordt de YouTube-speler ingesloten en kunnen cookies van derden worden ingesteld en gegevens van YouTube worden verzameld.

Correcte belichting

Je merkt dat de sneeuw ondanks de hoogtevariaties nog vlak oogt. Dat komt omdat we nog geen normaaldata aan de shader voeden.

Onze eerste poging was om de node “Normal from Height” op de heightmap toe te passen:

Gebruik de Normals from Height-node op de heightmap.
Gebruik de Normals from Height-node op de heightmap.

Het resultaat was niet overtuigend. Zelfs met een extra “Normal Strength”-node met bijvoorbeeld waarde 2 bleef de sneeuw plat.

Belichting na Normal from Height – weinig reliëf.
Belichting na Normal from Height – weinig reliëf.

Daarom berekenen we de normal zelf door naburige texels op de heightmap te meten. Hier alvast het eindresultaat ter vergelijking:

Belichting na handmatige normalberekening – veel beter!
Belichting na handmatige normalberekening – veel beter!

Om deze probes te doen moet de shader weten hoe “groot” een texel is in wereldruimte en in genormaliseerde coördinaten, dus afmeting van het mesh / resolutie van het mesh en 1 / resolutie.

We voegen twee nieuwe shaderparameters toe, HeightTexelUV en MetersPerTexel, met standaardwaarden die passen bij je mesh. Als je dezelfde Blender-parameters gebruikt als hierboven, zijn dat HeightTexelUV = 1 / 64 = 0.015625 en MetersPerTexel = 10 / 64 = 0.15625. We tellen HeightTexelUV bij de huidige u- of v-waarde, vragen het buurland op, kijken hoe de hoogte naar rechts en vooruit verandert, delen door meters-per-texel en krijgen zo echte hellingen. Van die twee hellingen maken we een normaalvector (−slopeX, 1, −slopeY) en normaliseren die. De mintekens komen door de richting van het kruisproduct, zodat de normaal tegengesteld helt aan de stijgende richting.

Handmatige normalberekening via texelprobes.
Handmatige normalberekening via texelprobes.

Je kunt het resultaat verder verfijnen door ook de tegengestelde richtingen te meten, maar dit werkt al erg goed.

Extra noise

Omdat we in VR werken, bekijkt de speler de sneeuw van dichtbij. Een extra ruislaag helpt daarbij. Die is alleen nodig in de buurt van de camera en fade out op afstand om visuele ruis te vermijden. Hier een close-up uit een Ho-Ho Hunt-build.

Een extra ruislaag die alleen dichtbij zichtbaar is.
Een extra ruislaag die alleen dichtbij zichtbaar is.

Deze extra noise is eigenlijk gewoon nog een noisemap, maar nu als normal map. De sample voedt de normalberekening die we net hebben opgebouwd, met een veel hogere resolutie dan de grote snow-noise van het begin. Heb je geen mooie noise normal? Dupliceer de bestaande noise, zet het type op Normal map en vink “Create from grayscale” aan om het effect te testen.

We voegen dus een Texture2D DetailNormalTex en een schaalparameter DetailScale toe, sampelen de waarde (denk eraan het type op “Normal” te zetten!) en sturen de sterkte aan op basis van de afstand tot de camera, zodat het effect netjes uitfadet:

De extra normal noise werkt alleen in de buurt van de camera.
De extra normal noise werkt alleen in de buurt van de camera.

De output van deze detailnoise en die van de heightmap-normalberekening worden gemengd via de Normal Blend-node. Het resultaat gaat naar de Normal-input van de fragmentfase.

Ik gebruikte DetailScale 200 en heb in de normal map-configuratie bumpiness 0.2 met smooth filtering aangezet. Dat geeft dit resultaat:

Detailnoise beïnvloedt alleen het gebied rondom de camera en fade out in de verte.
Detailnoise beïnvloedt alleen het gebied rondom de camera en fade out in de verte.

Zoals je ziet, ziet het voorbeeld dat we speciaal voor dit artikel bouwden er iets anders uit dan de versie uit de echte game. Dat toont hoe groot de impact van noise-textures en scale-parameters is – experimenteren blijft de moeite waard.

Glitters

We hebben geëxperimenteerd met een glinsterlaag om de fonkelende ijskristallen van winterlicht na te bootsen. Daarvoor heb je emissie nodig met óf gebakken licht óf bloom-postprocessing. Baked lighting paste niet bij het dynamische sneeuwveld, bloom kostte te veel performance, dus we hebben de glitters geskipt.

Extra opmerkingen

Het mooie aan deze aanpak is dat alles op de GPU gebeurt – de textures zijn er al, de shaders ook, en er is geen verkeer tussen GPU en CPU. Zelfs het pollen van de heightmap voor collision-updates draait asynchroon (AsyncGPUReadback.Request) en blokkeert niets. Perfect voor mobiele devices zoals Quest!

Dit is uiteraard alleen een startpunt voor simpele sneeuwmechanieken. Tijdens het experimenteren hebben we van alles geprobeerd en de kern van het schep- en renderingsysteem verder uitgebreid.

Wat kun je nog meer met compute shaders?

Onze volledige compute shader in Ho-Ho Hunt bevat een paar extra functies. Eén methode lijkt meer op het blad van een schep, een andere voert na bepaalde acties een blur uit om harde randen in het mesh te verzachten en een sneeuwdrift-effect te creëren. Een derde methode leest de heightmap asynchroon uit om een collision mesh te bouwen, maar alleen wanneer de sneeuw echt verandert.

Snelheidsafhankelijke sneeuwdepositie

We voegden ook sneeuwafzetting en particle based sneeuwspray toe, afhankelijk van de richting van de schep.

Video Voorvertoning

Als je op de afspeelknop klikt, wordt de YouTube-speler ingesloten en kunnen cookies van derden worden ingesteld en gegevens van YouTube worden verzameld.

Sneeuwbalmechaniek

Een leuke zijstap: met de compute shader-brush sneden we het spoor van een bewegende bol in de sneeuw. Je kunt de bol gemakkelijk laten groeien terwijl hij rolt, totdat hij onderdeel wordt van een sneeuwpop.

Video Voorvertoning

Als je op de afspeelknop klikt, wordt de YouTube-speler ingesloten en kunnen cookies van derden worden ingesteld en gegevens van YouTube worden verzameld.

Optimalisatie

We hebben de render target met standaardparameters aangemaakt, ook al gebruiken we maar één kanaal – we lezen alleen R uit. Je kunt bandbreedte besparen door het format daarop af te stemmen, of extra informatie toevoegen indien nodig. In onze implementatie hadden we ook een compaction map om sneeuw harder te laten aanvoelen als hij gecomprimeerd wordt, maar die hebben we uitgeschakeld om de gameplay toegankelijker te houden.

Het is verleidelijk om noise rechtstreeks met function nodes in Shader Graph te genereren. Dat is prima voor prototypes, maar vaak duurder dan texture lookups. Bovendien kunnen designers makkelijker een texture vervangen dan een graph rewires.

Let ook op precisie. Shader Graph staat standaard op “Single” precision. Wij hebben het naar “Half” gezet en alleen een specifiek subgraph op “Single” gehouden om bandbreedte te besparen.

Conclusie

Daarmee eindigt onze blik achter de schermen op Ho-Ho Hunt. Wil je Ho-Ho Hunt of een van onze andere experiences licentiëren, of heb je een eigen idee om te bespreken? Laat het ons weten!

Meer van de blog