谷歌、百度、高德等常用地图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);
    }

实现思路

  1. 首先确立屏幕中心点的经纬度,根据缩放等级计算出中心瓦片的编号tileX和tileY
  2. 基于中心瓦片,可以找出周围 +-R的范围内的所有瓦片
  3. 根据滚轮或者触控缩放事件,重复步骤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以免图片由于缩放摄像机视角而造成模糊,字体大小不一的问题

优化点

  1. 清除屏幕周围不必要的瓦片
  2. 批量设置瓦片
  3. 异步加载图片Sprite

最终效果

参考文献

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注