Files
benilla-octo/crates/benilla-world/src/clouds/layer.rs
T

315 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;
use bevy::image::Image;
use bevy::mesh::{Indices, MeshVertexBufferLayoutRef, PrimitiveTopology};
use bevy::pbr::{
ExtendedMaterial, MaterialExtension, MaterialExtensionKey, MaterialExtensionPipeline,
};
use bevy::prelude::*;
use bevy::render::render_resource::{
AsBindGroup, Extent3d, RenderPipelineDescriptor, SpecializedMeshPipelineError,
TextureDimension, TextureFormat,
};
use bevy::shader::ShaderRef;
use crate::dev_state::DebugState;
use crate::sky_order::{sky_pipeline_state, SKY_VERTEX_SHADER};
use crate::view::WorldCamera;
use super::kernel::COLS;
/// The cloud dome material: the kernel's colored texels, blended premultiplied in gamma.
pub type CloudMaterial = ExtendedMaterial<StandardMaterial, CloudExt>;
/// 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)]
#[bind_group_data(CloudFxKey)]
pub struct CloudExt {
/// The colored tile, alpha the coverage, re-uploaded each regen (`0x58ac70`).
#[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`]).
#[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 (sky): the pipeline key: High compiles `SKY_FX_CLOUDS` into `cloud.wgsl`; below High
/// the reference's code runs with nothing added.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct CloudFxKey {
high: bool,
}
impl From<&CloudExt> for CloudFxKey {
fn from(e: &CloudExt) -> Self {
Self { high: e.fx.x >= 1.5 }
}
}
impl MaterialExtension for CloudExt {
/// The shared sky vertex stage, which pins every sky vertex to the far depth.
fn vertex_shader() -> ShaderRef {
SKY_VERTEX_SHADER.into()
}
fn fragment_shader() -> ShaderRef {
"embedded://benilla_world/shaders/cloud.wgsl".into()
}
fn specialize(
_pipeline: &MaterialExtensionPipeline,
descriptor: &mut RenderPipelineDescriptor,
_layout: &MeshVertexBufferLayoutRef,
key: MaterialExtensionKey<Self>,
) -> Result<(), SpecializedMeshPipelineError> {
// MONKEY (sky): the High cloud branch.
if key.bind_group_data.high {
if let Some(fragment) = descriptor.fragment.as_mut() {
fragment.shader_defs.push("SKY_FX_CLOUDS".into());
}
}
sky_pipeline_state(descriptor);
Ok(())
}
}
#[derive(Component)]
pub(super) struct CloudDome;
/// 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-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,
];
/// Per-ring vertex alpha (`0x8115a0`): opaque inner 9 rings, half ring 10, transparent rim.
#[rustfmt::skip]
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`).
const AZ_STEPS: usize = 16;
/// 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);
let mut normals = Vec::with_capacity(12 * AZ_STEPS);
let mut uvs = Vec::with_capacity(12 * AZ_STEPS);
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; // 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();
// 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 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]);
}
}
// The reference's 11 band strips (34 indices each), as a triangle list.
let mut indices = Vec::with_capacity(11 * AZ_STEPS * 6);
for ring in 0..11u32 {
for j in 0..AZ_STEPS as u32 {
let jn = (j + 1) % AZ_STEPS as u32;
let a = ring * AZ_STEPS as u32 + j;
let b = (ring + 1) * AZ_STEPS as u32 + j;
let c = ring * AZ_STEPS as u32 + jn;
let d = (ring + 1) * AZ_STEPS as u32 + jn;
indices.extend_from_slice(&[a, b, c, c, b, d]);
}
}
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
mesh.insert_indices(Indices::U32(indices));
mesh
}
pub(super) fn setup_cloud_layer(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<CloudMaterial>>,
mut images: ResMut<Assets<Image>>,
) {
let image = images.add(Image::new(
Extent3d {
width: COLS as u32,
height: COLS as u32,
depth_or_array_layers: 1,
},
TextureDimension::D2,
vec![0; COLS * COLS * 4], // fully transparent until the field primes
// Not sRGB: the texels are gamma bytes and must sample raw.
TextureFormat::Rgba8Unorm,
RenderAssetUsages::default(),
));
let material = materials.add(CloudMaterial {
base: StandardMaterial {
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.
depth_bias: crate::sky_order::CLOUDS_BIAS,
..default()
},
extension: CloudExt {
texels: image.clone(),
// MONKEY (sky): Classic until `update_cloud_fx` reads the tier.
fx: Vec4::ZERO,
lit: Vec4::ZERO,
},
});
commands.spawn((
Mesh3d(meshes.add(cloud_dome_mesh())),
MeshMaterial3d(material.clone()),
Transform::default(),
CloudDome,
));
commands.insert_resource(CloudLayer { image, material });
}
/// 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>,
underwater: Res<crate::liquid::Underwater>,
mut dome: Query<&mut Visibility, With<CloudDome>>,
) {
if let Ok(mut vis) = dome.single_mut() {
*vis =
if debug.lighting.disable_sky_dome || skybox.replaces_celestial() || underwater.0.any()
{
Visibility::Hidden
} else {
Visibility::Inherited
};
}
}
/// 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>>,
mut dome: Query<
(&mut Transform, &mut GlobalTransform),
(With<CloudDome>, Without<WorldCamera>),
>,
) {
let Some((cam_gt, proj)) = cam.iter().next() else {
return;
};
let Ok((mut tf, mut gt)) = dome.single_mut() else {
return;
};
let far = match proj {
Projection::Perspective(p) => p.far,
_ => 3000.0,
};
tf.translation = cam_gt.translation();
tf.rotation = Quat::IDENTITY;
tf.scale = Vec3::splat(far * 0.87);
*gt = GlobalTransform::from(*tf);
}
/// MONKEY (sky): feeds the High cloud shading.
/// Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-retail-clouds, Clouds.cpp.
/// The march walks the sheet toward the glow body
/// (sun by day, moon by night), scaled by how far the body sits off the zenith (a noon sun has no
/// direction on the sheet worth marching, as in WarcraftXL) and by the kernel's own glow envelope.
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>>,
) {
let Some(layer) = layer else {
return;
};
let (fx, lit) = if quality.high() {
let body = light.cloud_glow_dir;
let flat = Vec2::new(body.x, body.z);
let len = flat.length();
let d = if len > 1e-3 { flat / len } else { Vec2::ZERO };
let strength =
(len * 1.6).min(1.0) * light.cloud_glow_track * (1.0 - 0.75 * light.storm_bcc);
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(q(sun[0]), q(sun[1]), q(sun[2]), q(strength.clamp(0.0, 1.0))),
)
} else {
(Vec4::ZERO, Vec4::ZERO)
};
benilla_assets::write_gated(
&mut materials,
&layer.material,
|m| m.extension.fx != fx || m.extension.lit != lit,
|m| {
m.extension.fx = fx;
m.extension.lit = lit;
},
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dome_matches_the_reference_build() {
let mesh = cloud_dome_mesh();
assert_eq!(mesh.count_vertices(), 192);
let Some(Indices::U32(idx)) = mesh.indices() else {
panic!("u32 indices")
};
assert_eq!(idx.len(), 11 * AZ_STEPS * 6); // 11 strips × 32 tris
let pos = mesh
.attribute(Mesh::ATTRIBUTE_POSITION)
.unwrap()
.as_float3()
.unwrap();
// 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}");
}
assert!((pos[0][1] - 1.0).abs() < 1e-6);
let rim = pos[11 * AZ_STEPS][1];
assert!(rim.abs() < 1e-6, "rim y {rim}");
}
}