Comments in benilla-world: one line of fact each, no history, no record numbers (#121)
Part of #41: the comments of `benilla-world`, Rust and WGSL, are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 24,226 comment lines become 8,863. - **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal. - **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them: - `0x6b62e0` is the WMO group's liquid renderer, not a doodad drawer. - The reference creates a WMO's props once, when a group enters its streaming window (`0x698720` → `0x695aa0`). It then admits them each frame the portal walk visits the group (`0x685d70` → `0x6838f0`). The old text had them created per visible group. - The liquid query's comments now state the reference's order: the map-object leg first, then the ADT (`0x69b6d0`). They no longer call the Outdoors/Inside split a match. - The interior probe runs from the character, not the camera. - The reference lights ground clutter. The old comment said it draws unlit. - The model-particle "pool order" deviation was wrong. Each instance is its own mesh, depth-sorted every frame. - The reference's vertical field of view follows the aspect ratio (44.1° at 16:9). The comment now gives it beside our fixed 45°. - The effect shader's far wall is the live `farclip`, 177 to 777 yd, not a fixed 777 yd. - Dozens more turned up on the way: renamed or deleted items, docs sitting on the wrong item, stale counts and links. - **Deviation labels.** "Deviation:" now marks only a choice with a reason. An unbuilt behaviour says so plainly. - **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Five second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the reference. Defects the rewrite surfaced outside the comments are filed as their own issues (#106, #114, #115, #116). The comments here state what the code does. String literals are untouched; they are #92. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d69d09dfce
commit
fdfd8d39ec
@@ -1,52 +1,20 @@
|
||||
//! The cloud coverage kernel — a faithful port of the reference's procedural cloud field.
|
||||
//!
|
||||
//! The real client maintains a scrolling 128×128 byte tile of cloud coverage (the SkyManager
|
||||
//! working-set `0xce98e8`), regenerated in 32-row bands at ~10 Hz by the coverage-noise kernel
|
||||
//! (`WoW.exe 0x6cffc0`): 4-octave
|
||||
//! toroidal value noise (lacunarity 2, persistence 0.5) → `rawByte`, thresholded by the authored
|
||||
//! Light.dbc cloud density `C` (`T = trunc((1−C)·255)`, `0x6d0970`) and shaped through a fixed
|
||||
//! 256-byte tone curve (`0x6d0900`, gamma 0.96 — frozen here as [`CURVE`]). One field serves every
|
||||
//! consumer: the glare occlusion `occ1` samples the same tile the visible layer renders from
|
||||
//! (`0x6cfa90`).
|
||||
//!
|
||||
//! The noise lattice is keyed by three axes: tile row (`row_key`, advancing `freq` per row), tile
|
||||
//! column (`col_key`, advancing `freq` per column), and time (the u16 `phase`, bumped once per full
|
||||
//! tile wrap ≈ 0.4 s — its high byte picks the permutation slice pair, its low byte the fade
|
||||
//! weight between them). High key bytes select lattice cells through the permutation table; low
|
||||
//! bytes index the raised-cosine [`fade`] table as interpolation weights. Band regeneration at an
|
||||
//! unchanged `phase`/`T` is idempotent (keys derive from absolute tile coordinates), so the field
|
||||
//! only *moves* when the phase advances or the authored density changes — exactly the reference's
|
||||
//! slow cloud drift.
|
||||
//!
|
||||
//! Each regen fire ends with the **color pass** (`0x6cfb00` — diffed
|
||||
//! bit-exact): the coverage bytes become RGBA texels (gradient + sun-aligned glow, alpha = the
|
||||
//! coverage byte), and *that image* is what the dome uploads — the reference binds its color
|
||||
//! buffer zero-copy to the gx texture (`0x58ac70`). The glow's per-cell surface normal comes
|
||||
//! from the octave-2 derivative leg (`[cfg+0x68]`, written by `0x6cffc0`, read by `0x6cfb00`).
|
||||
//!
|
||||
//! Deviations from the bytes, all in never-hit or non-visual domains (recorded in the decision
|
||||
//! record): the `acos` argument is clamped to ±1 (the reference NaNs above ~70° elevation, a
|
||||
//! domain its sun/moon never reach); LUT reads wrap toroidally instead of running off the flat
|
||||
//! heap at the measure-zero `u == 1.0` edge; the gradient table uses the reference's MSVC-LCG
|
||||
//! *formula* with a fixed seed (the reference seed is process-random and not visually
|
||||
//! load-bearing — any 256 uniform values in [−1, 1] are equivalent, `0x6d0c90`);
|
||||
//! and the color buffer seeds alpha-0 instead of `0xFFFFFFFF` (no white flash before the first
|
||||
//! build).
|
||||
//! The reference's procedural cloud field (`0xce98e8`): a scrolling 128×128 byte tile of 4-octave
|
||||
//! toroidal value noise, thresholded by the Light.dbc cloud density, shaped by a fixed tone curve
|
||||
//! (`0x6d0900`) and colored into the texels the dome draws. The glare samples the same tile.
|
||||
|
||||
use bevy::math::Vec3;
|
||||
|
||||
use super::tables::{fade_table, gradient_table, CURVE, PERM};
|
||||
|
||||
/// Tile side at `SkyCloudLOD 0` (`cols = 128 << LOD`; the CVar clamps to [0,1] and defaults to 0 —
|
||||
/// `0x6d1d60`). We implement LOD 0.
|
||||
/// Tile side at `SkyCloudLOD` 0, its default (`128 << LOD`, `0x6d1d60`); only LOD 0 is built.
|
||||
pub const COLS: usize = 128;
|
||||
/// `log2(COLS)` — the row-pitch shift the sampler uses (`[cfg+0x20]`).
|
||||
/// `log2(COLS)`, the sampler's row-pitch shift (`[cfg+0x20]`).
|
||||
pub const SHIFT: u32 = 7;
|
||||
/// Rows regenerated per fire (`[cfg+0x14]`, default 32).
|
||||
/// Rows regenerated per fire (`[cfg+0x14]`).
|
||||
pub const ROWS_PER_TICK: usize = 32;
|
||||
/// Octave count (`[cfg+0x28]`, constant 4).
|
||||
/// Octaves (`[cfg+0x28]`).
|
||||
pub const OCTAVES: usize = 4;
|
||||
/// Regen countdown reset (`0x8115b4` = 0.1 s) — the ~10 Hz cadence.
|
||||
/// Regen countdown reset in seconds (`0x8115b4`): a 10 Hz cadence.
|
||||
pub const REGEN_PERIOD: f32 = 0.1;
|
||||
/// Per-octave lattice frequencies, LOD 0 row of the base table `0x86f3dc` (`(16 >> LOD) << oct`).
|
||||
const BASE_FREQ: [u16; OCTAVES] = [16, 32, 64, 128];
|
||||
@@ -56,26 +24,18 @@ fn perm(i: u32) -> u32 {
|
||||
u32::from(PERM[(i & 0xff) as usize])
|
||||
}
|
||||
|
||||
/// Per-octave lattice walk state — the 0x54-byte stack record of `0x6cffc0`, kept as named fields.
|
||||
/// Per-octave lattice walk state: `0x6cffc0`'s 0x54-byte stack record as named fields.
|
||||
struct Octave {
|
||||
/// Lattice frequency (`base_table[LOD·5 + oct]`; the record's `B+8`/`B+0xa` key delta).
|
||||
freq: u16,
|
||||
/// Row axis key (`B+4`): starts at `scroll·freq`, advances `freq` per row. High byte = row
|
||||
/// lattice cell, low byte = row fade index.
|
||||
/// Row key (`B+4`), from `scroll·freq`, `+freq` per row: high byte the cell, low byte the fade.
|
||||
row_key: u16,
|
||||
/// Column axis key (`B+2`): re-seeded to `phase` each row, advances `freq` per column. High
|
||||
/// byte = column lattice cell (`uVar14`), low byte = column fade index.
|
||||
col_key: u16,
|
||||
/// Octave amplitude `1 / 2^oct` (`B+0x1a`).
|
||||
amp: f32,
|
||||
/// Per-row lattice corner seeds (`B+0x1e..0x2a`): row cell hashed through the phase-selected
|
||||
/// permutation slices (`x*` = current time slice, `y*` = next).
|
||||
/// Row corner seeds (`B+0x1e..0x2a`): `x*` through the current time slice, `y*` the next.
|
||||
x0: u32,
|
||||
x1: u32,
|
||||
y0: u32,
|
||||
y1: u32,
|
||||
/// Cached corner gradients + deltas for the current column cell (`B+0x2e..0x4a`), rebuilt
|
||||
/// lazily when the column cell changes (`B+0x4c` cache key).
|
||||
g00: f32,
|
||||
g00d: f32,
|
||||
g10: f32,
|
||||
@@ -87,36 +47,27 @@ struct Octave {
|
||||
cached: u32,
|
||||
}
|
||||
|
||||
/// The cloud coverage field: the byte tile, the colored texture, and the regen state.
|
||||
/// The cloud coverage field: the byte tile, its colored texels and the regen state.
|
||||
pub struct CloudKernel {
|
||||
/// The coverage byte tile (`[cfg+0x44]`), `COLS²` row-major. `byte/255 = R ∈ [0,1]`.
|
||||
/// Coverage bytes (`[cfg+0x44]`), `COLS²` row-major; `byte/255` is the coverage `R`.
|
||||
tile: Vec<u8>,
|
||||
/// The float accumulation tile (`[cfg+0x50]`).
|
||||
accum: Vec<f32>,
|
||||
/// The per-cell shape derivative pairs (`[cfg+0x68]`, 2 f32/cell) — written by the octave-2
|
||||
/// leg, consumed by the color pass as the glow surface normal `S = (dx, dy, 1)`
|
||||
/// (`0x6cffc0` writes it, `0x6cfb00` reads it).
|
||||
/// Per-cell slopes from the octave-2 leg (`[cfg+0x68]`), the glow normal `(dx, dy, 1)`.
|
||||
deriv: Vec<[f32; 2]>,
|
||||
/// The previous-row scratch (`[cfg+0x5c]`, `COLS` f32) feeding the row derivative.
|
||||
prevrow: Vec<f32>,
|
||||
/// The colored RGBA texels (`[cfg+0x38]`, 4 B/cell: gradient+glow RGB, alpha = the coverage
|
||||
/// byte) — **this is what the reference uploads** (`0x58ac70` binds `[cfg+0x38]` zero-copy).
|
||||
/// Colored texels (`[cfg+0x38]`), the buffer the reference binds as its texture (`0x58ac70`).
|
||||
rgba: Vec<[u8; 4]>,
|
||||
/// Scroll position — the tile row the next band starts at (`[cfg+0x18]`).
|
||||
/// The tile row the next band starts at (`[cfg+0x18]`).
|
||||
scroll: usize,
|
||||
/// Noise-space phase (`[cfg+0xa0]`, u16) — +1 per full tile wrap; the time axis.
|
||||
/// The noise's time axis (`[cfg+0xa0]`), +1 per full tile wrap.
|
||||
phase: u16,
|
||||
/// Regen countdown (`[cfg+0xb0]`); starts expired so the first tick fires.
|
||||
countdown: f32,
|
||||
/// The gradient table (`0xce92d8`) — 256 × f32 in [−1, 1], built once (see [`gradient_table`]).
|
||||
gradient: [f32; 256],
|
||||
/// The fade table (`0xce8dd8`) — `0.5·(1 − cos(iπ/256))`, built once.
|
||||
fade: [f32; 256],
|
||||
}
|
||||
|
||||
/// Per-fire inputs to the color pass — `0x6cfb00`'s per-frame setup, resolved by the caller from
|
||||
/// [`crate::lighting::WowLighting`]: the three Light.dbc cloud palette rows, the weather blend,
|
||||
/// and the glow body (sun by day / moon by night, with its 8-key day envelope).
|
||||
/// The color pass inputs (`0x6cfb00`'s per-frame setup), from [`crate::lighting::WowLighting`].
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub struct CloudFrame {
|
||||
/// Sun-glow palette (IntBand sub-10), sRGB 0..1.
|
||||
@@ -125,12 +76,11 @@ pub struct CloudFrame {
|
||||
pub(crate) slope: [f32; 3],
|
||||
/// Gradient base (IntBand sub-12).
|
||||
pub(crate) gbase: [f32; 3],
|
||||
/// The weather storm blend `bcc` — feeds the glow z-bias (`bcc·192 + 64`) and the dim
|
||||
/// (`1 − 0.75·bcc`).
|
||||
/// The weather storm blend, which sets the glow's z-bias and dim.
|
||||
pub(crate) bcc: f32,
|
||||
/// Camera→body direction of the glow body (Bevy frame).
|
||||
/// Camera→glow body direction (Bevy frame): the sun by day, the moon by night.
|
||||
pub(crate) glow_dir: Vec3,
|
||||
/// The glow day-envelope factor (`0xce9ab8` track — `daynight::cloud_glow_track`).
|
||||
/// The glow's day envelope, the reference's static track at `0xce9ab8`.
|
||||
pub(crate) glow_track: f32,
|
||||
}
|
||||
|
||||
@@ -141,8 +91,8 @@ impl Default for CloudKernel {
|
||||
accum: vec![0.0; COLS * COLS],
|
||||
deriv: vec![[0.0; 2]; COLS * COLS],
|
||||
prevrow: vec![0.0; COLS],
|
||||
// The reference inits the color buffer 0xFFFFFFFF; we keep alpha 0 until the first
|
||||
// build so an unprimed dome can never flash white (cosmetic-only deviation).
|
||||
// Deviation: the reference starts this buffer at 0xFFFFFFFF; alpha 0 keeps a dome
|
||||
// drawn before the first build from flashing white.
|
||||
rgba: vec![[255, 255, 255, 0]; COLS * COLS],
|
||||
scroll: 0,
|
||||
phase: 0,
|
||||
@@ -154,9 +104,7 @@ impl Default for CloudKernel {
|
||||
}
|
||||
|
||||
impl CloudKernel {
|
||||
/// Advance the countdown and regenerate one 32-row band when it expires (`0x6cffc0`'s
|
||||
/// self-throttle: fire when the decremented countdown ≤ 0, reset to 0.1). Returns whether the
|
||||
/// tile changed.
|
||||
/// Regenerates one band when the countdown runs out (`0x6cffc0`); true if the tile changed.
|
||||
pub fn tick(&mut self, dt: f32, density: f32, frame: &CloudFrame) -> bool {
|
||||
self.countdown -= dt;
|
||||
if self.countdown > 0.0 {
|
||||
@@ -167,33 +115,28 @@ impl CloudKernel {
|
||||
true
|
||||
}
|
||||
|
||||
/// Full-tile rebuild (`0x6cff90`): regenerate every row at once — the reference runs this on
|
||||
/// discontinuities (init / zone / LOD change) by forcing the row count to `cols`.
|
||||
/// Regenerates every row (`0x6cff90`), as the reference does on init, zone and LOD changes.
|
||||
pub fn rebuild(&mut self, density: f32, frame: &CloudFrame) {
|
||||
self.scroll = 0;
|
||||
self.regen(density, COLS, frame);
|
||||
self.countdown = REGEN_PERIOD;
|
||||
}
|
||||
|
||||
/// Re-run the color pass over the whole tile without touching the coverage — the frozen
|
||||
/// capture clock's path when only the palette/sun/weather inputs moved.
|
||||
/// Re-runs the color pass over the whole tile without touching coverage, for the frozen clock.
|
||||
pub fn recolor(&mut self, frame: &CloudFrame) {
|
||||
self.color_band(0, COLS, frame);
|
||||
}
|
||||
|
||||
/// One regeneration fire: `rows` tile rows starting at `self.scroll` (noise + quantize +
|
||||
/// the color pass, the reference's `0x6cffc0` → `0x6cfb00` order), then the scroll/phase
|
||||
/// advance. The body is the `0x6cffc0` transcription in named-field form.
|
||||
/// One fire of `0x6cffc0`: noise, quantize and color `rows` rows from `scroll`, then advance.
|
||||
fn regen(&mut self, density: f32, rows: usize, frame: &CloudFrame) {
|
||||
// Threshold refresh (`0x6d0970`, called at the top of every fire): T = trunc((1−C)·255).
|
||||
// The reference does not clamp C; authored bands stay in [0,1] and we clamp for safety.
|
||||
// Threshold (`0x6d0970`): the reference does not clamp; authored densities stay in [0, 1].
|
||||
let threshold = ((1.0 - density.clamp(0.0, 1.0)) * 255.0) as i32;
|
||||
|
||||
// Phase-selected permutation slice pair: the time axis. `seed` = phase HIGH byte.
|
||||
// The time axis: the phase's high byte picks the permutation slice pair.
|
||||
let seed = u32::from(self.phase >> 8);
|
||||
let slice_a = perm(seed); // local_18 — current time slice
|
||||
let slice_b = perm(seed + 1); // local_1c — next time slice
|
||||
let fade_t = f64::from(self.fade[(self.phase & 0xff) as usize]); // f_b — time fraction
|
||||
let slice_a = perm(seed); // current time slice
|
||||
let slice_b = perm(seed + 1); // next time slice
|
||||
let fade_t = f64::from(self.fade[(self.phase & 0xff) as usize]); // time fraction
|
||||
|
||||
let scroll = self.scroll;
|
||||
let mut oct: Vec<Octave> = (0..OCTAVES)
|
||||
@@ -201,8 +144,7 @@ impl CloudKernel {
|
||||
let freq = BASE_FREQ[c];
|
||||
Octave {
|
||||
freq,
|
||||
// B+4 init: `(u16)(tile_x · key)` — absolute-row keyed, which is what makes
|
||||
// band regeneration idempotent at a fixed phase.
|
||||
// Keyed by the absolute row, so a band regenerates identically at one phase.
|
||||
row_key: (scroll as u32).wrapping_mul(u32::from(freq)) as u16,
|
||||
col_key: 0,
|
||||
amp: 1.0 / (1u32 << c) as f32,
|
||||
@@ -223,15 +165,12 @@ impl CloudKernel {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Float-tile zero-clear over the band.
|
||||
for v in &mut self.accum[scroll * COLS..(scroll + rows).min(COLS) * COLS] {
|
||||
*v = 0.0;
|
||||
}
|
||||
|
||||
for row in 0..rows {
|
||||
let base = (scroll + row) * COLS;
|
||||
// Per-row lattice setup: hash the row cell (row_key high byte) through the two time
|
||||
// slices; re-seed the column walk from the phase; invalidate the corner cache.
|
||||
for o in oct.iter_mut() {
|
||||
let bv = u32::from(o.row_key >> 8);
|
||||
o.x0 = perm(slice_a + bv);
|
||||
@@ -241,14 +180,13 @@ impl CloudKernel {
|
||||
o.col_key = self.phase;
|
||||
o.cached = u32::MAX;
|
||||
}
|
||||
// fVar4 — the previous column's third-octave partial sum (reset per row: the
|
||||
// `fld 0.0` before the column loop).
|
||||
// The previous column's three-octave partial sum, reset per row.
|
||||
let mut prev_accum = 0.0f32;
|
||||
for col in 0..COLS {
|
||||
let cell = base + col;
|
||||
for (oi, o) in oct.iter_mut().enumerate() {
|
||||
let fade_row = f64::from(self.fade[(o.row_key & 0xff) as usize]); // f_a
|
||||
let cz = u32::from(o.col_key >> 8); // uVar14 — column lattice cell
|
||||
let fade_row = f64::from(self.fade[(o.row_key & 0xff) as usize]);
|
||||
let cz = u32::from(o.col_key >> 8); // column lattice cell
|
||||
if cz != o.cached {
|
||||
o.cached = cz;
|
||||
let g = |s: u32| self.gradient[perm(s) as usize];
|
||||
@@ -262,8 +200,7 @@ impl CloudKernel {
|
||||
o.g11 = g(s3);
|
||||
o.g11d = g(s3 + 1) - o.g11;
|
||||
}
|
||||
// The fade-interpolated bilinear + time lerp, mirroring the binary's f64
|
||||
// in-register chain with its one f32 round-trip (`v8`).
|
||||
// The reference's f64 chain, with its one f32 round trip (`v8`).
|
||||
let fx = f64::from(self.fade[(o.col_key & 0xff) as usize]);
|
||||
let v7 = fx * f64::from(o.g00d) + f64::from(o.g00);
|
||||
let v8 = f64::from((fx * f64::from(o.g01d) + f64::from(o.g01)) as f32);
|
||||
@@ -273,10 +210,7 @@ impl CloudKernel {
|
||||
let acc = f64::from(self.accum[cell]);
|
||||
let stored = (((v8b - v7b) * fade_t + v7b) * f64::from(o.amp) + acc) as f32;
|
||||
self.accum[cell] = stored;
|
||||
// The octave-2 derivative leg (`0x6cffc0`, the `local_10 == 2` branch): the
|
||||
// column/row slopes of the three-octave partial sum, into the pair buffer the
|
||||
// color pass reads as the glow surface normal. `scale = 1 << (shift − 7)` — 1
|
||||
// at LOD 0.
|
||||
// Octave 2 stores the running sum's slopes: the color pass's glow normal.
|
||||
if oi == 2 {
|
||||
let scale = f64::from((1i32 << ((SHIFT - 7) & 0x1f)) as f32);
|
||||
self.deriv[cell][0] =
|
||||
@@ -288,24 +222,21 @@ impl CloudKernel {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Per-row key advance: walks the row axis one `freq` step.
|
||||
for o in oct.iter_mut() {
|
||||
o.row_key = o.row_key.wrapping_add(o.freq);
|
||||
}
|
||||
}
|
||||
|
||||
// Palette-quantize the band into the byte tile: the binary's float-bits pack
|
||||
// (`fmul 64; fadd 128; fadd 512; fstp` then bits `>> 14`), threshold, tone curve.
|
||||
// Quantize by the reference's float-bits pack, then threshold and tone curve.
|
||||
for cell in scroll * COLS..(scroll + rows).min(COLS) * COLS {
|
||||
let q = ((f64::from(self.accum[cell]) * 64.0 + 128.0 + 512.0) as f32).to_bits();
|
||||
let idx = ((q >> 14) & 0xff) as i32 - threshold;
|
||||
self.tile[cell] = if idx >= 0 { CURVE[idx as usize] } else { 0 };
|
||||
}
|
||||
|
||||
// The color pass over the same band (`0x6cfb00`, called before the scroll advance).
|
||||
// The color pass (`0x6cfb00`) runs before the scroll advance.
|
||||
self.color_band(scroll, rows, frame);
|
||||
|
||||
// Scroll advance with wrap: the wrap bumps the noise-space phase — the time axis moves.
|
||||
self.scroll += rows;
|
||||
if self.scroll >= COLS {
|
||||
self.phase = self.phase.wrapping_add(1);
|
||||
@@ -313,20 +244,15 @@ impl CloudKernel {
|
||||
}
|
||||
}
|
||||
|
||||
/// The color pass — the exact `0x6cfb00` per-cell algorithm (diffed
|
||||
/// bit-exact): per cell `t` = the coverage byte — `t == 0` copies the previous
|
||||
/// cell's RGB with alpha 0 (the filtering-friendly hole fill); else the gradient
|
||||
/// `slope·p + gbase` with `p = (((255−t)>>1) + 64)/255`, plus the sun-aligned glow
|
||||
/// `sun·(cosθ·intensity)` where `cosθ` aligns the cell→body vector (tile-cell units, z =
|
||||
/// `bcc·192 + 64`) against the cell's shape normal `(dx, dy, 1)` through the binary's integer
|
||||
/// fast-inverse-sqrt. Channels clamp at 1 and pack `floor(ch·255)`; alpha = `t`.
|
||||
/// The color pass (`0x6cfb00`): a hole copies the previous cell's RGB at alpha 0; a covered
|
||||
/// cell is a gradient in its coverage plus a glow by the angle to its slope normal.
|
||||
fn color_band(&mut self, start: usize, rows: usize, frame: &CloudFrame) {
|
||||
let f = f64::from;
|
||||
let z_bias = (f(frame.bcc) * 192.0 + 64.0) as f32;
|
||||
let intensity = (f(frame.glow_track) * (1.0 - f(frame.bcc) * 0.75)) as f32;
|
||||
let body = body_cells(frame.glow_dir);
|
||||
for row in start..(start + rows).min(COLS) {
|
||||
let row_base = row as f32; // [ebp-0x84] = f18 + row — the absolute tile row
|
||||
let row_base = row as f32; // the absolute tile row
|
||||
for col in 0..COLS {
|
||||
let g = row * COLS + col;
|
||||
let t = self.tile[g];
|
||||
@@ -337,7 +263,7 @@ impl CloudKernel {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// The angle byte: n = (((255 − t) >> 1) + 0x40) & 0xff ∈ [64, 191].
|
||||
// The angle byte, in [64, 191].
|
||||
let n = u32::from((255u8.wrapping_sub(t) >> 1).wrapping_add(0x40));
|
||||
let p = f(INV_255) * f64::from(n);
|
||||
let mut ch = [
|
||||
@@ -346,8 +272,7 @@ impl CloudKernel {
|
||||
(f(frame.slope[2]) * p + f(frame.gbase[2])) as f32,
|
||||
];
|
||||
if let Some((su, sv)) = body {
|
||||
// V = (Su − col, Sv − row, z_bias); S = (dx, dy, 1). Accumulation and
|
||||
// product order per the bytes (±1-ulp load-bearing in the diff).
|
||||
// The reference's sum and product order: a one-ulp change moves output bytes.
|
||||
let vx = (f(su) - f64::from(col as u32)) as f32;
|
||||
let vy = (f(sv) - f(row_base)) as f32;
|
||||
let vz = z_bias;
|
||||
@@ -373,26 +298,23 @@ impl CloudKernel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample the coverage `R ∈ [0,1]` toward a camera-relative offset `d` (Bevy frame, +Y up) —
|
||||
/// the reference's `FUN_006cfa90`: project onto the tile, read the byte. `d` is the
|
||||
/// un-normalized body offset — the glare samples at its 12-unit sky point, so the zenith
|
||||
/// shift contributes `cos45°/|d|`.
|
||||
/// The coverage in [0, 1] toward camera-relative `d` (`0x6cfa90`). `d` is not normalized: the
|
||||
/// glare samples at its 12-unit sky point, so the zenith shift weighs `cos45°/|d|`.
|
||||
pub fn coverage(&self, d: Vec3) -> f32 {
|
||||
let Some((u, v)) = project_cells(d) else {
|
||||
return f32::from(self.tile[(COLS / 2) * COLS + COLS / 2]) / 255.0;
|
||||
};
|
||||
let (col, row) = (u as i32, v as i32); // _ftol
|
||||
// Toroidal mask (the reference reads the flat heap unchecked; `u == 1.0` is measure-zero).
|
||||
let (col, row) = (u as i32, v as i32);
|
||||
// Deviation: the index wraps where the reference reads past the tile, only at `u == 1.0`.
|
||||
let cell = ((row as usize & (COLS - 1)) << SHIFT) + (col as usize & (COLS - 1));
|
||||
f32::from(self.tile[cell]) / 255.0
|
||||
}
|
||||
|
||||
/// The colored RGBA texels for the visible-layer texture upload (`0x58ac70`'s zero-copy bind).
|
||||
/// The colored texels the visible layer uploads (`0x58ac70`).
|
||||
pub fn rgba(&self) -> &[[u8; 4]] {
|
||||
&self.rgba
|
||||
}
|
||||
|
||||
/// Raw coverage bytes (tests).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn tile(&self) -> &[u8] {
|
||||
&self.tile
|
||||
@@ -404,10 +326,8 @@ impl CloudKernel {
|
||||
}
|
||||
}
|
||||
|
||||
/// The azimuthal tile projection (`FUN_006cf870`): camera-relative offset → fractional tile cell
|
||||
/// `(col, row)`. LUT centre = zenith, radius grows with the angle off a `+cos(π/4)`-shifted
|
||||
/// zenith axis, saturating at 45° (the rim; below-horizon directions clamp there). `None` for a
|
||||
/// degenerate zero offset.
|
||||
/// The azimuthal tile projection (`0x6cf870`): the zenith at the centre, the radius growing with
|
||||
/// the angle off a `+cos(π/4)`-shifted axis up to the 45° rim, where lower directions clamp.
|
||||
fn project_cells(d: Vec3) -> Option<(f32, f32)> {
|
||||
let len = f64::from(d.length());
|
||||
if len < 1e-6 {
|
||||
@@ -415,8 +335,7 @@ fn project_cells(d: Vec3) -> Option<(f32, f32)> {
|
||||
}
|
||||
let quarter_pi = f64::from(std::f32::consts::FRAC_PI_4);
|
||||
let c = f64::from(d.y) + f64::from(std::f32::consts::FRAC_PI_4.cos());
|
||||
// The reference feeds `c/len` to acos unclamped and NaNs above ~70° elevation — a domain
|
||||
// its bodies never reach; we clamp (recorded deviation).
|
||||
// Deviation: clamped; the reference NaNs above about 70° elevation, where its bodies never go.
|
||||
let theta = (c / len).clamp(-1.0, 1.0).acos();
|
||||
let phase = theta.min(quarter_pi) / quarter_pi * 0.5;
|
||||
let hyp = (f64::from(d.x) * f64::from(d.x) + f64::from(d.z) * f64::from(d.z)).sqrt();
|
||||
@@ -435,17 +354,15 @@ fn project_cells(d: Vec3) -> Option<(f32, f32)> {
|
||||
))
|
||||
}
|
||||
|
||||
/// The glow body's tile cell (`0x6cfb00` setup, steps 1–2): intersect the camera→body ray with
|
||||
/// the unit sky dome (`0x6cf9c0` — the `−cos(π/4)`-shifted sphere, larger
|
||||
/// quadratic root) and project the hit point onto the tile. `None` on the degenerate no-root /
|
||||
/// zero-direction case.
|
||||
/// The glow body's tile cell (`0x6cfb00` setup): the camera→body ray's far hit on the unit dome
|
||||
/// shifted by `−cos(π/4)` (`0x6cf9c0`), projected onto the tile.
|
||||
fn body_cells(dir: Vec3) -> Option<(f32, f32)> {
|
||||
let f = f64::from;
|
||||
// k = −cos(0.25·π), the quarter-arc constant both functions open with.
|
||||
// k = −cos(π/4), the dome's shift.
|
||||
let k = -(f(0.25f32) * f(std::f32::consts::PI)).cos();
|
||||
let a = (f(dir.x) * f(dir.x) + f(dir.y) * f(dir.y) + f(dir.z) * f(dir.z)) as f32;
|
||||
let b = {
|
||||
// `fchs; fmul v.z; fadd st,st` — (−k·up) doubled (the reference's z-up ↔ our y-up).
|
||||
// −k·up, doubled; the reference's up is z, ours is y.
|
||||
let nk = -k * f(dir.y);
|
||||
(nk + nk) as f32
|
||||
};
|
||||
@@ -459,8 +376,7 @@ fn body_cells(dir: Vec3) -> Option<(f32, f32)> {
|
||||
project_cells(hit)
|
||||
}
|
||||
|
||||
/// The cmath quadratic solver (`0x454f40`): larger root of `a·t² + b·t + c = 0`, Vieta-stable
|
||||
/// exactly as the binary rounds it; `None` on the no-root leg (`b² ≤ 4ac` or unordered).
|
||||
/// The larger root of `a·t² + b·t + c = 0`, rounded as the reference's solver does (`0x454f40`).
|
||||
fn quadratic_larger_root(a: f32, b: f32, c: f32) -> Option<f32> {
|
||||
let f = f64::from;
|
||||
let g = f(a) * f(c) * 4.0;
|
||||
@@ -482,33 +398,26 @@ fn quadratic_larger_root(a: f32, b: f32, c: f32) -> Option<f32> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `1/255` as the binary stores it (`0x8026c8` = `0x3b808081`).
|
||||
/// `1/255` as the reference stores it (`0x8026c8`).
|
||||
const INV_255: f32 = f32::from_bits(0x3b80_8081);
|
||||
|
||||
/// The binary's integer fast-inverse-sqrt leaf (`0x456330`): a one-shot seed, NO Newton step —
|
||||
/// deterministic bit-manipulation, so ported verbatim (the approximation error is part of the
|
||||
/// reference's glow shape).
|
||||
/// The reference's inverse-sqrt seed (`0x456330`), no Newton step: its error shapes the glow.
|
||||
fn fisr(x: f32) -> f32 {
|
||||
f32::from_bits(0x5f39_97bbu32.wrapping_sub((x.to_bits() >> 1) & 0x3fff_ffff))
|
||||
}
|
||||
|
||||
/// Pack one color channel to a byte exactly as the binary does (`0x6cfef6..0x6cff44`): clamp
|
||||
/// `≤ 1.0` (no lower clamp), then the `bits(ch·255 + 512) >> 14` trick — `floor(ch·255)` after
|
||||
/// f32 rounding.
|
||||
/// Packs a channel as the reference does (`0x6cfef6..0x6cff44`): clamped above only, floored.
|
||||
fn pack_channel(ch: f64) -> u8 {
|
||||
let clamped = if ch < 1.0 { ch } else { 1.0 };
|
||||
(((clamped * 255.0 + 512.0) as f32).to_bits() >> 14) as u8
|
||||
}
|
||||
|
||||
/// Sun glare cloud occlusion (`occ1_sun = 1 − R`, `0x6cf7b0`): a cloud over the sun dims the
|
||||
/// flare linearly with coverage.
|
||||
/// The sun glare's cloud occlusion (`0x6cf7b0`): the flare dims linearly with coverage.
|
||||
pub fn occ1_sun(r: f32) -> f32 {
|
||||
1.0 - r
|
||||
}
|
||||
|
||||
/// Moon glare cloud occlusion — the tent `1 − |2(R − 0.5)|` (`0x6cf7d0`): zero at R=0 *and* R=1.
|
||||
/// The reference's moon halo is a thin-cloud effect — off in a perfectly clear patch of sky,
|
||||
/// blooming when a wisp crosses the moon.
|
||||
/// The moon glare's cloud occlusion (`0x6cf7d0`): a tent, so the halo blooms only in thin cloud.
|
||||
pub fn occ1_moon(r: f32) -> f32 {
|
||||
1.0 - (2.0 * (r - 0.5)).abs()
|
||||
}
|
||||
@@ -517,8 +426,6 @@ pub fn occ1_moon(r: f32) -> f32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A daytime frame: mid-grey slope over a dark base, warm glow, clear weather, the sun high
|
||||
/// in the +X sky at full envelope.
|
||||
fn frame() -> CloudFrame {
|
||||
CloudFrame {
|
||||
sun: [1.0, 0.78, 0.54],
|
||||
@@ -530,9 +437,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear sky (C=0 ⇒ T=255): every cell quantizes below the threshold ⇒ R = 0 everywhere ⇒
|
||||
/// occ1_sun = 1 (full flare), occ1_moon = 0 (no halo). The colored
|
||||
/// texels are all alpha 0 (the t==0 hole fill).
|
||||
/// Density 0 puts the threshold at 255, so every cell quantizes to 0.
|
||||
#[test]
|
||||
fn clear_sky_is_empty_coverage() {
|
||||
let mut k = CloudKernel::default();
|
||||
@@ -545,11 +450,7 @@ mod tests {
|
||||
assert_eq!(occ1_moon(0.0), 0.0);
|
||||
}
|
||||
|
||||
/// Full density (C=1 ⇒ T=0): every raw byte clears the threshold, so the whole sky reads as
|
||||
/// heavy cover through the tone curve's high region — a storm's overcast field, not a
|
||||
/// scattered one. Regeneration is deterministic. Intermediate density (C=0.6, the reference's
|
||||
/// own init threshold) cuts the threshold through the noise distribution: a real mix of
|
||||
/// clear (0) and covered cells — the scattered-cloud texture.
|
||||
/// Density 1 is overcast; 0.6 gives the reference's init threshold, 101, and scattered cloud.
|
||||
#[test]
|
||||
fn density_shapes_the_field_and_regen_is_deterministic() {
|
||||
let mut a = CloudKernel::default();
|
||||
@@ -558,8 +459,7 @@ mod tests {
|
||||
b.rebuild(1.0, &frame());
|
||||
assert_eq!(a.tile(), b.tile());
|
||||
assert_eq!(a.rgba(), b.rgba());
|
||||
// Overcast: no cell clears (the noise's whole range sits above T=0), and the curve pushes
|
||||
// the bulk high (measured mean ≈ 242, min 135 — the accumulator tail through the toe).
|
||||
// Measured: mean about 242, minimum 135.
|
||||
assert!(
|
||||
a.tile().iter().all(|&v| v > 0),
|
||||
"overcast leaves no clear cell"
|
||||
@@ -576,19 +476,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Band regeneration is keyed by absolute tile coordinates: four 32-row ticks at a fixed
|
||||
/// phase reproduce exactly the tile a full rebuild computes at that phase. This pins the
|
||||
/// toroidal walk (row_key seeding, per-row advance, quantize windows) against the reference
|
||||
/// structure — a wrong scroll seed or window shears the field between bands. The colored
|
||||
/// texels agree from row 1 (row 0's row-derivative reads the persistent prev-row scratch,
|
||||
/// which legitimately differs between a fresh full pass and a scrolled one — the reference's
|
||||
/// own post-rebuild wart, gone by the next wrap).
|
||||
/// Row 0's texels differ: its row slope reads the previous-row scratch, which a fresh rebuild
|
||||
/// and a scrolled band see differently, as in the reference.
|
||||
#[test]
|
||||
fn incremental_bands_tile_the_full_field() {
|
||||
let mut inc = CloudKernel::default();
|
||||
inc.rebuild(0.6, &frame()); // ends with phase bumped to 1, scroll 0
|
||||
inc.rebuild(0.6, &frame()); // leaves phase 1, scroll 0
|
||||
for _ in 0..4 {
|
||||
inc.tick(1.0, 0.6, &frame()); // four band fires cover the whole tile at phase 1
|
||||
inc.tick(1.0, 0.6, &frame());
|
||||
}
|
||||
let mut full = CloudKernel::default();
|
||||
full.set_phase(1);
|
||||
@@ -597,10 +492,6 @@ mod tests {
|
||||
assert_eq!(inc.rgba()[COLS..], full.rgba()[COLS..]);
|
||||
}
|
||||
|
||||
/// The color pass (`0x6cfb00`): a covered cell away from the glow carries the pure gradient
|
||||
/// bytes (`floor((slope·p + gbase)·255)`, `p = (((255−t)>>1)+64)/255`) with alpha = t; a
|
||||
/// cell whose body-alignment is positive gains the sun term; a hole copies its left
|
||||
/// neighbour's RGB at alpha 0.
|
||||
#[test]
|
||||
fn color_pass_matches_the_byte_math() {
|
||||
let mut k = CloudKernel::default();
|
||||
@@ -619,7 +510,6 @@ mod tests {
|
||||
assert_eq!(px[1], want(f.slope[1], f.gbase[1]));
|
||||
assert_eq!(px[2], want(f.slope[2], f.gbase[2]));
|
||||
}
|
||||
// Glow on: texels toward the sun brighten, none darken below the gradient base.
|
||||
let lit = frame();
|
||||
let mut kl = CloudKernel::default();
|
||||
kl.rebuild(1.0, &lit);
|
||||
@@ -633,8 +523,6 @@ mod tests {
|
||||
assert!(kl.rgba().iter().zip(k.rgba()).all(|(a, b)| a[0] >= b[0]));
|
||||
}
|
||||
|
||||
/// The hole fill: force a hole next to a covered cell and recolor — the hole copies its left
|
||||
/// neighbour's RGB with alpha 0 (the reference's filtering-friendly early-out).
|
||||
#[test]
|
||||
fn holes_copy_the_left_neighbour_rgb() {
|
||||
let mut k = CloudKernel::default();
|
||||
@@ -649,32 +537,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The integer fast-inverse-sqrt seed (`0x456330`): the exact bit formula, ~3% accurate.
|
||||
#[test]
|
||||
fn fisr_is_the_binary_seed() {
|
||||
assert_eq!(fisr(1.0).to_bits(), 0x3f79_97bb);
|
||||
assert!((fisr(4.0) - 0.5).abs() < 0.02);
|
||||
}
|
||||
|
||||
/// The azimuthal projection: zenith reads the tile centre; a low direction lands on the rim
|
||||
/// ring at its azimuth; below-horizon clamps to the same rim radius.
|
||||
#[test]
|
||||
fn sampler_projects_zenith_to_centre_and_horizon_to_rim() {
|
||||
let mut k = CloudKernel::default();
|
||||
k.tile.fill(0);
|
||||
let mid = COLS / 2;
|
||||
k.tile[mid * COLS + mid] = 255;
|
||||
// Straight up: phase 0 ⇒ the centre cell.
|
||||
assert_eq!(k.coverage(Vec3::new(0.0, 12.0, 0.0)), 1.0);
|
||||
// A horizontal +X direction: phase clamps to 0.5 ⇒ col = COLS (wraps to 0), row = mid.
|
||||
// Horizontal +X clamps to the rim: col = COLS, which wraps to 0, row = mid.
|
||||
k.tile[mid * COLS] = 51;
|
||||
let r = k.coverage(Vec3::new(12.0, 0.0, 0.0));
|
||||
assert!((r - 0.2).abs() < 1e-3, "rim read {r}");
|
||||
// Below the horizon: same clamp, same cell.
|
||||
assert_eq!(k.coverage(Vec3::new(12.0, -4.0, 0.0)), r);
|
||||
}
|
||||
|
||||
/// The moon tent peaks at half coverage and vanishes at both extremes (`0x6cf7d0`).
|
||||
#[test]
|
||||
fn moon_tent_shape() {
|
||||
assert_eq!(occ1_moon(0.5), 1.0);
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
//! The visible cloud layer — the reference's celestial-pass cloud dome (`0x6d0530` mesh,
|
||||
//! `0x6cfb00` coloring, `0x58ac70` per-regen texture upload).
|
||||
//!
|
||||
//! The reference draws a 12-ring hemisphere strip (pole → the 45° rim, ring co-latitudes bunched
|
||||
//! toward the rim, per-ring vertex alpha fading the rim out) textured by an image generated from
|
||||
//! the coverage byte tile every regen, as the **last draw of its sky pass** — one squashed depth
|
||||
//! slice `[0.975, 0.98]` shared by stars/discs/gradient/clouds, depth-write off, painter's order —
|
||||
//! so terrain occludes the clouds
|
||||
//! and the clouds blend over a setting sun. We reproduce the layering with real depth: every
|
||||
//! vertex is pushed to **uniform radius** along its recentred direction (the reference's squashed
|
||||
//! cap relies on the depth-range remap; at real depth its apex would sit at `0.29·r` — inside the
|
||||
//! scene, clouds drawing over cliffs) and the dome is camera-centred at `far·0.87` — inside the
|
||||
//! opaque sky dome (`far·0.9`), behind all terrain — with its slot in the reference's fixed order
|
||||
//! held by [`crate::sky_order::CLOUDS_BIAS`]: after the discs, before the rain, before the glare
|
||||
//! (the frame's last render, per the same pin).
|
||||
//!
|
||||
//! The strip topology is converted to a triangle list (11 bands × 32 triangles — the reference's
|
||||
//! 11 separate 34-index strips; same pixels, one mesh). The coverage image is R8; the color math
|
||||
//! runs per-fragment in `cloud.wgsl` from the same bytes the occlusion sampler reads.
|
||||
//! The visible cloud layer, the reference's cloud dome (`0x6d0530` mesh, `0x6cfb00` coloring,
|
||||
//! `0x58ac70` upload): a 12-ring cap from the pole to the 45° rim, faded at the rim, drawn last in
|
||||
//! the sky pass so clouds blend over a setting sun. [`crate::sky_order::CLOUDS_BIAS`] holds that
|
||||
//! place; the shared far-depth pin puts it behind all terrain.
|
||||
|
||||
use bevy::asset::RenderAssetUsages;
|
||||
use bevy::camera::Projection;
|
||||
@@ -38,25 +23,21 @@ use crate::view::WorldCamera;
|
||||
|
||||
use super::kernel::COLS;
|
||||
|
||||
/// The cloud dome material: unlit premultiplied-gamma blend over the sky, coverage from the R8
|
||||
/// tile texture, palette/glow uniforms packed like `SkyExt`.
|
||||
/// The cloud dome material: the kernel's colored texels, blended premultiplied in gamma.
|
||||
pub type CloudMaterial = ExtendedMaterial<StandardMaterial, CloudExt>;
|
||||
|
||||
/// The colored cloud texture (see `cloud.wgsl`). All color math already happened CPU-side in the
|
||||
/// kernel's `0x6cfb00` port (gradient + glow + weather dim, in gamma bytes exactly like the
|
||||
/// reference) — the material only carries the resulting RGBA image, whose texels are **raw gamma
|
||||
/// values** (a non-sRGB texture, never linearised).
|
||||
/// The colored cloud texture. The color math is CPU-side as in the reference, so the texels are
|
||||
/// raw gamma bytes in a non-sRGB texture.
|
||||
#[derive(Asset, AsBindGroup, Clone, TypePath)]
|
||||
pub struct CloudExt {
|
||||
/// The live colored tile (RGBA8, 128², alpha = coverage), re-uploaded on regen — the
|
||||
/// reference's `0x58ac70` zero-copy bind of the `0x6cfb00` color buffer.
|
||||
/// The colored tile, alpha the coverage, re-uploaded each regen (`0x58ac70`).
|
||||
#[texture(100)]
|
||||
#[sampler(101)]
|
||||
pub(crate) texels: Handle<Image>,
|
||||
}
|
||||
|
||||
impl MaterialExtension for CloudExt {
|
||||
/// The shared sky vertex stage — the far-depth pin ([`crate::sky_order`], "The depth law").
|
||||
/// The shared sky vertex stage, which pins every sky vertex to the far depth.
|
||||
fn vertex_shader() -> ShaderRef {
|
||||
SKY_VERTEX_SHADER.into()
|
||||
}
|
||||
@@ -76,22 +57,18 @@ impl MaterialExtension for CloudExt {
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker for the cloud dome entity.
|
||||
#[derive(Component)]
|
||||
pub(super) struct CloudDome;
|
||||
|
||||
/// The tick system's upload target — the colored tile image the dome samples, plus the material
|
||||
/// to touch on every upload (a modified `Image` gets a fresh GPU texture, and the material's
|
||||
/// cached bind group must be rebuilt to see it — without the touch the dome keeps sampling the
|
||||
/// first upload forever).
|
||||
/// The tick's upload target: the image the dome samples, and the material to touch on each upload
|
||||
/// so its bind group picks up the new texture.
|
||||
#[derive(Resource)]
|
||||
pub(super) struct CloudLayer {
|
||||
pub(crate) image: Handle<Image>,
|
||||
pub(crate) material: Handle<CloudMaterial>,
|
||||
}
|
||||
|
||||
/// Ring co-latitude fractions ×π (`0x811570` table, `0x6d0530`): pole → the 45° rim, bunched
|
||||
/// toward the rim.
|
||||
/// Ring co-latitudes in units of π (`0x811570`), from the pole to the 45° rim.
|
||||
const RING_COLAT: [f32; 12] = [
|
||||
0.0, 0.025, 0.05, 0.075, 0.10, 0.125, 0.15, 0.175, 0.205, 0.23, 0.245, 0.25,
|
||||
];
|
||||
@@ -100,13 +77,11 @@ const RING_COLAT: [f32; 12] = [
|
||||
const RING_ALPHA: [f32; 12] = [
|
||||
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 128.0 / 255.0, 0.0, 0.0,
|
||||
];
|
||||
/// Azimuth steps per ring (`0x6d0530`: 12 rings × 16 = 192 verts).
|
||||
/// Azimuth steps per ring (`0x6d0530`).
|
||||
const AZ_STEPS: usize = 16;
|
||||
|
||||
/// The reference dome (`0x6d0530`, radius 1): positions recentred `−cos(π/4)` so the rim sits at
|
||||
/// eye level, the polar UV `(sin·V + 0.5, cos·V + 0.5)` with `V = ring/24` — the same square
|
||||
/// mapping the coverage sampler uses, so the drawn cloud and the glare occlusion co-locate.
|
||||
/// Normals = the unit sky direction (feeds the sun-glow alignment).
|
||||
/// The reference dome (`0x6d0530`): recentred by `−cos(π/4)` so the rim sits at eye level, with
|
||||
/// the coverage sampler's polar UV so the drawn cloud and the glare's occlusion line up.
|
||||
fn cloud_dome_mesh() -> Mesh {
|
||||
let shift = std::f32::consts::FRAC_PI_4.cos();
|
||||
let mut positions = Vec::with_capacity(12 * AZ_STEPS);
|
||||
@@ -115,16 +90,15 @@ fn cloud_dome_mesh() -> Mesh {
|
||||
let mut colors = Vec::with_capacity(12 * AZ_STEPS);
|
||||
for (ring, (&colat, &alpha)) in RING_COLAT.iter().zip(RING_ALPHA.iter()).enumerate() {
|
||||
let phi = colat * std::f32::consts::PI;
|
||||
let v_r = ring as f32 / 24.0; // ring·(1/12)·0.5 — the polar UV radius
|
||||
let v_r = ring as f32 / 24.0; // the polar UV radius
|
||||
for j in 0..AZ_STEPS {
|
||||
let az = j as f32 / AZ_STEPS as f32 * std::f32::consts::TAU;
|
||||
let (sa, ca) = az.sin_cos();
|
||||
// The recentred sky direction (`cos φ − cos45°` height: rim at eye level), pushed to
|
||||
// unit radius so the whole cap sits at one distance (see the module docs).
|
||||
// Recentred so the rim sits at eye level, then pushed to unit radius.
|
||||
let dir = Vec3::new(phi.sin() * sa, phi.cos() - shift, phi.sin() * ca).normalize();
|
||||
positions.push([dir.x, dir.y, dir.z]);
|
||||
normals.push([dir.x, dir.y, dir.z]);
|
||||
// u tracks world x, v tracks world z — matching the sampler's (col ← x, row ← z).
|
||||
// u follows world x and v world z, as the sampler's column and row do.
|
||||
uvs.push([sa * v_r + 0.5, ca * v_r + 0.5]);
|
||||
colors.push([1.0, 1.0, 1.0, alpha]);
|
||||
}
|
||||
@@ -167,7 +141,7 @@ pub(super) fn setup_cloud_layer(
|
||||
},
|
||||
TextureDimension::D2,
|
||||
vec![0; COLS * COLS * 4], // fully transparent until the field primes
|
||||
// NON-sRGB: the texels are the kernel's gamma bytes; sampling must return them raw.
|
||||
// Not sRGB: the texels are gamma bytes and must sample raw.
|
||||
TextureFormat::Rgba8Unorm,
|
||||
RenderAssetUsages::default(),
|
||||
));
|
||||
@@ -176,8 +150,7 @@ pub(super) fn setup_cloud_layer(
|
||||
unlit: true,
|
||||
cull_mode: None, // viewed from inside
|
||||
alpha_mode: AlphaMode::Premultiplied,
|
||||
// The sky pass's last draw — over the discs, under the rain and the glare (the
|
||||
// reference's fixed order; see the ladder in `sky_order`).
|
||||
// The sky pass's last draw: over the discs, under the rain and the glare.
|
||||
depth_bias: crate::sky_order::CLOUDS_BIAS,
|
||||
..default()
|
||||
},
|
||||
@@ -194,18 +167,9 @@ pub(super) fn setup_cloud_layer(
|
||||
commands.insert_resource(CloudLayer { image, material });
|
||||
}
|
||||
|
||||
/// The sky-dome visibility toggle (the clouds hide with the rest of the sky). All color state
|
||||
/// lives in the texture — the kernel's `0x6cfb00` port owns it.
|
||||
///
|
||||
/// A WMO skybox ([`crate::skybox`]) hides the clouds too: `CSky::Render`'s one shared boolean skips
|
||||
/// **all six** sky elements together (`0x6d4a3b test edi,edi; je`), so the painted art is the whole
|
||||
/// sky rather than a backdrop the procedural clouds keep drawing over. The art carries its own cloud
|
||||
/// banks; layering ours on top lifted the painted zenith out of its near-black. The gate is the
|
||||
/// slot's **weight** past 0.99 ([`crate::skybox::SkyboxWeight`]): through the body of the 4-second
|
||||
/// crossfade the clouds keep drawing and the painted sky alpha-blends over them.
|
||||
///
|
||||
/// A **submerged eye** hides them the same way: the scene driver's `0x6812a4` submerged test skips
|
||||
/// the whole `CSky::Render` call — from under the surface there is no sky, only the murk.
|
||||
/// Hides the dome with the rest of the sky: under a WMO skybox past weight 0.99, as `CSky::Render`
|
||||
/// skips all six sky elements on one flag (`0x6d4a3b`), and while submerged, as the scene skips
|
||||
/// `CSky::Render` (`0x6812a4`).
|
||||
pub(super) fn apply_cloud_visibility(
|
||||
debug: Res<DebugState>,
|
||||
skybox: Res<crate::skybox::SkyboxWeight>,
|
||||
@@ -223,11 +187,8 @@ pub(super) fn apply_cloud_visibility(
|
||||
}
|
||||
}
|
||||
|
||||
/// Pin the dome to the camera at `far·0.87` — inside the opaque sky dome (`far·0.9`), sorted after
|
||||
/// the disc shells so the transparent pass draws the clouds over a setting sun, the reference's
|
||||
/// depth-band layering. The radius sets the dome's *screen* geometry only: occlusion against the
|
||||
/// world is the far depth `sky_vertex.wgsl` pins (`sky_order`, "The depth law"), not this shell —
|
||||
/// the WDL horizon reaches past it (0588).
|
||||
/// Centres the dome on the camera at `far·0.87`, inside the sky dome. The radius only sizes it on
|
||||
/// screen; occlusion comes from the far-depth pin in `sky_vertex.wgsl`.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn follow_cloud_dome(
|
||||
cam: Query<(&GlobalTransform, &Projection), With<WorldCamera>>,
|
||||
@@ -256,9 +217,6 @@ pub(super) fn follow_cloud_dome(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The dome is the reference's `0x6d0530` build: 192 verts (12 rings × 16), the 11 band
|
||||
/// strips as 352 triangles, rim at eye level (y = 0 after the −cos45° recentre), UV radius
|
||||
/// growing ring-linear to 11/24.
|
||||
#[test]
|
||||
fn dome_matches_the_reference_build() {
|
||||
let mesh = cloud_dome_mesh();
|
||||
@@ -272,8 +230,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.as_float3()
|
||||
.unwrap();
|
||||
// Every vertex sits at unit radius (the uniform-distance push); the pole points straight
|
||||
// up and the rim (colat 0.25π, recentred to eye level) stays at y = 0.
|
||||
// Unit radius everywhere; the pole points straight up and the rim sits at y = 0.
|
||||
for p in pos {
|
||||
let r = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
|
||||
assert!((r - 1.0).abs() < 1e-5, "radius {r}");
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
//! Clouds — the procedural sky cloud subsystem.
|
||||
//! The procedural sky clouds: as in the reference, one coverage field feeds both the glare's cloud
|
||||
//! occlusion and the visible dome, so the cloud over the sun is the cloud that dims it.
|
||||
//!
|
||||
//! One coverage field serves every consumer, exactly like the reference: the [`kernel`] maintains
|
||||
//! the scrolling 128² byte tile (4-octave toroidal value noise, thresholded by the authored
|
||||
//! Light.dbc cloud density `C`, tone-curved), the flare gate samples it for the cloud occlusion
|
||||
//! `occ1` (sun `1−R`, moon the thin-cloud tent), and the visible [`layer`] renders the same bytes
|
||||
//! as a dome texture. A cloud drifting over the sun therefore dims the glare *and* is the cloud
|
||||
//! you see — they cannot desynchronize.
|
||||
//!
|
||||
//! `C` rides [`WowLighting::cloud_density`] (LightFloatBand sub-3, weather/underwater blends
|
||||
//! included), so storms with an authored overcast band raise the coverage — and with it the
|
||||
//! glare dimming — with no extra wiring.
|
||||
//!
|
||||
//! **Capture mode freezes the field's clock**: the tile becomes a pure function of `C` (a full
|
||||
//! rebuild whenever it changes, no phase drift), so capture fixtures stay bit-identical across
|
||||
//! runs — the settle-frame count varies with real frame timing, and a live phase would smear a
|
||||
//! few bytes per run. Live runs keep the reference's ~10 Hz scroll + slow morph.
|
||||
//! Capture mode freezes the field's clock and rebuilds only when the density changes, so captures
|
||||
//! stay byte-identical whatever the frame timing.
|
||||
|
||||
use bevy::pbr::MaterialPlugin;
|
||||
use bevy::prelude::*;
|
||||
@@ -30,19 +18,14 @@ mod tables;
|
||||
|
||||
pub use kernel::{occ1_moon, occ1_sun};
|
||||
|
||||
/// The live cloud coverage field. Read [`CloudCoverage::coverage`] with a camera-relative offset
|
||||
/// (e.g. the glare's 12-unit sky point) to get `R ∈ [0,1]` at that sky position.
|
||||
/// The live cloud coverage field.
|
||||
#[derive(Resource)]
|
||||
pub struct CloudCoverage {
|
||||
kernel: kernel::CloudKernel,
|
||||
/// Whether the first full rebuild ran (the reference full-rebuilds on init, then scrolls
|
||||
/// incrementally — `0x6cff90` vs `0x6cffc0`).
|
||||
/// Whether the init rebuild ran (`0x6cff90`); the bands scroll after it (`0x6cffc0`).
|
||||
primed: bool,
|
||||
/// Capture mode: freeze the phase clock; rebuild only when `C` changes (see module docs).
|
||||
frozen: bool,
|
||||
/// The density the tile was last built with (the frozen-mode rebuild trigger).
|
||||
last_density: f32,
|
||||
/// The color-pass inputs of the last build (the frozen-mode recolor trigger).
|
||||
last_frame: Option<kernel::CloudFrame>,
|
||||
}
|
||||
|
||||
@@ -59,7 +42,7 @@ impl Default for CloudCoverage {
|
||||
}
|
||||
|
||||
impl CloudCoverage {
|
||||
/// Coverage `R` toward camera-relative offset `d` (Bevy frame, +Y up).
|
||||
/// Coverage in [0, 1] toward camera-relative `d` (Bevy frame, +Y up).
|
||||
pub fn coverage(&self, d: Vec3) -> f32 {
|
||||
self.kernel.coverage(d)
|
||||
}
|
||||
@@ -75,25 +58,20 @@ impl Plugin for CloudsPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
// After the submersion verdict: the surfacing edge (wet→dry) must fire the
|
||||
// full rebuild the SAME frame the dome un-hides, or the first visible frame
|
||||
// still shows the submerged-era tile.
|
||||
// …and on the READ side of the lighting resolve: the surfacing rebuild
|
||||
// below takes its density and palette from `WowLighting`, so an unordered
|
||||
// tick rebuilds the field from the atmosphere we just LEFT — the underwater
|
||||
// one, whose cloud density is 0.0 in Stranglethorn (B354, decision 2032).
|
||||
// After the submersion verdict, so surfacing rebuilds the tile the frame the
|
||||
// dome reappears, and after the lighting resolve, so that rebuild takes the
|
||||
// air's density and palette rather than the water's.
|
||||
tick_clouds
|
||||
.after(crate::liquid::SubmersionVerdict)
|
||||
.in_set(crate::lighting::LightingConsumeSet),
|
||||
// After the resolve: the dome and the painted skybox must agree WITHIN a frame,
|
||||
// or one frame draws both (the ordering `crate::sky`'s gate already takes) —
|
||||
// and after the submersion verdict, for the same one-frame-agreement reason.
|
||||
// After the skybox resolve and the submersion verdict, so the dome hides in
|
||||
// the same frame as the rest of the sky.
|
||||
layer::apply_cloud_visibility
|
||||
.after(crate::skybox::SkyboxResolve)
|
||||
.after(crate::liquid::SubmersionVerdict),
|
||||
),
|
||||
)
|
||||
// Camera-anchored placement post-propagation, like the sky dome (decision 0504).
|
||||
// Camera-anchored after transform propagation, like the sky dome.
|
||||
.add_systems(
|
||||
PostUpdate,
|
||||
layer::follow_cloud_dome.in_set(crate::billboard::BillboardPlace),
|
||||
@@ -101,10 +79,8 @@ impl Plugin for CloudsPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the coverage field — a full rebuild the first frame, then the reference's ~10 Hz
|
||||
/// incremental 32-row band scroll (or the frozen capture clock) — and re-upload the colored
|
||||
/// RGBA texels when they changed (the reference's per-regen `0x58ac70` upload of the `0x6cfb00`
|
||||
/// color buffer).
|
||||
/// Advances the coverage field (a full rebuild first, then the 10 Hz band scroll) and re-uploads
|
||||
/// its texels when they change, as the reference does per regen (`0x58ac70`).
|
||||
fn tick_clouds(
|
||||
mut cov: ResMut<CloudCoverage>,
|
||||
light: Res<WowLighting>,
|
||||
@@ -115,17 +91,13 @@ fn tick_clouds(
|
||||
underwater: Res<crate::liquid::Underwater>,
|
||||
mut was_submerged: Local<bool>,
|
||||
) {
|
||||
// The surfacing edge — the reference's wet→dry detector `0x680ac3` fires `0x6d2210(1)`, the
|
||||
// FULL-rebuild selector (`bl!=0` → `0x6cff90`), the frame the eye leaves the liquid.
|
||||
// Without it the incremental 32-row scroll restores the field band-by-band over ~0.4 s+ —
|
||||
// the sky pass returns instantly (0902's gate) but the clouds creep back in behind it,
|
||||
// the "delayed pop-in" the director sighted. Dry→wet takes no edge, exactly like the
|
||||
// reference: the sky pass is skipped while submerged, so nobody sees the tile.
|
||||
// Surfacing forces a full rebuild: the reference's wet→dry edge (`0x680ac3`) calls
|
||||
// `0x6d2210(1)`, which selects `0x6cff90`; a band scroll would bring the clouds back over
|
||||
// 0.4 s. Going under needs nothing, since the sky pass is skipped while submerged.
|
||||
let submerged = underwater.0.any();
|
||||
let surfaced = *was_submerged && !submerged;
|
||||
*was_submerged = submerged;
|
||||
let density = light.cloud_density;
|
||||
// The color-pass inputs (`0x6cfb00`'s per-frame setup), from the resolved lighting.
|
||||
let frame = kernel::CloudFrame {
|
||||
sun: light.cloud_colors[0],
|
||||
slope: light.cloud_colors[1],
|
||||
@@ -152,8 +124,7 @@ fn tick_clouds(
|
||||
cov.kernel.rebuild(density, &frame);
|
||||
true
|
||||
} else if cov.frozen {
|
||||
// The frozen capture clock: the coverage stays put; recolor only when the palette /
|
||||
// glow / weather inputs moved (deterministic — the scenario pins them).
|
||||
// Frozen clock: coverage stays put; recolor only when the color inputs move.
|
||||
if cov.last_frame != Some(frame) {
|
||||
cov.last_frame = Some(frame);
|
||||
cov.kernel.recolor(&frame);
|
||||
@@ -172,9 +143,8 @@ fn tick_clouds(
|
||||
.as_mut()
|
||||
.expect("cpu-side cloud image")
|
||||
.copy_from_slice(cov.kernel.rgba().as_flattened());
|
||||
// Touch the material so its bind group rebuilds against the re-uploaded
|
||||
// texture (a modified Image gets a NEW GpuImage; a stale bind group keeps
|
||||
// sampling the first upload forever).
|
||||
// Touch the material: a modified Image gets a new GPU texture, and a stale bind
|
||||
// group keeps sampling the first upload.
|
||||
materials.get_mut(&layer.material);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! The frozen cloud noise tables — dumped from the binary or rebuilt by its exact one-time
|
||||
//! builders.
|
||||
//! The cloud noise tables, dumped from the reference or rebuilt by its one-time builders.
|
||||
|
||||
/// The static permutation table (`0x86f2d0`, `.rdata`) — 256 bytes, a permutation of 0..255,
|
||||
/// always indexed `& 0xff` (no doubled-512 layout). Dumped verbatim from the binary.
|
||||
/// The permutation table (`0x86f2d0`), dumped verbatim: 256 bytes, always indexed `& 0xff`.
|
||||
#[rustfmt::skip]
|
||||
pub(crate) const PERM: [u8; 256] = [
|
||||
225, 155, 210, 108, 175, 199, 221, 144, 203, 116, 70, 213, 69, 158, 33, 252,
|
||||
@@ -23,10 +21,8 @@ pub(crate) const PERM: [u8; 256] = [
|
||||
137, 214, 145, 93, 92, 100, 245, 0, 216, 186, 60, 83, 105, 97, 204, 52,
|
||||
];
|
||||
|
||||
/// The tone curve (`0xce91d8`) — built once at init by `0x6d0900` (gamma 0.96,
|
||||
/// init threshold 101: `curve[i] = ftol(255 − 255·0.96^(i·0.6015625))`) and fixed thereafter.
|
||||
/// Frozen to the reference's exact bytes rather than recomputed through libm `pow`
|
||||
/// (cross-platform determinism).
|
||||
/// The tone curve (`0xce91d8`), built once by `0x6d0900` as `ftol(255 − 255·0.96^(i·0.6015625))`
|
||||
/// for the init threshold 101; frozen to the reference's bytes, as libm `pow` varies by platform.
|
||||
#[rustfmt::skip]
|
||||
pub(crate) const CURVE: [u8; 256] = [
|
||||
0, 6, 12, 18, 23, 29, 34, 40, 45, 50, 55, 60, 65, 69, 74, 78,
|
||||
@@ -47,10 +43,9 @@ pub(crate) const CURVE: [u8; 256] = [
|
||||
254, 254, 254, 254, 254, 254, 254, 254, 254, 254, 254, 254, 254, 254, 254, 254,
|
||||
];
|
||||
|
||||
/// The gradient table build (`0x6d0c90` loop 1): `gradient[i] = 1 − 2·rand()/32767` with MSVC's
|
||||
/// LCG (`seed = seed·214013 + 2531011; (seed >> 16) & 0x7fff`). The reference's seed is whatever
|
||||
/// the process `srand` state was — not visually load-bearing (any uniform table is equivalent);
|
||||
/// we fix seed = 1 (the CRT default) for run-to-run determinism.
|
||||
/// The gradient table (`0xce92d8`, built by `0x6d0c90` loop 1): `1 − 2·rand()/32767` on MSVC's
|
||||
/// `rand`. Deviation: seed 1, the CRT default, for determinism; the reference's is the process
|
||||
/// `srand` state, and any uniform table looks the same.
|
||||
pub(crate) fn gradient_table() -> [f32; 256] {
|
||||
let mut seed: u32 = 1;
|
||||
std::array::from_fn(|_| {
|
||||
@@ -60,7 +55,7 @@ pub(crate) fn gradient_table() -> [f32; 256] {
|
||||
})
|
||||
}
|
||||
|
||||
/// The fade table build (`0x6d0c90` loop 2): the raised-cosine ease `0.5·(1 − cos(iπ/256))`.
|
||||
/// The fade table (`0xce8dd8`, built by `0x6d0c90` loop 2): a raised cosine.
|
||||
pub(crate) fn fade_table() -> [f32; 256] {
|
||||
std::array::from_fn(|i| 0.5 * (1.0 - ((i as f32) * std::f32::consts::PI / 256.0).cos()))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user