Skip to content
C.W.K.
Stream
Lesson 02 of 05 · published

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):

  1. No force at all, which means a finger, gives fingerPressure, 0.5.
  2. 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.
  3. If Dad has drawn his own curve with at least two points, the curve decides, through the same monotone interpolation the Curves adjustment uses.
  4. 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:

  • Declination = 90 − 60 · hypot(xtilt, ytilt). 90 means upright.
  • 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
import Foundation

// A minimal sketch of Ariandel's PencilMapping with its real defaults
// (gamma 0.8, floor 0, finger 0.5), and libmypaint's own reading of the result.
let pressureGamma = 0.8, pressureFloor = 0.0, fingerPressure = 0.5

func pressure(force: Double?) -> Double {
    guard let force else { return fingerPressure }
    let lifted = min(1, max(0, force - pressureFloor) / max(1e-6, 1 - pressureFloor))
    return min(1, pow(lifted, pressureGamma))
}

func tilt(altitude: Double, azimuth: Double) -> (x: Double, y: Double) {
    let degrees = altitude * 180 / .pi
    let magnitude = min(1, max(0, (90 - degrees) / 60))
    return (magnitude * cos(azimuth), magnitude * sin(azimuth))
}

func barrel(roll: Double) -> Double {
    var degrees = (roll * 180 / .pi).truncatingRemainder(dividingBy: 360)
    if degrees < 0 { degrees += 360 }
    return degrees
}

// libmypaint (mypaint_brush_stroke_to): declination = 90 - 60*|tilt|, ascension = atan2(-x, y).
func declination(_ t: (x: Double, y: Double)) -> Double { 90 - hypot(t.x, t.y) * 60 }
func ascension(_ t: (x: Double, y: Double)) -> Double { atan2(-t.x, t.y) * 180 / .pi }

print("force  pressure")
for force in [0.1, 0.25, 0.5, 0.75, 1.0] {
    print(String(format: "%.2f   %.3f", force, pressure(force: force)))
}
print(String(format: "finger %.3f", pressure(force: nil)))

print("\naltitude  libmypaint declination")
for altitude in [90.0, 75, 60, 45, 30, 15] {
    let t = tilt(altitude: altitude * .pi / 180, azimuth: .pi / 2)
    print(String(format: "%5.0f°    %5.1f°", altitude, declination(t)))
}

print("\ncap points      azimuth  libmypaint ascension")
for (name, azimuth) in [("right", 0.0), ("down, at you", .pi / 2), ("left", .pi), ("up, away", -.pi / 2)] {
    let t = tilt(altitude: 45 * .pi / 180, azimuth: azimuth)
    print(name.padding(toLength: 14, withPad: " ", startingAt: 0) + String(format: "  %6.0f°  %6.0f°", azimuth * 180 / .pi, (ascension(t) * 1000).rounded() / 1000 + 0.0))
}

print(String(format: "\nroll -30° -> barrel %.0f°", barrel(roll: -30 * .pi / 180)))

// Prints:
// force  pressure
// 0.10   0.158
// 0.25   0.330
// 0.50   0.574
// 0.75   0.794
// 1.00   1.000
// finger 0.500
//
// altitude  libmypaint declination
//    90°     90.0°
//    75°     75.0°
//    60°     60.0°
//    45°     45.0°
//    30°     30.0°
//    15°     30.0°
//
// cap points      azimuth  libmypaint ascension
// right                0°     -90°
// down, at you        90°       0°
// left               180°      90°
// up, away           -90°    -180°
//
// roll -30° -> barrel 330°
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
// What libmypaint makes of the barrel and view-rotation arguments Ariandel
// sends (degrees). Build against libmypaint master (the 2.0 API, as Ariandel vendors it).
#include <stdio.h>
#include "mypaint-brush.h"
#include "mypaint-surface.h"

static int no_dab(MyPaintSurface *s, float x, float y, float radius,
                  float r, float g, float b, float opaque, float hardness,
                  float softness, float eraser, float ratio, float angle,
                  float lock_alpha, float colorize, float posterize,
                  float posterize_num, float paint) { return 1; }
static void no_color(MyPaintSurface *s, float x, float y, float radius,
                     float *r, float *g, float *b, float *a, float paint) {
    *r = *g = *b = *a = 0;
}

static float seen(MyPaintBrush *brush, MyPaintSurface *surface,
                  float view_rotation, float barrel, MyPaintBrushState state) {
    mypaint_brush_reset(brush);
    mypaint_brush_new_stroke(brush);
    mypaint_brush_stroke_to(brush, surface, 100, 100, 0.5f, 0, 0, 10.0, 1, view_rotation, barrel, 0);
    mypaint_brush_stroke_to(brush, surface, 101, 100, 0.5f, 0, 0, 0.01, 1, view_rotation, barrel, 0);
    return mypaint_brush_get_state(brush, state);
}

int main(void) {
    MyPaintSurface surface = {0};
    surface.draw_dab = no_dab;
    surface.get_color = no_color;
    surface.refcount = 1;
    MyPaintBrush *brush = mypaint_brush_new();
    mypaint_brush_from_defaults(brush);

    float barrels[] = {30.0f, 90.0f, 30.25f, 30.0f / 360};
    for (int i = 0; i < 4; i++)
        printf("barrel_rotation %8.4f -> libmypaint's barrel %6.2f degrees\n", barrels[i],
               seen(brush, &surface, 0, barrels[i], MYPAINT_BRUSH_STATE_BARREL_ROTATION));
    float views[] = {90.0f, 1.5707963f};
    for (int i = 0; i < 2; i++)
        printf("viewrotation    %8.4f -> libmypaint's view %6.2f degrees\n", views[i],
               seen(brush, &surface, views[i], 0, MYPAINT_BRUSH_STATE_VIEWROTATION));
    mypaint_brush_unref(brush);
    return 0;
}

// Prints:
// barrel_rotation  30.0000 -> libmypaint's barrel   0.00 degrees
// barrel_rotation  90.0000 -> libmypaint's barrel   0.00 degrees
// barrel_rotation  30.2500 -> libmypaint's barrel  90.00 degrees
// barrel_rotation   0.0833 -> libmypaint's barrel  30.00 degrees
// viewrotation     90.0000 -> libmypaint's view 116.62 degrees
// viewrotation      1.5708 -> libmypaint's view  90.00 degrees

External links

Exercise

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.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.