별표 100,000개 만들기

Michael Chang
Michael Chang

안녕하세요. 저는 Google 데이터 아트팀에서 일하는 마이클 창입니다. 최근 Google은 인근의 별자리를 시각화하는 Chrome 실험별 100,000개를 완료했습니다. 프로젝트는 THREE.js 및 CSS3D로 빌드되었습니다. 이 우수사례에서는 탐색 프로세스의 개요를 설명하고 몇 가지 프로그래밍 기법을 공유하고 향후 개선을 위한 아이디어로 마무리하겠습니다.

여기서 다루는 주제는 상당히 광범위하고 THREE.js에 대한 지식이 필요하지만 기술적 사후 분석을 통해 여전히 유용하게 활용할 수 있기를 바랍니다. 오른쪽의 목차 버튼을 사용하여 관심 영역으로 자유롭게 이동할 수 있습니다. 먼저 프로젝트의 렌더링 부분과 셰이더 관리, 그리고 마지막으로 WebGL과 함께 CSS 텍스트 라벨을 사용하는 방법을 보여줍니다.

100,000 Stars, 데이터 아트팀의 Chrome 실험실 기능
100,000 Stars, THREE.js를 사용해 은하수의 주변 별을 시각화

우주 탐험

Small Arms Globe가 끝난 직후에 피사계 심도가 있는 THREE.js 입자 데모를 실험해 보았습니다. 적용된 효과의 양을 조정하면 장면의 해석된 '배율'을 변경할 수 있습니다. 피사계 심도가 매우 극단적일 때 멀리 있는 물체는 틸트 시프트 사진에서 미세한 장면을 바라보는 듯한 착각을 불러일으키는 방식과 유사하게 굉장히 흐려졌습니다. 반대로 효과를 낮추면 마치 먼 우주를 바라보는 것처럼 보입니다.

저는 입자 위치를 주입하는 데 사용할 수 있는 데이터를 찾기 시작했습니다. 이 경로는 사전에 계산된 xyz 카테시안 좌표와 함께 세 가지 데이터 소스 (Hipparcos, Yale Bright Star Catalog, Gliese/Jahreiss Catalog)로 구성된 모음인 astronexus.com의 HYG 데이터베이스로 이어지는 경로입니다. 지금부터 시작하겠습니다!

별표 데이터 표시
첫 번째 단계는 카탈로그의 모든 별을 단일 입자로 표시하는 것입니다.
이름이 지정된 별이에요.
카탈로그에 있는 일부 별표의 경우 여기에 표시된 고유 명칭이 사용됩니다.

별 데이터를 3D 공간에 배치한 무언가를 해킹하는 데 약 1시간이 걸렸습니다. 데이터 세트에 정확히 119,617개의 별이 있으므로 최신 GPU에서는 입자로 각 별을 나타내는 것이 문제가 되지 않습니다. 개별적으로 식별된 별도 87개 있으므로 Small Arms Globe에서 설명한 것과 동일한 기술을 사용하여 CSS 마커 오버레이를 만들었습니다.

이때 저는 매스 이펙트 시리즈를 방금 다 봤습니다. 플레이어는 은하계를 탐험하고 다양한 행성을 스캔하고 위키백과처럼 가상의 역사를 살펴보게 됩니다. 지구에서 어떤 종이 번성했는지, 지질학적 역사에 관해 자세히 알아볼 수 있습니다.

항성에 관한 방대한 실제 데이터를 알면 은하에 대한 실제 정보를 같은 방식으로 표시할 수 있을 것입니다. 이 프로젝트의 궁극적인 목표는 이 데이터에 생명을 불어넣고, 시청자가 매스효과 은하를 살펴보고, 별과 그 분포에 대해 알아보며, 우주에 대한 경외심과 경외심을 불러일으키는 것입니다. 다양한 혜택이 마음에 드셨나요?

이 우수사례의 나머지 부분을 시작할 때 나는 결코 천문학자가 아니며 이 연구는 외부 전문가의 조언을 통해 이루어진 아마추어 연구의 결과라고 말해야 할 것입니다. 이 프로젝트는 예술가가 공간에 대한 해석으로 해석해야 합니다.

은하 건설

제 계획은 별 데이터를 컨텍스트와 연관 지을 수 있는 은하 모델을 절차적으로 생성하는 것이었습니다. 그리고 은하수 속 우리의 위치를 멋지게 보여주는 것이었으면 좋겠습니다.

은하의 초기 프로토타입입니다.
은하수 입자 시스템의 초기 프로토타입

저는 은하수를 생성하기 위해 100,000개의 입자를 생성한 후 은하의 팔이 형성되는 방식을 모방하여 나선형으로 배치했습니다. 나선형 팔 형태의 세부사항에 대해서는 크게 걱정할 필요가 없었습니다. 이 모델은 수학적 모델이 아닌 표현 모델일 것이기 때문입니다. 하지만 나는 나선형 팔의 개수를 거의 맞추고 '올바른 방향'으로 회전하려고 노력했습니다.

이후 버전의 은하수 모델에서는 은하가 입자를 수반하는 평평한 이미지를 선호하여 입자를 사용하는 것을 강조하지 않았으며, 이는 마치 은하가 사진처럼 풍성한 느낌을 얻게 해주길 바랐습니다. 실제 이미지는 우리로부터 약 7천만 광년 떨어진 나선 은하 NGC 1232의 이미지로, 은하수처럼 보이도록 조작되었습니다.

은하의 규모를 파악하는 중입니다.
모든 GL 단위는 광년입니다. 이 경우 구의 너비는 110,000광년이며, 이는 입자 시스템을 포괄합니다.

초기에 GL 단위 하나, 기본적으로 3D 픽셀 한 개를 1광년으로 표현하기로 했습니다. 이는 시각화된 모든 항목의 배치를 통합하는 규칙으로 나중에 심각한 정밀도 문제를 일으켰습니다.

제가 결정한 또 다른 규칙은 카메라를 이동하는 대신 전체 장면을 회전하는 것이었습니다. 이 방식은 다른 몇 가지 프로젝트에서도 수행했습니다. 한 가지 이점은 모든 것이 '턴테이블'에 배치되어 마우스로 좌우로 드래그하면 해당 객체가 회전한다는 점입니다. 확대는 camera.position.z를 변경하기만 하면 됩니다.

카메라의 시야 (FOV)도 동적입니다. 바깥쪽으로 당기면 시야가 넓어지고 점점 더 많은 은하계를 들여다볼 수 있습니다. 반대로 별을 향해 안쪽으로 이동하면 시야가 좁아집니다. 덕분에 카메라는 은하에 비해 무한한 사물을 볼 수 있습니다. FOV를 신과 같은 돋보기처럼 내려다 볼 수 있기 때문에 근거리 비행기 클리핑 문제를 해결할 필요가 없습니다.

은하계를 렌더링하는 다양한 방법
(위) 초기 입자 은하 (아래) 이미지 평면과 함께 제공되는 입자

여기에서 나는 은하의 핵심에서 떨어진 몇 개의 단위에 태양을 '배치'할 수 있었습니다. 또한 카이퍼 절벽의 반지름을 매핑하여 태양계의 상대적인 크기를 시각화할 수 있었습니다 (결국 오트 구름을 시각화하기로 함). 이 모형 태양계 내에서 단순화된 지구 궤도와 실제 태양의 반지름을 시각화할 수도 있습니다.

태양계.
행성 주위를 도는 태양과 카이퍼대를 나타내는 구가 있습니다.

태양은 렌더링하기 어려웠습니다. 내가 아는 만큼 많은 실시간 그래픽 기술을 사용해야 했습니다. 태양의 표면은 뜨거운 플라즈마 거품이며 시간이 지남에 따라 깜빡이고 변화합니다. 이는 태양 표면의 적외선 이미지의 비트맵 텍스처를 통해 시뮬레이션되었습니다. 표면 셰이더는 이 텍스처의 그레이 스케일을 기반으로 색상을 룩업하고 별도의 색 램프에서 룩업을 실행합니다. 이 관측치는 시간이 지나면서 바뀌면서 용암과 같은 왜곡이 형성됩니다.

태양의 코로나에도 비슷한 기술이 사용되었습니다. 단, https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js를 사용하여 항상 카메라를 마주하는 평평한 스프라이트 카드입니다.

렌더링 솔.
태양의 초기 버전

태양광 플레어는 원환체에 적용한 꼭짓점과 프래그먼트 셰이더를 통해 만들어졌으며 태양 표면의 가장자리 바로 부근에서 회전합니다. 꼭짓점 셰이더에는 노이즈 함수가 있어 blob과 유사한 방식으로 짜게 됩니다.

여기서부터 GL 정밀도로 인해 z-파이팅 문제가 발생하기 시작했습니다. 정밀도와 관련된 모든 변수는 THREE.js에 사전 정의되어 있으므로 많은 노력 없이는 정밀도를 현실적으로 높일 수 없었습니다. 정밀도 문제는 원점 근처에서 그렇게 나쁘지 않았습니다. 하지만 다른 항성계를 모델링하기 시작하자 이 점이 문제가 되었습니다.

모델에 별표표시합니다.
태양을 렌더링하는 코드는 나중에 다른 별을 렌더링하도록 일반화되었습니다.

Z-파이팅을 완화하기 위해 제가 사용한 몇 가지 꿀팁이 있었습니다. THREE의 Material.polygonoffset은 내가 아는 한, 인식된 다른 위치에서 다각형을 렌더링할 수 있는 속성입니다. 이는 코로나 평면이 항상 태양 표면 위에 렌더링되도록 하는 데 사용되었습니다. 그 아래에는 태양 '후광'이 렌더링되어 구에서 벗어나는 선명한 광선을 줍니다.

정밀도와 관련된 또 다른 문제는 장면을 확대함에 따라 별 모델이 흔들리기 시작한다는 것입니다. 이 문제를 해결하려면 장면 회전을 '제로' 설정하고 별 모델과 환경 지도를 별도로 회전하여 사용자가 별 궤도를 도는 것처럼 보이게 했습니다.

Lensflare를 만드는 중

강력한 힘에는 큰 책임이 따릅니다.
엄청난 힘에는 큰 책임이 따릅니다.

공간 시각화를 사용하면 렌즈플레어 현상을 과도하게 사용하면 벗어날 수 있을 것 같은 느낌이 듭니다. 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 함수를 가져옵니다. 오프셋을 설정하면 해당 축을 따라 텍스처가 '스크롤'되고 needUpdate = 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,
/_..._/
});

required.js를 사용할 수도 있었을 것입니다. 다만 이 목적으로는 코드를 다시 어셈블해야 했습니다. 이 솔루션은 훨씬 더 쉽지만, 아마도 THREE.js 확장 프로그램으로도 개선할 수 있을 것 같습니다. 제안사항이나 개선 방법이 있으면 알려주시기 바랍니다.

THREE.js 위에 배치된 CSS 텍스트 라벨

마지막 프로젝트인 Small Arms Globe에서 THREE.js 장면 위에 텍스트 라벨을 표시하는 장난을 했습니다. 제가 사용한 메서드는 텍스트를 표시할 위치의 절대 모델 위치를 계산한 다음 THREE.Projector()를 사용하여 화면 위치를 확인하고, 마지막으로 CSS 'top'과 'left'를 사용하여 CSS 요소를 원하는 위치에 배치합니다.

이 프로젝트의 초기 반복에서도 같은 기법을 사용했지만 루이스 크루즈가 설명한 다른 방법을 사용해 보고 싶었습니다.

기본 아이디어는 CSS3D의 행렬 변환을 THREE의 카메라 및 장면에 일치시키고, 마치 3개의 장면 위에 있는 것처럼 3D로 CSS 요소를 '배치'하는 것입니다. 하지만 여기에는 제한사항이 있습니다. 예를 들어 텍스트를 THREE.js 객체 아래에 둘 수 없습니다. 이 방법은 '상단' 및 '왼쪽' CSS 속성을 사용하여 레이아웃을 실행하는 것보다 훨씬 빠릅니다.

텍스트 라벨
CSS3D 변환을 사용하여 WebGL 위에 텍스트 라벨을 배치합니다.

여기에서 이에 대한 데모 (및 뷰 소스의 코드)를 확인할 수 있습니다. 하지만 이후 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가 계속 재생된다는 것입니다.

텍스트 라벨
텍스트 라벨이 항상 카메라를 향하도록 하려면 카메라를 THREE.Gyroscope()에 연결합니다.

확대했을 때 서체의 크기 조정으로 인해 배치에 문제가 발생한다는 것을 발견했습니다. 텍스트의 커닝 및 패딩 때문일 수도 있습니다. DOM 렌더러가 렌더링된 텍스트를 텍스처 쿼드로 취급하기 때문에 확대하면 텍스트가 모자이크로 나타나는 문제도 있었습니다. 이 메서드를 사용할 때 주의해야 합니다. 되돌아 보면 글꼴 크기의 커다란 텍스트를 사용했을 수 있는데, 이는 향후 탐구를 위한 것입니다. 이 프로젝트에서는 또한 앞에서 설명한 '상단/왼쪽' CSS 배치 텍스트 라벨을 태양계의 행성에 수반되는 매우 작은 요소에 사용했습니다.

음악 재생 및 반복

매스 이펙트의 '은하계 지도(Galactic Map)'에서 바이오웨어 작곡가인 샘 훌릭과 잭 월(Sam Hulick)이 연주한 음악은 제가 방문객에게 보여주고 싶은 감정을 담고 있었습니다. 분위기에 중요한 부분이라고 생각하여 우리가 추구하는 경외심과 놀라움을 자아내는 데 도움이 되는 음악을 프로젝트에 포함시키고 싶었습니다.

프로듀서 Valdean Klump가 샘에게 연락하여 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 디자인 + 구현, 유형 처리, 둘러보기 구현에 대해 설명합니다. 프로젝트의 이름과 모든 사본을 지어주신 발딘 클럼프입니다. 사바 아흐메드는 데이터 및 이미지 소스에 대한 미터 톤의 사용권을 승인했습니다. 클레임 라이트가 기사 작성을 담당할 사람들에게 연락했습니다. 기술 우수성 부문에서 더그 프리츠가 맡고 있습니다. JS와 CSS를 가르쳐 주신 조지 브라워입니다. 물론 3개는 둡 씨.

참조