use bevy::asset::RenderAssetUsages;
use bevy::image::Image;
use bevy::pbr::decal::{ForwardDecal, ForwardDecalMaterial, ForwardDecalMaterialExt};
use bevy::prelude::*;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
use crate::pool::Pool;
use crate::soup::hash_f32;
use crate::spatter::Stain;
pub const SPLAT_VARIANTS: usize = 4;
const SPLAT_SIZE: u32 = 64;
#[derive(Resource, Debug, Clone)]
pub struct SplatTextures {
pub images: Vec<Handle<Image>>,
pub materials: Vec<Handle<ForwardDecalMaterial<StandardMaterial>>>,
}
impl SplatTextures {
pub fn material_for(&self, stain: &Stain) -> Handle<ForwardDecalMaterial<StandardMaterial>> {
let i = (stain.seed as usize) % self.materials.len().max(1);
self.materials[i].clone()
}
}
pub fn splat_image(variant: u32) -> Image {
const LOBES: usize = 8;
let mut lobe_angle = [0.0f32; LOBES];
let mut lobe_reach = [0.0f32; LOBES];
let mut lobe_width = [0.0f32; LOBES];
for k in 0..LOBES {
let key = variant
.wrapping_mul(0x9E37_79B9)
.wrapping_add((k as u32).wrapping_mul(0x85EB_CA6B));
lobe_angle[k] = hash_f32(key) * std::f32::consts::TAU;
lobe_reach[k] = 0.55 + hash_f32(key ^ 0xC2B2_AE35) * 0.60;
lobe_width[k] = 0.08 + hash_f32(key ^ 0x27D4_EB2F) * 0.30;
}
let n = SPLAT_SIZE as usize;
let mut data = vec![0u8; n * n * 4];
let half = SPLAT_SIZE as f32 * 0.5;
for y in 0..n {
for x in 0..n {
let dx = (x as f32 + 0.5 - half) / half;
let dy = (y as f32 + 0.5 - half) / half;
let r = (dx * dx + dy * dy).sqrt();
let theta = dy.atan2(dx);
let mut rim = 0.52f32;
for k in 0..LOBES {
let mut d = (theta - lobe_angle[k]).abs();
if d > std::f32::consts::PI {
d = std::f32::consts::TAU - d;
}
let falloff = (-(d * d) / (2.0 * lobe_width[k] * lobe_width[k])).exp();
rim += lobe_reach[k] * 0.42 * falloff;
}
let alpha = if r >= rim {
0.0
} else {
let t = (1.0 - r / rim).clamp(0.0, 1.0);
(t * t * 3.2).min(1.0)
};
let i = (y * n + x) * 4;
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = (alpha * 255.0).round().clamp(0.0, 255.0) as u8;
}
}
Image::new(
Extent3d { width: SPLAT_SIZE, height: SPLAT_SIZE, depth_or_array_layers: 1 },
TextureDimension::D2,
data,
TextureFormat::Rgba8UnormSrgb,
RenderAssetUsages::default(),
)
}
pub fn build_splats(
mut commands: Commands,
mut images: ResMut<Assets<Image>>,
mut materials: ResMut<Assets<ForwardDecalMaterial<StandardMaterial>>>,
) {
let mut image_handles = Vec::with_capacity(SPLAT_VARIANTS);
let mut material_handles = Vec::with_capacity(SPLAT_VARIANTS);
for variant in 0..SPLAT_VARIANTS as u32 {
let image = images.add(splat_image(variant));
material_handles.push(materials.add(ForwardDecalMaterial {
base: StandardMaterial {
base_color_texture: Some(image.clone()),
base_color: Color::srgb(0.30, 0.02, 0.02),
perceptual_roughness: 0.30,
alpha_mode: AlphaMode::Blend,
..default()
},
extension: ForwardDecalMaterialExt { depth_fade_factor: 1.0 },
}));
image_handles.push(image);
}
commands.insert_resource(SplatTextures { images: image_handles, materials: material_handles });
}
pub fn spawn_stain(
commands: &mut Commands,
splats: &SplatTextures,
stain: &Stain,
) -> Entity {
commands
.spawn((
ForwardDecal,
MeshMaterial3d(splats.material_for(stain)),
Transform::from_translation(stain.at + Vec3::Y * 0.002)
.with_scale(Vec3::splat(stain.radius * 2.0)),
))
.id()
}
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct PoolDecal(pub usize);
pub fn spawn_pool(commands: &mut Commands, splats: &SplatTextures, index: usize, pool: &Pool) -> Entity {
let as_stain = Stain { at: pool.at, radius: pool.radius, seed: pool.seed };
commands
.spawn((
ForwardDecal,
MeshMaterial3d(splats.material_for(&as_stain)),
Transform::from_translation(pool.at + Vec3::Y * 0.0015)
.with_scale(Vec3::splat(pool.radius * 2.0)),
PoolDecal(index),
))
.id()
}
pub fn update_pool_decals<'a>(
pools: &[Pool],
decals: impl Iterator<Item = (&'a PoolDecal, Mut<'a, Transform>)>,
) {
for (tag, mut transform) in decals {
let Some(pool) = pools.get(tag.0) else { continue };
transform.scale = Vec3::splat(pool.radius * 2.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_splat_is_solid_in_the_middle_and_empty_at_the_corners() {
let img = splat_image(0);
let n = SPLAT_SIZE as usize;
assert_eq!(img.width(), SPLAT_SIZE);
assert_eq!(img.height(), SPLAT_SIZE);
let data = img.data.as_ref().expect("a generated image must carry its pixels");
assert_eq!(data.len(), n * n * 4, "RGBA8 of {SPLAT_SIZE} squared");
let alpha_at = |x: usize, y: usize| data[(y * n + x) * 4 + 3];
assert_eq!(alpha_at(n / 2, n / 2), 255, "the centre of a splat must be opaque");
for (x, y) in [(0, 0), (n - 1, 0), (0, n - 1), (n - 1, n - 1)] {
assert_eq!(alpha_at(x, y), 0, "the corner at {x},{y} must be fully transparent");
}
let opaque = data.chunks(4).filter(|p| p[3] > 200).count();
let clear = data.chunks(4).filter(|p| p[3] == 0).count();
assert!(opaque > n * n / 8, "only {opaque} solid pixels — the splat is barely there");
assert!(clear > n * n / 8, "only {clear} clear pixels — the splat fills the whole texture");
}
#[test]
fn a_splat_is_not_a_circle() {
let img = splat_image(1);
let n = SPLAT_SIZE as usize;
let data = img.data.as_ref().expect("pixels");
let half = SPLAT_SIZE as f32 * 0.5;
let mut reach = Vec::new();
for step in 0..16 {
let theta = step as f32 / 16.0 * std::f32::consts::TAU;
let (c, sn) = (theta.cos(), theta.sin());
let mut last = 0.0f32;
for r in 1..(n / 2) {
let x = (half + c * r as f32) as usize;
let y = (half + sn * r as f32) as usize;
if x >= n || y >= n {
break;
}
if data[(y * n + x) * 4 + 3] > 8 {
last = r as f32;
}
}
reach.push(last);
}
let (lo, hi) = reach.iter().fold((f32::MAX, 0.0f32), |(a, b), r| (a.min(*r), b.max(*r)));
assert!(hi > 0.0, "no ray found any splat at all");
assert!(
hi - lo > 3.0,
"the rim varied by only {:.1} pixels between angles ({lo} to {hi}) — that is a circle, \
not a splat",
hi - lo
);
}
#[test]
fn the_variants_are_different_splats() {
let images: Vec<Image> = (0..SPLAT_VARIANTS as u32).map(splat_image).collect();
for i in 0..images.len() {
for j in (i + 1)..images.len() {
assert_ne!(
images[i].data, images[j].data,
"splat variants {i} and {j} are byte-identical"
);
}
}
}
#[test]
fn a_splat_is_the_same_bytes_every_time() {
for variant in 0..SPLAT_VARIANTS as u32 {
assert_eq!(
splat_image(variant).data,
splat_image(variant).data,
"variant {variant} generated differently the second time"
);
}
}
}