diff --git a/CHANGELOG.md b/CHANGELOG.md index babf132..9ae220a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +### Fixed +- `TortoiseCanvas` built one `Path` and issued one `ctx.stroke` call per committed line segment, so redrawing the committed layer cost ~0.37µs per element no matter how much of it was on screen — 4.2ms for a 10,000-stroke drawing, half of a 120 Hz frame budget, paid again on every command commit. (The dominant cost is per-element CPU overhead, not rasterization: overdraw and off-screen strokes barely move the number, so `ViewportMode` is not a performance lever.) Consecutive strokes sharing a pen color and width now merge into a single multi-subpath `Path` drawn with one `ctx.stroke` call; round caps apply per subpath, so the drawing is unchanged. A 10,000-stroke redraw drops from 4.17ms to 0.64ms (6.5×), 4,000 from 1.68ms to 0.36ms. Translucent pen colors (`alpha < 1`) are excluded from the merge, since overlapping segments must blend once per stroke to match the SVG renderer's one `` per stroke ([#37](https://github.com/temoki/TortoiseGraphics2/issues/37)) + ## 2.0.0-beta9 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 5e79ce8..e08a5f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,8 @@ Tortoise API → [TortoiseCommand] → CommandPlayer.play() → [PlaybackFrame] **Two-layer rendering in `TortoiseCanvas` (#35).** Committed elements draw in `CommittedLayer`, a `Canvas` *outside* the `TimelineView`; only the in-progress stroke/arc and the tortoise sprite (`AnimationLayer`) render at display refresh rate. Do not move committed-element drawing back inside the `TimelineView` — that is O(elements) Path-building per display frame and stutters at a few hundred commands. `CommittedLayer` reads `model.elements` / `backgroundColor` during *body* evaluation (snapshotted into the Canvas closure) so Observation invalidates it exactly on frame commits and step/seek/clear — keep those reads at body level rather than relying on tracking inside the Canvas rendering closure. Drawing primitives shared by both layers live in `CanvasRenderer`. +**Stroke batching in `CanvasRenderer.drawElements` (#37).** A maximal run of consecutive `.stroke` elements sharing `color` and `width` is merged into one multi-subpath `Path` and drawn with a single `ctx.stroke` — the per-element `Path` + draw-call overhead (~0.37µs) dominates committed-layer redraw, not rasterization, so this is ~6× at 10,000 strokes. Round caps apply per subpath, so the output is unchanged. Two invariants to preserve: **translucent strokes (`color.alpha < 1`) must not be batched** — overlapping segments have to blend once per stroke to match the SVG renderer's one `` per stroke (`translucentOverlaps` scenario guards this) — and **`.fill` / `.arcStroke` / `.dot` are never batched**, so element order and z-order (and therefore `fillInsertionIndex`) are untouched. Batching does change antialiasing where strokes overlap (one rasterization instead of two blends), which is why the canvas goldens were re-recorded. + **`isFillActive` on `PlaybackFrame`.** Added so SVG and other renderers can defer stroke emission until after `endFill`, placing the fill polygon below its outline strokes. `CommandPlayer` snapshots `fillPoints != nil` at the start of each command iteration to set this flag. **`[DrawElement]` + `fillInsertionIndex` in `CanvasModel`.** Drawing elements are stored as a single ordered `[DrawElement]` list (not separate arrays per type) to preserve command-execution order. Strokes/dots emitted while `isFillActive` are appended immediately (so they animate live during the fill); `fillInsertionIndex` records the `elements.count` at the moment the fill became active, and on `endFill` the fill polygon is `insert`ed at that index — so it renders below its outline strokes regardless of command order, without delaying those strokes' own appearance. diff --git a/Sources/TortoiseUI/CanvasRenderer.swift b/Sources/TortoiseUI/CanvasRenderer.swift index f360856..876f140 100644 --- a/Sources/TortoiseUI/CanvasRenderer.swift +++ b/Sources/TortoiseUI/CanvasRenderer.swift @@ -18,9 +18,13 @@ enum CanvasRenderer { _ ctx: inout GraphicsContext, elements: [DrawElement], transform t: CGAffineTransform, scale s: Double ) { - for element in elements { - switch element { + // Index loop rather than `for element in elements` so a run of + // strokes can be consumed in one step (see the `.stroke` case). + var i = elements.startIndex + while i < elements.endIndex { + switch elements[i] { case .fill(let fill): + i += 1 guard fill.points.count >= 3, let first = fill.points.first else { continue } var path = Path() path.move(to: CGPoint(x: first.x, y: first.y).applying(t)) @@ -30,21 +34,40 @@ enum CanvasRenderer { path.closeSubpath() ctx.fill(path, with: .color(SwiftUI.Color(fill.color))) - case .stroke(let stroke): + case .stroke(let first): + // Merge the maximal run of same-color, same-width strokes into + // one multi-subpath `Path` and stroke it once. Round caps are + // applied per subpath, so the drawing is unchanged, but the + // per-element `Path` allocation + `ctx.stroke` call — the + // dominant cost at thousands of elements — is paid once per run. + // Translucent strokes are excluded: overlapping segments must + // blend once per stroke to match the SVG renderer, which emits + // one `` per stroke. + let batchable = first.color.alpha >= 1 var path = Path() - path.move(to: CGPoint(x: stroke.from.x, y: stroke.from.y).applying(t)) - path.addLine(to: CGPoint(x: stroke.to.x, y: stroke.to.y).applying(t)) + var j = i + while j < elements.endIndex, case .stroke(let next) = elements[j], + next.color == first.color, next.width == first.width + { + path.move(to: CGPoint(x: next.from.x, y: next.from.y).applying(t)) + path.addLine(to: CGPoint(x: next.to.x, y: next.to.y).applying(t)) + j += 1 + if !batchable { break } + } ctx.stroke( - path, with: .color(SwiftUI.Color(stroke.color)), - style: strokeStyle(width: stroke.width * s)) + path, with: .color(SwiftUI.Color(first.color)), + style: strokeStyle(width: first.width * s)) + i = j case .arcStroke(let arc): + i += 1 ctx.stroke( arcPath(arc, sweep: arc.sweep, transform: t), with: .color(SwiftUI.Color(arc.color)), style: strokeStyle(width: arc.width * s)) case .dot(let dot): + i += 1 let center = CGPoint(x: dot.center.x, y: dot.center.y).applying(t) let r = dot.size / 2 * s let rect = CGRect(x: center.x - r, y: center.y - r, width: r * 2, height: r * 2) @@ -129,10 +152,11 @@ enum CanvasRenderer { // MARK: - Private helpers - /// Strokes are drawn one per command, so consecutive segments are - /// independent paths. Round caps overlap at the shared endpoint, making + /// Strokes are recorded one per command, so consecutive segments are + /// separate subpaths. Round caps overlap at the shared endpoint, making /// joints look connected — matching the SVG renderer's - /// `stroke-linecap="round"`. + /// `stroke-linecap="round"`. Caps are applied per subpath, so this holds + /// whether segments are stroked individually or batched into one `Path`. private static func strokeStyle(width: Double) -> StrokeStyle { StrokeStyle(lineWidth: width, lineCap: .round, lineJoin: .round) } diff --git a/Sources/TortoiseUI/ViewportMode.swift b/Sources/TortoiseUI/ViewportMode.swift index 9dab7a3..db22fab 100644 --- a/Sources/TortoiseUI/ViewportMode.swift +++ b/Sources/TortoiseUI/ViewportMode.swift @@ -6,11 +6,11 @@ import TortoiseCore /// Conformances are declared explicitly so that adding an associated value /// to a case later cannot silently drop the implicit ones. public enum ViewportMode: Sendable, Equatable { - /// Scale the logical canvas to fill the view, preserving aspect ratio (letterboxed). Default. + /// Scale the logical canvas to fill the view, preserving aspect ratio (letterboxed). case scaleToFit /// 1 tortoise unit = 1 point, origin at view center. Wider views show more canvas. case original - /// Scale and translate so the actual drawing bounding box fills the view. + /// Scale and translate so the actual drawing bounding box fills the view. Default. /// /// Use SwiftUI's `.padding()` modifier to add space around the view. /// Falls back to `.scaleToFit` when the command stream produces no visible output. diff --git a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.translucentOverlaps.svg b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.translucentOverlaps.svg new file mode 100644 index 0000000..f88a67b --- /dev/null +++ b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.translucentOverlaps.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/Tests/TortoiseTestSupport/DrawingScenarios.swift b/Tests/TortoiseTestSupport/DrawingScenarios.swift index 38a0f38..5292052 100644 --- a/Tests/TortoiseTestSupport/DrawingScenarios.swift +++ b/Tests/TortoiseTestSupport/DrawingScenarios.swift @@ -17,6 +17,7 @@ extension DrawingScenario { hiddenTortoise, showAfterHide, speedChanges, + translucentOverlaps, showcase, ] @@ -230,6 +231,31 @@ extension DrawingScenario { t.forward(100) } + /// Covers translucent pen and fill colors. Where two translucent strokes + /// overlap — at every round-cap joint and every self-crossing of the star — + /// each must blend separately, matching the SVG renderer's one `` + /// per stroke. Guards `CanvasRenderer` against batching them into a single + /// path, which would blend the overlap only once. + public static let translucentOverlaps = DrawingScenario("translucentOverlaps") { t in + t.penWidth = 10 + t.penColor = Color(red: 0, green: 0, blue: 1, alpha: 0.4) + for _ in 0..<5 { + t.forward(150) + t.right(144) + } + t.penUp() + t.setPosition(x: -40, y: -150) + t.penDown() + t.penColor = Color(red: 0, green: 0.502, blue: 0, alpha: 0.5) + t.fillColor = Color(red: 1, green: 0, blue: 0, alpha: 0.35) + t.beginFill() + for _ in 0..<4 { + t.forward(80) + t.right(90) + } + t.endFill() + } + /// Kitchen-sink regression scene combining fills, arcs, dots, and teleports. public static let showcase = DrawingScenario("showcase") { t in t.backgroundColor = Color(red: 0.9, green: 0.95, blue: 1) diff --git a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.clearAndRedraw.png b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.clearAndRedraw.png index eaaac73..6abec29 100644 Binary files a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.clearAndRedraw.png and b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.clearAndRedraw.png differ diff --git a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.clearDuringFill.png b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.clearDuringFill.png index 1b12c81..8061a86 100644 Binary files a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.clearDuringFill.png and b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.clearDuringFill.png differ diff --git a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.filledShapes.png b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.filledShapes.png index 05951d2..bfc5525 100644 Binary files a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.filledShapes.png and b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.filledShapes.png differ diff --git a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.hiddenTortoise.png b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.hiddenTortoise.png index ae47677..935dada 100644 Binary files a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.hiddenTortoise.png and b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.hiddenTortoise.png differ diff --git a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.linesAndTurns.png b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.linesAndTurns.png index 112b232..7b64c3c 100644 Binary files a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.linesAndTurns.png and b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.linesAndTurns.png differ diff --git a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.penStyles.png b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.penStyles.png index f56c7f4..242f3db 100644 Binary files a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.penStyles.png and b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.penStyles.png differ diff --git a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.showAfterHide.png b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.showAfterHide.png index 4c93aa8..eed14f0 100644 Binary files a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.showAfterHide.png and b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.showAfterHide.png differ diff --git a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.showcase.png b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.showcase.png index f206a18..006842b 100644 Binary files a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.showcase.png and b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.showcase.png differ diff --git a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.speedChanges.png b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.speedChanges.png index 8e84a27..61734b8 100644 Binary files a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.speedChanges.png and b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.speedChanges.png differ diff --git a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.teleportAndHome.png b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.teleportAndHome.png index aba01ab..9ac4bbb 100644 Binary files a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.teleportAndHome.png and b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.teleportAndHome.png differ diff --git a/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.translucentOverlaps.png b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.translucentOverlaps.png new file mode 100644 index 0000000..0e4c3c9 Binary files /dev/null and b/Tests/TortoiseUITests/__Snapshots__/DrawingScenarioCanvasTests/scenario.translucentOverlaps.png differ