Add an offscreen Renderer with RGBA readback and pinned fonts for headless consumers #441
Labels
No labels
priority/P0
priority/P1
priority/P2
release/v0.1.0
status/blocked
status/planned
type/bug
type/design
type/test-gap
type/tracker
No milestone
No project
No assignees
1 participant
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#441
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Motivation
felis-reel is a planned downstream tool that renders a felis-fcast
.fcastrecording to MP4, WebM or GIF headlessly, playing the roleaggplays for asciinema. It should draw through felis-render-wgpu so the output looks like a real felis window. Today it cannot:Renderercan only be built on a window surface.T: HasWindowHandle + HasDisplayHandle, callcreate_surface, pick the adapter withcompatible_surface: Some(&surface), and take the texture format from the surface (crates/felis-render-wgpu/src/lib.rs:370-449).render()ends inget_current_texture()/present()(lib.rs:999-1023).FontStack::auto_discoverunless aWarmupsupplies the stack (lib.rs:539-547). The same input therefore renders differently on two machines.docs/explanation/rendering/pipeline.md:262already anticipates a "recorder, relay, headless test client" consumer.Proposal
Surface(Surface)orOffscreen { texture, format }, and addRenderer::new_offscreen(width, height, cfg, warmup). It selects the adapter withcompatible_surface: None, asprobe.rs:23andWarmup::start_gpu()already do, and uses a fixedRgba8UnormSrgbformat.encode_cell_passandencode_post_passalready take any&TextureView.Renderer::read_frame(&mut self, out: &mut Vec<u8>). It returns tightly packed RGBA of the post-processed frame through a reusable, row-padded staging buffer.capture_last_frame()(capture.rs:38) already reads back an offscreen texture, but it returns only a histogram, skips the post pass, and allocates on every call. It could become a thin wrapper overread_frame.WarmuporRendererConfigaccept a preloadedFontStack(FontStack::new,felis-shaping/src/lib.rs:354), so the caller controls exactly which font files are used.Scope
Only "draw into memory and hand back pixels" belongs in felis. Video encoding, frame timing and the
.fcastformat stay in felis-reel. The smoke test and any screenshot-based golden tests could also use the new API.Rejected alternatives (from the felis-reel side)
felis-fcast serve. This needs a display, runs only in real time, and does not produce deterministic output.Checked the proposal against current
main. The direction holds, but several details in the body are wrong or under-scoped. Plan: split into two PRs; only the first blocks the felis-reel PoC.PR A: offscreen target and readback
SurfaceConfigurationfirst.Renderer.configis also the width/height/format store forresize,reconfigure_surface,capture.rsand the viewport math, so aTarget::{Surface, Offscreen}enum cannot be added without this split.Renderer::new_offscreen(width, height, cfg, warmup): adapter picked withcompatible_surface: None, fixedRgba8UnormSrgb, andbackground_opacityignored (sobg_premultipliedis false and the cell pass writes straight RGB).Instant::now()inpost.rs), and the bundled trail shader paints the whole frame whileTrailStateis at its all-zero default. An offscreen caller would have to drive both, so offscreen does not support post shaders at all:new_offscreenreturns an error whencfgnames a post shader, andset_post_shader(Some(_))returns aPostShaderErroron an offscreen target.resizeworks offscreen: it recreates the target texture and the staging buffer. A consumer that needs a fixed output size renders at the exact grid size and pads on its own side, becauseletterbox_origincenters a grid once the target has a cell of spare space.read_frame(&mut self, out: &mut Vec<u8>)copies the offscreen texture through a reusable, row-padded staging buffer into tightly packed RGBA. It makes no promise about alpha: DECSCNM and SGR 7 can swap a translucent theme color into a cell background, so a consumer that needs opaque output overwrites alpha itself.capture_last_framecannot become a thin wrapper overread_frame. The smoke test runs on a window surface whose swapchain texture has noCOPY_SRC, so the re-encode path stays for the Surface target.PR B: pinned fonts
FontStack::new(primary)drops the fallbacks and the bold/italic faces. Instead: (a) a public discovery entry point that takes a caller-builtfontdb::Database(the privateauto_discover_inalready has that shape), and (b) a construction path that accepts the finishedFontStack, since the renderer otherwise only gets its stack fromauto_discoveror the private warmup state.get_info().compatible_surface: NonewithHighPerformancestill prefers a hardware adapter when one is present.Justification
Correction:
docs/explanation/rendering/pipeline.md:262is a "Revisit if" aboutNextGridFramepacing for IPC consumers, not about the renderer, so it is not evidence for this change. What felis itself gains is window-less screenshot golden tests (lavapipe in CI) on the same API.