315 lines
12 KiB
Rust
315 lines
12 KiB
Rust
//! 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}");
|
||
}
|
||
}
|