DESTINY 2

World geometry and collision

A map’s static geometry has three separate parts: instance tables the renderer draws, a baked visibility database that culls them, and a baked Havok compound that collides with them. The three share no index. They agree because the content tools wrote matching transforms into each.

Class ids are package classes. Havok class names such as hkpStaticCompoundShape are the engine’s own, from Havok’s reflection in the image. Other names are descriptive.

Static instances

A map container (class 0x80808A54) names object lists. An object-list entry can carry a static instance owner (class 0x80806EF4), which names an instance table (class 0x8080966D). The table names a paired bounds table (class 0x80809671).

table and fieldelementstridecontent
instance table +0x400x808071A348one instance: transform, two LOD scales, ambient-occlusion offset and flag
instance table +0x580x8080967D4renderer resource tags
instance table +0x680x808071908ranges {u16 count, u16 first, u16 resource, u16 flags}
instance table +0x80–32the table-wide bounding box
bounds table +80x8080967348local min, local max, a 64-bit visibility id at +0x20

The ranges partition the instance rows, and each range selects one resource. The Leviathan map has 31 instance tables and 63,883 instance rows.

The instance row

row offsettypecontent
+0x00float4rotation quaternion
+0x10float3translation
+0x1Cfloatuniform scale
+0x20floatmultiplier for the resource’s first two LOD distances
+0x24floatmultiplier for the third LOD distance
+0x28u32ambient-occlusion byte offset
+0x2Cu8ambient-occlusion flag

The row is in the owner placement’s space. The renderer composes the two transforms before building a matrix:

q_world = q_owner * q_row
t_world = s_owner * rotate(q_owner, t_row) + t_owner
s_world = s_owner * s_row

The composed scale applies to the drawn mesh, the fine cull sphere and the LOD distances. A placement’s fourth translation lane is a uniform scale, not a homogeneous W of 1.0: on Leviathan it is 1.0 on 4,738 of 6,100 placements and other values on the rest.

Culling

Every instance passes two tests. The coarse test sets a per-instance visibility bit. The fine test checks the cull sphere and picks the LOD. The fine test can only remove more.

The owner’s byte +0x20 picks how the coarse bit is made:

owner +0x20modecoarse bit
1table modeeach row’s visibility id is looked up in the bubble’s id map; the bounds boxes are never read
0per-instance modeone visibility entry per bounds row, using that row’s box

On Leviathan, 26 of 31 owners use table mode. In table mode a row whose id is not a key in the active id map is never drawn. In per-instance mode the bounds row is in instance-table space: only the owner placement moves it, so it must already enclose the instance’s moved mesh.

Owner byte +0x21 set skips the coarse bits for the whole table, and makes the table follow hierarchy hiding. The game’s own tables with +0x20 and +0x21 both set (1,166 owners) all use an empty bounds table.

A box moved by a placement must have min at or below max in all four lanes, or it becomes the empty box and never shows. Both W lanes must be 1.0.

A visibility entry whose id is a key in the current bubble’s id map is culled by a baked visibility database, called the tome here. Its own box is never tested. An entry whose id is not a key is culled every frame by its own box, against the view frustum and, when one exists, an occlusion buffer. “The tome format” below has the tome’s layout.

The table-wide box gates the whole table in both modes. When that box is culled, no instance of the table is processed. On Leviathan its id is all ones, which is never a key, so it is culled by its own box.

So an instance scaled or moved past its baked extent vanishes whenever the old extent is out of view. To make culling follow a new box, the table must be in per-instance mode and the row must carry a fresh id: one that is not a key in any id map of any bubble where the table can be current. Moving a whole table to per-instance mode is limited: per-instance entries share a 4096-entry pool, and the allocation does not fail safely when it runs out. A fresh id in table mode hides the row instead. How Sunrise Studio edits an instance is on Static collision internals.

The id map

The id map (class 0x80807320) is placed per bubble. Its key at +0x50 is the bubble index. The client activates the map whose key matches the new bubble. It holds two tome tags, a 16,384-bucket hash table of {u64 id, u32 slot}, the map bounds and up to 20 view volumes. On Leviathan one map holds 6,974 ids over 2,835 tome objects, so several instances share one tome object. The map also lists the gate ids, in tome 0’s gate order.

The two tomes

An id map names two tomes. They are two bakes of the same map with different settings, and they serve different readers:

tomereaders
0instance visibility, the zone-crossing lookup, object cells, high-mip streaming of static geometry
1audio spatial connectivity, object cells

The id map’s object indices fit only tome 0.

The visibility query reads only the tome, the camera, the gate bits and an object filter. No instance box or mesh enters it. So occlusion comes only from the bake: a static instance baked as an occluder still blocks at its old place after it is moved, scaled or hidden, and blocks nothing at its new place. When the camera is outside every view volume of the id map, the query runs without occlusion and the per-box entries are tested against the frustum only.

Gates

A gate is a tome portal the game opens and closes at run time, such as a door. The visibility state holds 256 gate bits, all open when a bubble activates. Gate n is bit n. A gate entry is owned by a component of class 0x80807023, whose class messages attach, detach, open and close it. Where its definition and its open and close callers come from is not traced. Tome 0 on Leviathan has 9 gates.

The tome format

Every installed tome is an Umbra tome of format 18, written by the Umbra 3.3.6 optimizer. The client accepts a tome only when its first u16 is 18. It does not check the CRC.

offsetcontent
0x000xD600 << 16 | 18
0x04CRC-32C of bytes 0x08 to the end
0x08total size
0x0CLOD base distance
0x14, 0x20tile-tree box
0x40, 0x44object count, object boxes
0x4C, 0x50user id starts and user ids per object
0x68, 0x6Cgate count, user id of each gate
0x7C, 0x80cluster count, clusters
0x90tile count
0x98slot paths
0xA0tile table
0xBCthe computation string, 128 bytes

The header is 0x140 bytes. Pointers are u32 offsets from the start of the tome, or of the tile in a tile header; 0 means absent. Every block is 16-byte aligned. A tile tree and per-tile view trees are implicit breadth-first KD trees with 2-bit node codes. Tiles hold cells, portals and BSP nodes.

An edit must keep these runtime rules:

  • A point lookup returns the cluster, not the cell. The game keeps per-cluster tables, so an edit must not renumber clusters.
  • Gate order is run-time state. An edit must not add, drop or reorder gates.
  • A camera sees every portal of its own cell, so nothing inside one cell occludes. A new occluder blocks only once its cell is split with no portal across it.
  • The inside of an occluder can be a cell. What blocks is the missing portal between the cells on either side.
  • Portals are not checked against cell boxes.
  • The query rasterizes portals into a coarse buffer. The line-segment query does not, so it can read clear where the camera query hides an object.

Level of detail

Each frame the client turns the camera distance of every static instance into one LOD value from 0 to 12. The value is the sum of three ramps, each starting at a switch distance:

d  = distance from the camera to the instance's sphere centre
D0 = t0 * 16;  D1 = t1 * 30;  D2 = t2 * 200        /* default view scales */
b  = (d - D1) * 2
A  = clamp(max((d - D0) * 2, b + 4), 0, 4)
B  = clamp(b, 0, 4)
C  = clamp(min((d - D2) * 0.5, b - 4), 0, 4)
value = round_half_even(A + B + C)

The sizes come from the mesh header and the instance row:

s  = s_owner * s_row
t0 = max(1, s * mesh[0x38] * row[0x20])
t1 = max(1, s * mesh[0x3C] * row[0x20])
t2 = max(1, s * mesh[0x40] * row[0x24])

A value of 12 is the far cull, a little past D2. The value maps to a level: 0 and 1 are level 0, 2 to 5 level 1, and 6 to 11 level 2. Level 3 never draws in the live batched path. A part’s LOD category (part row byte +10, bits 0 to 4) says which levels it draws at:

categorylevels
0, 100
10, 1
20, 1, 2
30 to 3
41
51, 2
61, 2, 3
72
82, 3
93

generate_gbuffer and depth_prepass crossfade between levels with a stencil dither. Every other stage switches hard. Shadows use the main camera’s value. There is no hysteresis. depth_prepass also skips an instance whose radius is below 0.2 times its distance.

The environment_detail setting multiplies the three view scales by 0.8, 1.0 or 2.0. The factor is applied twice, so the effect is 0.64, 1 or 4; the default is 1. That the multiplier reads this setting is assumed. A field of view narrower than about 67 degrees raises the scales further. Two view types keep every instance at level 0 and never distance-cull it.

Collision for static content does not read the instance rows. Each map area has a collision entity whose Havok 2012 packfile holds one hkpStaticCompoundShape. Each compound instance repeats a render instance’s world transform, scale included, and points to a per-mesh shape in another packfile. There is no stored index between the two. On Leviathan, 59,767 of 63,883 render rows have a compound instance at the same position within 2e-3.

The collision owner (class 0x80808A15) holds:

offsetcontent
+0x08material tags
+0x18material-map tags
+0x28i16 material slots, each an index into +0x08
+0x38i16 run starts, one per imported shape
+0x48per-mesh shape packfile tags; import iN resolves to tag N
+0x58the compound packfile tag

The component (class 0x80808A11) patches the packfile’s imports, builds one fixed rigid body with the compound as its shape, and adds it to the physics world. It refuses the entity unless its placement scale is within 1e-4 of 1.0. Nothing bakes or rebuilds the compound at load.

A compound instance keeps its own scale. Flag bits in the low bits of its translation’s W lane record whether the child is convex (1), the transform is not identity (2), any scale lane is not 1 (4), and an odd number of scale lanes are negative (8).

The packfile

Every Havok packfile in the install is a package entry of type 26, subtype 7. The packfile’s user tag separates the roles:

user tagroot objectrole
0hkpStaticCompoundShapemap static collision, one per collision entity
0hclClothContainercloth
1hkpBvCompressedMeshShapea per-mesh shape
1hkpConvexVerticesShapea per-mesh convex shape
2s_hkpShape_array_datashape arrays, used by trigger volumes
3s_physics_component_havok_dataan entity’s own physics

The packfile header carries the magic 57 E0 E0 57 10 C0 C0 10, file version 9 and contents version hk_2012.2.0-r1. It has three sections, __classnames__, __types__ and __data__. Pointers are zero on disk and filled from local, global and virtual fixup tables, plus an import table for cross-packfile pointers.

Compressed meshes

A compressed mesh is split into sections of at most 128 primitives. A primitive is four byte indices: DE AD DE AD is empty, i1 == i2 == i3 is a custom convex piece, i2 == i3 is a triangle, anything else a quad. A vertex is either a packed u32 in 11, 11 and 10 bits over the section’s range, or a shared u64 in 21, 21 and 22 bits over the mesh’s range.

Every tree in these shapes codes a child box against its parent box. Per axis, with s = (parent.max - parent.min) / 226 and code byte c:

child.min = parent.min + (c >> 4)^2  * s
child.max = parent.max - (c & 15)^2  * s

Collision materials

A hit on static collision resolves to a surface material through the owner’s arrays: the instance’s user data picks a run, the mesh’s per-primitive data picks a slot in the run, and the slot picks a material tag. Neither array is bounds-checked. A few instances name a material map instead; its slot comes from a byte grid sampled at the hit’s texture coordinate. The hit carries the material as a tag, not as values.

A material record (class 0x80808BCB) holds a name hash, a parent material and four floats. Read as physical units, they fit friction, restitution, density in kg/m3, and a thickness in exact inch fractions. All four readings are assumed. No reader of the floats is known. An impact response matches a material by name, then by each parent in turn.

Entity physics

An entity’s collision, ragdoll and hitboxes come from one component (class 0x808092D8), whose resource names a user-tag-3 packfile and lists body rows, phantom rows and constraint rows.

A body row names one shape, a bone, an enabled-part bit, a collision layer, a mode, mass, friction, restitution and a Havok motion type. The mode decides what is built:

row modebuilt
1a rigid body
2a query collidable: a hitbox, in the broadphase but not a rigid body
3a rigid body that also takes queries

Every shape sits in its bone’s frame. Across 43 NPC physics configs, the rows take these roles. The roles are assumed from the values:

modelayermotionrole
113charactercharacter stand-in capsules on bone 0
115dynamicragdoll capsules
213, 23keyframedhitbox hulls; most carry a region name
37fixedsolid parts

Hitbox region names include head, body, gut, guts, pack, l_chest, r_chest, l_shoulder and r_shoulder.

Hit regions and damage parts

A hitbox’s region is only a key. The target’s damage component (class 0x80804B85) maps it to a damage part through a region table, then its ancestors’ tables, then a default region. A damage part row holds an FNV-1 part name, a label list, a material override and a damage scale, default 1.0.

Which part counts as precision is not known. The label table names precision as label 9, and head parts carry a head label. Marking a hitbox as precision when its part has a non-empty label list fits every installed head part. That rule is assumed. The precision multiplier itself is a per-weapon-family constant in script, not a stat.

Trigger volumes

A trigger volume is a placed shape with an hkpTriggerVolume attached. It is never solid collision. Its filter is layer 9, and it uses one shape from a user-tag-2 shape array. A trigger sits at its row’s rotation and translation. The row scale does not apply.

The collision filter

Every body carries a 32-bit filter:

bitscontent
0 to 4layer
5same-layer only
6takes part in solid contact
7takes part in queries
8, 9a 2-bit class, set per player relative to the local player
10 to 21collision group, 0xFFF for none
22 to 29layer property flags

Two bodies meet when their mode bits overlap, their layers meet in a 32 by 32 table, and their 2-bit classes are both 1 or either is 0. Two bodies in one group never meet. Layer 7, static collision, does not meet itself.

The 2-bit class is how players pass through each other. In the shipped default mode, players whose fireteam Activity Host id equals the local player’s get class 1 and collide with the local player. Every other player gets class 2 and does not. The member record’s fireteam Activity Host id feeds it.

Weapon fire

A weapon shot is a projectile ray, not a separate hitscan path. Each projectile segment submits a ray with filter 0x8C: layer 12, the query bit, and the shooter’s own physics group skipped. Under the layer table that ray hits static collision of mode 3, hitbox hulls on layers 13 and 23, and query-only static instances on layer 23. It does not hit character capsules, ragdolls or triggers. That hitscan weapons are fast projectiles on this path is assumed.

On a hit, the impact handler picks one impact-response row and runs its entries through a 21-case dispatch: attach, ricochet, detonate, overpenetrate, spawn at the hit, and more. A response row with a region list matches only hits on those regions.

Open questions

  • Whether the client renders an edited tome correctly. Edited tomes pass the Umbra runtime offline.
  • The reader of the four collision material floats.
  • The reader of a damage part’s label list, which would settle precision.
  • How the collision container’s zero mask binds to a bubble.
  • The filters of beam supers and aim assist, which cast their own queries.
  • Where gate definitions come from, and what opens and closes them.
  • The coverage of each LOD dither step, and which tags the LOD settings volumes are.