RenderPipelineManager.beginCameraRendering += RenderSurface;
And fetch a command buffer using CommandBufferPool.Get():
void RenderSurface(ScriptableRenderContext context, Camera camera)
{
CommandBuffer commandBuffer = CommandBufferPool.Get("Water CMD");
UpdateSurface(waterSurface, commandBuffer, context, camera);
context.ExecuteCommandBuffer(commandBuffer);
context.Submit();
commandBuffer.Clear();
CommandBufferPool.Release(commandBuffer);
}
The UpdateSurface() is used to queue compute shaders and Scriptable Render Passes; including the FFT, caustics, surface passes and underwater post processing.
You might have heard of the Fast Fourier Transform before, it has been used in dozens of games and movies after it was proposed in the 2004 paper Simulating Ocean Water by Jerry Tessendorf.
I learned of it from a source I am much more familiar with; a 2023 Youtube video by Acerola, a graphics programmer entertainer; I Tried Simulating The Entire Ocean.
What must be understood before understanding the Fourier Transform is that any periodic waveform can be identically reproduced with a sum of sinusoids.
The purpose of Fourier Transform, then, is to take a waveform and decompose it into that sum of sinusoids.
The output is now in the "frequency domain" which, as opposed to describing how amplitude at a given time (time domain), describes the amplitude of a wave at a given frequency.
For example, for a wave of frequency 30hz we see a spike in the frequency domain at 30hz.
The size of the spike is proportional to it's amplitude, as we see in the figure below.
These wave spectra can generate tens of thousands of frequencies, at a resolution of 256x256, we need to perform the IFT over 65,000 times.
This is the 'fast' part of the Fast Fourier Transform.
The FFT uses a 'Butterfly Algorithm' to recursively split the input data in half which avoids unnecessary calculations.
Please see here for a more in-depth exploration of the FFT.
To summarise all of this in a sentence: we generate and progress a frequency domain wave spectrum, then perform the Inverse FFT over all the texture points to attain an amplitude map in the time domain.
From the amplitude map, slope and displacement maps are derived (including vertical and horizontal offsets), these the textures we will send to the ocean shader:
However, even with very detailed wave spectra, we have not completely eradicated the tiling issue. It is inevitable that any periodic waveform, or sum or periodic waveforms, will repeat given enough distance. However, the FFT is so fast we can actually compute several wave spectra simultaneously and scale them over the ocean surface. The literal millions of waves all interact and distort one another virtually eradicating tiling up to an extreme distance.
As one last trick I learned from HDRP, you can define constants as part of a compute shader's kernals. I used to this give the user control over the resolution of the wave spectra and subsequent FFT passes as a graphics option.
#pragma kernel RowPass_128 FFTPass=RowPass_128 FFT_RESOLUTION=128 BUTTERFLY_COUNT=7
#pragma kernel ColPass_128 FFTPass=ColPass_128 COLUMN_PASS FFT_RESOLUTION=128 BUTTERFLY_COUNT=7
#pragma kernel RowPass_256 FFTPass=RowPass_256 FFT_RESOLUTION=256 BUTTERFLY_COUNT=8
#pragma kernel ColPass_256 FFTPass=ColPass_256 COLUMN_PASS FFT_RESOLUTION=256 BUTTERFLY_COUNT=8
#pragma kernel RowPass_512 FFTPass=RowPass_512 FFT_RESOLUTION=512 BUTTERFLY_COUNT=9
#pragma kernel ColPass_512 FFTPass=ColPass_512 COLUMN_PASS FFT_RESOLUTION=512 BUTTERFLY_COUNT=9
[numthreads(FFT_RESOLUTION, 1, 1)]
void FFTPass(uint3 id : SV_DISPATCHTHREADID)
{
for (int i = 0; i < 8; i++)
{
#ifdef COLUMN_PASS
_FFTTarget[uint3(id.xy, i)] = FFT(id.x, _FFTTarget[uint3(id.xy, i)]);
#else
_FFTTarget[uint3(id.yx, i)] = FFT(id.x, _FFTTarget[uint3(id.yx, i)]);
#endif
}
}
Dynamic meshing is how I am refering to constructing the mesh data that will be sent to the GPU.
Like using Levels of Detail to render decimated versions of objects as they get further from the camera, the same can be done to oceans only a little more complicated.
Upon creating a HDRP ocean, three meshes are generated; a dense central grid, a rectangular grid mesh and a colossal outer mesh which is essentially a flat plane with a hole in it.
These meshes are dynamically scaled and rotated to fill the surface.
Put together, the meshes look like this:
A neat optimisation here is to use indirect GPU instancing to draw the ring meshes since they are drawn dozens of times.
This eliminates duplicate vertex batches by asking the GPU to cache the ring mesh and simply reuse it when requested.
We fetch get the instance ID in the vertex shader (before tessellation) and apply rotation and scaling data from a Structured Buffer prepared beforehand.
These meshes are frustum culled when generating the buffer, and again in the tessellation stage (per mesh then per triangle).
Secondly, we use GPU Vertex Tessellation to generate more vertices on the GPU.
In a tessellation shader, the distance from the vertex to the camera is used to generate a tessellation constant.
This is used to recursively subdivide the ocean mesh, creating more displaceable geometry without sending it to the GPU with the rest of the scene.
bool ShouldRender(float2 uv, float sceneDepth)
{
float surfaceMask = SAMPLE_TEXTURE2D(_SurfaceFaceMask, sampler_PointClamp, uv).r;
if (surfaceMask > 0.9)
return false;
float3 skyboxPos = ComputeWorldSpacePosition(uv, 0.00001, UNITY_MATRIX_I_VP); // Tiny epsilon to remove line artefact created just under water plane.
return surfaceMask > 0 || (_SurfacePosition.y > skyboxPos.y);
}
Note that even though branches are disadvised in GPU programs, returning a bool here means I can skip the expensive ray-marching steps mentioned later.
float Frag (Varyings i) : SV_Target
{
float intialTriangleArea = length(ddx(i.originalPos)) * length(ddy(i.originalPos));
float refractedTriangleArea = length(ddx(i.refractedPos)) * length(ddy(i.refractedPos));
return intialTriangleArea / refractedTriangleArea;
}
Which produces a texture like this:
float SampleGodrays(float3 positionWS, float3 lightDirection)
{
float3 normal = float3(0.0, -1.0, 0.0);
// Project caustics texture in light direction.
float3 forward = refract(lightDirection, normal, 1.0 / _IndexOfRefraction);
float3 tangent = normalize(cross(forward, float3(0.0, 1.0, 0.0)));
float3 bitangent = cross(tangent, forward);
float3 sampleCoord = positionWS * _TilingFactor;
float2 uv = float2(dot(sampleCoord, tangent), dot(sampleCoord, bitangent)) * 0.5 + 0.5;
// Sample caustics texture at a low LOD of for some free blurring.
// This means we can get away with a larger step size because the artefacts are naturally hidden.
return SAMPLE_TEXTURE2D_LOD(_CausticsTexture, sampler_LinearRepeat, uv, 5).r;
}
This is called from a raymarching function, including the usual upsampling and blur passes, before being composited onto the final image.
float EvaluateTriangle(int triangleIndex, float3 pos)
{
// a, b and c are the vertices of the triangle.
float3 a = _Vertices[_Triangles[0 + triangleIndex * 3]];
float3 b = _Vertices[_Triangles[1 + triangleIndex * 3]];
float3 c = _Vertices[_Triangles[2 + triangleIndex * 3]];
float3 pointOnTriangle = ClosestPointOnTriangle(pos, a, b, c);
float3 normal = cross(b - a, c - a);
float3 v = pos - pointOnTriangle;
float3 dirToFace = normalize(v);
float distToFace = length(v);
if (dot(dirToFace, normal) < 0)
{
distToFace *= -1;
}
return distToFace;
}
float SmallestPointDistanceToMesh(float3 pos)
{
float minAbsoluteDistance = 3.40282347e+38F;
float minDistance = 3.40282347e+38F;
for (int i = 0; i < _NumTriangles; i++)
{
float distance = EvaluateTriangle(i, pos);
float absoluteDistance = abs(distance);
if (absoluteDistance < minAbsoluteDistance)
{
minAbsoluteDistance = absoluteDistance;
minDistance = distance;
}
}
return minDistance;
}
The function I used to find the closest point on a triangle is from the Embree Ray Tracing Repo.
The Fourier Transform
https://www.thefouriertransform.com/Wakes, Explosions and Lighting: Interactive Water Simulation in Atlas
https://www.youtube.com/watch?v=Dqld965-Vv0Reflection, Refraction and Fresnel
https://www.scratchapixel.com/lessons/3d-basic-rendering/introduction-to-shading/reflection-refraction-fresnel.htmlOcean Simulation - antoniospg
https://antoniospg.github.io/UnityOcean/OceanSimulation.htmlSimulating Ocean Water - Jerry Tessendorf
https://people.computing.clemson.edu/~jtessen/reports/papers_files/coursenotes2004.pdfI Tried Simulating The Entire Ocean - Acerola
https://www.youtube.com/watch?v=yPfagLeUa7kCrash Course in BRDF Implementation - Jakub Boksansky
https://boksajak.github.io/files/CrashCourseBRDF.pdfRendering Realtime Caustics in WebGL - Even Wallace
https://medium.com/@evanwallace/rendering-realtime-caustics-in-webgl-2a99a29a0b2cPeriodic Caustic Textures
https://www.dgp.toronto.edu/public_user/stam/reality/Research/PeriodicCaustics/index.html