Model
Every shape so far has been typed in by hand: a triangle, a rectangle, a cube of 36 vertices. That is fine for a cube, and hopeless for anything an artist would make. Characters, buildings and props are built in modelling tools such as Blender, Maya or 3ds Max, where the artist shapes the mesh, unwraps it onto texture images (
There are a great many model formats. Some are tiny and only describe geometry with a few material hints; others, like Collada or glTF, can carry whole scenes with cameras, lights, skeletons and animation. LearnOpenGL handles the variety by using Mesh class and a Model class that walk that tree.
We do something smaller. Our models are all in one format, the Wavefront .obj, which is plain text and simple enough to read ourselves, even in assembly. The reader lives in includes/learn/model.asm, about five hundred lines with no library behind it, not even a C runtime to turn text into numbers, and it covers in one place what Assimp, the Mesh class and the Model class cover in LearnOpenGL. This chapter walks through it, and then loads a backpack.
The Wavefront .obj format
An .obj file is a list of lines, each starting with a short keyword that says what the rest of the line is. Here are a few lines of the backpack we will load, one or two of each kind we care about:
mtllib backpack.mtl
o Cube.028__0
v -0.556751 -0.023763 -0.141540
v -0.556645 0.003057 -0.140628
vt 0.283512 0.401314
vn 0.0001 0.9989 0.0473
f 55/1/1 57/2/2 58/3/2
vis a position, three floats.vtis a texture coordinate, u and v.vnis a normal.fis a face. Each corner is writtena/b/c: the index of its position, of its texture coordinate and of its normal, counting from 1 in the order the lines appeared. Parts may be missing (a//c,a/b, or a barea), and a face may have more than three corners.mtllibnames amaterial library , a second text file (.mtl) next to the model that describes its materials.
Other keywords exist (o and g name objects and groups, usemtl switches material, s sets smoothing groups), and our reader skips every line it does not recognise. The material file is in the same style. The backpack's has one material, and the lines we read are the ones that name texture images:
newmtl Scene_-_Root
Kd 0.800000 0.800000 0.800000
map_Kd diffuse.jpg
map_Bump normal.png
map_Ks specular.jpg
map_Kd is the diffuse texture, map_Ks the specular map and map_Bump a normal map, which we will need in Normal Mapping. These are exactly the diffuse and specular maps from Lighting maps, so the lighting we already have will work on the model unchanged.
One feature of the format shapes the whole reader. A face indexes positions, texture coordinates and normals separately: corner 55/1/1 takes position 55, uv 1 and normal 1, and the next face may pair position 55 with a different uv. A GPU vertex is not like that. The input assembler reads one vertex as one record, with its position, normal and uv side by side, all fetched with the same index. So whatever the file says, every distinct corner has to become a complete vertex of its own.
A vertex, and a model
LearnOpenGL's Mesh chapter starts by defining what one vertex is. Ours is the same three attributes, in a struct:
model_vertex struct
position vec3 <>
normal vec3 <>
uv vec2 <>
model_vertex ends
model_semantic_position byte "POSITION", 0
model_semantic_normal byte "NORMAL", 0
model_semantic_texcoord byte "TEXCOORD", 0
align 8
model_layout D3D11_INPUT_ELEMENT_DESC <offset model_semantic_position, 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0>
D3D11_INPUT_ELEMENT_DESC <offset model_semantic_normal, 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0>
D3D11_INPUT_ELEMENT_DESC <offset model_semantic_texcoord, 0, DXGI_FORMAT_R32G32_FLOAT, 0, 24, D3D11_INPUT_PER_VERTEX_DATA, 0>
model_layout_count equ 3
A struct's fields sit in memory one after another, in the order they are declared, so an array of model_vertex is exactly the interleaved stream of floats a vertex buffer wants: 3 + 3 + 2 floats, 32 bytes per vertex. It is the same layout as the cube's in Colors. The input layout beside it says where each attribute starts inside that record. LearnOpenGL computes those offsets with C's offsetof; here they are written out, 0, 12 and 24 bytes, and the stride of 32 comes from sizeof model_vertex when drawing. Because the layout ships with the loader, any shader that reads POSITION, NORMAL and TEXCOORD can draw any model.
What the loader hands back is just as small:
model struct
vertices qword ? ; ID3D11Buffer of model_vertex
vertex_count dword ?
pad dword ?
diffuse qword ? ; ID3D11ShaderResourceView: map_Kd
specular qword ? ; map_Ks
normal qword ? ; map_Bump
model struct
One vertex buffer and up to three textures. (MASM places struct members exactly where they are written, so the four-byte pad after the count keeps the three pointers on 8-byte boundaries, where C would put them.) LearnOpenGL's Model holds a list of Meshes, each with its own buffers and its own list of textures, because Assimp hands models over as many meshes. Every model in these lessons uses a single material, so one buffer and one set of textures describes the whole thing, and drawing it is one draw call. A loader for bigger scenes would split the vertices by usemtl into several such ranges, one per material; we come back to that in the exercises.
Reading the file
rcx and comes back, moved, in rax. Skipping spaces and tabs is the simplest of them:
; past spaces and tabs (touching only rax and r9: callers keep pointers in rdx)
align 16
obj_skip_spaces proc
mov rax, rcx
skip:
mov r9b, [rax]
cmp r9b, ' '
je next
cmp r9b, 9
jne done
next:
inc rax
jmp skip
done:
ret
obj_skip_spaces endp
; rcx: cursor, rdx: keyword -> rax: 1 when the line starts with the keyword and a space or tab
align 16
obj_starts proc
compare:
mov al, [rdx]
test al, al
jz word_ended
cmp al, [rcx]
jne no
inc rcx
inc rdx
jmp compare
word_ended:
mov al, [rcx]
cmp al, ' '
je yes
cmp al, 9
je yes
no:
xor eax, eax
ret
yes:
mov eax, 1
ret
obj_starts endp
It walks the keyword and the text together, byte by byte, until the keyword's terminating zero. The check that a space or tab follows matters, since v is a prefix of vt and vn.
Numbers from text
A C program would parse numbers with the C library's
; rcx: text -> rax: the integer at its start (an optional sign, then digits), rdx: just past it
align 16
parse_int proc
xor eax, eax
xor r8d, r8d ; negative?
cmp byte ptr [rcx], '-'
jne digits
mov r8d, 1
inc rcx
digits:
movzx edx, byte ptr [rcx]
sub edx, '0'
cmp edx, 9
ja done
imul rax, rax, 10
add rax, rdx
inc rcx
jmp digits
done:
test r8d, r8d
jz positive
neg rax
positive:
mov rdx, rcx
ret
parse_int endp
The digit test is one comparison: subtracting '0' turns digits into 0 to 9, and anything below '0' wraps round to a huge unsigned number, so ja, "above" in unsigned terms, catches both sides at once. The float reader,
; rcx: text -> xmm0: the float at its start, rax: just past it
;
; Digits are gathered into one integer and the decimal point and exponent become a power of ten,
; applied once in double precision: "0.123456" is exactly 123456 / 10^6 before rounding to float.
The end of it does that arithmetic with SSE2's double-precision instructions:
cvtsi2sd xmm0, rax
mov rax, 3FF0000000000000h ; 1.0
movq xmm1, rax
mov rax, 4024000000000000h ; 10.0
movq xmm2, rax
; ...
cvtsd2ss xmm0, xmm0
Rounding once, at the end, gives the nearest float for the six or so decimals an .obj writes, which is all a model needs. (A full
; rcx: cursor -> xmm0: the float after any spaces, rax: cursor after it
align 16
obj_float proc
call obj_skip_spaces
mov rcx, rax
call parse_float
ret
obj_float endp
xmm0 and the cursor in rax. Win64 only defines one return value, but between our own procedures nothing stops us using both registers, and the comment above each procedure says what comes back where. The same goes for the registers a procedure is allowed to disturb: rax and r9, which its callers rely on.
Two passes
We do not know in advance how many positions or faces the file holds, and these lessons avoid growing arrays: memory comes from an
; First pass: count, so every array is allocated once at its final size.
xor r12, r12 ; positions
xor r13, r13 ; uvs
xor r14, r14 ; normals
xor r15, r15 ; corners
mov rsi, rbx
count_lines:
cmp byte ptr [rsi], 0
je counted
invoke obj_starts, rsi, "v"
add r12, rax
invoke obj_starts, rsi, "vt"
add r13, rax
invoke obj_starts, rsi, "vn"
add r14, rax
invoke obj_starts, rsi, "f"
test eax, eax
jz count_next
inc rsi
xor edi, edi ; corners on this face
count_corners:
mov rcx, rsi
call obj_at_corner
mov rsi, rdx
test eax, eax
jz corners_counted
invoke obj_read_corner, rsi, ADDR corner
mov rsi, rax
inc edi
jmp count_corners
corners_counted:
cmp edi, 3
jb count_next
lea rax, [rdi - 2]
lea rax, [rax + rax * 2] ; (n - 2) * 3
add r15, rax
count_next:
mov rcx, rsi
call obj_next_line
mov rsi, rax
jmp count_lines
Since rbx holds the start of the text, kept for the second pass, and rsi is the cursor. All of these are registers a call must preserve, so they survive the calls in the loop, and USING rbx, rsi, rdi, r12, r13, r14, r15. A face with n corners becomes n - 2 triangles, as we will see in a moment, so it contributes three times that many vertices; lea does the arithmetic, rax + rax * 2 being three times rax.
With the totals known, each array is allocated once, at its final size:
; Index 0 is the "missing" entry, so 1-based indices need no adjusting.
lea rax, [r12 + 1]
arenaPushArrayZero ADDR arena, vec3, rax, 16
mov p, rax
lea rax, [r13 + 1]
arenaPushArrayZero ADDR arena, vec2, rax, 16
mov t, rax
lea rax, [r14 + 1]
arenaPushArrayZero ADDR arena, vec3, rax, 16
mov n, rax
mov dword ptr [rax].vec3.y, 3F800000h ; the missing normal is up
arenaPushArray ADDR arena, model_vertex, r15, 16
mov vertices, rax
rax. Each array has one extra slot at the front. The file counts from 1, so entry i of the file lands at index i of the array with no subtraction, and index 0 is free to act as the default for a corner that leaves a part out. A face written 12//7, with no uv, reads uv index 0 and gets (0, 0); a face with no normal gets one pointing straight up, (0, 1, 0), its y written as the bits of 1.0, 3F800000h, since the rest is already zero. That is less clever than Assimp's aiProcess_GenNormals, which computes missing normals, but it means a sloppy file still loads.
The second pass rewinds the cursor and fills the arrays, with r12, r13 and r14 now pointing at the next free position, uv and normal (starting one entry in, past the default). Positions and normals are three floats each:
invoke obj_starts, rsi, "v"
test eax, eax
jz not_position
lea rcx, [rsi + 1]
call obj_float
movss [r12].vec3.x, xmm0
mov rcx, rax
call obj_float
movss [r12].vec3.y, xmm0
mov rcx, rax
call obj_float
movss [r12].vec3.z, xmm0
mov rsi, rax
add r12, sizeof vec3
jmp next_line
not_position:
Each rax, which becomes the next one's rcx.
Faces
A corner is read one part at a time, stopping early when a slash is missing:
; One `a/b/c` corner of a face. Missing parts (`a//c`, `a/b`, `a`) are 0.
obj_corner struct
position dword ?
uv dword ?
normal dword ?
obj_corner ends
; rcx: cursor at a corner, rdx: &obj_corner -> rax: cursor after it
align 16
obj_read_corner proc cursor:qword, corner:qword
mov rdx, corner
mov qword ptr [rdx], 0
mov dword ptr [rdx + 8], 0
mov rcx, cursor
call parse_int
mov rcx, corner
mov [rcx].obj_corner.position, eax
cmp byte ptr [rdx], '/'
jne done
inc rdx
cmp byte ptr [rdx], '/'
je no_uv
mov rcx, rdx
call parse_int
mov rcx, corner
mov [rcx].obj_corner.uv, eax
no_uv:
cmp byte ptr [rdx], '/'
jne done
inc rdx
mov rcx, rdx
call parse_int
mov rcx, corner
mov [rcx].obj_corner.normal, eax
done:
mov rax, rdx
ret
obj_read_corner endp
The corner is cleared first, eight bytes and then four, so a missing part reads as 0. rdx, which is where the slashes are looked for.
Faces with more than three corners have to be cut into triangles, which is what Assimp's aiProcess_Triangulate does for LearnOpenGL. The simplest cut is a
invoke obj_starts, rsi, "f"
test eax, eax
jz not_face
inc rsi
mov fan_count, 0
face_corners:
mov rcx, rsi
call obj_at_corner
mov rsi, rdx
test eax, eax
jz next_line
invoke obj_read_corner, rsi, ADDR corner
mov rsi, rax
cmp fan_count, 0
jne not_first
invoke mem_copy, ADDR first, ADDR corner, sizeof obj_corner
not_first:
cmp fan_count, 2
jb fan_later
; a triangle of the fan: the first corner, the previous one and this one
fan_vertex first
fan_vertex previous
fan_vertex corner
fan_later:
invoke mem_copy, ADDR previous, ADDR corner, sizeof obj_corner
inc fan_count
jmp face_corners
; One corner of a face, written as the vertex at r15: what its three indices point at. Used inside
; model_load, whose locals p, n and t are the position, normal and uv arrays.
fan_vertex macro c:req
mov eax, c.position
lea rax, [rax + rax * 2]
mov rcx, p
lea rcx, [rcx + rax * 4]
v3load xmm0, [rcx]
v3store [r15].model_vertex.position, xmm0
mov eax, c.normal
lea rax, [rax + rax * 2]
mov rcx, n
lea rcx, [rcx + rax * 4]
v3load xmm0, [rcx]
v3store [r15].model_vertex.normal, xmm0
mov eax, c.uv
mov rcx, t
mov rax, [rcx + rax * 8]
mov qword ptr [r15].model_vertex.uv, rax
add r15, sizeof model_vertex
endm
For one corner it looks up the position, normal and uv its indices name, and writes the three side by side as one model_vertex at r15, which then moves on to the next. A vec3 is 12 bytes, and an x86 address can scale a register by 1, 2, 4 or 8 but not 12, so the index is tripled with lea and the address scales that by 4. A vec2 is 8 bytes, so the uv is one scaled load, and one 8-byte move copies both its floats.
c replaced by the argument, so fan_vertex first reads first.position and so on. That is why it can use
Notice what we did not do: there is no index buffer. LearnOpenGL's Mesh stores unique vertices plus indices and draws with .obj we would have to notice when two corners name the same a/b/c triple and share one vertex between them, which means a hash table. Our reader just emits every corner. The backpack has 67,907 triangles, so it becomes 203,721 vertices of 32 bytes, about 6.5 MB of vertex buffer. That is more memory than an indexed mesh would take, and it also means the GPU cannot reuse an already-transformed vertex, but nothing draws differently. For these lessons the simplicity wins.
Flipping v
A few lines in the second pass are there because of Direct3D:
invoke obj_starts, rsi, "vt"
test eax, eax
jz not_uv
lea rcx, [rsi + 2]
call obj_float
movss [r13].vec2.x, xmm0
mov rcx, rax
call obj_float
mov rsi, rax
cmp flip_v, 0
je no_flip
movss xmm1, math_one
subss xmm1, xmm0
movss xmm0, xmm1
no_flip:
movss [r13].vec2.y, xmm0
add r13, sizeof vec2
jmp next_line
not_uv:
As we saw in Textures, Direct3D puts texture coordinate (0, 0) at the top-left of an image, with v growing downwards. The .obj convention comes from OpenGL, where v grows upwards. LearnOpenGL deals with this by flipping images as they load and asking Assimp for aiProcess_FlipUVs; we never flip images, so the place to fix it is the coordinates, \(v' = 1 - v\), here 1.0 from math.asm's constants minus the value read. Whether a model needs that depends on how its textures were saved, and the honest test is to load it and look. The backpack's textures already suit top-down v, so it is loaded with flip_v off; the planet and rock in Instancing need it on.
Materials
When the second pass meets mtllib, it reads the file name and loads the material library from the model's own directory:
invoke obj_starts, rsi, "mtllib"
test eax, eax
jz next_line
lea rdx, word_
mov rcx, rsi
call obj_word
lea rdx, word_
mov rcx, rax
call obj_word
mov rsi, rax
invoke obj_load_material, dir, ADDR word_, out_ref
The first word is a MASM type name). The material reader walks the .mtl the same way, looking for the three texture keywords:
mov kind, 0
invoke obj_starts, rsi, "map_Kd"
test eax, eax
jz not_diffuse
mov kind, model.diffuse
not_diffuse:
invoke obj_starts, rsi, "map_Ks"
test eax, eax
jz not_specular
mov kind, model.specular
not_specular:
invoke obj_starts, rsi, "map_Bump"
test eax, eax
jz not_normal
mov kind, model.normal
not_normal:
cmp kind, 0
je next_line
; the keyword, then the file name
lea rdx, word_
mov rcx, rsi
call obj_word
lea rdx, word_
mov rcx, rax
call obj_word
mov rsi, rax
invoke cstr_copy, ADDR path, dir
invoke cstr_append, ADDR path, ADDR word_
; Colours are drawn as stored, as LearnOpenGL's model loader does; the normal map is
; data either way.
invoke load_texture, ADDR path, 0
mov rcx, out_ref
mov edx, kind
mov [rcx + rdx], rax
kind remembers which keyword matched in a compact way: model.diffuse, a struct name and a member, is the member's offset within the struct, 16. So kind holds where in the model the texture belongs, and one store, [rcx + rdx], puts it there, whichever map it is. None of the offsets is 0, which leaves 0 to mean "not a texture line".
Texture paths are taken as relative to the model's directory, just as LearnOpenGL assumes: cstr.asm, glue the two together in a 260-byte local. Models downloaded from the internet sometimes carry absolute paths from the artist's machine (C:\Users\someone\...); those have to be edited in the .mtl by hand, which is easy, because it is a text file.
The texture itself is loaded by
mov desc.Format, DXGI_FORMAT_R8G8B8A8_UNORM
cmp srgb, 0
je linear
mov desc.Format, DXGI_FORMAT_R8G8B8A8_UNORM_SRGB
linear:
The loader passes 0, so the format is plain UNORM, not UNORM_SRGB: colours are used exactly as stored, which is what LearnOpenGL's model loader does too. Whether that is right is a question for Gamma Correction. For a normal map it is right in any case, since a normal map holds directions, not colours.
LearnOpenGL's Model chapter ends with an optimisation: remember which texture files have been loaded already, so that meshes sharing a texture do not load it twice. With one material per model our loader never loads a file twice, so it has no such cache. A scene format with dozens of materials sharing a few images would want one.
When both passes are done, the vertices go into an immutable vertex buffer, made by the same
; one immutable vertex buffer
mov r12, r15
sub r12, vertices ; bytes
mov rax, r12
xor edx, edx
mov ecx, sizeof model_vertex
div rcx
mov rbx, out_ref
mov [rbx].model.vertex_count, eax
invoke create_buffer, D3D11_BIND_VERTEX_BUFFER, vertices, r12
mov [rbx].model.vertices, rax
How far r15 has moved from the start of the array is the size in bytes, and dividing by 32 gives the vertex count. div rcx divides the 128-bit number in rdx:rax, which is why rdx is cleared first.
Drawing
LearnOpenGL's Mesh::Draw has a puzzle to solve: a mesh may have any number of diffuse and specular textures, and the shader has to know which sampler is which, so it adopts a naming convention (texture_diffuse1, texture_specular1, ...) and looks each uniform up by name. Direct3D has no uniforms to look up. A shader declares its textures in registers, t0, t1 and so on, and the program binds views to those slots by number. So we only need a convention about numbers, and ours is fixed: diffuse in t0, specular in t1, normal map in t2.
; rcx: &model, rdx: how many instances
align 16
model_draw_instanced proc m:qword, instances:qword
local stride:dword, offset_:dword, views[3]:qword
USING rbx
SaveRegs
mov rbx, m
mov stride, sizeof model_vertex
mov offset_, 0
mov rax, [rbx].model.diffuse
mov views[0], rax
mov rax, [rbx].model.specular
mov views[8], rax
mov rax, [rbx].model.normal
mov views[16], rax
vcall context, ID3D11DeviceContext_PSSetShaderResources, 0, 3, ADDR views
vcall context, ID3D11DeviceContext_IASetPrimitiveTopology, D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST
lea rax, [rbx].model.vertices
vcall context, ID3D11DeviceContext_IASetVertexBuffers, 0, 1, rax, ADDR stride, ADDR offset_
vcall context, ID3D11DeviceContext_DrawInstanced, dword ptr [rbx].model.vertex_count, instances, 0, 0
RestoreRegs
ret
model_draw_instanced endp
; rcx: &model
align 16
model_draw proc m:qword
invoke model_draw_instanced, m, 1
ret
model_draw endp
All three views are copied into a local array and bound with one call. A model without a normal map simply binds null to t2, which a shader that never reads t2 does not notice; a shader that does read it gets zeros. lea rax, [rbx].model.vertices computes; it is passed in rax as the fourth argument, before the two ADDRs that go on the stack. The instanced form will matter in Instancing; for now we always draw one copy.
Notice what
No more cubes!
Time to use it. The lesson's shader is compiled with the loader's layout, and the model loaded with one call:
invoke shader_create, ADDR s, "src/3.model_loading/1.model_loading/model_loading.hlsl", ADDR model_layout, model_layout_count
invoke model_load, ADDR backpack, "resources/objects/backpack/", "backpack.obj", 0
The backpack is the Survival Guitar Backpack by Berk Gedik, in the version LearnOpenGL prepared: textures renamed to diffuse.jpg and specular.jpg and paths made relative, so that it loads with a reader as simple as ours. The directory is relative to the repository root, like every resource path, and the last argument is flip_v, off for this model. backpack is a global model <> that
The pixel shader is the lighting from the Lighting chapter, reduced to one point light, with the diffuse colour and the specular strength both read from the model's maps:
Texture2D diffuse_map : register(t0);
Texture2D specular_map : register(t1);
SamplerState linear_wrap : register(s0);
float4 ps_main(vs_out p) : SV_Target
{
float3 albedo = diffuse_map.Sample(linear_wrap, p.uv).rgb;
float3 shine = specular_map.Sample(linear_wrap, p.uv).rgb;
float3 n = normalize(p.normal);
float3 to_light = normalize(light_position - p.world);
float3 to_eye = normalize(view_position - p.world);
float3 ambient = 0.15 * albedo;
float3 diffuse = 0.9 * max(dot(n, to_light), 0.0) * albedo;
float3 specular = pow(max(dot(to_eye, reflect(-to_light, n)), 0.0), 32.0) * shine;
return float4(ambient + diffuse + specular, 1.0);
}
The registers t0 and t1 are the convention transforms buffer itself. The model stays where it was modelled, so its model matrix and normal matrix are both the identity:
; the backpack stays where it was modelled
invoke transforms_from_camera, ADDR t, ADDR cam
invoke m4_identity, ADDR t.model
invoke m4_identity, ADDR t.normal_matrix
invoke update_buffer, transforms_buffer, ADDR t, sizeof transforms
The light circles the backpack slowly, its position recomputed every frame on the CPU:
; the light circles in front of it: (3 cos(t / 2), 2, 3 sin(t / 2) + 2)
movss xmm0, current_frame
mulss xmm0, math_half
call cosf
movf xmm1, 3.0
mulss xmm0, xmm1
movss light.position.x, xmm0
storef light.position.y, 2.0
movss xmm0, current_frame
mulss xmm0, math_half
call sinf
movf xmm1, 3.0
mulss xmm0, xmm1
movf xmm1, 2.0
addss xmm0, xmm1
movss light.position.z, xmm0
invoke update_buffer, light_buffer, ADDR light, sizeof light_constants
and drawing the model, after the shader and constant buffers are bound, is one line:
invoke shader_use, ADDR s
invoke bind_constants, 0, transforms_buffer
invoke bind_constants, 1, light_buffer
invoke model_draw, ADDR backpack
That is a great deal more interesting to look at than a marble cube. Fly around it (WASD, Space and Shift, right mouse button to look) and watch the specular map at work: the metal of the axe head and the buckles catches the light, while the leather and wood stay matte. A model that is moved or scaled would need its normal matrix from transforms.asm, which does for any vertex buffer what
Our reader handles the subset of .obj that these lessons' models use. Files from the internet will sometimes trip it up: faces with negative (relative) indices, several materials, absolute texture paths, or a format other than .obj altogether. Exporting from Blender with materials written and the path mode set to relative is the quickest way to get a file it reads. The full lesson is here, its shader here, and the loader here.
Additional resources
- Object Files (.obj) and MTL material format: Paul Bourke's copies of the format descriptions, including the parts we skip.
- Wavefront .obj file on Wikipedia: a shorter overview with examples.
- LearnOpenGL's Assimp, Mesh and Model chapters: the library route, with Assimp's scene and node structure.
- Survival Guitar Backpack by Berk Gedik, the model used here.
Exercises
- Count how many vertices a deduplicating loader would produce for the backpack: make a key of each corner's
a/b/ctriple, and count the distinct keys. Compare with the 203,721 vertices loaded now. - Use that count to build an index buffer: give each distinct triple one vertex, write a
dwordindex per corner, and draw withDrawIndexed . The picture should not change. - Load the backpack with flip_v, the last argument of
model_load , set to 1 and look at what happens to its textures. Then loadresources/objects/planet/planet.obj(directoryresources/objects/planet/) both ways and decide which one it needs. - Support several materials: note the vertex count at each
usemtlline, keep a range of vertices and a set of textures per material, and draw each range withDraw 's start-vertex argument.