오늘은 WebGL을 이용해서 구현한 3D 영상에 대한 내용입니다. 현재 레이지버드에서는 휴비츠의 제품 중 WebViewer를 개발하면서 수십장의 이미지를 연결하여 사용자에게 영상처럼 보여지는 기능을 구현하였습니다.
이 기능을 개발하신 송대표님께서 관련한 내용을 보내주셨습니다.
WebGL
WebGL은 인터넷 브라우저 환경에서 OpenGL을 플러그인 도움 없이 공식적으로 사용할 수 있도록 Khronos Group에서 제정한 웹 그래픽스 API 모음이다.
정확히는 OpenGL ES를 기반으로 정의하고 있다.
웹 브라우저 특성상 일반 사용자들의 PC에는 거의 다 설치되어 있다. 특별한 설치가 필요없는 웹브라우저에서 자사의 소프트웨어가 실행되는 것은 큰 장점이다.
이러한 웹에서 그래픽 관련 API(OpenGL)이 실행되는 것은 쉽지 않은 기술이다. 다행히 OpenGL 이 웹에서도 돌아가도록 해주는 기술이 이미 개발되었고 우리는 그것을 WebGL이라고 부른다. WebGL은 OpenGL 에 대한 배경지식이 있다면 어렵지 않게 다룰 수 있다. 하지만 OpenGL 라이브러리 자체가 이해하기가 어렵고 사용하기도 쉽지 않기 때문에 WebGL 또한 그렇게 쉬운 기술은 아니다. 그래서 사람들은 더 쉽게 사용하고자 Three.js 라는 것을 만들었다.
Three.js
Three.js 는 웹브라우저에서 실행되는 WebGL 을 인간이 직관적으로 이해하기 쉽게 작성된 3D 그래픽 라이브러리이다. js 로 끝나는 것에서 알 수 있듯이 Three.js는 자바스크립트(javascript) 언어로 작성되었다. Three.js 와 비슷하게 WebGL 을 랩핑하는 라이브러리는 많이 있다. Three.js는 자체적으로 제공하는 예제코드가 잘 되어 있고, 구글링을 통해 얻을 수 있는 레퍼런스 수가 가장 많아서 자사에서는 Three.js 를 선택하게 되었다.
WebViewer 의 구현
WebViewer 의 기능 중 환자의 안구를 단층 촬영(Computerized Tomography)하여 얻을 수 있는 96장의 사진(B-scan)들을 연결시켜 3D 모델링을 거쳐 사용자에게 입체감을 주는 영상으로 만들어주는 기술을 보유하고 있다.
이는 3D 용어로 Volume Rendering 이라고 하며 Standalone 소프트웨어(웹브라우저가 아닌 PC에 설치하는 소프트웨어)에는 그 솔루션이 이미 많이 있지만 웹으로 구현된 것은 그리 많지 않다. Volume Rendering 자체가 CPU/GPU 연산이 많이 발생하는 작업으로 웹에서 이러한 성능을 내기가 쉽지 않기 때문이다.
볼륨 렌더링이란 3차원 정보의 텍스쳐를 활용하여 각각의 픽셀들이 3차원 상의 어떤 좌표에 있는 지를 계산하여 컴퓨터 화면 상에 그리는 일이다. Volume Rendering 의 핵심 원리는 Ray Casting 이라는 기술인데, 이게 없으면 단순히 연속된 사진들을 나열하는 것에 불과하다.(Texture slicing)
Ray casting은, 카메라(현재 화면을 보고 있는 사용자의 눈)을 기준으로 2차 평면의 픽셀 하나에 보이지 않는 임의의 광선을 하나 쏜다고 가정하고, 그 광선상에 위치되는 모든 정점들의 색상을 계산하는 것이다. 이렇게 하면 눈에서 멀리있는 점, 중간에 보이지 않는 점, 맨 앞에 있는 점들이 공식으로 계산되어 보는 이로 하여금 입체감을 느끼게 한다.
WebViewer 의 동작방식
WebViewer 의 동작방식은 다음과 같다.
1. 96장의 B-Scan을 28장씩 나누어서 저장하는 후처리를 거친다. 이 작업이 필요한 이유는 WebGL 이 한번에 받을 수 있는 텍스처의 크기가 4KB 이기 때문이다. 낱개의 사진을 여러번 보내는 것보다 한번에 보낼 때 4KB 꽉 채워서 보내는 것이 성능상 이득이다.
2. WebGL Shader 코드에서 텍스처를 분해하여 3D 상의 정육면체의 어느 위치에 어떤 점이 위치할 것인가를 계산하고 RayCasting 작업을 수행한다. 실제로 이 단계가 가장 어려운 단계로서 shader 를 직접 다뤄야만 한다.
3. RayCastring 작업이 이루어지면 Fragment Shader를 통해서 화면에 출력한다.
Shader 코드 예제
vec4 sampleAs3DTexture( vec3 texCoord )
{
vec4 colorSlice1, colorSlice2;
vec2 texCoordSlice1, texCoordSlice2;
float numPerPng = floor(uSliceXnum) * floor(uSliceYnum);
float zSliceNumber1 = floor(texCoord.z * slices);
float zSliceNumber2 = min( zSliceNumber1 + 1.0, 255.0);
texCoord.xy /= vec2( uSliceXnum, uSliceYnum );
texCoord.y = texCoord.y - ( uTop * texCoord.y ) - ( uBottom * texCoord.y );
texCoordSlice1 = texCoordSlice2 = texCoord.xy;
texCoordSlice1.x += ( floor ( mod( zSliceNumber1, uSliceXnum ) ) / uSliceXnum );
texCoordSlice1.y += ( floor ( mod( floor((numPerPng - 1.0 - mod( zSliceNumber1, numPerPng)) / uSliceXnum ), uSliceYnum ) ) / uSliceYnum ) + ( uBottom / uSliceYnum );
texCoordSlice2.x += ( floor ( mod( zSliceNumber2, uSliceXnum ) ) / uSliceXnum );
texCoordSlice2.y += ( floor ( mod( floor((numPerPng - 1.0 - mod( zSliceNumber2, numPerPng)) / uSliceXnum ), uSliceYnum ) ) / uSliceYnum ) + ( uBottom / uSliceYnum );
if ( zSliceNumber1 >= 0.0 && zSliceNumber1 < numPerPng ) {
colorSlice1 = texture2D( cubeTex0, texCoordSlice1 );
}
else if ( zSliceNumber1 >= numPerPng && zSliceNumber1 < numPerPng * 2.0 ) {
colorSlice1 = texture2D( cubeTex1, texCoordSlice1 );
}
else if ( zSliceNumber1 >= numPerPng * 2.0 && zSliceNumber1 < numPerPng * 3.0 ) {
colorSlice1 = texture2D( cubeTex2, texCoordSlice1 );
}
else if ( zSliceNumber1 >= numPerPng * 3.0 && zSliceNumber1 < numPerPng * 4.0 ) {
colorSlice1 = texture2D( cubeTex3, texCoordSlice1 );
}
if ( zSliceNumber2 >= 0.0 && zSliceNumber2 < numPerPng ) {
colorSlice2 = texture2D( cubeTex0, texCoordSlice2 );
}
else if ( zSliceNumber2 >= numPerPng && zSliceNumber2 < numPerPng * 2.0 ) {
colorSlice2 = texture2D( cubeTex1, texCoordSlice2 );
}
else if ( zSliceNumber2 >= numPerPng * 2.0 && zSliceNumber2 < numPerPng * 3.0 ) {
colorSlice2 = texture2D( cubeTex2, texCoordSlice2 );
}
else if ( zSliceNumber2 >= numPerPng * 3.0 && zSliceNumber2 < numPerPng * 4.0 ) {
colorSlice2 = texture2D( cubeTex3, texCoordSlice2 );
}
colorSlice1.a = colorSlice1.g / 1.0;
if ( colorSlice1.r < levelMin ) colorSlice1.a = 0.0;
if ( colorSlice1.r > levelMax ) colorSlice1.a = 1.0 - levelMax / 1.0;
colorSlice2.a = colorSlice2.g / 1.0;
if ( colorSlice2.r < levelMin ) colorSlice2.a = 0.0;
if ( colorSlice2.r > levelMax ) colorSlice2.a = 1.0 - levelMax / 1.0;
colorSlice1.a *= uOpacity;
colorSlice2.a *= uOpacity;
if ( uColorMap == 0.0 ) {
colorSlice1.rgb = texture2D( transferTex, vec2( colorSlice1.a, 1.0) ).rgb;
colorSlice2.rgb = texture2D( transferTex, vec2( colorSlice2.a, 1.0) ).rgb;
}
else {
colorSlice1.rgb = texture2D( transferTex, vec2( 0.0, colorSlice1.a) ).rgb;
colorSlice2.rgb = texture2D( transferTex, vec2( 0.0, colorSlice2.a) ).rgb;
}
float zDifference = mod(texCoord.z * 255.0, 1.0);
return mix(colorSlice1, colorSlice2, zDifference);
}
3D Volume Rendering 기술을 이해하기 위해서는 OpenGL, WebGL, GLSL에 대한 이해가 선행되어야만 한다.