diff --git a/.github/issues/add-configurable-internal-render-scale.md b/.github/issues/add-configurable-internal-render-scale.md new file mode 100644 index 0000000..a6541f6 --- /dev/null +++ b/.github/issues/add-configurable-internal-render-scale.md @@ -0,0 +1,208 @@ +# Add configurable internal render scale to improve fill-bound FPS + +## Summary + +Complete-frame benchmarks show that the current sprite renderer is primarily +pixel/fill bound on Vulkan/Lavapipe, not CPU draw-preparation bound. Rendering +the world into a smaller offscreen color target and nearest-blitting it to the +native swapchain produced the largest measured FPS improvement. + +This should be an opt-in render-quality setting with a native-resolution +fallback. For pixel art, a 50% scale is especially useful because it maps to an +exact 2× nearest-neighbor upscale. + +## Evidence + +Environment: + +- Commit `dc66203259e92ce39291556f7b6c26c8dd999b84` +- Odin `dev-2026-05-nightly:ea5175d` +- `-debug -o:speed` +- SDL 3.4.12, Vulkan/Lavapipe, immediate present +- 800×600 swapchain +- Five 150-frame trials per invocation after 50 warm-up frames +- Order-balanced baseline/candidate invocations +- GPU idle wait included before stopping each trial timer + +### Fill and overdraw scaling + +The committed `perf-frame` harness produced: + +| Workload | Median frame time | Median FPS | +| --- | ---: | ---: | +| 1 visible sprite | 0.592 ms | 1,690 | +| 24 visible sprites | 2.249 ms | 445 | +| 64 visible sprites | 4.017 ms | 249 | +| 128 visible sprites | 6.255 ms | 160 | +| 128 stacked sprites | 7.692 ms | 130 | + +Stacking the same 128 sprites increased frame time by approximately 23%, +confirming that overdraw matters. + +With one centered sprite, render-target scaling produced: + +| Window size | Pixels | Median frame time | +| --- | ---: | ---: | +| 400×300 | 120,000 | 0.268 ms | +| 800×600 | 480,000 | 0.545 ms | +| 1600×1200 | 1,920,000 | 2.075 ms | + +The near-linear increase at larger sizes is further evidence of a pixel-bound +workload. + +### Internal render-scale prototype + +The temporary candidate kept the physical swapchain at 800×600, rendered +sprites into a smaller `COLOR_TARGET | SAMPLER` texture, and used +`BlitGPUTexture` with nearest filtering to upscale into the swapchain. + +Aggregate medians across ten trials per mode: + +| Workload | Native | 75% scale | 50% scale | +| --- | ---: | ---: | ---: | +| 128 spread sprites | 6.082 ms / 164 FPS | 4.082 ms / 245 FPS | 3.082 ms / 325 FPS | +| 128 stacked sprites | 7.912 ms / 126 FPS | 5.116 ms / 195 FPS | 3.034 ms / 330 FPS | + +Compared with native resolution: + +- 75% reduced frame time by 33–35% and increased FPS by 49–55%. +- 50% reduced frame time by 49–62% and increased FPS by 97–161%. + +A visual smoke test confirmed that the 50% path rendered the complete scene at +the correct orientation and 800×600 output size. It was visibly coarser, as +expected. A 75% scale at 800×600 does not produce an integer upscale and can +create uneven pixel sizing with nearest filtering. + +## Reproduction harness + +Native spread and stacked workloads: + +```bash +make perf-frame \ + PERF_FRAME_SCENARIO=0 \ + PERF_FRAME_SPRITES=128 \ + PERF_FRAME_WIDTH=800 \ + PERF_FRAME_HEIGHT=600 + +make perf-frame \ + PERF_FRAME_SCENARIO=3 \ + PERF_FRAME_SPRITES=128 \ + PERF_FRAME_WIDTH=800 \ + PERF_FRAME_HEIGHT=600 +``` + +Run the same commands on the candidate branch with internal render scale set to +75% and 50%. Keep every `PERF_FRAME_*` value unchanged between comparisons. + +## Suggested fix + +Add explicit logical/output and internal-render dimensions to `App`: + +```odin +Render_Scale :: enum { + Native, + Three_Quarter, + Half, +} + +App :: struct { + // Existing swapchain fields remain the logical/output dimensions. + swapchain_texture: ^sdl.GPUTexture, + swapchain_w: u32, + swapchain_h: u32, + + render_scale: Render_Scale, + scene_texture: ^sdl.GPUTexture, + scene_w: u32, + scene_h: u32, +} +``` + +Create the offscreen target with the swapchain format and both usages required +by SDL's blit path: + +```odin +app.scene_texture = sdl.CreateGPUTexture( + app.device, + { + type = .D2, + format = sdl.GetGPUSwapchainTextureFormat(app.device, app.window), + usage = {.COLOR_TARGET, .SAMPLER}, + width = app.scene_w, + height = app.scene_h, + layer_count_or_depth = 1, + num_levels = 1, + sample_count = ._1, + }, +) +``` + +Render the sprite pass into `scene_texture`. Continue using the logical +swapchain dimensions for camera and clip-space calculations so world layout +does not change with render scale: + +```odin +color_info := sdl.GPUColorTargetInfo { + texture = app.scene_texture, + clear_color = app.clear_color, + load_op = .CLEAR, + store_op = .STORE, + cycle = true, +} +``` + +After ending the render pass, upscale into the full swapchain: + +```odin +blit := sdl.GPUBlitInfo { + source = { + texture = app.scene_texture, + w = app.scene_w, + h = app.scene_h, + }, + destination = { + texture = app.swapchain_texture, + w = app.swapchain_w, + h = app.swapchain_h, + }, + load_op = .DONT_CARE, + filter = .NEAREST, +} +sdl.BlitGPUTexture(app.cmd, blit) +``` + +Use the existing direct-to-swapchain path at native scale to avoid an +unnecessary blit. Recreate the offscreen texture whenever the swapchain size, +format, or render-scale setting changes. If native-resolution UI or text is +added later, render it after the world blit in a separate swapchain pass. + +## Rejected experiment: fragment alpha discard + +Approximately 49.7% of pixels inside the baked frame rectangles have exact +alpha zero. A temporary fragment shader discarded those texels: + +```glsl +vec4 texel = texture(u_tex, v_uv); +if (texel.a == 0.0) { + discard; +} +out_color = texel; +``` + +Despite the high transparent coverage, this regressed frame time by roughly +5–6% in both spread and stacked workloads. Do not add alpha discard without +contradictory hardware-GPU evidence. + +## Acceptance criteria + +- Native, 75%, and 50% internal render-scale settings are available. +- Native scale retains the current direct-to-swapchain path. +- The scene target is recreated safely on resize, format change, or scale + change and released during shutdown. +- Camera/world coordinates remain stable when render scale changes. +- Nearest filtering is used for pixel-art output. +- Add screenshot-based checks for output orientation, viewport coverage, and + stable sprite placement at every scale. +- Benchmark spread and stacked scenarios on at least one hardware GPU. +- Document the quality tradeoff and recommend integer upscale ratios for pixel + art.