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:
@@ -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
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user