Episode 17 of a series about building an MMO from scratch, with the focus on the engineering around the game. We build the character screen: your name, level, health, experience and position next to an idle model of your character, facing you. The client draws that model into a render texture, through a small camera of its own, and Dear ImGui shows the texture in the character window. Along the way, we pull the drawing of a single figure out of the world's renderer, test the preview on a hidden window, and make C and View, Character open the screen.
Topics: Graphics
The code
Each step on screen is a tag in the game's repository. The code at the end of the episode is ep017/end.
Transcript
Intro
0:00 Hello and welcome back to episode seventeen, where we're going to build a character screen, with a live 3D preview of our player next to their details. Let's begin with a quick look at where we're going to be by the end of the episode.
0:12 Pressing C opens the character screen over the game, with our player standing idle in the preview and looking at us. Next to it are the name, the level, health, experience and position, which the client already gets from the server.
0:28 On the website of the series, you can find more information about the project. The code for the project is open source on GitHub, and each episode gets a page of its own, from the first window to the island we built in the last two episodes.
Last time
0:42 Now, before we get to today's work, let's quickly go over what we did in the last two episodes. We started from a seed, which is a single number that our terrain library turns into the world. Out of it, through value noise, comes an island five hundred and twelve metres across, with hills, a lake and the sea around it. The server and the client both build that library, so the seed is all that goes over the network, and the server uses it to decide where players can walk. Then trees grew where the seed says, and the server keeps us from walking through their trunks. A benchmark of the client showed us where the frame went, so we cut the island into chunks and drew the ones in sight, which made each frame a lot faster. And our player, the animated Mixamo character with a sword, runs around it, with a camera following from behind.
Today
1:29 Today, we're going to give the player a character screen, which is the window in most MMOs where you look at your character and its stats. The client already gets the name, the level, health and experience from the server, and the player frame in the corner shows some of them. To make it feel like a game, we want a model of the character next to those details, standing idle and looking at you. Dear ImGui draws our UI, and it can draw text, buttons and textures, but not a 3D model. So we're going to draw the model into a render texture, which is a texture on the GPU that we draw into instead of the window. Then we hand that texture to ImGui, which shows it like any other picture. The preview gets a camera of its own, fixed in front of the model, while the game camera keeps following us around the island. And as we draw the preview again on each frame, the idle animation plays in it, the way it does in the world. Then a class of ours, CharacterScreen, puts that texture in an ImGui window, next to the details. And to open it, we use the C key, like a lot of MMOs do, along with a Character item in the View menu. All right, let's get started, and the first thing we need is a way for the renderer to draw a single character on its own.
Drawing a preview
2:43 We start in renderer dot h, as the renderer can already draw our characters, with the Mixamo models from episode seven. For the preview, we want that drawing without the rest of the world around it, so no terrain, no trees and no other players. So the renderer gets a public function, DrawPreview, which draws one figure through a camera that the caller hands in. Camera3D is raylib's struct for a camera in 3D, with where it stands, what it looks at and its field of view. The caller owns the camera, so the renderer doesn't need to know anything about the window the preview ends up in. The comment over it is for whoever calls it: draws one figure on a plain background, for previews in the UI. In the private part goes DrawFigure, which picks the model for a figure and draws it. Until now, that code sat inside the loop in Draw, and as the preview needs it too, it moves into a function of its own. In renderer dot c c, the preview gets a background colour of its own, a dark blue-grey, kept as three floats like the sky's colour. A plain dark colour keeps your eye on the character, and it doesn't change when you pick another sky in the settings. In Draw, the loop over the figures now calls DrawFigure, and then still draws the white marker over our own player. The marker helps you spot your own player in a crowd, so it belongs in the world and not in the preview. DrawPreview starts by clearing the texture to the preview's colour. Color from normalized turns our floats into the four bytes that raylib's clear background takes, like Clear does with the sky. The alpha is one, so the background is solid, and the game behind the window doesn't show through the picture. Then it sets the fog on both of our shaders, the one for the cube and the one for the characters, with the preview's colour. The character stands four metres from the camera and the fog starts at fifty, so the fog isn't going to reach it. We set it anyway, as the fog's distances are uniforms that Draw sets, and on the first frame, the preview can be drawn before Draw has run at all. Left at zero, those distances would put the whole character in fog, and we'd get a flat silhouette for a frame. Then, between begin and end mode 3D, we draw the figure through our camera. DrawFigure is the code we took out of the loop, as it was: monsters get the skeleton, and players get the Mixamo characters, with a colour from their id. When the models have loaded, it draws the figure with them, and returns early. When they haven't, say after a fresh clone without the Mixamo download, it draws the cube we had before. So the game still runs, and so do our tests in Docker, where the characters aren't there. Next is offscreen dot h, with the Offscreen class from episode two. Until now, our tests used it to draw into a render texture and read the pixels back, and now the game uses it too. The header includes the animator's, for Figure, as the new method takes one by reference. RenderPreview takes the renderer, the figure and the camera, so whoever wants a preview doesn't switch raylib's drawing target on their own. And texture hands out the texture behind the render target, so that an ImGui window can show it. A Texture2D is a small struct with an id and a size, so handing it out copies those few numbers, while the pixels stay on the GPU. In offscreen dot c c, RenderPreview works like Render from episode two. Between begin and end texture mode, raylib draws into our texture, and DrawPreview does the drawing. Then the test, in renderer test dot c c, which checks that a preview lands in the texture. It opens a hidden window of sixty-four pixels, like our other renderer tests, as raylib needs an OpenGL context to draw anything, even into a texture. And if a machine can't open the window, assert true stops the test right there. Then a target of sixty-four pixels too, and a renderer like the one the game makes. The camera stands four metres in front of the origin and zero point nine metres up, and looks straight ahead at that height. Zero point nine metres is about half the height of our characters, and it's still inside the one-metre cube. So the middle of the picture hits whichever of the two gets drawn. We render a default figure, which is a player standing at the origin, so the Mixamo character on the Mac and the cube in Docker. Then we expect the pixel in the middle to differ from the pixel in the corner, which is the background. We keep the check that loose on purpose, so the test pins down that a figure lands where the camera points, without breaking whenever the character's looks change.
7:12 We build the client's tests and run the renderer's, with gtest filter. All four of them pass, the new preview test along with the three from before, for the background, the cube and the terrain.
The screen
7:24 Now for the screen, and first, in the client's CMake lists, character screen dot c c joins the core library, next to our other ImGui windows. Then a new header, character screen dot h, which opens with an include guard like our other headers. First vector, as the screen looks for our player in the list of figures, which comes as a vector. Then animator dot h, for the Figure struct that the animator fills in for each character we draw. Then offscreen dot h, and renderer dot h, for the two classes the screen works with. The screen holds an Offscreen as a member, so the compiler needs to know its size. And our protocol header, for the World message, as the experience comes from it, not from the figures. The constructor is defaulted, as both members come with their starting values. Copying is deleted, like in our other classes that own something on the GPU, which here is the texture. Declaring the copy constructor, even as deleted, also stops the compiler from writing the default constructor for us, which is why we asked for it on the line above. Mutable visible hands out a pointer to the flag that says whether the window is open. The View menu ticks and unticks it, like its other items since episode two. Toggle flips the flag, and that's what the C key is going to call. Then we split the work in two, and Render draws the preview into the texture, which has to happen before ImGui draws the window that shows it. Build makes the ImGui window, from the list of figures and the world message, the way the player frame in the corner does. We keep them apart, as they run at different points in the frame. In the private part, the preview is three hundred and sixty pixels square, as a static constexpr, so we can size the texture with it right below. Visible starts out false, so the window stays closed until you ask for it. And the preview is an Offscreen member, so the screen creates its texture when it's made and unloads it when it goes away, without any code of ours for either. In character screen dot c c, after its own header, imgui dot h comes in, for the window and the widgets in it. Then algorithm, for find if, and vector again, as each file includes the standard headers it uses, while the renderer comes with our header. Then icons font awesome six dot h, for the icons in front of the details, like the player frame's. The preview camera goes in an unnamed namespace, with the numbers from our test's camera. It looks straight ahead, so the character sits in the middle of the picture. Its field of view is thirty-five degrees, which is narrower than the game camera's fifty, a bit like a zoom lens. At four metres, that fits a character from the feet to the head, with some room around it. Then a comment says where it stands: four metres in front of the character, level with its middle. Render takes the renderer as a const reference, as it reads the figures and draws with the renderer, without changing it. The function isn't const, though, as it changes the pixels of the texture that the screen owns. First, it returns right away while the window is closed, so a closed screen costs us no drawing at all. Then it finds our player among the renderer's figures, with ranges find if on the flag called you, like we did for the camera in episode fifteen. If our figure isn't there yet, say for a frame or two after logging in, there's nothing to show, so it returns there too. Then we copy our figure, as the renderer still draws the original in the world, wherever it's running on the island. The copy moves to the origin, where the preview camera looks, and its yaw goes to zero, so it turns to face the camera. Its animation becomes idle, whatever the player is doing in the world, so the preview doesn't run on the spot while you run around. To time is how far into the idle clip we are. And raylib's get time, the seconds since the window opened, keeps the clip moving from one frame to the next. Blend at one means we're all the way into idle, so the clip it was blending from doesn't matter. And last, we render the copy into our texture, through the preview camera. Build skips a closed window as well, so a closed screen doesn't add any ImGui work to the frame either. Then it finds our player again, this time in the list it was given, the way the player frame does. Set next window size makes the window six hundred by four hundred pixels, which is room for the preview and the details. First use ever means ImGui applies that the first time the window appears, so when you resize it, it keeps your size and not ours. Begin gets the title and a pointer to visible, and with that pointer, ImGui adds a close button to the window that sets the flag back to false. Image draws our texture at three hundred and sixty pixels, from the id that raylib gave it on the GPU. The last two arguments are the texture coordinates of the top left and the bottom right corners, and we pass them upside down, from one to zero. Without that, our character would stand on their head, so the flip gets a comment, like the one in Offscreen's read pixel. OpenGL keeps the rows bottom up, so the UVs turn the picture over. SameLine puts the details to the right of the picture, instead of under it, and a group keeps them together as a column. At the top goes the name, with the level in grey under it, and then a separator line. Then the health out of the maximum, and the experience towards the next level, which comes from the world message, like in the player frame. They start with the player frame's icons, a heart and a star. And last, the position, after a crosshairs icon, as x and z on the ground, to a tenth of a metre. The client had all of these values already, so the screen needs nothing new in the protocol, and the server stays in charge of them.
Opening it
13:04 Now we need a way to open it, so we start in main dot c c. First, imgui dot h comes in, as main is going to ask ImGui whether it's using the keyboard. Then character screen dot h, in its place among our own headers. We make the character screen next to the menu, after the window, as its render texture needs the OpenGL context that the window creates. It lives as long as the client runs, so the screen keeps its texture from one login to the next. After we hand the figures to the renderer, pressing C toggles the screen, unless ImGui's want text input is true, which it is while one of its text fields has the typing. A C typed into one of them, say in the ImGui demo window, shouldn't open the screen. ImGui has a broader flag, want capture keyboard, which is also true while any of its windows has the focus, and the character screen takes the focus when it opens. With that flag, C could open the screen but not close it. The check gets a comment, so that nobody drops it by accident: C is ours, unless a text field has the typing. Then the screen renders its preview, and it does that before BeginDrawing. It draws into its own texture and not into the window, so it doesn't need the window's frame to have started. And doing it first means the texture is ready by the time ImGui shows it, later in the frame. Further down, after the nameplates and the player frame, the screen builds its window, from the figures and the world the connection holds. And the menu's build gets the pointer to the screen's flag, as a third argument. In menu dot h, Build takes that pointer as a new argument, show character, next to the window and the background colour it already gets. And in menu dot c c, the definition of Build takes the pointer as well. In the View menu, Character goes first, with a person in a circle for its icon, and C as its shortcut, which ImGui shows as a label next to the item, without listening for the key. So the C key, the menu item and the close button on the window all flip the one bool that decides whether the screen draws.
15:07 Pressing C opens the window over the island, with our player idling in the preview and the details from the server next to it. And pressing C a second time closes it, and we're back to running around the island.
Docs
15:22 The README now says that C, or View, Character, opens the character screen, what it shows, and how the client draws the preview into a texture for ImGui.
Wrap-up
15:32 So, today we gave the player a character screen, from details the client already had: the name, level, health, experience and position. A camera of its own looks at a copy of our figure, standing at the origin, idle and facing the camera. The renderer draws that copy into a render texture, through the Offscreen class from episode two. CharacterScreen hands that texture to Dear ImGui, which shows it next to the details, while the game keeps running behind the window. And the C key, the View menu and the window's close button all flip a single bool, which decides whether the screen is open.
Playing it
17:09 Let me know in the comments what you'd like to see next. And if you want to follow along with the series, subscribe to the channel. I hope you find this useful, and thanks for watching.