Skip to content

Animating using model bones

Chuck Walbourn edited this page Oct 22, 2021 · 9 revisions

In this lesson we learn how to use model bones for rigid-body animation.

Setup

First create a new project using the instructions from the previous lessons: Using DeviceResources and Adding the DirectX Tool Kit which we will use for this lesson.

Background

Most 3D modeling packages (a.k.a. Digital Content Creation DCC tools) create a relationship between the different parts of 3D models. The process of assigning this hierarchy is called rigging the model for animation. This hierarchy can be exported to runtime geometry formats like SDKMESH, then loaded into DirectX Tool Kit as an array of ModelBone structures that link to the parent, children, and siblings of each bone. There's also a local transformation matrix associated with each bone.

These 'bone' locations can be used as simple metadata for placing dynamic lights, collision geometry, or other game-related logic in an art-driven fashion. They can also be used to implement rigid-body animation, which is what we will implement in this lesson.

Rendering a test scene

Start by saving tank.sdkmesh, engine_diff_tex.dds, and turret_alt_diff_tex.dds into your new project's directory, and then from the top menu select Project / Add Existing Item.... Select "tank.sdkmesh" and click "OK". Then repeat for the two DDS texture files.

If you are using a Universal Windows Platform app or Xbox project rather than a Windows desktop app, you need to manually edit the Visual Studio project properties on the tank.sdkmesh file and make sure "Content" is set to "Yes" so the data file will be included in your packaged build.

In the Game.h file, add the following variables to the bottom of the Game class's private declarations:

DirectX::SimpleMath::Matrix m_world;
DirectX::SimpleMath::Matrix m_view;
DirectX::SimpleMath::Matrix m_proj;

std::unique_ptr<DirectX::CommonStates> m_states;
std::unique_ptr<DirectX::EffectFactory> m_fxFactory;
std::unique_ptr<DirectX::EffectTextureFactory> m_modelResources;
std::unique_ptr<DirectX::Model> m_model;
DirectX::Model::EffectCollection m_modelNormal;

In Game.cpp, add to the TODO of CreateDeviceDependentResources right after you create m_graphicsMemory:

m_states = std::make_unique<CommonStates>(device);

m_model = Model::CreateFromSDKMESH(device, L"tank.sdkmesh",
    ModelLoader_IncludeBones);

ResourceUploadBatch resourceUpload(device);

resourceUpload.Begin();

m_model->LoadStaticBuffers(device, resourceUpload);

m_modelResources = m_model->LoadTextures(device, resourceUpload);

m_fxFactory = std::make_unique<EffectFactory>(m_modelResources->Heap(), m_states->Heap());

auto uploadResourcesFinished = resourceUpload.End(
    m_deviceResources->GetCommandQueue());

uploadResourcesFinished.wait();

RenderTargetState rtState(m_deviceResources->GetBackBufferFormat(),
    m_deviceResources->GetDepthBufferFormat());

EffectPipelineStateDescription pd(
    nullptr,
    CommonStates::Opaque,
    CommonStates::DepthDefault,
    CommonStates::CullCounterClockwise,
    rtState);

m_modelNormal = m_model->CreateEffects(*m_fxFactory, pd, pd);

m_world = Matrix::Identity;

In Game.cpp, add to the TODO of CreateWindowSizeDependentResources:

auto size = m_deviceResources->GetOutputSize();
m_view = Matrix::CreateLookAt(Vector3(1000, 500, 0),
    Vector3(0, 150, 0), Vector3::UnitY);
m_proj = Matrix::CreatePerspectiveFieldOfView(XM_PI / 4.f,
    float(size.right) / float(size.bottom), 0.1f, 10000.f);

In Game.cpp, add to the TODO of OnDeviceLost:

m_states.reset();
m_fxFactory.reset();
m_modelResources.reset();
m_model.reset();
m_modelNormal.clear();

In Game.cpp, add to the TODO of Render:

ID3D12DescriptorHeap* heaps[] = { m_modelResources->Heap(), m_states->Heap() };
commandList->SetDescriptorHeaps(static_cast<UINT>(std::size(heaps)), heaps);

Model::UpdateEffectMatrices(m_modelNormal, m_world, m_view, m_proj);

m_model->Draw(commandList, m_modelNormal.cbegin());

In Game.cpp, add to the TODO of Update:

float time = float(timer.GetTotalSeconds());

m_world = XMMatrixRotationY(time * 0.1f);    

Build an run to see... well, something:

Screenshot of messed up tank model

This is more or less the same setup as Rendering a model with the addition of ModelLoader_IncludeBones.

Rendering with model bones

The reason the tank doesn't look right is that the individual meshes are set up for rendering with the model bone information. The default Draw method does not use them, so everything is just dropped around the scene's origin.

So now let's add rendering using the model bones. In the Game.h file, add the following variables to the bottom of the Game class's private declarations:

DirectX::ModelBone::TransformArray m_drawBones;
DirectX::ModelBone::TransformArray m_animBones;

In Game.cpp, add to the TODO of CreateDeviceDependentResources (after you have loaded the model):

const size_t nbones = m_model->bones.size();

m_drawBones = ModelBone::MakeArray(nbones);
m_animBones = ModelBone::MakeArray(nbones);

m_model->CopyBoneTransformsTo(nbones, m_animBones.get());

In Game.cpp, modify the TODO of Render to replace the Draw call above with the following:

size_t nbones = m_model->bones.size();

m_model->CopyAbsoluteBoneTransforms(nbones,
    m_animBones.get(), m_drawBones.get());

ID3D12DescriptorHeap* heaps[] = { m_modelResources->Heap(), m_states->Heap() };
commandList->SetDescriptorHeaps(static_cast<UINT>(std::size(heaps)), heaps);

Model::UpdateEffectMatrices(m_modelNormal, m_world, m_view, m_proj);

m_model->Draw(commandList, nbones, m_drawBones.get(),
    m_world, m_modelNormal.cbegin());

You can build and run now, but the result will be a bit disappointing: it's just a blank CornflowerBlue scene. We'll solve this problem next.

Modifying model bones

The reason the scene is blank is that the model itself is shifted out of view. One of the model bones is translating it away from our center of view. While there are a number of ways to resolve this, since we are using model bones to render we can fix it directly.

In Game.cpp, add to the TODO of CreateDeviceDependentResources (after you have loaded the model and created the bone arrays):

uint32_t index = 0;
for (const auto& it : m_model->bones)
{
    if (_wcsicmp(it.name.c_str(), L"tank_geo") == 0)
    {
        // Need to recenter the model.
        m_animBones[index] = XMMatrixIdentity();
    }

    ++index;  
}

Now if you build and run, the model is back in the center and actually looks like a tank.

Screenshot of tank model

Moving individual bones

Now that we have our tank drawing correctly with each mesh controlled by individual model bones, we can add the animation. Since it's a hierarchy, we only need to worry about the local changes (i.e. how far is the turret rotated) without having to worry about the global location (i.e. where is the tank body located).

In the Game.h file, add the following variables to the bottom of the Game class's private declarations:

uint32_t m_leftBackWheelBone;
uint32_t m_rightBackWheelBone;
uint32_t m_leftFrontWheelBone;
uint32_t m_rightFrontWheelBone;
uint32_t m_leftSteerBone;
uint32_t m_rightSteerBone;
uint32_t m_turretBone;
uint32_t m_cannonBone;
uint32_t m_hatchBone;

In Game.cpp, update the constructor:

Game::Game() noexcept(false) :
    m_leftBackWheelBone(ModelBone::c_Invalid),
    m_rightBackWheelBone(ModelBone::c_Invalid),
    m_leftFrontWheelBone(ModelBone::c_Invalid),
    m_rightFrontWheelBone(ModelBone::c_Invalid),
    m_leftSteerBone(ModelBone::c_Invalid),
    m_rightSteerBone(ModelBone::c_Invalid),
    m_turretBone(ModelBone::c_Invalid),
    m_cannonBone(ModelBone::c_Invalid),
    m_hatchBone(ModelBone::c_Invalid)
{
    m_deviceResources = std::make_unique<DX::DeviceResources>();
    m_deviceResources->RegisterDeviceNotify(this);
}

Since we already have a loop searching through the bones, we'll just update it to find all our needed bones. Modify in Game.cpp in CreateDeviceDependentResources:

uint32_t index = 0;
for (const auto& it : m_model->bones)
{
    if (_wcsicmp(it.name.c_str(), L"tank_geo") == 0)
    {
        // Need to recenter the model.
        m_animBones[index] = XMMatrixIdentity();
    }
    else if (_wcsicmp(it.name.c_str(), L"l_back_wheel_geo") == 0) { m_leftBackWheelBone = index; }
    else if (_wcsicmp(it.name.c_str(), L"r_back_wheel_geo") == 0) { m_rightBackWheelBone = index; }
    else if (_wcsicmp(it.name.c_str(), L"l_front_wheel_geo") == 0) { m_leftFrontWheelBone = index; }
    else if (_wcsicmp(it.name.c_str(), L"r_front_wheel_geo") == 0) { m_rightFrontWheelBone = index; }
    else if (_wcsicmp(it.name.c_str(), L"l_steer_geo") == 0) { m_leftSteerBone = index; }
    else if (_wcsicmp(it.name.c_str(), L"r_steer_geo") == 0) { m_rightSteerBone = index; }
    else if (_wcsicmp(it.name.c_str(), L"turret_geo") == 0) { m_turretBone = index; }
    else if (_wcsicmp(it.name.c_str(), L"canon_geo") == 0) { m_cannonBone = index; }
    else if (_wcsicmp(it.name.c_str(), L"hatch_geo") == 0) { m_hatchBone = index; }

    ++index;
}

In Game.cpp, add to the TODO of Update (after setting the m_world variable):

float wheelRotation = time * 5.f;
float steerRotation = sinf(time * 0.75f) * 0.5f;
float turretRotation = sinf(time * 0.333f) * 1.25f;
float cannonRotation = sinf(time * 0.25f) * 0.333f - 0.333f;
float hatchRotation = std::min(0.f, std::max(sinf(time * 2.f) * 2.f, -1.f));

XMMATRIX mat = XMMatrixRotationX(wheelRotation);
m_animBones[m_leftFrontWheelBone] = XMMatrixMultiply(mat,
    m_model->boneMatrices[m_leftFrontWheelBone]);
m_animBones[m_rightFrontWheelBone] = XMMatrixMultiply(mat,
    m_model->boneMatrices[m_rightFrontWheelBone]);
m_animBones[m_leftBackWheelBone] = XMMatrixMultiply(mat,
    m_model->boneMatrices[m_leftBackWheelBone]);
m_animBones[m_rightBackWheelBone] = XMMatrixMultiply(mat,
    m_model->boneMatrices[m_rightBackWheelBone]);

mat = XMMatrixRotationX(steerRotation);
m_animBones[m_leftSteerBone] = XMMatrixMultiply(mat,
    m_model->boneMatrices[m_leftSteerBone]);
m_animBones[m_rightSteerBone] = XMMatrixMultiply(mat,
    m_model->boneMatrices[m_rightSteerBone]);

mat = XMMatrixRotationY(turretRotation);
m_animBones[m_turretBone] = XMMatrixMultiply(mat,
    m_model->boneMatrices[m_turretBone]);

mat = XMMatrixRotationX(cannonRotation);
m_animBones[m_cannonBone] = XMMatrixMultiply(mat,
    m_model->boneMatrices[m_cannonBone]);

mat = XMMatrixRotationX(hatchRotation);
m_animBones[m_hatchBone] = XMMatrixMultiply(mat,
    m_model->boneMatrices[m_hatchBone]);

Now if you build and run, the tank wheels are rolling, the turret is swinging back & forth, and other parts of the model animate as well.

Screenshot of moving tank model

Technical notes

  • The ModelBone::TransformArray type is just a std::unique_ptr owning an array of XMMATRIX. The function ModelBone::MakeArray ensures the allocated memory is 16-byte aligned to support aligned SIMD operations.

  • The m_model->boneMatrices is a ModelBone::TransformArray in the Model that contains the original local transforms loaded from the file. The length of the array is the same as the number of bones (i.e. m_model->bones.size()).

  • The CopyBoneTransformsTo method is a simple helper for copying the entire m_model->boneMatrices to a another bones matrices array.

  • The CopyAbsoluteBoneTransforms method computes the final matrices using the bone hierarchy given the input local matrices for each bone.

Next lesson: Using skinned models

Credits

This tutorial lesson is based heavily on the XNA Game Studio Simple Animation sample, including using the Tank assets (MS-PL).

The tank.sdkmesh model was exported using -flipz-(i.e. do not invert the Z axis) to keep the text on the tank texture correct in the right-handed view set up for this lesson.

For Use

  • Universal Windows Platform apps
  • Windows desktop apps
  • Windows 11
  • Windows 10
  • Xbox One
  • Xbox Series X|S

For Development

  • Visual Studio 2022
  • Visual Studio 2019 (16.11)
  • clang/LLVM v12 - v18
  • MinGW 12.2, 13.2
  • CMake 3.20

Related Projects

DirectX Tool Kit for DirectX 11

DirectXMesh

DirectXTex

DirectXMath

Tools

Test Suite

Model Viewer

Content Exporter

DxCapsViewer

See also

DirectX Landing Page

Clone this wiki locally