use image::{Rgba, RgbaImage};
pub const PRESETS: [&str; 6] =
["grid", "checker", "bricks", "planks", "tiles", "noise"];
pub const SIZE: u32 = 256;
const PALETTE: [[f32; 3]; 8] = [
[0.86, 0.52, 0.24], [0.36, 0.55, 0.78], [0.45, 0.68, 0.40], [0.72, 0.42, 0.62], [0.80, 0.70, 0.36], [0.70, 0.36, 0.33], [0.42, 0.64, 0.64], [0.58, 0.58, 0.60], ];
pub(crate) fn palette(seed: u64) -> [f32; 3] {
PALETTE[(seed.wrapping_sub(1) % PALETTE.len() as u64) as usize]
}
fn hash(seed: u64, x: u32, y: u32) -> f32 {
let mut z = seed
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(u64::from(x) << 32 | u64::from(y));
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
(z >> 40) as f32 / (1u64 << 24) as f32
}
fn noise(seed: u64, x: u32, y: u32, cells: u32) -> f32 {
let step = SIZE / cells;
let (cx, cy) = (x / step, y / step);
let fade = |t: f32| t * t * (3.0 - 2.0 * t);
let (fx, fy) = (
fade((x % step) as f32 / step as f32),
fade((y % step) as f32 / step as f32),
);
let at = |i: u32, j: u32| hash(seed, (cx + i) % cells, (cy + j) % cells);
let top = at(0, 0) + (at(1, 0) - at(0, 0)) * fx;
let bottom = at(0, 1) + (at(1, 1) - at(0, 1)) * fx;
top + (bottom - top) * fy
}
#[must_use]
pub fn synth(preset: &str, seed: u64) -> Option<RgbaImage> {
if !PRESETS.contains(&preset) {
return None;
}
let base = palette(seed);
Some(RgbaImage::from_fn(SIZE, SIZE, |x, y| {
let grain = noise(seed, x, y, 64) * 0.08 - 0.04;
let shade = grain
+ match preset {
"grid" => {
let line = |v: u32, period: u32, width: u32| {
v % period < width || v % period >= period - width
};
if line(x, 64, 2) || line(y, 64, 2) {
0.35
} else if line(x, 16, 1) || line(y, 16, 1) {
0.15
} else {
0.0
}
}
"checker" => {
if (x / 32 + y / 32) % 2 == 0 {
0.12
} else {
-0.12
}
}
"bricks" => {
let row = y / 32;
let shifted = x + if row % 2 == 1 { 32 } else { 0 };
let brick = (shifted % SIZE) / 64;
if y % 32 < 3 || shifted % 64 < 3 {
-0.45
} else {
hash(seed, brick, row) * 0.2 - 0.1
}
}
"planks" => {
let board = x / 32;
if x % 32 < 2 {
-0.4
} else {
hash(seed, board, 0) * 0.16 - 0.08
+ (noise(seed ^ board as u64, x, y, 8) - 0.5) * 0.15
}
}
"tiles" => {
if x % 64 < 2 || y % 64 < 2 {
0.3
} else {
hash(seed, x / 64, y / 64) * 0.1 - 0.05
}
}
_ => {
(noise(seed, x, y, 8) - 0.5) * 0.35
+ (noise(seed ^ 7, x, y, 32) - 0.5) * 0.15
}
};
let channel = |value: f32| {
((value * (1.0 + shade)).clamp(0.0, 1.0) * 255.0).round() as u8
};
Rgba([channel(base[0]), channel(base[1]), channel(base[2]), 255])
}))
}
pub const SPRITES: [&str; 6] =
["circle", "square", "triangle", "diamond", "star", "heart"];
pub const SPRITE_SIZE: u32 = 128;
fn shape_distance(shape: &str, x: f32, y: f32) -> f32 {
match shape {
"circle" => x.hypot(y) - 0.8,
"square" => {
let (qx, qy) = (x.abs() - 0.6, y.abs() - 0.6);
qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - 0.15
}
"triangle" => {
let side = (0.866 * x.abs() + 0.5 * y) - 0.4;
side.max(-y - 0.7)
}
"diamond" => (x.abs() + y.abs()) * 0.707 - 0.6,
"star" => {
let (k1x, k1y) = (0.809_017_f32, -0.587_785_f32);
let (mut px, mut py) = (x.abs(), y);
let fold = 2.0 * (k1x * px + k1y * py).max(0.0);
(px, py) = (px - fold * k1x, py - fold * k1y);
let fold = 2.0 * (-k1x * px + k1y * py).max(0.0);
(px, py) = (px + fold * k1x, py - fold * k1y);
px = px.abs();
let (outer, inner) = (0.9, 0.45);
py -= outer;
let (bax, bay) = (inner * -k1y, inner * k1x - 1.0);
let h = ((px * bax + py * bay) / (bax * bax + bay * bay))
.clamp(0.0, outer);
let length = (px - bax * h).hypot(py - bay * h);
length * (py * bax - px * bay).signum()
}
_ => {
let scale = 1.5;
let (px, py) = (x.abs() / scale, (y + 0.8) / scale);
let distance = if px + py > 1.0 {
(px - 0.25).hypot(py - 0.75) - std::f32::consts::SQRT_2 / 4.0
} else {
let along = 0.5 * (px + py).max(0.0);
px.hypot(py - 1.0).min((px - along).hypot(py - along))
* (px - py).signum()
};
distance * scale
}
}
}
#[must_use]
pub fn sprite(shape: &str, seed: u64) -> Option<RgbaImage> {
if !SPRITES.contains(&shape) {
return None;
}
let base = palette(seed);
let half = SPRITE_SIZE as f32 / 2.0;
let pixel = 1.0 / half;
Some(RgbaImage::from_fn(SPRITE_SIZE, SPRITE_SIZE, |px, py| {
let x = (px as f32 + 0.5 - half) / half;
let y = (half - py as f32 - 0.5) / half;
let distance = shape_distance(shape, x, y);
let alpha = (0.5 - distance / pixel).clamp(0.0, 1.0);
let outline = (0.5 + (distance + 0.08) / pixel).clamp(0.0, 1.0);
let light = 1.0 + 0.25 * y;
let channel = |value: f32| {
let fill = (value * light).clamp(0.0, 1.0);
let color = fill + (value * 0.35 - fill) * outline;
(color * 255.0).round() as u8
};
Rgba([
channel(base[0]),
channel(base[1]),
channel(base[2]),
(alpha * 255.0).round() as u8,
])
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn presets_are_seeded_tileable_and_not_flat() {
for preset in PRESETS {
let one = synth(preset, 1).unwrap();
assert_eq!(one, synth(preset, 1).unwrap(), "{preset} repeats");
assert_ne!(one, synth(preset, 2).unwrap(), "{preset} seeds differ");
let lumas: Vec<u32> = one
.pixels()
.map(|p| u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]))
.collect();
let (min, max) = (lumas.iter().min(), lumas.iter().max());
assert!(max.unwrap() - min.unwrap() > 30, "{preset} is flat");
let step = |a: &Rgba<u8>, b: &Rgba<u8>| {
(0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap()
};
let inner = (0..SIZE)
.flat_map(|y| (1..SIZE).map(move |x| (x, y)))
.map(|(x, y)| {
step(one.get_pixel(x - 1, y), one.get_pixel(x, y))
})
.max()
.unwrap();
for y in 0..SIZE {
let edge =
step(one.get_pixel(SIZE - 1, y), one.get_pixel(0, y));
assert!(edge <= inner + 32, "{preset} has a seam at row {y}");
}
}
assert!(synth("marble", 1).is_none());
}
#[test]
fn sprites_are_shapes_on_a_clear_background() {
for shape in SPRITES {
let one = sprite(shape, 1).unwrap();
assert_eq!(one, sprite(shape, 1).unwrap(), "{shape} repeats");
assert_ne!(one, sprite(shape, 2).unwrap(), "{shape} seeds differ");
let at = |x, y| one.get_pixel(x, y)[3];
let half = SPRITE_SIZE / 2;
let last = SPRITE_SIZE - 1;
for i in 0..SPRITE_SIZE {
let border = [at(i, 0), at(i, last), at(0, i), at(last, i)];
assert_eq!(border, [0; 4], "{shape} touches the border");
}
assert_eq!(at(half, half), 255, "{shape}: the centre is solid");
let covered = one.pixels().filter(|p| p[3] > 127).count();
let share = covered as f32 / (SPRITE_SIZE * SPRITE_SIZE) as f32;
assert!((0.2..0.8).contains(&share), "{shape} covers {share}");
}
assert!(sprite("blob", 1).is_none());
}
}