​​Chap 27/100: Terrain Following 3D Mini MAP​
​
What I will Learn here?:
​On this tutorial ​​we will learn how to render a following 3D Mini-Map
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​Note:
Try to move to different map zones and check the respective Map and Mini-Map updates.
​​
​Let's check our current main source tree, except the LIBs:
​
Added source on White:
​​
​│ Applicationclass.cpp
│ Applicationclass.h
│ counter.h
│ main.cpp
│ main.h
│
├───camera
│ cameraClass.cpp
│ cameraClass.h
│ frustumClass.cpp
│ frustumClass.h​
│ lightClass.cpp
│ lightClass.h
│ positionClass.cpp
│ positionClass.h
│ RenderFrustumClass.cpp
│ RenderFrustumClass.h
│
├───game
│ playerClass.cpp
│ playerClass.h​
│​
├───graphics
│ renderTextureClass.cpp
│ renderTextureClass.h
│ spriteClass.cpp
│ spriteClass.h​
│ textClass.cpp
│ textClass.h
│ textFontClass.cpp
│ textFontClass.h
│
├───input
│ inputClass.cpp
│ inputClass.h
│
├───loader
│ objModelV2Class.cpp
│ objModelV2Class.h
│
├───shader
│ shaderClass.cpp
│ shaderClass.h
│
├───system
│ dx11Class.cpp
│ dx11class.h
│ SystemClass.cpp
│ SystemClass.h
│ xml_loader.cpp
│ xml_loader.h​
│​
​└───terrain
bitmapclass.cpp
bitmapclass.h
Minimapclass.cpp
Minimapclass.h
quadtreeClass.cpp
quadtreeClass.h
terrainClass.cpp
terrainClass.h
terrainManagerClass.cpp
terrainManagerClass.h
​
​​​(main.h) ​
- Set for Chapter 27
​
​
​​​​​
Actually this Chappter has a great visual effect and small changes.​
I am only rendering the Mini-Map 20x per second to allow more CPU to main Map​ Rendering.​
Here I have Average Render Main Map 1700 / Second and 20 /Second Mini-Map
​
////////////////////////////////////////////////////////////////////////////////
// Filename: minimapclass.cpp
////////////////////////////////////////////////////////////////////////////////
#include "../main.h"
#if TUTORIAL_CHAP >= 15
#pragma warning( disable : 4706 ) // Disable warning C4706: assignment within conditional expression
#include "../applicationClass.h"
#include <d3dx11tex.h>
#include "minimapclass.h"
extern ApplicationClass* g_applicationClass;
//Initialize the three bitmaps to null in the class constructor.
MiniMapClass::MiniMapClass()
{
m_Point = 0;
m_Border = 0;
m_MiniMapBitmap = 0;
m_MiniMapTexture = 0;
#if TUTORIAL_CHAP >= 26
m_MapBitmap = 0;
m_MapTexture = 0;
#endif//
for (UINT i=0; i < MAX_CLIENTS; i++)
{
m_pointLocationX[i] = 0;
m_pointLocationY[i] = 0;
m_pointRotation[i] = 0;
#if TUTORIAL_CHAP >= 26
m_pointMapLocationX[i] = 0;
m_pointMapLocationY[i] = 0;
#endif//
}
}
MiniMapClass::~MiniMapClass()
{
Shutdown();
}
bool MiniMapClass::Initialize(DX11Class* directX11, TerrainClass* m_Terrain_, float terrainWidth, float terrainHeight)
{ bool result;
m_DirectX11 = directX11;
m_Terrain = m_Terrain_;
//The Initialize function starts by storing the location of the mini-map, its size, the base view matrix for rendering, and the actual size of the terrain mesh.
// Initialize the location of the mini-map on the screen.
m_mapLocationX = g_ScreenWidth - 200;
m_mapLocationY = 75;
// Set the size of the mini-map:
m_mapSizeX = 150.0f;
m_mapSizeY = 150.0f;
#if TUTORIAL_CHAP >= 26
// Set the size of the main-map:
m_mainMapSizeX = 1000.0f;
m_mainMapSizeY = 1000.0f;
#endif
// Store the terrain size.
m_terrainWidth = terrainWidth;
m_terrainHeight = terrainHeight;
//Next we create the mini-map from the color map .dds file. Even thought the color map is 256x256 we have shrunk it down to 150x150 so that displaying it does not take up too much of the screen.
// Create the mini-map bitmap object.
m_MiniMapBitmap = NEW BitmapClass;
if(!m_MiniMapBitmap){return false;}
// Initialize the "mini-map" bitmap object.
result = m_MiniMapBitmap->Initialize(directX11->m_device, g_ScreenWidth, g_ScreenHeight, L"Engine/data/water_tex04.jpg", (int)m_mapSizeX, (int)m_mapSizeY, 1.0f);
if(!result)
{
MessageBox(g_hwnd, L"Could not initialize the mini-map object.", L"Error", MB_OK);
return false;
}
#if TUTORIAL_CHAP >= 26
// Initialize the "MAP" bitmap object.
m_MapBitmap = NEW BitmapClass;
if(!m_MapBitmap){return false;}
// Initialize the mini-map bitmap object.
result = m_MapBitmap->Initialize(directX11->m_device, g_ScreenWidth, g_ScreenHeight, L"Engine/data/mapFramev3.bmp", (int)m_mainMapSizeX, (int)m_mainMapSizeY, 1.0f); // Main Map Size
if(!result)
{
MessageBox(g_hwnd, L"Could not initialize the mini-map object.", L"Error", MB_OK);
return false;
}
#endif//
// Load the border which will surround the mini-map so the edges are clearly defined.
m_Border = NEW BitmapClass;
if(!m_Border){return false;}
// Initialize the border bitmap object.
result = m_Border->Initialize(directX11->m_device, g_ScreenWidth, g_ScreenHeight, L"Engine/data/015MiniMap.png", 161, 161, 1.0f);
if(!result)
{
MessageBox(g_hwnd, L"Could not initialize the border object.", L"Error", MB_OK);
return false;
}
//And finally we load the point indicator. It is a 3x3 green pixel that we will use and constantly update to show where the user currently is located on the mini-map.
// Create the point bitmap object.
m_Point = NEW BitmapClass;
if(!m_Point){return false;}
// Initialize the point bitmap object.
result = m_Point->Initialize(directX11->m_device, g_ScreenWidth, g_ScreenHeight, L"Engine/data/015arrowv2.png", 15, 19, 1.0f);//015arrow.png
if(!result)
{
MessageBox(g_hwnd, L"Could not initialize the point object.", L"Error", MB_OK);
return false;
}
// Initialize the shader object:
result = miniMapShader.Initialize(MY_SHADER_TEXTURE, directX11, L"engine/005Texture.hlsl", false/*NOGS*/, true/*2D*/);
if(!result)
{
MessageBox(g_hwnd, L"Could not initialize the MiniMap Shader object.", L"Error", MB_OK);
return false;
}
renderFrustumClass.Init(m_DirectX11, m_Terrain->m_terrainWidth, m_Terrain->m_terrainHeight);
MyOutputDebugString( L"Minimap Class: Initialized\n" );
return true;
}
//The Shutdown function releases the three bitmap objects that were used for rendering the mini-map elements.
void MiniMapClass::Shutdown()
{
//public:
SAFE_SHUTDOWN(m_MiniMapTexture);
#if TUTORIAL_CHAP >= 26
SAFE_SHUTDOWN(m_MapTexture);
#endif//
//private:
miniMapShader.Shutdown();
renderFrustumClass.Shutdown();
SAFE_SHUTDOWN(m_Point); // Release the point bitmap object.
SAFE_SHUTDOWN(m_Border); // Release the border bitmap object.
SAFE_SHUTDOWN(m_MiniMapBitmap); // Release the mini-map bitmap object.
#if TUTORIAL_CHAP >= 26
SAFE_SHUTDOWN(m_MapBitmap);
#endif//
}
bool MiniMapClass::Render(ID3D11DeviceContext* deviceContext)
{ bool result;
if (m_DirectX11->RenderfirstTime)
{
// Create the refraction render to texture object.
IF_NOT_RETURN_FALSE (m_MiniMapTexture = NEW RenderTextureClass);
if(!m_MiniMapTexture->Initialize(m_DirectX11, g_ScreenWidth, g_ScreenHeight))
{MyMessageBox(L"Could not initialize the MiniMapTexture object.");return false;}
#if TUTORIAL_CHAP >= 26
// Create the refraction render to texture object.
IF_NOT_RETURN_FALSE (m_MapTexture = NEW RenderTextureClass);
if(!m_MapTexture->Initialize(m_DirectX11, g_ScreenWidth, g_ScreenHeight))
{MyMessageBox(L"Could not initialize the MiniMapTexture object.");return false;}
m_DirectX11->TurnOffAlphaBlending();
RenderMapToTexture(deviceContext);
#endif
}
static bool last_g_GOD_MODE = g_GOD_MODE;
#if TUTORIAL_CHAP <= 26
// - On god Mode
// - On first frame after disable god mode (to clean blue frustrum)
// - On first frame
if (g_GOD_MODE || last_g_GOD_MODE != g_GOD_MODE || m_DirectX11->RenderfirstTime)
RenderMiniMapToTexture(deviceContext); // Render: 1st shot to mini-map or alwways on "god mode"
#else
//#if TUTORIAL_CHAP >= 27
static unsigned long m_pingTime = timeGetTime();
if(timeGetTime() >= (m_pingTime + 50)) //1 Second = 1000 ms
{
m_pingTime = timeGetTime();
RenderMiniMapToTexture(deviceContext); // Render: 1st shot to mini-map or alwways on "god mode"
}
#endif//
last_g_GOD_MODE = g_GOD_MODE;
#if TUTORIAL_CHAP >= 26
if (g_game_state == GAME_MAP)
{
// Map may Changed...
m_DirectX11->TurnOffAlphaBlending();
RenderMapToTexture(deviceContext);
return true; // If we are on main map dont render mini-map
}
#endif
#if defined (SCENE_MINIMAP)
if (g_MINIMAP_ENABLED)
{
deviceContext->RSSetState(m_DirectX11->m_rasterStateCullNone[FILL_SOLID]); // RESET
m_DirectX11->TurnOffAlphaBlending();
//[1] Render the pre-rendered "mini-map" bitmap
result = m_MiniMapBitmap->Render(deviceContext, m_mapLocationX+4, m_mapLocationY+4);
if(!result)return false;
miniMapShader.texture = m_MiniMapTexture->GetShaderResourceView();
miniMapShader.Render(deviceContext, m_MiniMapBitmap->GetIndexCount(), g_identMatrix, g_viewMatrix2D, g_orthoMatrix);
//[2] Render the "border" bitmap
result = m_Border->Render(deviceContext, (m_mapLocationX - 2), (m_mapLocationY - 2));
if(!result)return false;
miniMapShader.texture = m_Border->GetTexture();
miniMapShader.Render(deviceContext, m_Border->GetIndexCount(), g_identMatrix, g_viewMatrix2D, g_orthoMatrix);
//Render the "arrow" bitmap for all players:
m_DirectX11->TurnZBufferOn();
m_DirectX11->TurnOnAlphaBlending();
RenderMiniMapPlayers(deviceContext);
}
#endif
return true;
}
bool MiniMapClass::RenderMiniMapPlayers(ID3D11DeviceContext* deviceContext)
{
bool result;
//[3] Render the "arrow" bitmap for all players:
for (UINT i=0; i < MAX_CLIENTS; i++)
{
if (m_pointLocationX[i] != 0 || m_pointLocationY[i] != 0)
{
result = m_Point->RenderRotY(deviceContext, m_pointLocationX[i], m_pointLocationY[i]);
if(!result)return false;
D3DXMATRIXA16 worldMatrix = g_identMatrix;;
D3DXMATRIXA16 m;
D3DXMatrixRotationZ( &m, -m_pointRotation[i] * 0.0174532925f ); //0.0174532925f (PI / 180.0f): Convert degrees to radians.
worldMatrix *= m;
worldMatrix._41 = (float) -g_ScreenWidth/2 + m_Point->m_bitmapWidth/2 + m_pointLocationX[i];
worldMatrix._42 = (float) g_ScreenHeight/2 - m_Point->m_bitmapHeight/2 - m_pointLocationY[i];
worldMatrix._43 = -0.1f; //Make sure that arrow is on top of map
// Render the point bitmap using the: miniMapShader
miniMapShader.texture = m_Point->GetTexture();
miniMapShader.Render(deviceContext, m_Point->GetIndexCount(), worldMatrix, g_viewMatrix2D, g_orthoMatrix);
}
}
return true;
}
#if TUTORIAL_CHAP >= 26
bool MiniMapClass::RenderMapPlayers(ID3D11DeviceContext* deviceContext)
{
bool result;
//[3] Render the "arrow" bitmap for all players:
for (UINT i=0; i < MAX_CLIENTS; i++)
{
if (m_pointMapLocationX[i] != 0 || m_pointMapLocationY[i] != 0)
{
result = m_Point->RenderRotY(deviceContext, m_pointMapLocationX[i], m_pointMapLocationY[i]);
if(!result)return false;
D3DXMATRIXA16 worldMatrix = g_identMatrix;;
D3DXMATRIXA16 m;
D3DXMatrixRotationZ( &m, -m_pointRotation[i] * 0.0174532925f ); //0.0174532925f (PI / 180.0f): Convert degrees to radians.
worldMatrix *= m;
worldMatrix._41 = (float) -g_ScreenWidth/2 + m_Point->m_bitmapWidth/2 + m_pointMapLocationX[i];
worldMatrix._42 = (float) g_ScreenHeight/2 - m_Point->m_bitmapHeight/2 - m_pointMapLocationY[i];
worldMatrix._43 = -0.1f; //Make sure that arrow is on top of map
// Render the point bitmap using the: miniMapShader
miniMapShader.texture = m_Point->GetTexture();
miniMapShader.Render(deviceContext, m_Point->GetIndexCount(), worldMatrix, g_viewMatrix2D, g_orthoMatrix);
}
}
return true;
}
bool MiniMapClass::RenderMap(ID3D11DeviceContext* deviceContext) {
bool result;
//[1] Put the "mini-map" bitmap vertex and index buffers on the graphics pipeline to prepare them for drawing.
result = m_MapBitmap->Render(deviceContext, (g_ScreenWidth - m_MapBitmap->m_bitmapWidth)/2, (g_ScreenHeight - m_MapBitmap->m_bitmapHeight)/2);
if(!result)return false;
static D3DXMATRIXA16 worldMatrix = g_identMatrix;
miniMapShader.texture = m_MapTexture->GetShaderResourceView();
float scaleX = g_ScreenWidth / 1920.0f;
float scaleY = g_ScreenHeight / 1080.0f;
worldMatrix._11 = scaleX;
worldMatrix._22 = scaleY;
worldMatrix._33 = scaleY;
miniMapShader.Render(deviceContext, m_MapBitmap->GetIndexCount(), worldMatrix, g_viewMatrix2D, g_orthoMatrix);
//Render the "arrow" bitmap for all players:
m_DirectX11->TurnZBufferOn();
m_DirectX11->TurnOnAlphaBlending();
RenderMapPlayers(deviceContext);
return true;
}
#endif//
//The PositionUpdate function is used for updating where the 3x3 green pixel point indicator should be located on the mini-map.
//It converts the 3D float position of the camera on the terrain into a 2D position on the bitmap.
//It also makes sure the indicator never goes past the borders of the mini-map.
void MiniMapClass::PositionUpdate(int playerId, float positionX, float positionZ)
{
float percentX, percentY;
float percentMapX, percentMapY;
// Ensure the point does not leave the minimap borders even if the camera goes past the terrain borders.
if(positionX <= 0) {
positionX = 0;
return;
}
if(positionZ <= 0) {
positionZ = 0;
return;
}
if(positionX > m_terrainWidth)
positionX = m_terrainWidth;
if(positionZ > m_terrainHeight)
positionZ = m_terrainHeight;
// Calculate the position of the camera on the minimap in terms of percentage.
#if TUTORIAL_CHAP <= 25
percentX = positionX / m_terrainWidth;
percentY = 1.0f - (positionZ / m_terrainHeight);
#endif
#if TUTORIAL_CHAP == 26
percentX = positionX / m_terrainWidth;
percentY = 1.0f - (positionZ / m_terrainHeight);
#else
percentX = 0.5f;
percentY = 0.5f;
#endif
#if TUTORIAL_CHAP >= 26
// Check which Quadrant we are:
Qx = (int) (g_applicationClass->m_Camera.m_positionX / ((m_terrainWidth+1)/2));
Qz = (int) (g_applicationClass->m_Camera.m_positionZ / ((m_terrainHeight+1)/2));
percentMapX = (positionX-Qx*m_terrainWidth/2) / m_terrainWidth*2;
percentMapY = 1.0f - ((positionZ-Qz*(m_terrainHeight/2)) / m_terrainHeight*2); //Note: *2 couse we are viewing a 256 of a big 512 map
#endif
// Determine the pixel location of the point on the Mini-Map:
m_pointLocationX[playerId] = m_mapLocationX + (int)(percentX * m_mapSizeX); // Dont need rescale. always 150x150
m_pointLocationY[playerId] = m_mapLocationY + (int)(percentY * m_mapSizeY);
// Determine the pixel location of the point on the Main-Map:
float scaleX = g_ScreenWidth / 1920.0f;
float scaleY = g_ScreenHeight / 1080.0f;
#if TUTORIAL_CHAP >= 26
m_pointMapLocationX[playerId] = (int)((g_ScreenWidth - scaleX*m_MapBitmap->m_bitmapWidth)/2 + (int)(percentMapX * scaleX*m_mainMapSizeX)); // Re-Scale Map.
m_pointMapLocationY[playerId] = (int)((g_ScreenHeight - scaleY*m_MapBitmap->m_bitmapHeight)/2 + (int)(percentMapY * scaleY*m_mainMapSizeY));
#endif
// Subtract one from the location to center the point on the mini-map according to the 3x3 point pixel image size.
m_pointLocationX[playerId] -= 1;
m_pointLocationY[playerId] -= 1;
}
bool MiniMapClass::RenderMiniMapToTexture(ID3D11DeviceContext* pContext)
{
// Setup a clipping plane based on the height of the water to clip everything above it.
static D3DXVECTOR4 clipPlane = D3DXVECTOR4(0.0f, 0.0f, 0.0f, 0.0f);
//"worldMatrix:"
D3DXMATRIX worldMatrix = g_identMatrix;
//"viewMatrix": SET Camera Roration and Position to 2D Render: TEXT and SPRITES
CameraClass m_Camera;
m_Camera.SetRotation(+89.999f, 0, 0);
#if TUTORIAL_CHAP <= 26
/* /
/ |
/a | m_Terrain->m_terrainHeight/2
--- h--- |
Note:
angle a = 21.8f (half of our view frustrum)
*/
float h = ((float)(m_Terrain->m_terrainHeight/2)) / tan(21.8f * 0.0174532925f);
m_Camera.SetPosition((float)m_Terrain->m_terrainWidth/2, h, (float)m_Terrain->m_terrainHeight/2);
#else
m_Camera.SetPosition(g_applicationClass->m_Camera.m_positionX, 100, g_applicationClass->m_Camera.m_positionZ);
#endif//
m_Camera.Render();
m_Camera.GetViewMatrix(g_viewMatrix);
//"projectionMatrix" Change the projection Matrix to our MINIMAP projection
D3DXMATRIX g_projectionMatrixAux = g_projectionMatrix;
g_projectionMatrix = g_projectionMiniMapMatrix;
//[1] Set the render target to be the refraction render to "texture"
pContext->OMSetRenderTargets(1, &m_MiniMapTexture->m_renderTargetView, m_DirectX11->m_depthStencilView);
//[3] Clear the MiniMapTexture
m_MiniMapTexture->ClearRenderTarget(pContext, m_DirectX11->m_depthStencilView, 0.0f, 0.0f, 0.0f, 1.0f);
//[4] RENDER: terrain and water into mini map texture:
// -----------------------------------------------------------------
// Terrain:
#if TUTORIAL_CHAP >= 16
m_Terrain->m_ShaderFog.fogStart = 1024; // No fog...
m_Terrain->m_ShaderFog.fogEnd = 2048; // No fog...
m_Terrain->m_ShaderFog.m_hasLight = true;
m_Terrain->m_ShaderFog.m_isDay = true;
#endif
m_Terrain->Render(pContext, &g_projectionMatrix);
#if TUTORIAL_CHAP >= 16
m_Terrain->m_ShaderFog.m_hasLight = false;
#endif
// Infinite Water
g_applicationClass->InfiniteFixedTerraniWater(true);
// MiniFrustum 2D:
#if defined(_DEBUG)
if (g_GOD_MODE) {
pContext->RSSetState(m_DirectX11->m_rasterStateCullNone[FILL_SOLID]); // RESET
renderFrustumClass.Render(g_applicationClass->m_Camera.m_positionX,
1+g_applicationClass->m_Camera.m_positionY,
g_applicationClass->m_Camera.m_positionZ,
g_applicationClass->m_Camera.m_rotationY,
g_applicationClass->m_Frustum.m_screenDepth);
}
#endif
#if TUTORIAL_CHAP >= 40 && defined (SCENE_WATER)
g_applicationClass->m_WaterModel->m_WaterShader->waterOn = 0; // Enable AlphaBlend on Water
g_applicationClass->m_WaterModel->m_WaterShader->clipPlane = clipPlane;
#endif
//[5] Reset the render target back to the original back buffer and not the render to texture anymore.
pContext->OMSetRenderTargets(1, &m_DirectX11->m_renderTargetView, m_DirectX11->m_depthStencilView);
//[6] restore normal projection Matrix
g_projectionMatrix = g_projectionMatrixAux;
return true;
}
#if TUTORIAL_CHAP >= 26
bool MiniMapClass::RenderMapToTexture(ID3D11DeviceContext* pContext)
{
// Setup a clipping plane based on the height of the water to clip everything above it.
static D3DXVECTOR4 clipPlane = D3DXVECTOR4(0.0f, 0.0f, 0.0f, 0.0f);
//"worldMatrix:"
D3DXMATRIX worldMatrix = g_identMatrix;
//"viewMatrix": SET Camera Roration and Position to 2D Render: TEXT and SPRITES
CameraClass m_Camera;
m_Camera.SetRotation(+89.999f, 0, 0);
/* /
/ |
/a | m_Terrain->m_terrainHeight/2
--- h--- |
Note:
angle a = 21.8f (half of our view frustrum)
*/
float h = ((float)(m_Terrain->m_terrainHeight/4)) / tan(21.8f * 0.0174532925f);
m_Camera.SetPosition((float)m_Terrain->m_terrainWidth/4 + Qx*m_Terrain->m_terrainWidth/2, h,
(float)m_Terrain->m_terrainHeight/4 + Qz*m_Terrain->m_terrainHeight/2); //Note: Work with 512x512: 4 x 256x256
//m_Camera.SetPosition((float)(m_Terrain->m_terrainWidth)/2, h, (float)(m_Terrain->m_terrainHeight)/2); //Note: Work for 256x256
m_Camera.Render();
m_Camera.GetViewMatrix(g_viewMatrix);
//"projectionMatrix" Change the projection Matrix to our MINIMAP projection
D3DXMATRIX g_projectionMatrixAux = g_projectionMatrix;
g_projectionMatrix = g_projectionMiniMapMatrix;
//[1] Set the render target to be the refraction render to "texture"
pContext->OMSetRenderTargets(1, &m_MapTexture->m_renderTargetView, m_DirectX11->m_depthStencilView);
//[3] Clear the refraction render to texture.
m_MapTexture->ClearRenderTarget(pContext, m_DirectX11->m_depthStencilView, 1.0f, 1.0f, 1.0f, 0.0f);
//[4] RENDER: terrain and water into mini map texture:
// -----------------------------------------------------------------
// Terrain:
m_Terrain->m_ShaderFog.fogStart = 1024; // No fog...
m_Terrain->m_ShaderFog.fogEnd = 2048; // No fog...
m_Terrain->m_ShaderFog.m_hasLight = true; // hasLight = true to render mini-map
m_Terrain->m_ShaderFog.m_isDay = true; // Always day on Main Map
m_Terrain->Render(pContext, &g_projectionMatrix);
m_Terrain->m_ShaderFog.m_hasLight = false; // hasLight = false to render main terrain (back normal...)
// Infinite Water
g_applicationClass->InfiniteFixedTerraniWater(true);
//[5] Reset the render target back to the original back buffer and not the render to texture anymore.
pContext->OMSetRenderTargets(1, &m_DirectX11->m_renderTargetView, m_DirectX11->m_depthStencilView);
//[6] restore normal projection Matrix
g_projectionMatrix = g_projectionMatrixAux;
return true;
}
#endif//
#endif//
​​
​​​​​
​Project Code:
​
woma.no-ip.org/woma/WoMA_PartII_Chap27.zip
​
​What's next?
This is for now the Last Terrain Chapter, on the next Tutorial Part III we will cover the Elements of Nature.
Including a real time Weather Service.