본문으로 바로가기

동적 셰이더 조합

category Technical Report/Graphics Tech Reports 2017. 8. 18. 11:01
반응형


원문출처 : http://www.talula.demon.co.uk/hlsl_fragments/hlsl_fragments.html

ShaderX3 책에 셰이더 조합에 대해 기고한 문서를 저자가 웹에 공개한 걸 번역해봤습니다라고 하는데 원문은 링크가 깨져있네요. 번역하신분 블로그 링크는 아래 주소입니다.

http://adolys.tistory.com/entry/%EB%8F%99%EC%A0%81-%EC%85%B0%EC%9D%B4%EB%8D%94-%EC%A1%B0%ED%95%A9


프로젝트의 셰이더 관리/구성에 대해 읽어볼만한 내용입니다.




동적 세이더 조합



Generating Shaders From HLSL Fragments

By Shawn Hargreaves



 셰이더는 멋진 개념이다. 셰이더로 이때껏 ShaderX책에 나온 흥미로운 모든 것들을 해볼 수 있다.
그러나 이 힘의 한편에서는 프로그램 가능한 셰이더가 '조합의 폭발'로 치닫기도 한다. 내가 최근에 참여한 Xbox게임은 89개의 픽셀 셰이더를 사용했고, 지금 하고 있는 프로젝트는 훨씬 더 많은 셰이더를 사용한다. 이중에 대다수는 아주 기본적인 테마들의 조합이다. 예를 들어 매터리얼의 LOD라든가 애니메이션 유무, 광원 형태들의 여러가지 조합인 것이다. 가능한 모든 조합들의 가짓수는 엄청나며 계속해서 늘어만 간다. 모든 코드를 손으로 타이핑하는 것은 소모적이며, 에러를 유발하기 쉽고, 유지보수는 곧 악몽이다.


이 문서에서는 많은 수의 셰이더 조합을 여러개의 간단한 셰이더 코드 조각들로부터 자동적으로 생성해내는 방법에 대해 서술한다.




Uber Shaders (Uber:슈퍼, 커다란, 엄청난)


조합 폭발을 해결하는 일반적인 방법으로 모든 기능 동작을 하나의 셰이더 프로그램에 전부 코딩하고, 애플리케이션이 그때그때 필요하지 않은 기능 요소를 끄는disable 방법이 있다. 이것은 여러가지 방법으로 구현될 수 있다.


- (그냥 일단 다 계산하고) 원하지 않는 효과의 계산값을 그냥 무시하는 것. 이것은 셰이더 상수에 값을 넣는 것만큼 손쉽지만 데이터가 계산되고 나서 버려지기 때문에 gpu 파워를 낭비하는 꼴이다.
- vs2.0과 ps2.x에 제공되는 정적인 흐름 제어(if문)으로 분기 제어하기. 어떤 하드웨어에서는 이것이 전혀 시간이 들지 않는 방법이긴 하지만, 복잡한 분기제어는 컴파일러가 최적화를 하는데 문제가 된다. 게다가 너무 많은 명령어를 한 셰이더에 쑤셔넣다보면 명령어 제한 수에 금방 다다르게 된다.
- 프리프로세서문으로 해결하기. #ifndef 블록을 이용하여 코드의 여러군데를 켜고 끌 수 있게 한다. 조합코드를 모두 생성하기 위해 같은 소스를 여러번 컴파일해야 한다.


이런 접근방식의 문제은 하나의 커다란 셰이더 프로그램에 모든 것을 집어넣어야 한다는 점이다. 코어 라이팅 모델, 한번 생겼다 없어지는 이펙트, 디버깅용 정보 출력, 그리고 6개월전에 당신이 넣었다가 주석처리한 코드, 이 모든 것이 한데 얽혀서 당신은 감히 어디서부터 손을 대야할지, 어떻게 수정을 해야 전체 동작을 망가뜨리지 않을런지 하는 걱정에 휩싸이게 된다.




Micro Shaders


대안은 여러개의 작은 셰이더 조각들을 작성하고 그것들을 조합별로 합치는 것이다. strcat()을 사용해서 셰이더 코드를 합칠 수도 있다. 아니면 NVLink나 D3DXFragmentLinker를 사용해서 조각들을 합치던가.


세이더 1.x 시절로 돌아가보자. 우리는 그때 Climax(회사명)에서 #include 전처리문을 사용해서 조각 소스를 적절한 순서로 인클루드했었다. 일례로, MotoGP(게임명)에서 높은 퀄리티의 캐릭터 정점 셰이더를 다음처럼 처리했었다.


    #define WANT_NORMAL

    #include "animate.vsi"
    #include "transform.vsi"
    #include "light.vsi"
    #include "fog.vsi"
    #include "radiosity.vsi"
    #include "envmap.vsi"


    mov oT0.xy, iTex0


이런 접근방식은 간단한 셰이더에는 잘 작동했지만, 입력된 값이 각각의 셰이더 조각에서 어떤 레지스터에 얹혀 처리되는지 추적하기가 어려웠다. 일을 좀더 견고하고 알기쉽게 하려면 일종의 자동 레지스터 할당 처리기가 필요했다. 다행스럽게도 이건 바로 HLSL이 우리를 대신해 해주는 일이다!


고수준의 셰이더 언어는 누구라도 셰이더 코드를 프로그래밍할 수 있게 해주는 정말 멋진 혜택이다. 두개의 세이더 조각이 하나의 데이터를 공유하게 하고 싶다면, 그냥 변수 이름을 같게 써주면 된다. 그러면 컴파일러는 어느 레지스터에 그 데이터를 넣어야 좋을지 파악할 수 있다. 각각의 셰이더 조각이 private 데이터를 원한다면 대충 적당한 접미사따위를 붙여서 각각 다른 이름의 변수가 되도록 하기만 하면 된다. 그러면 컴파일러는 다른 심벌을 구분할 수 있고 데이터는 각자의 레지스터를 할당받을 수 있다. 이것말고도 HLSL이 주는 큰 이득은 컴파일러가 알아서 사용되지 않는 변수나 너저분한 코드를 알아서 정리해 주는 것이다.


보통 셰이더 조각은 몇개의 중간값을 계산해낸다. 그런데 뒤에 오는 세이더 조각은 이 값 중 하나만 빼고 다른 것은 필요하지 않아서, 다른 값으로 덮어써 버린다. 비슷한 경우로 여러개의 조각이 똑같은 계산을 각자가 수행하는 경우가 있다. 예를 들자면 입력 노멀을 뷰공간으로 변환하는 것이 있겠다. 이런 경우들은 직접 찾아내기가 정말 어렵고 없애기 어려운 낭비가 되겠지만 다행스럽게도 이런 일을 직접 할 필요가 없다. 조각 합성기(fragment combiner)는 그것이 무엇을 의미하는지만 알려주면 된다. 그러면 컴파일러가 중복을 무시하거나 사용되지 않는 계산을 제거하는 일을 해 줄 것이다.(이문장은 번역이 자신이 없습니다. 원문: The fragment combiner only has to say what it means, ignoring any duplicate or unused calculations that may result, as the compiler can be trusted to make the details efficient.)


다음 장에서는 HLSL 조각에서 셰이더를 조합해내는 방법의 주요 개념을 설명해보겠다.




HLSL Fragments


우리는 각각의 셰이더 조각을 텍스트 파일로 저장한다. 여기에는 셰이더 코드와 요구되는 사용 문맥usage context을 명시한 인터페이스 블록을 담는다.


다음은 아주 간단한 2D컬러맵 텍스처의 세이더 조각 예시다.


    interface()
    {
        $name = base_texture
        $textures = color_map
        $vertex = uv
        $interpolators = uv
        $uv = 2
    }


    ps 1_1

    void main(INPUT input, inout OUTPUT output)
    {
        output.color = tex2D(color_map, input.uv);
    }


우리는 인터페이스 블록을 읽어들이기 위해 Climax에서 자체 제작했던 스크립트 파서를 사용했다. 그렇다고는해도 이건 XML 형식과 비슷한 "var=value"형태의 포맷을 읽어들이는 파서일 뿐이다.


픽셀과 정점 처리는 함께 링크되어 있다. 각각의 조각은 픽셀과 정점 셰이더코드를 하나의 파일 안에 가지고 있다. 여러개의 조각이 합쳐질 때 최종 생성되는 픽셀,정점 세이더는 각기 병렬적으로 만들어진다. 이렇게 한데 묶어두는 것이 사용자가 쓰기에 좋은 인터페이스가 되고, 잘못된 셰이더 짝(정점, 픽셀 짝)을 연결해서 생기는 잠재적 문제들을 제거할 수도 있다. 게다가 이렇게 하는 것이 특정 코드를 정점 셰이더나 픽셀 셰이더로 옮기는 최적화 작업을 수월하게 만든다. 그러나 종종 픽셀 셰이더들은 같은 정점 셰이더에 대응하는 경우가 많기 때문에 이 방식은 때때로 필요이상의 결과를 만들어내기도 한다. 이 문제는 조합 시스템 외부에서 처리할 수가 있다. 컴파일된 코드의 토큰 스트림을 비교해서 같은 것은 합쳐버리면 되는 것이다.


위에서 보인 조각 예시에는 정점 셰이더가 없다. 이런 경우에 조합 시스템은 특별한 처리 없이 입력값을 픽셀 셰이더로 그냥 넘겨버리는 기본적인 정점 셰이더를 끼워넣는다. per-pixel 처리만을 필요로 하는 이런 예는 점점 많아지고 있다.




Code Generation


개발을 하면서, 조합 시스템은 엔진에서 요구받은 셰이더를 생성한 후 결과를 디스크에 저장하기 때문에 최종 제품에서는 조합에 의한 런타임 부하가 전혀 없다. 조합 프로세스는 가장 낮은 버전으로 셰이더를 컴파일 시도한다. 실패한다면 그다음 높은 버전으로 다시 시도한다. 만약 ps1.1버전의 조각들이 여러개가 있다고 치자. 이것들을 합치고 나면 명령어 갯수는 ps1.1에서 컴파일될 수 없을 정도로 많아질 수 있다. 각각의 조각들은 자신이 컴파일될 때 적어도 어떤 버전이 필요한지를 명시하고 있다. 예를 들어 어떤 조각이 ps 2.0이나 3.0의 기능을 사용하고 있다면 우리는 이것을 굳이 1.1에서 컴파일 시도해보는 수고는 하지 않아도 된다.


인터페이스 블록은 그 셰이더 조각이 필요로 하는 리소스의 목록을 보여준다.


- "params" 문은 이 셰이더 조각에서 사용하는 상수 레지스터
- "textures" 항목은 어떤 텍스처 샘플러가 사용될지를 선언
- "vertex" 문은 버텍스 셰이더의 인풋 데이터 포맷
- "interpolators" 행은 버텍스 셰이더에서 픽셀 셰이더로 보내질 데이터 포맷을 선언


모든 선언들은 타입 정보를 함께 표현될 수 있다. 메타데이터(즉, 타입정보)는 에디팅 툴이 매터리얼을 올바르게 처리할 수 있도록 하며, 이 조각이 자신이 사용되는 문맥context에 따라서 자신을 적절히 적응시키는 경우에 조건 테스트로 사용될 수 있다. 위에 보여드린 조각을 간단한 예로 들자면  이 조각은 "uv" 버텍스 인풋과 인터폴레이터 채널은 2차원 벡터임을 선언하고 있다.


조각들의 목록이 주어지면 우리는 문장을 검색하고 오퍼레이션을 치환하는 작업을 통해 완전한 셰이더를 생성해낼 수 있게 된다. 우리의 목적은 우리가 저 코드를 직접 컴파일하는 것이 아니고 단지 조각들을 합치는 것이기 때문에 여기에는 HLSL문법을 직접 파싱하거나 하는 일 같은 건 전혀 필요하지 않다!


우리는 간단한 예를 통해 우리의 시스템이 어떻게 작동하는지를 살펴보았다. 자, 이제 우리는 위에서 살펴본 "base_texture" 조각과 역시 또 간단한 "detail_tex" 조각을 합치기를 원한다고 해보자.


    interface()
    {
        $name = detail_tex
        $textures = detail_map
        $vertex = uv
        $interpolators = uv
        $uv = 2
    }


    ps 1_1

    void main(INPUT input, inout OUTPUT output)
    {
        output.color.rgb *= tex2D(detail_map, input.uv) * 2;
    }


제일 먼저 할 일은, 조각 각각이 필요로하는 모든 상수 입력과 샘플러를 출력하는 것이다. 픽셀 세이더 단계에서 두 조각 모두 전혀 상수 입력을 사용하지 않지만, 둘 다 샘플러를 하나씩 요구하고 있다. 같은 이름이 사용되는 것을 막아야 하므로 특별한 조각 인덱스를 붙여줘야 하겠다. 그렇지만 조각 이름을 붙여주는 편이 생성된 코드를 볼 때 더 읽기 쉬울 것이다.


    // base_texture0 textures
    sampler base_texture0_color_map;

    // detail_tex1 textures
    sampler detail_tex1_detail_map;


다음은, 입력 구조체를 선언해야 한다. 각각의 조각이 요구하는 데이터를 조합해서 생성하면 된다. 역시 같은 usage index를 사용하는 것을 막기 위해 적절한 번호를 할당해야 한다. 각각의 조각이 사용하는 입력 구조체를 생성하고 이것을 다시 전체 구조체로 묶는다(구조체를 품은 구조체 nested structure).


    // -------- input structures --------
    struct base_texture0_INPUT
    {
        float2 uv : TEXCOORD0;
    };


    struct detail_tex1_INPUT
    {
        float2 uv : TEXCOORD1;
    };


    struct INPUT
    {
        base_texture0_INPUT base_texture0;
        detail_tex1_INPUT detail_tex1;
    };


    INPUT gInput;


셰이더 3.0 버전 이전에서는 컬러와 텍스처 인터폴레이터 간에 일관성이 없는 탓에 이 두가지를 바꿔서 사용할 수가 없다. 이 두 개의 컬러 인터폴레이터는 정확도와 범위에 제한을 받는다. 즉, 조각은 높은 정확도를 갖는 텍스처 인터폴레이터를 꼭 필요한 곳에 사용할 수 있도록 평소에는 가능하다면 컬러 인터폴레이터를 사용하는 것이 좋다. 이렇게하면 조각들이 가용할 수 있는 갯수보다 많은 컬러 인터폴레이터를 사용하려 할때 문제가 된다. 그러므로 할당 시스템은 이런 경우에 대처할 수 있도록 어느정도의 유연함을 갖추어야 할 것이다. 범위 제한 때문에 컬러 채널이 요구되는 곳에 텍스처 인터폴레이터를 할당해서는 안될 것이고, 만약 컬러 채널이 소진되었을 때에야 비로소 텍스처 인터폴레이터를 컬러 인터폴레이터를 요구하는 곳에 할당해도 될 것이다.


만약 픽셀 셰이더 출력이 아래처럼 매우 간단하다면(= 어떤 조각도 멀티 렌더타겟이나 oDepth를 사용하지 않을 때) 정점 셰이더의 출력 구조체를 픽셀 셰이더 입력 구조체로 그대로 쓸 수 있을 것이다.


    // -------- output type --------
    struct OUTPUT
    {
        float4 color : COLOR0;
    };


셰이더 프로그램의 핵심부는 각 조각의 HLSL코드를 복사해오는 것이다. 이때 함수, 구조체, 변수의 이름은 적절히 바뀌어야mangle 한다.


    // -------- shader base_texture0 --------
    void base_texture0_main(base_texture0_INPUT input, inout OUTPUT output)
    {
        output.color = tex2D(base_texture0_color_map, input.uv);
    }


    // -------- shader detail_tex1 --------
    void detail_tex1_main(detail_tex1_INPUT input, inout OUTPUT
                          output)
    {
        output.color.rgb *= tex2D(detail_tex1_detail_map, input.uv) * 2;
    }


드디어 전체 셰이더의 main body를 생성할 차례다. 각각의 셰이더를 차례로 호출하면 된다.


    // -------- entrypoint --------
    OUTPUT main(const INPUT i)
    {
        gInput = i;

        OUTPUT output = (OUTPUT)0;

        base_texture0_main(gInput.base_texture0, output);
        detail_tex1_main(gInput.detail_tex1, output);

        return output;
    }


전역 구조체 변수 gInput은 이 예제에서는 중요해 보이진 않는다. 그러나 어떤 조각이 다른 조각의 입력값을 참조하길 원한다면 유용해질 수 있다.


지금까지의 전체 과정이 우스꽝스럽게 보일 수도 있다. 쓸데없이 길다란 코드를 만들어내고, 여러분이 보기에 차라리 다음과 같은 간결한 코드가 낫다고 생각할 수도 있다.


      ps_1_1
      tex t0
      tex t1
      mul_x2 r0.xyz, t1, t0
    + mov r0.w, t0.w


그러나 그건 포인트를 잘못 짚은 것이다. 입력용 조각이 작성하기 쉽다면 중간 코드의 크기 같은 건 별로 중요한 것이 아니다. 컴파일러가 코드를 효율적으로 컴파일해 주기 때문이다.


우리의 시스템의 실질적인 장점은, 이제 더이상 별다른 노력없이 하나의 셰이더 조각을 다른 어떤 셰이더에든 얹을 수 있게 되었다는 것이다.




Shade Trees


Maya의 Hypershade material editor와 같은 비실시간 렌더링 시스템을 보면, 끼웠다뺐다 할 수 있는pluggable 컴포넌트들로 구성된 트리나 그래프로 셰이더를 표현한 것을 볼 수 있다. 이렇게 하면 유저는 자신이 원하는 입력과 출력을 마음대로 연결할 수가 있게된다. 여기에 비해서 우리의 시스템은 매우 기본적인 기능을 가진다. 오퍼레이션들은 선형적으로 연결linear chain되며 옛날 DX7의 텍스처 조합texture cascade과 비슷한 방식이다.


일반적으로 셰이더가 사용될 시나리오들을 살펴봤을때 이런 방식이 나쁘지 않다는 것을 확신할 수 있다. 다음과 같은 세가지 사용 패턴이 있다.


- 전적으로 프로그래머의 필요로 만들어진 셰이더. 파티클이나 폭발 효과를 위한 셰이더 등.
- 아티스트에 의해 만들어진 셰이더. 에디팅 툴에서 셰이더 조각을 조합한 것.
- 아티스트에 의해 만들어졌지만, 게임 실행 코드에 맞게 라이팅이나 포그, 애니메이션 셰이더 조각을 덧붙인 셰이더. 이것이 가장 빈번한 경우일 것이다.


첫번째 경우, 코드에서 셰이더를 생성할 수 있다. 선형 구조이기 때문에 코드에서 생성문을 쉽게 작성할 수 있다. C++은 리스트나 배열을 위한 문법은 갖고 있지만 트리 구조를 한번에 표현할 수 있는 방법은 없다.


    setShader(ShaderList(ST::base_texture,
                         ST::detail_texture,
                         ST::normalmap,
                         ST::fresnel_envmap,
                         ST::light_specular,
                         ST::fog,
                         ST::depth_of_field));


작성하기 쉽고 읽기도 쉬우며 실행하기도 좋다. 만약 좀더 유연한 셰이드 트리를 사용했다면 이런 잇점을 얻기 어려울 것이다.


두번째 경우, 아티스트가 셰이더를 만들어야 할 때 트리구조는 설명하기가 어렵다. 시각화하기도 어렵고, 에러를 만들 소지도 있다. 이에 비해서 선형으로 레이어를 쌓는 구조는 기술 지식이 부족한 아티스트도 쉽게 이해할 수 있다. 이런 선형 레이어 구조는 아티스트가 포토샵 등에서 이미 접해본 것이고, 또 실제로 그림을 그릴 때도 색을 덧칠하는 것을 볼 때 이것은 매우 익숙한 개념이다. 내 생각에는, 아티스트를 프로그래머로 만드는 것보다 아티스트가 이해할 수 있는 형태로 셰이더를 사용할 수 있도록 해주는 편이 훨씬 결과가 좋은 것 같다.


그러나 가장 흥미로운 것 중 대부분은 선형 모델로는 구현할 수가 없다. 선행하는 텍스처 레이어의 알파 채널에 반사량을 담고 있는 환경맵을 예로 들 수 있겠다. 또다른 예로, 스펙큘러 라이팅 셰이더가 스펙큘러 파워를 베이스 텍스처 매터리얼에 속한 상수 레지스터에서 가져와야 하되, 알파 블렌딩된 데칼 텍스처가 얹어지지 않은 픽셀에만 스펙큘러를 적용하고 싶은 경우를 들 수 있다. (역주: 원문이 복잡해서 해석이 어렵습니다. 다만, 단순하게 선형으로 셰이더를 쌓아나가는 구조에서는 전후 셰이더들이 서로 정교하게 협업하기가 어렵다, 정도의 이야기인듯 합니다)


이러한 것들을 가능하게 하려면 조각이 제어 변수를 임포트/익스포트할 수 있어야 한다. 실제적인 처리는 자동으로 이루어진다. 조각이 어떤 변수를 임포트하고 싶다면 이전에 익스포트된 변수들 중 같은 이름을 가진 변수를 찾는다. 익스포트된 변수 중 같은 이름을 가진 변수가 없다면 그냥 초기값을 사용한다. 이것은 선형 셰이더 구조의 단순함을 지키면서도 풀 셰이드 트리full shade tree의 유연함을 흉내내는 방법이다. 또한 결과물이 항상 동작하리라는 보증이 되기도 한다. 어떤 셰이더 조각이라도 혼자서 작동할 수 있고, 여러개의 조각이 합쳐지더라도 자동적으로 서로 통신을 하며 더 복합적인 능력을 발휘할 수 있게 된다.


이런 견고함은 아티스트가 만든 셰이더에 우리가 프로그램적으로 뭔가 다른 조각을 덧붙여야 할때 특히 요구되는 특성이다. 현재 우리의 파이프라인(공정)에서는 아티스트가 직접 라이팅 셰이더를 가지고 작업을 하지는 않는다. 그러나 아티스트는 라이팅에 관련한 제어변수(gloss amount, specular power, Fresnel factor, amount of subsurface scattering)를 익스포트하는 셰이더 조각들을 가지고 작업을 한다. 에디팅 툴은 아티스트가 만드는 셰이더마다 프리뷰를 위한 방향광 셰이더를 붙여줘야 한다. 이 프리뷰용 라이팅 셰이더는 여러가지 제어변수들을 임포트해서 가능한한 정확한 프리뷰를 제공해야 한다.


한편 게임엔진에서는 그보다 더 복잡하고 정교한 라이팅 테크닉을 사용한다. 우리의 경우에는 deferred shading을 사용했다. 우리는 툴에서 만들어진 셰이더를 그대로 가져다가 deferred shading 조각과 조합했다. deferred shading 이란 멀티 렌더 타겟을 사용해서 여러개의 렌더링 요소들을 따로 생성한 후 마지막에 합성하는 방법이며, 조명은 나중에 계산된다. 렌더링 방식이 근본적으로 다름에도 불구하고 기존의 셰이더 조각들을 전혀 고칠 필요가 없었다. 오직 필요한 것은 임포트/익스포트되는 변수의 네이밍에 모든 사람이 동의하는 것 뿐이었다.




HLSL Metafunctions


셰이더 조각들이 파라미터를 통해 서로 교신할 수 있도록 우리는 새로운 키워드를 사용했다. import와 export이다. 이것은 순전히 문자열을 다루는 것으로 해낼 수 있다. 키워드를 적당한 코드로 변환하기만 하면 된다.

export 키워드는 매우 간단하다. 아래에 있는 조각은 간단한 2D 베이스 텍스처를 구현하는데, 자신의 알파 채널을 "specular_amount" 제어변수에 익스포트하고 있다.


    ps 1_1

    void main(INPUT input, inout OUTPUT output)
    {
        float4 t = tex2D(color_map, input.uv);

        output.color.rgb = t.rgb;

        export(float, specular_amount, t.a);
    }


전처리기가 export문을 인식하고 이것을 전역변수 대입문으로 교체한다.


    // -------- shader base_texture0 --------
    float base_texture0_export_specular_amount;

    void base_texture0_main(base_texture0_INPUT input, inout OUTPUT output)
    {
        float4 t = tex2D(base_texture0_color_map, input.uv);

        output.color.rgb = t.rgb;
  
        // metafunction: export(float, specular_amount, t.a);
        base_texture0_export_specular_amount = t.a;
    }


그 후, 다른 조각이 스펙큘러 양을 임포트하게 되었다.


    float spec = 0;
  
    import(specular_amount, spec += specular_amount);


전처리기는 이것을 다음과 같이 바꿀 것이다.


    float spec = 0;
  
    // metafunction: import(specular_amount,spec += specular_amount);
    spec += base_texture0_export_specular_amount;


임포트문의 내용은 대응되는 익스포트 문당 하나씩 확장(코드펼침)된다. 다른 셰이더 조각이 익스포트한 적 없는 값을 임포트하려하면 확장은 일어나지 않고 다면 디폴트 값인 spec = 0 이 대신 사용될 것이다. 여러개의 셰이더 조각이 특정 변수를 여러번 익스포트하면 여러줄의 코드가 생성될 것이다(그 변수를 익스포트한 셰이더 조각당 하나씩). 위에서 보인 예의 경우, spec은 여러개의 익스포트 값들의 합이 들어가게 될 것이다. 값들이 어떻게 조합될 것인지는 임포트문이 하기에 달려있다. 더하거나, 곱셉을 하거나, 아니면 함수를 호출하던가, 단순 대입을 해서 마지막 값만 남기고 모두 버리던가.


이렇게 생성된 코드는 대게 지저분한 코드일 수도 있다. 위 예처럼 0에 어떤 수를 더하려 한다던가, 아니면 아무 셰이더 조각에서도 사용하지 않는데도 전역변수로 값을 익스포트하는 경우가 있을 수 있다. 다행스럽게도 컴파일러는 이런 것들을 모두 알아서 제거해 준다.




Adaptive Fragments [Adaptive:적응하는]


셰이더 조각이 자신이 사용되는 문맥context에 따라 작동을 변경할 수 있다면 매우 유용할 것이다. 예를 들자면 다음 같은 경우이다.


- 텍스처 1개와 UV좌표 하나, 그리고 scalar fade value를 하나 가지는 셰이더 조각이 있다고 할 때 이것은 ps 1.1에서 잘 작동한다. 하지만 ps 1.1에서는 하나의 인터폴레이터가 텍스처 룩업과 직접 입력으로 두번 사용될 수 없기 때문에 두개의 인터폴레이터가 필요하게 된다. 만약 우리가 이것을 ps 2.0으로 컴파일하게 됐다면 이 데이터를 하나의 xyz 인터폴레이터로 묶어버리는 것이 효율적일 것이다. 가능하다면 우리는 이 ps 1.1 버전을 그냥 유지하면서도, 다른 셰이더 조각과의 조합 때문에 ps 2.0에서 컴파일하게 되었을 때 사용할 수 있는 2.0에서 효율적인 다른 구현을 함께 제공하는 것이 하드웨어 모델의 이점을 취하는 길일 것이다.

- 라이팅 셰이더. 우리가 라이팅 계산을 정점당으로 해야할까? 아니면 픽셀당으로 해야할까? 문맥context에 달린 문제다. 정점당 광원 계산은 카메라에서 멀리 떨어지거나 높은 퀄리티로 텍스처링된 물체에는 적절할 수 있다. 그러나 노멀맵을 사용했다면, 픽셀당 광원 계산을 하는게 올바른 선택일 것이다. 셰이더 조각 내에서 이러한 두가지 상황을 대응할 수 있다면 멋질 것이다.


모든 셰이더 조각들은 인터페이스 블록에 일종의 정의문을 제공하도록 한다. 이 정의문들은 코드 생성기에 요구사항을 말해준다. 예를들어, '입력값으로 노멀이 필요해' 라던지, '멀티 렌더타겟에 출력하고 싶다' 라던지. 또 정의문은 다른 셰이더 조각들과 교신하는데도 사용될 수 있다. '내가 픽셀당 섭동 노멀을 제공할게'라는 식으로, 그러면 뒤에 따라오는 셰이더 조각들은 그에 맞춰 작동 방식을 변경할 수 있다.


셰이더 코드를 생성할 때에, 입력요소마다 딸린 정의문들은 타겟 셰이더 버전에 따라 하나의 리스트로 묶이게 된다. 정의문 리스트는 이 셰이더가 변용될 수 있는 상황에 대한 조건으로 사용된다. 셰이더는 정의문을 통해 문맥에 따라 어떤 입력 상수나 인터폴레이터, 코드블록을 선택해야할지 결정할 수 있다. 또, 최종 생성된 HLSL 프로그램의 첫머리에 #define을 지정해서 프리프로세서가 코드가 가진 사용 조건을 검사하게 할 수 있다.


다음은 '문맥을 감지할 수 있는'context sensitive 반구 조명 셰이더 조각이다. 이 셰이더는 정점당과 픽셀당 두가지 방법으로 작동할 수 있다. 입력 상수가 정점 셰이더 또는 픽셀 셰이더 중 어디에서 사용되어야 하는지 결정하기 위해 우리는 ppl(per pixel lightin이라는 의미) 디파인을 검사한다. 이 셰이더는 정점당 조명 모드일 때 컬러 인터폴레이터 채널을 요구하고, 그외의 상황에 대체할 수 있는 코드블록을 준비해놓고 있다.


    interface()
    {
        $name = light_hemisphere

        $params = [ ambient, sky, diffuse ]

        $ambient = [ color, vs="!ppl", ps="ppl" ]
        $sky     = [ color, vs="!ppl", ps="ppl" ]
        $diffuse = [ color, vs="!ppl", ps="ppl" ]

        $interpolators = color
  
        $color = [ color, enable="!ppl" ]
    }



    // the core lighting function might be wanted in the vertex or pixel shader
    vs (!ppl),
    ps (ppl)


    float3 $light(float3 normal)
    {
        float upness = 0.5 + normal.y * 0.5;
        float3 hemisphere = lerp(ambient, sky, upness);
        float d = 0.5 - dot(normal, WorldLightDir) * 0.5;
        return saturate((hemisphere + d * diffuse) * 0.5);
    }



    // per vertex lighting shader
    vs (!ppl)


    void main(out OUTPUT output)
    {
        output.color.rgb = $light(gInput.normal);
        output.color.a = 1;
    }



    // when doing vertex lighting, just modulate each pixel by the vertex color
    ps (!ppl)


    void main(INPUT input, inout OUTPUT output)
    {
        output.color.rgb = saturate(output.color.rgb *
                                    input.color * 2);
    }



    // per pixel lighting shader
    ps (ppl)


    void main(inout OUTPUT output)
    {
        output.color.rgb = saturate(output.color.rgb *
                           $light(gInput.normal) * 2);
    }


ppl을 디파인하지 않고 컴파일했을 때, 조명함수($light)는 반구 조명을 정점 셰이더 내에서 계산한다.


    vs_1_1
    def c8, 0.5, -0.5, 0, 1
    dcl_position v0
    dcl_normal v1
    mad r0.w, v1.y, c8.x, c8.x
    mov r0.xyz, c6
    add r0.xyz, r0, -c5
    dp3 r1.x, v1, c4
    mad r0.xyz, r0.w, r0, c5
    mad r0.w, r1.x, c8.y, c8.x
    mad r0.xyz, r0.w, c7, r0
    mul r0.xyz, r0, c8.x
    max r0.xyz, r0, c8.z
    min oD0.xyz, r0, c8.w
    dp4 oPos.x, v0, c0
    dp4 oPos.y, v0, c1
    dp4 oPos.z, v0, c2
    dp4 oPos.w, v0, c3
    mov oD0.w, c8.w


자, 다음은 탄젠트 공간 노멀매핑을 위한 셰이더 조각이다.


    interface()
    {
        $name = normalmap
        $defines = ppl
        $textures = normalmap
        $vertex = uv
        $interpolators = [ uv, tangent, binormal ]
        $uv = [ 2, want_tangentspace=true ]
    }


    vs 1_1
  
    void main(INPUT input, out OUTPUT output)
    {
        output.uv = input.uv;

        output.tangent  = mul(input.uv_tangent,  NormalTrans);
        output.binormal = mul(input.uv_binormal, NormalTrans);
    }



    ps 2_0


    void main(INPUT input, inout OUTPUT output)
    {
        float3 n = tex2D(normalmap, input.uv);

        gInput.normal = normalize(n.x * input.tangent  +
                                  n.y * input.binormal +
                                  n.z * gInputNormal);
    }


인터페이스 블록의 uv 파라미터 선언을 살펴보자. 이 문장은 텍스처 좌표에 따른 탄젠트와 바이노멀 벡터를 필요로 한다는 것이다. 이 셰이더 조각은 gInput.normal 값을 조작하는 것 외에 별달리 하는 일이 없다. gInput.normal 값은 뒤에 실행되는 셰이더 조각이 사용하게 될 것이다.


위에 보인 노멀맵과 반구조명을 합친 셰이더를 생성할 때 '적응 메커니즘'이 발동하게 된다. 노멀맵 셰이더 조각이 ppl을 디파인했기 때문에 조명 계산 코드가 아까와는 다르게 포함되고, 결과적으로 노멀맵된 반구 조명의 픽셀 셰이더가 생성된다.


    ps_2_0
    def c4, 0.5, -0.5, 1, 0
    dcl t0.xyz
    dcl t1.xy
    dcl t2.xyz
    dcl t3.xyz
    dcl_2d s0
    texld r0, t1, s0
    mul r1.xyz, r0.y, t3
    mad r1.xyz, r0.x, t2, r1
    mad r1.xyz, r0.z, t0, r1
    nrm r0.xyz, r1
    dp3 r1.x, r0, c0
    mad r0.w, r0.y, c4.x, c4.x
    mov r0.xyz, c2
    add r0.xyz, r0, -c1
    mad r0.xyz, r0.w, r0, c1
    mad r0.w, r1.x, c4.y, c4.x
    mad r0.xyz, r0.w, c3, r0
    mul_sat r0.xyz, r0, c4.x
    mov r0.w, c4.z
    mov oC0, r0




Analysis and Conclusion 결론


우리의 접근방식은 광범위한 셰이더 작동을 성공적으로 캡슐화했으며 조각 코드들이 자동으로 수많은 조합을 이룰 수 있게 하였다.


이것은 매우 견고한 방법이다. 내가 deferred shading을 처음 구현했을 때(역주: 필자는 GDC에서 deferred shading을 발표한 사람이다) 나는 이미 사용중이던 텍스처 블렌딩, 노멀매핑, 애니메이션 셰이더들을 전혀 변경하지 않고도 deferred shading을 적용할 수 있었다.


또 효율적이다. 조각 조합 시스템이 만들어낸 코드 중에 직접 손으로 최적화해야만 하는 코드는 아직까진 없었다. (..번역의심)


하나 단점이 있다면 그것은 모든 조각마다 정확한 인터페이스를 기술해야 한다는 점이다. 이것 때문에 서드파티의 셰이더 코드를 끼워넣기가 어렵다. 실제로 인터페이스를 작성하는 것은 몇분이면 되는 일이지만 그래도 이건 꽤나 신경 곤두서는 일이다. 이런 일은 시스템이 D3DXEffect 프레임워크 위에서 구축되었다면 능률적으로 처리할 수도 있겠지만, 나에게 있어서는 그게 별로 중요한 목표는 아니었다.


디버깅이 힘들 수도 있다. 컴파일러는 입력된 셰이더 조각이 아니라 조합된 HLSL 코드에서 에러를 내뱉기 때문에 조합된 결과 코드를 출력하는 기능이 꼭 필요하다.


궁극적으로, 이 시스템은 '숫자'를 줄여줄 수 있다. 당신이 5개, 10개, 혹은 50개의 셰이더를 가지고 있다면 이 시스템은 당신을 위한 것이 아니다. 그러나 만약 수천개에 이른다면 이 자동화 시스템이 당신을 도와줄 것이다.



 쉐이더를 구현하면 발생하는 문제가 쉐이더코드를 작성하는게 아니라,
수많은 렌더링옵션조합을 어떻게 해결할것인가? 입니다.

몇가지 해결책은 다음과 같습니다.

1. RenderState-> 쉐이더 코드 조합기 제작
고정파이프라인 비슷하게 RenderState 를 입력하면
순차적으로 상응하는 코드를 스트링 베이스로 만들어주는
시스템을 제작하는 방식인데, 구현자체는 쉽지 않습니다.



2. ShaderModel 2.0 이상으로만 제작.
SM 2.0 부터 분기/루프를 지원합니다. SM 2.0 으로 쉐이더모델을 제한하면
코드 내부에서 분기/루프를 사용하여 코드제작을 쉽게 할수 있습니다.
GeForceFX 부터 SM 2.0 을 지원하며
GeForce 6xxx 계열부터 SM 3.0 을 지원합니다.



3. 상용엔진에서 제공되는 시스템 사용.
상용엔진의 경우 대부분 RenderState->쉐이더 코드 를 제작해주는
시스템을 지원하고 있습니다. 있는 시스템을 분석해서 사용하는 방식입니다.
(개인적으로는 가장 추천합니다.)



4. 노가다 코드 방식.
그냥 필요할때마다 코드를 만들어 쓰는 방식입니다.
수많은 렌더링조합을 어떻게 만들까? 싶지만 사실 모든코드를 무에서 작성하는건 아니고,
copy & paste and Modify 스타일이고, 모든 렌더링조합을 모두 사용하는건 아니기 때문에,
노가다 지향 코드방식도 불가능한건 아니며, 사용가능한 방법중 하나이기는 합니다.



5. 구관이 명관이다 전법.
말 그대로 구시대의 스타일을 사용하는 방식입니다.
쉐이더가 보급되면서 일반적인 오브젝트에서 특수효과까지 모두 쉐이더를 써야하는
강박관념이 없다면, 그냥 "고정 파이프라인" 으로 렌더링가능한 오브젝트는 "고정 파이프라인" 으로
렌더링하고, 반드시 쉐이더를 써야하는 오브젝트만 쉐이더를 사용하는 방식입니다.

고정파이프라인의 구조로 뭘 할수 있을까? 싶지만, 고정파이프라인으로도 최신의 화려한 효과들을
대부분 구현가능하고, 쉐이더를 쓰지 않아도 고정파이프라인으로 비슷하게 렌더링은 가능합니다.
(다른 이유보다는, 하위 호환성이 꼭 필요할경우 유용합니다.)




앞서 다른 분들이 몇가지를 언급해 주셨는데 제 생각도 조금 적어놔야겠네요. 이전 프로젝트에서 Fragment Linker 를 사용 했었는데 이유는 서너가지의 라이트 + 스키닝 + 노말 사용 + 탄젠트 사용 + 퍼 픽셀 스펙큘러 사용 + 쉐도우맵 을 지원하는 버텍스 쉐이더와, 디퓨즈 + 스펙뷸러 + 노말 + 쉐도우 맵을 사용하는 픽셀 쉐이더를 조합하기 시작하면 세자리로 넘어가버리고 여기 하나씩 추가될때마다 이런저런 복잡도가 계속 늘어났기 때문이죠. (게다가 라이트 배열 파라메터도 있었죠.)


전임자분이 가장 먼저 구축해 놓았던 부분은 D3DXMacro 와 D3DXInclude 를 사용하고 있었습니다. 필요에 따라 파라메터와 분기에 필요한 bool 값을 바꿔주어 컴파일 하는 방식이었습니다. 일단 효율성은 상당히 좋지만 실시간으로 컴파일이 되기 때문에 게임 초반에 컴파일 시간이 너무 많이 걸리고 Macro/Include 를 정의할 경우 특히 컴파일 시간이 길어지더군요. 1~2초는 별로 중요하지 않아 보이지만 그러한 것들이 5~10건이 되면 순식간에 10~20초가 들어가는 것이고 동적으로 걸어주기에는 너무 부담스러운 것이죠.


문제를 해결하기 위해 SM 2.0을 사용한 쉐이더 분기를 좀 사용해 봤습니다. SM 2.0 기준으로 작성했었는데 쉐이더내 분기가 생각만큼 잘 작동하지 않더군요. 나중에 발견한 것이지만 SM 2.0 내에서의 분기는 일반 C 코드같이 명확하지 않고 H/W 적으로 SM 2.0이 지원하는 분기는 레지스터에서 bool 값으로 전달해주는 값을 통한 분기만 지원하고 있었습니다. 그 말은 if(r10.x > 1) {~~} else {~~} 이런 코드는 전혀 작동하지 않는 다는 것이죠. 게다가 테크닉을 설정할때 쉐이더 함수에 bool 값으로 인자를 넘기는 것도 경우에 따라 정상적으로 작동하지 않았습니다. (잘 작동할때도 있고 말이죠. T_T)


그 다음 해결책으로 나온게 Fragment Linker 입니다. Fragment Linker 는 실시간으로 쉐이더 코드를 링크하는 방법으로 자신이 원하는 함수 조합을 선언해두고 실시간으로 링크를 해주는 방식인데 게임내에서 동적으로 함수들을 선택하고 링크하데까지의 속도도 상당히 좋았습니다. 맨 앞의 Macro와 Include 를 사용하는 것과 비슷하다고 할 수 있지만 코드를 전체 다시 컴파일 하는게 아니라 미리 컴파일 해둔 조각들을 합쳐주는 작업 뿐이라 속도가 훨씬 빠른 것이죠. 단점도 있는데 fxc 가 수행하는 어셈 코드 최적화를 해주지 못합니다.


몇몇 문서를 찾아보면 경우에 따라 fxc 의 최적화 옵션을 끄는 경우가 속도 향상에 도움이 되기도 한다지만 평상시 모든 코드가 최적화가 꺼진 상태에서 불필요한 명령까지 마구 추가되다보면 좀 화가 납니다. 경우에 따라서는 fxc 에서는 잘 꾸겨 들어가던 코드가 Fragment Linker 에서는 명령어 개수가 넘쳐버리기도 했으니까요. 코드 중간중간 mov r1, r1 같은 명령이 들어가거나 필요 이상으로 r0, r1 위주로 코드가 생성되어 효율성이 상당히 떨어지곤 했으니까요.


일단 답은 없지만 현재 생각중인 것은 쉐이더 코드가 클라이언트 코드에 너무 밀접하게 붙어있기 보다는 디자인 리소스중 하나로 포함되어야 한다는 점 입니다. 텍스쳐나 메쉬 같이 디자이너가 사용하는 폴더에 같이 포함되어지고 각각의 mesh에 적합한 쉐이더를 불러서 쓰도록 하고 코드에서 그러한 것을 다른 리소스와 동일하게 '여러가지중 하나'로 취급하는게 훨씬 적절하다는 거죠. 물론 쉐이더 코드 관리가 힘들어지긴 하겠지만 프로그래머 관점에서 '효율적인 관리'와 디자인 리소스 관점에서의 '효율적인 관리'는 좀 차이가 있으니까요. 쉐이더 코드이지만 좀더 디자인쪽의 방향에 맞게 준비를 해주는게 좋다고 생각합니다.


요즘 범용 쉐이더 조합툴을 짜야겠다고 고민만 하고 있습니다. Fragment Linker는 개발자가 그만둬서 추가 지원이 없어진 상태고 DX10에선 아예 지원하지 않는 듯 싶으니까요. 게다가 DX9도 지원이 점점 줄어서 텍스트에는 있는데 실제 코드는 없어진 부분들도 있으니까요. 그리고 범용 쉐이더 조합툴의 장점은 최적화된 쉐이더 파일을 생성할 수 있고 미리 컴파일된 바이너리 형식의 쉐이더를 사용할 수 있다는 뜻도 됩니다. 올해 내에 나오긴 힘들겠지만 내년쯤에 하나 공개할 수 있기를 빌고 있습니다. (잘 될까 T_T)

반응형