Вершинные шейдеры

Вступление.

Привет всем. Сегодня речь пойдет о вершинных шейдерах. Несомненно, Вы и раньше слышали этот термин. Если для их предназначение не является для Вас секретом, то смело можете пропустить эту часть статьи. Остальным же рекомендую не торопиться. До внедрения вершинных шейдеров, процесс визуализации выглядел так, как показано на рисунке 1. Сначала над вершинами выполнялись необходимые преобразования (домножение на проекционную матрицу, например), заполнялись их атрибуты (освещение, туман etc.), затем происходили растеризация и наложение текстур. В самом конце выполнялся антиалиайзинг. На эти процедуры программист качественно повлиять не мог (конечно, возможность менять некоторые моменты с помощью констант была, но её было явно недостаточно). Производительность видеокарт неуклонно росла, и вдруг оказалось, что уже можно было бы реализовать некоторые весьма интересные эффекты, если бы не фиксированность описанного выше конвейера. Посему было решено сделать первый этап визуализации программируемым (рисунок 2). Это позволило на аппаратном уровне реализовывать следующие эффекты:
  • meshskinning
  • спец. преобразование (например, волны на поверхности воды).
  • освещение
  • преобразование текстурных координат

  • ну и некоторые другие.

    Вместе с тем, на вершинные программы накладывались достаточно жесткие ограничения:

  • вершинные программы не имели возвращаемого значения
  • у шейдеров самых ранних версий не было условных переходов
  • вершинные программы ничего не знали о топологии вертексов в объекте (на вход каждой программы подавалась только одна вершина + содержимое нескольких регистров, которые погоды не делали)
  • на выходе мы имели только одну вершину
    Не смотря на все эти недостатки, шейдеры стали безумно популярными. Сейчас уже ни одна игра с претензией на красивую картинку не обходится без них.

    Для начала, нам стоит ознакомиться с основными понятиями и минимальным набором команд (в данной статье рассматриваются вершинные шейдеры версии 1.0):

    Регистры и команды.

    к началу статьи
    При написании вершинных программ Вы будете иметь дело с четырьмя типами регистров.
  • регистры входных данных (до 16 штук – v0, v1…v15) – в эти регистры помещается обрабатываемая вершина
  • регистры констант (96 штук – c0, c1…c95) – сюда Вы можете положить все что Вашей душе будет угодно
  • временные регистры (12 штук r0,r1…r11)
  • выходные регистры-
    1. oD0, oD1 – регистры цвета вершины
    2. oFog – “затуманенность”(не знаю как по человечески сказать) данной вершины
    3. oPos – позиция вершины
    4. oT0 - oT7 – регистры текстурных координат
  • Теперь разберемся с инструкциями (некоторыми из них): Их формат таков:
    Opcode dst, [-]s0 [,[-]s1 [,[-]s2]];#комментарий
    Где
    opcode – операция
    dst – регистр-приёмник
    s1, s2, s3 – регистры-источники
    так же перед регистрами можно помещать унарный минус (понятно зачем, да?), как размещать комментарий, тоже надеюсь всем понятно :)
    Теперь сами операции:

    mov dest, src; – на примере простой операции, копирующей содержимое одного регистра в другой, рассмотрим одну особенность шейдерного языка. Дело в том, что все команды работаю с векторами из 4-х float’ов. Но есть способ работать и с отдельными компонентами:
    mov r1.xy, r2.xy; #копирование только x и y компонент вектора
    При указании маски для регистра-приемника следует помнить следующие правила:

    1. если маски нет, то будут переписаны все компоненты
    2. если есть маска, то будут изменены только компоненты вектора, указанные в маске.

    Так в следующем примере значение r2.w будет записано во все компоненты вектора r1
    mov r1, r2.w;
    Циклический сдвиг компонент:
    mov r1, r2.yzwx;

    mul dest, src1, src2; – перемножение указанных в маске компонент вектора:
    mul r1.xyz, r2, r3;
    если на пальцах объяснять, то результат такой:

    r1.x = r2.x * r3.x
    r1.y = r2.y * r3.y
    r1.z = r2.z * r3.z
    w-компонента осталась без изменения
    add dest, src1, src2; – покомпонентное сложение
    add r1.xz, r2.yw, r3.xw
    Вот что происходит:
    r1.x = r2.y + r3.x;
    r1.z = r2.w + r3.w;
    остальные компоненты остались неизменными
    mad dst, src0, src1, src2; – покомпонентно перемножает src0 с src1 и прибавляет src2. Эта команда эквивалентна следующему фрагменту кода:
    mul r1, r2, r3;#src0 == r2 src1 == r3
    add r5, r1, r4;#src2 == r4

    rcp dest, src0.C; – в компоненты вектора dest записывает значение 1/src0.C, где C = x,y,z или w.

    rsq dest, src0.C; – почти то же самое что и предыдущая команда, только в dest заносится 1/sqrt(src0.C) , где C = x,y,z или w.

    dp3 dest, src0, src1; – скалярное произведение над ТРЕХмерными векторами (то есть компонента w не зависимо от своего значения не участвует).

    dp4 dest, src0, src1; – скалярное произведение над ЧЕТЫРЕХмерными векторами.

    min dest, src0, src1; – в компоненты dest помещается минимум соответствующих компонент векторов src0 и src1.

    max dest, src0, src1; – в компоненты dest помещается максимум соответствующих компонент векторов src0 и src1.

    slt dest, src0, src1; – если компонента src0 <соответствующей компоненты src1, то в компоненту dst пишется 1.0, иначе 0.0

    sge dest, src0, src1; – если компонента src0 > соответствующей компоненты src1, то в компоненту dst пишется 1.0, иначе 0.0

    expp dest, src0.C; - с помощью этой функции можно найти двойку в степени src0.C (где С = x , y , z или w).
    Действие этой команды следующее:

    dest.x = 2^floor(src0.C) dest.y = src0.C – floor(src0.C)
    dest.z ~= 2^(src0.C)
    dest.w = 1
    logp dst, src0.С – логарифм по основанию 2 (где С = x , y , z или w).
    dest.x = Exponent(src0.C) в пределах [-126.0, 127.0]
    dest.y = неизвестно
    dest.z ~= log2(|src0.C|)
    dest.w = 1

    dst dest, src0.C1, src1.C2; – заполняет вектор dest следующим образом:

    dest.x = 1;
    dest.y = src0.y * src1.y;
    dest.z = src0.z;
    dest.w = src1.w;
    На первое время Вам этих команд должно хватить. Пока же рассмотрим некоторые основные алгоритмы и их реализацию:
    Нормирование вектора
    # R1 = (nx,ny,nz)
    #
    # R0.xyz = normalize(R1)
    # R0.w = 1/sqrt(nx*nx + ny*ny + nz*nz)
    #
    DP3 R0.w, R1, R1;
    RSQ R0.w, R0.w;
    MUL R0.xyz, R1, R0.w;
    Векторное произведение
    # | i j k | в R2.
    # | R0.x R0.y R0.z |
    # | R1.x R1.y R1.z |
    #
    MUL R2, R0.zxyw, R1.yzxw;
    MAD R2, R0.yzxw, R1.zxyw, -R2;
    Определитель матрицы
    # | R0.x R0.y R0.z | в R3
    # | R1.x R1.y R1.z |
    # | R2.x R2.y R2.z |
    #
    MUL R3, R1.zxyw, R2.yzxw;
    MAD R3, R1.yzxw, R2.zxyw, -R3;
    DP3 R3, R0, R3;

    Реализация.

    к началу статьи
    Подробности приведенного ниже класса будем разбирать по ходу дела.
    class	cVertexShader{
    cArray<DWORD> ShaderDecl;
    public:
    cVertexShader( void ){ShaderDecl.Cursor=0;}
    cVertexShader( float ){ShaderDecl.Cursor=0;};
    DWORD VertexShaderHandle;
    void InitVertexShader( char* );
    void SetVertexShader( void );
    void AddDeclElem( DWORD );
    void operator=( float ){};
    };
    Дело в том, что GPU, перед тем как начать обрабатывать вершины, должен узнать их формат. Для связывания потоков входных данных с регистрами (v1, v2, v3…) помимо кода самого шейдера загружается массив DWORD’ов, которые описывают, из каких данных состоит обрабатываемая вершина. Например:
    //начало определения шейдера и одновременно
    //установка потока в который будут выводится данные,
    //формат которых описан далее
    Shader.AddDeclElem( D3DVSD_STREAM( 0 ) );
    //здесь мы говорим, что в вершине хранятся координаты…
    Shader.AddDeclElem( D3DVSD_REG( D3DVSDE_POSITION , D3DVSDT_FLOAT3 ) );
    //и цвет
    Shader.AddDeclElem( D3DVSD_REG( D3DVSDE_DIFFUSE , D3DVSDT_D3DCOLOR ) );
    //завершение определения
    Shader.AddDeclElem( D3DVSD_END() );
    С другими константами (помимо D3DVSDE_POSITION и D3DVSDE_DIFFUSE ещё есть D3DVSDE_NORMAL, D3DVSDE_SPECULAR, D3DVSDE_TEXCOORD0 и так далее). А вот менее тривиальный пример:
    struct XYZ{float x, y, z;};
    struct Tex0{float tu1, tv1;};
    struct Tex1{float tu2, tv2;};

    Shader.AddDeclElem( D3DVSD_STREAM( 0 ) );
    Shader.AddDeclElem( D3DVSD_REG( D3DVSDE_POSITION, D3DVSDT_FLOAT3 ) );
    Shader.AddDeclElem( D3DVSD_STREAM( 1 ) );
    Shader.AddDeclElem( D3DVSD_REG( D3DVSDE_TEXCOORD0, D3DVSDT_FLOAT2 ) );
    Shader.AddDeclElem( D3DVSD_STREAM( 2 ) );
    Shader.AddDeclElem( D3DVSD_REG( D3DVSDE_TEXCOORD1, D3DVSDT_FLOAT2 ) );
    Shader.AddDeclElem( D3DVSD_END() );

    Здесь координаты вершины выводятся в нулевой поток, а текстурные координаты в первый и второй. Соответственно и грузить их нужно отдельно:
    //грузим вершины
    D3DDevice->SetStreamSource( 0 , xyz_buffer , sizeof( XYZ ) );
    //одни текстурные координаты
    D3DDevice->SetStreamSource( 1 , tex0_buffer , sizeof( Tex0 ) );
    //другие текстурные координаты
    D3DDevice->SetStreamSource( 2 , tex1_buffer , sizeof( Tex1 ) );

    Затем шейдер надо загрузить из файла:
    void	cVertexShader::InitVertexShader( char * path ){
    LPD3DXBUFFER pShaderCode;
    HRESULT hr;

    //Компиляция файла вершинного шейдера
    hr = D3DXAssembleShaderFromFile( path , 0 , NULL , &pShaderCode , NULL );
    if( hr != D3D_OK ){
    MessageBox( NULL , "Ошибка компиляции вершинного шейдера" ,
    "Ошибка" , MB_OK );
    exit( 0 );
    }

    //Создаем вершинный шейдер
    //по VertexShaderHandle будем ставить шейдер
    D3DDevice->CreateVertexShader( ShaderDecl.Array , ( DWORD* ) pShaderCode->GetBufferPointer() , &VertexShaderHandle , 0 );
    pShaderCode->Release();
    }

    Теперь, собственно, шейдер готов к использованию:
    void	cVertexShader::SetVertexShader( void ){
    D3DDevice->SetVertexShader( VertexShaderHandle );
    }
    Дальше все стандартно: менеджер, загрузка из текстового файла… все это уже было много раз и в более сложных вариантах, поэтому следующий код я оставлю без комментариев.
    struct	sShaderInfo{char	SystemName[256];};

    class cShaderManager{
    public:
    cArray<sShaderInfo> VertexShadersInfo;
    cArray<cVertexShader> VertexShaders;
    void LoadShaders( char* );
    cVertexShader &operator[]( char* );
    int qCommand( char * );
    int qShader( char * );
    int qType( char * );
    };

    #define _STREAM 0
    #define _VERTEX 1
    #define _END 2

    cVertexShader &cShaderManager::operator[]( char *s ){
    int VSID( qShader( s ) );
    if( VSID == -1 ){
    char error_msg[1000];
    strcpy( error_msg , "Обращение к несуществующему ресурсу : " );
    strcat( error_msg , s );
    MessageBox( 0 , error_msg , "Ошибка" , 0 );
    exit( 0 );
    }
    return( VertexShaders[ VSID ] );
    }

    int cShaderManager::qType( char * s ){
    if( !strcmp( s , "pos3" ) ) return( D3DVSDE_POSITION );
    if( !strcmp( s , "norm3" ) ) return( D3DVSDE_NORMAL );
    if( !strcmp( s , "diffuse" ) ) return( D3DVSDE_DIFFUSE );
    if( !strcmp( s , "specular" ) ) return( D3DVSDE_SPECULAR );
    if( !strcmp( s , "blend3" ) ) return( D3DVSDE_BLENDWEIGHT );
    if( !strcmp( s , "tex0" ) ) return( D3DVSDE_TEXCOORD0 );
    if( !strcmp( s , "tex1" ) ) return( D3DVSDE_TEXCOORD1 );
    if( !strcmp( s , "tex2" ) ) return( D3DVSDE_TEXCOORD2 );
    if( !strcmp( s , "tex3" ) ) return( D3DVSDE_TEXCOORD3 );
    if( !strcmp( s , "tex4" ) ) return( D3DVSDE_TEXCOORD4 );
    if( !strcmp( s , "tex5" ) ) return( D3DVSDE_TEXCOORD5 );
    if( !strcmp( s , "tex6" ) ) return( D3DVSDE_TEXCOORD6 );
    if( !strcmp( s , "tex7" ) ) return( D3DVSDE_TEXCOORD7 );

    return( -1 );
    }

    int cShaderManager::qCommand( char * s ){
    if( !strcmp( s , "stream" ) ) return( 0 );
    if( !strcmp( s , "vertex" ) ) return( 1 );
    if( !strcmp( s , "end" ) ) return( 2 );
    return( -1 );
    }

    int cShaderManager::qShader( char * s ){
    for( int i ( 0 ) ; i < VertexShadersInfo.Cursor ; i++ )
    if( !strcmp( s , VertexShadersInfo[ i ].SystemName ) )
    return( i );
    return( -1 );
    }

    void cShaderManager::LoadShaders( char *path ){
    char path_enum[256];
    char path_shader[256];

    strcpy( path_enum , path );
    strcat( path_enum , "shader_enum.log" );

    VertexShaders.Cursor = 0;

    sShaderInfo ShaderInfo;
    FILE *f_stream = fopen( path_enum , "rt" );

    if( !f_stream ){
    MessageBox( 0 , "Невозможно загрузить описатель шэйдеров." , "Ошибка." , 0 );
    exit( 0 );
    }

    char directive[256];
    for( ; EOF != fscanf( f_stream , "%s" , directive ) ; ){
    int ShaderID;
    int FieldID;
    //объявление нового шейдера
    if( qCommand( directive ) == _VERTEX ){
    //типа описание нового шэйдера
    //читаем инфу
    fscanf( f_stream , "%s" , directive );
    memcpy( &ShaderInfo , directive , sizeof( directive ) );
    VertexShadersInfo.AddEnd( ShaderInfo );
    VertexShaders.AddEnd( NULL );
    //читаем название файла
    fscanf( f_stream , "%s" , directive );
    strcpy( path_shader , path );
    strcat( path_shader , directive );
    goto end;
    }
    //имя шейдера уже есть
    if( ( ShaderID = qShader( directive ) ) != -1 ){
    //читаем что там для этого шэйдера предназначено
    fscanf( f_stream , "%s" , directive );
    if( qCommand( directive ) == _STREAM ){
    //поток
    fscanf( f_stream , "%s" , directive );
    int stream_id( atoi( directive ) );
    VertexShaders[ ShaderID ].AddDeclElem(
    D3DVSD_STREAM( stream_id ) );
    VertexShaders.Cursor++;
    goto end;
    }
    if( qCommand( directive ) == _END ){
    VertexShaders[ ShaderID ].AddDeclElem( D3DVSD_END() );
    VertexShaders[ ShaderID ].InitVertexShader( path_shader );

    goto end;
    }
    if( ( FieldID = qType( directive ) ) != -1 ){
    //тип
    //вершина, нормаль
    if( FieldID >= 0 && FieldID <= 3 )
    VertexShaders[ ShaderID ].AddDeclElem(
    D3DVSD_REG( FieldID , D3DVSDT_FLOAT3 ) );

    //диффузный цвет || отраженный цвет
    if( FieldID == 5 || FieldID == 6 )
    VertexShaders[ ShaderID ].AddDeclElem(
    D3DVSD_REG( FieldID , D3DVSDT_D3DCOLOR ) );

    //текстурные координаты
    if( FieldID >= 7 && FieldID <= 14 )
    VertexShaders[ ShaderID ].AddDeclElem( D3DVSD_REG(
    FieldID , D3DVSDT_FLOAT2 ) );

    goto end;
    }
    }
    end:;
    memset( directive , '\0' , 256 );
    }
    }
    Мне осталось только рассказать Вам, как передавать в шейдер константы (для их хранения, как Вы помните, есть специальные регистры). Это делается с помощью функции SetVertexShaderConstant; она принимает в качестве параметра три значения:
  • номер регистра, в который писать значение
  • указатель на, собственно, само значение
  • число векторов, которое будет сохранено в регистрах

  • Например, если мы хотим поместить в регистры c0, c1, c2, c3 матрицу, то код следующий:
    //матрица, как Вы поняли состоит из 4-х векторов
    SetVertexShaderConstant( 0 , &mat , 4 );
    Здесь происходит автоматическое заполнение регистров c[i+0], c[i+1], c[i+2], c[i+3]. В нашем примере i==0.
    //вектор же заносится так:
    SetVertexShaderConstant( 5 , &vec , 1 );
    На сегодня это все, что я готов рассказать про шейдеры. Не очень много, но и не очень мало. Этого как раз должно хватить Вам для того, что бы начать самостоятельное изучение шейдеров. Возможно, мы к ним ещё вернемся. До встречи.

    к началу статьи

    ЗЫ: контакт по-прежнему по почте dodonov_a_a(___AT)inbox.ru.


    Смежные вопросы:
    Урок 1. Инициализация. Вершинные буфферы и камера.
    Урок 2. Проигрывание медиа-файлов.
    Урок 3. Автоматизация загрузки и управления аудио и видео ресурсами.
    Урок 4. Текстурирование. Текстурный менеджер.
    Урок 5. Шрифты в Direct3D.
    Урок 6. Элементы управления.
    Исходные коды.
    © 2004-2005 Savardge.ru
    Hosted by uCoz