Skip to content

Rewrite the volumetric cloud deck as a fully procedural cumulus model - #77

Open
arena-ai-coding-agent[bot] wants to merge 2 commits into
mainfrom
arena/01a0648d-testingcasutica
Open

Rewrite the volumetric cloud deck as a fully procedural cumulus model#77
arena-ai-coding-agent[bot] wants to merge 2 commits into
mainfrom
arena/01a0648d-testingcasutica

Conversation

@arena-ai-coding-agent

Copy link
Copy Markdown

Summary

The volumetric cloud deck is rebuilt from scratch. It was an extruded heightmap — a single 24-block octave of scalar value noise, a depth the player chose with a slider, and a lighting model that could not distinguish a fair-weather puff from a storm tower — which is exactly why it read as stretched cubes with soft edges.

This PR changes three independent things and deliberately leaves a fourth alone:

before after
Shape source scalar noise heightmap, 24-block coarsest octave 100 % procedural gradient-Perlin eroded by cellular Worley, 512-block coarsest octave
Depth Cloud Thickness slider × 110 blocks derived from weather + sun (RtCloudGenesis); slider is classic-only
Lighting single exponential, hand-tuned Beer-Lambert + reduced-extinction multi-scatter octave + density-based powder, dual-lobe phase
Classic style authored clouds.png boxes + slider untouched

Scope was agreed up front: classic keeps vanilla's authored cell map and its thickness slider; only volumetric becomes procedural. Genesis is automatic from weather, plus one advanced setting to pin a genus by hand. Full model ships with cheap optimisations (per-ray jitter, zero-density early-out, short light march) and nothing is gated behind quality tiers yet.


1. Shape: 100 % procedural, no textures

No PNG, no offline 3D texture, no authored data of any kind feeds the volumetric field. shaders/world/clouds.slang gains a noise library built from one integer hash:

cloudHashBits(int2) / cloudHashBits(int3)   -> uint (24-bit)
cloudPerlin2 / cloudPerlin3                 -> gradient noise, [-1, 1]
cloudWorley2 / cloudWorley3                 -> cellular F1, [0, 1]
cloudRand(float3)                           -> uniform [0, 1], per-ray jitter

cloudPerlin* is gradient noise, not value noise: the lattice corners contribute dot(cloudGradient(hash), f) with a quintic fade (6t⁵ − 15t⁴ + 10t³). Averaging four scalar lattice values gives flat plateaus with the grid visible in the second derivative — that plateau is a large part of why the old field looked like dough. cloudWorley* searches the 3×3 (2D) / 3×3×3 (3D) neighbourhood, which is what makes the cells round rather than diamond-shaped.

The silhouette follows Schneider, Real-Time Volumetric Cloudscapes (GPU Pro 7 / Horizon Zero Dawn) — the same construction Photon ships, except Photon bakes its low-frequency Perlin-Worley and high-frequency Worley-fBm into two offline 3D textures (sixthsurge/volume-noise-generator) and here the identical combination is evaluated arithmetically per sample, because the requirement is that nothing is authored:

base     = perlin-worley at the airmass lattice      (shape: where the cloud is)
erosion  = worley fBm at 3 octaves, domain-warped   (detail: what is carved out of it)
density  = remap(base, coverage-erosion*edge, 1, 0, 1)

Erosion amplitude is scaled up near the cloud's own boundary, so edges are torn and scalloped with cavities while the interior stays solid — the "airy cutout" look, and the thing that kills the smooth-blob silhouette. Above it, a sharp flat base at the condensation level and cauliflower-domed crown growth are imposed by the height profile rather than by the noise, which is what makes it read as cumulus instead of as fog.

Lattice, not scale. Coverage is sampled on power-of-two block lattices with a 512-block coarsest octave (CLOUD_LATTICE_MAX_BLOCKS). The old field divided positions by a 0.5 scale, so its coarsest feature was 24 blocks — a cloud the size of a house, tiled. A 512-block airmass lattice is what puts individual clouds in a sky with blue gaps between them.

2. Depth: a genesis model instead of a slider

A cloud's thickness is not a preference, it is a consequence of what kind of cloud the atmosphere is making. New RtCloudGenesis (pure arithmetic, no Minecraft dependency):

development = clamp(0.55·insolation + 0.60·rain + 0.40·thunder, 0, 1)

deckDepth   26.0 -> 88.0 blocks     base to the crown a TYPICAL cloud reaches
towerScale  1.30 -> 2.35            how much taller a dense core grows than that
turbulence  0.30 -> 1.00            erosion / warp / billow amplitude
genus       = development           the blend itself, so the shader need not re-derive it
slabDepth   = deckDepth · towerScale <= 208 blocks (reached: 206.8)

Two design points worth calling out:

  • The contributions are deliberately asymmetric. Insolation is scaled to 0.55 so a clear noon only reaches mediocris — fair-weather cumulus do not tower without a reason, and a model that let the sun alone reach congestus would have nothing left to express in a storm. The weather term enters at full weight so it can reach 1.0 on its own at any hour, including a night thunderstorm, which is precisely the case a purely diurnal model gets conspicuously wrong. Inside it, rain carries 0.60 and thunder 0.40 because vanilla only raises thunder while already raining: a full storm is 1.0, a steady rain settles at 0.60 — a deep overcast deck, not a towering one. This was found by writing the test first ("a storm towers at any hour") and then fixing the formula, which had capped at 0.75·forced and could never satisfy it.
  • Insolation is read, not scheduled. RtComposite.cloudState now takes the frame's SkyPush and computes clamp(sunDir.y / sunNoonY(), 0, 1). It is 0 below the horizon and 1 at local noon whatever the noon-tilt setting, and it follows the game's own celestial cycle rather than a hardcoded 24 000 ticks — a datapack with a long day gets a long convective cycle for free.

The slider is retired for this style. CLOUD_THICKNESS is now documented and enforced as classic-only (CLOUD_CLASSIC_MAX_THICKNESS_BLOCKS = 110, floored at vanilla's own 4-block extrusion). For volumetric, cloudState never consults it. In the UI the row is replaced, not shown-then-ignored — a knob that changes nothing is worse than no knob — by a greyed-out explanation, mirroring what the sub-screen already does for Cloud Coverage in classic mode; Cloud Development (auto / humilis / mediocris / congestus) takes its place. Pinning humilis is also the performance escape hatch: it is the shallowest slab this renderer marches. Cloud Height still sets the volumetric base, and the base is what stays put — the pushed value is the slab centre (base + thickness/2), so a deepening deck grows upward instead of visibly sinking.

3. Lighting and the march

Volume ray casting in the textbook sense (Wikipedia: Ray marching) — the ray is divided into segments and the medium sampled once per segment, front-to-back emission/absorption integration. Cloud density is not an SDF, so sphere tracing does not apply.

scatter      += transmittance · inScatter · (1 − exp(−density·σ·stepLen))
transmittance *= exp(−density·σ·stepLen)

inScatter = albedo · ( sunRadiance · exp(−τ_light · 0.30) · powder · phase · gain  +  ambient(heightFrac) )
  • σ is split in two. sigmaMedium is the medium's own property, normalised by slab depth (CLOUD_REFERENCE_THICKNESS / thickness) so a deeper deck adds volume rather than opacity. sigma is what the eye march integrates and additionally carries the horizon fade and the crossing exclusion — properties of where the observer is. The light march uses sigmaMedium only: a cloud's shadow on itself must not depend on the camera, or self-shadowing swims as the player turns.
  • The reduced-extinction octave. CLOUD_LIGHT_EXTINCTION_SCALE = 0.30 on the light path is the multiple-scattering stand-in — light that has been through a lot of cloud is not simply exp(−τ) attenuated, it has bounced.
  • ⚠ Why this is not the usual "beer's powder". The familiar 2·exp(−τ)·(1 − exp(−2τ)) bell was implemented and then removed: it tends to 0 at τ → 0. A sample on the sunlit crown exits the slab on its first light-march sample, so τ = 0 there — the bell would have painted a dark band roughly 20 blocks tall across the top of every deck in the sky. The replacement is a correctly-limited pair: plain cloudBeerLambert(τ) = exp(−max(τ,0)) for attenuation (→ 1 at the crown, → 0 deep inside) and cloudPowder(density) = 1 − exp(−density·4) mixed at 0.70 for the dark-edge term. The powder deliberately reads local density, never density·σ·stepLen: step length is a discretisation parameter, and shading by it would make a cloud's brightness depend on how far the ray has travelled.
  • Phase is a dual-lobe Henyey-Greenstein: the strong forward lobe puts the silver lining on the sun-facing rim, the weak backward lobe keeps the shaded side from collapsing into a silhouette.
  • Ambient is height-graded, which is not decoration: a sample near the crown sees most of the sky, a sample near the base sees the underside of its own cloud. Without the falloff the whole deck shades as one uniform grey volume, and the underside reads as dark rather than shaded. The sun's own colour leaks in at the base, where what little light arrives has been forward-scattered through a lot of cloud.
  • Cost. Per-ray (not per-step) blue-noise jitter turns the aliasing of a uniform sample grid — 8-block steps against a 16-block Worley cell — into variance the frame's own accumulation and denoiser already remove. Zero-density samples continue before the light march. The light march is 5 steps (2 in the cheap tier) with a quadratic distance ramp, and breaks the moment it leaves the slab: a high sun terminates after two or three samples, a low sun marches the full distance sideways, so cost follows the geometry. Step count scales to the distance actually marched, clamped both ways.
  • Classic keeps its flat vanilla-style face tone (bright tops, dark undersides, poster-flat), now via cloudBeerLambert with self-shadowing damped to 40 % so the boxes never grow soft volumetric gradients or a translucent rim.

4. The periodicity contract

Every hash wraps its lattice index to CLOUD_PERIOD_CELLS = 512, so each octave is exactly periodic over 512 × its lattice size. The anchor pushed from Java is reduced modulo

CLOUD_FIELD_PERIOD_BLOCKS = 3 · 512 · 512 = 786432 = lcm(262144, 3072, 6144)

  volumetric field   512 cells × CLOUD_LATTICE_MAX_BLOCKS(512) = 262144
  classic cell map   CLOUD_CELL_MAP_CELLS(256) × CLOUD_CELL_BLOCKS(12) = 3072
  classic fallback   CLOUD_PERIOD_CELLS(512) × CLOUD_CELL_BLOCKS(12) = 6144

so the wrap is a whole number of periods in every space the deck is sampled in at once. All ten lattice sizes are powers of two that divide 512, which is what makes every multiply exact in binary floating point — the identity holds bit-for-bit, not approximately. Negative lattice indices wrap continuously too (-1 & 511 == 511 matches the far end), so a camera west of the origin does not see a seam.

786 432 blocks is 256× the deck's view limit, so the repeat is never visible in one frame. It is also 32× the previous 24 576-block wrap, and that is not incidental: the old number was chosen to satisfy a field whose coarsest octave was 24 blocks. Enlarging the wrap is what allowed the 512-block airmass lattice. Wrapping an aperiodic field is the historical "clouds change shape while walking" bug.

5. ABI

WorldPush.cloudGenus (float4) is appended last, after fogTint. WorldPushData — offsets, byte size and serializer — is generated by Slang reflection over world_layout_probe.slang, so the lane flows through automatically and WORLD_PUSH_SIZE follows it. The Java push appends clouds.genus() in the same position.

cloudAnchor.z == cloudGenus.x · cloudGenus.y is an identity, not a convention: the height profile normalises against the slab while crown heights grade against the deck, and a mismatch would clip every tower at the top of its own slab. Both new test classes pin it.

Tests

RtCloudGenesisTest (new, 10 tests) — pure arithmetic, no client: the published ranges, monotonicity in all three drivers, a full storm reaching congestus at midnight, night-only skies staying humilis, the slab budget and the fact that clamping reduces towerScale rather than deckDepth, and override parsing including junk → auto.

RtCloudShaderRegressionTest (+5 tests, parsing clouds.slang and RtComposite.java with regex helpers so a drift fails the build):

  1. Procedural-only density — the volumetric branch must not touch cloudCellsAddr, and must call the Worley/Perlin library.
  2. Genesis-driven depth — volumetric thickness comes from genesis.slabDepth(); the slider is provably absent from that branch.
  3. Trailing cloudGenus lane — shader field order and the Java push order agree, and it is last.
  4. Anchor-wrap divisibility — 786 432 is a whole multiple of all 10 lattice periods, each lattice is a power of two, and Java and shader agree on the number.
  5. Lighting split — Beer-Lambert exists verbatim as exp(-max(tau, 0.0)), the light march returns raw optical depth with no fades baked in, powder reads local density, and no bell-shaped 2·exp(−τ)(1−exp(−2τ)) variant can come back (every one of them tends to zero at τ = 0).

RtConfigDefaultsTest perturbs every setting with junk; sanitizeCloudGenus maps junk → auto rather than to a pinned genus, so a hand-edited config with a typo cannot silently freeze the sky at one shape.

Notes

  • Localisations: en_us and pt_br updated (10 new keys each, 2 rewritten). The remaining 10 locales fall back to en_us.
  • docs/cloud-rework-plan.md gains a status entry. Its coordination note is now moot — this branch supersedes PR Fix storm cloud coverage and rain atmosphere #25's cloud work, since cloudState and clouds.slang are rewritten rather than patched.
  • References: Schneider, Real-Time Volumetric Cloudscapes for Horizon Zero Dawn (GPU Pro 7); sixthsurge/photon and its volume-noise-generator for the offline-texture variant of the same mask; Wikipedia — Ray marching for the volumetric-vs-sphere-tracing distinction.

xysgottaken2 and others added 2 commits September 3, 2026 00:47
The volumetric style was an extruded heightmap: one 24-block octave of scalar
noise, a thickness the player picked with a slider, and a lighting model that
could not tell a fair-weather puff from a storm tower. It read as stretched
cubes with soft edges. This replaces the shape, the depth model and the
shading, and leaves the classic style alone.

Shape - 100% procedural
  No texture feeds the volumetric field. clouds.slang gains a hash -> gradient
  Perlin -> cellular Worley noise library and composes Schneider's Real-Time
  Volumetric Cloudscapes mask arithmetically: a low-frequency Perlin-Worley
  base eroded by a high-frequency Worley fBm, rather than sampling the two
  offline 3D textures Photon ships. Coverage is sampled on power-of-two block
  lattices with a 512-block coarsest octave, so a cloud is an airmass-sized
  feature and every one of them is unique.

Depth - genesis, not a slider
  New RtCloudGenesis derives the deck from the weather and the sun:
  development = clamp(0.55*insolation + 0.60*rain + 0.40*thunder, 0, 1),
  mapped onto deck depth 26..88 blocks, tower scale 1.30..2.35 and turbulence
  0.30..1.00, published as WorldPush.cloudGenus. The weather term carries full
  weight so a night thunderstorm still towers. Insolation comes from the same
  SkyPush.sunDir the sky is lit by, normalised by sunNoonY(), so a datapack
  with a long day gets a long convective cycle for free.

  The Cloud Thickness slider is now classic-only and the volumetric deck
  ignores it entirely; the sub-screen swaps the row for a greyed-out
  explanation and offers Cloud Development (auto/humilis/mediocris/congestus)
  in its place. Cloud Height still sets the volumetric base.

Lighting
  Beer-Lambert on the light path, a reduced-extinction octave as the
  multiple-scattering stand-in, and a powder/dark-edge term that reads local
  density rather than the step's optical depth (a discretisation parameter
  must not change brightness). Deliberately not the usual
  2*exp(-t)*(1-exp(-2t)) beer's-powder bell: it tends to zero at t=0, and a
  sample on the sunlit crown exits the slab on its first light-march sample,
  so it would paint a dark band across the top of every deck.

Anchor wrap
  CLOUD_FIELD_PERIOD_BLOCKS = 3*512*512 = 786432 = lcm(262144, 3072, 6144): a
  whole number of periods in every space the deck is sampled in, and 256x the
  view limit. cloudAnchor.z == cloudGenus.x * cloudGenus.y by construction.

Tests
  RtCloudGenesisTest (new, 10 tests) pins the model's ranges, monotonicity,
  storm-at-night congestus and slab budget. RtCloudShaderRegressionTest gains
  5 tests: procedural-only density, genesis-driven depth, the trailing
  cloudGenus lane and its Java mirror, wrap divisibility across all 10
  lattices, and the Beer-Lambert / density-powder split.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
CI caught an off-by-one the local regex simulation did not: the 'nothing is
declared after cloudGenus' scan sliced from the FIRST character of the
declaration, so the slice always began with its own 'public ' and the
assertion could never pass. Start it at the end of the declaration instead,
and assert the lane is present explicitly so a renamed field reports itself
rather than failing the ordering check.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
@arena-ai-coding-agent

Copy link
Copy Markdown
Author

CI

Green on da1c74c — all three jobs (Build Windows NGX shim, Build Linux NGX shim, Build bundled mod jar./gradlew build): Slang compile with -warnings-as-errors all, SPIR-V emit + spirv-val, generateShaderRecords reflection over the new WorldPush.cloudGenus lane, Java compile, and 137 tests, 0 failed.

The first run of this PR (9fca8d5) failed with exactly one of those 137:

org.opentest4j.AssertionFailedError at RtCloudShaderRegressionTest.java:178
137 tests completed, 1 failed

cloudGenusIsTheTrailingWorldPushLane asserts nothing is declared after cloudGenus by slicing WorldPush from the field's index to the closing }; and requiring no public in between. The slice started at the first character of the declaration itself, so it always began with cloudGenus's own public and could never pass — a bug in the test, not in the ABI, which the shader/serializer side of the same run confirmed is correct. Fixed in da1c74c by starting the scan at the end of the declaration and asserting the lane exists explicitly, so a renamed field now reports itself instead of failing the ordering check.

Worth recording why this one slipped through: every other assertion in the new tests is a substring or regex search, which is insensitive to where a slice starts. This was the only one that inspects the slice's own contents, so it was the only one where an off-by-one in the start index changes the answer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant