LitMesh

**This page describes a file format and is intended for reference and reverse engineering.**

This page describes a file format in Smash Hit, going into many technical details. This page is not a tutorial about how to mod, but rather information that is useful for making tools that can be used to modify Smash Hit.

In *Smash Hit*, segments have two files, one of which is a **LitMesh** (extension `.mesh`) file. These files only store box vertex data and visuals. Box collision, obstacles, decals and everything else use XML segment files.

## Stored file format

The files are compressed in the ZLIB format (RFC 1950). After decompressing, the files consist of a list of vertices and indexes.

#### Vertex Header

```
struct Mesh\_Vertex\_Header {
 uint32\_t size;
};

```

#### Vertex

```
struct Mesh\_Vertex {
 float x;
 float y;
 float z;
 float u;
 float v;
 uint8\_t r;
 uint8\_t g;
 uint8\_t b;
 uint8\_t a;
};

```

#### Index Header

```
struct Mesh\_Index\_Header {
 uint32\_t size;
};

```

#### Index

```
struct Mesh\_Triangle {
 int32\_t d;
 int32\_t e;
 int32\_t f;
};

```

### Loading

There is not a single function in the game that loads the mesh files. They appear to be loaded manually each time. The general process is:

```
void GameFunction(...) {
 // Open the file stream ...
 DecompessingFileStream stream = DecompessingFileStream("path/to/mesh.mesh");

 uint32\_t vertex\_count;

 // Read vertex data count
 stream.readInt32(&vertex\_count);

 // Allocate vertex data
 void \*vertex\_data = QiAlloc(vertex\_count \* 24);

 // Load vertex data into memory
 for (size\_t i = 0; i < vertex\_count; i++) {
 stream.readFloat(&vertex\_data[(i \* 0x18) + 0x00]);
 stream.readFloat(&vertex\_data[(i \* 0x18) + 0x04]);
 stream.readFloat(&vertex\_data[(i \* 0x18) + 0x08]);
 stream.readFloat(&vertex\_data[(i \* 0x18) + 0x0c]);
 stream.readFloat(&vertex\_data[(i \* 0x18) + 0x10]);
 stream.readInt32(&vertex\_data[(i \* 0x18) + 0x14]);
 }

 uint32\_t index\_count;

 // Read index data count
 stream.readInt32(&index\_count);

 // Allocate vertex data
 void \*index\_data = QiAlloc(index\_count \* 12);

 // Load index data into memory
 for (size\_t i = 0; i < index\_count; i++) {
 stream.readInt32(&index\_data[(i \* 0xc) + 0x0]);
 stream.readInt32(&index\_data[(i \* 0xc) + 0x4]);
 stream.readInt32(&index\_data[(i \* 0xc) + 0x8]);
 }

 // Clean up and push data to gpu...
}

```

## Textures

The textures are only stored in one file: tiles.png.mtx. The texture coordinates are set up in such a way that the tile ID is just used to find the texture coordinates of the tile.

## Baking

Normally, the mesh files would be baked alongside the segment itself in the editor or using a developer menu. It appears that the `LitMesh` class was used for this during development. While the class is still present in the final `libsmashhit.so`, it is not possible to use it to directly bake a mesh file (or rather, it would be very inconvenient to do that), so mesh baking must be done using a third party utility.

The first step of baking is usually to load the segment and templates files. With this, the boxes can then be processed and converted to a triangle mesh. The first phrase of this usually involves subdividing the surfaces of the box into smaller quads, usually the size of one tile. After this, the textures and shading can be applied to the vertices and they can be serialised to a mesh file.

### Texture coordinates

The texture coordinates are OpenGL texture coordinates, like

(
u
,
v
)

{\displaystyle (u, v)}

. Note that the texture will be loaded flipped and will not be corrected, making the texture coordinates more "normal" in that the tile at

(
0
,
0
)

{\textstyle (0, 0)}

 has the texture coordinates

(
0
,
0
)

{\textstyle (0, 0)}

,

(
0
,
0
−

T
i
l
e
S
i
z
e

)

{\textstyle (0, 0 - \mathrm{TileSize})}

,

(
0

T
i
l
e
S
i
z
e

,
0
−

T
i
l
e
S
i
z
e

)

{\textstyle (0 + \mathrm{TileSize}, 0 - \mathrm{TileSize})}

 and

(
0

T
i
l
e
S
i
z
e

,
0
)

{\textstyle (0 + \mathrm{TileSize}, 0)}

.

In Smash Hit, the texture coordinate are cropped, most likely so that the edges of tiles don't bleed into other tiles. By default, Smash Hit cuts off `0.03125` of a tile on each side. This is why there is no tile in tiles.mtx that looks *exactly* like the tiles seen in the starting room: they are cropped by enough of a factor to cause part of the tile to not be seen, thus making it look a bit different than what the tile actually looks like by just looking at the texture.

#### Calculations

To calculate the coordinates of a texture on the grid, use integer division to find the row and modulo to find the column. After you have found these, divide them by the count of rows and columns to get the texture coordinates in the range [0, 1]. For example, to find the top left coordinate of a tile:

```
ROW\_COUNT = 8
COL\_COUNT = 8
TILE\_HEIGHT = 1 / COL\_COUNT
TILE\_WIDTH = 1 / ROW\_COUNT

def get\_tile\_uv(tile):
    row = tile // ROW\_COUNT
    col = tile % ROW\_COUNT
    row = row / ROW\_COUNT
    col = col / COL\_COUNT
    return (row, col)

```
Or expressed mathematically:

(
u
,
v
# )
(

T
i
l
e
N
u
m
b
e
r

mod

R
o
w
s

,
⌊

T
i
l
e
N
u
m
b
e
r

R
o
w
s

⌋
)

{\displaystyle (u, v) = (\mathrm{TileNumber} \bmod \mathrm{Rows}, \lfloor \frac{\mathrm{TileNumber}}{\mathrm{Rows}} \rfloor)}

The other points are

(
u
,
v

T
i
l
e
H
e
i
g
h
t

)

{\displaystyle (u, v + \mathrm{TileHeight})}

,

(
u

T
i
l
e
W
i
d
t
h

,
v

T
i
l
e
H
e
i
g
h
t

)

{\displaystyle (u + \mathrm{TileWidth}, v + \mathrm{TileHeight})}

and

(
u

T
i
l
e
W
i
d
t
h

,
v
)

{\displaystyle (u + \mathrm{TileWidth}, v)}

for the bottom left, bottom right and top right coordinates respectively, where

T
i
l
e
H
e
i
g
h
t

{\displaystyle \mathrm{TileHeight}}

 and

T
i
l
e
W
i
d
t
h

{\displaystyle \mathrm{TileWidth}}

are the width and height of a single tile in texture coordinates (which are the inverse of the row and column counts, respectively).

### Colours

Colours are a bit weird in mesh files. The RGB channels are eight bit integers within the range

[
0
,
255
]

{\displaystyle [0, 255]}

, though they are normalised (converted to floats then divided by 255) and multiplied by two in the shaders (see line 27 in the shaders section), so only the values in the range

[
0
,
127
]

{\displaystyle [0, 127]}

(or

[
0
,
0.5
]

{\displaystyle [0, 0.5]}

after normalisation) will produce colours that are not an overblown white. The same is not true for the A channel, which must represent everything in

[
0
,
255
]

{\displaystyle [0, 255]}

, otherwise everything will look overly dark. The A channel will be squared when it factored into the colour of the mesh, so a value of 127 (about 0.5) will actually make the colours 0.25 darker (multiply by

1
−
(
0.5

)
2

=
0.75

{\displaystyle 1 - (0.5)^2 = 0.75 }

), not 0.5.

Note that the colours will be gamma corrected, so it should not already be corrected.

#### Formulas

**Note:** Don't just blindly use these if you don't know what they mean. They are only intended for reference purposes.

Overall, to convert a colour from the `color` property to a mesh colour, use:

[

R

o
u
t

G

o
u
t

B

o
u
t

S

o
u
t

]

=

[

r
o
u
n
d

(

R

i
n

⋅
127
)

r
o
u
n
d

(

G

i
n

⋅
127
)

r
o
u
n
d

(

B

i
n

⋅
127
)

r
o
u
n
d

(

S

i
n

⋅
255
)

]

{\displaystyle \begin{bmatrix} R\_{out} \\ G\_{out} \\ B\_{out} \\ S\_{out} \end{bmatrix} = \begin{bmatrix} \mathrm{round}(R\_{in} \cdot 127) \\ \mathrm{round}(G\_{in} \cdot 127) \\ \mathrm{round}(B\_{in} \cdot 127) \\ \mathrm{round}(S\_{in} \cdot 255) \end {bmatrix} }

where

S

i
n

{\displaystyle S\_{in} }

 is the fourth component (otherwise called `a`). Since floating point to integer rounds automatically, this can be implemented as:

```
def convert\_colour(r, g, b, a):
    r = int(r \* 127)
    g = int(g \* 127)
    b = int(b \* 127)
    a = int(a \* 255)
    return r, g, b, a

```

### Ambient occlusion

**Note:** Ambient occlusion is how objects that have a small angle between faces tend to be darker at that edge.

As part of baking a mesh, Smash Hit computes the ambient occlusion for each vertex.

#### Standard algorithm

Before the actual ambient occlusion computation starts, the game computes some equally distributed points on a unit hemisphere. This is done using an algorithm that Dennis has previously provided and taken down source code for, but has been reuploaded as a github gist.

The rays are then used in a fairly simple inside-out algorithm for finding the ambient occlusion:

**Note:** I have relooked at the decompilation and it seems like old versions of Ghidra were misleading. I'm not really sure if anything on this page is accrate anymore - knot126

1. `AccumulatedShadow` = 0
2. For each point on the unit sphere
	1. Recalculate the base position as a bit off of the current one, so the raycast doesn't accidently hit the box
	2. Adjust the base position so that it's properly positioned at the vertex and rotate the vector to be properly aligned with the face
	3. Send a raycast for this ray
	4. If the raycast hits:
		1. Calculate the facing ratio for this ray
		2. Add the result to the shadow accumulator
	5. Otherwise, do nothing
3. Normalise the shadow accumulator by the number of samples tried
4. You can now transform this by

1.0
−

ShadowAccumulator

{\displaystyle 1.0 - \sqrt{\text{ShadowAccumulator}}}
5. This is the resulting amount of light

## Decompilation

**Note:** This section is not finished and may not be an accurate representation of what is happening.

Errors:

* This seems to say that if we have hits we are collecting light, when really we should be *taking away* light if something is hit as the object is being left in shadow.

This section contains an incomplete decompilation of `LitMesh` for reference purposes.

### Lighting

Here is some incomplete pseudocode that shows how `LitMesh::getLight` works:

```
bool LitMesh::raycast(QiVec3& origin, QiVec3& direction, QiVec3& first\_hit);
 /\*\*
 \* Computes a raycast and its first hit
 \*
 \* @param origin Origin of the ray
 \* @param direction Direction of the ray
 \* @param first\_hit The location of the first hit of the ray
 \* @return Non-zero, where zero is not hitting anything
 \*/

float LitMesh::getLight(QiVec3& position, QiVec3& normal, float side\_light, float in\_s0) {
 /\*\*
 \* Get the alpha value at a point in the scene.
 \*
 \* @param this <r0> Pointer to the current LitMesh instance (implict)
 \* @param position <r1> Position of the point to get the light around
 \* @param normal <r2> Normal vector for the surface (maybe?)
 \* @param in\_r3 <r3> The use of in\_r3 is not known
 \* @param in\_s0 <s0> The use of in\_s0 is not known
 \* @return Total light (is equal to the alpha channel)
 \*/

 // Initialise unit sphere points, if not already done
 if (!gSpherePointsInitialised) {
 distributePointsOnUnitSphere(1000, 64, &gSpherePoints, 12, true);
 }

 // There is more before the main of this function, but it
 // does not seem to be the most important for us.

 // Advance by 0.02 in the direction facing out of the surface
 // (the surface normal)
 position += (0.02f \* normal);

 // The accumulator for recording raycast hits
 float light = 0.0f;

 // Raycast in the direction of each point
 for (size\_t i = 0; i < 64; i++) {
 QiVec3 origin, direction, first\_hit;

 // Get the sphere point and call it our base ray
 QiVec3 base\_ray = gSpherePoints[i];

 // Rotate the sphere point to align to the side
 base\_ray = RotateTo(normal, base\_ray);

 // Calculate the origin of the raycast
 origin = position + ((float)in\_r3 \* direction);

 // Calculate the direction of the raycast
 direction = position + (0.02f \* base\_ray);

 // Preform the raycast
 bool hit = this->raycast(direction, origin, first\_hit);

 // If the raycast has been hit
 if (hit) {
 // Subtract hit position from current base position
 first\_hit = first\_hit - position;

 // Compute the dot product between the hit and direction
 // This computes the facing ratio (do a search for it)
 float facing = fmax(dot(first\_hit, direction), 0.0f);

 // We also limit the light to at most the side light
 facing = fmin(facing, side\_light);

 // Now we normalise with respect to the amount of side light
 // and subtaract it from one to get the light
 light += 1.0f - (facing / side\_light);
 }
 }

 // Normalise the amount with respect to the number of rays tested.
 light \*= (1.0f / 64.0f);

 // Just in case we somehow got outside this range, we truncate it to that
 // range
 light = fmin(fmax(light, 0.0f), 1.0f);

 // Return 1.0 - sqrt(light) which is
 return 1.0f - powf(light, 0.5f);
}

```
It is interesting to note that since we return

# Shadow
1.0
−

Light

{\displaystyle \text{Shadow} = 1.0 - \sqrt{\text{Light}}}

 here and we do

# Final
1.0
−
(
Shadow

)
2

{\displaystyle \text{Final} = 1.0 - (\text{Shadow})^2}

later in the graphics pipeline at runtime we get that

# Final
2

Light

−
Light

{\displaystyle \text{Final} = 2\sqrt{\text{Light}} - \text{Light}}

(not accounting for any gamma correction).

## Shaders

The shaders `assets/shaders/room.glsl` and `assets/shaders/roomlow.glsl` are used to draw the room in higher and low qualities, respectively. The higher-quality shader is shown below.

```
uniform mat4 uMvpMatrix;
uniform sampler2D uTexture0;
uniform vec4 uColor;
uniform vec4 uLowerFog;
uniform vec4 uUpperFog;

varying vec4 vColor;
varying vec2 vTexCoord;
varying float vShadow;
varying vec4 vFog;

#ifdef VERTEX
attribute vec3 aPosition;
attribute vec2 aTexCoord;
attribute vec4 aColor;

void main(void)
{
 gl\_Position = uMvpMatrix \* vec4(aPosition, 1.0);

 float nearPlane = 0.4;
 vec4 upperFog = uUpperFog;
 vec4 lowerFog = uLowerFog;
 float t = gl\_Position.y / (gl\_Position.z+nearPlane) \* 0.5 + 0.5;
 vec4 fogColor = mix(lowerFog, upperFog, t);
 float fog = clamp(0.05 \* (-5.0 + gl\_Position.z), 0.0, 1.0);
 vColor = vec4(aColor.rgb, 0.5) \* (2.0 \* (1.0-fog));
 vFog = fogColor \* fog;

 vShadow = 1.0-aColor.a;
 vTexCoord = aTexCoord;
}
#endif

#ifdef FRAGMENT
void main(void)
{
 float light = (1.0-vShadow\*vShadow);
 gl\_FragColor = texture2D(uTexture0, vTexCoord) \* vColor \* light + vFog;
}
#endif

```

## Gallery

Mesh Baking Update 2022-02-02Mesh baking with consistency updates, making tiles look more accurate.Smash Hit - Purple City-Trench (Recolour)An example of the already-exported mesh files being updated.Smash Hit Modding - Basic Texture Support in Custom MeshesThe first publicly known showcase of working textures in Smash Hit meshes.Smash Hit Modding - CUSTOM MESH FILES!!!!!!!!!A member of the community's first attempt at baking custom mesh files from segments.An edited Checkpoint 11 (City) segment showing visual color patterns on the stone.
## Further reading

1. wikipedia:Polygon\_mesh
2. wikipedia:Texture\_mapping
3. Chapter from *The Cg Tutorial* on Lighting
4. Introduction to Shading (Normals, Vertex Normals and Facing Ratio) from Scratchapixel

## Reference material

1. bake\_mesh.py source code (from blender tools)
2. Source code for `distributePointsOnUnitSphere` (reupload): https://gist.github.com/knot126/73d7b48e7617368e3f5c7bf9852e967f