Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<line>` per stroke ([#37](https://github.com/temoki/TortoiseGraphics2/issues/37))

## 2.0.0-beta9

### Added
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<line>` 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.
Expand Down
44 changes: 34 additions & 10 deletions Sources/TortoiseUI/CanvasRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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 `<line>` 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)
Expand Down Expand Up @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/TortoiseUI/ViewportMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 26 additions & 0 deletions Tests/TortoiseTestSupport/DrawingScenarios.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ extension DrawingScenario {
hiddenTortoise,
showAfterHide,
speedChanges,
translucentOverlaps,
showcase,
]

Expand Down Expand Up @@ -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 `<line>`
/// 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)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading