~16 min · c, libmypaint, vtable, brushes, symmetry
Level 0빈 캔버스
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
함수 포인터 표의 칸 하나
libmypaint가 까는 dab은 하나도 빠짐없이 한 호출을 거쳐. mypaint_surface_draw_dab. 이 함수가 하는 일은 self->draw_dab을 부르는 것뿐이야. 레슨 1에서 본 MyPaintSurface 가상 함수 테이블의 첫 칸. 브러시는 dab을 어디에, 얼마나 크게, 무슨 색으로, 얼마나 불투명하게, 지우개로 칠지까지 계산해. 그게 픽셀로 어떻게 나타날지는 서피스가 정하고. Ariandel한테 필요한 이음매가 딱 거기 있었어. Procreate랑 Photoshop 브러시는 그림을 찍거든. 잎사귀, 털 뭉치, 스캔한 분필 자국. libmypaint는 타원 래스터화밖에 몰라.
그림을 가르치려고 libmypaint를 포크하는 건 처음부터 선택지가 아니었어. 엔진은 바이트 단위로 그대로 벤더링했고, 없는 동작은 벤더 파일이 아니라 CAriandelSurface나 Swift에 둔다는 게 규칙이야. 그래서 ard_surface_create는 세 가지를 정확한 순서로 해. mypaint_tiled_surface_init를 불러서 libmypaint 자기 draw_dab이 칸에 꽂히게 해. 그 포인터를 자기 필드 round_dab에 챙겨. 그리고 그 칸에 shaped_draw_dab을 써넣어. 벤더 코드는 하나도 안 바뀌어. 넘겨받는 건 Ariandel이 소유한 구조체에 대입 한 번이면 끝나.
모양이 없으면 그대로 통과
shaped_draw_dab이 맨 먼저 보는 건 팁이 설정됐느냐야. 없으면(levels == 0) 서피스랑 dab 인자 열일곱 개를 하나도 안 바꾸고 round_dab에 넘기고, 그 답을 그대로 돌려줘. 넘겨받기가 정직한 건 이 통과 덕분이야. 평범한 MyPaint 브러시는 Ariandel이 칸에 손댄 적 없는 것처럼 똑같이 칠해. libmypaint의 colorize, posterize, 스펙트럴 안료 혼합, 작업 큐, 그리고 그 draw_dab 안에 사는 대칭 패스까지 다 그대로 돌아.
모양이 있으면: 스탬프, 그리고 round_dab이 하던 일 전부
팁이 있으면 stamp()가 libmypaint가 둥근 dab을 달라던 자리에 그림을 찍어. libmypaint의 크기, 불투명도, 색, 지우개, 알파 잠금을 받아서 libmypaint의 normal 블렌드랑 알파 잠금 블렌드처럼 섞어. colorize, posterize, 안료 혼합은 코드 주석 말대로 둥근 dab만의 몫이야. 큰 팁을 작은 브러시로 줄여도 자글거리지 않게, 팁은 텍셀 하나가 대략 픽셀 하나만 한 밉 레벨에서 떠(level_for, 최대 ARD_SHAPE_LEVELS = 8단계, 반씩 줄여서 4텍셀까지). 그리고 큐에 쌓는 대신 타일에 바로 쓰니까, 배치마다 닿는 타일을 한 번씩만 받아서 붙들고 있다가(held_tile) ard_surface_end에서 한꺼번에 놔줘.
팁이 도는 각도는 이렇게 정해져. 자기 각도에서 시작해서, 획 진행 방향을 따라가는 만큼, Pencil이 기운 쪽을 따라가는 만큼, 무작위 흩뿌림만큼을 더해. 원하면 무작위로 뒤집히고, 시드가 그 무작위를 되풀이 가능하게 해. 그래서 펜을 뗄 때 획을 다시 그려도(5트랙의 테이퍼) 똑같이 흩어져. libmypaint의 dab 각도는 일부러 무시해. 그 각도는 반 바퀴 단위로만 알 수 있거든. 타원엔 상관없고 잎사귀엔 틀린 값이야. 그 얘긴 5트랙에서 해.
대칭, 다시 구현하기
칸을 넘겨받는 데엔 대가가 따라. 옛 함수가 하던 일 중 아직 필요한 건 이제 네가 해야 해. libmypaint의 대칭 패스는 그 draw_dab 안에 살아. 그러니 칸 주인이 바뀌는 순간 모양 dab은 거울 대칭도 회전 대칭도 잃어. shaped_draw_dab은 그걸 직접 되풀이해. libmypaint가 들고 있는 그 symmetry_data 행렬을 그대로 읽어. 설정은 ard_surface_set_symmetry가 맡는데, mypaint_tiled_surface_set_symmetry_state로 넘기기만 하는 한 줄짜리야. 반사마다 미러 플래그를 넘기니까 팁 자체도 뒤집혀. 업스트림이랑 다른 데가 두 군데 있고, 둘 다 일부러야.
네 방향 대칭은 반 바퀴 돌아.VERTHORZ의 두 번째 행렬은 반사 두 번이라 결국 180° 회전이고, 세 번째는 다른 축에 대한 반사야. 업스트림은 회전한 dab에 각도를 그대로 주고, 세 번째엔 첫 번째 반사의 각도를 줘. 타원이면 둘 다 괜찮아. 반 바퀴 돌린 타원은 같은 타원이니까. 잎사귀는 아니지. 그래서 Ariandel은 둘 다 180°를 더해.
건너뛰기가 없어. 업스트림은 첫 dab이 아무것도 안 칠하면 대칭 패스를 통째로 건너뛰어. 선택 영역 마스크가 생기면 이 지름길은 "_must_ be removed"라고 자기 주석에 경고까지 해 뒀고. 지름길의 전제(첫 dab이 아무것도 안 칠하면 복사본도 안 칠한다)는 둥근 dab에선 맞아. 결이나 두 번째 팁이 찍히는 자리에 따라 달라지는 스탬프에선 안 맞고. Ariandel의 모양 경로는 늘 되풀이하고 결과를 OR로 모아.
솔직한 빈칸도 하나 있어. 엔진 테스트 스위트는 세로 거울 대칭에서 모양 팁을 고정해 둬(symmetryRepeatsAShapedTip). 네 방향 대칭의 반 바퀴는 행렬로 따져서 나온 거지, 테스트로 고정된 건 아니야.
나머지 훅, 전부 libmypaint 밖에
칸이 Ariandel 것이 되자 브러시 동작이 더 들어왔어. 전부 2026-10-03 하루에, 전부 서피스의 setter를 통해서.
훅
setter
설정 시점
그림 팁(Procreate의 Shape Source), 139c64f, 00:16
ard_surface_set_shape
획마다, 시드와 함께
dab 따라 구르는 결(Procreate의 moving grain), 9e23122, 12:06
ard_surface_set_grain
획마다
스탬프 단위 Photoshop Dual Brush, ad36a8c, 19:33
ard_surface_set_dual
획마다
Pencil 기운 쪽 따라 도는 팁(Photoshop의 angle from Pen Tilt), 7c548c9, 19:48
벤더링한 CMyPaint 디렉터리의 히스토리엔 커밋이 딱 하나야. 그걸 들여온 커밋. 기능 다섯 개가 하루에 들어왔는데 벤더 바이트는 하나도 안 움직였어. 기록이 증명 못 하는 건 손맛이야. 틸트 커밋 스스로 시뮬레이터엔 틸트가 없고 테스트는 방위각을 집어넣는다고 적어 뒀어. 기운 쪽을 따라가는 팁이 진짜 Pencil 아래서 어떤 느낌인지는 아빠 손을 기다리고 있어.
엔진을 포크하지 말고 엔진의 가상 함수 테이블을 감싸. 엔진이 자기 함수를 꽂게 두고, 그걸 챙기고, 칸을 넘겨받고, 보탤 게 없을 땐 그대로 통과시켜. 네가 소유하는 건 차이뿐이야. 대신 옛 칸이 하던 일 중 아직 필요한 걸 다시 할 의무도 같이 물려받아. 그 의무는 다음 업스트림 업데이트가 걸려 넘어질 자리에 적어 둬.
Code
넘겨받기: ard_surface_create(원문 그대로)와 통과 경로까지의 shaped_draw_dab 앞부분(일부 생략)·c
/* CAriandelSurface/ariandel_surface.c (verbatim) */
ArdSurface *
ard_surface_create(void *context, ArdTileAcquire acquire, ArdTileRelease release)
{
ArdSurface *self = calloc(1, sizeof(ArdSurface));
if (self == NULL) {
return NULL;
}
self->empty = calloc(ARD_TILE_VALUES, sizeof(uint16_t));
self->scratch = calloc(ARD_TILE_VALUES, sizeof(uint16_t));
if (self->empty == NULL || self->scratch == NULL) {
free(self->empty);
free(self->scratch);
free(self);
return NULL;
}
self->context = context;
self->acquire = acquire;
self->release = release;
mypaint_tiled_surface_init(&self->parent, tile_request_start, tile_request_end);
self->parent.parent.destroy = surface_destroy;
self->round_dab = self->parent.parent.draw_dab;
self->parent.parent.draw_dab = shaped_draw_dab;
return self;
}
/* The same file, the head of the replacement (trimmed after the call-through) */
/* libmypaint's draw_dab with a shaped tip: the tip turns with the stroke and
at random as asked, and symmetry repeats it as libmypaint repeats a round
dab (reflections mirror the tip too). Without a shape, libmypaint's own. */
static int
shaped_draw_dab(MyPaintSurface *surface, float x, float y, float radius,
float color_r, float color_g, float color_b,
float opaque, float hardness, float softness, float color_a,
float aspect_ratio, float angle, float lock_alpha, float colorize,
float posterize, float posterize_num, float paint)
{
ArdSurface *self = (ArdSurface *)surface;
if (self->levels == 0) {
return self->round_dab(surface, x, y, radius, color_r, color_g, color_b, opaque, hardness, softness,
color_a, aspect_ratio, angle, lock_alpha, colorize, posterize, posterize_num, paint);
}
/* ... the shaped path: heading, turn, flips, stamp(), symmetry ... */
}
업스트림 대칭 패스와 Ariandel 것 나란히: 건너뛰기와 네 방향 대칭(둘 다 일부 생략)·c
/* libmypaint, mypaint-tiled-surface.c, inside draw_dab (trimmed) */
// Symmetry pass
// OPTIMIZATION: skip the symmetry pass if surface was not modified by the initial dab;
// at current if the initial dab does not modify the surface, none of the symmetry dabs
// will either. If/when selection masks are added, this optimization _must_ be removed,
// and `surface_modified` must be or'ed with the result of each call to draw_dab_internal.
MyPaintSymmetryData *symm_data = &self->symmetry_data;
if (surface_modified && symm_data->active && symm_data->num_symmetry_matrices) {
/* ... */
case MYPAINT_SYMMETRY_TYPE_VERTHORZ: {
// Reflect across horizontal line
mypaint_transform_point(&matrices[0], x, y, &x_out, &y_out);
DDI(x_out, y_out, -2.0 * symm.angle - angle, 1);
// Then across the vertical line (diagonal)
mypaint_transform_point(&matrices[1], x, y, &x_out, &y_out);
DDI(x_out, y_out, angle, 2);
// Then back across the horizontal line
mypaint_transform_point(&matrices[2], x, y, &x_out, &y_out);
DDI(x_out, y_out, -2.0 * symm.angle - angle, 3);
num_bboxes_used = 4;
break;
/* ... */
/* Ariandel, ariandel_surface.c, inside shaped_draw_dab (trimmed) */
int painted = STAMP(x, y, turn, 0);
MyPaintSymmetryData *symm_data = &self->parent.symmetry_data;
if (symm_data->active && symm_data->num_symmetry_matrices) {
/* ... */
case MYPAINT_SYMMETRY_TYPE_VERTHORZ:
mypaint_transform_point(&matrices[0], x, y, &x_out, &y_out);
painted |= STAMP(x_out, y_out, -2.0f * symm.angle - turn, 1);
mypaint_transform_point(&matrices[1], x, y, &x_out, &y_out);
painted |= STAMP(x_out, y_out, turn + 180.0f, 0);
mypaint_transform_point(&matrices[2], x, y, &x_out, &y_out);
painted |= STAMP(x_out, y_out, -2.0f * symm.angle - turn + 180.0f, 1);
break;
/* ... */
최소 스케치: 엔진 함수를 챙기고, 칸을 넘겨받고, 보탤 게 없으면 통과(Apple clang -std=c11로 컴파일하고 실행함)·c
// A minimal sketch of the draw_dab takeover: keep the engine's function,
// install your own, call through when you have nothing to add.
// clang -std=c11 -Wall -Wextra wrap.c -o wrap && ./wrap
#include <stdio.h>
#include <stdlib.h>
typedef struct Surface Surface;
typedef int (*DrawDab)(Surface *self, float x, float y, float radius);
struct Surface { DrawDab draw_dab; }; /* the engine's vtable slot */
/* ---- the engine, untouched ---- */
static int engine_round_dab(Surface *self, float x, float y, float radius) {
(void)self;
printf(" engine: round dab r=%.1f at (%.0f, %.0f)\n", radius, x, y);
return 1;
}
static void engine_init(Surface *s) { s->draw_dab = engine_round_dab; }
static void engine_stroke(Surface *s) { s->draw_dab(s, 5, 5, 3); s->draw_dab(s, 9, 6, 3); }
/* ---- the host ---- */
typedef struct {
Surface parent; /* first, so Surface* -> Host* is valid */
DrawDab round_dab; /* the engine's own, saved */
const char *shape; /* NULL: no shape set */
} Host;
static int host_draw_dab(Surface *surface, float x, float y, float radius) {
Host *self = (Host *)surface;
if (self->shape == NULL) {
return self->round_dab(surface, x, y, radius); /* straight through */
}
printf(" host: stamp '%s' r=%.1f at (%.0f, %.0f)\n", self->shape, radius, x, y);
return 1;
}
static Host *host_create(void) {
Host *self = calloc(1, sizeof *self);
engine_init(&self->parent); /* let the engine install its own first */
self->round_dab = self->parent.draw_dab; /* then save it */
self->parent.draw_dab = host_draw_dab; /* and take the slot */
return self;
}
int main(void) {
Host *host = host_create();
puts("no shape:");
engine_stroke(&host->parent);
host->shape = "leaf";
puts("with a shape:");
engine_stroke(&host->parent);
free(host);
return 0;
}
/* Output:
no shape:
engine: round dab r=3.0 at (5, 5)
engine: round dab r=3.0 at (9, 6)
with a shape:
host: stamp 'leaf' r=3.0 at (5, 5)
host: stamp 'leaf' r=3.0 at (9, 6) */
네가 기대는 라이브러리 중에 함수 포인터 표나 직접 구현할 수 있는 프로토콜을 내놓는 걸 하나 찾아봐. SQLite VFS, 커스텀 할당자 훅, 로깅 싱크, URLProtocol 같은 거. ard_surface_create처럼 항목 하나를 감싸 봐. 라이브러리가 기본값을 꽂게 두고, 챙기고, 네 걸 꽂고, 네 조건이 꺼져 있으면 하나도 안 바꾸고 통과시켜. 그다음 원래 항목이 하던 일을 전부 적고, 항목마다 '통과', '다시 구현', '일부러 버림' 중 하나를 붙여.
Hint
통과 경로가 정직한지 제일 빨리 증명하는 법은, 감싼 걸 꽂고 조건은 끈 채로 라이브러리 자체 테스트나 네 기존 테스트를 돌리는 거야. 뭐라도 바뀌면 비켜선다던 래퍼가 몰래 일하고 있는 거야.
Progress
Progress is local-only — sign in to sync across devices.