Scenario
I'm building an interactive using 2d tiles from Open Street Maps. I have a shader that wraps tiles around a sphere but the tiles appear too tall near the poles and too short near the equator.
tiles near the equator are too short, making content look stretched horizontally
tiles near the north pole are too tall, making content look stretched vertically
My initial thought was to use Mathf.Sin
to shorten tiles near the poles (sin0) while expanding tiles near the equator (sin1). In theory the idea seems sound but in practice it's not going so well.
Question
How can I get my shader to adjust for necessary 2d-to-3d vertical tile adjustments ? I've been looking for some condensed shader code that takes care of everything in it's v2f function but I haven't had much luck.
some code
BaseTile->GetThetaPhiFromXY (core of what GetThetaPhiFromKey uses)
public static Vector2
GetThetaPhiFromXY(float x, float y, int zoomLevel, bool stretch = false)
{
Vector2 tilesXY = TileGroup.GetTilesXY(zoomLevel);
float theta = (x + 0.5f) / tilesXY.x * Mathf.PI * 2 - Mathf.PI / 2;
float phi = (y + 0.5f) / tilesXY.y * Mathf.PI;
// // stretch tiles vertically (poles are shorter, equator is taller) @jkr
// if (stretch == true)
// {
// float PI2 = Mathf.PI / 2;
// float sin = Mathf.Sin(phi);
// float phiSin = phi * sin;
// if (
// y > tilesXY.y / 2 - 1 // tiles north of equator @jkr
// )
// {
// // phi range 0-PI:bot2top
// // sin range 0-1:bot2mid, 1-0:mid2top
// float neuePhi = phi - PI2;
// phiSin = neuePhi * sin;
// // phiSin += PI2;
// phiSin = Mathf.PI;
// }
// Debug.Log($"{y} {phi} {sin}");
// phi = phiSin;
// }
return new Vector2(theta, phi);
}
TileBender.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/**
* this class helps with prepping the tiles for the TileBender shader
* @jkr
*/
public class TileBender : MonoBehaviour
{
void Start()
{
// SetUpTileLatLons(GetComponent<MeshFilter>().mesh, new TileKey(0, 0, 0));
}
private Vector2 GetThetaPhiOfVertex(TileKey tileKey, Vector2 uv)
{
// if (tileKey.x != 0) return Vector2.zero;
TileKey tileKey2 = tileKey;
tileKey2.x += 1;
tileKey2.y += 1;
Vector3 startPos = BaseTile.GetThetaPhiFromKey(tileKey, true);
Vector3 endPos = BaseTile.GetThetaPhiFromKey(tileKey2, true);
// todo: this can be done faster, but how ? @jkr
Vector3 diff2 = (endPos - startPos) / 2;
startPos -= diff2;
endPos -= diff2;
float theta = Mathf.Lerp(startPos.x, endPos.x, uv.x);
float phi = Mathf.Lerp(startPos.y, endPos.y, uv.y);
// Debug.Log($"{startPos.x}, {endPos.x}");
// Debug.Log($"{startPos.x} -- {endPos.x} -- {uv.x} -- {theta}");
return new Vector2(theta, phi);
}
public void SetUpTilePairs(Mesh mesh, TileKey tileKey)
{
Vector2[] uvs = mesh.uv;
Vector2[] thetaPhiPairs = new Vector2[uvs.Length];
for (int i = 0; i < thetaPhiPairs.Length; ++i)
{
thetaPhiPairs[i] = GetThetaPhiOfVertex(tileKey, uvs[i]);
}
mesh.uv2 = thetaPhiPairs;
}
}
TileBender.shader
Shader "Custom/TileBender" {
Properties{
_MainTex("Tex", 2D) = "" {}
_SphereCenter("SphereCenter", Vector) = (0, 0, 0, 1)
_EarthRadius("EarthRadius", Float) = 5
_ColorAlpha("TextureAlpha", Float) = 0.5
}
SubShader{
// Cull off // for doublesized texture @jkr todo: disable for prod
Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"}
ZWrite Off
// ZTest Off
Blend SrcAlpha OneMinusSrcAlpha
// Cull front
// LOD 100
Pass {
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata {
float2 uv : TEXCOORD0;
float2 thetaPhi : TEXCOORD1;
};
struct v2f
{
float4 pos : SV_POSITION;
float3 norm : NORMAL;
float2 uv : TEXCOORD0;
};
uniform float _EarthRadius;
float4 _SphereCenter;
v2f vert(appdata v)
{
v2f o;
float theta = v.thetaPhi.x;
float phi = v.thetaPhi.y;
float4 posOffsetWorld = float4(
_EarthRadius*sin(phi)*cos(theta),
_EarthRadius*-cos(phi),
_EarthRadius*sin(phi)*sin(theta),
0);
float4 posObj = mul(unity_WorldToObject,
posOffsetWorld + _SphereCenter);
o.pos = UnityObjectToClipPos(posObj);
o.uv = v.uv;
o.norm = mul(unity_WorldToObject, posOffsetWorld);
return o;
}
uniform fixed _TextureAlpha = 0.5;
sampler2D _MainTex;
float4 frag(v2f IN) : COLOR
{
fixed4 col = tex2D(_MainTex, IN.uv);
col[3] = _TextureAlpha; // transparency
return col;
}
ENDHLSL
}
}
FallBack "VertexLit"
}
** EDIT **
Based on @derHugo's comment I've started breaking down a formula from this wikipedia page about Web Mercator projection. I've only just started converting it to c# and here's the work-in-progress so far. I'll modify the edit as it progresses.
private void PointConversions() {
Vector3 pos = this.Camera.transform.position;
Vector3 rot = this.Camera.transform.rotation.eulerAngles;
// Debug.Log($"{rot.x} -- {rot.y}");
float lambda = rot.y; // longitude
if(pos.x > 0) lambda = (0 - (lambda - 360)) * -1;
lambda = lambda / 360 * PI;
float phi = rot.x; // latitude
if(pos.y < 0) phi = (0 - (phi - 360) * -1);
phi = phi / 360 * PI;
// Debug.Log($"{lambda} -- {phi}");
Vector2 lonLat = new Vector2(lambda, phi);
Vector2 pt3d = Point2DFromLonLat(lonLat);
Debug.Log(pt3d);
}
private Vector2 Point2DFromLonLat(Vector2 lonLat) {
float lon = lonLat.x;
float lat = lonLat.y;
float x = size / PI2;
x *= Mathf.Pow(zoomLevel, 2);
x *= lon + PI;
float y = size / PI2;
y *= Mathf.Pow(zoomLevel, 2);
float subY = Mathf.Tan((PI/4) + (lat/2));
y *= PI - Mathf.Log(subY);
// Debug.Log($"{x} -- {y}");
Vector2 point = Vector3.zero;
point.x = x;
point.y = y;
// point.z = earthRadius;
return point;
}
private Vector2 Point3dTo2D(Vector3 xyz) {
return Vector2.zero;
}
I am pretty sure that I've overthought this entire project. That said I've stripped things down to do find a major pain point which is finding an appropriate way to convert mercator tiles to equirectangular tiles on the fly.
the "magic" formula is the closest I've come to a Gudermannian Inverse
float phi = (float)Math.Atan(Math.Sinh(latRad *2));
It's not perfect because the original formula found does not require *2
so something else in my app must be wrong
It has been a while and I don't remember the exact piece that "solved" this issue. The afore mentioned Gudermannian Inverse was a crucial piece but here's my BaseTile class and associated shader include as well:
BaseTile.cs
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Unity.Collections;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.UI;
public class BaseTile : MonoBehaviour
{
protected TileKey _tileKey;
public static readonly IWebClient WebClient = new WebClient();
public static Vector3 scaleFactor = Vector3.one / 10;
//public static BaseTile PrefabFactory(GameObject prefab, Transform parent)
//{
// GameObject tileGameObject =
// UnityEngine.Object.Instantiate<GameObject>(prefab, parent);
// BaseTile baseTile = tileGameObject.GetComponent<BaseTile>();
// // baseTile.InitWithKey (tileKey);
// return baseTile;
//}
protected virtual void Awake() {}
protected virtual void Start() {}
protected virtual void OnDisable() {}
protected virtual void OnEnable() {}
protected virtual void Update() {}
private Renderer _renderer;
public virtual void InitWithKey(TileKey tileKey) // z is zoom level @jkr
{
if (this == null || this.gameObject == null) return;
this.TileKey = tileKey;
Vector2 tilesXY = TileGroup.GetTilesXY();
if (WeatherTracker.instance.renderMode == WeatherTracker.RenderMode.MODE_3D)
{
float earthRadiusAdjusted = WeatherTracker.instance.EarthRadius / 5;
float pi2 = Mathf.PI / 2;
scaleFactor.x = pi2 / tilesXY.x * earthRadiusAdjusted * 2f;
scaleFactor.y = pi2 / tilesXY.y * earthRadiusAdjusted;
scaleFactor.z = pi2 / tilesXY.x * earthRadiusAdjusted * 2f;
}
//this.transform.position = this.GetPosition();
//this.transform.localScale = this.GetScale();
//this.transform.rotation = this.GetRotation();
TileBender tileBender = this.GetComponent<TileBender>();
if (tileBender != null) tileBender.PrepShader(this);
}
public virtual void ReturnToPool()
{
try {
// Material mat = this.RendMat();
// // if(mat && mat.HasProperty("_MainTex"))
// Destroy(mat.mainTexture);
// Resources.UnloadAsset(mat);
} catch (UnityException) {}
this.gameObject.SetActive(false);
}
/**
* theta phi is lonRad latRad AFTER mercator > equirectangular conversion @jkr
*/
public static Vector2 GetThetaPhiFromXY(float x, float y, int zoomLevel)
{
Vector2 tilesXY = TileGroup.GetTilesXY(zoomLevel);
float latLen = 90; //84; // virtual latitude "radius" as merc > equi conversion eventually results to lat infiniti beyond a certain point @jkr
float adj = 0.5f; // offset tile position by half otherwise columns are off vertically @jkr
// adj = 0.0f; // todo: since theta phi refactor the half tile adjust hasn't worked @jkr
float lon = (x + adj) / tilesXY.x * 360 - 90; // could just go straight to lonRad @jkr
float lat = (y + adj) / tilesXY.y * (latLen * 2) - latLen;
return BaseTile.GetThetaPhiFromLatLon(lon, lat);
}
/**
* lat lon come in as angles
* @jkr
*/
public static Vector2 GetThetaPhiFromLatLon(float lon, float lat) {
float lonRad = lon * Mathf.Deg2Rad;
float latRad = lat * Mathf.Deg2Rad;
float theta = lonRad;
float phi = BaseTile.GudermannianInverse(latRad * 2); // todo: *2 should not be in there, it must be compensating for an error somewhere else @jkr
return new Vector2(theta, phi);
}
private static float GudermannianInverse(float latRad)
{
return (float) Math.Atan(Math.Sinh(latRad));
}
public static Vector3 GetPositionByKey(TileKey tileKey)
{
// if (key == null) key = this.TileKey;
if (
WeatherTracker.instance.renderMode ==
WeatherTracker.RenderMode.MODE_2D
)
{
Vector3 sf = BaseTile.scaleFactor;
Vector3 ret = Vector3.zero;
ret.x = tileKey.x * sf.x;
ret.y = tileKey.y * sf.y;
ret.z = 0;
return ret * 10;
// return new Vector3(this.TileKey.x, this.TileKey.y, 0) *
// BaseTile.scaleFactor *
// 10; // because tiles are 10x bigger than spheres @jkr
}
// Vector2 tilesXY = TileGroup.GetTilesXY(tileKey.z);
Vector3 pos = GetPositionByXY(tileKey.x, tileKey.y, tileKey.z);
return pos;
}
public static Vector3
GetPositionByXY(float tileX, float tileY, int zoomLevel, float plusRadius = 0)
{
var thetaPhi = BaseTile.GetThetaPhiFromXY(tileX, tileY, zoomLevel);
var pos = BaseTile.GetPositionByThetaPhi(thetaPhi, plusRadius);
return pos;
}
/**
* plusRadius is essentially additional distance from the earth @jkr
*/
public static Vector3 GetPositionByLatLon(Vector2 latLon, float plusRadius = 0) {
return BaseTile.GetPositionByLatLon(latLon.x, latLon.y, plusRadius);
}
public static Vector3 GetPositionByLatLon(float lat, float lon, float plusRadius = 0) {
lat *= Mathf.Deg2Rad;
lon *= Mathf.Deg2Rad;
lon += Mathf.PI/2; // additional quarter turn @jkr
var pos = BaseTile.GetPositionByThetaPhi(new Vector2(lon, lat), plusRadius);
return pos;
}
public static Vector3 GetPositionByThetaPhi(Vector2 thetaPhi, float plusRadius = 0) {
float theta = thetaPhi.x;
float phi = thetaPhi.y;
float r = WeatherTracker.instance.EarthRadius + plusRadius;
// https://en.wikipedia.org/wiki/Spherical_coordinate_system#
float x = r * Mathf.Cos(phi) * Mathf.Cos(theta);
float y = r * Mathf.Sin(phi);
float z = r * Mathf.Cos(phi) * Mathf.Sin(theta);
Vector3 pos = new Vector3(x, y, z);
return pos;
}
// position for 3d tiles; adding .5 to keys so the polar caps look correct @jkr
protected Vector3 GetPosition()
{
return BaseTile.GetPositionByKey(this.TileKey);
}
/**
* rotation for tiles. Does not effect the shader!
* @jkr
*/
protected Quaternion GetRotation()
{
if (
WeatherTracker.instance.renderMode ==
WeatherTracker.RenderMode.MODE_2D
)
{
return Quaternion.LookRotation(Vector3.up);
}
Vector3 pp = this.transform.position;
Vector3 pu = Vector3.up; // plane up
// if (WeatherTracker.instance.useOSMTiles == true) pu = Vector3.down;
Vector3 pr = Vector3.Cross(pp, pu); // plane right
Vector3 pf = Vector3.Cross(pr, pp); // plane forward - sometimes zero
Quaternion rotQuat = this.transform.rotation;
if (pf == Vector3.zero) return rotQuat;
return Quaternion.LookRotation(pf, pp);
}
// scaling for tiles @jkr
protected Vector3 GetScale()
{
if (
WeatherTracker.instance.renderMode ==
WeatherTracker.RenderMode.MODE_2D
)
{
return BaseTile.scaleFactor;
}
Vector2Int tilesXY = TileGroup.GetTilesXY(this.TileKey.z);
float keyY = this.TileKey.y + 0.5f;
float latRad = keyY / (tilesXY.y) * (Mathf.PI * 2) - Mathf.PI;
// Debug.Log($"{latRad} {keyY}");
float scaleX =
Mathf.Sin(keyY / tilesXY.y * Mathf.PI) * BaseTile.scaleFactor.x;
float scaleY = 1 * BaseTile.scaleFactor.y;
float scaleZ = 1 * BaseTile.scaleFactor.z;
scaleZ =
Mathf.Sin(keyY / tilesXY.y * Mathf.PI) * BaseTile.scaleFactor.z;
// BaseTile.GudermannianInverse(latRad) * BaseTile.scaleFactor.z;
// Debug.Log($"{scaleX},{scaleY},{scaleZ}");
return new Vector3(scaleX, scaleY, scaleZ);
}
public Material RendMat(GameObject go = null)
{
if(_renderer != null)
{
return _renderer.material;
}
else if(this == null || this.gameObject == null)
{
return null;
}
else
{
if(go == null)
{
go = this.gameObject;
}
//cache the renderer so we're not wasting time finding it
_renderer = go.GetComponent<Renderer>();
if(!_renderer) return null;
return _renderer.material;
}
}
public TileKey TileKey
{
get
{
return this._tileKey;
}
set
{
this.name = value.ToString();
this._tileKey = value;
}
}
public virtual void OnDestroy()
{
// // this.RendMat()mainTexture = null;
if (this && this.gameObject)
UnityEngine.Object.Destroy(this.gameObject);
if (this) UnityEngine.Object.Destroy(this);
}
/**
* sets texture alpha, to be used with fade-in fade-out of material textures @jkr
* @return returns the original alpha
* @author jkr
*/
//public float SetTextureAlpha(float alpha, bool fade = true)
//{
// Color origColor = Color.clear;
// Material rendMat = this.RendMat();
// if (rendMat == null) return origColor.a;
// if (RendMat().HasProperty("_Color")) origColor = this.RendMat().color;
// Color neueColor = origColor;
// if (fade == false)
// {
// neueColor.a = alpha;
// this.RendMat().color = neueColor;
// this.RendMat().SetFloat("_TextureAlpha", alpha);
// // yield return null;
// }
// return origColor.a;
// // yield return new WaitForSecondsRealtime(0.1f);
//}
public static async Task<Texture2D> DownloadTexture(string uri)
{
return await WebClient.HttpGET<Texture2D>(uri) ?? Texture2D.whiteTexture;
}
}
Shader .cginc
/**
// Upgrade NOTE: excluded shader from DX11; has structs without semantics (struct v2f members worldPos)
#pragma exclude_renderers d3d11
* this cginc helps bend shaders to a sphere
*/
#define PI2 1.5707963267948966192313216916398f
#define PI 3.1415926535897932384626433832795f
#define PIPI 6.283185307179586476925286766559f
#define QUARTER_PI 0.7853981634
struct appdata {
float4 pos: POSITION;
float2 uv : TEXCOORD0;
float4 normal : NORMAL;
};
struct appdata_tan_ {
float4 position: POSITION;
float2 uv : TEXCOORD1;
float4 normal : NORMAL;
float4 tangent : TANGENT;
};
struct v2f {
float4 pos : SV_POSITION;
float3 norm : NORMAL;
half2 uv : TEXCOORD0;
float3 worldPos : COLOR0;
// UNITY_FOG_COORDS(1)
};
float _EarthRadius;
// v2f vert(appdata v)
v2f vertBender(appdata v, float _EdgeUVEpsilon)
{
v2f o;
o.pos = UnityObjectToClipPos(v.pos);
o.uv = clamp(v.uv, _EdgeUVEpsilon, 1-_EdgeUVEpsilon);
o.norm = v.normal;
o.worldPos = mul(unity_ObjectToWorld, v.pos);
return o;
}
v2f vertBender(appdata_tan_ v, float _EdgeUVEpsilon)
{
v2f o;
o.pos = UnityObjectToClipPos(v.position);
o.uv = clamp(v.uv, _EdgeUVEpsilon, 1-_EdgeUVEpsilon);
o.norm = v.normal;
o.worldPos = mul(unity_ObjectToWorld, v.position);
return o;
}