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:
samhyunandClaude Opus 5.5 committed 2026-11-01 18:06:56 +09:00
1 parent 53259cf50e
commit 058cc24bef
3 files changed
+66 -4

No files matched your search

+16 -4
View File
@@ -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);
}
+20
View File
@@ -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() }));
+30
View File
@@ -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");
}
}