한 이미지를 다른 이미지로 바꾸는 가장 흔한 방법은 두 장을 겹쳐 놓고 투명도를 바꾸는 것입니다. 이 방식에서 화면의 색은 두 이미지를 섞은 중간값이고, 픽셀은 어디로도 이동하지 않습니다.
doroscar는 반대로 원본의 픽셀을 그대로 쓰는 접근입니다. 각 픽셀에 목표 이미지 위의 자리를 미리 배정해 두고, 재생하는 동안 픽셀들이 저마다 그 자리로 이동합니다. 변환이 끝나면 목표의 형태가 나타나지만, 화면을 채운 색은 전부 원본에서 온 것입니다.
색을 새로 만들지 않고, 픽셀의 자리만 바꿔서 다른 그림을 만들 수 있을까?
이미지 출처
프리셋 원본 이미지와 Golden Doro 목표 이미지의 출처는 에셋 크레딧에 정리되어 있습니다.
먼저 살펴보기
데모를 열면 프리셋 네 개(blackhole·cat·colorful·surgeonfish) 중 무작위로 고른 것이 바로 재생됩니다. 어느 프리셋을 골라도 도착하는 그림은 같은 Golden Doro 트로피이며 달라지는 것은 색뿐입니다. SPECIMEN으로 원본을 바꾸고 REVERSE를 켜면 트로피에서 원본으로 되돌아갑니다. SAVE GIF는 지금 방향의 움직임을 GIF로 저장합니다. TRANSFORM YOUR IMAGE로 올린 이미지는 서버로 보내지 않고 브라우저 안에서만 처리해요.
본문 세 절은 아래 세 장면을 차례로 설명합니다.
- 원본에서만 오는 색 — 목표 이미지에 없던 색이 변환이 끝난 트로피에 그대로 남음
- 먼저 잡히는 피사체 — 트로피가 먼저 형태를 갖추고, 배경은 더 오래 움직임
- 빈틈없는 캔버스 — 픽셀이 흩어지는 중에도 화면 전체가 색으로 채워져 있음
원본 색으로 목표를 만드는 배정
업로드한 이미지는 가운데를 정사각형으로 자르고 128×128로 줄입니다. 배정에 참여하는 픽셀은 이렇게 만든 16,384개가 전부이고, 이후 어느 단계에서도 색을 보간하거나 새 픽셀을 만들지 않습니다.
목표 이미지도 같은 크기의 그리드입니다. 각 칸에 원본 픽셀이 정확히 하나씩 들어가야 하므로 배정 결과는 16,384개 항목을 가진 순열입니다. 어떤 픽셀도 두 번 쓰이지 않고 버려지는 픽셀도 없습니다. 목표에 없던 색이 변환 뒤에도 남아 있는 첫 번째 장면은 이 성질의 결과입니다.

어느 픽셀을 어느 칸에 보낼지는 색만으로 정하지 않습니다. 색이 맞는 칸으로만 보내면 픽셀이 화면 반대편까지 이동해 과정이 뒤엉키고, 가까운 칸만 고르면 원본 구도가 그대로 남아 목표가 드러나지 않습니다. 그래서 배정 비용은 색과 거리를 함께 셉니다.
배정 비용 = 색상 거리² × 목표 가중치 + (위치 거리² × 근접 가중치)²최적화는 교환의 반복입니다. 두 픽셀의 목적지를 맞바꿔 본 뒤 전체 비용이 줄어드는 교환만 받아들입니다. 한 세대에는 픽셀 수의 32배만큼 교환을 시도합니다. 짝을 고르는 탐색 거리는 세대마다 3%씩 줄입니다. 화면 전체에서 큰 구도를 먼저 맞추고 뒤로 갈수록 가까운 칸을 다듬는 순서입니다.
식에 들어 있는 목표 가중치는 트로피의 안팎을 다르게 취급하는 맵입니다. 트로피 안쪽은 색 일치를 중요하게 재고, 바깥 배경은 위치 제약을 풀어 원본의 배경이 제자리에 그대로 남지 않게 합니다. 같은 목표를 쓰는데도 프리셋마다 전혀 다른 색의 트로피가 나오는 이유가 이 맵입니다.
힘으로 움직이는 픽셀
배정이 끝나면 픽셀마다 출발점과 도착점이 정해집니다. 둘 사이를 선형 보간으로 움직이는 것이 가장 간단하지만, 그러면 모든 픽셀이 같은 박자로 출발해 같은 박자로 도착하므로 픽셀 하나하나가 이동한다는 인상이 사라집니다.
그래서 doroscar는 픽셀을 입자로 두고 매 프레임 네 가지 힘을 더합니다.
- 목적지 끌림 — 시간이 지날수록 도착점을 향해 세지는 힘
- 분리 — 가까워진 입자끼리 겹치지 않게 밀어내는 힘
- 정렬 — 이동 중인 이웃의 속도를 따라가게 하는 힘
- 경계 반발 — 캔버스 가장자리에 닿은 입자를 안쪽으로 되미는 힘
끌림의 세기는 배정에서 쓴 가중치 맵을 다시 씁니다. 트로피 안쪽에 도착할 픽셀은 최대 0.22, 가장 바깥 배경은 0.15라서, 트로피가 먼저 자리를 잡고 배경은 더 오래 흐릅니다. 도착점에서 한 칸 남짓 안으로 들어온 입자는 감쇠를 키워 그 자리에 멈춥니다. 화면에서는 이 차이가 트로피가 먼저 자리를 잡는 두 번째 장면으로 보여요.
REVERSE는 배정을 다시 계산하지 않습니다. 입자마다 출발점과 도착점을 맞바꾸기만 하면 같은 순열이 반대 방향 재생으로 바뀝니다.
이 방식의 비용도 있습니다. 선형 보간보다 계산이 많고, 힘을 키우면 빨리 끝나는 대신 입자마다 다른 흐름이 사라지며, 줄이면 재생이 끝날 때까지 일부 픽셀이 정착하지 못합니다. 지금 값은 트로피가 먼저 읽히면서 배경의 흐름도 남는 지점에 맞춘 결과예요.
점 사이를 채우는 렌더링
시뮬레이션이 렌더러에 넘기는 것은 픽셀 16,384개의 색과 현재 좌표뿐입니다. 이 점들을 그대로 찍으면 모인 곳에서는 겹치고 벌어진 곳에서는 바탕이 드러납니다. 계산 그리드는 128×128인데 보여주는 캔버스는 768×768이라서 간격은 더 벌어집니다.
빈 곳은 Jump Flood Algorithm(JFA)으로 채웁니다. 각 점을 먼저 고유 번호로 텍스처에 찍고, JFA 패스가 화면의 모든 칸에 가장 가까운 점의 번호를 퍼뜨립니다. 마지막 패스가 그 번호로 원본 색을 찾아 칠하면, 점마다 자기 주변 영역을 맡는 보로노이 분할이 완성됩니다. 입자가 아무리 흩어져도 캔버스가 채워져 있는 세 번째 장면을 만드는 것이 이 분할이에요.
이 패스들은 전부 WGSL 셰이더이고, wgpu의 WebGL2 호환 경로에서 실행됩니다. Rust 시뮬레이션이 매 프레임 갱신하는 것은 좌표 텍스처뿐입니다.
업로드한 이미지의 배정 계산
내장 프리셋은 배정 결과를 미리 계산해 담아 두었기 때문에 열자마자 재생됩니다. 반면 사용자가 올린 이미지는 16,384개 픽셀의 배정을 그 자리에서 새로 찾아야 합니다. 이 계산을 메인 스레드에서 돌리면 끝날 때까지 버튼도 애니메이션도 함께 멈춥니다.
그래서 업로드 처리는 같은 WASM 모듈을 별도로 초기화한 전용 웹 워커가 맡습니다. 워커가 배정을 계산하는 동안 메인 스레드는 재생 중인 화면을 계속 그립니다. 계산이 끝나면 완성된 순열만 넘겨받아 새 프리셋으로 추가합니다. 취소 버튼은 워커 자체를 종료해 긴 계산을 끊습니다. 이렇게 추가된 프리셋은 localStorage에 저장되어 다음 방문에도 남아 있어요.
이 방식의 한계
이 구조가 모든 이미지 변환에 맞지는 않습니다.
- 세부 소실 — 배정 전에 128×128로 줄이므로 머리카락 같은 가는 디테일은 남지 않음
- 고정된 목표 — 목표 이미지와 가중치 맵이 Golden Doro 한 쌍으로 고정되어 있어, 임의의 두 이미지 사이 변환은 지원하지 않음
- 로컬 저장 — 사용자 프리셋은 브라우저
localStorage에만 남고 기기 간 동기화는 없음 - GIF 해상도 — 브라우저 안에서 400×400으로 인코딩하므로 고해상도 내보내기 용도는 아님
마치며
처음 질문의 답은 데모가 보여 줍니다. 색을 만드는 모델도 서버도 없이, 픽셀 16,384개의 자리 배정과 이동만으로 어떤 이미지든 Golden Doro로 바뀝니다. 그런데도 재생이 끝난 트로피의 색은 전부 원본에서 온 거예요.
가장 오래 본 프리셋은 colorful이었어요. 피사체가 없는 색 견본에서도 트로피가 잡히는 것을 보면, 이 변환에서 형태는 전적으로 배정이 만든다는 사실이 드러납니다. 다음에는 Golden Doro 바깥의 목표 이미지로도 같은 변환을 만들어 보고 싶어요.
참고 자료
- doroscar — 글에서 불러오는 실제 데모
- Rong & Tan, "Jump Flooding in GPU with Applications to Voronoi Diagram and Distance Transform", I3D 2006 — 렌더러가 쓰는 JFA의 원 논문
