Quantcast
Channel: GameDev.net
Viewing all articles
Browse latest Browse all 17825

A Room With A View

$
0
0
A Viewport allows for a much larger and richer 2-D universe in your game. It allows you to zoom in, pan across, and scale the objects in your world based on what the user wants to see (or what you want them to see).

The Viewport is a software component (written in C++ this time) that participates in a larger software architecture. UML class and sequence diagrams (below) show how these interactions are carried out.

The algorithms used to create the viewport are not complex. The ubiquitous line equation, y = m.x + b, is all that is needed to create the effect of the Viewport. The aspect ratio of the screen is also factored in so that "squares can stay squares" when rendered.

Beyond the basic use of the Viewport, allowing entities in your game to map their position and scale onto the display, it can also be a larger participant in the story your game tells and the mechanics of making your story work efficiently. Theatrical camera control, facilitating the level of detail, and culling graphics operations are all real-world uses of the Viewport.

NOTE: Even though I use Box2D for my physics engine, the concepts in this article are independent of that or even using a physics engine for that matter.

The Video


The video below shows this in action.




The Concept


The world is much bigger than what you can see through your eyes. You hear a sound. Where did it come from? Over "there". But you can't see that right now. You have to move "there", look around, see what you find. Is it an enemy? A friend? A portal to the bonus round? By only showing your player a portion of the bigger world, they are goaded into exploring the parts they cannot see. This way lies a path to immersion and entertainment.

A Viewport is a slice of the bigger world. The diagram below shows the basic concept of how this works.


Attached Image: Viewport-Concept.png


The Game World (left side) is defined to be square and in meters, the units used in Box2D. The world does not have to be square, but it means one less parameter to carry around and worry about, so it is convenient.

The Viewport itself is defined as a scale factor of the respective width/height of the Game World. The width of the Viewport is scaled by the aspect ratio of the screen. This makes it convenient as well. If the Viewport is "square" like the world, then it would have to lie either completely inside the non-square Device Screen or with part of it completely outside the Device Screen. This makes it unusable for "IsInView" operations that are useful (see Other Uses at the end).

The "Entity" is deliberately shown as partially inside the Viewport. When displayed on the Device Screen, it is also only shown as partially inside the view. Its aspect on the screen is not skewed by the size of the screen relative to the world size. Squares should stay squares, etc.

The "nuts and bolts" of the Viewport are linear equations mapping the two corner points (top left, bottom right) in the coordinate system of the world onto the screen coordinate system. From a "usage" standpoint, it maps the positions in the simulated world (meters) to a position on the screen (pixels). There will also be times when it is convenient to go the other way and map from pixels to meters. The Viewport class handles the math for the linear equations, computing them when needed, and also provides interfaces for the pixel-to-meter or meter-to-pixel transformations.

Note that the size of the Game World used is also specifically ambiguous. The size of all Box2D objects should be between 0.1m and 10m, the world can be much larger as needed and within realistic use of the float32 precision used in Box2D. That being said, the Viewport size is based on a scale factor of the Game World size, but it is conceivable (and legal) to move the Viewport outside of the "established" Game World size. What happens when you view things "off the grid" is entirely up to your game design.

Classes and Sequences


The Viewport does not live by itself in the ecosystem of the game architecture. It is a component that participates in the architecture. The diagram below shows the major components used in the Missile Demo application.


Attached Image: Missile-Demo-Main-Components.png


The main details of each class have been omitted; we're more interested in the overall component structure than internal APIs at this point.

Main Scene


The MainScene (top left) is the container for all the visual elements (CCLayer-derived objects) and owner of an abstract interface, the MovingEntityIFace. Only one instance exists at a time. The MainScene creates a new one when signaled by the DebugMenuLayer (user input) to change the Entity. Commands to the Entity are also executed via the MainScene. The MainScene also acts as the holder of the Box2D world reference.

Having the MainScene tie everything together is perfectly acceptable for a small single-screen application like this demonstration. In a larger multi-scene system, some sort of UI Manager approach would be used.

Viewport and Notifier


The Viewport (lower right) is a Singleton. This is a design choice. The motivations behind it are:
  • There is only one screen the user is looking at.
  • Lots of different parts of the graphics system may use the Viewport.
  • It is much more convenient to do it as a "global" singleton than to pass the reference around to all potential consumers.
  • Deriving it from the SingletonDynamic template ensures that it follows the Init/Reset/Shutdown model used for all the Singleton components. It's life cycle is entirely predictable: it always exists.
The Notifier is also pictured to highlight its importance; it is an active participant when the Viewport changes. The diagram below shows exactly this scenario.


Attached Image: Pinch-Changes-Viewport.png


The user user places both fingers on the screen and begins to move them together (1.0). This move is received by the framework and interpreted by the TapDragPinchInput as a Pinch gesture, which it signals to the MainScene (1.1). The MainScene calls SetCenter on the Viewport (1.2) which immediately leads to the Viewport letting all interested parties know the view is changing via the Notifier (1.3). The Notifier immediately signals the GridLayer, which has registered for the event (1.4). This leads to the GridLayer recalculating the position of its grid lines (1.5). Internally, the GridLayer maintains the grid lines as positions in meters. It will use the Viewport to convert these to positions in pixels and cache them off. The grid is not actually redrawn until the next draw(...) call is executed on it by the framework.

The first set of transactions were executed synchronously as the user moved their fingers; each time a new touch event came in, the change was made. The next sequence (starting with 1.6) is initiated when the framework calls the Update(...) method on the main scene. This causes an update of the Box2D physics model (1.7). At some point later, the framework calls the draw(...) method on the Box2dDebugLayer (1.8). This uses the Viewport to calculate the display positions of all the Box2D bodies (and other elements) it will display (1.9).

These two sequences demonstrate the two main types of Viewport update sequences. The first is triggered by the a direct change of the view leading to events that trigger immediate updates. The second is called by the framework every major update of the model (as in MVC).

Algorithms


The general method for mapping the world space limits (Wxmin, Wxmax) onto the screen coordinates (0,Sxmax) is done by a linear mapping with a y = mx + b formulation. Given the two known points for the transformation:

Wxmin (meters) maps onto (pixel) 0 and
Wxmax (meters) maps onto (pixel) Sxmax
Solving y0 = m*x0 + b and y1 = m*x1 + b1 yields:

m = Sxmax/(Wxmax - Wxmin) and
b = -Wxmin*Sxmax/(Wxmax - Wxmin) (= -m * Wxmin)

We replace (Wxmax - Wxmin) with scale*(Wxmax-Wxmin) for the x dimension and scale*(Wymax-Wymin)/aspectRatio in the y dimension.

The value (Wxmax - Wxmin) = scale*worldSizeMeters (xDimension)

The value Wxmin = viewport center - 1/2 the width of the viewport

etc.

In code, this is broken into two operations. Whenever the center or scale changes, the slope/offset values are calculated immediately.

void Viewport::CalculateViewport()
{
   // Bottom Left and Top Right of the viewport
   _vSizeMeters.width = _vScale*_worldSizeMeters.width;
   _vSizeMeters.height = _vScale*_worldSizeMeters.height/_aspectRatio;

   _vBottomLeftMeters.x = _vCenterMeters.x - _vSizeMeters.width/2;
   _vBottomLeftMeters.y = _vCenterMeters.y - _vSizeMeters.height/2;
   _vTopRightMeters.x = _vCenterMeters.x + _vSizeMeters.width/2;
   _vTopRightMeters.y = _vCenterMeters.y + _vSizeMeters.height/2;

   // Scale from Pixels/Meters
   _vScalePixelToMeter.x = _screenSizePixels.width/(_vSizeMeters.width);
   _vScalePixelToMeter.y = _screenSizePixels.height/(_vSizeMeters.height);

   // Offset based on the screen center.
   _vOffsetPixels.x = -_vScalePixelToMeter.x * (_vCenterMeters.x - _vScale*_worldSizeMeters.width/2);
   _vOffsetPixels.y = -_vScalePixelToMeter.y * (_vCenterMeters.y - _vScale*_worldSizeMeters.height/2/_aspectRatio);

   _ptmRatio = _screenSizePixels.width/_vSizeMeters.width;

   Notifier::Instance().Notify(Notifier::NE_VIEWPORT_CHANGED);
}

Note:  Whenever the viewport changes, we emit a notification to the rest of the system to let interested parties react. This could be broken down into finer detail for changes in scale vs. changes in the center of the viewport.


When the a conversion from world space to viewport space is needed:

CCPoint Viewport::Convert(const Vec2& position)
{
   float32 xPixel = position.x * _vScalePixelToMeter.x + _vOffsetPixels.x;
   float32 yPixel = position.y * _vScalePixelToMeter.y + _vOffsetPixels.y;
   return ccp(xPixel,yPixel);
}

And, occasionally, we need to go the other way.

/* To convert a pixel to a position (meters), we invert
 * the linear equation to get x = (y-b)/m.
 */
Vec2 Viewport::Convert(const CCPoint& pixel)
{
   float32 xMeters = (pixel.x-_vOffsetPixels.x)/_vScalePixelToMeter.x;
   float32 yMeters = (pixel.y-_vOffsetPixels.y)/_vScalePixelToMeter.y;
   return Vec2(xMeters,yMeters);
}

Position, Rotation, and PTM Ratio


Box2D creates a physics simulation of objects between the sizes of 0.1m and 10m (according to the manual, if the scaled size is outside of this, bad things can happen...the manual is not lying). Once you have your world up and running, you need to put the representation of the bodies in it onto the screen. To do this, you need its rotation (relative to x-axis), position, and a scale factor to convert the physical meters to pixels. Let's assume you are doing this with a simple sprite for now.

The rotation is the easiest. Just ask the b2Body what its rotation is and convert it to degrees with CC_RADIANS_TO_DEGREES(...). Use this for the angle of your sprite.

The position is obtained by asking the body for its position in meters and calling the Convert(...) method on the Viewport. Let's take a closer look at the code for this.

/* To convert a position (meters) to a pixel, we use
 * the y = mx + b conversion.
 */
CCPoint Viewport::Convert(const Vec2& position)
{
   float32 xPixel = position.x * _vScalePixelToMeter.x + _vOffsetPixels.x;
   float32 yPixel = position.y * _vScalePixelToMeter.y + _vOffsetPixels.y;
   return ccp(xPixel,yPixel);
}

This is about as simple as it gets in the math arena. A linear equation to map the position from the simulated physical space (meters) to the Viewport's view of the world on the screen (pixels). A key nuance here is that the scale and offset are calculated ONLY when the viewport changes.

The scale is called the pixel-to-meter ratio, or just PTM Ratio. If you look inside the CalculateViewport method, you will find this rather innocuous piece of code:

   _ptmRatio = _screenSizePixels.width/_vSizeMeters.width;

The PTM Ratio is computed dynamically based on the size of the width viewport (_vSizeMeters). Note that it could be computed based on the height instead; be sure to define the aspect ratio, etc., appropriately.

If you search the web for articles on Box2D, whenever they get to the display portion, they almost always have something like this:

#define PTM_RATIO 32

Which is to say, every physical body is represented by a ratio of 32 pixels (or some other value) for each meter in the simulation. The original iPhone screen was 480 x 320, and Box2D represents objects on the scale of 0.1m to 10m, so a full sized object would take up the full width of the screen. However, it is a fixed value. Which is fine.

Something very interesting happens though, when you let this value change. By letting the PTM Ratio change and scaling your objects using it, the viewer is given the illusion of depth. They can move into and out of the scene and feel like they are moving into and out of the scene in the third dimension.

You can see this in action when you use the pinch operation on the screen in the App. The Box2DDebug uses the Viewport's PTM Ratio to change the size of the displayed polygons. It can (and has) been used to also scale sprites so that you can zoom in/out.

Other Uses


With a little more work or a few other components, the Viewport concept can be expanded to yield other benefits. All of these uses are complementary. That is to say, they can all be used at the same time without interfering with each other.

Camera


The Viewport itself is "Dumb". You tell it change and it changes. It has no concept of time or motion; it only executes at the time of command and notifies (or is polled) as needed. To execute theatrical camera actions, such as panning, zooming, or combinations of panning and zooming, you need a "controller" for the Viewport that has a notion of state. This controller is the camera.

Consider the following API for a Camera class:

class Camera
{
public:
   // If the camera is performing any operation, return true.
   bool IsBusy();

   // Move/Zoom the Camera over time.
   void PanToPosition(const vec2& position, float32 seconds);
   void ZoomToScale(float32 scale, float32 seconds);

   // Expand/Contract the displayed area without changing
   // the scale directly.
   void ExpandToSize(float32 size, float32 seconds);

   // Stop the current operation immediately.
   void Stop();

   // Called every frame to update the Camera state
   // and modify the Viewport.  The dt value may 
   // be actual or fixed in a fixed timestep
   // system.
   void Update(float32 dt);
};

This interface presents a rudimentary Camera. This class interacts with the Viewport over time when commanded. You can use this to create cut scenes, quickly show items/locations of interest to a player, or other cinematic events.

A more sophisticated Camera could keep track of a specific entity and move the viewport automatically if the the entity started to move too close to the viewable edge.

Level of Detail


In a 3-D game, objects that are of little importance to the immediate user, such as objects far off in the distance, don't need to be rendered with high fidelity. If it is only going to be a "dot" to you, do you really need 10k polygons to render it? The same is true in 2-D as well. This is the idea of "Level of Detail".

The PTMRatio(...) method/member of the Viewport gives the number of pixels an object will be given its size in meters. If you use this to adjust the scale of your displayed graphics, you can create elements that are "sized" properly for the screen relative to the other objects and the zoom level. You can ALSO substitute other graphics when the displayed object will appear to be little more than a blob. This can cut down dramatically on the GPU load and improve the performance of your game.

For example, in Space Spiders Must Die!, each Spider is not single sprite, but a group of sprites loaded from a sprite sheet. This sheet must be loaded into the GPU, the graphics drawn, then another sprite sheet loaded in for other objects. When the camera is zoomed all the way out, we could get a lot more zip out of the system if we didn't have to swap out the sprite sheet at all and just drew a single sprite for each spider. A much smaller series of "twinkling" sprites could easily replace the full-size spider.

Culling Graphics Operations


If an object is not in view, why draw it at all? Well...you might still draw it...if the cost of keeping it from being drawn exceeds the cost of drawing it. In Cocos2D-x, it can get sticky to figure out whether or not you are really getting a lot by "flagging" elements off the screen and controlling their visibility (the GPU would probably handle it from here).

However, there is a much less-ambiguous situation: Skeletal Animations. Rather than use a lot of animated sprites (and sprite sheets), we tend to use Spine to create skeletal animated sprites. These absolutely use a lot of calculations which are completely wasted if you can't see the animation because it is off camera. To save CPU cycles, which are even more limited these days than GPU cycles for the games we make, we can let the AI for the animiation keep running but only update the "presentation" when needed.

The Viewport provides a method called IsInView(...) just for this purpose. Using it, you can flag entities as "in view" or "not in view". Internally, the representation used for the entity can make the decision to update or not based on this.

Conclusion


A Viewport has uses that allows you to create a richer world for the player to "live" in, both by providing "depth" via zooming and allowing you to keep content outside the Viewport. It also provides opportunities to improve the graphics processing efficiency of your game.

Get the Source Code for the this post hosted on GitHub by clicking here.

Article Update Log


6 Nov 2014: Initial release

Viewing all articles
Browse latest Browse all 17825

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>