Steve here with a quick foreword: I’ve got a lot to cover in this article as I’ve been neglecting writing anything down for it until now. This entry won’t cover absolutely everything, as it got quite long just getting through the early development phase. I hope you enjoy this relatively little stream-of-consciousness look into my mind during the development of this shader!
Back to the Sea
As some of you reading this may remember from my previous works (and on my Patreon banner if you’re reading this there), I have something of a Gerstner Waves based Ocean Shader made for VRChat, namely the Stiletto RP and Actium Knights projects. This shader has several shortcomings alongside being just generally unfinished. Namely, in these key areas:
- Tileability: The old shader requires that ocean tiles be marked as Static Batched, as the vertex math is bugged in a way that the worldspace vertices aren’t calculated entirely correctly, and leaving the tiles dynamic made the seams between tiles “pull away” from neighbors. Static Batching only fixed this because all static batch meshes seem to have their origins set to 0,0,0, eliminating any offsets perceived by the faulty math.
- Relies on Vertex Normal Reconstruction: The old shader generated displaced surface normals by sampling several points nearby the vertex and recalculating the entire Gerstner loop at these points to calculate the “slope” of the vertex. For each vertex. This is, of course, massively inefficient.
- Hard to Configure: The old shader was very easy to end up with a “corrupted” look, as every wave was individually, and manually, configured. This, and the previous point above, meant that only a limited number of Gerstner Waves were supported, and any sort of object scaling threw off the wave settings and generated lots of crazy loops.
- No Shoreline Interaction: The wave settings are the wave settings regardless of what’s in their way. This means that scenes with shorelines need to have the entire ocean tuned to deal with it, lest the waves clip through the terrain in odd ways. No foam, no breakers, no shoals.
- No Underwater Effects: For the version presented in the ATK episode Beach, Beers, and Battletech, there was no underwater fog and little in the way of underside surface rendering. In fact, because of time constraints, I ended up “borrowing” REDSIM’s underwater effects, modifying the fog to take Day Lighting into account, and setting up a quick script to turn the fog on/off depending on if the camera was under the waterline or not. It was shaky, but it worked.
The Plan
For quite a while now, I’ve been kicking around some ideas that would require all of the above things to be fixed. Recently, though, I’ve been inspired by the little river outside my mom’s house, staring at it and watching how the water bounces along the rocks just below the surface. How the rapids in Spring form standing waves amongst the turbulence. And how the bubbles churned up occasionally from colliding perturbations ride these waves like a roller coaster. Another inspiration I’ve had is just the general vibe of the Pacific Northwest coastline, with its long beaches, big rock spires, and comfortably gloomy atmosphere.
With this itch to make something new, and the fact that the old shader is… well frankly it’s a mess built on an antiquated system… I decided to start fresh and clean.
So, what does my new version of the shader aim to do?
- Support URP: The previous shader version was built on the Built-In Renderer because of its use in VRChat. This new one should be built on more modern Scriptable Render Pipelines, targeting Universal first. Hopefully I’ll be able to backport most of this to Built-In at a later phase.
- Fix ALL the Math: Two of the major issues above, namely Tileability and Vertex Normal Reconstruction, are majorly issues with my math. This is fixable by being smarter than I currently am.
- Support LOTS of Gerstner Waves: The previous version of the shader supported up to 8 Gerstner iterations due to the aforementioned inefficiency. This version should support up to 128 iterations. And in this same vein, the number of supported waves should be configurable, so as to allow for quick graphics settings tweaking.
- Physically Based Waves: The previous version was configured by raw numbers and hoping-for-the-best. This version should calculate waves in a more physical way by taking gravity, shoal slopes, and water depth into account.
- Flow Mapped Gerstner Waves: The one that’s probably going to be the biggest challenge. I want this version of the shader to also support other bodies of water, like rivers and lakes. The problem? Rivers bend, and Gerstner Waves do not. Also, normal Flow Mapping techniques rely on sampling textures, which cannot be done in the Vertex shader.
- Optionalize Surface Normals: The previous version’s lack of efficiency meant that most of the surface detailing was done by a pre-baked normal map. With the increased Gerstner Wave count, these surface details should be able to fill in all the required detailing physically. However, the option should still be available for optimization efforts.
- Streamline Gerstner Setup: The previous version’s wave settings were all done by raw numbers editing. This was slow with 8, and it’s going to be even worse with 128. The new version should allow for the user to quickly generate wave settings by use of a randomizer and an Editor UI.
Getting Started
So, the most present hurdle when getting this new shader started was, of course, learning the difference between Built-In and the Universal Render Pipeline’s shader programming. Majorly, the old ocean shader used the Surface shader paradigm, which does not compile in URP. Because of this, all the native rendering features like managed PBR inputs, Tessellation, and Lighting had to now be done manually. Just as well, the Built-In Renderer’s Shaders used the CG shader language, whereas anything using the Scriptable Render Pipelines uses HLSL. Luckily, both languages are rather C-shaped, so the transition shouldn’t be too difficult.
For the first iterations of this shader, I decided to purely focus on getting the basic geometry working and save the lighting for later. Instead, I wanted to first focus on getting Tessellation working again. As mentioned, Tessellation used to be handled by the Surface shader stuff by adding a couple includes and a driver function to make it go (the previous shader mostly just used the framework auto-generated by Amplify Shader Editor for Tessellation).
Luckily, though, there’s not too awfully much to talk about on the Tessellation front, as I found a really good resource talking about how to do just this in URP: Basic Tessellation setup in URP
As you might see in the above link, Tessellation in this method is done by adding some “Domain” and “Hull” functions which step in front of the normal Vertex shader to build the generated mesh. Then, these added functions just call the Vertex program in the return. One of the main non-feature things I wanted to do for this shader is to keep it MUCH cleaner, and in this regard, I separated all of the Tessellation stuff into its own include header file (“Tessellation.hlsl”).
This, though, required some odd finagling with macros to get it to work. As seen in the below image, it looks kinda odd. Note how the Vertex program is now TessellationVertexProgram rather than vert, and vert is defined as the target of TESSELLATION_VERTEX_FUNC. This abstracts out the structure of this method of adding Tessellation so I can just drop it in or, in the case of debugging, just comment out a couple lines to get rid of the feature. I think I can clean this up a bit better, but I’m going to save that for the polish pass nearer to the end.

Go Go Gadget Gerstner
The obvious next step to making the new Ocean shader was to, of course, make the waves. As I’ve mentioned a couple times in this article, I am using the Gerstner Waves algorithm to do my mesh displacements. To make a rather long story short: instead of just displacing the Ocean mesh by a Sine wave, which would make the vertices of the Ocean go up and down, Gerstner instead solves for a circle (Sine and Cosine) with different settings and some extra math to make waves “pinch” and “separate” as they animate. Then, it just layers a number of iterations of these circles on top of one another to make the Ocean look nice and turbulent. For more details on this, see the link I’m about to post later in this section.
Previously, I was using a guide on how to make Gerstner Waves work in Amplify Shader Editor and then modifying and cleaning up the code it generated. This is what lead to the need for the multi-sampled Vertex Normal Reconstruction, as the ASE method didn’t allow for complex in-out variable inputs, amongst other things, and I wasn’t smart enough at the time to work around it.
This time around, I am using the article from Catlike Coding as a basis for my shader. Most notably, this version does three things that mine did not:
- It factors in “gravity” into the wave equation in order to make waves behave more realistically based on its inputs, which is something that I wanted for my random generator.
- It uses Partial Derivatives in order to figure out the slope of the ocean surface, rather than multi-sampling the Gerstner loop.
- It simplifies the Wave Parameters to just Direction, Steepness, and Wavelength. The previous version of my shader used Direction, Steepness, Amplitude, Wavelength, and Speed. This simplification is gained by making Amplitude and Speed into functions of Wavelength and Gravity, reducing the number of inputs substantially.
I also integrated in some odd-looking “Wave Resolution” pragmas, allowing for the shader to be compiled with 4, 8, 32, 64, and 128 Gerstner iterations. For reference, and if what I had read was correct, Sea of Thieves uses this same Gerstner method for its oceans at about 64 iterations. In order to support this without adding in branching logic in the Gerstner loop (which GPUs are classically slow at processing), the loop header now is behind a cascading preprocessor directive and looks quite weird:

Other than that, the basic Gerstner implementation itself wasn’t too much to write home about. But it did come with some caveats. Namely…
Eldritch Gerstner Nonsense (Randomized Wave Generation)
… When one or more Gerstner Waves exceed 1.0 Steepness in a location, they curl over and become loops. And when you randomly generate the settings of 128 Waves, all of which having the ability to go up to 1.0 Steepness, you end up summoning Cthulu.
The answer to this is actually quite simple: add a global Steepness divisor to all waves during generation.
My first attempt for this was to divide all waves by the currently selected wave resolution. This… sorta worked for low wave counts, but when the wave resolution was set to the top 128 Waves setting, this divisor ended up setting all the waves to nearly 0.0 Steepness, and the ocean became really flat.
After a bunch of experimentation, I ended up just going with a flat, configurable divisor variable, which keeps things nice and uniform. When integrating this on in, the ocean surface now looks actually coherent.
Controlling the Randomness
At this point, it was time to start messing around with making something of a randomizer for the wave settings. I had a couple goals for this:
- The randomizer should be deterministic. I wanted randomizer settings to always generate the same results with the same inputs.
- The randomizer should prioritize big waves earlier on. As the Wave Resolution basically just loads from the top down, detail is lost as the resolution drops. The bigger the wave, the more noticeable it is when dropped, and therefore lower resolutions should be made up of the bigger waves.
For the first point, the need wasn’t a huge deal as this is mainly for the Editor, and I didn’t plan anything for the game state. However, as all waves and wave settings are generated in the same order each time, a simple seeded random generator was good enough for this. Saving the Seed out to the Randomizer Settings allowed for regeneration to make the same structure each time.
The second goal took a bit more effort. One of the first successful things I tried was to setup a “Steepness Budget” based on the Global Steepness Divisor I mentioned previously. This… sorta worked. However, by the time it got down to Wave #100 and beyond, the Steepness values were so small that the waves were basically non-existent.
I went through a few more iterations that didn’t quite do what I wanted, but the one that had the most promise was controlling Steepness and Wavelength biases with an AnimationCurve. Using a curve let me design the “shape” of the Wave Params array. However, it wasn’t quite there. Eventually, I learned that one can just completely yoink the Random Between Two Curves data type from the Particle System’s editor and use it for your own purposes. This type is called ParticleSystem.MinMaxCurve, and comes with the mode switch between Constant, Random Between Two Constants, and Random Between Two Curves. The one I wanted was that last one, and it turns out, it’s just two AnimationCurves stacked on top of each other, which isn’t amazingly terrible to define in code:

Now, instead of calling AnimationCurve.Evaluate(position), you just add in another value to determine the value between the min and max curves, which I just plugged in another call to the random number generator as MinMaxCurve.Evaluate(position, Random.value). Now all that was needed was to build some curves that prioritized larger wavelengths towards the start of the curve, and smaller ones near the end.
From there, it was basically just adding in more MinMaxCurves for the other values, like Wavelength and Direction. The latter, Directionality, is a little bit more nuanced as it defines how far away from 0.0 the direction can randomize on a given Wave. A Directionality of 0.0 means it can be anywhere from 0-360 degrees, and 1.0 can only be 0.0. Anything in between would “constrain” the possible offsets by that amount.
Putting it All Together
After all of the above is thrown together, you end up with something that looks a bit like this:
As you can see, the main structure is pretty well able to generate on its own without much input, and the Randomizer is doing a decent job at coming up with wave patterns. There is still, however, an obviously long way to go on getting this done.
There’s also quite a bit that I didn’t cover here due to needing to cut down this article a bit to not turn into an absolute novella. You may note the odd black hole near the edge of the water tiles. This is an early attempt to get Water Depth factored into the Gerstner equations, though it still needs a lot of help with the math to make it work right. I’ll be going into this more in a later article. For now, though, just know that it is a simple faked “Depth” going from 1.0 to 0.0 as the vertices get nearer to 0,0,0.
By the way, if you follow my accounts on social media, you’ll probably have seen that I’m quite a bit further along in this shader than this article got to. This is, again, due to the absolute mass of this article so far. I will be catching up to where I actually am as I write the next article.
So, stay tuned for the next episode where we turn the ocean into milk and summon THE ORB.