안녕하세요. 저는 Google의 데이터 아트팀에서 근무하는 마이클 장입니다. 최근에는 근처 별을 시각화하는 Chrome 실험인 100,000 Stars를 완료했습니다. 이 프로젝트는 THREE.js 및 CSS3D로 빌드되었습니다. 이 사례에서는 발견 프로세스를 간략히 설명하고 몇 가지 프로그래밍 기법을 공유한 후 향후 개선을 위한 몇 가지 생각을 마무리로 전달하겠습니다.
여기에서 다루는 주제는 상당히 광범위하며 THREE.js에 대한 약간의 지식이 필요하지만 기술적 사후 분석으로서도 즐겁게 읽으실 수 있기를 바랍니다. 오른쪽의 목차 버튼을 사용하여 관심 있는 영역으로 언제든지 이동하세요. 먼저 프로젝트의 렌더링 부분을 살펴본 다음 셰이더 관리를 살펴보고 마지막으로 WebGL과 함께 CSS 텍스트 라벨을 사용하는 방법을 살펴봅니다.
우주 탐색
Small Arms Globe를 완성한 직후, 필드는 피사계 심도가 있는 THREE.js 파티클 데모를 실험하고 있었습니다. 적용된 효과의 양을 조정하여 장면의 해석된 '크기'를 변경할 수 있음을 확인했습니다. 피사계 심도 효과가 극단적으로 강해지면 먼 물체가 매우 흐릿해집니다. 마치 현미경으로 장면을 보고 있는 것 같은 착각을 불러일으키는 틸트-시프트 사진과 비슷한 효과입니다. 반대로 효과를 낮추면 우주 공간을 응시하는 것처럼 보였습니다.
입자 위치를 삽입하는 데 사용할 수 있는 데이터를 찾기 시작했습니다. 이 과정에서 사전 계산된 xyz 데카르트 좌표와 함께 세 가지 데이터 소스 (Hipparcos, Yale Bright Star Catalog, Gliese/Jahreiss Catalog)를 모아 만든 astronexus.com의 HYG 데이터베이스를 찾았습니다. 지금부터 시작하겠습니다!
별 데이터를 3D 공간에 배치하는 프로그램을 만드는 데 약 1시간이 걸렸습니다. 데이터 세트에는 정확히 119,617개의 별이 있으므로 각 별을 파티클로 표현하는 것은 최신 GPU에 문제가 되지 않습니다. 개별적으로 식별된 별도 87개가 있으므로 소형 무기 지구에서 설명한 것과 동일한 기법을 사용하여 CSS 마커 오버레이를 만들었습니다.
이 시기에 Mass Effect 시리즈를 막 마쳤습니다. 이 게임에서 플레이어는 은하계를 탐색하고 완전히 허구의 위키백과식 역사(행성에서 번성했던 종, 지질학적 역사 등)를 다양한 행성을 스캔하고 읽어보면서 알아봅니다.
별에 관한 풍부한 실제 데이터를 알고 있으면 은하에 관한 실제 정보를 동일한 방식으로 표시할 수 있습니다. 이 프로젝트의 궁극적인 목표는 이 데이터에 생명을 불어넣고 시청자가 마치 '매스 이펙트'처럼 은하계를 탐색하고 별과 그 분포에 대해 알아보고 우주에 대한 경외심과 호기심을 느끼도록 하는 것입니다. 다양한 혜택이 마음에 드셨나요?
이 사례 연구의 나머지 부분을 시작하기 전에 저는 천문학자가 아니며 이 연구는 외부 전문가의 조언을 바탕으로 한 아마추어 연구 결과임을 밝혀야 할 것 같습니다. 이 프로젝트는 아티스트가 우주를 해석한 것으로 간주되어야 합니다.
갤럭시 빌드
별 데이터를 맥락에 맞게 배치할 수 있는 은하 모델을 절차적으로 생성하고, 이 모델을 통해 은하계에서 우리가 차지하는 위치를 멋지게 보여주고자 했습니다.
은하수를 생성하기 위해 10만 개의 입자를 생성하고 은하계의 팔이 형성되는 방식을 모방하여 나선형으로 배치했습니다. 수학적 모델이 아닌 표현적 모델이므로 나선 팔 형성의 세부사항에 대해서는 크게 신경 쓰지 않았습니다. 하지만 나선 팔의 수를 대략적으로 맞추고 '올바른 방향'으로 회전하도록 했습니다.
은하수 모델의 후반 버전에서는 입자를 강조하지 않고 입자와 함께 표면 이미지를 사용해 더 사진 같은 느낌을 주려고 했습니다. 실제 이미지는 약 7천만 광년 떨어진 나선 은하 NGC 1232이며, 은하수가 보이도록 이미지를 조작한 것입니다.
초기에 하나의 GL 단위(기본적으로 3D의 픽셀)를 1광년으로 표현하기로 결정했습니다. 이 약속은 시각화된 모든 항목의 배치를 통일했지만 나중에 심각한 정밀도 문제를 일으켰습니다.
다른 관례로는 카메라를 움직이는 대신 전체 장면을 회전하는 것이 있습니다. 이 방법은 다른 프로젝트에서 사용한 적이 있습니다. 한 가지 장점은 모든 것이 '턴테이블'에 배치되므로 마우스를 왼쪽과 오른쪽으로 드래그하면 해당 객체가 회전하지만 확대/축소는 camera.position.z를 변경하기만 하면 됩니다.
카메라의 시야 (FOV)도 동적입니다. 한쪽을 바깥쪽으로 당기면 시야가 넓어져 은하를 점점 더 많이 담을 수 있습니다. 별을 향해 안쪽으로 이동하면 그 반대의 현상이 발생하여 시야가 좁아집니다. 이렇게 하면 카메라가 근접 평면 클리핑 문제를 처리하지 않고도 FOV를 마치 신과 같은 돋보기처럼 좁혀 은하와 비교해 무한소인 것들을 볼 수 있습니다.
여기에서 은하핵에서 몇 단위 떨어진 곳에 태양을 '배치'할 수 있었습니다. 쿠이퍼 클리프의 반지름을 매핑하여 태양계의 상대적 크기도 시각화할 수 있었습니다 (결국 오오트 클라우드를 시각화하기로 선택함). 이 모델 태양계 내에서 지구의 단순화된 궤도와 태양의 실제 반지름을 비교하여 시각화할 수도 있습니다.
태양을 렌더링하기가 어려웠습니다. 내가 알고 있는 최대한 많은 실시간 그래픽 기법을 사용해 속여야 했습니다. 태양의 표면은 플라스마의 뜨거운 거품으로, 시간이 지남에 따라 맥박을 치고 변화해야 합니다. 이는 태양 표면의 적외선 이미지의 비트맵 텍스처를 통해 시뮬레이션되었습니다. 표면 셰이더는 이 텍스처의 그레이스케일을 기반으로 색상 조회를 실행하고 별도의 색상 램프에서 조회를 실행합니다. 이 조회가 시간 경과에 따라 이동하면 용암과 같은 왜곡이 발생합니다.
태양의 코로나에도 유사한 기법이 사용되었지만, https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js를 사용하여 항상 카메라를 향하는 평면 스프라이트 카드라는 점이 다릅니다.
태양 플레어는 토러스에 적용된 꼭짓점 및 프래그먼트 셰이더를 통해 만들어졌으며 태양 표면의 가장자리에서 회전합니다. 정점 셰이더에는 노이즈 함수가 있어 불룩한 모양으로 짜깁기됩니다.
이때 GL 정밀도 때문에 z-fighting 문제가 발생하기 시작했습니다. 정밀도에 관한 모든 변수가 THREE.js에 사전 정의되어 있으므로 엄청난 작업을 하지 않고는 정밀도를 실질적으로 높일 수 없었습니다. 원점 근처에서는 정확성 문제가 그다지 심각하지 않았습니다. 하지만 다른 별계를 모델링하기 시작하면서 문제가 발생했습니다.
z-fighting을 완화하기 위해 몇 가지 해킹을 사용했습니다. THREE의 Material.polygonoffset는 내가 이해하기로는 폴리곤을 다른 인식된 위치에 렌더링할 수 있는 속성입니다. 이는 코로나 평면이 항상 태양 표면 위에 렌더링되도록 강제하는 데 사용되었습니다. 그 아래에는 태양 '빛의 후광'이 렌더링되어 구체에서 멀어지는 날카로운 광선을 제공합니다.
정밀도와 관련된 또 다른 문제는 장면을 확대하면 별 모델이 흔들리기 시작한다는 점입니다. 이 문제를 해결하기 위해 장면 회전을 '0으로' 설정하고 별 모델과 환경 맵을 별 주위를 공전하는 것처럼 보이도록 별도로 회전해야 했습니다.
렌즈 플레어 만들기
우주 시각화에서는 렌즈 플레어를 과도하게 사용해도 괜찮을 것 같습니다. THREE.LensFlare를 사용하면 이 목적을 달성할 수 있습니다. 아나모픽 육각형과 약간의 JJ Abrams 효과를 추가하기만 하면 됩니다. 아래 스니펫은 장면에서 이를 구성하는 방법을 보여줍니다.
// This function returns a lesnflare THREE object to be .add()ed to the scene graph
function addLensFlare(x,y,z, size, overrideImage){
var flareColor = new THREE.Color( 0xffffff );
lensFlare = new THREE.LensFlare( overrideImage, 700, 0.0, THREE.AdditiveBlending, flareColor );
// we're going to be using multiple sub-lens-flare artifacts, each with a different size
lensFlare.add( textureFlare1, 4096, 0.0, THREE.AdditiveBlending );
lensFlare.add( textureFlare2, 512, 0.0, THREE.AdditiveBlending );
lensFlare.add( textureFlare2, 512, 0.0, THREE.AdditiveBlending );
lensFlare.add( textureFlare2, 512, 0.0, THREE.AdditiveBlending );
// and run each through a function below
lensFlare.customUpdateCallback = lensFlareUpdateCallback;
lensFlare.position = new THREE.Vector3(x,y,z);
lensFlare.size = size ? size : 16000 ;
return lensFlare;
}
// this function will operate over each lensflare artifact, moving them around the screen
function lensFlareUpdateCallback( object ) {
var f, fl = this.lensFlares.length;
var flare;
var vecX = -this.positionScreen.x _ 2;
var vecY = -this.positionScreen.y _ 2;
var size = object.size ? object.size : 16000;
var camDistance = camera.position.length();
for( f = 0; f < fl; f ++ ) {
flare = this.lensFlares[ f ];
flare.x = this.positionScreen.x + vecX * flare.distance;
flare.y = this.positionScreen.y + vecY * flare.distance;
flare.scale = size / camDistance;
flare.rotation = 0;
}
}
텍스처 스크롤을 실행하는 간단한 방법
'공간 방향 평면'의 경우 거대한 THREE.CylinderGeometry()가 만들어져 태양을 중심으로 배치되었습니다. 바깥쪽으로 퍼지는 '빛의 물결'을 만들기 위해 시간 경과에 따른 텍스처 오프셋을 다음과 같이 수정했습니다.
mesh.material.map.needsUpdate = true;
mesh.material.map.onUpdate = function(){
this.offset.y -= 0.001;
this.needsUpdate = true;
}
map
는 재료에 속한 텍스처로, 재정의할 수 있는 onUpdate 함수를 가져옵니다. 오프셋을 설정하면 텍스처가 해당 축을 따라 '스크롤'되며, needsUpdate = true를 스팸하면 이 동작이 반복됩니다.
색상 램프 사용
각 별은 천문학자가 할당한 '색상 색인'에 따라 색상이 다릅니다. 일반적으로 빨간색 별은 더 차갑고 파란색/보라색 별은 더 뜨겁습니다. 이 그라데이션에는 흰색과 중간 주황색의 띠가 있습니다.
별을 렌더링할 때 이 데이터를 기반으로 각 입자에 고유한 색상을 부여하고 싶었습니다. 이를 위해서는 입자에 적용된 셰이더 재료에 '속성'을 부여하면 됩니다.
var shaderMaterial = new THREE.ShaderMaterial( {
uniforms: datastarUniforms,
attributes: datastarAttributes,
/_ ... etc _/
});
var datastarAttributes = {
size: { type: 'f', value: [] },
colorIndex: { type: 'f', value: [] },
};
colorIndex 배열을 채우면 셰이더에서 각 입자에 고유한 색상이 지정됩니다. 일반적으로 색상 vec3을 전달하지만 이 경우에는 최종 색상 램프 조회용으로 부동 소수점을 전달합니다.
색 램프는 다음과 같았지만 JavaScript에서 비트맵 색상 데이터에 액세스해야 했습니다. 이를 위해 먼저 이미지를 DOM에 로드하고 캔버스 요소에 그린 다음 캔버스 비트에 액세스했습니다.
// make a blank canvas, sized to the image, in this case gradientImage is a dom image element
gradientCanvas = document.createElement('canvas');
gradientCanvas.width = gradientImage.width;
gradientCanvas.height = gradientImage.height;
// draw the image
gradientCanvas.getContext('2d').drawImage( gradientImage, 0, 0, gradientImage.width, gradientImage.height );
// a function to grab the pixel color based on a normalized percentage value
gradientCanvas.getColor = function( percentage ){
return this.getContext('2d').getImageData(percentage \* gradientImage.width,0, 1, 1).data;
}
그런 다음 이 동일한 메서드를 사용하여 별표 모델 뷰에서 개별 별표에 색상을 지정합니다.
셰이더 랭글링
프로젝트를 진행하면서 모든 시각 효과를 구현하려면 더 많은 셰이더를 작성해야 한다는 것을 알게 되었습니다. index.html에 셰이더가 있는 것이 지겹기 때문에 이 목적으로 맞춤 셰이더 로더를 작성했습니다.
// list of shaders we'll load
var shaderList = ['shaders/starsurface', 'shaders/starhalo', 'shaders/starflare', 'shaders/galacticstars', /*...etc...*/];
// a small util to pre-fetch all shaders and put them in a data structure (replacing the list above)
function loadShaders( list, callback ){
var shaders = {};
var expectedFiles = list.length \* 2;
var loadedFiles = 0;
function makeCallback( name, type ){
return function(data){
if( shaders[name] === undefined ){
shaders[name] = {};
}
shaders[name][type] = data;
// check if done
loadedFiles++;
if( loadedFiles == expectedFiles ){
callback( shaders );
}
};
}
for( var i=0; i<list.length; i++ ){
var vertexShaderFile = list[i] + '.vsh';
var fragmentShaderFile = list[i] + '.fsh';
// find the filename, use it as the identifier
var splitted = list[i].split('/');
var shaderName = splitted[splitted.length-1];
$(document).load( vertexShaderFile, makeCallback(shaderName, 'vertex') );
$(document).load( fragmentShaderFile, makeCallback(shaderName, 'fragment') );
}
}
loadShaders() 함수는 셰이더 파일 이름 목록 (프래그먼트 셰이더의 경우 .fsh, 정점 셰이더의 경우 .vsh 예상)을 가져와 데이터를 로드하려고 시도한 후 목록을 객체로 대체합니다. 최종 결과는 THREE.js 유니폼에 있으며 다음과 같이 셰이더를 전달할 수 있습니다.
var galacticShaderMaterial = new THREE.ShaderMaterial( {
vertexShader: shaderList.galacticstars.vertex,
fragmentShader: shaderList.galacticstars.fragment,
/_..._/
});
require.js를 사용할 수도 있었지만 이 목적으로 코드를 재조합해야 했습니다. 이 솔루션은 훨씬 쉽지만 THREE.js 확장 프로그램으로 개선할 수 있다고 생각합니다. 더 나은 방법을 제안해 주시거나 알려주시면 감사하겠습니다.
THREE.js 위에 CSS 텍스트 라벨
지난 프로젝트인 Small Arms Globe에서는 THREE.js 장면 위에 텍스트 라벨을 표시하는 방법을 연구했습니다. 사용한 메서드는 텍스트가 표시될 위치의 절대 모델 위치를 계산한 다음 THREE.Projector()를 사용하여 화면 위치를 확인하고 마지막으로 CSS 'top' 및 'left'를 사용하여 CSS 요소를 원하는 위치에 배치합니다.
이 프로젝트의 초기 반복에서는 동일한 기법을 사용했지만 루이스 크루즈가 설명한 다른 방법을 시도해 보고 싶었습니다.
기본 개념: CSS3D의 행렬 변환을 THREE의 카메라 및 장면에 일치시키면 마치 THREE의 장면 위에 있는 것처럼 CSS 요소를 3D에 '배치'할 수 있습니다. 하지만 여기에는 제한사항이 있습니다. 예를 들어 텍스트가 THREE.js 객체 아래에 표시될 수는 없습니다. 이는 'top' 및 'left' CSS 속성을 사용하여 레이아웃을 실행하는 것보다 훨씬 빠릅니다.
여기에서 데모 (및 소스 보기의 코드)를 확인할 수 있습니다. 하지만 그 이후로 THREE.js의 행렬 순서가 변경된 것으로 확인되었습니다. 업데이트한 함수는 다음과 같습니다.
/_ Fixes the difference between WebGL coordinates to CSS coordinates _/
function toCSSMatrix(threeMat4, b) {
var a = threeMat4, f;
if (b) {
f = [
a.elements[0], -a.elements[1], a.elements[2], a.elements[3],
a.elements[4], -a.elements[5], a.elements[6], a.elements[7],
a.elements[8], -a.elements[9], a.elements[10], a.elements[11],
a.elements[12], -a.elements[13], a.elements[14], a.elements[15]
];
} else {
f = [
a.elements[0], a.elements[1], a.elements[2], a.elements[3],
a.elements[4], a.elements[5], a.elements[6], a.elements[7],
a.elements[8], a.elements[9], a.elements[10], a.elements[11],
a.elements[12], a.elements[13], a.elements[14], a.elements[15]
];
}
for (var e in f) {
f[e] = epsilon(f[e]);
}
return "matrix3d(" + f.join(",") + ")";
}
모든 것이 변환되므로 텍스트가 더 이상 카메라를 향하지 않습니다. 해결 방법은 Object3D가 장면에서 상속된 방향을 '잃게' 하는 THREE.Gyroscope()를 사용하는 것이었습니다. 이 기법을 '게시판'이라고 하며 자이로스코프는 이 작업에 적합합니다.
좋은 점은 3D 텍스트 라벨 위로 마우스를 가져가면 드롭섀도우로 빛나게 하는 등 모든 일반 DOM 및 CSS가 계속 작동한다는 것입니다.
확대하면 서체 크기 조정으로 인해 배치 문제가 발생하는 것을 확인했습니다. 텍스트의 커닝과 패딩 때문일 수 있나요? 또 다른 문제는 DOM 렌더러가 렌더링된 텍스트를 텍스처가 적용된 쿼드로 취급하므로 확대하면 텍스트가 모자이크 현상을 일으킨다는 점입니다. 이 메서드를 사용할 때는 주의해야 합니다. 돌이켜 생각해 보면 거대한 글꼴 크기의 텍스트를 사용했을 수도 있었습니다. 이는 향후 살펴볼 만한 사항입니다. 이 프로젝트에서는 태양계 행성 옆에 있는 매우 작은 요소에 앞서 설명한 'top/left' CSS 배치 텍스트 라벨도 사용했습니다.
음악 재생 및 반복
Mass Effect의 'Galactic Map'에서 재생된 음악은 Bioware 작곡가 샘 훌릭과 잭 월의 작품으로, 방문자가 느끼기를 바랐던 감정을 담고 있었습니다. 음악은 분위기를 조성하는 데 중요한 부분이며, 우리가 추구하는 경외감과 경이감을 표현하는 데 도움이 된다고 생각했기 때문에 프로젝트에 음악을 사용하고자 했습니다.
제작자인 발디안 클럼프가 샘에게 연락하여 Mass Effect의 '편집실' 음악을 사용하도록 허락해 주었습니다. 트랙 제목은 'In a Strange Land'입니다.
음악 재생에 오디오 태그를 사용했지만 Chrome에서도 'loop' 속성이 안정적이지 않았습니다. 가끔 루프에 실패하기도 했습니다. 결국 이 이중 오디오 태그 해킹은 재생 종료 여부를 확인하고 재생을 위해 다른 태그로 전환하는 데 사용되었습니다. 아쉬운 점은 이 스틸이 항상 완벽하게 반복되지 않는다는 점입니다. 하지만 이것이 최선의 방법이라고 생각합니다.
var musicA = document.getElementById('bgmusicA');
var musicB = document.getElementById('bgmusicB');
musicA.addEventListener('ended', function(){
this.currentTime = 0;
this.pause();
var playB = function(){
musicB.play();
}
// make it wait 15 seconds before playing again
setTimeout( playB, 15000 );
}, false);
musicB.addEventListener('ended', function(){
this.currentTime = 0;
this.pause();
var playA = function(){
musicA.play();
}
// otherwise the music will drive you insane
setTimeout( playA, 15000 );
}, false);
// okay so there's a bit of code redundancy, I admit it
musicA.play();
개선의 여지가 있음
THREE.js를 사용한 지 얼마 되지 않아 데이터가 코드와 너무 많이 혼합되는 지경에 이르렀습니다. 예를 들어 재료, 텍스처, 도형 안내를 인라인으로 정의할 때는 기본적으로 '코드로 3D 모델링'을 했습니다. 이는 매우 불편한 부분이며 향후 THREE.js를 사용한 작업에서 크게 개선할 수 있는 영역입니다. 예를 들어 별도의 파일에서 재료 데이터를 정의하고, 가급적 일부 컨텍스트에서 보고 조정할 수 있으며 기본 프로젝트로 다시 가져올 수 있습니다.
Google의 동료인 레이 맥클러는 멋진 생성형 '우주 소음'을 만드는 데 시간을 보냈지만 웹 오디오 API가 불안정하여 Chrome이 자주 비정상 종료되기 때문에 이를 삭제해야 했습니다. 안타깝지만 향후 작업을 위해 사운드 공간에 대해 더 많이 생각하게 되었습니다. 이 글을 작성하는 시점에 Web Audio API가 패치되었으므로 이제 작동할 수 있습니다. 향후 주의해야 할 사항입니다.
WebGL과 결합된 서체 요소는 여전히 과제로 남아 있으며, 여기서 사용 중인 방법이 올바른지 100% 확신할 수 없습니다. 여전히 해킹처럼 느껴집니다. 향후 버전의 THREE는 신규 CSS 렌더러를 사용하여 두 세계를 더 효과적으로 결합할 수 있습니다.
크레딧
이 프로젝트를 진행할 수 있도록 허락해 주신 아론 코블린님께 감사드립니다. 훌륭한 UI 디자인 + 구현, 서체 처리, 둘러보기 구현에 참여해 주신 조노 브랜델님 발디안 클럼프님: 프로젝트 이름과 모든 문구를 제공해 주셔서 감사합니다. 데이터 및 이미지 소스의 수많은 사용 권리를 확보해 준 사바 아흐메드님 클렘 라이트님, 게시를 위해 적절한 담당자에게 연락해 주셔서 감사합니다. Doug Fritz(기술적 우수성) 조지 브라우어님께 JS와 CSS를 배웠습니다. 그리고 물론 THREE.js의 Doob님도 있습니다.