четверг, 23 июня 2011 г.

Четыре метода использования Вершинных Текстур (Vertex Textures) Часть 2

 

Использование Вершинных Текстур в играх на платформе XNA (перевод)

Catalin Zima

После того как мы закончили с файлом эффекта (шейдером), давайте вернёмся к классу Game. Создайте в проекте ещё одну новую папку с именем Textures и в ней добавьте файл height1.dds из архива resources, установите для файла контент процессор Texture (mipmapped). Потом нам необходимо добавить в класс поля для шейдера (эффекта) и текстуры.
    Effect gridEffect;
    
    Texture2D displacementTexture;

В методе LoadGraphicsContent, нам нужно загрузить эффект и текстуру, воспользовавшись следующей строкой:
    gridEffect = content.Load<Effect>("Shaders\\VTFDisplacement");
    
    displacementTexture = content.Load<Texture2D>("Textures\\height1");

В метод Draw добавляем код, устанавливающий параметры эффекта и код отрисовки сетки. Мы помещаем наш ландшафт в центр виртуального мира, поэтому матрицу мира (world) оставляем единичной (Identity), тем временем матрицы вида (view) и проекции (projection) получаем из компонента камеры.
    protected override void Draw(GameTime gameTime)
    {
        graphics.GraphicsDevice.Clear(Color.CornflowerBlue);
        
        graphics.GraphicsDevice.RenderState.CullMode = CullMode.None;
 
        gridEffect.Parameters["world"].SetValue(Matrix.Identity);
        gridEffect.Parameters["view"].SetValue(camera.View);
        gridEffect.Parameters["proj"].SetValue(camera.Projection);
        
        gridEffect.Parameters["maxHeight"].SetValue(128);
        
        gridEffect.Parameters["displacementMap"].SetValue(
                                                displacementTexture);
 
        gridEffect.Begin();
        foreach (EffectPass pass in gridEffect.CurrentTechnique.Passes)
        {
            pass.Begin();
            
            grid.Draw();
            
            pass.End();
        }
        gridEffect.End();
        
        base.Draw(gameTime);
    }

На данном этапе, советую запустить наше приложение, и Вы увидите что-то на подобие этого:

clip_image001

Пока что всё замечательно, но давайте посмотрим, что случится, если мы попытаемся увеличить размер ландшафта и установим значение свойств grid.CellSize = 8, grid.Dimension = 512 и значение переменной maxHeight = 512. Получаем следующее:

clip_image002

Это выглядит просто ужасно! Но в чём причина? Карта высот у нас имеет размер 256 на 256 пикселей, так что пока размерность сетки была 256, то каждый пиксель текстуры проецировался на одну вершину в сетке, и было все замечательно. Но после того как мы увеличили размер сетки до 512, каждый пиксель стал проецироваться на 2 вершины, которые на самом деле должны иметь разную высоту, но получилось так, что они находятся на одной высоте. Если бы у нас была билинейная фильтрация, то графический процессор автоматически бы рассчитал среднее значение на основе данных 4 соседних пикселей, и поверхность была бы более сглаженной, а не такими “ступеньками” как сейчас. Но так как вершинные текстуры не поддерживают фильтрации, то нам придется самим, вручную, реализовывать билинейную фильтрацию в шейдере. Итак, давайте откроем файл VTFDisplacement.fx и добавим следующий код.
    //Размер текстуры (эти два параметра, которые должны 
    //задаваться из приложения) и 
    float textureSize = 256.0f;
    
    //размер одного текселя
    float texelSize =  1.0f / 256.0f;
 
    float4 tex2Dlod_bilinear( sampler texSam, float4 uv )
    {
        float4 height00 = tex2Dlod(texSam, uv);
        float4 height10 = tex2Dlod(texSam, uv + float4(texelSize,0,0,0));                                         
        float4 height01 = tex2Dlod(texSam, uv + 
                                           float4(0,texelSize,0,0)); 
                                                  
        float4 height11 = tex2Dlod(texSam, uv + 
                                           float4(texelSize,texelSize,0,0));
 
        float2 f = frac( uv.xy * textureSize );
        float4 tA = lerp( height00, height10, f.x );
        float4 tB = lerp( height01, height11, f.x );
        return lerp( tA, tB, f.y );
    }

Данный код реализует билинейную фильтрацию, берется 4 ближайших пикселя от текущих текстурных координат и между ними производится интерполяция, таким образом, находится среднее значение высоты вершины.

Для иcпользования билинейной фильтрации в вершинном шейдере замените
    float height = tex2Dlod ( displacementSampler, 
                              float4(In.uv.xy, 0, 0 ));

на
  float height = tex2Dlod_bilinear( displacementSampler, 
                                      float4(In.uv.xy, 0, 0));

Теперь для этих же значений Dimension = 512, cellSize = 8 и maxHeight = 512, поверхность ландшафта выглядит значительно лучше:

clip_image004

Бонуc: Текстурирование

Давайте наложим на наш ландшафт несколько текстур. Наилучшим способом затекстурить поверхность является смешать несколько текстур (например: песок, траву, скалы и снег) в пиксельном шейдере, основываясь на весах вершин, которые рассчитываются из данных высот вершин. У Riemer`s есть отличный урок, как реализовать это, но в его реализации веса для смешивания текстур рассчитываются на CPU, на этапе загрузки карты высот, мы же будем это делать на GPU. Представленный далее код написан по материалам уроков Riemer`s.

Для начала в папку проекта Textures добавьте файлы sand.dds, grass.dds, rock.dds и snow.dds (примечание: для уменьшения размера скачиваемого архива эти файлы представлены в низком разрешении, наличие этих файлов в высоком разрешении заметно улучшит картинку в визуальном плане). Затем откройте файл VTFDisplacement.fx и добавьте четыре параметра для текстур и самплеры.
texture sandMap;
    sampler sandSampler = sampler_state
    {
        Texture   = <sandMap>;
        
        MipFilter = Linear;
        MinFilter = Linear;
        MagFilter = Linear;
        
        AddressU  = Wrap;
        AddressV  = Wrap;
    };
 
    texture grassMap;
    sampler grassSampler = sampler_state
    {
        Texture   = <grassMap>;
        
        MipFilter = Linear;
        MinFilter = Linear;
        MagFilter = Linear;
        
        AddressU  = Wrap;
        AddressV  = Wrap;
    };
 
    texture rockMap;
    sampler rockSampler = sampler_state
    {
        Texture   = <rockMap>;
        
        MipFilter = Linear;
        MinFilter = Linear;
        MagFilter = Linear;
        
        AddressU  = Wrap;
        AddressV  = Wrap;
    };
 
    texture snowMap;
    sampler snowSampler = sampler_state
    {
        Texture   = <snowMap>;
        
        MipFilter = Linear;
        MinFilter = Linear;
        MagFilter = Linear;
        
        AddressU  = Wrap;
        AddressV  = Wrap;
    };
 

Теперь нам надо добавить параметр в выходную структуру вершинного шейдера. Для каждой вершины на выходе мы будем задавать 4 значения веса для каждой из текстур (песок, трава, скала, снег). Итак, для вершин с малым значением высоты мы будем накладывать только текстуру песка, так что вес для песка будет равен 1, пока все остальные веса будет равны 0. По мере увеличения высоты мы должны будем сделать переход от песка к траве, так что вес песка будет уменьшаться, а все травы увеличиваться. Так как каждое значение веса лежит в промежутке от 0,0 до 1,0, мы можем запаковать все значения в 4 переменные типа float, а его в один float4 параметр. Новая выходная структура будет выглядеть примерно так:
   struct VS_OUTPUT
    {
        float4 position       : POSITION;
        
        float4 uv             : TEXCOORD0;
        float4 worldPos       : TEXCOORD1;
        
        // вес, использующийся для мультитекстурирования
        float4 textureWeights : TEXCOORD2;
    };

Давайте двигаться дальше, и в конец вершинного шейдера добавим следующий код:
float4 TexWeights = 0;
 
    TexWeights.x = saturate( 1.0f - abs(height - 0.0) / 0.20f);
    TexWeights.y = saturate( 1.0f - abs(height - 0.3) / 0.25f);
    TexWeights.z = saturate( 1.0f - abs(height - 0.6) / 0.25f);
    TexWeights.w = saturate( 1.0f - abs(height - 0.9) / 0.25f);
 
    float totalWeight = TexWeights.x +
                        TexWeights.y +
                        TexWeights.z +
                        TexWeights.w;
                                 
    TexWeights /= totalWeight;
 
    Out.textureWeights = TexWeights;
 

Каждый компонент вектора textureWeights содержит в себе вес для конкретной текстуры, X – песок, Y – трава, Z – скала и W – снег. Для каждой из текстур есть область, на которой она начинает проявлять себя, затем достигает максимальной «видимости» и затухает, переходя в следующую текстуру. Последние инструкции нормализуют значения, чтобы сумма всех весов имела значение 1, иначе мы получим темные или светлые участки в местах перехода текстур.

Когда мы производим выборку в пиксельном шейдере, то текстурные координаты мы будем перемножать на специально подобранное значение (в нашем случае 8), это делается для повтора текстуры на все участки ландшафта. Если выбрать данное значение слишком маленьким, то уровень «натяжки» текстуры на ландшафт будет слишком большим и при близком рассмотрении будет заметна пикселизация. Если задать слишком большое значение, то повторение текстуры будет бросаться в глаза при просмотре с дальнего расстояния. Но Вы свободны в выборе данного значения, можете поэкспериментировать. Техника, называемая “detail texturing”, может помочь в решении данной проблемы, она заключается в комбинировании с более детализированными текстурами при приближении к поверхности, но я не буду рассматривать её в данном уроке.

И в завершении в пиксельном шейдере, мы считываем цвет со всех 4 текстур и на основе весов смешиваем их.
float4 PixelShader(in float4 uv      : TEXCOORD0, 
                       in float4 weights : TEXCOORD2) : COLOR
    {
         float4 sand  = tex2D(sandSampler,  uv * 8 );
         float4 grass = tex2D(grassSampler, uv * 8 );
         float4 rock  = tex2D(rockSampler,  uv * 8 );
         float4 snow  = tex2D(snowSampler,  uv * 8 );
         
         return sand  * weights.x + 
                grass * weights.y + 
                rock  * weights.z + 
                snow  * weights.w;  
    }

В итоге ландшафт будет выглядеть примерно так:

clip_image005

Подведём итоги первого раздела нашего обучающего приложения.

Итак, в первом уроке мы рассмотрели, как использовать вершинные текстуры для построения ландшафта, используя карту высот и как наложить на него текстуры при помощи мультитекстурирования, причем вся обработка велась на графическом процессоре. Также мы рассмотрели, как реализовать билинейную фильтрацию в вершинном шейдере. Вы можете удивиться тому, что мы все это сделали на GPU и пересчитывали каждый кадр, вместо того чтобы сделать все расчета один раз на CPU в момент загрузки. В следующей части мы рассмотрим, как реализовать морфинг (morph) ландшафта и как добавлять на него деформации. Вообще-то, всю эту функциональность можно реализовать и на CPU, но мы все сделали на GPU, оставив ресурсы центрального процессора для других задач, например: реализация логики геймплея, физики, AI и т.п.

Полный код этой главы можете скачать здесь: Chapter1.zip

I. Морфинг ландшафта


В данном разделе мы рассмотрим как создать трансформации ландшафта между двумя картами высот. Некоторые мысли по поводу того, где можно применить данный эффект: перемещение между уровнями, ускоренная анимация изменения поверхности, происходящие с ландшафтом в течение миллиардов лет или анимация природных катастроф и землетрясений. Я уверен, что Вы придумаете, где использовать этот эффект. Метод, рассматриваемый в этой части является отправной точкой для реализации прочих эффектов, которые будут рассматриваться далее.

Как мы видели ранее, каждая вершина соотносится с пикселем карты высот, чтобы осуществить морфинг между двумя картами высот мы должны интерполировать представление высоты между первой и второй картой высот. Даная интерполяция будет оперировать данными в промежутке между 0,0f и 1,0f. Так что, если мы плавно будем изменять это число, то ландшафт так же плавно “перетечет” из одной формы в другую. Итак, для простоты назовем это число – морф фактором (morph factor).

Итак, сейчас мы уже можем видеть преимущества выполнения данной операции на GPU. Если мы будем производить все расчеты на стороне CPU, то вершинный буфер будет изменяться каждый кадр, что является довольно дорогостоящей операцией. Ребята из команды разработчиков XNA проделали великолепную работу, и использование DrawUserIndexedPrimitives или DrawUserPrimitives может значительно упростить задачу, чтобы как можно меньше связываться с динамическим вершинным буфером, но мы попытаемся все же реализовать это на GPU, если получится. Есть два способа достичь желаемого результата.

Комментариев нет:

Отправить комментарий