[id="C4276659642"] .page-layout { align-items: center; } [id="C4276659642"] .page-layout { align-items: center; }




Raymarched Sand ◍

HLSL Shader

For my freelance work with Gnarly Casual, I was asked to build a platformer prototype around their mascot, Sunkai. The core gameplay came together quickly, but the sand terrain went through a lot of visual iteration. Small changes to the shape or flow of the ground kept sending us back into Maya, which slowed iteration during early design.

To speed this up, I built a procedural raymarching sand system that lets the team shape terrain directly in Unity using simple sphere primitives. Designers could move, scale, or reorder spheres in the scene and instantly get smooth, dune-like ground without re-exporting meshes.

The goal was not fancy rendering. It was faster iteration.

To view/hide full shader code expand/collapse this block
Raymarching Metaball Sand Shader (Unity / URP)

Shader "Custom/MeatballsRaymarchedSand"
{
    Properties
    {
        [Header(SDF Metaballs)]
        [IntRange]_SphereCount("Sphere Count", Range(1,32)) = 20
        _BlendK("Blend K (smooth union)", Float) = 0.25
        _MaxSteps("Raymarch Max Steps", Range(4,256)) = 128
        _MaxDist("Raymarch Max Dist", Float) = 50
        _Epsilon("Surface Epsilon", Float) = 0.001

        [Header(Optional Bounds (World Space))]
        _BoundsMin("Bounds Min (WS)", Vector) = (-1000,-1000,-1000,0)
        _BoundsMax("Bounds Max (WS)", Vector) = ( 1000, 1000, 1000,0)

        [Header(Triplanar Mapping)]
        _Tiling("World Tiling", Float) = 1
        _TriplanarSharpness("Triplanar Sharpness", Range(1,12)) = 4

        [Header(Material Maps)]
        _BaseColor("Base Color Tint", Color) = (1,1,1,1)
        _BaseMap("Base (Albedo)", 2D) = "white" {}

        _NormalScale("Normal Scale", Range(0,2)) = 1
        _NormalMap("Normal", 2D) = "bump" {}

        [Toggle(_USE_AO)] _UseAO("Enable AO Map", Float) = 0
        _AO("AO (fallback)", Range(0,1)) = 1
        _AOMap("AO (R)", 2D) = "white" {}

        [Toggle(_USE_HEIGHT)] _UseHeight("Enable Height (Parallax)", Float) = 0
        _Parallax("Height Strength", Range(0,0.1)) = 0.02
        _HeightMap("Height (R)", 2D) = "black" {}

        [Header(PBR Lite Controls)]
        _Smoothness("Smoothness", Range(0,1)) = 0.5
    }

    SubShader
    {
        Tags { "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" "RenderType"="Opaque" }

        Pass
        {
            Name "UniversalForward"
            Tags { "LightMode"="UniversalForward" }

            ZWrite On
            ZTest LEqual
            Cull Back

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 3.0

            #pragma shader_feature_local _USE_AO
            #pragma shader_feature_local _USE_HEIGHT

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

            #define MAX_SPHERES 32

            CBUFFER_START(UnityPerMaterial)
                int    _SphereCount;
                float  _BlendK;
                float  _MaxSteps;
                float  _MaxDist;
                float  _Epsilon;

                float4 _BoundsMin;
                float4 _BoundsMax;

                float  _Tiling;
                float  _TriplanarSharpness;

                float4 _BaseColor;

                float  _NormalScale;
                float  _Smoothness;

                float  _AO;
                float  _Parallax;

                float4 _Spheres[MAX_SPHERES];
            CBUFFER_END

            TEXTURE2D(_BaseMap);   SAMPLER(sampler_BaseMap);
            TEXTURE2D(_NormalMap); SAMPLER(sampler_NormalMap);

            #if defined(_USE_AO)
            TEXTURE2D(_AOMap);     SAMPLER(sampler_AOMap);
            #endif

            #if defined(_USE_HEIGHT)
            TEXTURE2D(_HeightMap); SAMPLER(sampler_HeightMap);
            #endif

            struct Attributes
            {
                float4 positionOS : POSITION;
            };

            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float3 positionWS : TEXCOORD0;
            };

            float sdSphere(float3 p, float3 c, float r)
            {
                return length(p - c) - r;
            }

            float smin_poly(float a, float b, float k)
            {
                float kk = max(k, 1e-6);
                float h = saturate(0.5 + 0.5 * (b - a) / kk);
                return lerp(b, a, h) - kk * h * (1.0 - h);
            }

            float mapSDF(float3 p)
            {
                int count = clamp(_SphereCount, 1, MAX_SPHERES);

                float4 s0 = _Spheres[0];
                float d = sdSphere(p, s0.xyz, s0.w);

                float k = max(_BlendK, 1e-6);

                [loop]
                for (int i = 1; i < count; i++)
                {
                    float4 s = _Spheres[i];
                    float di = sdSphere(p, s.xyz, s.w);

                    if (abs(d - di) > k)
                        d = min(d, di);
                    else
                        d = smin_poly(d, di, k);
                }

                return d;
            }

            float3 calcSDFNormal(float3 p)
            {
                float e = max(_Epsilon * 4.0, 1e-4);
                float3 k1 = float3( 1, -1, -1);
                float3 k2 = float3(-1, -1,  1);
                float3 k3 = float3(-1,  1, -1);
                float3 k4 = float3( 1,  1,  1);

                float d1 = mapSDF(p + k1 * e);
                float d2 = mapSDF(p + k2 * e);
                float d3 = mapSDF(p + k3 * e);
                float d4 = mapSDF(p + k4 * e);

                return normalize(k1 * d1 + k2 * d2 + k3 * d3 + k4 * d4);
            }

            bool RayAABB(float3 ro, float3 rd, float3 bmin, float3 bmax, out float tEnter, out float tExit)
            {
                float3 inv = 1.0 / max(abs(rd), 1e-8) * sign(rd);

                float3 t0 = (bmin - ro) * inv;
                float3 t1 = (bmax - ro) * inv;

                float3 tmin3 = min(t0, t1);
                float3 tmax3 = max(t0, t1);

                tEnter = max(tmin3.x, max(tmin3.y, tmin3.z));
                tExit  = min(tmax3.x, min(tmax3.y, tmax3.z));

                return (tExit >= tEnter);
            }

            bool RaymarchBounded(float3 ro, float3 rd, float tStart, float tEnd, out float3 pHitWS)
            {
                float t = max(tStart, 0.0);

                [loop]
                for (int i = 0; i < (int)_MaxSteps; i++)
                {
                    if (t > tEnd || t > _MaxDist) break;

                    float3 p = ro + rd * t;
                    float d = mapSDF(p);

                    if (d < _Epsilon)
                    {
                        pHitWS = p;
                        return true;
                    }

                    t += max(d, _Epsilon * 0.5);
                }

                pHitWS = 0;
                return false;
            }

            float3 TriWeights(float3 n)
            {
                n = abs(n);
                float s = max(_TriplanarSharpness, 1.0);
                float3 w = pow(n, s);
                return w / (w.x + w.y + w.z + 1e-6);
            }

            float2 UV_X(float3 pWS) { return pWS.zy * _Tiling; }
            float2 UV_Y(float3 pWS) { return pWS.xz * _Tiling; }
            float2 UV_Z(float3 pWS) { return pWS.xy * _Tiling; }

            float2 ParallaxOffset(float2 uv, float3 T, float3 B, float3 N, float3 viewDirWS, float height01)
            {
                float ndotv = abs(dot(N, viewDirWS)) + 1e-4;
                float2 vPlane = float2(dot(viewDirWS, T), dot(viewDirWS, B)) / ndotv;
                float h = (height01 - 0.5) * _Parallax;
                return uv + vPlane * h;
            }

            struct TriData
            {
                float3 w;
                float2 uvx, uvy, uvz;

                float3 Nx, Tx, Bx;
                float3 Ny, Ty, By;
                float3 Nz, Tz, Bz;
            };

            TriData TriSetup(float3 pWS, float3 nWS, float3 viewDirWS)
            {
                TriData t;
                t.w = TriWeights(nWS);

                float sx = (nWS.x >= 0) ? 1.0 : -1.0;
                float sy = (nWS.y >= 0) ? 1.0 : -1.0;
                float sz = (nWS.z >= 0) ? 1.0 : -1.0;

                t.Nx = float3(sx, 0, 0);
                t.Tx = float3(0, 0, sx);
                t.Bx = float3(0, 1, 0);

                t.Ny = float3(0, sy, 0);
                t.Ty = float3(1, 0, 0);
                t.By = float3(0, 0, sy);

                t.Nz = float3(0, 0, sz);
                t.Tz = float3(1, 0, 0);
                t.Bz = float3(0, sz, 0);

                t.uvx = UV_X(pWS);
                t.uvy = UV_Y(pWS);
                t.uvz = UV_Z(pWS);

                #if defined(_USE_HEIGHT)
                if (_Parallax > 0.0)
                {
                    float hx = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, t.uvx).r;
                    float hy = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, t.uvy).r;
                    float hz = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, t.uvz).r;

                    t.uvx = ParallaxOffset(t.uvx, t.Tx, t.Bx, t.Nx, viewDirWS, hx);
                    t.uvy = ParallaxOffset(t.uvy, t.Ty, t.By, t.Ny, viewDirWS, hy);
                    t.uvz = ParallaxOffset(t.uvz, t.Tz, t.Bz, t.Nz, viewDirWS, hz);
                }
                #endif

                return t;
            }

            float3 TriSampleAlbedo(TriData t)
            {
                float3 x = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, t.uvx).rgb;
                float3 y = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, t.uvy).rgb;
                float3 z = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, t.uvz).rgb;
                return x * t.w.x + y * t.w.y + z * t.w.z;
            }

            float TriSampleAO(TriData t)
            {
                #if defined(_USE_AO)
                float ax = SAMPLE_TEXTURE2D(_AOMap, sampler_AOMap, t.uvx).r;
                float ay = SAMPLE_TEXTURE2D(_AOMap, sampler_AOMap, t.uvy).r;
                float az = SAMPLE_TEXTURE2D(_AOMap, sampler_AOMap, t.uvz).r;
                float a = ax * t.w.x + ay * t.w.y + az * t.w.z;
                return saturate(a * _AO);
                #else
                return 1.0;
                #endif
            }

            float3 TriSampleNormalWS(TriData t)
            {
                float3 nTSx = UnpackNormalScale(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, t.uvx), _NormalScale);
                float3 nTSy = UnpackNormalScale(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, t.uvy), _NormalScale);
                float3 nTSz = UnpackNormalScale(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, t.uvz), _NormalScale);

                float3 nWx = normalize(t.Tx * nTSx.x + t.Bx * nTSx.y + t.Nx * nTSx.z);
                float3 nWy = normalize(t.Ty * nTSy.x + t.By * nTSy.y + t.Ny * nTSy.z);
                float3 nWz = normalize(t.Tz * nTSz.x + t.Bz * nTSz.y + t.Nz * nTSz.z);

                return normalize(nWx * t.w.x + nWy * t.w.y + nWz * t.w.z);
            }

            Varyings vert(Attributes IN)
            {
                Varyings OUT;
                float3 posWS = TransformObjectToWorld(IN.positionOS.xyz);
                OUT.positionWS = posWS;
                OUT.positionCS = TransformWorldToHClip(posWS);
                return OUT;
            }

            struct FragOut
            {
                float4 color : SV_Target;
                float  depth : SV_Depth;
            };

            FragOut frag(Varyings IN)
            {
                FragOut OUT;

                float3 camPosWS = GetCameraPositionWS();

                float3 viewForwardWS = normalize(mul((float3x3)UNITY_MATRIX_I_V, float3(0, 0, -1)));
                float3 rayDirWS = viewForwardWS;

                float3 rayOriginWS = IN.positionWS - rayDirWS * (_MaxDist * 0.5);

                float tEnter, tExit;
                if (!RayAABB(rayOriginWS, rayDirWS, _BoundsMin.xyz, _BoundsMax.xyz, tEnter, tExit))
                    discard;

                tEnter = max(tEnter, 0.0);
                tExit  = min(tExit, _MaxDist);

                float3 pHitWS;
                if (!RaymarchBounded(rayOriginWS, rayDirWS, tEnter, tExit, pHitWS))
                    discard;

                float3 nSDF = calcSDFNormal(pHitWS);
                float3 V = normalize(camPosWS - pHitWS);

                TriData tri = TriSetup(pHitWS, nSDF, V);

                float3 albedo = TriSampleAlbedo(tri) * _BaseColor.rgb;
                float ao = TriSampleAO(tri);

                float3 nMapWS = TriSampleNormalWS(tri);
                float normalBlend = saturate(_NormalScale);
                float3 N = normalize(lerp(nSDF, nMapWS, normalBlend));

                InputData inputData;
                ZERO_INITIALIZE(InputData, inputData);
                inputData.positionWS = pHitWS;
                inputData.normalWS = N;
                inputData.viewDirectionWS = V;
                inputData.shadowCoord = TransformWorldToShadowCoord(pHitWS);
                inputData.fogCoord = 0;

                SurfaceData surfaceData;
                ZERO_INITIALIZE(SurfaceData, surfaceData);
                surfaceData.albedo = albedo;

                surfaceData.metallic = 0.0;
                surfaceData.specular = half3(0.04, 0.04, 0.04);
                surfaceData.smoothness = saturate(_Smoothness);
                surfaceData.occlusion = ao;
                surfaceData.emission = 0.0;
                surfaceData.alpha = 1.0;

                half4 col = UniversalFragmentPBR(inputData, surfaceData);

                OUT.color = float4(col.rgb, 1.0);

                float4 hitCS = TransformWorldToHClip(pHitWS);
                OUT.depth = hitCS.z / hitCS.w;

                return OUT;
            }
            ENDHLSL
        }
    }

    FallBack Off
}

  

What I Built

Metaball Terrain (SDF + Smooth Blending)

The ground is defined as a signed distance field made from multiple spheres blended together. Each sphere works like a control point, and the smooth union creates continuous, soft sand silhouettes that can be edited live.

This made it easy to:
  • block out platforms quickly
  • adjust curvature and softness on the fly
  • explore shapes that would be annoying to model by hand

Performance-Aware Raymarching

Raymarching is expensive, so I treated it like a gameplay feature, not a tech demo:

  • rays are bounded to the terrain region so pixels outside the blob exit early

  • blending math is skipped when spheres do not meaningfully overlap

Triplanar Texturing and Procedural Normals

Because the surface is generated procedurally, it uses world-space triplanar mapping. That avoids UV authoring and prevents texture stretching as the sand shape changes. Normals are derived from the distance field so lighting stays smooth across blended regions.

Optional AO and height detail can be enabled per material depending on performance needs.

How the System is Driven in Unity


To make this usable for designers, I wrote a Unity driver component that uploads sphere data into the shader at edit time and runtime.

What the driver does:
  • collects up to 32 sphere transforms as control points
  • computes world-space centers and radii
  • caches the arrays so values persist through domain reloads and inspector edits
  • pushes the sphere arrays into the shader so the terrain updates immediately in-engine
  • includes a simple utility to space spheres evenly along X for fast layout

This is what turned the shader into a practical tool for iteration rather than a one-off effect.

Why This Was Worth Doing


This approach removed a lot of friction from early environment iteration. Instead of locking down geometry too early, the team could keep experimenting with terrain feel while staying inside the engine.

What I’d Improve Next


If this were pushed further:
  • switch per-object uploads to MaterialPropertyBlock and only upload when control points actually change
  • add chunking or LOD so distant terrain stops raymarching entirely
  • render the sand at half resolution and composite it back for a large performance win
  • once the shape is stable, generate a mesh from the SDF (marching cubes or similar) and render it as normal geometry




thanks for visiting
made with 🩷and ☕ on a beautiful June afternoon🌻