# Does the PDF viewport have a minimal-drawcall strategy? Short answer: **no — and unlike CAD, the question is currently moot, because the renderer that would issue those draw calls is never called by the widget.** The page content is not drawn at all. That is the finding. Everything else below is secondary to it. This note answers the same question asked of the CAD codebase in `REVIEWS/CAD_DRAWCALL_STRATEGY_ANALYSIS.md`, prompted by a datagrid brief asking for "virtual viewport on both axes" and "an optimal minimal drawcall strategy". Those are two distinct techniques. PDF has a partial version of the first and none of the second. Read at commit `06a3de6`. Sources: `pdf-makepad/src/renderer.rs` (710 lines), `page_view.rs` (585), `pdf-graphics/src/cache.rs`, and Makepad `draw_vector.rs` / `draw_text.rs` at the pinned rev `ecf5a572ab62a1c1598909971f602f99083671cc`. Makepad's batching model was read from source, not inferred. The CAD note records getting exactly this wrong (§0.1 there), so the two `new_draw_call` sites in `draw_vector.rs` were checked by grep before any number below was computed. ## 0. The finding that precedes the others `PdfRenderer` is exported from `lib.rs` and referenced by **nothing** in the widget's draw path: ``` $ grep -rn "PdfRenderer" crates/apps/pdf/ --include=*.rs pdf-makepad/src/lib.rs:15:pub use renderer::PdfRenderer; ``` `PdfPageWidget::draw_walk` draws a background, then either a placeholder or `draw_affordances` — link underlines and form fields. It never constructs a `PdfRenderer` and never replays a `RenderCommand`. The 710 lines of renderer are reachable only from outside the crate. So the honest statement of today's cost is: **the page content costs zero draw calls because it is not drawn.** Every number in §2 is a projection of what will happen the moment the renderer is wired in — which is precisely when a drawcall strategy stops being theoretical. This also explains why `ClipRect` carries `#[allow(dead_code)] // retained for the scissor-rect work in Phase 7`: clipping is modelled but unused, because nothing renders. ## 1. Batching: Makepad provides it, the PDF renderer uses neither mechanism Two batching mechanisms exist at the pinned rev, and the renderer opts out of both. ### 1.1 `DrawVector` — one draw call per `end()` In `draw/src/shader/draw_vector.rs`, `cx.new_draw_call(...)` appears twice, both inside `end()`. `begin()` only clears `acc_verts`/`acc_indices`. `stroke()` and `fill()` tessellate into the accumulator and issue **no** draw call. So an arbitrary number of fills and strokes cost one draw call, provided nothing calls `end()` between them. `renderer.rs` calls `end()` from `finish_path()`, and `finish_path()` runs on **`Save`, `Restore`, `PushClip`, `PopClip`, `ClipEvenOdd`, `ClipWinding`** — plus once at the end of `render()`. `Save`/`Restore` are `q`/`Q`. In real PDFs they are extremely frequent and they are *graphics-state* operations, not clip operations. Flushing the vector batch on `q`/`Q` is the defect: colour, stroke width and the CTM are all baked into vertices on the CPU by `to_screen_with_ctm` before tessellation, so a state change needs no draw-call boundary. Only a **clip** change genuinely does, because clipping is a GPU scissor/stencil concern. ### 1.2 `DrawText` — an explicit instance-batch API, unused `draw_text.rs` exposes `begin_many_instances` / `end_many_instances`, and `draw_rasterized_glyphs_abs` documents the cost of not using them: > An already-open batch ran `update_draw_vars` when it began; running it > again per call dominates CPU when thousands of glyphs share a batch. A bare `draw_abs` opens and closes its own instance batch. `renderer.rs` contains no reference to `many_instances` or `begin_deferred_slug_flush`, so **every text run is its own batch**. It also alternates between three separate `DrawText` objects (`draw_text`, `draw_text_bold`, `draw_text_code`), and switching draw objects breaks a batch even when the API is used — `TextFlow` hits this, which is why `begin_deferred_slug_flush` exists. ## 2. Measured: replaying the corpus through the renderer's own state machine `pdf_drawcall_census.rs` (kept out of tree; see §6) replays the exact `finish_path` rules above over every parseable page in `crates/apps/pdf/tests/corpus`, counting what the renderer *would* issue. **165 pages, 15,185 render commands.** | | count | |---|---| | `DrawVector::end()` | 2,578 | | `DrawText::draw_abs()` | 1,331 | | `DrawImage::draw_abs()` | 2 | | **total draw calls** | **3,911** (23.7 / page) | What forced the vector breaks: | cause | count | |---|---| | `Save` | 1,230 | | `Restore` | 1,230 | | clip ops | **2** | | fill/stroke paint ops | 2,457 | **2,460 of the 2,578 vector draw calls are caused by `q`/`Q`. Two are caused by clipping.** That is the whole finding in one line: the renderer flushes on the operation that does not need a flush, and the operation that does need one barely occurs. Counterfactual — flush only when a clip actually changes, and coalesce contiguous text runs: | | count | |---|---| | `DrawVector::end()` | 166 (≈ one per page) | | `DrawText` batches | 1,318 | | **total** | **1,486** (9.0 / page) | | **reduction** | **2.6×** | Two honest caveats on that 2.6×: - The vector side improves **15.5×** (2,578 → 166). The blended figure is dragged down by text, which dominates once vectors are fixed. - The text figure is barely moved by contiguity alone (1,331 → 1,318) because this corpus interleaves text with vector paints. Text needs the `many_instances` API, not run-merging. Fixing vectors alone gets 3,911 → 1,499; the text work is a separate, larger job. ## 3. Virtual viewport: partially present, and better than CAD's This is where PDF is genuinely ahead of CAD. **Present.** `pdf-graphics/src/cache.rs` is a real LRU page cache with a **byte** budget rather than an entry count — the docstring gives the right reason ("one image-heavy page can cost more than fifty text pages"). It carries generation tagging so a page from an abandoned document is never shown. `phase7_exit_criterion.rs` asserts the behaviours by name: `opening_a_large_document_does_not_render_every_page`, `scrolling_evicts_pages_that_left_the_viewport`, `a_page_from_an_abandoned_document_is_never_shown`. That is a virtual viewport on the **page** axis, tested. **Absent.** There is no culling *within* a page: - `draw_affordances` loops **every** annotation on the page, filtering only by page index and visibility flags — never against the viewport rect, which it already holds as `self.interaction.viewport`. - `PdfRenderer::render` walks the whole command list. Nothing skips a command whose geometry lies outside the visible rect. A grep for `cull`/`offscreen`/`intersect` in `renderer.rs` returns nothing. So: virtual on the page axis, not on the intra-page axes. For a zoomed-in A0 drawing or a page with thousands of annotations, everything is submitted. ## 4. Do the datagrid techniques transfer? Yes — with one that transfers better than it does for a grid, and one that transfers worse. | Technique | Transfers? | Notes | |---|---|---| | **Minimal draw calls / batching** | **Yes, directly.** | Strongest fit. A PDF page is a flat command list over one coordinate space; that is exactly what `DrawVector`'s accumulator wants. Requires only *removing* flushes, not adding structure. | | **Virtual viewport, page axis** | **Already done.** | `cache.rs`, with tests. | | **Virtual viewport, intra-page** | **Yes, but harder than a grid.** | A grid computes visible rows by division: `first = scroll / row_height`. A PDF command list has no such index — the CTM is stateful, so you cannot know a command's screen rect without interpreting everything before it. Needs a precomputed per-command bbox built once at record time and cached alongside the commands. | | **Cell-hosted child widgets** | **Partially — as annotations.** | Form fields and links are already the analogue. `render_fields` is the equivalent of a cell renderer, and it is where intra-page culling should land first: it is a flat list with known rects, so it is the easy half. | | **Proper clipping** | **Modelled, unimplemented.** | `ClipRect` is `dead_code`; `apply_clip` only calls `finish_path()` and sets no scissor rect. Nested `/BBox` clips from Phase 7's `render_form` are therefore not honoured on the GPU path. | The important asymmetry: a datagrid's virtual viewport is cheap because cell geometry is *derivable*. A PDF's is expensive because geometry is *accumulated*. The bbox index is the price of entry, and it should be built during recording — `RecordingDevice` already tracks the CTM, so it is the one place that can compute a screen rect per command without a second interpretation pass. ## 5. What is not measured Stated plainly, because §2 is a static replay and not a profile. - **No GPU profiling.** No frame times, no counters from a live app. The `ui.rs` suite that would host such a benchmark is `#[ignore]`d on the missing Makepad headless backend, unchanged since Phase 1. - **Draw calls are a proxy, not a cost.** 3,911 draw calls spread over 165 pages is ~24 per visible page, which is not alarming on a desktop GPU. The argument for fixing it is that the count scales with document complexity, not viewport size — a dense vector page multiplies `q`/`Q` without bound. - **The census counts what the renderer *would* do.** Since the renderer is unwired (§0), these are projections. They will become real on the commit that wires it in, which is the right moment to add the guard. - **CPU tessellation cost is not measured**, only draw-call boundaries. Batching removes submission overhead; it does not remove tessellation. ## 6. Suggested order Cheapest first, each independently testable. No code was changed for this note. 1. **Stop flushing on `q`/`Q`.** Remove `finish_path()` from `Save` and `Restore`; keep it on the clip commands. Projected 2,578 → 166 vector draw calls. Guard with a test asserting the count for a fixture page, in the spirit of CAD's `param_hash_is_not_a_shape_key...` pin. 2. **Cull annotations against the viewport rect** in `draw_affordances`. The rect is already in hand; this is a few lines and is the flat, easy half of intra-page virtualisation. 3. **Wire `PdfRenderer` into `draw_walk`** so page content is actually drawn — with 1 and 2 already in place, so the strategy lands with the feature rather than after it. 4. **Batch text** via `begin_many_instances`, and collapse the three `DrawText` objects to one where the style permits, or use `begin_deferred_slug_flush` where it does not. 5. **Implement clipping properly** — give `apply_clip` a real scissor rect and drop the `dead_code` allow. 6. **Per-command bbox index** at record time, then cull the command list. Only worth doing after 1–5, and only if profiling justifies it. Step 1 is the whole of the drawcall answer. Steps 2 and 6 are the whole of the virtual-viewport answer. ## 7. Cross-reference with CAD | | CAD | PDF | |---|---|---| | Vector batching used correctly | yes, in the 2D path | **no** — flushes on `q`/`Q` | | Draw call per item | yes, in 3D (one per part) | **no** — but 2,460 flushes from state ops | | Viewport culling | **none** | page-level yes (tested); intra-page none | | Culling data structure exists | yes, benchmarked, unused on the hot path | page bboxes yes; per-command no | | Renderer actually wired up | yes | **no** | The two codebases fail the same question in opposite directions. CAD renders everything and batches some of it. PDF batches nothing and renders none of it.