Rewrite the volumetric cloud deck as a fully procedural cumulus model - #77
Rewrite the volumetric cloud deck as a fully procedural cumulus model#77arena-ai-coding-agent[bot] wants to merge 2 commits into
Conversation
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>
CIGreen on The first run of this PR (
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. |
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:
Cloud Thicknessslider × 110 blocksRtCloudGenesis); slider is classic-onlyclouds.pngboxes + sliderScope 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.slanggains a noise library built from one integer hash:cloudPerlin*is gradient noise, not value noise: the lattice corners contributedot(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: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):Two design points worth calling out:
RtComposite.cloudStatenow takes the frame'sSkyPushand computesclamp(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_THICKNESSis now documented and enforced as classic-only (CLOUD_CLASSIC_MAX_THICKNESS_BLOCKS = 110, floored at vanilla's own 4-block extrusion). For volumetric,cloudStatenever 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. Pinninghumilisis 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.
sigmaMediumis the medium's own property, normalised by slab depth (CLOUD_REFERENCE_THICKNESS / thickness) so a deeper deck adds volume rather than opacity.sigmais what the eye march integrates and additionally carries the horizon fade and the crossing exclusion — properties of where the observer is. The light march usessigmaMediumonly: a cloud's shadow on itself must not depend on the camera, or self-shadowing swims as the player turns.CLOUD_LIGHT_EXTINCTION_SCALE = 0.30on the light path is the multiple-scattering stand-in — light that has been through a lot of cloud is not simplyexp(−τ)attenuated, it has bounced.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: plaincloudBeerLambert(τ) = exp(−max(τ,0))for attenuation (→ 1 at the crown, → 0 deep inside) andcloudPowder(density) = 1 − exp(−density·4)mixed at 0.70 for the dark-edge term. The powder deliberately reads local density, neverdensity·σ·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.continuebefore 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.cloudBeerLambertwith 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 over512 × its lattice size. The anchor pushed from Java is reduced moduloso 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 == 511matches 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, afterfogTint.WorldPushData— offsets, byte size and serializer — is generated by Slang reflection overworld_layout_probe.slang, so the lane flows through automatically andWORLD_PUSH_SIZEfollows it. The Java push appendsclouds.genus()in the same position.cloudAnchor.z == cloudGenus.x · cloudGenus.yis 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 reducestowerScalerather thandeckDepth, and override parsing including junk →auto.RtCloudShaderRegressionTest(+5 tests, parsingclouds.slangandRtComposite.javawith regex helpers so a drift fails the build):cloudCellsAddr, and must call the Worley/Perlin library.genesis.slabDepth(); the slider is provably absent from that branch.cloudGenuslane — shader field order and the Java push order agree, and it is last.exp(-max(tau, 0.0)), the light march returns raw optical depth with no fades baked in, powder reads local density, and no bell-shaped2·exp(−τ)(1−exp(−2τ))variant can come back (every one of them tends to zero at τ = 0).RtConfigDefaultsTestperturbs every setting with junk;sanitizeCloudGenusmaps junk →autorather than to a pinned genus, so a hand-edited config with a typo cannot silently freeze the sky at one shape.Notes
en_usandpt_brupdated (10 new keys each, 2 rewritten). The remaining 10 locales fall back toen_us.docs/cloud-rework-plan.mdgains a status entry. Its coordination note is now moot — this branch supersedes PR Fix storm cloud coverage and rain atmosphere #25's cloud work, sincecloudStateandclouds.slangare rewritten rather than patched.