DERIVED FROM THE MASTERS
The gerstner waves are procedural, but if you scale them down too much, a repetitive pattern becomes very apparent. Instead of adding more expensive gerstner wave clusters to create the low level detail, I followed Naughty Dog’s suite and added procedural wave particles. In his GDC session Carlos Gonzalez Ochoa sourced a 2007 Siggraph presentation on Wave Particles.
Cem Yuskel’s Siggraph dissertation discussed spawning N number of Wave Particles from point sources ( such as objects falling into the water ). However, Carlos at Naughty Dog vied for just a random distribution of N wave particles ( at initialization ) to create ambient displacement. And so, I followed suite. Although, I will discuss later our implementation of Oculus Motion Controller interaction with the water surface ( to create ripples and waves ).
CPU AND GPU
The brunt of the work is generating the Wave Particle onto a RenderTexture. That’s all handled in the OceanManagement class in C++. The Material on the GPU side samples the RenderTexture for the offset and generates the Normal Map using derivative screenspace. Since Wave Particles are only portion of the ocean puzzle, the RenderTexture and “particle” count needs to be kept rather low.
For our Unreal Ocean, I’m updating N number of Wave Particles every frame and writing them onto a TextureRenderTarget2D. The RenderTexture ends up looking like an undulating heightmap. The Ocean Material reads from the RenderTexture and adds it value to the World Position Offset and Normal Output of the material. Each Wave Particle only requires two variables; its XY position and XY velocity. So, in code, I’m storing all Wave Particles into a TArray.
RANDOM AND REPETITION
The Wave Particle simulation is completely deterministic; a particle’s position is 100% knowable at any point in time because the velocity is constant. If the randomized values are seeded the same, then the Wave Particle simulation will always turn out the same. Now, in the Siggraph paper, they were using upwards of 10,000 Wave Particles to generate “true to life” ocean waves….that’s a tad bit over Naughty Dog and Sanzaru rendering budget! Keeping the count below 1K still results in some pretty dynamic and varied looking small-detail waves.
As said before, we’re using Wave Particles instead of more Gerstner Waves because scaling down for small surface details would make the repeating pattern too noticeable…unless dozens more Gerstner Waves were used. As for Wave Particle repetition, there’s too much chaos to find a pattern with 800 Wave Particles, at 1/16th the size of the Texture.
Wave repetition is based mostly on the canvas size that the Ocean Material is sampling the Render Texture. Even repeating every 500 Units ( 5 meters ), its hard to see any “checkerboarding” with the Wave Particles, unlike the scaled down Gersnter Waves.
In C++, IntializeWaveParticles() is called at BeginPlay(). First, It creates and initializes the TextureRenderTarget2D; setting its size, clear color, and to grayscale compression. Second, it creates a TextureRenderTarget2DResource and Fcanvas; this is what will actually be “drawn” on. Lastly, the WaveParticleList TArray will be initialized, foreach item in the Array, a random start pos from ( 1 to TextureSize ) will be chosen. For the particles’ velocity, I wanted to ensure an even dispersal from all directions. So, the the first direction is randomly decided, and the following Wave Particles always increment between 2 and 5 degrees clockwise.
Both UpdateWaveParticles() and DrawWaveParticles() happen successively during OceanManagement’s Tick(). The Update function is only for incrementing the position of each Wave Particle. In the for loop, the current particle’s new position is added to by a vector ( direction * speed * deltaTime ). There’s four conditional statements checking if current particle has passed bounds of Canvas. If so, its current position is either subtracted or added by the Texture Size so that it wraps around.
Once again, the WaveParticlesList TArray is put through a for loop, each particle is drawn on the canvas as a TileItem. The Tile Item is set to Additive so that Particle Tile pixels drawn on top of each other will become brighter. The Particle Tile Alpha is user-driven, but set to a very low number. By default, its set to 0.05 so that it will take 20 Particle Tile overlaps to reach max intensity ( height ). At the end, the Canvas is flushed so the Rendering Thread will know to draw the batched elements.
So there’s a really obtrusive, and amauter-looking wall of code in the Draw function…eight conditional statements ( each looking very similar ). This is for addressing the texture wrapping issue. When a Particle is reaching the bounds of the canvas, it has the potential to be four places at once ( if it hits a corner )!! Therefore, the eight conditional statements address all cardinal and intermediate directions. I wish I could make the wrapping code cleaner, but brute force is the only real solution for this matter.
The OceanManagement Blueprint points the Wave Particle Render Texture to a Texture Object that the Ocean Material is using. I created a Wave Particle Function Material that outputs both the Displacement and Normal value. To get more bang for the buck; the function material samples the Render Texture three times, at 33%, 66%, and 100% the intended canvas size. This will further add Wave Particle detail and a fraction of the cost.
WAVE PARTICLE NORMALS
The wave particle Normal Map derives from it’s height map. There’s several ways to do this, and I tried two within the Material Editor. First, I tried to the partial difference derivative functions DDX and DDY. However, because they rely on screen-space coordinates, artifacts were popping up with extreme view angles from the camera to the water surface. These artifacts become especially severe in VR. So, I went with Unreal’s built in function material called “Normal From HeightMap.”
This approach produces the perfect result, but much more expensive. The function material simply uses arithmetic and inexpensive Dot and Cross Products. It becomes expensive because it needs to sample the heightmap three times ( for each axis ). In the end, the Wave Particle normal is added to the Gerstner and Texture Normals, but only the Red and Green channels.