3D Letter Render Code - brunovalads/yoshisisland-disassembly GitHub Wiki
General knowledge on how Yoshi's Island renders the 3D letters from the pause and game over screen.
Page is currently WIP and initially only serves as a general knowledge base but will be formatted into a proper page once that's done.
Data
General description
All the letters are prisms meaning they're two copies of a base shape connected by rectangles on the perimeters. As such, the game only stores the letters as a 2D vector image and turns them into a 3D model using a copy of this data and some width which is stored a (signed) byte in $08F176.
Memory map
-
$700000: Buffer for vertices of a single face in rendering
-
$700020: Colour index to palette
-
$702AF6: Position for each vertex with rotation applied
-
$702BBE: Submodel data, contains address, previously calculated faces and total amount of faces
-
$702C0E: Face data, contains number of vertices, palette and nod indices
-
$702D3A: Cross product of first three vertices of each face
-
$08F165: Entire code which renders a single letter
-
$08F49A: Palette data / normal map
-
$08F4AE: Pointers for each letter
Structs
The entire subroutine uses some additional tables in order to decompress a 2D vector image into a 3D image. All of these tables follow a strict format which are defined as structs.
// Table for vertices
// The most straightforward struct: One byte for the x position and another for the y position
// There is no byte for the z position because x and y are enough to calculate the faces' orientation
struct vertex {
y: signed byte
x: signed byte
}
// Table for 3D submodels
// Each model consists of multiple submodels to handle complex shapes like a loop
// In it are stored a pointer for the faces followed by how many faces have been processed before
// as well as how faces will be processed in this model
struct submodel {
face_ptr: unsigned word
total_faces: unsigned byte
current_faces: unsigned byte
}
// A single face
// Every face can have a variable amount of vertices
// And also is a assigned a certain base colour
// The vertices are stored as indices to the vertex table
// Data is stored in memory at $2C0E
struct face {
size: unsigned byte
color: unsigned byte
vertices: unsigned byte[size]
}
Code
The main code is handled at $08F165 which can be split into the following portions:
- $08F165: Code initialisation. Calculates the cosine and sine both normal and offset
- $08F1A9: Calculate palette. This takes the rotation data and affects the model's colours. Result has yet to be inspected in-depth
- $08F1D7: Calculate positions for the 3D model. The output consists of 2D vertices with the perspective applied.
- $08F28B: Generate mapping. Decompresses a table into another one (has yet to be figured out)
- $08F31E: Generate cross products. Gets the faces from $702C0E, get vertices of X+0, X+1 and X+3 and calculate their cross product.
- $08F367: TBD
- $08F3EA: Plot the data. TBD
Unpacking the model
Since the letters are stored as a 2D vector image, a 3D model has to be dynamically generated. The first part in the calculation is to transform all the vertices using a 3D rotation with two angles and create two sets of points in question (one for the front and one for the back). The entire data is still stored as 2D points but that information is enough to calculate a projection of a 3D model. That format is stored simply as a size followed by that many x and y coordinates:
size: byte
vertices: vertex[size]
The calculation uses a 3D transformation with a rotation around the X and Y axis (which are set in r2 and r3, respectively), there's no parameter for the Z axis.
Right after all the points are calculated is where the actual models with its faces are defined. Each model consists of multiple submodels (this is, among others, to handle holes like that of the P) and each model consists of multiple faces which themselves contain multiple vertices.
Each submodel takes a base shape and creates a prism out of it using 2 + n faces (two for the base, the rest for the perimeter). In the ROM the submodel is stored in the following format:
size: byte
vertices: byte[size]
colors: byte[size]
Each face consists of multiple vertices with vertex count and a colour value. For the front face (one of the two base shapes), at first a face struct gets generated with a copy of vertices from the ROM table with a hardcoded color index of $20, then for the back (the other base), the bytes are all stored in reverse order and incremented by 1 with the same color. Now we get to the perimeter rectangles: The data in the ROM defines the face's colour and its vertices are the current vertex N, its opposite side N', the next vertex on the opposite side (N+1)' followed by the next vertex on the main side (N+1).
For the last rectangle, get the first vertex in the list instead.
For example, if a submodel is stored as this table in the ROM:
db $04
db $02,$16,$14,$04
db $22,$21,$22,$21
It gets transformed into the following table in RAM:
db $04,$20,$02,$16,$14,$04
db $04,$20,$05,$15,$17,$03
db $04,$22,$02,$03,$17,$16
db $04,$21,$16,$17,$15,$14
db $04,$22,$14,$15,$05,$04
db $04,$21,$04,$05,$03,$02
Rendering
After all the faces have been decompressed, the code also calculates a cross product of all the faces. The calculation uses the first three vertices of each face, the rest is discarded (this does mean that for custom letters, you may want to arrange the faces' vertices in a specific order).
Of these, three cross products are selected (whose data is located in the model) and checked for their polarity (sign). The polarity is then used for a 3-bit index indexing another table right after it (this is where the model data ends). The table determines the drawing order of each submodel.
The rendering process itself goes over multiple steps. Take the current submodel and loop through each face. Within a face, check its orientation (normal vector) and its colour. If the orientation is negative or the colour is 0, don't render the face. Otherwise, it gets the value within the face colour and use that for plotting. Now comes the calculation of the bounding box: At first the routine finds the top- and bottommost vertices of a face which make up the vertical boundary box. Each vertex also gets buffered separately and the pointer of the topmost vertex to this buffer also is preserved. Now the horizontal boundaries are calculated and the face itself is split at each vector's vertical position twice (one for the left and another for the right position). Anytime a new section is reached, the code gets the position of the next vertex, one clockwise and the other counterclockwise. Should two vertices have the same Y position, the next vertex is loaded. The difference between both points is then taken and the horizontal distance is divided by the vertical distance and is preserved for each direction which is used to calculate the position and width of the next row. Should the width be zero or negative, nothing gets rendered, otherwise plot the pixels.
Once the face has been, rendered, proceed with the next face until all of them are rendered and proceed to the next submodel in the list.