OHAO · Codebase tree

Monograph · Tree · ← Sitemap · Atmosphere & water includes

§

shaders

Atmosphere & water includes

Design unit

Gerstner waves, cloud density fields.

Compiled every build, executed never

Of the 32 headers under `shaders/includes/`, two are reachable from exactly one stage each, and both of those stages sit in `shaders/_disabled/`. `water/gerstner.glsl` is included only by `_disabled/water/water.vert`; `cloud/cloud_density.glsl` only by `_disabled/cloud_shadow.comp`.

#include "water/gerstner.glsl"shaders/_disabled/water/water.vert:26
#include "includes/cloud/cloud_density.glsl"shaders/_disabled/cloud_shadow.comp:9

`_disabled/` is a naming convention, not a build exclusion. The C++ render library carries a filter that looks like one, but the regex matches nothing — there is no `_disabled` directory anywhere under `ohao/`, and that is the only line in the C++ tree that mentions the name:

list(FILTER RENDERER_SOURCES EXCLUDE REGEX "/_disabled/")ohao/render/CMakeLists.txt:12

The shader side globs recursively with no filter at all, so `glslc` gets a rule for every `_disabled` stage — `build/shaders/` holds `_disabled_water_water.vert.spv` and `_disabled_cloud_shadow.comp.spv`, which no C++ ever loads.

file(GLOB_RECURSE SHADER_SOURCESshaders/CMakeLists.txt:9

Compilation is incremental rather than unconditional — but the dependency list attached to *every* stage is the shared include glob, which contains both of these headers, so editing either dead header rebuilds every shader in the tree:

DEPENDS ${SHADER} ${SHADER_INCLUDES} ${CMAKE_BINARY_DIR}/shadersshaders/CMakeLists.txt:47

Both headers are consequently **type-checked** whenever a shader or a tracked include changes, and **executed** never. A syntax error in either breaks `--target shaders` for the whole engine; a numerical error is invisible. The rest of this page is what that second clause has cost.

One degenerate parameter, one inverted dispersion relation

A Gerstner (trochoidal) wave displaces a flat grid horizontally as well as vertically, so crests sharpen and troughs flatten instead of staying sinusoidal. The header documents the standard parameterisation on one line:

// where k = 2π/λ, omega = sqrt(9.81 * k) * speed, Q = steepness / (k * A)shaders/includes/water/gerstner.glsl:22

with wavenumber $k = 2\pi/\lambda$, amplitude $A$, and $Q$ the horizontal-pinch factor. `gerstnerDisplace` and `gerstnerNormal` then open with the same six-line preamble — $k$, $\omega$, $\mathbf{d}$, $f$, $A$, $Q$ — recomputed independently in each.

The first problem is not a disagreement with the comment but a missing field. `GerstnerWave` carries `dir`, `wavelength`, `steepness`, `speed` and no amplitude, so the preamble manufactures $A$ from the `steepness` field $s$ and feeds it straight back into the documented $Q$:

$$ A=\frac{s}{k},\qquad Q=\frac{s}{k\max(A,10^{-4})}=\frac{s}{k\cdot s/k}=1 $$

$s$ and $k$ cancel. $Q$ is identically 1 for every wave at every wavelength, whenever $A$ clears the $10^{-4}$ floor. That floor is reachable: `waveAmp` is a runtime push constant, `water.vert`'s smallest of the four amplitudes is $0.143a$ for `waveAmp` $a$, so the guard starts clamping below $a \approx 7\times10^{-4}$, and for a clamped wave $Q$ falls *below* 1, reaching 0 at $a = 0$.

The code implements its comment verbatim; the formula is simply degenerate, and what it costs is a knob. Wave steepness is $kA = s$ and the horizontal pinch fraction $QkA = s$ — the same number — so `steepness` is named correctly and does control steepness, but it fixes amplitude $s/k$ and pinch in lockstep. There is no tall smooth swell and no low sharp chop; every wave has one shape, uniformly scaled.

A Gerstner wave self-intersects once $QkA$ exceeds 1, so a lone wave here cusps at $s = 1$, not at $Q = 1$. The standard no-loop budget for a sum, $\sum_i Q_i k_i A_i \le 1$, collapses to $\sum_i s_i \le 1$, and the four steepnesses are $a$, $0.8a$, $0.5a$, $0.3a$:

waves[0] = GerstnerWave(vec2( 1.0, 0.0), 8.0, waveAmp * 1.0, 1.0);shaders/_disabled/water/water.vert:46

so the surface starts looping above `waveAmp` $= 1/2.6 \approx 0.385$.

The genuine disagreement with the comment is dispersion. The comment specifies $\omega=\sqrt{gk}$, the deep-water relation; both copies of the preamble compute `sqrt(9.81 / k)`. Dimensionally $g/k$ is m²/s², so its root is a *speed*, used where rad/s belongs in the phase $f = k(\mathbf{d}\cdot\mathbf{p}) - \omega t$. Crest speed becomes $\sqrt{g}\,k^{-3/2}$ instead of $\sqrt{g/k}$ — scaling as $\lambda^{3/2}$ rather than $\lambda^{1/2}$. The two agree only at $\lambda = 2\pi \approx 6.28$ m; against `water.vert`'s 8, 12, 5 and 3 m waves the crest speeds are off by 1.27×, 1.91×, 0.80× and 0.48×. The wavelength spread is badly exaggerated — precisely the error class a still frame cannot show, and nothing here animates.

The Stokes term is a quarter period out of phase

The header adds a second-harmonic term labelled a Stokes 2nd-order correction:

disp.y += stokesAmp * sin(stokesPhase);shaders/includes/water/gerstner.glsl:38

The coefficient $\tfrac12 kA^2$ is the textbook second-order amplitude. The phase is not. A Stokes expansion puts the second harmonic *in phase* with the fundamental — against this file's $A\sin f$ that would be $-\tfrac12 kA^2\cos 2f$, raising crests and flattening troughs symmetrically. Using $\sin 2f$ shifts it a quarter period: it vanishes at crest and trough ($\sin(\pm\pi)=0$), leaving both heights untouched, and instead lifts one flank while dropping the other. That is the fore-aft asymmetry the comment asks for, but it is a skew, not the crest-sharpening a Stokes wave describes. `gerstnerNormal` carries the matching analytic derivative, so displacement and shading normal stay consistent with each other even though neither matches Stokes.

Key idea

`gerstnerNormal` returns a *perturbation*, not a normal — there is no unit-up term in it. The caller must seed the accumulator with geometric up before summing, which `water.vert` does. Drop that seed and flat water normalises a zero vector.

vec3 normAccum = vec3(0.0, 1.0, 0.0); // start from geometric upshaders/_disabled/water/water.vert:52

A density field built entirely to defeat tiling

`cloud_density.glsl` is a Schneider-style (SIGGRAPH 2015) density function, and nearly every constant in it exists to stop one 128³ noise texture from reading as a grid. Altitude comes from a virtual Earth centre 6371 km below the origin rather than `worldPos.y`, so the height gradient follows a sphere instead of collapsing into a horizontal band when the camera looks up:

float posAlt = length(worldPos - CLOUD_EARTH_CENTER) - CLOUD_EARTH_RADIUS;shaders/includes/cloud/cloud_density.glsl:58

Normalised altitude $h$ drives a per-archetype vertical profile built from four control points and two smoothsteps. The archetype vectors blend by weights forming a partition of unity across `cloudType` ∈ [0,1], so 0, 0.5 and 1.0 land on pure stratus, stratocumulus and cumulus with continuous interpolation between.

Vertical density profiles evaluated directly from the four control-point vectors in cloud_density.glsl. Conceptual — computed from the constants, not a captured render. The dashed line marks the 0.01 early-out that rejects a sample before any 3D texture fetch.
Vertical density profiles evaluated directly from the four control-point vectors in cloud_density.glsl. Conceptual — computed from the constants, not a captured render. The dashed line marks the 0.01 early-out that rejects a sample before any 3D texture fetch.

Everything after that is scale-ratio engineering. Base noise is sampled at 0.000015 per world unit, so the texture repeats every 66,667 world units (~67 km); a second base layer runs at 0.0000356, a frequency ratio of 2.373:

vec3 baseUVW2 = worldPos * 0.0000356 + windOffset * 1.3;shaders/includes/cloud/cloud_density.glsl:96

The file's header comment calls that ratio incommensurable and the combined repeat "effectively infinite". It is neither: $0.0000356/0.000015 = 178/75$ in lowest terms, so the layers realign after exactly 75 tiles of the first — 5,000,000 world units, 5,000 km. Finite and computable, but past any draw distance, so the intent holds even though the arithmetic does not.

Both layers are then domain-warped by a vector read from the `.gba` channels of the *same* 3D texture at a much lower frequency — and the two warps are not the same warp. Layer 1 is warped at 0.0000018, roughly one eighth of its 0.000015 base, with amplitude 0.10 — 6.7 km of peak-to-peak displacement — and its warp scrolls at 5% of the wind offset:

vec3 warp = (warpRaw - 0.5) * 0.10; // ~6.6km displacement in worldshaders/includes/cloud/cloud_density.glsl:88

Layer 2 is warped at 0.0000025 against its own faster 0.0000356 base — one fourteenth, not one eighth — with amplitude 0.08, about 2.2 km, and it carries no wind offset at all, so that warp field is pinned in world space while everything layered over it drifts:

vec3 warp2Raw = texture(noiseTexture, worldPos * 0.0000025 + 0.5).gba;shaders/includes/cloud/cloud_density.glsl:98

The weather map is sampled at two scales (~80 km and ~26 km, ratio 3.04) for the same reason; only its `.r` coverage and `.g` cloud-type channels are read, never the `.b` precipitation channel the binding comment documents.

Density is Schneider's remap rather than a threshold, and the *direction* of each remap repays reading. The base-shape step maps the `.r` Perlin–Worley channel from $[-(1-w),\,1]$ into $[0,1]$, where $w$ is the Worley FBM assembled from `.gba`:

float baseCloud = cloudRemap(noise.r, -(1.0 - worleyFBM), 1.0, 0.0, 1.0);shaders/includes/cloud/cloud_density.glsl:109

Because that lower bound sits below zero, this is a dilation, not an erosion: it evaluates to $(r+1-w)/(2-w)$, which is $\ge r$ for every $w \in [0,1]$ and lifts the field most where the Worley FBM is lowest. The low-frequency Worley adds body here. Carving happens later, in the detail stage, where the lower bound is positive.

Why

Detail erosion is skipped wholesale when `cheapSample` is set, and the shadow march takes that path for all 16 of its vertical steps: erosion changes the silhouette, and a shadow map integrates that away. The rejected alternative — one sampling path for everything — puts the two skipped fetches back, eight texture fetches per surviving sample instead of six, for detail nothing downstream can resolve. The header advertises the cheap path as "3x cheaper"; by fetch count it is nearer 1.3×, and since the stage never runs, nothing in the tree has measured either number.

if (cheapSample) return clamp(baseCloud * densityScale, 0.0, 1.0);shaders/includes/cloud/cloud_density.glsl:122

When erosion does run, its direction flips with altitude. The blend weight `clamp(h * 5, 0, 1)` is fully inverted only at $h = 0$, fully upright from $h = 0.2$ upward, and interpolates linearly across the bottom fifth of the slab — wispy undersides and crisp tops from a single noise fetch:

float detailMod = mix(1.0 - detailFBM, detailFBM, clamp(h * 5.0, 0.0, 1.0));shaders/includes/cloud/cloud_density.glsl:135

That modulator becomes the *lower* bound of a second remap, and this bound is positive, which is what makes this the step that carves: anything at or below $0.35\,\text{detailMod}$ drops to zero while the top of the range stays at 1, so interiors survive and thin edges are eaten away.

float finalCloud = cloudRemap(baseCloud, detailMod * 0.35, 1.0, 0.0, 1.0);shaders/includes/cloud/cloud_density.glsl:138

The stale contract, and the receiver with no producer

The header declares its Earth constants must match `cloud.comp`.

// Earth geometry (must match cloud.comp)shaders/includes/cloud/cloud_density.glsl:13

`cloud.comp` has never had Earth geometry to match. The literal 6371000 has been introduced exactly once in this repository's history, in this header; the pre-rewrite `cloud.comp` ray-marched a flat slab in world Y, and the current one is a 2D layer with analytic FBM that includes `phase.glsl` rather than this header and finds its cloud plane by planar ray intersection:

float t = (cloudAlt - ro.y) / rd.y;shaders/_disabled/cloud.comp:105

The header and that rewrite landed in the same commit, `1bd2b27`, so the contract was stale on arrival — a shared constant declared against a consumer that never used it.

The consumer end of cloud shadows, by contrast, is fully built in live code. `deferred_lighting.frag` samples a shadow map at set 0 binding 13 behind flag bit 16 and floors it so shaded ground never goes black:

cloudShadow = max(cloudShadow, 0.3); // never pitch-blackshaders/core/deferred_lighting.frag:321

and `DeferredLightingPass` raises that bit from whether a view was handed to it:

if (m_cloudShadowView != VK_NULL_HANDLE) m_params.flags |= 16; // Cloud shadowsohao/render/deferred/deferred_lighting_pass.cpp:175

`setCloudShadow` has no callers anywhere in the tree, so bit 16 is never set and binding 13 always resolves to the fallback view. `SkyPass::setCloudBuffer` is in the same state. The feature is a receiver, a switch and a disabled producer, with the connecting call missing.

Why one of these headers cannot be co-included

The two files differ sharply in namespace discipline, which constrains where each can go. `gerstner.glsl` declares a bare file-scope `const float PI`; three other headers under `includes/` define that name, two of them as macros:

#define PI 3.14159265359shaders/includes/common/math.glsl:17

A stage that pulls in either macro before `gerstner.glsl` expands its declaration to `const float 3.14159265359 = …` and fails to compile; `lighting/ibl.glsl` spells `PI` as a plain `const float` and would collide by redeclaration instead. `water.vert` compiles only because it includes nothing else.

`cloud_density.glsl` is better behaved but not clean. Its constants and its helper are namespaced — `CLOUD_EARTH_RADIUS`, `CLOUD_EARTH_CENTER`, `cloudRemap` — and it declares no `PI`, so it has no collision with any of the common headers. Its two entry points, `densityHeightGradient` and `sampleCloudDensity`, are unprefixed and would still collide with any same-named function a host stage already has. One header collides with the engine's most-included math headers; the other is two prefixes short of portable.

Contracts

  • `gerstnerNormal` returns a perturbation; the caller must seed the accumulator with `vec3(0,1,0)` before summing waves, or flat water normalises a zero vector.
  • $Q = 1$ is structural, not tunable, and `steepness` sets amplitude and crest pinch together — they cannot be separated without adding an amplitude field. The summed no-loop budget is $\sum s_i \le 1$; with `water.vert`'s weights that caps `waveAmp` at 0.385.
  • `gerstner.glsl` cannot be co-included with `common/math.glsl`, `lighting/phase.glsl` or `lighting/ibl.glsl` until its `PI` is removed.
  • The "must match cloud.comp" comment was stale the day it was written: no version of `cloud.comp` has ever used Earth geometry, and the current one does not include this header.
  • The shadow march passes `cheapSample = true` on all 16 steps and is budgeted on skipping the erosion fetches; the deferred consumer floors the result at 0.3 regardless of accumulated density.
  • Neither file is exercised at runtime, and both are recompiled whenever any tracked include changes — they cannot rot syntactically, and cannot be validated numerically without re-enabling a consumer.

Source files

shaders/_disabled/water/water.vert
shaders/_disabled/cloud_shadow.comp
ohao/render/CMakeLists.txt
shaders/CMakeLists.txt
shaders/includes/water/gerstner.glsl
shaders/includes/cloud/cloud_density.glsl
shaders/_disabled/cloud.comp
shaders/core/deferred_lighting.frag
ohao/render/deferred/deferred_lighting_pass.cpp
shaders/includes/common/math.glsl
shaders/includes/lighting/phase.glsl
Navigate

Parent hub for the full pipeline narrative; this page is the file-level design unit. Sitemap · hover glossary terms anywhere.