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 (uv-mapping) and paints those images. The tool then writes everything out to a file: positions, normals, texture coordinates, and which images go where. Our job is to read such a file and turn it into the buffers and textures Direct3D wants.

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 Assimp, the Open Asset Import Library, which reads dozens of formats into one common in-memory structure. Its structure is a tree: a scene holds meshes and materials, and nodes refer to meshes by index, so parts of a model can be parented to other parts. LearnOpenGL then spends two chapters on a 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

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

filesystem_read reads the whole file into memory as one string, followed by a zero byte, and a cursor walks along it. The cursor is simply an address held in a register, and the small procedures that move it share one convention: the cursor goes in in 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

obj_next_line scans to the byte after the next newline in the same way, and obj_word copies a word out into a buffer. To recognise a line we compare its first word:

; 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 strtof and strtol, which return where they stopped. We have no C library, so includes/cstr.asm has the two we need, with the same habit of reporting where they stopped. The integer is a few lines:

; 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, parse_float, is longer because it handles a sign, a decimal point and an exponent, but the idea is one its comment states:

; 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 strtof is careful about far more digits and far larger exponents than these files contain.) The reader wraps it so that spaces are skipped first:

; 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
parse_float returns two things, the float in 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: obj_skip_spaces promises to touch only 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 arena (saha.asm), a large block we hand out pieces of and never free piece by piece. So model_load reads the file twice. The first pass only counts, keeping its four totals in registers:

; 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 obj_starts returns 1 or 0, the count is simply added on, with no branch. 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 model_load saves the caller's values on entry with 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

arenaPushArrayZero is a macro: it multiplies the count by the size of the type and pushes that many zeroed bytes onto the arena, aligned to 16, returning their address in 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 obj_float hands back the cursor in 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. parse_int leaves the cursor in 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 triangle fan: keep the first corner, and make one triangle from it and each following pair of neighbours. A quad 1-2-3-4 becomes 1-2-3 and 1-3-4; a pentagon becomes three triangles. The fan keeps the face's winding, so counter-clockwise faces stay counter-clockwise, and it is correct for any convex polygon, which is what modelling tools export.

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

obj_at_corner skips spaces and says whether a number comes next, which is how the loop knows the face has ended. The first corner is kept in first, the last one read in previous, and from the third corner on each new one closes a triangle. fan_vertex is where the three separate index streams are joined:

; 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.

fan_vertex is a macro, not a procedure: wherever it is used, the assembler writes its body out in full with c replaced by the argument, so fan_vertex first reads first.position and so on. That is why it can use model_load's locals p, n and t by name: its lines end up inside model_load. A procedure would need them passed in, and a call per vertex.

Notice what we did not do: there is no index buffer. LearnOpenGL's Mesh stores unique vertices plus indices and draws with glDrawElements, the indexed drawing from Hello Triangle. To do that from an .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 obj_word reads the keyword itself, the second its argument, both into the same 128-byte local, word_ (with an underscore, because 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_copy and cstr_append, from 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 load_texture from gpu.asm, the texture loading from Textures: decode the image, create an RGBA8 texture with a full mip chain, upload the top level and let the GPU generate the rest. Its second argument picks the format:

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 create_buffer as the cube's:

; 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. IASetVertexBuffers wants the address of the buffer pointer, which 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 model_draw does not touch: the shader, the input layout, the constant buffers and the sampler. Those belong to the caller, who decides how the model is lit. The only thing the shader and the loader agree on is the vertex layout and the three texture slots.

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 model_load fills in.

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 model_draw keeps. The backpack is not a cube, so draw_cube is no use here; the lesson fills the 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
The backpack model, lit by one point light, with its leather straps, axe and wooden body

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 m4_normal_matrix, as in Basic Lighting, or simply draw_vertices from transforms.asm, which does for any vertex buffer what draw_cube does for the cube.

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

Exercises

  1. Count how many vertices a deduplicating loader would produce for the backpack: make a key of each corner's a/b/c triple, and count the distinct keys. Compare with the 203,721 vertices loaded now.
  2. Use that count to build an index buffer: give each distinct triple one vertex, write a dword index per corner, and draw with DrawIndexed. The picture should not change.
  3. Load the backpack with flip_v, the last argument of model_load, set to 1 and look at what happens to its textures. Then load resources/objects/planet/planet.obj (directory resources/objects/planet/) both ways and decide which one it needs.
  4. Support several materials: note the vertex count at each usemtl line, keep a range of vertices and a set of textures per material, and draw each range with Draw's start-vertex argument.
Credit: this chapter follows LearnOpenGL's Model by Joey de Vries, rewritten for Direct3D 11 and x64 assembly. LearnOpenGL's text and code are licensed CC BY-NC 4.0, and so is this page; the wording, code and screenshots here are new, and diagrams marked as LearnOpenGL's are its own.