Skip to content

Instantly share code, notes, and snippets.

@Frixs
Created October 16, 2022 16:17
Show Gist options
  • Save Frixs/fa3b1bec1f7626c05c4c3c6ff1475618 to your computer and use it in GitHub Desktop.
Save Frixs/fa3b1bec1f7626c05c4c3c6ff1475618 to your computer and use it in GitHub Desktop.
Mapbox Unity SDK (v2.1.1) issue solving
// @author Tomáš Frixs
using Mapbox.Map;
using Mapbox.Unity.Map;
using Mapbox.Unity.Map.TileProviders;
using Mapbox.Unity.Utilities;
using Mapbox.Utils;
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Assets.Scripts.Mapbox
{
/// <summary>
/// Custom TIle Provider for Mapbox. It follows the <see cref="MapExtentType.CameraBounds"/> which normally results in <seealso cref="QuadTreeTileProvider"/>,
/// but it tries to fix the issue, where infinite distance tries to be rendered instead of limited one.
/// This one combines knowledge from <seealso cref="QuadTreeTileProvider"/> and <seealso cref="RangeAroundTransformTileProvider"/>.
/// </summary>
public class CameraBoundsWithRangeTileProvider : AbstractTileProvider
{
private const int HitPointsCount = 4;
#region Tile decision and raycasting fields
private HashSet<UnwrappedTileId> _tiles;
private HashSet<CanonicalTileId> _canonicalTiles;
private Ray _ray00;
private Ray _ray01;
private Ray _ray10;
private Ray _ray11;
private Vector3[] _hitPnt = new Vector3[HitPointsCount];
private Vector2d[] _hitPntGeoPos = new Vector2d[HitPointsCount];
#endregion
private Plane _groundPlane;
private bool _ready; // Indicates if the provider is ready to work
private UnwrappedTileId _currentTargetTile;
private Vector2dBounds _viewPortWebMercBounds;
[SerializeField]
private CameraBoundsWithRangeTileProviderOptions _tileProviderOptions;
#region Interface Implementations
/// <inheritdoc/>
public override void OnInitialized()
{
_tiles = new HashSet<UnwrappedTileId>();
_canonicalTiles = new HashSet<CanonicalTileId>();
_currentExtent.activeTiles = new HashSet<UnwrappedTileId>();
_groundPlane = new Plane(Vector3.up, 0);
if (Options != null) _tileProviderOptions = (CameraBoundsWithRangeTileProviderOptions)_options;
else _tileProviderOptions ??= new CameraBoundsWithRangeTileProviderOptions();
if (_tileProviderOptions.Camera == null || _tileProviderOptions.TargetTransform == null)
{
Debug.LogError($"{nameof(CameraBoundsWithRangeTileProvider)}: Critical references are missing!");
return;
}
_tileProviderOptions.CameraTransformState.HasChanged = false;
_ready = true;
}
/// <inheritdoc/>
public override void UpdateTileExtent()
{
if (!_ready)
return;
// Update viewport in case it was changed by switching zoom level
_viewPortWebMercBounds = GetcurrentViewPortWebMerc();
_currentExtent.activeTiles = GetWithWebMerc(_viewPortWebMercBounds, _map.AbsoluteZoom);
OnExtentChanged();
}
/// <inheritdoc/>
/// <remarks>
/// Indicates if the tile should be removed/recycled - all tiles we don't want to see.
/// </remarks>
public override bool Cleanup(UnwrappedTileId tile)
{
bool dispose = false;
dispose = tile.X > _currentTargetTile.X + _tileProviderOptions.DisposeBuffer || tile.X < _currentTargetTile.X - _tileProviderOptions.DisposeBuffer;
dispose = dispose || tile.Y > _currentTargetTile.Y + _tileProviderOptions.DisposeBuffer || tile.Y < _currentTargetTile.Y - _tileProviderOptions.DisposeBuffer;
dispose = dispose || (!_currentExtent.activeTiles.Contains(tile));
return dispose;
}
/// <inheritdoc/>
/// <remarks>
/// The key method that gets called from <seealso cref="AbstractMap.Update"/>.
/// </remarks>>
public override void UpdateTileProvider()
{
if (!_tileProviderOptions.CameraTransformState.HasChanged)
return;
_tileProviderOptions.CameraTransformState.HasChanged = false;
UpdateTileExtent();
}
#endregion
/// <summary>
/// ?!#
/// </summary>
public HashSet<UnwrappedTileId> GetWithWebMerc(Vector2dBounds bounds, int zoom)
{
_tiles.Clear();
_canonicalTiles.Clear();
if (bounds.IsEmpty()) { return _tiles; }
//stay within WebMerc bounds
Vector2d swWebMerc = new Vector2d(Math.Max(bounds.SouthWest.x, -Constants.WebMercMax), Math.Max(bounds.SouthWest.y, -Constants.WebMercMax));
Vector2d neWebMerc = new Vector2d(Math.Min(bounds.NorthEast.x, Constants.WebMercMax), Math.Min(bounds.NorthEast.y, Constants.WebMercMax));
UnwrappedTileId swTile = WebMercatorToTileId(swWebMerc, zoom);
UnwrappedTileId neTile = WebMercatorToTileId(neWebMerc, zoom);
// Calculate max range around the target and find intersection with camera bounds
_currentTargetTile = TileCover.CoordinateToTileId(_map.WorldToGeoPosition(_tileProviderOptions.TargetTransform.localPosition), zoom);
int xMin = Math.Max(swTile.X, _currentTargetTile.X - _tileProviderOptions.VisibleBuffer);
int xMax = Math.Min(neTile.X, _currentTargetTile.X + _tileProviderOptions.VisibleBuffer);
int yMin = Math.Max(neTile.Y, _currentTargetTile.Y - _tileProviderOptions.VisibleBuffer);
int yMax = Math.Min(swTile.Y, _currentTargetTile.Y + _tileProviderOptions.VisibleBuffer);
// Go through the extent and add found tiles into the list
for (int x = xMin; x <= xMax; x++)
{
for (int y = yMin; y <= yMax; y++)
{
UnwrappedTileId uwtid = new UnwrappedTileId(zoom, x, y);
if (!_canonicalTiles.Contains(uwtid.Canonical))
{
if (_tiles.Count < _tileProviderOptions.MaxTilesToProvide)
{
_tiles.Add(uwtid);
_canonicalTiles.Add(uwtid.Canonical);
}
else
{
Debug.LogError($"Tile Provider tries to provide more than allowed number of tiles - {(xMax - xMin) * (yMax - yMin)} ({x};{y}) - {_tiles.Count}.");
}
}
}
}
return _tiles;
}
/// <summary>
/// ?!#
/// </summary>
public UnwrappedTileId WebMercatorToTileId(Vector2d webMerc, int zoom)
{
var tileCount = Math.Pow(2, zoom);
var dblX = webMerc.x / Constants.WebMercMax;
var dblY = webMerc.y / Constants.WebMercMax;
int x = (int)Math.Floor((1 + dblX) / 2 * tileCount);
int y = (int)Math.Floor((1 - dblY) / 2 * tileCount);
return new UnwrappedTileId(zoom, x, y);
}
/// <summary>
/// ?!#
/// </summary>
private Vector2dBounds GetcurrentViewPortWebMerc(bool useGroundPlane = true)
{
if (useGroundPlane)
{
// rays from camera to groundplane: lower left and upper right
_ray00 = _tileProviderOptions.Camera.ViewportPointToRay(new Vector3(0, 0));
_ray01 = _tileProviderOptions.Camera.ViewportPointToRay(new Vector3(0, 1));
_ray10 = _tileProviderOptions.Camera.ViewportPointToRay(new Vector3(1, 0));
_ray11 = _tileProviderOptions.Camera.ViewportPointToRay(new Vector3(1, 1));
_hitPnt[0] = GetGroundPlaneHitPoint(_ray00);
_hitPnt[1] = GetGroundPlaneHitPoint(_ray01);
_hitPnt[2] = GetGroundPlaneHitPoint(_ray10);
_hitPnt[3] = GetGroundPlaneHitPoint(_ray11);
}
// Find min max bounding box.
// TODO : Find a better way of doing this.
double minLat = double.MaxValue;
double minLong = double.MaxValue;
double maxLat = double.MinValue;
double maxLong = double.MinValue;
for (int pointIndex = 0; pointIndex < HitPointsCount; ++pointIndex)
{
_hitPntGeoPos[pointIndex] = _map.WorldToGeoPosition(_hitPnt[pointIndex]);
}
for (int i = 0; i < HitPointsCount; i++)
{
if (_hitPnt[i] == Vector3.zero)
{
continue;
}
else
{
if (minLat > _hitPntGeoPos[i].x)
{
minLat = _hitPntGeoPos[i].x;
}
if (minLong > _hitPntGeoPos[i].y)
{
minLong = _hitPntGeoPos[i].y;
}
if (maxLat < _hitPntGeoPos[i].x)
{
maxLat = _hitPntGeoPos[i].x;
}
if (maxLong < _hitPntGeoPos[i].y)
{
maxLong = _hitPntGeoPos[i].y;
}
}
}
Vector2d hitPntSWGeoPos = new Vector2d(minLat, minLong);
Vector2d hitPntNEGeoPos = new Vector2d(maxLat, maxLong);
Vector2dBounds tileBounds = new Vector2dBounds(Conversions.LatLonToMeters(hitPntSWGeoPos), Conversions.LatLonToMeters(hitPntNEGeoPos)); // Bounds debugging.
#if UNITY_EDITOR
Debug.DrawLine(_tileProviderOptions.Camera.transform.position, _map.GeoToWorldPosition(hitPntSWGeoPos), Color.blue);
Debug.DrawLine(_tileProviderOptions.Camera.transform.position, _map.GeoToWorldPosition(hitPntNEGeoPos), Color.red);
#endif
return tileBounds;
}
/// <summary>
/// ?!#
/// </summary>
private Vector3 GetGroundPlaneHitPoint(Ray ray)
{
float distance;
if (!_groundPlane.Raycast(ray, out distance)) { return Vector3.zero; }
return ray.GetPoint(distance);
}
}
}
using Assets.Scripts.Utils;
using Mapbox.Unity.Map;
using System;
using UnityEngine;
namespace Assets.Scripts.Mapbox
{
[Serializable]
public class CameraBoundsWithRangeTileProviderOptions : ExtentOptions
{
/// <summary>The camera according to which provide tiles</summary>
public Camera Camera;
/// <summary>Linked separately, but should be part of <see cref="Camera"/>.
/// It is here to replace standard <see cref="Transform.hasChanged"/> property,
/// because this can be used by Cinemachine camera or any other script, but we want to make
/// separate state value of transform change state to make provider provide tiles
/// only on actual camera change.</summary>
public ImmediateTransformStateCheck CameraTransformState;
/// <summary>Target around which to generate/provide tiles</summary>
public Transform TargetTransform;
/// <summary>Buffer (radius) around <see cref="TargetTransform"/> in which to provide tiles</summary>
public int VisibleBuffer;
/// <summary>Buffer (radius) around <see cref="TargetTransform"/> in which to destroy/clean up tiles once
/// they needs to be removed (e.g. due to it goes out of the camera bounds)</summary>
public int DisposeBuffer;
/// <summary>Max limit of tiles to provide at once</summary>
public ushort MaxTilesToProvide;
public override void SetOptions(ExtentOptions extentOptions)
{
CameraBoundsWithRangeTileProviderOptions options = extentOptions as CameraBoundsWithRangeTileProviderOptions;
if (options != null)
{
Camera = options.Camera;
CameraTransformState = options.CameraTransformState;
TargetTransform = options.TargetTransform;
VisibleBuffer = options.VisibleBuffer;
DisposeBuffer = options.DisposeBuffer;
MaxTilesToProvide = options.MaxTilesToProvide;
}
else
{
Debug.LogError("ExtentOptions type mismatch : Using " + extentOptions.GetType() + " to set extent of type " + this.GetType());
}
}
public void SetOptions(Camera mapCamera, ImmediateTransformStateCheck cameraStateCheck, Transform targetTransform, int visibleRange = 1, int disposeRange = 1, ushort maxTilesToProvide = 35)
{
Camera = mapCamera;
CameraTransformState = cameraStateCheck;
TargetTransform = targetTransform;
VisibleBuffer = visibleRange;
DisposeBuffer = disposeRange;
MaxTilesToProvide = maxTilesToProvide;
}
}
}
// @author Tomáš Frixs
using UnityEngine;
namespace Assets.Scripts.Utils
{
/// <summary>
/// Simple checker for change of <see cref="Transform.position"/> and <see cref="Transform.rotation"/> of the target.
/// The check is performed every single frame.
/// </summary>
public class ImmediateTransformStateCheck : MonoBehaviour
{
/// <summary>
/// Target to check
/// </summary>
[SerializeField] private Transform _target;
/// <summary>
/// previous position of the target
/// </summary>
private Vector3 _prevPosition = Vector3.zero;
/// <summary>
/// previous rotation of the target
/// </summary>
private Quaternion _prevRotation = Quaternion.identity;
/// <summary>
/// Indicates if transform got changed from the last point that this values was set to <see langword="false"/>.
/// </summary>
public bool HasChanged { get; set; }
#region Unity Methods
private void Update()
{
CheckTransformChange();
_prevPosition = _target.transform.position;
_prevRotation = _target.transform.rotation;
}
#endregion
/// <summary>
/// Checks transform for any change.
/// </summary>
public void CheckTransformChange()
{
if (HasChanged)
return;
if (_target.transform.position != _prevPosition)
{
HasChanged = true;
return;
}
if (_target.transform.rotation != _prevRotation)
{
HasChanged = true;
return;
}
}
}
}
// @author Tomáš Frixs
using Mapbox.Map;
using Mapbox.Platform;
using Mapbox.Platform.Cache;
using Mapbox.Unity;
using Mapbox.Unity.Utilities;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Assets.Scripts.Mapbox
{
/// <summary>
/// This class is the middleman between calling web requests and trying to pull the data from local cache.
/// This class is fully based on <see cref="CachingWebFileSource"/> (original class - Mapbox Unity SDK v2.1.1) and tries to fix
/// the asynchronous issues (cache manipulation is synchronous in there) that the original class has.
/// <para>
/// To replace the original class with this one, update <see cref="MapboxAccess"/> class and
/// change there all references (4) of the original class to this one.
/// Also, it is important to initialize this class in there in <see cref="MapboxAccess.ConfigureFileSource"/> (e.g. like this):
/// <code>
/// void ConfigureFileSource()
/// {
/// _fileSource = new ImprovedCachingWebFileSource(_configuration.AccessToken, _configuration.GetMapsSkuToken, _configuration.AutoRefreshCache)
/// .AddCache(new MemoryCache(_configuration.MemoryCacheSize))
/// #if !UNITY_WEBGL
/// .AddCache(new SQLiteCache(_configuration.FileCacheSize))
/// #endif
/// ;
/// }
/// </code>
///
/// Another change has to be made. Due to the change to make caching asynchronous, SQLite cache Add method (<see cref="SQLiteCache.Add"/>) must be adjusted accordingly.
/// In the original method, there is a lock statement for concurrent access. This lock statement is made only for tileset insertion,
/// but not for the actual insertion of cached data. If you compare the method with the original one,
/// you can clearly see the change is just in changing the lock statement scope.
/// I changed the method to this:
/// <code>
/// public void Add(string tilesetName, CanonicalTileId tileId, CacheItem item, bool forceInsert = false)
/// {
/// ...
/// lock (_lock)
/// {
/// try
/// {
/// ...
/// }
/// catch (Exception ex)
/// {
/// ...
/// }
///
/// // update counter only when new tile gets inserted
/// ...
/// }
/// }
/// </code>
/// </para>
/// </summary>
/// <remarks>Tested/Built with Unity 2022.1.16f1</remarks>
public class ImprovedCachingWebFileSource : IFileSource, IDisposable
{
#region Private Fields
#if MAPBOX_DEBUG_CACHE
private string _className;
#endif
private string _accessToken;
private Func<string> _getMapsSkuToken;
private bool _autoRefreshCache;
private List<ICache> _caches = new List<ICache>();
private bool _disposed;
/// <summary>
/// Max limit of jobs running (<see cref="CacheAsyncRequestJob.CurrentlyDispatched"/>) that forces us to wait until other jobs finish before starting a new one.
/// </summary>
private const int CacheJobDispatchLimit = 6;
#endregion
#region Constructors
/// <summary>
/// Default constructor
/// </summary>
public ImprovedCachingWebFileSource(string accessToken, Func<string> getMapsSkuToken, bool autoRefreshCache)
{
#if MAPBOX_DEBUG_CACHE
_className = this.GetType().Name;
#endif
_accessToken = accessToken;
_getMapsSkuToken = getMapsSkuToken;
_autoRefreshCache = autoRefreshCache;
}
/// <summary>
/// Destructor
/// </summary>
~ImprovedCachingWebFileSource()
{
Dispose(false);
}
#endregion
#region Interface Implementation - IDisposable
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposeManagedResources)
{
if (!_disposed)
{
if (disposeManagedResources)
{
for (int i = 0; i < _caches.Count; i++)
{
IDisposable cache = _caches[i] as IDisposable;
if (null != cache)
{
cache.Dispose();
cache = null;
}
}
}
_disposed = true;
}
}
#endregion
#region Interface Implementation - IFileSource
/// <inheritdoc/>
public IAsyncRequest Request(
string uri
, Action<Response> callback
, int timeout = 10
, CanonicalTileId tileId = new CanonicalTileId()
, string tilesetId = null
)
{
if (string.IsNullOrEmpty(tilesetId))
{
throw new Exception("Cannot cache without a tileset id");
}
var result = new WebFileSourceRequest();
int coroutineId = Runnable.Run(DoRequestCoroutine(result, uri, callback, timeout, tileId, tilesetId));
result.SetRequestCoroutineId(coroutineId);
return result;
}
#endregion
#region Cache-Specific Methods
/// <summary>
/// Add an ICache instance
/// </summary>
/// <param name="cache">Implementation of ICache</param>
/// <returns>Return self for chaining.</returns>
public ImprovedCachingWebFileSource AddCache(ICache cache)
{
// Don't add cache when cache size is 0
if (0 == cache.MaxCacheSize)
return this;
_caches.Add(cache);
return this;
}
/// <summary>
/// Clear all caches
/// </summary>
public void Clear()
{
foreach (var cache in _caches)
cache.Clear();
}
public void ReInit()
{
foreach (var cache in _caches)
cache.ReInit();
}
#endregion
#region Request Implementation
/// <summary>
/// Coroutine for <see cref="Request"/> to perform the actual request asynchronously for the Unity engine.
/// </summary>
private IEnumerator DoRequestCoroutine(
WebFileSourceRequest resultRequest,
string uri,
Action<Response> callback,
int timeout = 10,
CanonicalTileId tileId = new CanonicalTileId(),
string tilesetId = null
)
{
CacheItem cachedItem = null;
// Wait here before starting a new job if the currently running cache jobs exceeded the max limit
//while (CacheAsyncRequestJob.CurrentlyDispatched > CacheJobDispatchLimit) yield return null;
// Try to find the data in cache first
var cacheGetJob = new CacheAsyncRequestJob();
cacheGetJob.StartGet(_caches, tileId, tilesetId);
// Wait here for the cache result till the next processing
while (!cacheGetJob.IsCompleted) yield return null;
cachedItem = cacheGetJob.ResultCacheItem;
#if MAPBOX_DEBUG_CACHE
string methodName = _className + "." + new System.Diagnostics.StackFrame().GetMethod().Name;
#endif
// if tile was available call callback with it, propagate to all other caches and check if a newer one is available
if (null != cachedItem)
{
#if MAPBOX_DEBUG_CACHE
UnityEngine.Debug.LogFormat("{0} {1} {2} {3}", methodName, tilesetId, tileId, null != cachedItem.Data ? cachedItem.Data.Length.ToString() : "cachedItem.Data is NULL");
#endif
// immediately return cached tile
callback(Response.FromCache(cachedItem.Data));
// check for updated tiles online if this is enabled in the settings
if (_autoRefreshCache)
{
string finalUrl = BuildRequestUrl(uri);
// check if tile on the web is newer than the one we already have locally
IAsyncRequestFactory.CreateRequest(
finalUrl,
(Response headerOnly) =>
{
// on error getting information from API just return. tile we have locally has already been returned above
if (headerOnly.HasError)
{
return;
}
// TODO: remove Debug.Log before PR
//UnityEngine.Debug.LogFormat(
// "{1}{0}cached:{2}{0}header:{3}"
// , Environment.NewLine
// , finalUrl
// , cachedItem.ETag
// , headerOnly.Headers["ETag"]
//);
// data from cache is the same as on the web:
// * tile has already been returned above
// * make sure all all other caches have it too, but don't force insert via cache.add(false)
// additional ETag empty check: for backwards compability with old caches
if (!string.IsNullOrEmpty(cachedItem.ETag) && cachedItem.ETag.Equals(headerOnly.Headers["ETag"]))
{
// Propagate the data to caches
var cacheAddJob = new CacheAsyncRequestJob();
cacheAddJob.StartAdd(_caches, tileId, tilesetId, cachedItem, false);
}
else
{
// TODO: remove Debug.Log before PR
UnityEngine.Debug.LogWarningFormat(
"updating cached tile {1} tilesetId:{2}{0}cached etag:{3}{0}remote etag:{4}{0}{5}"
, Environment.NewLine
, tileId
, tilesetId
, cachedItem.ETag
, headerOnly.Headers["ETag"]
, finalUrl
);
// request updated tile and pass callback to return new data to subscribers
RequestTileAndCache(finalUrl, tilesetId, tileId, timeout, callback);
}
}
, timeout
, HttpRequestType.Head
);
}
resultRequest.SetRequest(new MemoryCacheAsyncRequest(uri));
}
else
{
// requested tile is not in any of the caches yet, get it
#if MAPBOX_DEBUG_CACHE
UnityEngine.Debug.LogFormat("{0} {1} {2} not cached", methodName, tilesetId, tileId);
#endif
string finalUrl = BuildRequestUrl(uri);
resultRequest.SetRequest(RequestTileAndCache(finalUrl, tilesetId, tileId, timeout, callback));
}
}
/// <summary>
/// Perform the web API request and cache the data afterwards.
/// </summary>
private IAsyncRequest RequestTileAndCache(string url, string tilesetId, CanonicalTileId tileId, int timeout, Action<Response> callback)
{
return IAsyncRequestFactory.CreateRequest(
url,
(Response r) =>
{
// if the request was successful add tile to all caches
if (!r.HasError && null != r.Data)
{
string eTag = string.Empty;
DateTime? lastModified = null;
if (!r.Headers.ContainsKey("ETag"))
{
UnityEngine.Debug.LogWarningFormat("no 'ETag' header present in response for {0}", url);
}
else
{
eTag = r.Headers["ETag"];
}
// not all APIs populate 'Last-Modified' header
// don't log error if it's missing
if (r.Headers.ContainsKey("Last-Modified"))
{
lastModified = DateTime.ParseExact(r.Headers["Last-Modified"], "r", null);
}
// Propagate the data to caches
var cacheAddJob = new CacheAsyncRequestJob();
cacheAddJob.StartAdd(_caches, tileId, tilesetId, new CacheItem
{
Data = r.Data,
ETag = eTag,
LastModified = lastModified
}, true); // force insert/update
}
if (null != callback)
{
r.IsUpdate = true;
callback(r);
}
}, timeout);
}
/// <summary>
/// Build the request URL address
/// </summary>
private string BuildRequestUrl(string uri)
{
var uriBuilder = new UriBuilder(uri);
if (!string.IsNullOrEmpty(_accessToken))
{
string accessTokenQuery = "access_token=" + _accessToken;
string mapsSkuToken = "sku=" + _getMapsSkuToken();
if (uriBuilder.Query != null && uriBuilder.Query.Length > 1)
{
uriBuilder.Query = uriBuilder.Query.Substring(1) + "&" + accessTokenQuery + "&" + mapsSkuToken;
}
else
{
uriBuilder.Query = accessTokenQuery + "&" + mapsSkuToken;
}
}
return uriBuilder.ToString();
}
#endregion
#region Class: Cache Async Job
/// <summary>
/// Asynchronous job you can start and let it work,
/// while you can access this object and check
/// if it already finished and get the result out of it.
/// <para>For each request, use a new instance of this.</para>
/// </summary>
protected class CacheAsyncRequestJob
{
#region Properties
/// <summary>
/// Indicates the current number of running tasks of this class.
/// </summary>
public static int CurrentlyDispatched { get; private set; } = 0;
/// <summary>
/// Indicates if this job already started.
/// </summary>
public bool HasStarted { get; private set; }
/// <summary>
/// Indicates if the job already finished.
/// </summary>
public bool IsCompleted { get; private set; }
/// <summary>
/// Cache item that is the result of <see cref="StartGet"/> async job task.
/// </summary>
public CacheItem ResultCacheItem { get; private set; }
#endregion
/// <summary>
/// Starts the job asynchronously - to retrieve (get) item from any of the caches afterwards.
/// The result is put into the <see cref="ResultCacheItem"/> upon finish.
/// </summary>
/// <param name="caches">List of caches to use to search for the cache item.</param>
/// <param name="tileId">Tile identifier</param>
/// <param name="tilesetId">Tileset identifier</param>
public async void StartGet(IEnumerable<ICache> caches, CanonicalTileId tileId, string tilesetId)
{
if (HasStarted)
return;
HasStarted = true;
++CurrentlyDispatched;
await Task.Run(() =>
{
// Go through existing caches and check if we already have the requested tile available
foreach (var cache in caches)
{
ResultCacheItem = cache.Get(tilesetId, tileId);
if (null != ResultCacheItem)
break;
}
if (CurrentlyDispatched > 0) --CurrentlyDispatched;
IsCompleted = true;
});
}
/// <summary>
/// Start the job asynchronously - to propagate (add) data to caches.
/// </summary>
/// <param name="caches">List of caches to use to search for the cache item.</param>
/// <param name="tileId">Tile identifier</param>
/// <param name="tilesetId">Tileset identifier</param>
/// <param name="dataToAdd">Data to add to cache</param>
/// <param name="forceToAdd">Force modifier of the cache Add method to propagate in there as parameter.</param>
public async void StartAdd(IEnumerable<ICache> caches, CanonicalTileId tileId, string tilesetId, CacheItem dataToAdd, bool forceToAdd)
{
if (HasStarted)
return;
HasStarted = true;
++CurrentlyDispatched;
await Task.Run(() =>
{
// Propagate to all caches forcing update
foreach (var cache in caches)
cache.Add(tilesetId, tileId, dataToAdd, forceToAdd);
if (CurrentlyDispatched > 0) --CurrentlyDispatched;
IsCompleted = true;
});
}
}
#endregion
#region Class: Cache Request Wrapper
/// <summary>
/// Class request to return back as request result instead of real request
/// - cache does not really do any web request
/// - we need to return something, this class takes place for it
/// </summary>
class MemoryCacheAsyncRequest : IAsyncRequest
{
public string RequestUrl { get; private set; }
public MemoryCacheAsyncRequest(string requestUrl)
{
RequestUrl = requestUrl;
}
public bool IsCompleted => true;
public HttpRequestType RequestType => HttpRequestType.Get;
public void Cancel()
{
// Empty. We can't cancel an instantaneous response.
}
}
#endregion
#region Class: Result Request Wrapper
/// <summary>
/// Request result wrapper that is retrieved from <see cref="ImprovedCachingWebFileSource"/> class
/// as a result of request. But not all request that are produced in the class are the same and
/// may differ in their cancel procedure. We want to make sure to return back the correct request.
/// </summary>
class WebFileSourceRequest : IAsyncRequest
{
/// <summary>
/// The request we get from async call.
/// </summary>
private IAsyncRequest _request;
/// <summary>
/// ID of the coroutine that process the actual request.
/// </summary>
private int _requestCoroutineId;
public bool IsCompleted => _request?.IsCompleted ?? false;
public HttpRequestType RequestType => _request?.RequestType ?? HttpRequestType.Get;
public void Cancel()
{
_request?.Cancel();
Runnable.Stop(_requestCoroutineId);
}
/// <summary>
/// Update/Set request
/// </summary>
/// <param name="request">The request</param>
public void SetRequest(IAsyncRequest request)
{
_request = request;
}
/// <summary>
/// Update/Set coroutine that processes the request
/// </summary>
/// <param name="requestCoroutineId">The couroutine ID</param>
public void SetRequestCoroutineId(int requestCoroutineId)
{
_requestCoroutineId = requestCoroutineId;
}
}
#endregion
}
}
// @author Tomáš Frixs
using Assets.Scripts.Attributes.Inspector;
using Mapbox.Unity.Location;
using Mapbox.Unity.Map.Interfaces;
using UnityEngine;
namespace Assets.Scripts.Map.Player
{
/// <summary>
/// Controls the movement of the player object on the map.
/// </summary>
public class MapPlayerLocationController : MonoBehaviour
{
#region Private Fields
/// <summary>
/// Reference to the map the player object is displayed on.
/// </summary>
private IMap _map;
/// <summary>
/// Reference to location provider the player object is positioned based on.
/// </summary>
private ILocationProvider _locationProvider;
/// <summary>
/// Indicates once following is ready to go
/// </summary>
private bool _ready;
/// <summary>
/// State value of the currently performed position movement from <see cref="_currentStartPosition"/> to <see cref="_currentDestinationPosition"/>.
/// </summary>
private float _currentPositionTransition;
/// <summary>
/// State value of the currently performed rotation movement from <see cref="_currentStartRotation"/> to calculated from from <see cref="_currentDestinationPosition"/>.
/// </summary>
private float _currentRotationTransition;
/// <summary>
/// Defines currently selected destination the player object should move to.
/// </summary>
private Vector3 _currentDestinationPosition;
/// <summary>
/// Defines currently selected position from which the player is heading to <see cref="_currentDestinationPosition"/>.
/// </summary>
private Vector3 _currentStartPosition;
/// <summary>
/// Defines current player object rotation from which the player started to move to the its destination.
/// </summary>
private Quaternion _currentStartRotation;
/// <summary>
/// Defines timestamp at which location is updated from <seealso cref="ILocationProvider"/>
/// and at which <see cref="_currentStartPosition"/> and <see cref="_currentDestinationPosition"/> are updated accordingly.
/// </summary>
private double _locationLastUpdateTimestamp;
/// <summary>
/// Defines the speed of the movement from point to point.
/// </summary>
[Header("Movement Configuration")]
[SerializeField]
[Range(0.1f, 10f)]
private float _movementSpeed = 1f;
/// <summary>
/// The threshold indicating from what point the player is going to be teleported instead of letting him move.
/// </summary>
[SerializeField]
[Range(1f, 1000f)]
private float _teleportDistanceThreshold = 250f;
/// <summary>
/// Defines movement progress/smoothness from point to point
/// </summary>
[SerializeField]
private AnimationCurve _movementCurve;
/// <summary>
/// Indicates if the player object should be rotated based on the heading location.
/// </summary>
[Header("Rotation Configuration")]
[SerializeField]
private bool _rotateWithMovement = true;
/// <summary>
/// Defines the speed of the rotation change during player object movement to its destination.
/// </summary>
[SerializeField]
[SerializeFieldIf(nameof(_rotateWithMovement), true, SerializeFieldIfAttribute.ComparisonType.Equals)]
[Range(0.1f, 10f)]
private float _rotationSpeed = 5f;
#endregion
#region Unity Methods
private void Start()
{
_map = LocationProviderFactory.Instance.mapManager;
_locationProvider = LocationProviderFactory.Instance.DefaultLocationProvider;
if (_movementCurve == null)
Debug.LogError("Required components are missing!");
if (_map == null || _locationProvider == null)
{
Debug.LogError("Critical references are missing!");
return;
}
// Get us know once map is ready
_map.OnInitialized += () => _ready = true;
}
private void LateUpdate()
{
Transform();
}
#endregion
/// <summary>
/// Make the transform based on the location provider
/// </summary>
private void Transform()
{
if (!_ready)
return;
// If location provider updated location that provides...
if (!_locationProvider.CurrentLocation.Timestamp.Equals(_locationLastUpdateTimestamp))
{
_locationLastUpdateTimestamp = _locationProvider.CurrentLocation.Timestamp;
// Calculate new start/destination transform data
_currentDestinationPosition = _map.GeoToWorldPosition(_locationProvider.CurrentLocation.LatitudeLongitude, false);
_currentStartPosition = transform.position;
_currentStartRotation = transform.rotation;
// Reset the previous transitions to a fresh ones
_currentPositionTransition = 0f;
_currentRotationTransition = 0f;
}
// Current distance to destination
float currentDistance = Vector3.Distance(transform.position, _currentDestinationPosition);
// If distance is greater then specific threshold...
// ... to not let recalculate the position with non-sense distance
if (currentDistance > 0.1f)
{
// If we are able to move smoothly...
if (currentDistance < _teleportDistanceThreshold)
{
// Calculate the current movement transition state value
_currentPositionTransition = Mathf.MoveTowards(_currentPositionTransition, 1f, _movementSpeed * Time.deltaTime);
// Make the transform
transform.position = Vector3.Lerp(_currentStartPosition,
_currentDestinationPosition,
_movementCurve.Evaluate(_currentPositionTransition));
// If rotation is enabled...
if (_rotateWithMovement)
{
// Calculate the current movement transition state value
_currentRotationTransition = Mathf.MoveTowards(_currentRotationTransition, 1f, _rotationSpeed * Time.deltaTime);
// Make the transform
transform.rotation = Quaternion.Slerp(_currentStartRotation,
Quaternion.LookRotation((_currentDestinationPosition - _currentStartPosition).normalized),
_currentRotationTransition);
}
}
// Otherwise, the distance is too great...
else
{
// Teleport anyway
transform.position = _currentDestinationPosition;
}
}
}
}
}
@Frixs
Copy link
Author

Frixs commented Oct 16, 2022

Mapbox Unity SDK (v2.1.1) issue solving

ISSUES

  • FPS spikes (while changing tiles - e.g. camera transform in the world)
  • CameraBounds/QuadTree Tile provider (tries to load an enormous amount of tiles while looking at the horizon)
  • Map Update loop condition (Map tries to update even in the time the view to the map is not changing - e.g. camera is not moving)

FPS spikes

Caching / Vector loading

ImprovedCachingWebFileSource.cs works perfectly with MapImageFactory and it reduces FPS spikes. But when VectorTileFactory is attached to the AbstractMapVisualizer.Factories list, then I found an issue with Android deployment. The app freezes unexpectedly (I don't have knowledge about task scheduling in the SDK good enough yet).

Anyway, this tries to fix the issues that come with Mapbox v2.1.1 for location-based games. One of the issues is unexpected FPS spiking which I mentioned above. After a long time spent going through the Mapbox Unity SDK code, I made this class, that tries to solve it.
However, it does not help us with Vector-based maps. For this, I found a solution that is already solved in one of the dev branches of Mapbox (vector coroutine setting fix). This fixes Inspector GUI functionality with optimization via coroutines which you can set in Inspector of AbstractMap in Map Layers (Features). Setting up optimalisation via coroutines fixes the FPS spikes a lot and the class above is not needed anymore.

CameraBounds/QuadTree Tile provider fix

In this native provider provided by Mapbox, we can see the Options class for this provider contains fields that you can use to specify the max render distance. But these fields are not available in the inspector and also they are not even used in the code to limit the actual render distance. So, if you try to look at the horizon of the map, your Unity editor will most probably end up frozen.
I decided to make a temporary solution (CameraBoundsWithRangeTileProvider.cs, CameraBoundsWithRangeTileProviderOptions.cs) until the next version of Mapbox Unity SDK fix. I put together QuadTreeTileProvider and RangeAroundTransformTileProvider Tile provider which works the same way as the QuadTreeTileProvider but with the actual limitation of render distance that is made the same way as it is in the RangeAroundTransformTileProvider.

Map Update loop condition

If you decide to control your camera with an external/custom script, most probably you will end up updating the camera transform a lot. If you decide to use tile provider option CameraBounds which is QuadTreeTileProvider you can see simple condition in QuadTreeTileProvider.UpdateTileProvider() method that checks for camera transform change. Well, as I mentioned, if we take control of the camera transform by ourselves, most probably this condition will end up always true. So we need to make a custom check for this. I made this simple check - ImmediateTransformStateCheck.cs - and I already made use of it in CameraBoundsWithRangeTileProvider.cs.


I would like to share a simple script that gives your location-based game player object the ability to move smoothly around the map. The native script for moving the player object (ImmediatePositionWithLocationProvider) might not be the best fit for you. So you can check out this script - MapPlayerLocationController.cs.


What I would also recommend is adding one line of code to UnityTile that sets the loading texture before the game object activation of the tile. This will guarantee that the loading texture will be set always if the data are not loaded yet for any reason.
You can set this in UnityTile.Initialize() method right before gameObject.SetActive(true);.

MeshRenderer.sharedMaterial.mainTexture = _loadingTexture;

Edit:

If you get error due to missing SerializeFieldIf attribute, check out this response.

@asevillanom
Copy link

Hi Frixs! Thank you for your work. This sdk needs to be improved in order to be used in professional works. We are testing your mapplayerlocationcontroller.cs but some errors are shown in the console:

error CS0234: The type or namespace name 'Attributes' does not exist in the namespace 'Assets.Scripts' (are you missing an assembly reference?)
error CS0246: The type or namespace name 'SerializeFieldIfAttribute' could not be found (are you missing a using directive or an assembly reference?)
error CS0246: The type or namespace name 'SerializeFieldIf' could not be found (are you missing a using directive or an assembly reference?)
error CS0103: The name 'SerializeFieldIfAttribute' does not exist in the current context

@Frixs
Copy link
Author

Frixs commented Oct 17, 2022

Hi Frixs! Thank you for your work. This sdk needs to be improved in order to be used in professional works. We are testing your mapplayerlocationcontroller.cs but some errors are shown in the console:

error CS0234: The type or namespace name 'Attributes' does not exist in the namespace 'Assets.Scripts' (are you missing an assembly reference?) error CS0246: The type or namespace name 'SerializeFieldIfAttribute' could not be found (are you missing a using directive or an assembly reference?) error CS0246: The type or namespace name 'SerializeFieldIf' could not be found (are you missing a using directive or an assembly reference?) error CS0103: The name 'SerializeFieldIfAttribute' does not exist in the current context

Hello @asevillanom,

Yes, I can see the issue with this. I forgot to mention I use a custom attribute for collapsing fields in Inspector on condition. I am sorry for that.
You can simply remove this line of code or if you are interested in and you want to add the attribute, you can check it below. I made it based on the discussion mentioned in the code-documentation.

The first script (Drawer) has to be placed in the 'Editor' folder in your Assets folder for making Unity recognize it as a special script for Editor. The other script (Attribute), you can place in your general Scripts folder, e.g.

Drawer Source Code
using Assets.Scripts.Attributes.Inspector;
using System.Collections;
using UnityEditor;
using UnityEngine;

namespace Assets.Scripts.Editor
{
    /// <summary>
    ///     Based on: https://forum.unity.com/threads/draw-a-field-only-if-a-condition-is-met.448855/
    /// </summary>
    [CustomPropertyDrawer(typeof(SerializeFieldIfAttribute))]
    public class SerializeFieldIfPropertyDrawer : PropertyDrawer
    {
        #region Private Fields

        // Reference to the attribute on the property.
        private SerializeFieldIfAttribute _attr;

        // Field that is being compared.
        private SerializedProperty _comparedField;

        #endregion

        #region Overrides

        public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
        {
            if (!ShowMe(property) && _attr.DisablingTypeValue == SerializeFieldIfAttribute.DisablingType.DoNotSerialize)
            {
                return -EditorGUIUtility.standardVerticalSpacing;
            }
            else
            {
                if (property.propertyType == SerializedPropertyType.Generic)
                {
                    float totalHeight = 0.0f;

                    IEnumerator children = property.GetEnumerator();

                    while (children.MoveNext())
                    {
                        SerializedProperty child = children.Current as SerializedProperty;

                        GUIContent childLabel = new GUIContent(child.displayName);

                        totalHeight += EditorGUI.GetPropertyHeight(child, childLabel) + EditorGUIUtility.standardVerticalSpacing;
                    }

                    // Remove extra space at end, (we only want spaces between items)
                    totalHeight -= EditorGUIUtility.standardVerticalSpacing;

                    return totalHeight;
                }

                return EditorGUI.GetPropertyHeight(property, label);
            }
        }

        public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
        {
            // If the condition is met, simply draw the field.
            if (ShowMe(property))
            {
                // A Generic type means a custom class...
                if (property.propertyType == SerializedPropertyType.Generic)
                {
                    IEnumerator children = property.GetEnumerator();

                    Rect offsetPosition = position;

                    while (children.MoveNext())
                    {
                        SerializedProperty child = children.Current as SerializedProperty;

                        GUIContent childLabel = new GUIContent(child.displayName);

                        float childHeight = EditorGUI.GetPropertyHeight(child, childLabel);
                        offsetPosition.height = childHeight;

                        EditorGUI.PropertyField(offsetPosition, child, childLabel);

                        offsetPosition.y += childHeight + EditorGUIUtility.standardVerticalSpacing;
                    }
                }
                else
                {
                    EditorGUI.PropertyField(position, property, label);
                }

            } //...check if the disabling type is read only. If it is, draw it disabled
            else if (_attr.DisablingTypeValue == SerializeFieldIfAttribute.DisablingType.ReadOnly)
            {
                GUI.enabled = false;
                EditorGUI.PropertyField(position, property, label);
                GUI.enabled = true;
            }
        } 

        #endregion

        /// <summary>
        ///     Errors default to showing the property.
        /// </summary>
        private bool ShowMe(SerializedProperty property)
        {
            _attr = attribute as SerializeFieldIfAttribute;
            // Replace propertyname to the value from the parameter
            string path = property.propertyPath.Contains(".") ? System.IO.Path.ChangeExtension(property.propertyPath, _attr.ComparedPropertyName) : _attr.ComparedPropertyName;

            _comparedField = property.serializedObject.FindProperty(path);

            if (_comparedField == null)
            {
                Debug.LogError("Cannot find property with name: " + path);
                return true;
            }

            // Get the value & compare based on types
            switch (_attr.ComparisonTypeValue)
            { // Possible extend cases to support your own type
                //case "bool":
                //    return comparedField.boolValue.Equals(drawIf.ComparedValue);
                //case "Enum":
                //    return (comparedField.intValue & (int)drawIf.ComparedValue) == (int)drawIf.ComparedValue;

                case SerializeFieldIfAttribute.ComparisonType.Equals:
                    return _comparedField.boxedValue.Equals(_attr.ComparedValue);

                case SerializeFieldIfAttribute.ComparisonType.NotEqual:
                    return !_comparedField.boxedValue.Equals(_attr.ComparedValue);

                default:
                    Debug.LogError("Error: " + _comparedField.type + " is not supported of " + path);
                    return true;
            }
        }
    }
}
Attribute Source Code
using System;
using UnityEngine;

namespace Assets.Scripts.Attributes.Inspector
{
    /// <summary>
    ///     Draws the field/property ONLY if the compared property compared by the comparison type with the value of comparedValue returns true.
    /// </summary>
    [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = true)]
    public class SerializeFieldIfAttribute : PropertyAttribute
    {
        public string ComparedPropertyName { get; }
        public object ComparedValue { get; }
        public ComparisonType ComparisonTypeValue { get; }
        public DisablingType DisablingTypeValue { get; }

        /// <summary>
        ///     Only draws the field only if a condition is met.
        /// </summary>
        /// <param name="comparedPropertyName">The name of the property that is being compared (case sensitive).</param>
        /// <param name="comparedValue">The value the property is being compared to.</param>
        /// <param name="comparisonType">The type of comparison the values will be compared by.</param>
        /// <param name="disablingType">The type of disabling that should happen if the condition is NOT met.</param>
        public SerializeFieldIfAttribute(string comparedPropertyName, object comparedValue, ComparisonType comparisonType, DisablingType disablingType = DisablingType.DoNotSerialize)
        {
            ComparedPropertyName = comparedPropertyName;
            ComparedValue = comparedValue;
            ComparisonTypeValue = comparisonType;
            DisablingTypeValue = disablingType;
        }

        /// <summary>
        ///     Types of comparisons for <see cref="SerializeFieldIfAttribute"/>.
        /// </summary>
        public enum ComparisonType
        {
            Equals = 1,
            NotEqual = 2,
        }

        /// <summary>
        ///     Types of disabling methods for <see cref="SerializeFieldIfAttribute"/>.
        /// </summary>
        public enum DisablingType
        {
            ReadOnly = 1,
            DoNotSerialize = 2
        }
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment