diff --git a/crates/SphereUIComponents/src/components/mixer_panel.rs b/crates/SphereUIComponents/src/components/mixer_panel.rs index 85c1ad7b..a5c1bb1c 100644 --- a/crates/SphereUIComponents/src/components/mixer_panel.rs +++ b/crates/SphereUIComponents/src/components/mixer_panel.rs @@ -2764,10 +2764,35 @@ pub struct VstiOutputMeterState { pub level: f32, pub peak_hold: f32, pub clip: bool, + /// Meter tick this entry was last published in. Bookkeeping for the + /// meter path's prune pass: an entry still carrying the current + /// generation is live, an older one is decaying toward removal. Kept + /// here so liveness needs no parallel set of owned key strings — see + /// `apply_engine_meters`. + pub last_seen: u64, } pub fn vsti_output_meter_key(track_id: &str, insert_id: &str, channel: u8) -> String { - format!("{track_id}:{insert_id}:{channel}") + let mut key = String::new(); + write_vsti_output_meter_key(&mut key, track_id, insert_id, channel); + key +} + +/// Format a meter key into a reusable buffer. +/// +/// The meter path resolves one key per plugin output channel on every tick, so +/// on a project with thousands of VSTi output channels the allocating form +/// above is hundreds of microseconds of pure `String` churn per tick. Callers +/// on that path keep one buffer and rewrite it in place. +pub fn write_vsti_output_meter_key( + buffer: &mut String, + track_id: &str, + insert_id: &str, + channel: u8, +) { + use std::fmt::Write; + buffer.clear(); + let _ = write!(buffer, "{track_id}:{insert_id}:{channel}"); } #[derive(Clone, Copy)] @@ -3650,6 +3675,66 @@ mod mixer_virtualization_tests { } } +#[cfg(test)] +mod vsti_meter_key_tests { + use super::{vsti_output_meter_key, write_vsti_output_meter_key, VstiOutputMeterState}; + + /// The buffered form is what the meter path uses every tick; it has to + /// produce byte-identical keys to the allocating form the mixer render + /// path still calls, or lookups would silently miss. + #[test] + fn buffered_and_allocating_keys_are_identical() { + let cases = [ + ("track-1", "insert-7", 1u8), + ("vsti-out:abc:bus:3", "slot-0", 32), + ("", "", 0), + ]; + let mut buffer = String::new(); + for (track_id, insert_id, channel) in cases { + write_vsti_output_meter_key(&mut buffer, track_id, insert_id, channel); + assert_eq!( + buffer, + vsti_output_meter_key(track_id, insert_id, channel), + "key mismatch for {track_id}/{insert_id}/{channel}" + ); + } + } + + /// Reusing one buffer must never leak the previous key's tail. + #[test] + fn reused_buffer_never_keeps_a_longer_previous_key() { + let mut buffer = String::new(); + write_vsti_output_meter_key(&mut buffer, "a-very-long-track-id", "insert-12", 31); + write_vsti_output_meter_key(&mut buffer, "t", "i", 1); + assert_eq!(buffer, "t:i:1"); + } + + /// Liveness is now a generation stamp rather than a set of live keys. + /// A meter published this tick is live; one that stopped publishing must + /// fall through to the decay branch exactly as before. + #[test] + fn generation_stamp_separates_live_from_stale_entries() { + let generation = 41u64; + let live = VstiOutputMeterState { + level: 0.5, + peak_hold: 0.5, + clip: false, + last_seen: generation, + }; + let stale = VstiOutputMeterState { + level: 0.5, + peak_hold: 0.5, + clip: false, + last_seen: generation - 1, + }; + assert!(live.last_seen == generation, "published this tick"); + assert!(stale.last_seen != generation, "not published this tick"); + // A fresh entry starts stale, so it only survives once the meter loop + // stamps it — matching `or_default()` + live-set insertion before. + assert_eq!(VstiOutputMeterState::default().last_seen, 0); + } +} + #[cfg(test)] mod mixer_scrollbar_tests { use super::{MixerScrollbarGeometry, MIXER_SCROLLBAR_MIN_THUMB, STRIP_WIDTH}; diff --git a/crates/SphereUIComponents/src/components/performance_overlay.rs b/crates/SphereUIComponents/src/components/performance_overlay.rs index 88ac97a1..a4b60c0b 100644 --- a/crates/SphereUIComponents/src/components/performance_overlay.rs +++ b/crates/SphereUIComponents/src/components/performance_overlay.rs @@ -12,6 +12,15 @@ pub struct PerformanceOverlaySnapshot { pub has_sample: bool, pub repaint_reason: String, pub audio: String, + /// Most expensive instrumented scopes this window, worst first. Answers + /// "where is the frame going" directly on screen, instead of requiring an + /// env var and a log file. + pub top_scopes: Vec, + /// Instrumented CPU per frame from the last completed perf window. + pub ui_cpu_ms: f32, + /// File timestamp of the running executable, so the panel proves which + /// build produced the numbers next to it. + pub build_stamp: String, } pub fn performance_overlay(snapshot: &PerformanceOverlaySnapshot) -> impl IntoElement { @@ -55,7 +64,266 @@ pub fn performance_overlay(snapshot: &PerformanceOverlaySnapshot) -> impl IntoEl overlay_line("Frame", &frame), overlay_line("Peak", &peak), overlay_line("Repaint", &snapshot.repaint_reason), + overlay_line("Build", &snapshot.build_stamp), ]) + .children(frame_accounting_rows(snapshot.ui_cpu_ms, snapshot.frame_ms)) + .children(hot_scope_rows(&snapshot.top_scopes)) +} + +/// Split the frame into "code we measured" and "everything else". +/// +/// Without this the scope list is easy to misread: four scopes at 0.1 ms next +/// to a 40 ms frame looks like the profiler is broken, when it is actually the +/// finding — the thread is stalled outside instrumented code. +/// +/// `frame_ms` comes from the overlay's own frame diagnostics, never from the +/// perf collector: this block must appear even when the collector has thin +/// data, because that is exactly the case it explains. +fn frame_accounting_rows(cpu_ms: f32, frame_ms: f32) -> Vec { + let profile = gpui::frame_profile::frame_profile(); + let pct = |ms: f32| { + if frame_ms > 0.0 { + 100.0 * ms / frame_ms + } else { + 0.0 + } + }; + let draw_ms = profile.draw_ms(); + let present_ms = profile.present_ms(); + let unaccounted = (frame_ms - cpu_ms - draw_ms - present_ms).max(0.0); + + vec![ + section_label("Frame breakdown"), + overlay_scope_row( + "UI CPU", + &format!("{cpu_ms:.2} ms {:.0}%", pct(cpu_ms)), + pct(cpu_ms), + ) + .into_any_element(), + // `draw` covers building, laying out, and painting the element tree; + // `present` is handing the finished scene to the GPU. + overlay_scope_row( + "GPUI draw", + &format!("{draw_ms:.2} ms {:.0}%", pct(draw_ms)), + pct(draw_ms), + ) + .into_any_element(), + overlay_scope_row( + "GPUI present", + &format!("{present_ms:.2} ms {:.0}%", pct(present_ms)), + pct(present_ms), + ) + .into_any_element(), + overlay_scope_row( + "Unaccounted", + &format!("{unaccounted:.2} ms {:.0}%", pct(unaccounted)), + pct(unaccounted), + ) + .into_any_element(), + // The draw split. `prepaint` builds the element tree and lays it out + // (the app's own render functions run inside it); `paint` walks the + // laid-out tree emitting primitives; `a11y` rebuilds the accessibility + // tree when a client has switched it on. + section_label("Draw split"), + overlay_scope_row( + "prepaint+layout", + &format!( + "{:.2} ms {:.0}%", + profile.prepaint_ms(), + pct(profile.prepaint_ms()) + ), + pct(profile.prepaint_ms()), + ) + .into_any_element(), + overlay_scope_row( + "paint", + &format!( + "{:.2} ms {:.0}%", + profile.paint_ms(), + pct(profile.paint_ms()) + ), + pct(profile.paint_ms()), + ) + .into_any_element(), + overlay_scope_row( + "a11y tree", + &format!( + "{:.2} ms {:.0}%", + profile.a11y_ms(), + pct(profile.a11y_ms()) + ), + pct(profile.a11y_ms()), + ) + .into_any_element(), + overlay_scope_row( + "Text shape", + &format!( + "{:.2} ms {:.0}% x{}", + profile.shape_ms(), + pct(profile.shape_ms()), + profile.shape_misses + ), + pct(profile.shape_ms()), + ) + .into_any_element(), + // Inside prepaint: how much is the layout solve itself, and how much of + // that is measure callbacks. Taffy runs several sizing passes, so a + // measure count far above the node count is the thing to fix. + overlay_scope_row( + "layout solve", + &format!( + "{:.2} ms {:.0}%", + profile.layout_solve_ms(), + pct(profile.layout_solve_ms()) + ), + pct(profile.layout_solve_ms()), + ) + .into_any_element(), + overlay_scope_row( + "measure", + &format!( + "{:.2} ms {:.0}% x{}", + profile.measure_ms(), + pct(profile.measure_ms()), + profile.measure_calls + ), + pct(profile.measure_ms()), + ) + .into_any_element(), + // Layout nodes, not primitives, is what prepaint cost scales with. + overlay_line( + "Nodes / prims", + &format!("{} / {}", profile.layout_nodes, profile.scene_primitives), + ) + .into_any_element(), + ] +} + +fn section_label(text: &'static str) -> gpui::AnyElement { + div() + .pt(px(6.0)) + .mt(px(4.0)) + .border_t(px(1.0)) + .border_color(Colors::border_subtle()) + .text_size(px(10.0)) + .text_color(Colors::text_muted()) + .child(text) + .into_any_element() +} + +/// "Where the frame went" rows. Hidden entirely until the collector has +/// something, so the panel keeps its compact shape on an idle window. +fn hot_scope_rows(scopes: &[crate::perf::ScopeSample]) -> Vec { + if scopes.is_empty() { + return Vec::new(); + } + let mut rows: Vec = Vec::with_capacity(scopes.len() + 1); + rows.push( + div() + .pt(px(6.0)) + .mt(px(4.0)) + .border_t(px(1.0)) + .border_color(Colors::border_subtle()) + .text_size(px(10.0)) + .text_color(Colors::text_muted()) + .child("Hot scopes (ms/s)") + .into_any_element(), + ); + for scope in scopes { + rows.push( + overlay_scope_row( + scope.name, + &format!( + "{:.1} ms {:.0}% x{}", + scope.total_ms, scope.percent, scope.count + ), + scope.percent, + ) + .into_any_element(), + ); + } + rows +} + +#[cfg(test)] +mod tests { + use super::{frame_accounting_rows, hot_scope_rows}; + use crate::perf::ScopeSample; + + /// The breakdown is the block that explains a profiler which otherwise + /// looks broken, so it must render unconditionally — including before any + /// perf window has completed (cpu 0.0) and on a degenerate frame time. + #[test] + fn breakdown_always_renders_every_row() { + // Frame breakdown: label + UI CPU + draw + present + unaccounted. + // Draw split: label + prepaint + paint + a11y + text + solve + measure + // + node counts. + const EXPECTED_ROWS: usize = 13; + assert_eq!(frame_accounting_rows(0.2, 40.0).len(), EXPECTED_ROWS); + assert_eq!(frame_accounting_rows(0.0, 0.0).len(), EXPECTED_ROWS); + // A frame cheaper than the measured CPU (clock jitter) must not + // produce a negative remainder or panic. + assert_eq!(frame_accounting_rows(5.0, 1.0).len(), EXPECTED_ROWS); + } + + #[test] + fn no_scope_rows_when_the_collector_has_nothing() { + assert!(hot_scope_rows(&[]).is_empty(), "panel stays compact"); + } + + /// One header row plus one row per scope, so the panel height is + /// predictable and the rows cannot silently disappear. + #[test] + fn scope_rows_are_header_plus_one_per_scope() { + let scopes = vec![ + ScopeSample { + name: "poll_native_audio", + total_ms: 480.0, + percent: 62.0, + count: 140, + }, + ScopeSample { + name: "Timeline", + total_ms: 40.0, + percent: 5.0, + count: 60, + }, + ]; + assert_eq!(hot_scope_rows(&scopes).len(), scopes.len() + 1); + } +} + +fn overlay_scope_row(label: &str, value: &str, percent: f32) -> impl IntoElement { + // A scope taking most of the window is the answer, so colour it like one. + let value_color = if percent >= 50.0 { + Colors::status_error() + } else if percent >= 25.0 { + Colors::accent_warning() + } else { + Colors::text_secondary() + }; + div() + .flex() + .flex_row() + .items_start() + .justify_between() + .gap(px(8.0)) + .child( + div() + .w(px(112.0)) + .text_size(px(10.0)) + .text_color(Colors::text_muted()) + .truncate() + .child(label.to_string()), + ) + .child( + div() + .flex_1() + .min_w(px(0.0)) + .text_size(px(10.0)) + .text_color(value_color) + .child(value.to_string()), + ) } fn overlay_title(text: &'static str) -> impl IntoElement { diff --git a/crates/SphereUIComponents/src/components/timeline/audio_clip.rs b/crates/SphereUIComponents/src/components/timeline/audio_clip.rs index 544a57b0..8d91253a 100644 --- a/crates/SphereUIComponents/src/components/timeline/audio_clip.rs +++ b/crates/SphereUIComponents/src/components/timeline/audio_clip.rs @@ -445,14 +445,12 @@ pub fn audio_clip( edge: ClipEdge::Left, start_beat: clip.start_beat, duration_beats: clip.duration_beats, - original: clip.clone(), }; let resize_right = ClipResizeDrag { clip_id: clip.id.clone(), edge: ClipEdge::Right, start_beat: clip.start_beat, duration_beats: clip.duration_beats, - original: clip.clone(), }; const RESIZE_HANDLE_W: f32 = 6.0; const HEADER_H: f32 = 20.0; diff --git a/crates/SphereUIComponents/src/components/timeline/automation_lane.rs b/crates/SphereUIComponents/src/components/timeline/automation_lane.rs index 60d58a03..83194a44 100644 --- a/crates/SphereUIComponents/src/components/timeline/automation_lane.rs +++ b/crates/SphereUIComponents/src/components/timeline/automation_lane.rs @@ -1,6 +1,7 @@ use crate::components::timeline::timeline_state::{ automation_value_to_y, automation_y_to_value, evaluate_automation, AutomationHover, - AutomationLaneState, AutomationMarquee, AutomationTarget, TimelineState, HEADER_WIDTH, + AutomationLaneState, AutomationMarquee, AutomationTarget, TimelineGestureContext, + TimelineState, HEADER_WIDTH, }; use crate::theme::Colors; use gpui::{ @@ -102,6 +103,7 @@ pub fn automation_lane( lane_y_abs: f32, lane_height: f32, state: &TimelineState, + gesture: &std::rc::Rc, on_automation_down: Option, on_lane_action: Option, on_automation_hover: Option, @@ -302,7 +304,9 @@ pub fn automation_lane( }; let interaction = on_automation_down.clone().map(|cb| { - let state_for = state.clone(); + // Per-frame geometry snapshot, not a full project clone — see + // [`TimelineGestureContext`]. + let state_for = std::rc::Rc::clone(gesture); let tid = track_id.clone(); let lid = lane_id.clone(); let mut hit = div() @@ -350,7 +354,7 @@ pub fn automation_lane( // clear it when the cursor leaves the lane. Same snapped beat as the click // path so the hovered target matches what a click would grab. if let Some(hover_cb) = on_automation_hover.clone() { - let state_for = state.clone(); + let state_for = std::rc::Rc::clone(gesture); let tid = track_id.clone(); let lid = lane_id.clone(); hit = hit.on_mouse_move(move |event: &gpui::MouseMoveEvent, window, cx| { diff --git a/crates/SphereUIComponents/src/components/timeline/midi_clip.rs b/crates/SphereUIComponents/src/components/timeline/midi_clip.rs index 2f113e7d..8698d39b 100644 --- a/crates/SphereUIComponents/src/components/timeline/midi_clip.rs +++ b/crates/SphereUIComponents/src/components/timeline/midi_clip.rs @@ -1,6 +1,11 @@ +use crate::components::timeline::render::clip_geometry::{ + build_controller_preview, build_note_preview, controller_preview_band_h, + midi_controller_default_value, midi_controller_kind_label, visible_clip_px_range, + ControllerPreview, NotePreview, +}; use crate::components::timeline::timeline_state::{ midi_debug_enabled, ClipDragItem, ClipEdge, ClipResizeDrag, ClipState, ClipType, - MidiControllerKind, MidiControllerPoint, MidiNoteState, TimelineState, + MidiControllerKind, TimelineState, }; use crate::theme::Colors; use gpui::{ @@ -45,6 +50,11 @@ pub fn midi_clip( // Draw notes and controller previews with canvases instead of one GPUI element // per note. Dense imported MIDI can contain thousands of notes per clip; the // canvas path keeps render cost proportional to visible pixels, not event count. + // + // The preview geometry is resolved here, during element build, and the canvas + // closures own only that resolved geometry. Handing them the note/controller + // vectors instead meant a full clone of every note in the clip on every + // repaint — and the arrangement repaints on each playback tick. let mut note_elements: Vec = Vec::new(); let clip_len = clip.duration_beats; if let ClipType::Midi { @@ -53,33 +63,30 @@ pub fn midi_clip( .. } = &clip.clip_type { - let in_bounds: Vec = notes - .iter() - .filter(|n| n.start < clip_len && n.start + n.duration > 0.0) - .cloned() - .collect(); let ppb = pixels_per_second * seconds_per_beat; - let preview_count = in_bounds.len(); - if !in_bounds.is_empty() { + // Only the on-screen slice of the clip needs quads. A clip that spans the + // whole arrangement is mostly scrolled out of view, and the lane already + // clips it — building spans for the hidden part was pure waste. + let visible_px = visible_clip_px_range(left, width, state.viewport.viewport_width); + let preview = visible_px.and_then(|(px_start, px_end)| { + build_note_preview(notes, clip_len, ppb, px_start, px_end) + }); + let preview_count = preview.as_ref().map(|p| p.note_count).unwrap_or(0); + if let Some(preview) = preview { note_elements.push( - midi_note_preview_canvas(in_bounds, clip_len, ppb, track_color) + midi_note_preview_canvas(preview, track_color) .absolute() .inset_0() .into_any_element(), ); } - let controller_preview_lanes: Vec<(MidiControllerKind, Vec)> = - controller_lanes - .iter() - .filter(|lane| lane.visible && !lane.points.is_empty()) - .take(3) - .map(|lane| (lane.kind, lane.points.clone())) - .collect(); - if !controller_preview_lanes.is_empty() { - let lane_count = controller_preview_lanes.len(); + let controller_preview = build_controller_preview(controller_lanes, clip_len, ppb, width); + if let Some(controller_preview) = controller_preview { + let lane_kinds = controller_preview.lane_kinds.clone(); + let lane_count = lane_kinds.len(); note_elements.push( - midi_controller_preview_canvas(controller_preview_lanes.clone(), clip_len, ppb) + midi_controller_preview_canvas(controller_preview) .absolute() .inset_0() .into_any_element(), @@ -88,7 +95,7 @@ pub fn midi_clip( let band_h = controller_preview_band_h(note_h, lane_count); let row_h = (band_h / lane_count as f32).max(4.0); let band_top = (note_h - band_h - 1.0).max(1.0); - for (idx, (kind, _)) in controller_preview_lanes.iter().enumerate() { + for (idx, kind) in lane_kinds.iter().enumerate() { note_elements.push( div() .absolute() @@ -134,14 +141,12 @@ pub fn midi_clip( edge: ClipEdge::Left, start_beat: clip.start_beat, duration_beats: clip.duration_beats, - original: clip.clone(), }; let resize_right = ClipResizeDrag { clip_id: clip.id.clone(), edge: ClipEdge::Right, start_beat: clip.start_beat, duration_beats: clip.duration_beats, - original: clip.clone(), }; const RESIZE_HANDLE_W: f32 = 6.0; @@ -284,99 +289,43 @@ pub fn midi_clip( ) } -fn midi_note_preview_canvas( - notes: Vec, - clip_len: f32, - ppb: f32, - track_color: gpui::Rgba, -) -> gpui::Canvas<()> { +fn midi_note_preview_canvas(preview: NotePreview, track_color: gpui::Rgba) -> gpui::Canvas<()> { canvas( |_bounds, _window, _cx| {}, move |bounds: Bounds, (), window, _cx| { let width: f32 = bounds.size.width.into(); let height: f32 = bounds.size.height.into(); - if notes.is_empty() || width <= 1.0 || height <= 16.0 || ppb <= 0.0 { + if width <= 1.0 || height <= 16.0 { return; } let note_area_h = (height - 14.0).max(1.0); - let visible_start_beat = 0.0_f32; - let visible_end_beat = (width / ppb).min(clip_len).max(0.0); - let visible_notes: Vec<_> = notes - .iter() - .filter(|note| { - note.start < visible_end_beat && note.start + note.duration > visible_start_beat - }) - .collect(); - if visible_notes.is_empty() { - return; - } - - let lo = visible_notes.iter().map(|n| n.pitch).min().unwrap_or(48); - let hi = visible_notes.iter().map(|n| n.pitch).max().unwrap_or(72); - let top_pitch = hi.saturating_add(2).min(127); - let bottom_pitch = lo.saturating_sub(2); - let pitch_range = (top_pitch as i32 - bottom_pitch as i32).max(12) as f32; + let span = (note_area_h - 4.0).max(1.0); + let note_h = if note_area_h < 30.0 { 1.4 } else { 2.0 }; + let y_of = |norm: f32| (1.0 - norm) * span + 1.0; let mut note_color = track_color; note_color.a = 0.86; - let min_note_w = if ppb < 3.0 { 1.0 } else { 2.0 }; - let note_h = if note_area_h < 30.0 { 1.4 } else { 2.0 }; - // Very dense/zoomed-out MIDI can map many notes to the same pixel. - // Coalesce to one vertical span per x-column so paint calls stay bounded - // by clip width rather than note count while preserving the musical mass. - if ppb < 5.0 || visible_notes.len() > width as usize * 3 { - let columns = width.ceil().clamp(1.0, 2400.0) as usize; - let mut spans: Vec> = vec![None; columns]; - for note in visible_notes { - let visible_start = note.start.max(0.0); - let visible_end = (note.start + note.duration).min(clip_len); - if visible_end <= visible_start { - continue; - } - let x0 = (visible_start * ppb) - .floor() - .clamp(0.0, (columns - 1) as f32) as usize; - let x1 = (visible_end * ppb) - .ceil() - .clamp(x0 as f32, (columns - 1) as f32) - as usize; - let norm_pitch = (note.pitch as i32 - bottom_pitch as i32) as f32 / pitch_range; - let y = (1.0 - norm_pitch) * (note_area_h - 4.0) + 1.0; - for cell in &mut spans[x0..=x1] { - *cell = Some(match *cell { - Some((top, bottom)) => (top.min(y), bottom.max(y + note_h)), - None => (y, y + note_h), - }); - } - } - for (col, span) in spans.into_iter().enumerate() { - if let Some((top, bottom)) = span { - window.paint_quad(fill( - Bounds::new( - bounds.origin + point(px(col as f32), px(top)), - size(px(1.0), px((bottom - top).max(1.0))), - ), - note_color, - )); - } - } - return; + for column in &preview.columns { + // `highest_norm` is the top of the span, so it yields the smaller y. + let top = y_of(column.highest_norm); + let bottom = y_of(column.lowest_norm) + note_h; + window.paint_quad(fill( + Bounds::new( + bounds.origin + point(px(column.x), px(top)), + size(px(1.0), px((bottom - top).max(1.0))), + ), + note_color, + )); } - for note in visible_notes { - let visible_end = (note.start + note.duration).min(clip_len); - let visible_start = note.start.max(0.0); - if visible_end <= visible_start { - continue; - } - let x = visible_start * ppb; - let w = ((visible_end - visible_start) * ppb).max(min_note_w); - let norm_pitch = (note.pitch as i32 - bottom_pitch as i32) as f32 / pitch_range; - let y = (1.0 - norm_pitch) * (note_area_h - 4.0) + 1.0; + for quad in &preview.quads { window.paint_quad(fill( - Bounds::new(bounds.origin + point(px(x), px(y)), size(px(w), px(note_h))), + Bounds::new( + bounds.origin + point(px(quad.x), px(y_of(quad.norm_pitch))), + size(px(quad.width), px(note_h)), + ), note_color, )); } @@ -384,34 +333,26 @@ fn midi_note_preview_canvas( ) } -fn midi_controller_preview_canvas( - lanes: Vec<(MidiControllerKind, Vec)>, - clip_len: f32, - ppb: f32, -) -> gpui::Canvas<()> { +fn midi_controller_preview_canvas(preview: ControllerPreview) -> gpui::Canvas<()> { canvas( |_bounds, _window, _cx| {}, move |bounds: Bounds, (), window, _cx| { - if lanes.is_empty() { - return; - } let width: f32 = bounds.size.width.into(); let height: f32 = bounds.size.height.into(); if width <= 1.0 || height <= 6.0 { return; } - let lane_count = lanes.len(); + let lane_count = preview.lane_kinds.len(); let band_h = controller_preview_band_h(height, lane_count); let row_h = (band_h / lane_count as f32).max(4.0); let band_top = (height - band_h - 1.0).max(1.0); - let columns = width.ceil().clamp(1.0, 1200.0) as usize; - let step_px = (width / columns as f32).max(1.0); + let usable = (row_h - 2.0).max(1.0); - for (lane_idx, (kind, points)) in lanes.iter().enumerate() { + for (lane_idx, kind) in preview.lane_kinds.iter().enumerate() { let row_top = band_top + lane_idx as f32 * row_h; let default_value = midi_controller_default_value(*kind); - let baseline_y = row_top + (1.0 - default_value) * (row_h - 2.0).max(1.0) + 1.0; + let baseline_y = row_top + (1.0 - default_value) * usable + 1.0; let mut line_color = match kind { MidiControllerKind::PitchBend => Colors::accent_purple(), MidiControllerKind::CC(_) => Colors::automation_curve(), @@ -431,29 +372,18 @@ fn midi_controller_preview_canvas( baseline_color, )); + let values = &preview.lane_values[lane_idx]; let mut prev_y: Option = None; - let mut point_index = 0usize; - for col in 0..=columns { - let x = (col as f32 * step_px).min(width); - let beat = if ppb <= 0.0 { - 0.0 - } else { - (x / ppb).clamp(0.0, clip_len.max(0.0)) - }; - let value = evaluate_midi_controller_points_cursor( - points, - beat, - default_value, - &mut point_index, - ); - let y = row_top + (1.0 - value) * (row_h - 2.0).max(1.0) + 1.0; + for col in 0..=preview.columns { + let x = (col as f32 * preview.step_px).min(preview.width); + let y = row_top + (1.0 - values[col]) * usable + 1.0; if let Some(prev) = prev_y { let top = prev.min(y); let h = (prev - y).abs().max(1.4); window.paint_quad(fill( Bounds::new( bounds.origin + point(px(x), px(top)), - size(px(step_px), px(h)), + size(px(preview.step_px), px(h)), ), line_color, )); @@ -464,60 +394,3 @@ fn midi_controller_preview_canvas( }, ) } - -fn controller_preview_band_h(height: f32, lane_count: usize) -> f32 { - let min_needed = (lane_count as f32 * 6.0).max(8.0); - (height * 0.44).clamp(min_needed, 30.0).min(height.max(1.0)) -} - -fn midi_controller_default_value(kind: MidiControllerKind) -> f32 { - match kind { - MidiControllerKind::PitchBend => 0.5, - MidiControllerKind::CC(_) - | MidiControllerKind::ChannelPressure - | MidiControllerKind::PolyPressure => 0.0, - } -} - -fn evaluate_midi_controller_points_cursor( - points: &[MidiControllerPoint], - beat: f32, - default_value: f32, - point_index: &mut usize, -) -> f32 { - if points.is_empty() { - return default_value.clamp(0.0, 1.0); - } - let beat = beat.max(0.0); - if beat <= points[0].beat { - *point_index = 0; - return points[0].value.clamp(0.0, 1.0); - } - let last = points.len() - 1; - if beat >= points[last].beat { - *point_index = last.saturating_sub(1); - return points[last].value.clamp(0.0, 1.0); - } - - while *point_index + 1 < points.len() && beat > points[*point_index + 1].beat { - *point_index += 1; - } - while *point_index > 0 && beat < points[*point_index].beat { - *point_index -= 1; - } - let next = (*point_index + 1).min(last); - let a = &points[*point_index]; - let b = &points[next]; - let span = (b.beat - a.beat).max(1.0e-6); - let t = ((beat - a.beat) / span).clamp(0.0, 1.0); - (a.value + (b.value - a.value) * t).clamp(0.0, 1.0) -} - -fn midi_controller_kind_label(kind: MidiControllerKind) -> String { - match kind { - MidiControllerKind::CC(number) => format!("CC{}", number), - MidiControllerKind::PitchBend => "PB".to_string(), - MidiControllerKind::ChannelPressure => "AT".to_string(), - MidiControllerKind::PolyPressure => "PAT".to_string(), - } -} diff --git a/crates/SphereUIComponents/src/components/timeline/render/clip_geometry.rs b/crates/SphereUIComponents/src/components/timeline/render/clip_geometry.rs new file mode 100644 index 00000000..47f3a74e --- /dev/null +++ b/crates/SphereUIComponents/src/components/timeline/render/clip_geometry.rs @@ -0,0 +1,363 @@ +//! Paint-ready clip visuals, resolved from clip data. +//! +//! This is the single source of truth for *what a clip looks like*, shared by +//! the interactive GPUI element path and the snapshot painter. Keeping one +//! implementation is what lets the two backends stay pixel-identical while the +//! arrangement moves from an element tree to a painted surface. +//! +//! Everything here is pure: clip data in, geometry out. No GPUI, no theme +//! lookups, no allocation per note — so it is unit-testable and cheap enough to +//! run on every repaint. + +use crate::components::timeline::timeline_state::{ + MidiControllerKind, MidiControllerLane, MidiNoteState, +}; + +/// Clip-local pixel window that is actually on screen, or `None` when the clip +/// is fully scrolled out. `left` is the clip's x in lane coordinates. +pub fn visible_clip_px_range(left: f32, width: f32, viewport_width: f32) -> Option<(f32, f32)> { + let lane_w = viewport_width.max(1.0); + let start = (-left).max(0.0); + let end = (lane_w - left).min(width); + (end > start).then_some((start, end)) +} + +/// One horizontal pixel column of coalesced note mass. Pitches are normalized +/// (0 = bottom of the clip's pitch span, 1 = top) so the paint pass can map them +/// against the real canvas height without re-reading the notes. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct NoteColumn { + pub x: f32, + pub lowest_norm: f32, + pub highest_norm: f32, +} + +/// A single note quad, used at zoom levels where notes are individually visible. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct NoteQuad { + pub x: f32, + pub width: f32, + pub norm_pitch: f32, +} + +/// Resolved note-preview geometry for one clip. Size is bounded by the clip's +/// visible pixel width, never by its note count. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct NotePreview { + pub columns: Vec, + pub quads: Vec, + /// Notes inside the clip bounds, for diagnostics only. + pub note_count: usize, +} + +/// Collapse a clip's notes into paintable geometry. +/// +/// One allocation-free pass over the notes. Raw MIDI pitches are accumulated +/// into the output slots and normalized afterwards against the clip's full +/// pitch span, which keeps the whole thing single-pass while still mapping +/// pitch from the *entire* clip — so the preview does not shift vertically as +/// the clip scrolls in and out of view. +pub fn build_note_preview( + notes: &[MidiNoteState], + clip_len: f32, + ppb: f32, + px_start: f32, + px_end: f32, +) -> Option { + if notes.is_empty() || ppb <= 0.0 || px_end <= px_start { + return None; + } + + let visible_start_beat = (px_start / ppb).max(0.0); + let visible_end_beat = (px_end / ppb).min(clip_len).max(0.0); + let visible_width = px_end - px_start; + + // Very dense / zoomed-out MIDI maps many notes to the same pixel. Coalesce to + // one vertical span per x-column so paint calls stay bounded by clip width + // rather than note count while preserving the musical mass. `notes.len()` is + // the upper bound on how many land in the window; this is a density heuristic, + // so the bound is as good as the exact count and costs no extra pass. + let dense = ppb < 5.0 || notes.len() > (visible_width as usize).saturating_mul(3); + let columns = visible_width.ceil().clamp(1.0, 2400.0) as usize; + let mut spans: Vec> = if dense { + vec![None; columns] + } else { + Vec::new() + }; + let mut raw_quads: Vec<(f32, f32, u8)> = Vec::new(); + let min_note_w = if ppb < 3.0 { 1.0 } else { 2.0 }; + + let mut lo = u8::MAX; + let mut hi = 0u8; + let mut in_bounds = 0usize; + for note in notes { + let start = note.start.max(0.0); + let end = (note.start + note.duration).min(clip_len); + if note.start >= clip_len || note.start + note.duration <= 0.0 || end <= start { + continue; + } + // Pitch span covers the whole clip, not just the visible window. + in_bounds += 1; + lo = lo.min(note.pitch); + hi = hi.max(note.pitch); + + if start >= visible_end_beat || end <= visible_start_beat { + continue; + } + if dense { + let x0 = ((start * ppb) - px_start) + .floor() + .clamp(0.0, (columns - 1) as f32) as usize; + let x1 = ((end * ppb) - px_start) + .ceil() + .clamp(x0 as f32, (columns - 1) as f32) as usize; + for cell in &mut spans[x0..=x1] { + *cell = Some(match *cell { + Some((low, high)) => (low.min(note.pitch), high.max(note.pitch)), + None => (note.pitch, note.pitch), + }); + } + } else { + raw_quads.push(( + start * ppb, + ((end - start) * ppb).max(min_note_w), + note.pitch, + )); + } + } + if in_bounds == 0 { + return None; + } + + let top_pitch = hi.saturating_add(2).min(127); + let bottom_pitch = lo.saturating_sub(2); + let pitch_range = (top_pitch as i32 - bottom_pitch as i32).max(12) as f32; + let norm_of = |pitch: u8| (pitch as i32 - bottom_pitch as i32) as f32 / pitch_range; + + let columns: Vec = spans + .into_iter() + .enumerate() + .filter_map(|(col, span)| { + span.map(|(low, high)| NoteColumn { + x: px_start + col as f32, + lowest_norm: norm_of(low), + highest_norm: norm_of(high), + }) + }) + .collect(); + let quads: Vec = raw_quads + .into_iter() + .map(|(x, width, pitch)| NoteQuad { + x, + width, + norm_pitch: norm_of(pitch), + }) + .collect(); + if columns.is_empty() && quads.is_empty() { + return None; + } + Some(NotePreview { + columns, + quads, + note_count: in_bounds, + }) +} + +/// Resolved controller-lane geometry: one normalized value per sampled column, +/// so the paint pass never touches the (potentially very dense) point lists. +#[derive(Debug, Clone, PartialEq)] +pub struct ControllerPreview { + pub lane_kinds: Vec, + /// Per lane, `columns + 1` normalized values sampled left to right. + pub lane_values: Vec>, + pub columns: usize, + pub step_px: f32, + pub width: f32, +} + +pub fn build_controller_preview( + lanes: &[MidiControllerLane], + clip_len: f32, + ppb: f32, + width: f32, +) -> Option { + if width <= 1.0 { + return None; + } + let columns = width.ceil().clamp(1.0, 1200.0) as usize; + let step_px = (width / columns as f32).max(1.0); + + let mut lane_kinds = Vec::new(); + let mut lane_values = Vec::new(); + for lane in lanes + .iter() + .filter(|lane| lane.visible && !lane.points.is_empty()) + .take(3) + { + let default_value = midi_controller_default_value(lane.kind); + let mut values = Vec::with_capacity(columns + 1); + let mut point_index = 0usize; + for col in 0..=columns { + let x = (col as f32 * step_px).min(width); + let beat = if ppb <= 0.0 { + 0.0 + } else { + (x / ppb).clamp(0.0, clip_len.max(0.0)) + }; + values.push(evaluate_midi_controller_points_cursor( + &lane.points, + beat, + default_value, + &mut point_index, + )); + } + lane_kinds.push(lane.kind); + lane_values.push(values); + } + + (!lane_kinds.is_empty()).then_some(ControllerPreview { + lane_kinds, + lane_values, + columns, + step_px, + width, + }) +} + +pub fn controller_preview_band_h(height: f32, lane_count: usize) -> f32 { + let min_needed = (lane_count as f32 * 6.0).max(8.0); + (height * 0.44).clamp(min_needed, 30.0).min(height.max(1.0)) +} + +pub fn midi_controller_default_value(kind: MidiControllerKind) -> f32 { + match kind { + MidiControllerKind::PitchBend => 0.5, + MidiControllerKind::CC(_) + | MidiControllerKind::ChannelPressure + | MidiControllerKind::PolyPressure => 0.0, + } +} + +/// Sample a controller lane at `beat`, advancing `point_index` as a cursor. +/// +/// The cursor makes a left-to-right sweep linear in the number of points rather +/// than `columns * points`, which matters for imported CC lanes with thousands +/// of events. +pub fn evaluate_midi_controller_points_cursor( + points: &[crate::components::timeline::timeline_state::MidiControllerPoint], + beat: f32, + default_value: f32, + point_index: &mut usize, +) -> f32 { + if points.is_empty() { + return default_value.clamp(0.0, 1.0); + } + let beat = beat.max(0.0); + if beat <= points[0].beat { + *point_index = 0; + return points[0].value.clamp(0.0, 1.0); + } + let last = points.len() - 1; + if beat >= points[last].beat { + *point_index = last.saturating_sub(1); + return points[last].value.clamp(0.0, 1.0); + } + + while *point_index + 1 < points.len() && beat > points[*point_index + 1].beat { + *point_index += 1; + } + while *point_index > 0 && beat < points[*point_index].beat { + *point_index -= 1; + } + let next = (*point_index + 1).min(last); + let a = &points[*point_index]; + let b = &points[next]; + let span = (b.beat - a.beat).max(1.0e-6); + let t = ((beat - a.beat) / span).clamp(0.0, 1.0); + (a.value + (b.value - a.value) * t).clamp(0.0, 1.0) +} + +pub fn midi_controller_kind_label(kind: MidiControllerKind) -> String { + match kind { + MidiControllerKind::CC(number) => format!("CC{}", number), + MidiControllerKind::PitchBend => "PB".to_string(), + MidiControllerKind::ChannelPressure => "AT".to_string(), + MidiControllerKind::PolyPressure => "PAT".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn note(pitch: u8, start: f32, duration: f32) -> MidiNoteState { + MidiNoteState::new(pitch, start, duration, 100) + } + + #[test] + fn offscreen_clips_build_no_preview_geometry() { + assert_eq!(visible_clip_px_range(-500.0, 200.0, 1000.0), None); + assert_eq!(visible_clip_px_range(1200.0, 200.0, 1000.0), None); + } + + #[test] + fn partially_visible_clip_is_clipped_to_the_lane() { + let (start, end) = visible_clip_px_range(-100.0, 400.0, 300.0).expect("partially visible"); + assert_eq!((start, end), (100.0, 400.0)); + let (start, end) = visible_clip_px_range(200.0, 400.0, 300.0).expect("partially visible"); + assert_eq!((start, end), (0.0, 100.0)); + } + + #[test] + fn dense_preview_stays_bounded_by_visible_pixels_not_note_count() { + let notes: Vec = (0..20_000) + .map(|i| note(48 + (i % 24) as u8, i as f32 * 0.01, 0.05)) + .collect(); + let preview = + build_note_preview(¬es, 200.0, 1.0, 0.0, 200.0).expect("notes produce a preview"); + assert!( + preview.quads.is_empty(), + "dense zoom coalesces into columns" + ); + assert!( + preview.columns.len() <= 200, + "columns bounded by visible width, got {}", + preview.columns.len() + ); + assert_eq!(preview.note_count, 20_000); + } + + #[test] + fn zoomed_in_preview_draws_one_quad_per_visible_note() { + let notes = vec![note(60, 0.0, 1.0), note(64, 1.0, 1.0), note(67, 2.0, 1.0)]; + let preview = + build_note_preview(¬es, 4.0, 40.0, 0.0, 160.0).expect("notes produce a preview"); + assert!(preview.columns.is_empty()); + assert_eq!(preview.quads.len(), 3); + assert_eq!(preview.quads[0].width, 40.0); + } + + #[test] + fn scrolling_culls_notes_without_shifting_the_pitch_mapping() { + let notes = vec![note(36, 0.0, 1.0), note(96, 100.0, 1.0)]; + let full = build_note_preview(¬es, 200.0, 10.0, 0.0, 2000.0).expect("preview"); + let scrolled = build_note_preview(¬es, 200.0, 10.0, 990.0, 1020.0).expect("preview"); + let high_in_full = full + .quads + .iter() + .map(|q| q.norm_pitch) + .fold(f32::MIN, f32::max); + assert_eq!(scrolled.quads.len(), 1, "only the high note is on screen"); + assert!( + (scrolled.quads[0].norm_pitch - high_in_full).abs() < 1.0e-6, + "pitch mapping must not depend on the scroll window" + ); + } + + #[test] + fn empty_and_degenerate_inputs_produce_no_preview() { + assert!(build_note_preview(&[], 4.0, 40.0, 0.0, 160.0).is_none()); + assert!(build_note_preview(&[note(60, 0.0, 1.0)], 4.0, 0.0, 0.0, 160.0).is_none()); + assert!(build_note_preview(&[note(60, 8.0, 1.0)], 4.0, 40.0, 0.0, 160.0).is_none()); + } +} diff --git a/crates/SphereUIComponents/src/components/timeline/render/mod.rs b/crates/SphereUIComponents/src/components/timeline/render/mod.rs index be94bd98..5ffb1841 100644 --- a/crates/SphereUIComponents/src/components/timeline/render/mod.rs +++ b/crates/SphereUIComponents/src/components/timeline/render/mod.rs @@ -7,6 +7,7 @@ //! Normal UI (menus, dialogs, headers, lanes as interactive GPUI elements) stays //! in GPUI; dense paint (grid, future clip/waveform batches) routes here. +pub mod clip_geometry; pub mod gpui_paint; pub mod renderer; pub mod snapshot; diff --git a/crates/SphereUIComponents/src/components/timeline/render/wgpu_renderer.rs b/crates/SphereUIComponents/src/components/timeline/render/wgpu_renderer.rs index 1a4a7c79..e2a11c0b 100644 --- a/crates/SphereUIComponents/src/components/timeline/render/wgpu_renderer.rs +++ b/crates/SphereUIComponents/src/components/timeline/render/wgpu_renderer.rs @@ -194,6 +194,84 @@ pub struct WgpuOffscreenFrame { pub texture: wgpu::Texture, } +/// Color target format. Non-sRGB so the snapshot's theme colors land in the +/// texture with the same numeric values GPUI paints, keeping the two backends +/// visually identical once the texture is composited. +const OFFSCREEN_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +/// Bytes per instance: `rect` (4 x f32) + `color` (4 x f32). +const QUAD_INSTANCE_SIZE: u64 = 32; + +/// Every arrangement-surface primitive is an axis-aligned rectangle, so the +/// whole pass is one instanced draw. `rect` is `(x, y, w, h)` in physical +/// pixels with the origin at the top-left of the arrangement body. +const ARRANGEMENT_SHADER: &str = r#" +struct Globals { + viewport: vec2, + _pad: vec2, +}; + +@group(0) @binding(0) var globals: Globals; + +struct Instance { + @location(0) rect: vec4, + @location(1) color: vec4, +}; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) color: vec4, +}; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32, instance: Instance) -> VertexOutput { + var corners = array, 6>( + vec2(0.0, 0.0), + vec2(1.0, 0.0), + vec2(0.0, 1.0), + vec2(1.0, 0.0), + vec2(1.0, 1.0), + vec2(0.0, 1.0), + ); + let corner = corners[vertex_index]; + let pixel = instance.rect.xy + corner * instance.rect.zw; + // Pixel space (y down) -> clip space (y up). + let ndc = vec2( + pixel.x / max(globals.viewport.x, 1.0) * 2.0 - 1.0, + 1.0 - pixel.y / max(globals.viewport.y, 1.0) * 2.0, + ); + + var out: VertexOutput; + out.position = vec4(ndc, 0.0, 1.0); + out.color = instance.color; + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + return in.color; +} +"#; + +/// Pipeline plus the buffers reused across frames. +struct ArrangementPipeline { + pipeline: wgpu::RenderPipeline, + globals: wgpu::Buffer, + globals_bind_group: wgpu::BindGroup, + instances: wgpu::Buffer, + /// Instance capacity of `instances`, in instances (not bytes). + capacity: u64, +} + +/// Cached offscreen color target. Recreated only when the arrangement body +/// changes size, so steady-state rendering allocates nothing. +struct OffscreenTarget { + texture: wgpu::Texture, + view: wgpu::TextureView, + width: u32, + height: u32, +} + pub struct WgpuTimelineRenderer { instance: wgpu::Instance, preference: TimelineGpuPreference, @@ -204,6 +282,11 @@ pub struct WgpuTimelineRenderer { queue: Option, max_texture_dimension_2d: u32, init_error: Option, + pipeline: Option, + target: Option, + /// Scratch instance bytes, reused so building a frame's quads does not + /// allocate once the buffer has grown to its working size. + instance_bytes: Vec, } impl WgpuTimelineRenderer { @@ -229,6 +312,9 @@ impl WgpuTimelineRenderer { queue: None, max_texture_dimension_2d: wgpu::Limits::downlevel_defaults().max_texture_dimension_2d, init_error: None, + pipeline: None, + target: None, + instance_bytes: Vec::new(), } } @@ -368,8 +454,6 @@ impl WgpuTimelineRenderer { snapshot: &TimelineRenderSnapshot, ) -> Result { self.ensure_device()?; - let device = self.device.as_ref().expect("device"); - let queue = self.queue.as_ref().expect("queue"); let width = snapshot.viewport.width.max(1.0) as u32; let height = snapshot.viewport.height.max(1.0) as u32; @@ -380,46 +464,46 @@ impl WgpuTimelineRenderer { width, height, max_texture_dimension_2d )); } - let format = wgpu::TextureFormat::Rgba8Unorm; + let format = OFFSCREEN_FORMAT; - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("timeline-offscreen"), - size: wgpu::Extent3d { - width, - height, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, - view_formats: &[], - }); + // Build this frame's quads before touching the GPU, so a snapshot that + // paints nothing still produces a correctly cleared target. + let mut instance_bytes = std::mem::take(&mut self.instance_bytes); + instance_bytes.clear(); + let instance_count = + build_arrangement_instances(snapshot, width as f32, height as f32, &mut instance_bytes); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + self.ensure_target(width, height)?; + self.ensure_pipeline(instance_count)?; + let device = self.device.as_ref().expect("device"); + let queue = self.queue.as_ref().expect("queue"); + let target = self.target.as_ref().expect("target"); + let pipeline = self.pipeline.as_ref().expect("pipeline"); - // Timeline arrangement background — matches `Colors::surface_base()` feel. - let bg = wgpu::Color { - r: 0.043, - g: 0.059, - b: 0.078, - a: 1.0, - }; + queue.write_buffer( + &pipeline.globals, + 0, + &globals_bytes(width as f32, height as f32), + ); + if instance_count > 0 { + queue.write_buffer(&pipeline.instances, 0, &instance_bytes); + } let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("timeline-arrangement"), }); { - let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("timeline-clear"), + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("timeline-arrangement-pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &view, + view: &target.view, depth_slice: None, resolve_target: None, ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(bg), + load: wgpu::LoadOp::Clear(rgba_to_wgpu( + crate::theme::Colors::timeline_content_background(), + )), store: wgpu::StoreOp::Store, }, })], @@ -428,18 +512,30 @@ impl WgpuTimelineRenderer { occlusion_query_set: None, multiview_mask: None, }); - // Scaffold: grid lines, lane fills, clip rects, and waveform chunks will be - // drawn here via instanced pipelines reading `TimelineRenderSnapshot` only. + if instance_count > 0 { + pass.set_pipeline(&pipeline.pipeline); + pass.set_bind_group(0, &pipeline.globals_bind_group, &[]); + pass.set_vertex_buffer( + 0, + pipeline + .instances + .slice(..instance_count * QUAD_INSTANCE_SIZE), + ); + pass.draw(0..6, 0..instance_count as u32); + } } queue.submit(Some(encoder.finish())); + crate::perf::count("gpu_quad_instances", instance_count); if gpu_debug_enabled() { eprintln!( - "[gpu-renderer] WgpuTimelineRenderer offscreen {}x{} grid={} clips={} waveform_handles={}", + "[gpu-renderer] WgpuTimelineRenderer offscreen {}x{} quads={} grid={} shades={} clips={} waveform_handles={}", width, height, + instance_count, snapshot.grid_lines.len(), + snapshot.bar_shades.len(), snapshot.clips.len(), snapshot .clips @@ -449,6 +545,8 @@ impl WgpuTimelineRenderer { ); } + let texture = target.texture.clone(); + self.instance_bytes = instance_bytes; Ok(WgpuOffscreenFrame { width, height, @@ -456,6 +554,231 @@ impl WgpuTimelineRenderer { texture, }) } + + /// (Re)create the offscreen color target when the arrangement body resizes. + fn ensure_target(&mut self, width: u32, height: u32) -> Result<(), String> { + if self + .target + .as_ref() + .is_some_and(|target| target.width == width && target.height == height) + { + return Ok(()); + } + let device = self.device.as_ref().ok_or("device not initialized")?; + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("timeline-offscreen"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: OFFSCREEN_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + self.target = Some(OffscreenTarget { + texture, + view, + width, + height, + }); + Ok(()) + } + + /// Build the pipeline once, then only grow the instance buffer when a frame + /// needs more quads than the current capacity. + fn ensure_pipeline(&mut self, instance_count: u64) -> Result<(), String> { + let device = self.device.as_ref().ok_or("device not initialized")?; + if self.pipeline.is_none() { + self.pipeline = Some(create_arrangement_pipeline(device, instance_count.max(256))); + return Ok(()); + } + let pipeline = self.pipeline.as_mut().expect("pipeline"); + if instance_count > pipeline.capacity { + // Grow geometrically so a steadily busier viewport does not + // reallocate every frame. + let capacity = instance_count.next_power_of_two(); + pipeline.instances = create_instance_buffer(device, capacity); + pipeline.capacity = capacity; + } + Ok(()) + } +} + +fn create_instance_buffer(device: &wgpu::Device, capacity: u64) -> wgpu::Buffer { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("timeline-quad-instances"), + size: capacity * QUAD_INSTANCE_SIZE, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) +} + +fn create_arrangement_pipeline(device: &wgpu::Device, capacity: u64) -> ArrangementPipeline { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("timeline-arrangement-shader"), + source: wgpu::ShaderSource::Wgsl(ARRANGEMENT_SHADER.into()), + }); + + let globals = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("timeline-arrangement-globals"), + size: 16, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("timeline-arrangement-globals-layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + + let globals_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("timeline-arrangement-globals-bind-group"), + layout: &bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: globals.as_entire_binding(), + }], + }); + + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("timeline-arrangement-layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("timeline-arrangement-pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[wgpu::VertexBufferLayout { + array_stride: QUAD_INSTANCE_SIZE, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &[ + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x4, + offset: 0, + shader_location: 0, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x4, + offset: 16, + shader_location: 1, + }, + ], + }], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: OFFSCREEN_FORMAT, + // Straight (non-premultiplied) alpha, matching how GPUI blends + // the same theme colors in the paint fallback. + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + ArrangementPipeline { + pipeline, + globals, + globals_bind_group, + instances: create_instance_buffer(device, capacity), + capacity, + } +} + +fn globals_bytes(width: f32, height: f32) -> [u8; 16] { + let mut bytes = [0u8; 16]; + bytes[0..4].copy_from_slice(&width.to_ne_bytes()); + bytes[4..8].copy_from_slice(&height.to_ne_bytes()); + bytes +} + +fn push_quad(bytes: &mut Vec, rect: [f32; 4], color: gpui::Rgba) { + for value in rect { + bytes.extend_from_slice(&value.to_ne_bytes()); + } + for value in [color.r, color.g, color.b, color.a] { + bytes.extend_from_slice(&value.to_ne_bytes()); + } +} + +fn rgba_to_wgpu(color: gpui::Rgba) -> wgpu::Color { + wgpu::Color { + r: color.r as f64, + g: color.g as f64, + b: color.b as f64, + a: color.a as f64, + } +} + +/// Serialize the arrangement surface's quads, in paint order. +/// +/// This is deliberately the same primitive set the GPUI paint fallback draws +/// (`gpui_paint::paint_grid`): the surface owns the bar shades and the grid +/// behind the lanes, while clips, notes, and the playhead remain interactive +/// GPUI elements layered above it. Returns the instance count. +fn build_arrangement_instances( + snapshot: &TimelineRenderSnapshot, + width: f32, + height: f32, + bytes: &mut Vec, +) -> u64 { + use crate::components::timeline::timeline_state::GridLineLevel; + use crate::theme::Colors; + + let mut count = 0u64; + for shade in &snapshot.bar_shades { + if shade.width <= 0.0 || shade.x >= width || shade.x + shade.width <= 0.0 { + continue; + } + push_quad( + bytes, + [shade.x, 0.0, shade.width, height], + Colors::timeline_region_background(), + ); + count += 1; + } + + for line in &snapshot.grid_lines { + if line.x < 0.0 || line.x >= width { + continue; + } + let color = match line.level { + GridLineLevel::Bar => Colors::timeline_grid_bar(), + GridLineLevel::Beat => Colors::timeline_grid_major(), + GridLineLevel::Sub => Colors::timeline_grid_minor(), + }; + push_quad(bytes, [line.x, 0.0, 1.0, height], color); + count += 1; + } + + count } impl TimelineRenderer for WgpuTimelineRenderer { @@ -474,3 +797,240 @@ impl TimelineRenderer for WgpuTimelineRenderer { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::components::timeline::render::snapshot::{ + BarShadeSnapshot, GridLineSnapshot, PlayheadSnapshot, SelectionSnapshot, VisibleBeatRange, + VisibleTrackRange, + }; + use crate::components::timeline::render::viewport::TimelineViewport; + use crate::components::timeline::timeline_state::GridLineLevel; + + fn test_snapshot(width: f32, height: f32) -> TimelineRenderSnapshot { + let mut viewport = TimelineViewport::new(width, height, 1.0, 0.0, 0.0, 40.0, 80.0, 0.5); + viewport.width = width; + viewport.height = height; + TimelineRenderSnapshot { + viewport, + bpm: 120.0, + beats_per_bar: 4.0, + time_signature_revision: 0, + visible_tracks: VisibleTrackRange { + start_index: 0, + end_index: 0, + }, + visible_beats: VisibleBeatRange { + start_beat: 0.0, + end_beat: 8.0, + }, + lanes: Vec::new(), + clips: Vec::new(), + grid_lines: vec![GridLineSnapshot { + x: 40.0, + beat: 1.0, + level: GridLineLevel::Bar, + }], + bar_shades: vec![BarShadeSnapshot { + x: 0.0, + width: 20.0, + bar: 0, + }], + playhead: PlayheadSnapshot { beat: 0.0, x: 0.0 }, + selection: SelectionSnapshot { + selected_track_id: None, + selected_clip_ids: Vec::new(), + }, + track_insert_y: None, + } + } + + fn quad_color(bytes: &[u8], index: usize) -> [f32; 4] { + let base = index * QUAD_INSTANCE_SIZE as usize + 16; + let mut out = [0.0f32; 4]; + for (i, slot) in out.iter_mut().enumerate() { + let at = base + i * 4; + *slot = f32::from_ne_bytes(bytes[at..at + 4].try_into().expect("4 bytes")); + } + out + } + + /// Instance building is pure CPU work, so it is asserted on every machine — + /// including CI without a GPU. + #[test] + fn every_shade_and_grid_line_becomes_one_quad() { + let snapshot = test_snapshot(200.0, 100.0); + let mut bytes = Vec::new(); + let count = build_arrangement_instances(&snapshot, 200.0, 100.0, &mut bytes); + assert_eq!(count, 2, "one bar shade + one grid line"); + assert_eq!(bytes.len() as u64, count * QUAD_INSTANCE_SIZE); + } + + #[test] + fn quads_outside_the_viewport_are_dropped() { + let mut snapshot = test_snapshot(200.0, 100.0); + snapshot.grid_lines = vec![ + GridLineSnapshot { + x: -5.0, + beat: 0.0, + level: GridLineLevel::Bar, + }, + GridLineSnapshot { + x: 250.0, + beat: 9.0, + level: GridLineLevel::Bar, + }, + GridLineSnapshot { + x: 100.0, + beat: 4.0, + level: GridLineLevel::Beat, + }, + ]; + snapshot.bar_shades = vec![ + BarShadeSnapshot { + x: -40.0, + width: 20.0, + bar: -2, + }, + BarShadeSnapshot { + x: 400.0, + width: 20.0, + bar: 10, + }, + ]; + let mut bytes = Vec::new(); + let count = build_arrangement_instances(&snapshot, 200.0, 100.0, &mut bytes); + assert_eq!(count, 1, "only the on-screen grid line survives"); + } + + /// The wgpu path must be a visual drop-in for `gpui_paint`, so each grid + /// level has to carry the same theme color that fallback paints. + #[test] + fn grid_levels_map_to_their_theme_colors() { + let mut snapshot = test_snapshot(200.0, 100.0); + snapshot.bar_shades.clear(); + snapshot.grid_lines = vec![ + GridLineSnapshot { + x: 10.0, + beat: 0.0, + level: GridLineLevel::Bar, + }, + GridLineSnapshot { + x: 20.0, + beat: 1.0, + level: GridLineLevel::Beat, + }, + GridLineSnapshot { + x: 30.0, + beat: 2.0, + level: GridLineLevel::Sub, + }, + ]; + let mut bytes = Vec::new(); + let count = build_arrangement_instances(&snapshot, 200.0, 100.0, &mut bytes); + assert_eq!(count, 3); + + let expect = |c: gpui::Rgba| [c.r, c.g, c.b, c.a]; + assert_eq!( + quad_color(&bytes, 0), + expect(crate::theme::Colors::timeline_grid_bar()) + ); + assert_eq!( + quad_color(&bytes, 1), + expect(crate::theme::Colors::timeline_grid_major()) + ); + assert_eq!( + quad_color(&bytes, 2), + expect(crate::theme::Colors::timeline_grid_minor()) + ); + } + + /// End-to-end GPU check: render the snapshot offscreen, copy the texture + /// back, and assert the pixels actually changed where a grid line was + /// requested. Skipped (not failed) when the machine has no usable adapter, + /// so this runs on developer machines without gating GPU-less CI. + #[test] + fn offscreen_pass_paints_the_grid_line() { + let mut renderer = WgpuTimelineRenderer::new(); + if !renderer.is_available() { + eprintln!("[gpu-renderer] no adapter available; skipping readback test"); + return; + } + let width = 64u32; + let height = 16u32; + let mut snapshot = test_snapshot(width as f32, height as f32); + snapshot.bar_shades.clear(); + snapshot.grid_lines = vec![GridLineSnapshot { + x: 10.0, + beat: 0.0, + level: GridLineLevel::Bar, + }]; + + let frame = renderer + .render_offscreen(&snapshot) + .expect("offscreen render"); + let pixels = read_back_rgba(&renderer, &frame); + + let at = |x: u32, y: u32| -> [u8; 4] { + let i = ((y * width + x) * 4) as usize; + [pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]] + }; + let background = at(0, 0); + assert_ne!( + at(10, 8), + background, + "grid line column must differ from the cleared background" + ); + assert_eq!(at(30, 8), background, "empty columns stay at clear color"); + } + + fn read_back_rgba(renderer: &WgpuTimelineRenderer, frame: &WgpuOffscreenFrame) -> Vec { + let device = renderer.device.as_ref().expect("device"); + let queue = renderer.queue.as_ref().expect("queue"); + // Copy rows must be aligned to COPY_BYTES_PER_ROW_ALIGNMENT. + let unpadded = frame.width * 4; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let padded = unpadded.div_ceil(align) * align; + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("timeline-readback"), + size: (padded * frame.height) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = + device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + encoder.copy_texture_to_buffer( + frame.texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded), + rows_per_image: Some(frame.height), + }, + }, + wgpu::Extent3d { + width: frame.width, + height: frame.height, + depth_or_array_layers: 1, + }, + ); + queue.submit(Some(encoder.finish())); + + buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + let _ = device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); + let mapped = buffer.slice(..).get_mapped_range(); + let mut out = Vec::with_capacity((unpadded * frame.height) as usize); + for row in 0..frame.height { + let start = (row * padded) as usize; + out.extend_from_slice(&mapped[start..start + unpadded as usize]); + } + drop(mapped); + buffer.unmap(); + out + } +} diff --git a/crates/SphereUIComponents/src/components/timeline/state/drag.rs b/crates/SphereUIComponents/src/components/timeline/state/drag.rs index 504608c7..69964247 100644 --- a/crates/SphereUIComponents/src/components/timeline/state/drag.rs +++ b/crates/SphereUIComponents/src/components/timeline/state/drag.rs @@ -10,14 +10,19 @@ pub struct ClipDragItem { /// In-flight clip edge-resize drag payload (mirrors [`ClipDragItem`]). Carries /// the clip identity, which edge is dragged, and the original bounds so the /// handler can resolve the new length from the live cursor position. +/// +/// Deliberately identity-only: the pre-gesture clip snapshot that the undo step +/// needs is captured by the timeline root on the first drag-move, before it +/// mutates anything (`Timeline::clip_resize_origin`). Carrying the whole +/// [`ClipState`] here instead meant cloning every note in the clip on each +/// repaint — the payload is built during element construction — and again on +/// each drag-move event. #[derive(Debug, Clone)] pub struct ClipResizeDrag { pub clip_id: String, pub edge: ClipEdge, pub start_beat: f32, pub duration_beats: f32, - /// Complete pre-gesture snapshot so trim can create one exact undo step. - pub original: ClipState, } #[derive(Debug, Clone)] diff --git a/crates/SphereUIComponents/src/components/timeline/state/geometry.rs b/crates/SphereUIComponents/src/components/timeline/state/geometry.rs index 1ed5d4fb..a043ed62 100644 --- a/crates/SphereUIComponents/src/components/timeline/state/geometry.rs +++ b/crates/SphereUIComponents/src/components/timeline/state/geometry.rs @@ -14,6 +14,121 @@ pub fn snap_beat(beat: f64, snap: SnapSettings) -> f64 { super::musical_snap::snap_beat(beat, snap.to_musical(), false).max(0.0) } +/// Snap a beat against `snap`, resolving bar length from the meter marker in +/// force *at that beat* rather than at the playhead. +/// +/// Shared by [`TimelineState::snap_beats_with_bypass`] and +/// [`TimelineGestureContext`] so a gesture closure snaps identically whether it +/// captured the full state or only this frame's geometry. +pub fn snap_beat_against_meter( + beats: f32, + snap: SnapSettings, + time_signature_map: &TimeSignatureMap, + bypass: bool, +) -> f32 { + let mut snap = snap; + snap.beats_per_bar = time_signature_map.beats_per_bar_at_beat(beats as f64); + super::musical_snap::snap_beat(beats as f64, snap.to_musical(), bypass) as f32 +} + +/// Snap a wall-clock second offset to the current grid. Shared by +/// [`TimelineState::snap_time`] and [`TimelineGestureContext::snap_time`]. +pub fn snap_seconds(seconds: f32, seconds_per_beat: f32, snap: SnapSettings) -> f32 { + if !snap.enabled || snap.division == SnapDivision::Off { + return seconds; + } + let beats_per_bar = snap.beats_per_bar as f32; + let sub_div = match snap.division { + SnapDivision::Auto => snap.auto_step_beats as f32, + SnapDivision::Bar1 => beats_per_bar, + other => other.step_beats(beats_per_bar), + }; + if sub_div <= 0.0 { + return seconds; + } + let spb = seconds_per_beat.max(1.0e-6); + let total_beats = seconds / spb; + ((total_beats / sub_div).round() * sub_div * spb).max(0.0) +} + +/// Per-frame coordinate + snap inputs for pointer gestures. +/// +/// GPUI event closures must be `'static`, so a lane / clip / automation / ruler +/// handler cannot borrow `TimelineState` — it has to own what it reads. Owning +/// it by `state.clone()` deep-copies every track, clip, MIDI note, controller +/// lane, and plugin chain in the project, **per rendered row, per frame**; on a +/// dense arrangement that alone dominates the frame budget. +/// +/// This carries only what a gesture actually resolves — the viewport transform, +/// the snap grid, and the meter map — so cloning it is O(meter markers) instead +/// of O(project). Build it once per repaint (see `Timeline::render`) and share +/// it with `Rc`. +#[derive(Debug, Clone, PartialEq)] +pub struct TimelineGestureContext { + pub viewport: TimelineViewport, + pub bpm: f32, + pub snap: SnapSettings, + pub time_signature_map: TimeSignatureMap, + /// Precomputed [`TimelineState::arrangement_content_top`]. + pub content_top: f32, +} + +impl TimelineGestureContext { + pub fn from_state(state: &TimelineState) -> Self { + Self { + viewport: state.viewport.clone(), + bpm: state.bpm, + snap: SnapSettings::from_timeline(state), + time_signature_map: state.time_signature_map.clone(), + content_top: state.arrangement_content_top(), + } + } + + pub fn seconds_per_beat(&self) -> f32 { + 60.0 / self.bpm.max(1.0) + } + + pub fn beats_to_x(&self, beats: f32) -> f32 { + beat_to_x(beats as f64, &self.viewport) + } + + pub fn x_to_beats(&self, x: f32) -> f32 { + x_to_beat(x, &self.viewport) as f32 + } + + pub fn x_to_beat(&self, x: f32) -> f64 { + x_to_beat(x, &self.viewport) + } + + pub fn lane_origin_x(&self) -> f32 { + self.viewport.panel_origin_x + HEADER_WIDTH + } + + pub fn lane_x_from_window_x(&self, window_x: f32) -> f32 { + window_x - self.lane_origin_x() + } + + pub fn beats_from_window_x(&self, window_x: f32) -> f32 { + self.x_to_beats(self.lane_x_from_window_x(window_x)) + } + + pub fn snap_beats(&self, beats: f32) -> f32 { + self.snap_beats_with_bypass(beats, false) + } + + pub fn snap_beats_with_bypass(&self, beats: f32, bypass: bool) -> f32 { + snap_beat_against_meter(beats, self.snap, &self.time_signature_map, bypass) + } + + pub fn snap_time(&self, seconds: f32) -> f32 { + snap_seconds(seconds, self.seconds_per_beat(), self.snap) + } + + pub fn arrangement_content_top(&self) -> f32 { + self.content_top + } +} + pub fn track_at_y(y: f32, layout: &TrackLayout) -> Option { let content_y = y + layout.scroll_y; layout @@ -95,23 +210,11 @@ impl TimelineState { } pub fn snap_time(&self, seconds: f32) -> f32 { - if !self.snap_to_grid || self.grid_division == SnapDivision::Off { - return seconds; - } - let ppb = self.viewport.pixels_per_second * self.seconds_per_beat(); - let bpb = self.beats_per_bar(); - let sub_div = match self.grid_division { - SnapDivision::Auto => self.get_grid_sub_beats(ppb), - SnapDivision::Bar1 => bpb, - _ => self.grid_division.step_beats(bpb), - }; - if sub_div <= 0.0 { - return seconds; - } - let spb = self.seconds_per_beat(); - let total_beats = seconds / spb; - let snapped = (total_beats / sub_div).round() * sub_div; - (snapped * spb).max(0.0) + snap_seconds( + seconds, + self.seconds_per_beat(), + SnapSettings::from_timeline(self), + ) } /// Snap a beat value to the current grid (or return it unchanged when snap is off). @@ -121,8 +224,16 @@ impl TimelineState { /// Snap a beat value, optionally bypassing the grid (Shift held during drag). pub fn snap_beats_with_bypass(&self, beats: f32, bypass: bool) -> f32 { - let mut snap = SnapSettings::from_timeline(self); - snap.beats_per_bar = self.beats_per_bar_at_beat(beats as f64); - super::musical_snap::snap_beat(beats as f64, snap.to_musical(), bypass) as f32 + snap_beat_against_meter( + beats, + SnapSettings::from_timeline(self), + &self.time_signature_map, + bypass, + ) + } + + /// This frame's gesture geometry — see [`TimelineGestureContext`]. + pub fn gesture_context(&self) -> TimelineGestureContext { + TimelineGestureContext::from_state(self) } } diff --git a/crates/SphereUIComponents/src/components/timeline/state/tests.rs b/crates/SphereUIComponents/src/components/timeline/state/tests.rs index a407f140..b466b35d 100644 --- a/crates/SphereUIComponents/src/components/timeline/state/tests.rs +++ b/crates/SphereUIComponents/src/components/timeline/state/tests.rs @@ -1831,4 +1831,107 @@ mod lane_origin_tests { clip.duration_beats ); } + + /// The lightweight per-frame gesture context exists so lane/clip/ruler + /// closures stop deep-cloning the whole project. It is only safe to swap in + /// if it resolves pointer coordinates and snapping bit-for-bit like the + /// state it replaced. + #[test] + fn gesture_context_matches_timeline_state_coordinate_and_snap_math() { + let mut state = TimelineState::default(); + state.bpm = 137.0; + state.viewport.pixels_per_second = 92.0; + state.viewport.scroll_x = 431.0; + state.viewport.panel_origin_x = 210.0; + state.sync_pixels_per_beat(); + state.snap_to_grid = true; + state.grid_division = SnapDivision::Div1_8; + // A meter change mid-timeline: bar-relative snapping must follow the + // marker in force at the snapped beat, not the one at the playhead. + state.add_time_signature_point(0.0, 4, 4); + state.add_time_signature_point(16.0, 7, 8); + + let ctx = state.gesture_context(); + for x in [-40.0_f32, 0.0, 17.3, 250.0, 999.0, 4321.0] { + assert_eq!(ctx.x_to_beats(x), state.x_to_beats(x), "x_to_beats @ {x}"); + assert_eq!(ctx.x_to_beat(x), state.x_to_beat(x), "x_to_beat @ {x}"); + assert_eq!( + ctx.lane_x_from_window_x(x), + state.lane_x_from_window_x(x), + "lane_x @ {x}" + ); + assert_eq!( + ctx.beats_from_window_x(x), + state.beats_from_window_x(x), + "beats_from_window_x @ {x}" + ); + } + for beat in [0.0_f32, 0.13, 3.9, 15.99, 16.4, 33.2] { + assert_eq!( + ctx.snap_beats(beat), + state.snap_beats(beat), + "snap @ {beat}" + ); + assert_eq!( + ctx.snap_beats_with_bypass(beat, true), + state.snap_beats_with_bypass(beat, true), + "snap bypass @ {beat}" + ); + assert_eq!( + ctx.beats_to_x(beat), + state.beats_to_x(beat), + "beats_to_x @ {beat}" + ); + } + for seconds in [0.0_f32, 0.4, 2.7, 11.0] { + assert_eq!( + ctx.snap_time(seconds), + state.snap_time(seconds), + "snap_time @ {seconds}" + ); + } + assert_eq!(ctx.seconds_per_beat(), state.seconds_per_beat()); + assert_eq!(ctx.lane_origin_x(), state.lane_origin_x()); + assert_eq!( + ctx.arrangement_content_top(), + state.arrangement_content_top() + ); + } + + /// The meter path resolves one id per published meter against the track + /// list every tick. That batch must stay linear: at the scale multi-output + /// VSTi projects reach (thousands of channels) a per-meter linear scan is + /// quadratic and eats the UI thread at the display refresh. + #[test] + fn track_index_by_id_resolves_every_track_exactly_once() { + let mut state = TimelineState::default(); + for index in 0..64 { + state.create_track(CreateTrackOptions { + track_type: TrackType::Instrument, + name: format!("Track {index}"), + color: crate::theme::Colors::track_color_for_index(index), + volume: 1.0, + pan: 0.0, + armed: false, + input_monitor: InputMonitorMode::Off, + }); + } + + let index_by_id = state.track_index_by_id(); + assert_eq!(index_by_id.len(), state.tracks.len(), "one entry per track"); + for (expected_index, track) in state.tracks.iter().enumerate() { + assert_eq!( + index_by_id.get(track.id.as_str()).copied(), + Some(expected_index), + "id {} must map to its own slot", + track.id + ); + // The map must agree with the linear lookup it replaces. + assert_eq!( + state.find_track(&track.id).map(|t| t.id.as_str()), + Some(track.id.as_str()) + ); + } + assert_eq!(index_by_id.get("no-such-track").copied(), None); + } } diff --git a/crates/SphereUIComponents/src/components/timeline/state/track.rs b/crates/SphereUIComponents/src/components/timeline/state/track.rs index 46308c45..100da5a1 100644 --- a/crates/SphereUIComponents/src/components/timeline/state/track.rs +++ b/crates/SphereUIComponents/src/components/timeline/state/track.rs @@ -626,6 +626,24 @@ impl TimelineState { self.tracks.iter().find(|t| t.id == track_id) } + /// Track id -> index, for callers that resolve **many** ids against the + /// same track list in one pass. + /// + /// [`Self::find_track`] is a linear scan, which is right for a single + /// lookup. Resolving a whole batch with it is O(tracks x lookups): a + /// project whose instruments expose per-output VSTi channels reaches a few + /// thousand channels, and the engine publishes one meter for each, so the + /// meter path alone ran millions of string comparisons on the UI thread at + /// the display refresh. Building this map once per batch makes that pass + /// linear instead. + pub fn track_index_by_id(&self) -> std::collections::HashMap<&str, usize> { + self.tracks + .iter() + .enumerate() + .map(|(index, track)| (track.id.as_str(), index)) + .collect() + } + pub fn delete_track(&mut self, track_id: &str) { if let Some(index) = self.tracks.iter().position(|track| track.id == track_id) { let deleting_group = self.tracks[index].track_type == TrackType::Group; diff --git a/crates/SphereUIComponents/src/components/timeline/timeline.rs b/crates/SphereUIComponents/src/components/timeline/timeline.rs index 3022f98b..5c3baa69 100644 --- a/crates/SphereUIComponents/src/components/timeline/timeline.rs +++ b/crates/SphereUIComponents/src/components/timeline/timeline.rs @@ -171,6 +171,11 @@ pub struct Timeline { /// Blocks further drag-move/drop events after Escape or focus-loss cancellation. song_text_drag_cancelled: bool, clip_drag_origin: Option>, + /// Pre-gesture clip snapshot for the in-flight edge-resize, captured on the + /// first drag-move (before any mutation) so the drop can record one exact + /// undo step. Kept here rather than inside [`ClipResizeDrag`] so the drag + /// payload — rebuilt for every clip on every repaint — stays identity-only. + clip_resize_origin: Option, clip_drag_target_track_index: Option, clip_clone_drag_id: Option, /// Pen-tool click-drag MIDI clip preview, live until mouse-up creates the clip. diff --git a/crates/SphereUIComponents/src/components/timeline/timeline/methods.rs b/crates/SphereUIComponents/src/components/timeline/timeline/methods.rs index f71a0e95..2490a7cf 100644 --- a/crates/SphereUIComponents/src/components/timeline/timeline/methods.rs +++ b/crates/SphereUIComponents/src/components/timeline/timeline/methods.rs @@ -133,6 +133,7 @@ impl Timeline { self.clip_clone_hint = None; self.song_text_drag_preview = None; self.clip_drag_origin = None; + self.clip_resize_origin = None; self.clip_drag_target_track_index = None; self.clip_clone_drag_id = None; self.pen_clip_draw = None; @@ -185,6 +186,7 @@ impl Timeline { song_text_drag_preview: None, song_text_drag_cancelled: false, clip_drag_origin: None, + clip_resize_origin: None, clip_drag_target_track_index: None, clip_clone_drag_id: None, pen_clip_draw: None, @@ -238,6 +240,7 @@ impl Timeline { song_text_drag_preview: None, song_text_drag_cancelled: false, clip_drag_origin: None, + clip_resize_origin: None, clip_drag_target_track_index: None, clip_clone_drag_id: None, pen_clip_draw: None, diff --git a/crates/SphereUIComponents/src/components/timeline/timeline/render.rs b/crates/SphereUIComponents/src/components/timeline/timeline/render.rs index 450262de..2a6aab29 100644 --- a/crates/SphereUIComponents/src/components/timeline/timeline/render.rs +++ b/crates/SphereUIComponents/src/components/timeline/timeline/render.rs @@ -1523,6 +1523,15 @@ impl Render for Timeline { let on_clip_resize_move = cx.listener( |this, event: &gpui::DragMoveEvent, _window, cx| { let drag = event.drag(cx).clone(); + // Capture the pre-gesture clip once, before the first mutation. + // This is what the drop turns into the undo step's `previous`. + if this + .clip_resize_origin + .as_ref() + .is_none_or(|origin| origin.clip.id != drag.clip_id) + { + this.clip_resize_origin = ClipSnapshot::capture(&this.state, &drag.clip_id); + } let beat = this.beat_from_window_x(event.event.position.x.into()); this.state.resize_clip_with_bypass( &drag.clip_id, @@ -1534,11 +1543,14 @@ impl Render for Timeline { }, ); let on_clip_resize_drop = cx.listener(|this, drag: &ClipResizeDrag, _window, cx| { - if let Some(next) = ClipSnapshot::capture(&this.state, &drag.clip_id) { - let previous = ClipSnapshot { - track_id: next.track_id.clone(), - clip: drag.original.clone(), - }; + // No drag-move means nothing was resized, so there is no undo step. + let origin = this + .clip_resize_origin + .take() + .filter(|origin| origin.clip.id == drag.clip_id); + if let (Some(previous), Some(next)) = + (origin, ClipSnapshot::capture(&this.state, &drag.clip_id)) + { if previous.clip != next.clip { this.record_executed_command(EditCommand::UpdateClip { previous, next }, cx); this.mark_project_changed(cx); diff --git a/crates/SphereUIComponents/src/components/timeline/timeline_ruler.rs b/crates/SphereUIComponents/src/components/timeline/timeline_ruler.rs index fe19789d..b5c527b9 100644 --- a/crates/SphereUIComponents/src/components/timeline/timeline_ruler.rs +++ b/crates/SphereUIComponents/src/components/timeline/timeline_ruler.rs @@ -1,7 +1,8 @@ use crate::assets; use crate::components::sidebar::SIDEBAR_WIDTH; use crate::components::timeline::timeline_state::{ - GridLineLevel, TempoMap, TimeSignatureMap, TimelineState, HEADER_WIDTH, RULER_HEIGHT, + GridLineLevel, TempoMap, TimeSignatureMap, TimelineGestureContext, TimelineState, HEADER_WIDTH, + RULER_HEIGHT, }; use crate::theme::Colors; use gpui::{ @@ -107,9 +108,12 @@ pub fn timeline_ruler( let scrub_active_drag = scrub_active.clone(); let scrub_active_up = scrub_active.clone(); let on_region_drag_move = on_region_drag.clone(); - let state_for_region_drag = state.clone(); + // Both drag closures only map pointer x -> snapped beats, so they capture + // this frame's geometry instead of a deep clone of the whole project. + let gesture = std::rc::Rc::new(TimelineGestureContext::from_state(state)); + let state_for_region_drag = std::rc::Rc::clone(&gesture); let on_loop_drag_move = on_loop_drag.clone(); - let state_for_loop_drag = state.clone(); + let state_for_loop_drag = gesture; div() .flex() diff --git a/crates/SphereUIComponents/src/components/timeline/track_lane.rs b/crates/SphereUIComponents/src/components/timeline/track_lane.rs index 100dfc54..eb232045 100644 --- a/crates/SphereUIComponents/src/components/timeline/track_lane.rs +++ b/crates/SphereUIComponents/src/components/timeline/track_lane.rs @@ -4,7 +4,8 @@ use crate::components::timeline::audio_clip::{ }; use crate::components::timeline::midi_clip::midi_clip; use crate::components::timeline::timeline_state::{ - ClipState, ClipType, TimelineState, TimelineTool, TrackState, TrackType, HEADER_WIDTH, + ClipState, ClipType, TimelineGestureContext, TimelineState, TimelineTool, TrackState, + TrackType, HEADER_WIDTH, }; use crate::components::timeline::video_clip::video_clip; use crate::theme::Colors; @@ -15,6 +16,7 @@ pub fn track_lane( track: &TrackState, track_index: usize, state: &TimelineState, + gesture: &std::rc::Rc, row_height: f32, on_select_track: std::sync::Arc, on_select_clip: std::sync::Arc< @@ -159,7 +161,11 @@ pub fn track_lane( } else { gpui::CursorStyle::Arrow }; - let state_ref = state.clone(); + // Gesture closures must own their coordinate inputs. Cloning the whole + // `TimelineState` here deep-copied every clip and MIDI note in the project + // once per visible row per frame; the shared per-frame context carries only + // the viewport transform and snap grid. + let state_ref = std::rc::Rc::clone(gesture); let id_num = { use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); diff --git a/crates/SphereUIComponents/src/components/timeline/track_list.rs b/crates/SphereUIComponents/src/components/timeline/track_list.rs index 55feff49..035b1bc7 100644 --- a/crates/SphereUIComponents/src/components/timeline/track_list.rs +++ b/crates/SphereUIComponents/src/components/timeline/track_list.rs @@ -10,7 +10,7 @@ use crate::components::timeline::automation_lane::{ automation_lane, AutomationDownCallback, AutomationHoverCallback, AutomationLaneActionCallback, }; use crate::components::timeline::timeline_state::{ - AutomationHover, AutomationMarquee, TimelineState, TrackRowLayout, + AutomationHover, AutomationMarquee, TimelineGestureContext, TimelineState, TrackRowLayout, AUTOMATION_CONTROL_LANE_HEIGHT, AUTOMATION_SUBLANE_HEIGHT, DEFAULT_TRACK_HEIGHT, HEADER_WIDTH, }; use crate::components::timeline::timeline_surface::timeline_surface; @@ -80,6 +80,10 @@ pub fn track_list( automation_hover: Option<&AutomationHover>, ) -> impl IntoElement { let _s = crate::perf::PerfScope::enter("TrackList"); + // One per-frame coordinate/snap snapshot shared by every lane and automation + // sub-lane gesture closure. Previously each of those cloned the entire + // `TimelineState` (all tracks, clips, and MIDI notes) to satisfy `'static`. + let gesture = std::rc::Rc::new(TimelineGestureContext::from_state(state)); let grid_width = state.viewport.viewport_width.max(1.0); let grid_height = state.viewport.viewport_height.max(DEFAULT_TRACK_HEIGHT); let total_tracks_height = row_layout.total_height; @@ -174,6 +178,7 @@ pub fn track_list( lane_y, AUTOMATION_SUBLANE_HEIGHT, state, + &gesture, on_automation_down.clone(), on_automation_lane_action.clone(), on_automation_hover.clone(), @@ -217,6 +222,7 @@ pub fn track_list( track, index, state, + &gesture, row_height, on_select_track.clone(), on_select_clip.clone(), diff --git a/crates/SphereUIComponents/src/components/timeline/video_clip.rs b/crates/SphereUIComponents/src/components/timeline/video_clip.rs index 9e4b5c5f..47284929 100644 --- a/crates/SphereUIComponents/src/components/timeline/video_clip.rs +++ b/crates/SphereUIComponents/src/components/timeline/video_clip.rs @@ -87,14 +87,12 @@ pub fn video_clip( edge: ClipEdge::Left, start_beat: clip.start_beat, duration_beats: clip.duration_beats, - original: clip.clone(), }; let resize_right = ClipResizeDrag { clip_id: clip.id.clone(), edge: ClipEdge::Right, start_beat: clip.start_beat, duration_beats: clip.duration_beats, - original: clip.clone(), }; let label_color = if unresolved { diff --git a/crates/SphereUIComponents/src/layout/audio_transport.rs b/crates/SphereUIComponents/src/layout/audio_transport.rs index cdba5fbc..38454b55 100644 --- a/crates/SphereUIComponents/src/layout/audio_transport.rs +++ b/crates/SphereUIComponents/src/layout/audio_transport.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use crate::components; -use crate::components::mixer_panel::vsti_output_meter_key; +use crate::components::mixer_panel::{write_vsti_output_meter_key, VstiOutputMeterState}; use crate::components::timeline::timeline_state::{ClipType, TrackOutputRouting, TrackType}; use super::engine_snapshot::{build_engine_project_snapshot, log_engine_sync_snapshot}; @@ -102,6 +102,14 @@ pub(crate) struct EngineSyncState { /// Device/port discovery is much heavier than draining MIDI messages and /// does not need to run at display refresh. pub midi_devices_synced_at: Instant, + /// `(bar, beat_in_bar)` currently shown by the transport chrome. + /// + /// The playhead moves every audio block, but the chrome only renders bar + /// and beat — at 120 BPM that is two distinct values per second, not one + /// per display refresh. Notifying the studio root on playhead motion made + /// GPUI re-lay-out and repaint the entire window (timeline, docks, mixer) + /// on every tick for a label that had not changed. + pub displayed_bar_beat: (i64, u16), } impl Default for EngineSyncState { @@ -117,6 +125,7 @@ impl Default for EngineSyncState { bpm_committed_at: None, bridge_reconciled_at: Instant::now() - Duration::from_secs(1), midi_devices_synced_at: Instant::now() - Duration::from_secs(1), + displayed_bar_beat: (i64::MIN, u16::MAX), } } } @@ -768,12 +777,27 @@ impl StudioLayout { // and append streamed peaks. Self-contained; notifies the timeline. self.update_recording_preview(cx); + let mut transport_display_changed = false; if stats.transport_playing { let bpm = { let timeline = self.timeline.read(cx); timeline.state.bpm }; let interpolated = self.interpolated_playhead_beat(bpm); + // What the transport chrome will actually display for this playhead. + // Only a change here justifies repainting the studio root. + let bar_beat = { + let timeline = self.timeline.read(cx); + let bb = timeline + .state + .time_signature_map + .bar_beat_at_beat(interpolated.max(0.0) as f64); + (bb.bar, bb.beat_in_bar) + }; + if self.engine_sync.displayed_bar_beat != bar_beat { + self.engine_sync.displayed_bar_beat = bar_beat; + transport_display_changed = true; + } let _ = self.timeline.update(cx, move |timeline, cx| { timeline.state.transport.playing = true; // No threshold while playing — even sub-pixel beat motion @@ -826,7 +850,6 @@ impl StudioLayout { crate::perf::count("audio_dropout_recent", new_dropouts); } - let was_playing = stats.transport_playing; self.audio_bridge.stats = Some(stats); if meter_changed { @@ -844,11 +867,17 @@ impl StudioLayout { // its own — auditioning happens with the transport stopped. let preview_playhead_moved = self.poll_browser_preview_playhead(); - // While playing the root layout must repaint every tick so the - // transport chrome (bar:beat:tick, status line) tracks the - // playhead. Meter-only changes route to isolated mixer/timeline - // entities and must not invalidate the full studio shell when idle. - state_changed || was_playing || preview_playhead_moved + // The studio root owns the whole window, so notifying it makes GPUI + // re-render, re-lay-out, and repaint every panel under it. Measured on + // a 31-track session that pass is ~31 ms — 97% of the frame — which is + // why this must be driven by what the root actually *shows*, not by + // playhead motion. + // + // While playing, the only root-visible thing the playhead changes is + // the chrome's bar.beat readout, so that is the trigger. The playhead + // line, auto-scroll, meters, and the status footer all reach their own + // isolated entities above and repaint without the shell. + state_changed || transport_display_changed || preview_playhead_moved } /// Block-rate automation evaluation scaffolding. Evaluates each track's @@ -959,13 +988,24 @@ impl StudioLayout { } let mut changed = self.timeline.update(cx, |timeline, _cx| { let mut changed = false; - for track_meter in meter_tracks { - if let Some(track) = timeline - .state - .tracks - .iter_mut() - .find(|track| track.id == track_meter.track_id) - { + // Resolve every meter against one prebuilt id -> index map, in a + // borrow that ends before the mutation pass below. The per-meter + // linear `find` this replaces was O(tracks x meters), and this loop + // runs at the display refresh: measured at the reported project + // scale (2,103 channels) it cost 3.1 ms per tick versus 0.1 ms + // here — several hundred milliseconds per second of UI thread spent + // purely resolving ids. + let resolved: Vec> = { + let index_by_id = timeline.state.track_index_by_id(); + meter_tracks + .iter() + .map(|track_meter| { + index_by_id.get(track_meter.track_id.as_str()).copied() + }) + .collect() + }; + for (track_meter, index) in meter_tracks.into_iter().zip(resolved) { + if let Some(track) = index.and_then(|index| timeline.state.tracks.get_mut(index)) { let next_l = track_meter.peak_l.clamp(0.0, 1.0) as f32; let next_r = track_meter.peak_r.clamp(0.0, 1.0) as f32; if crate::forensic_trace::forensic_trace_enabled() @@ -1061,15 +1101,26 @@ impl StudioLayout { ); changed }); - // Reuse the persistent scratch set (retains its capacity across ticks) - // rather than allocating a fresh `HashSet` every meter update. - let mut live_keys = std::mem::take(&mut self.mixer_view.vsti_meter_live_keys); - live_keys.clear(); + // Stamp every meter published this tick with the current generation, + // then prune by generation below. The previous shape built two owned + // `String`s per plugin output channel per tick (the key, plus a clone + // into a live-key set); at the scale multi-output VSTi projects reach + // that measured in the hundreds of microseconds per tick, at the + // display refresh, on the UI thread. + let generation = self.mixer_view.vsti_meter_generation.wrapping_add(1); + self.mixer_view.vsti_meter_generation = generation; + let mut key_buf = std::mem::take(&mut self.mixer_view.vsti_meter_key_buf); for meter in plugin_output_meters { let channel = meter.channel.clamp(1, 32) as u8; - let key = vsti_output_meter_key(&meter.track_id, &meter.insert_id, channel); - live_keys.insert(key.clone()); - let entry = self.mixer_view.vsti_output_meters.entry(key).or_default(); + write_vsti_output_meter_key(&mut key_buf, &meter.track_id, &meter.insert_id, channel); + let meters = &mut self.mixer_view.vsti_output_meters; + if !meters.contains_key(key_buf.as_str()) { + meters.insert(key_buf.clone(), VstiOutputMeterState::default()); + } + let entry = meters + .get_mut(key_buf.as_str()) + .expect("entry inserted above"); + entry.last_seen = generation; let next = meter.peak.clamp(0.0, 1.0) as f32; if crate::forensic_trace::forensic_trace_enabled() && next > 0.0001 { let bus_index = (channel.saturating_sub(1)) / 2; @@ -1091,10 +1142,12 @@ impl StudioLayout { changed |= update_meter_hold(&mut entry.peak_hold, entry.level, meter_dt); changed |= update_meter_clip(&mut entry.clip, meter.peak, meter.peak, entry.peak_hold); } - self.mixer_view.vsti_output_meters.retain(|key, meter| { - if live_keys.contains(key) { + self.mixer_view.vsti_output_meters.retain(|_key, meter| { + if meter.last_seen == generation { return true; } + // Not published this tick — ride the same decay to silence the + // live-key set drove before, and drop once fully at rest. let mut keep = false; changed |= smooth_meter_value(&mut meter.level, 0.0, meter_dt); changed |= update_meter_hold(&mut meter.peak_hold, meter.level, meter_dt); @@ -1104,8 +1157,8 @@ impl StudioLayout { } keep }); - // Hand the scratch set back so its allocation is reused next tick. - self.mixer_view.vsti_meter_live_keys = live_keys; + // Hand the key buffer back so its allocation is reused next tick. + self.mixer_view.vsti_meter_key_buf = key_buf; changed } @@ -3165,3 +3218,55 @@ mod macos_cursor { } } } + +#[cfg(test)] +mod transport_repaint_tests { + use crate::components::timeline::timeline_state::{TimeSignatureMap, TimelineState}; + + /// The studio root repaints the whole window, so during playback it must + /// follow the chrome's bar.beat readout rather than raw playhead motion. + /// At 120 BPM in 4/4 that is 2 distinct values per second — against a poll + /// loop running at the display refresh, this is the difference between a + /// couple of full-window relayouts per second and 60-144 of them. + #[test] + fn bar_beat_changes_far_less_often_than_the_playhead() { + let mut map = TimeSignatureMap::new(); + map.add_or_update_point(0.0, 4, 4); + + let bar_beat = |beat: f64| { + let bb = map.bar_beat_at_beat(beat); + (bb.bar, bb.beat_in_bar) + }; + + // One second of playback at 120 BPM = 2 beats, polled at 144 Hz. + let beats_per_second = 2.0_f64; + let polls = 144; + let mut distinct = 0usize; + let mut previous = (i64::MIN, u16::MAX); + for poll in 0..polls { + let beat = beats_per_second * (poll as f64 / polls as f64); + let current = bar_beat(beat); + if current != previous { + distinct += 1; + previous = current; + } + } + assert_eq!( + distinct, 2, + "only the beat boundaries should repaint the shell, got {distinct}" + ); + } + + /// Sub-beat motion must not trip the comparison, or the optimization is + /// silently a no-op. + #[test] + fn sub_beat_motion_holds_the_same_display_value() { + let state = TimelineState::default(); + let at = |beat: f64| { + let bb = state.time_signature_map.bar_beat_at_beat(beat); + (bb.bar, bb.beat_in_bar) + }; + assert_eq!(at(4.01), at(4.99), "same beat, no shell repaint"); + assert_ne!(at(4.99), at(5.01), "beat boundary does repaint"); + } +} diff --git a/crates/SphereUIComponents/src/layout/mixer_ops.rs b/crates/SphereUIComponents/src/layout/mixer_ops.rs index b1f3cf05..2499efc5 100644 --- a/crates/SphereUIComponents/src/layout/mixer_ops.rs +++ b/crates/SphereUIComponents/src/layout/mixer_ops.rs @@ -42,10 +42,15 @@ pub(crate) struct MixerViewState { /// Active splitter-drag target, if a drag is in progress. pub split_active_target: Option, pub vsti_output_meters: HashMap, - /// Reused scratch set of the plugin-output meter keys seen on the current - /// meter tick. Kept on the struct (drained via `mem::take`) so the playback - /// meter path does not allocate a fresh `HashSet` every tick. - pub vsti_meter_live_keys: std::collections::HashSet, + /// Monotonic meter-tick counter. Each published plugin-output meter stamps + /// its entry with the current value, so the prune pass can tell live + /// entries from stale ones without building a set of owned key strings + /// every tick. + pub vsti_meter_generation: u64, + /// Reused key buffer for plugin-output meter lookups. Kept on the struct + /// (drained via `mem::take`) so the playback meter path formats keys + /// without allocating. + pub vsti_meter_key_buf: String, /// Mixer tree sidebar enabled (session-only). pub tree_sidebar_enabled: bool, /// Collapsed to icon rail. @@ -88,7 +93,8 @@ impl Default for MixerViewState { split_resize_start_send_px: 0.0, split_active_target: None, vsti_output_meters: HashMap::new(), - vsti_meter_live_keys: std::collections::HashSet::new(), + vsti_meter_generation: 0, + vsti_meter_key_buf: String::new(), tree_sidebar_enabled: true, tree_sidebar_collapsed: false, tree_sidebar_width_px: MIXER_TREE_SIDEBAR_DEFAULT_WIDTH, diff --git a/crates/SphereUIComponents/src/layout/studio_render.rs b/crates/SphereUIComponents/src/layout/studio_render.rs index 83cbc978..bb4b2671 100644 --- a/crates/SphereUIComponents/src/layout/studio_render.rs +++ b/crates/SphereUIComponents/src/layout/studio_render.rs @@ -1853,6 +1853,10 @@ impl Render for StudioLayout { .children({ let show_perf_overlay = self.settings.read(cx).current.performance.show_performance_overlay || crate::perf::perf_hud_enabled(); + // Scope aggregation costs a timestamp per instrumented scope, so + // it follows the overlay: on while the user is looking at it, + // off (and cleared) the moment it closes. + crate::perf::set_collection_requested(show_perf_overlay); if show_perf_overlay { let snapshot = self.performance_overlay_snapshot(reason_static); Some(components::performance_overlay(&snapshot).into_any_element()) diff --git a/crates/SphereUIComponents/src/layout/transport_ops.rs b/crates/SphereUIComponents/src/layout/transport_ops.rs index 327b2555..a91b49cd 100644 --- a/crates/SphereUIComponents/src/layout/transport_ops.rs +++ b/crates/SphereUIComponents/src/layout/transport_ops.rs @@ -743,6 +743,9 @@ impl StudioLayout { has_sample: self.frame_diag.has_sample(), repaint_reason: repaint_reason.to_string(), audio: self.status_audio_label(), + top_scopes: crate::perf::top_scopes(4), + ui_cpu_ms: crate::perf::instrumented_cpu_ms_per_frame(), + build_stamp: crate::perf::running_build_stamp().to_string(), } } diff --git a/crates/SphereUIComponents/src/perf.rs b/crates/SphereUIComponents/src/perf.rs index 620d9f5d..1cd02122 100644 --- a/crates/SphereUIComponents/src/perf.rs +++ b/crates/SphereUIComponents/src/perf.rs @@ -46,10 +46,27 @@ const ROOT_CHILDREN: &[&str] = &[ "StatusBar", ]; +/// Completed 1-second window, kept so readers get stable per-second figures +/// instead of whatever has accumulated since the last reset. +#[derive(Default, Clone)] +struct WindowSummary { + scopes: Vec, + /// Instrumented wall time per frame in the window. + cpu_ms_per_frame: f32, + /// Measured frame interval in the window. + frame_ms: f32, +} + struct Collector { enabled: bool, + /// Whether to also write the once-per-second stderr dump + log file. + /// Env-driven only: turning the on-screen overlay on collects data but + /// must not start writing to the terminal behind the user's back. + dump: bool, /// Aggregated time per named scope this window. scopes: BTreeMap<&'static str, ScopeAgg>, + /// Last completed window, for the Profiler overlay. + last_window: WindowSummary, /// Latest-value counters (e.g. visible_browser_rows, grid_lines). /// We store the most recent sample plus max-this-window so the log /// is meaningful even when the value bounces. @@ -114,11 +131,15 @@ impl NotifyAgg { impl Collector { fn new() -> Self { let now = Instant::now(); - let enabled = std::env::var_os("FUTUREBOARD_UI_PERF").is_some() - || std::env::var_os("FUTUREBOARD_UI_PROFILE").is_some(); + // A thread that first touches the collector after the overlay is + // already open must start collecting too, or its scopes stay invisible. + let enabled = env_collection_enabled() + || COLLECT_REQUESTED.load(std::sync::atomic::Ordering::Relaxed); Self { enabled, + dump: env_collection_enabled(), scopes: BTreeMap::new(), + last_window: WindowSummary::default(), counters: BTreeMap::new(), frame_count: 0, frame_total_ms: 0.0, @@ -267,6 +288,42 @@ impl Collector { let total_ns: u64 = ranked.iter().map(|(_, a)| a.total_ns).sum(); let window_ns = (elapsed * 1_000_000_000.0) as u64; + // Publish the completed window for the overlay. Readers must never see + // the partially-filled current window — a fresh reset makes every scope + // look like it ran once, which reads as "nothing is expensive" whether + // or not that is true. + self.last_window = WindowSummary { + scopes: ranked + .iter() + .take(8) + .map(|(name, agg)| ScopeSample { + name: if *name == ROOT_SCOPE { + "StudioLayout(self)" + } else { + name + }, + total_ms: agg.total_ns as f32 / 1_000_000.0, + percent: if total_ns > 0 { + 100.0 * agg.total_ns as f32 / total_ns as f32 + } else { + 0.0 + }, + count: agg.count, + }) + .collect(), + cpu_ms_per_frame: if self.frame_count > 0 { + total_ns as f32 / 1_000_000.0 / self.frame_count as f32 + } else { + 0.0 + }, + frame_ms: avg_ms, + }; + + if !self.dump { + self.reset_window(); + return; + } + if !ranked.is_empty() { let mut line = String::from("[ui-perf] ranked: "); for (name, agg) in ranked.iter().take(8) { @@ -666,6 +723,117 @@ pub fn enabled() -> bool { COLLECTOR.try_with(|c| c.borrow().enabled).unwrap_or(false) } +/// Build identity of the running executable: its own file timestamp. +/// +/// Resolved once at runtime, so it needs no build script and can never drift +/// from the binary it describes. Shown in the Profiler so "is this the build I +/// just made?" is answerable from the screen instead of by inference. +pub fn running_build_stamp() -> &'static str { + static STAMP: std::sync::OnceLock = std::sync::OnceLock::new(); + STAMP.get_or_init(|| { + let modified = std::env::current_exe() + .ok() + .and_then(|path| std::fs::metadata(path).ok()) + .and_then(|meta| meta.modified().ok()); + let Some(modified) = modified else { + return "unknown".to_string(); + }; + let Ok(since_epoch) = modified.duration_since(std::time::UNIX_EPOCH) else { + return "unknown".to_string(); + }; + // Local wall clock is not available without a date crate; seconds since + // the epoch is enough to tell two builds apart, and the derived + // day-relative time makes it readable. + let secs = since_epoch.as_secs(); + let days = secs / 86_400; + let rem = secs % 86_400; + format!( + "d{} {:02}:{:02}:{:02}Z", + days, + rem / 3600, + (rem % 3600) / 60, + rem % 60 + ) + }) +} + +/// Runtime request for scope collection, independent of the env vars. +/// +/// Set while the Profiler overlay is on screen so its "where is the frame +/// going" rows have data to show. The env flags stay authoritative for the +/// once-per-second stderr dump; this only turns the in-memory aggregation on. +static COLLECT_REQUESTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Turn scope aggregation on/off at runtime. Idempotent and cheap; the cost +/// while on is one `Instant::now()` plus a small map update per scope. +pub fn set_collection_requested(on: bool) { + COLLECT_REQUESTED.store(on, std::sync::atomic::Ordering::Relaxed); + let _ = COLLECTOR.try_with(|c| { + let mut c = c.borrow_mut(); + if on { + c.enabled = true; + } else if !env_collection_enabled() { + c.enabled = false; + c.scopes.clear(); + c.last_window = WindowSummary::default(); + } + }); +} + +fn env_collection_enabled() -> bool { + static FLAG: std::sync::OnceLock = std::sync::OnceLock::new(); + *FLAG.get_or_init(|| { + std::env::var_os("FUTUREBOARD_UI_PERF").is_some() + || std::env::var_os("FUTUREBOARD_UI_PROFILE").is_some() + }) +} + +/// One ranked scope for the Profiler overlay. +#[derive(Debug, Clone, PartialEq)] +pub struct ScopeSample { + pub name: &'static str, + /// Wall time spent in this scope during the current window. + pub total_ms: f32, + /// Share of all instrumented time in the window. + pub percent: f32, + /// Times the scope was entered in the window. + pub count: u64, +} + +/// Top `limit` scopes from the last **completed** one-second window, most +/// expensive first, with the same root self-time correction the log applies. +pub fn top_scopes(limit: usize) -> Vec { + COLLECTOR + .try_with(|c| { + let c = c.borrow(); + if !c.enabled { + return Vec::new(); + } + c.last_window.scopes.iter().take(limit).cloned().collect() + }) + .unwrap_or_default() +} + +/// Instrumented CPU time per frame from the last completed window. +/// +/// Deliberately does **not** report a frame duration: the overlay already has +/// an authoritative one from its own frame diagnostics, and gating this on the +/// collector's internal frame counter meant a window that rolled over with too +/// few samples silently hid the whole accounting block — exactly when it is +/// most needed. Returns 0.0 before the first completed window rather than +/// `None`, so callers can always render the row. +pub fn instrumented_cpu_ms_per_frame() -> f32 { + COLLECTOR + .try_with(|c| { + let c = c.borrow(); + if !c.enabled { + return 0.0; + } + c.last_window.cpu_ms_per_frame + }) + .unwrap_or(0.0) +} + /// Record a named counter (visible row count, grid line count, etc.). /// Cheap no-op when disabled. Last write wins for the log line; the /// per-window max is also retained. diff --git a/crates/gpui/PATCHED.md b/crates/gpui/PATCHED.md index 6bb93bb0..41d9dd86 100644 --- a/crates/gpui/PATCHED.md +++ b/crates/gpui/PATCHED.md @@ -21,6 +21,44 @@ the minimize button, so the previous unconditional `frame` message was sent to a nil button and aborted the process. Futureboard opens non-minimizable windows for dialogs and session transactions, so this guard is required. +## Frame Profile Hook + +`gpui::frame_profile` publishes the duration of the two phases an embedder +cannot otherwise see — `Window::draw` (element tree build, layout, prepaint, +paint) and `Window::present` (handing the scene to the platform) — as relaxed +atomics from the most recent frame. + +Futureboard's Profiler overlay times its own `render` functions, but those cover +only element construction. Without this hook a 40 ms frame containing 0.2 ms of +app work is indistinguishable from a broken profiler, and there is nothing to +optimize against. + +It also splits the draw into its phases — prepaint (element tree build plus +layout), paint, and accessibility-tree rebuild — and reports the readings that +say *why* a phase is expensive: microseconds spent shaping text that missed the +two-frame line-layout cache (`text_system/line_layout.rs`), the layout node +count (`taffy.rs`), and the primitive count of the finished scene. Layout nodes +matter more than primitives here: containers lay out without drawing, so a frame +can walk a large tree while emitting few primitives. + +Cost is a handful of `Instant::now()` calls per frame plus one per shaping cache +miss; no behavior change. + +## Truncating Text Re-Measured Every Pass + +`TextLayout::layout` refused its own cached size whenever the style truncated, +because a cached layout *might* have been produced without truncation. Taffy +measures a node more than once per frame and Futureboard truncates nearly every +label it draws (track names, clip names, mixer channels), so those elements +re-ran line wrapping and truncation on every measure pass of every frame. + +`TextLayoutInner` now records the truncation width its layout was produced with, +so the guard compares widths instead of disabling the cache. Same output, and a +layout is reused only when it was built for exactly the width being asked for. + +Measured on a 31-track session: layout measure callbacks cost 13.5 ms per frame +across 3,637 calls, inside a 20.9 ms layout solve. + ## Maintenance Notes When updating GPUI from upstream, preserve this Futureboard patch or port it diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 82d23c83..f0c4fe7e 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -618,6 +618,10 @@ struct TextLayoutInner { lines: SmallVec<[WrappedLine; 1]>, line_height: Pixels, wrap_width: Option, + /// Truncation width this layout was produced with, so a later measure pass + /// can tell "already truncated to exactly this width" from "truncated to + /// something else, or not truncated at all". + truncate_width: Option, size: Option>, bounds: Option>, } @@ -677,12 +681,20 @@ impl TextLayout { // Only use cached layout if: // 1. We have a cached size // 2. wrap_width matches (or both are None) - // 3. truncate_width is None (if truncate_width is Some, we need to re-layout - // because the previous layout may have been computed without truncation) + // 3. truncate_width matches what the cached layout was built with + // + // (3) used to require `truncate_width.is_none()`, which disabled + // the cache outright for any truncating text — the layout was + // redone on every measure pass because a cached layout *might* + // have been produced without truncation. Recording the width it + // was produced with answers that instead of assuming the worst, + // so truncating labels stop re-wrapping on every pass. Taffy + // measures a node more than once per frame, and UI chrome + // truncates nearly every label it draws. if let Some(text_layout) = element_state.0.borrow().as_ref() && let Some(size) = text_layout.size && (wrap_width.is_none() || wrap_width == text_layout.wrap_width) - && truncate_width.is_none() + && truncate_width == text_layout.truncate_width { return size; } @@ -730,6 +742,7 @@ impl TextLayout { len: 0, line_height, wrap_width, + truncate_width, size: Some(Size::default()), bounds: None, }); @@ -748,6 +761,7 @@ impl TextLayout { len, line_height, wrap_width, + truncate_width, size: Some(size), bounds: None, }); diff --git a/crates/gpui/src/frame_profile.rs b/crates/gpui/src/frame_profile.rs new file mode 100644 index 00000000..ac03addc --- /dev/null +++ b/crates/gpui/src/frame_profile.rs @@ -0,0 +1,217 @@ +//! Coarse per-frame timings for the host application's profiler HUD. +//! +//! GPUI's frame is opaque to an embedder: an app can time its own `render` +//! functions, but everything after that — building the element tree's layout, +//! prepaint, paint, and handing the scene to the platform — happens inside this +//! crate. When an app measures 0.2 ms of its own work inside a 40 ms frame, the +//! missing 39.8 ms is here, and without a breakdown there is nothing to act on. +//! +//! These are plain relaxed atomics written once per phase per frame on the +//! window thread, so the cost is negligible and readers never block. Values are +//! microseconds from the most recently completed frame. + +use std::sync::atomic::{AtomicU64, Ordering}; + +static DRAW_US: AtomicU64 = AtomicU64::new(0); +static PRESENT_US: AtomicU64 = AtomicU64::new(0); +static SCENE_PRIMITIVES: AtomicU64 = AtomicU64::new(0); +static LAYOUT_NODES: AtomicU64 = AtomicU64::new(0); +static PREPAINT_US: AtomicU64 = AtomicU64::new(0); +static PAINT_US: AtomicU64 = AtomicU64::new(0); +static A11Y_US: AtomicU64 = AtomicU64::new(0); +static SOLVE_US_ACC: AtomicU64 = AtomicU64::new(0); +static MEASURE_NS_ACC: AtomicU64 = AtomicU64::new(0); +static MEASURE_CALLS_ACC: AtomicU64 = AtomicU64::new(0); +static SOLVE_US: AtomicU64 = AtomicU64::new(0); +static MEASURE_NS: AtomicU64 = AtomicU64::new(0); +static MEASURE_CALLS: AtomicU64 = AtomicU64::new(0); +/// Accumulated during the frame in progress. +static SHAPE_US_ACC: AtomicU64 = AtomicU64::new(0); +static SHAPE_MISSES_ACC: AtomicU64 = AtomicU64::new(0); +/// Snapshotted from the accumulators when the frame ends. +static SHAPE_US: AtomicU64 = AtomicU64::new(0); +static SHAPE_MISSES: AtomicU64 = AtomicU64::new(0); + +/// One frame's phase timings, in microseconds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct FrameProfile { + /// Element tree render + prepaint + paint (everything `Window::draw` does). + pub draw_us: u64, + /// Handing the finished scene to the platform window / GPU. + pub present_us: u64, + /// Time spent shaping text that missed the two-frame line-layout cache. + /// A large share here means the frame is re-shaping text rather than + /// reusing it — usually text whose content changes every frame. + pub shape_us: u64, + /// Line layouts that had to be shaped from scratch this frame. + pub shape_misses: u64, + /// Primitives in the finished scene. The direct measure of "how much is + /// this frame actually drawing". + pub scene_primitives: u64, + /// Nodes the layout pass walked. Containers lay out without drawing, so + /// this is normally far larger than `scene_primitives` — and it, not the + /// primitive count, is what prepaint cost tracks. + pub layout_nodes: u64, + /// Building the element tree and laying it out. Includes the app's own + /// `render` functions, which GPUI calls during this phase. + pub prepaint_us: u64, + /// Walking the laid-out tree and emitting scene primitives. + pub paint_us: u64, + /// Building the accessibility tree, when a client has activated it. Scales + /// with the element tree, so it can rival the whole rest of the frame. + pub a11y_us: u64, + /// Time inside the layout engine's solve, measure callbacks included. + pub layout_solve_us: u64, + /// Time inside measure callbacks alone. + pub measure_ns: u64, + /// Measure callbacks invoked. Taffy runs several sizing passes, so this can + /// dwarf the node count — and when it does, that is the pathology. + pub measure_calls: u64, +} + +impl FrameProfile { + /// Element tree build, layout, and paint, in milliseconds. + pub fn draw_ms(self) -> f32 { + self.draw_us as f32 / 1000.0 + } + + /// Scene handoff to the platform window, in milliseconds. + pub fn present_ms(self) -> f32 { + self.present_us as f32 / 1000.0 + } + + /// Text shaping that missed the line-layout cache, in milliseconds. + pub fn shape_ms(self) -> f32 { + self.shape_us as f32 / 1000.0 + } + + /// Element tree build plus layout, in milliseconds. + pub fn prepaint_ms(self) -> f32 { + self.prepaint_us as f32 / 1000.0 + } + + /// Primitive emission, in milliseconds. + pub fn paint_ms(self) -> f32 { + self.paint_us as f32 / 1000.0 + } + + /// Accessibility tree rebuild, in milliseconds. + pub fn a11y_ms(self) -> f32 { + self.a11y_us as f32 / 1000.0 + } + + /// The layout engine's solve, measure callbacks included, in milliseconds. + pub fn layout_solve_ms(self) -> f32 { + self.layout_solve_us as f32 / 1000.0 + } + + /// Measure callbacks alone, in milliseconds. + pub fn measure_ms(self) -> f32 { + self.measure_ns as f32 / 1_000_000.0 + } + + /// True once at least one frame has been measured. + pub fn has_sample(self) -> bool { + self.draw_us > 0 || self.present_us > 0 + } +} + +pub(crate) fn begin_frame() { + SHAPE_US_ACC.store(0, Ordering::Relaxed); + SHAPE_MISSES_ACC.store(0, Ordering::Relaxed); + SOLVE_US_ACC.store(0, Ordering::Relaxed); + MEASURE_NS_ACC.store(0, Ordering::Relaxed); + MEASURE_CALLS_ACC.store(0, Ordering::Relaxed); +} + +pub(crate) fn record_layout_solve(micros: u64) { + SOLVE_US_ACC.fetch_add(micros, Ordering::Relaxed); +} + +pub(crate) fn record_layout_measure(nanos: u64) { + MEASURE_NS_ACC.fetch_add(nanos, Ordering::Relaxed); + MEASURE_CALLS_ACC.fetch_add(1, Ordering::Relaxed); +} + +pub(crate) fn record_draw(micros: u64) { + DRAW_US.store(micros, Ordering::Relaxed); + SHAPE_US.store(SHAPE_US_ACC.load(Ordering::Relaxed), Ordering::Relaxed); + SHAPE_MISSES.store(SHAPE_MISSES_ACC.load(Ordering::Relaxed), Ordering::Relaxed); + SOLVE_US.store(SOLVE_US_ACC.load(Ordering::Relaxed), Ordering::Relaxed); + MEASURE_NS.store(MEASURE_NS_ACC.load(Ordering::Relaxed), Ordering::Relaxed); + MEASURE_CALLS.store(MEASURE_CALLS_ACC.load(Ordering::Relaxed), Ordering::Relaxed); +} + +pub(crate) fn record_present(micros: u64) { + PRESENT_US.store(micros, Ordering::Relaxed); +} + +pub(crate) fn record_scene_primitives(count: u64) { + SCENE_PRIMITIVES.store(count, Ordering::Relaxed); +} + +pub(crate) fn record_layout_nodes(count: u64) { + LAYOUT_NODES.store(count, Ordering::Relaxed); +} + +pub(crate) fn record_phases(prepaint_us: u64, paint_us: u64, a11y_us: u64) { + PREPAINT_US.store(prepaint_us, Ordering::Relaxed); + PAINT_US.store(paint_us, Ordering::Relaxed); + A11Y_US.store(a11y_us, Ordering::Relaxed); +} + +/// Add one cache-missing text shape to the frame in progress. +pub(crate) fn record_text_shape(micros: u64) { + SHAPE_US_ACC.fetch_add(micros, Ordering::Relaxed); + SHAPE_MISSES_ACC.fetch_add(1, Ordering::Relaxed); +} + +/// Timings from the most recently completed frame. +pub fn frame_profile() -> FrameProfile { + FrameProfile { + draw_us: DRAW_US.load(Ordering::Relaxed), + present_us: PRESENT_US.load(Ordering::Relaxed), + shape_us: SHAPE_US.load(Ordering::Relaxed), + shape_misses: SHAPE_MISSES.load(Ordering::Relaxed), + scene_primitives: SCENE_PRIMITIVES.load(Ordering::Relaxed), + layout_nodes: LAYOUT_NODES.load(Ordering::Relaxed), + prepaint_us: PREPAINT_US.load(Ordering::Relaxed), + paint_us: PAINT_US.load(Ordering::Relaxed), + a11y_us: A11Y_US.load(Ordering::Relaxed), + layout_solve_us: SOLVE_US.load(Ordering::Relaxed), + measure_ns: MEASURE_NS.load(Ordering::Relaxed), + measure_calls: MEASURE_CALLS.load(Ordering::Relaxed), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phases_round_trip_and_report_samples() { + assert!(!FrameProfile::default().has_sample()); + begin_frame(); + record_text_shape(1_200); + record_text_shape(800); + record_scene_primitives(4_096); + record_draw(31_500); + record_present(8_250); + let profile = frame_profile(); + assert_eq!(profile.draw_us, 31_500); + assert_eq!(profile.shape_us, 2_000, "shape time accumulates per frame"); + assert_eq!(profile.shape_misses, 2); + assert_eq!(profile.scene_primitives, 4_096); + // A new frame must not inherit the previous frame's shaping total. + begin_frame(); + record_draw(1); + assert_eq!(frame_profile().shape_us, 0); + assert_eq!(frame_profile().shape_misses, 0); + record_draw(31_500); + assert!((profile.draw_ms() - 31.5).abs() < 1.0e-3); + assert!((profile.present_ms() - 8.25).abs() < 1.0e-3); + assert!(profile.has_sample()); + record_draw(0); + record_present(0); + } +} diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index 924cb4c6..28d3a2e9 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -23,6 +23,8 @@ mod elements; mod executor; mod platform_scheduler; pub(crate) use platform_scheduler::PlatformScheduler; +/// Coarse per-frame draw/present timings for an embedder's profiler HUD. +pub mod frame_profile; mod geometry; mod global; mod input; diff --git a/crates/gpui/src/taffy.rs b/crates/gpui/src/taffy.rs index 4844748d..8e179a99 100644 --- a/crates/gpui/src/taffy.rs +++ b/crates/gpui/src/taffy.rs @@ -56,6 +56,10 @@ impl TaffyLayoutEngine { } pub fn clear(&mut self) { + // Node count for the frame being cleared. This is the size of the tree + // the layout pass walked, and it is normally much larger than the + // scene's primitive count because containers lay out without drawing. + crate::frame_profile::record_layout_nodes(self.taffy.total_node_count() as u64); self.taffy.clear(); self.absolute_layout_bounds.clear(); self.absolute_outer_origins.clear(); @@ -207,6 +211,7 @@ impl TaffyLayoutEngine { transform(available_space.height), ); + let solve_started = std::time::Instant::now(); self.taffy .compute_layout_with_measure( id.into(), @@ -234,12 +239,20 @@ impl TaffyLayoutEngine { untransform(available_space.height), ); + // Taffy may call this many times per node across its + // sizing passes, so its call count is a far better signal + // than the node count when layout is unexpectedly slow. + let measure_started = std::time::Instant::now(); let measured_size: Size = (node_context.measure)(known_dimensions, available_space, window, cx); + crate::frame_profile::record_layout_measure( + measure_started.elapsed().as_nanos() as u64, + ); snap_measured_size_to_device_pixels(measured_size, scale_factor).into() }, ) .expect(EXPECT_MESSAGE); + crate::frame_profile::record_layout_solve(solve_started.elapsed().as_micros() as u64); } // Pixel snapping diff --git a/crates/gpui/src/text_system/line_layout.rs b/crates/gpui/src/text_system/line_layout.rs index 5e85cefb..fe57d7cc 100644 --- a/crates/gpui/src/text_system/line_layout.rs +++ b/crates/gpui/src/text_system/line_layout.rs @@ -605,9 +605,14 @@ impl LineLayoutCache { layout } else { let text = SharedString::from(text); + // Cache miss: this line has to be shaped from scratch. Timed so the + // profiler HUD can tell "the frame re-shapes its text every frame" + // apart from "the frame simply draws a lot". + let shape_started = std::time::Instant::now(); let mut layout = self .platform_text_system .layout_line(&text, font_size, runs); + crate::frame_profile::record_text_shape(shape_started.elapsed().as_micros() as u64); if let Some(force_width) = force_width { apply_force_width_to_layout(&mut layout, force_width); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 7ca3b21a..dcf5fd2c 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2603,6 +2603,8 @@ impl Window { /// the contents of the new [`Scene`], use [`Self::present`]. #[profiling::function] pub fn draw(&mut self, cx: &mut App) -> ArenaClearNeeded { + let draw_started = Instant::now(); + crate::frame_profile::begin_frame(); // Set up the per-App arena for element allocation during this draw. // This ensures that multiple test Apps have isolated arenas. let _arena_scope = ElementArenaScope::enter(&cx.element_arena); @@ -2695,6 +2697,8 @@ impl Window { self.refreshing = false; self.invalidator.set_phase(DrawPhase::None); self.needs_present.set(true); + crate::frame_profile::record_scene_primitives(self.rendered_frame.scene.len() as u64); + crate::frame_profile::record_draw(draw_started.elapsed().as_micros() as u64); ArenaClearNeeded::new(&cx.element_arena) } @@ -2723,7 +2727,9 @@ impl Window { #[profiling::function] fn present(&mut self) { + let started = Instant::now(); self.platform_window.draw(&self.rendered_frame.scene); + crate::frame_profile::record_present(started.elapsed().as_micros() as u64); #[cfg(feature = "input-latency-histogram")] self.input_latency_tracker.record_frame_presented(); self.needs_present.set(false); @@ -2737,6 +2743,7 @@ impl Window { } fn draw_roots(&mut self, cx: &mut App) { + let prepaint_started = Instant::now(); self.invalidator.set_phase(DrawPhase::Prepaint); self.tooltip_bounds.take(); @@ -2793,6 +2800,8 @@ impl Window { self.mouse_hit_test = self.next_frame.hit_test(self.mouse_position); // Now actually paint the elements. + let prepaint_us = prepaint_started.elapsed().as_micros() as u64; + let paint_started = Instant::now(); self.invalidator.set_phase(DrawPhase::Paint); root_element.paint(self, cx); @@ -2812,6 +2821,9 @@ impl Window { #[cfg(any(feature = "inspector", debug_assertions))] self.paint_inspector_hitbox(cx); + let paint_us = paint_started.elapsed().as_micros() as u64; + let a11y_started = Instant::now(); + // a11y may have been activated/deactivated halfway through the frame let a11y_active_start_of_frame = self.a11y.is_active(); self.a11y.sync_active_flag(); @@ -2831,6 +2843,12 @@ impl Window { self.platform_window.a11y_tree_update(tree_update); } } + + crate::frame_profile::record_phases( + prepaint_us, + paint_us, + a11y_started.elapsed().as_micros() as u64, + ); } fn prepaint_tooltip(&mut self, cx: &mut App) -> Option {