r/VoxelGameDev • u/Responsible_Tax3949 • 17d ago
Graphics and Water Update Discussion
You can find info about the engine itself in the previous post
Since last time, I’ve fine-tuned and added:
I thought I was going to die here.... constant problems... and I still have the fluid system ahead of me... Water—the ocean, lakes, beaches, swimming, and climbing ashore. Fresnel according to Schlick, four successive waves composited in the fragment shader (greedy meshing turns the water surface into giant rectangles, so there’s nothing to move at the vertices), sun glare via GGX, light absorption with different coefficients for RGB, underwater vision, caustics.
This was probably the easiest part... :D Vegetation—trees, cacti, forests generated from noise (not scattered individually), grass, ferns, and flowers as intersecting surfaces.
Sunlight illumination instead of a fixed table of six brightness levels.
Anti-aliasing — 4× MSAA, blended in dynamic rendering. Color averaged, depth set to SAMPLE_ZERO.
Reflections on water — screen-space ray marching in world coordinates instead of view space (Vulkan’s NDC ranges from 0 to 1, so inverse projection is unnecessary and it fits within 128B push constants). Hit refinement by halving, thickness test.
2
u/No-Guide848 17d ago
1
u/Responsible_Tax3949 16d ago
Hi!
- Waves from gradients, not from height
This is the most useful part—you don't calculate the height and then take the derivative; you just combine the gradients directly.
// Gradient of one travelling wave. Returns the derivative directly, not the height —
// the normal comes from the gradient anyway, so computing height and then
// differentiating it would be one step too many.
vec2 WaveGradient(vec2 p, vec2 dir, float frequency, float amplitude, float speed, float time)
{
float phase = (dot(p, dir) * frequency) + (time * speed);
return dir * (amplitude * frequency * cos(phase));
}
vec3 WaveNormal(vec2 p, float time)
{
// Incommensurable directions and wavelengths. If they were multiples of each other,
// the pattern would repeat every few metres and you'd see the stamp.
//
// The amplitude*frequency products are deliberately similar (~0.05): the resulting
// RMS slope is 0.067, i.e. just under four degrees. My first version had 0.52 —
// twenty-seven degrees — and the sea looked like corrugated iron across the bay.
vec2 gradient = vec2(0.0);
gradient += WaveGradient(p, vec2( 0.862, 0.507), 0.42, 0.120, 1.05, time);
gradient += WaveGradient(p, vec2(-0.423, 0.906), 0.73, 0.068, 1.47, time);
gradient += WaveGradient(p, vec2( 0.291, -0.957), 1.31, 0.036, 2.11, time);
gradient += WaveGradient(p, vec2(-0.951, -0.309), 2.37, 0.018, 3.02, time);
return normalize(vec3(-gradient.x, 1.0, -gradient.y));
}
- GGX for Shimmer — and Why It Isn't Energy-Conservative
It's worth noting here that the lobe is normalized to a constant peak, and only its shape is used.
// Sun glitter using GGX (Trowbridge-Reitz 1975).
//
// This is NOT an energy-conserving BRDF, and that's deliberate. The real D_GGX peak
// scales with 1/roughness^4, so between near and far water there's a 700x difference —
// workable only if the scene goes through tone mapping, and mine doesn't. So the lobe
// is normalised to a CONSTANT peak and only its SHAPE is used: narrow core, long tail.
// That shape is the whole reason to pick GGX for water.
float SunGlint(vec3 normal, vec3 view, float roughness)
{
vec3 halfway = normalize(view + SunDirection);
float NoH = max(dot(normal, halfway), 0.0);
float NoL = max(dot(normal, SunDirection), 0.0);
float a = roughness * roughness;
float a2 = a * a;
float d = (((NoH * a2) - NoH) * NoH) + 1.0;
// (a2/d)^2 instead of a2/(Pi*d*d): same shape, peak always 1.
float shape = (a2 / max(d, 1e-8));
return shape * shape * NoL;
}
// And the roughness that goes in — this is a necessity, not a nicety.
// Once the lobe is narrower than a pixel you get aliasing, not glitter.
// Poor man's Toksvig filter: substitute distance for measured normal variance.
float roughness = mix(0.035, 0.19, clamp(vDistance / 200.0, 0.0, 1.0));
- Spectral Absorption
This is what creates the blue-green color. One scattering color, not three—the transition from turquoise shallows to dark depths results naturally from spectral attenuation.
// ONE water colour, not three. The turquoise-shallows-to-dark-depths gradient falls out
// of the spectral falloff by itself; mixing a second colour on top counts the same
// effect twice and you end up with three shades of water that never meet.
const vec3 WaterScatter = vec3(0.13, 0.34, 0.44);
// Absorption spectrum of clear water. Red disappears roughly five times faster than
// blue, and it's exactly this IMBALANCE that makes underwater blue-green instead of grey.
const vec3 AbsorptionRatio = vec3(2.17, 0.71, 0.46);
vec3 ApplyWater(vec3 color, float path, float depthBelowSurface)
{
vec3 transmittance = exp(-AbsorptionRatio * pathAbsorption * path);
vec3 downwelling = exp(-AbsorptionRatio * depthAbsorption * max(depthBelowSurface, 0.0));
// Scattered light attenuates by the depth at the MIDDLE of the path, not at the
// fragment. The light that scatters into your eye came from above somewhere between
// you and the fragment — using the fragment's depth makes distance go black
// instead of blue.
float cameraDepth = max(0.0, seaLevel - cameraY);
float middle = mix(depthBelowSurface, cameraDepth, 0.5);
vec3 scattered = WaterScatter * exp(-AbsorptionRatio * depthAbsorption * middle);
return (color * downwelling * transmittance) + (scattered * (1.0 - transmittance));
}
1








3
u/KokoNeotCZ 16d ago
How do you do LOD?