谷歌、百度、高德等常用地图app、网站通过client + remote server的方式加载地图,在C端有较好的体验
经过调研发现,其中客户端从服务端加载的是一张一张叫做瓦片地图的图片,通过当前地图缩放等级和经纬度能计算出唯一的瓦片地图图片,即 zoom,lng,lat 能确定唯一的图片。基于该原理,在unity中可通过计算,动态加载瓦片地图,并且使用TileMap可以十分方便的实现瓦片地图的渲染。
知识点1:墨卡托投影
将三维空间中的经纬度坐标投影在二维平面坐标系
知识点2: 瓦片金字塔切割
根据不同缩放等级,将地图切割为 2^zoom 张256px*256px瓦片地图,即缩放等级越大,切割图片越多,且显示越精细
基础转化方法(C#实现)
//将tile(瓦片)坐标系转换为LatLngt(地理)坐标系,pixelX,pixelY为图片偏移像素坐标
public static LatLng TileXYToLatLng(int tileX, int tileY, int zoom, int pixelX, int pixelY)
{
double size = Math.Pow(2, zoom);
double pixelXToTileAddition = pixelX / 256.0;
double lng = (tileX + pixelXToTileAddition) / size * 360.0 - 180.0;
double pixelYToTileAddition = pixelY / 256.0;
double lat = Math.Atan(Math.Sinh(Math.PI * (1 - 2 * (tileY + pixelYToTileAddition) / size))) * 180.0 / Math.PI;
return new LatLng(lat, lng);
}
//将LatLngt地理坐标系转换为tile瓦片坐标系,pixelX,pixelY为图片偏移像素坐标
public static void LatLngToTileXY(LatLng latlng, int zoom, out int tileX, out int tileY, out int pixelX, out int pixelY)
{
double size = Math.Pow(2, zoom);
double x = ((latlng.Longitude + 180) / 360) * size;
double lat_rad = latlng.Latitude * Math.PI / 180;
double y = (1 - Math.Log(Math.Tan(lat_rad) + 1 / Math.Cos(lat_rad)) / Math.PI) / 2;
y = y * size;
tileX = (int)x;
tileY = (int)y;
pixelX = (int)((x - tileX) * 256);
pixelY = (int)((y - tileY) * 256);
}
实现思路
- 首先确立屏幕中心点的经纬度,根据缩放等级计算出中心瓦片的编号tileX和tileY
- 基于中心瓦片,可以找出周围 +-R的范围内的所有瓦片
- 根据滚轮或者触控缩放事件,重复步骤1和步骤2即可实现动态瓦片图加载
细节要点记录
1. 初始化TileMap将瓦片锚点设置为0,0,0,即瓦片的0,0位置在左下角,方便计算
2. 怎样定位中心点的世界坐标?
步骤1中使用 LatLngToTileXY
计算出了瓦片的的编号tileX和tileY,同时也计算出偏移量pixelX, pixelY,这里有偏移量产出是因为某经纬坐标虽然属于该编号瓦片,但相对瓦片还有个内部偏移,⚠️⚠️⚠️注意偏移是基于图片的左上角⚠️⚠️⚠️
在前期设置中,规定一个世界坐标对应一张图片,即像素和坐标的比例关系为256:1
所以当中心瓦片的位置是世界坐标Vector2(0,0)
的时候,应该将主摄像机的位置设置到 Vector2(pixelX/256,(256-pixelY) / 256)
3. 如何遍历并找出周围的瓦片
首先通过Unity中的Camera.main.ScreenToWorldPoint
拿到屏幕范围的实际世界坐标,进行遍历,可以稍微扩大遍历范围,以防止镜头拖动过快出现周围瓦片还没加载出来的情况发生
void renderViewTile()
{
// 获取屏幕的世界位置
Vector3 leftTop = Camera.main.ScreenToWorldPoint(new Vector3(0, Screen.height, Math.Abs(Camera.main.transform.position.z)));
Vector3 rightBottom = Camera.main.ScreenToWorldPoint(new Vector3(Screen.width, 0, Math.Abs(Camera.main.transform.position.z)));
// 计算出需要渲染的
int horizontal = (int)(leftTop.x - 3);
int vertical = (int)(rightBottom.y - 3);
Stack<Tile> tiles = new Stack<Tile>();
Stack<Vector3Int> positions = new Stack<Vector3Int>();
for (int i = horizontal; i <= Math.Abs(horizontal); i++)
{
for (int j = vertical; j <= Math.Abs(vertical); j++)
{
int pX;
int pY;
int tX;
int tY;
LatLng ll = TileXYToLatLng(centerTileX, centerTileY, zoom, i * unit2pixel, j * unit2pixel);
LatLngToTileXY(ll, zoom, out tX, out tY, out pX, out pY);
Tile tile = generateTile(tX, tY);
Vector3Int position = new Vector3Int(i, -j, 0);
tiles.Push(tile);
positions.Push(position);
}
}
tilemap.SetTiles(positions.ToArray(), tiles.ToArray());
}
4. 如何加载不同层级的瓦片图
可以在Update中实现,修改zoom,当zoom发生变化时重新渲染,以Mouse ScrollWheel例子
// 滚轮切换相机视口大小
void ScrollWheelViewChange()
{
int viewScaleRange = 20;
float maxViewScale = originViewScale + viewScaleRange;
float minViewScale = originViewScale - viewScaleRange;
Camera c = Camera.main;
float mouseCenter = Input.GetAxis("Mouse ScrollWheel");
//mouseCenter >0 = 正数 往前滑动,放大镜头
if (mouseCenter < 0)
{
//滑动限制
if (viewScale <= maxViewScale)
{
if (zoom > minZoom)
{
viewScale += 10 * slideSpeed * Time.deltaTime;
Camera.main.fieldOfView = viewScale;
}
if (viewScale >= maxViewScale)
{
zoom -= 1;
setCameraPositionCenter();
renderViewTile();
Camera.main.fieldOfView = originViewScale;
viewScale = originViewScale;
}
}
}
else if (mouseCenter > 0)
{
//滑动限制
if (viewScale >= minViewScale)
{
if (zoom < maxZoom)
{
viewScale -= 10 * slideSpeed * Time.deltaTime;
Camera.main.fieldOfView = viewScale;
}
if (viewScale <= minViewScale)
{
zoom += 1;
setCameraPositionCenter();
renderViewTile();
Camera.main.fieldOfView = originViewScale;
viewScale = originViewScale;
}
}
}
}
本质上是修改Camera.main.fieldOfView
来实现缩放效果,在每次zoom变化且重新渲染后,重置Camera.main.fieldOfView
以免图片由于缩放摄像机视角而造成模糊,字体大小不一的问题
优化点
- 清除屏幕周围不必要的瓦片
- 批量设置瓦片
- 异步加载图片Sprite