feat: Enhance canvas texture management and selection encoding

- Introduced `SharedCompositePixels` for stable full-canvas pixel storage, enabling efficient pipeline creation and recovery.
- Added `TextureUpdate` struct for tightly packed dirty rectangle uploads, reducing unnecessary data copying.
- Refactored `upload_dirty_region` to utilize `TextureUpdate`, improving performance by eliminating full buffer scans.
- Implemented `encode_selection_texture` to generate an encoded R8 selection mask, optimizing selection rendering.
- Updated shader to sample selection texture only once, reducing GPU workload.
- Added comprehensive tests for texture updates, selection encoding, and performance diagnostics.
- Documented design decisions and validation sequences to ensure future performance stability.
This commit is contained in:
2026-07-22 02:49:22 +03:00
parent 7b4073e55b
commit 2d5b7a37e8
11 changed files with 994 additions and 399 deletions
+1 -1
View File
@@ -16,4 +16,4 @@ rstest = "0.23"
proptest = "1.5"
approx = "0.5"
egui = "0.34"
eframe = { version = "0.34", default-features = false, features = ["default_fonts", "glow"] }
eframe = { version = "0.34", default-features = false, features = ["default_fonts", "glow", "x11"] }
+153 -34
View File
@@ -1223,49 +1223,89 @@ pub fn draw_watercolor_brush(
mask: Option<&[u8]>,
) {
let mut rng = rand::thread_rng();
draw_watercolor_brush_with_rng(
pixels, width, height, cx, cy, size, pressure, color, opacity, hardness, is_eraser, mask,
&mut rng,
);
}
/// Maximum raster dabs emitted for one interpolated watercolor sample.
const WATERCOLOR_MAX_DABS_PER_SAMPLE: usize = 8;
/// Computes bounded watercolor detail counts for one effective brush size.
///
/// **Arguments:** `effective_size` is pressure-adjusted diameter and `rng` supplies the optional
/// bloom decision. **Returns:** Satellite, splatter, and bloom counts whose total plus one core is
/// at most eight. **Side Effects / Dependencies:** Advances the provided random generator once for
/// eligible blooms.
fn watercolor_detail_counts<R: rand::Rng + ?Sized>(
effective_size: f32,
rng: &mut R,
) -> (usize, usize, usize) {
let satellites = ((effective_size / 24.0).ceil() as usize).clamp(1, 2);
let splatters = ((effective_size / 16.0).ceil() as usize).clamp(1, 4);
let bloom = usize::from(effective_size > 25.0 && rng.gen_bool(0.3));
debug_assert!(1 + satellites + splatters + bloom <= WATERCOLOR_MAX_DABS_PER_SAMPLE);
(satellites, splatters, bloom)
}
/// Renders one bounded watercolor sample using a caller-provided random generator.
///
/// **Arguments:** Pixel target, dimensions, center, brush settings, optional selection mask, and
/// random generator. **Returns:** Nothing. **Logic & Workflow:** Draws one irregular full-size core,
/// up to two satellites, up to four tiny splatters, and at most one faint bloom. **Side Effects /
/// Dependencies:** Mutates target pixels and advances `rng`; never exceeds eight raster dabs.
#[allow(clippy::too_many_arguments)]
fn draw_watercolor_brush_with_rng<R: rand::Rng + ?Sized>(
pixels: &mut [u8],
width: u32,
height: u32,
cx: f32,
cy: f32,
size: f32,
pressure: f32,
color: [u8; 4],
opacity: f32,
hardness: f32,
is_eraser: bool,
mask: Option<&[u8]>,
rng: &mut R,
) {
let effective_size = size * pressure;
if effective_size < 0.5 {
return;
}
let base_opacity = opacity * 0.2 * pressure;
let base_opacity = opacity * 0.3 * pressure;
let center_color = color;
let (satellite_count, splatter_count, bloom_count) =
watercolor_detail_counts(effective_size, rng);
// ── Central irregular core ──────────────────────────────────────────────
// Build the main mark from several overlapping soft dabs so the boundary
// is lumpy instead of a perfect circle.
let core_dabs = rng.gen_range(2..=4);
for _ in 0..core_dabs {
let angle = rng.gen_range(0.0..std::f32::consts::TAU);
let offset = effective_size * rng.gen_range(0.0..0.12);
let dx = angle.cos() * offset;
let dy = angle.sin() * offset;
let s = effective_size * rng.gen_range(0.85..1.15);
let a = base_opacity * rng.gen_range(0.7..1.3);
draw_dab(
pixels,
width,
height,
cx + dx,
cy + dy,
s,
hardness,
center_color,
a,
is_eraser,
mask,
);
}
let core_angle = rng.gen_range(0.0..std::f32::consts::TAU);
let core_offset = effective_size * rng.gen_range(0.0..0.08);
draw_dab(
pixels,
width,
height,
cx + core_angle.cos() * core_offset,
cy + core_angle.sin() * core_offset,
effective_size * rng.gen_range(0.95..1.1),
hardness,
center_color,
base_opacity * rng.gen_range(0.85..1.15),
is_eraser,
mask,
);
// ── Satellite puddles ───────────────────────────────────────────────────
// Secondary dabs pulled away from the stroke path by water tension,
// giving the edge its characteristic ragged bloom.
let satellite_count = (effective_size / 6.0).clamp(2.0, 8.0) as i32;
for _ in 0..satellite_count {
let angle = rng.gen_range(0.0..std::f32::consts::TAU);
let dist = effective_size * rng.gen_range(0.25..0.65);
let dx = angle.cos() * dist;
let dy = angle.sin() * dist;
let s = effective_size * rng.gen_range(0.2..0.55);
let s = effective_size * rng.gen_range(0.18..0.38);
let a = base_opacity * rng.gen_range(0.25..0.75);
draw_dab(
pixels,
@@ -1284,7 +1324,6 @@ pub fn draw_watercolor_brush(
// ── Tiny splatter particles ───────────────────────────────────────────
// Small droplets flung outward, visible on high-resolution strokes.
let splatter_count = (effective_size * 1.2).clamp(3.0, 22.0) as i32;
for _ in 0..splatter_count {
let angle = rng.gen_range(0.0..std::f32::consts::TAU);
let dist = effective_size * rng.gen_range(0.45..1.15);
@@ -1310,17 +1349,12 @@ pub fn draw_watercolor_brush(
// ── Back-run / blossom bursts ───────────────────────────────────────────
// A few very large, faint "blooms" that extend beyond the main stroke,
// simulating water pushing pigment to the edges.
let bloom_count = if effective_size > 25.0 {
rng.gen_range(1..=3)
} else {
0
};
for _ in 0..bloom_count {
let angle = rng.gen_range(0.0..std::f32::consts::TAU);
let dist = effective_size * rng.gen_range(0.5..0.9);
let dx = angle.cos() * dist;
let dy = angle.sin() * dist;
let s = effective_size * rng.gen_range(0.6..1.0);
let s = effective_size * rng.gen_range(0.45..0.7);
let a = base_opacity * rng.gen_range(0.15..0.35);
draw_dab(
pixels,
@@ -3345,3 +3379,88 @@ fn alpha_blend(dst: [u8; 4], src: [u8; 4]) -> [u8; 4] {
(out_a * 255.0).round() as u8,
]
}
#[cfg(test)]
mod watercolor_performance_tests {
use super::{
draw_dab, draw_watercolor_brush_with_rng, watercolor_detail_counts,
WATERCOLOR_MAX_DABS_PER_SAMPLE,
};
use rand::{rngs::StdRng, SeedableRng};
/// Ensures every supported size stays within the fixed raster-work budget.
#[test]
fn watercolor_detail_count_is_hard_bounded() {
let mut rng = StdRng::seed_from_u64(0x0057_4154_4552);
for size in [0.5, 1.0, 8.0, 24.0, 64.0, 128.0, 1024.0] {
for _ in 0..1000 {
let (satellites, splatters, blooms) = watercolor_detail_counts(size, &mut rng);
let total = 1 + satellites + splatters + blooms;
assert!(satellites <= 2);
assert!(splatters <= 4);
assert!(blooms <= 1);
assert!(total <= WATERCOLOR_MAX_DABS_PER_SAMPLE);
}
}
}
/// Reports p50/p95/max raster time for one round dab and the bounded watercolor sample.
#[test]
#[ignore = "diagnostic benchmark; run with --ignored --nocapture"]
fn diagnostic_watercolor_sample_latency() {
let mut pixels = vec![0u8; 512 * 512 * 4];
let mut rng = StdRng::seed_from_u64(0x0050_4149_4e54);
for size in [24.0, 64.0, 128.0] {
let mut round_samples = Vec::with_capacity(100);
let mut watercolor_samples = Vec::with_capacity(100);
for sample in 0..110 {
let started = std::time::Instant::now();
draw_dab(
&mut pixels,
512,
512,
256.0,
256.0,
size,
0.5,
[20, 80, 180, 255],
0.8,
false,
None,
);
let round = started.elapsed().as_secs_f64() * 1000.0;
let started = std::time::Instant::now();
draw_watercolor_brush_with_rng(
&mut pixels,
512,
512,
256.0,
256.0,
size,
1.0,
[20, 80, 180, 255],
0.8,
0.5,
false,
None,
&mut rng,
);
let watercolor = started.elapsed().as_secs_f64() * 1000.0;
if sample >= 10 {
round_samples.push(round);
watercolor_samples.push(watercolor);
}
}
round_samples.sort_by(f64::total_cmp);
watercolor_samples.sort_by(f64::total_cmp);
let p95_index = (round_samples.len() as f64 * 0.95).floor() as usize;
println!(
"watercolor size={size:.0}: round_p95={:.3}ms watercolor_p50={:.3}ms watercolor_p95={:.3}ms watercolor_max={:.3}ms",
round_samples[p95_index.min(round_samples.len() - 1)],
watercolor_samples[watercolor_samples.len() / 2],
watercolor_samples[p95_index.min(watercolor_samples.len() - 1)],
watercolor_samples[watercolor_samples.len() - 1],
);
}
}
}