Sgt. Conker We are "absolutely fine"

13Sep/100

Article: Arbitrarily Shaped Secondary 2D Viewports

by Harry Trautmann

This tutorial shows a technique to implement a secondary 2D viewport which has a shape that does not need to be rectangular, but can be customized by the programmer. Even moving sprite shapes are possible. Utilized code techniques: secondary rendertarget (of XNA Game Studio 3.1) and an alphamap by a pixelshader (PS 2.0).

This tutorial is for beginner to intermediate level programmers. At least you should know the oo basics and know the nitty-gritty about the XNA framework. Also, basic shader programming is explained somewhere else (e. g. here: http://www.sgtconker.com/2009/11/crash-course-in-hlsl/)

What is a Secondary Viewport?

Normally games let the player watch one scene at a given time. In a jump'n'run game the player's avatar is somewhere in the middle of the screen, and the game always displays a cut-out part of the world depending on the avatar´s position. This cut-out part is the primary (main) viewport.

A secondary viewport would be some kind of a window inside of the main viewport that shows either the same scene by another view or a completely different scene.

What could a secondary viewport be useful for?

I can think of at least 5 applications:

  1. Surveillance cameras: the player can behold faraway places while the screen mainly displays some totally other scene. E. g. this could be useful in multiplayer games where one could be able to place such cameras inside of the enemy´s base for spy actions.
  2. Dimension gates: through the door you see a scene of some fantasy land that gets real when you enter.
  3. Microscope: works by applying some magnifying factor.
  4. Mirrors: as the player´s avatar walks by, the secondary viewport shows it horizontally mirrorred and somewhat displaced.
  5. Special FX: do you remember the intro of some old James Bond movie, where some scenes where shown inside of the shape of a dancer? In principle this can be done with a secondary viewport, when the alpha map is a moving sprite.

These are just some suggestions for where secondary viewports are useful. Nevertheless this tutorial concentrates on showing how the shape of the viewport can be free from a rectangular nature.

Principle

The secondary viewport consists of a secondary RenderTarget object and an alphamap. The alphamap consists of a texture which defines its shape and a pixelshader that modulates pixels by the alphamap texture. The drawing process of the secondary viewport follows these steps:

  1. The spectated scene gets rendered onto the secondary rendertarget.
  2. A texture of the secondary rendertarget is kept. It will be drawn later on.
  3. Then the main scene is drawn onto the videocard´s own rendertarget (main rendertarget).
  4. Finally the texture of the spectated scene is drawn onto the main rendertarget by applying the alphamap as a shader effect.

Create the Background Scene

First we need a scene that we can spectate by the secondary viewport. Understanding the scene implementation itself is not required to comprehend the rest of the tutorial. So to avoid too much confusion we keep the scene code rather simple and encapsulate it mostly into the Star class. Since it is really easy to understand we just skim over some hotspots. Here (SecondaryViewport-SceneOnly.zip) is the code that produces the plain scene. In case you are unsure, if some code belongs to the scene or to the viewport implementation, you can compare this project and the project we will build for the viewport to see the differences.

Idea

Figure 1: Only the Scene - no secondary viewport yet

A field of randomly sized, randomly moving sprite stars will do the trick. The Game1 object will have an array of Star objects that will get updated and drawn. Figure 1 shows the resulting scene that yet misses the secondary viewport.

The Star class

To keep the scene code as far as possible separated from the code that actually implements the secondary viewport, we have the Star class. A Star object keeps track of its position, rotation, moving speed, moving direction and has a unique (also random) color. Each Star object can only move inside of a rectangular area that gets set in the constructor.

Only few things are notably about this class. First all objects share the same texture graphic. The first created Star object loads the static Texture2D member during its constructor call. The same thing applies to the Random value generator.

protected static Texture2D _texStar = null;
protected static Random _randGen = null;

These static objects get created in the constructor call of the first Star object.

if (_texStar == null)
        _texStar = game.Content.Load<Texture2D>("textures//star");
if (_randGen == null)
        _randGen = new Random();

Then in Update, the movement direction of a Star object gets reversed when the position collides with one of the initially set movement boundaries.

if (_pos.X <= _bounds.X || _pos.X >= _bounds.X + _bounds.Width) {
        _move.X *= -1.0f;
        _pos.X = MathHelper.Clamp(_pos.X, _bounds.X, _bounds.X + _bounds.Width);
}// fi
if (_pos.Y <= _bounds.Y || _pos.Y >= _bounds.Y + _bounds.Height) {
        _move.Y *= -1.0f;
        _pos.Y = MathHelper.Clamp(_pos.Y, _bounds.Y, _bounds.Y + _bounds.Height);
}// fi

Last, also in Update, the rotation gets clipped to some value inside of the range 0 to 2 pi.

_rot += (float) (_rotInc * gametime.ElapsedGameTime.TotalMilliseconds);
if (_rot < 0.0f) {
        _rot = MathHelper.TwoPi + (_rot % MathHelper.TwoPi);
} else {
        _rot = _rot % MathHelper.TwoPi;
}// fi

Creating the Scene

The Game1 object has an array of Star objects.

protected Star[] _stars;

It gets created inside of the LoadContent method. Each Star´s movement gets limited to the main window´s ClientBounds rectangle.

Rectangle rcWin = new Rectangle(0, 0, Window.ClientBounds.Width,
                                             Window.ClientBounds.Height);
_stars = new Star[_StarCount];
for (int i = 0; i < _StarCount; i++)
        _stars[i] = new Star(this, rcWin);

Note, that in LoadContent a font gets loaded ("Arial" should be available on any Windows system). It´s used for drawing some coordinates on the screen so the user can orientate easier.

_font = Content.Load<SpriteFont>("fonts\\Arial10");

Updating the Scene

Straightforward. For each Star its Update method gets called.

for (int i = 0; i < _StarCount; i++)
        _stars[i].Update(gameTime);

Drawing the Scene

Each Star has its Draw method called and now and then we draw some coordinate values.

GraphicsDevice.Clear(Color.White);
_spriteBatch.Begin(SpriteBlendMode.AlphaBlend, SpriteSortMode.Immediate,
        SaveStateMode.None);
        // The stars know how to draw by themselves.
        for (int i = 0; i < _StarCount; i++)
                _stars[i].Draw(_spriteBatch);
        // For easier orientation draw some coordinate values.
        float chunksW = Window.ClientBounds.Width / 5;
        float chunksH = Window.ClientBounds.Height / 5;
        for (int x=0; x < 5; x++)
                for (int y = 0; y < 5; y++) {
                        _spriteBatch.DrawString(_font, x.ToString() + "/" + y.ToString(),
                                new Vector2(chunksW * (0.5f + x), chunksH * (0.5f + y)), Color.Black);
                }// for
        _spriteBatch.End();

Create the Alphamap Texture

Now that we have a scene, let´s create the texture for the alphamap. This texture defines the actual shape of the secondary viewport. The texture gets interpreted as a grayscale image. The darker a pixel is in this image, the more transparent the pixel of an image onto which the alphamap gets applied will be. This means a pixel of the alphamap that is completely black, will modulate an incoming pixel to be completely transparent and a pixel of the alphamap that is completely white will modulate an incoming pixel to be completely opaque (i. e. retaining its original color value).
Notes:

  • The alpha value of a pixel in the alphamap texture gets ignored completely. Of course you could use the alpha value of the pixels instead of grayscale, but its easier to see the shape of the map in the image editor by using grayscale.
  • You can use any image editor to create the alphamap. Personally I favour Inkscape which can be used by a GNU license. If you never worked with a vector graphics editor before you may find the following tips useful when creating the alphamap texture.

1. Show the layers

Select the main menu option "Layer / Layers..." (figure 2). It displays the "Layers" window where you can see the layers of the current Inkscape image. Think of a layer being like a transparent sheet on an overhead light projector. The "plus" button creates a new layer, the "minus" button deletes the selected layer. By clicking on the "eye" symbol everything drawn on that layer gets hidden or shown. Create a new layer and rename it by clicking on its name e. g. as "background".

Figure 2: Main menu option "Layer / Layers..." in Inkscape.

2. Create a black background

Select the "background" layer. Then click on the black color at the bottom of the Inkscape window to have black assigned as the color that fills any shape you will draw. On the left side of the Inkscape window you find the "Toolbox" toolbar. Click the "square" button ("Create rectangles and squares") and draw a square onto the image. The results should look similar to figure 3.

Figure 3: Step 2, drawing a rectangular background for the alphamap.

3. Create a sphere

Create another layer (e. g. called "alphashape"). Make sure, that layer is selected. Select the white color at the bottom of the Inkscape window. Then click the "circle" tool ("Create circles, ellipses, and arcs") in the toolbox and draw a spherical object inside of the black background rectangle. The results should look similar to figure 4.

Figure 4: Step 3, rectangular background with sphere on it.

4. Apply a gradient to the sphere shape

First show the "Fill and Stroke" colors by selecting the main menu option "Object / Fill and Stroke...". Above the "Layers" window another window named "Fill and Stroke" should be displayed now. Make sure the "Fill" tab and inside of the drawing area the white sphere are selected (You may need to click the 'select' tool ("Select and transform objects") in the toolbox first to select the sphere). Now click the 'gradient' tool ("Create and edit gradients") in the toolbox. Then press the left mouse button in the center of the white sphere and drag the cursor towards its edge. The results should look similar to figure 5. Since we want to have most of our alphamap opaque, we have to modify the gradient. Press the "Edit..." button ("Edit the stops of the gradient") of the 'tool controls' bar (figure 6). In the appearing "Gradient editor" window press the "Add stop" button. Behold new control points appearing on the gradient lines (figure 7). Close the gradient editor window and click on one of these new points to have it selected in the "Fill and Stroke" window. Now set the "Opacity, %" gauge to "100.0" (figure 8). Finally press the left mouse button onto the new gradient point and drag it towards the edge of the spherical object (figure 9).

Figure 5: Step 4a, a simple gradient applied to the sphere.

Figure 6: Step 4b, the gradient edit button.

Figure 7: Step 4c, the new points on the gradient lines.

Figure 8: Step 4d, modify new gradient step.

Figure 9: Step 4e, repositioning gradient step points.

5. Export the image as PNG

Select the black background rectangle we created in the second step. Select the main menu entry "File / Document Properties...". Inside of the appearing "Document Properties" window in the "Custom size" box press the little plus button and then the "Resize page to drawing or selection" (figure 10). Close the "Document Properties" window. Select the main menu option "File / Export Bitmap...". Press the "Page" button to have the size of the exported bitmap fixed to the page size. Press the "Browse..." button and enter a filename and storage location for the bitmap file. Also do not forget to select PNG as file format and have the filename have a ".png" suffix. Press "Save" to close the file selection dialog. Finally press the "Export" button to actually have the image exported.

Figure 10: the button to have the page resized is a little bit hidden.

6. Import the texture into the Visual C# Project

Back in the MS VC# IDE do not forget to add the alphamap image to the "Content" in the project explorer.

Implement the Viewport

Note that in this sample the main scene and the spectated scene are the same. Therefore it is not necessary to render the scene twice. Instead the main scene gets rendered once onto the texture of the secondary rendertarget, and then this texture gets rendered twice. Otherwise, if there would be many complex objects in the scene (though obviously not the case in this tutorial), the need to render the scene itself twice could become a serious performance hit.

The idea in this sample is to draw a magnified cut-out of the main scene as the secondary viewport. With the mouse you can steer the cut-out rectangle.

New Members of Game1

The Game1 class gets some new members that are necessary for the secondary viewport.
The actual secondary rendertarget. Note that this is a memory space inside of the graphics card memory.

protected RenderTarget2D _rendtarg2;

This Texture2D object that will hold the alphamap texture that we created in the last step:

protected Texture2D _texAlphamap;

The member that will hold the compiled pixel shader effect:

protected Effect _fxAlphamap;

A Rectangle member will hold the rectangular cut-out of the main scene that defines the source of the secondary viewport:

protected Rectangle _rcSrc;

Changes to the Game1 class

In Initialize the cursor gets shown to make tracing the cut-out source easier for the user.

IsMouseVisible = true;

In LoadContent we need to load the alphamap texture and shader effect.

_texAlphamap = Content.Load<Texture2D>("textures\\alphamap");
_fxAlphamap = Content.Load<Effect>("shaders\\AlphaMapping");

Also in LoadContent the secondary rendertarget is created here by calling CreateRenderTarget:

CreateRenderTarget();
[/csharp

Note that the secondary rendertarget always needs to be of the current size of the <em>GraphicsDevice</em>´s backbuffer. Therefore, if you implement some screen resolution / fullscreen mode switches you will also have to call <em>CreateRenderTarget </em>again to fit the secondary rendertarget to the new size of the backbuffer.

In <strong><em>Update </em></strong>the positional change of the mouse cursor defines the source position of the secondary viewport. The <em>MathHelper.Clamp</em> calls make sure that the source rectangle does cover space outside of the main window.
[csharp]
        // Keep track of where the mouse is and update the viewport source.
        MouseState mouse = Mouse.GetState();
        float factor = 0.33f;
        int w = (int)Math.Round(Window.ClientBounds.Width * factor);
        int h = (int)Math.Round(Window.ClientBounds.Height * factor);
        int w2 = (int)Math.Round(Window.ClientBounds.Width * factor * 0.5f);
        int h2 = (int)Math.Round(Window.ClientBounds.Height * factor * 0.5f);
        _rcSrc = new Rectangle(mouse.X - w2, mouse.Y - h2, w, h);
        _rcSrc.X = (int)MathHelper.Clamp(_rcSrc.X, 0, Window.ClientBounds.Width – w);
        _rcSrc.Y = (int)MathHelper.Clamp(_rcSrc.Y, 0, Window.ClientBounds.Height - h);

The most and most important changes to Game1 appear in Draw.

As stated above, the main scene only gets rendered once – and not onto the main rendertarget, but onto the secondary rendertarget (_rendtarg2). So before drawing our scene we need to set the secondary rendertarget as the current one. In order to be able to reset the main rendertarget to be the current one, we have to keep a reference in the local variable rtTemp.

RenderTarget2D rtTemp = null;
rtTemp = (RenderTarget2D)_graphics.GraphicsDevice.GetRenderTarget(0);
_graphics.GraphicsDevice.SetRenderTarget(0, _rendtarg2);

After regularly drawing the scene, we keep a Texture2D of what we just drew. But since GetTexture is only possible on a RenderTarget2D that is not the current one, we first have to reset to the original rendertarget.

_graphics.GraphicsDevice.SetRenderTarget(0, rtTemp);
texTemp = _rendtarg2.GetTexture();

Now we´re ready to assemble the graphical output as it is to be seen by the user. So we clear the (original) rendertarget to black (just to be safe) and paint the Texture2D that we just rendered on it. Note that the texture gets tinted into some light blue so that it´s easier to distinguish it from the secondary viewport graphics.

GraphicsDevice.Clear(Color.Black);
_spriteBatch.Begin(SpriteBlendMode.AlphaBlend, SpriteSortMode.Immediate,
                              SaveStateMode.None);
_spriteBatch.Draw(texTemp,
                         Vector2.Zero, null,
                         new Color(225, 245, 255, 255),
                         0.0f, Vector2.Zero,
                         Vector2.One,
                         SpriteEffects.None, 0.0f);
_spriteBatch.End();

Finally the secondary viewport will be rendered atop of the main scene. Note that the drawing code is outsourced into a method of its own: Draw2ndViewport. Why? Admitted, in this sample it does not make much sense. But assuming you write a program build atop of this sample, which needs to have more than one secondary viewport (e. g. an ingame monitor wall with say 4 monitors showing what goes on elsewhere), it is easier to have a method dedicated to draw a single secondary viewport.
So what does Draw2ndViewport need as input?

  1. rcOut defines the output rectangle of the secondary viewport, i. e. Its size and where it gets drawn at. We calculate this Rectangle just before the call of Draw2ndView.
  2. rcSrc defines the rectangular cut-out source of the secondary viewport. Remember that we calculated this Rectangle in Update.
  3. rcScreen is a Rectangle holding the size of the screen onto which the secondary viewport is to be drawn. It gets used for calculating mapping factors from incoming image space to output space. Here it is set to the main window´s extends. We calculate this Rectangle just before the call of Draw2ndView.
  4. tex is the reference to the texture to be drawn. If drawing cut-out´s of a scene, then a single texture reference can be reused for this parameter.

So the Draw2ndView call looks like this:

Rectangle outrect = new Rectangle(
                     (int)Math.Round(Window.ClientBounds.Width * 0.25f),
                     0,
                     (int)Math.Round(Window.ClientBounds.Width * 0.5f),
                     (int)Math.Round(Window.ClientBounds.Height * 0.5f));
Rectangle screenrect = new Rectangle(0, 0,
                     Window.ClientBounds.Width, Window.ClientBounds.Height);
Draw2ndViewport(outrect, _rcSrc, screenrect, texTemp);

For now treat Draw2ndViewport as a black box. After explaining the pixelshader its workings are easier to understand, and we come back to it.

How the Pixelshader works

The alphamap pixelshader ("AlphaMapping.fx") has three parameter members that need to be set outside, before the effect can be applied.

  1. float4 srcRect: these 4 float values defines the source area inside of the incoming image. The values can be between 0.0f and 1.0f as usual for texel coordinates.
  2. float4 destRect: these 4 float values define the destination area inside of the shaded image. These values too are to be inside of 0.0f and 1.0f.
  3. texture AlphaMap: is to hold the reference to the alphamap texture.

The shader has two samplers, the first for the incoming image and the other for the alphamap.

sampler IncomingSampler;
sampler AlphaMapSampler = sampler_state {
        Texture = <AlphaMap>;
        MinFilter = Linear;
        MagFilter = Linear;
        MipFilter = Linear;
        AddressU  = Mirror;
        AddressV  = Mirror;
};

The actual work of modulating pixels of the incoming image by corresponding pixels of the alphamap texture happens inside of the PS_AlphaMap function.

float4 PS_AlphaMap(float2 texCoord: TEXCOORD0) : COLOR

The function receives texture coordinate inside of the texture image to be processed. Again these two float values are between 0.0f and 1.0f. It returns four float values defining the color that the pixel at that coordinate position is to have.
First we get whatever color is at the original position of the incoming texture image. colOut will hold the color value that PS_AlphaMap returns.

float4 colOut = tex2D(IncomingSampler, texCoord);

Then we check, if the incoming coordinate is inside of the output area to be modulated by the alphamap.

if ((texCoord[0] >= destRect[0]) && (texCoord[0] < (destRect[0] + destRect[2])) &&
      (texCoord[1] >= destRect[1]) && (texCoord[1] < (destRect[1] + destRect[3]))){

If it is not inside of the destination area, the read color is assured to stay like it is. Therefore the a value is set to opaque.

} else {
      colOut.a = 0.0f;
}// fi

But if the coordinate is inside of the destination area, its color value gets modulated by the alphamap before returning it. Then it is necessary to respect that the source of the output color could be some other pixel than the one we currently have the coordinates for in texCoord. Depending on the size of the incoming texture image and the size of the destination rectangle the new source coordinate gets calculated and stored in newSrc. Remember that all these values are relative share values between 0.0f and 1.0f. Afterwards colOut is filled with the color of the pixel at this new coordinates in the incoming texture image.

float2 newSrc;
float shareX = (texCoord[0] - destRect[0]) / destRect[2];
float shareY = (texCoord[1] - destRect[1]) / destRect[3];
newSrc[0] = srcRect[0] + shareX * srcRect[2];
newSrc[1] = srcRect[1] + shareY * srcRect[3];
colOut = tex2D(IncomingSampler, newSrc);

Now we have to read the color value of the alphamap that corresponds to the incoming coordinates relative to the destination rectangle size.

float2 mappedTC;
float4 colAlphaMap;
mappedTC[0] = ((texCoord[0] - destRect[0]) / destRect[2]);
mappedTC[1] = ((texCoord[1] - destRect[1]) / destRect[3]);
colAlphaMap = tex2D(AlphaMapSampler, mappedTC);

Finally the current incoming color value gets modulated by the alphamap color value. This is done by simply multiplying the a parts of the colors and leaving the r, g, b of the incoming value as they are. Note that since the alphamap texture should be grayscale the r, g, b values should be the same for a single pixel in the alphamap texture. Therefore in the pixelshader only the r value of a pixel´s color is processed, r and b get ignored.

colOut.a = colOut.a * colAlphaMap.r;

Finally in the technique AlphaMapShader the Pass0 defines that the AlphaMapSampler and the PS_AlphaMap functions are to be used.

technique AlphaMapShader {
        pass Pass0 {
                AlphaBlendEnable = True;
                SrcBlend = SrcAlpha;
                DestBlend = InvSrcAlpha;
                Sampler[0] = (AlphaMapSampler);
                PixelShader = compile ps_2_0 PS_AlphaMap();
        }// Pass0
} // AlphaMapShader

Draw 2nd Viewport

So back in Game1 we only need to explain what Draw2ndView does. Actually it only sets the parameters of the pixelshader and draws the given texture image by applying the alphamap shader – all wrapped in the SpriteBatch´s Begin and End methods.
Setting the AlphaMap shader parameter:

_fxAlphamap.Parameters["AlphaMap"].SetValue(_texAlphamap);

For setting the srcRect shader parameter we first need to have the source rectangle mapped to share values between 0.0f and 1.0f relative to the texture´s size.

float[] srcRect = new float[4] {
        (float) rcSrc.X / (float)tex.Width,
        (float) rcSrc.Y / (float)tex.Height,
        (float) rcSrc.Width / (float)tex.Width,
        (float) rcSrc.Height / (float)tex.Height
};
_fxAlphamap.Parameters["srcRect"].SetValue(srcRect);

Setting the destRect shader parameter also needs mapping the values to share values, but this time relative to the output screen´s size.

float[] destRect = new float[4];
destRect[0] = (rcOut.X + rcScreen.X) / (float)rcScreen.Width;
destRect[1] = (rcOut.Y + rcScreen.Y) / (float)rcScreen.Height;
destRect[2] = (rcOut.Width) / (float)rcScreen.Width;
destRect[3] = (rcOut.Height) / (float)rcScreen.Height;
_fxAlphamap.Parameters["destRect"].SetValue(destRect);

After setting these parameters drawing the given texture by applying the shader is straightforward just like with other shaders.

_fxAlphamap.CurrentTechnique = _fxAlphamap.Techniques["AlphaMapShader"];
_fxAlphamap.Begin();
_fxAlphamap.CurrentTechnique.Passes[0].Begin();
        _spriteBatch.Draw(tex,
                        new Vector2(),
                        null,
                        Color.White,
                        0.0f, Vector2.Zero,
                        Vector2.One,
                        SpriteEffects.None, 0.0f);
_fxAlphamap.CurrentTechnique.Passes[0].End();
_fxAlphamap.End();

Figure 11 shows what the result looks like.

Figure 11: the scene with a magnifying secondary viewport.

Find the sample code here: SecondaryViewport.zip.

Ideas for Extensions

Homework for you!

  • The mousewheel could steer the zoom. The zoom is controlled by the size of the rectangle defining the cut-out of the main scene.
  • Of course the spherical form of the alphamap is only a suggestion. It´s easy to reproduce, therefore it seemed appropriate for this tutorial. Actually having a completely customized alphamap shape is what this tutorial is about – go ahead and change it to whatever form you like!
  • As mentioned in the intro, the alphamap could also be a moving 2D sprite. I extended the sample of this tutorial to reflect, how this could be established. You can get the code here: SecondaryViewport-MovingSprite.zip.

License

This text and any referred code is licensed under a "Creative Commons Attribution 3.0 Unported" license, which can be read here: http://creativecommons.org/licenses/by/3.0/

The author of all text, any code and any assets found in this tutorial likes to be referred by Harry Trautmann, http://codemachinery.net

About Absolutely Fine Tutorial Contest

Look out for the complete list of entries in our tutorial contest! Coming soon (it's being built step by step)!
Comments (0) Trackbacks (1)

Leave a comment


*