Skip to content

feat: implement multi-output support with scaling - #29

Open
bigFin wants to merge 4 commits into
sin-ack:masterfrom
bigFin:multi-output
Open

bigFin wants to merge 4 commits into
sin-ack:masterfrom
bigFin:multi-output

Conversation

@bigFin

@bigFin bigFin commented Nov 29, 2025 •

Copy link
Copy Markdown
Contributor

Description

Papertoy but on multiple displays

  • independent configuration of frame rate, resolution, and scaling

  • handles display rotation

  • tested on Niri and Hyprland, both Nixos and Arch

  • Per-Output Paper Instances: Manage each output independently. A new Paper struct encapsulates all state necessary for rendering on a single output (including WlrSurface, Shader, and OpenGL contexts). Subsequently decided to try a shared OpenGL context to see if there were any per benefits, if so it wasn't major.

  • Respect Compositor Configuration: The application now correctly processes zwlr_layer_surface_v1.configure events, which provide the authoritative logical dimensions from the compositor. This ensures the application always resizes correctly based on compositor instructions.

  • Output Orientation Support: The application now handles wl_output.geometry events, which communicate display rotation. It swaps width and height when a display is rotated by 90 or 270 degrees, tested on 3440x1440 vs 1440x3440.

  • wlr_layer_surface Anchoring: Layer surfaces are now anchored to all four edges (top, bottom, left, right). This explicitly tells the compositor to stretch the wallpaper to fill the entire output area- this was needed for Niri when the surface might not cover the whole display when in vertical orientation.

  • display scaling: Added a scale parameter to uniformly adjust the resolution.

How to Test

1. Performance Mode (Recommended for 4K)
Render at half resolution (1080p on 4K) to save GPU power, but keep the window fullscreen.

$ papertoy shader.glsl --output "id=HDMI-A-1,scale=0.5"

2. Multi-Monitor Setup
Configure a high-refresh main monitor and a slower secondary monitor.

$ papertoy shader.glsl \
    --output "id=DP-1,frame-rate=144" \
    --output "id=HDMI-A-1,scale=0.5,frame-rate=30"

3. Custom Resolution
Force a specific aspect ratio or size.

$ papertoy shader.glsl --output "id=DP-1,resolution=800x600"

4. All displays at native settings
Automatically render papertoy on all displays

$ papertoy shader.glsl 

Verify that animations are smooth on all displays and that the wallpaper correctly fills the entire screen area, including any fractionally scaled or rotated displays.

@sin-ack sin-ack left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution. From what I can tell you're getting help from an LLM for this change. That's fine, but:

  • The PR description and commit message seem to be generated too. Please describe the change with your own words. LLMs are good at explaining what happened but they're not good at explaining why.
  • Unfortunately LLM providers really like to RL their coding models into doing a single commit for all changes, resulting in a hard-to-review PR because it's more difficult to tell which line of code belongs to which change. Please use atomic commits for each separate change being made. I recommend you to look at this repo's history for how I expect things to be structured. You can ask the LLM about how to modify Git history, or make it do the split by itself (although I wouldn't really trust an LLM to run commands on my behalf, git reflog is your friend here).
  • As I detailed below, one of the reasons I kinda postponed working on this was due to not being able to figure out how to handle multiple frame-rate options within the same loop (I had initially planned to spawn multiple threads and run a queue on each). The changes below seem to mostly address that nicely, but they use the same frame-rate for all outputs. Instead, make the --output flag configure individual outputs which you can then use as the basis for creating Paper objects.
  • Since as part of the changes the command-line options will change, please make sure to update the README too.

Comment thread src/main.zig Resolved
Comment thread src/main.zig Resolved
Comment thread src/main.zig Resolved
Comment thread src/main.zig Resolved
Comment thread src/main.zig Resolved
Comment thread src/main.zig Resolved
Comment thread src/main.zig Resolved
Comment thread src/main.zig Resolved
Comment thread src/main.zig Resolved
Comment thread src/main.zig Resolved
@bigFin
bigFin force-pushed the multi-output branch 5 times, most recently from e69a7f0 to 5c65287 Compare December 1, 2025 06:10
@bigFin

bigFin commented Dec 2, 2025

Copy link
Copy Markdown
Contributor Author

TYVM for the review and many thanks for taking the time to help me clean up the slop. I divided the chungus squash commit to be atomic. Independent configurable frame rates works. I tested this on Niri and Hyprland and both work for me.

@bigFin

bigFin commented Dec 4, 2025

Copy link
Copy Markdown
Contributor Author

I tried a few things help the user manage gpu utilization

  • Add a render scaling parameter so the user can choose to render at a lower res. Some combination of this and lower fps can significantly reduce gpu use.
  • shared OpenGL context for multi output rendering. Im not sure if this really makes a big impact in practice.

@bigFin
bigFin force-pushed the multi-output branch 2 times, most recently from bf9161d to d0f513d Compare December 4, 2025 02:59
@bigFin
bigFin requested a review from sin-ack December 4, 2025 03:21
@bigFin
bigFin force-pushed the multi-output branch 6 times, most recently from f07eb8b to 45b23c3 Compare December 6, 2025 00:36
@sin-ack

sin-ack commented Dec 6, 2025

Copy link
Copy Markdown
Owner

Sorry for the lack of a review, I was on a trip. I'll take a closer look at this on Sunday.

Add a render scaling parameter so the user can choose to render at a lower res.

The --resolution argument already does this. The result will be scaled to the output's size but the surface size is set to the argument's value so that rendering at a smaller resolution is possible.

shared OpenGL context for multi output rendering.

Some benchmarks for render time would be cool!

@bigFin

bigFin commented Dec 7, 2025 •

Copy link
Copy Markdown
Contributor Author

Hope you had a good trip! No rush. As you can see the scope of this PR has extended a bit over the week as I tinkered and tried a few ideas, which has been fun and a good learning experience. Thanks for the feedback and for working with me on this!

Good point about the resolution argument, this type of optimisation was always possible. The uniform scale is just a convenience.

Benchmarks on the shared OpenGL context was interesting technically but not so impactful in my case and probably for most desktop users. Sharing the context reduces the cost of switching surfaces on the driver side ~5% reduction fromeglMakeCurrent (-2.4 us). This shifts the work to the render to swap the viewport +8% (+1.5 us). So maybe we save 1 us per frame with the shared context on my system, seems negligible to me but maybe it adds up to something when using this to drive a lot of outputs.

Commit: 58d7610 (Shared Context + Telemetry) vs Reverted HEAD (Independent Contexts + Telemetry)
Methodology:

  • Control: Independent GLContext per Output.
  • Experiment: Single Shared GLContext across all Outputs.
  • Metric: Internal Telemetry (average nanoseconds per frame) + perf stat.
  • Workload: shaders/stochastic-asym-quads.glsl on 2 outputs (4K@119Hz, UWQHD@100Hz).
  • Duration: Fixed 600 frames per run (approx 3 seconds).

Results (Average of 3 runs, Normalized per Render)

Metric Independent Contexts (Control) Shared Context (Experiment) Diff (ns) % Diff
Total System CPU Time (ms) ~704 ~713 +9 +1.3%
MakeCurrent (Context Switch) ~47,978 ~45,601 -2,377 -5.0%
Render (GPU Submission) ~17,517 ~18,985 +1,468 +8.4%
SwapBuffers (Present) ~1,780,501 ~1,563,170 -217,331 -12.2%
OS Context Switches (total) ~8,790 ~9,007 +217 +2.5%

edit----
ran the perf test a bit longer and also added GPU metrics- variance was whack on the short runs now its <1% across all metrics. Lower VRAM use is also nice.

  • Duration: Fixed 12000 frames per run (approx 60 seconds) for maximum stability.

Results (Average of 3 runs, Normalized per Render)

Metric Independent Contexts (Control) Shared Context (Experiment) Diff % Diff
VRAM Usage (Delta) 405 MiB 333 MiB -72 MiB -17.8%
Avg MakeCurrent (ns) ~44,943 ~42,587 -2,356 -5.2%
Avg Render (ns) ~15,983 ~15,709 -274 -1.7%
Avg SwapBuffers (ns) ~2,649,089 ~2,841,274 +192,185 +7.3%
Total System CPU Time (ms) ~9,431 ~9,305 -126 -1.3%

Seems like a win to me

@bigFin

bigFin commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Thanks again for the detailed review. Following up after spending more time using and testing Papertoy locally: this has been working really well for me, and I appreciate the project. I also have some unrelated local experiments on top of it, but I intentionally kept those out of this PR so this stays focused on multi-output support.

I force-pushed a cleaned-up version of the branch with the goal of making the PR easier to review.

What changed and why:

  • Dropped the Nix/nixGL/Arch-on-Nix changes from this PR. They are useful for my local setup, but they are a separate packaging/runtime concern and not required for multi-output support.

  • Rolled back the shared-context/layer-surface experiment. It looked promising as a performance direction, but in real multi-monitor use it made output pacing more coupled than I was comfortable with. The edge case I noticed was when one output stopped regularly presenting the desktop background, for example due to a fullscreen window; another output could become visibly choppy. The current version keeps one Paper per output with independent frame strategies, while sharing only the EGL display/config setup where that keeps ownership straightforward.

  • Added the explicit per-output config form discussed here, e.g. --output "id=DP-1,resolution=1920x1080,frame-rate=30", while preserving the existing single-output style --output DP-1. The existing --frame-rate and --resolution options are also preserved as defaults for selected outputs, with per-output values taking precedence.

  • Default/native outputs now use compositor frame callbacks. Explicit frame-rate= still uses a timer. This keeps native rendering paced by the compositor while still allowing per-output frame-rate limits.

  • The first native/vsync frame now renders immediately. Otherwise a new surface can end up waiting for a frame callback before it has attached its first buffer.

  • Frame callback ownership is cleaned up so callbacks are destroyed in one place.

  • Output config parsing now rejects missing ids, duplicate fields, duplicate output ids, zero resolutions, and malformed resolutions before surfaces are created.

  • Layer-shell configure handling now treats zero width and zero height independently, matching the protocol's "client decides this dimension" behavior.

  • Added focused parser/config tests for the new CLI behavior.

Validation:

  • nix develop -c zig build test
  • nix develop -c zig fmt --check src/main.zig src/shader.zig
  • nix run .#build
  • git diff --check upstream/master
  • nix flake check

Runtime smoke tested on Niri with:

  • DP-3: 3440x1440@100, scale 1.25, transform 270 degrees
  • HDMI-A-1: 3840x2160@119.880, scale 1.25
  • both outputs using native compositor frame callbacks
  • DP-3 native plus HDMI-A-1,frame-rate=30

@bigFin

bigFin commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up fix pushed for the explicit frame-rate path.

Why: --frame-rate was using a timer-driven render path that ignored compositor frame callbacks. On a multi-output compositor, fullscreening an app can stop presentation/frame callbacks for the wallpaper surface on that output. Continuing to force timer-driven swaps for that occluded surface can stall the single-threaded render loop and make another output stutter.

Change: every surface now always keeps a Wayland frame callback. --frame-rate is treated as a cap on compositor-driven frames rather than a replacement for compositor pacing. If one output stops receiving callbacks because it is covered/occluded, that output is skipped independently while the other outputs can continue rendering.

Checked locally:

  • nix run .#build -- test --summary all
  • git diff --check
  • nix build .
  • nix flake check
  • short two-output run with --frame-rate 30 started both HDMI-A-1 and DP-3 successfully

I also confirmed the behavior manually on niri before porting the fix here.

Own-line section comment, boolean fractional-scale check, direct resolution passthrough, discard configure result explicitly. No behavior change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants