From 09c2e71141f22172ea5bbd514a20999d65522cbd Mon Sep 17 00:00:00 2001 From: mrrobbiec Date: Tue, 20 Oct 2026 16:01:25 +1000 Subject: [PATCH] Edit window: draw large edited sessions without per-clip work every frame On a 63,424-clip drum edit the UI thread spent ~187 ms per frame, so with a plugin editor open (a frame every 30 ms) it never went idle and the editor windows, which Windows only paints when the thread's queue is empty, stopped redrawing. Now ~11 ms of CPU per frame: - Overlap shadows: each drawn clip checked every clip of its track; on a sorted track the clips starting inside it are found by binary search (sortedness is checked once per track and frame, and an unsorted track is still scanned). - Fade curves: a fade took at least 65 points, even under a pixel wide. A narrow one takes ~4 * its width in physical pixels; the chord error stays under a quarter pixel (tested). - Clip List: rows are listed and filtered once per document revision and filter text, and only the rows in view are laid out (show_rows). - Universe: each track's clips are merged into same-colour runs covering exactly the same pixels, cached per revision and area (5,000 clips become a handful of rects). - Session::content_end, which walks every clip, is worked out once per revision. - Alternate playlist lanes skip clips outside the view, like the main lane, and clip names are not laid out where no glyph would show. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/edit_window.rs | 210 ++++++++++++++++++++++++++---- crates/ui-egui/src/panels.rs | 106 ++++++++++++--- 2 files changed, 274 insertions(+), 42 deletions(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 7e24a4c7..95d3ce86 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -115,26 +115,81 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { crate::docking::show(app, ui, crate::MainWindow::Edit); } -/// Universe: the whole session in miniature; the box is the visible area (drag or click to move). -fn universe(app: &mut SoundApp, ui: &mut Ui) { - let r = ui.max_rect().shrink2(vec2(8.0, 4.0)); - let s = app.engine.session(); +/// The Universe's clip blocks for one document revision and area: each track's clips merged into +/// runs of one colour. A drum edit holds tens of thousands of clips a pixel or less wide here; +/// painting one rect per clip every frame would cost more than all the rest of the window. +#[derive(Clone)] +struct UniverseRuns { + revision: u64, + area: Rect, + runs: std::sync::Arc>, +} + +/// Each clip covers `[x0, max(x1, x0 + 1)]`; neighbours of one colour that touch or overlap are +/// painted as one rect, which covers exactly the same pixels in the same order. +fn universe_runs(s: &Session, r: Rect) -> Vec<(Rect, Color32)> { let total = (s.content_end() + s.sample_rate.samples(10.0)).max(1) as f32; let tracks: Vec<&Track> = s.tracks.iter().filter(|t| !t.hidden).collect(); let n = tracks.len().max(1) as f32; let rh = (r.height() / n).max(1.0); + let mut runs = Vec::new(); for (i, t) in tracks.iter().enumerate() { let y = r.min.y + i as f32 * rh; + let block = |x0: f32, x1: f32, color| (Rect::from_min_max(pos2(x0, y + 0.5), pos2(x1, y + rh - 0.5)), color); + let mut run: Option<(f32, f32, Color32)> = None; for c in t.clips() { let x0 = r.min.x + c.start as f32 / total * r.width(); - let x1 = r.min.x + c.end() as f32 / total * r.width(); - ui.painter().rect_filled( - Rect::from_min_max(pos2(x0, y + 0.5), pos2(x1.max(x0 + 1.0), y + rh - 0.5)), - 0.0, - rgb(c.color.unwrap_or(t.color)), - ); + let x1 = (r.min.x + c.end() as f32 / total * r.width()).max(x0 + 1.0); + let color = rgb(c.color.unwrap_or(t.color)); + run = match run { + Some((a, b, col)) if col == color && x0 >= a && x0 <= b => Some((a, b.max(x1), col)), + Some((a, b, col)) => { + runs.push(block(a, b, col)); + Some((x0, x1, color)) + } + None => Some((x0, x1, color)), + }; + } + if let Some((a, b, col)) = run { + runs.push(block(a, b, col)); } } + runs +} + +/// [`Session::content_end`], worked out once per document revision: it walks every clip. +fn content_end(app: &SoundApp, ctx: &egui::Context) -> Samples { + let key = egui::Id::new("content_end"); + let revision = app.engine.revision; + if let Some((at, end)) = ctx.memory(|m| m.data.get_temp::<(u64, Samples)>(key)) + && at == revision + { + return end; + } + let end = app.engine.session().content_end(); + ctx.memory_mut(|m| m.data.insert_temp(key, (revision, end))); + end +} + +/// Universe: the whole session in miniature; the box is the visible area (drag or click to move). +fn universe(app: &mut SoundApp, ui: &mut Ui) { + let r = ui.max_rect().shrink2(vec2(8.0, 4.0)); + let key = egui::Id::new("universe_runs"); + let revision = app.engine.revision; + let runs = match ui.ctx().memory(|m| m.data.get_temp::(key)).filter(|u| u.revision == revision && u.area == r) { + Some(u) => u.runs, + None => { + let runs = std::sync::Arc::new(universe_runs(app.engine.session(), r)); + ui.ctx().memory_mut(|m| m.data.insert_temp(key, UniverseRuns { revision, area: r, runs: runs.clone() })); + runs + } + }; + for &(rect, color) in runs.iter() { + ui.painter().rect_filled(rect, 0.0, color); + } + let end = content_end(app, ui.ctx()); + let s = app.engine.session(); + let total = (end + s.sample_rate.samples(10.0)).max(1) as f32; let vis0 = s.edit.zoom.scroll as f32 / total; let tlw = app.edit_layout.timeline[2] - app.edit_layout.timeline[0]; let vis1 = vis0 + (f64::from(tlw.max(100.0)) * s.edit.zoom.samples_per_px) as f32 / total; @@ -373,9 +428,10 @@ fn pan(app: &mut SoundApp, px: f64) { fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { let t = Tokens::current(); ui.painter().rect_filled(sb, 0.0, t.panel_bg); + let end = content_end(app, ui.ctx()); let s = app.engine.session(); let vis = f64::from(tl.width()) * s.edit.zoom.samples_per_px; - let total = (s.content_end().saturating_add(s.sample_rate.samples(30.0)).max(1) as f64).max(s.edit.zoom.scroll as f64 + vis); + let total = (end.saturating_add(s.sample_rate.samples(30.0)).max(1) as f64).max(s.edit.zoom.scroll as f64 + vis); let frac = (vis / total).clamp(0.02, 1.0) as f32; let off = (s.edit.zoom.scroll as f64 / total).clamp(0.0, 1.0) as f32; let thumb = Rect::from_min_size(pos2(sb.min.x + sb.width() * off, sb.min.y + 3.0), vec2((sb.width() * frac).max(24.0), sb.height() - 6.0)); @@ -838,11 +894,12 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect, } let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x)); let auto_view = AutoParam::parse(&track.view); + let sorted = track.clips().is_sorted_by_key(|c| c.start); for clip in track.clips() { if !clip.range().overlaps(&view) { continue; } - draw_clip(app, &painter, &s, track, clip, tl, lane, auto_view.is_some()); + draw_clip(app, &painter, &s, track, sorted, clip, tl, lane, auto_view.is_some()); } draw_live_take(app, &painter, &s, track, tl, lane); crate::video_track::draw_lane(app, &painter, &s, track, tl, lane); @@ -885,8 +942,10 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai ui.painter().rect_filled(lane, 0.0, t.playlist_lane); ui.painter().text(pos2(head.min.x + 4.0, head.center().y), Align2::LEFT_CENTER, &pl.name, regular(10.5), t.text_dim); let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect())); - for c in &pl.clips { - draw_clip(app, &painter, &s, track, c, tl, lane, false); + let sorted = track.clips().is_sorted_by_key(|c| c.start); + let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x)); + for c in pl.clips.iter().filter(|c| c.range().overlaps(&view)) { + draw_clip(app, &painter, &s, track, sorted, c, tl, lane, false); } let resp = ui.interact(lane, ui.id().with(("pl_lane", track.id.0, i)), Sense::click()); if resp.clicked() @@ -1511,9 +1570,13 @@ fn fade_curve_points(fade: soundcraft_model::Fade, fade_in: bool, a: f32, b: f32 let scale = if pixels_per_point.is_finite() { pixels_per_point.clamp(0.5, 4.0) } else { 1.0 }; let extent = (b - a).abs().max(body.height().abs()) * scale; let extent = if extent.is_finite() { extent.clamp(64.0, 4096.0) } else { 64.0 }; + // A narrow fade needs fewer: the curve between two samples stays inside a box `width / segments` + // wide, so it is never further than that from its chord. Zoomed out on an edited drum take, + // thousands of fades are a pixel or two wide. + let narrow = ((b - a).abs() * scale * 4.0).ceil() + 1.0; // Physical-pixel sampling avoids visible corners, even for steep bends. // Keep an exact midpoint and cap allocations for extreme zoom/clip sizes. - let segments = (extent.ceil() as usize).div_ceil(2) * 2; + let segments = (extent.min(narrow).max(2.0).ceil() as usize).div_ceil(2) * 2; (0..=segments) .map(|i| { let k = i as f32 / segments as f32; @@ -1582,7 +1645,8 @@ fn grid_veil(line: Color32, bg: Color32) -> Color32 { } } -fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, clip: &Clip, tl: Rect, lane: Rect, dim: bool) { +/// `sorted`: whether `track.clips()` is sorted by start (worked out once per track and frame). +fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, sorted: bool, clip: &Clip, tl: Rect, lane: Rect, dim: bool) { let x0 = x_of(s, tl, clip.start); let x1 = x_of(s, tl, clip.end()).max(x0 + 1.0); let r = Rect::from_min_max(pos2(x0, lane.min.y + 1.0), pos2(x1, lane.max.y - 1.0)); @@ -1604,13 +1668,16 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track let nb = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + name_h)); painter.rect_filled(nb, CornerRadius { nw: 2, ne: 2, sw: 0, se: 0 }, if clip.muted { Color32::from_rgb(70, 70, 70) } else { bar }); let name_clip = nb.intersect(lane); - painter.with_clip_rect(name_clip).text( - pos2(nb.min.x.max(lane.min.x) + 4.0, nb.center().y), - Align2::LEFT_CENTER, - &clip.name, - bold(10.5), - Color32::from_rgb(12, 12, 12), - ); + // Narrower than this, no glyph would show: the name starts 4 points in. + if name_clip.width() > 6.0 { + painter.with_clip_rect(name_clip).text( + pos2(nb.min.x.max(lane.min.x) + 4.0, nb.center().y), + Align2::LEFT_CENTER, + &clip.name, + bold(10.5), + Color32::from_rgb(12, 12, 12), + ); + } } let body_r = Rect::from_min_max(pos2(r.min.x, r.min.y + name_h), r.max); let vis = body_r.intersect(lane); @@ -1772,8 +1839,16 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track painter.text(pos2(r.max.x - 6.0, r.max.y - 7.0), Align2::RIGHT_CENTER, "FX", bold(9.0), Color32::from_rgb(250, 230, 120)); } if s.edit.flag("view.clip.overlap_shadows") { - // Shade where a later clip covers this one (crossfades and layered edits). - for o in track.clips() { + // Shade where a later clip covers this one (crossfades and layered edits). Only clips that + // start inside this one can: on a sorted track, find them instead of checking every clip. + let clips = track.clips(); + let later = if sorted { + let from = clips.partition_point(|o| o.start <= clip.start); + clips.get(from..clips.partition_point(|o| o.start < clip.end()).max(from)).unwrap_or_default() + } else { + clips + }; + for o in later { if o.id != clip.id && o.start > clip.start && o.start < clip.end() { let x0 = x_of(s, tl, o.start); let x1 = x_of(s, tl, clip.end().min(o.end())); @@ -2776,6 +2851,60 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { } } +#[cfg(test)] +mod universe_tests { + use super::*; + use soundcraft_model::{ChannelFormat, Clip, ClipId, SourceId, TrackKind}; + + /// The pixel columns each block covers, per colour, as the old one-rect-per-clip painting did. + fn covered(blocks: &[(Rect, Color32)]) -> Vec<(i32, Color32)> { + let mut px: Vec<(i32, Color32)> = Vec::new(); + for (r, c) in blocks { + for x in (r.min.x * 4.0).round() as i32..(r.max.x * 4.0).round() as i32 { + px.retain(|p| p.0 != x); + px.push((x, *c)); + } + } + px.sort_by_key(|p| p.0); + px + } + + fn per_clip(s: &Session, r: Rect) -> Vec<(Rect, Color32)> { + let total = (s.content_end() + s.sample_rate.samples(10.0)).max(1) as f32; + let t = &s.tracks[0]; + t.clips() + .iter() + .map(|c| { + let x0 = r.min.x + c.start as f32 / total * r.width(); + let x1 = (r.min.x + c.end() as f32 / total * r.width()).max(x0 + 1.0); + (Rect::from_min_max(pos2(x0, r.min.y + 0.5), pos2(x1, r.max.y - 0.5)), rgb(c.color.unwrap_or(t.color))) + }) + .collect() + } + + #[test] + fn a_sliced_track_is_a_few_runs_covering_the_same_pixels() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let mut clips = Vec::new(); + let mut at = 0; + for i in 0..5000u64 { + let mut c = Clip::audio(ClipId(i + 1), "hit", SourceId(1), 0, at, 200 + (i % 7) as i64 * 50); + // A recoloured stretch, and a gap, in the middle. + if (2000..2100).contains(&i) { + c.color = Some([200, 40, 40]); + } + at = c.end() + if i == 3000 { 2_000_000 } else { 0 }; + clips.push(c); + } + s.track_mut(t).unwrap().playlist_mut().unwrap().clips = clips; + let r = Rect::from_min_size(pos2(8.0, 4.0), vec2(900.0, 56.0)); + let runs = universe_runs(&s, r); + assert!(runs.len() <= 6, "{} rects for 5000 clips", runs.len()); + assert_eq!(covered(&runs), covered(&per_clip(&s, r))); + } +} + #[cfg(test)] mod tests { use super::*; @@ -3444,6 +3573,37 @@ mod tests { } } + #[test] + fn narrow_fades_take_few_points_and_stay_within_a_quarter_pixel() { + for width in [0.2f32, 1.0, 3.0, 12.0] { + for height in [40.0f32, 320.0] { + let body = Rect::from_min_size(Pos2::ZERO, vec2(width, height)); + for scale in [1.0, 2.0] { + for shape in soundcraft_model::FadeShape::ALL { + for curvature in [-1.0, 0.0, 1.0] { + let fade = soundcraft_model::Fade { len: 1000, shape, curvature }; + let points = fade_curve_points(fade, true, 0.0, width, body, scale); + assert!(points.len() <= ((width * scale * 4.0).ceil() as usize + 4), "{width} wide: {} points", points.len()); + assert_eq!((points.len() - 1) % 2, 0, "an exact midpoint"); + let segments = (points.len() - 1) as f32; + for (i, pair) in points.windows(2).enumerate() { + let line = pair[1] - pair[0]; + for fraction in [0.1, 0.25, 0.5, 0.75, 0.9] { + let t = (i as f32 + fraction) / segments; + let actual = pos2(width * t, height - height * fade.gain(t)); + let along = + if line.length_sq() > 0.0 { ((actual - pair[0]).dot(line) / line.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + let distance = actual.distance(pair[0] + line * along) * scale; + assert!(distance < 0.25, "{width}x{height}, scale={scale}, {shape:?}, curve={curvature}, error={distance}"); + } + } + } + } + } + } + } + } + fn painted_fade_control(h: &Harness, clip_rect: Rect) -> Pos2 { let controls: Vec<_> = h .output() diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a208511f..54383aea 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -184,32 +184,75 @@ fn gutter_scroll(ui: &Ui, gutter: Rect, out: &egui::scroll_area::ScrollAreaOu } } +/// One row of the clip list: a whole file, or a clip on a track (positions in the session). +#[derive(Clone, Copy)] +enum ClipRow { + Source(usize), + Clip { track: usize, clip: usize }, +} + +/// The clip list's rows for one document revision and filter. A drum edit holds tens of thousands +/// of clips: the rows are listed (and filtered) when either changes, not every frame. +#[derive(Clone)] +struct ClipRows { + revision: u64, + filter: String, + rows: std::sync::Arc>, +} + +fn clip_rows(s: &soundcraft_model::Session, needle: &str) -> Vec { + let shown = |name: &str| needle.is_empty() || name.to_lowercase().contains(needle); + // Whole files first (bold), then clips on tracks. + let sources = + s.sources.iter().enumerate().filter(|(_, src)| shown(&format!("{} ({}ch)", src.name, src.channels))).map(|(i, _)| ClipRow::Source(i)); + let clips = + s.tracks.iter().enumerate().flat_map(|(track, tr)| { + tr.clips().iter().enumerate().filter(|(_, c)| shown(&c.name)).map(move |(clip, _)| ClipRow::Clip { track, clip }) + }); + sources.chain(clips).collect() +} + pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); panel_header(ui, "CLIPS"); - let items: Vec<(u64, String, bool, [u8; 3], bool)> = { - let s = app.engine.session(); - let mut v = Vec::new(); - // Whole files first (bold), then clips on tracks. - for src in &s.sources { - v.push((src.id.0, format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150], s.edit.selected_sources.contains(&src.id))); - } - for tr in &s.tracks { - for c in tr.clips() { - v.push((c.id.0, c.name.clone(), false, c.color.unwrap_or(tr.color), s.edit.selected_clips.contains(&c.id))); - } - } - v - }; let fkey = egui::Id::new("clip_filter"); let mut filter: String = ui.ctx().memory(|m| m.data.get_temp(fkey)).unwrap_or_default(); ui.add(egui::TextEdit::singleline(&mut filter).hint_text("🔍 Find clips").desired_width(f32::INFINITY)); ui.ctx().memory_mut(|m| m.data.insert_temp(fkey, filter.clone())); let needle = filter.to_lowercase(); - let items: Vec<_> = items.into_iter().filter(|(_, n, _, _, _)| needle.is_empty() || n.to_lowercase().contains(&needle)).collect(); - egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show(ui, |ui| { + let rkey = egui::Id::new("clip_rows"); + let revision = app.engine.revision; + let cached = ui.ctx().memory(|m| m.data.get_temp::(rkey)).filter(|c| c.revision == revision && c.filter == needle); + let rows = match cached { + Some(c) => c.rows, + None => { + let rows = std::sync::Arc::new(clip_rows(app.engine.session(), &needle)); + ui.ctx().memory_mut(|m| m.data.insert_temp(rkey, ClipRows { revision, filter: needle, rows: rows.clone() })); + rows + } + }; + let row_h = 17.0; + egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, shown| { + // Only the rows in view: what they show is read now, before any of them acts on the session. + let items: Vec<(u64, String, bool, [u8; 3], bool)> = { + let s = app.engine.session(); + rows.get(shown) + .unwrap_or_default() + .iter() + .filter_map(|row| match *row { + ClipRow::Source(i) => s.sources.get(i).map(|src| { + (src.id.0, format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150], s.edit.selected_sources.contains(&src.id)) + }), + ClipRow::Clip { track, clip } => { + let tr = s.tracks.get(track)?; + let c = tr.clips().get(clip)?; + Some((c.id.0, c.name.clone(), false, c.color.unwrap_or(tr.color), s.edit.selected_clips.contains(&c.id))) + } + }) + .collect() + }; for (id, name, whole, color, selected) in items { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), if whole { Sense::click_and_drag() } else { Sense::click() }); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), if whole { Sense::click_and_drag() } else { Sense::click() }); resp.context_menu(|ui| { if whole { if ui.button("Place on New Track").clicked() { @@ -708,3 +751,32 @@ fn preset_menu( }); ui.ctx().memory_mut(|m| m.data.insert_temp(key, name)); } + +#[cfg(test)] +mod tests { + use super::*; + use soundcraft_model::{ChannelFormat, Clip, Session, TrackKind}; + + #[test] + fn clip_rows_list_every_clip_in_track_order_and_filter_by_name() { + let mut s = Session::default(); + for names in [["Kick 1", "Snare 1"], ["Kick 2", "Tom"]] { + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + for (i, n) in names.into_iter().enumerate() { + let c = Clip::audio(s.new_clip_id(), n, SourceId(1), 0, i as i64 * 100, 100); + s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(c); + } + } + let names = |rows: Vec| -> Vec { + rows.into_iter() + .filter_map(|r| match r { + ClipRow::Clip { track, clip } => Some(s.tracks[track].clips()[clip].name.clone()), + ClipRow::Source(_) => None, + }) + .collect() + }; + assert_eq!(names(clip_rows(&s, "")), ["Kick 1", "Snare 1", "Kick 2", "Tom"]); + assert_eq!(names(clip_rows(&s, "kick")), ["Kick 1", "Kick 2"]); + assert!(clip_rows(&s, "cowbell").is_empty()); + } +}