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

180도 함정

~13 min · libmypaint, geometry, symmetry, debugging, c

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

뒤집힌 타이틀 브러시

커밋 139c64f(2026-10-03, KST 00:16)가 Ariandel에 셰이프 팁을 줬어. Procreate의 Shape Source 말이야. libmypaint가 둥근 댑을 찍으라고 하는 자리에, Ariandel 자신의 draw_dab을 거쳐 그림을 찍는 기능이야. 30분도 안 돼서, 가져온 Procreate 브러시를 Procreate 자체 썸네일 옆에 놓고 보니 스탬프로 찍은 타이틀 브러시가 위아래로 뒤집히고 좌우로도 뒤집혀 있었어. 이걸 남긴 coop 교훈 기록(#756, 스코프 libmypaint)에 증상이 정확히 적혀 있어. 그림이 왼쪽 위 구석에 있는 팁이 오른쪽 아래 구석에 찍혔다고.

"위아래로 뒤집히고 좌우로도 뒤집혔다"는 말에서 잠깐 멈춰 봐. 그림을 세로로 그리고 가로로 뒤집으면 반 바퀴 돌린 거야. 뒤집기 한 번은 축 하나를 바꾸고, 반 바퀴는 둘 다 바꿔. 진단은 증상 설명 안에 이미 들어 있었어.

엉뚱한 첫 용의자

첫 가설은 그림을 디코딩할 때 생긴 세로 뒤집기였어. Core Graphics는 원점을 왼쪽 아래에 둬. 게다가 버퍼에 이미지를 거꾸로 읽어 들이는 건 업계에서 제일 오래된 버그야. 그럴듯한 용의자였고, 틀렸어. 왕복 테스트로 디코딩한 그림이 똑바로 서 있다는 게 증명됐어. 디코딩 뒤집기였다면 축 하나만 뒤집혔을 텐데, 증상은 둘 다였어.

903번째 줄

원인은 libmypaint에 있었고, 거기선 버그가 아니었어. mypaint-brush.c는 draw_dab에 각도를 넘기기 전에 화면 회전만큼 보정해(첫 번째 코드 블록).

ACTUAL_ELLIPTICAL_DAB_ANGLE = mod_arith(setting − viewrotation + 180, 180) − 180

이러면 모든 각도가 −180 이상 0 미만 범위로 접혀. 기본 설정 90°는 −90°로 도착해. 설정 0과 설정 180은 같은 숫자로 도착해. draw_dab이 받는 각도는 반 바퀴 단위로만 알 수 있어.

libmypaint 자기 댑한테는 잃는 게 없어. 댑이 타원이고, 타원은 반 바퀴 돌려도 같은 타원이니까. 엔진이 그리는 모양은 타원뿐이니, 엔진 입장에선 이렇게 접어도 맞아.

Ariandel의 첫 셰이프 팁 코드는 그 각도를 믿었어. 팁을 angle − rest_angle만큼 돌렸고, BrushShape.restAngle = 90에 이런 문서 주석이 붙어 있었어. "The angle libmypaint gives a dab when the brush does not turn it: the tip stands upright there." libmypaint가 주는 건 −90이야. 그래서 안 돌린 브러시가 팁을 −90 − 90 = −180°만큼 돌렸어. 딱 반 바퀴, 딱 그 증상.

답을 찾아낸 탐침

교훈 기록엔 결판을 낸 실험이 적혀 있어. 좋은 탐침이 어떤 건지 보여 주는 본보기야. 왼쪽 위 4분의 1만 흰 팁을 만들어. 비대칭이라 회전이든 뒤집기든 한 비트로 드러나는 모양이야. (100, 100)에 반지름 30으로 찍어. 물감은 중심에서 오른쪽 아래인 (120, 120)에 나타났고, 왼쪽 위인 (80, 80)엔 없었어. 스탬프 하나, 픽셀 둘이면 반 바퀴라는 게 더 따질 것도 없이 확정돼. 두 번째 코드 블록이 공식만으로 그 탐침을 다시 세워. 쓰는 건 libmypaint의 mod_arith, 넘어가는 각도, 그리고 Ariandel의 stamp()와 같은 방식의 팁 공간 샘플링이야.

수정

커밋 963bef1(KST 00:43)은 팁이 어느 쪽을 가리키는지 libmypaint에 묻는 걸 그만뒀어. 표면은 이제 (void)angle로 각도 인자를 아예 버리고, 한 바퀴 전체를 아는 출처들로 회전을 만들어(세 번째 코드 블록).

  • 팁 자기 각도 BrushShape.angle: Procreate의 Shape Angle이고, Brush Studio의 슬라이더야. 임포터가 채워.
  • 스트로크 진행 방향: 표면이 연속한 댑 위치로 atan2f를 써서 직접 계산해. 한 바퀴 전체의 방향이고, follow만큼 반영돼.
  • Pencil의 방위각: 입력마다 ard_surface_set_azimuth로 들어오고, follow_tilt만큼 반영돼.
  • 무작위 회전: 스트로크 자기 시드에서 나오고, 양쪽으로 반 바퀴의 scatter배까지.

libmypaint는 여전히 댑 비율을 보태. 팁을 높이 방향으로 납작하게 눌러. 비율엔 방향이 없으니 거기선 잃은 게 없어. 대칭 반사는 거울 플래그와 반사된 회전을 넘겨. 그래서 거울에 비친 댑엔 거울에 비친 팁이 찍혀.

코드 수정이 먼저였어. 교훈은 세 시간 반 뒤인 04:17에 두 곳에 동시에 적혔어. 하우스 사인의 한 줄(c9bd18e), 그리고 탐침의 숫자가 남아 있는 coop 교훈 기록 #756이야.

엔진 자신의 기본형에는 무해하던 관례도, 덜 대칭인 기본형을 더하는 순간 버그가 돼. libmypaint는 타원 자신의 대칭만큼 각도를 줄였고, 그건 공짜였어. 그림엔 그런 대칭이 없으니 같은 축약이 정보를 부숴 버렸어. 더 풍부한 기본형으로 엔진을 확장할 땐, 엔진이 넘겨주는 값을 전부 적고 물어봐. 옛 기본형의 어떤 대칭 덕분에 이 값은 뭘 잊어도 됐지?

다른 데서 보는 같은 실수

이 함정은 그림에만 있는 게 아니야. 단위 쿼터니언 q와 −q는 같은 회전이야. 둘 사이를 보간하다 먼 길로 돌기 전까진 무해해. 방향 없는 간선은 방향을 버려. 그래프를 한쪽으로 걸어야 할 때까진 무해해. 부호가 제멋대로인 표면 법선은 양면 재질엔 무해하고 단면 재질엔 틀려. 어느 경우든 생산자는 원래 소비자한테는 옳았어. 새 소비자가 뭔가를 조용히 잊어버린 값을 물려받았을 뿐이야.

Code

반 바퀴가 사라지는 자리 (CMyPaint/mypaint-brush.c, 벤더링된 libmypaint, 원문 그대로)·c
// correct dab angle for view rotation
STATE(self, ACTUAL_ELLIPTICAL_DAB_ANGLE) = mod_arith(SETTING(self, ELLIPTICAL_DAB_ANGLE) - viewrotation + 180.0, 180.0) - 180.0;
교훈 기록 #756의 탐침을 재현한 최소 스케치 (mod_arith 본문은 libmypaint helpers.c에서 원문 그대로, clang으로 컴파일해서 실행함)·c
/* Why an angle known modulo 180 cannot orient an asymmetric tip.
   mod_arith is libmypaint's (helpers.c); delivered() is the line in
   mypaint-brush.c that hands draw_dab its dab angle. covered() samples a
   tip the way Ariandel's stamp() does: canvas offset turned into tip space,
   (u, v) in -1...1. The tip is the probe from learning #756: only its
   top-left quarter is white. */
#include <math.h>
#include <stdio.h>

static float
mod_arith(float a, float N)
{
    float ret = a - N * floor (a / N);
    return ret;
}

/* ACTUAL_ELLIPTICAL_DAB_ANGLE, with no view rotation. */
static float
delivered(float setting)
{
    return mod_arith(setting + 180.0f, 180.0f) - 180.0f;
}

/* Does a tip stamped at (cx, cy), radius r, turned by `angle` degrees,
   cover pixel (px, py)? */
static int
covered(float cx, float cy, float r, float angle, int px, int py)
{
    const float t = angle * (float)M_PI / 180.0f, cs = cosf(t), sn = sinf(t);
    const float dx = (float)px + 0.5f - cx, dy = (float)py + 0.5f - cy;
    const float u = (dx * cs + dy * sn) / r, v = (-dx * sn + dy * cs) / r;
    if (u <= -1.0f || u >= 1.0f || v <= -1.0f || v >= 1.0f) {
        return 0;
    }
    return u < 0.0f && v < 0.0f; /* the top-left quarter of the tip */
}

int
main(void)
{
    printf("setting -> delivered\n");
    for (int s = 0; s < 360; s += 45) {
        printf("  %3d -> %6.1f\n", s, delivered((float)s));
    }

    /* Before 963bef1 the tip turned by libmypaint's angle less its rest angle
       (the default setting, 90). After it, by its own angle (0 here). */
    const float rest = 90.0f;
    const float old_turn = delivered(rest) - rest;
    const float new_turn = 0.0f;
    printf("\nprobe: stamp at (100,100), radius 30\n");
    printf("  turned by libmypaint (%.0f):  (80,80) %s  (120,120) %s\n", old_turn,
           covered(100, 100, 30, old_turn, 80, 80) ? "paint" : "none ",
           covered(100, 100, 30, old_turn, 120, 120) ? "paint" : "none ");
    printf("  turned by its own angle (%.0f): (80,80) %s  (120,120) %s\n", new_turn,
           covered(100, 100, 30, new_turn, 80, 80) ? "paint" : "none ",
           covered(100, 100, 30, new_turn, 120, 120) ? "paint" : "none ");
    return 0;
}

/* Output:
setting -> delivered
    0 -> -180.0
   45 -> -135.0
   90 ->  -90.0
  135 ->  -45.0
  180 -> -180.0
  225 -> -135.0
  270 ->  -90.0
  315 ->  -45.0

probe: stamp at (100,100), radius 30
  turned by libmypaint (-180):  (80,80) none   (120,120) paint
  turned by its own angle (0): (80,80) paint  (120,120) none 
*/
팁은 자기 각도로 돌아 (CAriandelSurface/ariandel_surface.c, shaped_draw_dab, 원문 그대로, 들여쓰기만 조정)·c
/* libmypaint's dab angle is only known modulo half a turn (an ellipse
   looks the same either way up), so a tip turns by its own angle instead. */
(void)angle;
float turn = self->angle + self->follow * (self->heading + 90.0f);
if (self->follow_tilt > 0.0f && self->has_azimuth) {
    turn += self->follow_tilt * (self->azimuth + 90.0f);
}
if (self->scatter > 0.0f) {
    turn += self->scatter * (random_unit(self) * 360.0f - 180.0f);
}
const int fx = self->flip_x && (next_random(self) & 1);
const int fy = self->flip_y && (next_random(self) & 1);

External links

Exercise

네 코드에서 업스트림 라이브러리가 이미 줄여서 넘겨주는 값을 하나 찾아. 반 바퀴나 4분의 1 바퀴로 감긴 각도, 부호 없는 방향, 길이를 잃은 정규화 벡터, 분 단위로 잘린 타임스탬프 같은 거. 버려진 정보가 필요한 새 소비자가 생겼을 때 그걸 잡아낼 한 줄짜리 테스트를 써. 4분의 1만 흰 팁 수법을 써. 손실이 관찰 한 번에 드러날 만큼 비대칭인 입력 말이야.
Hint
그 값에 대해 라이브러리 문서가 말하는 범위에서 출발해. 원 위 어디든 될 수 있는 값이 절반에만 산다고 문서에 적혀 있거나 실제로 그렇게 관찰되면, 뭔가가 줄어든 거야. 그다음 그 값의 소비자가 전부 생산자가 가정한 만큼 대칭인지 물어봐.

Progress

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

댓글 0

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

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