From 12bbec384d683112e62f7911829b185cf0934f88 Mon Sep 17 00:00:00 2001 From: arizkami Date: Mon, 17 Aug 2026 11:18:03 +0700 Subject: [PATCH 1/5] Cut per-frame UI cost and add frame-level profiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playback repainted far more than the visible state changed, and the profiler could not show where the time went, so each fix was a guess. Measured at the reported project scale (release, 2103 channels): TimelineState::clone x12 rows 54.30 ms/frame -> 0.003 ms meter tick (lookup + keys) 3.41 ms -> 0.32 ms meter path per second 491 ms/s -> 47 ms/s Arrangement: - Add TimelineGestureContext so lane, clip, automation, and ruler event closures own only the viewport transform and snap grid. They cloned the whole TimelineState — every track, clip, and MIDI note — once per visible row per frame to satisfy 'static. - Resolve MIDI clip previews into paintable geometry during element build, in one allocation-free pass bounded by visible pixels rather than note count, and cull to the on-screen slice of the clip. - Drop the pre-gesture ClipState from ClipResizeDrag; the timeline root captures it on the first drag-move instead, so the payload rebuilt for every clip on every repaint is identity-only. Meters: - Resolve published meters through one id -> index map instead of a linear find per meter (O(tracks x meters) at the display refresh). - Stamp entries with a generation counter instead of rebuilding a set of owned key strings, and format keys into a reusable buffer. Repaint scope: - Notify the studio root only when the transport chrome's bar.beat readout actually changes. Notifying it on playhead motion made GPUI re-render, re-lay-out, and repaint every panel in the window ~31 times a second for a label that changes twice. Renderer: - Replace the clear-only WGPU scaffold with a real instanced quad pipeline drawing the same primitives as the GPUI paint fallback, with cached target and buffers. Compositing into GPUI is still pending, so the fallback remains the user-visible path. Profiling: - Show hot scopes, a frame breakdown, and the running build stamp in the Profiler overlay; collection follows the overlay and clears when it closes. - Patch GPUI with frame_profile, reporting draw/present duration, text shaping that missed the line-layout cache, and scene primitive count. Without it a 40 ms frame containing 0.2 ms of app work is indistinguishable from a broken profiler. Co-Authored-By: Claude Opus 5 --- .../src/components/mixer_panel.rs | 87 ++- .../src/components/performance_overlay.rs | 204 ++++++ .../src/components/timeline/audio_clip.rs | 2 - .../components/timeline/automation_lane.rs | 10 +- .../src/components/timeline/midi_clip.rs | 463 +++++++++---- .../timeline/render/wgpu_renderer.rs | 624 +++++++++++++++++- .../src/components/timeline/state/drag.rs | 9 +- .../src/components/timeline/state/geometry.rs | 151 ++++- .../src/components/timeline/state/tests.rs | 103 +++ .../src/components/timeline/state/track.rs | 18 + .../src/components/timeline/timeline.rs | 5 + .../components/timeline/timeline/methods.rs | 3 + .../components/timeline/timeline/render.rs | 22 +- .../src/components/timeline/timeline_ruler.rs | 10 +- .../src/components/timeline/track_lane.rs | 10 +- .../src/components/timeline/track_list.rs | 8 +- .../src/components/timeline/video_clip.rs | 2 - .../src/layout/audio_transport.rs | 155 ++++- .../src/layout/mixer_ops.rs | 16 +- .../src/layout/studio_render.rs | 4 + .../src/layout/transport_ops.rs | 3 + crates/SphereUIComponents/src/perf.rs | 172 ++++- crates/gpui/PATCHED.md | 18 + crates/gpui/src/frame_profile.rs | 128 ++++ crates/gpui/src/gpui.rs | 2 + crates/gpui/src/text_system/line_layout.rs | 5 + crates/gpui/src/window.rs | 6 + 27 files changed, 2012 insertions(+), 228 deletions(-) create mode 100644 crates/gpui/src/frame_profile.rs 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..e33e9df9 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,202 @@ 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(), + // Two direct readings of *why* a draw is expensive: how much text the + // frame had to re-shape, and how many primitives it emitted. + 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(), + overlay_line("Primitives", &format!("{}", 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() { + // label + UI CPU + GPUI draw + GPUI present + Unaccounted + // + Text shape + Primitives + assert_eq!(frame_accounting_rows(0.2, 40.0).len(), 7); + assert_eq!(frame_accounting_rows(0.0, 0.0).len(), 7); + // 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(), 7); + } + + #[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..af097dc9 100644 --- a/crates/SphereUIComponents/src/components/timeline/midi_clip.rs +++ b/crates/SphereUIComponents/src/components/timeline/midi_clip.rs @@ -45,6 +45,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 +58,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 +90,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 +136,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 +284,191 @@ pub fn midi_clip( ) } -fn midi_note_preview_canvas( - notes: Vec, +/// 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. +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)] +struct NoteColumn { + x: f32, + lowest_norm: f32, + highest_norm: f32, +} + +/// A single note quad, used at zoom levels where notes are individually visible. +#[derive(Debug, Clone, Copy)] +struct NoteQuad { + x: f32, + width: f32, + 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. +struct NotePreview { + columns: Vec, + quads: Vec, + 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. +fn build_note_preview( + notes: &[MidiNoteState], clip_len: f32, ppb: f32, - track_color: gpui::Rgba, -) -> gpui::Canvas<()> { + 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, + }) +} + +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 +476,86 @@ fn midi_note_preview_canvas( ) } -fn midi_controller_preview_canvas( - lanes: Vec<(MidiControllerKind, Vec)>, +/// Resolved controller-lane geometry: one normalized value per sampled column, +/// so the paint pass never touches the (potentially very dense) point lists. +struct ControllerPreview { + lane_kinds: Vec, + /// Per lane, `columns + 1` normalized values sampled left to right. + lane_values: Vec>, + columns: usize, + step_px: f32, + width: f32, +} + +fn build_controller_preview( + lanes: &[crate::components::timeline::timeline_state::MidiControllerLane], clip_len: f32, ppb: f32, -) -> gpui::Canvas<()> { + 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, + }) +} + +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 +575,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, )); @@ -521,3 +654,87 @@ fn midi_controller_kind_label(kind: MidiControllerKind) -> 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() { + // Scrolled off to the left, and off to the right of a 1000px lane. + 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() { + // Clip starts 100px left of the lane and runs 400px: only 0..300 shows. + let (start, end) = visible_clip_px_range(-100.0, 400.0, 300.0).expect("partially visible"); + assert_eq!((start, end), (100.0, 400.0)); + // Clip starts inside the lane and overruns its right edge. + 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() { + // 20k notes packed into a clip drawn 200px wide — the pathological + // imported-MIDI case that used to emit one quad per note per frame. + 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)]; + // The pitch span must come from the whole clip, or the surviving note + // would jump vertically as the low note scrolls out of view. + 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()); + // Zero zoom, and a clip whose notes all sit outside its own bounds. + 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/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..5949db10 100644 --- a/crates/gpui/PATCHED.md +++ b/crates/gpui/PATCHED.md @@ -21,6 +21,24 @@ 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 reports two direct readings of *why* a draw is expensive: microseconds +spent shaping text that missed the two-frame line-layout cache +(`text_system/line_layout.rs`), and the primitive count of the finished scene. +Cost is two `Instant::now()` calls per frame plus one per shaping cache miss; no +behavior change. + ## Maintenance Notes When updating GPUI from upstream, preserve this Futureboard patch or port it diff --git a/crates/gpui/src/frame_profile.rs b/crates/gpui/src/frame_profile.rs new file mode 100644 index 00000000..057a22a4 --- /dev/null +++ b/crates/gpui/src/frame_profile.rs @@ -0,0 +1,128 @@ +//! 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); +/// 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, +} + +impl FrameProfile { + pub fn draw_ms(self) -> f32 { + self.draw_us as f32 / 1000.0 + } + + pub fn present_ms(self) -> f32 { + self.present_us as f32 / 1000.0 + } + + pub fn shape_ms(self) -> f32 { + self.shape_us as f32 / 1000.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); +} + +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); +} + +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); +} + +/// 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), + } +} + +#[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/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..1ce308d1 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); From 069ee8ffa1fc91ea9bc64fa88afaf9d6b0b94578 Mon Sep 17 00:00:00 2001 From: arizkami Date: Mon, 17 Aug 2026 11:23:03 +0700 Subject: [PATCH 2/5] Extract clip visual geometry into a shared module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arrangement is moving from an element tree to a painted surface, so the note preview and clip visuals need one implementation both paths can read. Living inside the per-clip GPUI element made that impossible without duplicating it — and two copies of this geometry would drift. render/clip_geometry.rs is pure: clip data in, paint-ready geometry out, no GPUI and no theme lookups. midi_clip.rs now renders from it, so the element path and the coming snapshot painter cannot disagree. No behavior change; the geometry tests move with the code. Co-Authored-By: Claude Opus 5 --- .../src/components/timeline/midi_clip.rs | 356 +---------------- .../timeline/render/clip_geometry.rs | 363 ++++++++++++++++++ .../src/components/timeline/render/mod.rs | 1 + 3 files changed, 370 insertions(+), 350 deletions(-) create mode 100644 crates/SphereUIComponents/src/components/timeline/render/clip_geometry.rs diff --git a/crates/SphereUIComponents/src/components/timeline/midi_clip.rs b/crates/SphereUIComponents/src/components/timeline/midi_clip.rs index af097dc9..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::{ @@ -284,154 +289,6 @@ pub fn midi_clip( ) } -/// 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. -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)] -struct NoteColumn { - x: f32, - lowest_norm: f32, - highest_norm: f32, -} - -/// A single note quad, used at zoom levels where notes are individually visible. -#[derive(Debug, Clone, Copy)] -struct NoteQuad { - x: f32, - width: f32, - 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. -struct NotePreview { - columns: Vec, - quads: Vec, - 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. -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, - }) -} - fn midi_note_preview_canvas(preview: NotePreview, track_color: gpui::Rgba) -> gpui::Canvas<()> { canvas( |_bounds, _window, _cx| {}, @@ -476,66 +333,6 @@ fn midi_note_preview_canvas(preview: NotePreview, track_color: gpui::Rgba) -> gp ) } -/// Resolved controller-lane geometry: one normalized value per sampled column, -/// so the paint pass never touches the (potentially very dense) point lists. -struct ControllerPreview { - lane_kinds: Vec, - /// Per lane, `columns + 1` normalized values sampled left to right. - lane_values: Vec>, - columns: usize, - step_px: f32, - width: f32, -} - -fn build_controller_preview( - lanes: &[crate::components::timeline::timeline_state::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, - }) -} - fn midi_controller_preview_canvas(preview: ControllerPreview) -> gpui::Canvas<()> { canvas( |_bounds, _window, _cx| {}, @@ -597,144 +394,3 @@ fn midi_controller_preview_canvas(preview: ControllerPreview) -> gpui::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(), - } -} - -#[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() { - // Scrolled off to the left, and off to the right of a 1000px lane. - 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() { - // Clip starts 100px left of the lane and runs 400px: only 0..300 shows. - let (start, end) = visible_clip_px_range(-100.0, 400.0, 300.0).expect("partially visible"); - assert_eq!((start, end), (100.0, 400.0)); - // Clip starts inside the lane and overruns its right edge. - 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() { - // 20k notes packed into a clip drawn 200px wide — the pathological - // imported-MIDI case that used to emit one quad per note per frame. - 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)]; - // The pitch span must come from the whole clip, or the surviving note - // would jump vertically as the low note scrolls out of view. - 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()); - // Zero zoom, and a clip whose notes all sit outside its own bounds. - 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/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; From a81a8e5d80fb4f8e03b69d69c27e7fbfe007d762 Mon Sep 17 00:00:00 2001 From: arizkami Date: Mon, 17 Aug 2026 11:42:54 +0700 Subject: [PATCH 3/5] Split the GPUI draw into prepaint, paint, and a11y MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frame breakdown proved the cost is inside GPUI's draw (27 ms of a 31 ms frame) and ruled out text shaping (0.04 ms, 3 cache misses) and the GPU (0.30 ms present). But 27 ms to emit 5,367 primitives is ~5 us each, which no reasonable paint pass costs — so the remaining question is whether the time is layout or paint, and that needs measuring rather than guessing. Report each phase separately, plus the layout node count. Containers lay out without drawing, so the node count is normally far larger than the primitive count and is what prepaint scales with — the number that says whether replacing per-clip elements with a painted surface is the right fix. Co-Authored-By: Claude Opus 5 --- .../src/components/performance_overlay.rs | 55 ++++++++++++++++--- crates/gpui/PATCHED.md | 15 +++-- crates/gpui/src/frame_profile.rs | 42 ++++++++++++++ crates/gpui/src/taffy.rs | 11 ++++ crates/gpui/src/window.rs | 12 ++++ 5 files changed, 122 insertions(+), 13 deletions(-) diff --git a/crates/SphereUIComponents/src/components/performance_overlay.rs b/crates/SphereUIComponents/src/components/performance_overlay.rs index e33e9df9..60d693ce 100644 --- a/crates/SphereUIComponents/src/components/performance_overlay.rs +++ b/crates/SphereUIComponents/src/components/performance_overlay.rs @@ -120,8 +120,41 @@ fn frame_accounting_rows(cpu_ms: f32, frame_ms: f32) -> Vec { pct(unaccounted), ) .into_any_element(), - // Two direct readings of *why* a draw is expensive: how much text the - // frame had to re-shape, and how many primitives it emitted. + // 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!( @@ -133,7 +166,12 @@ fn frame_accounting_rows(cpu_ms: f32, frame_ms: f32) -> Vec { pct(profile.shape_ms()), ) .into_any_element(), - overlay_line("Primitives", &format!("{}", profile.scene_primitives)).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(), ] } @@ -193,13 +231,14 @@ mod tests { /// perf window has completed (cpu 0.0) and on a degenerate frame time. #[test] fn breakdown_always_renders_every_row() { - // label + UI CPU + GPUI draw + GPUI present + Unaccounted - // + Text shape + Primitives - assert_eq!(frame_accounting_rows(0.2, 40.0).len(), 7); - assert_eq!(frame_accounting_rows(0.0, 0.0).len(), 7); + // Frame breakdown: label + UI CPU + draw + present + unaccounted. + // Draw split: label + prepaint + paint + a11y + text + node counts. + const EXPECTED_ROWS: usize = 11; + 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(), 7); + assert_eq!(frame_accounting_rows(5.0, 1.0).len(), EXPECTED_ROWS); } #[test] diff --git a/crates/gpui/PATCHED.md b/crates/gpui/PATCHED.md index 5949db10..9d4da593 100644 --- a/crates/gpui/PATCHED.md +++ b/crates/gpui/PATCHED.md @@ -33,11 +33,16 @@ 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 reports two direct readings of *why* a draw is expensive: microseconds -spent shaping text that missed the two-frame line-layout cache -(`text_system/line_layout.rs`), and the primitive count of the finished scene. -Cost is two `Instant::now()` calls per frame plus one per shaping cache miss; no -behavior change. +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. ## Maintenance Notes diff --git a/crates/gpui/src/frame_profile.rs b/crates/gpui/src/frame_profile.rs index 057a22a4..15cf163f 100644 --- a/crates/gpui/src/frame_profile.rs +++ b/crates/gpui/src/frame_profile.rs @@ -15,6 +15,10 @@ 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); /// Accumulated during the frame in progress. static SHAPE_US_ACC: AtomicU64 = AtomicU64::new(0); static SHAPE_MISSES_ACC: AtomicU64 = AtomicU64::new(0); @@ -38,6 +42,18 @@ pub struct FrameProfile { /// 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, } impl FrameProfile { @@ -53,6 +69,18 @@ impl FrameProfile { self.shape_us as f32 / 1000.0 } + pub fn prepaint_ms(self) -> f32 { + self.prepaint_us as f32 / 1000.0 + } + + pub fn paint_ms(self) -> f32 { + self.paint_us as f32 / 1000.0 + } + + pub fn a11y_ms(self) -> f32 { + self.a11y_us as f32 / 1000.0 + } + /// True once at least one frame has been measured. pub fn has_sample(self) -> bool { self.draw_us > 0 || self.present_us > 0 @@ -78,6 +106,16 @@ 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); @@ -92,6 +130,10 @@ pub fn frame_profile() -> FrameProfile { 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), } } diff --git a/crates/gpui/src/taffy.rs b/crates/gpui/src/taffy.rs index 4844748d..86049752 100644 --- a/crates/gpui/src/taffy.rs +++ b/crates/gpui/src/taffy.rs @@ -55,7 +55,18 @@ impl TaffyLayoutEngine { } } + /// Layout nodes built for the frame being cleared. + /// + /// This is the size of the tree the layout pass actually walked, which is + /// what a frame's prepaint cost scales with — and it is much larger than + /// the scene's primitive count, because containers lay out without drawing + /// anything. + pub fn node_count(&self) -> usize { + self.taffy.total_node_count() + } + pub fn clear(&mut self) { + 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(); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 1ce308d1..dcf5fd2c 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2743,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(); @@ -2799,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); @@ -2818,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(); @@ -2837,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 { From 3f2cd8de585a79cffdf2a8b41ca3f41369ba3bb9 Mon Sep 17 00:00:00 2001 From: arizkami Date: Mon, 17 Aug 2026 12:01:56 +0700 Subject: [PATCH 4/5] Measure the layout solve and its measure callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The draw split put 26 ms of a 31 ms frame in prepaint, and ruled out the accessibility tree (0.04 ms) and text shaping (0.08 ms). But it also ruled out the theory behind the planned painter rewrite: the frame lays out only 3,006 nodes, so ~8.7 us per node is the anomaly, not the tree size. Replacing per-clip elements with a painted surface would cut nodes that are not what is costing the time. Report the layout engine's solve separately from the rest of prepaint, and count measure callbacks. Taffy runs several sizing passes, so a measure count far above the node count would explain the per-node cost — and points at element styles rather than element quantity. Co-Authored-By: Claude Opus 5 --- .../src/components/performance_overlay.rs | 29 +++++++++++- crates/gpui/src/frame_profile.rs | 47 +++++++++++++++++++ crates/gpui/src/taffy.rs | 22 +++++---- 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/crates/SphereUIComponents/src/components/performance_overlay.rs b/crates/SphereUIComponents/src/components/performance_overlay.rs index 60d693ce..a4b60c0b 100644 --- a/crates/SphereUIComponents/src/components/performance_overlay.rs +++ b/crates/SphereUIComponents/src/components/performance_overlay.rs @@ -166,6 +166,30 @@ fn frame_accounting_rows(cpu_ms: f32, frame_ms: f32) -> Vec { 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", @@ -232,8 +256,9 @@ mod tests { #[test] fn breakdown_always_renders_every_row() { // Frame breakdown: label + UI CPU + draw + present + unaccounted. - // Draw split: label + prepaint + paint + a11y + text + node counts. - const EXPECTED_ROWS: usize = 11; + // 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 diff --git a/crates/gpui/src/frame_profile.rs b/crates/gpui/src/frame_profile.rs index 15cf163f..ac03addc 100644 --- a/crates/gpui/src/frame_profile.rs +++ b/crates/gpui/src/frame_profile.rs @@ -19,6 +19,12 @@ 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); @@ -54,33 +60,56 @@ pub struct FrameProfile { /// 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 @@ -90,12 +119,27 @@ impl FrameProfile { 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) { @@ -134,6 +178,9 @@ pub fn frame_profile() -> FrameProfile { 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), } } diff --git a/crates/gpui/src/taffy.rs b/crates/gpui/src/taffy.rs index 86049752..8e179a99 100644 --- a/crates/gpui/src/taffy.rs +++ b/crates/gpui/src/taffy.rs @@ -55,17 +55,10 @@ impl TaffyLayoutEngine { } } - /// Layout nodes built for the frame being cleared. - /// - /// This is the size of the tree the layout pass actually walked, which is - /// what a frame's prepaint cost scales with — and it is much larger than - /// the scene's primitive count, because containers lay out without drawing - /// anything. - pub fn node_count(&self) -> usize { - self.taffy.total_node_count() - } - 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(); @@ -218,6 +211,7 @@ impl TaffyLayoutEngine { transform(available_space.height), ); + let solve_started = std::time::Instant::now(); self.taffy .compute_layout_with_measure( id.into(), @@ -245,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 From 4375febab47bf13e7714923812858efa6f57bd93 Mon Sep 17 00:00:00 2001 From: arizkami Date: Mon, 17 Aug 2026 12:19:12 +0700 Subject: [PATCH 5/5] Stop re-measuring truncated text on every layout pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout solve is 20.9 ms of a 32 ms frame, and 13.5 ms of that is measure callbacks — 3,637 of them, against 3,034 layout nodes. About one call per node, so taffy is not thrashing; each call simply costs 3.7 us, which is the anomaly. TextLayout::layout refused its own cached size whenever the style truncated, on the grounds that the cached layout might have been built without truncation. Taffy measures a node more than once per frame, and this UI truncates nearly every label it draws, so those elements re-ran line wrapping and truncation on every pass. Text shaping stayed cheap (0.08 ms) precisely because the shaping cache was working — it was the wrapping around it that was thrown away. Record the truncation width the layout was produced with and compare it, instead of disabling the cache. A layout is reused only when it was built for exactly the width being asked for, so the output is unchanged. Co-Authored-By: Claude Opus 5 --- crates/gpui/PATCHED.md | 15 +++++++++++++++ crates/gpui/src/elements/text.rs | 20 +++++++++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/gpui/PATCHED.md b/crates/gpui/PATCHED.md index 9d4da593..41d9dd86 100644 --- a/crates/gpui/PATCHED.md +++ b/crates/gpui/PATCHED.md @@ -44,6 +44,21 @@ 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, });