본문 바로가기
C.W.K.
Stream
Lesson 04 of 05 · published

펜이 닿은 자리에 첫 댑

~17 min · libmypaint, painter, dabs, verification, swift, c

Level 0빈 캔버스
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

아무것도 안 칠한 탭

libmypaint는 두 입력 사이에 댑을 몇 개 찍을지 세 항을 더해서 정해. 움직인 거리를 실제 반지름으로 나눠 실제 반지름당 댑 수를 곱한 것, 같은 거리를 기본 반지름으로 나눠 기본 반지름당 댑 수를 곱한 것, 흐른 시간에 초당 댑 수를 곱한 것. 이 합이 1을 넘을 때마다 댑이 하나 떨어져. 2강을 떠올려 봐. 획의 첫 입력은 "지난번"보다 10초 뒤에 온 걸로 들어가. 그러면 libmypaint는 붓을 리셋하는데, 그 분기는 댑을 하나도 안 찍고 돌아가. 첫 입력은 붓이 어디 있는지만 알려 줘.

탭은 안 움직여. 그리고 붓 대부분은 초당 댑 수가 0이야. 그래서 합이 1에 못 닿아. 2026-10-03에 이걸 고친 커밋(8973b8a, 19:38)은 피해를 그대로 적었어. "A tap therefore painted nothing with any brush." 같은 산수가 증상 하나를 더 숨기고 있었어. 반지름 두 배 간격으로 찍는 스탬프 붓은 간격보다 짧은 획에서 아무것도 안 칠했어. Photoshop도 Procreate도 펜이 닿는 순간 찍어. 아빠의 Photoshop 붓 세트로 릴리스 빌드에서 재 보니, 탭으로 칠해지는 붓이 세트마다 하나도 없던 데서 대부분이나 전부로 바뀌었어.

수: 240분의 1초 동안, 240분의 1초마다 댑 하나

Painter.layFirstDab(첫 번째 코드 블록)은 첫 입력 바로 뒤에 돌아. 붓의 원래 초당 댑 수를 기억해 두고, 240 × 1.001로 바꾸고, 같은 점을 deltaTime = 1/240로 한 번 더 먹이고, 속도를 되돌려. 1초에 240개가 조금 넘는 속도로 240분의 1초면 댑 하나가 조금 넘어. 0.1% 여유는 반올림 때문에 개수가 1에 털끝만큼 모자라는 걸 막아. 듀얼 브러시는 보조 붓에도 같은 수를 써.

댑이 실제로 떨어지는 자리

함수 위 주석은 libmypaint가 "lays exactly one there"라고 해. 강의에 그대로 옮기기 전에, Ariandel이 들고 있는 libmypaint를 컴파일하고 댑 세는 일만 하는 서피스로 세 봤어(세 번째 코드 블록). 결과는 이래. 두 번째 호출은 아무것도 안 찍어. 댑은 획의 다음 입력에서 떨어지고, 초당 댑 수가 0인 붓이면 거기서 정확히 하나야. 단, 그 입력이 주석에 없는 창 안에 들어와야 해. 닿고 나서 약 4.16 ms부터 5초까지야.

이유는 mypaint-brush.c 안에 있어. 개수를 셀 때 초당 댑 수를 기본값이 아니라 붓의 상태에서 읽어. 리셋이 상태를 전부 0으로 만들었고, 상태는 호출이 끝날 때에야 기본값에서 다시 계산돼. 그러니까 layFirstDab의 호출은 속도를 장전만 하고 쓰진 않아. 다음 입력이 그걸 댑 하나에 쓰고, 그 댑의 상태 갱신이 붓 원래 속도를 되돌려. 1.001 여유는 그래도 제 몫을 해. 펜의 다음 샘플은 대략 240분의 1초 뒤에 오고, 그래야 장전된 개수가 1을 살짝 넘거든. 세 번째 코드 블록은 그 간격을 쭉 바꿔 가며 창의 양 끝을 찾아. 아래쪽 끝은 여유의 산수 그대로야. 장전된 개수는 간격 곱하기 240.24이고, 이게 1에 닿아야 해. 그래서 끝은 240.24분의 1초, 4.1625 ms야. 240분의 1초보다 털끝만큼 앞이지. 4.162 ms는 아무것도 안 찍고, 4.163 ms는 댑을 찍어. 장전은 호출 한 번만 가. 다음 입력이 그보다 빨리 오면 댑 하나에 못 미치고, 그 호출의 상태 갱신이 붓 원래 속도를 그대로 되돌려. 초당 댑 수가 0이면 가만히 있는 펜은 그 댑을 끝내 못 찍어. 위쪽 끝은 Ariandel 게 아냐. 2강에서 본 5초 리셋 그대로야. 마지막 입력보다 5초 넘게 늦은 입력이 오면 libmypaint는 붓을 리셋하고 아무것도 안 찍은 채 돌아가. 장전된 속도도 리셋이 나머지 상태와 같이 버려. 5초는 댑을 찍고, 5.001초와 6초는 아무것도 안 찍어. 두 끝 사이, 4.163 ms 줄부터 5초 줄까지는 전부 정확히 하나야.

다음 입력이 뭐냐가 중요해. libmypaint가 댑을 그쪽으로 보간하니까. 진짜 엔진(빌드 24, 릴리스)을 임시 패키지에 빌드해서 Painter로 탭을 재연해 봤어. 힘 0.8로 닿고, 240분의 1초 뒤에 샘플 하나가 따라오는 경우야.

다음 입력닿은 자리의 알파 (32,768 중)
힘 0.812,681
힘 0.33,997
힘 0149
닿은 뒤 입력 없음0

캔버스에선 탭에 늘 다음 샘플이 있어. 떼는 처리가 떼기 전에 끝 이벤트의 합쳐진 터치를 먼저 먹이거든. 진짜 펜이 그 마지막 샘플들에서 힘을 얼마나 보고하는지는 하드웨어 사실이고, 여기 어떤 테스트도 그걸 안 재. Brush Studio의 Drawing Pad는 끝 이벤트의 합쳐진 터치는 안 먹이고 떼기만 먹여. 그래도 endStroke가 떼는 입력의 힘을 0으로 바꿔서 샘플 하나로 먹여. 그러니까 이동 이벤트 없이 끝난 패드 탭에도 다음 입력은 있어. 힘 0이고, 닿고 나서 탭 길이만큼 지난 샘플이야. 표의 149가 그 경우를 240분의 1초로 잰 거야. 진짜 탭은 그보다 길고, 그럼 답이 달라져. 장전된 댑은 구간 끝이 아니라 구간에 240분의 1초쯤 들어간 자리에 떨어지고, 압력은 두 샘플 사이에서 보간되거든. 세 번째 코드 블록을 조금 바꿔서 댑마다 불투명도를 찍어 보면 보여. 0.8로 닿은 뒤 힘 0짜리 다음 입력이 240분의 1초 뒤면 0.0002, 20 ms 뒤면 0.24, 80 ms 뒤면 0.32야. 다음 입력도 0.8로 누르면 0.35고. 엔진 테스트는 어느 쪽이든 통과해. 힘 0.8짜리 샘플을 일곱 개 먹이니까. 이게 수정을 무르는 건 아냐. 증명된 범위를 좁힐 뿐이야. 엔진에서 탭은 다음 입력이 닿고 약 4.16 ms에서 5초 사이에 올 때 칠해지고, 그 세기는 닿은 자리에서 다음 입력 쪽으로 240분의 1초쯤 들어간 데서 읽혀.

배치 안의 순서

continueStroke는 surface.begin()과 surface.end() 사이에서 샘플마다 같은 순서를 밟아(두 번째 코드 블록).

  1. Drawing Assist가 점을 고른 가이드 선 위로 붙여. 화면 기준 10포인트를 움직여서 방향이 정해질 때까진 시작점에 잡아 둬.
  2. 손떨림 보정과 모션 필터링이 위치를 옮겨. 매핑보다 먼저야.
  3. PencilMapping.sample이 libmypaint 인자로 바꿔(2강).
  4. 테이퍼가 기본 반지름을 정하고, 획을 따라 간 거리에 맞춰 압력을 줄여.
  5. setAzimuth가 펜이 기운 방향을 Ariandel 서피스에 알려. 기울기를 따라가는 팁을 위해서야.
  6. brush.stroke, 그다음 획의 첫 샘플이면 layFirstDab, 그다음 반지름 차이를 얹은 듀얼 보조 붓.

획을 다시 그릴 땐 1, 2단계를 건너뛰어. 뗄 때 테이퍼가 다시 그리면서 먹이는 위치는 이미 붙이고 다듬은 거라서야. end() 뒤엔 5트랙의 배치 단위 합치기가 와.

댑 예산

첫 샘플보다 먼저, beginStroke에서 한도가 하나 더 걸려. 댑 하나의 비용은 그 넓이야. 그래서 획의 비용은 반지름 곱하기 반지름당 댑 수로 커져. Photoshop의 1% 간격은 반지름당 댑 50개고, 릴리스 빌드에서 300픽셀 붓을 1%로 1초 긋는 데 1~2초가 걸렸어(7241359). Painter.dabBudget은 3,000이야. 획을 긋는 동안 실제 반지름당 댑 수를 많아야 max(2, 3000 / radius)로 묶어. 100픽셀이면 30, 300이면 10이고, 획이 끝나면 붓 원래 값이 돌아와.

주석은 호출에 대한 주장이야. 그러니까 불러 봐. "Lays exactly one there"는 효과로는 맞고, 자리로는 틀렸고, 시간 창 하나 안에서만 맞아. 진짜 라이브러리에 대고 C 열 줄을 돌리니까 문장이 측정이 됐고, 그 측정이 아직 펜이 있어야만 풀리는 질문 하나를 찾아냈어.

Code

Painter.swift 원문 그대로: layFirstDab·swift
    /// A dab where the pen lands, as Photoshop and Procreate lay one: libmypaint
    /// lays its first only after a spacing's travel, so a tap, or a short
    /// stroke of a stamp spaced wide, laid nothing. Its first input only sets
    /// where the brush is; one 240th of a second more at a dab per 240th lays
    /// exactly one there, and the brush's own rate comes back.
    private static func layFirstDab(_ brush: Brush, at sample: StrokeSample, on surface: BrushSurface) {
        let rate = BrushAttribute.setting("dabs_per_second")
        let own = brush.baseValue(rate)
        var again = sample
        again.deltaTime = 1.0 / 240
        brush.setBaseValue(rate, 240 * 1.001)
        brush.stroke(to: again, on: surface)
        brush.setBaseValue(rate, own)
    }
Painter.continueStroke 원문(샘플마다 도는 루프만 잘라 옴)·swift
        for raw in inputs {
            var input = raw
            if !current.replaying {
                input = assisted(raw, stroke: &current, viewZoom: viewZoom)
                if current.smoothing != nil {
                    input = current.smoothing!.filter(input)
                }
            }
            var sample = mapping.sample(for: input, previousTimestamp: current.lastTimestamp,
                                        viewZoom: viewZoom, viewRotationDegrees: viewRotationDegrees)
            if current.taper != nil {
                let distance = current.taper!.advance(to: SIMD2(input.x, input.y))
                brush.setBaseValue(.radiusLogarithmic, current.taper!.radius(at: distance))
                sample.pressure *= current.taper!.pressure(at: distance)
            }
            surface.setAzimuth(input.azimuth)
            brush.stroke(to: sample, on: surface)
            if current.samples == 0 { Self.layFirstDab(brush, at: sample, on: surface) }
            if let dual = current.dual {
                dual.brush.setBaseValue(.radiusLogarithmic, brush.baseValue(.radiusLogarithmic) + dual.radiusOffset)
                dual.brush.stroke(to: sample, on: dual.secondarySurface)
                if current.samples == 0 { Self.layFirstDab(dual.brush, at: sample, on: dual.secondarySurface) }
            }
            current.lastTimestamp = input.timestamp
            current.samples += 1
            if !current.replaying {
                current.inputs.append(raw)
                current.fed.append(input)
            }
        }
        surface.end()
Ariandel이 들고 있는 libmypaint(손대지 않은 업스트림 master)에 대고 컴파일한, 직접 쓴 하네스: 가만히 있는 탭의 댑 세기. layFirstDab 수 없이 한 번, 그다음엔 수를 쓰고 다음 입력이 닿고 나서 얼마 뒤에 오는지 바꿔 가며·c
// Count the dabs libmypaint lays for a still tap, without Ariandel's layFirstDab
// move and then with it, sweeping how long after the landing the next input comes.
// 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 dabs;
static int count_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) {
    dabs++;
    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 MyPaintSurface surface;
static MyPaintBrush *brush;
static float own;

// One tap at a single point: the landing, the move (if asked), the next input
// `next` seconds later, then fifty more samples 1/240 s apart. Prints the dab counts.
static void tap(int lay_first_dab, double next) {
    mypaint_brush_reset(brush);
    mypaint_brush_new_stroke(brush);
    dabs = 0;
    // The first input of a stroke, reported 10 s after "the last one".
    mypaint_brush_stroke_to(brush, &surface, 100, 100, 0.8f, 0, 0, 10.0, 1, 0, 0, 0);
    int after_landing = dabs;
    if (lay_first_dab) {
        mypaint_brush_set_base_value(brush, MYPAINT_BRUSH_SETTING_DABS_PER_SECOND, 240 * 1.001f);
        mypaint_brush_stroke_to(brush, &surface, 100, 100, 0.8f, 0, 0, 1.0 / 240, 1, 0, 0, 0);
        mypaint_brush_set_base_value(brush, MYPAINT_BRUSH_SETTING_DABS_PER_SECOND, own);
    }
    int after_move = dabs;
    mypaint_brush_stroke_to(brush, &surface, 100, 100, 0.8f, 0, 0, next, 1, 0, 0, 0);
    int at_next = dabs - after_move;
    for (int i = 0; i < 50; i++)
        mypaint_brush_stroke_to(brush, &surface, 100, 100, 0.8f, 0, 0, 1.0 / 240, 1, 0, 0, 0);
    printf("%-11s next input %-10.6g s later | landing %d, move %d, next input %d, total %d\n",
           lay_first_dab ? "layFirstDab" : "plain", next,
           after_landing, after_move - after_landing, at_next, dabs);
}

int main(void) {
    surface.draw_dab = count_dab;
    surface.get_color = no_color;
    surface.refcount = 1;
    brush = mypaint_brush_new();
    mypaint_brush_from_defaults(brush);
    mypaint_brush_set_base_value(brush, MYPAINT_BRUSH_SETTING_RADIUS_LOGARITHMIC, 3.7f);
    own = mypaint_brush_get_base_value(brush, MYPAINT_BRUSH_SETTING_DABS_PER_SECOND);
    printf("the brush's own dabs per second: %g\n", own);

    tap(0, 1.0 / 240);
    double nexts[] = {0.001, 0.004162, 0.004163, 1.0 / 240, 0.08, 1.0, 5.0, 5.001, 6.0};
    for (int k = 0; k < (int)(sizeof nexts / sizeof nexts[0]); k++)
        tap(1, nexts[k]);
    mypaint_brush_unref(brush);
    return 0;
}

// Prints:
// the brush's own dabs per second: 0
// plain       next input 0.00416667 s later | landing 0, move 0, next input 0, total 0
// layFirstDab next input 0.001      s later | landing 0, move 0, next input 0, total 0
// layFirstDab next input 0.004162   s later | landing 0, move 0, next input 0, total 0
// layFirstDab next input 0.004163   s later | landing 0, move 0, next input 1, total 1
// layFirstDab next input 0.00416667 s later | landing 0, move 0, next input 1, total 1
// layFirstDab next input 0.08       s later | landing 0, move 0, next input 1, total 1
// layFirstDab next input 1          s later | landing 0, move 0, next input 1, total 1
// layFirstDab next input 5          s later | landing 0, move 0, next input 1, total 1
// layFirstDab next input 5.001      s later | landing 0, move 0, next input 0, total 0
// layFirstDab next input 6          s later | landing 0, move 0, next input 0, total 0

External links

Exercise

네 코드에서 라이브러리 호출이 뭘 하는지 정확하게 주장하는 주석을 하나 찾아("정확히 하나를 돌려줌", "바로 플러시함", "멱등임" 같은 것). 라이브러리를 똑같이 부르고, 주장한 효과를 카운터나 로그나 상태 되읽기로 직접 관찰하는 제일 작은 프로그램을 써 봐. 주장이 맞는지, 다른 자리에서 맞는지, 주석이 말 안 한 조건에서만 맞는지 적어 둬.
Hint
호출의 비싼 쪽을 카운터로 바꿔. 위 하네스가 서피스를 댑만 세는 함수로 바꾼 것처럼. 그다음 한 번에 하나씩 바꿔 봐. 먼저 그 호출의 인자, 그다음 그 뒤에 오는 것. 다음 호출 덕분에 맞는 주장이 이런 어긋남의 제일 흔한 모양이야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.