Todas as páginas de formatos ↓
Unidades, scripts e ficheiros de configuração

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 VertexIndexArray da 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 de ColorIndex.
  • OffsetToTextureName == 0 significa que não há textura. Se o bit 0 de IsColored també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 0x34 do 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; -1 significa nenhuma. O JSON de modelo do kbot mantém a primitiva oculta marcada como hidden e aponta selectionPrim para 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 coordenada int32 são a parte fracionária. Um vértice escrito como 0x00010000 vale "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.md do 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 script Walk dos respetivos COB.
  • Veículos com torres — ARMBULL, ARMSAM, CORREAP: mostram o padrão pai/filho turret1 → barrel1.
  • Os modelos maiores — CORKROG (Krogoth) e ARMCOM (Comandante): os ficheiros .3do mais 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.