Fast Map Zooming Using DirectX
March 9, 2013
New York, N.Y.
Do you know that movie that starts out with a far-off view of Earth from outer space, and then the camera seems to move in closer and closer as if you're heading towards Earth in a rocket, and in what seems to be one continuous take you plunge through the clouds and go straight down to the Earth's surface, with more and more detail quickly coming into view, and then you zoom right into someone's backyard, and two people are sitting by the side of a swimming pool, and one of them is dead?
That was my idea! Unfortunately I was a mere youngster at the time without the money or technical resources to realize my vision. Somebody in Hollywood later stole this concept and probably made billions of dollars on it. So what else is new?
But I've recently been thinking about mimicking that zoom effect using Bing Map tiles. A couple months ago I wrote one and two articles for MSDN Magazine on downloading and stitching together Bing Map tiles in a Windows Phone application, and Chapter 18 of my new book, Programming Windows, 6th edition also has a program for Windows 8 that accesses Bing Map tiles.
Briefly, Bing Maps are assembled from 256-pixel square bitmap tiles, each one corresponding to a particular longitude, latitude, and zoom level. Level 1 is the most zoomed-out view. At Level 1, most of the Earth (excluding areas at the poles where the Mercator map projection breaks down) is covered with just 4 tiles. Level 2 covers the same area with 16 tiles, Level 3 with 64 tiles, and so forth up to Level 21. Each tile in Level N covers the same area of the Earth as four tiles in Level N+1. Each tile is identified with a base-4 number called a quadkey, which is a very clever system that incorporates the zoom level as well as the longitude and latitude corresponding to the tile's center.
Of course, we're all accustomed to using buttons or a mouse wheel or our fingers to zoom an on-screen map. Sometimes these zooms are discrete, which involves tiles of one level being substituted with those of the next higher or lower level. If the zoom is continuous, generally map tiles of two consecutive levels are scaled to render at the same size, and displayed with a fade effect using varying opacity.
But I had something a little different in mind. If I store in memory all the map tiles for all the zoom levels for a particular geographic area, I could possibly implement a continuous zoom from Level 1 to Level 21 and back again using a Slider control. I began wondering how fast that zoom could be. Could I go from minimum to maximum zoom in a fraction of a second? What would that look like?
Surely it was worth a try!
As I thought about this program in more depth, I decided to target Windows 8 (of course) but even in Windows 8 there are basically two ways to do it: The first is to use an Image element to display each map tile, scaled with a ScaleTransform applied to the RenderTransform property, and doing the fade using the Opacity property.
But I wasn't quite sure this approach was really up to the task. I found myself gravitating towards the alternative approach using Direct2D graphics, well renowned for its power and performance.
Windows 8 and DirectX
A Windows Store application for Windows 8 can take advantage of DirectX graphics (including Direct2D, Direct3D, and DirectWrite) in basically three different ways:
- A Windows Store app can implement all its visuals using DirectX on a CoreWindow object. This CoreWindow object supports user-input events from the keyboard, mouse, and touch, but the application would have to provide its own controls, such as buttons and sliders. This approach might be suitable for games or other applications requiring heavy graphics but not a standard user interface.
- A Windows Store app can display mostly DirectX graphics but also overlay those graphics with XAML-based controls. In this case, the DirectX graphics are displayed on an object of type SwapChainBackgroundPanel, which besides being a DirectX rendering surface is also a Panel derivative (specifically, a Grid derivative) and can host XAML content. There are some restrictions, however: This SwapChainBackgroundPanel must be the root element in a Page derivative, and this Page derivative must be the content of the application's Window object. A Frame object for navigating among pages is not allowed in such an application!
- A Windows Store app can be mostly XAML-based but also have one or more DirectX rendering surfaces in the form of SurfaceImageSource and VirtualSurfaceImageSource objects. These two classes derive from ImageSource and hence can be image sources for Image elements and ImageBrush objects. (A recent article in MSDN Magazine discusses this approach.)
Because I wanted a standard Slider in my app, I decided to go with the SwapChainBackgroundPanel approach. All the DirectX graphics are displayed on this object, and the XAML overlay consists of the Slider and a little bit of other markup to display the Bing Maps logo.
Visual Studio's DirectX Templates
Microsoft Visual Studio Express 2012 for Windows 8 has two standard templates for creating Windows Store applications that use DirectX graphics. Both templates are available when programming in C++ only. They are called:
- Direct2D App (XAML)
- Direct3D App
The first problem is that these are misnomers of sorts. Both templates can be used for either Direct2D or Direct3D programming or both. Indeed, with the introduction of the DirectX Graphics Infrastructure (DXGI), 2D and 3D have become more intertwined than ever, and you really expose both programming interfaces simultaneously.
The much more significant difference between these two Visual Studio templates is that "Direct2D App (XAML)" does the SwapChainBackgroundPanel approach while "Direct3D App" does a CoreWindow application.
The "Direct2D App (XAML)" template results in the creation of the following files (plus a couple others), some of which are analogous to the "Blank App (XAML)" template used for simple non-DirectX Windows Store applications:
- App.xaml, App.xaml.h, and App.xaml.cpp define a class named App that derives from Application.
- DirectXPage.xaml, DirectXPage.xaml.h, and DirectXPage.xaml.cpp define a class named DirectXPage that derives from Page.
- DirectXBase.h and DirectXBase.cpp define a class that implements most of the messy DirectX overhead and maintenance.
- SimpleTextRenderer.h and SimpleTextRenderer.cpp define a class that derives from DirectXBase and implements application-specific DirectX graphics.
- BasicTimer.h and DirectXHelper.h are small support files for DirectX graphics.
This template results in a complete working program that displays two text strings, one in XAML and one with DirectX, and allows mouse movement to change the location of the DirectX string. The DirectXPage.xaml file contains the SwapChainBackgroundPanel, and the code-behind file handles mouse input and application bar commands. All the DirectX rendering occurs in the SimpleTextRenderer class using DirectX objects created by DirectXBase and itself. Thus, the application is effectively divided between user input code and DirectX graphics output.
The "Direct3D App" template creates files that result in a similar overall architecture. For a project named MyApp, the following files are created:
- The MyApp.h and MyApp.cpp files define classes that implement the IFrameworkView and IFrameworkViewSource interfaces.
- The Direct3DBase.h and Direct3DBase.cpp files define a class named Direct3DBase that handles the DirectX overhead and maintenance.
- The CubeRenderer.h and CubeRenderer.cpp files define a class that derives from Direct3DBase to perform application-specific DirectX rendering.
- BasicTimer.h and DirectXHelper.h are small support files for DirectX graphics.
- SimplePixelShader.hlsl and SimpleVertexShader.hlsl are High-Level Shader Language files for 3D rendering.
In this template, user input is handled in the MyApp class (which has access to the CoreWindow object) and DirectX rendering occurs in the CubeRenderer class, which constructs and renders a rotating colorful 3D cube.
The DirectXBase and Direct3DBase classes in these two templates are very similar. The big difference is that the first one is geared towards displaying graphics on a SwapChainBackgroundPanel and the second is for a CoreWindow. But it would be easy to combine this functionality in a single class that would be common to both templates.
I'm not too happy with these templates. The names of the SimpleTextRenderer and CubeRenderer classes sound as if you could supplement these classes with additional rendering classes for other types of graphics output. However, these classes derive from DirectXBase (or Direct3DBase), which perform all the DirectX overhead. You can't have multiple derivatives of these classes. With this architecture your application will only have one rendering class that does all the DirectX rendering for the application. So you'll probably rename this rendering class to something closer to what your app actually does, and you'll also replace much of the interaction between this rendering class and the user-input class (DirectXPage or MyApp).
These are not templates where you just start adding your own code. These are templates that you first need to maul up a bit.
For the SwapChainBackgroundPanel approach, I think I'd prefer a template that instead implements all the DirectX overhead in a Page derivative called (for example) DirectXPageBase. User-input code and rendering code can then be added to a class that derives from DirectXPageBase called (for example) ApplicationPage. This might be more suitable for smaller DirectX programs and would require less "glue" between the user-input code and the rendering code. (I have demonstrated to myself that this architecture is possible, but I haven't turned it into a template yet.)
At any rate, I decided to use the "Direct2D (XAML)" template for this project.
Creating the FastZoomMap Project
Let me guide you through the process I used to customize a project named FastZoomMap based on the "Direct2D (XAML)" template. You can also download the complete FastZoomMap project.
The big change I made to the template files listed above is that I renamed SimpleTextRenderer to ZoomMapRenderer.
To the list of mostly DirectX header files in pch.h, I needed to add three more:
#include <ppltasks.h>
#include <shcore.h>
#include <xmllite.h>
The first is for task and related classes for ease in calling asynchronous methods in the Windows Runtime; the second is for the CreateStreamOverRandomAccessStream function that converts a Windows Runtime stream to a COM IStream needed for working with the Windows Imaging Component to read and decode bitmap files; and the third is for parsing XML metadata from the Bing Maps REST service. In addition, the project needs to be linked with shcore.lib and xmllite.lib. Add these two import libraries in the Project Properties dialog, Linker item, Input item, and Additional Dependencies field.
The application needs to be able to obtain the computer's location using the GPS hardware, so I opened the package.appxmanifest file in Visual Studio, went to the Capabilities tab, and checked Location. The first time the program runs on a machine, it will ask the user's permission to obtain the computer's location.
To compile and run this application on your own Windows 8 machine, you'll need a Bing Maps Key suitable for a Windows Store application. The process of getting a key is described in Getting a Bing Maps Key. You'll then insert that key right into the program source code where I've left a placeholder.
I made no changes to the App, DirectXBase, DirectXHelper, or BasicTimer files. I won't attempt here to describe the workings of these classes.
Program Overview
The FastZoomMap program access the Bing Maps REST service to obtain some metadata — most importantly, a URI template used for downloading the individual map tiles — and uses the Windows 8 Geolocator class to obtain the current longitude and latitude of the computer. The program then downloads a bunch of Bing Map tiles for that location. These are JPEG files. It saves these files in application local storage so it doesn't have to download them the next time it's run (unless it's run in a different location), and also keeps them in memory in an array. Based on a Slider value, the program displays map tiles of a particular zoom level using Direct2D. Manipulating the Slider allows fast zooming.
In theory, the program could respond to dynamic changes in location while running by downloading additional tiles. However, I chose not to implement this feature. Once the program obtains a longitude and latitude, it uses that location until the next time the program is run.
The number of tiles displayed on the screen should be based on the size of the screen. This size could change while the program is running. Most obviously, a tablet could be rotated. Or the program might be put into a snap mode. If the Windows 8 machine has a second monitor of a different size, the program could be moved from one monitor to another and experience a change in screen size.
Again, in theory, the program could respond to increases in screen size.by downloading additional tiles as needed. I chose not to respond to screen size changes. Once the program gets a screen size, it sticks with it.
Actually, I went even further than that: To keep the "pigginess" of the program to a minimum, it basically hard codes a screen size of 1024-by-768 and downloads only enough tiles to fill that area. That restriction can be easily removed, however.
The Page Derivative
In the standard "Direct2D (XAML)" template the DirectXPage class derives from Page and comprises a XAML file, a header file, and a code file, all of which I needed to customize.
In the DirectXPage.xaml file you can see how the SwapChainBackgroundPanel forms the root element of the page, and how it can host XAML elements:
<Page
x:Class="FastZoomMap.DirectXPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:FastZoomMap"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<SwapChainBackgroundPanel x:Name="SwapChainPanel">
<StackPanel VerticalAlignment="Bottom">
<Slider Name="zoomLevelSlider"
Margin="48 0"
StepFrequency="0.01"
ValueChanged="OnSliderValueChanged" />
<!-- "powered by bing" display -->
<Border Name="poweredByDisplay"
Visibility="Collapsed"
Background="Black"
HorizontalAlignment="Center"
Margin="12"
CornerRadius="12"
Padding="3">
<StackPanel Orientation="Horizontal">
<TextBlock Text=" powered by "
Foreground="White"
VerticalAlignment="Center" />
<Image Stretch="None">
<Image.Source>
<BitmapImage x:Name="poweredByBitmap" />
</Image.Source>
</Image>
</StackPanel>
</Border>
</StackPanel>
</SwapChainBackgroundPanel>
</Page>
I left intact much of the constructor of the DirectXPage.cpp code-behind file created by the template (indicated by a comment in the following listing). This standard constructor code involves attaching a bunch of event handlers that call into the renderer and its base class DirectXBase:
DirectXPage::DirectXPage() : m_renderNeeded(true)
{
InitializeComponent();
// Attempt to keep rotation to a minimum
DisplayProperties::AutoRotationPreferences = DisplayProperties::NativeOrientation;
// Install handler for Loaded event
Loaded += ref new RoutedEventHandler(this, &DirectXPage::OnPageLoaded);
// Create the ZoomMapRenderer
m_renderer = ref new ZoomMapRenderer();
// The following code was created by the template
m_renderer->Initialize(Window::Current->CoreWindow,
SwapChainPanel,
DisplayProperties::LogicalDpi);
Window::Current->CoreWindow->SizeChanged +=
ref new TypedEventHandler<CoreWindow^, WindowSizeChangedEventArgs^>(this, &DirectXPage::OnWindowSizeChanged);
DisplayProperties::LogicalDpiChanged +=
ref new DisplayPropertiesEventHandler(this, &DirectXPage::OnLogicalDpiChanged);
DisplayProperties::OrientationChanged +=
ref new DisplayPropertiesEventHandler(this, &DirectXPage::OnOrientationChanged);
DisplayProperties::DisplayContentsInvalidated +=
ref new DisplayPropertiesEventHandler(this, &DirectXPage::OnDisplayContentsInvalidated);
m_eventToken = CompositionTarget::Rendering::add(ref new EventHandler<Object^>(this, &DirectXPage::OnRendering));
m_timer = ref new BasicTimer();
}
Notice the standard call to InitializeComponent that parses the XAML at runtime and hooks up event handlers. Notice also the creation of the ZoomMapRenderer object, and the event handler for the CompositionTarget::Rendering event. This event is fired in synchronization with the video display refresh rate (generally about 60 times per second), and provides an excellent opportunity to update video graphics. I didn't change any of the other event handlers in the file created by the template, but I added just a little something to this OnRendering method.
void DirectXPage::OnRendering(Object^ sender, Object^ args)
{
m_renderNeeded |= m_renderer->NeedsRender();
if (m_renderNeeded)
{
m_timer->Update();
m_renderer->Update(m_timer->Total, m_timer->Delta);
m_renderer->Render();
m_renderer->Present();
m_renderNeeded = false;
}
}
In the general case, a DirectX program can redraw its entire screen with new graphics at the video refresh rate. In the OnRendering handler, a call to the BasicTimer class obtains the current time that can then be used by the Update method in the renderer to prepare for rendering. (ZoomMapRenderer doesn't use Update.). The Render method in the renderer contains the code to display those graphics, and the Present method (which is a verb, not a noun) in DirectXBase delivers them to the video display.
However, if the screen doesn't need to be updated so frequently, none of these calls need occur. Both the DirectXPage class and the SimpleTextRenderer class (renamed in my project to ZoomMapRenderer) define a private Boolean field named m_renderNeeded. However, only the field in DirectXPage does anything, and you can see how it's used here to govern if the screen should be updated. I also needed the renderer itself to flag that the screen needs updating, so I added a NeedsRender method to ZoomMapRenderer, and this OnRendering handler calls that method.
The Loaded handler for the page performs my application-specific initialization chores: obtaining the computer's current location and downloading Bing Maps metadata. Both involve asynchronous operations and thus involve use of the task class.
Bing Map tiles are available for a couple "views," the big ones being Road and Aerial. The C++ version of the RotatingMap project in Chapter 18 of Programming Windows, 6th edition, supported both views. In this FastZoomMap project, I restricted myself to Aerial.
Each required view requires downloading some metadata for that view. This metadata includes a URI template for accessing the individual map tiles, the minimum and maximum zoom levels for that view, and a URI for the Bing logo that your program should display when using Bing Maps. From the C++ version of RotatingMap, I borrowed a ViewParams structure for storing this information:
namespace FastZoomMap
{
struct ViewParams
{
std::wstring UriTemplate;
int minZoomLevel;
int maxZoomLevel;
std::wstring PoweredByBingUri;
};
}
I also borrowed a method that downloads the metadata using the Bing Maps REST web service and then parses the XML. Notice that you'll need to insert your own Bing Maps Key near the top of this method:
task<ViewParams> DirectXPage::GetViewParamsAsync(wchar_t* type)
{
// Define the URI to get Bing imagery metadata
wchar_t* bingCredentials = L"insert your own Bing Maps key here!";
std::wstring strUri(L"http://dev.virtualearth.net/REST/V1/Imagery/Metadata/");
strUri.append(type);
strUri.append(L"?output=xml&key=");
strUri.append(bingCredentials);
Uri^ uri = ref new Uri(ref new String(strUri.c_str()));
// Open the URI for reading
RandomAccessStreamReference^ streamRef = RandomAccessStreamReference::CreateFromUri(uri);
task<IRandomAccessStreamWithContentType^> openReadTask = create_task(streamRef->OpenReadAsync());
return openReadTask.then([this](IRandomAccessStreamWithContentType^ stream)
{
// Create a COM IStream object from the IRandomAccessStreamWithContentType
ComPtr<IStream> pStream;
HRESULT hr = CreateStreamOverRandomAccessStream(stream, __uuidof(IStream), &pStream);
if (FAILED(hr))
throw ref new COMException(hr, "CreateStreamOverRandomAccessStream failure");
// Create a COM IXmlReader object
ComPtr<IXmlReader> pXmlReader;
hr = CreateXmlReader(__uuidof(IXmlReader), &pXmlReader, nullptr);
if (FAILED(hr))
throw ref new COMException(hr, "CreateXmlReader failure");
hr = pXmlReader->SetInput(pStream.Get());
if (FAILED(hr))
throw ref new COMException(hr, "XmlReader->SetInput failure");
// Look for five elements
bool awaitingZoomMin = false;
bool awaitingZoomMax = false;
bool awaitingSubDomains = false;
bool awaitingImageUrl = false;
bool awaitingPoweredByBingUri = false;
// Store the results here
ViewParams viewParams;
std::vector<std::wstring> subdomains;
// Look until the Read doesn't return any more nodes
XmlNodeType xmlNodeType;
while (S_OK == (hr = pXmlReader->Read(&xmlNodeType)))
{
// Check for beginning and end of elements
if (xmlNodeType == XmlNodeType_Element || xmlNodeType == XmlNodeType_EndElement)
{
LPCWSTR pName;
unsigned int charCount;
hr = pXmlReader->GetLocalName(&pName, &charCount);
if (FAILED(hr))
throw ref new COMException(hr, "XmlReader->GetLocalName failure");
if (wcscmp(pName, L"ImageUrl") == 0)
{
awaitingImageUrl = (xmlNodeType == XmlNodeType_Element);
}
else if (wcscmp(pName, L"ImageUrlSubdomains") == 0)
{
awaitingSubDomains = (xmlNodeType == XmlNodeType_Element);
}
else if (wcscmp(pName, L"ZoomMin") == 0)
{
awaitingZoomMin = (xmlNodeType == XmlNodeType_Element);
}
else if (wcscmp(pName, L"ZoomMax") == 0)
{
awaitingZoomMax = (xmlNodeType == XmlNodeType_Element);
}
else if (wcscmp(pName, L"BrandLogoUri") == 0)
{
awaitingPoweredByBingUri = (xmlNodeType == XmlNodeType_Element);
}
}
// Get value of element
if (xmlNodeType == XmlNodeType_Text)
{
LPCWSTR pValue = nullptr;
unsigned int charCount = 0;
hr = pXmlReader->GetValue(&pValue, &charCount);
if (FAILED(hr))
throw ref new COMException(hr, "XmlReader->GetValue failure");
if (awaitingImageUrl)
{
viewParams.UriTemplate = std::wstring(pValue);
}
else if (awaitingSubDomains)
{
subdomains.push_back(std::wstring(pValue));
}
else if (awaitingZoomMin)
{
viewParams.minZoomLevel = _wtoi(pValue);
}
else if (awaitingZoomMax)
{
viewParams.maxZoomLevel = _wtoi(pValue);
}
else if (awaitingPoweredByBingUri)
{
viewParams.PoweredByBingUri = std::wstring(pValue);
}
}
}
// Need to make some modifications to the URI string
int pos = viewParams.UriTemplate.find(L"{culture}");
if (pos != std::wstring::npos)
viewParams.UriTemplate.replace(pos, 9, L"en-us");
pos = viewParams.UriTemplate.find(L"{subdomain}");
if (pos != std::wstring::npos)
viewParams.UriTemplate.replace(pos, 11, subdomains[0].c_str());
return viewParams;
});
}
Before calling this GetViewParamsAsync method the Loaded handler first trys to obtain the computer's location. This will raise an exception if the user has decided not to grant the program permission to do so. Only if the location is available does the program pass that information to the renderer and commence the download of the Bing Maps metadata, which it also forks over to the renderer:
void DirectXPage::OnPageLoaded(Object^ sender, RoutedEventArgs^ args)
{
// Create a Geolocator object
geolocator = ref new Geolocator();
// Get the current location
task<Geoposition^> getGeopositionTask = create_task(geolocator->GetGeopositionAsync());
getGeopositionTask.then([this] (task<Geoposition^> thisTask)
{
Geoposition^ geopos = nullptr;
try
{
geopos = thisTask.get();
}
catch (Exception^)
{
}
if (geopos == nullptr)
{
MessageDialog^ msgdlg = ref new MessageDialog("Program needs access to computer's location");
task<IUICommand^> showMsgTask = create_task(msgdlg->ShowAsync());
showMsgTask.then([] (IUICommand^ command)
{
});
}
else
{
// Give renderer the geographical position
m_renderer->SetGeolocation(geopos->Coordinate->Longitude,
geopos->Coordinate->Latitude);
// Access Bing Maps to get metadata
task<ViewParams> getViewParamsTask = create_task(GetViewParamsAsync(L"Aerial"));
getViewParamsTask.then([this] (ViewParams viewParams)
{
// Set the Slider minimum and maximum
zoomLevelSlider->Minimum = viewParams.minZoomLevel;
zoomLevelSlider->Maximum = viewParams.maxZoomLevel;
// Set the "powered by Bing" bitmap
poweredByBitmap->UriSource = ref new Uri(ref new String(viewParams.PoweredByBingUri.data()));
poweredByDisplay->Visibility = Windows::UI::Xaml::Visibility::Visible;
// Pass the view parameters to the renderer
m_renderer->SetViewParams(ref new String(viewParams.UriTemplate.data()),
viewParams.minZoomLevel,
viewParams.maxZoomLevel);
});
}
});
}
Notice that the metadata is also used to initialize the Slider range and provide the Bing Maps logo bitmap. Although much of this program is not hard-coded for a particular range of zoom levels, other parts are well aware that the maximum zoom level is 21.
The Slider fires a ValueChanged event, and the event handler simply sends the new value to the renderer:
void DirectXPage::OnSliderValueChanged(Object^ sender,
RangeBaseValueChangedEventArgs^ args)
{
m_renderer->SetZoomLevel(args->NewValue);
}
The ZoomMapRenderer Class
If you've already seen some of my earlier writings about Bing Maps tiles, some of the map-related code in ZoomMapRenderer will look familiar. Keep in mind that this ZoomMapRenderer class derives from DirectXBase, so a lot of the really messy DirectX initialization and maintenance code is safely out of sight. The only DirectX code implemented in this class involves getting the bitmaps and displaying them.
The constructor merely initializes a bunch of private fields:
ZoomMapRenderer::ZoomMapRenderer() : integerLongitude(-1),
integerLatitude(-1),
fractionalZoomLevel(1.0),
zoomLevelCount(0),
horzTileCount(0),
vertTileCount(0),
pBitmapTileArray(nullptr),
pPixelOffsetArray(nullptr),
uriTemplate(L""),
m_renderNeeded(true)
{
}
The Visual Studio template uses a "m_" naming convention for "member" variables. I've left variables already named like that, but I tend not to do that with my own fields.
The Bing Maps tiles support integral zoom levels from 1 to 21. To zoom continuously, you'll want a floating-point zoom level that the program stores as the fractionalZoomLevel field. That field is set directly from the Slider value:
void ZoomMapRenderer::SetZoomLevel(double zoomLevel)
{
fractionalZoomLevel = zoomLevel;
m_renderNeeded = true;
}
Once that m_renderNeeded field is set to true, the public NeedsRender method allows DirectXPage to know that a render is required:
bool ZoomMapRenderer::NeedsRender()
{
bool renderNeeded = m_renderNeeded;
m_renderNeeded = false;
return renderNeeded;
}
The map tiles are always 256-pixels square. To implement continuous zoom, you'll need to display these tiles with a scaling factor. For example, suppose you want to zoom in continuously from Level 10 to Level 11. One approach (not the best, I think) is to begin applying a scaling factor greater than 1 to the Level 10 tiles, and when that scaling factor reaches 2, you can replace the Level 10 tiles with the Level 11 tiles displayed at normal size.
For this transition to be smoother and more aesthetically pleasing, you want to fade from one level to the next. Halfway through the transition from Level 10 to Level 11, for example, you might want to display Level 11 tiles at 50% opacity on top of the Level 10 tiles. If these Level 10 tiles are scaled to 1.5 times their actual pixel size, then the Level 11 tiles must be scaled to half that, or 0.75 times their pixel size. (Actually, halfway between two integral zoom levels involves scaling factors of 1.414 and 0.707, the square root of 2 and half the square root of 2.)
In this implemention of fading, tiles might be displayed anywhere between half their pixel sizes and double their pixel sizes. These extremes don't actually come into play in this application because I restricted the fading to fractional zoom levels between 0.25 and 0.75. Actual scaling factors corresponding to these fractional zoom levels range from 0.595 (half of 2 to the 0.25 power) hrough 1.68 (2 to the 0.75 power), but the minimum of 0.5 is a handy approximation.
This minimum scaling factor of 0.5 governs how many tiles we need to fill the screen. We need horzTileCount tiles horizontally and vertTileCount tiles vertically. It's most convenient if these are both odd numbers. In that case, the tile in the center of the screen contains the computer's actual longitude and latitude. Based on where the longitude and latitude is within this tile, the center of the screen could actually correspond with the edge of this center tile. Consider a screen 768 pixels tall, for example. The top half of the screen (which is 384 pixels) requires 3 tiles scaled to half their 256-pixel size to fill it, which means that the entire height of the screen requires 3 tiles on the top and 3 on the bottom, and one in the center, for a total of 7. A screen that is 1024 pixels wide is filled by 9 tiles horizontally.
DirectXBase defines a CreateWindowSizeDependentResources virtual method that allows derived rendering classes to perform any initialization or maintenance that depends on the window size. The ZoomMapRenderer uses this opportunity to determine how many tiles it needs to fill the screen:
void ZoomMapRenderer::CreateWindowSizeDependentResources()
{
DirectXBase::CreateWindowSizeDependentResources();
// A possible enhancement is to re-do the bitmap array when
// the display size changes.
if (horzTileCount > 0 && vertTileCount > 0)
return;
horzTileCount = 1 + 2 * int(ceil(m_windowBounds.Width / 2 / 128));
vertTileCount = 1 + 2 * int(ceil(m_windowBounds.Height / 2 / 128));
// Remove the following code to fill the window
horzTileCount = 9; // width of 1024
vertTileCount = 7; // height of 768
DownloadAllMapTiles();
}
However, there could be quite a few tiles for large screens, so this method concludes by setting numbers appropriate for 1024-by-768. You can remove that code if you want.
The SetGeolocation method in ZoomMapRenderer is called from DirectXPage with the longitude and latitude. These are converted to integer values that are easier for the program to deal with. (See my first article on Bing Maps in MSDN Magazine for an explanation.) The BITRES value is 29, which accounts for the maximum zoom level of 21 plus 8 bits per pixel. This is adequate for determining a location of the longitude and latitude within a Level 21 tile accurate to a pixel.
void ZoomMapRenderer::SetGeolocation(double longitude, double latitude)
{
// A possible enhancement is to to re-do the bitmap array when
// the longitude and latitude changes.
if (integerLongitude != -1 && integerLatitude != -1)
return;
// Calculate integer longitude and latitude
double pi = 3.14159;
double relativeLongitude = (180 + longitude) / 360;
integerLongitude = (int)(relativeLongitude * (1 << BITRES));
double sineTerm = sin(pi * latitude / 180);
double relativeLatitude = 0.5 - log((1 + sineTerm) / (1 - sineTerm)) / (4 * pi);
integerLatitude = (int)(relativeLatitude * (1 << BITRES));
DownloadAllMapTiles();
}
The SetViewParams method is also called from DirectXPage:
void ZoomMapRenderer::SetViewParams(String^ uriTemplate, int minZoomLevel, int maxZoomLevel)
{
this->uriTemplate = std::wstring(uriTemplate->Data());
this->minZoomLevel = minZoomLevel;
this->maxZoomLevel = maxZoomLevel;
zoomLevelCount = maxZoomLevel - minZoomLevel + 1;
DownloadAllMapTiles();
}
You've now seen three calls to DownloadAllMapTiles. This method needs information that is coming into the program asynchronously, but at some point everything it needs will be available and the method can begin the process of downloading all the map tiles it needs. For 21 zoom levels, 7 tiles vertically on the screen, and 9 horizontally, that's a total of 1,323 bitmaps. These are all stored as pointers to ID2D1Bitmap objects in an array called pBitmapTileArray. In addition, for each zoom level there is a pixel location in the center tile that corresponds to the computer's longitude and latitude. These pixel locations are stored in an array called pPixelOffsetArray.
void ZoomMapRenderer::DownloadAllMapTiles()
{
// Check if we're already downloaded
if (pBitmapTileArray != nullptr)
return;
// Check if we have the tile URI template
if (uriTemplate.length() == 0)
return;
// This should also be fine by this point
if (zoomLevelCount == 0)
return;
// Check if we have a geographical position
if (integerLatitude == -1 || integerLongitude == -1)
return;
// Check if we have the window size
if (horzTileCount == 0 || vertTileCount == 0)
return;
// Ready to go! Allocate two arrays
pBitmapTileArray = new ID2D1Bitmap*[zoomLevelCount * vertTileCount * horzTileCount];
pPixelOffsetArray = new D2D1_POINT_2U[zoomLevelCount];
// Loop through the zoom levels
for (int zoomLevel = minZoomLevel; zoomLevel <= maxZoomLevel; zoomLevel++)
{
int zoomIndex = zoomLevel - minZoomLevel; // zero based
// Get coordinates and pixel offsets based on zoom level
int croppedLongitude = integerLongitude >> (BITRES - zoomLevel);
int croppedLatitude = integerLatitude >> (BITRES - zoomLevel);
int xPixelOffset = (integerLongitude >> (BITRES - zoomLevel - 8)) % 256;
int yPixelOffset = (integerLatitude >> (BITRES - zoomLevel - 8)) % 256;
// Save that pixel offset as a Point
pPixelOffsetArray[zoomIndex] = Point2U(xPixelOffset, yPixelOffset);
// This is the maximum limit of an integer geographical location
int maxValue = (1 << zoomLevel) - 1;
// Loop through the rows of tiles
for (int row = -vertTileCount / 2; row <= vertTileCount / 2; row++)
{
int rowIndex = row + vertTileCount / 2;
// Loop through the columns of tiles
for (int col = -horzTileCount / 2; col <= horzTileCount / 2; col++)
{
int colIndex = col + horzTileCount / 2;
int arrayIndex = (zoomIndex * vertTileCount + rowIndex) * horzTileCount + colIndex;
// Initialize the bitmap tile array cell
pBitmapTileArray[arrayIndex] = nullptr;
// Check if we've gone beyond the bounds
if (croppedLongitude + col < 0 ||
croppedLongitude + col > maxValue ||
croppedLatitude + row < 0 ||
croppedLatitude + row > maxValue)
{
continue;
}
// Calculate a quadkey
int longitude = croppedLongitude + col;
int latitude = croppedLatitude + row;
std::wstring quadkey = ToQuadKey(longitude, latitude, zoomLevel);
// Determine tile URI
std::wstring uri(uriTemplate);
int pos = uri.find(L"{quadkey}");
uri.replace(pos, 9, quadkey);
// Extract filename for caching
pos = uri.rfind('/') + 1;
int endPos = uri.find('?', pos);
std::wstring filename;
if (endPos != std::wstring::npos)
filename = uri.substr(pos, endPos - pos);
else
filename = uri.substr(pos, std::wstring::npos);
// Check if the required tile is stored in local storage
StorageFolder^ localFolder = ApplicationData::Current->LocalFolder;
task<StorageFile^> getFileTask =
create_task(localFolder->GetFileAsync(ref new String(filename.data())));
getFileTask.then([this, arrayIndex, filename, uri] (task<StorageFile^> thisTask)
{
StorageFile^ storageFile = nullptr;
try
{
storageFile = thisTask.get();
}
catch (Exception^)
{
}
task<ID2D1Bitmap *> loadBitmapTask;
// If tile is in local storage, load it
if (storageFile != nullptr)
{
loadBitmapTask = create_task(LoadBitmap(storageFile, nullptr));
}
// Otherwise, download it and save it to local storage
else
{
// Open the URI for reading
Uri^ uriTile = ref new Uri(ref new String(uri.data()));
RandomAccessStreamReference^ streamRef =
RandomAccessStreamReference::CreateFromUri(uriTile);
loadBitmapTask = create_task(LoadBitmap(streamRef,
ref new String(filename.data())));
}
loadBitmapTask.then([this, arrayIndex] (ID2D1Bitmap * bitmap)
{
// Save the bitmap in the array
pBitmapTileArray[arrayIndex] = bitmap;
// Refresh the screen
m_renderNeeded = true;
});
});
}
}
}
}
If the tile does not yet exist in application local storage, the call to GetFileAsync on the StorageFolder object will raise an exception. This exception is caught in the try and catch block. Despite that, if you're running this program under the Visual Studio debugger, it will want to break when that exception is raised. You can turn off that notification, however, because the program is handling that exception itself, and using it to determine if it needs to download the tile.
I was originally planning to have this program account for changes in location and window size by reallocating the pBitmapTileArray and possibly downloading new tiles. However, because the tiles are downloaded asynchronously, this is not as easy as it sounds. The program would need to distinguish downloads that are completed after the array reallocation but which are no longer needed because the location or window size has changed
The calculation of the quadkey for the URI is another method I lifted from the C++ version of RotatingMap:
std::wstring ZoomMapRenderer::ToQuadKey(int longitude, int latitude, int level)
{
int64 quadkey = 0;
int mask = 1 << (level - 1);
for (int i = 0; i < level; i++)
{
quadkey <<= 2;
if ((longitude & mask) != 0)
quadkey |= 1;
if ((latitude & mask) != 0)
quadkey |= 2;
mask >>= 1;
}
std::wstring str;
for (int i = 0; i < level; i++)
{
str.insert(0, 1, (quadkey & 3) + '0');
quadkey >>= 2;
}
return str;
}
Again, see that first MSDN Magazine article for details on how this works.
By the end of the DownloadAllMapTiles method, the method knows whether the tile is stored in local storage or must be downloaded. If it's a file in local storage, it's StorageFile object. If it must be downloaded, it's a RandomAccessStreamReference object. Both of these classes implement the IRandomAccessStreamReference interface that defines the OpenReadAsync method, which means that loading and downloading can both be handled in a method I called LoadBitmap:
task<ID2D1Bitmap*> ZoomMapRenderer::LoadBitmap(IRandomAccessStreamReference^ streamRef,
String^ saveFilename)
{
task<IRandomAccessStreamWithContentType^> openReadTask =
create_task(streamRef->OpenReadAsync());
return openReadTask.then([this, saveFilename](IRandomAccessStreamWithContentType^ stream)
{
// Check for a zero size, and avoid dealing with it
if (stream->Size == 0)
return (ID2D1Bitmap*) nullptr;
// Possibly save the file to local storage
if (saveFilename != nullptr)
{
SaveBitmapFile(stream->CloneStream(), saveFilename);
}
// Create a COM IStream object from the IRandomAccessStreamWithContentType
ComPtr<IStream> pStream;
HRESULT hr = CreateStreamOverRandomAccessStream(stream, IID_PPV_ARGS(&pStream));
if (FAILED(hr))
throw ref new COMException(hr, "CreateStreamOverRandomAccessStream failure");
// Create a WIC Stream object from the IStream object
ComPtr<IWICStream> pWicStream;
hr = m_wicFactory->CreateStream(&pWicStream);
if (FAILED(hr))
throw ref new COMException(hr, "CreateStream failure");
pWicStream->InitializeFromIStream(pStream.Get());
// Create a decoder based on the bitmap stream
ComPtr<IWICBitmapDecoder> pDecoder;
hr = m_wicFactory->CreateDecoderFromStream(pWicStream.Get(), nullptr,
WICDecodeMetadataCacheOnDemand,
&pDecoder);
if (FAILED(hr))
throw ref new COMException(hr, "CreateDecoderFromStream failure");
// Get the first (and only) frame
ComPtr<IWICBitmapFrameDecode> pFrame;
hr = pDecoder->GetFrame(0, &pFrame);
if (FAILED(hr))
throw ref new COMException(hr, "GetFrame failure");
// Create a converter to obtain the correct color format
ComPtr<IWICFormatConverter> pConverter;
hr = m_wicFactory->CreateFormatConverter(&pConverter);
if (FAILED(hr))
throw ref new COMException(hr, "CreateFormatConverter failure");
hr = pConverter->Initialize(pFrame.Get(), GUID_WICPixelFormat32bppPBGRA,
WICBitmapDitherTypeNone, nullptr, 0.0f,
WICBitmapPaletteTypeCustom);
if (FAILED(hr))
throw ref new COMException(hr, "pConverter->Initialize failure");
// Now create a Direct2D bitmap from the WIC bitmap
ID2D1Bitmap * bitmap;
hr = m_d2dContext->CreateBitmapFromWicBitmap(pConverter.Get(), &bitmap);
if (FAILED(hr))
throw ref new COMException(hr, "CreateBitmapFromWicBitmap failure");
return bitmap;
});
}
This method makes use of WIC (Windows Imaging Component) to download and decode the map tiles, which for the Aerial view are JPEGs. The IWICBitmapDecoder object is obtained from the stream and is actually a JPEG decoder. The next step is to obtain the first "frame." Animated GIFs can have multiple frames, but most bitmap files support only one frame, which is the bitmap itself, but this must be converted to a color scheme compatible with Direct2D. Towards the end, the CreateBitmapFromWicBitmap method of the Direct2D device context creates a bitmap appropriate for Direct2D. This is returned back to LoadAllMapTiles and stored in the big array.
This code is where I had the most problems, and I'm not sure exactly why. It took awhile getting CreateDecoderFromStream working well. I seemed to fix the biggest problems by checking for a zero size in the input stream early on in the method. What you really want to avoid is caching a bad file! A better program would try to download the errant file again.
As you might know, any method in the Windows Runtime that has the potential of requiring 50 milliseconds to complete was made asynchronous and runs in a separate thread. That is not the case for DirectX, however. I am fairly certain that some of these methods that result in the creation of a WIC bitmap involve disk or network accesses. It might be worthwhile to explore how they can be executed in a secondary thread.
If the map tile is being downloaded, it must also be saved to a file in local storage for future access. This occurs in the following method:
void ZoomMapRenderer::SaveBitmapFile(IRandomAccessStream^ inpStream, String^ filename)
{
StorageFolder^ localFolder = ApplicationData::Current->LocalFolder;
task<StorageFile^> createFileTask =
create_task(localFolder->CreateFileAsync(filename,
CreationCollisionOption::ReplaceExisting));
createFileTask.then([inpStream] (StorageFile^ storageFile)
{
task<IRandomAccessStream^> openTask =
create_task(storageFile->OpenAsync(FileAccessMode::ReadWrite));
openTask.then([inpStream] (IRandomAccessStream^ outStream)
{
int size = (int)inpStream->Size;
IBuffer^ buffer = ref new Buffer(size);
task<IBuffer^> readTask =
create_task(inpStream->ReadAsync(buffer, size, InputStreamOptions::None));
readTask.then([outStream] (IBuffer^ buffer)
{
task<size_t> writeTask = create_task(outStream->WriteAsync(buffer));
writeTask.then([outStream] (size_t count)
{
task<bool> flushTask = create_task(outStream->FlushAsync());
flushTask.then([] (bool success)
{
});
});
});
});
});
}
Particularly if the program is running for the first time, it seems to hang as the downloads are taking place. But even as map tiles are still being downloaded or retrieved from memory, it's possible for the available map tiles to be rendered. This occurs in the Render method, which can be called as often as possible up to the frame rate of the video display. Most often, a DirectX program uses such a Render call to redraw the entire screen.
A program begins the Render method by calling BeginDraw on the ID3D11DeviceContext1 object (created by DirectXBase and stored as the m_d3dContext field) and concludes by calling EndDraw. Between those two calls can be many other calls to the device context. My Render method calls Clear to clear the screen to the traditional Cornflower Blue, PushAxisAlignedClip to create a 1024-by-768 clipping rectangle (but you can remove that call), PopAxisAlignClip to remove it, and many calls to DrawBitmap, which renders an ID2D1Bitmap to the screen with an optional scaling factor and opacity.
In general, this Render method draws map tiles from two consecutive zoom levels. Map tiles from the lower of the two zoom levels are drawn first with a scaling factor ranging from 1 to 2, and tiles from the next higher zoom level are displayed on top of that with scaling factors ranging from 0.5 to 1, and a particular opacity value for the fade. In addition, to align these tiles, values from the pPixelOffsetArray indicate the particular pixel in the center tile that corresponds to the exact longitude and latitude:
void ZoomMapRenderer::Render()
{
m_d2dContext->BeginDraw();
m_d2dContext->Clear(ColorF(ColorF::CornflowerBlue));
if (pBitmapTileArray != nullptr)
{
// Remove these three statements (and Pop) to display on the whole window
float left = (m_windowBounds.Width - 1024) / 2;
float top = (m_windowBounds.Height - 1024) / 2;
m_d2dContext->PushAxisAlignedClip(RectF(left, top, left + 1024, top + 1024),
D2D1_ANTIALIAS_MODE_PER_PRIMITIVE);
double fractionalZoomIndex = fractionalZoomLevel - minZoomLevel;
float fractionalPart = float(fmod(fractionalZoomIndex, 1));
// Calculate opacity for map tile on top
float opacity = max(0, min(1, 2 * (fractionalPart - 0.25f)));
int zoomIndex1 = int(fractionalZoomIndex);
float scale1 = float(pow(2, fractionalPart));
int zoomIndex2 = zoomIndex1 + 1;
float scale2 = float(scale1 / 2);
if (opacity < 1)
DrawTileArray(zoomIndex1, scale1, 1);
if (opacity > 0 && fractionalZoomLevel < maxZoomLevel)
DrawTileArray(zoomIndex2, scale2, opacity);
m_d2dContext->PopAxisAlignedClip();
}
// Ignore D2DERR_RECREATE_TARGET. This error indicates that the device
// is lost. It will be handled during the next call to Present.
HRESULT hr = m_d2dContext->EndDraw();
if (hr != D2DERR_RECREATE_TARGET)
{
DX::ThrowIfFailed(hr);
}
m_renderNeeded = false;
}
void ZoomMapRenderer::DrawTileArray(int zoomIndex, float scale, float opacity)
{
D2D_POINT_2U pixelOffset = pPixelOffsetArray[zoomIndex];
for (int rowIndex = 0; rowIndex < vertTileCount; rowIndex++)
{
int row = rowIndex - vertTileCount / 2;
for (int colIndex = 0; colIndex < horzTileCount; colIndex++)
{
int col = colIndex - horzTileCount / 2;
int index = (zoomIndex * vertTileCount + rowIndex) * horzTileCount + colIndex;
ID2D1Bitmap* bitmap = pBitmapTileArray[index];
if (bitmap != nullptr)
{
float x = int(m_windowBounds.Width) / 2 + scale * (256 * col - float(pixelOffset.x));
float y = int(m_windowBounds.Height) / 2 + scale * (256 * row - float(pixelOffset.y));
D2D1_RECT_F rect = RectF(x, y, x + scale * 256, y + scale * 256);
m_d2dContext->DrawBitmap(bitmap, &rect, opacity);
}
}
}
}
If you run the program under the Visual Studio debugger, you might get a message in the Output window from the Direct2D debugging system that the application has many "unreleased interfaces allocated." This is normal and refers to the very many ID2D1Bitmap objects stored in the array. These are released in the class's destructor:
ZoomMapRenderer::~ZoomMapRenderer()
{
if (pPixelOffsetArray != nullptr)
delete[] pPixelOffsetArray;
if (pBitmapTileArray != nullptr)
{
for (int i = 0; i < zoomLevelCount * horzTileCount * vertTileCount; i++)
if (pBitmapTileArray[i] != nullptr)
pBitmapTileArray[i]->Release();
delete[] pBitmapTileArray;
}
}
The program definitely takes awhile initially downloading all the images, and even on subsequent runs, it take a little while to load them all from local storage. But I've successfully run the program on the Surface RT's ARM processor.
Once everything is loaded, however, the zooming is quite fast, and definitely was something I've never quite seen before. I can't reproduce the zooming effect in this blog entry, but here's zoom level 14.27 where I live:
It's been a long wait from when I first had a vision of continuous zooming towards the Earth as a youngster, so playing with this program is particuarly satisfying.