From b9dbd20511c49141b7683baa7368fa68858ea971 Mon Sep 17 00:00:00 2001 From: xysgottaken2 <217395987+xysgottaken2@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:10:05 +0000 Subject: [PATCH 1/7] Rework the volumetric cloud deck into a modelled water cloud The volumetric style was a 2D noise field extruded through a height profile and lit by single scattering. Three measurable consequences: lobes lined up vertically through the whole depth (a picture, not a cloud), an optically thick medium rendered as a silhouette with a black underside, and the interior was about eight times dimmer than a sunlit cloud top should be. Geometry: coverage stays 2D (a cloud layer is one condensing air mass) and is now one function shared by the deck and its shadow, which used to merge the weather fill differently and so disagreed in rain. Erosion moves to a 3D billow lattice sampled in blocks above the deck's own base, so the crown breaks into individual cauliflower heads and the structure stays pinned to the world instead of swimming past the camera. A weather-driven genus model picks the height profile from lanes the frame already pushes: a closed sky is a stratocumulus sheet, a scattered one is fair-weather cumulus heaps, thunderstorms are convective towers, and precipitating cloud adds absorption. Wind shear displaces the sample position by an amplitude that grows with altitude, so towers lean and crowns curl. Light: three optical-depth probes (sun on exponentially growing strides, zenith, analytic ground bounce) feed a six-order multi-scattering expansion that relaxes scattering, extinction and phase per bounce order, through a normalised three-lobe Mie-approximating phase and a powder term that lets go toward the light. Distant cloud fades into the sky rather than being deleted at the view limit. The overall gain is calibrated against the real sky (0.75/PI of the incident irradiance for an optically thick conservative deck) instead of tuned by eye. March: energy-conserving step integral, per-pixel per-frame dithered start, transmittance early exit, distance LOD on the fine erosion octave. Thickness stays the deck's BULK and height stays its base: extinction is normalised by the slab depth, so raising thickness adds volume without adding opacity. The classic style is unchanged apart from borrowing the new sun probe, and stays out of the storm absorption and aerial perspective terms. docs/realistic-volumetric-clouds.md records the model, the reference material and where every number comes from. Two test classes guard it: nine new assertions on the transport model, and RtCloudPeriodMirrorTest re-deriving the anchor wrap identity from both the shader's octave divisors and RtComposite's pushed period. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- README.md | 7 +- docs/realistic-volumetric-clouds.md | 349 +++++++ shaders/world/clouds.slang | 922 +++++++++++++++--- .../comfyfluffy/caustica/CausticaConfig.java | 45 +- .../comfyfluffy/caustica/rt/RtComposite.java | 26 +- .../resources/assets/caustica/lang/en_us.json | 6 +- .../resources/assets/caustica/lang/pt_br.json | 6 +- .../caustica/rt/RtCloudPeriodMirrorTest.java | 182 ++++ .../rt/RtCloudShaderRegressionTest.java | 211 ++++ 9 files changed, 1587 insertions(+), 167 deletions(-) create mode 100644 docs/realistic-volumetric-clouds.md create mode 100644 src/test/java/dev/comfyfluffy/caustica/rt/RtCloudPeriodMirrorTest.java 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..b662e31b --- /dev/null +++ b/docs/realistic-volumetric-clouds.md @@ -0,0 +1,349 @@ +# 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. + +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** (`cumulus.glsl`, `coverage_map.glsl`, `common.glsl`, `parameters.glsl`, `settings.glsl`) — read +as source. Taken from it: the density pipeline order (coverage → altitude shaping → detail erosion → +edge sharpening → final height ramp); `edge_sharpening` as an exponent that thins or fattens the field; +the optical-depth march with exponentially growing strides and a dithered start; the multi-octave +scattering loop with `scatter *= falloff * powder`, `extinct *= 0.4`, `phase_g *= 0.8`, +`powder = mix(powder, sqrt(powder), 0.5)`; three separate light probes (toward the sun/moon, toward the +sky, and a ground term guessed from altitude fraction); the `min_transmittance` early exit; the +two-scale coverage field; and per-cloud-type parameter sets rather than one global shape. Its phase +function is a weighted sum of HG lobes, which is what this file uses too. + +**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. + +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(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. +* `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. + +### 4.3 Height profile — flat base, crown that knows the genus + +* **Base** (`CLOUD_BASE_RAMP_HEAP = 0.07`, `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.70`, `TOWER = 0.94`, `SHEET = 0.48`, rounding `0.30`): where the top starts to + round off. Heaps round at ~70% of the slab; convective towers fill it entirely; an inversion-capped + sheet flattens at half. **Dense cores tower** — the *local* coverage lifts the crown + (`CLOUD_TOWER_COVERAGE_LIFT`), so the thick middle of a bank billows up into towers while its wispy + edges stay low and flat. That one coupling is most of why the result reads as a field of individual + clouds instead of one extruded slab. +* **Belly bulge** (`CLOUD_BULGE = 0.30` 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`. + +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 + +Two octaves of **3D** billow noise (`cloudBillow3` = `1 − |2·noise₃ − 1|`, which folds value noise into +rounded cellular lobes rather than a cloudy blur) at `CLOUD_BILLOW_DIV_COARSE = 1.0` and +`FINE = 0.25` — lobes 24 and 6 blocks across, a fraction of a cloud's width, which is the cumulus look. +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 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.115` per block at unit density. With one block to a metre this sits at the top of +the measured 0.005–0.1 m⁻¹ range *before* normalisation and comfortably inside it after, which is the +point: it is not a look knob, it is the deck's optical depth per block of cloud. Vertical optical depth +through a full deck works out at ~2.8 at mean density, so dense regions are effectively opaque (T ≈ 6%) +while a wisp at density 0.1 is ~63% transmissive — the right split. + +`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. (Photon uses the same shape: `0.65·HG(g) + 0.10·HG(g2) + 0.25·HG(−g3)`.) + +### 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). 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 same relaxation schedule Photon uses. + +### 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. (Photon exits at + 0.075.) +* **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 — Photon's own cloud pass is heavier still, because it samples a +3D detail texture this module cannot have. + +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 too wispy / too solid | `CLOUD_EXTINCTION` (with `CLOUD_REFERENCE_THICKNESS` if the slider feels like opacity) | +| lobes too small / too big | `CLOUD_BILLOW_DIV_COARSE`, `CLOUD_BILLOW_DIV_FINE` (power of two only!) | +| 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` | +| 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.** A Perlin-Worley shape atlas plus a Worley detail atlas (the industry + standard, and what Photon samples) would beat procedural hash noise on both quality and cost, but it + needs a texture binding this module deliberately does not have (§7.4) and an asset pipeline to produce + it. +* **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..248bbf79 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; @@ -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,116 @@ 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; +// 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.115; // 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). With one block to a metre this constant sits at the top of that range before the +// slab normalisation below and comfortably inside it after, which is the point: the number is not a +// look knob, it is the deck's optical depth per block of cloud. +// +// 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 (Photon's `extinct *= 0.4`) +// and of the phase anisotropy (`phase_g *= 0.8`). Both express the same 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 (Photon's `powder = mix(powder, sqrt(powder), .5)`): +// 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). Photon uses the same early exit at 0.075. +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 +212,113 @@ 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 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, 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_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. +// 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; +// ---- 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; +// 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.07; +public static const float CLOUD_BASE_RAMP_SHEET = 0.22; +public static const float CLOUD_CROWN_START_HEAP = 0.70; +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 starts lower where the local coverage is thin, so a bank develops +// cauliflower towers out of its thick parts while its wispy edges stay low and flat. +public static const float CLOUD_TOWER_COVERAGE_LIFT = 0.55; +// 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.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 +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. Two octaves of 3D billow noise, weighted, erode the shape field's edge. +public static const float CLOUD_DETAIL_FINE_WEIGHT = 0.45; +// 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.55; +public static const float CLOUD_EROSION_BASE = 0.35; +// Edge sharpening exponent, wispy at the base and hard at the crown (Photon's l0_edge_sharpening): +// > 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.55; +public static const float CLOUD_EDGE_SHARPEN_CROWN = 0.90; +// ---- 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. +// Photon uses the same three-lobe shape (0.65·HG(g) + 0.10·HG(g2) + 0.25·HG(−g3)). +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.7; +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 +396,95 @@ 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. +float cloudHash3(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 float(h & 0x00ffffffu) * (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); +} + +// 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); + 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; +} + /** * 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 +510,31 @@ 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); + float shape = cloudCoverageField(samplePos * (CLOUD_VOLUMETRIC_SCALE / CLOUD_SHAPE_DIV)); + 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 +584,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 +897,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. + +// 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. */ +/** 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 +996,23 @@ 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 — two octaves of 3D billow noise 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 +1027,262 @@ 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; + + // ---- 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.7 of the slab; 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 into towers while its wispy + // edges stay low and flat. 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, 1.0, + clamp(coverage * CLOUD_TOWER_COVERAGE_LIFT, 0.0, 1.0)); + float crownStart = lerp(heapCrown, CLOUD_CROWN_START_TOWER, w.convection); + crownStart = lerp(crownStart, CLOUD_CROWN_START_SHEET, w.sheet); + float top = 1.0 - smoothstep(crownStart, crownStart + CLOUD_CROWN_ROUNDING, 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: two octaves of 3D billow noise, weighted. The fine one fades out with distance + // (detailFade) and is skipped below the FULL tier. Both octaves average 0.5, which is why the cheap + // tier can drop one 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 = cloudBillow3(cloudDetailCoord(warpedXZ, warpedHeight, CLOUD_BILLOW_DIV_FINE) + + float3(19.0, 7.0, 23.0)); + 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 Photon's l0_edge_sharpening, 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 = coverage * (0.85 + 0.75 * coverage); + 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 +1292,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) { @@ -813,8 +1377,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 @@ -842,38 +1406,108 @@ 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. + CloudWeather weather = cloudWeather(push); + 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). Photon exits at 0.075. + 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 same + // transmittance-ratio blend Photon uses. + 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. diff --git a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java index 1fef2912..df5aaa59 100644 --- a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java +++ b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java @@ -771,21 +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}. + * + *
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: 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 + * non-marched path); 1 is a deep bank. Raising it adds VOLUME without making the clouds more + * opaque — the shader normalises extinction by the slab depth ({@code + * CLOUD_REFERENCE_THICKNESS}), so a 110-block bank and a 20-block sheet reach the same + * optical depth straight down and the slider controls shape alone, with see-through left to + * {@link #CLOUD_OPACITY}. Within whatever depth is set here, the weather picks the genus: a + * storm's towers fill the whole slab, fair-weather heaps round off at about 70% of it, and an + * overcast sheet hugs the lower half. 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. */ @@ -813,6 +834,10 @@ public static final class Composite { * 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. * + *
Position only: this is where the deck is, never how big it is — that is + * {@link #CLOUD_THICKNESS}. The deck grows UPWARD from this Y (RtComposite pushes the slab's + * centre, so raising the thickness does not sink the base the player is looking at). + * *
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. @@ -822,6 +847,14 @@ public static final class Composite { /** * 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/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index 111a459b..122a8ce8 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -443,25 +443,33 @@ private static Int4 sharcGridOrigin(RtTerrain terrain) { // snapped the entire cloudscape to a different pattern — clouds visibly changing shape while // walking, in the volumetric style only. // - // The wrap must therefore be a whole period in EVERY space the field is sampled in: the base - // octaves, the domain warp, and both billow layers. The binding constraint is the largest octave - // divisor (CLOUD_WARP_DIV = 2.0 in clouds.slang): + // The wrap must therefore be a whole period in EVERY space the field is sampled in: the coverage + // octaves, the turbulent displacement, and both 3D erosion octaves. The binding constraint is the + // largest octave divisor — CLOUD_SHAPE_DIV and CLOUD_WARP_DIV, both 2.0 in clouds.slang: // // period = 512 cells * 12 blocks/cell * maxDivisor(2.0) / scale(0.5) = 24576 blocks // // Every divisor there is a power of two, so all of these multiplies are exact in binary floating - // point and the wrap identity holds bit-for-bit rather than approximately. Verified: the full - // density function (base octaves + warp + billow) is now identical across a wrap to 0.0. + // point and the wrap identity holds bit-for-bit rather than approximately, and 24576 is an integer + // multiple of every smaller octave's own period (the erosion layers at 12288 and 3072). Verified: the + // full density function (coverage + displacement + 3D erosion) is identical across a wrap to 0.0. + // RtCloudPeriodMirrorTest re-derives this from the shader's constants so the two cannot drift. private static final double CLOUD_FIELD_PERIOD_BLOCKS = 512.0 * 12.0 * 2.0 / 0.5; // Vanilla's clouds drift at 0.03 blocks/tick; matched so the sky moves at a familiar speed. private static final double CLOUD_WIND_BLOCKS_PER_TICK = 0.03; - // Deck thickness at the slider's 100%. Both styles march a real slab now, so this is the depth the - // clouds actually have in the world: at full thickness a bank is tall enough to fly into, while the - // slider at 0 collapses the deck to the old flat plane. + // Deck thickness at the slider's 100%: the BULK of the cloud layer, i.e. how deep the slab the + // shader marches is. This is the cloud's size, never its altitude (that is CLOUD_HEIGHT, which sets + // the base) — a distinction that only exists because the deck stopped being a flat texture. + // At full thickness a bank is deep enough to fly into; at 0 it collapses to the old flat plane. // Real cumulus is as tall as it is wide, often taller — a bank whose base sits at cloud height can // easily tower 100+ blocks. 40 was too shallow for the deck to ever read as heaped rather than - // layered, and since extinction is now normalised by the slab depth (CLOUD_REFERENCE_THICKNESS in + // layered, and since extinction is normalised by the slab depth (CLOUD_REFERENCE_THICKNESS in // clouds.slang) raising this adds VOLUME without making the clouds more opaque. + // + // The weather does not change this number: the player's slider stays the size budget, and the genus + // model in clouds.slang decides how much of the budget a given sky actually uses — storm towers fill + // the slab, fair-weather heaps round off at ~70% of it, an overcast sheet hugs the lower half. So a + // storm reads as deeper cloud without ever overriding the setting. private static final float CLOUD_MAX_THICKNESS_BLOCKS = 110.0f; // Classic boxes never get thinner than vanilla's own 4-block extrusion (CloudRenderer's // putVec3(12, 4, 12)): the thickness slider scales the box HEIGHT from that baseline up, per the diff --git a/src/main/resources/assets/caustica/lang/en_us.json b/src/main/resources/assets/caustica/lang/en_us.json index cdf54fae..cf81ebe3 100644 --- a/src/main/resources/assets/caustica/lang/en_us.json +++ b/src/main/resources/assets/caustica/lang/en_us.json @@ -76,7 +76,7 @@ "caustica.options.rt.clouds": "Ray-Traced Clouds", "caustica.options.rt.clouds.tooltip": "Draw a cloud deck in the ray-traced sky. Caustica replaces the vanilla world renderer, which is where vanilla's own clouds are drawn, so this is what puts clouds back in the sky. The deck is visible in reflections and can shadow the world below.", "caustica.options.rt.cloudStyle": "Cloud Style", - "caustica.options.rt.cloudStyle.tooltip": "How clouds are drawn. Classic reproduces Minecraft's flat, blocky cloud layer. Volumetric ray-marches the clouds as real 3D shapes with depth, self-shadowing and light scattering, like heavy shaderpacks - much better looking, but noticeably more demanding. Both use the same cloud shapes, so switching does not move the clouds.", + "caustica.options.rt.cloudStyle.tooltip": "How clouds are drawn. Classic reproduces Minecraft's flat, blocky cloud layer. Volumetric ray-marches a modelled water-cloud deck: a coverage field for where the layer is, 3D erosion for the shape of each cloud, and multi-scattered light with separate sun, sky and ground-bounce depths - the look heavy shaderpacks produce, at roughly twice the cost of the older volumetric model. Both read the same coverage field, so switching does not move the clouds; the volumetric style also reads the weather to pick the cloud type, so rain changes the shape of the sky and not just its brightness.", "caustica.options.rt.cloudStyle.classic": "Classic", "caustica.options.rt.cloudStyle.volumetric": "Volumetric", "caustica.options.rt.skyStyle": "Sky Style", @@ -86,7 +86,7 @@ "caustica.options.rt.cloudHeight": "Cloud Height", "caustica.options.rt.cloudHeight.tooltip": "World height the bottom of the clouds sits at. Vanilla's clouds sit at Y 192; the default here is higher because these clouds have real thickness, which makes a low deck feel much closer than vanilla's flat sheet. Raise it if the clouds feel too close to the ground. Measures the cloud base, so changing thickness does not move it.", "caustica.options.rt.cloudThickness": "Cloud Thickness", - "caustica.options.rt.cloudThickness.tooltip": "How tall the clouds are. 0% makes them a flat sheet; higher values give them real 3D depth you can fly into. Classic clouds become solid boxes with bright tops and darker sides like vanilla's, and volumetric clouds become tall, billowing cumulus. This changes their shape only, not how see-through they are - use Cloud Opacity for that. Thicker clouds cost more performance.", + "caustica.options.rt.cloudThickness.tooltip": "How much bulk the clouds have - how deep the layer is from its base to its top. This is the cloud's size, not its position: where the deck sits is Cloud Height, and the two are independent. 0% makes them a flat sheet; higher values give real 3D depth you can fly into. Thicker clouds do not get more opaque (the shader normalises extinction by depth), so this changes their shape only - use Cloud Opacity for see-through. Classic clouds become solid boxes with bright tops and darker sides like vanilla's; volumetric clouds become deep cumulus, and within whatever depth you set the weather picks the type: storm towers fill it, fair-weather heaps round off below the top, an overcast sheet hugs the bottom. Thicker clouds cost more performance.", "caustica.options.rt.cloudShadowStrength": "Cloud Shadows", "caustica.options.rt.cloudShadowStrength.tooltip": "How much the cloud deck dims the sunlight and moonlight reaching the ground under it. 0% draws clouds that cast no shadow at all; 100% lets a solid cloud block the sun completely. Costs no extra rays.", "caustica.options.rt.cloudOpacity": "Cloud Opacity", @@ -238,7 +238,7 @@ "caustica.options.rt.parallaxQuality": "POM Quality", "caustica.options.rt.parallaxQuality.tooltip": "Sampling level of Parallax Occlusion Mapping: multiplies the per-hit height-field walk budget on top of the depth-derived base. Higher keeps fine relief intact at grazing angles on high-resolution packs (and costs more); lower saves GPU. Independent of POM Depth, which still sets how far the relief visually goes.", "caustica.options.rt.cloudCoverage": "Cloud Coverage", - "caustica.options.rt.cloudCoverage.tooltip": "Fraction of the sky the cloud deck covers in clear weather. Rain and thunderstorms drive the sky toward fully overcast on top of this; 0% leaves isolated wisps, 100% a nearly closed ceiling.", + "caustica.options.rt.cloudCoverage.tooltip": "Fraction of the sky the cloud deck covers in clear weather. Rain and thunderstorms drive the sky toward fully overcast on top of this; 0% leaves isolated wisps, 100% a nearly closed ceiling. In the volumetric style this also picks the cloud type, as it does in the real sky: a scattered field is fair-weather cumulus (separate heaps with a flat base), while a closed sky becomes a shallow stratocumulus sheet that stops growing upward.", "caustica.options.rt.cloudCoverage.classicUnavailable": "Cloud Coverage: Disabled (Classic Deck)", "caustica.options.rt.cloudCoverage.classicUnavailable.tooltip": "The classic deck is a single flat texture (like vanilla's clouds.png): its coverage is baked into the image, so this slider cannot change it. The volumetric deck renders the live coverage field with real depth — switch the style back to use the slider.", "caustica.options.rt.fogHeader": "Volumetric Fog", diff --git a/src/main/resources/assets/caustica/lang/pt_br.json b/src/main/resources/assets/caustica/lang/pt_br.json index 84a8e0e0..9cdf4cdb 100644 --- a/src/main/resources/assets/caustica/lang/pt_br.json +++ b/src/main/resources/assets/caustica/lang/pt_br.json @@ -76,7 +76,7 @@ "caustica.options.rt.clouds": "Nuvens de ray tracing", "caustica.options.rt.clouds.tooltip": "Desenha um deck de nuvens no céu de ray tracing. O Caustica substitui o renderizador de mundo do vanilla, que é onde as nuvens do vanilla são desenhadas — e é isso que traz as nuvens de volta ao céu. O deck é visível em reflexos e pode sombrear o mundo abaixo.", "caustica.options.rt.cloudStyle": "Estilo das nuvens", - "caustica.options.rt.cloudStyle.tooltip": "Como as nuvens são desenhadas. Clássico reproduz a camada de nuvens plana e quadrada do Minecraft. Volumétrico renderiza as nuvens com ray marching como formas 3D reais, com profundidade, auto-sombreamento e espalhamento de luz, como os shaderpacks pesados — muito mais bonito, mas bem mais pesado. Os dois usam as mesmas formas de nuvem, então trocar não move as nuvens.", + "caustica.options.rt.cloudStyle.tooltip": "Como as nuvens são desenhadas. Clássico reproduz a camada de nuvens plana e quadrada do Minecraft. Volumétrico faz ray marching de um deck de nuvens modelado: um campo de cobertura define onde a camada fica, a erosão 3D define o formato de cada nuvem, e a luz tem espalhamento múltiplo com profundidades ópticas separadas para o sol, o céu e o reflexo do chão — o visual dos shaderpacks pesados, a cerca do dobro do custo do modelo volumétrico anterior. Os dois leem o mesmo campo de cobertura, então trocar não move as nuvens; o volumétrico também lê o clima para escolher o tipo de nuvem, então a chuva muda o formato do céu e não apenas o brilho.", "caustica.options.rt.cloudStyle.classic": "Clássico", "caustica.options.rt.cloudStyle.volumetric": "Volumétrico", "caustica.options.rt.skyStyle": "Estilo do céu", @@ -86,7 +86,7 @@ "caustica.options.rt.cloudHeight": "Altura das nuvens", "caustica.options.rt.cloudHeight.tooltip": "Altura do mundo onde fica a base das nuvens. As do vanilla ficam em Y 192; o padrão aqui é mais alto porque essas nuvens têm espessura real, o que faz um deck baixo parecer muito mais próximo do que a folha plana do vanilla. Aumente se as nuvens parecerem perto demais do chão. Mede a base das nuvens, então mudar a espessura não a desloca.", "caustica.options.rt.cloudThickness": "Espessura das nuvens", - "caustica.options.rt.cloudThickness.tooltip": "Quão altas são as nuvens. 0% as torna uma folha plana; valores maiores dão profundidade 3D de verdade para voar para dentro. Nuvens clássicas viram caixas sólidas com o topo claro e laterais escuras como no vanilla, e volumétricas viram cúmulos altos e cheios. Muda só a forma, não a transparência — para isso use a opacidade das nuvens. Nuvens mais espessas custam mais performance.", + "caustica.options.rt.cloudThickness.tooltip": "Quanto volume as nuvens têm — quão profunda é a camada, da base até o topo. É o tamanho da nuvem, não a posição dela: onde o deck fica é a Altura das nuvens, e os dois são independentes. 0% as torna uma folha plana; valores maiores dão profundidade 3D de verdade para voar para dentro. Nuvens mais espessas não ficam mais opacas (o shader normaliza a extinção pela profundidade), então isso muda só a forma — para transparência use a Opacidade das nuvens. Nuvens clássicas viram caixas sólidas com o topo claro e laterais escuras como no vanilla; as volumétricas viram cúmulos profundos e, dentro da profundidade que você definir, o clima escolhe o tipo: torres de tempestade a preenchem, cúmulos de tempo bom arredondam abaixo do topo, e um céu encoberto fica colado na parte de baixo. Nuvens mais espessas custam mais performance.", "caustica.options.rt.cloudShadowStrength": "Sombras das nuvens", "caustica.options.rt.cloudShadowStrength.tooltip": "Quanto o deck de nuvens escurece a luz do sol e da lua que chega ao chão embaixo. 0% desenha nuvens sem sombra nenhuma; 100% permite que uma nuvem sólida bloqueie o sol por completo. Não custa raios extras.", "caustica.options.rt.cloudOpacity": "Opacidade das nuvens", @@ -238,7 +238,7 @@ "caustica.options.rt.parallaxQuality": "Qualidade do POM", "caustica.options.rt.parallaxQuality.tooltip": "Nível de amostragem do Parallax Occlusion Mapping: multiplica o orçamento de percorrida do campo de altura por acerto sobre a base derivada da profundidade. Maior mantém o relevo fino intacto em ângulos rasos em pacotes de alta resolução (e custa mais); menor economiza GPU. Independe da Profundidade do POM, que continua definindo a profundidade do relevo.", "caustica.options.rt.cloudCoverage": "Cobertura de Nuvens", - "caustica.options.rt.cloudCoverage.tooltip": "Fração do céu que a camada de nuvens cobre em tempo limpo. Chuva e tempestades levam o céu ao nublado por cima disso; 0% deixa fios isolados, 100% um teto quase fechado.", + "caustica.options.rt.cloudCoverage.tooltip": "Fração do céu que a camada de nuvens cobre em tempo limpo. Chuva e tempestades levam o céu ao nublado por cima disso; 0% deixa fios isolados, 100% um teto quase fechado. No estilo volumétrico isso também escolhe o tipo de nuvem, como no céu real: um campo esparsado é cúmulo de tempo bom (montes separados com base plana), enquanto um céu fechado vira uma camada rasa de estratocúmulo que para de crescer para cima.", "caustica.options.rt.cloudCoverage.classicUnavailable": "Cobertura de nuvens: desativada (deck Clássico)", "caustica.options.rt.cloudCoverage.classicUnavailable.tooltip": "O deck clássico é uma textura plana única (como o clouds.png do vanilla): a cobertura já vem gravada na imagem, então este slider não consegue alterá-la. O deck volumétrico renderiza o campo de cobertura ao vivo com profundidade real — volte o estilo para usar o slider.", "caustica.options.rt.fogHeader": "Névoa Volumétrica", diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/RtCloudPeriodMirrorTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/RtCloudPeriodMirrorTest.java new file mode 100644 index 00000000..ec97f0c1 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/RtCloudPeriodMirrorTest.java @@ -0,0 +1,182 @@ +package dev.comfyfluffy.caustica.rt; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Guards the cloud field's wrap identity — the one invariant that is invisible until it breaks, and + * which has broken twice. + * + *
{@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..7daf8893 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/RtCloudShaderRegressionTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/RtCloudShaderRegressionTest.java @@ -81,6 +81,209 @@ 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"); + } + + /** + * 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 +292,14 @@ private static String slice(String source, String startNeedle, String endNeedle) return source.substring(start, end); } + 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) { From 1abeb3af9b4e5cbb40a80c38bea14b303abcfed7 Mon Sep 17 00:00:00 2001 From: xysgottaken2 <217395987+xysgottaken2@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:24:53 +0000 Subject: [PATCH 2/7] Attribute the cloud techniques to their published sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit's comments cited another shader pack by name and quoted three of its expressions and two of its parameter names while doing so. That was sloppy in both directions: it credited a redistributable implementation for techniques that were published in the literature years earlier, and it put fragments of someone else's code — however trivial — into this repository's comments under a license that reserves all rights not explicitly granted. Nothing here was ever derived from that source: no file, function, expression, identifier, constant set or asset of it exists in this repo, and the two implementations differ mechanically at every level (GLSL sampling precomputed 3D textures through uniforms, versus Slang reading WorldPush lanes and generating every field from a periodic integer hash written here, with no texture or sampler in the module at all). The sources were read, which its license explicitly permits, and the ideas are what was taken. So: the octave relaxation schedule, the powder term, the growing-stride light march, the early exit and the edge-sharpening exponent are now attributed to where they were published (Wrenninge's multi-scattering model, the Frostbite and Nubis implementations, Mie/HG practice), and the reference section of docs/realistic-volumetric-clouds.md states the provenance and licensing position explicitly instead of leaving a reader to infer it. Comment and documentation changes only — no code, no constants, no behaviour. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- docs/realistic-volumetric-clouds.md | 43 ++++++++++++++++++----------- shaders/world/clouds.slang | 33 ++++++++++++---------- 2 files changed, 46 insertions(+), 30 deletions(-) diff --git a/docs/realistic-volumetric-clouds.md b/docs/realistic-volumetric-clouds.md index b662e31b..902a596b 100644 --- a/docs/realistic-volumetric-clouds.md +++ b/docs/realistic-volumetric-clouds.md @@ -44,15 +44,26 @@ rain made it darker but never made it a *different kind of cloud*. ## 3. Reference material -**Photon** (`cumulus.glsl`, `coverage_map.glsl`, `common.glsl`, `parameters.glsl`, `settings.glsl`) — read -as source. Taken from it: the density pipeline order (coverage → altitude shaping → detail erosion → -edge sharpening → final height ramp); `edge_sharpening` as an exponent that thins or fattens the field; -the optical-depth march with exponentially growing strides and a dithered start; the multi-octave -scattering loop with `scatter *= falloff * powder`, `extinct *= 0.4`, `phase_g *= 0.8`, -`powder = mix(powder, sqrt(powder), 0.5)`; three separate light probes (toward the sun/moon, toward the -sky, and a ground term guessed from altitude fraction); the `min_transmittance` early exit; the -two-scale coverage field; and per-cloud-type parameter sets rather than one global shape. Its phase -function is a weighted sum of HG lobes, which is what this file uses too. +**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 (`cloudHash3`/`cloudNoise3`/`cloudBillow3`), 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). **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 @@ -193,7 +204,7 @@ scattering by ~10 µm droplets is dominated by a very tight forward peak inside 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. (Photon uses the same shape: `0.65·HG(g) + 0.10·HG(g2) + 0.25·HG(−g3)`.) +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 @@ -224,7 +235,7 @@ direction a little more). The geometric series converges, costs no extra march, 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 same relaxation schedule Photon uses. +— the relaxation schedule the octave model was published with. ### 5.5 Powder @@ -271,8 +282,7 @@ dissolving *into* the sky rather than by cloud being deleted at a line. 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. (Photon exits at - 0.075.) + 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. @@ -312,8 +322,9 @@ dissolving *into* the sky rather than by cloud being deleted at a line. 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 — Photon's own cloud pass is heavier still, because it samples a -3D detail texture this module cannot have. +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 @@ -339,7 +350,7 @@ half a full density each. ## 10. Not done * **No 3D detail texture.** A Perlin-Worley shape atlas plus a Worley detail atlas (the industry - standard, and what Photon samples) would beat procedural hash noise on both quality and cost, but it + standard) would beat procedural hash noise on both quality and cost, but it needs a texture binding this module deliberately does not have (§7.4) and an asset pipeline to produce it. * **No curl noise** — approximated by shearing a scalar field (§4.4). diff --git a/shaders/world/clouds.slang b/shaders/world/clouds.slang index 248bbf79..a5d36a9a 100644 --- a/shaders/world/clouds.slang +++ b/shaders/world/clouds.slang @@ -137,19 +137,22 @@ public static const float CLOUD_MAX_SLAB_CROSSINGS = 3.5; 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 (Photon's `extinct *= 0.4`) -// and of the phase anisotropy (`phase_g *= 0.8`). Both express the same 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. +// 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 (Photon's `powder = mix(powder, sqrt(powder), .5)`): -// deep inside a bank, where several bounce orders have already contributed, the thin-edge darkening of -// the single-scattering model no longer applies. +// 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). Photon uses the same early exit at 0.075. +// 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. @@ -289,7 +292,8 @@ public static const float CLOUD_DETAIL_FINE_WEIGHT = 0.45; public static const float CLOUD_EROSION_EDGE = 0.75; public static const float CLOUD_EROSION_CROWN = 0.55; public static const float CLOUD_EROSION_BASE = 0.35; -// Edge sharpening exponent, wispy at the base and hard at the crown (Photon's l0_edge_sharpening): +// 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.55; public static const float CLOUD_EDGE_SHARPEN_CROWN = 0.90; @@ -301,7 +305,8 @@ public static const float CLOUD_EDGE_SHARPEN_CROWN = 0.90; // * 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. -// Photon uses the same three-lobe shape (0.65·HG(g) + 0.10·HG(g2) + 0.25·HG(−g3)). +// 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; @@ -1115,7 +1120,7 @@ float cloudVolumeDensity(WorldPush push, CloudWeather w, float3 posRel, float he 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 Photon's l0_edge_sharpening, and it is what separates "soft + // 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); @@ -1458,7 +1463,7 @@ public CloudVolume cloudMarch(WorldPush push, float3 originRel, float3 dir, floa 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). Photon exits at 0.075. + // are the most expensive thing in this loop (each is a light probe). break; } float3 p = originRel + dir * (marchStart + stepLen * float(i)); @@ -1502,8 +1507,8 @@ public CloudVolume cloudMarch(WorldPush push, float3 originRel, float3 dir, floa // 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 same - // transmittance-ratio blend Photon uses. + // 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); From e55e2abdbf64934f93bfc6b23011609bb963ef68 Mon Sep 17 00:00:00 2001 From: xysgottaken2 <217395987+xysgottaken2@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:50:00 +0000 Subject: [PATCH 3/7] Calibrate the genus profiles against UE5's published height gradients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deck had the right structure for a real sky but the wrong calibration: with the convection parabola peaking at 1.0, the DEFAULT coverage (0.55) made almost every cloud a slab-filling tower, so the sky read as one thick layer with a top at the slab ceiling — the "straight top that follows the thickness slider" look, from the inside. UE5's Volumetric Cloud shape stage publishes the clearest numeric statement of what cumulus and congestus mean: a fair-weather cumulus closes its dome at roughly (0.0, 0.2, 0.42, 0.6) of the layer, a developing tower at (0.0, 0.08, 0.75, 0.98). Tuned against those two gradients: * heap crown 0.70 -> 0.55 with rounding 0.30 -> 0.26, so an ordinary cumulus domes at ~55-75% of the layer instead of hugging the ceiling; * the crown fade is clamped to close AT the slab top at the latest (crownEnd = min(start + rounding, 1)). Without it a tall crown's fade ends above the slab and the deck is sliced flat by its own ceiling; * the tower lift is now a smoothstep over local coverage (0.35..0.90) instead of a linear ramp, so one sky holds low fringes, mid heaps and tall towers at once — a linear lift gives every cloud in the sky the same height; * clear-sky convection is scaled to 0.45 of the parabola (thunderstorms still carry the full range), so scattered fair weather develops to about half its layer the way the published cumulus gradient does; * base ramp 0.07 -> 0.06: a crisper condensation-level floor. Two shaping passes, both free of extra hash lookups: * the coverage field is pushed toward its own extremes before the threshold, which separates individual clouds with clean air and crisp edges instead of a sky of connected blobs; * the billow field gets the same treatment before eroding, giving the erosion Worley-like ridge-and-cell character (defined scoops with crisp boundaries) rather than a smooth wash. docs/realistic-volumetric-clouds.md records the UE5 presets in the reference list and the new numbers in the genus and erosion sections. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- docs/realistic-volumetric-clouds.md | 48 +++++++++++++++++++++++------ shaders/world/clouds.slang | 45 +++++++++++++++++++++------ 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/docs/realistic-volumetric-clouds.md b/docs/realistic-volumetric-clouds.md index 902a596b..ef7c8eba 100644 --- a/docs/realistic-volumetric-clouds.md +++ b/docs/realistic-volumetric-clouds.md @@ -65,6 +65,16 @@ is all that was done; where this document names it, that is provenance of an ide 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 @@ -99,6 +109,11 @@ periodic value noise) at `CLOUD_SHAPE_DIV = 2.0`, which puts one cell at 48 bloc 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. + 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. @@ -117,9 +132,11 @@ deck can disagree about what the sky is doing: * `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(4c(1−c) · (1−sheet), thunder)` — vertical development peaks on a sunny day +* `convection` = `max(0.45 · 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. + it: nothing is being heated from below. The 0.45 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, 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. @@ -134,12 +151,17 @@ are one reading of one state. 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.70`, `TOWER = 0.94`, `SHEET = 0.48`, rounding `0.30`): where the top starts to - round off. Heaps round at ~70% of the slab; convective towers fill it entirely; an inversion-capped - sheet flattens at half. **Dense cores tower** — the *local* coverage lifts the crown - (`CLOUD_TOWER_COVERAGE_LIFT`), so the thick middle of a bank billows up into towers while its wispy - edges stay low and flat. That one coupling is most of why the result reads as a field of individual - clouds instead of one extruded slab. +* **Crown** (`HEAP = 0.55`, `TOWER = 0.94`, `SHEET = 0.48`, rounding `0.26`, 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 ~55–75% of the layer, a developing tower at the very top, + an inversion-capped sheet at half. **Dense cores tower**: the *local* coverage lifts the crown through + `smoothstep(0.35, 0.90, coverage)`, so the thick middle of a bank billows up into towers while its + wispy edges stay low and flat, and one sky holds low fringes, mid heaps and tall towers at once. 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.30` 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 @@ -171,7 +193,11 @@ camera-relative Y: the deck's internal structure is pinned to the world, so it d eye as the camera rises or falls. `RtCloudShaderRegressionTest` asserts the absence of `posRel.y` in the density function for exactly this reason. -Erosion bites hardest where the field is thin (the fraying edge) and at the slab extremes — the crown +Before eroding anything, the billow is pushed toward its own extremes (`x²(3−2x)`), which gives the +erosion Worley-like ridge-and-cell character — defined scoops with crisp boundaries instead of a smooth +wash — for zero extra hash lookups. + +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, @@ -343,6 +369,10 @@ half a full density each. | 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` | diff --git a/shaders/world/clouds.slang b/shaders/world/clouds.slang index a5d36a9a..1cab0722 100644 --- a/shaders/world/clouds.slang +++ b/shaders/world/clouds.slang @@ -260,15 +260,23 @@ public static const float CLOUD_SHEET_COVERAGE_HI = 0.92; // 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.07; +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.70; +public static const float CLOUD_CROWN_START_HEAP = 0.55; 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 starts lower where the local coverage is thin, so a bank develops -// cauliflower towers out of its thick parts while its wispy edges stay low and flat. -public static const float CLOUD_TOWER_COVERAGE_LIFT = 0.55; +public static const float CLOUD_CROWN_ROUNDING = 0.26; +// 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.35; +public static const float CLOUD_TOWER_COVERAGE_HI = 0.90; +// 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.45; // 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.30; @@ -481,7 +489,7 @@ public CloudWeather cloudWeather(WorldPush push) { // 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); + 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 @@ -537,6 +545,13 @@ float cloudVolumetricCoverage(float coverage, float2 samplePos) { } float threshold = 1.0 - clamp(coverage, 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); } @@ -1060,10 +1075,16 @@ float cloudVolumeDensity(WorldPush push, CloudWeather w, float3 posRel, float he // edges stay low and flat. 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, 1.0, - clamp(coverage * CLOUD_TOWER_COVERAGE_LIFT, 0.0, 1.0)); + 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); - float top = 1.0 - smoothstep(crownStart, crownStart + CLOUD_CROWN_ROUNDING, hf); + // 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) { @@ -1108,6 +1129,12 @@ float cloudVolumeDensity(WorldPush push, CloudWeather w, float3 posRel, float he erosionField = lerp(coarse, lerp(coarse, fine, CLOUD_DETAIL_FINE_WEIGHT), detailFade); } } + // Cellular shaping: push the billow toward its own extremes before it erodes anything, so erosion + // bites in defined scoops with crisp cell boundaries instead of a smooth wash. That ridge-and-cell + // character is what Worley (cellular) noise gives a cloud crown in the texture-based implementations; + // this gets most of the same bubbly-cauliflower read out of the value lattice for zero extra hashes. + erosionField = erosionField * erosionField * (3.0 - 2.0 * erosionField); + // 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. From 8b468d93be5729262fff42a0456c1ab3487755fc Mon Sep 17 00:00:00 2001 From: xysgottaken2 <217395987+xysgottaken2@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:55:17 +0000 Subject: [PATCH 4/7] Generate the erosion detail as true 3D Worley noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The volumetric deck's erosion was a billow-only FBM: rounded lobes with soft boundaries everywhere, then a smoothstep push toward the field's extremes to fake cellular character. Every write-up of the technique — Horizon Zero Dawn, Frostbite, UE5, and the Minecraft packs that followed them — erodes with the Perlin-Worley pair instead, and the cellular half is what carves the aerated, scooped silhouette a cumulus crown actually has. It was the one field this module approximated rather than computed, because the usual answer is a precomputed 3D texture and this shader has no texture binding at all. So generate it: cloudWorley3 walks the 3x3x3 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. Keeping the jitter under half a cell per axis is what makes 27 taps an EXACT F1 rather than an approximation, and unpacking all three components from one 24-bit hash per neighbour (the new cloudHash3Bits) keeps it at 27 hashes rather than 81. It stays the most expensive thing in the density, so it replaces the fine octave only, behind the existing detail LOD and tier. The remap is fitted, not guessed. Raw F1 averages 0.511 on this lattice; 2.55x - 0.81 lands the mean on exactly 0.500 with ~25% of the range on the clamps, and that clipping is the crispness. The mean matters because the SHAPE tier substitutes this octave's expected value for it when probing optical depth, so an unfitted remap would silently bias every light probe. A regression test now pins the cellular octave, the jitter bound and the remap. Periodicity is unchanged: the Worley lattice uses the same divisor and the same hash masks as the fine billow it replaces, so the anchor wrap the mirror test proves still holds for the cellular field. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- docs/realistic-volumetric-clouds.md | 46 +++++++--- shaders/world/clouds.slang | 89 +++++++++++++++---- .../rt/RtCloudShaderRegressionTest.java | 52 +++++++++++ 3 files changed, 157 insertions(+), 30 deletions(-) diff --git a/docs/realistic-volumetric-clouds.md b/docs/realistic-volumetric-clouds.md index ef7c8eba..c303c966 100644 --- a/docs/realistic-volumetric-clouds.md +++ b/docs/realistic-volumetric-clouds.md @@ -59,7 +59,8 @@ function, expression, identifier, constant set or asset of its appears here, and 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 (`cloudHash3`/`cloudNoise3`/`cloudBillow3`), with no texture or sampler of any +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 @@ -184,19 +185,33 @@ the anchor wrap may only sample fields that are periodic with it (§7). Altitude ### 4.5 Erosion — the 3D part -Two octaves of **3D** billow noise (`cloudBillow3` = `1 − |2·noise₃ − 1|`, which folds value noise into -rounded cellular lobes rather than a cloudy blur) at `CLOUD_BILLOW_DIV_COARSE = 1.0` and -`FINE = 0.25` — lobes 24 and 6 blocks across, a fraction of a cloud's width, which is the cumulus look. +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 = 1.0` — lobes 24 blocks across — carries the cauliflower, 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. -Before eroding anything, the billow is pushed toward its own extremes (`x²(3−2x)`), which gives the -erosion Worley-like ridge-and-cell character — defined scoops with crisp boundaries instead of a smooth -wash — for zero extra hash lookups. - 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 @@ -246,7 +261,8 @@ extinction coefficients. | **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). That keeps a probe unbiased about how much cloud is +**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. @@ -366,6 +382,9 @@ half a full density each. | no silver lining on backlit edges | `CLOUD_HG_SILVER`, `CLOUD_PHASE_SILVER`, then `CLOUD_POWDER_SUN_RELAX` | | clouds too wispy / too solid | `CLOUD_EXTINCTION` (with `CLOUD_REFERENCE_THICKNESS` if the slider feels like opacity) | | 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` | @@ -379,10 +398,11 @@ half a full density each. ## 10. Not done -* **No 3D detail texture.** A Perlin-Worley shape atlas plus a Worley detail atlas (the industry - standard) would beat procedural hash noise on both quality and cost, but it - needs a texture binding this module deliberately does not have (§7.4) and an asset pipeline to produce - it. +* **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, diff --git a/shaders/world/clouds.slang b/shaders/world/clouds.slang index 1cab0722..4a250672 100644 --- a/shaders/world/clouds.slang +++ b/shaders/world/clouds.slang @@ -215,8 +215,8 @@ public static const float CLOUD_SKY_ANALYTIC_FILL = 0.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 base shape, 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 +// 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 @@ -292,8 +292,21 @@ 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. Two octaves of 3D billow noise, weighted, erode the shape field's edge. +// ---- 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. @@ -422,13 +435,17 @@ float cloudNoise(float2 p) { // 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. -float cloudHash3(int3 cell) { +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 float(h & 0x00ffffffu) * (1.0 / 16777216.0); + 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. @@ -451,6 +468,43 @@ 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 @@ -1023,8 +1077,9 @@ float cloudDither(WorldPush push) { * * 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 — two octaves of 3D billow noise 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, + * * 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. * @@ -1116,24 +1171,24 @@ float cloudVolumeDensity(WorldPush push, CloudWeather w, float3 posRel, float he float2 warpedXZ = samplePos + warp * warpAmp; float warpedHeight = height + (warp.x + warp.y) * warpAmp * CLOUD_WARP_VERTICAL; - // ---- Erosion: two octaves of 3D billow noise, weighted. The fine one fades out with distance - // (detailFade) and is skipped below the FULL tier. Both octaves average 0.5, which is why the cheap - // tier can drop one without changing how much sky the deck closes off on average. + // ---- 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 = cloudBillow3(cloudDetailCoord(warpedXZ, warpedHeight, CLOUD_BILLOW_DIV_FINE) - + float3(19.0, 7.0, 23.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); } } - // Cellular shaping: push the billow toward its own extremes before it erodes anything, so erosion - // bites in defined scoops with crisp cell boundaries instead of a smooth wash. That ridge-and-cell - // character is what Worley (cellular) noise gives a cloud crown in the texture-based implementations; - // this gets most of the same bubbly-cauliflower read out of the value lattice for zero extra hashes. - erosionField = erosionField * erosionField * (3.0 - 2.0 * erosionField); // 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 diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/RtCloudShaderRegressionTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/RtCloudShaderRegressionTest.java index 7daf8893..26f4828b 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/RtCloudShaderRegressionTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/RtCloudShaderRegressionTest.java @@ -135,6 +135,58 @@ void erosionIsThreeDimensionalAndAnchoredToTheDeckBase() throws IOException { + "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. + * + *
Four guards: the march overrides the pushed thickness with {@code cloudDeckDepth} before any + * consumer of it (slab bounds, crossing fade, sigma normalisation) runs; 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("thickness = cloudDeckDepth(weather);"), + "the volumetric march must replace the pushed thickness with the genus depth BEFORE " + + "the slab bounds, the crossing fade and the sigma normalisation derive from " + + "it, or those four consumers disagree about how deep the deck is"); + 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. * @@ -344,6 +400,14 @@ 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)) { From f6cb989608d8d098c18123bf2fb296c94fcfb31c Mon Sep 17 00:00:00 2001 From: xysgottaken2 <217395987+xysgottaken2@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:59:16 +0000 Subject: [PATCH 6/7] Anchor the volumetric base to the height option, and lower the default The genus-depth slab was centred on the pushed slab centre, which Java computes as configured base + pushedDepth/2. Once the volumetric march stopped reading the pushed thickness, that centre stopped matching the genus slab, and the deck's FLOOR drifted with a slider the style no longer reads: with a saved high thickness the base floated tens of blocks above the altitude the height option promises - reported from game as "the clouds are too high". The march now recovers the base from the pushed centre and the PUSHED depth (exactly the Y the options screen shows), and grows the genus slab upward from it; the crossing-exclusion zone follows the new slab's own mid-plane. Classic bounds are algebraically unchanged. The shipped default height also drops 320 -> 224: with the base now exact, a little above vanilla's 192 is where a modelled deck reads right, and the old number carried an offset that was compensating for the drift. Config javadoc follows the decision: thickness is documented as the classic-only knob it became, height as the deck's exact floor. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- shaders/world/clouds.slang | 21 ++++++------ .../comfyfluffy/caustica/CausticaConfig.java | 32 +++++++++---------- .../rt/RtCloudShaderRegressionTest.java | 25 +++++++++------ 3 files changed, 43 insertions(+), 35 deletions(-) diff --git a/shaders/world/clouds.slang b/shaders/world/clouds.slang index 450046f6..9ae56977 100644 --- a/shaders/world/clouds.slang +++ b/shaders/world/clouds.slang @@ -1456,17 +1456,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. - // Overriding it HERE, before the slab bounds, the crossing fade and the sigma normalisation are - // derived, keeps every consumer of `thickness` inside this march reading one number. bool classic = cloudStyle(push) == CLOUD_STYLE_CLASSIC; CloudWeather weather = cloudWeather(push); - if (!classic) { - thickness = cloudDeckDepth(weather); - } + 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 @@ -1477,15 +1481,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; diff --git a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java index df5aaa59..02dcd404 100644 --- a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java +++ b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java @@ -799,16 +799,14 @@ public static final class Composite { * number for both concepts): a modelled deck has real depth, and depth is what the light * transport integrates over. * - *
Volumetric: 0 is a flat sheet (the deck collapses to a plane and takes the cheap - * non-marched path); 1 is a deep bank. Raising it adds VOLUME without making the clouds more - * opaque — the shader normalises extinction by the slab depth ({@code - * CLOUD_REFERENCE_THICKNESS}), so a 110-block bank and a 20-block sheet reach the same - * optical depth straight down and the slider controls shape alone, with see-through left to - * {@link #CLOUD_OPACITY}. Within whatever depth is set here, the weather picks the genus: a - * storm's towers fill the whole slab, fair-weather heaps round off at about 70% of it, and an - * overcast sheet hugs the lower half. Classic: the slider scales the HEIGHT of vanilla's + *
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); @@ -830,20 +828,22 @@ 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 is, never how big it is — that is - * {@link #CLOUD_THICKNESS}. The deck grows UPWARD from this Y (RtComposite pushes the slab's - * centre, so raising the thickness does not sink the base the player is looking at). + *
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}). diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/RtCloudShaderRegressionTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/RtCloudShaderRegressionTest.java index f6dd5a00..70784206 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/RtCloudShaderRegressionTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/RtCloudShaderRegressionTest.java @@ -198,11 +198,13 @@ void detailErosionIsCellularWorleyWithAMeanMatchedRemap() throws IOException { * coverage/weather reading that picks the profile, and the classic boxes keep the slider because * their extrusion genuinely is it. * - *
Four guards: the march overrides the pushed thickness with {@code cloudDeckDepth} before any - * consumer of it (slab bounds, crossing fade, sigma normalisation) runs; 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. + *
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 { @@ -211,10 +213,15 @@ void volumetricDepthIsGenusDrivenAndClassicKeepsTheSlider() throws IOException { "// ---- Opacity as a genuine ceiling"); String boxes = slice(source, "CloudVolume cloudClassicBoxes(", "// Slab entry/exit along the ray"); - assertTrue(march.contains("thickness = cloudDeckDepth(weather);"), - "the volumetric march must replace the pushed thickness with the genus depth BEFORE " - + "the slab bounds, the crossing fade and the sigma normalisation derive from " - + "it, or those four consumers disagree about how deep the deck is"); + 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"); From 814fe99b38db5444f284adbc2d7260e8187c8584 Mon Sep 17 00:00:00 2001 From: xysgottaken2 <217395987+xysgottaken2@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:22:17 +0000 Subject: [PATCH 7/7] Cap the tower lift: clouds, not columns Played from game, the deck read as vertically stretched pillars. The cause was the crown lift: it runs to 1.0, and a cumulus core is dense by nature, so smoothstep(0.35, 0.90, coverage) read essentially every core as "tower" and closed its dome at the slab top - 150-block columns on a 200-block base, with the convection term (0.45 of the parabola at default coverage) pushing the same direction and a 165-block slab giving the columns room to grow. Rebalanced to real cumulus proportions (height ~0.2-0.7 of width): * crown lift capped at CLOUD_CROWN_LIFT_MAX = 0.78, window 0.45-0.95 - only convection (storm, or a sunny day building) carries a crown past that; * CLOUD_CROWN_START_HEAP 0.55 -> 0.48, the published gradient's mid-layer; * CLOUD_CONVECTION_PEAK 0.45 -> 0.30, so fair weather builds instead of erupting; * deck headroom 165 -> 128 blocks of heap (tower 210 -> 192), so even a full tower stays a cloud shape; * vigour band narrowed to 1.25-0.85, biased shallower. Fringes still stay low, cores still rise above them, storms still fill the sky - the spread is just the sky's, not the slab's. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- docs/realistic-volumetric-clouds.md | 32 ++++++++++++++++------------- shaders/world/clouds.slang | 32 +++++++++++++++++------------ 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/docs/realistic-volumetric-clouds.md b/docs/realistic-volumetric-clouds.md index 6b179113..39ce44e2 100644 --- a/docs/realistic-volumetric-clouds.md +++ b/docs/realistic-volumetric-clouds.md @@ -146,11 +146,12 @@ deck can disagree about what the sky is doing: * `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.45 · 4c(1−c) · (1−sheet), thunder)` — vertical development peaks on a sunny day +* `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.45 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, while - the published cumulus height gradient (§3) closes its dome at about half the layer. + 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. @@ -159,8 +160,8 @@ There is deliberately **no slider for any of this**. It is the same weather stat 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, 165 for -heaps and 210 for towers, and the volumetric march takes that as its slab instead of the pushed +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. @@ -171,14 +172,17 @@ its own height. 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.55`, `TOWER = 0.94`, `SHEET = 0.48`, rounding `0.30`, clamped to close at the +* **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 ~55–75% of the layer, a developing tower at the very top, - an inversion-capped sheet at half. **Dense cores tower**: the *local* coverage lifts the crown through - `smoothstep(0.35, 0.90, coverage)`, so the thick middle of a bank billows up into towers while its - wispy edges stay low and flat, and one sky holds low fringes, mid heaps and tall towers at once. 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. + 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. @@ -186,7 +190,7 @@ its own height. 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.30, 0.72, …)` of one low-frequency +* **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 diff --git a/shaders/world/clouds.slang b/shaders/world/clouds.slang index 9ae56977..dc5b6d23 100644 --- a/shaders/world/clouds.slang +++ b/shaders/world/clouds.slang @@ -270,9 +270,9 @@ public static const float CLOUD_SHEET_COVERAGE_HI = 0.92; // 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 = 165.0; +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 = 210.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 @@ -280,7 +280,7 @@ public static const float CLOUD_DECK_DEPTH_TOWER = 210.0; // 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.55; +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; @@ -288,13 +288,19 @@ public static const float CLOUD_CROWN_ROUNDING = 0.30; // 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.35; -public static const float CLOUD_TOWER_COVERAGE_HI = 0.90; +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.45; +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; @@ -1161,7 +1167,7 @@ float cloudVolumeDensity(WorldPush push, CloudWeather w, float3 posRel, float he // 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.30, 0.72, smoothstep(0.30, 0.70, vigor)); + float stretch = lerp(1.25, 0.85, smoothstep(0.30, 0.70, vigor)); hf = hf * lerp(stretch, 1.0, w.sheet); // ---- Height profile. @@ -1173,13 +1179,13 @@ float cloudVolumeDensity(WorldPush push, CloudWeather w, float3 posRel, float he 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.7 of the slab; 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 into towers while its wispy - // edges stay low and flat. That one coupling is most of why the result reads as a field of + // 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, 1.0, + 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);