본문 바로가기

Function

OD 시각화 5 : deck.gl.TripsLayer 의 커스터마이징

TripsLayer란?

 

deck.gl의 TripsLayer를 이용하면 아래와 같은 표현을 할 수 있다.

 

물론 경로와 시각(timestamp) 정보가 들어간 데이터가 미리 준비되어야 한다. 이를테면 아래와 같은 데이터다.

 

const data = {
	coords : [[0,0], [0,1], [0,2], [0,3], [1,3], [2,3], [3,3]],
    timestamp : [0,  0.1,    0.2,   0.3,   0.4,   0.5,  0.6]]
};

 

위의 데이터를 TripsLayer에 잘 넣고 시간을 0부터 0.6까지 변화시켜 렌더링해본다면, 원점에서 [0,3]까지 수직으로 이동하다가 꺾어서 오른쪽으로 [3,3]까지 이동하게 된다.

 

머리가 밝고 꼬리로 갈수록 어두워지는 TripsLayer의 표현은 셰이더에서 이루어진다. 

병렬 방식으로 처리되는 vertex shader는 각각의 선분(정확히 말하자면 삼각형) 정점들에 시각 정보를 결합시켜 fragment shader로 보낸다. fragment shader 에서는 화면에 1:1로 대응하는 각 픽셀의 색상을 결정하게 되는데, 특정 픽셀에 부여된 시각값을 별도로 입력받은 uniform 변수인 현재 시각값과 비교하여 픽셀의 투명도를 결정하게 된다.

 

 

픽셀이 들고 있는 시각값이 현재 시각보다 크면 그리기를 취소(discard)한다. 머리와 꼬리로 개체를 그릴 때 이 픽셀은 아직 개체가 도달하지 않은 지점이다.

픽셀의 시각 값이 현재 시각값과 일치하거나 약간 작으면 불투명도를 높인다. 진하게 나온다. 머리에 해당하는 부분이다.

픽셀 값이 현재 시각보다 많이 작으면(trailLength로 설정한 값과 관련됨) 투명하게 그린다. 이미 지나간지 좀 된 부분이다. 꼬리에 해당한다.

셰이더의 특성상 정점과 정점 사이의 픽셀 값들은 fragment shader로 넘어갈 때 선형 보간되어 전달된다. 그래서 자연스러운 그라데이션이 그려지게 된다.

 

 

하나의 Path에 여러개의 개체를 그리고 싶을 때

 

 

 

여기서 고민이 생겼다.

TripsLayer의 특성상 하나의 path 위에는 한 개의 개체밖에 그리지 못한다. 그런데 한 path 선 상에 여러개의 개체들이 움직이도록 하고 싶으면 어떻게 할까?

 

가장 쉬운 방법은 TripsLayer 두 벌을 겹쳐 그리는 방법이다. 그런데 사실 이것도 그리 간단하지 않다.

한 개체의 정점 배열에 0부터 1까지 넣고, 다른 개체는 0.5부터 1.5까지 넣는다.

그리고 시각을 0부터 1.5까지 진행시키면  첫 번째 개체가 경로의 절반쯤 진행되었을 때 두 번째 개체가 등장하게 된다.

 

여기서 또 고민이 생긴다.

그러면 하나의 path에 마치 이동 무늬 패턴처럼 10개의 개체가 움직이도록 하려면?

위처럼 시각을 다르게 넣어서 TripsLayer를 열 번 겹쳐 그릴까?

사실 이 방법은 굉장히 비효율적이다.

이 때는 TripsLayer를 수정해야 한다. 즉, 커스텀 레이어를 만들어서 셰이더를 수정하고, 데이터에 timestamp 도 약간 다른 방식으로 넣어야 한다.

 

 

Deck.gl 에서 커스텀 레이어 작성

 

deck.gl은 scatterplot-layer, column-layer 같은 기본 레이어 클래스와 mvt-layer 처럼 여러 레이어 클래스를 상속하여 만든 레이어 클래스로 구성된다.

trips-layer의 경우 path-layer를 상속해서 만들어졌는데, 단순하게 만들어진 편이다. 라이브러리 내부의 다양한 파일들을 참조하지 않고 공개된 모듈들만 참조해서 만들어졌다. 여기서는 trips-layer를 커스터마이징 하려고 하는데, 이 TripsLayer를 상속하지 않고, tirps-layer 전체를 복사해와서 약간을 수정하려고 한다. 즉, PathLayer를 상속해서 TripsLayer를 다시 쓴다고 보면 된다.

 

trips-layer는 node_modules 폴더의 @deck.gl/geo-layers/src/trips-layer/ 밑에 있다. trips-layer.ts 파일 하나로 이루어졌다.

참고로 살펴볼 path-layer는 @deck.gl/layers/src/path-layer 폴더 밑에 있다. glsl.ts 확장자로 이름붙은 셰이더 두 개만 참고하면 된다.

 

trips-layer.ts를 열어보면 아래와 같다.

 

꼬리 형태의 개체 표현을 위한 uniform 변수들을 받기 위해서 레이어 정의에 fadeTrail, trilaLength, currentTime(현재 시각), getTimestamps(vertex와 1:1로 매칭되는, 시각 정보가 새겨진 데이터 배열)과 같은 변수들이 추가된다.

 

그리고 아래에 조금 잘린 부분을 보면 getShaders() 로 기존 셰이더를 패치하는 방식을 사용하는데, 이와 같은 방식은 deck.gl의 공식 문서에서 설명하고 있는 커스텀 레이어의 일반적 작성 부분이다.

vs#decl, 혹은 fs:DECKGL_FILTER_COLOR 와 같은 항목을 선언하고 내용을 쓰면 미리 약속된 부분에 셰이더 코드가 추가 주입(shaders.inject) 된다. 아예 새로 쓸 수도 있고  getShader 한 뒤에 replace처럼 문자열을 대체해서 셰이더를 수정 패치할 수도 있다.

 

 

본격 수정하기 1 - 패턴 개수 조정

 

우선 trips-layer.ts 내용 전체를 복사해서 자신의 작업 폴더에 새로운 파일을 만든다.

TripsLayerCustom.ts 정도로 해주자.

 

문자열 찾기-바꾸기를 이용해서 코드 내용 중 TripsLayer 를 TripsLayerCustom으로 바꿔준다.(파일명과 일치시킨다)

아래와 같다.

 

사용하는 방법은 똑같다.

파일이 생성된 경로만 참고해서 import 한 후 기존의 TripsLayer와 똑같이 다루면 된다. 아직 아무것도 바꾸지 않은 채 그대로 복사만 해왔기 때문이다. 

import TripsLayerCustom from "@/lib/TripsLayerCustom";

 

 

TripsLayer와 똑같이 작동하는지 확인해보자.

 

데이터는 아래와 같은 과정으로 넣었다.

for (const d of refreshedArray) {
        const length = Math.sqrt(
          Math.pow(d.desPos[0] - d.oriPos[0], 2) +
            Math.pow(d.desPos[1] - d.oriPos[1], 2)
        );

        const beginTime = 0;
        const endTime = 1;

        const normal = [
          (d.desPos[0] - d.oriPos[0]) / length,
          (d.desPos[1] - d.oriPos[1]) / length,
        ];
        const normal_t = [normal[1], -normal[0]];

        const path: number[][] = [];
        const timestamps: number[] = [];
        for (let i = 0; i <= division; i++) {
          let t = i / division;

          path.push([
            d.oriPos[0] +
              normal[0] * length * t +
              normal_t[0] * length * diffLR * Math.sin(t * Math.PI),
            d.oriPos[1] +
              normal[1] * length * t +
              normal_t[1] * length * diffLR * Math.sin(t * Math.PI),
          ]);

          let time = beginTime + (endTime - beginTime) * t;

          timestamps.push(time);
        }

        d.path = path;
        d.timestamps = timestamps;
      }

 

약간 휘어가도록 하기 위해서 path.push 부분이 약간 복잡해졌다. ori에서 des를 향하는 normal 벡터를 만들고 90도 회전시킨 normal_t 벡터를 만들어 진행도에 따라 직선 경로에서 sin함수값만큼 떨어지도록 했다.

거리는 위경도임에도 불구하고 단순히 대각선 길이로 구했는데, 그렇게 그려도 보이는데 특별히 문제가 없어서 그렇게 했다. 좀 더 영역이 넓거나 정밀한 계산이 필요할 때는 haversine 공식이나 좌표계 변환을 통해 구해야 한다.

 

timestamp는 0부터 1까지 미리 설정된 division(=20, 전체를 얼마만큼 분절된 곡선으로 그릴 것인지)값으로 분할해서 균등 간격으로 들어가도록 했다.

 

이렇게 하고 시각을 0부터 1까지 변화시키면 아래와 같은 궤적이 그려진다.

밑에다가 같은 데이터로 선굵기 등이 비슷한 옵션의 PathLayer를 깔아서 전체 이동 경로를 표현한 뒤, TripsLayer를 형광색으로 덧붙였다.


위에서 보이는게 TripsLayer의 일반적인 작동 방식이다.

 

이제 여기에 두 개의 패턴이 움직이도록 해보자.

 

애니메이션을 위한 시각은 여전히 0부터 1까지 변화시킬텐데, 데이터의 timestamp에 0부터 2까지 넣고 셰이더에서는 소수점 이하 부분만 계산에 사용하도록 한다. 즉, 시각이 0.1일때 timestamp의 0.1과 1.1 두 부분 근처가 계산되어 두 개의 개체가 그려질 것이다.

 

timestamp만 0부터 2까지로 변화시키고 그려보면 아래처럼 그려진다.

위의 코드에서 아래 부분을 수정하면 된다.

const beginTime = 0;
const endTime = 2;

 

 

 

 

timestamp가 0~1로 기록된 부분만 반복되는 것을 확인할 수 있다.

 

 

이제 셰이더를 고쳐보자.

사실 방법은 매우 간단한데, 아래처럼 하면 된다.

 

 

우선 기존 코드에서 discard 즉, 진행 정도보다 timestamp가 큰 부분에 그리기 취소하는 부분을 주석처리하면 된다.

 

그러면 아래처럼 보인다.

취소되지 않은 앞부분이 그대로 그려진다.

 

두번째로는 시각 차이를 계산한 뒤 투명도를 계산할 때 fract를 사용하여 소수점 이하 값만 사용한다.

 

그러면 이제 위의 그림처럼 나온다. 패턴 2개가 자연스럽게 움직이는 것처럼 보인다.

만약 패턴 10개를 넣고 싶으면 데이터를 그렇게 넣으면 된다.

        const beginTime = 0;
        const endTime = 10;

 

10개의 패턴이 잘 움직인다.

 

 

 

본격 수정하기 2 - 화살표 머리처럼 만들기

 

기왕 셰이더를 건드린 김에 이제 좀 더 다른걸 해보자

다시 패턴 2개로 되돌린 후 셰이더를 좀 바꿔봤다.

 

offsetPos라는 변수를 추가했는데, color.a 즉 알파값을 계산할 때 시각 차이 계산에서 offsetPos를 추가로 빼서 선의 중심부터 바깥쪽의 픽셀에 따라 할당된 시각이 조금씩 달라지도록 했다.

 

offsetPos는 geometry.uv.x 값을 20으로 나누었는데, 20을 바꾸면 화살표가 튀어나온 정도가 달라진다. geometry.uv.x는 선의 중심부가 0, 가장자리로 갈수록 -1과 1에 가까워지는 값을 지니게 되어 있다. tripslayer 셰이더에서 그렇게 계산되도록 미리 설정되어 있다는 뜻이다. 이는 셰이더에 대한 기본을 공부하고 tripslayer의 셰이더를 살펴보면 어렵지 않게 이해할 수 있다.

 

이제 아래처럼 보인다.

 

 

 

 

본격 수정하기 3 - 확대축소에 관계없이 일정한 형상 유지시키기

 

그런데 좀 찜찜한 부분이 있다. 아래 그림을 보자.

 

 

현재 이 tripslayer의 굵기는 "pixels" 기준으로 설정되어 있는데, 그러다보니 줌의 확대 축소에 따라 형태가 좀 달라진다.

우선 꼬리의 길이가 달라지고, 화살표가 꺾인 각도도 달라진다. 그리기 단위를 "meters"가 아니라 "pixels" 기준으로 설정할 경우, 전체 path 형상의 길이와 굵기 비율이 달라지기 때문에 화살표 모양이 변하는 것처럼 보인다. 꼬리의 길이, 즉 옵션값의 trailLength는 전체 길이를 1로 설정한 상대적 길이이기 때문이다. 화살표 머리 역시 마찬가지 이유에서 변형된다.

 

이 부분의 해결은 아까보다는 좀 까다롭지만 비교적 간단하게 해결된다.

 

우선 fragment shader에서 최종적으로 그려지는 선의 width 정보를 알고 있어야 하는데, 현재의 구성으로는 vertex shader에서 해당 정보가 넘어오지 않는다.

 

그러면 드나드는 변수를 좀 수정해보자.

 

우선 수정한 결과를 보면 아래와 같다.

 

vertex shder에서 widthMultiplier라는 이름의 변수를 만들어 fragment shader로 보낸다. 각각 out과 in 부분이 추가되었다.

그리고 이 변수에는 width.x 값을 넣어 보낸다. 

 

path layer의 해당 부분을 보면 아래와 같다.

 

 

width 에 연결된 함수들을 모두 다 알 수는 없지만, width.x 에는 최종적으로 그려지는 pixel 폭이 들어감을 확인할 수 있다.

 

다시 앞의 셰이더를 보면 아래 부분을 추가로 수정했음을 확인할 수 있다.

float offsetPos = abs(geometry.uv.x)/35.0 * (widthMultiplier * 50.0);

 

선의 중심과 가장자리값의 차이를 계산하는 부분에 이 폭에 50을 곱한 값을 다시 곱해주고 있다.

즉 실제 픽셀 두께값으로 중심과 가장자리 차이를 보정함으로써 아래처럼 확대 축소해도 일정한 만큼 화살표가 꺾이도록 했다.

50은 앞 부분의 35와 함께 적절한 꺾임 정도를 만들어주는 계수에 해당한다.

 

위의 그림에서는 아직 확대하면 꼬리 길이는 변한다.

color.a *= 1.0 - fract(currentTime - (offsetPos + vTime)) / (trailLength);// * widthMultiplier * 30.0);

 

이제 셰이더 코드에서 주석 부분을 해제하면 꼬리 길이도 일정하게 유지되는 것을 확인할 수 있다.

 

 

 

이로써 원하는 내용들을 모두 반영했다. 꼬리 길이는 경우/취향에 따라 그냥 변화하도록 둘 수도 있겠다.

 

 

 

 

추가 수정하기 - 서로 다른 길이 경로의 변화 속도를 비슷하게 만들기

 

이제 필요한 부분은 모두 수정했는데, 아래 그림을 한번 보자.

 

 

 

궤적의 속도가 전체 길이를 1로 둔 상대적 속도이다보니 길이가 긴 경로에서는 빨리 움직이고 짧은 경로에서는 천천히 움직인다. 축소 상태에서는 크게 거슬리지 않는데, 확대할 경우에는 좀 신경이 쓰인다.

 

그럼 속도를 비슷하게 맞춰주려면 어떻게 하면 될까?

 

이번에는 셰이더가 아니라 데이터다.

애초에 timestamp를 넣을 때 길이에 비례하도록 넣으면 된다. 예를들어 길이가 길때는 0에서 5까지 넣고, 짧을 때는 0에서 2까지 넣는다. 그러면 같은 정도의 속도로 맞출 수 있다.

 

const length = Math.sqrt(
          Math.pow(d.desPos[0] - d.oriPos[0], 2) +
            Math.pow(d.desPos[1] - d.oriPos[1], 2)
        );

const beginTime = Math.random();
const endTime = beginTime + Math.max(length * 2.3, 1.0);

 

전체 길이를 대략 계산해서 endTime에 반영했다. 아무리 짧아도 전체 경로상 하나는 보이도록 최소값이 1보다 작아지지는 않도록 했다.

beginTime은 0~1사이를 랜덤으로 넣었는데, 그렇게 하면 서로 다른 경로의 궤적들이 좀 더 자연스럽게 발생하는 것처럼 보인다. random 대신 0을 넣으면 모든 경로의 궤적이 동시에 출발하기 때문에 좀 어색하다.

 

그래서 다시 그려보면 아래와 같다.

 

 

이제 여러가지로 자연스러워졌다. 이동 속도가 비슷해보인다.

 

끝.