English · 简体中文
把 Ghost Racer Enhanced(BeamNG.drive 圈速 Ghost Mod,独立项目)记录的圈速遥测读出来,在浏览器里查看轨迹、通道曲线和多圈 Δt 对比。
单个 Go 二进制,零依赖,默认只监听 127.0.0.1。两种取数方式,可以同时用:
- 直接解析存档目录(默认):启动时扫描 BeamNG 用户目录下的
ghostReplays/,游戏里跑完点一下"重新扫描"就能看到新圈。不需要改 mod。 - 游戏内推送(端点已就绪):mod 里的"导出分析"按钮把选中的记录 POST 到
/api/import,服务写进自己的数据目录,和存档库并列显示。协议见docs/import-api.md。
从 Releases 下对应平台的单文件二进制,或者自己编译:
go build -o ghost-racer-telemetry . # Windows: GOOS=windows GOARCH=amd64 go build -o ghost-racer-telemetry.exe .
./ghost-racer-telemetry不带参数时会自己找 BeamNG 用户目录,然后打开 http://127.0.0.1:8777/。Windows 下依次试这些位置的 ghostReplays:
%LOCALAPPDATA%\BeamNG\BeamNG.drive\current\ ← 现在的安装是这个(current 是指向当前版本的联接)
%LOCALAPPDATA%\BeamNG\BeamNG.drive\<版本号>\ ← 没有 current 就挑版本号最大的
%LOCALAPPDATA%\BeamNG.drive\<版本号>\ ← 旧版安装
找不到或者想指定时:
./ghost-racer-telemetry -root "C:\Users\你\AppData\Local\BeamNG\BeamNG.drive\current"-root 给用户目录、给版本目录、或者直接给 ghostReplays 目录都行。找不到时会把试过的路径全打出来。
别把游戏目录填给
-data。-root是只读的游戏存档,-data是服务自己的可写导入目录——填错了记录照样能看,但那棵树会被当成服务自己的数据,删除不再受-allow-delete保护,点一下就真删了游戏里的记录。填错时启动会有警告。
| 参数 | 默认值 | 说明 |
|---|---|---|
-addr |
127.0.0.1:8777 |
监听地址。留在回环地址上,除非确实要让别的机器访问 |
-root |
自动探测 | BeamNG 用户目录或其中的 ghostReplays 目录,只读 |
-data |
用户配置目录下 bng_ghost_web/ghostReplays |
游戏内导出的记录写到这里,可写 |
-web |
内嵌 | 从磁盘目录提供前端,改前端时不用重新编译 |
-allow-delete |
false |
允许删除游戏存档里的记录。删之前先关掉 BeamNG,否则它会把内存里的清单再写回去 |
-token |
空 | 给 /api/import 加一个共享口令(X-Ghost-Token 头或 ?token=) |
-game |
自动探测 | BeamNG 安装目录(不是用户目录),用来读关卡里的道路几何 |
- 左栏:按
地图 → 起点 → 赛道变体分层列出,和游戏里的层级一致,可逐层折叠,展开状态记在浏览器里。地图超过 6 个时默认收起(74 个库全展开没法看),当前打开的那条会自动展开并滚到可见处;单一变体的起点直接显示为叶子,不多套一层。P2P是点对点,TT是 Time Trial,导入是游戏内推上来的。搜索时全部展开。 - 记录列表:按圈速/名次/录制顺序排序,可按完整圈 / 未完成 / 手动录制和车型筛选。名次只算有计时的完整圈,和 mod 里的分类口径一致。每行可以导出 CSV 或删除。
- 轨迹图:俯视轨迹,着色可切换速度、油门/刹车、档位、横向 G、Δt 对比或按记录配色。起点门画成绿色虚线,终点门(点对点)画成黄色。
- 曲线区:速度、Δt、油门/刹车、横向 G、纵向加速度、档位、海拔、坡度。鼠标在轨迹图或任意曲线上移动,所有图共用一个游标,顶部读数栏同步显示每条记录在该位置的数值。
- 汇总表:圈速、距离、最高/平均/最低速、最大加减速、最大横向 G、全油门/刹车/滑行占比、爬升、最陡上坡/下坡、采样点数、车型。
点轨迹图右上角的 RD,会把游戏关卡里真实的道路画在轨迹下面——灰色路面加边界线,一眼看出车在路上的位置、压没压线。
道路直接从游戏安装目录的关卡存档里读:content/levels/<level>.zip 内的 levels/<level>/main/MissionGroup/**/items.level.json,按行的 JSON 对象,取其中的 DecalRoad 和 MeshRoad:
每个节点自带宽度,所以路沿是把中线沿法线偏移半个宽度算出来的真实边界,不是估的。坐标和遥测同一个世界系,直接叠。
- 安装目录自动探测:读 Steam 的
libraryfolders.vdf,所以装在别的盘也能找到;也可以用-game手动指定 - 按
drivability过滤,不按材质。DecalRoad不是"路"这个类——人行道、停车位标线、路缘、地面裂纹、建筑周围的水泥裙边全是 DecalRoad,全画出来是一张镇子的平面图而不是赛道。游戏自己的判据是 drivability:BeamNG 的地图制作指南让作者复制一条路、把材质改成road_invisible、drivability 设成 1,路才会出现在游戏内 minimap 上——也就是说 minimap 画的就是这层"故意隐形但可驾驶"的 AI 路网。所以隐形材质是"这是真路"的标志,反而要保留;该丢的是那些画上去的装饰贴花。 - 路面画成一个整体:逐段梯形 + 每个节点一个圆盘,全部累积进一条路径一次填充(这是 BeamNG 导航 App 自己的画法:它对 navgraph 的每条 link 画梯形并在两端各加一个圆)。只在每条路的两端加圆是不够的——弯折处和路与路的接头都会裂。圆盘和梯形必须同向缠绕,否则 nonzero 规则会让它们互相抵消,在路中间挖出一排缺口。
- 关卡里真的没有路面数据时,用你自己的轨迹补:有些桥(比如 hirochi_raceway 的)在 items、prefab.json、Torque prefab 里都查不到任何路面几何——桥面就是静态模型,没有解析器能变出来。但车从上面开过去了,轨迹本身就是那里有路的证据。所以参照圈下方没有路面覆盖的连续区段,会按周围道路的平均宽度补一条走廊。只在确实缺数据的地方触发:路网完整时补 0 段,人为挖掉一条路后精确识别出那一段并补上。
- 端口自动焊接:短缝(路宽 + 4 米以内)按距离直接接上;长缝最远到 60 米,但必须两端互相指向对方才接——桥面是独立对象,两侧的路网可能差几十米,但它是顺着自己的方向停下的,横穿的路不会。两种判据都带高差限制(
2.5 + 缝长 × 0.15米),所以立交桥永远不会被粘到下面的路上。 - Prefab 里的路也读,两种格式都读:桥常常是预制件。新的是
.prefab.json(整篇 JSON 而不是按行对象,所以按行解析失败时会退回整篇递归遍历);旧的是 TorqueScript 文本.prefab——hirochi_raceway 有 8 个 JSON、56 个 Torque,桥就在后者里。Torque 的形状是new DecalRoad(name) { Material = "…"; drivability = "1"; Node = "x y z width"; };,字段名大小写跨版本会变、对象会嵌套,所以解析器按大小写不敏感匹配并跟踪花括号深度,不假设结构。 - 缓存以「格式版本 + 构建版本」为键:只按存档大小和时间校验是不够的——存档没变,但解析器变了,旧缓存会让新增的解析完全不生效(MeshRoad 和 prefab 两次都栽在这上面:手动升版本号是会忘的)。加上构建版本后,换了二进制必然重新解析。
?stats=1里的files会报告实际扫了哪些文件:items、prefabJSON、prefabTorque,以及prefabRoads(从 prefab 里读出来的路数)。- 桥和高架也读:那些是
MeshRoad(节点比 DecalRoad 多一个深度值,前四个数含义相同),而且往往自身没有 drivability——按 drivability 过滤会恰好在桥这里把路面切断,所以 MeshRoad 不走 drivability 判据 ?visible=1可以反过来只要可见材质,?mindriv=可以调阈值;?stats=1会列出该关卡的材质分布(条数、节点数、可驾驶数、中位宽度),用来核对过滤规则- 只取圈的包围盒附近的路(外扩 300 米),不会把整张地图的路都塞过来
- 解析结果缓存在
<data>/roads/<level>.json,按存档大小和修改时间校验,游戏更新会自动重新解析;游戏卸载后缓存仍然可用 - 900 MB 的关卡存档不会被整个解压,只读里面那些几十 KB 的
items.level.json
高度是被用起来的,不只是存着。 每个采样点都有世界高度 z:距离按 3D 累加(爬坡段的里程不会被低估);服务端从中导出坡度通道(%,按 10 米的行进距离取窗口——按采样点差分测到的是悬挂不是路面);轨迹图可以按海拔或坡度着色;3D 倾斜视角下轨迹会按真实高度抬起,爬坡就是坡。相对高度做了 2.5× 的视觉放大,否则 2 公里的圈上几米落差只有一两个像素——高度数据是真的,强调是人为的。
- 界面语言中英文可切(首次按浏览器语言自动选),主题有自动 / 浅色 / 深色三档(自动跟随系统),选择记在浏览器里。
两边独立缩放,互不干扰:
- 轨迹图:滚轮以指针为中心缩放(最大 40×),放大后拖动平移,双击或右上角 1:1 还原。缩放会钉住指针下的那个点,所以对准一个弯滚就是放大那个弯。
- 曲线区:滚轮缩放 X 轴窗口(最小到全程的 1/500),拖动平移,双击还原;六张图共用同一个窗口,游标和读数跟着走。Y 轴只按窗口内的数据定标——放大一个弯时,速度曲线会真的把那段展开,而不是贴在整圈的量程上压成一条平线。顶栏会出现
全程 · 900–1500 m的复位按钮。
右键拖动或 Ctrl + 拖动框选一段:在曲线上拖是选 X 轴区间,在轨迹图上拖是拉一个框,框住的那段赛道会换算成对应的区间。选中的区间在曲线上打底色;在轨迹图上只加粗、两端加一道垂直标记,不改变颜色——那段的速度/油门/档位着色正是你要看的东西,盖掉就没意义了。
顶栏的播放条可以在这段里回放:▶ 播放(空格也行)、⟲ 循环、倍速 0.25× ~ 4×、✕ 清除选区。回放时游标不受悬停干扰,和锁定一样;没有选区就播整圈。
回放是真的并排跑,不是叠在一起。 各条记录经过区间起点的时刻本来就差好几秒(同一个 700 米点,三条记录分别在 19.185 / 19.932 / 20.603 秒到达),所以回放会先按各自的进入时刻对齐到同一个出发点,再让它们按各自的真实用时跑——快的会一点点拉开。上面那个例子跑 9 秒后,最快的已经领先最慢的 40 米。暂停时保持拉开的画面不收回。
拖动游标时则相反:所有记录读同一个位置(同一点对比"这里各自在干什么")。手动移动游标就会从"比赛"切回"对比"。
轨迹图右下角是竖条仪表:每条记录一组,T 油门(绿)、B 刹车(红)、H 手刹(黄),下面是当前档位,顶上的色条标明是哪条记录(最多显示 4 条,只显示带输入数据的记录)。速度色标仍在左下角,位置不变。
轨迹图上的悬停不会改变任何东西,只有点击才设定游标。 游标驱动着曲线、仪表和读数,而地图右上角就是 RD / N / 3D 那几个按钮——指针每次跨过地图去点它们,正在看的位置就被沿途的轨迹抢走了,看起来就是点追着鼠标跑。现在悬停时指针变成手型,表示点这里会选中;点击才把游标移过去并自动锁定;点空白处不做任何事。
点击可以锁定游标。 游标默认跟着鼠标走,所以把鼠标从曲线移到轨迹图(去缩放、去看某个弯)时,轨迹图的悬停会立刻把游标改到指针附近的点,原来那个位置就丢了。在曲线或轨迹图上点一下即可把当前位置钉住:钉住后任何悬停都动不了它,你可以放心去另一侧缩放、平移、再回来,读数栏一直停在那个点。锁定时游标线变成实心橙色,轨迹图上的点带一圈橙色光晕;读数栏会出现 📌 已锁定 徽章——点徽章把地图移到锁定点,点 ✕ 或按 Esc 释放。再点别处就是改钉到新位置。分段表也一样:悬停是预览,点击是锁定。
上面的记录列表和下面的汇总/分段都可以折叠(标题左侧的小三角),空间全部让给轨迹图和曲线;折叠状态记在浏览器里,下次打开保持。
轨迹图可以切成行进方向朝上(右上角 N / ▲),像手机导航那样:当前点固定在水平居中、垂直 78% 的位置——绝大部分画面留给前方的路,身后留一点点,刚出的弯还看得见。这个模式下滚轮绕车缩放、拖动被禁用,所以车永远钉在那个位置不会漂走。
再点 3D 可以倾斜视角:这是真的透视除法(相机俯角 55°),远处的路向地平线收敛并变窄,不是把画面压扁。相机后方的地面直接剔除。选择都记在浏览器里。
朝向取自轨迹切线而不是车头朝向——这是防抖的关键。漂移时车身横着、方向盘来回打,车头朝向会剧烈摆动,但行进方向是平稳的。切线按距离取窗口(前后各 12 米,退化时最大放宽到 40 米),所以低速、停车、原地打转都不会把角度算成噪声;实在退化就保持上一帧的朝向,位置照样跟。
实测对比(构造的漂移/打转数据,采样步长 2 个点):
| 轨迹切线(本实现) | 车头朝向 | |
|---|---|---|
| 漂移(车身横着、方向盘来回打) | 最大步进 0.54°,0 次反向 | 14.56°,18 次反向 |
| 原地打转(位置冻结,车头转两圈) | 14.77°,0 次反向 | 116°,2 次反向 |
漂移时地图基本不动;用车头朝向会来回抖十几次。正常跑圈时全程最大步进 0.92°(每 2 米采一次)。
放大后轨迹图会跟着游标走。 在右侧曲线上移动游标(或把鼠标放到分段表的某一行)时,地图会平移把对应的点保持在正中。最早写成「推回安全框边缘就停」,想的是别让地图一直爬动——但放大之后那样读起来就是坏的:第一次修正之后点一直挂在框边,再也回不到中间。游标本身是平滑移动的,所以持续居中也是平滑的。实测 23× 放大下扫完整圈,锚点偏离中心始终为 0 像素;手动把地图拖偏之后,一次游标移动就拉回中心。在地图上直接悬停、或正在拖动时不跟随,免得和你的操作打架。
切换距离/时间轴会清掉窗口(米的范围换算不到秒上)。放大时轨迹图会剔除视口外的线段,所以越放大画得越快。
选中两条以上记录时,「分段」页会把共同路线切成 10 米一格,逐格比谁用时最短,那一格就归谁。连续归属同一条记录的格子合成一个区间,就是这条记录的最佳表现段。
- 理论最佳圈:把每格的最快用时加起来。和实际最快圈的差,就是"同样的开法还能快多少"。
- 区间表:
区间 / 最快 / 速率 / 峰值 / 累计领先 / 相对参照圈,按速率排序——排在最上面的就是最该去练的那一段。鼠标划过预览,点一下这一行就把这段设为选区(地图加粗高亮、曲线打底色),并自动把地图居中放大到这一段(占画面 75%,四周留余量看进出弯);行尾的 ▶ 直接选中并回放。也就是说"哪儿丢的时间"和"把它放出来看"是同一个动作。 - 轨迹着色选「分段归属」:赢下的段用该记录的颜色,其余画灰。Δt 图顶部也有一条同样的归属带。
三个实现上的取舍:
-
按参照圈的路径取里程,不按各自跑过的距离。 用自己的里程会系统性地冤枉走外线的圈——跑了 500 米时它其实还没到参照圈的 500 米处,却被拿去和那里比。实测一条外扩 3 米的线自己跑了 2306.3 米,投影到参照路径上是 2289.7 米,正好等于参照圈长度,这 16.6 米的偏差就是不做投影时的误差。
-
比的是每格净耗时,不是累计时间。 累计时间(Δt 曲线本身)会把上游的优势一路背下去,看着像整圈都在赢;只有格内耗时才回答"这一段单独看谁快"。Δt 曲线的斜率是等价的信息。
-
排序看的是速率,不是总量。 总领先量和区间长度成正比:一段 770 米的温和优势累计 2.15 秒,一段 430 米的凌厉优势只累计 1.43 秒,但后者的 s/100m 更高,真正的差距在那儿拉开。所以表格按速率(每 100 米领先多少秒)排序,同时给出峰值速率(区间内最陡的那一格),这样长区间里藏着的短促爆发也跑不掉;
累计领先仍然显示,因为那才是最终落到圈速上的数字。 -
噪声要清掉。 夹在两段同主之间、短于 3 格的归属判为抖动,并入邻段;累计领先不到 0.02 秒或速率不到 0.01 s/100m 的区间不进表——只靠总量过滤会放过"很长但很平"的段,同时误杀"很短但很猛"的段。
区间仍然按归属(谁最快)切分,所以一段里可能既有猛的部分也有平的部分——峰值速率就是用来暴露这一点的。
未完成片段按它实际跑到的地方参与:只在覆盖到的格子里竞争,不会把整个对比范围截短,也不参与理论最佳圈的总时对比。同一起点下不同车型的记录会一起参与归属,需要时用车型筛选分开看。
X 轴可以切距离或时间。 默认是距离——多圈对比按"从起点跑了多远"对齐才有意义,Δt 也是这么算的:把参照圈的时间插值到当前圈每个采样点所在的距离上,两者相减。所以未完成的片段会在它停下的地方结束,不会污染对比。
地址栏的 hash 记录了当前视图(#lib=…&laps=1,3&ref=1&axis=dist&color=speed),可以直接存成书签或发给别人。
服务读的是 mod 写在用户目录下的这些文件(只读,除非开了 -allow-delete):
ghostReplays/freeRoam/<level>/startLines.json 起点注册表(名字、位置、终点门、变体分组 startKey)
ghostReplays/freeRoam/<level>/starts/<startId>/ghostracer.save.library.json 圈清单
ghostReplays/freeRoam/<level>/starts/<startId>/ghostracer.save.ghosts/<id>.json 样本
ghostReplays/races/<level>/<raceKey>/ghostracer.save.* Time Trial / 比赛
ghostReplays/freeRoam/<level>/<vehicleDir>/... 2.9.8 之前的旧布局,同样能读
样本是紧凑数组,下标固定:
1=t 2..4=位置 xyz 5..7=前向量 8..10=上向量 11=速度(m/s)
12=油门 13=刹车 14=档位 15=手刹 16=离合 ← 2.18 起才有
原版 1.6 的对象格式({pos, dirFront, dirUp, speed},没有时间戳)也能读,时间戳按 sampleInterval 推算。横向 G 和纵向加速度是服务端从位置/速度/前向量算出来的,mod 本身不记录这两个通道。
| 方法 | 路径 | 说明 |
|---|---|---|
GET |
/api/catalog |
缓存的记录库目录 |
POST |
/api/rescan |
重新扫描磁盘 |
GET |
/api/laps?lib=<key> |
一个库里的所有圈(元数据) |
GET |
/api/lap?lib=<key>&id=<id> |
单圈的完整通道和汇总 |
DELETE |
/api/lap?lib=<key>&id=<id> |
删除一圈(受 -allow-delete 约束) |
GET |
/api/roads?level=<level>&minx=…&miny=…&maxx=…&maxy=… |
该范围内的道路几何(stats=1 材质分布 · visible=1 只要可见材质 · mindriv= 阈值) |
POST |
/api/import |
接收游戏内导出的记录,见 docs/import-api.md |
lib 的 key 形如 game:freeRoam/east_coast_usa/starts/s001/ghostracer.save.json,前缀是数据源(game / import)。
./tools/check.sh # gofmt + vet + test + 前端语法 + UI 冒烟 + 双平台编译
./tools/build.sh v0.1.0 # 打 release 产物到 dist/(4 个平台 + SHA256SUMS)
./tools/smoke.sh # 单跑冒烟:无头 Chrome 加载真实页面(含道路叠加),有 JS 报错就失败
go run ./cmd/genfixture -out /tmp/gr/ghostReplays # 造一份假的存档树
go run ./cmd/genfixture -out /tmp/gr/ghostReplays -size 6 # 加大圈长(约 9500 采样点/圈)用来压渲染
go run . -root /tmp/gr/ghostReplays -web ./web # 前端改完刷新即可,不用重编译轨迹和曲线分成静态层和游标层:轨迹、网格、坐标和曲线画进离屏 canvas,键由"选了哪些圈 / 尺寸 / 着色模式 / X 轴"决定;鼠标移动只做一次 drawImage 加十字线和圆点。轨迹按屏幕像素抽稀并把颜色量化成 32 档,同色连成一条 path 一次描边;曲线在采样点多于像素列时按列取 min/max,既不丢刹车尖峰也不按行遍历。
实测(无头 Chrome 软件渲染,5 圈 / 45909 采样点):游标重绘 0.60 ms,同样内容不走缓存是 191 ms。
MIT,见 LICENSE。产生这些记录的 BeamNG.drive Mod 是独立项目,用它自己的许可证。
{"class":"DecalRoad","material":"road_asphalt_2lane","drivability":1, "nodes":[[x, y, z, width], ...]}