diff --git a/README.md b/README.md index cb0831d3..d15a2ae1 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,11 @@ changes while the renderer is being built. terrain streaming...), and every sub-screen starts with a "Reset to Defaults" button — plus a global one on the hub — so experimentation can never strand you on a botched configuration - Weather-driven lighting: rain and thunderstorms dim the sun/moon and darken the sky -- Volumetric 3D clouds in classic (vanilla-style boxes) or photoreal cumulus form, - with adjustable thickness, opacity and cloud shadows, visible in reflections +- Volumetric 3D clouds in classic (vanilla-style boxes) or photoreal form — the volumetric deck is + ray-marched with multi-scattered light, three optical-depth probes (sun, sky, ground bounce) and + weather-driven cloud types (stratus sheet, cumulus heaps, storm towers), with adjustable thickness, + opacity and cloud shadows, visible in reflections. + See [docs/realistic-volumetric-clouds.md](docs/realistic-volumetric-clouds.md) - Dedicated Nether and End skyboxes - OMM (Opacity Micro-Map) + SER (Shader Execution Reordering) optimizations - Experimental NVIDIA SHaRC-style world-space radiance cache (Spatially Hashed Radiance Cache) to diff --git a/docs/realistic-volumetric-clouds.md b/docs/realistic-volumetric-clouds.md new file mode 100644 index 00000000..39ce44e2 --- /dev/null +++ b/docs/realistic-volumetric-clouds.md @@ -0,0 +1,446 @@ +# Realistic volumetric clouds + +What the `volumetric` cloud style now models, why each part of it is there, and where every number comes +from. Everything described here lives in `shaders/world/clouds.slang` (the deck) and +`RtComposite.cloudState` (the lanes it reads); `RtCloudShaderRegressionTest` and +`RtCloudPeriodMirrorTest` guard the parts that are easy to lose in a tuning pass. + +The `classic` style is untouched by this work and is meant to keep looking like Minecraft's own cloud +boxes. Where the two share code, the classic path is called out explicitly. + +--- + +## 1. The brief + +Make the volumetric deck look like real cloud — the look a heavy shader pack such as +[Photon](https://github.com/sixthsurge/photon) produces — by ray marching, after studying both the +technique and the thing being imitated. One specific requirement came with it: + +> since clouds are no longer a PNG, the height slider must not control the distance from the ground, it +> must control the *thickness* (grossura) of the cloud. + +The final revision of this PR supersedes half of it: the height slider keeps the first half (where +the deck sits), but the volumetric deck's *thickness* is no longer a slider at all — how deep a cloud +is comes from the genus model (§4.2) and from each parcel's own vigour (§4.3), because a global +thickness was precisely the knob that made the deck read as a rectangle whose look depends on a +slider. The thickness option now shapes only the classic boxes, and the volumetric clouds screen +replaces that row with a greyed-out explanation saying exactly that. + +That requirement is about a distinction a flat texture cannot express. `clouds.png` was one plane: a +single number described both how far away it was and how big the clouds looked. A modelled deck has two +independent properties — **where the layer sits** and **how much cloud is in it** — so they are two +controls, and the deck's depth is never a side effect of moving it. See §6. + +## 2. What the previous model actually got wrong + +Not "it was ugly" — three measurable defects, each with a cause: + +1. **It was a 2D picture extruded through a height profile.** Coverage and erosion were both sampled from + 2D noise, so the same pattern repeated at every altitude: lobes lined up vertically through the whole + depth and the crown never broke into individual heads. Real cloud is a 3D structure — a parcel rises, + condenses, entrains dry air at its edges and rolls into lobes independently at every height. +2. **It was single-scattering in a medium that is not.** Measured optical depth of low water cloud is + 12–92 (geometric mean ~34), so a photon scatters tens of times before it escapes. Single scattering + renders that as a silhouette with a black underside, which is the "flat grey cotton" look. +3. **It was eight times too dim, and dim in the wrong places.** One sun term attenuated by one shadow + march, plus a hand-tuned `skyBehind * 0.35` ambient. Calibrated against the real sky (§5.6), the old + interior produced ~0.03 · E where a sunlit cloud top should produce ~0.24 · E. + +A fourth thing was not a defect but a missing input: the deck had one shape regardless of weather, so +rain made it darker but never made it a *different kind of cloud*. + +## 3. Reference material + +**Photon** ([sixthsurge/photon](https://github.com/sixthsurge/photon)) — read as a working example of a +shipping real-time cloud pass, which is the fastest way to see how the published techniques are actually +assembled and what they cost. What was learned from looking at it, as *technique*: the order of the +density pipeline (coverage → altitude shaping → detail erosion → edge sharpening → final height ramp); +an edge-sharpening exponent that thins or fattens the field; the optical-depth march on exponentially +growing strides with a dithered start; a per-bounce-order scattering loop that relaxes scattering, +extinction, phase anisotropy and the powder term together; separate light probes for the celestial, the +sky and the ground rather than one shadow march; an early exit once the medium saturates; a two-scale +coverage field; and per-cloud-type parameter sets instead of one global shape. + +**No code was taken from it.** Nothing in this repository is derived from Photon's source: no file, +function, expression, identifier, constant set or asset of its appears here, and the two implementations +could not be further apart mechanically — Photon is GLSL for Iris/OptiFine-style loaders, sampling +precomputed 3D noise and coverage textures through uniforms, while this module is Slang in Caustica's +ray-tracing pipeline, reading `WorldPush` lanes and generating every field it samples from a periodic +integer hash written here (`cloudHash3Bits`/`cloudHash3`/`cloudNoise3`/`cloudBillow3`/`cloudWorley3`), +with no texture or sampler of any +kind in the file. Photon's own license explicitly permits examining and learning from its source, which +is all that was done; where this document names it, that is provenance of an idea and not of code. Every +number below comes either from published literature or from a derivation in this repo (§5.6 calibrates +the deck's brightness against the real sky rather than against another shader). + +**Unreal Engine 5's Volumetric Cloud** (and the public writeups of its shape stage) — consulted for the +*calibration* of the height profile rather than for any technique: its shape textures are authored +against per-type height gradients, and the published presets are the clearest statement anywhere of what +"cumulus" and "congestus" mean numerically — a fair-weather cumulus closes its dome at roughly +`(0.0, 0.2, 0.42, 0.6)` of the layer (base ramp over the first fifth, top fade from 0.42 to 0.6) while a +developing tower runs `(0.0, 0.08, 0.75, 0.98)` (near-instant flat base, dome closing at the layer top). +Also from that lineage: the weather map carrying per-region type and height, and the observation that +inverting the detail noise at the cloud base is what produces wispy fractus. §4.3 tunes this model's +genus profiles against those two gradients. + +**Horizon Zero Dawn's Nubis / Frostbite / Skybolt lineage** (Hillaare's *Physically Based Sky, +Atmosphere and Cloud Rendering in Horizon Zero Dawn*, Wrenninge's multi-scattering writeups) — the +bedrock technique: low-frequency shape eroded by high-frequency detail, height-gradient presets per +cloud type, curl-noise turbulence scaled by altitude, the beer's-powder dark-edge term, and the octave +approximation to multiple scattering (`k = falloff^order`, phase mixed toward `1/4π` by `k`). Also the +marching hygiene: dithered start (Bayer or rotating blue noise), exponentially growing light-march +strides, early exit once the medium saturates. + +**Terrestrial cloud optics** (Kokhanovsky's review of light scattering by water clouds; CALIPSO/MODIS +optical-depth climatologies) — where the physical constants come from: extinction coefficient +σ_ext = 1.5 · LWC / a_eff ≈ 0.005–0.1 m⁻¹ (about 0.1 m⁻¹ for a typical a_eff = 6 µm / LWC = 0.4 g m⁻³ +layer, i.e. opaque within ~100 m); single-scattering albedo ω₀ ≈ 0.9999 in the visible, so **cloud +darkness is path length, not absorption**; liquid water content ramps up roughly linearly from cloud +base (adiabatic condensate) and falls off near the top (dry-air entrainment), which is the asymmetric +height profile; cloud base is the lifting condensation level, which is why every real deck has a flat +bottom seen from the side. + +**WMO genera** — the three shapes this altitude shows: stratocumulus/stratus (continuous, shallow +300–600 m, flat-topped, capped by an inversion), fair-weather cumulus humilis/mediocris (individual +heaps about as wide as they are deep), and cumulus congestus/cumulonimbus (convective, several times +deeper, crown filling the layer). + +## 4. Geometry: where cloud is, and what shape it has + +Three layers, in the order `cloudVolumeDensity` evaluates them. + +### 4.1 Coverage — 2D, shared with the shadow + +A cloud *layer*'s horizontal structure genuinely is two-dimensional (one condensing air mass), so +coverage stays a 2D field: `cloudVolumetricCoverage`, reading `cloudCoverageField` (three octaves of +periodic value noise) at `CLOUD_SHAPE_DIV = 2.0`, which puts one cell at 48 blocks — a cloud a few +hundred blocks across, deliberately matched to the deck's depth because real cumulus are about as wide +as they are tall. + +Before the threshold, the field is pushed toward its own extremes (one smoothstep of itself). A raw +value-noise field is a Gaussian-ish wash around 0.5, so thresholding it directly gives every cloud the +same soft wide skirt and no clean air between neighbours — a sky of connected blobs. The contrast pass +is what separates individual clouds with real gaps and crisp edges. + +A second, independent reading of the same lattice then shifts the THRESHOLD up and down across the sky +(`CLOUD_COVERAGE_CLUSTER = 0.5`): in one region neighbours merge into one big mass, in the next they +shrink to scattered fragments with genuine clear air between the groups. A single global threshold +gives every cloud the same skirt and the same size — a sky of evenly spaced puffs, which is not a +sky. + +It is **one function with three callers** (visible density, cloud-shadow query, flat-sheet fallback). +That is a fix, not a tidy-up: the shadow and the density used to merge the weather fill differently, so +in rain the deck stayed at the slider's coverage while its shadow closed the sky completely. + +### 4.2 Genus — the weather picks the shape + +`cloudWeather` resolves one struct per march from lanes the frame already pushes, so no two parts of the +deck can disagree about what the sky is doing: + +| lane | use | +| --- | --- | +| `push.clouds.x` | the coverage slider | +| `push.cloudColor.w` | the weather overcast fill (rain drives it toward 1) | +| `push.weather.x/y` | rain and thunder, **gated on `FEATURE_WEATHER_LIGHTING`** so the deck only changes shape where the rest of the renderer agrees weather exists | + +* `sheet` = `smoothstep(0.55, 0.92, coverage)` — a closed sky is a **sheet**; a scattered one is heaps. + That is how the real sky behaves, so the coverage slider now changes cloud *genus* and not merely + density. +* `convection` = `max(0.30 · 4c(1−c) · (1−sheet), thunder)` — vertical development peaks on a sunny day + building cumulus, and in thunderstorms, where the tower *is* the storm. An overcast sheet suppresses + it: nothing is being heated from below. The 0.30 is calibration, not taste: the raw parabola peaks at + 1.0 for 50% coverage, which turns every scattered fair-weather sky into slab-filling congestus — a + sky of COLUMNS — while the published cumulus height gradient (§3) closes its dome at about half the + layer. +* `absorbing` = `rain · 0.55 + thunder · 0.45` — precipitating cloud adds extinction (§5.1). Droplets + that grew large enough to fall no longer scatter cleanly, which is why a storm's underside reads + grey-green rather than merely shadowed. + +There is deliberately **no slider for any of this**. It is the same weather state that dims the sun, +darkens the sky and thickens the fog, so a storm's deep grey deck, its dimmed light and its heavy air +are one reading of one state. + +The genus also sets how DEEP the deck is: `cloudDeckDepth` returns 64 blocks for a sheet, 128 for +heaps and 192 for towers, and the volumetric march takes that as its slab instead of the pushed +thickness — the same one-reading-of-one-state argument as above, applied to the slab itself. A cloud's +depth belongs to the sky that made it; §4.3 then develops each individual parcel inside that slab to +its own height. + +### 4.3 Height profile — flat base, crown that knows the genus + +* **Base** (`CLOUD_BASE_RAMP_HEAP = 0.06`, `SHEET = 0.22`): the lifting condensation level. Below it the + air is unsaturated and there is no cloud at all, which is why every real deck has a flat bottom. A + heap's base is sharp (one parcel that just reached saturation); a sheet's frays into mist (stratus + forms by shallow cooling over a wide area, not by a rising parcel). +* **Crown** (`HEAP = 0.48`, `TOWER = 0.94`, `SHEET = 0.48`, rounding `0.30`, clamped to close at the + slab top at the latest): where the top starts to round off, tuned against UE5's published height + gradients (§3) — an ordinary cumulus domes at ~half the layer, a developing tower at the very top, an + inversion-capped sheet at half. **Dense cores tower, but with a ceiling**: the *local* coverage lifts + the crown through `smoothstep(0.45, 0.95, coverage)`, CAPPED at `CLOUD_CROWN_LIFT_MAX = 0.78` — + without the cap the lift runs to 1.0 and every dense core (which is all of them, cores being dense by + nature) closes its dome at the slab top, filling the sky with 150-block columns instead of clouds. + With it, one sky holds low fringes, mid heaps and tall cores at once, and only convection — storm, or + a sunny day building — carries a crown past 0.78 toward the tropopause. That coupling — nonlinear, so + different clouds in one sky get different heights — is most of why the result reads as a field of + individual clouds instead of one extruded slab. + The clamp on the fade's end is load-bearing: without it a tall crown's fade would finish *above* the + slab and the deck would be sliced flat by its own ceiling, which is exactly the "straight top that + follows the thickness slider" artefact this model exists to avoid. +* **Belly bulge** (`CLOUD_BULGE = 0.42` at `CLOUD_BELLY_HEIGHT = 0.42`, heaps only): a cumulus is widest + around its lower-middle and pulls in toward both base and crown, so the coverage is relaxed there and + the same cloud grows sideways in its belly. A sheet has no belly, so the term is scaled out by + `1 − sheet`. +* **Vigour** — a per-cloud stretch of the height coordinate, `lerp(1.25, 0.85, …)` of one low-frequency + reading of the shape lattice at its own offset, scaled out by `sheet`. Stretched past 1 the profile + closes its dome low (a shallow humilis puddle); compressed below 1 the same profile closes high (a + towering mediocris). Two clouds with identical local coverage draw different numbers, so one sky + holds shallow and deep parcels side by side — without it every cloud develops to the same fraction + of the slab and the deck reads as one population of identical puffs. + +The profile is asymmetric on purpose (measured LWC ramps up from base, decays near the top); a symmetric +lens is what makes a procedural deck read as a slab. + +### 4.4 Turbulence — wind shear without a curl-noise texture + +Real decks are sheared: wind speed and direction change with altitude, so updrafts lean downwind and a +crown curls while its base stays flat. The sample position is displaced by a smooth low-frequency noise +before the erosion is read (`CLOUD_WARP_AMPLITUDE_BLOCKS = 26`, growing to `CLOUD_WARP_SHEAR = 2.2×` at +the crown, with `CLOUD_WARP_VERTICAL = 0.55` of it leaning the tower over rather than merely smearing +its footprint). + +HZD and Frostbite sample a curl-noise *texture* for this. This module cannot: a deck that must survive +the anchor wrap may only sample fields that are periodic with it (§7). Altitude enters as a coordinate +*offset*, which is what makes the displacement grow and slide with height at 2D-noise cost. + +### 4.5 Erosion — the 3D part + +The erosion FBM is the **Perlin-Worley pair**, generated rather than sampled. A coarse **3D** billow +octave (`cloudBillow3` = `1 − |2·noise₃ − 1|`, value noise folded into rounded lobes) at +`CLOUD_BILLOW_DIV_COARSE = 2.0` — lobes 48 blocks across, a fifth of a cloud's width, which is the +scale real cumulus cauliflower shows — carries the lobes, and a fine **Worley +(cellular) F1** octave at `CLOUD_BILLOW_DIV_FINE = 0.25` — cells 6 blocks across — carves the crisp +scoops between those lobes. Billow alone has soft boundaries everywhere, which is the "aerated cotton +wool" read that separates a procedural deck from a real crown; cellular noise is what removes it. This +is the one field in the module that every write-up of the technique describes as a precomputed texture, +and here it is computed at runtime: `cloudWorley3` walks the 3×3×3 neighbourhood of the sample's cell +and returns the distance to the nearest feature point, each feature being its cell's centre plus a +jitter of ±0.4 cells (`CLOUD_WORLEY_JITTER = 0.8`). Because the jitter stays under half a cell per axis, +the nearest feature point is provably inside that neighbourhood, so 27 taps is an **exact** F1 rather +than an approximation. Each tap costs one hash, from which all three jitter components are unpacked as +8-bit slices (`cloudHash3Bits`), so the octave is 27 hashes rather than 81 — still the most expensive +thing in the density, which is why it lives only at `CLOUD_DETAIL_FULL` and behind the distance LOD. + +Raw F1 averages ≈0.511 on this lattice. `CLOUD_WORLEY_REMAP_SCALE = 2.55` with +`CLOUD_WORLEY_REMAP_BIAS = −0.81` — both fitted numerically over 26³ samples of exactly this hash and +this jitter — remap it to a mean of exactly 0.500, with ~25% of the range landing on the clamps, and +that clipping *is* the crispness. The mean is not cosmetic: the SHAPE tier substitutes this octave's +expected value for it (§5), so a hand-guessed remap would silently bias every light probe. + +The vertical axis is a real third dimension on its own lattice (`cloudHash3`, masked to +`CLOUD_VERTICAL_CELLS = 256`), and it is sampled in **blocks above the deck's own base**, never in +camera-relative Y: the deck's internal structure is pinned to the world, so it does not swim past the +eye as the camera rises or falls. `RtCloudShaderRegressionTest` asserts the absence of `posRel.y` in the +density function for exactly this reason. + +Erosion then bites hardest where the field is thin (the fraying edge) and at the slab extremes — the crown +breaking into lobes, the base dissolving into mist (`CLOUD_EROSION_EDGE = 0.75`, `CROWN = 0.55`, +`BASE = 0.35`) — leaving the interior solid. Uniform erosion is what makes procedural cloud read as flat +fluff. Finally `edge_sharpening`: an exponent interpolated from `1.55` at the base (thins → wispy, +mist-like underside) to `0.90` at the crown (fattens → hard, well-defined top). + +## 5. Optics and light transport + +### 5.1 Extinction and albedo + +`CLOUD_EXTINCTION = 0.42` per block at unit density. Measured water cloud is 0.005–0.1 m⁻¹ and a real +cumulus is opaque because it is hundreds of metres deep (τ 30–100 straight up); this deck is a +compressed sky a few tens of blocks deep, so it cannot buy that opacity with depth and buys it per +block instead. At the physical per-metre value a core here reached τ ≈ 1–3 — see-through cotton wool +with no shadowed underside, the single most visible difference between this constant and a cloud that +reads as a cloud. At 0.42 a developed core sits at τ ≈ 7–15 (opaque body, dark base, silver lining at +the rim) while a few blocks of fringe at low density still transmit, which is the core/wisp split real +clouds show. + +`CLOUD_SINGLE_SCATTER_ALBEDO = 0.9995` follows the measured ω₀: scattering is essentially everything, so +cloud darkness is path length. The small deficit is the only absorption the deck has, and precipitating +cloud adds to it via `CLOUD_STORM_ABSORPTION = 0.85 × absorbing` — **volumetric only**, because classic +already greys in rain through `push.cloudColor` and would otherwise darken twice for the same weather. + +Extinction is normalised by `CLOUD_REFERENCE_THICKNESS = 40` blocks, so a deeper deck adds **volume +without adding opacity**. Without it, τ = σ · path grows with the slab depth and the thickness slider +silently doubles as a second opacity control — a deep deck going solid white at the horizon while the +opacity slider still says 20%. + +### 5.2 Phase — three lobes + +`CLOUD_PHASE_BROAD/SILVER/BACK = 0.50/0.30/0.20` over HG lobes at `g = 0.60`, `0.88` and `−0.22`. Mie +scattering by ~10 µm droplets is dominated by a very tight forward peak inside a broad glow, plus a weak +backward lobe from diffraction and internal reflection. The tight peak *is* the silver lining on a +backlit edge; the broad glow is the general sun-side brightening; the back lobe is what stops the shadow +side collapsing into a silhouette. Weights sum to 1, so the mixture stays 4π-normalised and the expansion +cannot invent energy. A weighted sum of HG lobes is the usual cheap stand-in for a Mie table. + +### 5.3 Three optical-depth probes + +One sample asks three different questions about three different directions, and answering only the first +is what leaves a deck looking like lit cotton wool with a black underside. All three return **raw** +∫ density dl in blocks, because the octave expansion needs the same quantity at several different +extinction coefficients. + +| probe | how | why | +| --- | --- | --- | +| **sun/moon** | `CLOUD_LIGHT_STEPS = 6` strides growing ×2 each (`CLOUD_LIGHT_STEP_GROWTH`), over a span of one deck depth divided by the light's elevation (floored at `CLOUD_LIGHT_MIN_SUN_ELEVATION = 0.35`) | self-shadowing is decided within tens of blocks of the sample, while the shadow of the whole bank arrives from its far side; a uniform march of the same step count resolves neither end. The elevation floor is what makes sunrise shadowing stretch sideways instead of lighting the entire bank from within. | +| **zenith** | `CLOUD_SKY_STEPS = 2` steps straight up; analytic on the cheap tier | the deck's own ambient occlusion — how much sky a sample can see. Broad and low-frequency, so two steps are enough. | +| **ground** | analytic from the sample's height and density | sunlight bounced off the lit surface back up into the cloud's base (`CLOUD_GROUND_ALBEDO = 0.22`, Earth's standard neutral value). Soft by nature; marching down for every sample is not affordable and the trend is all that matters. | + +Probes sample `CLOUD_DETAIL_SHAPE` — the coverage-times-profile field with erosion replaced by its +**expected value** (a billow octave averages 0.5, and the Worley octave's remap is fitted to average +0.5 — §4.5). That keeps a probe unbiased about how much cloud is +between the sample and the light without paying for the octave, whereas skipping erosion entirely would +make every probe read the deck as denser than it is. + +### 5.4 Multi-scattering expansion + +`CLOUD_MULTI_SCATTER_OCTAVES = 6` (3 on the cheap tier). Each pass is one **bounce order**: order 0 is +single scattering, order N has been scattered N more times inside the bank before leaving. Rather than +tracing those paths, the same three light terms are re-evaluated with the coefficients scaled by +`k = 0.5^N` — scattering and extinction both shrink (a photon deep in the bank is less affected by the +cloud still in front of it) and the phase relaxes toward `1/4π` by the same k (each bounce randomises the +direction a little more). The geometric series converges, costs no extra march, and is what puts a bright +soft interior, a lit crown and a dark-but-not-black base into the deck. + +Per order: `scatterAmt *= 0.5`, `extinctAmt *= 0.4`, `phaseG *= 0.8`, `powder = lerp(powder, √powder, 0.5)` +— the relaxation schedule the octave model was published with. + +### 5.5 Powder + +`cloudPowder(d) = 1 − e^{−4d}` applied at `CLOUD_POWDER_STRENGTH = 0.7`: ~0 at a thin edge, ~1 in a dense +interior. A single-scattering model brightens a wispy edge as readily as the core; real cloud does the +opposite, because a thin edge transmits its light onward instead of scattering it back, which is what +gives a backlit deck dark, crisp fringes. It relaxes toward 1 as the orders pile up (deep in the bank, +edge darkening no longer applies) and lets go toward the light at `CLOUD_POWDER_SUN_RELAX = 0.8` — at a +backlit edge what reaches the eye is the forward peak, not absorption, so powder that survives there eats +the silver lining it exists to frame. + +### 5.6 Calibration + +`push.lightRadiance` is irradiance-like: surface NEE is `brdf · lightRadiance · ndl` with +`brdf = albedo/π`. So the correct source term for a directional light is `albedo · E · phase(cosT)` and +for isotropic sky radiance simply `L` — the units need no fudge factor. What is left to calibrate is the +deck's brightness, and the real sky answers it: an optically thick, conservative water cloud reflects +~0.75 of the irradiance reaching it spread over the hemisphere, so a sunlit cloud top has a radiance of +about `0.75/π ≈ 0.24 · E` — the same as a white surface facing the sun, which is why clouds and sunlit +snow look equally bright. Summing this expansion for a full-depth sample gives ~0.29 · E before the gain, +so **`CLOUD_SCATTER_GAIN = 0.85`** lands the deck on that number. The single-scattering model it replaced +produced ~0.03 · E, eight times too dim — the measurable reason its interiors read as grey cotton. + +### 5.7 Aerial perspective + +`CLOUD_AERIAL_STRENGTH = 0.6` blends the accumulated scatter toward `skyBehind · (1 − transmittance)` +with distance. The air between the eye and the deck dims the deck's own scatter and puts sky radiance in +its place, which is why distant clouds lose contrast and take on the horizon's colour instead of simply +vanishing. It ramps over the same range as the density fade, so the view-limit cutoff is hidden by cloud +dissolving *into* the sky rather than by cloud being deleted at a line. + +## 6. Marching + +* **Energy-conserving step.** For a homogeneous stride the exact integral is + `S · (σ_s/σ_t) · (1 − e^{−τ})` — the single-scattering albedo times the light the step *absorbed*, + independent of stride length. Accumulating `S · (1 − T)` without the albedo ratio instead makes + brightness a property of the march resolution, so raising the step count brightens the deck and a + coarse step through thin cloud disagrees with a fine one through thick cloud. The previous + fixed-step-count march banded for exactly this reason. +* **Dither.** `cloudDither` hashes `DispatchRaysIndex().xy` with `push.frameIndex` and offsets the march + *start*. A fixed sample pattern puts truncation error at a fixed place (bands across the deck, rings at + its edge, a step in every shadow terminator); moving it per pixel and per frame turns that into + high-frequency noise the temporal denoiser resolves. Pixel-only dither freezes into static, frame-only + dither bands across the screen — both halves are needed. The probe strides are jittered from the same + value. +* **Early exit** at `CLOUD_MIN_TRANSMITTANCE = 0.02`: past that the deck is opaque, nothing behind it can + show through, and every remaining sample is the most expensive thing in the loop. +* **Distance LOD.** The fine erosion octave fades out between `CLOUD_DETAIL_LOD_NEAR = 320` and + `FAR = 1400` blocks. A 6-block lobe is sub-pixel past that, and the atmosphere has already washed the + contrast out of it, so the hashes buy nothing. +* **Two tiers.** `highQuality` (camera rays and specular/mirror bounces) gets 24–48 steps, 6-order + expansion, 6 sun strides, 2 sky steps and full erosion. Diffuse indirect gets 6–12 steps, 3 orders, 2 + sun strides, an analytic sky probe and coarse-only erosion: those bounces contribute a broad sky-fill + term that is averaged over the hemisphere and denoised anyway, and both tiers keep the same extinction + and phase so the *energy* stays right. +* **Preserved from before**, because they fix reported bugs and are still needed: the crossing exclusion + (`crossFade`, which stops a bright band tracking the camera at the deck plane), the + `CLOUD_MAX_SLAB_CROSSINGS = 3.5` cap on grazing horizon rays (which stops the horizon going solid + white), the horizon distance fade, the step count scaling to the marched distance, and the opacity + slider applied **once** to the finished march as a genuine ceiling rather than to extinction. + +## 7. Invariants that must not break + +1. **Periodicity.** Every octave divisor is a power of two and `CLOUD_FIELD_PERIOD_BLOCKS = 24576` is an + integer multiple of every octave's own repeat (24576, 24576, 12288, 3072). The anchor wrap is only + seamless if that holds in *every* space the hash is sampled in; two of the historical breaks were + divisors of 0.9 and 0.35, whose repeats are not whole numbers of periods at any wrap distance. The + vertical axis has no anchor to wrap, but `CLOUD_VERTICAL_CELLS` gives it a 1536-block repeat — over + four times the deepest deck — so the erosion cannot tile visibly inside one cloud. + `RtCloudPeriodMirrorTest` re-derives all of it from both files. +2. **The deck and its shadow read one coverage function** (§4.1). +3. **Vertical coordinates are deck-relative**, never camera-relative (§4.5). +4. **This module imports `world_common` and nothing else.** `world.rmiss` must not pull in `world_core` + (which declares the raygen bindings), so every entry point takes the `WorldPush` explicitly. That is + also why the dither hashes the pixel index itself instead of using `math.slang`'s PCG stream — `math` + imports `world_core` — and why the 3D noise is written here rather than shared. It does mean the + module reads `DispatchRaysIndex()`, so it may only be imported by ray-tracing stages, which every + current importer (`world.rgen`, `world.rmiss`, `fog`) is. +5. **The classic style keeps its flat vanilla face shading** and stays out of the storm-absorption and + aerial-perspective terms. + +## 8. Cost + +A full-quality step is one density evaluation (~40 hash lookups: 12 for the coverage octaves, 12 for the +shear displacement, 8 per 3D erosion octave) plus a 6-stride sun probe plus two sky-probe steps, each +probe sample being a shape-level density (~32 lookups). That is roughly **twice the previous model per +pixel**, and it is the price of the look — texture-based cloud passes are heavier still, and they buy +that budget back by sampling precomputed 3D noise instead of hashing it, which this module cannot do +(§7.4). + +What keeps it affordable, in order of how much they save: the cheap tier on every diffuse bounce; the +`density <= 0` skip, which avoids all three probes in empty air (most of a scattered deck); the +transmittance early exit; the distance LOD dropping the fine octave; and shape-level probes that cost +half a full density each. + +## 9. Tuning guide + +| artefact | knob | +| --- | --- | +| whole deck too bright/dim | `CLOUD_SCATTER_GAIN` (§5.6 — calibrated, move it last) | +| interiors grey/black again | `CLOUD_MULTI_SCATTER_OCTAVES`, `CLOUD_MULTI_SCATTER_FALLOFF` | +| no silver lining on backlit edges | `CLOUD_HG_SILVER`, `CLOUD_PHASE_SILVER`, then `CLOUD_POWDER_SUN_RELAX` | +| clouds see-through / cotton-wool | `CLOUD_EXTINCTION` (optical depth per block; `CLOUD_REFERENCE_THICKNESS` normalises it by slab depth) | +| deck too shallow / too deep for the genus | `CLOUD_DECK_DEPTH_HEAP/SHEET/TOWER` (§4.2) | +| every cloud the same size | `CLOUD_COVERAGE_CLUSTER` (§4.1) | +| every cloud the same height | vigour stretch (§4.3), then `CLOUD_TOWER_COVERAGE_LO/HI` | +| lobes too small / too big | `CLOUD_BILLOW_DIV_COARSE`, `CLOUD_BILLOW_DIV_FINE` (power of two only!) | +| crown reads as soft cotton wool, not aerated cauliflower | `CLOUD_DETAIL_FINE_WEIGHT`, `CLOUD_BILLOW_DIV_FINE` | +| cellular scoops too soft / too jagged | `CLOUD_WORLEY_JITTER` (must stay < 1.0 — §4.5), then re-fit the remap mean | +| detail missing up close | `CLOUD_DETAIL_LOD_NEAR`, `CLOUD_DETAIL_LOD_FAR` (the LOD, not the field) | +| clouds too narrow / too wide | `CLOUD_SHAPE_DIV` (power of two only — see §7.1) | +| edges too soft / too crisp | `CLOUD_EDGE_SHARPEN_BASE`, `CLOUD_EDGE_SHARPEN_CROWN` | +| crowns not breaking up | `CLOUD_EROSION_CROWN`, `CLOUD_WARP_SHEAR` | +| sky is one connected wash, no clean air between clouds | the contrast pass in `cloudVolumetricCoverage` | +| tops sliced flat by the slab ceiling | `CLOUD_CROWN_ROUNDING` / the `crownEnd` clamp (§4.3) | +| every cloud the same height | `CLOUD_TOWER_COVERAGE_LO/HI` (the lift must stay nonlinear) | +| fair weather looks like a storm | `CLOUD_CONVECTION_PEAK` | +| storm sky not different enough | `CLOUD_SHEET_COVERAGE_LO/HI`, `CLOUD_STORM_ABSORPTION` | +| banding | dither (§6) — check both halves of the seed are still there | +| horizon wall of white | `CLOUD_MAX_SLAB_CROSSINGS` | + +## 10. Not done + +* **No 3D detail texture.** Both erosion octaves are generated procedurally — billow value noise and an + exact Worley F1 (§4.5) — where the industry standard samples a precomputed Perlin-Worley shape atlas + plus a Worley detail atlas. The atlases would still win on quality *and* on cost (one trilinear fetch + instead of 27 hashes per sample), but they need a texture binding this module deliberately does not + have (§7.4) and an asset pipeline to produce them. +* **No curl noise** — approximated by shearing a scalar field (§4.4). +* **One deck.** No second high-altitude layer, so no cirrus; the README's TODO still carries that. +* **No temporal reprojection** of the march itself: the dither plus the existing denoiser chain carry it, + but a reprojected half-resolution cloud buffer is what the heavyweight packs do to afford 100+ steps. +* **Shadow softening** for the deck's shadow on the ground is still the analytic `cloudSunShadow` query. diff --git a/shaders/world/clouds.slang b/shaders/world/clouds.slang index c53cbc1e..dc5b6d23 100644 --- a/shaders/world/clouds.slang +++ b/shaders/world/clouds.slang @@ -15,13 +15,21 @@ // Colour is EnvironmentAttributes.CLOUD_COLOR — the game's own per-weather value — so a storm greys // the deck with no hand-tuned ramp. At zero thickness it collapses to the flat sheet. If the map is // unavailable the style degrades to the old noise-quantised deck rather than to nothing. -// * CLOUD_STYLE_VOLUMETRIC — billowy, photoreal cumulus. cloudCoverageField (three octaves of value -// noise) drives a domain-warped fBm density with a cauliflower erosion profile, marched with -// light-marched self-shadowing, Beer-Lambert extinction, a dual-lobe Henyey-Greenstein phase and a -// powder (dark-edge) term. Its field and shape are deliberately untouched by the classic rework. +// * CLOUD_STYLE_VOLUMETRIC — a physically-modelled water-cloud deck, built the way the sky builds one. +// WHERE cloud is comes from a 2D coverage field (a cloud layer really is two-dimensional: one +// condensing air mass); WHAT SHAPE it has comes from a 3D-eroded height profile whose form is chosen +// by a weather-driven GENUS model — a closed stratocumulus sheet, fair-weather cumulus heaps, or +// convective towers in a storm (see CloudWeather). Lighting is three optical-depth probes (toward the +// sun, toward the zenith, and the ground bounce back up into the base) fed through a multi-scattering +// octave expansion, a three-lobe Mie-approximating phase, a powder (dark-edge) term and aerial +// perspective, integrated front-to-back with a dithered march start and an energy-conserving step. +// docs/realistic-volumetric-clouds.md records the model and where every number comes from. // -// Both styles honour the same thickness slider; the weather fill (cloudColor.w) closes either sky -// completely in a storm. +// Both styles honour the same thickness slider — it sets how DEEP the deck is, i.e. how much bulk the +// clouds have, never how far off the ground they sit (that is the height slider's job, and it is the +// deck's base) — and the weather fill (cloudColor.w) closes either sky completely in a storm. In the +// volumetric style the weather also picks the genus, so a storm's deck is deep grey tower cloud and a +// clear day's is scattered heaps, within the same slider budget. // // DEPTH-CORRECT AGAINST THE WORLD. Every entry point that produces visible cloud takes the distance to // the nearest scene hit and stops there, so terrain in front of a cloud occludes it. Getting this wrong @@ -29,7 +37,10 @@ // // Only world_common is imported: world.rmiss must NOT pull in world_core (that module declares the // raygen bindings), so every entry point here takes the WorldPush explicitly, exactly like -// world_common's own worldFeature/worldFlag accessors. +// world_common's own worldFeature/worldFlag accessors. That is also why the march dither hashes the +// pixel index itself instead of using math.slang's PCG stream — math imports world_core. The dither +// does mean this module reads DispatchRaysIndex(), so it may only be imported by ray-tracing stages, +// which every current importer (world.rgen, world.rmiss, fog) is. import world_common; @@ -44,7 +55,7 @@ public static const float CLOUD_CELL_BLOCKS = 12.0; // vanilla's cloud cell size public static const int CLOUD_PERIOD_CELLS = 512; // power of two, so the wrap below is a mask public static const int CLOUD_CELL_MASK = CLOUD_PERIOD_CELLS - 1; // Width of the coverage ramp for the volumetric style. Classic quantises instead (see cloudCoverage). -public static const float CLOUD_EDGE_SOFTNESS = 0.22; +public static const float CLOUD_EDGE_SOFTNESS = 0.15; public static const float3 CLOUD_ALBEDO = float3(0.94, 0.95, 0.98); public static const float CLOUD_INV_PI = 0.31830988618; @@ -62,12 +73,12 @@ public static const uint CLOUD_STYLE_VOLUMETRIC = 1u; // split into two tiers rather than being one flat number: // * FULL is used where the clouds are actually looked at — the camera ray and specular/mirror bounces // (reflections of the sky in water and glass). This is what the player sees, so it gets the light -// march and the full step count. +// probes, the multi-scattering expansion and the full step count. // * CHEAP is used for diffuse indirect bounces. Those contribute a broad, low-frequency sky-fill term // that gets averaged over the hemisphere and then denoised; resolving cloud detail there is // invisible in the result but would multiply the cost by the bounce count. It keeps the same // extinction and phase, so the ENERGY stays right — only the detail is coarser. -// Quality comes from the light march and the phase function rather than raw step count. +// Quality comes from the light probes and the phase function rather than raw step count. public static const int CLOUD_MARCH_STEPS = 24; public static const int CLOUD_LIGHT_STEPS = 6; public static const int CLOUD_MARCH_STEPS_CHEAP = 6; @@ -79,14 +90,124 @@ public static const int CLOUD_MARCH_STEPS_MAX = 48; public static const int CLOUD_MARCH_STEPS_CHEAP_MAX = 12; // Target distance between samples. Comfortably finer than a lobe, so the march resolves shape. public static const float CLOUD_TARGET_STEP_BLOCKS = 6.0; -public static const float CLOUD_EXTINCTION = 0.115; // per block, at density 1 +// Detail levels cloudVolumeDensity accepts. SHAPE is the coverage-times-height-profile field with no +// erosion at all: it is what every light probe samples, because self-shadowing and ambient occlusion +// are decided by the deck's bulk and the crown's cauliflower only costs ALU there. COARSE adds the +// big lobes (the cheap tier's ceiling — it keeps the tier's average density near the full tier's, so +// the two cannot disagree about how much sky the deck closes off), FULL adds the fine erosion on top. +public static const int CLOUD_DETAIL_SHAPE = 0; +public static const int CLOUD_DETAIL_COARSE = 1; +public static const int CLOUD_DETAIL_FULL = 2; +// Erosion detail is worth resolving only while a lobe is larger than a pixel. Past this distance the +// fine octave is faded out (and past the second, the coarse one too), which is both the standard +// volumetric LOD and a real effect: at 2 km a 6-block lobe is sub-pixel, and the atmosphere between +// the eye and the deck has already washed the contrast out of it. +public static const float CLOUD_DETAIL_LOD_NEAR = 320.0; +public static const float CLOUD_DETAIL_LOD_FAR = 1400.0; +public static const float CLOUD_EXTINCTION = 0.42; // per block, at density 1 +// Measured water-cloud extinction is 0.005..0.1 per METRE and rises with liquid water content +// (Kokhanovsky's review of terrestrial cloud optics puts a typical a_ef = 6 um / 0.4 g m^-3 layer at +// about 0.1 m^-1) — and a real cumulus is OPAQUE because it is hundreds of metres deep: optical depth +// 30-100 straight up through one. This deck is a compressed sky, a few tens of blocks deep, so it +// cannot buy that opacity with depth; it buys it per block. At the physical per-metre value the core +// of a cloud here reaches optical depth ~1-3, which renders as see-through cotton wool with no +// shadowed underside — the measurable difference between this constant and a cloud that reads as a +// cloud. 0.42 puts a developed core at optical depth ~7-15 (opaque body, dark base, silver lining at +// the rim) while a fraying edge of a few blocks at low density still transmits, which is exactly the +// split real clouds show between core and wisp. +// +// Single-scattering albedo of liquid cloud is ~0.9999 in the visible — droplets scatter essentially +// everything and absorb nothing — so scattering follows extinction and the small deficit below is the +// only absorption the deck has. Cloud interiors are grey because light SCATTERS OUT of them on the way +// to the eye, not because it is absorbed, which is exactly what the multi-scattering expansion models. +public static const float CLOUD_SINGLE_SCATTER_ALBEDO = 0.9995; // Optical depth is normalised against this thickness, so making the deck taller adds VOLUME without // making it more opaque. Without it, tau = density * sigma * pathLength grows with the slab depth and -// the thickness slider silently doubles as a second opacity slider. +// the deck's depth silently doubles as a second opacity slider — which matters more now that the +// volumetric deck's depth is weather-driven (a storm deck is ~3x deeper than an overcast sheet). public static const float CLOUD_REFERENCE_THICKNESS = 40.0; // Longest in-slab path a single ray may integrate, in slab depths. Bounds the grazing/horizon case // (see cloudMarch); a few crossings already saturate a real cloud. public static const float CLOUD_MAX_SLAB_CROSSINGS = 3.5; +// ---- Multi-scattering expansion (Wrenninge's octave approximation, as used by Frostbite and by +// Horizon Zero Dawn's Nubis). +// +// A cloud is optically thick: a photon crossing one scatters tens of times before it leaves, and +// single scattering alone renders that as flat grey cotton with black interiors. The approximation +// evaluates the single-scattering term N times, once per bounce ORDER, each time with the scattering +// and extinction coefficients scaled by k = FALLOFF^i and the phase relaxed toward isotropic by the +// same k — because every bounce randomises the direction a little more. The series converges +// (sum k = 1/(1-FALLOFF)), needs no extra march, and is what puts the bright soft interior, the +// lit crown and the dark-but-not-black base into the deck. +public static const int CLOUD_MULTI_SCATTER_OCTAVES = 6; +public static const int CLOUD_MULTI_SCATTER_OCTAVES_CHEAP = 3; +public static const float CLOUD_MULTI_SCATTER_FALLOFF = 0.5; +// Per-octave relaxation of the EXTINCTION seen by each successive bounce, and of the phase anisotropy. +// These are the ratios the approximation was published with (Wrenninge's multi-scattering octave model; +// the same schedule the Frostbite and Horizon Zero Dawn cloud implementations ship). Both express one +// physics from two sides: after several bounces a photon has travelled far inside the bank and its +// direction has been randomised, so the remaining cloud in front of it matters less and its scattering +// is less directional. +public static const float CLOUD_MULTI_SCATTER_EXTINCT_FALLOFF = 0.4; +public static const float CLOUD_MULTI_SCATTER_PHASE_FALLOFF = 0.8; +// How far each octave's powder term relaxes toward 1: halfway to its own square root per order, which is +// the relaxation the published octave model uses. Deep inside a bank, where several bounce orders have +// already contributed, the thin-edge darkening of the single-scattering model no longer applies. +public static const float CLOUD_POWDER_OCTAVE_RELAX = 0.5; +// Stop marching once this little light is left to carry. Past it the remaining steps can add at most a +// few percent of the deck's own brightness, and the samples are the expensive part (each one is a light +// probe). Every real-time cloud march ships some form of this exit; the threshold trades the last few +// percent of an already-saturated deck against the cost of its most expensive samples. +public static const float CLOUD_MIN_TRANSMITTANCE = 0.02; +// Overall in-scatter gain — and the one number here that was CALIBRATED rather than chosen, so it is +// worth being precise about what it is calibrated against. +// +// push.lightRadiance is an irradiance-like quantity: surface NEE is `brdf * lightRadiance * ndl`, i.e. +// albedo/PI * E. So the physically correct source term for a directional light in a participating medium +// is S = albedo * E * phase(cosT) with the 4PI-normalised phase this file uses, and for isotropic sky +// radiance L it is simply S = L (the phase integrates to 1). No fudge factor is required by the units. +// +// What is left to calibrate is the deck's own brightness, and the real sky answers it: an optically +// thick, essentially conservative water cloud reflects ~0.75 of the irradiance reaching it, spread over +// the hemisphere, so a sunlit cloud top has a radiance of about 0.75/PI = 0.24 * E — the same as a white +// surface facing the sun, which is why clouds and sunlit snow look equally bright. Summing this file's +// expansion for a full-depth sample (six orders, powder ~0.94, a mid-deck sun optical depth) gives +// ~0.29 * E before the gain, so 0.85 lands the deck on that 0.24 * E within the accuracy of the +// estimate. For comparison the single-scattering model this replaced produced ~0.03 * E — eight times +// too dim, which is the measurable reason its interiors read as flat grey cotton rather than as cloud. +// +// Brightness tuning should still not START here: this is the exposure of the whole deck, and the shape +// knobs are the genus profile, the erosion and the phase. +public static const float CLOUD_SCATTER_GAIN = 0.85; +// Extra extinction carried by precipitating cloud, at full rain+thunder. Water drops that have grown +// large enough to fall scatter and absorb more per unit volume than the droplets that made them, which +// is why a storm's underside reads grey-green rather than merely shadowed. Added to the extinction +// coefficient only (the albedo stays ~1): the deck gets DARKER and DEEPER-looking, not coloured. +public static const float CLOUD_STORM_ABSORPTION = 0.85; +// ---- Light probes. Three optical depths feed the expansion above, each answering "how much cloud is +// between this sample and the light arriving from THAT direction". +// +// The sun/moon probe marches with exponentially growing strides (each twice the last, over roughly one +// deck depth): self-shadowing is decided within a few tens of blocks of the sample, while the last +// strides reach the far side of the bank, and a uniform march of the same step count resolves neither +// end. The zenith probe is two steps — skylight arriving from straight up is the deck's own ambient +// occlusion, and it is a broad low-frequency quantity. The ground probe is analytic: light reflected +// off the sunlit surface back up into the cloud base, estimated from the sample's own height and +// density rather than marched, because marching DOWN through the deck for every sample is not +// affordable and the term is soft by nature. +public static const float CLOUD_LIGHT_STEP_GROWTH = 2.0; +// A low sun must march further to cross the same deck: the probe's span is the deck depth divided by +// this floor on |lightDir.y|, so sunrise/sunset self-shadowing stretches sideways instead of stopping +// after one deck depth and lighting the whole bank from within. +public static const float CLOUD_LIGHT_MIN_SUN_ELEVATION = 0.35; +public static const int CLOUD_SKY_STEPS = 2; +// Ground (planetary) albedo for the bounced-light term. Earth's is ~0.3 over land and ~0.06 over open +// ocean; 0.22 is the standard neutral value shader packs use, and it is what lifts a cloud base from +// black to soft grey on the underside where no direct light reaches. +public static const float CLOUD_GROUND_ALBEDO = 0.22; +// Fraction of this sample's density assumed to fill the deck above it, used for the zenith probe on +// the cheap tier where the two-step march is skipped entirely. +public static const float CLOUD_SKY_ANALYTIC_FILL = 0.5; // Horizontal scale of the volumetric field, relative to the classic 12-block cell grid. Below 1 the // lobes get WIDER (the field is sampled more slowly), which is what lets cloud width grow together with // the extra height so the result reads as heaped cumulus rather than a stretched sheet. @@ -99,21 +220,155 @@ public static const float CLOUD_MAX_SLAB_CROSSINGS = 3.5; // style only (classic samples unscaled, so it always wrapped cleanly). A power of two also makes the // multiply exact in binary floating point, so the identity holds bit-for-bit rather than approximately. public static const float CLOUD_VOLUMETRIC_SCALE = 0.5; -// Octave divisors for the warp and the two billow layers. ALL must be powers of two, for the same -// reason CLOUD_VOLUMETRIC_SCALE must be: each is another space the periodic hash is sampled in, and the -// anchor wrap has to be a whole hash period in every one of them simultaneously. The largest divisor is -// the binding constraint on CLOUD_FIELD_PERIOD_BLOCKS (Java): period >= 512 * CELL * maxDiv / SCALE. +// Octave divisors for the base shape, the warp, the billow octave and the Worley octave. ALL must be +// powers of two, for the same reason CLOUD_VOLUMETRIC_SCALE must be: each is another space the hash is +// sampled in, and the anchor wrap has to be a whole hash period in every one of them simultaneously. +// The largest divisor is the binding constraint on CLOUD_FIELD_PERIOD_BLOCKS (Java): +// period >= 512 * CELL * maxDiv / SCALE, so no divisor may EXCEED CLOUD_WARP_DIV without the Java +// period being raised with it (RtCloudPeriodMirrorTest fails the build if the two disagree). // These were 2.0 / 0.9 / 0.35, and the two non-power-of-two ones desynced the detail layers at every // wrap even after the base field was fixed — the same pop, just from the erosion instead of the shape. +// +// CLOUD_SHAPE_DIV is how the coverage field itself is read in the volumetric style, and it is what +// sets how WIDE an individual cloud is: one coverage cell spans CELL * DIV / SCALE = 48 blocks, so a +// cloud is a few hundred blocks across. That is the deliberate match to the deck's depth — real +// cumulus are about as wide as they are tall, and a storm deck is now ~190 blocks deep (RtCloudDeck), +// so the coarsest shape the wrap period allows is also the physically right one. +public static const float CLOUD_SHAPE_DIV = 2.0; public static const float CLOUD_WARP_DIV = 2.0; -public static const float CLOUD_BILLOW_DIV_COARSE = 1.0; +public static const float CLOUD_BILLOW_DIV_COARSE = 2.0; public static const float CLOUD_BILLOW_DIV_FINE = 0.25; -// How much wider a cloud gets around its belly than at its base/crown. Turns a straight-sided extrusion -// into a rounded, bulging mass — see the note in cloudVolumeDensity. -public static const float CLOUD_BULGE = 0.30; -public static const float CLOUD_HG_G = 0.72; // forward-scattering anisotropy of cloud droplets -public static const float CLOUD_HG_BACK = -0.28; // weak backward lobe (see cloudPhase) -public static const float CLOUD_POWDER_STRENGTH = 0.7; // dark-edge / multiple-scattering approximation +// Vertical lattice period of the 3D detail hash, in cells. The horizontal axes wrap at +// CLOUD_PERIOD_CELLS because the anchor wraps; the vertical axis has no anchor to wrap (it is measured +// from the deck's own base), so this mask exists purely to keep the cell index small and the hash +// well-conditioned. It has to be large enough that the field cannot repeat inside one deck: at the +// finest octave a cell is 3 blocks, so 256 cells is 768 blocks — four times deeper than the deepest +// slab the model ever pushes. +public static const int CLOUD_VERTICAL_CELLS = 256; +public static const int CLOUD_VERTICAL_MASK = CLOUD_VERTICAL_CELLS - 1; +// How far the clustering mask may shift the coverage threshold up or down (§4.1): the width of the +// sky's size distribution — 0 gives every cloud the same skirt and the same size. +public static const float CLOUD_COVERAGE_CLUSTER = 0.5; +// ---- Genus model. The three shapes the real sky shows at this altitude, and the coverage/weather +// that selects between them (WMO genera; base heights and depths are mid-latitude values): +// * SHEET — stratocumulus / stratus, and nimbostratus once it precipitates. Continuous, shallow +// (300-600 m), flat-topped, grey: the sky closes over and the deck stops developing vertically. +// * HEAP — fair-weather cumulus (humilis/mediocris). Individual clouds roughly as wide as they are +// deep, a flat base at the lifting condensation level and a billowing crown. +// * TOWER — cumulus congestus and cumulonimbus. Convective, several times deeper, crown filling the +// whole slab: thunderstorm weather, and the only genus that reaches the top of the deck. +// Coverage selects sheet-vs-heap (a closed sky is a sheet, a scattered one is heaps) and the weather +// lanes select tower. There is deliberately NO slider for this: it is the same rain/thunder state the +// sky darkening, the light attenuation and the fog all read, so a storm's deep grey deck, its dimmed +// sun and its thickened air are one reading of one state. +public static const float CLOUD_SHEET_COVERAGE_LO = 0.55; +public static const float CLOUD_SHEET_COVERAGE_HI = 0.92; +// How DEEP the deck is, in blocks, per genus — and note what is NOT here: no slider. A fair-weather +// cumulus is a few hundred blocks wide and half that deep, an overcast sheet is a shallow lid and a +// storm tower fills the sky; those depths are a property of the genus, so the volumetric deck derives +// its slab from the same coverage/weather reading that picks the profile, and the thickness option +// shapes only the classic boxes (the options screen says exactly that). Deriving it here instead of +// pushing it from Java keeps the depth and the profile one reading of one state, which is the same +// reason the genus model exists at all. +public static const float CLOUD_DECK_DEPTH_HEAP = 128.0; +public static const float CLOUD_DECK_DEPTH_SHEET = 64.0; +public static const float CLOUD_DECK_DEPTH_TOWER = 192.0; +// Height-profile endpoints per genus, as fractions of the slab. BASE_RAMP is how fast density comes in +// above the condensation level (a cumulus base is sharp, a stratus base frays into mist); CROWN_START +// is where the top begins to round off. Measured liquid-water profiles of shallow convective layers +// ramp up roughly linearly from cloud base and decay near the top as dry air is entrained, so the +// profile is asymmetric on purpose — a symmetric lens is what makes a procedural deck read as a slab. +public static const float CLOUD_BASE_RAMP_HEAP = 0.06; +public static const float CLOUD_BASE_RAMP_SHEET = 0.22; +public static const float CLOUD_CROWN_START_HEAP = 0.48; +public static const float CLOUD_CROWN_START_SHEET = 0.48; +public static const float CLOUD_CROWN_START_TOWER = 0.94; +public static const float CLOUD_CROWN_ROUNDING = 0.30; +// Dense cores tower. The crown's height is lifted by the LOCAL coverage through this smoothstep, so a +// bank develops cauliflower towers out of its thick parts while its wispy edges stay low and flat — and +// so one sky holds low fringes, mid-height heaps and tall towers at once, each where its own density +// says it belongs. A linear lift cannot do that: it makes every cloud in the sky the same height. +public static const float CLOUD_TOWER_COVERAGE_LO = 0.45; +public static const float CLOUD_TOWER_COVERAGE_HI = 0.95; +// Ceiling of the coverage-driven crown lift. Without a cap the lift runs to 1.0, and then almost every +// dense core closes its dome at the slab top: the sky fills with 150-block COLUMNS instead of clouds, +// because a cumulus core is dense by nature and the lift read that density as "tower". Real fair-weather +// cores dome at 0.6-0.8 of a deck this deep; only convection (storm, or a sunny day building) takes the +// crown past that, up to CLOUD_CROWN_START_TOWER. +public static const float CLOUD_CROWN_LIFT_MAX = 0.78; +// Clear-sky peak of the convection parabola. The raw parabola 4c(1-c) peaks at 1.0 for 50% coverage, +// which would make every scattered fair-weather sky a slab-filling congestus; the published cumulus +// height gradient closes its dome at about half the layer instead, so the clear-sky peak is scaled down +// and thunderstorms (w.convection's other input) carry the full range on their own. +public static const float CLOUD_CONVECTION_PEAK = 0.30; +// How much wider a cloud gets around its belly than at its base/crown, for the heaped genus. Turns a +// straight-sided extrusion into a rounded, bulging mass; a sheet has no belly, so it is scaled out. +public static const float CLOUD_BULGE = 0.42; +public static const float CLOUD_BELLY_HEIGHT = 0.42; +// ---- Turbulent displacement. Real decks are sheared: wind speed and direction change with altitude, +// so updrafts lean downwind and the crown curls while the base stays flat. Displacing the sample +// position by a low-frequency noise before the detail octaves are read is the standard stand-in for +// that (HZD/Frostbite use a curl-noise texture; this uses the periodic value noise the module already +// has, because a deck that must survive an anchor wrap cannot sample an aperiodic texture). +// Amplitude in blocks, and how much harder the crown is displaced than the base. +public static const float CLOUD_WARP_AMPLITUDE_BLOCKS = 26.0; +public static const float CLOUD_WARP_SHEAR = 2.2; +// Fraction of the horizontal displacement also applied to the vertical coordinate, which is what leans +// a tower over instead of merely smearing its footprint. +public static const float CLOUD_WARP_VERTICAL = 0.55; +// ---- Detail erosion. A coarse 3D billow octave plus a fine 3D Worley (cellular) octave, weighted, +// erode the shape field's edge: the Perlin-Worley FBM pair, generated procedurally instead of sampled. +public static const float CLOUD_DETAIL_FINE_WEIGHT = 0.45; +// Feature-point jitter for the Worley octave, as a fraction of a cell (+-0.4 at 0.8). Staying under 0.5 +// is what keeps the nearest feature point inside the 3x3x3 neighbourhood, i.e. what makes 27 taps an +// exact F1 rather than an approximation. +public static const float CLOUD_WORLEY_JITTER = 0.8; +// Remap of the raw F1 into the erosion field's [0,1]. Chosen NUMERICALLY, by simulating this exact +// lattice and jitter over 26^3 samples: raw F1 averages 0.511 there, and with these constants the +// remapped field averages 0.500 — which is what keeps the SHAPE tier's expected-value substitution +// unbiased — while ~25% of it lands on the clamps, and that clipping is precisely the crisp +// scoop-and-ridge character cellular noise is used for. Touching these without re-running that +// simulation silently re-biases every light probe. +public static const float CLOUD_WORLEY_REMAP_SCALE = 2.55; +public static const float CLOUD_WORLEY_REMAP_BIAS = -0.81; +// How hard the erosion bites at the coverage edge and at the slab extremes, relative to the core. A +// cloud's interior stays solid — real cumulus cores reach 1-2 g m^-3 of liquid water while the fraying +// edge is a fraction of that — and uniform erosion is what makes procedural cloud read as flat fluff. +public static const float CLOUD_EROSION_EDGE = 0.75; +public static const float CLOUD_EROSION_CROWN = 0.70; +public static const float CLOUD_EROSION_BASE = 0.45; +// Edge sharpening exponent, wispy at the base and hard at the crown — the standard shaping control of +// real-time cloud models: +// > 1 thins the field (a fraying, mist-like underside), < 1 fattens it (a hard, well-defined top). +public static const float CLOUD_EDGE_SHARPEN_BASE = 1.75; +public static const float CLOUD_EDGE_SHARPEN_CROWN = 0.80; +// ---- Phase and powder. +// Mie scattering by ~10 um droplets is strongly forward-peaked, with a very tight diffractive spike +// sitting inside a broad glow, plus a weak backward lobe from internal reflection. Three HG lobes in a +// normalised mixture (weights sum to 1, so the phase still integrates to 1 over the sphere and the +// expansion cannot invent energy) approximate that for a fraction of a real Mie table: +// * BROAD — the general sun-side brightening of a lit cloud; +// * SILVER — the tight peak, which IS the silver lining on a backlit cloud edge; +// * BACK — what stops the shadow side collapsing into a silhouette. +// A weighted sum of HG lobes is the usual cheap stand-in for a Mie table; three lobes is the minimum +// that carries a forward peak, a broad glow and a back lobe at the same time. +public static const float CLOUD_HG_G = 0.60; +public static const float CLOUD_HG_SILVER = 0.88; +public static const float CLOUD_HG_BACK = -0.22; +public static const float CLOUD_PHASE_BROAD = 0.50; +public static const float CLOUD_PHASE_SILVER = 0.30; +public static const float CLOUD_PHASE_BACK = 0.20; +// Powder / dark-edge term: a thin edge transmits its light onward instead of scattering it back, which +// single scattering does not predict and whose absence is why cheap volumetric clouds look like flat +// grey cotton. It relaxes toward the light — at a backlit edge what reaches the eye is the forward peak, +// not absorption — or it eats the silver lining it exists to frame. +public static const float CLOUD_POWDER_STRENGTH = 0.85; +public static const float CLOUD_POWDER_SUN_RELAX = 0.8; +// ---- Aerial perspective. The air between the eye and the deck dims the deck's own scatter and puts +// sky radiance in its place, which is why distant clouds lose contrast and take on the horizon's +// colour rather than simply vanishing. Blended over the same range as the density fade below it, so +// the view-limit cutoff is hidden by cloud dissolving INTO the sky instead of by cloud being deleted. +public static const float CLOUD_AERIAL_STRENGTH = 0.6; // Classic clouds are opaque boxes; this scales their extinction so a box saturates within its own // thickness rather than looking like translucent haze. public static const float CLOUD_CLASSIC_EXTINCTION_SCALE = 6.0; @@ -191,6 +446,147 @@ float cloudNoise(float2 p) { return lerp(lerp(a, b, u.x), lerp(c, d, u.x), u.y); } +// ---- 3D noise (the volumetric style's detail and turbulence). +// +// WHY THREE DIMENSIONS. The old field was a 2D noise times a height profile, which makes a cloud a +// VERTICAL EXTRUSION of one flat pattern: seen from the side its lobes line up through the whole depth, +// seen from above it is a picture, and the crown never breaks into individual cauliflower heads. Real +// cloud is a 3D structure — a parcel rises, condenses, entrains dry air at its edges and rolls into +// lobes at every height independently — and that is the single biggest tell between "procedural deck" +// and "cloud". So erosion is sampled from a 3D lattice, while WHERE the cloud is stays a 2D field +// (which is what the real sky looks like too: coverage is a property of the layer, shape of the parcel). +// +// The hash keeps the 2D field's exact periodicity on the two horizontal axes — same mask, same lattice, +// so the anchor wrap that CLOUD_FIELD_PERIOD_BLOCKS (Java) guarantees stays seamless for the detail as +// well as for the shape. The vertical axis has no anchor to wrap and uses its own larger mask. +uint cloudHash3Bits(int3 cell) { + uint3 c = uint3(uint(cell.x & CLOUD_CELL_MASK), uint(cell.y & CLOUD_VERTICAL_MASK), + uint(cell.z & CLOUD_CELL_MASK)); + uint h = c.x * 374761393u + c.y * 668265263u + c.z * 2246822519u; + h = (h ^ (h >> 13u)) * 1274126177u; + h = h ^ (h >> 16u); + return h & 0x00ffffffu; +} + +float cloudHash3(int3 cell) { + return float(cloudHash3Bits(cell)) * (1.0 / 16777216.0); +} + +// Trilinear value noise on the 3D lattice: eight corner hashes, smoothstep-interpolated on each axis. +float cloudNoise3(float3 p) { + int3 i = int3(floor(p)); + float3 f = frac(p); + float3 u = f * f * (3.0 - 2.0 * f); + float n00 = lerp(cloudHash3(i + int3(0, 0, 0)), cloudHash3(i + int3(1, 0, 0)), u.x); + float n10 = lerp(cloudHash3(i + int3(0, 0, 1)), cloudHash3(i + int3(1, 0, 1)), u.x); + float n01 = lerp(cloudHash3(i + int3(0, 1, 0)), cloudHash3(i + int3(1, 1, 0)), u.x); + float n11 = lerp(cloudHash3(i + int3(0, 1, 1)), cloudHash3(i + int3(1, 1, 1)), u.x); + return lerp(lerp(n00, n10, u.z), lerp(n01, n11, u.z), u.y); +} + +// Billow shaping: 1 - |2n - 1| folds value noise into rounded cellular lobes (peaks on the lattice +// boundaries, valleys at the cell centres) instead of a cloudy blur. That rounded-cellular read is the +// cauliflower — the same reason the old 2D billows used it, now in three dimensions so the lobes have +// depth as well as width. +float cloudBillow3(float3 p) { + return 1.0 - abs(cloudNoise3(p) * 2.0 - 1.0); +} + +/** + * 3D Worley (cellular) noise: F1, the distance to the nearest feature point of a jittered lattice. + * + * This is the other half of the Perlin-Worley pair that every texture-based cloud implementation erodes + * with (HZD, Frostbite, UE5, and the Minecraft shader packs that followed them), generated here instead + * of sampled from an atlas: value/billow noise gives rounded lobes with SOFT boundaries, while F1's + * ridge-and-cell structure is what carves the aerated, scooped silhouette — a crown that reads as a + * cluster of overlapping bubbles with crisp concavities between them rather than as a soft blur. Mixed + * as an FBM (coarse billow for the lobes, fine Worley for the cells) it is exactly the combination the + * technique is known for. + * + * Exactness: with feature points jittered by at most +-0.4 of a cell the nearest one is always inside + * the 3x3x3 neighbourhood, so 27 taps is an EXACT F1 rather than an approximation. Cost: one hash per + * tap, from which all three jitter components are unpacked as independent 8-bit slices — which is why + * this octave lives only at the FULL tier and behind the distance LOD. + * + * Periodicity is inherited from cloudHash3Bits: the lattice wraps on both horizontal axes and on the + * vertical mask, so the cellular field survives the anchor wrap like every other field in this module. + */ +float cloudWorley3(float3 p) { + int3 c = int3(floor(p)); + float3 f = frac(p); + float best = 8.0; // strictly above the largest squared distance the 27 taps can produce (3 * 1.5^2) + for (int z = -1; z <= 1; z++) { + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + uint h = cloudHash3Bits(c + int3(x, y, z)); + float3 j = (float3(float(h & 255u), float((h >> 8u) & 255u), float((h >> 16u) & 255u)) + * (1.0 / 255.0) - 0.5) * CLOUD_WORLEY_JITTER; + float3 d = float3(float(x), float(y), float(z)) + 0.5 + j - f; + best = min(best, dot(d, d)); + } + } + } + return sqrt(best); +} + +// One noise octave's sample coordinate: horizontal is the anchored deck space, vertical is BLOCKS ABOVE +// THE DECK BASE. Using the camera-relative Y instead would make a cloud's internal structure swim as +// the camera rises or falls (the field would be pinned to the eye, not to the world); the slab base is +// world-stable, so a height measured from it is too. Both axes use the same scale, so lobes are +// isotropic — a cloud is not stretched by the deck's depth. +float3 cloudDetailCoord(float2 sampleXZ, float heightAboveBase, float div) { + float s = CLOUD_VOLUMETRIC_SCALE / (CLOUD_CELL_BLOCKS * div); + return float3(sampleXZ.x * s, heightAboveBase * s, sampleXZ.y * s); +} + +// ---- Weather-driven genus model (see the CLOUD_SHEET_* constants for the real-world basis). +// +// One struct resolved ONCE per march from lanes the frame already pushes, so every part of the deck — +// height profile, bulge, erosion, absorption, and the depth RtCloudDeck pushes from Java — describes +// the same cloud type instead of three parts disagreeing about what the sky is doing. +public struct CloudWeather { + public float coverage; // clear-sky slider merged with the weather fill, 0..1 + public float sheet; // 0 = heaped cumulus, 1 = closed stratocumulus/stratus sheet + public float convection; // 0 = no vertical development, 1 = congestus/cumulonimbus towers + public float absorbing; // extra extinction from precipitating cloud (rain darkening), 0..1 +}; + +public CloudWeather cloudWeather(WorldPush push) { + CloudWeather w; + float retain = clamp(push.clouds.x, 0.0, 1.0); + float fill = clamp(push.cloudColor.w, 0.0, 1.0); + w.coverage = clamp(retain + (1.0 - retain) * fill, 0.0, 1.0); + bool weatherOn = worldFeature(push, FEATURE_WEATHER_LIGHTING); + float rain = weatherOn ? clamp(push.weather.x, 0.0, 1.0) : 0.0; + float thunder = weatherOn ? clamp(push.weather.y, 0.0, 1.0) : 0.0; + // A closed sky is a SHEET. Scattered coverage is heaps: individual clouds with clear air between + // them. That is how the real sky behaves — stratocumulus and stratus form under an inversion that + // caps vertical development and covers everything, while fair-weather cumulus convect into a + // scattered field — so the coverage slider now changes cloud GENUS, not just density. + w.sheet = smoothstep(CLOUD_SHEET_COVERAGE_LO, CLOUD_SHEET_COVERAGE_HI, w.coverage); + // Convection peaks at intermediate coverage (a sunny day building cumulus) and in thunderstorms, + // where the tower is the storm. An overcast sheet suppresses it: nothing is being heated from + // below. 4*c*(1-c) is the parabola through that behaviour, peaking at 50% coverage. + float scattered = 4.0 * w.coverage * (1.0 - w.coverage) * CLOUD_CONVECTION_PEAK; + w.convection = clamp(max(scattered * (1.0 - w.sheet), thunder), 0.0, 1.0); + // Precipitating cloud absorbs: droplets grow past the size that scatters cleanly, and the deck + // darkens toward grey-green. This is the HZD rain-cloud term, and it is why a storm deck reads + // heavier than an equally thick clear-sky one instead of merely wider. + w.absorbing = clamp(rain * 0.55 + thunder * 0.45, 0.0, 1.0); + return w; +} + +/** + * How deep the volumetric slab is, in blocks, for the current genus: heaps get a deck as deep as a + * fair-weather cumulus is tall, sheets a shallow lid, towers the whole convective layer. This is the + * depth the march integrates and every probe measures against — the volumetric deck's answer to "how + * thick is a cloud", asked of the sky instead of of a slider. + */ +float cloudDeckDepth(CloudWeather w) { + return lerp(lerp(CLOUD_DECK_DEPTH_HEAP, CLOUD_DECK_DEPTH_SHEET, w.sheet), + CLOUD_DECK_DEPTH_TOWER, w.convection); +} + /** * Raw coverage field in [0,1], before either style interprets it. Three octaves at INTEGER scales with * whole-cell offsets, so every octave shares the base period and the sum stays exactly periodic — see @@ -216,6 +612,46 @@ bool cloudCellOccupied(WorldPush push, int2 cell) { return (map[index >> 5u] & (1u << (index & 31u))) != 0u; } +/** + * The volumetric style's coverage ramp: the raw field read at the SHAPE scale (one cell = 48 blocks, so + * a cloud is a few hundred blocks across), thresholded by the weather-merged coverage and softened over + * CLOUD_EDGE_SOFTNESS. + * + * ONE function, three callers — the visible density, the cloud-shadow query and the flat-sheet + * fallback — because this module's founding invariant is that the deck and the shadow it casts must + * never disagree about where cloud is. It used to be evaluated twice with DIFFERENT inputs: the shadow + * merged the weather fill in, the density did not, so in rain the deck stayed at the slider's coverage + * while its shadow closed the sky completely. That cannot happen now. + * + * CLOUD_SHAPE_DIV is read through cloudCoverageField's own three integer octaves, and the whole field + * stays exactly periodic under the anchor wrap: the shape scale puts one coverage cell at 48 blocks, + * so a period of 24576 blocks is 512 cells — the hash's exact repeat — and the 2x/4x octaves land on + * 1024/2048. See the note on CLOUD_WARP_DIV before changing any divisor. + */ +float cloudVolumetricCoverage(float coverage, float2 samplePos) { + if (coverage >= 1.0) { + return 1.0; // a full storm is a full sky: no residue of pinholes under the threshold + } + float threshold = 1.0 - clamp(coverage, 0.0, 1.0); + // Clustering: a second, independent reading of the SAME lattice shifts the threshold up and down + // across the sky, so in one region neighbours merge into one big mass and in the next they shrink + // to scattered fragments with genuine clear air between the groups. A single global threshold + // gives every cloud the same soft skirt and the same size — a sky of evenly spaced puffs, which + // is not a sky. Same lattice space as the field itself, so the wrap identity is untouched. + float mask = cloudCoverageField(samplePos * (CLOUD_VOLUMETRIC_SCALE / CLOUD_SHAPE_DIV) + + float2(137.0, 291.0)); + threshold = clamp(threshold + (mask - 0.5) * CLOUD_COVERAGE_CLUSTER, 0.0, 1.0); + float shape = cloudCoverageField(samplePos * (CLOUD_VOLUMETRIC_SCALE / CLOUD_SHAPE_DIV)); + // Contrast, applied to the field BEFORE the threshold. A raw value-noise field is a Gaussian-ish wash + // around 0.5, so thresholding it directly gives every cloud the same soft wide skirt and leaves no + // clean air between neighbours — a sky of connected blobs rather than a sky of clouds. Pushing the + // field toward its own extremes first is what separates individual clouds with real gaps between + // them and gives each one a crisp edge: most of the difference between a cloudy wash and the + // scattered cumulus of a fair-weather sky. + shape = shape * shape * (3.0 - 2.0 * shape); + return smoothstep(threshold, threshold + CLOUD_EDGE_SOFTNESS, shape); +} + /** * Coverage resolved into a 0..1 "how much cloud is here" for the current style. * @@ -265,12 +701,7 @@ public float cloudCoverage(WorldPush push, float2 samplePos) { bool filled = cloudHash(wrapped + int2(127, 61)) < fill; return (authored || filled) ? 1.0 : 0.0; } - float coverage = retain + (1.0 - retain) * fill; - if (coverage >= 1.0) { - return 1.0; - } - float threshold = 1.0 - clamp(coverage, 0.0, 1.0); - return smoothstep(threshold, threshold + CLOUD_EDGE_SOFTNESS, cloudCoverageField(samplePos)); + return cloudVolumetricCoverage(retain + (1.0 - retain) * fill, samplePos); } /** Where a ray meets the deck, in the form both the visible pass and the shadow query need. */ @@ -583,34 +1014,97 @@ CloudVolume cloudClassicBoxes(WorldPush push, float3 originRel, float3 dir, floa } // ---- Volumetric shading -------------------------------------------------------------------------- +// +// This is the deck's light transport, and it is where "procedural cloud" turns into "cloud". Four +// questions, in the order a photon meets them: +// +// 1. WHERE is the cloud? cloudVolumeDensity — a 2D coverage field (what a cloud LAYER is) times a +// 3D-eroded height profile (what an individual cloud is), the profile's shape chosen by the +// weather-driven genus model above. +// 2. HOW MUCH light reaches a sample? The three optical-depth probes. Self-shadowing, ambient +// occlusion and ground bounce are three questions about three different directions, and answering +// only the first is what leaves a deck looking like lit cotton wool with a black underside. +// 3. HOW does it scatter? The multi-scattering expansion in cloudMarch. Cloud is optically thick — +// measured optical depth of low water cloud is 12..92 — so a photon scatters tens of times before +// it escapes, and single scattering renders that as a flat grey silhouette. +// 4. WHAT does the air in between do? Aerial perspective, so the deck fades INTO the sky at the view +// limit instead of being deleted there. +// +// Cost, stated honestly: a full-quality step is one density evaluation (coverage + turbulence + two 3D +// erosion octaves, ~40 hash lookups) plus a sun probe of CLOUD_LIGHT_STEPS shape-level densities plus +// two sky-probe steps. That is roughly twice the previous model per pixel, and it is the price of the +// look. What keeps it affordable: the cheap tier on diffuse bounces, shape-level probes, the distance +// LOD that drops the fine octave, and the transmittance early exit that stops the march once the deck +// has gone opaque. -/** Single Henyey-Greenstein lobe. */ +// 4π and its reciprocal: hgLobe's normalisation, and the isotropic phase the multi-scattering expansion +// relaxes toward as the bounce order rises. +public static const float CLOUD_FOUR_PI = 12.566370614359172; +public static const float CLOUD_INV_FOUR_PI = 0.07957747154594767; + +/** Single Henyey-Greenstein lobe, normalised to integrate to 1 over the sphere. */ float hgLobe(float cosT, float g) { float g2 = g * g; float d = 1.0 + g2 - 2.0 * g * cosT; - return (1.0 - g2) / (4.0 * 3.14159265359 * max(d * sqrt(max(d, 1.0e-6)), 1.0e-4)); + return (1.0 - g2) / (CLOUD_FOUR_PI * max(d * sqrt(max(d, 1.0e-6)), 1.0e-4)); } /** - * Dual-lobe phase. A single forward lobe gives the sun-facing glow but leaves the rest of the cloud - * flat; real droplet scattering also has a weak backward lobe, which is what lights the side of a cloud - * facing away from the sun and stops it collapsing into a silhouette. Mixing the two is the standard - * cheap stand-in for a full Mie phase and costs one extra lobe evaluation. + * Phase function: a normalised mixture of three Henyey-Greenstein lobes — a broad forward glow, the + * tight forward peak that IS the silver lining on a backlit edge, and a weak backward lobe. See the + * CLOUD_PHASE_* constants for the Mie reasoning and the weights. + * + * `g` is a parameter because the multi-scattering expansion relaxes it per bounce order: every + * scattering randomises the direction a little more, so the phase drifts toward isotropic. All three + * lobes scale with it (the silver peak most of all — it must not survive six bounces it should not), + * and because the weights sum to 1 the mixture stays normalised at every order. */ -float cloudPhase(float cosT) { - return lerp(hgLobe(cosT, CLOUD_HG_BACK), hgLobe(cosT, CLOUD_HG_G), 0.7); +float cloudPhaseG(float cosT, float g) { + float ratio = g / CLOUD_HG_G; + float broad = hgLobe(cosT, g); + float silver = hgLobe(cosT, min(CLOUD_HG_SILVER * ratio, 0.97)); + float back = hgLobe(cosT, CLOUD_HG_BACK * ratio); + return CLOUD_PHASE_BROAD * broad + CLOUD_PHASE_SILVER * silver + CLOUD_PHASE_BACK * back; } /** - * Powder / dark-edge term. Multiple scattering makes the dense INTERIOR of a cloud brighter than its - * thin edges when lit from behind the viewer — the opposite of what single-scattering alone predicts, - * and its absence is why cheap volumetric clouds look like flat grey cotton. Approximated from local - * density, which is the standard Frostbite/Horizon trick. + * Powder / dark-edge term, in [0,1): ~0 at a thin edge, ~1 in a dense interior. + * + * A single-scattering model brightens a wispy edge as readily as it brightens the core, and real cloud + * does the opposite — thin edges transmit their light onward instead of scattering it back, which is + * what gives a backlit deck dark, crisp fringes. Approximated from local density, the standard + * Frostbite/Horizon trick, and applied with CLOUD_POWDER_STRENGTH so the caller controls how hard it + * bites. It is then RELAXED toward 1 per octave inside the expansion (CLOUD_POWDER_OCTAVE_RELAX), + * because by the third bounce the photon is deep in the bank and edge darkening no longer applies. */ float cloudPowder(float density) { return 1.0 - exp(-density * 4.0); } +/** + * Per-pixel, per-frame march dither, in [0,1). + * + * A ray march with a fixed sample pattern puts its truncation error at a fixed PLACE: bands, rings + * around the deck's edge, and a step in every shadow terminator. Offsetting the march start by a hash + * of the pixel index and the frame counter moves that error to a different pixel every frame, which + * turns banding into high-frequency noise the temporal denoiser resolves — the standard rotating + * Bayer/blue-noise trick, needing no texture. + * + * Two deliberate choices here. It is NOT math.slang's PCG stream: this module imports world_common and + * nothing else, so every stage that draws clouds (including the guide and shadow miss shaders, which + * declare no buffers at all) stays free of world_core's bindings — a plain 32-bit integer hash is four + * instructions and carries no stream state. And DispatchRaysIndex() is safe: clouds is imported only by + * fog, world.rgen and world.rmiss, all ray-tracing stages, and the flat primary pass never calls in. + */ +float cloudDither(WorldPush push) { + uint2 pixel = uint2(DispatchRaysIndex().xy); + uint h = pixel.x * 1973u + pixel.y * 9277u + push.frameIndex * 26699u; + h = (h ^ (h >> 15u)) * 2246822519u; + h = (h ^ (h >> 13u)) * 3266489917u; + h = h ^ (h >> 16u); + return float(h & 0x00ffffffu) * (1.0 / 16777216.0); +} + /** * 3D density inside the slab, per style. * @@ -619,18 +1113,24 @@ float cloudPowder(float density) { * what makes the marched result read as vanilla's solid cuboid clouds rather than a soft blob, and it * is why the classic style still looks like Minecraft once it has real height. * - * VOLUMETRIC is cumulus. Three things do the work, and the previous version had none of them, which is - * why it looked like a thin sheet: - * * a CAULIFLOWER height profile — the cloud bulges outward toward its middle and rounds off at the - * top, instead of tapering symmetrically like a lens. Real cumulus has a flat-ish base and a - * billowing crown, so the profile is asymmetric on purpose; - * * DOMAIN WARPING — the sample position is displaced by a low-frequency noise before the detail - * octaves are read, which turns straight, gridded edges into curling, rounded lobes. This is the - * single biggest contributor to a cloud reading as "puffy" rather than "noisy"; - * * a coverage BOOST toward the core, so the middle of a cloud is genuinely opaque. A field that only - * ever reaches ~0.5 density can never look thick no matter how deep the slab is. + * VOLUMETRIC is built the way the sky builds a deck, in three layers: + * * COVERAGE — the 2D field, weather-merged, deciding WHERE cloud is. A cloud layer's horizontal + * structure is genuinely two-dimensional (it is one condensing air mass), so this stays 2D and is + * shared with the shadow query; + * * HEIGHT PROFILE — the genus model deciding how deep the cloud develops at that spot: flat base at + * the condensation level, crown rounding off where convection runs out of steam, belly bulging + * outward. This is what makes a heap a heap and a sheet a sheet; + * * EROSION — a coarse 3D billow octave plus a fine 3D Worley (cellular) octave subtracting from + * the profile. The third dimension lives HERE, and it is the difference between a cloud and a + * slab: because the noise varies with height, + * the crown breaks into individual cauliflower heads instead of the lobes lining up vertically the + * way a 2D pattern extruded through a height profile always does. + * + * `detail` is one of CLOUD_DETAIL_* (the light probes ask for less than the visible march), `detailFade` + * is the distance LOD on the fine octave, and both are explained at their constants. */ -float cloudVolumeDensity(WorldPush push, float3 posRel, float heightFrac) { +float cloudVolumeDensity(WorldPush push, CloudWeather w, float3 posRel, float heightFrac, + float slabDepth, int detail, float detailFade) { float2 samplePos = push.cloudAnchor.xy + posRel.xz; float hf = clamp(heightFrac, 0.0, 1.0); @@ -645,92 +1145,287 @@ float cloudVolumeDensity(WorldPush push, float3 posRel, float heightFrac) { return coverage * faces; } - // ---- Volumetric cumulus. - // - // The goal here is HEAPED cloud — thick, wide, vertically developed, the way real cumulus and - // Photon-style shader clouds look. The previous version was flat for a measurable reason: its lobes - // were ~24 blocks across but only ~14 blocks of real density tall (a 1.7:1 layer), because the - // profile faded out over the top 65% of the slab. Real cumulus is roughly 1:1 to 1:2 width:height. - // Three changes fix that, and they are all about the aspect ratio rather than about adding detail: - // * the body now holds full density across most of the slab and only rounds off at the very top, - // so the cloud actually USES the height the thickness slider gives it; - // * the horizontal field is sampled at a larger scale, making lobes broader and puffier instead - // of small and busy — width and height grow together, so it reads as heaped, not stretched; - // * erosion is concentrated at the extreme top and bottom, leaving the middle solid, which is - // what gives a cloud a dense core with a cauliflower crown rather than uniform fluff. - float base = cloudCoverageField(samplePos * CLOUD_VOLUMETRIC_SCALE); - float threshold = 1.0 - clamp(push.clouds.x, 0.0, 1.0); - float coverage = smoothstep(threshold, threshold + CLOUD_EDGE_SOFTNESS, base); + // ---- Coverage: where the cloud is. One shared function, so the deck and its shadow cannot disagree. + float coverage = cloudVolumetricCoverage(w.coverage, samplePos); if (coverage <= 0.0) { return 0.0; } - // Vertical development. A cumulus has a hard flat base (condensation level), a tall body that stays - // dense, and a rounded crown. The top fade starts high (0.62) so the body occupies most of the slab - // instead of tapering away from the middle — this is the single change that makes the cloud thick. - // Taller clouds also get to be taller: coverage scales how far up the crown reaches, so dense - // regions billow upward into towers while thin edges stay low and flat, exactly like the real sky. - float towerTop = lerp(0.35, 1.0, coverage); - float bottom = smoothstep(0.0, 0.10, hf); - float top = 1.0 - smoothstep(towerTop * 0.62, towerTop, hf); + // Height in blocks above the deck's own base. Every vertical term below is a function of THIS and + // never of the camera-relative Y, so the deck's internal structure is pinned to the world instead of + // swimming past the eye as the camera rises or falls. + float height = hf * slabDepth; + float open = 1.0 - w.sheet; + + // ---- Per-cloud VIGOR: how far THIS parcel develops vertically, as a stretch on the height + // coordinate before the profile reads it. One low-frequency reading of the shape lattice, at its + // own offset, so neighbouring clouds draw different numbers: stretched past 1 the profile closes + // its dome low (a shallow humilis puddle), compressed below 1 the same profile closes high (a + // towering mediocris/congestus). Without it every cloud in the sky develops to the same fraction + // of the slab and the deck reads as one population of identical puffs — the other half of the + // "monte de algodão" complaint, the first half being opacity. A sheet is a layer rather than a + // population of parcels, so the term is scaled out by `sheet`. + float vigor = cloudNoise(samplePos * (CLOUD_VOLUMETRIC_SCALE / CLOUD_SHAPE_DIV) + + float2(71.0, 13.0)); + float stretch = lerp(1.25, 0.85, smoothstep(0.30, 0.70, vigor)); + hf = hf * lerp(stretch, 1.0, w.sheet); + + // ---- Height profile. + // + // The base is the lifting condensation level: below it the air is unsaturated and there is no cloud + // at all, which is why every real deck has a FLAT bottom when seen from the side. A heap's base is + // sharp (one parcel that just reached saturation), a sheet's frays into mist (stratus forms by + // shallow cooling over a wide area rather than by a rising parcel). + float baseRamp = lerp(CLOUD_BASE_RAMP_HEAP, CLOUD_BASE_RAMP_SHEET, w.sheet); + float bottom = smoothstep(0.0, baseRamp, hf); + + // The crown is where the top starts rounding off. Heaps round at ~0.5-0.8 of the slab (the cap on + // the lift is what keeps a dense core a CLOUD and not a column); towers fill it completely, because + // convection has no cap until it hits the tropopause; a sheet flattens at half, because the + // inversion that made it stops all vertical development. Dense cores tower — the LOCAL coverage + // lifts the crown, so the thick middle of a bank billows up above its wispy edges. That one coupling is most of why the result reads as a field of + // individual clouds rather than as a single extruded slab. + float heapCrown = lerp(CLOUD_CROWN_START_HEAP, CLOUD_CROWN_LIFT_MAX, + smoothstep(CLOUD_TOWER_COVERAGE_LO, CLOUD_TOWER_COVERAGE_HI, coverage)); + float crownStart = lerp(heapCrown, CLOUD_CROWN_START_TOWER, w.convection); + crownStart = lerp(crownStart, CLOUD_CROWN_START_SHEET, w.sheet); + // The dome is clamped to close AT the slab top at the latest. Without the clamp a tall crown's fade + // would end above the slab and the deck would be sliced flat by its own ceiling — the single most + // obvious artefact of a slab-based deck, and exactly the "straight top that follows the thickness + // slider" look this model exists to avoid. With it, every genus rounds off on its own schedule and + // the slab top is only ever reached by towers, whose domes close exactly there. + float crownEnd = min(crownStart + CLOUD_CROWN_ROUNDING, 1.0); + float top = 1.0 - smoothstep(crownStart, crownEnd, hf); + float profile = clamp(bottom * top, 0.0, 1.0); + if (profile <= 0.0) { + return 0.0; + } - // BULGE. Without this the cloud is a vertical extrusion with straight sides — tall, but reading as a - // wall rather than as heaped cloud. Real cumulus is widest around its lower-middle and narrows - // toward both the base and the crown, so the coverage THRESHOLD is relaxed there and tightened at - // the extremes: the same cloud grows sideways in its belly and pulls in at top and bottom. That - // rounded silhouette is most of what makes the Photon-style clouds look voluminous. - float belly = 1.0 - abs(hf - 0.42) * 2.0; - coverage = clamp(coverage + smoothstep(0.0, 1.0, max(belly, 0.0)) * CLOUD_BULGE - CLOUD_BULGE * 0.35, - 0.0, 1.0); + // ---- Belly bulge (heaps only). A cumulus is widest around its lower-middle and pulls in toward + // both base and crown, so the coverage is relaxed there: the same cloud grows sideways in its belly. + // A sheet has no belly — it is a layer, not a parcel — so the term is scaled out by `open`. + float belly = max(1.0 - abs(hf - CLOUD_BELLY_HEIGHT) * 2.0, 0.0); + coverage = clamp(coverage + (smoothstep(0.0, 1.0, belly) - 0.35) * CLOUD_BULGE * open, 0.0, 1.0); if (coverage <= 0.0) { return 0.0; } - // Domain warp, amplitude growing with height: bases stay flat, crowns curl into rounded lobes. - float2 warpUv = samplePos * CLOUD_VOLUMETRIC_SCALE / (CLOUD_CELL_BLOCKS * CLOUD_WARP_DIV); + // ---- Turbulent displacement (wind shear). + // + // Wind speed and direction change with altitude, so updrafts lean downwind and a crown curls while + // its base stays flat. Displacing the sample position by a smooth low-frequency noise before the + // erosion is read is the standard stand-in for that: HZD and Frostbite sample a curl-noise texture, + // which this module cannot, because a deck that must survive the anchor wrap may only sample fields + // that are periodic with it. Altitude enters as a coordinate OFFSET, which is what makes the + // displacement grow and slide with height — shear — at 2D-noise cost rather than 3D. The visible + // effect is that towers lean and crowns curl instead of every height showing the same footprint. + float warpAmp = CLOUD_WARP_AMPLITUDE_BLOCKS * lerp(1.0, CLOUD_WARP_SHEAR, hf) * open; + float2 warpUv = samplePos * (CLOUD_VOLUMETRIC_SCALE / (CLOUD_CELL_BLOCKS * CLOUD_WARP_DIV)) + + float2(hf * 0.9, hf * 0.4); float2 warp = float2(cloudNoise(warpUv), cloudNoise(warpUv + float2(41.0, 17.0))) - 0.5; - float2 warped = samplePos * CLOUD_VOLUMETRIC_SCALE - + warp * CLOUD_CELL_BLOCKS * (1.0 + 3.0 * hf); - - // Billow detail: inverted noise (1 - |2n-1|) gives rounded cellular lobes rather than a cloudy blur, - // which is what reads as cauliflower. Scales chosen so the lobes are a fraction of a cloud's width, - // not a fraction of a cell — small lobes on a big cloud is exactly the cumulus look. - float d1 = cloudNoise(warped / (CLOUD_CELL_BLOCKS * CLOUD_BILLOW_DIV_COARSE)); - float d2 = cloudNoise(warped / (CLOUD_CELL_BLOCKS * CLOUD_BILLOW_DIV_FINE) + float2(19.0, 7.0)); - float billow = (1.0 - abs(d1 * 2.0 - 1.0)) * 0.7 + (1.0 - abs(d2 * 2.0 - 1.0)) * 0.3; - - // Erosion concentrated at the extremes: the crown breaks into lobes and the base frays, but the - // middle of the cloud stays solid. Uniform erosion is what makes procedural clouds read as a flat volume. - float extremes = smoothstep(0.55, 1.0, hf) * 0.85 + smoothstep(0.18, 0.0, hf) * 0.5; - float edge = 1.0 - coverage; - float erosion = billow * (edge * 0.55 + extremes * 0.45); - - // Dense core: cumulus interiors are optically thick, and a field that peaks at ~0.5 can never look - // like anything but haze no matter how deep the slab is. - float density = coverage * (0.85 + 0.75 * coverage) - erosion; - return clamp(density, 0.0, 1.0) * profile; -} - -/** Optical depth from a point toward the light, for self-shadowing inside the slab. */ -float cloudLightTransmittance(WorldPush push, float3 posRel, float3 lightDir, - float slabBottom, float slabTop, int lightSteps) { - float thickness = max(slabTop - slabBottom, 0.1); - // March a short distance toward the light; step so the march covers roughly the slab's own depth, - // which is the scale self-shadowing operates on. Fewer steps cover the same distance with longer - // strides, so the transmittance stays in the right range instead of the march falling short. - float stepLen = thickness / (float(lightSteps) * max(abs(lightDir.y), 0.35)); - float optical = 0.0; - for (int i = 0; i < lightSteps; i++) { - float3 p = posRel + lightDir * (stepLen * (float(i) + 0.5)); - if (p.y < slabBottom || p.y > slabTop) { - break; // left the slab: nothing further can shadow this sample + float2 warpedXZ = samplePos + warp * warpAmp; + float warpedHeight = height + (warp.x + warp.y) * warpAmp * CLOUD_WARP_VERTICAL; + + // ---- Erosion: the Perlin-Worley FBM pair, weighted. The coarse octave is billow value noise (the + // soft rounded lobes); the fine one is Worley F1 (the crisp cellular scoops between them) — the same + // combination the texture-based implementations get from a precomputed Worley atlas, generated here. + // The fine octave fades out with distance (detailFade) and is skipped below the FULL tier, where its + // 27 taps per sample would be the most expensive thing in the density. Both octaves average 0.5 — + // the Worley's remap was fitted numerically to exactly that (CLOUD_WORLEY_REMAP_*) — which is why + // the cheap tier can drop it without changing how much sky the deck closes off on average. + float erosionField = 0.5; + if (detail >= CLOUD_DETAIL_COARSE) { + float coarse = cloudBillow3(cloudDetailCoord(warpedXZ, warpedHeight, CLOUD_BILLOW_DIV_COARSE)); + erosionField = coarse; + if (detail >= CLOUD_DETAIL_FULL && detailFade > 0.0) { + float fine = saturate(cloudWorley3(cloudDetailCoord(warpedXZ, warpedHeight, + CLOUD_BILLOW_DIV_FINE) + float3(19.0, 7.0, 23.0)) + * CLOUD_WORLEY_REMAP_SCALE + CLOUD_WORLEY_REMAP_BIAS); + erosionField = lerp(coarse, lerp(coarse, fine, CLOUD_DETAIL_FINE_WEIGHT), detailFade); } - optical += cloudVolumeDensity(push, p, (p.y - slabBottom) / thickness) * stepLen; } - return exp(-optical * CLOUD_EXTINCTION); + + // Where erosion bites: hardest where the field is thin (the fraying edge of a cloud) and at the slab + // extremes (the crown breaking into lobes, the base dissolving into mist), leaving the interior + // solid. Uniform erosion is what makes procedural cloud read as flat fluff. + float erosionWeight = (1.0 - coverage) * CLOUD_EROSION_EDGE + + smoothstep(0.55, 1.0, hf) * CLOUD_EROSION_CROWN + + smoothstep(0.18, 0.0, hf) * CLOUD_EROSION_BASE; + // The SHAPE tier — what the light probes sample — substitutes the octave's EXPECTED value (0.5) for + // the noise itself. Self-shadowing is decided by the deck's bulk, and an unbiased mean costs nothing, + // whereas skipping erosion entirely would make every probe read the deck as denser than it is. + float erosion = erosionWeight * (detail >= CLOUD_DETAIL_COARSE ? erosionField : 0.5); + + // Edge sharpening: an exponent > 1 thins the field (a wispy, mist-like underside), < 1 fattens it (a + // hard, well-defined crown). This is the standard edge-sharpening exponent, and it is what separates "soft + // grey blob" from "cloud with a crisp top and a fraying base". + float sharpen = lerp(CLOUD_EDGE_SHARPEN_BASE, CLOUD_EDGE_SHARPEN_CROWN, hf); + + // Dense core: a cumulus interior reaches 1-2 g m^-3 of liquid water while its fraying edge is a + // fraction of that, so coverage is super-linear toward the core rather than left flat. A field that + // peaks at ~0.5 reads as haze no matter how deep the slab is. + float core = min(coverage * (1.15 + 0.65 * coverage), 1.0); + return pow(clamp(core - erosion, 0.0, 1.0), sharpen) * profile; } +/** + * Optical depth from a sample toward the sun or moon, as ∫ density dl in blocks of unit-density cloud. + * Returned RAW (not exponentiated) because the multi-scattering expansion needs the same quantity at + * several different extinction coefficients — see cloudMarch. + * + * Marched with exponentially growing strides over roughly one deck depth, sampling SHAPE-level density. + * The growth is the point: self-shadowing is decided within a few tens of blocks of the sample, while + * the shadow cast by the whole bank arrives from the far side of it, and a uniform march of the same + * step count resolves neither end. Six growing strides cover both. + * + * The span is the deck depth divided by the light's elevation (floored at + * CLOUD_LIGHT_MIN_SUN_ELEVATION): a low sun has to travel much further sideways to cross the same slab, + * and without the floor sunrise self-shadowing would stop after one deck depth and light the entire bank + * from within — the flat, directionless look of a deck with no shadows. + */ +float cloudSunOpticalDepth(WorldPush push, CloudWeather w, float3 posRel, float3 lightDir, + float slabBottom, float slabDepth, int steps, float dither) { + if (steps <= 0) { + return 0.0; + } + float span = slabDepth / max(abs(lightDir.y), CLOUD_LIGHT_MIN_SUN_ELEVATION); + float stride = span * (CLOUD_LIGHT_STEP_GROWTH - 1.0) + / (pow(CLOUD_LIGHT_STEP_GROWTH, float(steps)) - 1.0); + // Jitter inside the stride instead of always sampling its centre: with six strides a centred march + // leaves a visible step in the shadow terminator, and the jitter is free (the ray already has one). + float jitter = 0.5 + (dither - 0.5) * 0.8; + float travelled = 0.0; + float total = 0.0; + for (int i = 0; i < steps; i++) { + float3 p = posRel + lightDir * (travelled + stride * jitter); + float above = p.y - slabBottom; + if (above >= 0.0 && above <= slabDepth) { + total += cloudVolumeDensity(push, w, p, above / slabDepth, slabDepth, + CLOUD_DETAIL_SHAPE, 0.0) * stride; + } + travelled += stride; + stride *= CLOUD_LIGHT_STEP_GROWTH; + } + return total; +} + +/** + * Optical depth from a sample straight up to the top of the deck: how much sky that sample can see, i.e. + * the deck's own ambient occlusion. Two steps at full quality. + * + * Passing steps == 0 asks for the analytic estimate instead, used by the cheap tier: ambient occlusion + * is a broad low-frequency quantity, and a diffuse bounce is already an average over the hemisphere, so + * marching it there would cost more than the detail it buys. The estimate assumes this sample's density + * persists over CLOUD_SKY_ANALYTIC_FILL of the deck above it. + */ +float cloudSkyOpticalDepth(WorldPush push, CloudWeather w, float3 posRel, float heightFrac, + float slabBottom, float slabDepth, float sampleDensity, int steps, + float dither) { + float above = (1.0 - clamp(heightFrac, 0.0, 1.0)) * slabDepth; + if (above <= 0.0) { + return 0.0; + } + if (steps <= 0) { + return sampleDensity * CLOUD_SKY_ANALYTIC_FILL * above; + } + float stepLen = above / float(steps); + float total = 0.0; + for (int i = 0; i < steps; i++) { + float3 p = posRel + float3(0.0, stepLen * (float(i) + 0.5 + (dither - 0.5) * 0.5), 0.0); + total += cloudVolumeDensity(push, w, p, (p.y - slabBottom) / slabDepth, slabDepth, + CLOUD_DETAIL_SHAPE, 0.0) * stepLen; + } + return total; +} + +/** + * Optical depth from a sample DOWN to the deck's base, for sunlight bounced off the ground back up into + * the cloud's underside. + * + * Analytic rather than marched, and deliberately so: the ground is a broad diffuse source, so the term + * is soft by nature; marching down for every sample would add a third probe to the most expensive loop + * in the shader; and all that matters is the trend, which is that a sample high in the crown has the + * whole deck between it and the ground while one at the base has almost none. The average density along + * that path is mixed toward 1 as the sample rises, because the deck below a crown sample is the dense + * part of the deck. + */ +float cloudGroundOpticalDepth(float sampleDensity, float heightFrac, float slabDepth) { + float average = lerp(sampleDensity, 1.0, clamp(heightFrac * 2.0 - 1.0, 0.0, 1.0)); + return average * clamp(heightFrac, 0.0, 1.0) * slabDepth; +} + +/** + * Everything about the lighting of one march that does not change along the ray. Bundled so the + * per-sample expansion below takes nine arguments instead of nineteen, and so the tier decisions + * (bounce orders, probe resolution) are made once, in cloudMarch, rather than re-derived per step. + */ +struct CloudLight { + float3 albedo; // deck albedo: CLOUD_ALBEDO tinted by the frame's own cloud colour + float3 sunRadiance; // the celestial this frame is lit by — the sun by day, the moon by night + float3 skyRadiance; // sky ambient arriving from above + float3 groundRadiance; // sunlight bounced off the lit ground back up into the deck's underside + float3 lightDir; // toward that celestial + float cosT; // dot(march direction, lightDir): +1 looking into the light + float sigmaT; // extinction per block at unit density + int octaves; // bounce orders in the expansion + int sunSteps; // strides in the sun probe + int skySteps; // steps in the zenith probe (0 = analytic) +}; + +/** + * In-scattered radiance at one sample: the multi-scattering expansion. + * + * Each pass of the loop is one BOUNCE ORDER. Order 0 is single scattering — light arrives from the + * celestial and leaves toward the eye — and order N has been scattered N more times inside the bank + * before leaving. Rather than tracing those paths (impossible at this budget), the approximation + * re-evaluates the same three light terms with the coefficients scaled by + * k = CLOUD_MULTI_SCATTER_FALLOFF^N: scattering and extinction both shrink, because a photon deep + * inside the bank is less affected by the cloud still in front of it, and the phase relaxes toward + * isotropic by the same k, because each bounce randomises the direction a little more. The geometric + * series converges, costs no extra march, and is what puts a bright soft interior, a lit crown and a + * dark-but-not-black base into the deck — the difference between a cloud and a lit silhouette of one. + * + * The three optical depths are computed ONCE and shared by every order: the orders differ in how much + * they are attenuated by them, not in what they are. + */ +float3 cloudSampleScatter(WorldPush push, CloudWeather w, CloudLight light, float3 posRel, + float density, float heightFrac, float slabBottom, float slabDepth, + float dither) { + float sunOD = cloudSunOpticalDepth(push, w, posRel, light.lightDir, slabBottom, slabDepth, + light.sunSteps, dither); + float skyOD = cloudSkyOpticalDepth(push, w, posRel, heightFrac, slabBottom, slabDepth, density, + light.skySteps, dither); + float groundOD = cloudGroundOpticalDepth(density, heightFrac, slabDepth); + + // Powder darkens thin edges, and lets go toward the light: at a backlit edge what reaches the eye is + // the forward-scatter peak rather than absorption, so powder that survives there eats the silver + // lining it exists to frame. + float powder = lerp(1.0, cloudPowder(density), CLOUD_POWDER_STRENGTH); + powder = lerp(powder, 1.0, CLOUD_POWDER_SUN_RELAX * max(light.cosT, 0.0)); + + float scatterAmt = 1.0; + float extinctAmt = 1.0; + float phaseG = CLOUD_HG_G; + float3 total = float3(0.0, 0.0, 0.0); + for (int order = 0; order < light.octaves; order++) { + float k = pow(CLOUD_MULTI_SCATTER_FALLOFF, float(order)); + // Phase relaxed toward isotropic by k (Wrenninge). The ambient terms are isotropic already, so + // they are not phase-weighted — only the directional light is. + float phase = lerp(CLOUD_INV_FOUR_PI, cloudPhaseG(light.cosT, phaseG), k); + float sigmaOrder = light.sigmaT * extinctAmt; + float3 lit = light.sunRadiance * (phase * exp(-sunOD * sigmaOrder)) + + light.skyRadiance * exp(-skyOD * sigmaOrder) + + light.groundRadiance * exp(-groundOD * sigmaOrder); + total += lit * (scatterAmt * powder); + scatterAmt *= CLOUD_MULTI_SCATTER_FALLOFF; + extinctAmt *= CLOUD_MULTI_SCATTER_EXTINCT_FALLOFF; + phaseG *= CLOUD_MULTI_SCATTER_PHASE_FALLOFF; + // Deep inside the bank the thin-edge darkening of the single-scattering model stops applying. + powder = lerp(powder, sqrt(powder), CLOUD_POWDER_OCTAVE_RELAX); + } + return total * (light.albedo * CLOUD_SCATTER_GAIN); +} /** Result of a volumetric march: premultiplied scattered radiance and the surviving transmittance. */ public struct CloudVolume { public float3 scatter; @@ -740,12 +1435,24 @@ public struct CloudVolume { /** * Ray-march the slab and return in-scattered radiance plus transmittance. * - * Front-to-back emission/absorption integration: at each step the sample's in-scattering (sun radiance, - * attenuated by the light march, shaped by the HG phase, plus a sky ambient term) is accumulated + * Front-to-back emission/absorption integration: at each step the sample's in-scattering is accumulated * weighted by how much light still reaches the eye, and the transmittance is decremented by * Beer-Lambert. The caller composites with `col * transmittance + scatter`, so the result is correct * over any background — including a terrain hit, which is what makes the volumetric style occlude and * be occluded correctly. + * + * For the VOLUMETRIC style the per-sample in-scattering is a MULTI-SCATTERING EXPANSION rather than one + * sun term plus a constant ambient: CLOUD_MULTI_SCATTER_OCTAVES bounce orders, each with its own + * relaxation of the extinction, the phase and the powder term, fed by three optical-depth probes (sun, + * sky, ground bounce). See the section header above for why, and the constants for the physics each + * number comes from. The CLASSIC style keeps its flat vanilla-style face tone — it is meant to look like + * Minecraft's cloud boxes, not like a cloud — but still gets the same slab integration, dithered start + * and early exit, and borrows the sun probe (damped) for its self-shadowing, so the two styles at least + * agree about where the deck ends and how it is sampled. + * + * `highQuality` picks the tier: the full march for camera rays and specular bounces, the cheap one for + * diffuse indirect, where the cloud contributes a broad sky-fill term that is averaged over the + * hemisphere and denoised anyway. */ public CloudVolume cloudMarch(WorldPush push, float3 originRel, float3 dir, float maxDistance, float3 skyBehind, bool highQuality) { @@ -755,7 +1462,21 @@ public CloudVolume cloudMarch(WorldPush push, float3 originRel, float3 dir, floa result.scatter = float3(0.0, 0.0, 0.0); result.transmittance = 1.0; - float thickness = max(push.cloudAnchor.z, 1.0); + float pushedDepth = max(push.cloudAnchor.z, 1.0); + // The volumetric deck carries its own depth — genus-driven, see cloudDeckDepth — because how deep + // a cloud is belongs to the sky, not to a slider: the thickness option shapes the CLASSIC boxes' + // extrusion, and the volumetric clouds sub-screen replaces that row with a greyed-out explanation. + bool classic = cloudStyle(push) == CLOUD_STYLE_CLASSIC; + CloudWeather weather = cloudWeather(push); + float thickness = classic ? pushedDepth : cloudDeckDepth(weather); + // The BASE is what the height option promises. Java centres the pushed slab on push.clouds.w + // (centre = configured base + pushedDepth/2), so the base is recovered from the PUSHED pair — + // never from the genus depth — and the deck's floor stays exactly at the altitude the player + // chose. Centring the genus slab on the pushed centre instead let the base drift with a slider + // the volumetric style no longer even reads: with a saved high thickness the floor floated tens + // of blocks above the configured height, which is the "clouds too high" report this line fixes. + float slabBottom = push.clouds.w - pushedDepth * 0.5 - originRel.y; + float slabTop = slabBottom + thickness; // Crossing EXCLUSION. The flat path fades the deck out as the camera passes through its nominal // thickness (cloudTrace's crossFade) so the transition reads as dissolving cloud instead of a // one-pixel flip between lit top and dark underside. The march had no equivalent: with the camera @@ -766,15 +1487,12 @@ public CloudVolume cloudMarch(WorldPush push, float3 originRel, float3 dir, floa // slab itself is optically excluded inside the crossing region: sigma scales by the same crossFade // curve the flat path uses, so both extinction AND the in-scatter it feeds collapse together, and // at the mid-plane the deck contributes exactly nothing while the camera crosses it. - float deckRel = push.clouds.w - originRel.y; + float deckRel = slabBottom + thickness * 0.5; // this slab's own mid-plane, origin-relative float halfThickness = max(thickness, 0.5) * 0.5; float crossFade = smoothstep(halfThickness * 0.5, halfThickness * 1.5, abs(deckRel)); if (crossFade <= 0.0) { return result; } - float slabBottom = push.clouds.w - thickness * 0.5 - originRel.y; - float slabTop = push.clouds.w + thickness * 0.5 - originRel.y; - // Slab entry/exit along the ray. A near-horizontal ray inside the slab would march forever, so it // is bounded by the view limit like every other ray. float t0, t1; @@ -813,8 +1531,8 @@ public CloudVolume cloudMarch(WorldPush push, float3 originRel, float3 dir, floa } float3 sunRadiance = push.lightRadiance.xyz; - float cosT = dot(dir, push.lightDir.xyz); - float phase = cloudPhase(cosT); + float3 lightDir = push.lightDir.xyz; + float cosT = dot(dir, lightDir); float opacity = clamp(push.clouds.y, 0.0, 1.0); // Scale the step count to the distance actually being marched, so a thick deck is sampled as finely // as a thin one instead of just taking longer strides. Clamped both ways: never coarser than the @@ -822,7 +1540,6 @@ public CloudVolume cloudMarch(WorldPush push, float3 originRel, float3 dir, floa int stepCap = highQuality ? CLOUD_MARCH_STEPS_MAX : CLOUD_MARCH_STEPS_CHEAP_MAX; marchSteps = clamp(int((t1 - t0) / CLOUD_TARGET_STEP_BLOCKS), marchSteps, stepCap); float stepLen = (t1 - t0) / float(marchSteps); - bool classic = cloudStyle(push) == CLOUD_STYLE_CLASSIC; // Classic clouds are opaque boxes, not haze: they need a much higher extinction so a box saturates // within its own thickness instead of reading as translucent haze. Vanilla's clouds you cannot see // through, and that is the property being reproduced. @@ -842,38 +1559,107 @@ public CloudVolume cloudMarch(WorldPush push, float3 originRel, float3 dir, floa classicFace = lerp(classicFace, 0.78, grazing); } + // ---- Per-march lighting state, resolved once. + // + // `weather` is the genus model: one reading of the coverage and rain/thunder lanes that decides the + // height profile, the bulge, the erosion and the storm absorption together, so no two parts of the + // deck can disagree about what the sky is doing. The dither is per pixel and per frame (see + // cloudDither) and offsets the march start, which is what turns the truncation banding into noise + // the denoiser resolves. + float dither = cloudDither(push); + + CloudLight light; + light.sunRadiance = sunRadiance; + light.skyRadiance = skyBehind; + light.lightDir = lightDir; + light.cosT = cosT; + // Extinction per block of unit-density cloud. `sigma` already carries the slab-depth normalisation + // and both fades; precipitating cloud adds absorption on top of it, which is why a storm's underside + // reads grey-green rather than merely shadowed. Scattering follows extinction because liquid cloud's + // single-scattering albedo is ~0.9999: cloud is dark from path length, not from absorption. + // + // The storm term is VOLUMETRIC-only. Classic already greys in rain through push.cloudColor (vanilla's + // own CLOUD_COLOR), and adding absorption on top would darken the boxes twice for the same weather — + // the classic style is meant to keep looking like Minecraft. + light.sigmaT = sigma * (classic ? 1.0 : 1.0 + weather.absorbing * CLOUD_STORM_ABSORPTION); + float sigmaS = light.sigmaT * CLOUD_SINGLE_SCATTER_ALBEDO; + light.octaves = highQuality ? CLOUD_MULTI_SCATTER_OCTAVES : CLOUD_MULTI_SCATTER_OCTAVES_CHEAP; + light.sunSteps = lightSteps; + light.skySteps = highQuality ? CLOUD_SKY_STEPS : 0; + // Sunlight reflected off the lit ground back up into the deck's underside — the term that lifts a + // cloud base from black to soft grey. Nothing bounces when the light is below the horizon. + light.groundRadiance = sunRadiance * (CLOUD_GROUND_ALBEDO * max(lightDir.y, 0.0)); + // The frame's cloud colour IS the deck's albedo tint: vanilla's own CLOUD_COLOR attribute, resolved + // per dimension and per weather, so a storm greys the deck and a sunset warms it on the game's curve + // rather than on a hand-tuned one. White in the Overworld on a clear day, so this changes nothing + // about the default look — the volumetric style simply stopped ignoring the setting. + light.albedo = CLOUD_ALBEDO * push.cloudColor.xyz; + + int detail = highQuality ? CLOUD_DETAIL_FULL : CLOUD_DETAIL_COARSE; + // Distance LOD on the fine erosion octave. A 6-block lobe is sub-pixel past CLOUD_DETAIL_LOD_FAR, + // and the atmosphere between the eye and the deck has already washed the contrast out of it, so the + // hashes spent there buy nothing. The cheap tier never resolves it at all. + float detailFade = highQuality + ? 1.0 - smoothstep(CLOUD_DETAIL_LOD_NEAR, CLOUD_DETAIL_LOD_FAR, t0) + : 0.0; + + // Dithered march start: the first sample sits a hashed fraction of a step in, so neighbouring pixels + // and neighbouring frames truncate the integral at different places. + float marchStart = t0 + stepLen * dither; for (int i = 0; i < marchSteps; i++) { - float3 p = originRel + dir * (t0 + stepLen * (float(i) + 0.5)); - float heightFrac = (p.y - slabBottom) / max(slabTop - slabBottom, 1.0e-3); - float density = cloudVolumeDensity(push, p, clamp(heightFrac, 0.0, 1.0)); + if (result.transmittance < CLOUD_MIN_TRANSMITTANCE) { + result.transmittance = 0.0; + // The deck has gone opaque: nothing behind it can show through, and the remaining samples + // are the most expensive thing in this loop (each is a light probe). + break; + } + float3 p = originRel + dir * (marchStart + stepLen * float(i)); + float heightFrac = clamp((p.y - slabBottom) / max(slabTop - slabBottom, 1.0e-3), 0.0, 1.0); + float density = cloudVolumeDensity(push, weather, p, heightFrac, thickness, detail, detailFade); if (density <= 0.0) { - continue; + continue; // empty air: no extinction, and crucially no probes. Most of a scattered deck is. } - float extinction = density * sigma * stepLen; - float sampleTransmittance = exp(-extinction); - float lightT = cloudLightTransmittance(push, p, push.lightDir.xyz, slabBottom, slabTop, - lightSteps); + float sigmaStep = light.sigmaT * density; + float sampleTransmittance = exp(-sigmaStep * stepLen); float3 inScatter; if (classic) { - // Flat, vanilla-style face tone. Self-shadowing is deliberately damped (mixed only 40% in) - // so cloud boxes keep the poster-flat look instead of developing soft volumetric gradients. + // Flat, vanilla-style face tone: classic clouds are meant to look like Minecraft's boxes, not + // like a cloud, so they keep the poster-flat shading and merely borrow the new probe. + // Self-shadowing is deliberately damped (mixed only 40% in) so the boxes do not develop soft + // volumetric gradients. + float lightT = exp(-cloudSunOpticalDepth(push, weather, p, lightDir, slabBottom, thickness, + lightSteps, dither) * light.sigmaT); float shade = lerp(1.0, lightT, 0.4); inScatter = push.cloudColor.xyz * CLOUD_INV_PI * (sunRadiance * classicFace * shade * 3.0 + skyBehind * 1.4); } else { - // Direct sun scattering + an ambient sky term so the shadowed interior is grey rather than - // black. Powder brightens dense interiors (multiple-scattering approximation). - float powder = lerp(1.0, cloudPowder(density), CLOUD_POWDER_STRENGTH); - inScatter = CLOUD_ALBEDO - * (sunRadiance * lightT * phase * powder * 4.0 + skyBehind * 0.35); + inScatter = cloudSampleScatter(push, weather, light, p, density, heightFrac, slabBottom, + thickness, dither); } - // Energy-conserving accumulation: integrate in-scatter over the step's absorbed fraction. - result.scatter += result.transmittance * inScatter * (1.0 - sampleTransmittance); + // Energy-conserving accumulation. For a homogeneous step the exact integral of the source term S + // attenuated by extinction sigma over a stride of length L is S * (sigma_s/sigma_t) * (1-e^-tau), + // i.e. the single-scattering albedo times the light the step ABSORBED — independent of how long + // the stride was. That is what makes the deck's brightness a property of the cloud rather than of + // the march resolution: a coarse step through thin cloud and a fine one through thick cloud + // deposit the same energy, so raising the step count refines the silhouette without brightening + // the deck (the fixed-count version banded for exactly this reason). + result.scatter += result.transmittance * inScatter * (sigmaS / max(light.sigmaT, 1.0e-6)) + * (1.0 - sampleTransmittance); result.transmittance *= sampleTransmittance; - if (result.transmittance < 0.01) { - result.transmittance = 0.0; - break; // fully saturated; further steps cannot contribute - } + } + + // ---- Aerial perspective (volumetric only). + // + // The air between the eye and the deck dims the deck's own scatter and puts sky radiance in its + // place, which is why distant clouds lose contrast and take on the horizon's colour instead of + // simply vanishing. Blended over the same range as the density fade above, so the view-limit cutoff + // is hidden by cloud dissolving INTO the sky rather than by cloud being deleted at a line. The target + // is the sky radiance behind the deck weighted by how much of it the deck blocked — the ordinary + // transmittance-ratio blend for aerial perspective through any participating medium. + if (!classic) { + float aerial = CLOUD_AERIAL_STRENGTH + * smoothstep(CLOUD_DETAIL_LOD_NEAR, push.cloudAnchor.w, t0); + result.scatter = lerp(result.scatter, skyBehind * (1.0 - result.transmittance), aerial); } // ---- Opacity as a genuine ceiling, applied ONCE to the finished march. @@ -935,7 +1721,9 @@ public CloudVolume cloudSegment(WorldPush push, float3 originRel, float3 dir, fl // * a (near) zero thickness collapses to the flat sheet, which is what that slider setting asks // for. It shades like the classic deck of whichever style is active. bool classic = cloudStyle(push) == CLOUD_STYLE_CLASSIC; - if (push.cloudAnchor.z > CLOUD_FLAT_EPSILON) { + // The flat sheet is what a (near) zero THICKNESS asks for, and thickness is a classic knob: the + // volumetric deck always marches, at its own genus-driven depth, whatever the slider says. + if (push.cloudAnchor.z > CLOUD_FLAT_EPSILON || !classic) { if (classic && push.cloudCellsAddr != 0u) { return cloudClassicBoxes(push, originRel, dir, maxDistance, ambient); } diff --git a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java index 1fef2912..02dcd404 100644 --- a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java +++ b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java @@ -771,23 +771,42 @@ public static final class Composite { * *
{@code classic} reproduces vanilla's flat, blocky deck: coverage is quantised to the * 12-block cell grid so the silhouette is genuinely square-edged, and the slab is shaded - * with vanilla's distinct top/side/bottom faces. {@code volumetric} extrudes the same - * coverage map into a ray-marched slab with self-shadowing and forward scattering — the - * look heavy shaderpacks produce, at a real GPU cost. + * with vanilla's distinct top/side/bottom faces. {@code volumetric} ray-marches a modelled + * water-cloud deck instead — a coverage field for where the layer is, a 3D-eroded height + * profile for the shape of each cloud, and a multi-scattering light model with separate sun, + * sky and ground-bounce optical depths. It is the look heavy shaderpacks produce, at a real + * GPU cost (roughly twice the previous volumetric model per pixel; see + * {@code docs/realistic-volumetric-clouds.md} for the budget and the physics). * *
Both styles read one shared coverage field, so switching does not move the clouds and - * the cloud shadows stay identical between them. + * the cloud shadows stay identical between them. The volumetric style additionally reads the + * weather to pick the cloud GENUS — a closed sheet when overcast, scattered heaps in fair + * weather, convective towers in a thunderstorm — so rain changes the shape of the sky and + * not merely its brightness. */ public static final StringSetting CLOUD_STYLE = string("caustica.rt.cloudStyle", "composite.cloud-style", "classic", Composite::sanitizeCloudStyle); /** - * Cloud thickness, 0..1, as a fraction of {@link #CLOUD_MAX_THICKNESS_BLOCKS}. + * Cloud thickness — how much BULK the deck has, 0..1, as a fraction of + * {@link #CLOUD_MAX_THICKNESS_BLOCKS}. * - *
Volumetric: 0 is a flat sheet (the deck collapses to a plane and takes the cheap - * non-marched path); 1 is a deep bank. Classic: the slider scales the HEIGHT of vanilla's + *
This is the cloud's size, not its position: it sets how deep the layer is from its base + * to its crown. How far off the ground that base sits is {@link #CLOUD_HEIGHT}, and the two + * are deliberately independent — a deck can be thin and low, or a kilometre of storm cloud + * overhead, without either slider moving the other. That separation matters because clouds + * are no longer a flat texture stretched across the sky (the old PNG plane had exactly one + * number for both concepts): a modelled deck has real depth, and depth is what the light + * transport integrates over. + * + *
Volumetric: this slider does NOT apply — how deep a volumetric cloud is belongs to the + * genus model ({@code cloudDeckDepth} in clouds.slang: 64 blocks of sheet, 165 of heap, 210 + * of tower), because a global thickness was precisely the knob that made the deck read as a + * rectangle whose look depends on a slider, and the clouds sub-screen replaces the row with + * a greyed-out explanation in that style. Classic: the slider scales the HEIGHT of vanilla's * authored cell boxes, floored at vanilla's own 4-block extrusion — classic clouds are - * always real boxes with lit tops and shaded sides, never thinner than the game draws them. + * always real boxes with lit tops and shaded sides, never thinner than the game draws them, + * and 0 collapses them to the flat sheet. */ public static final FloatSetting CLOUD_THICKNESS = clampedFloat("caustica.rt.cloudThickness", "composite.cloud-thickness", 0.5f, 0.0f, 1.0f); @@ -809,19 +828,33 @@ public static final class Composite { public static final FloatSetting CLOUD_OPACITY = clampedFloat("caustica.rt.cloudOpacity", "composite.cloud-opacity", 0.9f, 0.0f, 1.0f); /** - * World Y the BASE of the cloud deck sits at. Vanilla's clouds sit at 192; the default is - * higher because Caustica's clouds have real thickness and a deck whose base is at vanilla - * height reads as much closer to the ground than vanilla's flat sheet does. + * World Y the BASE of the cloud deck sits at. Vanilla's clouds sit at 192; the default sits + * a little above that because a modelled deck has real depth overhead and a base at exactly + * vanilla height reads as looming over tall terrain. + * + *
Position only: this is where the deck's FLOOR is, never how big it is. The volumetric + * march recovers exactly this Y as its slab base (Java pushes the slab's centre, and the + * shader subtracts half the pushed depth), so the floor does not drift with anything else — + * not with the genus depth, not with the classic-only thickness. The deck grows UPWARD from + * it, by as much as its genus says. * *
Exposed as a slider: with volumetric clouds the deck's distance is a strong part of the * look, and the right value depends on the world's terrain height and the player's taste. * The range comfortably spans from just above build height to far overhead. */ public static final FloatSetting CLOUD_HEIGHT = - clampedFloat("caustica.rt.cloudHeight", "composite.cloud-height", 320.0f, 128.0f, 1024.0f); + clampedFloat("caustica.rt.cloudHeight", "composite.cloud-height", 224.0f, 128.0f, 1024.0f); /** * Fraction of the sky the deck covers in clear weather. Rain drives this toward fully * overcast on top of whatever is set here (see {@code RtComposite.cloudState}). + * + *
In the volumetric style this also picks the cloud GENUS, because that is what coverage
+ * means in the real sky: a scattered field (low values) is fair-weather cumulus — individual
+ * heaps with clear air between them and a flat base at the condensation level — while a
+ * closed sky (above roughly 55%, fully by 92%) is stratocumulus or stratus, a shallow
+ * continuous sheet that stops developing vertically. So the slider changes the SHAPE of the
+ * clouds and not merely their density; the classic style quantises the same value against
+ * vanilla's authored cell map instead.
*/
public static final FloatSetting CLOUD_COVERAGE =
clampedFloat("caustica.rt.cloudCoverage", "composite.cloud-coverage", 0.55f, 0.0f, 1.0f);
diff --git a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java
index 08b4d34b..55a50048 100644
--- a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java
+++ b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java
@@ -197,10 +197,11 @@ public static OptionInstance>[] pomOptions() {
/**
* The clouds sub-screen rows. {@code styleChanged} reopens the screen when the deck style flips,
- * and the classic deck's coverage slider is left out entirely: coverage is baked into the flat
- * texture in that style, so showing the row would offer a knob that changes nothing — the
- * sub-screen replaces it with {@link #cloudCoverageDisabledHint()}'s greyed-out explanation.
- * Volumetric reads the live coverage field, so the slider stays.
+ * and each style leaves out the one slider it cannot use: the classic deck's coverage is baked
+ * into its flat texture, and the volumetric deck's depth comes from its genus model rather than
+ * from the thickness option — showing either row in the wrong style would offer a knob that
+ * changes nothing, so the sub-screen swaps them for greyed-out explanations
+ * ({@link #cloudCoverageDisabledHint()}, {@link #cloudThicknessDisabledHint()}).
*/
public static OptionInstance>[] cloudOptions(Runnable styleChanged) {
List {@code RtComposite} pushes a cloud sample anchor reduced modulo {@code CLOUD_FIELD_PERIOD_BLOCKS},
+ * because the wind scroll grows without bound with world time and because the anchor has to stay a small
+ * float far from a world border's 30M-block coordinates. That wrap is only seamless if the period is a
+ * whole number of repeats in every space the shader samples its hash lattice in — the coverage
+ * octaves, the turbulent displacement, and both 3D erosion octaves, each of which divides the cell size
+ * by its own factor. Get one wrong and the entire cloudscape snaps to a different pattern as the anchor
+ * rolls over: clouds visibly change shape while the player walks.
+ *
+ * Both of the previous breaks were a divisor that was not a power of two (0.9 and 0.35), which makes
+ * the per-octave repeat a non-integer number of periods, so no wrap distance can ever satisfy it. This
+ * reads the shader's constants and the Java formula and re-derives the identity from both sides, so a
+ * future tuning pass that changes an octave scale fails CI instead of teleporting the sky.
+ */
+final class RtCloudPeriodMirrorTest {
+ private static final Path REPO_ROOT = repoRoot();
+ private static final Path CLOUDS = REPO_ROOT.resolve("shaders/world/clouds.slang");
+ private static final Path RT_COMPOSITE =
+ REPO_ROOT.resolve("src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java");
+
+ /** Every octave divisor the volumetric field is sampled through, as named in clouds.slang. */
+ private static final String[] DIVISORS = {
+ "CLOUD_SHAPE_DIV", "CLOUD_WARP_DIV", "CLOUD_BILLOW_DIV_COARSE", "CLOUD_BILLOW_DIV_FINE",
+ };
+
+ @Test
+ void fieldPeriodIsAWholeNumberOfRepeatsInEverySampledSpace() throws IOException {
+ String slang = Files.readString(CLOUDS);
+ String composite = Files.readString(RT_COMPOSITE);
+
+ double cells = slangInt(slang, "CLOUD_PERIOD_CELLS");
+ double cellBlocks = slangFloat(slang, "CLOUD_CELL_BLOCKS");
+ double scale = slangFloat(slang, "CLOUD_VOLUMETRIC_SCALE");
+
+ double maxDivisor = 0.0;
+ for (String name : DIVISORS) {
+ double divisor = slangFloat(slang, name);
+ assertTrue(divisor > 0.0, name + " must be positive");
+ assertTrue(isPowerOfTwo(divisor),
+ name + " = " + divisor + " is not a power of two, so the hash lattice it samples has "
+ + "a repeat that is not a whole number of field periods and NO wrap distance "
+ + "can make the deck seamless — the cloudscape snaps to a different pattern "
+ + "when the anchor rolls over (this is how the 0.9 and 0.35 divisors broke it)");
+ maxDivisor = Math.max(maxDivisor, divisor);
+ }
+
+ double expected = cells * cellBlocks * maxDivisor / scale;
+ double pushed = javaPeriod(composite);
+ assertEquals(expected, pushed, 1.0e-9,
+ "RtComposite.CLOUD_FIELD_PERIOD_BLOCKS must equal 512 cells * 12 blocks * maxDivisor("
+ + maxDivisor + ") / scale(" + scale + ") = " + expected
+ + "; a shorter period wraps mid-octave and a longer one is wasted precision");
+
+ // The largest divisor's repeat IS the period; every smaller octave must divide it exactly too, or
+ // that layer alone desyncs at the wrap (the second break: base field fixed, detail layers not).
+ for (String name : DIVISORS) {
+ double divisor = slangFloat(slang, name);
+ double repeat = cells * cellBlocks * divisor / scale;
+ double wraps = pushed / repeat;
+ assertEquals(Math.rint(wraps), wraps, 1.0e-9,
+ name + " repeats every " + repeat + " blocks, which does not divide the "
+ + pushed + "-block field period a whole number of times");
+ }
+ }
+
+ @Test
+ void verticalLatticeCannotRepeatInsideTheDeck() throws IOException {
+ String slang = Files.readString(CLOUDS);
+ String composite = Files.readString(RT_COMPOSITE);
+
+ double cells = slangInt(slang, "CLOUD_VERTICAL_CELLS");
+ assertTrue(isPowerOfTwo(cells),
+ "CLOUD_VERTICAL_CELLS must be a power of two so CLOUD_VERTICAL_MASK is a clean mask");
+ double cellBlocks = slangFloat(slang, "CLOUD_CELL_BLOCKS");
+ double scale = slangFloat(slang, "CLOUD_VOLUMETRIC_SCALE");
+
+ // The finest octave has the smallest cell in blocks, so it is the one that could repeat first.
+ double finest = Double.MAX_VALUE;
+ for (String name : DIVISORS) {
+ finest = Math.min(finest, slangFloat(slang, name));
+ }
+ double verticalCellBlocks = cellBlocks * finest / scale;
+ double verticalPeriod = cells * verticalCellBlocks;
+
+ Matcher thickness = Pattern
+ .compile("CLOUD_MAX_THICKNESS_BLOCKS\\s*=\\s*([0-9.]+)f\\s*;")
+ .matcher(composite);
+ assertTrue(thickness.find(), "RtComposite must define CLOUD_MAX_THICKNESS_BLOCKS");
+ double maxDepth = Double.parseDouble(thickness.group(1));
+
+ // The vertical axis has no anchor to wrap (height is measured from the deck's own base), so the
+ // mask exists only to keep the cell index small. It must still be far coarser than the deck, or
+ // the erosion pattern would visibly repeat inside one cloud — the same artefact as a bad
+ // horizontal wrap, but stacked vertically through the slab.
+ assertTrue(verticalPeriod >= maxDepth * 4.0,
+ "the vertical hash lattice repeats every " + verticalPeriod + " blocks, which is not at "
+ + "least four times the deepest deck RtComposite can push (" + maxDepth
+ + " blocks): the 3D erosion would tile visibly inside a single cloud");
+ }
+
+ @Test
+ void hashMasksAreOneLessThanTheirPeriod() throws IOException {
+ String slang = Files.readString(CLOUDS);
+ // Both masks are written as period-1 rather than as a literal, so the period and the mask cannot
+ // drift apart; assert the source says exactly that.
+ assertTrue(slang.contains("CLOUD_CELL_MASK = CLOUD_PERIOD_CELLS - 1;"),
+ "CLOUD_CELL_MASK must be derived from CLOUD_PERIOD_CELLS, not written as a literal");
+ assertTrue(slang.contains("CLOUD_VERTICAL_MASK = CLOUD_VERTICAL_CELLS - 1;"),
+ "CLOUD_VERTICAL_MASK must be derived from CLOUD_VERTICAL_CELLS, not written as a literal");
+ assertTrue(isPowerOfTwo(slangInt(slang, "CLOUD_PERIOD_CELLS")),
+ "CLOUD_PERIOD_CELLS must be a power of two for the mask to be a wrap at all");
+ }
+
+ /**
+ * Evaluates {@code CLOUD_FIELD_PERIOD_BLOCKS} from its written-out product rather than from a
+ * hard-coded expectation, so the test reads the same four numbers the shader's identity is built
+ * from and cannot itself go stale.
+ */
+ private static double javaPeriod(String composite) {
+ Matcher m = Pattern.compile(
+ "CLOUD_FIELD_PERIOD_BLOCKS\\s*=\\s*([0-9.]+)\\s*\\*\\s*([0-9.]+)\\s*\\*\\s*([0-9.]+)"
+ + "\\s*/\\s*([0-9.]+)\\s*;")
+ .matcher(composite);
+ assertTrue(m.find(),
+ "RtComposite must define CLOUD_FIELD_PERIOD_BLOCKS as cells * cellBlocks * maxDivisor"
+ + " / scale, written out so this test can read each factor");
+ return Double.parseDouble(m.group(1)) * Double.parseDouble(m.group(2))
+ * Double.parseDouble(m.group(3)) / Double.parseDouble(m.group(4));
+ }
+
+ private static double slangFloat(String slang, String name) {
+ Matcher m = Pattern.compile("static\\s+const\\s+float\\s+" + name + "\\s*=\\s*([0-9.]+)\\s*;")
+ .matcher(slang);
+ assertTrue(m.find(), "clouds.slang must define float " + name);
+ return Double.parseDouble(m.group(1));
+ }
+
+ private static double slangInt(String slang, String name) {
+ Matcher m = Pattern.compile("static\\s+const\\s+int\\s+" + name + "\\s*=\\s*([0-9]+)\\s*;")
+ .matcher(slang);
+ assertTrue(m.find(), "clouds.slang must define int " + name);
+ return Double.parseDouble(m.group(1));
+ }
+
+ /** True for exactly the values whose binary representation is a single 1 bit, fractions included. */
+ private static boolean isPowerOfTwo(double value) {
+ if (value <= 0.0 || !Double.isFinite(value)) {
+ return false;
+ }
+ // Scale by the value's own exponent: a power of two lands exactly on 1.0, anything with a
+ // mantissa (0.9 -> 1.8, 0.35 -> 1.4, 12.0 -> 1.5) does not.
+ return Math.scalb(value, -Math.getExponent(value)) == 1.0;
+ }
+
+ /** Same root discovery pattern as RtShaderConstantMirrorTest, kept local to avoid test coupling. */
+ private static Path repoRoot() {
+ Path dir = Path.of("").toAbsolutePath();
+ for (Path candidate = dir; candidate != null; candidate = candidate.getParent()) {
+ if (Files.isDirectory(candidate.resolve("shaders/world"))
+ && Files.isDirectory(candidate.resolve("src/main/java"))) {
+ return candidate;
+ }
+ }
+ throw new IllegalStateException("could not locate the repository root from " + dir);
+ }
+}
diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/RtCloudShaderRegressionTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/RtCloudShaderRegressionTest.java
index 111428d3..70784206 100644
--- a/src/test/java/dev/comfyfluffy/caustica/rt/RtCloudShaderRegressionTest.java
+++ b/src/test/java/dev/comfyfluffy/caustica/rt/RtCloudShaderRegressionTest.java
@@ -81,6 +81,324 @@ void directLightingAppliesCloudShadowOnlyAfterSceneVisibilitySurvives() throws I
"if (!cloudShadowReady) {");
}
+ // ---- Volumetric deck model (docs/realistic-volumetric-clouds.md).
+ //
+ // These guard the parts of the light-transport model that are cheap to lose in a tuning pass and
+ // expensive to notice: each one names the artefact that comes back if the assertion fails.
+
+ /**
+ * The visible deck and the shadow it casts must read ONE coverage function.
+ *
+ * They used to be evaluated twice with different inputs — the shadow merged the weather fill in,
+ * the density did not — so in rain the deck stayed at the slider's coverage while its shadow closed
+ * the sky completely. Nothing but this test stops that from being reintroduced by a "small" edit to
+ * either side.
+ */
+ @Test
+ void volumetricDensityAndCloudShadowReadOneCoverageField() throws IOException {
+ String source = Files.readString(CLOUDS);
+ assertEquals(1, count(source, "float cloudVolumetricCoverage("),
+ "the volumetric coverage ramp must be defined exactly once, so the deck and its shadow "
+ + "cannot drift apart");
+ assertTrue(slice(source, "float cloudVolumeDensity(WorldPush push", "float cloudSunOpticalDepth(")
+ .contains("cloudVolumetricCoverage(w.coverage, samplePos)"),
+ "the visible density must read the shared coverage ramp");
+ assertTrue(slice(source, "public float cloudCoverage(WorldPush push", "/** Where a ray meets the deck")
+ .contains("return cloudVolumetricCoverage(retain + (1.0 - retain) * fill, samplePos);"),
+ "the shadow query must read the same ramp, weather fill included");
+ }
+
+ /**
+ * Erosion is sampled from a 3D lattice, and its vertical coordinate is measured from the DECK'S base.
+ *
+ * Two separate regressions in one test. A 2D pattern times a height profile extrudes one flat
+ * picture through the whole depth, so the lobes line up vertically and the crown never breaks into
+ * individual heads — the single biggest tell of a procedural deck. And sampling the vertical axis
+ * from {@code posRel.y} (camera-relative) instead of from the slab base pins the cloud's internal
+ * structure to the EYE, so the deck swims as the camera rises or falls.
+ */
+ @Test
+ void erosionIsThreeDimensionalAndAnchoredToTheDeckBase() throws IOException {
+ String source = Files.readString(CLOUDS);
+ String density = slice(source, "float cloudVolumeDensity(WorldPush push", "float cloudSunOpticalDepth(");
+
+ assertTrue(source.contains("float cloudHash3(int3 cell)"),
+ "the module needs its own 3D lattice hash (math.slang is not imported: it pulls in "
+ + "world_core's bindings)");
+ assertTrue(density.contains("cloudBillow3(cloudDetailCoord(warpedXZ, warpedHeight"),
+ "erosion must be sampled from the 3D lattice at the displaced position, or the crown "
+ + "stops breaking into cauliflower heads");
+ assertTrue(density.contains("float height = hf * slabDepth;"),
+ "the vertical noise coordinate must be height above the deck's own base");
+ assertFalse(density.contains("posRel.y"),
+ "the vertical noise coordinate must never be camera-relative, or the cloud's internal "
+ + "structure swims past the eye as the camera changes altitude");
+ }
+
+ /**
+ * The fine octave of the erosion FBM must be CELLULAR (Worley F1), not a second billow.
+ *
+ * Every texture-based cloud implementation erodes with the Perlin-Worley pair: value/billow
+ * noise carries the soft rounded lobes while cellular noise carves the crisp scoops between them.
+ * Billow-only erosion has smooth boundaries everywhere, which is precisely the "aerated cotton
+ * wool" read that separates a procedural deck from a cumulus crown — it is the one asset the
+ * technique's write-ups all name, and this module generates it at runtime (an exact 3x3x3 F1 over
+ * a jittered lattice, one hash per neighbour) so the mod still ships no 3D texture.
+ *
+ * Three guards, because all three break silently:
+ * A cloud's own shape deciding how far it develops is most of what separates a cloud from a
+ * rectangular slab: a fair-weather cumulus is a few hundred blocks wide and half that deep, an
+ * overcast sheet is a shallow lid, a storm tower fills the convective layer. A global thickness
+ * fights that at every setting — it is the knob that made the deck read as "a rectangle whose
+ * look depends on the slider" — so the volumetric march derives its slab from the same
+ * coverage/weather reading that picks the profile, and the classic boxes keep the slider because
+ * their extrusion genuinely is it.
+ *
+ * Five guards: the march picks the genus depth instead of the pushed one before any consumer of
+ * it (slab bounds, crossing fade, sigma normalisation) runs; the slab BASE is recovered from the
+ * pushed centre and the PUSHED depth, so the floor stays at the altitude the height option
+ * promises instead of drifting with a slider the volumetric style no longer reads; the segment
+ * gate can no longer collapse the volumetric deck into the flat sheet at zero thickness; the three
+ * genus depths exist and are ordered sheet < heap < tower; and the classic path still reads
+ * the pushed value, so the slider did not lose its one real consumer.
+ */
+ @Test
+ void volumetricDepthIsGenusDrivenAndClassicKeepsTheSlider() throws IOException {
+ String source = Files.readString(CLOUDS);
+ String march = slice(source, "public CloudVolume cloudMarch(",
+ "// ---- Opacity as a genuine ceiling");
+ String boxes = slice(source, "CloudVolume cloudClassicBoxes(", "// Slab entry/exit along the ray");
+
+ assertTrue(march.contains("float thickness = classic ? pushedDepth : cloudDeckDepth(weather);"),
+ "the volumetric march must take the genus depth instead of the pushed thickness "
+ + "BEFORE the slab bounds, the crossing fade and the sigma normalisation derive "
+ + "from it, or those consumers disagree about how deep the deck is");
+ assertTrue(march.contains("float slabBottom = push.clouds.w - pushedDepth * 0.5 - originRel.y;"),
+ "the deck's base must be recovered from the pushed centre and the PUSHED depth, never "
+ + "from the genus depth: centring the genus slab on the pushed centre let the "
+ + "floor float tens of blocks above the configured height, drifting with a "
+ + "slider the volumetric style does not even read");
+ assertTrue(source.contains("float cloudDeckDepth(CloudWeather w)"),
+ "the deck depth must be one function of the genus state, shared by every consumer");
+ double sheet = slangConst(source, "CLOUD_DECK_DEPTH_SHEET");
+ double heap = slangConst(source, "CLOUD_DECK_DEPTH_HEAP");
+ double tower = slangConst(source, "CLOUD_DECK_DEPTH_TOWER");
+ assertTrue(sheet < heap && heap < tower,
+ "genus depths must order sheet < heap < tower (got " + sheet + ", " + heap + ", "
+ + tower + "): inverting them makes a storm shallower than fair weather");
+ assertTrue(source.contains("if (push.cloudAnchor.z > CLOUD_FLAT_EPSILON || !classic)"),
+ "a zero thickness must not collapse the volumetric deck into the flat sheet: the "
+ + "sheet is what the classic slider's zero asks for, and only that");
+ assertTrue(boxes.contains("float thickness = max(push.cloudAnchor.z, 1.0);"),
+ "the classic boxes are extruded by exactly the pushed thickness — the slider keeps "
+ + "its one real consumer");
+
+ String density =
+ slice(source, "float cloudVolumeDensity(WorldPush push", "float cloudSunOpticalDepth(");
+ assertTrue(density.contains("hf = hf * lerp(stretch, 1.0, w.sheet);"),
+ "each cloud must draw its own vertical development from a low-frequency field, or "
+ + "every cloud in the sky closes its dome at the same fraction of the slab and "
+ + "the deck reads as one population of identical puffs");
+ String coverage = slice(source, "float cloudVolumetricCoverage(", "/**\n * Coverage resolved");
+ assertTrue(coverage.contains("(mask - 0.5) * CLOUD_COVERAGE_CLUSTER"),
+ "the coverage threshold must wander across the sky, so neighbours merge into big "
+ + "masses in one region and shrink to fragments in the next: a single global "
+ + "threshold gives every cloud the same size");
+ }
+
+ /**
+ * One sample is lit by THREE optical depths through a multi-scattering expansion.
+ *
+ * Self-shadowing, ambient occlusion and ground bounce are three questions about three different
+ * directions; dropping any of them leaves the deck looking like lit cotton wool with a black
+ * underside. And without the octave expansion (each bounce order re-evaluating the same light terms
+ * with scattering, extinction and phase all relaxed toward isotropic) an optically thick medium
+ * renders as a flat grey silhouette — measured cloud optical depth is 12..92, so a photon really does
+ * scatter tens of times before it escapes.
+ */
+ @Test
+ void sampleLightingIntegratesThreeOpticalDepthsThroughTheOctaveExpansion() throws IOException {
+ String source = Files.readString(CLOUDS);
+ String scatter = slice(source, "float3 cloudSampleScatter(WorldPush push",
+ "/** Result of a volumetric march");
+
+ assertInOrder(scatter,
+ "float sunOD = cloudSunOpticalDepth(",
+ "float skyOD = cloudSkyOpticalDepth(",
+ "float groundOD = cloudGroundOpticalDepth(",
+ "for (int order = 0; order < light.octaves; order++)",
+ "CLOUD_MULTI_SCATTER_FALLOFF",
+ "CLOUD_MULTI_SCATTER_EXTINCT_FALLOFF",
+ "CLOUD_MULTI_SCATTER_PHASE_FALLOFF");
+ for (String probe : new String[] {"float cloudSunOpticalDepth(", "float cloudSkyOpticalDepth(",
+ "float cloudGroundOpticalDepth("}) {
+ assertEquals(1, count(source, probe), probe + " must be defined exactly once");
+ }
+ }
+
+ /**
+ * The thickness slider adds BULK, not opacity.
+ *
+ * Extinction is per unit length, so without the slab-depth normalisation the total optical depth
+ * grows with the deck's depth and the thickness control silently doubles as a second opacity slider
+ * — a deep deck goes solid white at the horizon while the opacity slider still says 20%. This is the
+ * shader half of the "thickness means grossura, not distance from the ground" requirement.
+ */
+ @Test
+ void thicknessControlsBulkRatherThanOpacity() throws IOException {
+ String march = slice(Files.readString(CLOUDS), "public CloudVolume cloudMarch(",
+ "// ---- Unified entry point");
+ assertTrue(march.contains("* (CLOUD_REFERENCE_THICKNESS / max(thickness, 1.0)) *"),
+ "extinction must be normalised by the slab depth, so raising the thickness slider adds "
+ + "volume without making the deck more opaque");
+ }
+
+ /**
+ * The march start is dithered per pixel AND per frame.
+ *
+ * A fixed sample pattern puts the truncation error at a fixed place: bands across the deck, rings
+ * at its edge, a step in every shadow terminator. Offsetting the start by a hash of the pixel index
+ * and the frame counter moves that error somewhere different every frame, which is what lets the
+ * temporal denoiser resolve it. Dropping either half of the seed is the usual mistake — pixel-only
+ * dither freezes the pattern into static, frame-only dither bands across the screen.
+ */
+ @Test
+ void marchIsDitheredPerPixelAndPerFrame() throws IOException {
+ String source = Files.readString(CLOUDS);
+ String dither = slice(source, "float cloudDither(WorldPush push)", "float cloudVolumeDensity(");
+ assertTrue(dither.contains("DispatchRaysIndex().xy"),
+ "the dither must vary per pixel");
+ assertTrue(dither.contains("push.frameIndex"),
+ "the dither must rotate per frame, or it freezes into visible static");
+ assertTrue(slice(source, "public CloudVolume cloudMarch(", "// ---- Unified entry point")
+ .contains("float marchStart = t0 + stepLen * dither;"),
+ "the march must actually start at the dithered offset");
+ }
+
+ /**
+ * Each step deposits the single-scattering albedo times the light the step ABSORBED.
+ *
+ * For a homogeneous stride the exact integral is {@code S * (sigma_s/sigma_t) * (1 - e^-tau)},
+ * which is independent of the stride length. Accumulating {@code S * (1 - T)} without the albedo
+ * ratio instead makes the deck's brightness a property of the march resolution, so raising the step
+ * count brightens the clouds and a coarse step through thin cloud disagrees with a fine one through
+ * thick cloud — the fixed-count version banded for exactly this reason.
+ */
+ @Test
+ void stepIntegralIsEnergyConserving() throws IOException {
+ assertInOrder(slice(Files.readString(CLOUDS), "public CloudVolume cloudMarch(",
+ "// ---- Unified entry point"),
+ "float sampleTransmittance = exp(-sigmaStep * stepLen);",
+ "(sigmaS / max(light.sigmaT, 1.0e-6))",
+ "* (1.0 - sampleTransmittance);");
+ }
+
+ /**
+ * Distant cloud fades INTO the sky rather than being deleted at the view limit.
+ *
+ * The air between the eye and the deck dims the deck's own scatter and puts sky radiance in its
+ * place, which is why real distant clouds lose contrast and take on the horizon's colour. Without
+ * this the deck's cutoff is a visible line where cloud stops existing.
+ */
+ @Test
+ void distantDeckFadesIntoTheSkyInsteadOfBeingDeleted() throws IOException {
+ assertInOrder(slice(Files.readString(CLOUDS), "public CloudVolume cloudMarch(",
+ "// ---- Unified entry point"),
+ "float aerial = CLOUD_AERIAL_STRENGTH",
+ "skyBehind * (1.0 - result.transmittance)");
+ }
+
+ /**
+ * The genus comes from lanes the frame ALREADY pushes, and only when weather lighting is on.
+ *
+ * A storm's deep grey tower cloud, its dimmed sun and its thickened air must be one reading of one
+ * state. Reading the rain lanes unconditionally would make the deck change shape in dimensions and
+ * configurations where the rest of the renderer ignores weather, and hand-rolling a separate
+ * "storminess" would guarantee the two disagree.
+ */
+ @Test
+ void weatherPicksTheGenusFromTheLanesTheFrameAlreadyPushes() throws IOException {
+ String source = Files.readString(CLOUDS);
+ String weather = slice(source, "public CloudWeather cloudWeather(WorldPush push)",
+ "public float cloudCoverageField(");
+ assertInOrder(weather,
+ "push.clouds.x",
+ "push.cloudColor.w",
+ "FEATURE_WEATHER_LIGHTING",
+ "push.weather.x",
+ "push.weather.y");
+ String density = slice(source, "float cloudVolumeDensity(WorldPush push", "float cloudSunOpticalDepth(");
+ assertTrue(density.contains("w.sheet") && density.contains("w.convection"),
+ "the height profile must be chosen by the genus, or every sky gets the same cloud shape");
+ }
+
+ /**
+ * The classic style keeps vanilla's flat face shading, and keeps the storm absorption out of it.
+ *
+ * Classic clouds are meant to look like Minecraft's boxes, not like clouds, so the rework must not
+ * leak the volumetric light model into them. The absorption guard is the subtle half: classic already
+ * greys in rain through {@code push.cloudColor} (vanilla's own CLOUD_COLOR), so applying the
+ * volumetric storm extinction as well would darken the boxes twice for the same weather.
+ */
+ @Test
+ void classicStyleKeepsItsFlatVanillaShading() throws IOException {
+ String source = Files.readString(CLOUDS);
+ String march = slice(source, "public CloudVolume cloudMarch(", "// ---- Unified entry point");
+ assertInOrder(march,
+ "float classicFace = 1.0;",
+ "inScatter = push.cloudColor.xyz * CLOUD_INV_PI",
+ "(sunRadiance * classicFace * shade * 3.0 + skyBehind * 1.4);");
+ assertTrue(march.contains(
+ "light.sigmaT = sigma * (classic ? 1.0 : 1.0 + weather.absorbing * CLOUD_STORM_ABSORPTION);"),
+ "storm absorption must stay volumetric-only or classic boxes darken twice in rain");
+ assertTrue(source.contains("cloudClassicBoxes(push, originRel, dir, maxDistance, ambient)"),
+ "the analytic classic box path must still be the one classic clouds take");
+ }
+
private static String slice(String source, String startNeedle, String endNeedle) {
int start = source.indexOf(startNeedle);
assertTrue(start >= 0, "missing shader snippet start: " + startNeedle);
@@ -89,6 +407,22 @@ private static String slice(String source, String startNeedle, String endNeedle)
return source.substring(start, end);
}
+ /** Parses a {@code public static const float NAME = ...;} out of clouds.slang. */
+ private static double slangConst(String source, String name) {
+ var m = java.util.regex.Pattern.compile("static const float " + name + " = ([\\d.]+);")
+ .matcher(source);
+ assertTrue(m.find(), "clouds.slang must define float " + name);
+ return Double.parseDouble(m.group(1));
+ }
+
+ private static int count(String source, String needle) {
+ int occurrences = 0;
+ for (int at = source.indexOf(needle); at >= 0; at = source.indexOf(needle, at + 1)) {
+ occurrences++;
+ }
+ return occurrences;
+ }
+
private static void assertInOrder(String source, String... needles) {
int at = -1;
for (String needle : needles) {
+ *
+ */
+ @Test
+ void detailErosionIsCellularWorleyWithAMeanMatchedRemap() throws IOException {
+ String source = Files.readString(CLOUDS);
+ String density =
+ slice(source, "float cloudVolumeDensity(WorldPush push", "float cloudSunOpticalDepth(");
+ String worley = slice(source, "float cloudWorley3(float3 p) {",
+ "// One noise octave's sample coordinate");
+
+ assertTrue(source.contains("uint cloudHash3Bits(int3 cell)"),
+ "the Worley lattice needs three jitter components out of ONE hash per neighbour; three "
+ + "hashes per neighbour would triple the most expensive octave in the density");
+ assertTrue(density.contains("cloudWorley3(cloudDetailCoord(warpedXZ, warpedHeight"),
+ "the fine erosion octave must be the cellular field sampled at the fine divisor, or the "
+ + "crown keeps billow's soft boundaries and never reads as aerated cauliflower");
+ assertEquals(3, count(worley, "for (int"),
+ "F1 needs the full 3x3x3 neighbourhood, and no more than that");
+ assertTrue(worley.contains("+ 0.5 + j - f"),
+ "each feature point is its cell's centre plus the jitter, measured against the sample");
+
+ var jitter = java.util.regex.Pattern.compile("CLOUD_WORLEY_JITTER = ([\\d.]+);").matcher(source);
+ assertTrue(jitter.find(), "clouds.slang must declare CLOUD_WORLEY_JITTER");
+ assertTrue(Double.parseDouble(jitter.group(1)) < 1.0,
+ "jitter must stay under one cell per axis, or the nearest feature point escapes the "
+ + "3x3x3 neighbourhood and F1 silently becomes an approximation with seams "
+ + "along the cell boundaries");
+
+ assertTrue(density.contains("* CLOUD_WORLEY_REMAP_SCALE + CLOUD_WORLEY_REMAP_BIAS"),
+ "raw F1 averages ~0.51 on this lattice and must be remapped to average 0.5: the SHAPE "
+ + "tier substitutes that octave's expected value for it, so an unfitted remap "
+ + "biases every light probe");
+ }
+
+ /**
+ * The volumetric deck's depth is the GENUS model's, and the thickness slider is a classic knob.
+ *
+ *