Lulucat

초크 도구 성능: 전체 화면 패스에서 스트로크별 scissor 사각형까지

Gaoge ZhangGaoge Zhang

Lulucat Notes의 초크 도구는 손글씨가 빽빽한 영역에서 느려졌다. 병목은 3,571개의 입력 샘플이 아니라 프레임마다 실행되는 70회의 전체 화면 스크래치 패스였다. 거부한 저해상도 캐시와 스트로크별 scissor 사각형이 나머지 이야기를 보여 준다.

255% 확대의 iPad에서 Lulucat Notes를 잘라 본 화면. 빨간색과 파란색 초크로 쓴 중국 고전 문장과 앱 툴바 일부가 보인다.

최종 확인에 사용한 기기 빌드의 빨간색과 파란색 초크, 255% 확대에서 155개 스트로크.

Lulucat Notes의 초크 도구에는 특유의 성능 문제가 있었다. 빈 영역에 쓰면 부드러웠지만, 이미 초크 스트로크로 가득 찬 영역으로 들어가면 펜 끝이 뒤처지기 시작했다. 같은 영역에서 계속 쓰면 캔버스 패닝도 점점 느려졌다.

평범한 손글씨 한 페이지면 문제를 재현하기에 충분했다. 300% 확대, 로컬 영역에 보이는 초크 스트로크 70개, 총 3,571개의 입력 샘플 포인트였다. 빈 영역은 계속 부드러웠고, 스트로크가 몰린 영역만 느려졌다.

수정 후에는 같은 페이지에 255% 확대에서 계속 새 글씨를 쓸 수 있고, 펜을 대고 있는 동안과 패닝하는 동안 모두 기존 스트로크가 완전한 선명도를 유지한다.

255% 확대의 iPad에서 Lulucat Notes가 빨간색과 파란색 초크 손글씨를 보여 준다. 글자는 "天行健,君子以自强不息;地势坤,君子以厚德载物"이라고 쓰여 있다 — 중국 고전 문장이다. 오른쪽 위에는 파란색 Lulucat 마스코트가 있다. 아래 툴바에는 스트로크 수 155, Save, Clear, 255% 확대 슬라이더가 표시된다.

최종 확인에 사용한 기기의 스크린샷, 총 155개 스트로크. 이 확대 수준에서는 펜을 대거나 패닝해도 기존 스트로크의 선명도가 잠시 다른 수준으로 바뀌지 않는다.

초크에 scratch 텍스처가 필요한 이유

일반 펜은 원형 스탬프를 하나씩 source-over 블렌딩으로 잉크 텍스처에 직접 합성할 수 있다. 초크는 grain-gating 레이어를 추가한다. 렌더러는 먼저 스트로크 전체의 body coverage와 depth를 누적한 다음, 고정된 grain 텍스처를 사용해 어느 위치에 초크 가루가 들어갈지 정하고, 마지막으로 결과를 기존 잉크에 합성한다.

이 scratch 텍스처는 하나의 초크 스트로크를 격리한다. 같은 스트로크 안의 스탬프가 크게 겹치기 때문에 격리가 중요하다. 각 스탬프에 개별적으로 grain gate를 적용하면 스트로크 중심선에 색이 반복해서 누적되고, 초크의 구멍이 샘플링 밀도에 따라 이동한다.

확대 수준이 높을 때 Lulucat Notes는 현재 viewport에 보이는 벡터 스트로크를 다시 그린다. 이전 구현은 보이는 각 초크 스트로크에 다음 단계를 수행했다.

  1. main render encoder를 종료한다.
  2. scratch 텍스처를 지운다.
  3. 이 초크 스트로크 하나를 scratch에 그린다.
  4. main render encoder를 다시 연다.
  5. 전체 화면 삼각형으로 scratch를 drawable에 다시 합성한다.

스트로크 하나의 의미는 올바랐지만 작업 범위가 훨씬 컸다. iPad의 drawable은 2732×2048, 대략 5.6백만 픽셀이었다. 초크 스트로크 하나마다 scratch 패스 하나와 전체 화면 합성 하나가 발생했다. 초크 스트로크 70개면 render encoder 약 141개와 전체 화면 합성 70회였다.

보이는 초크 스트로크 수를 , drawable 픽셀 수를 라고 하자. 픽셀 범위에 따라 커지는 작업만 보면 이전 구현은 다음에 가까웠다.

각 초크 스트로크에는 고정된 render-pass 오버헤드도 있었으므로 그 비용 역시 에 따라 선형으로 늘었다. 3,571개의 입력 포인트는 부차적인 비용만 더했다. 로컬 스트로크 수에 따라 커진 것은 각 스트로크가 일으킨 전체 화면 작업 범위였다.

측정은 iPadOS 18.6.2를 실행하는 12.9인치 iPad Pro(5세대, M1)에서 진행했다. 이 특정 iPad의 동일한 Debug 기기 빌드에서 command-buffer timestamp를 사용해 변경 전후 동일한 viewport의 GPU timestamp를 비교했다. 아래 범위는 여러 프레임 로그에서 나타난 일반적인 변동이며, 향후 버전의 프레임 레이트를 약속하는 수치가 아니다. 보이는 초크 스트로크가 70개일 때 한 프레임에는 보통 GPU 시간 52–60 ms가 필요했고, 스트로크가 약 120개인 영역에서는 GPU 시간이 77–80 ms로 올랐다.

스크래치와 합성 패스의 전체 화면 사각형 면적을 기준으로 추정하면, 프레임당 이론적인 작업 범위는 약 783백만 픽셀에서 1.34십억 픽셀로 늘었다. 이 수치는 사각형 면적의 합이며 fragment invocation 수, 비디오 메모리 읽기/쓰기 바이트 수 또는 GPU 하드웨어 카운터와 같지 않다. Metal의 fast clear, attachment load/store, 패스 전환은 여전히 GPU와 드라이버의 제어 아래 있다.

이것은 빈 영역이 계속 부드러웠던 이유도 설명한다. visibility culling은 viewport 밖의 스트로크를 건너뛴다. 빈 영역에서 은 0에 가깝고, 빽빽한 영역에서는 이 계속 올라간다.

0.85 ms에서 얻은 잘못된 답

앱에는 이미 포인트당 2픽셀로 구워 둔 전체 페이지 잉크 텍스처가 있었다. 쓰기, 패닝, 확대 중에는 이 텍스처를 직접 표시하고 현재 Apple Pencil 스트로크만 live vector로 남겨 두는 방법을 시도했다. 상호작용이 끝나면 추가 프레임 하나에서 고해상도 벡터 결과를 다시 그렸다.

이 방법은 성능이 아주 좋았다. 같은 빽빽한 영역을 300% 확대했을 때 GPU 시간이 0.84–0.85 ms로 내려갔고, 기존 초크 스트로크 수에 따라 더 늘어나지도 않았다.

실제 기기에서의 문제도 똑같이 분명했다. 300% 확대에서는 포인트당 화면 픽셀 약 6개가 필요했지만 캐시는 2개만 제공했다. Apple Pencil이 닿는 순간 기존 스트로크 전체가 부드러운 저해상도 이미지로 바뀌었고, Pencil을 떼면 다시 완전히 선명해졌다.

테스터는 이렇게 말했다. “글을 쓰고 있으면 캔버스 전체가 흐려져요. 손을 떼면 다시 선명해져요.”

이 최적화는 제거했다. 0.85 ms는 측정된 최저 결과였지만 받아들일 수 있는 초크 도구는 아니었다. 기존 스트로크는 쓰기 피드백의 일부이므로 펜을 대는 순간 선명도가 바뀌어서는 안 된다.

각 초크 스트로크를 자기 사각형으로 제한하기

최종 수정은 스트로크별 scratch와 스트로크별 합성을 유지하면서 픽셀 작업 범위만 줄였다. 각 스트로크에는 모든 스탬프 반지름의 합집합에서 구한 캔버스 bounding box가 이미 있었다. 렌더러는 이 bounding box를 현재 viewport의 drawable 좌표로 변환하고 앤티앨리어싱 여백으로 2픽셀을 패딩한다.

같은 scissor 사각형을 세 가지에 사용한다. scratch를 지우고, 스트로크를 그리고, 결과를 메인 표면에 다시 합성하는 일이다.

let rect = displayScissorRect(for: stroke.bounds, viewport: viewport)

scratchEncoder.setScissorRect(rect)
clearScratchExplicitly()
drawStrokeIntoScratch(stroke)

mainEncoder.setScissorRect(rect)
compositeChalkFromScratch(stroke)
mainEncoder.setScissorRect(fullDrawable)

같은 로직은 4096² 잉크 텍스처의 baking과 partial replay에도 사용한다. 따라서 고배율 디스플레이와 정착된 잉크 레이어가 서로 다른 초크 동작을 만들지 않는다.

여기에는 놓치기 쉬운 세부 사항이 2가지 있다.

첫째, render pass의 loadAction = .clear는 attachment load 단계에서 실행되며 rasterization scissor의 제한을 받지 않는다. 계속 이것을 사용하면 스크래치 텍스처 전체가 여전히 지워진다. 수정된 패스는 .dontCare를 사용한 뒤 scissor 안에서 clear_fragment를 그린다. 이 사각형은 이후 전체가 기록되고 합성은 같은 사각형만 읽으므로 이전 attachment 내용을 로드할 필요가 없다.

둘째, 각 초크 스트로크의 합성이 끝난 뒤에는 바깥쪽 scissor를 복원해야 한다. 이 상태 복원 줄을 빼면 이후의 펜, 이미지, 선택 영역이 이전 초크 스트로크의 bounds에 계속 잘려서 스트로크나 이미지가 빠진 것처럼 보인다.

초크 grain은 사각형 안의 로컬 UV가 아니라 절대 캔버스 좌표에서 계속 샘플링한다. scissor를 옮겨도 GPU가 처리하는 픽셀만 달라질 뿐 각 픽셀이 읽는 grain 텍스처 위치는 달라지지 않는다. 따라서 인접한 사각형 사이에 텍스처 이음새가 생기지 않고, 캔버스를 끌어도 grain이 떠다니지 않는다.

픽셀 작업량만 생각하면 새로운 작업 범위는 다음에 가깝다.

여기서 는 현재 화면에서 번째 초크 스트로크의 axis-aligned bounding-box 면적이다. render encoder 수는 줄지 않았지만 이제 각 clear와 composite는 스트로크의 화면 bounding box 안으로 제한된다.

같은 색 초크 스트로크를 배치 처리하지 않은 이유

페이지의 초크 스트로크 대부분은 같은 색과 밀도를 공유한다. 그래서 수십 개의 스트로크를 한 번에 스크래치에 그린 뒤 한 번만 합성하고 싶은 유혹이 생긴다. 그러면 render pass를 더 줄일 수 있지만 겹치는 영역에서 색과 grain의 의미가 바뀐다.

의도적으로 단순화한 경우를 보자. 어떤 픽셀에서 두 스트로크가 같은 grain-gate 값 를 공유하고 body coverage가 , 라고 하자. 실제 shader에서는 gate가 각 스트로크의 pressure depth에도 의존한다. 이처럼 단순화한 경우만으로도 batching이 일반적으로 동등하지 않음을 보이기에 충분하다. 현재의 스트로크별 합성은 다음을 만든다.

반면 body를 먼저 합친 다음 단일 gate를 적용하면 다음을 만든다.

차이는 이다. 두 스트로크가 겹치고 grain gate가 순수한 0도 순수한 1도 아닐 때마다 결과가 달라진다. 직접 배치 처리하면 교차점에 초크 가루가 놓이는 방식이 달라진다.

정확한 배치 처리를 하려면 스트로크 픽셀이 서로 겹치지 않는다는 것을 증명하거나, 각 스트로크에 독립적인 atlas 영역을 할당하고 원래 순서대로 합성해야 한다. 최종 기기 승인은 scissor 방식을 유지했으므로 이번 단계에서는 atlas나 그 관리 복잡성을 도입하지 않았다.

10억 픽셀에서 다시 수백만 픽셀로

최종 확인에 사용한 기기 측정값:

시나리오수정 전정밀 scissor
보이는 초크 스트로크 70개, 300% 확대, 쓰기GPU 52–60 ms≈ 9–10 ms
보이는 초크 스트로크 ≈ 121개, 300% 확대GPU 77–80 ms13.7–15.6 ms
프레임당 이론적 사각형 범위 (scratch + composite)783 M–1.34 B 픽셀≈ 1.7 M–3 M 픽셀

기기 문서에서 가져온 스트로크 3,452개와 샘플 포인트 202,710개를 사용해 clipping bounds도 확인했다. 0.5×, 1×, 2×, 3×, 5×, 8× 확대에서 186,408개의 viewport 사례를 생성했고, 0이 아닌 coverage를 만들 수 있는 모든 point sprite가 계산된 scissor 안에 들어왔다. 이 검사는 캔버스 가장자리, viewport 가장자리, 여러 offset 조합을 포함했다.

최종 코드는 상호작용 상태에 따라 저해상도 LOD로 전환하지 않는다. 낮은 확대 수준에서는 계속 전체 페이지 잉크 텍스처를 표시하고, 높은 확대 수준에서는 계속 보이는 스트로크를 벡터로 다시 그린다. 임계값의 같은 쪽에 있는 동안에는 펜을 대거나 패닝해도 기존 스트로크를 다른 선명도 수준으로 바꾸지 않는다. 고배율 벡터를 다시 그리는 동안 각 초크 스트로크의 scratch clear와 composite는 자신의 화면 bounding box만 덮는다.

GPU 시간으로는 선명도를 포착할 수 없었다

GPU 성능 문제는 데이터 구조에서 가장 눈에 띄는 양과 반드시 함께 커지지는 않는다. 이 경우 3,571개의 입력 포인트가 의심하기 쉬운 대상이었지만, 프레임 시간을 결정한 것은 초크 스트로크 70개 각각이 일으킨 전체 화면 작업과 render-pass 전환이었다.

시각적 의미도 사용할 수 있는 최적화를 제한했다. 스트로크별 scratch, 원래의 합성 순서, 절대 캔버스 grain 좌표는 마음대로 제거할 수 없었다. 같은 색과 같은 밀도는 매개변수가 일치한다는 뜻일 뿐, 겹친 결과를 합쳐도 된다는 증거가 아니다.

실제 기기 피드백은 GPU 시간이 가장 짧은 버전을 거부했다. “캔버스 전체가 흐려진다”는 말은 측정만으로는 드러나지 않았던 제품 제약을 알려 줬다. Apple Pencil이 닿을 때 사용자는 기존 스트로크도 함께 보고 있기 때문이다.

최종 버전은 새로운 상호작용 캐시 레이어를 추가하지 않고 선명도도 낮추지 않는다. 고배율 벡터 다시 그리기는 각 초크 스트로크의 작업을 자기 화면 bounding box로 제한할 뿐이다. LucasPad에 다시 로드한 뒤 피드백은 이랬다. “아주 좋네요.”