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 bloodstain::stain::{StainShape, rasterise};
use bloodstain::{Pool, Stain};
const MASK_SIZE: u32 = 64;
pub const MAX_MASKS: usize = 32;
#[derive(Debug, Clone)]
pub struct StainMask {
pub key: u32,
pub image: Handle<Image>,
pub material: Handle<ForwardDecalMaterial<StandardMaterial>>,
}
#[derive(Resource, Debug, Default)]
pub struct StainMasks {
masks: Vec<StainMask>,
epoch: u32,
}
pub fn mask_key(shape: &StainShape) -> u32 {
let aspect = if shape.major > 0.0 { (shape.minor / shape.major).clamp(0.0, 1.0) } else { 1.0 };
let a = (aspect * 31.0).round() as u32;
let sector = {
let (x, y) = (shape.direction[0], shape.direction[1]);
let ang = y.atan2(x);
let t = (ang + std::f32::consts::PI) / std::f32::consts::TAU;
(t.clamp(0.0, 0.999) * 16.0) as u32
};
a | ((shape.spines as u32) << 5) | ((shape.satellites as u32) << 10) | (sector << 15)
}
impl StainMasks {
pub fn len(&self) -> usize {
self.masks.len()
}
pub fn is_empty(&self) -> bool {
self.masks.is_empty()
}
pub fn material_for(
&mut self,
shape: &StainShape,
images: &mut Assets<Image>,
materials: &mut Assets<ForwardDecalMaterial<StandardMaterial>>,
) -> Handle<ForwardDecalMaterial<StandardMaterial>> {
let key = mask_key(shape);
if let Some(hit) = self.masks.iter().find(|m| m.key == key) {
return hit.material.clone();
}
if self.masks.len() < MAX_MASKS {
let image = images.add(mask_image(shape));
let material = materials.add(ForwardDecalMaterial {
base: StandardMaterial {
base_color_texture: Some(image.clone()),
base_color: Color::srgb(0.30, 0.02, 0.02),
perceptual_roughness: bloodstain::BloodSettings::default().wet_roughness,
alpha_mode: AlphaMode::Blend,
..default()
},
extension: ForwardDecalMaterialExt { depth_fade_factor: 1.0 },
});
self.masks.push(StainMask { key, image, material: material.clone() });
return material;
}
let n = self.masks.len() as u32;
let index = bloodstain::pick(self.epoch, key, n, 2) as usize;
self.epoch = self.epoch.wrapping_add(1);
match self.masks.get(index) {
Some(m) => m.material.clone(),
None => self.masks[0].material.clone(),
}
}
}
pub fn mask_image(shape: &StainShape) -> Image {
let n = MASK_SIZE as usize;
let mut coverage = vec![0u8; n * n];
let _ = rasterise(shape, MASK_SIZE, &mut coverage);
let mut data = vec![0u8; n * n * 4];
for (i, &c) in coverage.iter().enumerate() {
data[i * 4] = 255;
data[i * 4 + 1] = 255;
data[i * 4 + 2] = 255;
data[i * 4 + 3] = c;
}
Image::new(
Extent3d { width: MASK_SIZE, height: MASK_SIZE, depth_or_array_layers: 1 },
TextureDimension::D2,
data,
TextureFormat::Rgba8UnormSrgb,
RenderAssetUsages::default(),
)
}
pub fn spawn_stain(
commands: &mut Commands,
masks: &mut StainMasks,
images: &mut Assets<Image>,
materials: &mut Assets<ForwardDecalMaterial<StandardMaterial>>,
stain: &Stain,
shape: &StainShape,
) -> Entity {
let material = masks.material_for(shape, images, materials);
let at = Vec3::new(stain.at[0], stain.at[1], stain.at[2]);
commands
.spawn((
ForwardDecal,
MeshMaterial3d(material),
Transform::from_translation(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,
masks: &mut StainMasks,
images: &mut Assets<Image>,
materials: &mut Assets<ForwardDecalMaterial<StandardMaterial>>,
index: usize,
pool: &Pool,
shape: &StainShape,
) -> Entity {
let material = masks.material_for(shape, images, materials);
let at = Vec3::new(pool.at[0], pool.at[1], pool.at[2]);
commands
.spawn((
ForwardDecal,
MeshMaterial3d(material),
Transform::from_translation(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::*;
use bloodstain::stain::{Impact, stain_shape};
use bloodstain::BloodSettings;
fn shape_at(deg: f32, speed: f32, seed: u32) -> StainShape {
let s = BloodSettings::default();
stain_shape(
&Impact {
speed,
diameter: 0.004,
angle_rad: deg.to_radians(),
roughness: s.substrate_roughness,
travel: [1.0, 0.0],
},
&s,
seed,
)
}
#[test]
fn a_mask_is_solid_in_the_middle_and_empty_at_the_corners() {
let img = mask_image(&shape_at(90.0, 6.0, 0));
let n = MASK_SIZE as usize;
assert_eq!(img.width(), MASK_SIZE);
assert_eq!(img.height(), MASK_SIZE);
let data = img.data.as_ref().expect("a generated image must carry its pixels");
assert_eq!(data.len(), n * n * 4, "RGBA8 of {MASK_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 stain 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 / 16, "only {opaque} solid pixels — the stain is barely there");
assert!(clear > n * n / 8, "only {clear} clear pixels — the stain fills the whole texture");
}
#[test]
fn a_mask_is_not_a_circle() {
let img = mask_image(&shape_at(90.0, 6.0, 1));
let n = MASK_SIZE as usize;
let data = img.data.as_ref().expect("pixels");
let half = MASK_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 stain at all");
assert!(
hi - lo > 2.0,
"the rim varied by only {:.1} pixels between angles ({lo} to {hi}) — that is a circle, \
not a stain",
hi - lo
);
}
#[test]
fn masks_differ_by_impact_and_repeat_only_when_the_impact_does() {
let shallow = mask_image(&shape_at(15.0, 6.0, 7));
let steep = mask_image(&shape_at(90.0, 6.0, 7));
assert_ne!(shallow.data, steep.data, "a 15° and a 90° impact must not share a mask");
assert_eq!(
mask_image(&shape_at(40.0, 6.0, 3)).data,
mask_image(&shape_at(40.0, 6.0, 3)).data,
"the same silhouette generated differently the second time"
);
}
#[test]
fn the_cache_key_separates_visibly_different_stains() {
assert_ne!(
mask_key(&shape_at(15.0, 6.0, 1)),
mask_key(&shape_at(90.0, 6.0, 1)),
"impact angle must reach the key — it is the aspect ratio"
);
assert_eq!(
mask_key(&shape_at(45.0, 6.0, 1)),
mask_key(&shape_at(45.0, 6.0, 1)),
"the key must be a pure function of the silhouette"
);
}
}