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

엔진은 빌려 쓰고, 앱은 우리가 가져

~13 min · architecture, boundaries, libmypaint, scope

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

하우스 사인의 한 문장

가족 저장소엔 전부 CLAUDE.md가 있어. 코드를 읽어선 되살릴 수 없는 걸 적어두는 곳이야. Ariandel의 설계 철학은 이 줄로 시작해.

"브러시 물리는 libmypaint 거고, 손대지 않은 채 벤더링해. 앱이 가져야 할 건 전부, 그러니까 타일, 레이어, 합성, 저장, 입력, 인터페이스는 우리 거야."

이 문장이 국경을 그어. 국경을 제대로 긋는 게 양쪽에서 하는 어떤 작업보다 중요해. 어떤 문제를 한 번 풀고 끝낼지, 어떤 문제를 영영 붙들고 살지가 거기서 갈리거든.

엔진이 주는 것

셋이야. 셋 다 어려운 쪽이고.

  • dab 물리. 스트로크를 따라 dab이 어디 떨어지는지, 어떻게 래스터화되는지, 스머지가 색을 어떻게 집어오는지, 안료가 어떻게 섞이는지. 아키텍처 불변식 1이 이 넷을 libmypaint 몫으로 못박아.
  • 브러시 모델. 설정이 65개야. 설정마다 기본값이 있고 그 위에 입력 18개에 걸친 곡선이 얹혀. 이게 .myb 프리셋으로 저장돼. Ariandel은 이걸 libmypaint 자체 파서(mypaint_brush_from_string)로 읽어. 그래서 MyPaint 브러시 형식이랑 CC0 브러시 라이브러리가 공짜로 딸려와. json-c가 같이 오는 이유도 이거야. 파서가 json-c를 써.
  • 타일 표면 계약. libmypaint는 픽셀을 소유하지 않아. MyPaintTiledSurface한테 프리멀티플라이된 15비트 RGBA 64×64 타일을 달라고 하고, 거기 칠하고, 돌려줘. 헤더에 대놓고 적혀 있어. "The size of the surface is infinite, and consumers need just implement two vfuncs." 표면 크기는 무한이고, 쓰는 쪽은 함수 두 개만 구현하면 된다는 거지.

직접 가져야 하는 것

엔진이 할 일을 다 하고 남은 걸 봐. 평가 표엔 libmypaint에 레이어가 없다고 적혀 있어. "일부러. 표면 인터페이스는 호스트 몫"이라고. 네 일이 어디서 시작하는지 엔진이 알려주는 셈이야.

부분Ariandel에서 사는 곳
타일그 함수 두 개짜리 계약 뒤에 있는 Ariandel 자체 희소 타일 저장소. 실행 취소가 타일마다 이전 상태를 잡는 곳도 여기야
레이어Layer.swift와 그 주변: 불투명도, 표시 여부, 블렌드 모드, 그룹, 마스크
실행 취소History.swift: 타일 단위로 정확한 단계, 바이트로 상한
파일ArtworkFile.swift: .ariandel 패키지. 레이어마다 엔진 고유 형식 그대로의 타일을 LZFSE로 압축
합성CanvasRenderer.swift의 Metal 합성기
입력PencilMapping: 펜슬의 힘, 고도, 방위, 회전이 libmypaint 입력으로 바뀌는 단 한 곳
인터페이스아이패드 앱 그 자체
동반자여기서 아예 안 만들어. 캔버스 옆에 붙인 cwk피파의 Sidekick 패널이야

두 반쪽 사이의 이음매는 호출 하나야. 펜슬 샘플은 전부 mypaint_brush_stroke_to를 거쳐 libmypaint로 들어가. 위치, 압력, 기울기 둘, 경과 시간, 뷰 줌, 뷰 회전, 배럴 회전이 이 호출 하나에 실려. 브러시는 dab을 표면에 줄 세우고, 그게 어디 떨어질지는 표면이 정해. Ariandel의 Swift는 libmypaint 내부를 건드리지 않고, libmypaint는 레이어가 있다는 것조차 몰라.

스케치북은 출발점이지, 축소가 아냐

야심 찬 프로젝트를 아무도 모르게 줄이는 고전적인 수법이 있어. 기술적으로 돌아가는 제일 작은 걸 내보내고, 그게 슬쩍 계획이 되게 두는 거. 그림 앱에선 그게 레이어 하나짜리 스케치북이야. 하우스 사인은 그 문을 이름까지 불러서 닫아. "목표는 Procreate급이다. 레이어 하나짜리 스케치북은 이정표일 뿐, 제품이 아니다." 아빠가 첫날 못박은 말도 같이 적혀 있어. 기본 레이어는 어려운 일이 아니라고. 레이어 처리는 익숙한 구현 작업이야. 진짜 어렵고 진짜 모르는 건 브러시 손맛, 펜슬 반응성, 그리고 전부를 한 덩어리로 맞물리게 하는 일이야. 엔진 선택이 지키려던 게 바로 그거고.

얼마나 진지했는지는 git 로그가 보여줘. 저장소가 21시 51분에 태어났어. 엔진 패키지가 22시 09분. 레이어, 블렌드 모드, 히스토리, .ariandel 패키지가 22시 14분. Metal 합성기가 22시 19분. 빌드 1이 22시 51분에 TestFlight로 갔고, 그때 이미 레이어가 있었어. 레이어 하나짜리 스케치북은 단 한 번도 배포된 적이 없어.

성장은 어디로 갔나

빌드 24 시점의 숫자도 같은 얘기를 해. libmypaint의 C 5,419줄이 든 디렉터리를 건드린 커밋은 167개 중 딱 하나야. 그걸 거기 넣은 커밋. Ariandel 자체 C 브리지는 커밋 22개가 손댔고 약 2,800줄로 컸어. 엔진 패키지의 Swift가 약 14,400줄, 앱이 약 17,500줄. 성장은 전부 Ariandel이 가진 쪽에서 일어났어. 건강한 국경은 안에서 보면 이렇게 생겼어.

이름

Ariandel이라는 이름은 다크 소울 3의 Painted World of Ariandel에서 왔어. 화가가 새로 그려내는 그림 속 세계야. 이 국경이랑 꽤 잘 맞아. 그리는 사람은 아빠야. 피파는 그 세계 안에 살면서 보고 얘기하지만, 대신 그려주지는 않아.

엔진에선 어렵고 완성된 걸 가져와. 그걸 네 앱으로 만드는 건 전부 직접 가져. 엔진에 맞추려고 제품을 줄이고 있거나, 제품에 맞추려고 엔진을 고치고 있다면, 국경이 엉뚱한 데 그어진 거야.

Code

d5a88fb의 libmypaint mypaint-tiled-surface.h에서 줄여 발췌: 호스트가 구현하는 계약·c
typedef struct {
    int tx;
    int ty;
    gboolean readonly;
    guint16 *buffer;
    gpointer context; /* Only to be used by the surface implementations. */
    int thread_id;
    int mipmap_level;
} MyPaintTileRequest;

/* ... mypaint_tile_request_init and the function-pointer typedefs (trimmed) ... */

/**
  * MyPaintTiledSurface:
  *
  * Interface and convenience class for implementing a #MyPaintSurface backed by a tile store.
  *
  * The size of the surface is infinite, and consumers need just implement two vfuncs.
  */
struct MyPaintTiledSurface {
    MyPaintSurface parent;
    /* private: */
    MyPaintTileRequestStartFunction tile_request_start;
    MyPaintTileRequestEndFunction tile_request_end;
    /* ... symmetry data, operation queue, bounding boxes, tile size (trimmed) ... */
};

void
mypaint_tiled_surface_init(MyPaintTiledSurface *self,
                           MyPaintTileRequestStartFunction tile_request_start,
                           MyPaintTileRequestEndFunction tile_request_end);
Ariandel의 Brush.swift 원문 그대로: 펜슬 샘플이 전부 지나가는 호출 하나·swift
    /// Feeds one input sample. Dabs are queued on the surface and rendered
    /// at the surface's `end()`.
    public func stroke(to sample: StrokeSample, on surface: BrushSurface) {
        // A grain whose depth follows the pressure reads it as the stamps are laid.
        ard_surface_set_pressure(surface.handle, sample.pressure)
        _ = mypaint_brush_stroke_to(handle, surface.base,
                                    sample.x, sample.y, sample.pressure,
                                    sample.xTilt, sample.yTilt, sample.deltaTime,
                                    sample.viewZoom, sample.viewRotation, sample.barrelRotation,
                                    0)
    }

External links

Exercise

어려운 부분을 라이브러리에 맡기고 감싸 쓰는 네 프로젝트 하나를 골라. 렌더러든, 데이터베이스든, 파서든, 물리 엔진이든. 칸을 두 개 만들어. 왼쪽엔 라이브러리가 주는 것 전부, 오른쪽엔 네 앱이 가진 것 전부. 그다음 히스토리를 뒤져서 라이브러리 파일 안쪽을 직접 고친 변경을 찾아. 변경마다 국경의 어느 쪽에 있었어야 했는지, 거기로 어떻게 옮길 수 있는지 적어봐.
Hint
국경이 잘못 그어졌다는 신호가 있어. 양쪽을 한꺼번에 고쳐야 했던 기능이 하나. "라이브러리가 지원을 안 해서" 접었는데 알고 보니 처음부터 앱 몫이었던 기능이 또 하나. 그림 앱에서 레이어가 브러시 엔진 몫이 아니라 앱 몫인 것처럼.

Progress

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

댓글 0

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

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