셰이더 소개

소개

앞서 Three.js를 소개했습니다. 이 도움말에서 다룰 내용의 기반이 되는 내용이므로 아직 읽지 않으신 경우 읽어 보시기 바랍니다.

셰이더에 대해 이야기하고자 합니다. WebGL은 훌륭합니다. 앞서 말씀드렸듯이 Three.js (및 기타 라이브러리)는 어려움을 추상화하여 해결해 줍니다. 하지만 특정 효과를 얻거나 화면에 표시되는 놀라운 효과를 자세히 살펴보고 싶을 때가 있을 수 있으며, 셰이더는 이러한 방정식의 일부가 될 것입니다. 또한 저와 마찬가지로 이전 튜토리얼의 기본적인 내용에서 조금 더 까다로운 내용으로 넘어가고 싶을 수도 있습니다. Three.js를 사용하고 있다고 가정하고 진행하겠습니다. Three.js는 셰이더를 실행하는 데 필요한 많은 작업을 대신해 주기 때문입니다. 처음에는 셰이더의 컨텍스트를 설명하고 이 튜토리얼의 후반부에서는 좀 더 고급 영역을 다룰 예정입니다. 셰이더는 언뜻 보기에 특이하고 설명이 필요하기 때문입니다.

1. 두 가지 셰이더

WebGL은 고정 파이프라인을 사용하지 않습니다. 즉, 즉시 렌더링할 수 있는 수단을 제공하지 않습니다. 하지만 프로그래밍 가능한 파이프라인이 제공하는 것은 더 강력하지만 이해하고 사용하기도 더 어렵습니다. 즉, 프로그래밍 가능한 파이프라인이란 프로그래머가 꼭짓점 등을 화면에 렌더링하는 작업을 개발자가 담당한다는 것을 의미합니다. 셰이더는 이 파이프라인의 일부이며 두 가지 유형이 있습니다.

  1. 정점 셰이더
  2. 프래그먼트 셰이더

두 가지 모두, 동의하시겠지만, 그 자체로는 아무런 의미가 없습니다. 두 가지 모두 그래픽 카드의 GPU에서 완전히 실행된다는 점에 유의하세요. 즉, CPU가 다른 작업을 할 수 있도록 최대한 많은 작업을 GPU에 오프로드해야 합니다. 최신 GPU는 셰이더에 필요한 기능에 크게 최적화되어 있으므로 사용하기에 좋습니다.

2. 정점 셰이더

구와 같은 표준 기본 도형을 선택합니다. 꼭짓점으로 이루어져 있죠? 정점 셰이더는 이러한 정점의 각각을 차례로 제공받으며 이를 조작할 수 있습니다. 각 꼭짓점으로 실제로 실행하는 작업은 정점 셰이더에 따라 다르지만 한 가지 책임이 있습니다. 정점 셰이더는 화면에서 정점의 최종 위치인 4D 부동 소수점 벡터인 gl_Position을 설정해야 합니다. 그 자체로 꽤 흥미로운 프로세스입니다. 실제로 3D 위치 (x, y, z가 있는 정점)를 2D 화면에 가져오거나 투영하는 것에 관해 이야기하고 있기 때문입니다. 다행히 Three.js와 같은 것을 사용하는 경우 너무 무거워하지 않고 gl_Position을 간단하게 설정할 수 있습니다.

3. 프래그먼트 셰이더

이제 정점이 있는 객체가 있고 이를 2D 화면에 투사했습니다. 그런데 사용하는 색상은 어떻게 되나요? 텍스처링과 조명은 어떨까요? 프래그먼트 셰이더의 용도는 바로 이것입니다. 정점 셰이더와 마찬가지로 프래그먼트 셰이더에도 하나의 필수 작업이 있습니다. 프래그먼트의 최종 색상인 또 다른 4D 부동 소수점 벡터인 gl_FragColor 변수를 설정하거나 삭제해야 합니다. 하지만 프래그먼트란 무엇인가요? 삼각형을 이루는 세 개의 꼭짓점을 생각해 보세요. 삼각형 안에 있는 각 픽셀을 그려야 합니다. 프래그먼트는 해당 삼각형의 각 픽셀을 그리기 위해 세 개의 정점에서 제공하는 데이터입니다. 따라서 프래그먼트는 구성 정점에서 보간된 값을 수신합니다. 한 정점의 색상이 빨간색이고 이웃 정점의 색상이 파란색이면 색상 값이 빨간색에서 보라색을 거쳐 파란색으로 보간됩니다.

4. 셰이더 변수

변수에 관해 이야기할 때 Uniforms, Attributes, Varyings라는 세 가지 선언을 할 수 있습니다. 이 세 가지에 대해 처음 들었을 때 제가 함께 일했던 다른 것과 일치하지 않기 때문에 매우 혼란스러웠습니다. 다음과 같이 생각해 보세요.

  1. 유니폼은 꼭짓점 셰이더와 프래그먼트 셰이더 모두로 전송되며 렌더링되는 전체 프레임에서 동일하게 유지되는 값을 포함합니다. 조명의 위치가 좋은 예입니다.

  2. 속성은 개별 정점에 적용되는 값입니다. 속성은 정점 셰이더에서 사용할 수 있습니다. 각 정점에 고유한 색상이 있는 것과 같은 예가 있습니다. 속성은 꼭지점과 일대일로 연결됩니다.

  3. Varying은 정점 셰이더에서 선언되고 프래그먼트 셰이더와 공유하려는 변수입니다. 이를 위해 정점 셰이더와 프래그먼트 셰이더 모두에서 동일한 유형과 이름의 가변 변수를 선언합니다. 조명 계산에 사용할 수 있으므로 정점의 법선이 이를 사용하는 일반적인 예입니다.

나중에는 세 가지 유형을 모두 사용하여 실제로 어떻게 적용되는지 가늠해 볼 것입니다.

이제 버텍스 셰이더와 프래그먼트 셰이더, 그리고 처리하는 변수 유형에 대해 알아봤으므로 만들 수 있는 가장 간단한 셰이더를 살펴볼 가치가 있습니다.

5. Bonjourno World

다음은 꼭짓점 셰이더의 Hello World입니다.

/**
* Multiply each vertex by the model-view matrix
* and the projection matrix (both provided by
* Three.js) to get a final vertex position
*/
void main() {
gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(position,1.0);
}   

다음은 프래그먼트 셰이더의 예시입니다.

/**
* Set the colour to a lovely pink.
* Note that the color is a 4D Float
* Vector, R,G,B and A and each part
* runs from 0.0 to 1.0
*/
void main() {
gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
}

그렇게 복잡하지는 않죠?

정점 셰이더에서는 Three.js에 의해 몇 개의 유니폼이 전송됩니다. 이 두 가지 유니폼은 모델 뷰 매트릭스와 프로젝션 매트릭스라고 하는 4D 매트릭스입니다. 이러한 요소가 어떻게 작동하는지 정확히 알 필요는 없지만 가능하면 항상 작동 방식을 이해하는 것이 가장 좋습니다. 간단히 말해, 꼭짓점의 3D 위치가 실제로 화면의 최종 2D 위치에 투영되는 방식입니다.

위의 스니펫에서는 이러한 변수를 제외했습니다. Three.js가 셰이더 코드 자체의 맨 위에 이러한 변수를 추가하므로 개발자가 직접 추가할 필요가 없기 때문입니다. 사실, 조명 데이터, 정점 색상, 정점 노멀과 같은 그 밖의 많은 항목도 추가됩니다. Three.js 없이 이 작업을 수행하면 모든 유니폼과 속성을 직접 만들고 설정해야 합니다. 실화입니다.

6. MeshShaderMaterial 사용

셰이더를 설정했지만 Three.js와 함께 사용하려면 어떻게 해야 할까요? 생각보다 쉬웠습니다. 다음과 같이 표시됩니다.

/**
* Assume we have jQuery to hand and pull out
* from the DOM the two snippets of text for
* each of our shaders
*/
var shaderMaterial = new THREE.MeshShaderMaterial({
vertexShader:   $('vertexshader').text(),
fragmentShader: $('fragmentshader').text()
});

거기에서 Three.js는 해당 머티리얼을 제공하는 메시에 연결된 셰이더를 컴파일하고 실행합니다. 이보다 더 간편한 것은 없습니다. 아마 그럴 것입니다. 하지만 브라우저에서 3D를 실행하는 것에 대해 이야기하고 있으므로 어느 정도의 복잡성을 예상할 수 있습니다.

MeshShaderMaterial에 두 가지 속성인 유니폼과 속성을 추가할 수 있습니다. 둘 다 벡터, 정수 또는 부동 소수점을 사용할 수 있지만 앞서 언급한 바와 같이 전체 프레임(즉, 모든 정점)에서 동일하므로 단일 값인 경우가 많습니다. 하지만 속성은 꼭짓점별 변수이므로 배열이어야 합니다. 속성 배열의 값 수와 메시의 정점 수는 일대일 관계여야 합니다.

7. 다음 단계

이제 애니메이션 루프, 꼭짓점 속성, 유니폼을 추가하는 데 시간을 할애하겠습니다. 또한 정점 셰이더가 일부 데이터를 프래그먼트 셰이더로 전송할 수 있도록 변동하는 변수를 추가합니다. 최종 결과는 분홍색이었던 구체가 위와 옆에서 빛을 받는 것처럼 보이고 맥박이 뛰는 것처럼 보입니다. 약간 기이하지만 이 그래프를 통해 세 가지 변수 유형과 서로의 관계, 그리고 기본 도형을 잘 이해할 수 있기를 바랍니다.

8. A Fake Light

평면적 색상의 객체가 아니도록 색상을 업데이트해 보겠습니다. Three.js에서 조명을 처리하는 방법을 살펴볼 수 있지만, 지금은 필요 이상으로 복잡하므로 조명을 조작해 보겠습니다. Three.js에 포함된 멋진 셰이더와 크리스 밀크와 Google의 최근 멋진 WebGL 프로젝트인 Rome셰이더를 모두 살펴보세요. 셰이더로 돌아갑니다. 각 정점 노멀을 프래그먼트 셰이더에 제공하도록 Vertex Shader를 업데이트합니다. 이를 위해 다음과 같은 다양한 요소를 사용합니다.

// create a shared variable for the
// VS and FS containing the normal
varying vec3 vNormal;

void main() {

// set the vNormal value with
// the attribute value passed
// in by Three.js
vNormal = normal;

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(position,1.0);
}

그리고 프래그먼트 셰이더에서 동일한 변수 이름을 설정한 다음, 꼭짓점 법선과 구체의 위쪽과 오른쪽에서 비추는 빛을 나타내는 벡터의 내적을 사용합니다. 이렇게 하면 3D 패키지의 방향광과 유사한 효과를 얻을 수 있습니다.

// same name and type as VS
varying vec3 vNormal;

void main() {

// calc the dot product and clamp
// 0 -> 1 rather than -1 -> 1
vec3 light = vec3(0.5,0.2,1.0);
    
// ensure it's normalized
light = normalize(light);

// calculate the dot product of
// the light to the vertex normal
float dProd = max(0.0, dot(vNormal, light));

// feed into our frag colour
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);

}

따라서 내적을 사용할 수 있는 이유는 두 벡터가 주어지면 두 벡터의 '유사성'을 나타내는 숫자가 나오기 때문입니다. 정규화된 벡터가 정확히 동일한 방향을 가리키면 값이 1이 됩니다. 반대 방향을 가리키면 -1이 됩니다. 이 숫자를 사용하여 조명에 적용합니다. 따라서 오른쪽 상단의 정점은 1과 같거나 그에 가까운 값(즉, 완전히 밝음)을 갖고, 측면의 정점은 0과 가까운 값을 갖고, 뒤쪽은 -1입니다. 음수인 경우 값을 0으로 제한하지만 숫자를 넣으면 기본 조명이 표시됩니다.

다음 단계 일부 정점 위치를 조정해 보는 것이 좋습니다.

9. 속성

이제 속성을 통해 각 정점에 임의의 숫자를 연결해 보겠습니다. 이 숫자를 사용하여 정규화를 따라 정점을 밀어 냅니다. 최종 결과는 페이지를 새로고침할 때마다 변경되는 일종의 이상한 스파이크 볼입니다. 아직 애니메이션이 적용되지는 않지만 (다음 단계에서 적용됨) 페이지를 새로고침하면 무작위로 선택된 것을 확인할 수 있습니다.

먼저 정점 셰이더에 속성을 추가해 보겠습니다.

attribute float displacement;
varying vec3 vNormal;

void main() {

vNormal = normal;

// push the displacement into the three
// slots of a 3D vector so it can be
// used in operations with other 3D
// vectors like positions and normals
vec3 newPosition = position + 
                    normal * 
                    vec3(displacement);

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(newPosition,1.0);
}

어떤 모습인가요?

별 차이가 없습니다. 이는 MeshShaderMaterial에서 속성이 설정되지 않았기 때문에 셰이더가 대신 0 값을 사용하기 때문입니다. 지금은 일종의 자리표시자와 같습니다. 이제 JavaScript의 MeshShaderMaterial에 속성을 추가하면 Three.js가 두 항목을 자동으로 연결해 줍니다.

또한 모든 속성과 마찬가지로 원래 속성은 읽기 전용이므로 업데이트된 위치를 vec3 변수에 할당해야 했습니다.

10. MeshShaderMaterial 업데이트

이제 변위를 구동하는 데 필요한 속성으로 MeshShaderMaterial을 업데이트하겠습니다. 속성은 정점당 값이므로 구체에는 정점당 하나의 값이 필요합니다. 다음 행을 추가하면 됩니다.

var attributes = {
displacement: {
    type: 'f', // a float
    value: [] // an empty array
}
};

// create the material and now
// include the attributes property
var shaderMaterial = new THREE.MeshShaderMaterial({
attributes:     attributes,
vertexShader:   $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});

// now populate the array of attributes
var vertices = sphere.geometry.vertices;
var values = attributes.displacement.value
for(var v = 0; v < vertices.length; v++) {
values.push(Math.random() * 30);
}

이제 깨진 구체가 보이지만 멋진 점은 모든 변위가 GPU에서 발생한다는 것입니다.

11. 애니메이션 처리

애니메이션을 적용해야 합니다. 어떻게 해야 하나요? 두 가지를 준비해야 합니다.

  1. 각 프레임에 얼마나 많은 변위를 적용해야 하는지 애니메이션으로 보여주는 유니폼입니다. 사인 또는 코사인을 사용하면 됩니다. 사인과 코사인은 -1에서 1 사이의 값을 갖기 때문입니다.
  2. JS의 애니메이션 루프

MeshShaderMaterial과 Vertex Shader에 모두 유니폼을 추가합니다. 먼저 정점 셰이더를 살펴보겠습니다.

uniform float amplitude;
attribute float displacement;
varying vec3 vNormal;

void main() {

vNormal = normal;

// multiply our displacement by the
// amplitude. The amp will get animated
// so we'll have animated displacement
vec3 newPosition = position + 
                    normal * 
                    vec3(displacement *
                        amplitude);

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(newPosition,1.0);
}

다음으로 MeshShaderMaterial을 업데이트합니다.

// add a uniform for the amplitude
var uniforms = {
amplitude: {
    type: 'f', // a float
    value: 0
}
};

// create the final material
var shaderMaterial = new THREE.MeshShaderMaterial({
uniforms:       uniforms,
attributes:     attributes,
vertexShader:   $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});

이제 셰이더가 완성되었습니다. 하지만 지금은 한 걸음 뒤로 물러난 것 같습니다. 이는 주로 진폭 값이 0이고 이를 변위와 곱하면 아무것도 변경되지 않기 때문입니다. 또한 애니메이션 루프를 설정하지 않았으므로 0이 다른 것으로 변경되지 않습니다.

이제 JavaScript에서 렌더링 호출을 함수로 래핑한 다음 requestAnimationFrame을 사용하여 호출해야 합니다. 여기에서 또한 균일 값을 업데이트해야 합니다.

var frame = 0;
function update() {

// update the amplitude based on
// the frame value
uniforms.amplitude.value = Math.sin(frame);
frame += 0.1;

renderer.render(scene, camera);

// set up the next call
requestAnimFrame(update);
}
requestAnimFrame(update);

12. 결론

이상입니다 이제 이상하고 약간 트립한 듯한 맥박이 뛰는 방식으로 애니메이션이 적용되는 것을 확인할 수 있습니다.

셰이더에 관해 다룰 수 있는 주제는 훨씬 더 많지만 이 소개가 도움이 되었기를 바랍니다. 이제 셰이더를 볼 때 셰이더를 이해하고 멋진 셰이더를 직접 만들 수 있습니다.