diff --git a/Compositor/Document/BrushStroke.swift b/Compositor/Document/BrushStroke.swift index 318af7e4f..8d8ac7a2f 100644 --- a/Compositor/Document/BrushStroke.swift +++ b/Compositor/Document/BrushStroke.swift @@ -24,6 +24,23 @@ nonisolated struct BrushSettings: Sendable { var erasing = false var healing = false var healingMode: SpotHealingMode = .contentAware + /// A pen's pressure sets the size of each part of the stroke, and how strongly it lays down, as Photoshop's two + /// pressure buttons do. A mouse or trackpad always presses fully. + var pressureSize = false + var pressureOpacity = false + + /// What a press of `pressure` (0–1) does to the tip: its size as a share of the full diameter, and its strength. + func dynamics(_ pressure: CGFloat) -> (scale: CGFloat, strength: CGFloat) { + let pressure = pressure.isFinite ? min(1, max(0, pressure)) : 1 + // Never smaller than a pixel across, so the lightest touch still leaves a line. + return (pressureSize ? max(pressure, min(1, 1 / diameter)) : 1, pressureOpacity ? pressure : 1) + } +} + +/// One piece of a continuous stroke for the GPU: the line, and the tip's radius and strength at each end. +struct BrushSegment { + var line: SIMD4 + var dynamics: SIMD4 } nonisolated struct BrushPatch: @unchecked Sendable { @@ -168,6 +185,9 @@ final class BrushStroke { private var allocatedBounds: CGRect? private var previous: CGPoint? private var samples: [CGPoint] = [] + /// The pen pressure at each of `samples`, 1 for a mouse. + private var pressures: [CGFloat] = [] + private var previousPressure: CGFloat = 1 /// Coverage under the provisional tail; nil where the tile had no coverage yet. private var tailBackup: [Int: CGImage?] = [:] private var distanceToNext: CGFloat = 0 @@ -271,21 +291,24 @@ final class BrushStroke { /// than straight chords. A curve piece needs the sample after it, so the newest piece /// is first drawn as a provisional straight tail (the stroke never trails the cursor), /// then erased and replaced by the curve when the next sample arrives or on `flush()`. - func append(_ point: CGPoint) throws { + func append(_ point: CGPoint, pressure: CGFloat = 1) throws { guard point.x.isFinite, point.y.isFinite, abs(point.x) <= 10_000_000, abs(point.y) <= 10_000_000 else { return } guard samples.last != point else { return } - if gpu != nil { try appendContinuous(point); return } + let pressure = pressure.isFinite ? min(1, max(0, pressure)) : 1 + if gpu != nil { try appendContinuous(point, pressure: pressure); return } var changed = removeTail() samples.append(point) - if samples.count > 4 { samples.removeFirst() } + pressures.append(pressure) + if samples.count > 4 { samples.removeFirst(); pressures.removeFirst() } let count = samples.count if count == 1 { - try walk(to: point, changed: &changed) + try walk(to: point, pressure: pressure, changed: &changed) } else if count >= 3 { try curve(from: samples[count - 3], to: samples[count - 2], - before: samples[max(0, count - 4)], after: samples[count - 1], changed: &changed) + before: samples[max(0, count - 4)], after: samples[count - 1], + pressures: (pressures[count - 3], pressures[count - 2]), changed: &changed) } - if count >= 2 { try drawTail(from: samples[count - 2], to: point, changed: &changed) } + if count >= 2 { try drawTail(from: samples[count - 2], to: point, pressure: pressure, changed: &changed) } try publish(changed) } @@ -296,23 +319,26 @@ final class BrushStroke { let count = samples.count if count >= 2 { try curve(from: samples[count - 2], to: samples[count - 1], - before: samples[max(0, count - 3)], after: samples[count - 1], changed: &changed) + before: samples[max(0, count - 3)], after: samples[count - 1], + pressures: (pressures[count - 2], pressures[count - 1]), changed: &changed) samples = [samples[count - 1]] + pressures = [pressures[count - 1]] } try publish(changed) } - private func appendContinuous(_ point: CGPoint) throws { + private func appendContinuous(_ point: CGPoint, pressure: CGFloat) throws { samples.append(point) - if samples.count > 4 { samples.removeFirst() } + pressures.append(pressure) + if samples.count > 4 { samples.removeFirst(); pressures.removeFirst() } let n = samples.count - var settled: [SIMD4] = [] - if n == 1 { settled = [segment(point, point)] } + var settled: [BrushSegment] = [] + if n == 1 { settled = [segment(point, point, pressure, pressure)] } else if n >= 3 { settled = continuousCurve(from: samples[n - 3], to: samples[n - 2], - before: samples[max(0, n - 4)], after: point) + before: samples[max(0, n - 4)], after: point, pressures: (pressures[n - 3], pressures[n - 2])) } - let tail = n >= 2 ? [segment(samples[n - 2], point)] : [] + let tail = n >= 2 ? [segment(samples[n - 2], point, pressures[n - 2], pressure)] : [] try renderContinuous(settled: settled, tail: tail) } @@ -320,18 +346,25 @@ final class BrushStroke { let n = samples.count guard n >= 2 else { return } let settled = continuousCurve(from: samples[n - 2], to: samples[n - 1], - before: samples[max(0, n - 3)], after: samples[n - 1]) + before: samples[max(0, n - 3)], after: samples[n - 1], pressures: (pressures[n - 2], pressures[n - 1])) try renderContinuous(settled: settled, tail: []) samples = [samples[n - 1]] + pressures = [pressures[n - 1]] } - private func segment(_ a: CGPoint, _ b: CGPoint) -> SIMD4 { - SIMD4(Float(a.x), Float(a.y), Float(b.x), Float(b.y)) + private func segment(_ a: CGPoint, _ b: CGPoint, _ pressureA: CGFloat, _ pressureB: CGFloat) -> BrushSegment { + let radius = settings.diameter / 2 + let start = settings.dynamics(pressureA), end = settings.dynamics(pressureB) + return BrushSegment(line: SIMD4(Float(a.x), Float(a.y), Float(b.x), Float(b.y)), + dynamics: SIMD4(Float(radius * start.scale), Float(radius * end.scale), + Float(start.strength), Float(end.strength))) } /// Adaptive chord subdivision keeps the centerline within 0.2 document pixels /// of the spline. Straight movement requires just one segment even at 4K. - private func continuousCurve(from start: CGPoint, to end: CGPoint, before: CGPoint, after: CGPoint) -> [SIMD4] { + private func continuousCurve(from start: CGPoint, to end: CGPoint, before: CGPoint, after: CGPoint, + pressures: (CGFloat, CGFloat)) -> [BrushSegment] { + func pressure(_ u: CGFloat) -> CGFloat { pressures.0 + (pressures.1 - pressures.0) * u } func knot(_ t: CGFloat, _ a: CGPoint, _ b: CGPoint) -> CGFloat { t + max(0.0001, sqrt(hypot(b.x - a.x, b.y - a.y))) } func mix(_ a: CGPoint, _ b: CGPoint, _ ta: CGFloat, _ tb: CGFloat, _ t: CGFloat) -> CGPoint { let wa = (tb - t) / (tb - ta), wb = (t - ta) / (tb - ta) @@ -344,7 +377,7 @@ final class BrushStroke { let a = mix(before, start, t0, t1, t), b = mix(start, end, t1, t2, t), c = mix(end, after, t2, t3, t) return mix(mix(a, b, t0, t2, t), mix(b, c, t1, t3, t), t1, t2, t) } - var result: [SIMD4] = [] + var result: [BrushSegment] = [] func subdivide(_ a: CGPoint, _ b: CGPoint, _ lo: CGFloat, _ hi: CGFloat, _ depth: Int) { let dx = b.x - a.x, dy = b.y - a.y, lengthSquared = dx * dx + dy * dy func error(_ p: CGPoint) -> CGFloat { @@ -353,7 +386,7 @@ final class BrushStroke { } let mid = (lo + hi) / 2, m = point(mid) let deviation = max(error(m), error(point((lo + mid) / 2)), error(point((mid + hi) / 2))) - if deviation <= 0.2 || depth >= 10 { result.append(segment(a, b)); return } + if deviation <= 0.2 || depth >= 10 { result.append(segment(a, b, pressure(lo), pressure(hi))); return } subdivide(a, m, lo, mid, depth + 1) subdivide(m, b, mid, hi, depth + 1) } @@ -361,12 +394,12 @@ final class BrushStroke { return result } - private func continuousKeys(_ segments: [SIMD4]) -> Set { + private func continuousKeys(_ segments: [BrushSegment]) -> Set { var keys = Set() let reach = settings.diameter / 2 + 2 let inverse = pixelToDocument.inverted() let columns = (width + Self.tileSize - 1) / Self.tileSize - for s in segments { + for s in segments.map(\.line) { let box = CGRect(x: CGFloat(min(s.x, s.z)), y: CGFloat(min(s.y, s.w)), width: CGFloat(abs(s.z - s.x)), height: CGFloat(abs(s.w - s.y))) .insetBy(dx: -reach, dy: -reach).intersection(canvas) @@ -382,7 +415,7 @@ final class BrushStroke { return keys } - private func renderContinuous(settled: [SIMD4], tail: [SIMD4]) throws { + private func renderContinuous(settled: [BrushSegment], tail: [BrushSegment]) throws { guard let gpu else { return } let tailKeys = continuousKeys(tail) let changed = continuousKeys(settled).union(tailKeys).union(gpuTailKeys) @@ -404,7 +437,7 @@ final class BrushStroke { /// Draws a straight tail to the cursor, first saving the coverage it can touch and the /// dab spacing state, so `removeTail()` can put both back exactly. - private func drawTail(from start: CGPoint, to end: CGPoint, changed: inout Set) throws { + private func drawTail(from start: CGPoint, to end: CGPoint, pressure: CGFloat, changed: inout Set) throws { let reach = settings.diameter / 2 + 2 let box = CGRect(x: min(start.x, end.x), y: min(start.y, end.y), width: abs(end.x - start.x), height: abs(end.y - start.y)) .insetBy(dx: -reach, dy: -reach).intersection(canvas) @@ -420,9 +453,9 @@ final class BrushStroke { } } } - let saved = (previous, distanceToNext) - try walk(to: end, changed: &changed) - (previous, distanceToNext) = saved + let saved = (previous, distanceToNext, previousPressure) + try walk(to: end, pressure: pressure, changed: &changed) + (previous, distanceToNext, previousPressure) = saved } private func removeTail() -> Set { @@ -441,7 +474,8 @@ final class BrushStroke { /// Centripetal Catmull–Rom between `start` and `end`: it passes through every sample /// without the loops or overshoot uniform splines make at uneven mouse speeds. - private func curve(from start: CGPoint, to end: CGPoint, before: CGPoint, after: CGPoint, changed: inout Set) throws { + private func curve(from start: CGPoint, to end: CGPoint, before: CGPoint, after: CGPoint, + pressures: (CGFloat, CGFloat), changed: inout Set) throws { func knot(_ t: CGFloat, _ a: CGPoint, _ b: CGPoint) -> CGFloat { t + max(0.0001, sqrt(hypot(b.x - a.x, b.y - a.y))) } func mix(_ a: CGPoint, _ b: CGPoint, _ ta: CGFloat, _ tb: CGFloat, _ t: CGFloat) -> CGPoint { let wa = (tb - t) / (tb - ta), wb = (t - ta) / (tb - ta) @@ -453,7 +487,8 @@ final class BrushStroke { let t = t1 + (t2 - t1) * CGFloat(index) / CGFloat(pieces) let a1 = mix(before, start, t0, t1, t), a2 = mix(start, end, t1, t2, t), a3 = mix(end, after, t2, t3, t) let b1 = mix(a1, a2, t0, t2, t), b2 = mix(a2, a3, t1, t3, t) - try walk(to: index == pieces ? end : mix(b1, b2, t1, t2, t), changed: &changed) + let pressure = pressures.0 + (pressures.1 - pressures.0) * CGFloat(index) / CGFloat(pieces) + try walk(to: index == pieces ? end : mix(b1, b2, t1, t2, t), pressure: pressure, changed: &changed) } } @@ -462,7 +497,7 @@ final class BrushStroke { static func spacingFraction(_ hardness: CGFloat) -> CGFloat { hardness >= 1 ? 0.015 : 0.025 } /// Lays evenly spaced dabs along a straight run from the previous dab position. - private func walk(to point: CGPoint, changed: inout Set) throws { + private func walk(to point: CGPoint, pressure: CGFloat = 1, changed: inout Set) throws { let spacing = max(0.25, settings.diameter * Self.spacingFraction(settings.hardness)) if let previous { let dx = point.x - previous.x, dy = point.y - previous.y @@ -470,16 +505,19 @@ final class BrushStroke { if length > 0 { var distance = distanceToNext while distance <= length { - try dab(CGPoint(x: previous.x + dx * distance / length, y: previous.y + dy * distance / length), changed: &changed) + let along = distance / length + try dab(CGPoint(x: previous.x + dx * along, y: previous.y + dy * along), + pressure: previousPressure + (pressure - previousPressure) * along, changed: &changed) distance += spacing } distanceToNext = distance - length } } else { - try dab(point, changed: &changed) + try dab(point, pressure: pressure, changed: &changed) distanceToNext = spacing } previous = point + previousPressure = pressure } private func publish(_ changed: Set) throws { @@ -573,8 +611,12 @@ final class BrushStroke { private static let eraseColor = CGColor(srgbRed: 0, green: 0, blue: 0, alpha: 1) private static let healingWash = CGColor(srgbRed: 0.12, green: 0.12, blue: 0.12, alpha: 1) - private func dab(_ point: CGPoint, changed: inout Set) throws { - let radius = settings.diameter / 2 + private func dab(_ point: CGPoint, pressure: CGFloat = 1, changed: inout Set) throws { + let dynamics = settings.dynamics(pressure) + guard dynamics.strength > 0 else { return } + // A dab smaller or fainter than the full tip is drawn rather than blitted from the full-size one. + let full = dynamics.scale == 1 && dynamics.strength == 1 + let radius = settings.diameter / 2 * dynamics.scale let circle = CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2) let clipped = circle.intersection(canvas) guard !clipped.isNull, !clipped.isEmpty else { return } @@ -583,7 +625,7 @@ final class BrushStroke { // Snapped to whole pixels so the tip lands 1:1 with nothing to resample. The tip is // radially symmetric, so the layer's flip is harmless, and half a pixel of placement // sits far below what a dab's soft rim resolves. - let blit: CGRect? = gridTip.map { tip in + let blit: CGRect? = (full ? gridTip : nil).map { tip in let center = point.applying(inverse) return CGRect(x: (center.x - CGFloat(tip.width) / 2).rounded(), y: (center.y - CGFloat(tip.height) / 2).rounded(), @@ -609,7 +651,7 @@ final class BrushStroke { } guard let context = coverage[key] else { continue } context.saveGState() - if let gridTip, let blit { + if full, let gridTip, let blit { context.clip(to: pixelCanvas.offsetBy(dx: -tile.rect.minX, dy: -tile.rect.minY)) context.setBlendMode(settings.hardness >= 1 ? .lighten : .screen) context.interpolationQuality = .none @@ -622,7 +664,12 @@ final class BrushStroke { context.concatenate(inverse) context.clip(to: canvas) context.setBlendMode(settings.hardness >= 1 ? .lighten : .screen) - if let stamp { + if settings.hardness >= 1, dynamics.strength < 1 { + // Lighten with a gray as strong as the press: the stroke builds to the firmest press, never past it. + context.setFillColor(gray: dynamics.strength, alpha: 1) + context.fillEllipse(in: circle) + } else if let stamp { + context.setAlpha(dynamics.strength) // Stamp the pre-rendered tip: drawing the falloff procedurally for every // dab (a 60 px brush lays ~14 per mouse move) is what made strokes stutter. context.interpolationQuality = .low @@ -631,6 +678,7 @@ final class BrushStroke { context.setFillColor(gray: 1, alpha: 1) context.fillEllipse(in: circle) } else if let falloff { + context.setAlpha(dynamics.strength) context.drawRadialGradient(falloff, startCenter: point, startRadius: radius * settings.hardness, endCenter: point, endRadius: radius, options: [.drawsBeforeStartLocation]) } diff --git a/Compositor/Document/EditorSession+Brush.swift b/Compositor/Document/EditorSession+Brush.swift index 5719157dc..dee5cbd3c 100644 --- a/Compositor/Document/EditorSession+Brush.swift +++ b/Compositor/Document/EditorSession+Brush.swift @@ -47,7 +47,8 @@ extension EditorSession { } return stroke } - func beginBrush(at point: CGPoint) { + /// `pressure` is a pen's, 0–1; nil for a mouse or trackpad, which presses fully. + func beginBrush(at point: CGPoint, pressure: CGFloat? = nil) { // Spot Healing and Clone Stamp rework image pixels; they have nothing to do on a mask. if tool == .blur, blurMode != .blur { beginWarp(at: point); return } guard tool == .brush || tool == .blur || (tool.isBrushTool && !isMaskSelected) else { return } @@ -66,6 +67,8 @@ extension EditorSession { settings.healing = tool == .spotHealing settings.erasing = tool == .brush && brushMode == .erase && !isMaskSelected settings.healingMode = spotHealingMode + // The pressure buttons belong to the Brush; the other brush tools paint at full pressure. + if tool != .brush { settings.pressureSize = false; settings.pressureOpacity = false } if isMaskSelected { settings.red = maskPaintWhite ? 1 : 0; settings.green = settings.red; settings.blue = settings.red } let stroke = try makeRasterEdit(for: layer, settings: settings, growsMask: tool == .brush) if let offset = sourceOffset { @@ -81,19 +84,21 @@ extension EditorSession { } stroke.isBlur = tool == .blur brushStroke = stroke - try stroke.append(point) + brushPressure = pressure ?? 1 + try stroke.append(point, pressure: brushPressure) brushAnchor = point brushPointer = point lastBrushPoint = (point, layer.id, isMaskSelected) brushRevision += 1 } catch { cancelBrush(); brushError = error.localizedDescription } } - func continueBrush(at point: CGPoint) { + func continueBrush(at point: CGPoint, pressure: CGFloat? = nil) { if let warpStroke { warpStroke.append(point); lastBrushPoint?.point = point; brushRevision += 1; return } guard let brushStroke else { return } brushPointer = point + if let pressure { brushPressure = pressure } guard let painted = smoothed(point) else { return } - do { try brushStroke.append(painted); lastBrushPoint?.point = painted; brushRevision += 1 } + do { try brushStroke.append(painted, pressure: brushPressure); lastBrushPoint?.point = painted; brushRevision += 1 } catch { cancelBrush(); brushError = error.localizedDescription } } /// Where the brush actually is, with Smoothing on: it trails the pointer on a string, and only @@ -138,7 +143,7 @@ extension EditorSession { // Smoothing leaves the brush short of the pointer; the stroke ends where the hand did. if let pointer = brushPointer, let anchor = brushAnchor, pointer != anchor, tool == .brush, brushSettings.smoothing > 0 { - try stroke.append(pointer) + try stroke.append(pointer, pressure: brushPressure) } try stroke.flush() if stroke.settings.healing { try stroke.heal() } diff --git a/Compositor/Document/EditorSession.swift b/Compositor/Document/EditorSession.swift index 5681cf63b..26c9efa82 100644 --- a/Compositor/Document/EditorSession.swift +++ b/Compositor/Document/EditorSession.swift @@ -191,6 +191,8 @@ final class EditorSession { @ObservationIgnored var brushAnchor: CGPoint? /// The pointer itself, so a smoothed stroke can catch up to it when the button is released. @ObservationIgnored var brushPointer: CGPoint? + /// The pen's latest pressure in this stroke; 1 for a mouse. Events that carry none (the release) keep it. + @ObservationIgnored var brushPressure: CGFloat = 1 @ObservationIgnored var maskDistortPreviewCache: MaskDistortPreviewCache? /// The last rounded rectangle drawn for a transform in progress, by layer, with the size it was drawn at. @ObservationIgnored var shapeTransformPreviewCache: [UUID: (size: CGSize, image: CGImage)] = [:] diff --git a/Compositor/Rendering/EditorCanvas.swift b/Compositor/Rendering/EditorCanvas.swift index 39a521d83..2ac841aca 100644 --- a/Compositor/Rendering/EditorCanvas.swift +++ b/Compositor/Rendering/EditorCanvas.swift @@ -1734,6 +1734,12 @@ final class CanvasView: NSView { brushPointer = convert(event.locationInWindow, from: nil) updateBrushCursor() } + /// A pen's pressure on a tablet, 0–1. A mouse, or a trackpad's Force Touch, has none: it paints at full pressure, + /// as it does in Photoshop. + static func penPressure(_ event: NSEvent) -> CGFloat? { + event.subtype == .tabletPoint ? CGFloat(event.pressure) : nil + } + override func mouseDown(with event: NSEvent) { session.effectSelection = nil optionHeld = event.modifierFlags.contains(.option) @@ -1810,10 +1816,10 @@ final class CanvasView: NSView { brushPointer = point // Shift paints a straight line on from where the last stroke ended, as in Photoshop. if event.modifierFlags.contains(.shift), let from = session.shiftLineStart() { - session.beginBrush(at: from) - session.continueBrush(at: pixel) + session.beginBrush(at: from, pressure: Self.penPressure(event)) + session.continueBrush(at: pixel, pressure: Self.penPressure(event)) } else { - session.beginBrush(at: pixel) + session.beginBrush(at: pixel, pressure: Self.penPressure(event)) } brushAxisAnchor = event.modifierFlags.contains(.shift) ? pixel : nil brushAxisHorizontal = nil @@ -1938,7 +1944,7 @@ final class CanvasView: NSView { brushAxisHorizontal = nil } brushLastPixel = pixel - session.continueBrush(at: pixel) + session.continueBrush(at: pixel, pressure: Self.penPressure(event)) synchronizeDisplay() return } diff --git a/Compositor/Rendering/MetalBrushCoverage.swift b/Compositor/Rendering/MetalBrushCoverage.swift index 4ba54e611..e4b86d0ba 100644 --- a/Compositor/Rendering/MetalBrushCoverage.swift +++ b/Compositor/Rendering/MetalBrushCoverage.swift @@ -26,17 +26,18 @@ final class MetalBrushCoverage { pipeline = try device.makeComputePipelineState(function: function) } func tile(width: Int, height: Int) throws -> Tile { - guard let permanent = device.makeBuffer(length: width * height * MemoryLayout.stride, options: .storageModeShared), + // Two floats a pixel: the paint laid down, and the firmest press that reached it (see the shader). + guard let permanent = device.makeBuffer(length: width * height * 2 * MemoryLayout.stride, options: .storageModeShared), let preview = device.makeBuffer(length: width * height, options: .storageModeShared) else { throw ExportError.render } - memset(permanent.contents(), 0, width * height * MemoryLayout.stride) + memset(permanent.contents(), 0, width * height * 2 * MemoryLayout.stride) return Tile(permanent: permanent, preview: preview) } - func render(_ tiles: [(Tile, CGRect, CGContext)], settled: [SIMD4], tail: [SIMD4], + func render(_ tiles: [(Tile, CGRect, CGContext)], settled: [BrushSegment], tail: [BrushSegment], mapping: CGAffineTransform, settings: BrushSettings, canvas: CGSize) throws { guard !tiles.isEmpty else { return } // A dummy segment supplies a valid buffer for tail removal with no new geometry. let segments = settled + tail - let storage = segments.isEmpty ? [SIMD4(repeating: 0)] : segments + let storage = segments.isEmpty ? [BrushSegment(line: SIMD4(repeating: 0), dynamics: SIMD4(repeating: 0))] : segments guard let buffer = storage.withUnsafeBytes({ bytes in device.makeBuffer(bytes: bytes.baseAddress!, length: bytes.count, options: .storageModeShared) }), let command = queue.makeCommandBuffer(), let encoder = command.makeComputeCommandEncoder() else { throw ExportError.render } @@ -78,39 +79,65 @@ struct BrushUniforms { uint4 counts; // tile width, height, committed segment count, total segment count }; -float segmentDistanceSquared(float2 p, float4 segment) { +// dynamics: the tip's radius at the start and end, and its strength at the start and end (a pen's pressure). +struct BrushSegment { + float4 line; + float4 dynamics; +}; + +// Where along the segment, 0 to 1, is nearest `p`. +float closestAlong(float2 p, float4 segment) { float2 v = segment.zw - segment.xy; - float t = clamp(dot(p - segment.xy, v) / max(dot(v, v), 1e-12f), 0.0f, 1.0f); - float2 delta = p - (segment.xy + t * v); + return clamp(dot(p - segment.xy, v) / max(dot(v, v), 1e-12f), 0.0f, 1.0f); +} + +float segmentDistanceSquared(float2 p, float4 segment, float t) { + float2 delta = p - (segment.xy + t * (segment.zw - segment.xy)); return dot(delta, delta); } -float brushCoverage(float distanceSquared, constant BrushUniforms &u) { +float brushCoverage(float distanceSquared, float radius, constant BrushUniforms &u) { float distance = sqrt(distanceSquared); - float radius = u.geometry.z; if (u.geometry.w >= 1.0f) { return clamp((radius - distance) / u.canvas.z + 0.5f, 0.0f, 1.0f); } + if (radius <= 0.0f) return 0.0f; float t = clamp((distance / radius - u.geometry.w) / (1.0f - u.geometry.w), 0.0f, 1.0f); return max(0.0f, (exp(-2.5f * t * t) - exp(-2.5f)) / (1.0f - exp(-2.5f))); } +// A hard tip's coverage from one segment, at the radius and strength of its nearest point. +float hardCoverage(float2 p, BrushSegment s, constant BrushUniforms &u) { + float t = closestAlong(p, s.line); + return mix(s.dynamics.z, s.dynamics.w, t) * brushCoverage(segmentDistanceSquared(p, s.line, t), mix(s.dynamics.x, s.dynamics.y, t), u); +} + +// The strength of the press where a segment reaches `p`; 0 where it doesn't. +float segmentStrength(float2 p, BrushSegment s) { + float t = closestAlong(p, s.line); + float radius = mix(s.dynamics.x, s.dynamics.y, t); + return segmentDistanceSquared(p, s.line, t) < radius * radius ? mix(s.dynamics.z, s.dynamics.w, t) : 0.0f; +} + // Integrate paint deposition by distance travelled, not pointer-event count or // spline subdivision count. Optical density adds; coverage is 1 - exp(-density). // This is the continuous form of source-over soft dabs at the shared deposition spacing. -float tipDensity(float distanceSquared, constant BrushUniforms &u) { - return -log(max(1.0f - brushCoverage(distanceSquared, u), 0.001f)); +float tipDensity(float distanceSquared, float radius, constant BrushUniforms &u) { + return -log(max(1.0f - brushCoverage(distanceSquared, radius, u), 0.001f)); } -float segmentDensity(float2 p, float4 segment, constant BrushUniforms &u) { +float segmentDensity(float2 p, BrushSegment s, constant BrushUniforms &u) { + float4 segment = s.line; float2 v = segment.zw - segment.xy; float length = metal::length(v); - if (length < 1e-6f) return tipDensity(dot(p - segment.xy, p - segment.xy), u); // initial click + if (length < 1e-6f) return tipDensity(dot(p - segment.xy, p - segment.xy), s.dynamics.x, u); // initial click float2 direction = v / length; float projection = dot(p - segment.xy, direction); float2 perpendicular = p - segment.xy - projection * direction; float perpendicularSquared = dot(perpendicular, perpendicular); - float radiusSquared = u.geometry.z * u.geometry.z; + // The widest the tip gets along the segment bounds where it can reach. + float widest = max(s.dynamics.x, s.dynamics.y); + float radiusSquared = widest * widest; if (perpendicularSquared >= radiusSquared) return 0.0f; float reach = sqrt(radiusSquared - perpendicularSquared); float lo = max(0.0f, projection - reach), hi = min(length, projection + reach); @@ -124,8 +151,11 @@ float segmentDensity(float2 p, float4 segment, constant BrushUniforms &u) { for (uint i = 0; i < 4; ++i) { float a = midpoint - halfLength * nodes[i] - projection; float b = midpoint + halfLength * nodes[i] - projection; - integral += weights[i] * (tipDensity(perpendicularSquared + a * a, u) - + tipDensity(perpendicularSquared + b * b, u)); + // The tip's radius where each sample sits along the segment. + float ra = mix(s.dynamics.x, s.dynamics.y, clamp((projection + a) / length, 0.0f, 1.0f)); + float rb = mix(s.dynamics.x, s.dynamics.y, clamp((projection + b) / length, 0.0f, 1.0f)); + integral += weights[i] * (tipDensity(perpendicularSquared + a * a, ra, u) + + tipDensity(perpendicularSquared + b * b, rb, u)); } return integral * halfLength / u.canvas.w; } @@ -135,7 +165,7 @@ float segmentDensity(float2 p, float4 segment, constant BrushUniforms &u) { kernel void continuousBrush(device float *permanent [[buffer(0)]], device uchar *preview [[buffer(1)]], constant BrushUniforms &u [[buffer(2)]], - device const float4 *segments [[buffer(3)]], + device const BrushSegment *segments [[buffer(3)]], uint2 pixel [[thread_position_in_grid]]) { if (pixel.x >= u.counts.x || pixel.y >= u.counts.y) return; uint index = pixel.y * u.counts.x + pixel.x; @@ -143,19 +173,29 @@ kernel void continuousBrush(device float *permanent [[buffer(0)]], float2 p = u.geometry.xy + local.x * u.mapping.xy + local.y * u.mapping.zw; if (any(p < 0.0f) || any(p >= u.canvas.xy)) { preview[index] = 0; return; } if (u.geometry.w >= 1.0f) { - // Hard tips already have a solid interior. Preserve pixel-edge antialiasing. - float settled = INFINITY, tail = INFINITY; - for (uint i = 0; i < u.counts.z; ++i) settled = min(settled, segmentDistanceSquared(p, segments[i])); - for (uint i = u.counts.z; i < u.counts.w; ++i) tail = min(tail, segmentDistanceSquared(p, segments[i])); - float value = max(permanent[index], brushCoverage(settled, u)); - permanent[index] = value; - preview[index] = uchar(round(255.0f * max(value, brushCoverage(tail, u)))); + // Hard tips already have a solid interior. Preserve pixel-edge antialiasing. The strongest press to reach a + // pixel sets it, so a stroke builds to its firmest press and never past it. + float settled = 0.0f, tail = 0.0f; + for (uint i = 0; i < u.counts.z; ++i) settled = max(settled, hardCoverage(p, segments[i], u)); + for (uint i = u.counts.z; i < u.counts.w; ++i) tail = max(tail, hardCoverage(p, segments[i], u)); + float value = max(permanent[2 * index], settled); + permanent[2 * index] = value; + preview[index] = uchar(round(255.0f * max(value, tail))); } else { - float value = permanent[index], tail = 0.0f; - for (uint i = 0; i < u.counts.z; ++i) value += segmentDensity(p, segments[i], u); - for (uint i = u.counts.z; i < u.counts.w; ++i) tail += segmentDensity(p, segments[i], u); - permanent[index] = min(value, 20.0f); - preview[index] = uchar(round(255.0f * (1.0f - exp(-min(value + tail, 20.0f))))); + // Soft paint builds up as it always has, then the firmest press to reach the pixel caps it. + float value = permanent[2 * index], tail = 0.0f; + float cap = permanent[2 * index + 1], tailCap = 0.0f; + for (uint i = 0; i < u.counts.z; ++i) { + value += segmentDensity(p, segments[i], u); + cap = max(cap, segmentStrength(p, segments[i])); + } + for (uint i = u.counts.z; i < u.counts.w; ++i) { + tail += segmentDensity(p, segments[i], u); + tailCap = max(tailCap, segmentStrength(p, segments[i])); + } + permanent[2 * index] = min(value, 20.0f); + permanent[2 * index + 1] = cap; + preview[index] = uchar(round(255.0f * max(cap, tailCap) * (1.0f - exp(-min(value + tail, 20.0f))))); } } """ diff --git a/Compositor/UI/BrushControls.swift b/Compositor/UI/BrushControls.swift index 82318d8f2..ec26d3b53 100644 --- a/Compositor/UI/BrushControls.swift +++ b/Compositor/UI/BrushControls.swift @@ -67,6 +67,17 @@ struct BrushControls: View { change: { session.brushSettings.opacity = CGFloat(min(100, max(1, $0)) / 100) }) .help("Press 1–9 for 10–90%, 0 for 100%") .unitSuffix("%") + // A pen's pressure, as Photoshop's two buttons beside Size and Opacity. + if session.tool == .brush { + Toggle(isOn: $session.brushSettings.pressureSize) { Image(systemName: "scribble.variable") } + .toggleStyle(.button) + .help("Pen pressure sets the size: a light touch paints a thinner line. A mouse always paints full size.") + .accessibilityLabel("Pressure for size") + Toggle(isOn: $session.brushSettings.pressureOpacity) { Image(systemName: "drop.halffull") } + .toggleStyle(.button) + .help("Pen pressure sets the opacity: a light touch paints fainter, up to the brush’s Opacity.") + .accessibilityLabel("Pressure for opacity") + } // Blur softens by a radius of its own, apart from how strongly it lays the softening down. if session.tool == .blur, session.blurMode == .blur { Text("Radius").scrubbable(sensitivity: 0.1, value: $session.brushSettings.blurRadius, range: 0.5...50) diff --git a/CompositorTests/PenPressureTests.swift b/CompositorTests/PenPressureTests.swift new file mode 100644 index 000000000..9699c2a32 --- /dev/null +++ b/CompositorTests/PenPressureTests.swift @@ -0,0 +1,121 @@ +import AppKit +import Testing +@testable import Compositor + +@MainActor +struct PenPressureTests { + private func session(hardness: CGFloat = 1) -> EditorSession { + let session = EditorSession() + session.createDocument(width: 300, height: 100) + session.addBlankLayer() + session.selectTool(.brush) + session.brushSettings = BrushSettings(diameter: 40, hardness: hardness, red: 1, green: 0, blue: 0) + return session + } + + /// The canvas as exported, and one pixel's alpha from it. + private func canvas(_ session: EditorSession) async throws -> NSBitmapImageRep { + NSBitmapImageRep(cgImage: try await ImageExporter.shared.render(try #require(session.projectSnapshot())).image) + } + private func alpha(_ bitmap: NSBitmapImageRep, _ x: Int, _ y: Int) -> Int { + var pixel = [Int](repeating: 0, count: 4) + bitmap.getPixel(&pixel, atX: x, y: y) + return pixel[3] + } + /// How many rows of column `x` the stroke covers. + private func thickness(_ bitmap: NSBitmapImageRep, _ x: Int) -> Int { + (0.. 127 }.count + } + + /// A left-to-right stroke along y = 50, its pressure easing from `from` to `to`. + private func stroke(_ session: EditorSession, from: CGFloat?, to: CGFloat?) { + session.beginBrush(at: CGPoint(x: 30, y: 50), pressure: from) + for step in 1...24 { + let t = CGFloat(step) / 24 + session.continueBrush(at: CGPoint(x: 30 + 240 * t, y: 50), pressure: from.map { $0 + ((to ?? $0) - $0) * t }) + } + session.finishBrushImmediately() + } + + @Test func pressureSetsTheSizeWhenAsked() async throws { + let session = session() + session.brushSettings.pressureSize = true + stroke(session, from: 1, to: 0.25) + let bitmap = try await canvas(session) + let start = thickness(bitmap, 40), end = thickness(bitmap, 260) + #expect(abs(start - 40) <= 3, "full pressure paints the full size") + #expect(end < 16 && end >= 8, "a quarter of the pressure, about a quarter of the size: \(end)") + #expect(thickness(bitmap, 150) < start && thickness(bitmap, 150) > end) + } + + @Test func pressureSetsTheOpacityAndTheStrokeNeverPassesItsFirmestPress() async throws { + for hardness: CGFloat in [1, 0] { + let session = session(hardness: hardness) + session.brushSettings.pressureOpacity = true + stroke(session, from: 0.5, to: 0.5) + let bitmap = try await canvas(session) + // The middle of the line, well inside a soft tip's full-strength center once dabs have built up. + #expect(abs(alpha(bitmap, 150, 50) - 128) <= 4, "hardness \(hardness): \(alpha(bitmap, 150, 50))") + } + } + + @Test func aMouseOrTheButtonsOffPaintAsBefore() async throws { + let plain = session() + stroke(plain, from: nil, to: nil) + let expected = try await canvas(plain) + // Buttons on, but a mouse: no pressure to follow. + let mouse = session() + mouse.brushSettings.pressureSize = true + mouse.brushSettings.pressureOpacity = true + stroke(mouse, from: nil, to: nil) + // A pen, with the buttons off: its pressure is ignored. + let pen = session() + stroke(pen, from: 0.2, to: 0.6) + for other in [try await canvas(mouse), try await canvas(pen)] { + #expect(other.representation(using: .png, properties: [:]) == expected.representation(using: .png, properties: [:])) + } + } + + /// The release carries no pressure: the stroke ends at the last pen pressure, not a full-size blot. + @Test func theReleaseKeepsTheLastPressure() async throws { + let session = session() + session.brushSettings.pressureSize = true + session.brushSettings.smoothing = 30 + stroke(session, from: 0.25, to: 0.25) + let bitmap = try await canvas(session) + #expect((0..<300).map { thickness(bitmap, $0) }.max()! < 16) + } + + /// Only a tablet's events carry a pressure to follow; a mouse's, even pressed hard on a Force Touch trackpad, don't. + @Test func theCanvasReadsPressureFromATabletOnly() throws { + func event(tablet: Bool, pressure: Double) throws -> NSEvent { + let event = try #require(CGEvent(mouseEventSource: nil, mouseType: .leftMouseDragged, + mouseCursorPosition: .zero, mouseButton: .left)) + event.setDoubleValueField(.mouseEventPressure, value: pressure) + if tablet { event.setIntegerValueField(.mouseEventSubtype, value: Int64(CGEventMouseSubtype.tabletPoint.rawValue)) } + return try #require(NSEvent(cgEvent: event)) + } + #expect(abs(try #require(CanvasView.penPressure(try event(tablet: true, pressure: 0.3))) - 0.3) < 0.01) + #expect(CanvasView.penPressure(try event(tablet: false, pressure: 0.3)) == nil) + } + + /// Without Metal, the dabs follow the pressure the same way. + @Test func theSoftwareBrushFollowsThePressureToo() throws { + let document = CanvasDocument(width: 300, height: 100) + var settings = BrushSettings(diameter: 40, hardness: 1, red: 1, green: 0, blue: 0) + settings.pressureSize = true + let layer = ImageLayer(name: "Blank", blankSize: document.size) + let stroke = try BrushStroke(layer: layer, mask: false, settings: settings, canvas: document.size, useGPU: false) + for step in 0...24 { + let t = CGFloat(step) / 24 + try stroke.append(CGPoint(x: 30 + 240 * t, y: 50), pressure: 1 - 0.75 * t) + } + try stroke.flush() + let snapshot = try stroke.paintSnapshot() + // Painted only where the line went: a stroke that thins as it goes is taller at its start than its end. + let image = snapshot.asset.image, bitmap = NSBitmapImageRep(cgImage: image) + let origin = snapshot.transform.origin + func rows(_ x: Int) -> Int { (0.. 127 }.count } + #expect(abs(rows(40) - 40) <= 3 && rows(260) < 16) + } +} \ No newline at end of file diff --git a/README.md b/README.md index 9540063cf..a6efaa5b0 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ brew install --cask robbietilton-compositor ### Painting and retouching - Brush with size, hardness, opacity and smoothing, in Paint or Erase mode (B and E), and Shift for straight lines +- Pen pressure for the Brush's size and opacity, on a graphics tablet - Spot Healing Brush (content-aware) - Clone Stamp, aligned or not, sampling one layer or all of them - Blur tool, on pixels or masks