안녕하세요. 저는 Google의 Data Arts팀에서 일하는 마이클 창입니다. 최근에는 주변 별을 시각화하는 Chrome 실험인 100,000 Stars를 완료했습니다. 이 프로젝트는 THREE.js 및 CSS3D로 빌드되었습니다. 이 우수사례에서는 검색 프로세스를 설명하고, 몇 가지 프로그래밍 기법을 공유하며, 향후 개선을 위한 몇 가지 아이디어를 제시합니다.
여기에서 다루는 주제는 상당히 광범위하며 THREE.js에 관한 지식이 필요하지만, 기술적 사후 분석으로도 즐길 수 있기를 바랍니다. 오른쪽의 목차 버튼을 사용하여 관심 있는 영역으로 바로 이동할 수 있습니다. 먼저 프로젝트의 렌더링 부분을 보여드리고, 셰이더 관리, 마지막으로 WebGL과 함께 CSS 텍스트 라벨을 사용하는 방법을 보여드리겠습니다.

스페이스 살펴보기
Small Arms Globe를 마친 직후 THREE.js 피사계 심도 파티클 데모를 실험하고 있었습니다. 적용된 효과의 양을 조정하여 해석된 장면의 '규모'를 변경할 수 있다는 것을 알게 되었습니다. 피사계 심도 효과가 매우 강할 때는 원거리 물체가 매우 흐릿해져 마치 현미경으로 장면을 보는 듯한 착시를 주는 틸트 시프트 사진과 유사해졌습니다. 반대로 효과를 낮추면 깊은 우주를 응시하는 것처럼 보였습니다.
입자 위치를 삽입하는 데 사용할 수 있는 데이터를 찾기 시작했고, 그 과정에서 astronexus.com의 HYG 데이터베이스를 알게 되었습니다. 이 데이터베이스는 사전 계산된 xyz 데카르트 좌표와 함께 세 가지 데이터 소스 (Hipparcos, Yale Bright Star Catalog, Gliese/Jahreiss Catalog)를 컴파일한 것입니다. 지금부터 시작하겠습니다


별 데이터를 3D 공간에 배치하는 데는 한 시간 정도 걸렸습니다. 데이터 세트에는 정확히 119,617개의 별이 있으므로 각 별을 파티클로 표현하는 것은 최신 GPU에 문제가 되지 않습니다. 개별적으로 식별된 별도 87개 있으므로 Small Arms Globe에서 설명한 것과 동일한 기법을 사용하여 CSS 마커 오버레이를 만들었습니다.
이때 Mass Effect 시리즈를 막 끝냈습니다. 게임에서 플레이어는 은하계를 탐험하고 다양한 행성을 스캔하여 위키피디아와 유사한 완전히 허구적인 역사를 읽어보도록 초대됩니다. 행성에서 번성한 종, 지질학적 역사 등이 있습니다.
별에 관한 실제 데이터가 풍부하다는 점을 고려하면 은하에 관한 실제 정보를 같은 방식으로 제시할 수 있습니다. 이 프로젝트의 궁극적인 목표는 이 데이터를 생생하게 구현하여 시청자가 Mass Effect처럼 은하계를 탐험하고, 별과 그 분포에 대해 배우고, 우주에 대한 경외감과 호기심을 불러일으키는 것입니다. 다양한 혜택이 마음에 드셨나요?
이 케이스 스터디의 나머지 부분을 시작하기 전에 저는 결코 천문학자가 아니며, 외부 전문가의 조언을 바탕으로 한 아마추어 연구의 결과물임을 밝혀두는 것이 좋을 것 같습니다. 이 프로젝트는 공간에 대한 아티스트의 해석으로 간주해야 합니다.
갤럭시 빌드
내 계획은 별 데이터를 맥락에 맞게 배치할 수 있는 은하 모델을 절차적으로 생성하여 우리 위치를 멋지게 보여주는 것이었습니다.

은하수를 생성하기 위해 100,000개의 입자를 생성하고 은하 팔이 형성되는 방식을 모방하여 나선형으로 배치했습니다. 수학적 모델이 아닌 표현 모델이므로 나선팔 형성의 세부사항에 대해서는 크게 걱정하지 않았습니다. 하지만 나선형 팔의 수를 대략적으로 맞추고 '올바른 방향'으로 회전하도록 노력했습니다.
나중에 출시된 Milky Way 모델에서는 입자 사용을 강조하지 않고 입자와 함께 은하의 평면 이미지를 사용하여 사진과 같은 모양을 더 많이 표현했습니다. 실제 이미지는 약 7천만 광년 떨어진 나선은하 NGC 1232로, 우리 은하처럼 보이도록 이미지 조작을 거쳤습니다.

저는 일찍부터 3D의 기본 단위인 GL 단위를 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-파이팅을 완화하기 위해 사용한 몇 가지 트릭이 있었습니다. THREE.js의 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을 전달하지만 이 인스턴스에서는 최종 색상 램프 조회를 위해 float를 전달합니다.

색상 램프는 다음과 같았지만 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 요소를 배치합니다.
이 프로젝트의 초기 반복에서는 동일한 기법을 사용했지만 Luis Cruz가 설명한 다른 방법을 시도해 보고 싶었습니다.
기본 아이디어는 CSS3D의 매트릭스 변환을 Three.js의 카메라 및 장면과 일치시켜 CSS 요소를 Three.js의 장면 위에 있는 것처럼 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()를 사용하는 것이었습니다. 이 기법을 '빌보딩'이라고 하며, Gyroscope는 이 작업을 수행하는 데 적합합니다.
정상적인 DOM과 CSS가 모두 함께 작동하여 3D 텍스트 라벨에 마우스를 가져가면 그림자가 빛나는 등 아주 좋습니다.

확대할 때 서체의 크기 조절로 인해 배치 문제가 발생하는 것을 확인했습니다. 텍스트의 커닝과 패딩 때문일 수 있습니다. 또 다른 문제는 DOM 렌더러가 렌더링된 텍스트를 텍스처가 적용된 사각형으로 처리하므로 확대하면 텍스트가 모자이크 처리된다는 것입니다. 이 방법을 사용할 때 주의해야 합니다. 돌이켜보면 거대한 글꼴 크기의 텍스트를 사용해도 되었을 것 같습니다. 이는 향후 탐구해 볼 만한 사항입니다. 이 프로젝트에서는 앞서 설명한 'top/left' CSS 배치 텍스트 라벨을 태양계의 행성과 함께 표시되는 아주 작은 요소에도 사용했습니다.
음악 재생 및 반복
Mass Effect의 'Galactic Map'에서 재생되는 음악은 Bioware 작곡가인 Sam Hulick과 Jack Wall이 작곡한 곡으로, 제가 방문자에게 경험시키고 싶었던 감정을 담고 있었습니다. 프로젝트에 음악을 넣고 싶었습니다. 음악이 분위기를 조성하는 데 중요한 역할을 하여 저희가 목표로 하는 경외감과 놀라움을 만들어낼 수 있다고 생각했기 때문입니다.
프로듀서인 발딘 클럼프가 샘에게 연락했고, 샘은 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를 사용한 향후 노력으로 크게 개선할 수 있는 영역입니다. 예를 들어 일부 컨텍스트에서 볼 수 있고 조정할 수 있으며 기본 프로젝트로 다시 가져올 수 있는 별도의 파일에 재질 데이터를 정의할 수 있습니다.
동료인 Ray McClure도 멋진 생성형 '공간 노이즈'를 만드는 데 시간을 할애했지만, 웹 오디오 API가 불안정하여 Chrome이 자주 비정상 종료되는 바람에 잘라내야 했습니다. 아쉽지만 향후 작업을 위해 사운드 공간에 대해 더 많이 생각하게 되었습니다. 이 글을 쓰는 시점에 Web Audio API가 패치되었다고 들었으므로 지금은 작동할 수 있습니다. 향후에 주의해야 할 사항입니다.
WebGL과 결합된 서체 요소는 여전히 해결해야 할 문제이며, 여기서 하고 있는 작업이 올바른 방법인지 100% 확신할 수 없습니다. 여전히 해킹처럼 느껴집니다. 출시 예정인 CSS 렌더러가 포함된 향후 버전의 THREE를 사용하면 두 세계를 더 잘 결합할 수 있을 것입니다.
크레딧
이 프로젝트를 마음껏 진행할 수 있도록 허락해 준 Aaron Koblin에게 감사드립니다. 훌륭한 UI 디자인 및 구현, 서체 처리, 투어 구현을 담당한 Jono Brandel 프로젝트 이름과 모든 사본을 제공해 준 Valdean Klump 데이터 및 이미지 소스의 사용 권한을 정리해 주신 Sabah Ahmed님께 감사드립니다. 게시를 위해 적절한 사람에게 연락해 주신 Clem Wright님께 감사드립니다. 기술적 우수성을 인정받은 더그 프리츠 JS와 CSS를 가르쳐 준 George Brower에게 감사드립니다. THREE. js를 만들어 주신 Mr.Doob에게도 감사드립니다.