Pressure, Tilt and Roll, Translated for libmypaint
~16 min · apple-pencil, libmypaint, pressure-curve, tilt, units
Level 0Blank Canvas
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
One Mapping Under Every Brush
Lesson one ended with a StylusInput in canvas space. The next step is PencilMapping, a small Codable struct the engine describes as "how hard is hard, for every brush." It sits under each brush's own settings and turns a StylusInput into a StrokeSample, the exact argument list of libmypaint's mypaint_brush_stroke_to. Its defaults are worth knowing by heart: pressureGamma 0.8, pressureFloor 0, fingerPressure 0.5, no pressure curve, and stabilization, motion filtering and pressure smoothing all at 0. Where a brush has smoothing of its own, the two combine as "the stronger of each". And settings saved before the curve and the smoothing existed still decode, because a hand-written init(from:) fills every missing key with its default.
Pressure: Floor, Then Curve
The pressure rule is four lines (first code block):
No force at all, which means a finger, gives fingerPressure, 0.5.
Otherwise the floor comes off and the rest is stretched back to full range: lifted = clamp((force − floor) / (1 − floor)). A floor filters a resting contact and still lets a full press reach 1.
If Dad has drawn his own curve with at least two points, the curve decides, through the same monotone interpolation the Curves adjustment uses.
Otherwise pow(lifted, gamma). A gamma below 1 lifts the middle: half the Pencil's maximum force paints at 0.574, a quarter at 0.330. "Below 1 is softer touch" means less effort for the same mark.
One quiet detail makes switching modes painless. The curve editor never starts from a straight line. It starts from the gamma curve sampled at 0, 0.25, 0.5, 0.75 and 1, so turning the curve on changes nothing until a point is dragged.
Tilt: Inverting libmypaint's Sixty
libmypaint does not take an altitude. It takes xtilt and ytilt between −1 and 1 and derives its own angles from them, in mypaint_brush_stroke_to:
Ascension = atan2(−xtilt, ytilt), in degrees, the direction of the lean.
Ariandel inverts the first formula exactly. The magnitude is (90 − altitude°) / 60, clamped to 0…1, and it is split by the azimuth into cos and sin. Composed, the two give declination = altitude for every Pencil angle from 90° down to 30°. Below 30° the magnitude saturates at 1, and the second code block prints the consequence: a Pencil at 15° reaches libmypaint as 30°. That floor is Ariandel's own clamp, not libmypaint's. libmypaint clamps each component to −1…1 separately, so a lean straight along an axis can never read flatter than 30°, but a diagonal one can: components of 0.884 each make a hypotenuse of 1.25 and a declination of 15°. Ariandel clamps the magnitude instead of the components, so the floor is 30° whichever way the Pencil leans.
The direction needs a second check, because two conventions meet here. Apple's azimuth is 0 when the Pencil's cap points along +x and grows as the cap swings clockwise. libmypaint's tooltip for ascension says "+90 when rotated 90 degrees clockwise." The sketch feeds four cap directions through Ariandel's split and libmypaint's formula: cap pointing down the screen gives 0, cap to the left +90, cap to the right −90. Clockwise is positive on both sides, so the split is the right one. The same azimuth also reaches Ariandel's own surface every input (setAzimuth), so a picture tip can lean with the Pencil (Track 2).
Barrel Roll: the Unit the Signature Hides
barrelRotation(for:) turns the Pencil Pro's roll from radians into degrees and wraps it into 0…360, under a comment that says "Barrel roll in libmypaint's 0...360 degrees." The brush input called barrel_rotation is indeed read in degrees. The argument of mypaint_brush_stroke_to is not: inside, libmypaint multiplies it by 360 ("converts barrel_ration to degrees"), so it expects a fraction of a turn. While writing this lesson I compiled Ariandel's vendored libmypaint and asked it (third code block). A 30° roll arrives as 0°, and so does 90°. 30.25° arrives as 90°, and only 30/360 arrives as 30°. As of build 24, a whole-degree roll reaches a brush as no roll at all, and a fractional one as noise.
The blast radius is narrow. Only brushes that map the barrel input are affected; Liquify's twirl reads the roll itself and is fine. No engine test feeds a roll to a brush, and the coop history row for round eight lists "The Pencil Pro barrel roll" among the things not exercised. The view-rotation argument has the same shape: libmypaint expects radians and converts them, while Ariandel sends degrees. A 90° canvas turn reaches libmypaint as 116.62°, which matters only for brushes that read view-dependent inputs on a turned canvas. Both fixes are a division at one place, which is the dividend lesson one's single conversion point pays.
Time: Ten Seconds for the First Event
The last field is deltaTime. Between samples it is the real interval, never less than 0.1 ms. For a stroke's first event it is strokeStartInterval, ten seconds, and the reason is an order of operations inside libmypaint. Slow tracking moves the position from wherever the brush last was toward the new point, but only by the fraction 1 − exp(−100 · dtime / slow_tracking) of the way, and only after that does libmypaint ask whether more than max_dtime, 5 s, has passed and reset the brush. Give a slow-tracking brush a short first interval and that fraction is small, so the stroke begins close to where the previous stroke ended instead of under the pen: a line dragged out of nowhere. Ten seconds makes the fraction all but 1, so the position lands on the pen, and makes the reset certain. MyPaint on a desktop never needed this, as the code's own comment says, because it feeds the hovering pen between strokes and the brush is already there. That reset branch also returns before laying any dab, which is where lesson four begins.
A unit lives in the callee, not in the type. A Float called barrel_rotation can mean degrees in one function and turns in the next. When two libraries meet, read the line that consumes the value, and when you can, feed it a known number and read back what it did.
Code
PencilMapping in the engine's StylusInput.swift, verbatim (trimmed: smoothing(with:) omitted)·swift
/// libmypaint's pressure for a normalized force.
public func pressure(for input: StylusInput) -> Float {
guard let force = input.force else { return Float(fingerPressure) }
let lifted = min(1, max(0, force - pressureFloor) / max(1e-6, 1 - pressureFloor))
if let curve = pressureCurve, curve.count >= 2 {
return Float(min(1, max(0, ToneCurves.value(curve, at: lifted))))
}
return Float(min(1, pow(lifted, pressureGamma)))
}
// ... smoothing(with:) omitted ...
/// libmypaint's tilt components. libmypaint reads declination as
/// 90° − 60°·|tilt|, so an altitude of 90° maps to 0 and 30° or flatter to 1.
public func tilt(for input: StylusInput) -> (x: Float, y: Float) {
guard let altitude = input.altitude, let azimuth = input.azimuth else { return (0, 0) }
let altitudeDegrees = altitude * 180 / .pi
let magnitude = min(1, max(0, (90 - altitudeDegrees) / 60))
return (Float(magnitude * cos(azimuth)), Float(magnitude * sin(azimuth)))
}
/// Barrel roll in libmypaint's 0...360 degrees.
public func barrelRotation(for input: StylusInput) -> Float {
guard let roll = input.roll else { return 0 }
var degrees = roll * 180 / .pi
degrees = degrees.truncatingRemainder(dividingBy: 360)
if degrees < 0 { degrees += 360 }
return Float(degrees)
}
/// Seconds reported for the first event of a stroke. libmypaint smooths
/// the position (slow tracking) before it checks for a fresh stroke, so a
/// short first interval starts the stroke near wherever the brush last
/// was and drags a line from there. Over its five-second threshold the
/// smoothing collapses onto the real point and the brush starts clean —
/// what MyPaint gets for free by feeding the hovering pen between strokes.
public static let strokeStartInterval = 10.0
/// The sample libmypaint receives for `input`, given the previous event's
/// time (nil for the first event of a stroke).
public func sample(for input: StylusInput, previousTimestamp: Double?, viewZoom: Double,
viewRotationDegrees: Double) -> StrokeSample {
let tilt = tilt(for: input)
let delta = previousTimestamp.map { max(1e-4, input.timestamp - $0) } ?? Self.strokeStartInterval
return StrokeSample(x: Float(input.x), y: Float(input.y), pressure: pressure(for: input),
xTilt: tilt.x, yTilt: tilt.y, deltaTime: delta,
viewZoom: Float(viewZoom), viewRotation: Float(viewRotationDegrees),
barrelRotation: barrelRotation(for: input))
}
A minimal sketch (compiled with swiftc and run): the mapping's real constants, and what libmypaint's own formulas make of the result·swift
A probe I wrote and compiled against Ariandel's vendored libmypaint (unmodified upstream master): feed a known barrel and view rotation, read back the brush state·c
Choose one boundary in a project of yours where a number crosses into a library you did not write: an angle, a duration, a color component, a timestamp. Write a ten-line probe that feeds the library two or three known values and prints what it stored or produced, the way the C probe above reads libmypaint's state back. Compare the result with the unit your own code assumes.
Hint
Pick values that make unit mistakes loud: 90 against π/2 for angles, 1 against 1000 for seconds and milliseconds, 1.0 against 255 for color. A whole number that comes back as zero is a strong sign the callee is multiplying or wrapping by something you did not expect.
Progress
Progress is local-only — sign in to sync across devices.