ShaderLab Procedural Ocean in Unity URP: Vertex Displacement, Normal Reconstruction, and Texture-Free Foam Fresnel Shading

This Unity URP procedural ocean shader uses vertex displacement to generate waves, reconstructs normals in the fragment stage to recover fine detail, and layers Fresnel, foam, and fog to produce a cost-effective ocean surface without relying on textures. It addresses common issues such as heavy asset dependency, discontinuous detail, and weak surface quality across near and far viewing distances. Keywords: ShaderLab, URP, Procedural Ocean.

The technical specification snapshot outlines the shader at a glance

Parameter Value
Rendering Language ShaderLab + HLSL
Render Pipeline Unity URP
Lighting Path UniversalForward
Queue Type Geometry / Opaque
Shader Model target 3.0
Core Mechanisms Vertex Displacement, Normal Reconstruction, Fresnel, Foam, Fog
External Dependencies Core.hlsl, Lighting.hlsl
Article Popularity 273 views, 7 likes, 6 bookmarks

This ocean approach delivers its core value by replacing texture-driven detail with geometric deformation

The original approach does not focus on fullscreen post-processing. Instead, it operates directly on a planar mesh. In the vertex stage, it raises the mesh height to create large-scale waves. In the fragment stage, it samples the height field again and reconstructs finer normals through finite differences, recovering detail lost due to limited vertex density.

This design offers a clear structure, minimal dependencies, and strong controllability. Developers can achieve wave crests, reflections, specular highlights, and fog blending without requiring normal maps, flow maps, or foam textures.

The preview demonstrates large-scale wave motion and layered specular highlights

AI Visual Insight: The image shows an ocean surface generated from a planar mesh. You can see continuous large-scale wave crests, view-dependent bright reflections, and fog attenuation in the distance. This indicates that the shader combines vertex displacement, per-pixel normal reconstruction, and view-direction-based Fresnel blending rather than relying on a simple color gradient.

Material parameters define the ocean shape, color, and motion rhythm

The parameters fall into three groups: shape, lighting, and foam. Shape parameters include _WaveHeight, _WaveFrequency, _WaveSpeed, and _WaveChoppiness; lighting parameters include _DeepColor, _ShallowColor, _SpecColor0, _Smoothness, and _FresnelPower; foam parameters include _FoamThreshold, _FoamSoftness, and _FoamSteepness.

Among them, _SeaScale controls the overall scale and works well for quickly switching between a close-range water surface and a wide open sea. _NormalEpsilon and _NormalStrength directly affect normal sampling stability and the perceived granularity of the surface, making them the two most sensitive parameters during tuning.

float normalizedHeight = saturate(waveHeight / max(_WaveHeight * 2.4, 0.001)); // Normalize wave height and avoid division by zero
float crest = normalizedHeight + (1.0 - normalWS.y) * _FoamSteepness; // Combine height and steepness to detect crests
float foam = smoothstep(_FoamThreshold - _FoamSoftness,
                        _FoamThreshold + _FoamSoftness,
                        crest); // Generate a smooth foam mask

This code unifies wave height and surface steepness into a single foam classification rule.

The shader configuration shows that it targets the URP forward rendering path

The RenderPipeline=UniversalPipeline tag indicates that this shader is bound to URP. RenderType=Opaque and Queue=Geometry mean that it renders through the opaque geometry path, which suits most ocean scenarios.

Inside the Pass block, LightMode=UniversalForward enables forward rendering for the main light, while #pragma multi_compile_fog generates both fog and non-fog variants from the same code. Core.hlsl provides space transforms and fog functions, and Lighting.hlsl provides GetMainLight() and spherical harmonics ambient-light sampling.

Noise perturbation and layered octaves define the wave function

The ocean is not built from a single sine wave. Instead, it uses multiple layered octaves. Each octave changes frequency, amplitude, and sharpness, and rotates the sampling direction through the OCTAVE_M matrix to avoid overly regular patterns.

The hash21 function maps 2D coordinates to pseudo-random values, while noise2 produces smooth noise through four-corner interpolation. Then seaOctave combines noise with sine and cosine ridge shapes to generate local waveforms that look more natural.

float sampleWaveHeight(float2 uv, int iter)
{
    uv.x *= 0.75; // Compress the x direction to break symmetry
    float freq = _WaveFrequency;
    float amp = _WaveHeight;
    float choppy = _WaveChoppiness;
    float h = 0.0;
    float t = 1.0 + _Time.y * _WaveSpeed; // Advance ocean motion over time

    [unroll]
    for (int i = 0; i < iter; i++)
    {
        float d = seaOctave((uv + t) * freq, choppy); // Forward wave shape
        d += seaOctave((uv - t) * freq, choppy); // Reverse accumulation for added complexity
        h += d * amp; // Accumulate height for the current octave
        uv = mul(OCTAVE_M, uv); // Rotate and scale the sampling direction
        freq *= 1.9;
        amp *= 0.22;
        choppy = lerp(choppy, 1.0, 0.2);
    }
    return h;
}

This code builds the procedural ocean height field and serves as the geometric foundation of the entire effect.

The vertex stage handles low-cost shaping, while the fragment stage handles high-precision shading

The vertex shader performs only a limited number of wave-height samples to control per-frame geometry cost. It first transforms object-space vertices into world space, then samples ocean height using worldPos.xz and writes the result back to worldPos.y.

The fragment shader resamples a higher-precision height field and uses eps for finite differences: it calculates the heights at the current point, an x-offset point, and a z-offset point, then constructs the normal as (-dh/dx, 1, -dh/dz). This approach captures fine ripples more effectively than directly interpolating vertex normals.

float3 sampleWaveNormal(float2 xz)
{
    float eps = max(_NormalEpsilon, 0.001); // Prevent numerical instability from a too-small sampling step
    float h  = sampleWaveHeight(xz, FRAGMENT_ITER);
    float hx = sampleWaveHeight(xz + float2(eps, 0.0), FRAGMENT_ITER);
    float hz = sampleWaveHeight(xz + float2(0.0, eps), FRAGMENT_ITER);

    float dhdx = (hx - h) * _NormalStrength; // Approximate the derivative along x
    float dhdz = (hz - h) * _NormalStrength; // Approximate the derivative along z
    return normalize(float3(-dhdx, 1.0, -dhdz)); // Reconstruct the normal from height-field derivatives
}

This code reconstructs normals in real time from the height field, enabling lighting detail far beyond the underlying mesh density.

Ambient light, Fresnel, and foam blending define the final color composition

In the fragment stage, the shader first computes ambient, the main light direction lightDirWS, the view direction viewDirWS, and the half vector halfDirWS. Diffuse lighting establishes the base shading, specular highlights come from the Blinn-Phong model, and Fresnel interpolates between reflection and pseudo-refraction.

The base water color is not fixed. Instead, it blends between deep-water and shallow-water colors based on wave height and normal orientation. The shader then builds a reflection vector through reflect(-viewDirWS, normalWS) and feeds it into a custom sky-gradient function to produce a low-cost sky reflection.

This approach fits controllable ocean rendering in small to mid-sized projects

If your target is a mobile game, indie game, or educational demo, this texture-free procedural ocean approach offers an excellent cost-to-quality ratio. It reduces dependency on art assets and allows rapid iteration on wave rhythm, foam thresholds, and specular style through material parameters.

However, you should keep two trade-offs in mind. First, vertex displacement depends on the density of the base mesh; if the mesh is too sparse, large waveforms will break apart visibly. Second, repeatedly sampling the height field in the fragment stage is not cheap. If the ocean covers a very large area or you stack multiple water layers, you should reduce FRAGMENT_ITER or limit screen coverage appropriately.

Developer FAQ

Q1: Why do both the vertex stage and the fragment stage sample wave height?

A: The vertex stage handles geometric displacement, while the fragment stage handles high-precision normals and foam classification. They serve different purposes, so resampling is necessary to preserve detail under limited mesh density.

Q2: Why can this approach work without a normal map?

A: Because the normal is derived directly from finite differences on the procedural height field. As long as the height function remains continuous, the shader can reconstruct normals in real time and keep them strictly consistent with the wave shape.

Q3: How can I optimize performance while preserving visual quality as much as possible?

A: Start by reducing fragment iteration count, controlling the ocean’s screen coverage, and moderately shrinking mesh area. Next, keep vertex displacement but simplify foam and high-frequency normal detail to achieve a more stable frame rate.

The core summary reconstructs a Unity URP procedural ocean shader pipeline

This article rebuilds a procedural ocean shader for Unity URP. It combines wave displacement in the vertex stage, normal reconstruction in the fragment stage, Fresnel reflection, specular highlights, foam, and fog to create a dynamic texture-free ocean surface. It also explains the key parameters, noise functions, and rendering pipeline behind the effect.