MONKEY (reviewfix-a): merge graphics-programme-polish

Conflicts: sun_shafts prepare keeps the integration warm loop on the polish
format-only blur key (mask pipelines are queued at init); sky_fx keeps the
clock ordered before LightingConsumeSet plus the polish clock buffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-25 23:25:28 +01:00
co-authored by Claude Opus 5.5
10 changed files with 553 additions and 107 deletions
+176 -12
View File
@@ -25,7 +25,7 @@ use bevy::{
renderer::{RenderContext, RenderDevice, RenderQueue},
texture::GpuImage,
view::ViewTarget,
RenderApp, RenderStartup,
Render, RenderApp, RenderStartup, RenderSystems,
},
};
use std::collections::{HashMap, HashSet};
@@ -43,10 +43,66 @@ struct GradeCatalog {
struct GradeView {
day: Handle<Image>,
night: Handle<Image>,
// x = authored strength, y = night blend.
/// MONKEY (polish): the pair being faded out (identity once the fade is done).
prev_day: Handle<Image>,
prev_night: Handle<Image>,
// x = authored strength, y = night blend, z = crossfade weight of the current pair (1 = done),
// w = the previous pair's strength.
control: Vec4,
}
/// MONKEY (polish): the zone crossfade's length; a border crossing no longer snaps the grade.
const GRADE_FADE_S: f32 = 2.0;
/// One zone's LUT pair at its authored strength; a zone with no row grades at strength 0.
#[derive(Clone, PartialEq, Debug)]
struct GradePair {
day: Handle<Image>,
night: Handle<Image>,
strength: f32,
}
/// MONKEY (polish): the crossfade from the previous zone's pair to the current one.
#[derive(Resource, Default)]
struct GradeBlend {
current: Option<GradePair>,
previous: Option<GradePair>,
/// The current pair's weight, 0..1.
fade: f32,
primed: bool,
}
impl GradeBlend {
/// Steps toward `target`. The first graded zone after login (or re-enabling) and a disabled
/// grade snap; a return to the
/// pair still fading out reverses the fade instead of restarting it.
fn step(&mut self, target: Option<GradePair>, dt: f32, snap: bool) {
if snap || !self.primed {
*self = Self {
primed: !snap && target.is_some(),
current: target,
previous: None,
fade: 1.0,
};
return;
}
if target != self.current {
if self.fade < 1.0 && target == self.previous {
std::mem::swap(&mut self.current, &mut self.previous);
self.fade = 1.0 - self.fade;
} else {
self.previous = self.current.take();
self.current = target;
self.fade = 0.0;
}
}
self.fade = (self.fade + dt / GRADE_FADE_S).min(1.0);
if self.fade >= 1.0 {
self.previous = None;
}
}
}
#[derive(Clone, Copy, ShaderType)]
struct GradeUniform {
control: Vec4,
@@ -55,6 +111,7 @@ struct GradeUniform {
impl Plugin for GradingPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(ExtractComponentPlugin::<GradeView>::default())
.init_resource::<GradeBlend>()
.add_systems(Last, update_views);
if !app.is_plugin_added::<AssetPlugin>() {
return;
@@ -73,6 +130,10 @@ impl Plugin for GradingPlugin {
render_app
.insert_resource(GradeShader(shader))
.add_systems(RenderStartup, init_pipeline)
.add_systems(
Render,
prepare_uniforms.in_set(RenderSystems::PrepareResources),
)
.add_render_graph_node::<ViewNodeRunner<GradeNode>>(Core3d, GradeLabel)
.add_render_graph_edges(
Core3d,
@@ -215,6 +276,7 @@ fn volume_image(cube: Vec<u8>) -> Image {
)
}
#[allow(clippy::too_many_arguments)]
fn update_views(
mut commands: Commands,
video: Res<VideoConfig>,
@@ -222,6 +284,8 @@ fn update_views(
area: Res<CurrentArea>,
areas: Option<Res<crate::area::AreaTableRes>>,
time: Res<WorldTime>,
clock: Res<Time>,
mut blend: ResMut<GradeBlend>,
cameras: Query<(Entity, &Camera, Option<&GradeView>), With<WorldCamera>>,
) {
let selected = catalog.as_ref().and_then(|catalog| {
@@ -234,7 +298,7 @@ fn update_views(
.rows
.for_area(leaf)
.or_else(|| catalog.rows.for_area(zone))?;
(row.strength > 0.0).then(|| GradeView {
(row.strength > 0.0).then(|| GradePair {
day: catalog
.luts
.get(&row.day_lut)
@@ -245,7 +309,33 @@ fn update_views(
.get(&row.night_lut)
.unwrap_or(&catalog.identity)
.clone(),
control: Vec4::new(row.strength, night_weight(time.minute_f), 0.0, 0.0),
strength: row.strength,
})
});
blend.step(selected, clock.delta_secs(), !video.color_grading);
let selected = catalog.as_ref().and_then(|catalog| {
if blend.current.is_none() && blend.previous.is_none() {
return None;
}
let pair = |p: &Option<GradePair>| {
p.as_ref().map_or_else(
|| (catalog.identity.clone(), catalog.identity.clone(), 0.0),
|p| (p.day.clone(), p.night.clone(), p.strength),
)
};
let (day, night, strength) = pair(&blend.current);
let (prev_day, prev_night, prev_strength) = pair(&blend.previous);
Some(GradeView {
day,
night,
prev_day,
prev_night,
control: Vec4::new(
strength,
night_weight(time.minute_f),
blend.fade,
prev_strength,
),
})
});
for (entity, camera, old) in &cameras {
@@ -307,6 +397,9 @@ fn init_pipeline(
texture_3d(TextureSampleType::Float { filterable: true }),
sampler(SamplerBindingType::Filtering),
uniform_buffer::<GradeUniform>(false),
// MONKEY (polish): the previous zone's pair, crossfaded out.
texture_3d(TextureSampleType::Float { filterable: true }),
texture_3d(TextureSampleType::Float { filterable: true }),
),
),
);
@@ -344,17 +437,47 @@ fn init_pipeline(
});
}
/// MONKEY (polish): the view's uniform, created once and rewritten in prepare, like bloom's.
#[derive(Component)]
struct GradeUniformBuffer(Buffer);
fn prepare_uniforms(
mut commands: Commands,
device: Res<RenderDevice>,
queue: Res<RenderQueue>,
views: Query<(Entity, &GradeView, Option<&GradeUniformBuffer>)>,
) {
for (entity, grade, buffer) in &views {
let rows = [grade.control.to_array()];
match buffer {
Some(buffer) => queue.write_buffer(&buffer.0, 0, bytemuck::cast_slice(&rows)),
None => {
let buffer = device.create_buffer_with_data(&BufferInitDescriptor {
label: Some("post_grading_uniform"),
contents: bytemuck::cast_slice(&rows),
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
});
commands.entity(entity).insert(GradeUniformBuffer(buffer));
}
}
}
}
#[derive(Default)]
struct GradeNode;
impl ViewNode for GradeNode {
type ViewQuery = (&'static ViewTarget, &'static GradeView);
type ViewQuery = (
&'static ViewTarget,
&'static GradeView,
&'static GradeUniformBuffer,
);
fn run<'w>(
&self,
_graph: &mut RenderGraphContext,
context: &mut RenderContext<'w>,
(target, grade): QueryItem<'w, '_, Self::ViewQuery>,
(target, grade, uniform): QueryItem<'w, '_, Self::ViewQuery>,
world: &'w World,
) -> Result<(), NodeRunError> {
let settings = world.resource::<GradePipeline>();
@@ -363,14 +486,15 @@ impl ViewNode for GradeNode {
return Ok(());
};
let images = world.resource::<RenderAssets<GpuImage>>();
let (Some(day), Some(night)) = (images.get(&grade.day), images.get(&grade.night)) else {
let (Some(day), Some(night), Some(prev_day), Some(prev_night)) = (
images.get(&grade.day),
images.get(&grade.night),
images.get(&grade.prev_day),
images.get(&grade.prev_night),
) else {
return Ok(());
};
let device = context.render_device();
let mut uniform = UniformBuffer::from(GradeUniform {
control: grade.control,
});
uniform.write_buffer(device, world.resource::<RenderQueue>());
let out = target.post_process_write();
let bind = device.create_bind_group(
"post_grading",
@@ -381,7 +505,9 @@ impl ViewNode for GradeNode {
&day.texture_view,
&night.texture_view,
&settings.lut_sampler,
uniform.binding().unwrap(),
uniform.0.as_entire_binding(),
&prev_day.texture_view,
&prev_night.texture_view,
)),
);
let diagnostics = context.diagnostic_recorder();
@@ -422,6 +548,44 @@ mod tests {
assert_eq!(at(0, 0, 31), &[0, 0, 255, 255]);
}
#[test]
fn zone_change_crossfades_and_a_quick_return_reverses() {
let pair = |id: u128, strength| GradePair {
day: Handle::Uuid(bevy::asset::uuid::Uuid::from_u128(id), default()),
night: Handle::Uuid(bevy::asset::uuid::Uuid::from_u128(id + 1), default()),
strength,
};
let (a, b) = (pair(10, 1.0), pair(20, 0.5));
let mut blend = GradeBlend::default();
// No zone yet, then the first graded zone snaps in.
blend.step(None, 0.016, false);
blend.step(Some(a.clone()), 0.016, false);
assert_eq!((blend.current.clone(), blend.fade), (Some(a.clone()), 1.0));
// A border crossing fades over GRADE_FADE_S.
blend.step(Some(b.clone()), 0.5, false);
assert_eq!(blend.previous, Some(a.clone()));
assert!((blend.fade - 0.25).abs() < 1e-6);
// Stepping back mid-fade reverses from the same image.
blend.step(Some(a.clone()), 0.0, false);
assert_eq!(
(blend.current.clone(), blend.previous.clone()),
(Some(a.clone()), Some(b))
);
assert!((blend.fade - 0.75).abs() < 1e-6);
blend.step(Some(a.clone()), 1.0, false);
assert_eq!((blend.fade, blend.previous.is_none()), (1.0, true));
// An ungraded zone fades out to identity rather than cutting.
blend.step(None, 1.0, false);
assert_eq!(
(blend.current.is_none(), blend.previous.clone()),
(true, Some(a))
);
assert!((blend.fade - 0.5).abs() < 1e-6);
// Disabling snaps.
blend.step(None, 0.0, true);
assert!(blend.previous.is_none() && blend.current.is_none());
}
#[test]
fn day_night_blend_is_continuous() {
assert_eq!(night_weight(0.0), 1.0);
+13 -1
View File
@@ -15,6 +15,9 @@ struct Grade {
control: vec4<f32>,
};
@group(0) @binding(5) var<uniform> grade: Grade;
// MONKEY (polish): the previous zone's pair, crossfaded out over `grade.control.z`.
@group(0) @binding(6) var prev_day_lut: texture_3d<f32>;
@group(0) @binding(7) var prev_night_lut: texture_3d<f32>;
fn grade_one(lut: texture_3d<f32>, color: vec3<f32>) -> vec3<f32> {
// Half a texel in from each face, with 31 intervals across the 32-texel cube.
@@ -29,7 +32,16 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
let day = grade_one(day_lut, base);
let night = grade_one(night_lut, base);
let authored = mix(day, night, grade.control.y);
let graded = mix(base, authored, grade.control.x);
var graded = mix(base, authored, grade.control.x);
// MONKEY (polish): mid-crossfade, the previous zone's pair through the same day/night blend
// at its own strength; a missing zone or LUT is the identity cube, so it fades to ungraded.
if (grade.control.z < 1.0) {
let prev_day = grade_one(prev_day_lut, base);
let prev_night = grade_one(prev_night_lut, base);
let prev_authored = mix(prev_day, prev_night, grade.control.y);
let prev_graded = mix(base, prev_authored, grade.control.w);
graded = mix(prev_graded, graded, grade.control.z);
}
// Bloom already consumed the HDR signal; preserve its remaining excess for the FFX clamp.
return vec4(graded + max(source.rgb - vec3(1.0), vec3(0.0)), source.a);
}
+170 -45
View File
@@ -1,4 +1,7 @@
//! MONKEY (post): screen-space sun shafts from the visible sky depth.
//!
//! MONKEY (polish): the depth is read once per half-resolution texel into an R8 sky mask
//! (`fs_mask`, the only MSAA-specialised stage); the 28-tap radial blur then samples the mask.
use super::bloom::BloomLabel;
use crate::video::VideoConfig;
@@ -11,6 +14,7 @@ use bevy::{
ecs::query::QueryItem,
prelude::*,
render::{
camera::ExtractedCamera,
diagnostic::RecordDiagnostics,
extract_component::{ExtractComponent, ExtractComponentPlugin},
render_graph::{
@@ -18,7 +22,8 @@ use bevy::{
},
render_resource::{binding_types::*, *},
renderer::{RenderContext, RenderDevice, RenderQueue},
view::{Msaa, ViewDepthTexture, ViewTarget},
texture::{CachedTexture, TextureCache},
view::{ViewDepthTexture, ViewTarget},
Render, RenderApp, RenderStartup, RenderSystems,
},
};
@@ -54,7 +59,13 @@ impl Plugin for SunShaftsPlugin {
.insert_resource(ShaftShader(shader))
.init_resource::<SpecializedRenderPipelines<ShaftPipeline>>()
.add_systems(RenderStartup, init_pipeline)
.add_systems(Render, prepare_pipelines.in_set(RenderSystems::Prepare))
.add_systems(
Render,
(
prepare_pipelines.in_set(RenderSystems::Prepare),
prepare_masks.in_set(RenderSystems::PrepareResources),
),
)
.add_render_graph_node::<ViewNodeRunner<ShaftNode>>(Core3d, ShaftLabel)
.add_render_graph_edges(
Core3d,
@@ -139,12 +150,25 @@ struct ShaftShader(Handle<Shader>);
#[derive(Resource)]
struct ShaftPipeline {
layouts: [BindGroupLayoutDescriptor; 2],
/// The blur's layout; the mask's per MSAA (`[single, multisampled]`).
layout: BindGroupLayoutDescriptor,
mask_layouts: [BindGroupLayoutDescriptor; 2],
masks: [CachedRenderPipelineId; 2],
shader: Handle<Shader>,
fullscreen: FullscreenShader,
sampler: Sampler,
}
/// The half-resolution sky mask the blur marches through.
const MASK_FORMAT: TextureFormat = TextureFormat::R8Unorm;
#[derive(Component)]
struct ShaftMask(CachedTexture);
/// MONKEY (polish): the view's uniform, created once and rewritten in prepare, like bloom's.
#[derive(Component)]
struct ShaftUniform(Buffer);
#[derive(Component)]
struct ViewShaftPipeline(CachedRenderPipelineId);
@@ -154,29 +178,61 @@ struct ShaftLabel;
fn init_pipeline(
mut commands: Commands,
device: Res<RenderDevice>,
cache: Res<PipelineCache>,
shader: Res<ShaftShader>,
fullscreen: Res<FullscreenShader>,
) {
let layouts = [false, true].map(|multisampled| {
let layout = BindGroupLayoutDescriptor::new(
"post_sun_shafts_layout",
&BindGroupLayoutEntries::sequential(
ShaderStages::FRAGMENT,
(
texture_2d(TextureSampleType::Float { filterable: true }),
sampler(SamplerBindingType::Filtering),
texture_2d(TextureSampleType::Float { filterable: true }),
uniform_buffer::<ShaftView>(false),
),
),
);
let mask_layouts = [false, true].map(|multisampled| {
BindGroupLayoutDescriptor::new(
"post_sun_shafts_layout",
&BindGroupLayoutEntries::sequential(
"post_sun_shafts_mask_layout",
&BindGroupLayoutEntries::single(
ShaderStages::FRAGMENT,
(
texture_2d(TextureSampleType::Float { filterable: true }),
sampler(SamplerBindingType::Filtering),
if multisampled {
texture_depth_2d_multisampled()
} else {
texture_depth_2d()
},
uniform_buffer::<ShaftView>(false),
),
if multisampled {
texture_depth_2d_multisampled()
} else {
texture_depth_2d()
},
),
)
});
let masks = [false, true].map(|multisampled| {
let mut shader_defs = vec!["SHAFT_MASK".into()];
if multisampled {
shader_defs.push("MULTISAMPLED".into());
}
cache.queue_render_pipeline(RenderPipelineDescriptor {
label: Some("post_sun_shafts_mask".into()),
layout: vec![mask_layouts[multisampled as usize].clone()],
vertex: fullscreen.to_vertex_state(),
fragment: Some(FragmentState {
shader: shader.0.clone(),
shader_defs,
entry_point: Some("fs_mask".into()),
targets: vec![Some(ColorTargetState {
format: MASK_FORMAT,
blend: None,
write_mask: ColorWrites::ALL,
})],
}),
..default()
})
});
commands.insert_resource(ShaftPipeline {
layouts,
layout,
mask_layouts,
masks,
shader: shader.0.clone(),
fullscreen: fullscreen.clone(),
sampler: device.create_sampler(&SamplerDescriptor {
@@ -190,19 +246,16 @@ fn init_pipeline(
}
impl SpecializedRenderPipeline for ShaftPipeline {
type Key = (TextureFormat, bool);
type Key = TextureFormat;
fn specialize(&self, (format, multisampled): Self::Key) -> RenderPipelineDescriptor {
fn specialize(&self, format: Self::Key) -> RenderPipelineDescriptor {
RenderPipelineDescriptor {
label: Some("post_sun_shafts".into()),
layout: vec![self.layouts[multisampled as usize].clone()],
layout: vec![self.layout.clone()],
vertex: self.fullscreen.to_vertex_state(),
fragment: Some(FragmentState {
shader: self.shader.clone(),
shader_defs: multisampled
.then(|| "MULTISAMPLED".into())
.into_iter()
.collect(),
shader_defs: vec![],
entry_point: Some("fragment".into()),
targets: vec![Some(ColorTargetState {
format,
@@ -220,25 +273,68 @@ fn prepare_pipelines(
cache: Res<PipelineCache>,
pipeline: Res<ShaftPipeline>,
mut specialized: ResMut<SpecializedRenderPipelines<ShaftPipeline>>,
views: Query<(Entity, &ViewTarget, &Msaa), With<ShaftView>>,
all_views: Query<(&ViewTarget, &Msaa), With<Camera3d>>,
views: Query<(Entity, &ViewTarget), With<ShaftView>>,
all_views: Query<&ViewTarget, With<Camera3d>>,
) {
// MONKEY (integration): warm every reachable key on every 3-D view, feature on or off, so the
// compile happens under the entry cover and never live when the player turns the row on.
// (Named in `pipe_warm/menagerie.rs`'s custom-lane census.)
for (target, msaa) in &all_views {
specialized.specialize(&cache, &pipeline, (target.main_texture_format(), msaa.samples() > 1));
// (Named in `pipe_warm/menagerie.rs`'s custom-lane census.) The mask pipelines (per MSAA) are
// queued once at init, so only the blur's format key needs warming here.
for target in &all_views {
specialized.specialize(&cache, &pipeline, target.main_texture_format());
}
for (entity, target, msaa) in &views {
let id = specialized.specialize(
&cache,
&pipeline,
(target.main_texture_format(), msaa.samples() > 1),
);
for (entity, target) in &views {
let id = specialized.specialize(&cache, &pipeline, target.main_texture_format());
commands.entity(entity).insert(ViewShaftPipeline(id));
}
}
/// The half-size mask target, rounded up so an odd edge column still has a texel, and the
/// view's uniform, written in place.
fn prepare_masks(
mut commands: Commands,
mut textures: ResMut<TextureCache>,
device: Res<RenderDevice>,
queue: Res<RenderQueue>,
views: Query<(Entity, &ExtractedCamera, &ShaftView, Option<&ShaftUniform>)>,
) {
for (entity, camera, shaft, uniform) in &views {
let Some(size) = camera.physical_viewport_size else {
continue;
};
let rows = [shaft.sun.to_array(), shaft.color.to_array()];
match uniform {
Some(uniform) => queue.write_buffer(&uniform.0, 0, bytemuck::cast_slice(&rows)),
None => {
let buffer = device.create_buffer_with_data(&BufferInitDescriptor {
label: Some("post_sun_shafts_uniform"),
contents: bytemuck::cast_slice(&rows),
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
});
commands.entity(entity).insert(ShaftUniform(buffer));
}
}
let mask = textures.get(
&device,
TextureDescriptor {
label: Some("post_sun_shafts_mask"),
size: Extent3d {
width: size.x.div_ceil(2).max(1),
height: size.y.div_ceil(2).max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: MASK_FORMAT,
usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
);
commands.entity(entity).insert(ShaftMask(mask));
}
}
#[derive(Default)]
struct ShaftNode;
@@ -246,19 +342,27 @@ impl ViewNode for ShaftNode {
type ViewQuery = (
&'static ViewTarget,
&'static ViewDepthTexture,
// Gates the pass: the mask and uniform outlive a disabled frame.
&'static ShaftView,
&'static ViewShaftPipeline,
&'static ShaftMask,
&'static ShaftUniform,
);
fn run<'w>(
&self,
_graph: &mut RenderGraphContext,
context: &mut RenderContext<'w>,
(target, depth, shaft, id): QueryItem<'w, '_, Self::ViewQuery>,
(target, depth, _, id, mask, uniform): QueryItem<'w, '_, Self::ViewQuery>,
world: &'w World,
) -> Result<(), NodeRunError> {
let cache = world.resource::<PipelineCache>();
let Some(pipeline) = cache.get_render_pipeline(id.0) else {
let settings = world.resource::<ShaftPipeline>();
let multisampled = (depth.texture.sample_count() > 1) as usize;
let (Some(pipeline), Some(mask_pipeline)) = (
cache.get_render_pipeline(id.0),
cache.get_render_pipeline(settings.masks[multisampled]),
) else {
return Ok(());
};
if !depth
@@ -268,24 +372,45 @@ impl ViewNode for ShaftNode {
{
return Ok(());
}
let settings = world.resource::<ShaftPipeline>();
let device = context.render_device();
let mut uniform = UniformBuffer::from(*shaft);
uniform.write_buffer(device, world.resource::<RenderQueue>());
let mask_bind = device.create_bind_group(
"post_sun_shafts_mask",
&cache.get_bind_group_layout(&settings.mask_layouts[multisampled]),
&BindGroupEntries::single(depth.view()),
);
let out = target.post_process_write();
let bind = device.create_bind_group(
"post_sun_shafts",
&cache.get_bind_group_layout(
&settings.layouts[(depth.texture.sample_count() > 1) as usize],
),
&cache.get_bind_group_layout(&settings.layout),
&BindGroupEntries::sequential((
out.source,
&settings.sampler,
depth.view(),
uniform.binding().unwrap(),
&mask.0.default_view,
uniform.0.as_entire_binding(),
)),
);
let diagnostics = context.diagnostic_recorder();
{
let mut pass = context
.command_encoder()
.begin_render_pass(&RenderPassDescriptor {
label: Some("post_sun_shafts_mask"),
color_attachments: &[Some(RenderPassColorAttachment {
view: &mask.0.default_view,
depth_slice: None,
resolve_target: None,
ops: Operations::default(),
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
let span = diagnostics.pass_span(&mut pass, "post_sun_shafts_mask");
pass.set_pipeline(mask_pipeline);
pass.set_bind_group(0, &mask_bind, &[]);
pass.draw(0..3, 0..1);
span.end(&mut pass);
}
let mut pass = context
.command_encoder()
.begin_render_pass(&RenderPassDescriptor {
+28 -21
View File
@@ -1,14 +1,33 @@
// MONKEY (post): screen-space radial blur through sky pixels in the scene depth.
// MONKEY (polish): two passes. `SHAFT_MASK` reads the depth once per half-resolution texel into
// a sky mask; the blur then samples that mask 28 times instead of the full-size depth.
#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput
#ifdef SHAFT_MASK
#ifdef MULTISAMPLED
@group(0) @binding(0) var depth: texture_depth_multisampled_2d;
#else
@group(0) @binding(0) var depth: texture_depth_2d;
#endif
@fragment
fn fs_mask(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
let dims = vec2<i32>(textureDimensions(depth));
let p = min(vec2<i32>(in.position.xy) * 2, dims - vec2<i32>(1));
// MONKEY (fix-post): one sample, not a max over all of them: the march is a blur.
// Only the cleared / sky-pinned depth (exactly 0 on infinite reverse-Z) is sky; a ramp on
// `near / z` would move with the live `nearclip` cvar and pour shafts through far terrain.
let sky = select(0.0, 1.0, textureLoad(depth, p, 0) <= 1.0e-7);
return vec4(sky, 0.0, 0.0, 1.0);
}
#else
@group(0) @binding(0) var scene: texture_2d<f32>;
@group(0) @binding(1) var scene_sampler: sampler;
#ifdef MULTISAMPLED
@group(0) @binding(2) var depth: texture_depth_multisampled_2d;
#else
@group(0) @binding(2) var depth: texture_depth_2d;
#endif
@group(0) @binding(2) var mask: texture_2d<f32>;
struct Shafts {
sun: vec4<f32>,
@@ -16,18 +35,6 @@ struct Shafts {
};
@group(0) @binding(3) var<uniform> shafts: Shafts;
fn scene_depth(uv: vec2<f32>) -> f32 {
let dims = textureDimensions(depth);
let p = vec2<i32>(clamp(uv, vec2(0.0), vec2(0.999999)) * vec2<f32>(dims));
#ifdef MULTISAMPLED
// MONKEY (fix-post): one sample, not a max over all of them: the march is a blur, and the
// per-sample loop cost 28 x N loads per pixel (224 at 8x).
return textureLoad(depth, p, 0);
#else
return textureLoad(depth, p, 0);
#endif
}
@fragment
fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
let base = textureSample(scene, scene_sampler, in.uv);
@@ -41,10 +48,8 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
if (i >= count) { break; }
uv += step_uv;
decay *= 0.965;
// MONKEY (fix-post): only the cleared / sky-pinned depth (exactly 0 on infinite reverse-Z)
// is sky. The old ramp read d = near / z, so it moved with the live `nearclip` cvar and
// let shafts pour through terrain beyond ~125 yd at nearclip 0.01.
let sky = select(0.0, 1.0, scene_depth(uv) <= 1.0e-7);
// The clamp-to-edge bilinear tap is the old clamped depth read, pre-thresholded.
let sky = textureSampleLevel(mask, scene_sampler, uv, 0.0).r;
light += sky * decay;
norm += decay;
}
@@ -56,3 +61,5 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
let rays = light / max(norm, 0.0001) * radial * radial;
return vec4(base.rgb + shafts.color.rgb * rays * shafts.sun.z * 0.16, base.a);
}
#endif
+14 -5
View File
@@ -12,7 +12,7 @@ use bevy::pbr::{
};
use bevy::prelude::*;
use bevy::render::render_resource::{
AsBindGroup, Extent3d, RenderPipelineDescriptor, SpecializedMeshPipelineError,
AsBindGroup, Buffer, Extent3d, RenderPipelineDescriptor, SpecializedMeshPipelineError,
TextureDimension, TextureFormat,
};
use bevy::shader::ShaderRef;
@@ -35,13 +35,18 @@ pub struct CloudExt {
#[texture(100)]
#[sampler(101)]
pub(crate) texels: Handle<Image>,
/// MONKEY (sky): `x` the sky tier (detail at 2), `y` the sky clock, `zw` the unit direction
/// toward the glow body on the sheet ([`update_cloud_fx`]).
/// MONKEY (sky): `x` the sky tier (detail at 2), `y` reserved (0; the clock is
/// [`Self::clock`]), `zw` the unit direction toward the glow body on the sheet
/// ([`update_cloud_fx`]).
#[uniform(102)]
pub(crate) fx: Vec4,
/// MONKEY (sky): the Light.dbc cloud sun colour (`rgb`, gamma) and the march strength (`w`).
#[uniform(102)]
pub(crate) lit: Vec4,
/// MONKEY (polish): the shared sky clock ([`crate::sky_fx::SkyClockBuffer`]), outside the
/// uniform so the drift never rewrites the material.
#[storage(103, read_only, buffer)]
pub(crate) clock: Buffer,
}
/// MONKEY (sky): the pipeline key: High compiles `SKY_FX_CLOUDS` into `cloud.wgsl`; below High
@@ -159,7 +164,11 @@ pub(super) fn setup_cloud_layer(
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<CloudMaterial>>,
mut images: ResMut<Assets<Image>>,
clock: Option<Res<crate::sky_fx::SkyClockBuffer>>,
) {
let Some(clock) = clock else {
return;
};
let image = images.add(Image::new(
Extent3d {
width: COLS as u32,
@@ -186,6 +195,7 @@ pub(super) fn setup_cloud_layer(
// MONKEY (sky): Classic until `update_cloud_fx` reads the tier.
fx: Vec4::ZERO,
lit: Vec4::ZERO,
clock: clock.0.clone(),
},
});
commands.spawn((
@@ -251,7 +261,6 @@ pub(super) fn follow_cloud_dome(
pub(super) fn update_cloud_fx(
light: Res<crate::lighting::WowLighting>,
quality: Res<crate::sky_fx::SkyQuality>,
clock: Res<crate::sky_fx::SkyClock>,
layer: Option<Res<CloudLayer>>,
mut materials: ResMut<Assets<CloudMaterial>>,
) {
@@ -268,7 +277,7 @@ pub(super) fn update_cloud_fx(
let q = |v: f32| benilla_assets::quantize(v, 4096.0);
let sun = light.cloud_colors[0];
(
Vec4::new(f32::from(quality.0), clock.secs, q(d.x), q(d.y)),
Vec4::new(f32::from(quality.0), 0.0, q(d.x), q(d.y)),
Vec4::new(q(sun[0]), q(sun[1]), q(sun[2]), q(strength.clamp(0.0, 1.0))),
)
} else {
+5 -3
View File
@@ -14,7 +14,7 @@
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var cloud_tex: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(101) var cloud_samp: sampler;
// MONKEY (sky): `fx.x` the sky tier, `.y` the sky clock (s), `.zw` the unit direction toward the
// MONKEY (sky): `fx.x` the sky tier, `.y` reserved, `.zw` the unit direction toward the
// glow body on the sheet (u = world x, v = world z); `lit.rgb` the Light.dbc cloud sun colour
// (gamma), `.w` the march strength (the body's flatness × the glow envelope).
struct CloudFx {
@@ -22,6 +22,8 @@ struct CloudFx {
lit: vec4<f32>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var<uniform> cfx: CloudFx;
// MONKEY (polish): x = the sky clock (s, wrapped at a day), written per frame outside the material.
@group(#{MATERIAL_BIND_GROUP}) @binding(103) var<storage, read> sky_clock: vec4<f32>;
#ifdef SKY_FX_CLOUDS
// MONKEY (sky): coverage with detail at a sheet point; `oct` detail octaves.
@@ -30,7 +32,7 @@ fn detailed_cover(uv: vec2<f32>, oct: i32) -> f32 {
if (a <= 0.0) {
return 0.0;
}
return sky_fx::cloud_erode(a, sky_fx::cloud_detail(uv, cfx.fx.y, oct));
return sky_fx::cloud_erode(a, sky_fx::cloud_detail(uv, sky_clock.x, oct));
}
#endif
@@ -43,7 +45,7 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// kernel's texels as they are.
#ifdef SKY_FX_CLOUDS
if (cfx.fx.x >= 1.5) {
let n = sky_fx::cloud_detail(in.uv, cfx.fx.y, 4);
let n = sky_fx::cloud_detail(in.uv, sky_clock.x, 4);
a = sky_fx::cloud_erode(a, n);
// A painterly tone inside the mass from the same detail.
rgb = rgb * (0.93 + 0.14 * n);
+4 -2
View File
@@ -21,7 +21,7 @@ struct SkyColors {
sky4: vec4<f32>, // 1.8°
fog: vec4<f32>, // horizon (0°) and below: LightIntBand row 7
warp: vec4<f32>, // x = dawn/dusk warp strength S (0 = off), y = sun azimuth (rad), zw reserved
// MONKEY (sky): x = skyQuality (0 Classic), y = sky clock (s), z = night-sky alpha, w = glow
// MONKEY (sky): x = skyQuality (0 Classic), y = reserved, z = night-sky alpha, w = glow
// strength.
fx: vec4<f32>,
// MONKEY (sky): xyz = camera to the visible sun, w unused.
@@ -34,6 +34,8 @@ struct SkyColors {
mf_fog_d: vec4<f32>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var<uniform> sky: SkyColors;
// MONKEY (polish): x = the sky clock (s, wrapped at a day), written per frame outside the material.
@group(#{MATERIAL_BIND_GROUP}) @binding(101) var<storage, read> sky_clock: vec4<f32>;
// Glow `g` for a sun-relative azimuth phase, sampled linearly like `0x6d0f50` from the reference's
// six-keyframe wrap-around table at `0xce9af8` (written by `0x6ce210`); 0.125 is the sun bearing.
@@ -137,7 +139,7 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let px = length(fwidth(dir)) * sky_fx::STAR_GRID * 0.5;
if (night > 0.0 && dir.y > 0.0) {
let band = sky_fx::galaxy_band(dir);
let stars = sky_fx::star_field(dir, sky.fx.y, max(px, 1e-4), band)
let stars = sky_fx::star_field(dir, sky_clock.x, max(px, 1e-4), band)
* smoothstep(0.0, 0.2, dir.y);
let milky = sky_fx::milky_way(dir, band) * 0.05 * smoothstep(0.03, 0.35, dir.y);
lin += (stars * 0.85 + milky) * night;
+27 -8
View File
@@ -54,14 +54,18 @@ fn hash_cell(p: vec2<i32>, salt: u32) -> f32 {
// Value noise with the quintic fade: the cubic's second-derivative seam shows as square plateaus
// on a slowly drifting sky (the WarcraftXL note).
fn value_noise_plane(p: vec2<f32>, salt: u32) -> f32 {
// MONKEY (polish): the lattice repeats every `period` cells, so a shift by `period` is no change.
fn value_noise_plane(p: vec2<f32>, salt: u32, period: i32) -> f32 {
let i = vec2<i32>(floor(p));
let f = fract(p);
let u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
let a = hash_cell(i, salt);
let b = hash_cell(i + vec2<i32>(1, 0), salt);
let c = hash_cell(i + vec2<i32>(0, 1), salt);
let d = hash_cell(i + vec2<i32>(1, 1), salt);
let per = vec2<i32>(period);
let lo = ((i % per) + per) % per;
let hi = (lo + vec2<i32>(1)) % per;
let a = hash_cell(lo, salt);
let b = hash_cell(vec2<i32>(hi.x, lo.y), salt);
let c = hash_cell(vec2<i32>(lo.x, hi.y), salt);
let d = hash_cell(hi, salt);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
@@ -244,6 +248,15 @@ fn milky_way(dir: vec3<f32>, band: f32) -> vec3<f32> {
// ---- S3: cloud detail -------------------------------------------------------------------------
// MONKEY (polish): the detail field loops over the sky clock's wrap. It tiles every `CLOUD_TILE`
// cells of the detail plane (octave o every `CLOUD_TILE << o` of its own lattice; the half-rate
// warp every `CLOUD_TILE / 2`), and the drift covers whole tiles per wrap, so the field at
// `SKY_WRAP` is the field at 0. One tile is 64 / 36 of the sheet, wider than the dome's UV.
const CLOUD_TILE: i32 = 64;
// Tiles of drift per wrap on u and v: 0.01111 and 0.00444 cells/s, WarcraftXL's 0.011 and 0.0043
// rounded to whole tiles.
const CLOUD_DRIFT_TILES: vec2<f32> = vec2<f32>(15.0, 6.0);
// WarcraftXL's billow blend: fold each octave toward rounded puffs by `cotton` (0.6).
fn billow(v: f32) -> f32 {
return v + (1.0 - abs(2.0 * v - 1.0) - v) * 0.6;
@@ -256,7 +269,7 @@ fn cloud_billow(p: vec2<f32>, octaves: i32) -> f32 {
var tot = 0.0;
var q = p;
for (var o = 0; o < octaves; o++) {
s += a * billow(value_noise_plane(q, u32(o) * 57u));
s += a * billow(value_noise_plane(q, u32(o) * 57u, CLOUD_TILE << u32(o)));
tot += a;
a *= 0.55;
q = q * 2.0 + vec2<f32>(3.7, 1.9);
@@ -267,8 +280,14 @@ fn cloud_billow(p: vec2<f32>, octaves: i32) -> f32 {
// The detail field in [0, 1] on the sheet: a broad warp twists the domain before the billowed fbm
// is read, which turns thresholded blobs into cauliflower edges.
fn cloud_detail(uv: vec2<f32>, t: f32, octaves: i32) -> f32 {
let p = uv * 36.0 + vec2<f32>(0.011, 0.0043) * t;
let w = vec2<f32>(value_noise_plane(p * 0.5, 173u), value_noise_plane(p * 0.5 + 5.3, 219u)) - 0.5;
// The drift modulo one tile: seamless at the wrap, and small enough to keep f32 precision.
let drift = fract(CLOUD_DRIFT_TILES * (t / SKY_WRAP)) * f32(CLOUD_TILE);
let p = uv * 36.0 + drift;
let half_tile = CLOUD_TILE / 2;
let w = vec2<f32>(
value_noise_plane(p * 0.5, 173u, half_tile),
value_noise_plane(p * 0.5 + 5.3, 219u, half_tile),
) - 0.5;
return cloud_billow(p + w * 2.6, octaves);
}
+15 -7
View File
@@ -17,7 +17,7 @@ use bevy::pbr::{
};
use bevy::prelude::*;
use bevy::render::render_resource::{
AsBindGroup, RenderPipelineDescriptor, SpecializedMeshPipelineError,
AsBindGroup, Buffer, RenderPipelineDescriptor, SpecializedMeshPipelineError,
};
use bevy::shader::ShaderRef;
@@ -57,7 +57,8 @@ pub struct SkyExt {
#[uniform(100)]
pub(crate) warp: Vec4,
/// MONKEY (sky): `x` the [`crate::sky_fx::SkyQuality`] tier (0 = Classic, the fields below
/// unused), `y` the sky clock in seconds, `z` the night-sky alpha, `w` the sun-glow strength.
/// unused), `y` reserved (0; the clock is [`Self::clock`]), `z` the night-sky alpha, `w` the
/// sun-glow strength.
#[uniform(100)]
pub(crate) fx: Vec4,
/// MONKEY (sky): camera to the visible sun (`xyz`).
@@ -73,6 +74,10 @@ pub struct SkyExt {
pub(crate) mf_fog_c: Vec4,
#[uniform(100)]
pub(crate) mf_fog_d: Vec4,
/// MONKEY (polish): the shared sky clock ([`crate::sky_fx::SkyClockBuffer`]), outside the
/// uniform so the clock never rewrites the material.
#[storage(101, read_only, buffer)]
pub(crate) clock: Buffer,
}
/// MONKEY (sky): the pipeline key: Enhanced/High compile `SKY_FX` into `sky.wgsl`; Classic keeps
@@ -206,7 +211,11 @@ fn setup_sky(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<SkyMaterial>>,
clock: Option<Res<crate::sky_fx::SkyClockBuffer>>,
) {
let Some(clock) = clock else {
return;
};
let mesh = meshes.add(dome_mesh());
// Seeded with the neutral-day fallback; `update_sky_colors` overwrites from Light.dbc each frame.
let material = materials.add(SkyMaterial {
@@ -231,6 +240,7 @@ fn setup_sky(
mf_fog_b: Vec4::ZERO,
mf_fog_c: Vec4::ZERO,
mf_fog_d: Vec4::ZERO,
clock: clock.0.clone(),
},
});
commands.spawn((
@@ -293,9 +303,8 @@ fn apply_sky_visibility(
#[allow(clippy::too_many_arguments)]
fn update_sky_colors(
light: Res<WowLighting>,
// MONKEY (sky): the tier, the sky clock and the cloud cover over the sun.
// MONKEY (sky): the tier and the cloud cover over the sun.
quality: Res<crate::sky_fx::SkyQuality>,
clock: Res<crate::sky_fx::SkyClock>,
clouds: Res<crate::clouds::CloudCoverage>,
// MONKEY (fog): the Modern fog rows for the shared horizon colour.
monkey: Res<crate::lighting::MonkeyFrame>,
@@ -326,7 +335,7 @@ fn update_sky_colors(
0.0,
);
// MONKEY (sky): the Enhanced/High inputs; all zero at Classic so the write gate stays quiet.
let (fx, sun_v, glow) = sky_fx_inputs(&light, *quality, clock.secs, &clouds);
let (fx, sun_v, glow) = sky_fx_inputs(&light, *quality, &clouds);
// MONKEY (fog): rows B-D as packed for the light buffer; clock lanes are not read here.
let rows = monkey.pack(0.0, 0.0);
let mf_fog = [
@@ -374,7 +383,6 @@ fn update_sky_colors(
fn sky_fx_inputs(
light: &WowLighting,
quality: crate::sky_fx::SkyQuality,
secs: f32,
clouds: &crate::clouds::CloudCoverage,
) -> (Vec4, Vec4, Vec4) {
if !quality.enhanced() {
@@ -393,7 +401,7 @@ fn sky_fx_inputs(
// Quantized like every other lane here, so an idle frame writes nothing.
let q = |v: f32| benilla_assets::quantize(v, 4096.0);
(
Vec4::new(f32::from(quality.0), secs, q(night), q(strength)),
Vec4::new(f32::from(quality.0), 0.0, q(night), q(strength)),
Vec4::new(q(sun.x), q(sun.y), q(sun.z), 0.0),
Vec4::new(q(glow.x), q(glow.y), q(glow.z), 0.0),
)
+101 -3
View File
@@ -9,6 +9,12 @@
//! Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-retail-clouds, Clouds.cpp, Clouds.hpp.
use bevy::prelude::*;
use bevy::render::{
extract_resource::{ExtractResource, ExtractResourcePlugin},
render_resource::{Buffer, BufferDescriptor, BufferUsages},
renderer::{RenderDevice, RenderQueue},
Render, RenderApp, RenderSystems,
};
/// The sky tier: 0 Classic, 1 Enhanced, 2 High. Written by the app from the `skyQuality` cvar;
/// `$WOW_SKY_QUALITY` pins it for the session (captures).
@@ -41,8 +47,8 @@ impl SkyQuality {
///
/// MONKEY (fix-sky): it wrapped hourly in f32, and every star's twinkle jumped at the wrap. Now it
/// accumulates in f64 and wraps at [`SKY_CLOCK_WRAP_S`]; the shader's twinkle rates are whole
/// cycles per wrap (seamless). The High cloud-detail drift still re-patterns at the wrap, once
/// per 24 h of continuous play.
/// cycles per wrap (seamless). MONKEY (polish): the High cloud detail tiles and drifts whole
/// tiles per wrap (`CLOUD_TILE`, `CLOUD_DRIFT_TILES`), so it is seamless there too.
#[derive(Resource, Default)]
pub struct SkyClock {
pub secs: f32,
@@ -53,6 +59,25 @@ pub struct SkyClock {
/// The sky clock's wrap in seconds; `sky_fx.wgsl`'s `SKY_WRAP` must match.
pub const SKY_CLOCK_WRAP_S: f64 = 86_400.0;
/// MONKEY (polish): the sky clock on the GPU, one `vec4` (`x` = [`SkyClock::secs`]) that the sky
/// and cloud materials bind read-only. It is rewritten in place each frame in the render world,
/// so the materials re-prepare only when a real input moves, not to advance the clock. Bevy's
/// `globals.time` is not used: it wraps hourly, and the twinkle rates are whole cycles per day.
#[derive(Resource, Clone, ExtractResource)]
pub struct SkyClockBuffer(pub Buffer);
/// The clock value the render world uploads.
#[derive(Resource, Clone, Copy)]
struct SkyClockSecs(f32);
impl ExtractResource for SkyClockSecs {
type Source = SkyClock;
fn extract_resource(source: &SkyClock) -> Self {
Self(source.secs)
}
}
/// How strongly the glow shows: a broad halo at `GLOW_GAIN` × the sun colour at the sun itself.
pub(crate) const GLOW_GAIN: f32 = 0.22;
@@ -92,10 +117,50 @@ impl Plugin for SkyFxPlugin {
let quality = SkyQuality(SkyQuality::env_override().unwrap_or(0));
app.insert_resource(quality)
// MONKEY (integration): the clock ticks before its readers (sky colours, cloud FX).
.add_systems(Update, tick_sky_clock.before(crate::lighting::LightingConsumeSet));
.add_systems(Update, tick_sky_clock.before(crate::lighting::LightingConsumeSet))
// Beside the shared light buffer, ahead of the sky and cloud materials built after it.
.add_systems(
Startup,
init_sky_clock_buffer.in_set(benilla_assets::AssetSet::Open),
)
.add_plugins((
ExtractResourcePlugin::<SkyClockBuffer>::default(),
ExtractResourcePlugin::<SkyClockSecs>::default(),
));
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
render_app.add_systems(
Render,
upload_sky_clock.in_set(RenderSystems::PrepareResources),
);
}
}
}
/// A headless build has no device, and then no sky or cloud dome either.
fn init_sky_clock_buffer(mut commands: Commands, device: Option<Res<RenderDevice>>) {
let Some(device) = device else {
return;
};
commands.insert_resource(SkyClockBuffer(device.create_buffer(&BufferDescriptor {
label: Some("sky_clock"),
size: 16,
usage: BufferUsages::STORAGE | BufferUsages::COPY_DST,
mapped_at_creation: false,
})));
}
/// Render-world: the clock into the shared buffer before any sky draw reads it.
fn upload_sky_clock(
queue: Res<RenderQueue>,
buffer: Option<Res<SkyClockBuffer>>,
secs: Option<Res<SkyClockSecs>>,
) {
let (Some(buffer), Some(secs)) = (buffer, secs) else {
return;
};
queue.write_buffer(&buffer.0, 0, bytemuck::cast_slice(&[secs.0, 0.0, 0.0, 0.0]));
}
fn tick_sky_clock(time: Res<Time>, mut clock: ResMut<SkyClock>) {
if let Some(t) = clock.frozen {
if clock.secs != t {
@@ -111,6 +176,39 @@ fn tick_sky_clock(time: Res<Time>, mut clock: ResMut<SkyClock>) {
mod tests {
use super::*;
/// A `const NAME ... = <value>;` line of `sky_fx.wgsl`, as text.
fn wgsl_const(name: &str) -> String {
let src = include_str!("shaders/sky_fx.wgsl");
let line = src
.lines()
.find(|l| l.starts_with(&format!("const {name}:")))
.unwrap_or_else(|| panic!("no const {name}"));
let value = line.split('=').nth(1).unwrap().trim();
value.trim_end_matches(';').trim().to_string()
}
#[test]
fn the_shader_wrap_and_the_cloud_loop_match_the_clock() {
let wrap: f64 = wgsl_const("SKY_WRAP").parse().unwrap();
assert_eq!(wrap, SKY_CLOCK_WRAP_S);
// The loop needs an even tile (the half-rate warp tiles at half of it) and whole tiles of
// drift per wrap; the rates stay within 5% of WarcraftXL's 0.011 and 0.0043 cells/s.
let tile: i32 = wgsl_const("CLOUD_TILE").parse().unwrap();
assert!(tile > 0 && tile % 2 == 0);
let drift = wgsl_const("CLOUD_DRIFT_TILES");
let tiles: Vec<f64> = drift
.trim_start_matches("vec2<f32>(")
.trim_end_matches(')')
.split(',')
.map(|v| v.trim().parse().unwrap())
.collect();
for (n, rate) in tiles.iter().zip([0.011, 0.0043]) {
assert_eq!(n.fract(), 0.0, "whole tiles per wrap");
let actual = n * f64::from(tile) / SKY_CLOCK_WRAP_S;
assert!((actual / rate - 1.0).abs() < 0.05, "drift {actual} vs {rate}");
}
}
#[test]
fn glow_fades_out_below_the_horizon_and_under_cloud() {
assert_eq!(glow_strength(-0.2, 0.0, 0.0), 0.0);