306 lines
14 KiB
Markdown
306 lines
14 KiB
Markdown
# 资产生成
|
||
|
||
> 适用:改动 Blender 侧的几何构建、材质、实例化,或往场景里加新资产。
|
||
> 贯穿全篇的约束是两条:**压低对象数**(GLB 要能在浏览器里跑)和
|
||
> **构建必须确定性**(parity 校验的前提)。
|
||
|
||
---
|
||
|
||
## MeshBatch:几何构建的主力
|
||
|
||
`mesh.py:1-9` 说明了它为什么存在:场景的绝大部分是平面多边形和拉伸棱柱,
|
||
**把它们批进一个 mesh datablock 能同时压低 Blender 对象数和导出 glTF 的节点数**。
|
||
|
||
用法固定为「累积 → 一次 `finish()`」:
|
||
|
||
```python
|
||
batch = MeshBatch("Lake Surface", water_c, water_mat)
|
||
batch.add_polygon(ring, 0.10)
|
||
batch.finish() # water.py:12-14
|
||
```
|
||
|
||
### 三条内建行为
|
||
|
||
1. **自动去掉重复的闭合点**(`mesh.py:41-42, 55-56`)。传闭合环或开放环都行,
|
||
与 `geom.py` 的宽容度一致
|
||
2. **退化输入静默返回**:`len(ring) < 3` 直接 return,不抛
|
||
3. **空批次 `finish()` 返回 `None`**(`mesh.py:66-67`),不产生空对象
|
||
|
||
### 名字前缀是承重的
|
||
|
||
```python
|
||
# Foliage reads as blobby volume, so it wants smooth normals; the built
|
||
# environment wants its facets. The name prefix is the discriminator.
|
||
if self.name.startswith("Tree_") or self.name.startswith("Scrub_"):
|
||
for polygon in mesh.polygons:
|
||
polygon.use_smooth = True # mesh.py:72-77
|
||
```
|
||
|
||
**改植被对象的命名前缀会静默改变着色**。加新的植被类资产时要么沿用
|
||
`Tree_` / `Scrub_` 前缀,要么显式扩展这个判断。
|
||
|
||
### 单材质约束
|
||
|
||
一个 `MeshBatch` 只挂一个材质(`mesh.py:64`)。需要多材质就开多个 batch——
|
||
这也正是[材质顺序决定 GLB 索引](../pipeline/layer-registry.md#顺序是承重的)的地方。
|
||
|
||
### 便捷包装
|
||
|
||
`make_prism` / `add_roof`(`mesh.py:83, 89`)是「单个形体」的一次性包装,内部就是
|
||
`MeshBatch` + `finish()`。只放一个形体时用它们,批量累积时直接用 `MeshBatch`。
|
||
|
||
---
|
||
|
||
## 材质:声明与构建分离
|
||
|
||
```
|
||
catalog.MATERIALS 声明「是什么」 纯 Python,无 bpy
|
||
│
|
||
▼ materials.from_spec(spec) materials.py:198
|
||
真实的 bpy.types.Material 只在 Blender 内
|
||
```
|
||
|
||
这个拆分让 catalog 能被任何不启动 Blender 的工具读取(`materials.py:3-7`)。
|
||
|
||
### `kind` 选构建器
|
||
|
||
| `kind` | 走哪条路 | 必填字段 |
|
||
|---|---|---|
|
||
| `solid` | `make_material()` | `name`、`color` |
|
||
| `textured` | `make_textured_material()` | `name`、`diffuse`、`normal`、`scale` |
|
||
|
||
`solid` 可以再叠 `procedural`(噪声驱动的基色和凹凸,`from_spec` 里判断)。
|
||
可选字段一律 `spec.get(key, 默认值)`——**加新的可选字段不要改已有条目**。
|
||
|
||
### 加一种材质
|
||
|
||
1. `catalog.MATERIALS` **末尾追加**一个条目(顺序决定 GLB 材质索引)
|
||
2. 若 `kind` 是 `textured`,贴图放 `assets/textures/`(`materials.py:14` 的
|
||
`TEXTURE_ROOT`)
|
||
3. `generate_scene.py` 里用 `material_from_spec(catalog.MATERIALS["<key>"])` 取
|
||
4. **若这个材质在 Cesium 里需要调色,把 `cesium` 子契约写在同一个
|
||
`MATERIALS` 条目里**。`materials.from_spec()` 会把它序列化到
|
||
`material["cesium_export"]`,导出器优先读这个属性
|
||
|
||
### 颜色是线性 RGB
|
||
|
||
`catalog` 里的 `color` 是 Blender 的线性值,**不是** sRGB hex,也**不**从
|
||
`scripts/lib/scene-layers.js` 换算。两套配色独立调过,理由见
|
||
[图层表](../pipeline/layer-registry.md#为什么颜色刻意不同步)。
|
||
|
||
---
|
||
|
||
## 实例化:树
|
||
|
||
`tree.py:1-8` 的模式——**import 一次 → bake 朝向 → 每棵树只 link 一个轻对象复用
|
||
同一个 datablock**:
|
||
|
||
> Nothing is duplicated per tree, so the .blend and the exported GLB carry each
|
||
> mesh and each texture exactly once no matter how many trees are planted.
|
||
|
||
`TreeVariant` 是 `(meshes, height, base_z)` 三元组(`tree.py:62-65`):
|
||
|
||
- `height` — 变体自身的高度,目标高度除以它得到缩放系数
|
||
- `base_z` — 变体自身的地面线,**取负乘以缩放**就能把树干落到 `z=0`,
|
||
不管源文件把原点放在哪(`tree.py:301-303`)
|
||
|
||
`assemble()` 返回种植数量,**0 表示模型缺失或 style 未知**,调用方据此回退到程序化
|
||
树。
|
||
|
||
### Shapespark 是当前模型树样式
|
||
|
||
当前唯一模型树 style 是 `shapespark`,资产来自
|
||
`assets/models/custom/shapespark_plants/tree-*/model.gltf`。该目录是从
|
||
Shapespark low-poly plants kit 拆分出的单植物 glTF/bin,贴图共享在
|
||
`assets/models/custom/shapespark_plants/textures/`。
|
||
|
||
`tree.py` 加载 12 个 tree 变体,每个变体 import 一次、bake 成共享 mesh datablock;
|
||
OSM 树实例只 link 这些 mesh。变体选择必须确定性,当前用 stable instance index 经过
|
||
golden-turn 序列选变体:
|
||
|
||
```python
|
||
variant = variants[int(((index * GOLDEN_TURN) % 1.0) * len(variants))]
|
||
```
|
||
|
||
拆分 glTF 会让 Blender 生成 `branch-01.001`、`branch-01.002` 这类重复材质名;
|
||
`tree.py` 必须按 base name 去重材质槽,否则 `export_cesium.py` 会为同一张贴图重复
|
||
bake Cesium 材质。
|
||
|
||
### 已删除的模型树 style 有记录
|
||
|
||
`tree.py:18-25` 记着 `polyhaven` style 被删的原因(LOD1 对象不是整棵树,是给几何节点
|
||
散布用的树枝和叶簇,直接种出来是一地树枝,连同 78MB 资产一起删了)。
|
||
|
||
`apple` 和 `fattree` style 已在 Shapespark 方案被用户接受后删除:apple 需要强抠图、
|
||
法线和 Cesium 曝光补偿,远近观感不如 Shapespark 稳定;fattree 视觉风格不匹配。
|
||
对应资产目录 `assets/models/speedtree/apple_low/` 和 `assets/models/lyrog/fattree/`
|
||
也已删除。
|
||
|
||
**这类"试过、不行、为什么"的记录要保留。** 删掉它,下一个人会重新引入同一个资产。
|
||
|
||
## 灌木边缘实例有性能预算
|
||
|
||
`generate_scene.py` 的 scrub 不是只有贴地面:`natural=scrub` 会先生成
|
||
`Scrub_<way_id>` 地面覆盖,再沿边界实例化
|
||
`assets/models/custom/shapespark_plants/bush-03/model.gltf`
|
||
(`add_scrub_edge_bushes()`),并在大 scrub 面内部稀疏补树。
|
||
|
||
这些 bush 是共享 mesh 的多 node 实例。共享 mesh 能压 GLB 体积,但 Cesium 近景 Follow
|
||
仍要处理每个 node / draw。`nantaizi-lake-innovation-valley` 曾经用
|
||
`SCRUB_BUSH_SPACING = 0.82`、`SCRUB_BUSH_LIMIT_PER_PATCH = 180`,结果一个场景有约
|
||
`707` 个 bush nodes;车辆 Follow 到灌木密集区域时明显卡顿。当前预算是:
|
||
|
||
```python
|
||
SCRUB_BUSH_SPACING = 1.8
|
||
SCRUB_BUSH_LIMIT_PER_PATCH = 60
|
||
```
|
||
|
||
在 nantaizi 上约 `266` 个 bush nodes,scrub 地面覆盖仍保留。旧高模 bush 和
|
||
rejected `bush_low` 资产已删除:前者太重,后者几何从
|
||
`63,762` vertices / `67,536` indices 降到
|
||
`4,044` / `4,050`,但用户实测视觉效果不理想,不能作为默认方向。
|
||
|
||
不要只为了“更满”把间距调回 1m 以下;那是在把预览流畅度换成近景装饰密度。若确实
|
||
需要更密的灌木,先做一个视觉可接受的低模 bush,或做显式 LOD / instancing 方案,再
|
||
跑 nantaizi 的 Blender/Cesium/preview 验证。继续压 GLB 总体体积应考虑贴图尺寸/KTX2,
|
||
而不是只减面。
|
||
|
||
---
|
||
|
||
## 确定性:用无理数周期代替 RNG
|
||
|
||
这是全仓最容易被无意破坏的约定。`tree.py:290-292`:
|
||
|
||
```python
|
||
# Irrational periods stand in for an RNG: no repeat over any realistic
|
||
# tree count, and a pure function of the index, so rebuilding an area
|
||
# plants the identical forest.
|
||
scale_wobble = 1.0 + SCALE_JITTER * math.sin(index * 2.399963)
|
||
yaw = ((index * GOLDEN_TURN) % 1.0) * math.tau
|
||
tilt_x = TILT_JITTER * math.sin(index * 1.114517)
|
||
tilt_y = TILT_JITTER * math.cos(index * 0.927295)
|
||
```
|
||
|
||
黄金角 `GOLDEN_TURN`(`tree.py:47-49`)让相邻的树朝向永不重复也永不成规律——
|
||
一排树看起来像种的,不像盖章盖的。
|
||
|
||
**规则**:需要"随机"外观时,用 `index` 的纯函数(无理数周期 / 黄金角),
|
||
或者收一个显式 `seed`(`geom.sample_polygon_interior` 就是这么做的)。
|
||
|
||
**绝不要**用无种子的 `random` 或任何时间相关的量——
|
||
重建同一片区域必须得到逐字节相同的结构,否则
|
||
[parity 校验](../guides/artifact-parity-guide.md)永久性地红。
|
||
|
||
---
|
||
|
||
## Cesium 导出:一层独立的调色
|
||
|
||
`export_cesium.py:1-9`:创作用的场景刻意使用了一些 Blender 专有节点
|
||
(草地 tint、程序化树冠变化),而 glTF 的材质词汇小得多。所以导出器
|
||
**新建临时的、仅供导出的 PBR 材质**,展 UV,把引用到的图片全部内嵌进 GLB。
|
||
|
||
导出材质带 `EXPORT_PREFIX = "Cesium "` 前缀(`export_cesium.py:30`),
|
||
这样第二遍扫到实例化网格的共享材质槽时能认出自己的产物、跳过不重复处理。
|
||
|
||
模型保持在**局部 ENU 坐标系**(X 东、Y 北、Z 上),靠伴生 JSON 配合
|
||
`Cesium.Transforms.eastNorthUpToFixedFrame` 摆放。
|
||
|
||
### Cesium contract
|
||
|
||
新生成场景的 Cesium 导出调色写在 `catalog.MATERIALS[*]["cesium"]`,由
|
||
`materials.from_spec()` 保存成材质自定义属性 `material["cesium_export"]`。导出器打开
|
||
`.blend` 后优先读这个属性,不 import `catalog`。
|
||
|
||
`cesium` 子契约字段:
|
||
|
||
| 字段 | 作用 |
|
||
|---|---|
|
||
| `tint` | diffuse 贴图导出前往目标颜色混合 |
|
||
| `metallic` | 覆盖导出 PBR metallic |
|
||
| `base_color` | 直接替换导出基色,并禁用 diffuse/normal 图 |
|
||
| `emission` | 自发光兜底 |
|
||
|
||
`export_cesium.py` 仍保留 `EXPORT_TINTS`、`EXPORT_METALLIC_OVERRIDES`、
|
||
`EXPORT_BASE_COLOR_OVERRIDES`、`EXPORT_EMISSION_OVERRIDES` 四张按材质名字符串匹配的表,
|
||
但它们只是旧 `.blend` 兼容回退。新材质不要只写旧表。
|
||
|
||
### 交通信号倒计时字体
|
||
|
||
`assets/fonts/7LED-1.ttf` 是项目纳入版本管理的倒计时字体。它的字形是反向轮廓:可见
|
||
的 LED 段是字体轮廓里的孔,而不是普通实心文字。因此 Blender 侧不能直接把文字曲线
|
||
转成普通填充面(会得到“发光背景+黑色数字”),也不能依赖曲线描边。正确做法是在
|
||
`blender/osmassets/traffic_signals.py` 中采样负 Bezier 轮廓,构造带前后盖面的挤出棱柱,
|
||
使 LED 段成为实心发光几何。数字 mesh 必须先在 Blender 中单独渲染确认,再进入 Cesium
|
||
导出;导出器出现“Could not calculate tangents”只表示这些无 UV 的纯色网格没有切线,
|
||
不等同于倒计时集合为空或几何失败。
|
||
|
||
### 共享与拆分动态资产
|
||
|
||
倒计时数字按 phase group 共享 20 个数字 mesh(0-19),不要按信号灯复制网格。Cesium
|
||
阶段必须生成三个动态 GLB:`traffic-signals-dynamic.glb` 只含灯珠,
|
||
`traffic-signals-countdown-0.glb` 和 `traffic-signals-countdown-1.glb` 分别含两个相位组的
|
||
倒计时节点。两个倒计时模型与灯珠模型使用同一个 `modelMatrix`,浏览器只切换当前数字
|
||
节点,并给整个倒计时模型设置 `color` + `ColorBlendMode.REPLACE`,从而让字色跟随当前
|
||
红/黄/绿相位且不增加每个灯的材质/几何副本。
|
||
|
||
导出器按完整材质名包含 `Countdown Group 0` / `Countdown Group 1` 判断分组;不能用
|
||
集合名的精确相等比较,否则实际材质名 `Traffic Signal Countdown Group 0` 会被误判为
|
||
空集合。
|
||
|
||
### 为什么新资产总是"发黑"
|
||
|
||
`export_cesium.py:38-54` 记录了这个反复出现的问题:
|
||
|
||
> Cesium 的默认光照偏白,**场景里每一个材质都被手工提亮过**——草往亮绿混 72%、
|
||
> 带肋墙面往白混 86%、建筑自发光 0.18。一个没调过的新资产是唯一如实渲染的东西,
|
||
> 放在旁边就显得发黑。
|
||
|
||
所以**加新资产时,"它在 Blender 里看着对"不代表在 Cesium 里对**,必须在
|
||
`catalog.MATERIALS[*]["cesium"]` 里给它配一份调校。
|
||
|
||
抠图植被走的是另一套(`FOLIAGE_ALBEDO_GAIN = 2.1` + `FOLIAGE_SATURATION = 1.75`,
|
||
`:55, 66`),用**增益**而不是 tint——因为那是一张同时装着叶片、树皮、果实的图集,
|
||
往绿色混会把树干也染绿。增益保留色相关系,只把整体曝光抬到和邻居一致。
|
||
|
||
`FOLIAGE_EMISSION = 0.25` 的职责只是给背光面兜底,**不是主要提亮手段**
|
||
(`:32-36`)。想让植被更亮就调增益,别调自发光。
|
||
|
||
---
|
||
|
||
## 反模式
|
||
|
||
| 反模式 | 后果 |
|
||
|---|---|
|
||
| 每个形体建一个对象而不用 `MeshBatch` | 对象数与 glTF 节点数爆炸 |
|
||
| 改 `Tree_` / `Scrub_` 命名前缀 | 平滑着色静默失效 |
|
||
| 用无种子 `random` 或时间量做抖动 | parity 校验永久红 |
|
||
| 每棵树复制一份 mesh/贴图 | .blend 与 GLB 体积按棵数线性膨胀 |
|
||
| 直接 append vendored 资产的材质 | alpha-clip 缺失,树冠渲染成一块 |
|
||
| 删掉"试过不行"的注释 | 下一个人重新踩同一个坑 |
|
||
| 从 `scene-layers.js` 的 hex 换算 Blender 颜色 | 抹掉独立调过的配色 |
|
||
| 加新资产不配 Cesium 调色 | Cesium 里显得发黑 |
|
||
| 靠调 `FOLIAGE_EMISSION` 提亮植被 | 用错了旋钮,该调 albedo gain |
|
||
| 在 `MATERIALS` 中间插入条目 | GLB 材质索引整体平移 |
|
||
| 直接用 Cesium `Model.getMaterial().setValue()` 改普通 glTF PBR 材质 | 运行时数字仍保持原色,不能实现相位字色 |
|
||
| 每个信号灯各自生成 0-19 全套倒计时 mesh | 节点和几何按信号数量线性膨胀;应按两个 phase group 共享 |
|
||
|
||
## 第三方资产导入的源文件边界
|
||
|
||
第三方 Blend 只属于一次性入库工具的输入,不能成为 `build:area` 或 Cesium 预览的运行时依赖。
|
||
完成拆分后,仓库必须包含可直接消费的 glTF/bin、共享贴图、manifest 和人工预览;原始下载文件可
|
||
以删除。导入工具应将源文件路径作为显式 `--source` 参数,manifest 最多保留源文件名作溯源,
|
||
不能写死用户 Downloads 目录。
|
||
|
||
删除原始文件前必须确认入库资产已通过结构验证和人工预览;删除后若需重新导入,必须重新取得
|
||
同一 Blend 与其外部贴图。`blender/tools/split_lowpoly_cars.py` 是这一约定的实例,不参与区域
|
||
构建阶段。
|
||
|
||
---
|
||
|
||
## 相关
|
||
|
||
- [模块结构](./module-structure.md):往哪儿放新代码
|
||
- [测试](./testing.md):纯几何部分怎么测
|
||
- [图层表](../pipeline/layer-registry.md):道路九层的材质从哪来
|
||
- [产物一致性指南](../guides/artifact-parity-guide.md):改完怎么验证产物没变
|