Freigeben über


Эффекты на основе XNA: рисование ASCII-символами в трехмерном пространстве

Опубликовано 14 апреля 2010 г. 09:00 | Coding4Fun

В этой статье я продемонстрирую, как создавать трехмерные изображения ASCII-символами.

Автор: Льюис Ингентрон (Louis Ingenthron), FV Productions
Исходный код: https://xnaascii.codeplex.com/
Запуск программы: https://xnaascii.codeplex.com/
Сложность: средняя
Необходимое время: 1–4 часа
Затраты: бесплатно
ПО: Visual C# Express, .NET Framework 3.5, XNA Game Studio 3.1
Оборудование: ПК под управлением Windows

Введение

В ноябре 2009 года я столкнулся с одной интересной задачей. Тот, кто часто бывает на форумах сообщества разработчиков на основе XNA (XNA Community Forums), вероятно, слышал о Нике Грейвлине (Nick Gravelyn). Он был XNA MVP, а теперь работает в группе XNA. Он организовал небольшой конкурс под названием «xna7day», в котором разработчикам предлагалось попробовать свои силы в создании игры за семь дней, используя заранее заданную тему.

Такой темой в ноябре была формула «Text Based» (на основе текста), и правило гласило, что для визуализации своей игры вы могли использовать только текстовые символы. Я видел некоторые интересные работы от других участников, включая ту, в которой персонажи были созданы из текстовых символов и могли ходить и говорить, как настоящие люди. Но мне на ум тут же пришло другое решение: мне захотелось сделать игру, которая была бы полностью трехмерной (что, конечно же, нарушает правило, требующее использования текстового режима для визуализации), но после подготовки сцены осуществляла бы ее постобработку для преобразования в ASCII-символы, так что в итоге игра выглядела бы так, будто она визуализируется в текстовом режиме.

В этой статье я покажу, как сделать вот такое:

clip_image001clip_image002

Подготовка

Отлично, давайте прямо к делу. Откройте базовый проект. Он должен компилироваться и выполняться в том виде, как есть. Это простой (пусть и уродливо выглядящий) шутер от первого лица. Пройдите первый уровень. Давайте, я подожду. Бонусные очки всем, кто опознает, откуда взят дизайн уровня!

Ну что, поиграли? Тогда начнем! Я разбил эту игру на три проекта, чтобы начинку, связанную с ASCII, можно было переносить в другие игры. Основную часть работы мы будем выполнять в проекте ASCII_Renderer, поэтому откройте его и добавьте новый класс с именем Renderer в папку Source\ascii. Для всех файлов в этом проекте я использую пространство имен ASCII3D — это позволяет избавиться от указания полных путей. Также вставьте следующие выражения using:

C#

 using Microsoft.Xna.Framework;

using Microsoft.Xna.Framework.Content;

using Microsoft.Xna.Framework.Graphics; 

Эффект будет применяться при постобработке, поэтому мы попытаемся реализовать его так, чтобы он был портируемым. Добавьте следующие объявления пустых функций:

C#

 public Renderer(ContentManager content, int xRes, int yRes)

{ }

public void StartScene()

{ }

public void EndScene()

{ }

Конструктор принимает три аргумента: ContentManager, который мы будем использовать для загрузки нашего шрифта и шейдера постобработки, а также переменные разрешения, которые фактически сообщают, сколько строк текста нужно рисовать и сколько символов умещается на каждой строке. Поскольку у нас есть прототипы, мы внесем правки только в файл Source\Game1.cs из проекта ASCIIFPS. Но сначала нужно определить и инициализировать новый объект Renderer. Добавьте новую переменную класса:

C#

 /// <summary>

/// Обрабатывает преобразование в ASCII

/// </summary>

private Renderer renderer; 

А в конец функции LoadContent вставьте:

C#

 // Инициализирует наш ASCII-рендер.

// Передаваемые нами переменные задают размер каждого символа

// примерно 10×14, что является наименьшим читаемым размером

renderer = new Renderer(Content, Global.ScreenWidth/10,Global.ScreenHeight/14);

Наконец, вызывайте функции BeginScene и EndScene. Измените функцию Draw в Game1 так:

C#

 protected override void Draw(GameTime gameTime)
{
    // Заметьте, что мы просто обертываем StartScene
    // и EndScene, если флаг UsingASCII равен true
    if (UsingASCII)
        renderer.StartScene();

    currentState.Draw(gameTime);

    if (UsingASCII)
        renderer.EndScene();

    base.Draw(gameTime);
}

Булева переменная UsingASCII определена еще до нас. По умолчанию она равна false, и ее можно переключать в любой момент в процессе игры нажатием клавиши M. Если для тестовых целей нужно сделать так, чтобы она была равна true по умолчанию, просто измените ее значение в функции Initialize в Game1. На этом этапе Renderer готов, и все изменения должны сработать.

Ранее в статье я пропустил другие классы, но о некоторых из них нужно сказать хотя бы несколько слов. Почти все в GlobalModules представляет собой стандартный вспомогательный код. Мы будем использовать его, так что, возможно, вам стоит взглянуть на класс Global. В ASCIIFPS содержится движок FPS, и он практически не имеет отношения к данной статье за тем исключением, что предоставляет своего рода испытательный стенд для проверки нашего эффекта. Можете просмотреть весь исходный код, но спешу предупредить вас: я писал его для семидневного конкурса, поэтому некоторые его части весьма невнятны и лишь по какой-то случайности работоспособны. Ну и наконец, у нас есть проект ASCII_Renderer. В нем уже имеются два файла: BitmapFontGenerator и Letter. Последний из них предназначен для генерации битовой карты заданной буквы шрифта.

Эта функциональность неспецифична для данной статьи, так как любой дизайнер мог бы легко сделать то же самое. Но я не дизайнер и поэтому использую компьютер, который делает такие вещи за меня. В BitmapFontGenerator есть статическая функция, которая принимает текстуры букв (см. проект Content в ASCII_Renderer), обрезает и сортирует их, а затем выводит их в новый объект Texture для наших функций. Этот код я довольно хорошо прокомментировал, так что можете просмотреть его.

Основы постобработки

Прежде чем заняться кодированием следующей части, нужно получить представление об основах постобработки. При постобработке сцены вы сначала осуществляете ее рендеринг вне экрана с помощью RenderTarget. Затем вы можете применить любые нужные эффекты к полученной текстуре перед ее выводом на экран. И это все. Вот как это делается с помощью XNA:

C#

 public class Renderer
{
    private RenderTarget2D SrcImage;

    public Renderer(ContentManager content, int xRes, int yRes)
    {
        SrcImage = new RenderTarget2D(Global.Graphics, 
            xRes, yRes, 1, SurfaceFormat.Color);
    }

    public void StartScene()
    {
        Global.Graphics.SetRenderTarget(0, SrcImage);
        Global.Graphics.Clear(Color.Black);
    }

    public void EndScene()
    {
        Global.Graphics.SetRenderTarget(0, null);
        Global.Graphics.Clear(Color.Black);

        // TODO: рисовать текстуру из RenderTarget
    }
}

Мы создаем нужный RenderTarget2D в конструкторе. Заметьте, что размер RenderTarget совпадает с количеством ASCII-символов, которые мы собираемся рисовать на экране. Подробнее об этом я расскажу в следующем разделе. В функции StartScene мы задаем RenderTarget для нужного устройства, чтобы все операции рисования реально выполнялись на этом устройстве. Затем мы очищаем буфер, так как содержимое объектов RenderTarget между кадрами является неопределенным. В EndScene объекту RenderTarget присваивается null — так в DirectX дается команда «использовать буфер кадров для экрана». Теперь нам нужно вывести созданное изображение. XNA требует применения шейдеров для всех вызовов, связанных с рисованием, даже если они скрыты с помощью BasicEffect и SpriteBatch. В данном случае достаточно создать очень простой шейдер для вывода текстуры на экран. Создайте в проекте Content в ASCII_Renderer новый файл эффектов и присвойте ему имя TextEffect. Включите в него следующий код:

HLSL

 texture2D sourceTex;
sampler2D SourceTextureSampler = sampler_state
{
    Texture = <sourceTex>;
    MinFilter = point;
    MagFilter = point;
    MipFilter = point;
    AddressU = wrap;
    AddressV = wrap;
};

struct VertexShaderInput
{
    float4 Position : POSITION0;
    float2 TexCoords : TEXCOORD0;
};

struct VertexShaderOutput
{
    float4 Position : POSITION0;
    float2 TexCoords : TEXCOORD0;
};

VertexShaderOutput VertexShaderFunction(VertexShaderInput input)
{
    VertexShaderOutput output;
    
    output.Position = input.Position;
    output.TexCoords = input.TexCoords;

    return output;
}

float4 PixelShaderFunction(VertexShaderOutput input) : COLOR0
{
    float4 sourceColor = tex2D(SourceTextureSampler,input.TexCoords);
    
    return sourceColor;
}

technique Technique1
{
    pass Pass1
    {
        VertexShader = compile vs_2_0 VertexShaderFunction();
        PixelShader = compile ps_2_0 PixelShaderFunction();
    }
}

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

Теперь нам нужно внести некоторые изменения в класс Renderer, чтобы использовать Effect и отрисовать подготовленную сцену. Для начала добавьте несколько переменных класса:

C#

 private Effect effect;

private VertexPositionTexture[] verts;

private VertexDeclaration vd;

Вершины (vertices) предназначены для простой четырехугольной области (quad), которая занимает весь экран. Эти вершины нужно инициализировать в функции LoadContent:

C#

 effect = content.Load<Effect>("TextEffect");

verts = new VertexPositionTexture[6];
verts[0] = new VertexPositionTexture(
    new Vector3(-1, -1, 0), new Vector2(0, 1));
verts[1] = new VertexPositionTexture(
    new Vector3(1, -1, 0), new Vector2(1, 1));
verts[2] = new VertexPositionTexture(
    new Vector3(-1, 1, 0), new Vector2(0, 0));
verts[3] = verts[1];
verts[4] = verts[2];
verts[5] = new VertexPositionTexture(
    new Vector3(1, 1, 0), new Vector2(1, 0));

vd = new VertexDeclaration(
    Global.Graphics, VertexPositionTexture.VertexElements);

Наконец, добавляем код рисования в функцию Draw:

C#

 effect.Begin();
effect.CurrentTechnique.Passes[0].Begin();

effect.Parameters["sourceTex"].SetValue(SrcImage.GetTexture());
effect.CommitChanges();
Global.Graphics.RenderState.CullMode = CullMode.None;

Global.Graphics.VertexDeclaration = vd;
Global.Graphics.DrawUserPrimitives<VertexPositionTexture>
     (PrimitiveType.TriangleList, verts, 0, 2);

effect.CurrentTechnique.Passes[0].End();
effect.End();

Мы задаем текстуру, полученную от RenderTarget, отключаем отсечение (culling) (что тут отсекать?) и рисуем ее. Теперь запустите проект. У вас должна появиться возможность переключения между режимами визуализации клавишей M.

clip_image004

Рисованиетекста

«Хорошо, ‑ скажете вы, ‑ при нажатии на клавишу M устанавливается более низкое разрешение. И что?» Помните, мы задали такое разрешение в RenderTarget? Да, каждый пиксель в этой версии с более низким разрешением будет символом в конечном изображении. Как мы преобразуем эти огромные пиксели в символы? Сначала мы определяем шрифт в битовой карте текстуры. Давайте добавим переменную класса Texture2D в Renderer и целочисленную константу для BitmapFontGenerator:

C#

 public Texture2D text; 

private const int NUM_SLOTS = 256;

А теперь создадим эту текстуру с помощью BitmapFontGenerator:

C#

 text = BitmapFontGenerator.GenerateTextTexture(content, NUM_SLOTS);

Вот и все пока. Вернемся к TextEffect.fx. Мы должны добавить несколько переменных:

HLSL

 float2 dstLetterSize;

int numLetters;

texture2D textTex;
sampler2D TextTextureSampler = sampler_state
{
    Texture = <textTex>;
    MinFilter = point;
    MagFilter = point;
    MipFilter = point;
    AddressU = wrap;
    AddressV = wrap;
};

Переменная dstLetterSize будет сообщать нам о размере букв на экране, а переменная numLetters — о количестве букв в текстуре шрифта. Вот как рисуются символы в пиксельном шейдере:

HLSL

 float4 PixelShaderFunction(VertexShaderOutput input) : COLOR0
{
     float4 sourceColor = tex2D(SourceTextureSampler,input.TexCoords);
    
     float2 texCoords = float2(
          input.TexCoords.x-((int)
               (input.TexCoords.x/dstLetterSize.x)*dstLetterSize.x),
          input.TexCoords.y-((int)
               (input.TexCoords.y/dstLetterSize.y)*dstLetterSize.y));
     texCoords /= dstLetterSize;
     texCoords.x /= numLetters;
    
     float4 letterColor = tex2D(TextTextureSampler,texCoords);
    
     return letterColor;
}

Мы уже видели строку задания исходного цвета. В следующей строке кода будет получено смещение на размер буквы вычитанием округленных до ближайших меньших значений (или приведенных к целым числам) координат текстуры. Далее выполняется масштабирование обратно до диапазона 0–1. В итоге новая переменная texCoords заполняется координатами текстуры для буквы в рамках 0–1. Мы делим x на numLetters, потому что наша текстура представляет собой одну длинную горизонтальную строку букв. Наконец, мы получаем цвет буквы по этим новым координатам текстуры. Этот цвет представляет собой один из оттенков серого; когда его значение умножается на значение исходного цвета, каждая буква визуализируется на своем месте с цветом, который используется оригинальным трехмерным рендером. И последнее, что нужно сделать, — задать эти переменные эффекта, определенные в функции EndScene класса Renderer. Там, где мы раньше просто указывали исходную текстуру, теперь делается так:

C#

 effect.Parameters["dstLetterSize"].SetValue(
     new Vector2(1.0f/(float)SrcImage.Width, 1.0f/(float)SrcImage.Height));
effect.Parameters["numLetters"].SetValue(NUM_SLOTS);
effect.Parameters["sourceTex"].SetValue(SrcImage.GetTexture());
effect.Parameters["textTex"].SetValue(text);
effect.CommitChanges();

По большей части этот код понятен и без пояснений, но dstLetterSize — штука более сложная. Чтобы получить размер буквы в пространстве координат текстуры 0–1, общий размер (от 0 до 1) делится на количество букв по данной оси; в нашем случае это размер RenderTarget.

clip_image006

Колесо фортуны

Переключение по клавише M не представляет особого интереса. В традиционном рисовании ASCII-символами весь смысл в том, что для обработки полутонов вы используете знаки, занимающие больше или меньше места. Добавим такой код после умножения texCoords.x:

HLSL

 float lum = (sourceColor.r+sourceColor.g+sourceColor.b)/3.0; 
float val = max(fullValueColor.r,
     max(fullValueColor.g,fullValueColor.b));
    
int ind = ((numLetters-1)-
     max(0,min((numLetters-1),(int)(lum*numLetters))));

texCoords.x += ind*(1.0/numLetters);

В первой строке мы получаем яркость исходного цвета. В следующей — значение. Далее на основе яркости вычисляется индекс буквы. Потом вычитаем его из (numLetters – 1) для изменения значения индекса на обратное, так как нам нужно, чтобы яркость, равная 1, соответствовала нулевому индексу (при таком значении выбирается буква, занимающая наибольшее пространство). Запустите игру. Вы увидите, что теперь показывается значение, но разглядеть, что именно отображается на экране, по-прежнему весьма проблематично:

clip_image008

И, наконец, назначим цвета. Для этого можно было бы просто умножать на значение исходного цвета и получить нечто в таком духе:

clip_image010

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

HLSL

 float4 fullValueColor = sourceColor * (1.0/val);

Здесь мы умножаем исходное значение цвета на его обратное значение val, а затем умножаем полученное значение fullValueColor на цвет буквы в выражении return:

HLSL

 return fullValueColor * letterColor;

Как видите, картинка получается гораздо лучше:

clip_image012

Заключение

Ну вот и все! Мы добились преобразования в реальном времени вывода трехмерного рендера в изображение на основе ASCII-символов. На нем трудновато что-либо разглядеть и пользы особой от этого нет, но ведь изящно, а? Почему бы не добавить такую штуку в качестве пасхального яйца в очередную казуальную игру для Xbox Live (XBL)? Мне было бы очень интересно узнать, как вы используете этот эффект! Если вы им пользуетесь, пожалуйста, пришлите мне экранный снимок на мой адрес электронной почты.

Обавторе

Льюис Ингентрон (Louis Ingenthron) — разработчик игр из Орландо, штат Флорида. Работает над коммерческими играми для игровых приставок и ПК, но вдобавок является владельцем собственной компании FV Productions, выпускающей казуальные игры. Больше всего он известен как автор игры Unsigned с открытым исходным кодом, созданной на основе XNA; специализируется на программировании графики реального времени. Использует XNA со времен бета-версии 2.0 и примерно столько же работает с .NET C#. Также знает несколько других языков программирования, в том числе C, C++ и Java. Изредка он занимается даже веб-разработкой и использует Flash. Льюис также пишет статьи для веб-сайта MSDN Coding4Fun.

Comments

  • Anonymous
    March 24, 2013
    Достаточно интересно. При хорошем желании получиться хороший MATRIX эффект.