使用Rust解析Godot .tres文件并遍历资源图
摘要
本文详细介绍了在Rust中为Asset Hoard资产管理器实现.tres文件解析和资源图遍历的过程,支持Godot项目的外部依赖解析和拖放导出。
<p><a href="https://lobste.rs/s/7qxxww/using_rust_parse_godot_tres_files_walk">评论</a></p>
查看缓存全文
缓存时间: 2026/05/16 09:10
# 解析 Godot .tres 文件并遍历资源图
来源:https://assethoard.com/blog/parsing-godot-tres-files
Asset Hoard 库展示 Godot .tres 资源——材质球、精灵帧动画和瓦片集——右侧打开了一个 SpriteFrames 预览面板(https://assethoard.com/releases/assethoard-v0.1.13.png)
一个 `.tres` 文件看起来人畜无害。用文本编辑器打开它,你会得到接近 INI 格式的东西:一个头部、几个区块、一些键值对。很小,可读。肯定只需要几个正则表达式就能解析。这种印象大概能维持五分钟。Godot 资源文件是一种自定义格式,它通过路径、UID(有时两者同时使用)来引用其他资源。一个 `StandardMaterial3D.tres` 是一个小型文本文件,指向存放在别处的纹理。仅将 `.tres` 文件移到另一个项目,材质就会在另一端失效。纹理找不到,材质回退为品红色。你的资产,实际上,毫无用处。
这对于外部资产管理器来说是个问题。Asset Hoard 的全部意义在于:你在一个地方找到东西,然后在别处使用它。如果“在别处使用”只适用于自包含文件,那么 Godot 项目中一半的资源都会被排除在外。
因此,v0.1.13(https://assethoard.com/releases/0.1.13)提供了完善的 `.tres` 支持。材质、ShaderMaterial、SpriteFrames 和 TileSet 都能获得真正的预览,而不是通用图标。更重要的是,从 Asset Hoard 拖出 `.tres` 文件时,现在会遍历其引用图,并拉取所有关联文件,在放置位置重建 `res://` 文件夹布局。将结果拖入 Godot 项目,它就能直接工作。
这篇文章详细介绍了构建过程:词法分析器、解析器、资源解析、渲染以及拖出的行为。每一层都有尖锐的边缘。
## .tres 的 Godot 格式
在解析任何内容之前,你必须了解你正在解析什么。`.tres` 格式基于文本,乍一看简单得令人迷惑,实际上充满了陷阱。一个最小示例:
```
[gd_resource type="StandardMaterial3D" load_steps=3 format=3 uid="uid://abc123"]
[ext_resource type="Texture2D" uid="uid://def456" path="res://textures/stone_albedo.png" id="1_albedo"]
[ext_resource type="Texture2D" uid="uid://ghi789" path="res://textures/stone_normal.png" id="2_normal"]
[resource]
albedo_texture = ExtResource("1_albedo")
normal_enabled = true
normal_texture = ExtResource("2_normal")
```
结构是方括号内的区块,每个区块有一个类型和一些属性,后面跟着键值对。`[ext_resource]` 声明外部依赖。`[resource]` 是主资源定义。值可以是原始类型(数字、字符串、布尔值)或构造函数调用(`ExtResource(...)`,`Color(0.5, 0.5, 0.5, 1)`,`Vector3(0,1,0)`)。
但这是友好版本。实际项目中的真实文件更密集,解析器将工作分成了两部分。`handlers/tres.rs` 中的结构遍历读取 `[gd_resource]` 头部、每行 `[ext_resource]` 以及每个 `[sub_resource]` 区块,因为这种语法在不同资源类型间是统一的。然后 `[resource]` 块被交给特定类型的体解析器,根据 `header.resource_type` 进行分发:
```rust
pub fn parse_tres(content: &str) -> Result<TresFile, TresParseError> {
let structure = parse_tres_structure(content)?;
let body = match structure.header.resource_type.as_str() {
"SpriteFrames" => TresBody::SpriteFrames(
crate::handlers::tres_spriteframes::parse_sprite_frames(content)?,
),
"TileSet" => TresBody::TileSet(
crate::handlers::tres_tileset::parse_tile_set(content)?,
),
_ => TresBody::Flat(parse_resource_block_flat(content)),
};
Ok(TresFile { structure, body })
}
```
任何没有专用解析器的内容都会落入 `TresBody::Flat`,这是一个 `HashMap`,它通过一个识别字符串的扫描器跟踪括号平衡,从而逐字捕获多行数组和字典值。仅此一项就覆盖了 `StandardMaterial3D`、`ShaderMaterial`、`FontFile`、`Environment` 以及大量 `Resource` 子类,其主体只是 `key = value` 对。
## 格式特性和处理方法
边缘案例 | 表现形式 | 处理方法
--- | --- | ---
格式头部 | `format=2`(Godot 3)与 `format=3`(Godot 4)。相同的 `.tres` 扩展名,但 TileSet 语法几乎无关。 | TileSet 解析器根据格式属性分支。Godot 3 在 `[resource]` 上按整数索引瓦片;Godot 4 使用 `TileSetAtlasSource` 子资源。
`ExtResource` 样式 | `ExtResource("1_albedo")`(Godot 4,带引号的 id)与 `ExtResource(1)`(Godot 3,无引号整数)。 | 词法分析器两种都接受。
AtlasTexture 与 ExtResource 帧 | SpriteFrames 中的帧可以是 `SubResource("AtlasTexture_idle_0")`(图集切片)或 `ExtResource("1_xyz")`(整个纹理)。 | 两种形式通过一个 `FrameTextureRef` 枚举处理。
Aseprite Wizard 元数据 | `metadata/_aseprite_wizard_*` 键写在动画数组之后,包括一个多行嵌套字典。 | 通过键名而不是位置定位 `animations =`,因此忽略尾随的垃圾。
尾随逗号 | Godot 4 在字典和数组中输出它们;Godot 3 不输出。 | 变量解析器两种都容忍。
`StringName` 字面量 | Godot 4 字典键使用 `&"idle"` 形式,与常规字符串不同。 | 作为单独的词法单元类型进行词法分析。
类型构造函数 | `Color(...)`、`Vector2(...)`、`Rect2(...)`、`PackedColorArray(...)` 等。 | 作为不透明的 `TypedCall { name, raw_args }` 词法单元进行词法分析。原样保留;特定调用点在需要时手动解析 `Rect2` 和 `Vector2i`。
每个瓦片一个 PNG 的 TileSet | Godot 3 的 `hexagonal_map.tres` 引用了 26 个 PNG,每个瓦片一个。 | 标准化为“N 个图集源 × 每个图集1个瓦片”,这样管线的其余部分就不会分叉。
多单元格瓦片 | Godot 4 的 `size_in_atlas = Vector2i(W, H)` 声明了一个跨越 W×H 个单元格的瓦片。 | 预览合成器遵循这一点,因此 2×1 的树以两倍宽度绘制。
我们明确不支持的内容:GDScript 定义的定制资源(没有元数据可供渲染,也没有安全执行的方式),`.tscn` 场景(大部分格式可以工作,但不在 v0.1.13 中,https://assethoard.com/releases/0.1.13),`TileSetScenesCollectionSource`(静默跳过),以及 Godot 3 的 SpriteFrames(格式根本不同,目前返回空结果而不是部分解析)。
## 词法分析和解析
词法分析器和解析器位于自己的模块 `godot_variant/` 中,并有一条硬性规定:只从 `std` 和 `serde` 导入。crate 中的其他内容都不能引入。这种隔离是有意为之:它有望在将来被提取为工作区 crate,然后如果对其他人有用的话,可能会发布到 crates.io。
词法单元枚举:
```rust
pub enum Token {
LBracket,
RBracket,
LBrace,
RBrace,
Comma,
Colon,
String(String),
StringName(String), // &"idle"
Int(i64),
Float(f64), // 包括 inf, -inf, nan
Bool(bool),
Null,
SubResourceRef(String), // SubResource("AtlasTexture_xyz")
ExtResourceRef(String), // ExtResource("1_abc") 或 ExtResource(1)
TypedCall { name: String, raw_args: String }, // Color(1,1,1,1), Vector2(0,0), ...
}
```
`tokenize(input: &str) -> Result<Vec<Token>, LexError>` 返回一个 `Vec` 而不是迭代器。输入很小(每个 `animations = [...]` 块只有几 KB),解析器受益于向前查看,并且任何错误都会短路整个批次。
解析器是递归下降的,并且通过构造实现无 panic。没有 `unwrap()`,没有越界索引,每个可能失败的操作都返回 `Result`。AST 有意保持最小:
```rust
pub enum VariantValue {
Dict(HashMap<String, VariantValue>),
Array(Vec<VariantValue>),
String(String),
StringName(String),
Int(i64),
Float(f64),
Bool(bool),
Null,
SubResourceRef(String),
ExtResourceRef(String),
TypedCall { name: String, raw_args: String },
}
```
我没有认真评估 `nom`、`chumsky` 或 `pest`。这个格式恰好足够不规则,以至于通用的组合子方法会比集中的状态机产生更多代码,而且模块上的无第三方依赖规则使得以后将其提取到自己的 crate 中更容易。
错误不会导致整个解析崩溃。结构遍历会跳过格式错误的 `[ext_resource]` 和 `[sub_resource]` 块并继续。对于 `format=2` 文件,如果 SpriteFrames 体无法词法分析或解析,则降级为空结果并记录调试日志;对于 `format=3`,错误会传播。TileSet 解析器始终采用尽力而为的方式,部分结果始终有效:
```rust
let variant = match lexer::tokenize(animations_text) {
Ok(toks) => match parser::parse_variant(toks) {
Ok(v) => v,
Err(e) => {
if format == 2 {
log::debug!(
"parse_sprite_frames: format=2 graceful degradation (parse): {}",
e
);
return Ok(SpriteFramesData::empty());
}
return Err(TresParseError::BodyParseError(format!(
"failed to parse animations Variant: {}",
e
)));
}
},
...
};
```
单元测试涵盖了我们在实际中见过的每种 Variant 形状:Godot 4 的带引号引用,Godot 3 的无引号整数引用,带有 `StringName` 键的字典,字典数组,包含括号的嵌套 `PackedColorArray` 字符串。此外还有无 panic 的暴力测试:未终止的字符串、不平衡的括号、垃圾字节和单独的 `-`。
## 解析引用
解析会产生一个资源及其声明的外部引用列表。这是容易的一半。困难的一半是解析:弄清楚每个 `res://` 路径在磁盘上的实际位置,被引用的文件是否在你的库中,以及当它不在时该怎么办。
资源是嵌套的。一个 `StandardMaterial3D` 引用纹理。一个 SpriteFrames 可能引用一个 AtlasTexture 子资源,而该子资源本身又指向一个 PNG。一个 TileSet 引用图集纹理,并且可以链接到地形定义。任何一个 `.tres` 的完整图像是一个有向图,有时有几层深,但解析器并不将其视为一个图。它在导入时对每个 `.tres` 中的每个引用运行,而图会在每个资产的解析条目链接到库中其他条目时隐式出现。仅在拖出时才有必要将图作为图来遍历。
Asset Hoard 处理用户导入的文件夹,并且不能保证 `.tres` 位于真实的 Godot 项目树内。解析器不会查找 `project.godot` 文件。相反,对于单个 `.tres` 上的每个 `res://` 引用,它会从 `.tres` 的磁盘位置向上遍历目录树(上限深度为 10),并在每一层尝试两个候选路径:`/<完整相对路径>` 以保留 `res://` 内的目录结构,以及 `/<仅文件名>` 作为扁平化回退,适用于删除了 `textures/` 前缀的分发者。第一个匹配的获胜。
```rust
pub fn resolve_res_path(res_path: &str, tres_file: &Path) -> Option<PathBuf> {
let rel = res_path.strip_prefix("res://")?;
let rel_path = Path::new(rel);
let basename = rel_path.file_name();
const MAX_ANCESTOR_DEPTH: usize = 10;
let mut current = tres_file.parent()?;
for _ in 0..MAX_ANCESTOR_DEPTH {
let full = current.join(rel_path);
if full.exists() {
return Some(full);
}
if let Some(name) = basename {
let flat = current.join(name);
if flat.exists() {
return Some(flat);
}
}
match current.parent() {
Some(p) => current = p,
None => break,
}
}
None
}
```
解析器通过 `resolve_tres_references` Tauri 命令作为导入后处理运行。对于每个 `.tres` 资产,它读取头部以分配 `file_type`(`material`/`spriteframes`/`tileset`/`package`),遍历每个 `[ext_resource]` 块,尝试将每个 `res://` 路径解析为磁盘上的真实文件,将解析的路径与现有的库资产匹配,并将结果写入资产行的 `metadata.references[]` 中。这个处理是幂等的。重新运行它成本很低且具有自愈性,因此之前缺失的依赖项如果在之后被导入,则会在下一次处理时获取 `resolved_asset_id`。
引用不是单独的表。它们作为 JSON 数组存在于 `assets.metadata` 中:
```json
{
"references": [
{
"path": "res://textures/stone_albedo.png",
"resolved_asset_id": 1247,
"disk_path": "/abs/path/to/stone_albedo.png",
"slot": "albedo_texture"
}
]
}
```
`categorize_tres_references` 在读取时通过 stat 每个 `disk_path` 将该数组分为三个桶:**已导入**(`resolved_asset_id` 已填充)、**可导入**(未解析但磁盘路径存在)、**缺失**(未解析且无法定位文件)。Stat 在分类时进行,而不是在解析时,因此跨会话移动的文件不会留下过时的 UI 状态。
解析在导入时是急切的。整个 `.tres` 的图只遍历一次,结果缓存在 `metadata.references[]` 中,随后的读取(预览、拖出、引用面板)永远不会重新遍历。权衡结果是过时:如果被引用的文件在导入后但在重新运行解析器之前移动了磁盘位置,那么缓存的 `disk_path` 就是错误的。分类步骤的 stat 调用覆盖了当前缺失的情况,并且有一个“重新解析 Godot 引用”的上下文操作来强制重新遍历。
循环处理存在于拖出路径中,而不是解析器本身。`gather_tres_drag_payload` 通过使用具有深度上限 5 和已访问资产 ID 的 `HashSet` 的 BFS 队列递归遍历 `.tres → .tres` 链,因此一个引用另一个 `.tres` 的 `.tres` 如果形成循环,会干净地停止。
## 渲染每种资源类型
解析和解析是机械性的。渲染才是真正变得有趣的工作,因为每种资源类型都需要思考什么是有用的预览。
有用的预览是那种能让你一眼区分两个相似资源的预览。通用的“材质”图标立即无法通过这个测试。一个纯灰色的球体也好不了多少。目标是生成一个缩略图,使得 `stone_rough.tres` 和 `stone_polished.tres` 之间的差异在无需打开任何文件的情况下就能可见。
一个值得提前指出的整体架构选择:PBR 渲染通过 Three.js 在前端进行,而不是在 Rust 中无头进行。Tauri 已经提供了一个 WebView,WebGL 渲染器就在那里,结果是实时、可旋转的预览,而不是烘焙图像。网格视图的缩略图通过 `canvas.toDataURL()` 从同一个渲染器捕获,并往返到磁盘。一个管线,两个输出。
**StandardMaterial3D、ORMMaterial3D、SpatialMaterial。** 使用 `THREE.MeshStandardMaterial` 和 `THREE.SphereGeometry` 渲染到真实的球体上,使用 IBL 工作室环境进行反射和一个定向主光。缩略图生成器维护一个单例离屏 `WebGLRenderer`(`preserveDrawingBuffer: true`),将贴图交换到复用的球体上,并捕获结果。预览是真实的材质,而不是表示。
**ShaderMaterial。** 将链接的 `.gdshader` 源代码以带有 highlight.js 的 GLSL 语法高亮显示为代码。尝试在不运行的情况下渲染任意的用户着色器是不可能的,而针对虚拟统一集运行不受信任的用户着色器最多只会产生垃圾。代码预览在实践中证明更有用。你可以快速扫描着色器并立即识别它,这比又一个通用球体好得多。
**FastNoiseLite + Gradient。** 这些是程序化材质,完全没有纹理引用。天真的方法是“没有纹理,没有预览,回退到图标”,这将产生一个装满相同空白球体的文件夹。相反,我们将从 `.tres` 体中提取的 Godot 4 `FastNoiseLite` 参数输入到 `fastnoise-lite` npm 包中,这是 Godot 自身使用的同一库的 JavaScript 移植。噪声被采样到画布上,渐变逐像素应用,结果插入到真实反照率贴图所在的同一纹理槽位。它不会与 Godot 像素级一致。RNG 种子的处理和 Godot 的无缝模式(在环面上采样的 4D 噪声)不同。但视觉上是等效的,这才是重点。冰噪声材质看起来像冰,熔岩材质看起来像熔岩。
**SpriteFrames。** 动画播放。帧数据在 Rust 中在导入期间被预展平,因此每个帧都携带其源资产 ID、磁盘路径、图集区域(`sx, sy, sw, sh`)和每帧时长乘数。前端不需要等待
相似文章
AST-grep 如何使用 Rust 重写 Tree-sitter 并使其速度提升 30%
ast-grep 用 Rust 重写了 Tree-sitter 的 C 核心,实现了高达 30% 的解析速度提升和 22% 的端到端性能提升,但内存使用略有增加。
用Rust构建了一个开放的可定制代理循环的Agentic AI系统(TigrimOSR)
宣布TigrimOSR,一个用Rust构建的开源Agentic AI系统,具有可定制的代理循环。
@tom_doerr: 基于 Rust 的模块化 GraphRAG 实现,支持 WebGPU 加速。https://github.com/automataIA/graphrag-rs…
一种模块化、高性能的 GraphRAG(基于图的检索增强生成)Rust 实现,支持 WebGPU 加速,并提供三种部署架构:仅服务器、仅 WASM(客户端)以及混合模式。
Rust 与 GBA:环境搭建与像素渲染
一份教程指南,讲解如何搭建 Rust 项目以构建能在 Game Boy Advance 上运行的 ROM,涵盖项目设置和像素渲染。
从Go迁移到Rust
一份为Go开发者迁移到Rust编写的全面指南,专注于后端服务,对比正确性、运行时和人体工程学方面的权衡,并提供关于渐进式迁移的实用建议。