Jump to content
  • Advertisement
  • 01/28/19 02:11 AM

    XM-03 3D Model Workflow

    Visual Arts


    Hey! My name’s Tim Trankle and I’m a 3D Artist for Pixelmatic.

    My most recent task was to create the XM-03 Hornet for Infinite Fleet.  I overhauled our 3D asset production workflow to take advantage of new tools and techniques to keep our visuals on the cutting edge. The new workflow has really taken our ships to another level and I’d like to show you just how I make them.


    The Model

    Every ship model has to start somewhere, and I usually start a ship by making its most recognizable features. In the case of the Hornet, that would be the energy-sapping arms and the wings. They contribute heavily to the silhouette and help guide the overall shape.



    While I’ve worked with several modelling packages, my go-to for hard surface work is Blender. This is because its modifier toolset is great for creating detail in a non-destructive way and the modelling tools themselves are built for making meshes quickly. It also has great addon support for extending its functionality and there’s one addon that was crucial to this ship’s production that I’ll talk about in a little bit.

    For hard surface modelling, I usually like to start with a single polygon plane and build my way up from there. I find that it allows for the most flexibility when creating more unusual shapes. For the Hornet, nearly every large component that wasn’t based on a cylinder started its life as a single plane.




    Here, all of the components are finished, but the polygons need to be smoothed. Right now, you can see that they are all hard shaded and identifiable. Typically, the solution to this is to create Smoothing Groups where some edges are soft shaded and others are hard shaded.



    This gets you most of the way there, but the edge between the two surfaces is perfectly sharp. On any surface, no matter how sharp the edge is, you’ll still see a highlight due to a bevel between the sides. Simply changing the smoothing groups doesn’t give you this. In the past, I’ve used an addon for Blender called TexTools to bake out a specially made normal map which would give me the bevel.



    As you can see, the edge around the top has a highlight on it conveying a bevel even though the model itself hasn’t changed. This works very well and requires very little setup but it is dependent on the texture resolution. If the texture is too low-res, then the bevel will appear pixelated and show some artifacts. For a ship as complex as the Hornet, this would’ve required an absurdly high-resolution texture to get a crisp bevel on the entire ship. Since most of the surface is flat, that means the normal map is mostly wasted space. I needed a new solution.


    Bevelled Edges and Custom Normals

    The solution I used was to create real bevels in the model. Normally, doing this by itself would result in shading artifacts as large faces are used to blend between vertex normals that are facing different directions. However, by adjusting the vertex normals manually, I can change the shading to be much cleaner. In Blender, I used an addon called Blend4Web which makes the adjustment of these normals very straightforward.



    I went in and added these bevels to all the hard edges around the ship. Now, I don’t need smoothing groups as the entire model would be smooth shaded. I just needed to adjust the vertex normals. The results are edges that give crisp highlights and look much more solid and realistic.




    Adding the Detail

    The Hornet is covered in panel seams, extrusions, markers, and lights. Before, I’d do all of this with the texture. I’d paint custom normal map details in Quixel nDo and then do the rest of the texturing in 3D-Coat but that strategy wouldn’t work here. Like with the normal map bevels mentioned earlier, the texture for the ship would need to be exceedingly high-res to capture all the detail I wanted to put on and have it be crisp and legible. The answer to this is to only create textures for the area where the detail needs to be. This is accomplished with decals.



    The decals, in this case, are actually polygons that rest just above the surface of the model. They can affect the color, normal, and roughness maps of the surface underneath them. This means that they can add detail that blends seamlessly with the underlying textures and still adhere to all of the environmental lighting conditions.

    To create these, I use an addon for Blender called DecalMachine. It provides a wide assortment of tools for creating decal textures and applying them to your model. You can create panel lines based on mesh intersections, place decals directly on the surface, or project planes that will conform to the shape of your model if it’s curved.




    All of this makes the decal creation process fast, flexible, and fun. I can place the decals anywhere on the model and use Blenders modifiers like mirroring and creating arrays to easily populate the surface of the ship with detail.




    Not only do the decals affect the normal map to create things like rivets or vents, but I can also create text and icons like the USF logo and place it wherever I want without having to erase texture layers or rebake any maps.




    Once I place all the decals that the ship needs, I’ll create a texture atlas. This is a single texture that has all of the decals that I used. This is the real power of using decals as this texture is independent of the rest of the ship. I can make the decals as small as I want and they won’t lose resolution.




    On top of that, I can use this atlas for all of the ships in the game, which drastically reduces the number of textures that need to be stored in memory.

    For the XM-03 Hornet, every detail you see is done with decals. The panel seams, the vents, the warning labels, and even the lights are all separate polygons resting on the surface. This results in quite a few decals.





    Since all of the unique detail on the ship is accomplished with the decals, this means that I can cover the rest of the surface with a set of tiling materials. These textures can tile independently of the decals which means I can scale them down and maintain a high level of detail even when the ship is viewed from very close up. Even though these textures are only 512x512, there is no pixelation.




    One of the downsides of this approach though is that the surface of the ship becomes very homogeneous since everything is the same color. To break up the color and the roughness, I made a special shader that added a tiling dirt texture to the more occluded areas of the ship based on an ambient occlusion map.




    Just like the rest of the textures for the paint, this dirt texture can be tiled independently from the decals or other textures. With that, the ship is finished! All that’s left is to set up some lights and the right post-processing effects to really show it off.




    We hope you enjoy gorgeous graphics as much as we do. While we still need to improve and optimize this new workflow, this is a huge step forward for us to be able to provide you with stunning models in Infinite Fleet.

    Stay tuned for more to come and feel free to reach us on our Discord if you have any questions.


    Note: This article was originally published on the Infinite Fleet blog, and is reproduced here with the kind permission of the author.  You can chat with the creators on their Discord or Twitter, or check out the trailer for the game on YouTube.

      Report Article

    User Feedback

    Oh sweet ba-jeebers yes! Here we go.... I've always hated the 'slap a tile-able on it' but this is brilliant. Please come back :D

    Share this comment

    Link to comment
    Share on other sites

    Create an account or sign in to comment

    You need to be a member in order to leave a comment

    Create an account

    Sign up for a new account in our community. It's easy!

    Register a new account

    Sign in

    Already have an account? Sign in here.

    Sign In Now

  • Advertisement
  • Game Developer Survey


    We are looking for qualified game developers to participate in a 10-minute online survey. Qualified participants will be offered a $15 incentive for your time and insights. Click here to start!

    Take me to the survey!

  • Advertisement
  • Latest Featured Articles

  • Featured Blogs

  • Advertisement
  • Popular Now

  • Similar Content

    • By Peter Petrov
      Hello, Everyone!
      I created skeleton program QT5 + BGFX framework. I do not use QT3D.
      BGFX supports DX9, DX11, DX12, OpenGL, Vulkan, Metal - https://github.com/bkaradzic/bgfx
      My basic initialization is quite simple:
      void* native_window_handle = reinterpret_cast<void*>(main_window.centralwidget->winId()); bgfx::Init init; init.type = bgfx::RendererType::OpenGL; // Or Direct3D9 or Direct3D11 init.vendorId = BGFX_PCI_ID_NONE; init.resolution.width = width; init.resolution.height = height; init.resolution.reset = reset; init.platformData.nwh = native_window_handle; bgfx::init(init); However, I have the following problem: 3D rendering area is flicking \ blinking. Especially on OpenGL renderer.
      I put my skeleton program to GitHub - https://github.com/PetrPPetrov/bgfx-qt5-win - requires to have a valid QT5 library, CMake.
      Could somebody try my application skeleton and make a suggestion?
      Thank you very much!
    • By CHERI1230
      I am looking for a better way to create 2D / 3D icons for items from 3D models.  For example, I have about 120 items in the game.  
      Now I am creating icons for items as follows:
      1)I create a simple script inside the game that will sequentially make a screen shot from the camera and change the item. All this is done on a green background.
      2)I put all these screenshots in Photoshop.  And for each subject I delete the background, center it on the grid, crop it.  
      3)I put it into the game.  And when building inventory slots with items, I still have to align and scale them because each item has the different size. And I must select all these parameters for each subject manually.  It takes too much time.  
      Does anyone have any ideas how to do this faster?  Maybe there is software to create something like that?
      Sorry for my English.
    • By khawk
      SideFX has launched Houdini 18. The new release adds new features in Vellum, fluids, pyro, destruction, modelling, and more.
      Check out the launch presentation for more information:

      View full story
    • By khawk
      SideFX has launched Houdini 18. The new release adds new features in Vellum, fluids, pyro, destruction, modelling, and more.
      Check out the launch presentation for more information:
    • By Yarden2JR
      Hi there, everyone!
      I am programing a tessellation shader in OpenGL which computes the quartic Walton-Meek's Gregory patch. I am searching for a local G1 method with good shading/visual results. So I am trying this patch. I didn't get good (visual/shading) results with PN-Triangles.
      I've just had my first result with this WM patch, which isn't good as well. Perhaps the normal I am calculating is wrong. I will attach the equations I am using to compute the normal. Please, take a look there as I couldn't write in TeX here (I don't know why). Basically, they are the Bernstein polynomial, Bernstein-Bezier triangle and its derivatives. Then, I actually compute it through normalizing the cross product between the derivatives.
      If you want to take a look at the shader code, here it is (the first one is the tessellation control shader and the second one is the tessellation evaluation shader):
      #version 430 core layout (vertices = 3) out; in VertOut { vec3 normal; } vertOut[]; out TescOut { vec3 p0; vec3 p1; vec3 p2; vec3 p3; vec3 g0; vec3 g1; vec3 n; } tescOut[]; void main() { const float kTessLevel = 12.0; gl_TessLevelOuter[gl_InvocationID] = kTessLevel; gl_TessLevelInner[0] = kTessLevel; vec3 p0 = tescOut[gl_InvocationID].p0 = gl_in[gl_InvocationID].gl_Position.xyz; vec3 n = tescOut[gl_InvocationID].n = vertOut[gl_InvocationID].normal; const int nextInvID = gl_InvocationID < 2 ? gl_InvocationID + 1 : 0; vec3 edge = gl_in[nextInvID].gl_Position.xyz - p0; vec3 nNext = vertOut[nextInvID].normal; float d = length(edge), a = dot(n, nNext); vec3 gama = edge / d; float a0 = dot(n, gama), a1 = dot(nNext, gama); float ro = 6.0 * (2.0 * a0 + a * a1)/(4.0 - a * a); float sigma = 6.0 * (2.0 * a1 + a * a0)/(4.0 - a * a); vec3 v[4] = vec3[4] ( p0, p0 + (d / 18.0) * (6.0 * gama - 2.0 * ro * n + sigma * nNext), gl_in[nextInvID].gl_Position.xyz - (d / 18.0) * (6.0 * gama + ro * n - 2.0 * sigma * nNext), edge = gl_in[nextInvID].gl_Position.xyz ); vec3 w[3] = vec3[3] ( v[1] - v[0], v[2] - v[1], v[3] - v[2] ); vec3 A[3] = vec3[3] ( cross(n, normalize(w[0])), vec3(0.0), cross(nNext, normalize(w[2])) ); A[1] = normalize(A[0] + A[2]); vec3 l[5] = vec3[5] ( v[0], 0.25 * (v[0] + 3.0 * v[1]), 0.25 * (2.0 * v[1] + 2.0 * v[2]), 0.25 * (3.0 * v[2] + v[3]), v[3] ); vec3 p1 = tescOut[gl_InvocationID].p1 = l[1]; vec3 p2 = tescOut[gl_InvocationID].p2 = l[2]; vec3 p3 = tescOut[gl_InvocationID].p3 = l[3]; barrier(); const int previousInvID = gl_InvocationID > 0 ? gl_InvocationID - 1 : 2; vec3 D[4] = vec3[4] ( tescOut[previousInvID].p3 - 0.5 * (p0 + p1), vec3(0.0), vec3(0.0), tescOut[nextInvID].p1 - 0.5 * (p3 + tescOut[nextInvID].p0) ); float mi[2] = float[2](dot(D[0], A[0]), dot(D[3], A[2])); float lambda[2] = float[2](dot(D[0], w[0])/dot(w[0], w[0]), dot(D[3], w[2])/dot(w[2], w[2])); tescOut[gl_InvocationID].g0 = 0.5 * (l[1] + l[2]) + (2.0/3.0) * (lambda[0] * w[1] + mi[0] * A[1]) + (1.0/3.0) * (lambda[1] * w[0] + mi[1] * A[0]); tescOut[gl_InvocationID].g1 = 0.5 * (l[2] + l[3]) + (1.0/3.0) * (lambda[0] * w[2] + mi[1] * A[2]) + (2.0/3.0) * (lambda[1] * w[1] + mi[1] * A[1]); } #version 430 core layout(triangles, equal_spacing, ccw) in; in TescOut { vec3 p0; vec3 p1; vec3 p2; vec3 p3; vec3 g0; vec3 g1; vec3 n; } tescOut[]; out TeseOut { vec3 normal; vec3 viewPosition; } teseOut; uniform mat4 mvp; uniform mat4 modelView; uniform mat4 normalMatrix; uniform bool isNormalLinearlyInterpolated; #define uvw gl_TessCoord const float u = uvw.x, u2 = u * u, u3 = u2 * u, u4 = u2 * u2; const float v = uvw.y, v2 = v * v, v3 = v2 * v, v4 = v2 * v2; const float w = uvw.z, w2 = w * w, w3 = w2 * w, w4 = w2 * w2; #define p400 tescOut[0].p0 #define p310 tescOut[0].p1 #define p220 tescOut[0].p2 #define p130 tescOut[0].p3 #define G01 tescOut[0].g0 #define G02 tescOut[0].g1 #define p040 tescOut[1].p0 #define p031 tescOut[1].p1 #define p022 tescOut[1].p2 #define p013 tescOut[1].p3 #define G11 tescOut[1].g0 #define G12 tescOut[1].g1 #define p004 tescOut[2].p0 #define p103 tescOut[2].p1 #define p202 tescOut[2].p2 #define p301 tescOut[2].p3 #define G21 tescOut[2].g0 #define G22 tescOut[2].g1 #define B400 u4 #define B040 v4 #define B004 w4 #define B310 (4.0 * u3 * v) #define B031 (4.0 * v3 * w) #define B103 (4.0 * u * w3) #define B220 (6.0 * u2 * v2) #define B022 (6.0 * v2 * w2) #define B202 (6.0 * u2 * w2) #define B130 (4.0 * u * v3) #define B013 (4.0 * v * w3) #define B301 (4.0 * u3 * w) #define B211 (12.0 * u2 * v * w) #define B121 (12.0 * u * v2 * w) #define B112 (12.0 * u * v * w2) #define B300 u3 #define B030 v3 #define B003 w3 #define B210 (3.0 * u2 * v) #define B021 (3.0 * v2 * w) #define B102 (3.0 * w2 * u) #define B120 (3.0 * u * v2) #define B012 (3.0 * v * w2) #define B201 (3.0 * w * u2) #define B111 (6.0 * u * v * w) vec3 interpolate3D(vec3 p0, vec3 p1, vec3 p2) { return gl_TessCoord.x * p0 + gl_TessCoord.y * p1 + gl_TessCoord.z * p2; } void main() { vec4 pos = vec4(interpolate3D(tescOut[0].p0, tescOut[1].p0, tescOut[2].p0), 1.0); vec3 normal = normalize(interpolate3D(tescOut[0].n, tescOut[1].n, tescOut[2].n)); if (u != 1.0 && v != 1.0 && w != 1.0) { vec3 p211 = (v * G12 + w * G21)/(v + w); vec3 p121 = (w * G02 + u * G11)/(w + u); vec3 p112 = (u * G22 + v * G01)/(u + v); vec3 barPos = p400 * B400 + p040 * B040 + p004 * B004 + p310 * B310 + p031 * B031 + p103 * B103 + p220 * B220 + p022 * B022 + p202 * B202 + p130 * B130 + p013 * B013 + p301 * B301 + p211 * B211 + p121 * B121 + p112 * B112; pos = vec4(barPos, 1.0); vec3 dpdu = p400 * B300 + p130 * B030 + p103 * B003 + p310 * B210 + p121 * B021 + p202 * B102 + p220 * B120 + p112 * B012 + p301 * B201 + p211 * B111 ; vec3 dpdv = p310 * B300 + p040 * B030 + p013 * B003 + p220 * B210 + p031 * B021 + p112 * B102 + p130 * B120 + p022 * B012 + p211 * B201 + p121 * B111 ; normal = normalize(cross(dpdu, dpdv)); } gl_Position = mvp * pos; pos = modelView * pos; teseOut.viewPosition = pos.xyz / pos.w; teseOut.normal = (normalMatrix * vec4(normal, 0.0)).xyz; } There are also some screenshots attached of my current results. Please, take a look. In the "good ones" images, the normals are computed by linear interpolation, while in the bad ones the normals are computed through the equations I said previously and are shown in the code.
      So, how can I correctly compute the normals? Thanks in advance!


Important Information

By using GameDev.net, you agree to our community Guidelines, Terms of Use, and Privacy Policy.

GameDev.net is your game development community. Create an account for your GameDev Portfolio and participate in the largest developer community in the games industry.

Sign me up!