庄河地铁 发表于 2026-4-16 13:39

基于TsreGeoProjection的TSRE瓦片斜切修复

本帖最后由 庄河地铁 于 2026-4-16 13:48 编辑

作者:庄河地铁(模拟火车中国站 ID 398222)
发布平台:模拟火车中国站(trainsimchina.com)

前言
这两天作者在折腾 TSRE5 的背景地图时,先后试了自带的 OSM Vector、OpenRailwayMap (ORM)和一些第三方瓦片服务。结果很统一:浏览器里打开本地代理地址看到的瓦片都是正常的,到了 TSRE5 里面却统一变成了一种像整体斜了、同时又有点被拉伸的样子。


浏览器访问ORM本地代理瓦片的效果


同一区域的ORM瓦片在TSRE中发生的斜切变换

1.原理与背景
1.1 为什么默认投影会产生斜切现象
根据Open Rails 的 TrackViewer 手册,默认的 MSTS/ORTS 投影是 Interrupted Goode Homolosine,它是一种等面积世界投影。它在赤道附近采用的那一段正弦曲线投影区域,以及各个分瓣的中央经线附近,形状和方向失真相对较小;对于东亚这类区域,默认投影下表现出更明显的斜切感,越靠近对应分瓣边缘,这种现象越明显。ArcGIS 对 Goode homolosine 的说明也提到:它是等面积投影,形状、方向、角度和距离一般都会失真;赤道和中央经线一带失真较小,靠近边缘形状失真会明显增大。

1.2 斜切现象的验证
为了排除地图源本身存在旋转或拉伸的问题,作者制作了带有明确方向标识的十字测试图,并将其作为背景图加载到 TSRE5 中。首先用 Python 3.14 设置本地代理(附录1),用chrome访问:

http://127.0.0.1:8010/debugmap?lat=38.92&lon=121.645&zoom=17&res=640

然后在 TSRE5 的 settings.txt 里加入一行:

imageMapsUrl = http://127.0.0.1:8010/debugmap?lat={lat}&lon={lon}&zoom={zoom}&res={res}

测试结果表明,测试图在浏览器中显示正常,而在 TSRE5 地块上则发生了统一的斜切变换。因此可以确认,问题不在图源,而在 TSRE5 所采用的路线坐标投影及其贴图转换过程。


测试用十字在本地代理的显示


测试用十字在tile中的显示

1.3 TsreGeoProjection (TGP) 如何解决斜切问题
TGP是围绕参考点建立的一套局部平面坐标。从 TSRE 作者对新投影“保住角度和正确形状”的说明,以及 GeoTsreCoordinateConverter 按 centerLat / centerLon 计算本地 stepLat / stepLon 的实现方式来看,它的思路就是:在参考点附近尽量像正常平面地图那样工作,离参考点越远,这种局部线性近似的误差就会逐步累积。默认投影是面向全球 tile 体系的,东亚局部看起来斜;新投影是面向参考点附近线路编辑的,参考点附近最正,离参考点越远,局部线性近似带来的误差会逐步增大。

后来作者去翻了 TSRE 作者 Goku 当年的帖子,发现这个问题其实早就有人意识到了。2017 年他在 Elvas Tower 发帖时就说过,因为越来越多人用 TSRE 新建线路,所以为了让 OSM map layers 的体验更好,他专门做了一套新的投影方案。这个方案不是某个显示小技巧,而是通过在线路的 .trk 文件里加入 TsreGeoProjection (...),直接改变整条线路的坐标解释方式。作者原话里提到,这个新投影会尽量保住角度和正确形状。TSRE5 的源码里也能对上:Route.cpp 在加载线路时会先读 .trk;如果发现 tsreProjection 存在,就改用 GeoTsreCoordinateConverter;如果没有这一行,才继续使用默认的 GeoMstsCoordinateConverter。

这个方法不是只对TSRE5 自带的 OSM Vector有效,对第三方瓦片源如ORM也一样生效。OSM Vector 的绘制和 Raster Image 的绘制,最后都要经过同一个 Game::GeoCoordConverter。MapDataOSM.cpp 里可以看到,OSM 数据在画到屏幕前,会先调用 ConvertToInternal() 和 ConvertToTile();而 MapDataUrlImage.cpp 里,静态图片底图在贴到地块前,也会先根据当前的 GeoCoordConverter 去算 tile 四角的经纬度。换句话说,只要路线改成 TGP,OSM Vector 和各种本地代理加载的背景图都会一起变正。

1.4 .trk 文件的定义和修改
.trk 文件就是一条线路的主配置文件。它通常就在 ROUTES\线路名\ 目录下面,文件名一般和线路名一致。TSRE5 在加载路线时会先读取这个文件。TsreGeoProjection (...) 这一行也是需要手动加在这里面。

实际操作时,建议先把整个线路文件夹完整备份一份。因为这一步改的是路线级别的投影设置,不是普通的界面选项。如果线路已经做了很多内容,投影一改,底图和既有内容的对应关系有可能整体变化。但如果线路还在起步阶段,只铺了少量地块,那么反而是代价最低的时候。TSRE 作者最初发布这个功能时,本身也是把它定位在更适合新建线路配合 OSM 使用的方向上。

1.5 TGP 的四个参数是什么
TsreGeoProjection ( 纬度 经度 内部X 内部Z ) 这四个参数的作用,是把同一个现实世界参考点和同一个 TSRE 内部位置绑定起来。前两个参数是这个参考点的真实经纬度,后两个参数则是这个参考点在 TSRE 世界中的内部坐标。GeoTsreCoordinateConverter 的实现里,首先会用 centerLat / centerLon 作为新的地理基准点,再根据这个基准点把经纬度差值换算成内部平面坐标;随后又用 centerX / centerZ 把这个平面坐标放到 TSRE 的 tile 世界里。换句话说,前两个参数解决的是参考点在现实的哪里,后两个参数解决的是这个点放到 TSRE 世界里的哪里。

这一点很重要,因为很多人会下意识地把后两个参数理解成随便写个大概值让地图不歪就行。这种做法虽然能暂时把地图拉正,但是很难保证以后不返工。最稳妥的办法,是先选定一个长期不会变的真实参考点,然后让它在新投影下仍然落到它按默认 MSTS 投影本来就应该在的位置。这样做的好处是线路制作者并非随手定义一个新的世界,而是在尽量保持与默认路线世界连续的前提下,切换到更适合地图显示的新投影。

2. 设置方法
本参考点的选择目标是在以后的线路制作中尽量不返工。这样的参考点需要满足两个条件。第一,一个TSRE线路制作周期可能可达数年,应该确保它不会发生在线路建设过程中拆除、位移。第二,它在地图上容易辨认,后续做布景时也方便反复验证。基于此,本文选择大连站南站房作为参考点,取经纬度 38.9215, 121.628。

通过经纬度计算TGP参数。使用Python 3.14运行附录2所示代码。按 TSRE5 源码中默认 MSTS 投影的前向变换公式,先将参考点经纬度 38.9215, 121.628 转换为 IGH 内部坐标,再换算为 MSTS 精确 tile 坐标,可得该点在默认投影下位于 tile -1112 14262,且其 tile 内位置约为 x = 0.277789、z = 0.644873。随后根据 GeoTsreCoordinateConverter 的关系式再根据 TSRE5 新投影的内部坐标公式:

centerX = 2048 * (tileX + x)
centerZ = 2048 * (tileZ + 1 - z)

可得到对应的内部坐标为 -2276807.0875 和 29209303.2995。因此,以大连站南站房作为地图参考点,写成TGP配置参数行:

TsreGeoProjection ( 38.9215 121.628 -2276807.0875 29209303.2995 )

这组数的意义是把现实世界中的大连站南站房,经由TGP 投影,把大连站对应回默认坐标系里原本的位置。 这样做的好处是,既能解决背景地图在 TSRE5 中整体斜拉扭曲的问题,又尽量保持与原有 tile 体系的连续性。

关闭 TSRE5,备份好整个线路文件夹,用记事本打开 ROUTES\线路名\线路名.trk。在 Tr_RouteFile (...) 这个大块里面,找一个合适位置加入这一配置参数行。保存。


配置参数行在 .trk 文件中的位置

3.验证与效果
为了验证此方法的有效性,重复了1.2节的本地代理十字测试。结果显示斜切的现象显著减轻。



这里以自带的 OSM Vector 为例验证。重新读取 OSM Vector 和第三方 Raster Image Z17底图。此时能明显看到斜切显著减轻,地图方向和形状与浏览器直接访问瓦片看到的正常状态接近。同时,大连站南站房图案在地块-1112 14262上的相对位置基本不变。在 TSRE5 里用地理坐标跳转到参考点。在导航窗口输入 38.9215 121.628,然后执行 Jump。这个点稳定地落到背景地图中的大连站南站房附近。验证ORM和一些其他第三方代理瓦片的问题也得到了相同的修复,篇幅有限就不放图片了。


设置TGP前的背景图(OSM Vector )


设置TGP后的背景图(OSM Vector )


在参考点执行Jump后摄像机的落点

4. 讨论
对于线路制作者遇到的在浏览器里地图正常,到了 TSRE5 里发生斜切的问题,应该先检查一下这条线路是不是还在用默认的 MSTS 老投影,或者TGP的投影参考点是否离所做的线路的经纬度差的太远。对刚起步的新线路来说,使用 TGP 可以从根本上解决问题。TGP配置参数是以本文示例线路为前提推导出的结果,不同线路不能直接照抄,应按各自线路的参考点经纬度重新计算。
还是要说一句实话:TGP不是万能的。它解决的是默认 MSTS 投影不适合 北向上 网络地图显示的问题,并通过一个参考点把新的局部投影稳定地落到线路上。它特别适合新建线路,或者像作者这样还没正式开工、只铺了少量地块的情况;如果一条线路已经做了很久,轨道、站场、地形都铺了很多,再去改路线投影,就要谨慎一些,因为底图和既有内容的对应关系会变化。此外,如果读者计划做很长的干线,参考点最好放在线路的大致中部,而不是一端。TSRE 新投影本质上是一个围绕参考点建立的局部平面近似,离参考点越近,地图通常越像正常平面;离参考点越远,误差会逐步增加,这也是它和默认 MSTS 全局投影的本质区别。

参考文献
GokuMK. New TSRE Map projection. Elvas Tower. Available at: https://www.elvastower.com/forums/index.php?%2Ftopic%2F31336-new-tsre-map-projection%2F=

GokuMK. TSRE5/Route.cpp at master. GitHub. Available at: https://github.com/GokuMK/TSRE5/blob/master/Route.cpp

GokuMK. TSRE5/Trk.cpp at master. GitHub. Available at: https://github.com/GokuMK/TSRE5/blob/master/Trk.cpp

GokuMK. TSRE5/GeoCoordinates.cpp at master. GitHub. Available at: https://github.com/GokuMK/TSRE5/blob/master/GeoCoordinates.cpp

Open Rails Team. ORTS TrackViewer Manual. Open Rails. Available at: https://openrails.org/files/ORTS_Trackviewer_manual.pdf

Esri. Goode homolosine—ArcGIS Pro | Documentation. Available at: https://pro.arcgis.com/en/pro-app/3.6/help/mapping/properties/goode-homolosine.htm

附录1:debugmap.py
from flask import Flask, request, Response
from PIL import Image, ImageDraw, ImageFont
from io import BytesIO

app = Flask(__name__)

@app.get("/debugmap")
def debugmap():
    res = int(float(request.args.get("res", "640")))
    lat = request.args.get("lat", "0")
    lon = request.args.get("lon", "0")
    zoom = request.args.get("zoom", "0")

    img = Image.new("RGB", (res, res), (240, 240, 240))
    d = ImageDraw.Draw(img)

    # border
    d.rectangle((0, 0, res-1, res-1), outline=(0, 0, 0), width=3)

    # crosshair
    cx, cy = res//2, res//2
    d.line((cx, 0, cx, res), fill=(255, 0, 0), width=3)
    d.line((0, cy, res, cy), fill=(255, 0, 0), width=3)

    # N arrow
    d.polygon([(cx, 30), (cx-12, 55), (cx+12, 55)], fill=(0, 80, 255))
    d.text((cx-10, 60), "N", fill=(0, 80, 255))

    # E arrow
    d.polygon([(res-30, cy), (res-55, cy-12), (res-55, cy+12)], fill=(0, 160, 0))
    d.text((res-80, cy+15), "E", fill=(0, 160, 0))

    # label
    d.text((10, 10), f"lat={lat} lon={lon} z={zoom} res={res}", fill=(0, 0, 0))
    d.text((10, res-25), "Top should be NORTH in browser", fill=(0, 0, 0))

    buf = BytesIO()
    img.save(buf, "PNG")
    return Response(buf.getvalue(), mimetype="image/png")

if __name__ == "__main__":
    app.run("127.0.0.1", 8010, debug=False)

附录2:tgp_calc.py
import math

# ===== TSRE5 / MSTS constants =====
R = 6370997.0
IMG_LEFT = -20015000.0
IMG_TOP = 8673000.0

PAR41 = math.radians(40.0 + 44.0/60.0 + 11.8/3600.0)
MER20 = math.radians(20.0)
MER40 = math.radians(40.0)
MER80 = math.radians(80.0)
MER100 = math.radians(100.0)

CENTER_LON = [
    math.radians(-100.0), math.radians(-100.0),
    math.radians(30.0),   math.radians(30.0),
    math.radians(-160.0), math.radians(-60.0),
    math.radians(-160.0), math.radians(-60.0),
    math.radians(20.0),   math.radians(140.0),
    math.radians(20.0),   math.radians(140.0)
]

SIN_REGIONS = {1, 3, 4, 5, 8, 9}

def adjust_lon(v):
    if abs(v) >= math.pi:
      return v - 2 * math.pi if v > 0 else v + 2 * math.pi
    return v

def pick_region(lat, lon):
    if lat >= PAR41:
      return 0 if lon <= -MER40 else 2
    elif lat >= 0:
      return 1 if lon <= -MER40 else 3
    elif lat >= -PAR41:
      if lon <= -MER100:
            return 4
      elif lon <= -MER20:
            return 5
      elif lon <= MER80:
            return 8
      else:
            return 9
    else:
      if lon <= -MER100:
            return 6
      elif lon <= -MER20:
            return 7
      elif lon <= MER80:
            return 10
      else:
            return 11

def calc_tgp(lat_deg, lon_deg):
    lat = math.radians(lat_deg)
    lon = math.radians(lon_deg)

    region = pick_region(lat, lon)
    lon0 = CENTER_LON
    dlon = adjust_lon(lon - lon0)

    # 默认 MSTS 投影:sinusoidal / mollweide 分支
    if region in SIN_REGIONS:
      y = lat
      x = lon0 + dlon * math.cos(lat)
    else:
      theta = lat
      c = math.pi * math.sin(lat)
      for _ in range(100):
            dt = -(theta + math.sin(theta) - c) / (1 + math.cos(theta))
            theta += dt
            if abs(dt) < 1e-11:
                break
      theta /= 2.0
      y = 1.4142135623731 * math.sin(theta) - 0.0528035274542 * (1 if lat >= 0 else -1)
      x = lon0 + 0.900316316158 * dlon * math.cos(theta)

    # IGH 内部坐标
    line = IMG_TOP - y * R
    sample = x * R - IMG_LEFT

    # 默认 MSTS tile 坐标
    tile_x_raw = sample / 2048.0
    tile_z_raw = line / 2048.0

    tile_x = math.floor(tile_x_raw) - 16384
    tile_z = 16384 - math.floor(tile_z_raw) - 1
    x_in_tile = tile_x_raw - math.floor(tile_x_raw)
    z_in_tile = tile_z_raw - math.floor(tile_z_raw)

    # 反推出 TsreGeoProjection 第三、第四参数
    center_x = 2048.0 * (tile_x + x_in_tile)
    center_z = 2048.0 * (tile_z + 1.0 - z_in_tile)

    print(f"默认投影下: tile {tile_x} {tile_z}")
    print(f"tile 内位置: x = {x_in_tile:.6f}, z = {z_in_tile:.6f}")
    print(f"TsreGeoProjection ( {lat_deg} {lon_deg} {center_x:.4f} {center_z:.4f} )")

# 把这里改成你自己的参考点经纬度
calc_tgp(38.9215, 121.628)

input("按回车退出...")

本文为作者在实际测试基础上整理完成,转载请注明作者与出处。

庄河地铁 发表于 2026-4-16 18:15

Water饮水机 发表于 2026-4-16 14:11
https://www.bilibili.com/video/BV1qvw5eAEGB/
之前在线路制作教程中有简要介绍过,在此感谢楼主的深入研 ...

感谢补充。我这边主要是基于自己实际的需求来测试做的整理,能和之前教程里的内容互为补充也挺好。

Water饮水机 发表于 2026-4-16 14:11

https://www.bilibili.com/video/BV1qvw5eAEGB/
之前在线路制作教程中有简要介绍过,在此感谢楼主的深入研究

China-哈尔 发表于 2026-4-18 18:05

精品,作者威武,这样制作线路后里程可以贴近真实了。
页: [1]
查看完整版本: 基于TsreGeoProjection的TSRE瓦片斜切修复