render-path cleanup — viewport-end drift re-declare + per-frame buffer reuse

Two small follow-ons closing the GPU perf backlog:

- Viewport-end drift: typing grows the slice end while the declared
  range's end stays put, and the daemon clips styling to the
  declaration — long unbroken typing ate through the bottom overscan
  and the deepest lines lost styling. Re-declare once the drift
  exceeds half the overscan (in lines); origin moves keep their
  immediate re-declaration.
- render() allocated fresh wgpu vertex buffers for the background /
  caret / minimap quads every frame; they're now reused in place
  (rewrite when the data fits, grow with power-of-two slack). The
  minimap vertex bytes are cached by (summary generation, surface
  size, scroll_top) instead of rescanning every line shape per frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-10 16:44:53 -04:00
parent 76a13ba243
commit b1dba83adf
1 changed files with 121 additions and 30 deletions

View File

@ -40,7 +40,6 @@ use pmacs_protocol::{
cell::{Color as CellColor, Style as CellStyle},
};
use wgpu::MultisampleState;
use wgpu::util::DeviceExt;
use winit::application::ApplicationHandler;
use winit::event::{ElementState, WindowEvent};
use winit::event_loop::{ActiveEventLoop, EventLoop};
@ -425,6 +424,12 @@ struct State {
line_chunk_cache: Vec<Vec<RichChunk>>,
/// Absolute source-line index of `buffer.lines[0]`.
shaped_top: usize,
bg_vertex_buffer: ReusableVertexBuffer,
caret_vertex_buffer: ReusableVertexBuffer,
minimap_vertex_buffer: ReusableVertexBuffer,
/// Minimap vertex bytes cached by [`MinimapCacheKey`] —
/// rebuilding rescanned every line shape per frame.
minimap_cache: Option<(MinimapCacheKey, Vec<u8>)>,
}
/// pmacs-gpu's own cursor position, mirrored from `CursorByte`.
@ -833,6 +838,58 @@ fn optimistic_insert_text(key: ProtocolKey, mods: Modifiers, chbuf: &mut [u8; 4]
}
}
/// A vertex buffer reused across frames: rewritten in place while the
/// data fits, reallocated (with slack) when it grows. `render()`
/// previously allocated fresh wgpu buffers for the background / caret
/// / minimap quads on every frame.
/// `(summary generation, surface width, surface height, scroll_top)`
/// — everything the minimap quads depend on.
type MinimapCacheKey = (u64, u32, u32, usize);
struct ReusableVertexBuffer {
buffer: Option<wgpu::Buffer>,
capacity: u64,
}
impl ReusableVertexBuffer {
const fn new() -> Self {
Self {
buffer: None,
capacity: 0,
}
}
/// Upload `bytes`, reusing the existing allocation when possible.
/// Returns the buffer to bind, or `None` for empty input.
fn upload(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
label: &str,
bytes: &[u8],
) -> Option<&wgpu::Buffer> {
if bytes.is_empty() {
return None;
}
let len = bytes.len() as u64;
if self.buffer.is_none() || self.capacity < len {
// Grow with slack so steady selection/minimap churn
// settles into one allocation.
let capacity = len.next_power_of_two();
self.buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: capacity,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
self.capacity = capacity;
}
let buffer = self.buffer.as_ref().expect("just ensured");
queue.write_buffer(buffer, 0, bytes);
Some(buffer)
}
}
impl QuadRenderer {
fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@ -1025,6 +1082,10 @@ impl State {
hit_map_dirty: false,
line_chunk_cache: Vec::new(),
shaped_top: 0,
bg_vertex_buffer: ReusableVertexBuffer::new(),
caret_vertex_buffer: ReusableVertexBuffer::new(),
minimap_vertex_buffer: ReusableVertexBuffer::new(),
minimap_cache: None,
}
}
@ -1732,13 +1793,24 @@ impl State {
/// If the start byte moves, the top visible line itself shifted, so
/// the daemon needs a fresh declaration.
fn viewport_send_if_origin_changed(&mut self, buffer_id: BufferId) -> Option<ViewportSend> {
let Some((last_start, _)) = self.last_viewport_sent else {
let Some((last_start, last_end)) = self.last_viewport_sent else {
return self.viewport_send_if_changed(buffer_id);
};
if last_start == self.view_range.0 {
return None;
if last_start != self.view_range.0 {
return self.viewport_send_if_changed(buffer_id);
}
self.viewport_send_if_changed(buffer_id)
// End drift: typing grows the slice end while the declared
// end stays put, and the daemon clips styling to the declared
// range. Long unbroken typing would eat through the bottom
// overscan and the deepest lines would lose styling — once
// the drift exceeds half the overscan (in lines), re-declare.
let starts = &self.current_line_starts;
let declared_line = starts.partition_point(|&s| s <= last_end);
let current_line = starts.partition_point(|&s| s <= self.view_range.1);
if current_line.abs_diff(declared_line) * 2 > SCROLL_OVERSCAN {
return self.viewport_send_if_changed(buffer_id);
}
None
}
/// Adjust `scroll_top` so the own cursor's source line is within the
@ -2289,35 +2361,54 @@ impl State {
let frame_start = debug_frame().then(std::time::Instant::now);
let bg_vertices = self.decoration_background_vertex_bytes();
let bg_vertex_count = (bg_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32;
let bg_buffer = (!bg_vertices.is_empty()).then(|| {
self.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("pmacs-gpu decoration backgrounds"),
contents: &bg_vertices,
usage: wgpu::BufferUsages::VERTEX,
})
});
let bg_buffer = self
.bg_vertex_buffer
.upload(
&self.device,
&self.queue,
"pmacs-gpu decoration backgrounds",
&bg_vertices,
)
.cloned();
let caret_vertices = self.caret_vertex_bytes();
let caret_vertex_count = (caret_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32;
let caret_buffer = (!caret_vertices.is_empty()).then(|| {
self.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("pmacs-gpu caret"),
contents: &caret_vertices,
usage: wgpu::BufferUsages::VERTEX,
})
});
let caret_buffer = self
.caret_vertex_buffer
.upload(
&self.device,
&self.queue,
"pmacs-gpu caret",
&caret_vertices,
)
.cloned();
let after_bg = debug_frame().then(std::time::Instant::now);
let minimap_vertices = self.minimap_vertex_bytes();
// Minimap quads depend only on (summary, size, scroll); cache
// the vertex bytes instead of rescanning every line shape per
// frame.
let minimap_key = (
self.current_summary.as_ref().map_or(0, |s| s.generation),
self.config.width,
self.config.height,
self.scroll_top,
);
if self
.minimap_cache
.as_ref()
.is_none_or(|(key, _)| *key != minimap_key)
{
self.minimap_cache = Some((minimap_key, self.minimap_vertex_bytes()));
}
let minimap_vertices = &self.minimap_cache.as_ref().expect("just filled").1;
let minimap_vertex_count = (minimap_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32;
let minimap_buffer = (!minimap_vertices.is_empty()).then(|| {
self.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("pmacs-gpu minimap vertices"),
contents: &minimap_vertices,
usage: wgpu::BufferUsages::VERTEX,
})
});
let minimap_buffer = self
.minimap_vertex_buffer
.upload(
&self.device,
&self.queue,
"pmacs-gpu minimap vertices",
minimap_vertices,
)
.cloned();
let after_minimap = debug_frame().then(std::time::Instant::now);
let text_bounds_right = self.text_bounds_right();