feat: добавить экспорт в формат OBJ для рендеринга террейна и опциональных полигонов
This commit is contained in:
@@ -1,21 +1,37 @@
|
|||||||
# MSH animation
|
# MSH animation
|
||||||
|
|
||||||
Документ описывает анимационные ресурсы MSH: `Res8`, `Res19` и runtime-интерполяцию.
|
Документ фиксирует анимационную часть формата MSH (`Res8`, `Res19`) и runtime-алгоритм сэмплирования/смешивания, необходимый для 1:1 совместимого движка и toolchain (reader/writer/converter/editor).
|
||||||
|
|
||||||
|
Связанные документы:
|
||||||
|
- [MSH core](msh-core.md) — общая структура модели и `Res1`/`Res2`.
|
||||||
|
- [NRes / RsLi](nres.md) — контейнер и атрибуты записей.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1.13. Ресурсы анимации: Res8 и Res19
|
## 1. Область и источники
|
||||||
|
|
||||||
- **Res8** — массив анимационных ключей фиксированного размера 24 байта.
|
Спецификация основана на:
|
||||||
- **Res19** — `uint16` mapping‑массив «frame → keyIndex` (с per-node смещением).
|
- `tmp/disassembler1/AniMesh.dll.c` (псевдо-C): `sub_10015FD0`, `sub_10012880`, `sub_10012560`.
|
||||||
|
- `tmp/disassembler2/AniMesh.dll.asm` (ASM): подтверждение x87-пути (`FISTP`) и ветвлений.
|
||||||
|
- валидации corpus (`testdata`): 435 моделей `*.msh`.
|
||||||
|
|
||||||
### 1.13.1. Формат Res8 (ключ 24 байта)
|
Ниже разделено на:
|
||||||
|
- **Нормативно**: обязательно для runtime-совместимости.
|
||||||
|
- **Канонично**: как устроены исходные ассеты; важно для детерминированного writer/editor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Ресурсы и поля модели
|
||||||
|
|
||||||
|
### 2.1. Res8 — key pool (нормативно)
|
||||||
|
|
||||||
|
`Res8` — массив ключей фиксированного шага 24 байта.
|
||||||
|
|
||||||
```c
|
```c
|
||||||
struct AnimKey24 {
|
struct AnimKey24 {
|
||||||
float posX; // +0x00
|
float pos_x; // +0x00
|
||||||
float posY; // +0x04
|
float pos_y; // +0x04
|
||||||
float posZ; // +0x08
|
float pos_z; // +0x08
|
||||||
float time; // +0x0C
|
float time; // +0x0C
|
||||||
int16_t qx; // +0x10
|
int16_t qx; // +0x10
|
||||||
int16_t qy; // +0x12
|
int16_t qy; // +0x12
|
||||||
@@ -27,79 +43,378 @@ struct AnimKey24 {
|
|||||||
Декодирование quaternion-компонент:
|
Декодирование quaternion-компонент:
|
||||||
|
|
||||||
```c
|
```c
|
||||||
q = s16 * (1.0f / 32767.0f)
|
float q = (float)s16 * (1.0f / 32767.0f);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.13.2. Формат Res19
|
Атрибуты NRes:
|
||||||
|
- `attr1 = size / 24` (количество ключей).
|
||||||
|
- `attr2 = 0` (в observed corpus).
|
||||||
|
- `attr3 = 4` (не stride; это фактический runtime-инвариант формата).
|
||||||
|
|
||||||
Res19 читается как непрерывный массив `uint16`:
|
### 2.2. Res19 — frame->segment map (нормативно)
|
||||||
|
|
||||||
|
`Res19` — непрерывный `uint16` массив:
|
||||||
|
|
||||||
```c
|
```c
|
||||||
uint16_t map[]; // размер = size(Res19)/2
|
uint16_t map_words[]; // count = size / 2
|
||||||
```
|
```
|
||||||
|
|
||||||
Per-node управление mapping'ом берётся из заголовка узла Res1:
|
Атрибуты NRes:
|
||||||
|
- `attr1 = size / 2` (число `uint16` слов).
|
||||||
|
- `attr2 = animFrameCount` (глобальная длина таймлайна модели в кадрах).
|
||||||
|
- `attr3 = 2`.
|
||||||
|
|
||||||
- `node.hdr2` (`Res1 + 0x04`) = `mapStart` (`0xFFFF` => map отсутствует);
|
### 2.3. Связь с Res1 node header (нормативно)
|
||||||
- `node.hdr3` (`Res1 + 0x06`) = `fallbackKeyIndex` и одновременно верхняя граница валидного `map`‑значения.
|
|
||||||
|
|
||||||
### 1.13.3. Выбор ключа для времени `t` (`sub_10012880`)
|
Для `Res1` со stride 38 (основной формат):
|
||||||
|
- `hdr2` (`node + 0x04`) = `mapStart` (`0xFFFF` => map для узла отсутствует).
|
||||||
|
- `hdr3` (`node + 0x06`) = `fallbackKeyIndex` (индекс ключа в `Res8`).
|
||||||
|
|
||||||
1) Вычислить frame‑индекс:
|
Runtime читает эти поля напрямую в `sub_10012880`.
|
||||||
|
|
||||||
```c
|
### 2.4. Поля runtime-модели, задействованные анимацией (нормативно)
|
||||||
frame = (int64)(t - 0.5f); // x87 FISTP-путь
|
|
||||||
```
|
|
||||||
|
|
||||||
Для строгой 1:1 эмуляции используйте именно поведение x87 `FISTP` (а не «упрощённый floor»), т.к. путь в оригинале опирается на FPU rounding mode.
|
Инициализация в `sub_10015FD0`:
|
||||||
|
- `model+0x18` -> `Res8` pointer.
|
||||||
2) Проверка условий fallback:
|
- `model+0x1C` -> `Res19` pointer.
|
||||||
|
- `model+0x9C` <- `NResEntry(Res19).attr2` (`animFrameCount`).
|
||||||
- `frame >= model.animFrameCount` (`model+0x9C`, из `NResEntry(Res19).attr2`);
|
|
||||||
- `mapStart == 0xFFFF`;
|
|
||||||
- `map[mapStart + frame] >= fallbackKeyIndex`.
|
|
||||||
|
|
||||||
Если любое условие истинно:
|
|
||||||
|
|
||||||
```c
|
|
||||||
keyIndex = fallbackKeyIndex;
|
|
||||||
```
|
|
||||||
|
|
||||||
Иначе:
|
|
||||||
|
|
||||||
```c
|
|
||||||
keyIndex = map[mapStart + frame];
|
|
||||||
```
|
|
||||||
|
|
||||||
3) Сэмплирование:
|
|
||||||
|
|
||||||
- `k0 = Res8[keyIndex]`
|
|
||||||
- `k1 = Res8[keyIndex + 1]` (для интерполяции сегмента)
|
|
||||||
|
|
||||||
Пути:
|
|
||||||
|
|
||||||
- если `t == k0.time` → взять `k0`;
|
|
||||||
- если `t == k1.time` → взять `k1`;
|
|
||||||
- иначе `alpha = (t - k0.time) / (k1.time - k0.time)`, `pos = lerp(k0.pos, k1.pos, alpha)`, rotation смешивается через fastproc‑интерполятор quaternion.
|
|
||||||
|
|
||||||
### 1.13.4. Межкадровое смешивание (`sub_10012560`)
|
|
||||||
|
|
||||||
Функция смешивает два сэмпла (например, из двух animation time-позиций) с коэффициентом `blend`:
|
|
||||||
|
|
||||||
1) получить два `(quat, pos)` через `sub_10012880`;
|
|
||||||
2) выполнить shortest‑path коррекцию знака quaternion:
|
|
||||||
|
|
||||||
```c
|
|
||||||
if (|q0 + q1|^2 < |q0 - q1|^2) q1 = -q1;
|
|
||||||
```
|
|
||||||
|
|
||||||
3) смешать quaternion (fastproc) и построить orientation‑матрицу;
|
|
||||||
4) translation писать отдельно как `lerp(pos0, pos1, blend)` в ячейки `m[3], m[7], m[11]`.
|
|
||||||
|
|
||||||
### 1.13.5. Что хранится в `Res19.attr2`
|
|
||||||
|
|
||||||
При загрузке `sub_10015FD0` записывает `NResEntry(Res19).attr2` в `model+0x9C`.
|
|
||||||
Это поле используется как верхняя граница frame‑индекса в п.1.13.3.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 3. Runtime-сэмплирование узла (`sub_10012880`)
|
||||||
|
|
||||||
|
Функция возвращает:
|
||||||
|
- quaternion (4 float) в буфер `outQuat`,
|
||||||
|
- позицию (3 float) в `outPos`.
|
||||||
|
|
||||||
|
Вход:
|
||||||
|
- `t` — sample time.
|
||||||
|
- текущий `nodeIndex` берётся из runtime-объекта (не из аргумента).
|
||||||
|
|
||||||
|
### 3.1. Вычисление frame index (нормативно)
|
||||||
|
|
||||||
|
Алгоритм:
|
||||||
|
1. `x = t - 0.5`.
|
||||||
|
2. `frame = x87 FISTP(x)` (через 64-битный промежуточный буфер).
|
||||||
|
|
||||||
|
Важно:
|
||||||
|
- это не «просто floor»;
|
||||||
|
- поведение зависит от x87 rounding mode (в игре используется стандартный control word).
|
||||||
|
|
||||||
|
Если нужен byte/behavior 1:1, надо повторить именно x87-ветку или её точный эквивалент.
|
||||||
|
|
||||||
|
### 3.2. Выбор `keyIndex` (нормативно)
|
||||||
|
|
||||||
|
```c
|
||||||
|
node = Res1 + nodeIndex * 38;
|
||||||
|
mapStart = u16(node + 4); // hdr2
|
||||||
|
fallback = u16(node + 6); // hdr3
|
||||||
|
|
||||||
|
if ((uint32_t)frame >= animFrameCount
|
||||||
|
|| mapStart == 0xFFFF
|
||||||
|
|| map_words[mapStart + (uint32_t)frame] >= fallback) {
|
||||||
|
keyIndex = fallback;
|
||||||
|
} else {
|
||||||
|
keyIndex = map_words[mapStart + (uint32_t)frame];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Критично:
|
||||||
|
- runtime не проверяет bounds у `fallback` и `mapStart + frame`; некорректные данные приводят к OOB.
|
||||||
|
|
||||||
|
### 3.3. Сэмплирование ключей (нормативно)
|
||||||
|
|
||||||
|
`k0 = Res8[keyIndex]`.
|
||||||
|
|
||||||
|
Ветки:
|
||||||
|
1. fallback-ветка из п.3.2: возвращается строго `k0` (без `k1`).
|
||||||
|
2. map-ветка:
|
||||||
|
- если `t == k0.time` -> вернуть `k0`;
|
||||||
|
- иначе берётся `k1 = Res8[keyIndex + 1]`;
|
||||||
|
- если `t == k1.time` -> вернуть `k1`;
|
||||||
|
- иначе:
|
||||||
|
- `alpha = (t - k0.time) / (k1.time - k0.time)`;
|
||||||
|
- `pos = lerp(k0.pos, k1.pos, alpha)`;
|
||||||
|
- `quat = fastproc_interp(k0.quat, k1.quat, alpha)` (`g_FastProc[17]`).
|
||||||
|
|
||||||
|
Сравнение `t == key.time` строгое (битовая float-эквивалентность по FPU compare), без epsilon.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Runtime-смешивание двух сэмплов (`sub_10012560`)
|
||||||
|
|
||||||
|
`sub_10012560(this, tA, tB, blend, outMatrix4x4)` смешивает две позы.
|
||||||
|
|
||||||
|
### 4.1. Валидация входов (нормативно)
|
||||||
|
|
||||||
|
Выбор доступных сэмплов:
|
||||||
|
- `hasA = (blend < 1.0f) && (tA >= 0.0f)`.
|
||||||
|
- `hasB = (blend > 0.0f) && (tB >= 0.0f)`.
|
||||||
|
|
||||||
|
Ветки:
|
||||||
|
- только `hasA`: матрица из A.
|
||||||
|
- только `hasB`: матрица из B.
|
||||||
|
- оба: полноценное смешивание.
|
||||||
|
- ни одного: в оригинале путь не защищён (caller contract).
|
||||||
|
|
||||||
|
### 4.2. Смешивание quaternion (нормативно)
|
||||||
|
|
||||||
|
Перед интерполяцией выполняется shortest-path flip:
|
||||||
|
|
||||||
|
```c
|
||||||
|
if (|qA + qB|^2 < |qA - qB|^2) {
|
||||||
|
qB = -qB;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Далее:
|
||||||
|
- `q = fastproc_blend(qA, qB, blend)` (`g_FastProc[22]`);
|
||||||
|
- `outMatrix = quat_to_matrix(q)` (`g_FastProc[14]`).
|
||||||
|
|
||||||
|
### 4.3. Смешивание translation (нормативно)
|
||||||
|
|
||||||
|
Позиция смешивается отдельно:
|
||||||
|
|
||||||
|
```c
|
||||||
|
pos = (1-blend) * posA + blend * posB;
|
||||||
|
outMatrix[3] = pos.x;
|
||||||
|
outMatrix[7] = pos.y;
|
||||||
|
outMatrix[11] = pos.z;
|
||||||
|
```
|
||||||
|
|
||||||
|
(`sub_1000B8E0` подтверждает, что используются именно эти ячейки).
|
||||||
|
|
||||||
|
Reference pseudocode:
|
||||||
|
|
||||||
|
```c
|
||||||
|
void blend_pose(Model *m, float tA, float tB, float blend, float out_m[16]) {
|
||||||
|
bool hasA = (blend < 1.0f) && (tA >= 0.0f);
|
||||||
|
bool hasB = (blend > 0.0f) && (tB >= 0.0f);
|
||||||
|
|
||||||
|
float qA[4], qB[4], pA[3], pB[3];
|
||||||
|
if (hasA) sample_node_pose(m, m->node_index, tA, qA, pA);
|
||||||
|
if (hasB) sample_node_pose(m, m->node_index, tB, qB, pB);
|
||||||
|
|
||||||
|
if (hasA && !hasB) { quat_to_matrix(qA, out_m); set_translation(out_m, pA); return; }
|
||||||
|
if (!hasA && hasB) { quat_to_matrix(qB, out_m); set_translation(out_m, pB); return; }
|
||||||
|
// !hasA && !hasB: undefined by design, caller does not use this path.
|
||||||
|
|
||||||
|
if (dot4(qA + qB, qA + qB) < dot4(qA - qB, qA - qB)) negate4(qB);
|
||||||
|
float q[4];
|
||||||
|
fastproc_quat_blend(qA, qB, blend, q); // g_FastProc[22]
|
||||||
|
quat_to_matrix(q, out_m); // g_FastProc[14]
|
||||||
|
|
||||||
|
float p[3];
|
||||||
|
p[0] = (1.0f - blend) * pA[0] + blend * pB[0];
|
||||||
|
p[1] = (1.0f - blend) * pA[1] + blend * pB[1];
|
||||||
|
p[2] = (1.0f - blend) * pA[2] + blend * pB[2];
|
||||||
|
out_m[3] = p[0];
|
||||||
|
out_m[7] = p[1];
|
||||||
|
out_m[11] = p[2];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Каноническая модель данных для toolchain
|
||||||
|
|
||||||
|
Ниже правила, по которым удобно строить editor/writer. Они верифицированы на corpus (435 моделей), и совпадают с тем, как устроены оригинальные ассеты.
|
||||||
|
|
||||||
|
### 5.1. Декомпозиция key pool на track-и узлов (канонично)
|
||||||
|
|
||||||
|
Для `Res1` stride 38:
|
||||||
|
- `fallback_i = node[i].hdr3`.
|
||||||
|
- `start_i = (i == 0) ? 0 : (fallback_{i-1} + 1)`.
|
||||||
|
- track узла `i` = `Res8[start_i .. fallback_i]`.
|
||||||
|
|
||||||
|
Наблюдаемые инварианты:
|
||||||
|
- `fallback_i` строго возрастает по `i`.
|
||||||
|
- track всегда непустой (`fallback_i >= start_i`).
|
||||||
|
- для узлов без map (`hdr2 == 0xFFFF`) track длиной ровно 1 ключ.
|
||||||
|
- для узлов с map track длиной минимум 2 ключа.
|
||||||
|
|
||||||
|
### 5.2. Временная ось ключей (канонично)
|
||||||
|
|
||||||
|
В observed corpus:
|
||||||
|
- `time` всех ключей — целые неотрицательные float (`0.0, 1.0, ...`).
|
||||||
|
- внутри track: строго возрастают.
|
||||||
|
- `time(start_i) == 0.0` у каждого узла.
|
||||||
|
- глобальный `Res19.attr2 == max_i(time(fallback_i)) + 1`.
|
||||||
|
|
||||||
|
### 5.3. Компоновка Res19 map-блоков (канонично)
|
||||||
|
|
||||||
|
Если `Res19.size > 0`:
|
||||||
|
- map-блоки есть только у узлов с `hdr2 != 0xFFFF`;
|
||||||
|
- длина блока каждого такого узла: `frameCount = Res19.attr2`;
|
||||||
|
- блоки идут подряд, без дыр и overlap;
|
||||||
|
- итог: `Res19.attr1 == animated_node_count * frameCount`.
|
||||||
|
|
||||||
|
Если модель статическая:
|
||||||
|
- `Res19.size == 0`, `Res19.attr1 == 0`, `Res19.attr2 == 1`, `Res19.attr3 == 2`;
|
||||||
|
- у всех узлов `hdr2 == 0xFFFF`.
|
||||||
|
|
||||||
|
### 5.4. Семантика `map_words[f]` в каноничном writer
|
||||||
|
|
||||||
|
Для кадра `f` и track `keys[start..end]`:
|
||||||
|
- если `f < keys[start].time` или `f >= keys[end].time` -> писать `fallback = end`;
|
||||||
|
- иначе писать индекс левого ключа сегмента (`start <= idx < end`) такого, что:
|
||||||
|
- `keys[idx].time <= f < keys[idx+1].time`.
|
||||||
|
|
||||||
|
В исходных данных fallback-фреймы кодируются значением `== fallback` (не просто `>= fallback`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Reference IR для редактора/конвертера
|
||||||
|
|
||||||
|
Рекомендуемое промежуточное представление:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct NodeAnimTrack {
|
||||||
|
uint32_t node_index;
|
||||||
|
bool has_map; // hdr2 != 0xFFFF
|
||||||
|
uint16_t fallback_key; // hdr3 (derived on write)
|
||||||
|
vector<AnimKey> keys; // local keys for node
|
||||||
|
vector<uint16_t> frame_map; // optional, size == frame_count when has_map
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AnimModel {
|
||||||
|
uint32_t frame_count; // Res19.attr2
|
||||||
|
vector<NodeAnimTrack> tracks; // in node order
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Где `AnimKey`:
|
||||||
|
- `pos: float3`,
|
||||||
|
- `time: float`,
|
||||||
|
- `quat_raw: int16[4]` (для lossless),
|
||||||
|
- `quat_decoded: float4` (опционально для API/UI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Алгоритм чтения (reader)
|
||||||
|
|
||||||
|
1. Загрузить `Res1`, `Res8`, `Res19`.
|
||||||
|
2. Проверить `Res8.size % 24 == 0`, `Res19.size % 2 == 0`.
|
||||||
|
3. Для каждого узла `i` (stride 38):
|
||||||
|
- взять `hdr2/hdr3`;
|
||||||
|
- вычислить `start_i` через предыдущий `hdr3`;
|
||||||
|
- извлечь `keys[start_i..hdr3]`;
|
||||||
|
- если `hdr2 != 0xFFFF`, взять `frame_map = Res19[hdr2 : hdr2 + frame_count]`.
|
||||||
|
4. Валидировать, что map-значения либо `< hdr3`, либо fallback (`== hdr3` канонично).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Алгоритм записи (writer)
|
||||||
|
|
||||||
|
Нормативный минимум для runtime-совместимости:
|
||||||
|
|
||||||
|
1. Собрать keys всех узлов в один `Res8` pool в node-order.
|
||||||
|
2. Записать `hdr3 = end_index` каждого узла.
|
||||||
|
3. Вычислить `frame_count` и записать в `Res19.attr2`.
|
||||||
|
4. Для узлов с map:
|
||||||
|
- `hdr2 = cursor`;
|
||||||
|
- append `frame_count` слов в `Res19`;
|
||||||
|
- `cursor += frame_count`.
|
||||||
|
5. Для узлов без map: `hdr2 = 0xFFFF`.
|
||||||
|
6. Выставить атрибуты:
|
||||||
|
- `Res8.attr1 = key_count`, `Res8.attr2 = 0`, `Res8.attr3 = 4`;
|
||||||
|
- `Res19.attr1 = map_word_count`, `Res19.attr3 = 2`.
|
||||||
|
|
||||||
|
Каноничный writer (рекомендуется):
|
||||||
|
- генерирует map по правилу §5.4;
|
||||||
|
- fallback-фреймы записывает `== fallback`;
|
||||||
|
- для статических узлов использует 1 ключ (`time=0`, `hdr2=0xFFFF`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Валидация перед сохранением
|
||||||
|
|
||||||
|
Обязательные проверки:
|
||||||
|
|
||||||
|
1. `Res8.size % 24 == 0`, `Res19.size % 2 == 0`.
|
||||||
|
2. Для каждого узла: `fallbackKeyIndex < key_count`.
|
||||||
|
3. Если `hdr2 != 0xFFFF`: `hdr2 + frame_count <= map_word_count`.
|
||||||
|
4. Для map-сегмента узла:
|
||||||
|
- любое значение `< fallback` должно удовлетворять `value + 1 < key_count`.
|
||||||
|
5. В track узла:
|
||||||
|
- `time` строго возрастает;
|
||||||
|
- при наличии map минимум 2 ключа.
|
||||||
|
6. `frame_count > 0` (игровые ассеты используют минимум 1).
|
||||||
|
|
||||||
|
Рекомендуемые проверки (каноничность):
|
||||||
|
|
||||||
|
1. `fallback_i` строго возрастает по узлам.
|
||||||
|
2. track каждого узла начинается с `time == 0`.
|
||||||
|
3. `frame_count == max_end_time + 1`.
|
||||||
|
4. map-блоки узлов без дыр/overlap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Edge cases и совместимость
|
||||||
|
|
||||||
|
### 10.1. `Res19.size == 0`
|
||||||
|
|
||||||
|
Поддерживается runtime-ом:
|
||||||
|
- `frame_count` обычно 1;
|
||||||
|
- `hdr2 == 0xFFFF` у всех узлов;
|
||||||
|
- сэмплирование всегда через fallback key (`hdr3`).
|
||||||
|
|
||||||
|
### 10.2. Узлы без map
|
||||||
|
|
||||||
|
Это нормальный режим для статических/квазистатических узлов:
|
||||||
|
- `hdr2 = 0xFFFF`;
|
||||||
|
- `hdr3` указывает на единственный ключ узла (канонично).
|
||||||
|
|
||||||
|
### 10.3. `Res1.attr3 == 24` (legacy outlier)
|
||||||
|
|
||||||
|
В corpus встречается единично (`MTCHECK.MSH`).
|
||||||
|
Алгоритм из `sub_10012880` адресует node как stride 38, поэтому этот вариант нужно трактовать как отдельный legacy-формат и не применять к нему правила `hdr2/hdr3` из данного документа без дополнительного реверса.
|
||||||
|
|
||||||
|
### 10.4. Квантование quaternion при экспорте
|
||||||
|
|
||||||
|
Для новых данных:
|
||||||
|
- используйте `round(q * 32767)`;
|
||||||
|
- clamp к `[-32767, 32767]` (каноничный диапазон ассетов).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Reference pseudocode (1:1 runtime path)
|
||||||
|
|
||||||
|
```c
|
||||||
|
void sample_node_pose(Model *m, int node_idx, float t, float out_quat[4], float out_pos[3]) {
|
||||||
|
Node38 *node = (Node38 *)((uint8_t *)m->res1 + node_idx * 38);
|
||||||
|
uint16_t map_start = node->hdr2;
|
||||||
|
uint16_t fallback = node->hdr3;
|
||||||
|
uint32_t frame_cnt = m->anim_frame_count; // Res19.attr2
|
||||||
|
|
||||||
|
int32_t frame = x87_fistp_i32((double)t - 0.5); // strict path
|
||||||
|
|
||||||
|
uint16_t key_idx;
|
||||||
|
if ((uint32_t)frame >= frame_cnt ||
|
||||||
|
map_start == 0xFFFF ||
|
||||||
|
m->res19[map_start + (uint32_t)frame] >= fallback) {
|
||||||
|
key_idx = fallback;
|
||||||
|
decode_key_quat_pos(&m->res8[key_idx], out_quat, out_pos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
key_idx = m->res19[map_start + (uint32_t)frame];
|
||||||
|
AnimKey24 *k0 = &m->res8[key_idx];
|
||||||
|
if (t == k0->time) {
|
||||||
|
decode_key_quat_pos(k0, out_quat, out_pos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimKey24 *k1 = &m->res8[key_idx + 1];
|
||||||
|
if (t == k1->time) {
|
||||||
|
decode_key_quat_pos(k1, out_quat, out_pos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
float a = (t - k0->time) / (k1->time - k0->time);
|
||||||
|
out_pos[0] = lerp(k0->pos_x, k1->pos_x, a);
|
||||||
|
out_pos[1] = lerp(k0->pos_y, k1->pos_y, a);
|
||||||
|
out_pos[2] = lerp(k0->pos_z, k1->pos_z, a);
|
||||||
|
fastproc_quat_interp(decode_quat(k0), decode_quat(k1), a, out_quat); // g_FastProc[17]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@@ -181,6 +181,47 @@ def _write_ppm(path: Path, width: int, height: int, rgb: bytearray) -> None:
|
|||||||
handle.write(rgb)
|
handle.write(rgb)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_obj(
|
||||||
|
path: Path,
|
||||||
|
terrain_positions: list[tuple[float, float, float]],
|
||||||
|
terrain_faces: list[tuple[int, int, int]],
|
||||||
|
areals: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
include_areals: bool,
|
||||||
|
) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8", newline="\n") as out:
|
||||||
|
out.write("# Exported by terrain_map_preview_renderer.py\n")
|
||||||
|
out.write("o terrain\n")
|
||||||
|
for x, y, z in terrain_positions:
|
||||||
|
out.write(f"v {x:.9g} {y:.9g} {z:.9g}\n")
|
||||||
|
for i0, i1, i2 in terrain_faces:
|
||||||
|
# OBJ indices are 1-based.
|
||||||
|
out.write(f"f {i0 + 1} {i1 + 1} {i2 + 1}\n")
|
||||||
|
|
||||||
|
if include_areals and areals:
|
||||||
|
base = len(terrain_positions)
|
||||||
|
area_vertex_counts: list[int] = []
|
||||||
|
out.write("o areal_edges\n")
|
||||||
|
for area in areals:
|
||||||
|
verts = area["vertices"]
|
||||||
|
area_vertex_counts.append(len(verts))
|
||||||
|
for x, y, z in verts:
|
||||||
|
out.write(f"v {x:.9g} {y:.9g} {z:.9g}\n")
|
||||||
|
|
||||||
|
ptr = base
|
||||||
|
for area_idx, area in enumerate(areals):
|
||||||
|
cnt = area_vertex_counts[area_idx]
|
||||||
|
if cnt < 2:
|
||||||
|
ptr += cnt
|
||||||
|
continue
|
||||||
|
# closed polyline.
|
||||||
|
line = [str(ptr + i + 1) for i in range(cnt)]
|
||||||
|
line.append(str(ptr + 1))
|
||||||
|
out.write("l " + " ".join(line) + "\n")
|
||||||
|
ptr += cnt
|
||||||
|
|
||||||
|
|
||||||
def _render_scene(
|
def _render_scene(
|
||||||
terrain_positions: list[tuple[float, float, float]],
|
terrain_positions: list[tuple[float, float, float]],
|
||||||
terrain_faces: list[tuple[int, int, int]],
|
terrain_faces: list[tuple[int, int, int]],
|
||||||
@@ -430,6 +471,90 @@ def cmd_render(args: argparse.Namespace) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_export_obj(args: argparse.Namespace) -> int:
|
||||||
|
msh_path = Path(args.land_msh).resolve()
|
||||||
|
map_path = Path(args.land_map).resolve() if args.land_map else None
|
||||||
|
output_path = Path(args.output).resolve()
|
||||||
|
|
||||||
|
positions, faces, terrain_meta = load_terrain_msh(msh_path, max_faces=int(args.max_faces))
|
||||||
|
areals: list[dict[str, Any]] = []
|
||||||
|
if map_path and bool(args.include_areals):
|
||||||
|
areals, _ = load_areal_map(map_path)
|
||||||
|
|
||||||
|
_write_obj(
|
||||||
|
output_path,
|
||||||
|
positions,
|
||||||
|
faces,
|
||||||
|
areals,
|
||||||
|
include_areals=bool(args.include_areals),
|
||||||
|
)
|
||||||
|
|
||||||
|
areal_vertices = sum(len(a["vertices"]) for a in areals)
|
||||||
|
print(f"Terrain source : {msh_path}")
|
||||||
|
if map_path:
|
||||||
|
print(f"Areal source : {map_path}")
|
||||||
|
print(f"OBJ output : {output_path}")
|
||||||
|
print(
|
||||||
|
"Terrain geometry : "
|
||||||
|
f"vertices={terrain_meta['vertex_count']}, "
|
||||||
|
f"faces={terrain_meta['face_count_rendered']}/{terrain_meta['face_count_valid']}"
|
||||||
|
)
|
||||||
|
if bool(args.include_areals):
|
||||||
|
print(f"Areal edges : areals={len(areals)}, extra_vertices={areal_vertices}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_render_turntable(args: argparse.Namespace) -> int:
|
||||||
|
msh_path = Path(args.land_msh).resolve()
|
||||||
|
map_path = Path(args.land_map).resolve() if args.land_map else None
|
||||||
|
output_dir = Path(args.output_dir).resolve()
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
frames = int(args.frames)
|
||||||
|
if frames <= 0:
|
||||||
|
raise RuntimeError("--frames must be > 0")
|
||||||
|
|
||||||
|
positions, faces, terrain_meta = load_terrain_msh(msh_path, max_faces=int(args.max_faces))
|
||||||
|
areals: list[dict[str, Any]] = []
|
||||||
|
if map_path:
|
||||||
|
areals, _ = load_areal_map(map_path)
|
||||||
|
|
||||||
|
yaw_start = float(args.yaw_start)
|
||||||
|
yaw_end = float(args.yaw_end)
|
||||||
|
if frames == 1:
|
||||||
|
yaws = [yaw_start]
|
||||||
|
else:
|
||||||
|
step = (yaw_end - yaw_start) / (frames - 1)
|
||||||
|
yaws = [yaw_start + i * step for i in range(frames)]
|
||||||
|
|
||||||
|
prefix = str(args.prefix)
|
||||||
|
for i, yaw in enumerate(yaws):
|
||||||
|
rgb = _render_scene(
|
||||||
|
positions,
|
||||||
|
faces,
|
||||||
|
areals,
|
||||||
|
width=int(args.width),
|
||||||
|
height=int(args.height),
|
||||||
|
yaw_deg=yaw,
|
||||||
|
pitch_deg=float(args.pitch),
|
||||||
|
wireframe=bool(args.wireframe),
|
||||||
|
areal_overlay=bool(args.overlay_areals),
|
||||||
|
)
|
||||||
|
out = output_dir / f"{prefix}_{i:03d}.ppm"
|
||||||
|
_write_ppm(out, int(args.width), int(args.height), rgb)
|
||||||
|
|
||||||
|
print(f"Turntable source : {msh_path}")
|
||||||
|
if map_path:
|
||||||
|
print(f"Areal source : {map_path}")
|
||||||
|
print(f"Output dir : {output_dir}")
|
||||||
|
print(f"Frames : {frames} ({yaws[0]:.3f} -> {yaws[-1]:.3f} yaw)")
|
||||||
|
print(
|
||||||
|
"Terrain geometry : "
|
||||||
|
f"vertices={terrain_meta['vertex_count']}, faces={terrain_meta['face_count_rendered']}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_render_batch(args: argparse.Namespace) -> int:
|
def cmd_render_batch(args: argparse.Namespace) -> int:
|
||||||
maps_root = Path(args.maps_root).resolve()
|
maps_root = Path(args.maps_root).resolve()
|
||||||
output_dir = Path(args.output_dir).resolve()
|
output_dir = Path(args.output_dir).resolve()
|
||||||
@@ -489,6 +614,38 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
)
|
)
|
||||||
render.set_defaults(func=cmd_render)
|
render.set_defaults(func=cmd_render)
|
||||||
|
|
||||||
|
export_obj = sub.add_parser("export-obj", help="Export terrain (and optional areal edges) to OBJ.")
|
||||||
|
export_obj.add_argument("--land-msh", required=True, help="Path to Land.msh")
|
||||||
|
export_obj.add_argument("--land-map", help="Path to Land.map (optional)")
|
||||||
|
export_obj.add_argument("--output", required=True, help="Output .obj path")
|
||||||
|
export_obj.add_argument("--max-faces", type=int, default=0, help="Face limit (0 = all)")
|
||||||
|
export_obj.add_argument(
|
||||||
|
"--include-areals",
|
||||||
|
action="store_true",
|
||||||
|
help="Export areal polygons as OBJ polyline object",
|
||||||
|
)
|
||||||
|
export_obj.set_defaults(func=cmd_export_obj)
|
||||||
|
|
||||||
|
turn = sub.add_parser("render-turntable", help="Render turntable frame sequence to PPM.")
|
||||||
|
turn.add_argument("--land-msh", required=True, help="Path to Land.msh")
|
||||||
|
turn.add_argument("--land-map", help="Path to Land.map (optional)")
|
||||||
|
turn.add_argument("--output-dir", required=True, help="Output directory for frames")
|
||||||
|
turn.add_argument("--prefix", default="frame", help="Frame filename prefix (default: frame)")
|
||||||
|
turn.add_argument("--frames", type=int, default=36, help="Frame count (default: 36)")
|
||||||
|
turn.add_argument("--yaw-start", type=float, default=0.0, help="Start yaw in degrees (default: 0)")
|
||||||
|
turn.add_argument("--yaw-end", type=float, default=360.0, help="End yaw in degrees (default: 360)")
|
||||||
|
turn.add_argument("--pitch", type=float, default=26.0, help="Pitch angle in degrees (default: 26)")
|
||||||
|
turn.add_argument("--max-faces", type=int, default=160000, help="Face limit (default: 160000)")
|
||||||
|
turn.add_argument("--width", type=int, default=960, help="Image width (default: 960)")
|
||||||
|
turn.add_argument("--height", type=int, default=540, help="Image height (default: 540)")
|
||||||
|
turn.add_argument("--wireframe", action="store_true", help="Draw terrain wireframe overlay")
|
||||||
|
turn.add_argument(
|
||||||
|
"--overlay-areals",
|
||||||
|
action="store_true",
|
||||||
|
help="Draw ArealMap polygon overlay",
|
||||||
|
)
|
||||||
|
turn.set_defaults(func=cmd_render_turntable)
|
||||||
|
|
||||||
batch = sub.add_parser("render-batch", help="Render all MAPS/**/Land.msh under root.")
|
batch = sub.add_parser("render-batch", help="Render all MAPS/**/Land.msh under root.")
|
||||||
batch.add_argument(
|
batch.add_argument(
|
||||||
"--maps-root",
|
"--maps-root",
|
||||||
@@ -520,4 +677,3 @@ def main() -> int:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user