Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,7 @@ size = 3.0
[performance]
buffer_count = 3
enable_vsync = true
max_fps_no_vsync = 60
ui_animation_fps = 30

[ui]
Expand Down
4 changes: 4 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ buffer_count = 3
# Prevents tearing and limits rendering to display refresh rate
enable_vsync = true

# Max FPS when VSync is disabled (0 = unlimited)
# Prevents CPU spinning at very high FPS; set to match your monitor (60/120/144/240)
max_fps_no_vsync = 60

# UI animation frame rate (0 = unlimited)
# Higher values smooth UI effects at the cost of more redraws
ui_animation_fps = 30
Expand Down
31 changes: 22 additions & 9 deletions configurator/src/app/view/performance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,26 +28,39 @@ impl ConfiguratorApp {
scrollable(
column![
text("Performance").size(20),
text("Rendering").size(16),
labeled_control(
"Buffer count (2-4)",
buffer_control,
self.defaults.performance_buffer_count.to_string(),
self.draft.performance_buffer_count != self.defaults.performance_buffer_count,
),
labeled_input_with_feedback(
"UI animation FPS (0 = unlimited)",
&self.draft.performance_ui_animation_fps,
&self.defaults.performance_ui_animation_fps,
TextField::PerformanceUiAnimationFps,
Some("Range: 0-240"),
validate_u32_range(&self.draft.performance_ui_animation_fps, 0, 240),
),
toggle_row(
"Enable VSync",
self.draft.performance_enable_vsync,
self.defaults.performance_enable_vsync,
ToggleField::PerformanceVsync,
)
),
text("Synchronizes rendering with display refresh. Prevents tearing but adds slight input latency.").size(12),
labeled_input_with_feedback(
"Max FPS (VSync off)",
&self.draft.performance_max_fps_no_vsync,
&self.defaults.performance_max_fps_no_vsync,
TextField::PerformanceMaxFpsNoVsync,
Some("0 = unlimited, or match your monitor (60/120/144/240)"),
validate_u32_range(&self.draft.performance_max_fps_no_vsync, 0, 1000),
),
text("Caps frame rate when VSync is disabled. Prevents CPU spinning at 500+ FPS. Set to your monitor's refresh rate for best results, or 0 for unlimited (requires strong CPU).").size(12),
text("Animations").size(16),
labeled_input_with_feedback(
"UI Animation FPS",
&self.draft.performance_ui_animation_fps,
&self.defaults.performance_ui_animation_fps,
TextField::PerformanceUiAnimationFps,
Some("0 = unlimited, recommended: 30-60"),
validate_u32_range(&self.draft.performance_ui_animation_fps, 0, 1000),
),
text("Controls how often UI animations tick (fade effects, toasts, click highlights). Higher values = smoother animations but more CPU usage. Does not affect input responsiveness.").size(12),
]
.spacing(12),
)
Expand Down
1 change: 1 addition & 0 deletions configurator/src/models/config/draft/from_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ impl ConfigDraft {

performance_buffer_count: config.performance.buffer_count,
performance_enable_vsync: config.performance.enable_vsync,
performance_max_fps_no_vsync: config.performance.max_fps_no_vsync.to_string(),
performance_ui_animation_fps: config.performance.ui_animation_fps.to_string(),

ui_show_status_bar: config.ui.show_status_bar,
Expand Down
1 change: 1 addition & 0 deletions configurator/src/models/config/draft/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ pub struct ConfigDraft {

pub performance_buffer_count: u32,
pub performance_enable_vsync: bool,
pub performance_max_fps_no_vsync: String,
pub performance_ui_animation_fps: String,

pub ui_show_status_bar: bool,
Expand Down
1 change: 1 addition & 0 deletions configurator/src/models/config/setters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ impl ConfigDraft {
TextField::DrawingUndoStackLimit => self.drawing_undo_stack_limit = value,
TextField::ArrowLength => self.arrow_length = value,
TextField::ArrowAngle => self.arrow_angle = value,
TextField::PerformanceMaxFpsNoVsync => self.performance_max_fps_no_vsync = value,
TextField::PerformanceUiAnimationFps => self.performance_ui_animation_fps = value,
TextField::HistoryUndoAllDelayMs => self.history_undo_all_delay_ms = value,
TextField::HistoryRedoAllDelayMs => self.history_redo_all_delay_ms = value,
Expand Down
6 changes: 6 additions & 0 deletions configurator/src/models/config/to_config/performance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ impl ConfigDraft {
pub(super) fn apply_performance(&self, config: &mut Config, errors: &mut Vec<FormError>) {
config.performance.buffer_count = self.performance_buffer_count;
config.performance.enable_vsync = self.performance_enable_vsync;
parse_u32_field(
&self.performance_max_fps_no_vsync,
"performance.max_fps_no_vsync",
errors,
|value| config.performance.max_fps_no_vsync = value,
);
parse_u32_field(
&self.performance_ui_animation_fps,
"performance.ui_animation_fps",
Expand Down
1 change: 1 addition & 0 deletions configurator/src/models/fields/toggles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ pub enum TextField {
DrawingUndoStackLimit,
ArrowLength,
ArrowAngle,
PerformanceMaxFpsNoVsync,
PerformanceUiAnimationFps,
HistoryUndoAllDelayMs,
HistoryRedoAllDelayMs,
Expand Down
12 changes: 11 additions & 1 deletion docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,10 @@ buffer_count = 3
# Prevents tearing and limits rendering to display refresh rate
enable_vsync = true

# Max FPS when VSync is disabled (0 = unlimited)
# Prevents CPU spinning at very high FPS; set to match your monitor
max_fps_no_vsync = 60

# UI animation frame rate (0 = unlimited)
# Higher values smooth UI effects at the cost of more redraws
ui_animation_fps = 30
Expand All @@ -189,7 +193,12 @@ ui_animation_fps = 30

**VSync:**
- **true** (default): Synchronizes with display refresh rate, no tearing
- **false**: Uncapped rendering, may cause tearing but lower latency
- **false**: Capped by `max_fps_no_vsync` (set to 0 for uncapped); may cause tearing but lower latency

**Max FPS (VSync off):**
- **60** (default): Suitable for most displays
- **0**: Unlimited (uncapped; higher CPU usage)
- Set to your monitor refresh (60/120/144/240) for best balance

**UI Animation FPS:**
- **30** (default): Smooth enough for most effects
Expand All @@ -199,6 +208,7 @@ ui_animation_fps = 30
**Defaults:**
- Buffer count: 3 (triple buffering)
- VSync: true
- Max FPS (VSync off): 60
- UI animation FPS: 30

### `[ui]` - User Interface
Expand Down
42 changes: 34 additions & 8 deletions src/backend/wayland/backend/event_loop/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use log::{info, warn};
use std::sync::atomic::Ordering;
use std::time::Instant;
use wayland_client::{Connection, EventQueue};

use super::super::state::WaylandState;
Expand Down Expand Up @@ -27,6 +28,9 @@ pub(super) fn run_event_loop(
// Track consecutive render failures for error recovery.
let mut consecutive_render_failures = 0u32;

// Track last render time for frame rate capping when VSync is disabled.
let mut last_render_time: Option<Instant> = None;

// Main event loop.
let mut loop_error: Option<anyhow::Error> = None;
loop {
Expand Down Expand Up @@ -60,18 +64,35 @@ pub(super) fn run_event_loop(
|| state.overlay_suppressed();
let frame_callback_pending = state.surface.frame_callback_pending();
let vsync_enabled = state.config.performance.enable_vsync;
let animation_timeout = if capture_active

// Calculate timeout for dispatch:
// - If capture active, not configured, or waiting for VSync: block indefinitely
// - If VSync disabled and needs_redraw: use frame rate cap timeout
// - Otherwise: use animation timeout
let should_block = capture_active
|| !state.surface.is_configured()
|| (vsync_enabled && frame_callback_pending)
{
|| (vsync_enabled && frame_callback_pending);
let animation_timeout = state.ui_animation_timeout(Instant::now());
let timeout = if should_block {
None
} else if !vsync_enabled && state.input_state.needs_redraw {
// When VSync is off and we need to redraw, wake up when frame budget allows
let frame_cap_timeout = render::frame_rate_cap_timeout(
state.config.performance.max_fps_no_vsync,
last_render_time,
);
// Use the shorter of frame cap timeout and animation timeout.
// If unlimited FPS (None) and no animation, use zero to avoid blocking.
match (frame_cap_timeout, animation_timeout) {
(Some(fc), Some(anim)) => Some(fc.min(anim)),
(Some(fc), None) => Some(fc),
(None, _) => Some(std::time::Duration::ZERO),
}
} else {
state.ui_animation_timeout(std::time::Instant::now())
animation_timeout
};

if let Err(e) =
dispatch::dispatch_events(event_queue, state, capture_active, animation_timeout)
{
if let Err(e) = dispatch::dispatch_events(event_queue, state, capture_active, timeout) {
warn!("Event queue error: {}", e);
loop_error = Some(e);
break;
Expand Down Expand Up @@ -105,7 +126,12 @@ pub(super) fn run_event_loop(
capture::handle_pending_actions(state);
state.apply_onboarding_hints();

if let Some(err) = render::maybe_render(state, qh, &mut consecutive_render_failures) {
if let Some(err) = render::maybe_render(
state,
qh,
&mut consecutive_render_failures,
&mut last_render_time,
) {
loop_error = Some(err);
break;
}
Expand Down
62 changes: 55 additions & 7 deletions src/backend/wayland/backend/event_loop/render.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,59 @@
use std::time::{Duration, Instant};

use log::{debug, warn};

use super::super::super::state::WaylandState;

const MAX_RENDER_FAILURES: u32 = 10;

/// Calculates minimum frame time from FPS cap. Returns None if unlimited (0 FPS).
fn min_frame_time_from_fps(max_fps: u32) -> Option<Duration> {
if max_fps == 0 {
None
} else {
Some(Duration::from_micros(1_000_000 / max_fps as u64))
}
}

/// Returns the remaining time until the next frame is allowed when VSync is disabled.
/// Returns None if the cap is disabled, there was no previous render, or the frame is ready.
pub(super) fn frame_rate_cap_timeout(
max_fps: u32,
last_render_time: Option<Instant>,
) -> Option<Duration> {
let min_frame_time = min_frame_time_from_fps(max_fps)?;
let last = last_render_time?;
let elapsed = last.elapsed();
if elapsed >= min_frame_time {
None // Ready to render now
} else {
Some(min_frame_time - elapsed)
}
}

pub(super) fn maybe_render(
state: &mut WaylandState,
qh: &wayland_client::QueueHandle<WaylandState>,
consecutive_render_failures: &mut u32,
last_render_time: &mut Option<Instant>,
) -> Option<anyhow::Error> {
// Render if configured and needs redraw, but only if no frame callback pending.
// This throttles rendering to display refresh rate (when vsync is enabled).
let can_render = state.surface.is_configured()
&& state.input_state.needs_redraw
&& (!state.surface.frame_callback_pending() || !state.config.performance.enable_vsync);
// When VSync is disabled, enforce a minimum frame time to prevent CPU spinning.
let vsync_enabled = state.config.performance.enable_vsync;
let frame_time_ok = if vsync_enabled {
// VSync uses frame callbacks for throttling
!state.surface.frame_callback_pending()
} else {
// Without VSync, enforce configurable frame rate cap (0 = unlimited)
let min_frame_time = min_frame_time_from_fps(state.config.performance.max_fps_no_vsync);
match (*last_render_time, min_frame_time) {
(Some(t), Some(min)) => t.elapsed() >= min,
_ => true, // No previous render or unlimited FPS
}
};
let can_render =
state.surface.is_configured() && state.input_state.needs_redraw && frame_time_ok;

if can_render {
debug!(
Expand All @@ -22,12 +62,13 @@ pub(super) fn maybe_render(
);
match state.render(qh) {
Ok(keep_rendering) => {
// Reset failure counter on successful render.
// Reset failure counter and record render time.
*consecutive_render_failures = 0;
*last_render_time = Some(Instant::now());
state.input_state.needs_redraw =
keep_rendering || state.input_state.has_pending_history();
// Only set frame_callback_pending if vsync is enabled.
if state.config.performance.enable_vsync {
if vsync_enabled {
state.surface.set_frame_callback_pending(true);
debug!(
"Main loop: render complete, frame_callback_pending set to true (vsync enabled)"
Expand Down Expand Up @@ -59,8 +100,15 @@ pub(super) fn maybe_render(
}
} else {
state.render_layer_toolbars_if_needed();
if state.input_state.needs_redraw && state.surface.frame_callback_pending() {
debug!("Main loop: Skipping render - frame callback already pending");
if state.input_state.needs_redraw {
if vsync_enabled && state.surface.frame_callback_pending() {
debug!("Main loop: Skipping render - frame callback already pending");
} else if !vsync_enabled && !frame_time_ok {
debug!(
"Main loop: Skipping render - frame rate cap ({} FPS)",
state.config.performance.max_fps_no_vsync
);
}
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/backend/wayland/handlers/compositor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ impl CompositorHandler for WaylandState {
let scale = new_factor.max(1);
debug!("Scale factor changed to {}", scale);
self.surface.set_scale(scale);
self.buffer_damage.mark_all_full();
let (phys_w, phys_h) = self.surface.physical_dimensions();
self.frozen
.handle_resize(phys_w, phys_h, &mut self.input_state);
Expand Down Expand Up @@ -101,6 +102,8 @@ impl CompositorHandler for WaylandState {
if let Some(info) = self.output_state.info(output) {
let scale = info.scale_factor.max(1);
self.surface.set_scale(scale);
// Mark full damage when entering output - scale may have changed, pool may be new
self.buffer_damage.mark_all_full();
self.toolbar.maybe_update_scale(Some(output), scale);
self.toolbar.mark_dirty();
let (logical_w, logical_h) = info
Expand Down
1 change: 1 addition & 0 deletions src/backend/wayland/handlers/layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ impl LayerShellHandler for WaylandState {

if size_changed {
info!("Surface size changed - recreating SlotPool");
self.buffer_damage.mark_all_full();
}

self.input_state
Expand Down
1 change: 1 addition & 0 deletions src/backend/wayland/handlers/xdg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ impl WindowHandler for WaylandState {

if self.surface.update_dimensions(width, height) {
info!("xdg window configured: {}x{}", width, height);
self.buffer_damage.mark_all_full();
} else {
debug!(
"xdg window configure acknowledged without size change ({}x{})",
Expand Down
5 changes: 4 additions & 1 deletion src/backend/wayland/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ use super::{
};

mod activation;
mod buffer_damage;
mod capture;
mod core;
mod data;
Expand All @@ -90,7 +91,7 @@ type ScreencopyManager = wayland_protocols_wlr::screencopy::v1::client::zwlr_scr

pub(super) use helpers::{
damage_summary, debug_damage_logging_enabled, debug_toolbar_drag_logging_enabled, drag_log,
force_inline_toolbars_requested, resolve_damage_regions, scale_damage_regions, surface_id,
force_inline_toolbars_requested, scale_damage_regions, surface_id,
toolbar_drag_preview_enabled, toolbar_pointer_lock_enabled,
};

Expand Down Expand Up @@ -145,6 +146,8 @@ pub(super) struct WaylandState {
pub(super) surface: SurfaceState,
pub(super) toolbar: ToolbarSurfaceManager,
data: StateData,
/// Per-buffer damage tracking for correct incremental rendering.
pub(super) buffer_damage: buffer_damage::BufferDamageTracker,

// Configuration
pub(super) config: Config,
Expand Down
Loading