벡터 필드 형식의 데이터로 웹에서 해류를 표현해보자.
이 글에서 설명하는 우리나라 주변 해류 시각화는 한국해양과학기술원의 의뢰를 받아 작업한 결과물이다. 아래의 링크에서 서비스되고 있다.
2년 전에 OpenGL로 구현한 내용을 올린 적이 있다. 벡터필드의 기본적인 개념과 구현 방식에 대한 설명이 있다.
이 글은 그 내용의 웹 버전 구현에 해당한다.
차이점이 몇 가지 있는데, 우선 데이터를 읽는 형식이 다르다. 당시에는 로컬 PC 즉, C++ 기반으로 작업했기 때문에 1GB 정도의 데이터를 한번에 그대로 GPU로 보내서 그렸다. 반면 이번에는 웹에서 구현하는 것이 목표였는데, 접속하는 순간 1GB를 내려받은 후 그릴 수는 없었다. 그래서 서버에 미리 타일 형식의 벡터필드 데이터를 저장해놓고 화면 영역에 따라 소량의 데이터를 읽어 온 후 그리는 방식으로 변경했다. 인터넷 지도에서 이미지 형식 파일의 타일맵(WMTS : Web Map Tile Service)을 사용하듯 벡터필드 데이터를 저장해놓고 필요에 따라 불러오도록 했다.
두 번째로는 구체적인 구현 방식이 차이난다. WebGL에서는 OpenGL과 다르게 지오메트리 셰이더를 사용할 수 없으므로, 버텍스 셰이더에서 여러 지점의 데이터를 읽으면서 물결의 형상을 만들어야 했다. 몇 가지 어려운 부분이 있었는데 그건 글에서 차차 설명하기로 한다.
다른 구현방식과 차이점
이번에 구현한 방식의 특징은 다음과 같다.
1. 선 대신 다각형으로 표현
2. pan 조정시 화면의 끊김 없이 자연스럽게 이동
3. 인터넷 연결이 원활하다는 가정하에 zoom 조정시에도 끊김 최소화
4. 시간을 변화시킬때에도 자연스럽게 보간
5. 지도 기울임 등에 대응
위의 특징을 갖춘 웹 버젼 기상정보 시각화는 아직까지 다른 국내&해외 사이트에서 찾지 못했다. 단점이 있다면 전 세계 모델이 아니라 한반도 주변의 국지 모델이라는 점 정도다. (이건 주어진 데이터의 한계이니...)
우선 windy.com 에서 캡쳐한 아래의 바람 데이터 시각화를 보자.
windy.com은 점을 찍어 선을 만드는 방식이다. 아래 첨부한 그림처럼 점을 찍고 반투명한 흰 색으로 덮어 씌우고 다시 점을 찍는 과정을 반복하다보면 꼬리가 점점 흐려지는 선처럼 보이게 된다. 그런데 이런 방식이다보니 화면이 변경되면 즉, pan을 하는 경우에 제대로 대응하기 어렵다. 모두 지우고 다시 새로 그려야 한다. 시간을 진행시킬 때도 모두 지우고 다시 그리는 방식으로 구현되어 있다.
zoom.earth는 약간 응용한 방식 같다. 구체적으로 뜯어보지는 못했지만, 일단 표현 자체가 windy.com보다 세련되어 보인다. 배경 지도와 그라데이션 색상 모두 좀 더 신경을 썼다.
벡터 필드의 경우 GPU를 사용하는 것 같다.(점을 찍어서 표현하는 방식 같기도 한데 확실히 구분가지 않는다) 화면을 이동해도 궤적이 따라온다. 시간을 진행시키면 기존 궤적은 그대로 두고 새로 진행하는 부분만 변화된 벡터필드를 읽어서 꺾이기 때문에 좀 어색해보인다. 이 방식으로 유추해보건대, 궤적 진행 상태를 메모리에 저장해두고, 그 다음 진행할 부분만 새로 읽어서 결정하는 것 같다.
머리가 좀 굵고 꼬리가 가느다란 것 같은데, 만약 점을 찍어서 표현하는 방식이라면 점 자체의 형상을 머리와 꼬리를 구분해서 그린 것 같다.
아래에는 이번에 구현한 해류 시각화다.
GPU를 이용해서 매 프레임을 새로 그리기 때문에 pan과 시간 진행, 화면 기울임등에 자연스럽게 대응한다. 시간진행의 경우 특정 시점을 그릴 때 앞 뒤 시각 두 벌의 벡터타일을 이용해서 보간했다.
선이 아니라 다각형 볼륨을 사용했기 때문에 표현의 정도가 좀 더 풍부해졌다.
이제 데이터 전처리부터 하나씩 살펴보자.
데이터 : RAW
데이터는 해양과학기술원에서 자체 구축한 운용해양예보시스템(KOOS) 시뮬레이션 데이터다.
다수의 지점에서 관측된 데이터를 바탕으로 시뮬레이션을 돌려 24시간 예측 데이터셋을 만든다고 한다. 여기서 사용한 데이터는 위경도 1도가 48등분 되어 있는 LEVEL2 데이터다. 위도경도에 따라 차이가 있지만 0.0208도, 즉 대략 2km 정도의 격자라고 할 수 있다. 데이터의 간격은 그러하지만 물결 하나하나의 개체는 줌 레벨에 따라 화면을 기준으로 촘촘하게 표현했다.
원본 데이터 가공에는 사실 여러가지 디테일이 있는데, 여기서는 중요한 부분들만 설명해보겠다.
데이터 : 타일 맵 구성을 위한 벡터 타일만들기
데이터는 위와 같이 3 단계의 zoom 레벨로 가공해서 미리 서버에 저장해두었다.
데이터 원본 해상도를 그대로 담은 것이 가장 우측의 줌 레벨 5 데이터다. 전체 데이터 영역에서 부분적으로 잘라내거나 null 값을 덧붙여서 16도 x 16도 = 256개의 타일들로 만들었다. 각 타일은 49x49 개의 벡터 필드 데이터로 구성되어 있다.
48등분이지만 가장자리까지 모두 담아야 보간이 가능하기 때문에 48+1 = 49 지점으로 가로 세로 모두 구성했다. 따라서 가장자리 부분은 옆 타일과 겹치게 된다.
좀 더 화면을 축소하면 줌 레벨 4 를 읽어들이면서 그리게 된다. 줌 레벨 4는 줌 레벨 5 전체를 깔아놓고 가로세로 각각 기준으로 짝수번째 데이터를 모두 생략했다고 생각하면 된다. 그리고 타일의 크기를 동일하게 유지하려고 했기 때문에 기본 타일의 크기는 가로 두 배 세로 두 배가 된다. 줌 레벨 3은 거기서 또 다시 하나씩 건너뛰어서 만든 데이터다. 가장 축소된 줌 상태에서 사용된다. 줌 레벨이 달라도 타일 1장의 크기는 모두 동일하다.
한 지점의 벡터 필드 데이터를 4byte에 담았다.
각 벡터의 값은 대부분 –3.0~3.0 사이에 위치하므로 각 값에 10000을 곱한 후 32767을 더해서 0~65535 사이의 2byte 양의 정수로 인코딩한다. 바이너리 형식으로 저장하기 위함이다.
R에서의 변환식은 다음과 같다.
as.integer((df$value * 10000) +32767)
이렇게 u,v값을 4byte 안에 나란히 배열한 후 49x49=2401 개 단위 데이터 지점을 연속적으로 나열하여 전체 데이터를 만든다. 개별 파일에 별도의 헤더(header)정보는 두지 않았다. 따라서 타일 하나의 크기는 9604byte 정도가 된다.
특정 시점에서 화면을 띄우면 화면 안에 들어오는 공간과 주변 약간의 버퍼를 포함하여 데이터를 서버에서 불러들이게 되는데, 시간 보간을 위해 두 시점을 불러들이므로 최소 100KB안팎에서 200~300KB까지 받아온다. 예를 들어 초기 화면은 독도로 설정되어 있는데, 초기 로딩시 약 115KB를 불러들이게 된다. 굳이 2byte로 인코딩해서 파일 크기를 줄인 이유는 다수의 유저 접속시 서버 부담을 최소화 하기 위함이다.
위와같은 형식의 데이터는 다음과 같은 폴더 구조에 저장된다. 실제 사이트에서는 해류, 염분, 조위, 수온 모두를 구현했지만 이 글에서는 해류만 설명하고 있으므로 해류 중심으로 설명해보겠다.
사실 벡터 필드에는 시각 정보도 있어야 하고 기준점 정보도 있어야 한다. 그런데 그 모두를 다 담으면 파일의 용량이 커지게 되므로 시각과 기준점, 줌 레벨 정보는 폴더에 담았다.
예를 들어, 클라이언트 웹브라우저에서 특정 시각(1678190400)과 특정 기준점(동경 117도, 북위 28도), 줌레벨 5의 데이터가 필요하면 서버에 [~서버주소]/1678190400/5/117/117208.bin을 요청하면 된다. 사실 래스터 이미지 형식의 WMTS 서비스가 이런 식으로 표준 규칙을 정해서 데이터를 구축하고 서비스한다. 벡터 필드, 특히 시간 정보가 있는 벡터 필드는 별도의 표준을 찾지 못해서 위와 같이 구성했다. 시각을 유닉스타임 형식으로 변환하여 최상위 폴더로 구성했다.
1일 분량의 데이터는 데이터 유형에 따른 폴더 3종, 각 데이터 유형마다 24개 시간대, 각 시간대마다 16+64+256 = 336개 파일로 구성된다. 즉 모두 3 x 24 x 336 = 24,192개 파일로 구성된다. 1일 분량의 데이터는 12KB x 24,192 = 290,304KB 에 달한다.
1년 365일 데이터가 누락없이 존재한다고 가정할 때, 290,304KB x 365 = 105,960,960KB = 101GB 의 용량이 필요하다. 저장장치의 요새 비용을 생각해보면 그렇게 부담이 가는 용량은 아니다.
이제 시각화에 대해 이야기해보자.
화면을 구성하는 3개의 레이어
화면은 크게 3개의 레이어로 구성된다.
가장 밑바닥에는 네이티브 WebGL 레이어가 있다. 해류는 webgl 코드와 셰이더에서 직접 구현했다.
그 위에는 Deck.gl 레이어가 있다. 시행착오 과정에서는 deck.gl로 국토 경계 폴리곤을 그렸었는데, 후반부에 브이월드 위성사진으로 바꾸면서 역할이 많이 축소되었다.
인터페이스에 관련된 UI들은 가장 위의 레이어에 canvas나 svg 등을 이용해서 그려주었다.
가장 UI 레이어는 그다지 복잡하지 않아서 HTML+CSS, canvas, svg 등 손에 잡히는대로 만들었다. 어떤 인터랙션은 css에서, 또 다른 인터랙션은 자바스크립트에서 구현하였다.
배경지도 레이어는 Deck.gl로 그렸다.
TileLayer로 브이월드 위성사진을 불러들였다. 브이월드 위성사진의 색감이 줌 레벨에 따라 채도가 많이 차이나서 다소 조야한 편인데, TileLayer에서 지도를 읽을 때 0.7 정도로 desaturate 하는 기능을 사용했다.
바다 부분이 투명하게 뚫려 있어야 밑의 해류가 보이므로 좀 고민을 했는데, 다행히도 GeoJsonLayer를 마스크로 사용할 수 있어서 결과적으로는 편리하게 구현했다.
사실 해류와 국토경계를 어색하지 않게 표현하는 이슈에 대해서 한참 시행착오를 거쳤다. 일부분은 해류 데이터를 다시 만들었고, 폴리곤 지도를 위에 엎어 보기도 했는데 결론적으로는 위와 같은 방식으로 결정했다. 사실은 mapbox의 벡터 타일맵이 맵 확대시 세밀한 경계를 표현할 수 있기 때문에 가장 좋은 대안이라 생각했는데, 운영 과정에서 비용이 발생할 수 있어 대안에서 제외시켰다.
Deck.gl에서 배경 지도를 읽어들인 부분은 아래와 같다.
new PolygonLayer({
id: "default-map",
data: [Screen.maskArea],
stroked: false,
filled: true,
getFillColor: [8, 6, 15],
getPolygon: (d) => d,
extensions: [new MaskExtension()],
maskId: "map-mask",
}),
new TileLayer({
id: "background-map",
data: DrawDeck.BGMAP.vworld_satellite,
minZoom: Screen.zoom.MAP_MIN,
maxZoom: Screen.zoom.MAP_MAX,
tileSize: 256,
renderSubLayers: (props) => {
const {
bbox: { west, south, east, north },
} = props.tile;
return new BitmapLayer(props, {
data: null,
image: props.data,
bounds: [west, south, east, north],
desaturate: 0.7,
//transparentColor: [255, 0, 0, 255],
});
},
//extent: [117, 28, 133, 44],
onTileError: (error, tile) => {},
onHover: ({ lngLat }) => {
//console.log(`Longitude: ${lngLat[0]}, Latitude: ${lngLat[1]}`);
},
parameters: {
depthTest: false,
},
extensions: [new MaskExtension()],
maskId: "map-mask",
//tintColor: [200, 180, 180],
visible: true,
}),
해류 레이어는 DeckGL에서 그리는 gl 레이어와는 독립적인 gl 레이어를 별도로 만들어서 그렸다.
화면을 구성하는 레이어들의 싱크
이렇게 이종의 레이어를 합성하면, 지도를 조작할 때 서로 다른 레이어가 같이 움직여야 한다는 문제가 발생한다.
여러가지 대안들을 검토했는데, 결과적으로 전체적인 드로잉의 제어는 Deck.gl에서 하도록 했다.
new Deck() 클래스에서 제공하는 onAfterRender 함수가 있었는데, 한 프레임을 그릴 때 Deck 레이어를 그린 직후 실행할 함수들을 넣어둘 수 있었다.
DrawDeck.deckgl = new Deck({
parent: document.getElementById("map"),
//...중간 생략
onAfterRender: () => {
//해류 그리는 부분
DrawGL.drawGlContext();
//UI 레이어 그리는 부분
Tooltip.drawTooltip();
Poi.ctx.clearRect(0, 0, Canvas.poi.width, Canvas.poi.height);
Poi.drawAdm(Poi.admData, Screen.zoom.SGG_MIN, 16, "rgb(255,255,255)");
Poi.drawPoi(
Poi.marineToponymyData,
Screen.zoom.MARINE_TOPONYMY_MIN,
13,
"rgb(83, 142, 194)" //"rgb(237, 164, 5)"
);
Poi.drawPoi(Poi.poiData, Screen.zoom.POI_MIN, 16, "rgb(255,255,255)");
},
//후반부 생략
});
이제 문제는 deck.gl의 뷰 상태를 꺼내오는 부분이다. 마우스 조작 직후에 deck.gl의 기준점이 변하면 그 상태의 화면 경계영역, 화면 중심, 줌 레벨, 화면을 바라보는 각도 등을 받아와야 한다. 그래야 다른 레이어도 같은 기준으로 그릴 수 있기 때문이다.
deck.gl에서 mapbox를 사용할 때는 드로잉 주도권이 mapbox에 있고, mapbox 인터페이스 중 이런 화면 상태를 꺼낼 수 있는 함수가 제공된다. 그런데 그냥 deck.gl을 사용할 때는 도대체 왜 그랬는지 모르겠지만 제공하지 않는다.
다행인건 자바스크립트가 클래스를 private 으로 만들 수 없다는 점이다. 그래서 열심히 함수의 상태 값들을 뜯어보다 보면 해당 기능을 찾아내서 호출할 수 있다.
//-------------------
//Screen.js
//화면에 보이는 경계영역을 구한다. 정사각형이 아닐 수 있음
static getBoundPolygon(_viewStateProps) {
const polygon = new Array();
const viewport = DrawDeck.getViewPort();
//console.log(viewport.getBounds());
polygon.push(viewport.unproject([0, 0]));
polygon.push(viewport.unproject([0, _viewStateProps.height]));
polygon.push(
viewport.unproject([_viewStateProps.width, _viewStateProps.height])
);
polygon.push(viewport.unproject([_viewStateProps.width, 0]));
polygon.push(viewport.unproject([0, 0]));
return polygon;
}
static syncWithGLContext() {
//이걸 해줘야 커스텀 webgl에서 제대로 받는다.
Screen.viewStateProps.width = Canvas.gl.clientWidth;
Screen.viewStateProps.height = Canvas.gl.clientHeight;
}
//-------------------------------
//DrawDeck.js 중 일부
static getViewPort() {
return new WebMercatorViewport(Screen.viewStateProps);
}
static getViewPortDirect() {
return new WebMercatorViewport(DrawDeck.deckgl.viewState["default-view"]);
}
//-----------------------------------------
//DrawGL.js 중 일부
static getViewProjection() {
const viewport = DrawDeck.getViewPortDirect();
const __projectionMatrix = mat4.create(); // 빈 mat4 행렬 생성
mat4.copy(__projectionMatrix, viewport.projectionMatrix);
const __viewMatrix = mat4.create(); // 빈 mat4 행렬 생성
mat4.copy(__viewMatrix, viewport.viewMatrix);
//console.log(viewport.projectionMatrix);
//console.log(viewport.viewMatrix);
return {
view: __viewMatrix,
proj: __projectionMatrix,
};
}
위에서 DrawDeck.deckgl 은 new Deck()으로 생성된 Deckgl의 인스턴스다.
그 하위에 viewState["default-view"] 개체가 있고, 다시 그 밑에 지속적으로 업데이트 되는 projectionMatrix와 viewMatrix가 있다. 이건 3차원 표현에서 사용하는 표준 매트릭스이므로 이 두개의 매트릭스만 있으면 다른 개체들도 현재 화면 상태에 맞춰서 그릴 수 있다.
해류의 표현 - 벡터 타일을 텍스쳐로 만들기
이제 드디어 해류를 그리는 방법에 대해 설명해보자.
우선 개별적으로 받아온 10KB의 바이너리 형식의 파일들을 디코딩한 후 화면 크기에 대응하는 한장의 큰 텍스쳐에 결합한다.
여기서 설명하는 예시에는 벡터 타일 42장이 필요하다. (설명 그림을 그리다보니 이렇게 되었지만 한 장을 그리기 위해 벡터 타일이 이렇게 많이 필요한 경우는 거의 없다.)
이 벡터 타일들을 각각의 위치에 따라 다시 큰 한장의 타일로 결합한다. 가장자리 부분은 서로 겹치게 된다.
그렇게 큰 한장의 타일에 결합한 후 텍스쳐를 생성하여 셰이더로 넘긴다.
텍스쳐는 다음과 같이 정의했다.
static initializeTexture(dummyBuffer) {
const texture00 = gl.createTexture(dummyBuffer);
gl.bindTexture(gl.TEXTURE_2D, texture00);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
xsizeMax,
ysizeMax,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
dummyBuffer
);
return texture00;
}
화면이 1920x1080이라도, 텍스쳐는 벡터필드 정보만을 담으면 되기 때문에 200x200px 정도를 넘기는 일이 드물다. 매우 작은 크기 두 장(현재 시각과 뒤의 시각 각각 1장씩)이 gpu로 넘어간다.
아래는 큰 텍스쳐에 개별 타일들을 결합하는 부분이다.
static makeTextureBuffer(_textureDataExec0, _textureDataExec1) {
//gl.drawArray 용 텍스쳐를 만든다.
//타일들을 조합해서 넣는다.
//텍스쳐 전체 크기가 변했으면, 텍스쳐 원판을 재설정한다.
if (Texture.hitmax) {
Texture.reinitTexture();
Texture.hitmax = false;
}
gl.bindTexture(gl.TEXTURE_2D, Texture.getTextureWhole0());
for (const v of _textureDataExec0) {
const { xoffset, yoffset } = Texture.getOffset(
v.lon,
v.lat,
Screen.boundGlobal
);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
xoffset,
yoffset,
TEXTURE_XY, //49
TEXTURE_XY,
gl.RGBA,
gl.UNSIGNED_BYTE,
v.pixel
);
}
gl.bindTexture(gl.TEXTURE_2D, Texture.getTextureWhole1());
for (const v of _textureDataExec1) {
const { xoffset, yoffset } = Texture.getOffset(
v.lon,
v.lat,
Screen.boundGlobal
);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
xoffset,
yoffset,
TEXTURE_XY,
TEXTURE_XY,
gl.RGBA,
gl.UNSIGNED_BYTE,
v.pixel
);
}
//console.log("refreshed!");
}
줌 레벨이나 화면 기울임 정도에 따라 준비할 텍스쳐 한 판의 크기가 달라지기 때문에, 초기값보다 큰 텍스쳐가 필요하면 크기를 재설정하도록 했다. 텍스쳐 원판은 모두 채우지 않아도 된다. 필요한 부분에만 유효한 데이터를 넣고, 셰이더에서 유효한 값이 있는 부분만 읽어서 그리기 때문이다.
이 부분도 몇 번의 시행착오를 겪었다.
처음에는 읽어온 타일의 크기인 49x49 하나마다 텍스쳐를 만들어서 셰이더로 보냈는데, 인접한 영역의 텍스쳐 값을 읽을 수가 없어서(셰이더에서는 바인딩 문제로 이게 가능하지 않다.) 아래 그림과 같은 현상이 발생했다.
빠르게 흐르는 해류의 경우 이동하는 영역이 넓은데 이 때 옆 타일 영역의 데이터가 필요하다. 그렇지만 옆 타일을 읽을 수 없으므로 해당 타일의 경계에서 마지막으로 읽어들인 값의 방향으로 계속 뻗어나가게 되는 오류가 발생한다. 물결들이 겹쳐보인다. 따라서 이 문제를 해결하기 위해 화면에 들어오는 영역 모두를 하나의 텍스쳐로 합쳐야만 했다.
해류의 표현 - 개별 개체 표현
OpenGL로 바람 데이터를 그렸을 때 만든 이미지를 다시 가져와봤다.
벡터 필드의 표현 방법이 꼭 한 가지로 정해져있는건 아니겠지만, 대부분 위와 같이, 나타났다가 사라지는 개체들을 랜덤하게 발생시켜서 흐름을 표현한다. 여기서도 그 방법으로 그렸다. 따라서 셰이더에서는 개체 하나의 표현을 정의해주면 된다.
그런데 OpenGL과 WebGL은 큰 차이가 있다. 아래 그림을 보자.
OpenGL에서는 지오메트리 셰이더가 존재하므로, 하나의 점을 바탕으로 10여개의 삼각형으로 이루어진 온전한 물결 하나를 완성할 수 있다. 20개의 점을 Triangle Strip 으로 정의해서 발생시키면 9개의 삼각형을 만들 수 있다.
그런데 WebGL 에는 지오메트리 셰이더가 없다. 버텍스 셰이더만으로 삼각형을 만들어야 하기 때문에 같은 개념으로 접근할 수 없다. 게다가 GPU 는 병렬적으로 계산되므로 버텍스 셰이더를 루프로 돌리면서 순차적으로 그릴 수도 없다.
위의 그림처럼 버텍스 셰이더에서는 단 하나의 점만 발생시킨다. 버텍스 셰이더가 3개 모여야 고작 1개의 삼각형을 그릴 뿐이다. 병렬로 움직이는 버텍스 셰이더 각각은 gl_VertexID라는 고유 일련번호가 붙어있는데, 이 번호들을 통해 각각의 상호관계를 정의해주는 수 밖에 없다.
여기서는 하나의 물결이 움직이는 경로가 11지점에서 읽은 벡터필드 값을 토대로 정의된다. 즉, 11지점 각각에서 진행방향에 직교하는 좌우 방향으로 점들 22개를 발생시키고 이 점들로 삼각형 20개를 정의한다. 아래 그림을 보자.
그림에서 회색으로 균일하게 깔린 점들이 벡터필드로 만들어진 텍스쳐다. 텍스쳐는 확대해도 그 사이 공간을 하드웨어적으로 보간해서 채우기 때문에 실제로는 어떤 지점에서 데이터를 읽어도 벡터 필드값을 불러올 수 있다.
a, b, c, d 가 실제 벡터 필드를 읽는 값이다. a에서 벡터 필드 값을 읽고 벡터 만큼 진행한 곳이 b다. 다시 b에서 벡터필드 값을 읽어서 진행한 곳이 c가 된다. 그런데 이 점들만 이어 그리면 두께가 없는 선이 되어 버린다. 따라서 진행 방향에 직교하도록 0번과 1번 점을 발생시킨다. a로부터 이격되는 정도는 개체를 어떤 형태로 그릴 것인지에 따라 달라진다.
앞서 언급했지만 하나의 버텍스 셰이더에서 점 1개만 발생 가능하다. 따라서 한 개체에 60개의 버텍스 셰이더를 할당하면, gl_vertexID을 60으로 나눈 나머지 값이 한 세트 버텍스 셰이더에서 자기 자신의 번호가 된다.
60개라는 수는, 물결 하나가 움직이는 궤적을 20개 삼각형만큼의 공간으로 정의내리기 위해 정했다. 위의 그림처럼 0번~21번까지 22개의 점은 총 20개의 삼각형을 만들 수 있다. 60개의 셰이더에서는 gl_vertexID%60 의 값을 기준으로 0-1-2 번에서 삼각형 1개, 3-2-1 번을 따라 그리면서 삼각형 1개, 그 다음엔 2-3-4번, 그 다음엔 5,4,3 번을 따라 그리면서 삼각형 1개씩 발생시킨다. 따라서 20개의 삼각형을 발생시키기 위해서는 22개의 점들을 일부분 중복 호출해가면서 총 60개의 점들을 발생시켜야 한다.
셰이더에서 해당 점들의 번호를 정의내리는 부분은 아래와 같다.
// int drawArr[60] = {0, 1, 2, 3, 2, 1,
// 2, 3, 4, 5, 4, 3,
// 4, 5, 6, 7, 6, 5,
// 6, 7, 8, 9, 8, 7,
// 8, 9, 10, 11, 10, 9,
// 10, 11, 12, 13, 12, 11,
// 12, 13, 14, 15, 14, 13,
// 14, 15, 16, 17, 16, 15,
// 16, 17, 18, 19, 18, 17,
// 18, 19, 20, 21, 20, 19};
int drawArr(int idx)
{
int base = idx/6 * 2;
int diff = 3 - abs(idx%6 - 3);
return base + diff;
}
void main() {
//...
//한 개체는 반드시 60개
//그리드 번호
const int VERTICES_PER_LARVA = 60; //3*20
//LARVA는 물결 하나를 지칭하는 이름
int LARVA_ID = gl_VertexID / VERTICES_PER_LARVA;
int vertexID = gl_VertexID % VERTICES_PER_LARVA;
//...
int drawIndex = drawArr(vertexID);
//...
}
앞서 물결 하나는 20개 삼각형의 공간으로 정의된다고 했다. 아래 그림 각각에 20개의 삼각형으로 시간 진행에 따라 그린 3개의 세트가 있다.
특정 단위 시간 동안에 그려지는 물결은 움직이는 것처럼 보이는데, 실제로는 위와 같이 그리게 된다. 진행 정도에 따라 두께를 줄 부분과 두께를 0으로 그릴 부분을 다르게 계산하면 한정된 공간 안에서 나타났다가 사라지는 것처럼 그릴 수 있다.
즉, 60개의 버텍스 셰이더가 한 세트로 움직이면서, 하나의 셰이더에서는 저 20개 삼각형 중 하나의 점을 발생시키고 그 위치는 gl_vertexID 값으로 연결되면서 상호적인 위치를 결정하게 된다.
해당 물결이 생겨난지 얼마 되지 않은 순간에는 0,1,2 번등이 굵게 그려지고 뒤로 가면 두께가 0으로 그려진다. 물결이 사라질 즈음에는 거의 15번까지의 점에서 그려지는 삼각형의 두께가 0으로 표현되고 18,19,20,21번 정도가 굵게 표현된다.
관련된 부분만 가져오면 아래와 같다.
//반드시 22번의 loop가 되어야 한다. 그래야 60개의 인덱스와 매칭되는 버텍스와 맞음.
//루프는 22번이지만 vertex와 적중되는 단 하나를 emit하게 됨.
//60개의 쓰레드들은 모두 22바퀴를 돌고, 그 중 자신과 맞는 하나의 vertex와 매칭될 때 emit 하게 됨
for (int vtxIdx=0 ; vtxIdx< TRACK_VERTICES_CNT; vtxIdx++) //0~21
{
//....
//....
//....
int drawIndex = drawArr(vertexID);
if (drawIndex == vtxIdx) {
float plusMinus = plusMinusArr[drawIndex%2];
float texProgress = (float(vtxIdx) - begin_offset) / (LARVA_VERTICE_CNT_IN_TRACK);
if ( begin_offset <= float(vtxIdx)
&& float(vtxIdx) < begin_offset + float(LARVA_VERTICE_CNT_IN_TRACK) +1.0 )
{
//텍스쳐용 진행정도는 새로 정의한다. 앞뒤와 맞물려 있기 때문.
texCoords = vec4(plusMinus * finalW, finalW, texProgress, speed);
gl_Position = projection * view * vec4(pos + plusMinus * finalW * direction , 0.0, 1.0);
} else
{
texCoords = vec4(plusMinus, -1.0, texProgress, speed);
gl_Position = projection * view * vec4(pos , 0.0, 1.0);
}
}
} //for vtxIdx
어쩔 수 없는 부분은 마지막 k의 위치를 정하기 위해서는 a-b-c-d-....-i-j-k 를 순차적으로 훑으면서 벡터 필드 값을 모두 읽어야 한다는 점이다. 그리고 모든 버텍스 셰이더는 22번의 루프를 돌아야 한다. 만약 cpu 프로그래밍이었다면 a나 b점 부근을 그릴 때 루프에서 break로 빠져나오는 것이 당연히 효율적인데, GPU는 어차피 병렬적으로 움직이기 때문에 다른 쓰레드를 대기하게 된다. 섣불리 불규칙하게 빠져나와서 고르지 못하면 오히려 밸런스가 망가질 수도 있기 때문에 모든 셰이더에서 루프 22바퀴를 돌도록 했다.
이 방법을 고안했을 때 무언가 새로운 응용방식을 발견했다고 생각했는데, 나중에 알고 보니 deck.gl의 ArcLayer 같은 경우에도 이미 비슷한 방식으로 구현하고 있는 것을 알게 되었다. 물론 ArcLayer는 양 끝단의 점으로만 중간 값들을 계산하기 때문에 정확히 같다고는 할 수 없다.
그런데 그렇다면 다른 사이트에서는 왜 벡터필드를 이렇게 셰이더를 이용해서 볼륨있는 형태로 구현하지 않았는지 의문이 들기는 한다. 여튼, 온전히 새로운 응용 방식을 고안해낸 건 아니지만 최소한 바람이나 해류 데이터를 웹으로 표현하는 분야에서는 아직 구현된 바가 없는 것 같다.
버텍스 셰이더 전체는 아래와 같다.
const vert = `#version 300 es
#define PI 3.14159265358979323846
#define PI_4 0.785398163397448309615
#define DEGREES_TO_RADIANS 0.0174532925199432957
#define TILE_SIZE 512.0
#define TEXTURE_XY 48 //49로 하면 이음매가 겹친다.
#define LIFETIME 30
uniform mat4 projection;
uniform mat4 view;
uniform vec4 lonLatUnitTime;
uniform vec4 textureCoord;
uniform vec4 info0;
uniform ivec4 info1;
uniform sampler2D texture0;
uniform sampler2D texture1;
vec2 lngLatToWorld(vec2 lnglat) {
float lambda2 = lnglat.x * DEGREES_TO_RADIANS;
float phi2 = lnglat.y * DEGREES_TO_RADIANS;
float x = (TILE_SIZE * (lambda2 + PI)) / (2.0 * PI);
float y = (TILE_SIZE * (PI + log(tan(PI_4 + phi2 * 0.5)))) / (2.0 * PI);
return vec2(x, y);
}
vec4 vx = vec4(0.0, 1.0, 0.0, 1.0);
vec4 vy = vec4(0.0, 0.0, 1.0, 1.0);
// int drawArr[60] = {0, 1, 2, 3, 2, 1,
// 2, 3, 4, 5, 4, 3,
// 4, 5, 6, 7, 6, 5,
// 6, 7, 8, 9, 8, 7,
// 8, 9, 10, 11, 10, 9,
// 10, 11, 12, 13, 12, 11,
// 12, 13, 14, 15, 14, 13,
// 14, 15, 16, 17, 16, 15,
// 16, 17, 18, 19, 18, 17,
// 18, 19, 20, 21, 20, 19};
int drawArr(int idx)
{
int base = idx/6 * 2;
int diff = 3 - abs(idx%6 - 3);
return base + diff;
}
vec2 decodeUV(vec4 raw) {
float u = (raw.r *256.0*255.0 + raw.g * 255.0)-32767.0;
float v = (raw.b *256.0*255.0 + raw.a * 255.0)-32767.0;
return vec2(u / 1000.0, v/1000.0);
}
float rand(float x, float y) {
float t = dot(vec2(12.9898, 78.233), vec2(x,y));
return fract(sin(t) * (4375.85453 + t));
}
vec2 getVelocity(vec2 lonlat)
{
float unit = lonLatUnitTime.z; //한 단위의 1/48 크기다.
vec2 gridXY = (lonlat - lonLatUnitTime.xy)/unit;
float tx = textureCoord.x + ((gridXY.x / 48.0) * (textureCoord.z - textureCoord.x));
float ty = textureCoord.y + ((gridXY.y / 48.0) * (textureCoord.w - textureCoord.y));
vec2 vTexCoord = vec2(tx,ty);
vec4 raw0 = texture(texture0, vTexCoord);
vec4 raw1 = texture(texture1, vTexCoord);
vec2 uv0 = decodeUV(raw0);
vec2 uv1 = decodeUV(raw1);
float t = lonLatUnitTime.w; //0~1
vec2 uv = mix(uv0, uv1, t);
return uv;
}
vec2 getFirstPosWgs84(int LARVA_ID, int LARVA_ID_OF_LIFETIME) {
//텍스쳐 전체 기준점 구하기
float latlon_unit = lonLatUnitTime.z;
int UNIT_NUM = 8; //외부와 일치 필요
int HIVE_SIZE = TEXTURE_XY / UNIT_NUM; //6, 외부와 일치 필요
float ux = float(info1.z);
float uy = float(info1.w);
float basex = lonLatUnitTime.x + ux * float(HIVE_SIZE) * latlon_unit ;
float basey = lonLatUnitTime.y + uy * float(HIVE_SIZE) * latlon_unit ;
vec2 textureBase = vec2(basex, basey);
int larvasPerHive = info1.y; //줌에 따라 달라짐.
int HIVE_X = LARVA_ID % (HIVE_SIZE); //0~5
int HIVE_Y = LARVA_ID / (HIVE_SIZE * larvasPerHive); //0~48
float HIVE_Lon = textureBase.x + float(HIVE_X) * latlon_unit; //그리드 중심점 위경도
float HIVE_Lat = textureBase.y + float(HIVE_Y) * latlon_unit; //그리드 중심점 위경도
float seedAdd = float(LARVA_ID_OF_LIFETIME)*0.00001; //0.00001이 커지면 겹친다.
float seed0 = HIVE_Lon+ (float(LARVA_ID % 3600)) / 10.0; //
float seed1 = HIVE_Lat+ (float(LARVA_ID / 3600) / 10.0 - 90.0);
//동일 개체 기준으로 볼 때 5400(180*30초 기준으로 값이 변화함
float varX = rand(seed0+seedAdd, seed1+seedAdd)*2.0 -1.0; //-0.5~0.5
float varY = rand(seed1-seedAdd, seed0-seedAdd)*2.0 -1.0; //-0.5~0.5
float MIX_RANGE_TO_NEAR_HIVE = 8.0; //난수 결과를 주변 그리드로 퍼뜨린다.
float lonVar = latlon_unit * MIX_RANGE_TO_NEAR_HIVE * varX;
float latVar = latlon_unit * MIX_RANGE_TO_NEAR_HIVE * varY;
return vec2(HIVE_Lon + lonVar, HIVE_Lat + latVar);
}
float remap(float value, float inputMin, float inputMax, float outputMin, float outputMax) {
return outputMin + ((value - inputMin) / (inputMax - inputMin) * (outputMax - outputMin));
}
float getLarvaProgressLength(float currentZoom, float speed) {
float LARVA_LENGTH_1_UNIT_OF_30 = 0.36 / pow(currentZoom,0.84) ;
return LARVA_LENGTH_1_UNIT_OF_30;// * remap(speed, 0.0, 0.5, 2.0, 0.001);
}
float getLarvaProgress(int LARVA_ID) {
int playtime = info1.x;
float STATIC_DURATION = info0.z;
float larva_time = float(playtime) / STATIC_DURATION;
return float(LARVA_ID) + larva_time;
}
float plusMinusArr[2] = float[](1.0, -1.0);
out vec4 texCoords;
void main() {
float currentZoom = pow(2.0, min(max(info0.x, 7.0),13.0));
//한 개체는 반드시 60개
//그리드 번호
const int VERTICES_PER_LARVA = 60; //3*20
int LARVA_ID = gl_VertexID / VERTICES_PER_LARVA;
int vertexID = gl_VertexID % VERTICES_PER_LARVA;
float individual_progress_of_LARVA = getLarvaProgress(LARVA_ID);
float LARVA_age_in_LIFETIME_0_1 = mod(individual_progress_of_LARVA, float(LIFETIME)) / float(LIFETIME); //0.0~1.0
//동일 개체 기준으로 볼 때 (STATIC_DURATION * LIFETIME) 초마다 값이 변화함
int LARVA_ID_OF_LIFETIME = int(individual_progress_of_LARVA)/LIFETIME; //하나의 생멸이 가지는 고유한 ID
//i에 영향받지 않는 두께.
float BASE_INPUT_WIDTH = info0.y; //현재 1.0 들어오고 있음
float zoom_correction = 1.0 / currentZoom;
const float LARVA_VERTICE_CNT_IN_TRACK = 10.0;
const int TRACK_VERTICES_CNT = 22;
//진행에 따른 두께
//0~9.0
//0->0, 10-> 9 , 20->9, 30->0 늘었다가 유지하다가 줄어드는 사다리꼴 형상
float width_lifetime = sin(LARVA_age_in_LIFETIME_0_1 * PI);
float begin_offset = LARVA_age_in_LIFETIME_0_1 * (float(TRACK_VERTICES_CNT)-(LARVA_VERTICE_CNT_IN_TRACK-1.0));
// -- -- - --
// -- -- -- --
// -- -- -- --
// -- -- -- --
// ------------------------------------- 이런 식의 그래프를 그리면 y축이 0~1, 0~1 반복된다. 이걸 세부 개체의 단위 진행도로 삼음
// 0 30 60 90
///////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////
vec2 posWgs84 = getFirstPosWgs84(LARVA_ID, LARVA_ID_OF_LIFETIME);
vec2 posPre = lngLatToWorld(posWgs84);
vec2 pos, velocity, posNormal, direction;
float speed, body_shape;
//반드시 22번의 loop가 되어야 한다. 그래야 60개의 인덱스와 매칭되는 버텍스와 맞음.
//루프는 22번이지만 vertex와 적중되는 단 하나를 emit하게 됨.
//60개의 쓰레드들은 모두 22바퀴를 돌고, 그 중 자신과 맞는 하나의 vertex와 매칭될 때 emit 하게 됨
for (int vtxIdx=0 ; vtxIdx< TRACK_VERTICES_CNT; vtxIdx++) //0~21
{
float begin_point = float(vtxIdx)-begin_offset;
float progress_t = mod(begin_point, LARVA_VERTICE_CNT_IN_TRACK) / LARVA_VERTICE_CNT_IN_TRACK; // 0.0~1.0
velocity = getVelocity(posWgs84);
speed = length(velocity) /100.0;
posWgs84 += velocity * getLarvaProgressLength(currentZoom, speed); //일단 원점에 속도를 더한다.
pos = lngLatToWorld(posWgs84);
posNormal = normalize(pos - posPre);
direction = vec2(-posNormal.y, posNormal.x);
posPre = pos;
body_shape = sin(pow(progress_t, 2.0) * PI);
//스피드가 크면 두께가 커짐
float speed_width = 8.0 * pow(speed,0.15);
float finalW = zoom_correction * speed_width * body_shape * width_lifetime ;
int drawIndex = drawArr(vertexID);
if (drawIndex == vtxIdx) {
float plusMinus = plusMinusArr[drawIndex%2];
float texProgress = (float(vtxIdx) - begin_offset) / (LARVA_VERTICE_CNT_IN_TRACK);
if ( begin_offset <= float(vtxIdx)
&& float(vtxIdx) < begin_offset + float(LARVA_VERTICE_CNT_IN_TRACK) +1.0 )
{
//텍스쳐용 진행정도는 새로 정의한다. 앞뒤와 맞물려 있기 때문.
texCoords = vec4(plusMinus * finalW, finalW, texProgress, speed);
gl_Position = projection * view * vec4(pos + plusMinus * finalW * direction , 0.0, 1.0);
} else
{
texCoords = vec4(plusMinus, -1.0, texProgress, speed);
gl_Position = projection * view * vec4(pos , 0.0, 1.0);
}
}
} //for vtxIdx
}
`;
프래그먼트 셰이더에서는 한 덩어리로 생성된 물결 각 픽셀에 대한 색상값을 정의 내리게 된다.
머리 부분이 좀 진하고 꼬리가 있는 물결을 표현하기 위해 계산식으로 두 개의 형상을 만들어서 합성시켰다. 대략 그리면 아래 그림과 같다.
그렇게 해서 표현된 결과를 확대해보면 아래 그림과 같다.
지느러미가 흐느적 거리는 부분은 사실 의도한 건 아닌데, 좌우 값에 미묘한 비대칭이 발생해서 생긴 결과다. 그런데 느낌이 나쁘지 않아서 그대로 두었다.
개체들을 한데 모아 놓고 보면 아래와 같다.
물결이 자연스럽게 잘 표현된 것 같다.
windy.com 이나 zoom.earth처럼 별도의 셰이더에서 텍스쳐를 그대로 그라데이션으로 표현해서 한 판 깔고, 그 위에다가
물결 객체들을 표현해서 겹쳤다.
물결이 자연스럽게 난수처럼 발생하도록 하는 부분도 중요한데, 이에 대한 설명은 OpenGL에서 구현한 내용을 설명한 이전의 글과 겹치므로 여기서는 생략하겠다.
시간 진행에 따라 발생지점을 난수화 하지 않고 고정시켜버리면 위의 그림처럼 보이게 된다. 바다에 뿌리내린 해초가 물결에 흔들리는 것처럼 보인다. (만드는 과정 중에 그냥 한번 그려봤다)
프래그먼트 셰이더 전체 코드는 아래에 있다.
const frag = `#version 300 es
#define PI 3.14159265358979323846
precision highp float;
in vec4 texCoords;
float sigmoidCurve(float x) {
return 1.0 / (1.0 + exp(-15.0 * (x-0.5)));
}
out vec4 outColor;
void main(void) {
if (texCoords.y<=0.0)
{
outColor = vec4(0);
}
else
{
float tx = (texCoords.x / texCoords.y);
float progress_t = texCoords.z;
float speed = texCoords.w;
float alpha = 27.0* pow(progress_t,10.0) * (1.0-progress_t);
alpha = clamp(alpha, 0.1, 0.9);
alpha = 0.15*log((alpha/(1.0-alpha)))+0.5;
float alpha2 = 1.0;
vec2 distVec = vec2(0.7 * tx,1.1*progress_t);
float borderImpact = dot(distVec, distVec);
float borderImpact_HardHead = 45.0* pow(borderImpact,10.0) * (0.95-borderImpact);
float mixBorderEffect = 0.5* (1.0-abs(tx)) + 0.5 * borderImpact_HardHead;
vec3 color = vec3(clamp(speed*100.0,0.9,1.2), clamp(speed*20.0,0.8,1.0), 1.0 )
* vec3(mixBorderEffect);
outColor = vec4( color, alpha);
}
}
`;
표현에 대한 설명은 이 정도로 마친다.
나가면서
처음 작업을 시작할 때만 해도, OpenGL에서 한 번 해 본 작업이라 셰이더 문제만 해결하면 그리 어려울 것 같지 않았는데 의외로 난관들이 많았다.
타일맵으로 서버에서 실시간으로 데이터를 받고 이 데이터를 다시 GPU로 넘겨서 표현하는 이슈가 겹치면서 여러가지 문제가 생겼다.
다시 말해, 서버 요청과 응답도 비동기 방식으로 이루어지고, GPU와 CPU의 진행도 비동기이기 때문에 데이터가 원하는 타이밍에 자연스럽게 흘러가지 않았다.
데이터를 받지 못했는데 GPU 드로잉은 시작되어버려서 화면이 검게 나타나는 경우도 있었고, 비동기 응답 과정에서 앞뒤 시간의 데이터가 순간적으로 섞여버려서 화면에 불편하게 마구 그려진 선들이 보이기도 했다.
결국 데이터를 GPU로 넘기기 전에 온전하게 전달을 받았는지 검증하는 함수를 넣기도 하고 onQuerying, onUpdating, onTextureError등의 상태 변수를 만들어서 순서가 엉키지 않도록 검증하는 과정을 추가해서 오류처럼 보이는 부분들이 나타나지 않도록 했다. 정말 수많은 테스트를 거치면서 에러를 발견하고 해결해 나갔던 것 같다.
그 밖에도 시간을 진행시킬 때 흐름이 끊기지 않도록 중간 즈음에 다음 시간대 데이터를 미리 받아둔다든지, 서버의 과도한 부담을 줄이기 위해 한번 요청한 시공간의 타일은 캐시로 별도로 관리한다든지, 확대된 부분을 그릴 때 GPU 자원을 최소한도로 쓰기 위해 벡터 타일 하나를 다시 가로세로8등분 즉 64등분하여 필요한 부분만 그리는 등 여러가지 시행착오와 최적화 작업을 거쳐서 최종적인 시각화가 완성되었다. 벡터필드 말고도 해류, 조위, 수온 시각화도 있는데 벡터필드가 아닌 지점 데이터이므로 표현방식도 달리 해야 했다.
처음에 생각했던 셰이더 표현은 전체 작업에서 결국 작은 일부분이 되었을만큼 다른 이슈가 많았다. 그런데 그 이슈들은 결국 주어진 조건과 상황에서 시각화를 하기 위해 해결해야 했던 문제들이다.
시간 흐름에 따라 선택적인 공간의 해류를 보여주어야 하는데 웹이라서 대용량 데이터는 다루기 어려웠으므로 타일로 쪼개는 방법으로 진행해야 했고, 그렇게 서버와 타일을 비동기로 요청하고 받으면서 여러가지 문제들이 생겨났다. 휴대폰에서도 구현되도록 성능을 최적화하려다 보니 타일 하나를 다시 64등분했고, 그렇게 되면서 난수 seed 부분을 대대적으로 고쳐야 했다.
다시 말해 어디서 부터 정확히 어디까지를 표현의 영역이라 선을 긋고, 어디부터 어디까지는 엔지니어링의 문제라고 경계를 나누기 어렵다는 말이다. 그렇게 표현과 기술이 엉켜있기 때문에 프로젝트가 커져서 일을 나누어 해야 할 때는 커뮤니케이션의 문제가 불거지는 것 같다. 이 작업은 아슬아슬하게 혼자 할 수 있을 정도였지만 만약 규모가 커져서 두 사람이 나누어 했다면 어떻게 일을 분담해야 할지도 한 번 고민해 볼 문제다.
누군가 이 글을 읽고 좀 더 응용해서 또 다른 멋진 표현을 보여주었으면 하는 기대와 함께 글을 마무리해본다.
'Function' 카테고리의 다른 글
벡터 타일 2 : 웹에서 대용량 공간 데이터 시각화하기 (0) | 2024.08.11 |
---|---|
벡터 타일 1 : 웹에서 대용량 공간 데이터 시각화하기 (0) | 2024.08.11 |
OD 시각화 4 : python에서 그리는 화살표 머리 이동선 (0) | 2023.08.16 |
서로 간섭이 덜 되어 보이는 네트워크 링크 그리기 (4) | 2023.02.24 |
<화물차를 쉬게 하라> - 1. DTG 데이터의 처리 (0) | 2023.02.15 |