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.

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.

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.

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.

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:

(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.

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.

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.

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:

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

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

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.

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.

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 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:

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.

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.

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!

