3DO — Modelos 3D hierárquicos
Fonte: coreprime/kbot @ 0ab97bf · Editar no GitHub
Um ficheiro
.3doé uma árvore de objetos 3D: vértices, primitivas e referências a texturas organizados numa hierarquia de pais e filhos. Todas as unidades do jogo, todos os elementos estáticos do mapa com geometria e todos os projéteis com modelo 3D usam este formato. Um 3DO não tem dados de animação: as peças são movidas durante o jogo pelo script COB da unidade.
Tip
Experimente por si.
# 3DO is not currently surfaced as a kbot CLI subcommand, but it's
# rendered live by the Asset Explorer:
kbot mount ~/games/totala --server # opens the web UI
# then browse to objects3d/armcom.3do
Consulte a referência de kbot mount da linha de comandos.
A partir de Go. Use formats/objects3d:
import "github.com/coreprime/kbot/formats/objects3d"
model, _ := objects3d.LoadFromFile("objects3d/armcom.3do")
fmt.Println(model.Root.Name, "—",
model.TotalVertices(), "verts,",
model.TotalPrimitives(), "prims,",
"textures:", model.Textures())
for _, obj := range model.AllObjects {
fmt.Printf(" %s (%d verts)\n", obj.Name, len(obj.Vertices))
}
Porque é que o 3DO é feito assim
O .3do foi a resposta da Cavedog à pergunta "como deixamos os scripts
animar uma unidade sem cálculos matriciais caros?". A resposta é
hierarquia + transformações por peça. A unidade é uma árvore de peças
com nome:
armcom (root)
├── pelvis
│ ├── torso
│ │ ├── lupperarm
│ │ │ └── lforearm
│ │ │ └── lhand
│ │ ├── rupperarm ...
│ │ └── head
│ ├── lthigh
│ │ └── lleg
│ └── rthigh
│ └── rleg
└── ...
Quando o script COB diz turn rupperarm to x-axis <30> speed <60>;, o
motor roda exatamente essa peça, e tudo o que está preso por baixo dela
(antebraço, mão, cano da arma) acompanha automaticamente. Não há pesos de
skinning nem ossos no sentido moderno: cada peça é uma submalha rígida,
presa a um deslocamento fixo em relação ao pai.
Disposição no disco
Cada objeto é um cabeçalho de 52 bytes seguido de payloads em vetores com nome. O cabeçalho contém os deslocamentos para os filhos, os irmãos, os vértices e as primitivas.
typedef struct {
int32 VersionSignature; // Always 1
int32 NumberOfVertexes;
int32 NumberOfPrimitives;
int32 OffsetToSelectionPrim; // → primitive used as selection rectangle
// for the root; -1 for non-root objects
int32 XFromParent; // Fixed-point offset from parent piece
int32 YFromParent; // (Y is up; the engine renders Y-up)
int32 ZFromParent;
int32 OffsetToObjectName; // → NUL-terminated piece name string
int32 Always0;
int32 OffsetToVertexArray;
int32 OffsetToPrimitiveArray;
int32 OffsetToSiblingObject; // 0 = end of sibling chain
int32 OffsetToChildObject; // 0 = leaf piece
} Object;
Um vértice tem três componentes int32 em vírgula fixa:
typedef struct { int32 X, Y, Z; } Vertex;
A convenção da vírgula fixa é parte inteira = unidade do mundo do jogo; divida por 65536 para obter precisão abaixo da unidade. As unidades da Cavedog medem normalmente entre dezenas e centenas de unidades do mundo.
Uma primitiva é uma face (ou um único ponto, ou uma linha):
typedef struct {
int32 ColorIndex; // Palette index for solid-coloured faces (low byte used)
int32 NumberOfVertexIndexes; // 1 = point, 2 = line, 3 = tri, 4 = quad
int32 Always0;
int32 OffsetToVertexIndexArray; // → uint16[NumberOfVertexIndexes]
int32 OffsetToTextureName; // → NUL string, or 0 for untextured
int32 Unknown1; // Editor scratch
int32 Unknown2; // Editor scratch
int32 IsColored; // flag word: bit 0 set = solid-coloured
} Primitive;
O vetor de índices de vértices referido por OffsetToVertexIndexArray é
uma lista simples de índices uint16 para o vetor de vértices local do
objeto (e não para um conjunto global).
Percorrer a hierarquia
Cada cabeçalho de objeto aponta para o primeiro filho e para o próximo irmão. É a clássica codificação de árvores "primeiro filho / próximo irmão", para a qual é fácil escrever um percurso em profundidade:
def walk(file, offset, depth=0):
obj = read_object(file, offset)
yield (depth, obj.name)
if obj.child_offset:
yield from walk(file, obj.child_offset, depth + 1)
if obj.sibling_offset:
yield from walk(file, obj.sibling_offset, depth)
O objeto raiz está no deslocamento 0 do ficheiro. Em todos os modelos
da versão comercial, o seu SiblingOffset é 0, mas o jogo também segue a
cadeia de irmãos da raiz: cada irmão da raiz (e a sua subárvore) é
carregado como mais uma peça de topo depois da subárvore da raiz, e é
desenhado nas suas próprias coordenadas de vértices, porque a transformação
de peças do jogo começa na raiz e não lhes aplica deslocamentos de peça. O
JSON de modelo do kbot guarda-os como rootSiblings, e o explorador de
recursos conta-os e lista-os.
Cada nome de peça do script COB tem de corresponder a um nome de peça desta
árvore (sem distinguir maiúsculas de minúsculas); se não corresponder,
a animação do script não faz nada, sem qualquer aviso.
Texturas
OffsetToTextureName aponta para uma cadeia de caracteres ASCII com o nome de uma
sequência de anims/textures.gaf (ou do equivalente no TA: Kingdoms).
Algumas coisas a saber:
- Não há coordenadas UV. O motor gera as UV a partir da ordem dos
vértices da primitiva. Para "rodar" uma textura, reordene os índices de
vértices no
VertexIndexArrayda primitiva. O editor original, o 3do Builder, apresentava isto como um controlo de "orientação da textura". IsColoredé uma palavra de flags e só o bit 0 conta. Mais de metade das primitivas da versão comercial guardam aí outros valores (faces texturizadas com o bit 0 a zero, faces sem textura com o bit 0 a um). Uma face com o bit 0 a um é preenchida com o byte inferior deColorIndex.OffsetToTextureName == 0significa que não há textura. Se o bit 0 deIsColoredtambém estiver a zero, a primitiva não é desenhada: a Cavedog usa isto para faces que servem só para colisões ou apenas como âncoras para os scripts.- Um nome de textura que não se resolve (procurado sem distinguir
maiúsculas de minúsculas) preenche a face com o índice da paleta
0xd1, tenha cor ou não. - No TA, só as faces de quatro cantos levam textura, com o canto 0 no texel superior esquerdo da textura e os restantes no sentido dos ponteiros do relógio. Um triângulo ou polígono de n lados texturizado e sem cor não é desenhado (os modelos do TA: Kingdoms texturizam-nos).
- As faces só são desenhadas pela frente: o lado para onde a ordem dos cantos aponta pela regra da mão direita, nas coordenadas do ficheiro.
- Nos ficheiros feitos pela Cavedog, o conjunto de nomes de texturas
começa no deslocamento
0x34do ficheiro (logo a seguir ao cabeçalho do objeto raiz). É uma convenção, não um requisito do formato.
Important
Como o jogo desenha uma primitiva (Primitive.Style do kbot-io):
if (texture_name != "" && !texture_found) fill(0xd1);
else if (IsColored & 1) fill(ColorIndex & 0xff);
else if (texture_found && corners == 4) texture();
else /* not drawn */;
Errar nisto é o defeito de renderização de 3DO mais comum: um modelo com
faces só de colisão acaba preenchido com cores da paleta ao acaso. O JSON
de modelo do visualizador de unidades do kbot aplica estas regras no
servidor (cada primitiva traz style, fillIndex e colorRGB, e as
faces que o jogo nunca desenha ficam de fora), e as pré-visualizações de
objetos do painel de elementos são renderizadas com elas.
Exemplo comentado — armsy.3do (Estaleiro ARM)
O percurso original de Dark Rain pelo formato usava o Estaleiro ARM. A leitura do cabeçalho raiz dá:
00000000 Object
01 00 00 00 VersionSignature = 1
C4 00 00 00 VertexCount = 196 (0xC4)
6D 00 00 00 PrimitiveCount = 109 (0x6D)
00 00 00 00 SelectionPrim = 0 (= primitive #0)
00 00 00 00 X XFromParent = 0
00 00 00 00 Y YFromParent = 0
00 00 00 00 Z ZFromParent = 0
E5 1A 00 00 → "base"
00 00 00 00 Always0
15 04 00 00 → VertexArray
45 0D 00 00 → PrimitiveArray
00 00 00 00 SiblingOffset = 0 (root has no sibling)
EA 1A 00 00 → child object (turret1)
Seguir a cadeia de filhos e irmãos produz a árvore de peças:
base
├── turret1
│ └── nano1
│ └── beam1
├── turret2
│ └── nano2
│ └── beam2
├── slip
├── light
├── explode
├── explode1
└── explode2
Algumas folhas, como slip, são objetos com um único vértice e zero
primitivas: existem apenas como pontos de ancoragem para os scripts
COB (emit-sfx from slip;, move slip to z-axis [-10] speed [20];).
Armadilhas
Warning
O eixo vertical é o Y, não o Z. As ferramentas da Cavedog e a maior parte da documentação de terceiros usam o Y como eixo vertical. Se importar um 3DO para um motor com o Z para cima (Blender, Unreal moderno) sem trocar os eixos, todas as unidades ficam deitadas de lado.
- Os vértices são locais ao objeto, não globais. Dois objetos podem ter vetores de vértices idênticos: é sinal de geometria espelhada, não de dados partilhados.
OffsetToSelectionPrimé um índice, e essa primitiva nunca é desenhada. O jogo move-a para a posição 0 e salta a posição 0. Os modelos da versão comercial também guardam índices nas peças filhas;-1significa nenhuma. O JSON de modelo do kbot mantém a primitiva oculta marcada comohiddene apontaselectionPrimpara ela.Always0é mesmo sempre 0 nos conteúdos observados. Desde 1998 que é o campo do "avisem-nos se encontrarem um contraexemplo", e ninguém encontrou.- A escala da vírgula fixa é
1 / 65536, ou seja, os 16 bits inferiores de cada coordenadaint32são a parte fracionária. Um vértice escrito como0x00010000vale "1.0 unidades do mundo". - Os nomes de texturas não distinguem maiúsculas de minúsculas, mas conservam-nas. O motor calcula o hash das cadeias em minúsculas; o ficheiro guarda as maiúsculas e minúsculas que o editor produziu.
- O TA: Kingdoms usa uma variante evoluída, com dados adicionais por
primitiva e um verdadeiro modelo de skinning. O kbot ainda não a analisa;
o projeto iiCompleteDestruction (veja o
CLAUDE.mddo kbot) tem código funcional para as malhas do TA:K.
Tamanhos típicos
| Recurso | Intervalo observado em objects3d/*.3do da Cavedog |
|---|---|
| Tamanho do ficheiro | 400 B – 60 KB |
| Vértices por modelo | 8 – 1000 (Comandante: ~250) |
| Primitivas por modelo | 6 – 800 |
| Peças por modelo | 1 – 80 (Krogoth) |
box.3do (cubo trivial) |
420 B |
armcom.3do (Comandante ARM) |
~25 KB |
corkrog.3do (Krogoth) |
~60 KB |
| Cabeçalho por objeto | sempre 52 bytes |
| Cabeçalho por primitiva | sempre 32 bytes |
Apêndice — Dissecação hexadecimal completa de objects3d/box.3do
box.3do é um cubo de 420 bytes: um objeto raiz, 8 vértices, 6 faces
quadriláteras, 4 texturas diferentes, sem filhos. É pequeno o suficiente
para ser percorrido de uma ponta à outra e usa todos os campos do formato.
Dump hexadecimal completo:
00000000: 0100 0000 0800 0000 0600 0000 0400 0000 ← Object header
00000010: 0000 0000 0000 0000 0000 0000 a001 0000 (52 bytes)
00000020: 0000 0000 8000 0000 e000 0000 0000 0000
00000030: 0000 0000 4172 6d56 3362 0041 726d 5633 ← Texture-name pool
00000040: 6300 4172 6d56 3361 0041 726d 5633 6400 "ArmV3b\0..."
00000050: 0100 0200 0000 0300 0200 0600 0400 0000 ← Vertex-index array
00000060: 0600 0200 0100 0500 0500 0100 0300 0700 (uint16 × 24)
00000070: 0000 0400 0700 0300 0600 0500 0700 0400
00000080: 0000 f0ff 0000 0000 0000 f0ff 0000 f0ff ← Vertex array
00000090: 0080 1f00 0000 1000 0000 f0ff 0080 1f00 (8 × 12 bytes)
000000a0: 0000 f0ff 0000 f0ff 0000 0000 0000 1000
000000b0: 0000 1000 0000 0000 0000 f0ff 0000 1000
000000c0: 0080 1f00 0000 1000 0000 1000 0080 1f00
000000d0: 0000 f0ff 0000 1000 0000 0000 0000 1000
000000e0: 0000 0000 0400 0000 0000 0000 5000 0000 ← Primitive 0
000000f0: 3400 0000 0000 0000 0000 0000 0000 0000 (32 bytes)
00000100: 0000 0000 0400 0000 0000 0000 5800 0000 ← Primitive 1
00000110: 3b00 0000 0000 0000 0000 0000 0000 0000
00000120: 0000 0000 0400 0000 0000 0000 6000 0000 ← Primitive 2
00000130: 4200 0000 0000 0000 0000 0000 0000 0000
00000140: 0000 0000 0400 0000 0000 0000 6800 0000 ← Primitive 3
00000150: 3b00 0000 0000 0000 0000 0000 0000 0000
00000160: 0000 0000 0400 0000 0000 0000 7000 0000 ← Primitive 4
00000170: 3b00 0000 0000 0000 0000 0000 0000 0000
00000180: 0000 0000 0400 0000 0000 0000 7800 0000 ← Primitive 5
00000190: 4900 0000 0000 0000 0000 0000 0000 0000
000001a0: 424f 5800 ← "BOX\0"
1. Cabeçalho do objeto raiz (0x00..0x34)
| Deslocamento | Bytes | Campo | Valor |
|---|---|---|---|
0x00 |
01 00 00 00 |
VersionSignature |
1 ✓ |
0x04 |
08 00 00 00 |
NumberOfVertexes |
8 (cantos do cubo) |
0x08 |
06 00 00 00 |
NumberOfPrimitives |
6 (faces do cubo) |
0x0C |
04 00 00 00 |
OffsetToSelectionPrim |
4 |
0x10 |
00 00 00 00 |
XFromParent |
0 |
0x14 |
00 00 00 00 |
YFromParent |
0 |
0x18 |
00 00 00 00 |
ZFromParent |
0 |
0x1C |
a0 01 00 00 |
OffsetToObjectName |
0x1A0 → "BOX" |
0x20 |
00 00 00 00 |
Always0 |
0 |
0x24 |
80 00 00 00 |
OffsetToVertexArray |
0x80 |
0x28 |
e0 00 00 00 |
OffsetToPrimitiveArray |
0xE0 |
0x2C |
00 00 00 00 |
OffsetToSiblingObject |
0 (a raiz não tem irmão) |
0x30 |
00 00 00 00 |
OffsetToChildObject |
0 (folha — sem filhos) |
2. Conjunto de nomes de texturas (0x34..0x50)
Quatro cadeias de caracteres terminadas em NUL, encostadas umas às outras:
| Deslocamento | Cadeia |
|---|---|
0x34 |
"ArmV3b" |
0x3B |
"ArmV3c" |
0x42 |
"ArmV3a" |
0x49 |
"ArmV3d" |
Estas cadeias referem sequências de anims/textures.gaf (ou de uma folha
de paleta do TA:K). Por convenção, a Cavedog põe o conjunto de texturas em
0x34, logo a seguir ao cabeçalho raiz, mas o formato só exige que os
deslocamentos sejam válidos; as cadeias podem estar em qualquer lado.
3. Vetor de índices de vértices (0x50..0x80)
Um bloco simples uint16[24]: seis séries de quatro índices, uma série por
face quadrilátera. Lidos dois bytes de cada vez:
| Deslocamento | Índices | Usado pela primitiva |
|---|---|---|
0x50 |
1, 2, 0, 3 |
Primitiva 0 |
0x58 |
2, 6, 4, 0 |
Primitiva 1 |
0x60 |
6, 2, 1, 5 |
Primitiva 2 |
0x68 |
5, 1, 3, 7 |
Primitiva 3 |
0x70 |
0, 4, 7, 3 |
Primitiva 4 |
0x78 |
6, 5, 7, 4 |
Primitiva 5 |
Cada linha é "os quatro cantos (no sentido contrário ao dos ponteiros do relógio) de uma face do cubo", expressos como índices no vetor de vértices abaixo.
4. Vetor de vértices (0x80..0xE0)
Oito vértices × (int32 X, int32 Y, int32 Z) = 96 bytes. Os três
primeiros, lidos com descodificação little-endian:
| # | Bytes | (X, Y, Z) como int32 com sinal |
|---|---|---|
| 0 | 00 00 f0 ff 00 00 00 00 00 00 f0 ff |
(-0x1000, 0, -0x1000) |
| 1 | 00 00 f0 ff 00 80 1f 00 00 00 10 00 |
(-0x1000, +0x1F8000, +0x1000) |
| 2 | 00 00 f0 ff 00 80 1f 00 00 00 f0 ff |
(-0x1000, +0x1F8000, -0x1000) |
| … |
±0x1000 em X e Z põem os cantos a ±4096 unidades de vírgula fixa. O Y vai
de 0 (base) a +0x1F8000 (topo do cubo). Com a escala de vírgula fixa
de 1/65536, obtém-se um cubo de cerca de 0.0625 unidades do mundo em
X/Z, com o Y a chegar a cerca de 31 unidades do mundo de altura: as
ferramentas de criação do TA usam uma convenção de Y esticado para os
"objetos altos".
5. Vetor de primitivas (0xE0..0x1A0)
Seis primitivas × 32 bytes. A primitiva 0, em 0xE0:
| Deslocamento | Bytes | Campo | Valor |
|---|---|---|---|
0xE0 |
00 00 00 00 |
ColorIndex |
0 (ignorado — texturizada) |
0xE4 |
04 00 00 00 |
NumberOfVertexIndexes |
4 (quadrilátero) |
0xE8 |
00 00 00 00 |
Always0 |
0 |
0xEC |
50 00 00 00 |
OffsetToVertexIndexArray |
0x50 (o primeiro grupo de 4 acima) |
0xF0 |
34 00 00 00 |
OffsetToTextureName |
0x34 → "ArmV3b" |
0xF4 |
00 00 00 00 |
Unknown1 |
0 |
0xF8 |
00 00 00 00 |
Unknown2 |
0 |
0xFC |
00 00 00 00 |
IsColored |
0 (texturizada) |
Resumo das seis primitivas:
| # | Deslocamento | VtxIdxArray | TextureName | Textura |
|---|---|---|---|---|
| 0 | 0xE0 |
0x50 |
0x34 |
ArmV3b |
| 1 | 0x100 |
0x58 |
0x3B |
ArmV3c |
| 2 | 0x120 |
0x60 |
0x42 |
ArmV3a |
| 3 | 0x140 |
0x68 |
0x3B |
ArmV3c |
| 4 | 0x160 |
0x70 |
0x3B |
ArmV3c |
| 5 | 0x180 |
0x78 |
0x49 |
ArmV3d |
Três faces partilham a textura ArmV3c, e as outras três têm texturas
próprias. Reutilizar uma textura é apenas reutilizar um deslocamento do
conjunto de texturas: o ficheiro não elimina duplicados de forma
explícita.
6. Nome do objeto (0x1A0..0x1A4)
424f 5800 → "BOX\0"
Tamanho total do ficheiro: 0x1A4 = 420 bytes. ✓
Ler o ficheiro com o kbot
# The web UI dissects 3DOs interactively
kbot mount $(kbot ctx path) --server
# → browse to objects3d/box.3do
A partir de Go (formats/objects3d):
import "github.com/coreprime/kbot/formats/objects3d"
model, _ := objects3d.LoadFromFile("objects3d/box.3do")
fmt.Println(model.Root.Name) // BOX
fmt.Println(len(model.Root.Vertices)) // 8
fmt.Println(len(model.Root.Primitives)) // 6
fmt.Println(model.Textures()) // [ArmV3b ArmV3c ArmV3a ArmV3d]
Exemplos reais no catálogo de referência
Unidades ordenadas pela complexidade do 3DO (o campo Objectname do FBI é
o nome do ficheiro do modelo sem a extensão; veja
reference-ta/ta-units.md):
- Triviais —
BOX(o percurso acima; 420 bytes, 8 vértices, 6 quadriláteros), a maioria das minas (ARMMINE1–6,CORMINE1–6). - Uma só peça —
ARMRAD(radar),ARMTIDE(gerador maremotriz): malhas de um só objeto, boas para estudar a disposição das primitivas. - Kbots bípedes —
ARMCK,ARMHAM,ARMPW: hierarquias de várias peças que pode comparar com o scriptWalkdos respetivos COB. - Veículos com torres —
ARMBULL,ARMSAM,CORREAP: mostram o padrão pai/filhoturret1 → barrel1. - Os modelos maiores —
CORKROG(Krogoth) eARMCOM(Comandante): os ficheiros.3domais complexos do jogo.
Ver também
- COB — os comandos de script que animam as peças do 3DO pelo nome.
- GAF — as páginas de texturas (
anims/textures.gaf) referidas pelas primitivas do 3DO. - PAL — a paleta usada para colorir as primitivas sem textura.
- FBI — as definições de unidades que referem os modelos 3DO por
ObjectName(= nome do ficheiro 3DO sem a extensão). - Glossário — peça, objeto âncora, vírgula fixa.