Entries:
Comments:
Posts:

Loading User Information from Channel 9

Something went wrong getting user information from Channel 9

Latest Achievement:

Loading User Information from MSDN

Something went wrong getting user information from MSDN

Visual Studio Achievements

Latest Achievement:

Loading Visual Studio Achievements

Something went wrong getting the Visual Studio Achievements

XNA Effects – ASCII Art in 3D

In this article, I'll demonstrate how to create a post-processing effect to turn 3D renders into ASCII Art.

Louis Ingenthron
FV Productions

Code It: http://xnaascii.codeplex.com/
Run It: http://xnaascii.codeplex.com/

Difficulty: Intermediate
Time Required: 1-4 hours
Cost: FREE
Software Needed: Visual C# Express, .NET Framework 3.5, XNA Game Studio 3.1
Hardware: Windows PC

Introduction

Back in November of 2009, I was met with an interesting challenge. Those of you who frequent the XNA Community Forums have probably heard of Nick Gravelyn. He was an XNA MVP, and now he works for the XNA team. He started a little contest called xna7day, which was designed to challenge developers to make a game in 7 days using a pre-defined theme.

The theme in November was “Text Based” and its rule was that you were only allowed to use text in the visuals of your game. I saw some of the cool stuff other developers were working on, including one that looked like it had text characters that could walk and talk, like people. But my mind immediately went to a different solution: I wanted to make a game that was fully 3D (effectively breaking the rule of rendering text), but then post-process the render into ASCII art, so it would look like it was text.

In this article, I'm going to show you how to do just that:

clip_image002clip_image004

Setup

Alright, let's dive right in. Open up the base project. It should compile and run as is. It's a simple, if ugly, first-person shooter. Play through the first level. Go ahead, I'll wait. Bonus points to anyone who recognizes the level design!

Okay, got a playthrough? Let's start! I separated this into three projects so the ASCII stuff can be extracted into other games. We'll be doing most of our work in the ASCII_Renderer project, so let's open that up and add a new class to the Source\ascii folder called “Renderer.” I use the namespace “ASCII3D” for all files in this project, so get rid of the extra folder sub-namespaces. Add these using statements, too:

C#

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics; 

This is going to be a post-processing effect, so we'll try to make it portable. Add the following empty function declarations:

C#

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

public void StartScene()
{ }

public void EndScene()
{ }

The constructor takes three arguments: The ContentManager, which we will use to load in our font and post-processing shader, and the resolution variables, which will basically tell us how many lines of text to draw and how many characters in each line. Since we have these prototyped, we'll make our only edits to the ASCIIFPS project in the Source\Game1.cs file. But first, we need to define and initialize a new Renderer object. Add a new class variable:

C#

/// <summary>
/// This is what handles the ASCII conversion
/// </summary>
private Renderer renderer; 

And at the end of the LoadContent function:

C#

// and initialize our ASCII renderer
// the variables we pass make each character
// appx 10x14 which seems to be the smallest
// readable size
renderer = new Renderer(Content, Global.ScreenWidth/10,Global.ScreenHeight/14);

Finally, call the BeginScene and EndScene functions. Modify Game1's Draw function to look like this:

C#

protected override void Draw(GameTime gameTime)
{
    // notice we only wrap StartScene and
    // EndScene if we are UsingASCII
    if (UsingASCII)
        renderer.StartScene();

    currentState.Draw(gameTime);

    if (UsingASCII)
        renderer.EndScene();

    base.Draw(gameTime);
}

The Boolean variable UsingASCII is defined for us. It defaults to false and can be toggled any time in-game by pressing the [M] key. If you want to default it to true for testing purposes, just change its value in Game1's Initialize function. At this point, the Renderer is all hooked up, so changes will go right in.

I skimmed over the other classes earlier in the article, but I need to touch on a few of them. Almost everything in GlobalModules is fairly standard utility stuff. We'll be using it, so you might want to glance at the Global class. The ASCIIFPS has the FPS engine in it and is almost entirely irrelevant to this article, except that it offers a fun test-bed for our effect. Feel free to go through the code, but I'll warn you: I wrote it for a 7-day contest, so some of it looks like gibberish and is only incidentally functional. Finally, we have the ASCII_Renderer project. This already has two files in it: BitmapFontGenerator and Letter. The latter are for generating a bitmap font map that we'll use in a bit.

The functionality isn't specific to this article, since any artist could easily make this. But I'm no artist, which is why I had the computer do it for me. Basically, BitmapFontGenerator has a static function that will take a texture of letters (see the Content project in ASCII_Renderer), trim them, sort them by how much they fill their space, and print them into a new Texture for our functions. Feel free to read through the code for this process; I commented it pretty well.

Post-Processing Basics

We need to cover the basics of post-processing before we start coding the next part. When post-processing a scene, you first render the entire scene off-screen on a RenderTarget. Then, you can apply any effects you want to the resolved texture before putting it on the screen. That's it. Here's how in 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: draw the texture from the RenderTarget
    }
}

We create the RenderTarget2D we need in the constructor. Notice that the size of the RenderTarget is the same as the number of ASCII characters we plan to draw on screen. I'll explain that in the next section. In the StartScene function, we set the RenderTarget on the device so that all draw calls will actually draw to it. Then we clear the buffer, because RenderTargets' content from frame to frame is undefined. In EndScene, the RenderTarget is set to null, which is DirectX's way of saying “use the screen's framebuffer.” Now we need to actually draw the rendered image. XNA requires shaders for all draw calls, even if they're veiled by BasicEffect and SpriteBatch. We need to create a very simple shader to draw the texture to screen. Create a new Effect file in ASCII_Renderer's Content project and name it TextEffect. We'll start it out with this:

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();
    }
}

There are a couple things I want to note about the base shader. First, notice that we use point filtering. I'll explain why in the next section, but it's important to see where we did it. Next, notice that we took out all the default transformation matrices. We're going to be drawing a single full screen quad, so we can just pass in the coordinates in screen-space—no processing required. Now we need to make a few changes to the Renderer class to use this Effect and draw the rendered game. First, we need to add some new class variables:

C#

private Effect effect;

private VertexPositionTexture[] verts;
private VertexDeclaration vd;

The vertices are going to be for a simple quad that takes up the whole screen. They should be initialized in the LoadContent function like this:

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);

And finally, we add our draw code to the Draw function:

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();

We set the texture we got from the RenderTarget, turn off culling (what would be culled in this geometry?), and draw it. Run the project. You should be able to toggle a difference in-game with the [M] key.

clip_image006

Drawing Some Text

“Okay,” you say, “it runs at a lower resolution with the [M] key. So what?” Remember what we set the resolution to in the RenderTarget? Yes, every pixel on this low-resolution version will be a character in the final image. How do we turn these big pixels into characters? First we need to define a texture bitmap font. Let's add a Texture2D class variable to Renderer and a const int for the BitmapFontGenerator:

C#

public Texture2D text; 
private const int NUM_SLOTS = 256;

Then we'll use the BitmapFontGenerator to construct this texture:

C#

text = BitmapFontGenerator.GenerateTextTexture(content, NUM_SLOTS);

Okay, that's all for now. Let's go back to the TextEffect.fx. We need to add a few variables:

HLSL

float2 dstLetterSize;

int numLetters;

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

The variable “dstLetterSize” will tell us how big the letters on the screen are. The “numLetters” variable will tell us how many letters are in the font texture. And then there's the font texture itself. Here's how we draw actual characters in the Pixel Shader:

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;
}

We've already seen the source color line. The next line will get the offset into the letter in letter size by subtracting the rounded-down (or integer-casted) texture coordinates scaled down. The line after that scales it back up to a 0-1 range. So now we have the new variable texCoords that is filled with the texture coordinates of the letter in 0-1 terms. We divide the x by numLetters because our texture is one long horizontal line of letters. Finally we get the letter color with these new texture coordinates. This letter color is grayscale, and when multiplied by the source color, we've assured that each letter is rendered in its own slot with the color from the original 3D render. The last thing we have to do is set those effect variables we defined from Renderer's EndScene function. Where we once just set the source texture, we now set these:

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();

Most of these are self explanatory, but the dstLetterSize is a bit more complicated. To get the size of the letter in the 0-1 texture space, you take the total size (0 to 1) divided by the number of letters in that axis which, in our case, is the RenderTarget size.

clip_image008

Wheel of Fortune

‘M's aren't very interesting. In traditional ASCII art, the whole point is that you use characters that take more space or less space to handle shading. Let's add this code after the multiplication of the 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);

The first line gets the luminosity of the source color. The next gets the value. The next line gets the letter index from the luminosity. Subtracting it from (numLetters-1) reverses the index, because we want luminosity=1 to equate to index=0, because that has the letter that takes up the most space. Run the game. You'll see it now shows value, but it's still pretty hard to tell what's on the screen:

clip_image010

The last thing we can do is colorize it. You could just multiply it by the source color and get something like this…

clip_image012

…but everything looks too dark because the source color still has its value. Plus we're already practically multiplying it by choosing the letter. So we need to get the full value color first:

HLSL

float4 fullValueColor = sourceColor * (1.0/val);

We just multiply the original color by the inverse of the value. And then multiply that fullValueColor by the letter color in the return:

HLSL

return fullValueColor * letterColor;

That should produce a nice result:

clip_image014

Conclusion

And there it is! We have a real-time 3D render transformed into ASCII art. It's difficult to see and it's not very useful, but it's sure neat, eh? Why not add it as an unlockable easter egg in your next XBL Indie Game? I'm looking forward to seeing how people use this effect! If you use it, please send me an email with a screenshot!

About The Author

Louis Ingenthron is a Game Developer in Orlando, FL. He works on commercial Console and PC titles, but runs his own Indie Games company, FV Productions, on the side. He is best known for his open source XNA rhythm game Unsigned and specializes in real-time Graphics programming. He has been working with XNA since the 2.0 Beta and with .NET C# for just as long. He is also familiar with several other development languages, such as C, C++, and Java. Occasionally, he can even be found doing web development and Flash. Louis also writes for MSDN's Coding4Fun website, contributing articles on a monthly basis.

Tags:

Follow the Discussion

  • Richard CarterRichard Carter

    I think the result would be much clearer if you used some linear algebra (matrix math) to determine the best fit letters, rather than just downscaling to solid blocks of color and picking the letter based on the letter's "area". According to your explanation, the algorithm doesn't take into account the shape of each character at all, which is something that could be exploited for example to be able to make out the "8" in the first image, rather than downgrading it to something that looks like a "U".

  • Louis IngenthronLouis Ingenthron

    That looks like maybe a graphics driver issue?  Odd... try updating your graphics driver and if that doesn't work you can try switching your DirectX SDK to Debug Mode or running the app through PIX for windows.

  • Clint RutkasClint I'm a "developer"

    @Niklas Rother, I just compiled the program and it ran fine.  Did you alter the program at all?

  • Clint RutkasClint I'm a "developer"

    @Niklas Rother following with Louis

  • Niklas RotherNiklas Rother

    Hmm... the Download at Codeplex seems to be broken! It gave me this:

    http://img90.imageshack.us/img90/4422/screenshot009l.png

    The textures seems to be wrong!

  • Clint RutkasClint I'm a "developer"

    @Richard Carter The project is open source, we'd love it if you could contribute back!

  • Louis IngenthronLouis Ingenthron

    @Richard That's an interesting idea... think it could be done in real-time?

  • WillWill

    Its like Doom (I noticed the ammo clip).

  • Clint RutkasClint I'm a "developer"

    @Melonize sorry, right now XNA is c# only.  In addition, a lot of this is HLSL which is for shaders which is C-like as well.  No way of getting around that no matter what.

  • MelonizeMelonize

    Could you make a visual basic version? That would help... I seem interested in your project.

  • Louis IngenthronLouis Ingenthron

    @Will Nice try, but you are going to have to go back a bit further.  @Melonize Like @Coding4Fun said, XNA is C# only.  However, it doesn't take a lot of effort to use Managed DirectX in VB and all of the algorithms should translate 1 to 1, you just have to change the graphics calls and set up the window yourself.  The HLSL should work without any changes!

Remove this comment

Remove this thread

close

Comments Closed

Comments have been closed since this content was published more than 30 days ago, but if you'd like to continue the conversation, please create a new thread in our Forums,
or Contact Us and let us know.