use bevy::asset::{Assets, Handle, RenderAssetUsages};
use bevy::image::Image;
use bevy::log::warn;
use bevy::math::{Vec2, Vec3};
use bevy::mesh::Mesh;
use bevy::prelude::Component;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
use bevy::transform::components::GlobalTransform;
use bloodstain::dry::{DRY_REF_AREA_M2, DRY_REF_TICKS, appearance};
use bloodstain::stain::{StainShape, rasterise};
use crate::digest::Fnv1a;
use crate::settings::WetSettings;
use crate::uv::{Pick, mesh_key, ray_uv};
pub const UV_SPAN_M: f32 = 1.0;
const WARN_MEMO: usize = 16;
#[derive(Component, Debug)]
pub struct WetCanvas {
size: u32,
wet: Vec<(u8, u16)>,
prev: Vec<(u8, u16)>,
mask: Vec<u8>,
albedo_handle: Handle<Image>,
rough_handle: Handle<Image>,
albedo_px: Vec<u8>,
rough_px: Vec<u8>,
base_rgba: [u8; 4],
base_rough: u8,
dirty_since: Option<u32>,
warned: Vec<u64>,
}
impl WetCanvas {
pub fn new(
images: &mut Assets<Image>,
size: u32,
base_srgb: [f32; 3],
base_roughness: f32,
) -> Self {
let size = if size == 0 {
warn!("wetmap: a canvas of size 0 has no texels; using 1");
1
} else {
size
};
let base_rgba = [enc(base_srgb[0]), enc(base_srgb[1]), enc(base_srgb[2]), 255];
let base_rough = enc(base_roughness);
let base_rough_rgba = [0, base_rough, 0, 255];
let extent = Extent3d { width: size, height: size, depth_or_array_layers: 1 };
let usage = RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD;
let albedo = Image::new_fill(
extent,
TextureDimension::D2,
&base_rgba,
TextureFormat::Rgba8UnormSrgb,
usage,
);
let rough = Image::new_fill(
extent,
TextureDimension::D2,
&base_rough_rgba,
TextureFormat::Rgba8Unorm,
usage,
);
let texels = (size as usize) * (size as usize);
Self {
size,
wet: vec![(0, 0); texels],
prev: vec![(0, 0); texels],
mask: Vec::new(),
albedo_handle: images.add(albedo),
rough_handle: images.add(rough),
albedo_px: base_rgba.iter().copied().cycle().take(texels * 4).collect(),
rough_px: base_rough_rgba.iter().copied().cycle().take(texels * 4).collect(),
base_rgba,
base_rough,
dirty_since: None,
warned: Vec::new(),
}
}
pub fn albedo(&self) -> Handle<Image> {
self.albedo_handle.clone()
}
pub fn roughness(&self) -> Handle<Image> {
self.rough_handle.clone()
}
pub fn size(&self) -> u32 {
self.size
}
pub fn amount_at(&self, x: u32, y: u32) -> u8 {
self.at(x, y).map(|c| c.0).unwrap_or(0)
}
pub fn age_at(&self, x: u32, y: u32) -> u16 {
self.at(x, y).map(|c| c.1).unwrap_or(0)
}
pub fn is_dirty(&self) -> bool {
self.dirty_since.is_some()
}
pub fn dirty_since(&self) -> Option<u32> {
self.dirty_since
}
fn at(&self, x: u32, y: u32) -> Option<(u8, u16)> {
if x >= self.size || y >= self.size {
return None;
}
self.wet.get((y as usize) * (self.size as usize) + x as usize).copied()
}
pub fn paint_uv(&mut self, uv: Vec2, shape: &StainShape, tick: u32) {
if !uv.is_finite() {
return;
}
let px = self.mask_px(shape);
let need = (px as usize) * (px as usize);
if self.mask.len() != need {
self.mask.resize(need, 0);
}
if !rasterise(shape, px, &mut self.mask) {
return;
}
let n = self.size as i64;
let cx = ((uv.x.clamp(0.0, 1.0) * self.size as f32) as i64).clamp(0, n - 1);
let cy = ((uv.y.clamp(0.0, 1.0) * self.size as f32) as i64).clamp(0, n - 1);
let px_i = px as i64;
let half = px_i / 2;
let mut touched = false;
for my in 0..px_i {
let y = cy + my - half;
if y < 0 || y >= n {
continue;
}
for mx in 0..px_i {
let x = cx + mx - half;
if x < 0 || x >= n {
continue;
}
let Some(&cov) = self.mask.get((my * px_i + mx) as usize) else {
continue;
};
if cov == 0 {
continue;
}
let Some(cell) = self.wet.get_mut((y * n + x) as usize) else {
continue;
};
cell.0 = cell.0.saturating_add(cov);
cell.1 = 0;
touched = true;
}
}
if touched && self.dirty_since.is_none() {
self.dirty_since = Some(tick);
}
}
pub fn paint_world(
&mut self,
mesh: &Mesh,
xf: &GlobalTransform,
from: Vec3,
dir: Vec3,
shape: &StainShape,
tick: u32,
) -> bool {
let inv = xf.affine().inverse();
let origin = inv.transform_point3(from);
let local_dir = inv.transform_vector3(dir);
match ray_uv(mesh, origin, local_dir) {
Pick::At(uv) => {
self.paint_uv(uv, shape, tick);
true
}
Pick::Miss => false,
Pick::Unusable => {
let key = mesh_key(mesh);
if !self.warned.contains(&key) {
if self.warned.len() >= WARN_MEMO {
self.warned.remove(0);
}
self.warned.push(key);
warn!(
"wetmap: this mesh has no Float32x2 ATTRIBUTE_UV_0 (or is not a triangle \
list), so it cannot carry a wetmap; nothing was painted"
);
}
false
}
}
}
pub fn tick(&mut self, tick: u32, gravity_uv: Vec2, s: &WetSettings) {
self.drip(gravity_uv, s);
self.spread(s);
self.advance(s);
self.shade(tick, s);
}
pub fn flush(&mut self, images: &mut Assets<Image>) -> bool {
if self.dirty_since.is_none() {
return false;
}
let ok = upload(images, &self.albedo_handle, &self.albedo_px)
&& upload(images, &self.rough_handle, &self.rough_px);
if ok {
self.dirty_since = None;
}
ok
}
pub fn digest(&self) -> u64 {
let mut f = Fnv1a::new();
for &(amount, age) in &self.wet {
f.byte(amount);
f.u16(age);
}
f.finish()
}
pub fn wetted_area(&self) -> f32 {
let mut sum: u64 = 0;
for &(amount, _) in &self.wet {
sum += amount as u64;
}
let side = self.size as f32;
(sum as f32 / 255.0) * (UV_SPAN_M * UV_SPAN_M / (side * side))
}
fn mask_px(&self, shape: &StainShape) -> u32 {
let major = if shape.major.is_finite() { shape.major.max(0.0) } else { 0.0 };
let px = (major / UV_SPAN_M * self.size as f32).round();
(px as i64).clamp(1, self.size as i64) as u32
}
fn drip(&mut self, gravity_uv: Vec2, s: &WetSettings) {
let Some((sx, sy)) = dominant_step(gravity_uv) else {
return;
};
let thr = s.drip_threshold();
let span = s.dry_span();
self.prev.copy_from_slice(&self.wet);
let n = self.size as i64;
for y in 0..n {
for x in 0..n {
let Some(&(amount, age)) = self.prev.get((y * n + x) as usize) else {
continue;
};
let leaves = if is_wet(age, span) { amount.saturating_sub(thr) } else { 0 };
let residue = amount - leaves;
let (ax, ay) = (x - sx, y - sy);
let arrival_in_bounds = ax >= 0 && ax < n && ay >= 0 && ay < n;
let (arriving, arriving_age) = match self.prev.get((ay * n + ax) as usize) {
Some(&(aj, agej)) if arrival_in_bounds && is_wet(agej, span) => {
(aj.saturating_sub(thr), agej)
}
_ => (0, 0),
};
let new_amount = (residue as u32 + arriving as u32).min(255) as u8;
let new_age = if new_amount == 0 {
0
} else if residue == 0 {
arriving_age
} else if arriving == 0 {
age
} else {
age.min(arriving_age)
};
if let Some(cell) = self.wet.get_mut((y * n + x) as usize) {
*cell = (new_amount, new_age);
}
}
}
}
fn spread(&mut self, s: &WetSettings) {
let k = s.spread_rate.clamp(0.0, 1.0) * 0.25;
if !k.is_finite() || k <= 0.0 {
return;
}
let span = s.dry_span();
self.prev.copy_from_slice(&self.wet);
let n = self.size as i64;
const NEIGHBOURS: [(i64, i64); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)];
for y in 0..n {
for x in 0..n {
let Some(&(amount, age)) = self.prev.get((y * n + x) as usize) else {
continue;
};
if !is_wet(age, span) {
continue;
}
let mut net: i32 = 0;
let mut youngest = age;
for (dx, dy) in NEIGHBOURS {
let (nx, ny) = (x + dx, y + dy);
if nx < 0 || nx >= n || ny < 0 || ny >= n {
continue;
}
let Some(&(aj, agej)) = self.prev.get((ny * n + nx) as usize) else {
continue;
};
if !is_wet(agej, span) {
continue;
}
let flux = (k * (amount as f32 - aj as f32)).round() as i32;
net -= flux;
if flux < 0 {
youngest = youngest.min(agej);
}
}
let new_amount = (amount as i32 + net).clamp(0, 255) as u8;
if let Some(cell) = self.wet.get_mut((y * n + x) as usize) {
*cell = (new_amount, if new_amount == 0 { 0 } else { youngest });
}
}
}
}
fn advance(&mut self, s: &WetSettings) {
let span = s.dry_span();
let ceiling = span as u16;
for cell in &mut self.wet {
let (amount, age) = *cell;
if amount == 0 {
*cell = (0, 0);
continue;
}
let new_age = age.saturating_add(1).min(ceiling);
if !is_wet(age, span) {
*cell = (amount, new_age);
continue;
}
let taken = s.absorbed_by(new_age as u32).saturating_sub(s.absorbed_by(age as u32));
let new_amount = amount.saturating_sub(taken.min(255) as u8);
*cell = (new_amount, if new_amount == 0 { 0 } else { new_age });
}
}
fn shade(&mut self, tick: u32, s: &WetSettings) {
let blood = s.blood();
let span = s.dry_span();
let base = self.base_rgba;
let base_rough = self.base_rough;
let mut memo_age = u32::MAX;
let mut memo_srgb = [0u8; 3];
let mut memo_rough = 0u8;
let mut changed = false;
let cells = self.wet.iter();
let albedo = self.albedo_px.chunks_exact_mut(4);
let rough = self.rough_px.chunks_exact_mut(4);
for (&(amount, age), (a_px, r_px)) in cells.zip(albedo.zip(rough)) {
let (rgb, rough_byte) = if amount == 0 {
([base[0], base[1], base[2]], base_rough)
} else {
if age as u32 != memo_age {
memo_age = age as u32;
let scaled = ((age as u64 * DRY_REF_TICKS as u64) / span as u64) as u32;
let ap = appearance(scaled, 60, DRY_REF_AREA_M2, &blood);
memo_srgb = [enc(ap.srgb[0]), enc(ap.srgb[1]), enc(ap.srgb[2])];
memo_rough = enc(ap.roughness);
}
(
[
over(base[0], memo_srgb[0], amount),
over(base[1], memo_srgb[1], amount),
over(base[2], memo_srgb[2], amount),
],
over(base_rough, memo_rough, amount),
)
};
if let [r, g, b, _] = a_px
&& (*r != rgb[0] || *g != rgb[1] || *b != rgb[2])
{
*r = rgb[0];
*g = rgb[1];
*b = rgb[2];
changed = true;
}
if let [_, g, _, _] = r_px
&& *g != rough_byte
{
*g = rough_byte;
changed = true;
}
}
if changed && self.dirty_since.is_none() {
self.dirty_since = Some(tick);
}
}
}
#[inline]
fn is_wet(age: u16, span: u32) -> bool {
(age as u32) < span
}
fn dominant_step(g: Vec2) -> Option<(i64, i64)> {
if !g.is_finite() || (g.x == 0.0 && g.y == 0.0) {
return None;
}
if g.x.abs() >= g.y.abs() {
Some((if g.x > 0.0 { 1 } else { -1 }, 0))
} else {
Some((0, if g.y > 0.0 { 1 } else { -1 }))
}
}
#[inline]
fn over(base: u8, blood: u8, cov: u8) -> u8 {
let c = cov as u32;
(((base as u32) * (255 - c) + (blood as u32) * c + 127) / 255) as u8
}
#[inline]
fn enc(v: f32) -> u8 {
let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
(v * 255.0).round() as u8
}
fn upload(images: &mut Assets<Image>, handle: &Handle<Image>, px: &[u8]) -> bool {
let Some(mut image) = images.get_mut(handle) else {
return false;
};
let Some(data) = image.data.as_mut() else {
return false;
};
if data.len() != px.len() {
return false;
}
data.copy_from_slice(px);
true
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::mesh::PrimitiveTopology;
fn canvas(size: u32) -> (Assets<Image>, WetCanvas) {
let mut images = Assets::<Image>::default();
let c = WetCanvas::new(&mut images, size, [0.8, 0.7, 0.65], 0.6);
(images, c)
}
fn blob(major: f32) -> StainShape {
StainShape { major, minor: major, spines: 0, satellites: 0, direction: [1.0, 0.0], seed: 7 }
}
#[test]
fn a_uv_less_mesh_warns_exactly_once() {
let (_images, mut c) = canvas(16);
let mut m = Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::MAIN_WORLD);
m.insert_attribute(
Mesh::ATTRIBUTE_POSITION,
vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
);
let xf = GlobalTransform::default();
for _ in 0..5 {
assert!(!c.paint_world(
&m,
&xf,
Vec3::new(0.2, 0.2, 1.0),
Vec3::new(0.0, 0.0, -1.0),
&blob(0.2),
0
));
}
assert_eq!(c.warned.len(), 1);
}
#[test]
fn the_drip_pass_conserves_every_byte_it_does_not_lose_at_the_border() {
let s = WetSettings::default();
let (_images, mut c) = canvas(8);
for x in 0..8 {
c.wet[x] = (200, 0);
c.wet[8 + x] = (150, 0);
}
let before: u32 = c.wet.iter().map(|t| t.0 as u32).sum();
c.drip(Vec2::new(0.0, 1.0), &s);
let after: u32 = c.wet.iter().map(|t| t.0 as u32).sum();
assert_eq!(before, after, "the drip pass lost or invented coverage");
}
#[test]
fn the_drip_pass_moves_a_parcel_exactly_one_texel() {
let s = WetSettings::default();
let (_images, mut c) = canvas(8);
c.wet[8 * 2 + 3] = (255, 0);
c.drip(Vec2::new(0.0, 1.0), &s);
assert_eq!(c.amount_at(3, 2), s.drip_threshold(), "the source kept the wrong residue");
assert_eq!(c.amount_at(3, 3), 255 - s.drip_threshold(), "the parcel did not land next door");
assert_eq!(c.amount_at(3, 4), 0, "the parcel cascaded — the pass read its own writes");
}
#[test]
fn the_spread_pass_conserves_every_byte() {
let s = WetSettings::default();
let (_images, mut c) = canvas(8);
c.wet[8 * 4 + 4] = (255, 0);
c.wet[8 * 4 + 5] = (90, 0);
let before: u32 = c.wet.iter().map(|t| t.0 as u32).sum();
for _ in 0..20 {
c.spread(&s);
}
let after: u32 = c.wet.iter().map(|t| t.0 as u32).sum();
assert_eq!(before, after, "the antisymmetric flux is not antisymmetric");
}
#[test]
fn the_spread_pass_reaches_only_the_four_neighbourhood_in_one_call() {
let s = WetSettings::default();
let (_images, mut c) = canvas(8);
c.wet[8 * 4 + 4] = (255, 0);
c.spread(&s);
assert!(c.amount_at(5, 4) > 0, "nothing diffused east");
assert_eq!(c.amount_at(6, 4), 0, "diffusion jumped two texels in one pass");
assert_eq!(c.amount_at(5, 5), 0, "diffusion reached a diagonal");
}
#[test]
fn a_dried_canvas_is_a_fixed_point() {
let s = WetSettings { dry_ticks: 12, ..Default::default() };
let (mut images, mut c) = canvas(64);
c.paint_uv(Vec2::new(0.5, 0.2), &blob(0.2), 0);
for t in 0..40 {
c.tick(t, Vec2::new(0.0, 1.0), &s);
}
assert!(c.flush(&mut images), "the run never asked to be uploaded");
let settled = c.digest();
for t in 40..60 {
c.tick(t, Vec2::new(0.0, 1.0), &s);
}
assert_eq!(c.digest(), settled, "a dried canvas still moved");
assert!(!c.is_dirty(), "a dried canvas asked to be uploaded again");
}
}