방문자와 함께 하나의 풍경이 되는 지구본, Map

26. 10. 06. (3주 전)

TL;DR: Map은 방문자의 추정 위치와 서울을 연결하는 인터랙티브 지구본이에요. 입자의 착지, 윤곽선 전환, 근거리 표현에서 생긴 문제와 이를 해결한 과정을 담았어요.

샌프란시스코와 서울을 연결한 모습이에요. 조립과 연결이 끝나면 직접 돌려볼 수 있어요.

작은 점에서 지구본의 모습을 떠올렸어요

조르주 쇠라의 《그랑드자트섬의 일요일 오후》를 생각하면 작은 색점들이 먼저 떠올라요. 가까이에서는 서로 다른 점인데, 한 걸음 물러나면 사람과 나무, 빛과 그늘이 하나의 풍경으로 보여요. 저는 평소에도 그 모습이 참 인상 깊었어요.

《그랑드자트섬의 일요일 오후》 전체 풍경

멀리서 보기 · 전체 풍경

고해상도 원본의 부분 이미지. 양산 아래 두 인물과 잔디를 이루는 노랑, 초록, 파랑, 붉은색 점과 붓 자국

가까이서 보기 · 색점과 붓 자국

시카고 미술관 · Google Art Project · 퍼블릭 도메인

이런 색점들로 지구본을 만들고, 그 위에 저와 방문자의 위치를 함께 놓아보고 싶었어요. 제 홈페이지에는 이런 인사가 있어요.

서울에서 만들고, 다듬고 있습니다.

지구본을 통해 방문자와 제가 얼마나 멀리 떨어져 있는지 체감하고, 시차도 알아볼 수 있으면 재미있겠다고 생각했어요.

처음 구상은 방문자의 위치에서 화면을 천천히 축소해 서울을 찾고, 두 지점을 선으로 잇는 것이었어요. 여기에 입자가 모이는 장면과 입체적인 경로를 더하면서, 무엇을 먼저 보여주고 어떻게 이어갈지 하나씩 조정했어요.

인사부터 연결까지 보여줄 순서를 정했어요

입자가 모이고, 지구본이 회전하고, 선이 뻗으면서 방문자와 서울 사이의 거리가 표시돼요. 모든 동작이 한꺼번에 시작하면 방문자는 어디를 먼저 봐야 할지 알기 어려워요. 아직 두 위치 표시가 누구를 뜻하는지도 모르니까요.

먼저 방문자가 제목을 읽을 시간을 두었어요. 이어서 입자가 모여 지구본을 이루고, 카메라는 방문자와 서울을 함께 보여주는 구도로 이동해요. 두 위치를 연결한 뒤 거리와 시차를 표시해요.

홈페이지에 넣어보니 조립 시간도 조정해야 했어요. 조립에 3초를 쓴 버전에서는 마지막 입자의 착지를 기다리는 시간이 답답하게 느껴졌어요. 조립 시간을 1.8초로 줄이고, 크기 변화와 회전, 입자가 합류하는 시점의 비율은 유지했어요.

위치와 렌더러가 준비되면 연출을 시작해요. 제목 등장부터 마지막 시차 표시까지는 5.8초가 걸려요.

구간화면에서 일어나는 일
0–1초제목으로 먼저 인사해요
1–2.8초지구본이 커지고 회전하는 동안 입자가 내려앉아요
2.8–3초완성된 지도 위에 방문자 위치를 드러내요
3–4.4초방문자와 서울을 함께 볼 수 있는 구도로 이동해요
4.4–5.2초연결선과 거리 숫자가 함께 진행돼요
5.2–5.8초시차가 있으면 숫자로, 같으면 문장으로 알려줘요

동작마다 별도 타이머를 쓰면 일시정지 후 재개할 때 서로의 진행 시점을 맞추기 어려워져요. 그래서 하나의 경과 시간을 기준으로 입자의 조립 진행률, 카메라 위치, 윤곽선의 불투명도(opacity)를 계산했어요. 특정 시점의 상태를 다시 계산하면 마지막 입자가 착지할 때 윤곽선이 얼마나 남는지 확인할 수 있어요.

하나의 시간축에서 상태 계산하기

progress는 지정한 시간 구간을 0에서 1 사이의 진행률로 바꿔요. 조립을 포함한 모든 구간은 같은 경과 시간인 time을 기준으로 계산해요.

const progress = (time: number, from: number, to: number) =>
  Math.min(1, Math.max(0, (time - from) / (to - from)));
 
const formation = progress(time, 1000, 2800);

입자와 윤곽선이 자연스럽게 이어지도록 했어요

조립 장면에서는 입자가 착지할 때 모양과 색이 갑자기 바뀌지 않도록 다듬었어요. 윤곽선이 사라지는 시점도 마지막 착지에 맞췄어요.

입자마다 착지 시점을 달리했어요

조립 연출을 다듬는 중에는 빈 지구본을 먼저 회전시키거나, 대륙의 윤곽을 그려가는 단계를 두기도 했어요. 그런데 빈 구체를 보고 기다리는 시간과 지도가 만들어지는 시간이 분리돼 있었어요.

그래서 빈 지구본의 회전 구간과 경선을 걷어냈어요. 지구본이 커지는 동안 입자가 함께 내려오도록 했어요. 크기 변화는 먼저 잦아들고, 회전과 마지막 착지는 조금 더 이어지게 했어요. 착지한 입자 옆에는 아직 떠 있는 입자가 남아요.

날아다니던 입자가 그대로 지도를 이뤄요. 각 입자가 처음부터 도착 좌표를 갖고, 그 좌표에서 잠시 벗어나 있다가 돌아와요. 별도의 효과가 끝난 뒤 완성된 지도로 바뀌는 느낌을 피하고 싶었어요.

모든 입자에 같은 진행률을 적용하면 한 덩어리가 동시에 내려앉는 느낌이 강해져요. 입자마다 난수 생성의 기준값인 시드(seed)를 고정했어요. 이 값을 바탕으로 출발 높이와 지연 시간, 흩어진 각도, 착지 시점을 다르게 정했어요. 다시 재생해도 같은 입자는 같은 움직임을 유지해요.

대서양을 향해 회전하며 입자가 대륙 표면에 내려앉는 실제 Map 화면. 이미 착지한 입자와 위쪽에 남아 있는 입자가 함께 보여요.
입자마다 착지 시점이 달라요. 한 화면 안에 완성된 지도와 아직 내려오는 입자가 함께 남아요.
입자의 도착 좌표와 착지 진행률 계산하기

도착 좌표는 육지 영역을 구분한 로컬 이미지인 육지 마스크에서 만들어요. 구면에 입자를 고르게 분산시키고 육지에 해당하는 입자만 남겨요. 윤곽선도 같은 마스크에서 만들어 입자의 착지 위치와 대륙의 형태를 맞췄어요.

입자를 그리는 셰이더에서 전체 조립 진행률을 각 입자의 착지 진행률로 바꿔요.

float descent = clamp(
  (uFormation - delay) / (arrival - delay),
  0., 1.
);
float p = smoothstep(0., 1., descent * descent);
float q = pow(1. - p, .65);

p가 1에 가까워질수록 착지가 진행돼요. q가 0으로 줄어들면 도착 좌표와의 높이·각도 차이가 사라져요. 입자의 모양도 이 값에 연결했어요. 공중에서는 작고 반투명한 조각처럼 보이다가, 착지하면 둥근 점으로 바뀌어요.

착지 전후에도 같은 색을 유지했어요

작업 중에는 공중에 떠 있을 때와 착지한 뒤의 입자 색이 달랐어요. 착지 순간에 지도가 바뀌는 듯해 어색했어요. 위치가 부드럽게 이어져도 색이 갑자기 변하면 같은 입자를 따라보고 있다는 느낌이 끊겼어요.

색을 서서히 전환하는 방법도 고려했지만, 최종적으로는 각 입자가 자신의 색을 처음부터 끝까지 갖도록 했어요. 대륙마다 두 색을 골라 작은 구역 안에서 고르게 섞고, 다시 재생하거나 회전해도 배치를 유지했어요. 청자의 녹색과 단청의 붉은색, 숲과 금빛 같은 조합으로 이웃한 점의 색이 함께 보이도록 했어요.

마지막 착지와 윤곽선의 퇴장을 겹쳤어요

입자는 부드럽게 내려앉는데, 마지막에 대륙의 윤곽선만 갑자기 꺼지는 문제도 있었어요. 화면을 프레임 단위로 확인하고 각 시점의 불투명도를 계산했어요. 윤곽선은 조립이 끝나는 2.8초까지 불투명도 0.72를 유지한 뒤, 0.2초 동안 사라지고 있었어요.

불투명도의 변화 속도는 이징(easing) 곡선으로 조절했어요. 당시 사용한 ease-out 곡선은 초반 변화가 컸어요. 첫 약 33ms 안에 불투명도의 약 69%가 줄었어요.

윤곽선을 서서히 지우는 페이드 효과를 넣었지만, 변화가 처음 몇 프레임에 몰려 끊겨 보였어요. 그래서 페이드를 조립의 마지막 구간으로 옮겼어요. 2.08초부터 2.8초까지 윤곽선이 서서히 물러나면서 착지하는 입자가 지도를 이어받도록 했어요.

아래 코드를 한 부분씩 읽으며 시간과 불투명도 계산을 비교할 수 있어요. progress는 지정한 시간 구간을 0–1로 바꾸는 함수이고, reveal은 기존의 빠른 ease-out 곡선이에요.

CODE / WALKTHROUGH
01조립이 끝난 뒤 사라지기조립이 끝날 때까지 기다려요

윤곽선은 2.8초까지 남아 있어요. 입자가 모두 착지한 다음, 0.2초 동안 사라져요.

코드 1행
const fade = progress(time, 2800, 3000);
const coastOpacity = 0.72 * (1 - reveal(fade));

변경한 smoothstep은 시작과 끝의 변화 속도가 0이 돼요. 실제 구현에서는 앞쪽의 윤곽선 등장 값까지 곱하지만, 이미 등장한 뒤의 전환 구간에서는 위 코드와 같은 흐름이에요.

회귀 테스트에서는 1/60초, 약 16.7ms 간격으로 윤곽선의 불투명도를 계산해요. 불투명도가 계속 감소하고, 인접한 두 값의 차이가 0.03보다 작은지 확인해요.

이징 곡선을 바꾸고 페이드 시작을 앞당겨, 마지막 착지와 윤곽선이 사라지는 동작을 함께 다듬었어요.

거리에 맞춰 연결 방식을 바꿨어요

조립 장면의 카메라는 대서양 쪽을 바라봐요. 조립이 끝나면 방문자와 서울을 함께 보여주는 구도로 이동해요. 조립 장면은 일정하게 유지하고, 연결 장면은 방문자의 위치에 맞추기 위해서예요.

먼 곳은 아치로 연결했어요

연결선의 방향은 COBE V2 데모에서 출발했어요. 구면 위로 떠오른 아치처럼, 지구본을 돌려도 두 위치의 관계가 유지되는 선을 만들고 싶었어요.

두 좌표를 잇는 대원을 따라가며 가운데를 들어 올려 3D 경로를 만들었어요. 그런데 카메라가 경로의 평면을 정면으로 바라보면 아치가 납작해 보였어요. 두 끝점을 함께 볼 수 있는 범위에서 카메라를 살짝 기울이고, 작은 화면에서도 선과 표시가 잘리지 않도록 구도를 맞췄어요.

선의 끝에는 두 좌표 사이의 지표면 거리를 나타내는 숫자를 붙였어요. 선이 길어지는 동안 숫자도 올라가고, 서울에 다다르면 함께 멈춰요.

구면 경로를 아치로 들어 올리기

경로의 높이는 시작과 끝에서 0이고, 중간에서 가장 높아져요. 최종 구도를 정할 때는 경로의 중간 점까지 화면 안에 들어오는지 확인해요.

const surfacePoint = (progress: number) =>
  multiply(
    greatCircle(from, to, progress),
    1 + Math.sin(Math.PI * progress) * 0.32
  );

가까운 곳은 같은 반경으로 묶었어요

같은 표현을 서울 부근에 적용하자 문제가 드러났어요. 20km 테스트 화면인데도 두 표시가 일본과 중국을 잇는 것처럼 멀리 떨어져 보였어요. 표시된 거리에 비해 두 위치가 너무 멀리 떨어져 보였어요.

먼저 축소를 줄여봤지만 두 위치를 연결하는 움직임이 거의 보이지 않았어요. 다음에는 서울의 한 점에서 두 표시를 V자로 펼쳐봤어요. 두 표시가 같은 곳에서 갈라지는 모습은 표현했지만, 가까운 거리에서도 연결선이 필요한지 다시 고민했어요.

근거리 표현의 기준을 서울에서 50km 이내로 정했어요. 이 범위에서는 긴 연결선을 생략하고, 두 위치 표시를 하나의 반경 안에 놓았어요. “가까운 곳에서 만났네요”라는 문구도 함께 보여줘요.

서울에서 약 20km 떨어진 위치를 표시한 모습이에요. 두 위치 표시를 누르면 지구본 구도는 유지한 채 반경만 다시 재생해요.

위치와 시간 정보에 맞춰 문구를 골랐어요

IP 기반 추정 위치가 서울과 정확히 같으면 “같은 위치로 표시돼요”라고 써요. 실제 위치와는 다를 수 있기 때문이에요.

시차가 있으면 거리 표시 다음에 시차 숫자를 짧게 올려 보여줘요. 시차가 0이면 “같은 시간대에 머물고 있어요.”라는 문구를 바로 보여줘요. 시간대 정보만 없다면 거리는 유지하고 시차 행만 생략해요.

서머타임과 분 단위 시차 반영하기

UTC 오프셋은 협정 세계시(UTC)와 각 시간대의 시간 차이예요. 시차는 같은 시점의 서울과 방문자 시간대의 UTC 오프셋을 비교해 계산해요. 서머타임과 30분·45분 단위의 시간대도 반영할 수 있어요. 예를 들어 카트만두와 서울의 차이는 3시간 15분으로 표시해요.

연출이 끝나면 직접 돌려볼 수 있어요

도입 연출 중에 드래그를 허용하면 카메라 이동과 방문자의 회전 조작이 충돌할 수 있어요. 그래서 연출이 끝난 뒤 드래그를 허용했어요. 방문자는 지구본을 돌리기까지 기다려야 하지만, 그동안 페이지를 읽거나 스크롤할 수 있어요.

연출이 끝나면 “드래그해서 둘러보기” 안내를 보여줘요. 포인터를 놓으면 관성이 짧게 남다가 멈추고, 방향키로도 회전할 수 있어요. Home 키는 처음 연결을 보여주던 구도로 돌려줘요.

완성된 장면은 조립을 반복하지 않고, 연결선의 색만 12초 주기로 천천히 흘러요. 화면 밖으로 나가거나 탭이 비활성화되면 재생 시간을 멈추고, 돌아오면 그 지점에서 이어가요. 다른 페이지를 다녀와 홈이 다시 마운트되면 처음부터 인사하도록 했어요.

조건이 달라도 인사를 전하도록 했어요

모션을 줄여도 최종 정보는 남겼어요

모션 감소 설정을 켜면 입자 조립과 카메라 이동, 숫자가 올라가는 동작을 생략해요. 완성된 지도와 연결선, 최종 거리와 시차는 150ms 페이드로 보여줘요. 연결선의 색도 흐르지 않도록 고정해요.

연출 도중 설정을 바꿔도 같은 최종 상태로 정리해요. 다시 설정을 꺼도 끝난 연출을 재시작하지 않아요.

모션 감소를 처음부터 켠 경우와 중간에 켠 경우를 확인했어요. 설정을 다시 꺼도 완료된 장면을 유지해요.

이 글에 넣은 영상도 자동 재생하지 않고, 독자가 직접 재생하도록 했어요.

위치 조회나 WebGL이 실패할 때도 대응했어요

위치를 확인하지 못하면 임의의 좌표나 거리를 채우지 않고, 서울을 남긴 채 이렇게 인사해요.

방문자님의 위치를 확인하지 못했지만, 서울에서 반갑게 인사드려요.

WebGL을 사용할 수 없으면 CSS 지구본과 SVG 경로로 바꾸고, 위치 정보가 있을 때 거리와 시차를 보여줘요.

위치는 플랫폼이 요청에 붙여준 추정값을 사용해요. 브라우저에 정밀 위치 권한을 요청하지 않아요. API 응답에 원본 IP 주소를 담지 않고, 방문자별 응답은 캐시하지 않도록 했어요.

방문자가 들어와 풍경이 완성돼요

지금 우리는 약 9,029km 떨어져 있네요. 그래도 웹에서는 이렇게 빠르게 만날 수 있죠.

샌프란시스코에서 접속한 경우의 예시예요. 방문자의 위치에 따라 거리와 연결선의 모양, 안내 문구가 달라져요.

지구본이 만들어지고 두 위치가 연결되는 모습을 보며, 방문자가 잠깐이라도 재미를 느끼면 좋겠어요.


구현은 Map 소스 코드에서 볼 수 있어요. 시간 배치는 choreography.ts, 입자와 색은 particles.ts와 renderer.ts, 구면 경로와 카메라는 geometry.ts에 나뉘어 있어요. 본문의 코드는 판단에 필요한 부분만 발췌하거나 단순화했어요.