Chalk Tool Performance: Replacing Full-Screen Passes with Scissor Rectangles
Dense handwriting made Lulucat Notes' chalk tool slow. The bottleneck was not 3,571 input samples; it was 70 full-screen scratch passes in every frame. A rejected low-resolution cache and a per-stroke scissor rectangle explain the rest.

Red and blue chalk on the device test build, 155 strokes at 255% zoom.
Lulucat Notes’ chalk tool had a very specific performance issue: writing on a blank area felt smooth, but entering an area already filled with chalk strokes made the pen tip lag. Continuing to write in that area gradually slowed canvas panning too.
A single page of ordinary handwriting was enough to reproduce it: 300% zoom, 70 visible chalk strokes in the local area, and 3,571 input sample points altogether. Blank areas remained fluid; only the area containing the concentrated strokes became slow.
After the fix, the same page can accept more writing at 255% zoom, and existing strokes retain full clarity during both pen-down and panning.

The device test screenshot, 155 strokes total. At this zoom level, existing strokes do not temporarily change clarity during pen-down or panning.
Why chalk needs a scratch texture
A regular pen can composite each circular stamp directly onto the ink texture with source-over blending. Chalk adds grain gating: the renderer first accumulates body coverage and depth for an entire stroke, uses a fixed grain texture to select the positions that receive chalk dust, and then composites the result onto the existing ink.
The scratch texture isolates one chalk stroke. That isolation matters because stamps within a stroke overlap heavily; if each stamp were grain-gated separately, the centreline would accumulate colour repeatedly and the chalk pores would shift with sampling density.
At high zoom levels, Lulucat Notes redraws the vector strokes visible in the current viewport. The earlier implementation did the following for every visible chalk stroke:
- End the main render encoder;
- Clear the scratch texture;
- Draw the chalk stroke into the scratch;
- Reopen the main render encoder;
- Composite the scratch back onto the drawable with a full-screen triangle.
The semantics of a single stroke were correct, but the work scope was much larger. The iPad’s drawable measured 2732×2048 — roughly 5.6 million pixels. Each chalk stroke triggered one scratch pass and one full-screen composite. 70 chalk strokes meant approximately 141 render encoders and 70 full-screen composites.
Let
Each chalk stroke also incurred a fixed render-pass overhead, so that cost increased linearly with
We took measurements on a 12.9-inch iPad Pro (5th generation, M1) running iPadOS 18.6.2. We compared GPU timestamps from the same viewport before and after the change, using command-buffer timestamps in the same Debug device build on this particular iPad — called LucasPad below. The ranges below are typical fluctuations in multi-frame logs, not frame-rate commitments for a build distributed to users. With 70 visible chalk strokes, a frame typically required 52–60 ms of GPU time; in an area with about 120 strokes, GPU time rose to 77–80 ms.
Using the full-screen rectangle area of the scratch and composite passes, the theoretical work scope per frame grew from approximately 783 million pixels to 1.34 billion pixels. This figure is the sum of rectangle areas; it is not equivalent to fragment invocation counts, video-memory read/write bytes, or GPU hardware counters. Metal’s fast clear, attachment load/store, and pass switching remain under GPU and driver control.
This also explains why blank areas stayed smooth. Visibility culling skips strokes outside the viewport;
A wrong answer at 0.85 ms
The app already had a full-page ink texture baked at two pixels per point. We tried showing that texture directly while writing, panning, and zooming, leaving only the current Apple Pencil stroke as live vector; once the interaction ended, one additional frame would re-render the high-resolution vector result.
The result was very fast. In the same dense area at 300% zoom, GPU time dropped to 0.84–0.85 ms and no longer increased with the number of existing chalk strokes.
The actual-device problem was just as obvious. At 300% zoom, roughly six screen pixels per point were required, but the cache provided only two. As soon as the Apple Pencil touched down, every existing stroke became a soft, low-resolution image; lifting the Pencil restored full clarity in a snap.
The tester had one comment: “When I write, the entire canvas turns blurry. It clears up when I lift the Pencil.”
We removed the optimization. 0.85 ms was the lowest measured result, but it was not an acceptable chalk tool. Existing strokes are part of the writing feedback, so their clarity cannot change at pen-down.
Limiting each chalk stroke to its own rectangle
The implemented fix retained per-stroke scratch and per-stroke compositing, reducing only the number of pixels they processed. Each stroke already had a canvas bounding box derived from the union of its stamp radii. The renderer transforms that box into the current viewport’s drawable coordinates and pads it by two pixels for antialiasing:
The same scissor rectangle is used to clear the scratch, draw the stroke, and composite the result back onto the main surface.
let rect = displayScissorRect(for: stroke.bounds, viewport: viewport)
scratchEncoder.setScissorRect(rect)
clearScratchExplicitly()
drawStrokeIntoScratch(stroke)
mainEncoder.setScissorRect(rect)
compositeChalkFromScratch(stroke)
mainEncoder.setScissorRect(fullDrawable)
The same logic is used for baking and partial replay on the 4096² ink texture, so the high-zoom display and the settled ink layer do not produce different chalk behaviour.
Two details are easy to miss.
First, loadAction = .clear happens during a render pass’s attachment load stage and is not limited by the rasterization scissor. Keeping it would still clear the entire scratch texture. The fixed pass uses .dontCare, then draws a clear_fragment inside the scissor. That rectangle is written in full afterwards, and the composite reads only the same rectangle, so old attachment contents do not need to be loaded.
Second, the outer scissor must be restored after each chalk stroke’s composite finishes. If that state-restoration line is omitted, subsequent pens, images, or selections remain clipped by the previous chalk stroke’s bounds and appear to be missing.
Chalk grain still samples from absolute canvas coordinates rather than local UVs within the rectangle. Moving the scissor changes only which pixels the GPU processes; it does not change which grain-texture location each pixel reads. Adjacent rectangles therefore have no texture seams, and dragging the canvas does not make the grain drift.
Considering only the pixel workload, the new work scope is close to
where
Why same-colour chalk strokes were not batched
Most chalk strokes on the page share the same colour and density, making it tempting to draw dozens of them into the scratch at once and composite only once. That would reduce render passes further, but it would change the colour and grain semantics where strokes overlap.
Consider a deliberately simplified case: two strokes share a grain-gate value
whereas merging the bodies first and applying one gate produces
The difference is
Exact batching requires proving that the stroke pixels are mutually disjoint, or assigning an independent atlas region to each stroke and compositing in the original order. Device acceptance testing kept the scissor approach, so this round introduced neither an atlas nor its management complexity.
From a billion pixels back to a few million
The device test measurements were:
| Scenario | Before fix | Precise scissor |
|---|---|---|
| 70 visible chalk strokes, 300% zoom, writing | GPU 52–60 ms | ≈ 9–10 ms |
| ≈ 121 visible chalk strokes, 300% zoom | GPU 77–80 ms | 13.7–15.6 ms |
| Theoretical rectangle scope per frame (scratch + composite) | 783 M–1.34 B pixels | ≈ 1.7 M–3 M pixels |
We also verified the clipping bounds with 3,452 strokes and 202,710 sample points from a device document. At 0.5×, 1×, 2×, 3×, 5×, and 8× zoom, we generated 186,408 viewport cases; every point sprite that could produce non-zero coverage fell within the computed scissor. This check covered canvas edges, viewport edges, and various offset combinations.
The selected implementation does not switch to a low-resolution LOD according to interaction state. Low zoom levels still display the full-page ink texture; high zoom levels still redraw visible strokes as vectors. On the same side of the threshold, pen-down and panning do not replace existing strokes with a different clarity level. During high-zoom vector redraw, each chalk stroke’s scratch clear and composite cover only its own screen bounding box.
GPU time did not capture clarity
GPU performance problems do not necessarily scale with the most visible quantity in the data structure. In this case, 3,571 input points were an easy suspect; what determined frame time was the full-screen work triggered by each of the 70 chalk strokes, along with render-pass switching.
Visual semantics also constrained the available optimizations. Per-stroke scratch, the original compositing order, and absolute canvas grain coordinates could not be discarded at will. Same colour and same density only mean that the parameters match — they do not prove that overlapping results can be merged.
Real-device feedback rejected the version with the lowest GPU time. The comment “the entire canvas turns blurry” supplied the product constraint that measurement alone had not expressed: when the Apple Pencil touches down, users are also looking at the existing strokes.
The selected implementation introduces no new interaction cache layer and does not reduce clarity. High-zoom vector redraw simply limits each chalk stroke’s work to its own screen bounding box. After it was loaded onto LucasPad again, the feedback was: “Looks really good.”