WIKI실시간 렌더링
플레이북

실시간 렌더링

위젯이 아니라 매 프레임 살아 그려지는 존재.

SpriteKit실시간 렌더링SKShaderCGPathwatchOS셰이더

위젯이 아니라 존재

워치 화면에 뭔가를 그리는 흔한 방법은 이미지다. 상태마다 그림을 하나씩 그려두고, 조건에 맞는 걸 띄운다. 눈 감은 그림, 눈 뜬 그림, 웃는 그림. 정보를 보여주는 위젯이라면 그걸로 충분하다.

noon은 그 반대쪽에서 출발했다. 정보를 띄우는 위젯이 아니라, 매 프레임 살아 그려지는 존재. 손목 위에 사는 작은 외계인이 나를 관찰하고 반응하려면, 미리 그려둔 그림을 갈아끼우는 걸로는 안 됐다. 눈이 실시간으로 두리번거리고, 시선이 손가락을 따라오고, 표정이 이미지 사이를 점프하지 않고 이어져야 했다.

그래서 눈을 이미지 텍스처가 아니라 벡터(CGPath)로 그리기로 했다. 이건 noon이라는 프로젝트의 성격을 규정한다 — LLM 축이 아니라 그래픽·실시간 렌더링 축이다. SpriteKit 씬이 매 프레임 눈을 다시 그리고, 그 위에 셰이더와 실사영상을 얹는 그래픽 엔진을 워치라는 극한 제약 안에서 굴린 기록이다.

눈은 벡터다, CGPath로 그리는 표정

눈 하나는 MatrixEyeNode이고, 그 안에는 SKShapeNode 하나가 들어있다. 이 도형의 모양은 이미지가 아니라 CGPath다.

mainShape.path = EyeShapePaths.idle(isLeftEye: isLeftEye)
mainShape.fillColor = 초록
mainShape.strokeColor = .clear
mainShape.isAntialiased = true

EyeShapePaths는 표정마다 CGPath를 만들어내는 팩토리 모음이다. 기본(세로 캡슐), 멍함(위 평평·아래 둥근), 화남(바깥 높고 안쪽 낮은 \/ 눈썹), 웃음(>< 꺾쇠), 감격(네 갈래 별), 만족(감은 눈 = 얇은 선 3겹), 잔물결(~~ 리본), 나른(가운데 처진 실눈), 존엄(살짝 위로 아치)… 전부 중앙 원점 기준으로 좌표를 찍어 만든 벡터다.

핵심은 표정이 바뀔 때 이미지를 갈아끼우는 게 아니라 path를 다시 그린다는 것이다. 화나면 mainShape.pathangry 경로로 바꾸고 크로스페이드한다. 그림 사이를 점프하는 게 아니라 같은 벡터 도형의 모양이 바뀐다.

눈대중 대신 좌표, 스플라인 성형

둥근 사각형·꺾쇠 같은 건 코드로 직접 좌표를 찍으면 된다. 하지만 "쭈뼛한 눈", "우직한 눈"처럼 미묘한 곡선은 손으로 좌표를 못 짠다. 그래서 눈 모양 에디터에서 점을 찍어 뽑은 24점 닫힌 Catmull-Rom 스플라인 좌표를 코드에 박아두고, spline()이 그 점들을 제어점 공식으로 이어 매끄러운 CGPath를 만든다. 눈대중으로 곡선을 왕복하던 걸 좌표 export로 끝냈다.

깜빡임은 그림이 아니라 눌림

감은 눈 그림 같은 건 없다. 깜빡임은 SKAction.scaleY(to: 0.05)로 세로 스케일을 눌렀다 펴는 것이다. 벡터 도형이라 눌려도 뭉개지지 않고 깔끔하게 접힌다. 시선 이동은 눈 노드의 position, 표정은 path, 깜빡임은 scaleY — 서로 다른 축을 건드리니 겹쳐도 안 싸운다.

눈 = SKShapeNode + CGPath (비트맵 텍스처 아님)
표정 전환 = path 교체 + 크로스페이드 (그림 점프 아님)
스플라인 성형 = 에디터 좌표 export → 닫힌 Catmull-Rom
깜빡임 = scaleY 눌림, 시선 = position, 표정 = path (축 분리)
렌즈는 같은 path를 stencil로 삼아 클립 → 표정 자동 추종

매 프레임 살아 그려진다, 3-Layer 렌더 루프

SpriteKit 씬의 update(_ currentTime:)는 매 프레임 호출된다. 여기서 눈을 "살아있게" 만드는 계산이 돈다.

CGPath
눈을 그리는 단위 — 비트맵이 아니라 벡터 경로
3 + 1
렌더 레이어 — 행동·애니메이션·시선 + 크라운 패널
30f · 6×5
실사영상 렌즈 스프라이트 시트 (프레임 · 타일)
~1Hz
상시화면(AOD) 재드로우 — SpriteKit freeze 우회

씬은 세 레이어를 서로 침범하지 않게 쌓는다. Layer 1 행동은 한 번에 하나의 상태(idle·angry·sleepy·charging…)를 관리하는 FSM. Layer 2 애니메이션은 항상 도는 깜빡임 — 행동과 독립이다. Layer 3 시선은 탭 위치를 파라미터로 받아 어떤 행동에서도 눈이 그쪽을 보게 한다. 상태가 아니라 값이라, 화난 채로도 시선은 손가락을 따라간다.

idle일 때가 제일 공을 들인 부분이다. 가만히 있어도 눈은 상시 미세하게 두리번거리고(dart), 가끔 하나씩 "장면 비트"를 얹는다 — 갸웃, 끄덕, 쫑긋, 두리번, 이중 깜빡, 잔물결. 전부 확률로 골라 부드럽게 lerp하니, 같은 패턴이 반복되지 않아 "관찰하는 생명체"로 읽힌다. 미리 만든 애니메이션 클립을 트는 게 아니라 매 프레임 절차적으로 새로 계산하는 결이다.

렌즈 엔진: 언제 셰이더, 언제 실사영상

noon의 눈은 색만 바꾸는 게 아니라 렌즈를 갈아 끼운다. 눈 안에 통신장애 테스트카드가 뜨거나, 해파리가 헤엄치거나, 독액이 차오른다. 렌즈를 만들다 규칙 하나가 섰다.

유기적인 장면은 실사 영상(MP4→스프라이트 시트), 기하적·단순한 효과는 셰이더.

해파리의 흐느적거림이나 오로라의 유기적 일렁임을 셰이더로 흉내 내면 "AI가 대충 짠 것"처럼 어색해진다. 반대로 컬러바·도트매트릭스 같은 양식화된 이펙트는 실사로 찍을 것도 없이 코드로 그리는 쪽이 선명하다. 그래서 렌즈를 성질에 따라 갈랐다.

  • 셰이더 렌즈 — 신호 글리치, 통신장애(SMPTE 컬러바), 카툰 눈. 전부 GLSL ES 프래그먼트 셰이더로 절차적으로 그린다.
  • 실사영상 렌즈 — 해파리, 게코, 오로라, 해변. MP4를 스프라이트 시트로 구워 프레임 애니로 돌린다.
  • 정지 텍스처 렌즈 — 드래곤 눈, 백룸. 고퀄 그림 한 장을 눈 모양에 마스킹.
  • 절차 렌즈 — 코드 에디터(신택스 토큰 행 스크롤), 도트매트릭스, 배터리, 탄산수, 독액. 이미지 없이 도형으로 직접 그린다. 독액은 셰이더가 아니라 SKShapeNode 그라데이션에 해골 파티클을 얹어 눈 안에 차오르게 만든다.

성질이 뭐든 렌즈는 전부 SKCropNode눈과 같은 CGPath를 마스크로 물린다. 표정으로 눈 모양이 바뀌면 마스크도 같이 갱신되니, 렌즈가 낀 채로도 깜빡이고 화낸다. 벡터 눈이 래스터 콘텐츠의 stencil이 되는 구조다.

MP4를 눈 안에 굽기

실사영상 렌즈는 손으로 만들 수 없어 파이프라인을 짰다. make_lens_sheet.sh 한 방이면 영상이 렌즈가 된다.

MP4 클립
유기적 장면 (해파리·오로라 등)
ffmpeg 프레임 추출
fps=6 · 가운데 세로 크롭 · 셀 크기로 스케일
30프레임
6fps × 5초
ImageMagick montage
6×5 타일 스프라이트 시트 한 장
imageset + AOD 정지컷
중간 프레임을 상시화면용 스틸로 자동 생성
setAnimSceneLens
런타임에 아틀라스 슬라이스 → SKAction 프레임 애니

런타임의 setAnimSceneLens는 이 6×5 아틀라스를 30개의 SKTexture로 슬라이스해 SKAction.animate로 돌린다. 여기에 워치용 트릭 몇 개를 얹었다. 연속(continuous) — 두 눈이 한 장면의 좌/우 절반을 맡아 가로 영상이 한 화면처럼 이어진다. 핑퐁 — 앞→뒤→앞으로 재생해 루프 이음새를 지운다. 위상차(phaseOffset) — 오른눈을 반 바퀴 앞선 프레임에서 시작시켜, 두 눈이 살짝 엇박으로 움직이게 한다(해파리가 건너가는 듯한 부유감).

신호 글리치, 시그니처 셰이더

렌즈 중 대표는 신호 글리치다. "외계 신호로 존재하는 눈"이라는 컨셉이라, 신호가 약해지면 눈이 위태위태하게 깨진다. 인라인 GLSL ES 문자열로 짠 프래그먼트 셰이더가 SKEffectNode로 눈 그룹을 감싼다. 한 셰이더 안에서 가로 슬라이스 어긋남, RGB 색분리 프린지(마젠타·시안), 스캔라인, 천천히 투명해졌다 회복하는 swoon, 가끔 완전 두절까지 처리한다. 세기는 glitchAmount 유니폼 하나로 0(멀쩡)~1(풀 글리치)를 오간다.

이걸 원샷으로 쓰는 게 playSignalReception이다. 앱을 켜거나 noon이 등장하는 순간, 글리치를 세게 시작해 정해진 시간 동안 세기를 0으로 잦아들게 램프한다. 지직거리며 수신되다 눈이 또렷해지는 연출 — noon이 "신호로 도착하는" 순간이다.

(셰이더를 짜며 배운 사소하지만 치명적인 함정 하나: SpriteKit에서 u_ 접두사는 예약어라 커스텀 유니폼 이름에 못 쓴다. 그리고 프리멀티플라이드 알파를 안 풀면 눈 테두리에 검은 헤일로가 낀다. 실기기에서 눈으로 보고서야 잡았다.)

눈만 그리지 않는다

눈은 진공에 떠 있지 않다. 눈이 사는 씬 전체가 같은 렌더 루프 위에서 매 프레임 살아, 눈과 물과 떠다니는 것들이 한 화면에서 서로 반응한다. 처음엔 눈 하나였던 씬이 점점 하나의 작은 수중 세계로 자랐다.

물참/장마 — watchOS 네이티브 반사(SKCropNode)에 굴절 wobble·폭우·수면 거품띠. 비 오는 날 눈 아래로 물이 차고 그 위에 눈이 비친다
버블 — 씬 배경에 독립적으로 떠오르는 방울이 눈과 충돌하면 튕긴다. 얼굴 악세로도 버블 이미터
방문자(visitor) — 까마귀 상인 같은 손님이 10프레임 등장·퇴장 애니로 씬에 들어와 눈의 시선을 끈다. 여름엔 수중 방문자가 더 온다
악세 슬롯 — 머리·눈·얼굴에 뿔·귀·모자·선글라스가 눈 지오메트리를 따라 렌더돼 깜빡임·표정을 함께 탄다

물 반사는 눈 렌즈와 같은 도구(SKCropNode)를 반대로 쓴 것이다. 렌즈가 눈 path 안으로 콘텐츠를 가뒀다면, 반사는 수면 아래 영역을 크롭해 눈을 뒤집어 담는다. 방문자·버블·비도 전부 눈과 같은 씬 좌표계 위에 얹혀서, 눈이 방문자를 쳐다보고 방울을 피하는 게 별도 로직이 아니라 같은 루프 안의 값 계산으로 붙는다. 눈을 벡터로 정의하고 씬을 절차적으로 굴린 결정이, 콘텐츠가 늘어날 때마다 새 렌더러가 아니라 새 값 하나로 붙게 만들었다.

워치라는 제약, 상시화면과 배터리

이 모든 게 손목 위 워치에서 돈다. 화면은 작고 배터리는 야박하다. 제약이 렌더 구조를 두 번 갈랐다.

호스팅. SwiftUI가 SpriteView(scene:)로 SpriteKit 씬을 얹고(scaleMode = .resizeFill), 디지털 크라운 회전으로 설정 패널을 끌어내린다. 크라운은 Layer 4(외부 압력)로, 내려오는 동안 걷기 자동 모션을 멈추고 눈 그룹의 Y를 직접 지배한다.

상시화면(AOD)이 진짜 벽이었다. watchOS는 손목을 내리면 SpriteKit 씬을 통째로 freeze시킨다. 매 프레임 그리던 엔진이 멈추는 것이다. Apple이 AOD에서 주기적 재드로우를 허용하는 유일한 길은 SwiftUI TimelineView다. 그래서 AOD 전용으로 눈을 SwiftUI로 한 번 더 구현했다. isLuminanceReduced가 켜지면 SpriteKit은 일시정지(상태는 보존)하고, TimelineView(.periodic by: 1.0)가 ~1Hz로 눈을 다시 그린다. 손목을 들면 오버레이가 사라지고 SpriteKit이 멈췄던 행동을 이어서 재개한다.

이 재구현엔 정합성 문제가 있었다. 첫째, AOD의 SwiftUI 눈은 SpriteKit의 EyeShapePaths같은 모양이어야 한다 — 그래서 DazeEyeShape·SharpEyeShape·스플라인 셰이프를 SwiftUI Shape로 나란히 포팅했다(단일 소스에서 좌표 공유). 둘째, ~1Hz에서는 SwiftUI의 implicit animation이 프레임 보간을 못 해 "뚝뚝" 끊긴다. 그래서 깜빡임 openness를 시간 t의 연속 함수(smoothstep)로 직접 계산하고, 시선 두리번도 서로 다른 주파수의 sin을 합성해 매 재드로우마다 곡선 위 올바른 값이 찍히게 했다.

셋째는 한참 뒤에야 눈에 걸린 것이다. AOD가 재현해야 하는 건 "잠든 모습"이 아니라 씬이 멈춘 그 순간이다. 나른·만족·삐침·존엄처럼 눈이 감기는 표정은 스케일이 눌린 게 아니라 path 자체가 얇아지는 것이라, 상태 이름만 넘겨서는 AOD가 또렷이 뜬 눈을 그렸다. 지금은 씬이 현재 path의 높이를 idle 대비 비율로 재서 개폐도 값 자체를 AOD로 넘긴다. 임계 밑이면 AOD도 그만큼 눈을 감고 두리번을 멈춘다.

같은 이유로 씬에만 있는 것들도 따로 그려야 했다. 버블 악세는 배경 이미터라 정적 렌더에 아예 안 뜨고, 동물 귀는 씬이 페어 이미지를 버리고 좌·우 조각을 눈 그룹 기준으로 다시 배치하기 때문에 통이미지를 얹으면 벌어짐과 기울기가 통째로 빠진다. 버블은 Canvas로 멈춘 방울을 깔고, 귀는 씬과 같은 배치 공식을 쓰는 공용 레이어를 만들어 AOD와 보관함·상점 미리보기가 함께 쓰게 했다. 배치 상수는 EarLayout 한 곳에서만 온다. 그 전엔 씬·AOD·미리보기가 각자 숫자를 들고 있어서 미리보기만 어긋났고, 페어 썸네일 비율로 귀 모양을 짐작하던 탓에 20~32% 납작하게 그려지고 있었다. 지금은 조각 에셋에서 원본 비율을 직접 읽는다.

셰이더는 AOD에서 얼어붙는다. SKEffectNode 셰이더가 freeze되니, AOD에선 애니메이션 요소(치지직·버스트·거품 상승·글리치 슬라이스)를 의도적으로 빼고 정지된 눈 상태만 SwiftUI Canvas로 재현한다. 통신장애는 정지 컬러바, 픽셀은 양자화한 그리드, 산데비스탄은 정지된 색분리 윤곽. 살아있는 렌즈가 AOD에선 스틸 컷이 된다.

픽셀 렌즈는 아예 SKEffectNode를 버렸다. 셰이더로 픽셀화하면 기기와 시뮬레이터의 렌더가 달라지는 텍스처 이슈가 있었다. 그래서 MatrixEyeNode가 눈 안에 셀 블록(SKSpriteNode)을 직접 깔아 픽셀 그리드를 그린다. 셀 중심이 눈 path 안에 드는 칸만 채우고, 세로 그라데이션에 4단계 포스터라이즈를 건다. 우회했더니 오히려 깜빡임·화내기·지오메트리가 벡터 눈을 그대로 따라온다.

셀 크기는 표정을 따라간다. 처음엔 셀이 고정 크기라 만족(겹선)처럼 세로로 얇은 표정이 픽셀 렌즈에서 뭉개졌다. 지금은 path의 짧은 변이 일정 크기 아래면 셀을 줄이고, 얇은 선을 집으려고 큰 셀에서만 쓰던 멀티샘플을 꺼서 선 사이 간격을 보존한다.

Decision Log

Decision #01눈은 이미지가 아니라 CGPath 벡터로 그린다

정보 위젯이라면 상태별 이미지로 충분하다. 하지만 "살아 그려지는 존재"는 표정이 그림 사이를 점프하면 안 됐다. SKShapeNode + CGPath로 가니, 표정 전환은 path 교체 + 크로스페이드가 되고, 시선(position)·깜빡임(scaleY)·표정(path)이 서로 다른 축이라 겹쳐도 안 싸운다. 미묘한 곡선은 눈대중 대신 에디터에서 좌표를 뽑아 스플라인으로 박았다.

Decision #02유기적 장면은 실사영상, 기하적 효과는 셰이더

셰이더로 해파리의 흐느적거림을 흉내 내면 어색하고, 실사로 컬러바를 찍는 건 낭비다. 렌즈를 성질로 갈랐다 — 유기적이면 MP4를 스프라이트 시트로 굽고(파이프라인 자동화), 양식화됐으면 GLSL 셰이더로 절차적으로 그린다. 성질이 뭐든 같은 눈 path를 마스크로 물려, 렌즈가 낀 채로도 표정을 자동 추종하게 했다.

Decision #03AOD는 SpriteKit을 멈추고 SwiftUI로 다시 그린다

watchOS AOD는 SpriteKit 씬을 freeze시킨다. 우회가 아니라 별도 렌더 경로가 필요했다. isLuminanceReduced에 SpriteKit을 일시정지(상태 보존)하고, TimelineView가 ~1Hz로 SwiftUI 눈을 재드로우한다. 대신 같은 셰이프를 공유하고, 저주사율에서 끊기지 않게 깜빡임·시선을 시간의 연속 함수로 계산했다. 셰이더 렌즈는 정지 컷으로만 재현한다.

Decision #04렌더가 어긋나는 효과는 SKEffectNode를 버린다

픽셀 렌즈를 SKEffectNode 셰이더로 하니 기기와 시뮬레이터 렌더가 달랐다. 셰이더를 고집하는 대신, MatrixEyeNode가 눈 안에 셀 블록을 직접 까는 쪽으로 바꿨다. 우회가 손해가 아니라, 오히려 깜빡임·화내기·지오메트리를 벡터 눈이 그대로 끌고 가는 이득이 됐다. 도구가 문제를 만들면 도구를 바꾼다.

Decision #05같은 것을 그리는 좌표는 한 곳에서만 온다

같은 악세를 씬과 AOD와 미리보기 세 곳이 그리는데, 배치 숫자를 각자 들고 있었다. 그러니 미리보기만 조용히 어긋났고, 어긋난 걸 발견해도 어느 숫자가 원본인지 알 수 없었다. 배치 상수를 EarLayout 하나로 모으고 세 곳이 거기서 읽게 했다. 조각 비율도 짐작 대신 에셋에서 직접 읽고, 이름이 어긋나면 조용한 근사 대신 개발 빌드에서 터지게 했다.

배운 것

실시간 렌더링은 "그림을 잘 그리기"가 아니라 매 프레임 무엇을 계산할지 고르는 일이었다. 눈을 벡터로 정의하니 표정·시선·깜빡임이 각자의 축을 갖게 됐고, 그 위에 셰이더와 실사영상을 얹을 stencil이 공짜로 생겼다. 미리 그린 클립을 트는 대신 절차적으로 계산하니, 같은 코드가 무한히 반복 없는 결을 만들었다.

그리고 제약이 설계를 끌고 갔다. 워치가 아니었다면 AOD 재구현도, 저주사율용 연속 함수도, SKEffectNode를 버린 픽셀 직접 렌더도 없었을 것이다. 정직한 트레이드오프도 남았다 — 실사 렌즈는 30프레임 저프레임레이트라 핑퐁으로 이음새를 감춰야 하고, 셰이더 렌즈는 AOD에서 스틸 컷으로 죽고, 눈을 두 번(SpriteKit·SwiftUI) 구현한 만큼 정합성을 손으로 맞춰야 한다. 그래도 이 축 — 벡터 실시간 렌더, 셰이더, 영상 파이프라인 — 은 LLM과 완전히 다른 근육이었고, 손목 위라는 제일 야박한 화면에서 굴려봤다는 게 남았다.