~16 min · apple-pencil, libmypaint, pressure-curve, tilt, units
Level 0빈 캔버스
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
모든 붓 밑에 깔린 매핑 하나
1강은 캔버스 공간의 StylusInput에서 끝났어. 다음 단계는 PencilMapping이야. 작은 Codable 구조체고, 엔진은 이걸 "how hard is hard, for every brush"라고 설명해. 붓마다 있는 자기 설정 밑에 깔려서, StylusInput을 StrokeSample로 바꿔. StrokeSample은 libmypaint mypaint_brush_stroke_to의 인자 목록 그 자체야. 기본값은 외워 둘 만해. pressureGamma 0.8, pressureFloor 0, fingerPressure 0.5, 압력 곡선 없음, 손떨림 보정·모션 필터링·압력 스무딩은 전부 0. 붓한테 자기 스무딩이 있으면 둘 중 "더 센 쪽"이 이겨. 곡선과 스무딩이 생기기 전에 저장한 설정도 그대로 읽혀. 손으로 쓴 init(from:)이 빠진 키마다 기본값을 채워 넣거든.
압력은 바닥부터 떼고, 그다음 곡선
압력 규칙은 네 줄이야(첫 번째 코드 블록).
힘이 아예 없으면, 그러니까 손가락이면 fingerPressure, 0.5야.
아니면 바닥값을 떼고 남은 걸 다시 전 범위로 늘려. lifted = clamp((force − floor) / (1 − floor)). 바닥값은 그냥 얹어 둔 접촉을 걸러내고, 그래도 꽉 누르면 1까지 가.
아빠가 점 두 개 이상으로 자기 곡선을 그려 뒀으면 곡선이 정해. Curves 조정이 쓰는 단조 보간 그대로야.
아니면 pow(lifted, gamma). 감마가 1보다 작으면 가운데가 올라가. 최대 힘의 절반이면 0.574, 4분의 1이면 0.330으로 칠해. "Below 1 is softer touch"는 같은 자국에 힘이 덜 든다는 뜻이야.
모드를 바꿀 때 아무 일도 안 생기게 해 주는 조용한 디테일이 하나 있어. 곡선 편집기는 직선에서 출발하지 않아. 감마 곡선을 0, 0.25, 0.5, 0.75, 1에서 찍은 점들로 시작해. 그래서 곡선을 켜도 점 하나 끌기 전까진 아무것도 안 바뀌어.
기울기는 libmypaint의 60을 거꾸로 풀어
libmypaint는 고도각을 안 받아. −1에서 1 사이의 xtilt, ytilt를 받아서 mypaint_brush_stroke_to 안에서 자기 각도를 만들어.
방향각(ascension) = atan2(−xtilt, ytilt), 도 단위고, 기운 방향이야.
Ariandel은 첫 번째 식을 정확히 거꾸로 풀어. 크기는 (90 − 고도각°) / 60을 0…1로 자른 거고, 그걸 방위각으로 cos, sin에 나눠 실어. 둘을 이어 붙이면 펜 각도 90°부터 30°까지 전부 편각 = 고도각이 돼. 30° 밑으로 가면 크기가 1에 걸려. 두 번째 코드 블록이 그 결과를 찍어 줘. 15°로 눕힌 펜이 libmypaint엔 30°로 도착해. 이 바닥은 Ariandel이 건 거야. libmypaint 게 아니고. libmypaint는 두 성분을 따로따로 −1…1로 잘라. 그래서 축을 따라 똑바로 누운 펜은 30°보다 눕게 읽힐 수 없지만, 대각선으로 누운 펜은 돼. 성분이 0.884씩이면 빗변이 1.25고 편각은 15°야. Ariandel은 성분 말고 크기를 자르니까, 펜이 어느 쪽으로 기울든 바닥이 30°로 같아.
방향은 한 번 더 확인해야 해. 규약 두 개가 여기서 만나거든. Apple 방위각은 펜 뒤꽁무니가 +x를 가리킬 때 0이고, 뒤꽁무니가 시계 방향으로 돌수록 커져. libmypaint의 ascension 툴팁은 "+90 when rotated 90 degrees clockwise"라고 해. 스케치는 뒤꽁무니 방향 네 개를 Ariandel의 분해와 libmypaint 식에 통과시켜. 화면 아래를 가리키면 0, 왼쪽이면 +90, 오른쪽이면 −90. 양쪽 다 시계 방향이 양수니까 분해 방향이 맞아. 같은 방위각은 입력마다 Ariandel 자기 서피스에도 가(setAzimuth). 그래서 그림 팁이 펜 따라 기울 수 있어(2트랙).
몸통 롤, 시그니처가 숨긴 단위
barrelRotation(for:)은 Pencil Pro의 롤을 라디안에서 도로 바꾸고 0…360으로 감아. 위에 붙은 주석은 "Barrel roll in libmypaint's 0...360 degrees."야. barrel_rotation이라는 붓 입력은 진짜로 도 단위로 읽혀. 그런데 mypaint_brush_stroke_to의 인자는 아냐. libmypaint가 안에서 360을 곱해("converts barrel_ration to degrees"). 그러니까 한 바퀴의 몇 분의 몇을 기대하는 거야. 이 강의를 쓰다가 Ariandel이 들고 있는 libmypaint를 컴파일해서 직접 물어봤어(세 번째 코드 블록). 30° 롤은 0°로 도착해. 90°도 0°야. 30.25°는 90°로 오고, 30/360을 넣어야 비로소 30°가 돼. 빌드 24 기준으로 정수 도 단위 롤은 붓한테 롤이 없는 걸로 가고, 소수점이 붙은 롤은 잡음으로 가.
피해 범위는 좁아. 몸통 롤 입력을 매핑한 붓만 걸려. Liquify 트월은 롤을 직접 읽으니까 멀쩡해. 붓에 롤을 먹이는 엔진 테스트는 없고, 8라운드 coop 기록 행은 확인 못 한 항목에 "The Pencil Pro barrel roll"을 올려 놨어. 뷰 회전 인자도 생김새가 똑같아. libmypaint는 라디안을 받아서 바꾸는데 Ariandel은 도를 보내. 캔버스를 90° 돌리면 libmypaint엔 116.62°로 가. 돌린 캔버스에서 뷰에 따라 달라지는 입력을 읽는 붓만 영향을 받아. 둘 다 한 군데에서 나누기 하나면 고쳐져. 1강의 변환 지점 하나가 바로 이럴 때 제값을 해.
시간: 첫 이벤트엔 10초
마지막 필드는 deltaTime이야. 샘플 사이엔 진짜 간격이고, 0.1 ms보다 작아지진 않아. 획의 첫 이벤트엔 strokeStartInterval, 10초야. 이유는 libmypaint 안의 순서 하나야. 느린 추적(slow tracking)은 위치를 붓이 마지막에 있던 자리에서 새 점 쪽으로 옮기는데, 딱 1 − exp(−100 · dtime / slow_tracking)의 비율만큼만 옮겨. 그 다음에야max_dtime, 5초가 지났는지 보고 붓을 리셋해. 느린 추적이 걸린 붓에 첫 간격을 짧게 주면 그 비율이 작아. 그래서 획이 펜 밑이 아니라 앞 획이 끝난 자리 근처에서 시작해. 난데없는 선이 끌려 나오는 거지. 10초면 비율이 1에 붙어서 위치가 펜 자리에 내려앉고, 리셋도 확실해. 코드 주석 말대로, 데스크톱 MyPaint는 획 사이에도 떠 있는 펜을 계속 먹이니까 붓이 이미 거기 가 있어서 이게 필요 없었어. 그리고 그 리셋 분기는 댑을 하나도 안 찍고 돌아가. 4강이 거기서 시작해.
단위는 타입이 아니라 받는 쪽에 살아.barrel_rotation이라는 Float 하나가 이 함수에선 도고 다음 함수에선 바퀴 수일 수 있어. 라이브러리 둘이 만나는 데선 값을 실제로 쓰는 줄을 읽어. 할 수 있으면 아는 숫자를 넣고, 걔가 뭘 했는지 다시 읽어 봐.
Code
엔진 StylusInput.swift의 PencilMapping 원문(일부 생략: smoothing(with:) 뺌)·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))
}
최소 스케치(swiftc로 컴파일해서 돌림): 매핑의 진짜 상수, 그리고 libmypaint 자기 식이 그 결과를 어떻게 읽는지·swift
네 프로젝트에서 숫자가 남이 쓴 라이브러리로 넘어가는 경계를 하나 골라. 각도, 시간 길이, 색 성분, 타임스탬프 같은 것. 라이브러리에 아는 값 두세 개를 넣고 걔가 저장하거나 만들어 낸 걸 찍는 열 줄짜리 탐침을 써 봐. 위 C 탐침이 libmypaint 상태를 다시 읽듯이. 그 결과를 네 코드가 가정하는 단위랑 비교해.
Hint
단위 실수가 크게 티 나는 값을 골라. 각도면 90과 π/2, 초와 밀리초면 1과 1000, 색이면 1.0과 255. 정수를 넣었는데 0이 돌아오면, 받는 쪽이 네가 모르는 뭔가로 곱하거나 감고 있다는 강한 신호야.
Progress
Progress is local-only — sign in to sync across devices.