mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 19:42:05 +00:00
M8: grains stay in their cells whatever origin a render starts from
Noise and texture lay their grain cells out in the layer's own coordinates, so a patch, a band or a pan should draw the grains a full render draws. At a zoom of 1.7, a 1-unit cell is 1.7 pixels across and every tenth edge falls on a row of pixel centres; a render whose origin is a whole number of pixels off reached that edge with another rounding error and moved the row into the next cell. An edit over a textured layer patched up to 197 levels off a full render, and a noisy one 224. Cell positions are now rounded to 1/2^20 of a cell before they're cut into cells, and texture adds its scatter to the pixel index in whole pixels instead of flooring it in f32 together with the index. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
53259cf50e
commit
058cc24bef
3 files changed
+66
-4
No files matched your search
@@ -26,6 +26,14 @@ fn unit(h: u64, at: u32) -> f32 {
|
||||
f32::from(((h >> at) & 0xFFFF) as u16) / 65536.0
|
||||
}
|
||||
|
||||
/// Cell positions are rounded to 1 / `STEPS_PER_CELL` of a cell before they're cut into cells. A
|
||||
/// pixel centre can fall exactly on a cell edge (at a zoom of 1.7, a 1-unit cell is 1.7 pixels
|
||||
/// across: every tenth edge does), and a view whose origin is a whole number of pixels off (a
|
||||
/// patch, a band, a pan) reaches it with another rounding error. Unrounded, a row of grains lands
|
||||
/// in the next cell; rounded, the edge moves 1 / 2²¹ of a cell aside, off the centres layers at
|
||||
/// round positions and zooms put it on.
|
||||
const STEPS_PER_CELL: f64 = 1_048_576.0;
|
||||
|
||||
/// Maps layer pixels to the grain cells under their centres.
|
||||
struct Cells {
|
||||
/// Layer pixels → local units, divided by the grain size: `[a, b, c, d, e, f]`.
|
||||
@@ -44,7 +52,8 @@ impl Cells {
|
||||
fn at(&self, x: u32, y: u32) -> (i64, i64) {
|
||||
let (px, py) = (f64::from(x) + 0.5, f64::from(y) + 0.5);
|
||||
let [a, b, c, d, e, f] = self.m;
|
||||
((a * px + c * py + e).floor() as i64, (b * px + d * py + f).floor() as i64)
|
||||
let cell = |v: f64| ((v * STEPS_PER_CELL).round() / STEPS_PER_CELL).floor() as i64;
|
||||
(cell(a * px + c * py + e), cell(b * px + d * py + f))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,11 +129,14 @@ pub fn texture(layer: &mut Pixmap, t: &Texture, xf: Affine, scale: f64, seed: u3
|
||||
let hv = hash(ix, iy, seed ^ 0x7E57_u32);
|
||||
// A uniform point in the disc of radius r.
|
||||
let (ang, dist) = (unit(hv, 0) * std::f32::consts::TAU, unit(hv, 16).sqrt() * r);
|
||||
let (fx, fy) = (x as f32 + 0.5 + ang.cos() * dist, y as f32 + 0.5 + ang.sin() * dist);
|
||||
// The move in whole pixels, apart from `x` and `y`: the same pixel of the page moves
|
||||
// the same way wherever the layer's pixels start.
|
||||
let (mx, my) = ((0.5 + ang.cos() * dist).floor() as i64, (0.5 + ang.sin() * dist).floor() as i64);
|
||||
let (fx, fy) = (i64::from(x) + mx, i64::from(y) + my);
|
||||
let i = ((y * w + x) * 4) as usize;
|
||||
let mut px = [0u8; 4];
|
||||
if fx >= 0.0 && fy >= 0.0 && (fx as u32) < w && (fy as u32) < h {
|
||||
let j = (((fy as u32) * w + fx as u32) * 4) as usize;
|
||||
if fx >= 0 && fy >= 0 && fx < i64::from(w) && fy < i64::from(h) {
|
||||
let j = ((fy * i64::from(w) + fx) * 4) as usize;
|
||||
if let Some(s) = src.get(j..j + 4) {
|
||||
px.copy_from_slice(s);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,26 @@ fn noise_grains_have_their_size_and_stay_put() {
|
||||
assert_eq!(px(&z, 23, 23), c);
|
||||
}
|
||||
|
||||
/// Panning by whole pixels at a zoom where grain edges fall on pixel centres (1.7: a 1-unit grain
|
||||
/// is 1.7 pixels, every tenth edge on a centre) reaches those edges from another origin, with
|
||||
/// another rounding error: the grains still stay put.
|
||||
#[test]
|
||||
fn grains_stay_put_where_their_edges_fall_on_pixel_centres() {
|
||||
let s = 1.7;
|
||||
for (why, kind) in [
|
||||
("noise", noise(Noise { density: 0.5, size: 0.5, color: Color::BLACK, ..Noise::default() })),
|
||||
("texture", EffectKind::Texture(Texture { size: 0.5, radius: 12.0, clip_to_shape: false, ..Texture::default() })),
|
||||
] {
|
||||
let (d, r) = square(kind);
|
||||
let a = render_at(&d, r, (0.0, 0.0), s);
|
||||
for (dx, dy) in [(79u32, 62u32), (13, 7), (31, 47), (5, 99)] {
|
||||
let b = render_at(&d, r, (f64::from(dx) / s, f64::from(dy) / s), s);
|
||||
let off = (0..200 - dy).flat_map(|y| (0..200 - dx).map(move |x| (x, y))).filter(|&(x, y)| px(&a, x + dx, y + dy) != px(&b, x, y)).count();
|
||||
assert_eq!(off, 0, "{why}, a pan by ({dx}, {dy}) pixels");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_blend_mode_applies() {
|
||||
let (d, r) = square(noise(Noise { density: 1.0, color: Color::WHITE, blend: figcraft_color::BlendMode::Multiply, ..Noise::default() }));
|
||||
|
||||
@@ -569,3 +569,33 @@ fn pans_patch_backdrops_at_the_edges_that_moved_in() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grainy_layers_patch_as_they_render() {
|
||||
fonts();
|
||||
let texture = |clip_to_shape| EffectKind::Texture(figcraft_doc::Texture { size: 0.5, radius: 12.0, clip_to_shape, ..Default::default() });
|
||||
let noise = EffectKind::Noise(figcraft_doc::Noise { size: 0.5, density: 0.4, color: Color::BLACK, ..Default::default() });
|
||||
// At a zoom of 1.7, a grain of 0.5 is 1.7 pixels across: every tenth grain edge falls on a row
|
||||
// of pixel centres, and a patch renders those rows from another origin than a full render.
|
||||
for (why, kind) in [("texture", texture(false)), ("texture clipped to its shape", texture(true)), ("noise", noise)] {
|
||||
let mut d = Document::new("grain");
|
||||
let p = d.first_page().unwrap();
|
||||
let e = d.create(p, NodeKind::Rectangle, "E", 60.0, 50.0, 120.0, 90.0).unwrap();
|
||||
solid(d.get_mut(e).unwrap(), "E03030");
|
||||
d.get_mut(e).unwrap().effects = vec![Effect { kind, visible: true }];
|
||||
let m = d.create(p, NodeKind::Rectangle, "M", 220.0, 60.0, 30.0, 30.0).unwrap();
|
||||
solid(d.get_mut(m).unwrap(), "20A060");
|
||||
let doc = Arc::new(d);
|
||||
let v = View { origin: (0.0, 0.0), scale: 1.7, width: 400, height: 300 };
|
||||
let mut r = Retained::default();
|
||||
check(&mut r, &doc, p, &v);
|
||||
// Over the grainy layer, which is redrawn whole.
|
||||
let over = edit(&doc, |d| d.get_mut(m).unwrap().transform = Affine::translate((100.0, 80.0)));
|
||||
let (shown, pix, work) = r.render(&over, p, &v, &Options::default()).unwrap();
|
||||
let pix = pix.clone();
|
||||
let full = render_view(&over, p, &shown, &Options::default()).unwrap();
|
||||
let worst = pix.data().iter().zip(full.data()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
|
||||
assert!(partial(work), "{why}: {work:?}");
|
||||
assert!(worst <= 2, "{why}: {work:?} is {worst} levels off a full render");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user