← Back
Tool

Asset Brush

The asset brush is a tool I made during our tools course at The Game Assembly. We had just completed our top down adventure project and I identified a huge time sink for our artists when set dressing the levels. They had to place each individual foliage asset by hand, meaning duplicating each asset, rotate/scale to their liking, and then placing it. I wanted to make a tool that made this job fast, easy to learn and fun to use. This way, we could save loads of time in our future projects.

I will refer to a "brush stroke" and a "batch" in my descriptions. They are essentially the same thing, meaning one single placement of a group of assets.

Spacing & Draw Mode

Both draw and erase mode let the user choose between hold-and-drag or single click, giving fine-grained control over how assets are placed. Asset selection can be either random or sequential, with random selecting a random asset from the ones in the palette, and sequential placing them in the order they were added to the palette. The draw mode also supports preview and none preview mode, with preview mode showing green ghost assets representing the position and orientation the asset will be placed as. For more control, the user can select the option to freeze each brush stroke, effectively letting them move around the current batch of assets to place wherever they like, and then only generate a new batch if the user presses 'R'.

Spacing is handled in two ways. The user can control the distance between each brush stroke, so each batch is placed at a set interval from the previous one. On top of that, minimum spacing between individual objects can be enabled, either against all previously painted objects, or just within the current batch. One known issue with the current implementation is that the assets currently only check their absolute position against each other, which can make them overlap during certain circumstances — a problem that could be solved by instead checking their max bounds and computing the minimum distance that way. But for now, absolute position is more than enough for almost every case.

The user can also decide to scatter the positions relative to the brush size, meaning they can spread the assets around the edges of the brush, or concentrate the placement to the inner 20%, or in whatever combination they wish, all configurable under the "Position Scatter" option.

Code
1 / 3
void AssetBrush::Update(bool isLeftMouseDown)
{
    HandleInput();
    if (!myIsActive || !myBrush.isValid)
    {
        myWasMouseDownLastFrame = false;
        return;
    }
    if (myBrush.mode == BrushMode::Paint)
    {
        const bool hasAssets = !myAssetPalette.empty();
        if (!hasAssets)
        {
            myWasMouseDownLastFrame = false;
            return;
        }
    }
    // --- Hold and drag ---
    if (myPlacementMode == PlacementMode::HoldAndDrag)
    {
        if (isLeftMouseDown)
        {
            bool shouldAct = false;
            // First click - always act
            if (!myWasMouseDownLastFrame)
            {
                shouldAct = true;
            }
            // Holding - check distance from last action
            else
            {
                float distanceFromLast = (myBrush.position - myLastPlacedPosition).LengthSqr();
                if (distanceFromLast >= FMath::Sq(myBrush.strokeSpacing))
                {
                    shouldAct = true;
                }
            }
            if (shouldAct)
            {
                if (myBrush.mode == BrushMode::Paint)
                {
                    PlaceObject();
                }
                else if (myBrush.mode == BrushMode::Erase)
                {
                    EraseObjects();
                }
                myLastPlacedPosition = myBrush.position;
            }
        }
    }
    // --- Single click ---
    else if (myPlacementMode == PlacementMode::SingleClick)
    {
        if (isLeftMouseDown && !myWasMouseDownLastFrame)
        {
            if (myBrush.mode == BrushMode::Paint)
            {
                PlaceObject();
            }
            else if (myBrush.mode == BrushMode::Erase)
            {
                EraseObjects();
            }
        }
    }
    myWasMouseDownLastFrame = isLeftMouseDown;
}
std::shared_ptr<Tga::SceneObject> AssetBrush::PlaceObjectWithAsset(Tga::StringId aAssetPath, std::vector<Tga::Vector3f>& someCurrentBatchPositions) const
{
    constexpr int maxAttempts = 10;
    for (int attempt = 0; attempt < maxAttempts; attempt++)
    {
        OffsetResult offsetResult = CalculateOffsetPosition();
        if (!offsetResult.valid)
        {
            continue;
        }
        Tga::Vector3f position = offsetResult.position;
        Tga::Vector3f surfaceNormal = offsetResult.normal;
        if (!IsSlopeValid(surfaceNormal))
        {
            continue;
        }
        if (myBrush.useMinimumSpacing)
        {
            if (myBrush.checkOnlyCurrentBatch)
            {
                if (IsPositionTooCloseInBatch(position, myBrush.minimumSpacing, someCurrentBatchPositions))
                {
                    continue;
                }
            }
            else
            {
                if (IsPositionTooClose(position, myBrush.minimumSpacing))
                {
                    continue;
                }
            }
        }
        someCurrentBatchPositions.push_back(position);
        Tga::Quaternionf finalRotation = CalculateRandomRotation(surfaceNormal);
        Tga::Vector3f rotationEuler = finalRotation.GetYawPitchRoll();
        Tga::Vector3f randomScale = CalculateRandomScale();
        auto object = std::make_shared<Tga::SceneObject>();
        std::filesystem::path assetFilePath(aAssetPath.GetString());
        Tga::StringId objectDefinitionName = Tga::StringRegistry::RegisterOrGetString(assetFilePath.stem().string());
        object->SetSceneObjectDefintionName(objectDefinitionName);
        object->GetTRS().translation = position;
        object->GetTRS().rotation = rotationEuler;
        object->GetTRS().scale = randomScale;
        if (myAdjustPivot)
        {
            float zeroOffset = 0.f;
            CalculateNewPivot(object, zeroOffset);
        }
        return object;
    }
    return nullptr;
}
bool AssetBrush::IsPositionTooClose(const Tga::Vector3f& aNewPos, float aMinDistance) const
{
    int cellX = static_cast<int>(floorf(aNewPos.x / mySpatialCellSize));
    int cellY = static_cast<int>(floorf(aNewPos.y / mySpatialCellSize));
    int cellZ = static_cast<int>(floorf(aNewPos.z / mySpatialCellSize));
    for (int dx = -1; dx <= 1; dx++)
    {
        for (int dy = -1; dy <= 1; dy++)
        {
            for (int dz = -1; dz <= 1; dz++)
            {
                auto it = mySpatialGrid.find({ cellX + dx, cellY + dy, cellZ + dz });
                if (it == mySpatialGrid.end())
                {
                    continue;
                }
                for (const Tga::Vector3f& pos : it->second)
                {
                    if ((pos - aNewPos).LengthSqr() < FMath::Sq(aMinDistance))
                    {
                        return true;
                    }
                }
            }
        }
    }
    return false;
}
bool AssetBrush::IsPositionTooCloseInBatch(const Tga::Vector3f& aNewPos, float aMinDistance, const std::vector<Tga::Vector3f>& someBatchPositions)
{
    for (const auto& pos : someBatchPositions)
    {
        float distance = (pos - aNewPos).LengthSqr();
        if (distance < FMath::Sq(aMinDistance))
        {
            return true;
        }
    }
    return false;
}
Multi-Asset Drawing

As a first iteration I only allowed placement of one unique asset — this quickly became underwhelming. Multi asset drawing gives the user unlimited choices in how to design their clusters. With randomization per asset, spacing and the ability to tune how many assets to place, each brush stroke can generate something unique. Users do so by adding slots to an “Asset Palette”, a representation of which assets they are currently using in their brush stroke. This supports a large amount of assets, and for the time being, I set the maximum assets per stroke to 20.

As for now, a limitation to the system is if the brush size in combination with spacing makes the placement of a single asset within a batch fail because of the settings. I did implement a “Try Place” function, which tries placing the asset a maximum of 10 times before silently failing the placement, as you can see in the code snippet under “Spacing & Draw Mode”.

Code
1 / 3
// Using ImGui to add an asset to our palette:
if (ImGui::BeginChild("AssetListFrame", listSize, true, ImGuiWindowFlags_HorizontalScrollbar))
{
    int itemToDelete = -1;
    for (int i = 0; i < myAssetPalette.size(); i++)
    {
        ImGui::PushID(i);
        bool isSelected = (i == mySelectedPaletteIndex);
        const char* displayName = myAssetPalette[i].IsEmpty()
            ? "[ Empty Slot ]"
            : myAssetPalette[i].GetString();
        ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.8f, 0.2f, 0.2f, 1.0f));
        ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(1.0f, 0.3f, 0.3f, 1.0f));
        ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.7f, 0.1f, 0.1f, 1.0f));
        if (ImGui::SmallButton("X"))
        {
            itemToDelete = i;
        }
        ImGui::PopStyleColor(3);
        ImGui::SameLine();
        if (ImGui::Selectable(displayName, isSelected, ImGuiSelectableFlags_AllowDoubleClick))
        {
            mySelectedPaletteIndex = i;
        }
        if (ImGui::BeginDragDropTarget())
        {
            if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(".tgo"))
            {
                const char* dropped = static_cast<const char*>(payload->Data);
                myAssetPalette[i] = Tga::StringRegistry::RegisterOrGetString(dropped);
            }
            ImGui::EndDragDropTarget();
        }
        ImGui::PopID();
    }
}
// Then using the asset palette with placement count when placing objects, here Random as an example:
if (myPaletteMode == PaletteMode::Random)
{
    std::vector<Tga::StringId> validAssets;
    for (const auto& asset : myAssetPalette)
    {
        if (!asset.IsEmpty())
        {
            validAssets.push_back(asset);
        }
    }
    if (!validAssets.empty())
    {
        for (int i = 0; i < myPlacementCount; i++)
        {
            const int randomIndex = rand() % validAssets.size();
            auto object = PlaceObjectWithAsset(validAssets[randomIndex], currentBatchPositions);
            if (object)
            {
                objectsToPlace.push_back(object);
            }
        }
    }
}
// Using the position scatter to decide where to place it inside the brush circle:
float distance = RandomGenerator::GetFloat(myBrush.randomOffsetRadiusMin, myBrush.randomOffsetRadiusMax);
distance = FMath::Min(distance, myBrush.size);
Tga::Vector3f tangent;
Tga::Vector3f bitangent;
GetSurfaceTangentFrame(myBrush.normal, tangent, bitangent);
Tga::Vector3f offset = (tangent * cosf(angle) + bitangent * sinf(angle)) * distance;
Randomize

Randomization gives the user a tool for creating unique assets in each batch. I've chosen to divide it into yaw/pitch/roll and scale, to give the user an easy overview of what is available. I noticed that with the inclusion of previews and the ability to turn off auto generate (the freeze function described earlier), this became even more useful. The user can now generate a new randomized batch if they feel like the brush stroke is in a position they like. One thing I really would like to add is the ability to only regenerate the randomness of the current batch assets, without also switching out the current batch assets. But for now, this is more than enough, giving a lot of options.

I noticed this was also very useful for more detailed work, such as single asset placement, when working in a concentrated area. The user also has the ability to change yaw/pitch/roll and scale of the current batch manually, by certain key commands such as shift+scroll, x+scroll etc, for even more fine tuning.

Code
1 / 2
// First calculate the base rotation using the normal, then add the rotations as implied by the user:
Tga::Quaternionf AssetBrush::CalculateRandomRotation(const Tga::Vector3f& aSurfaceNormal) const
{
    Tga::Quaternionf baseRotation = Tga::Quaternionf::CreateFromUpDirection(aSurfaceNormal);
    Tga::Quaternionf finalRotation = baseRotation;
    if (myBrush.useRandomRotation)
    {
        float randomYaw = RandomGenerator::GetFloat(myBrush.randomRotationMin, myBrush.randomRotationMax);
        Tga::Vector3f localUp = finalRotation.GetUp();
        Tga::Quaternionf yawRotation(localUp, randomYaw);
        finalRotation = baseRotation * yawRotation;
    }
    if (myBrush.useRandomPitch)
    {
        float randomPitch = RandomGenerator::GetFloat(myBrush.randomPitchMin, myBrush.randomPitchMax);
        Tga::Vector3f localRight = finalRotation.GetRight();
        Tga::Quaternionf pitchRotation(localRight, randomPitch);
        finalRotation = pitchRotation * finalRotation;
    }
    if (myBrush.useRandomRoll)
    {
        float randomRoll = RandomGenerator::GetFloat(myBrush.randomRollMin, myBrush.randomRollMax);
        Tga::Vector3f localForward = finalRotation.GetForward();
        Tga::Quaternionf rollRotation(localForward, randomRoll);
        finalRotation = rollRotation * finalRotation;
    }
    return finalRotation;
}
// Calculate scale
Tga::Vector3f AssetBrush::CalculateRandomScale() const
{
    if (!myBrush.useRandomScale)
    {
        return Tga::Vector3f(1.0f, 1.0f, 1.0f);
    }
    if (myBrush.uniformScale)
    {
        const float scale = RandomGenerator::GetFloat(myBrush.randomScaleMin, myBrush.randomScaleMax);
        return Tga::Vector3f(scale, scale, scale);
    }
    return Tga::Vector3f(
        RandomGenerator::GetFloat(myBrush.randomScaleMin, myBrush.randomScaleMax),
        RandomGenerator::GetFloat(myBrush.randomScaleMin, myBrush.randomScaleMax),
        RandomGenerator::GetFloat(myBrush.randomScaleMin, myBrush.randomScaleMax)
    );
}
Slope Filtering

Slope filtering works by sampling the normals in the terrain map, giving the user the ability to only target specific elevations in the terrain, without worrying about being careful around edges such as walls or steep cliffs. This also works the other way around, if the user only wants to work on cliff walls or paint along a cliff wall for example. An easy implementation but one that made a huge impact on the end result!

Code
1 / 2
// By using the normal of the position we got from doing the placement calculations, we now check if the slope is valid:
const OffsetResult offsetResult = CalculateOffsetPosition();
if (!offsetResult.valid)
{
    continue;
}
const Tga::Vector3f position = offsetResult.position;
const Tga::Vector3f surfaceNormal = offsetResult.normal;
if (!IsSlopeValid(surfaceNormal))
{
    continue;
}
bool AssetBrush::IsSlopeValid(const Tga::Vector3f& aNormal) const
{
    if (!myBrush.useSlopeFilter)
    {
        return true;
    }
    // Calculate slope angle from normal
    float normalY = std::abs(aNormal.y);
    float slopeDegrees = acosf(std::clamp(normalY, 0.0f, 1.0f)) * (180.0f / FMath::Pi);
    if (slopeDegrees < myBrush.minSlope || slopeDegrees > myBrush.maxSlope)
    {
        return false;
    }
    return true;
}

Performance

Before
After

I encountered several performance issues during the development of the tool. As a first iteration, I kept iterating over all previously placed assets by the brush when doing checks for object spacing, an O(n) operation, meaning every new placement had to check against every single previously placed object, growing slower the more objects existed. I noticed this quickly became a blocker which introduced the need to handle scaleable performance. A first solution was to create a spatial grid based on the minimum spacing allowed between objects, with the current object checking a 3D grid around itself for other objects placed. This effectively removed several unnecessary iterations during the check.

Even with this improvement, performance was still an issue. After profiling, I found that the biggest bottleneck was our naming system in the editor, which iterated over all instances of the same asset to generate a unique name when adding an asset to the scene. After adding a simple counter system which names assets uniquely based on whether they are painted by the brush or not, and checking how many instances of one asset exist during startup, I effectively eliminated the lag that was previously caused.

1 / 2
struct CellKey
{
    int x, y, z;
    bool operator==(const CellKey& other) const
    {
        return x == other.x && y == other.y && z == other.z;
    }
};

struct CellKeyHash
{
    size_t operator()(const CellKey& key) const
    {
        size_t h1 = std::hash<int>{}(key.x);
        size_t h2 = std::hash<int>{}(key.y);
        size_t h3 = std::hash<int>{}(key.z);
        return h1 ^ (h2 << 1) ^ (h3 << 2);
    }
};

uint64_t AssetBrush::GetSpatialKey(int cellX, int cellZ)
{
    return (static_cast<uint64_t>(static_cast<uint32_t>(cellX)) << 32)
        | (static_cast<uint64_t>(static_cast<uint32_t>(cellZ)));
}
void AssetBrush::InitNameCounters()
{
    myAssetNameCounters.clear();

    Tga::Scene* scene = Tga::GetActiveScene();
    for (const auto& [id, obj] : scene->GetSceneObjects())
    {
        std::string name = obj->GetName();

        // Brush-placed assets are named "AssetName_b#"
        size_t separatorPos = name.rfind("_b");
        if (separatorPos == std::string::npos)
        {
            continue;
        }

        std::string baseName = name.substr(0, separatorPos);
        std::string indexStr = name.substr(separatorPos + 2);

        if (indexStr.empty() || !std::all_of(indexStr.begin(), indexStr.end(), ::isdigit))
        {
            continue;
        }

        int index = std::stoi(indexStr);
        int& counter = myAssetNameCounters[baseName];
        if (index >= counter)
        {
            counter = index + 1;
        }
    }
}

GBuffer Readback & Picking

Vertex Normal Texture
World Position Texture

Since we already had GBuffer textures in place, I decided to use those as a source for generating the textures I needed for the picking. Two staging textures were used for this, one containing the vertex normals and one containing the world position of the currently drawn scene. I created a component tag that could be added to any scene object, used to mark it as paintable terrain. Only objects carrying this tag were drawn to the staging textures, meaning the brush would only snap to intentionally marked surfaces. In the render pipeline, I then drew the terrain objects first. After these were drawn, we could fetch the textures.

The world position is drawn to a texture using the format DXGI_FORMAT_R32G32B32A32_FLOAT. Since the data is stored as 4 values of 32 bits and we use floats as a representation of our world position, reading the world position back is straightforward.

The normals however needed converting back to a -1 to 1 range, since when drawing to the GBuffer texture, we remap to 0-1 because the shader can't store negative values in a texture. The normals are saved to a texture with the format DXGI_FORMAT_R10G10B10A2_UNORM, using 10 bits for each channel. On readback, the 10-bit channels are unpacked and the encoding is reversed back to -1 to 1.

1 / 2
void TerrainMap::ReadPositions(ID3D11Texture2D* source)
{
    D3D11_MAPPED_SUBRESOURCE mapped;
    HRESULT hr = Tga::DX11::Context->Map(source, 0, D3D11_MAP_READ, 0, &mapped);

    // Position is R32G32B32A32_FLOAT — 4 floats per pixel, read directly
    const float* srcData = static_cast<const float*>(mapped.pData);

    for (int y = 0; y < myViewportSize.y; y++)
    {
        for (int x = 0; x < myViewportSize.x; x++)
        {
            int index    = y * myViewportSize.x + x;
            int srcIndex = (y * (mapped.RowPitch / sizeof(float))) + (x * 4);
            myCachedPositions[index] = Tga::Vector3f(
                srcData[srcIndex + 0],
                srcData[srcIndex + 1],
                srcData[srcIndex + 2]
            );
        }
    }
    Tga::DX11::Context->Unmap(source, 0);
}
void TerrainMap::ReadNormals(ID3D11Texture2D* source)
{
    D3D11_MAPPED_SUBRESOURCE mapped;
    Tga::DX11::Context->Map(source, 0, D3D11_MAP_READ, 0, &mapped);

    // Normal is R10G10B10A2_UNORM — packed into a single 32-bit uint
    const uint32_t* srcData = static_cast<const uint32_t*>(mapped.pData);

    for (int y = 0; y < myViewportSize.y; y++)
    {
        for (int x = 0; x < myViewportSize.x; x++)
        {
            uint32_t packed = srcData[(y * (mapped.RowPitch / sizeof(uint32_t))) + x];

            // Unpack 10 bits per channel
            float nx = ((packed >>  0) & 0x3FF) / 1023.0f;
            float ny = ((packed >> 10) & 0x3FF) / 1023.0f;
            float nz = ((packed >> 20) & 0x3FF) / 1023.0f;

            // GBuffer stores normals as (n * 0.5 + 0.5), reverse that:
            nx = (nx * 2.0f) - 1.0f;
            ny = (ny * 2.0f) - 1.0f;
            nz = (nz * 2.0f) - 1.0f;
            myCachedNormals[y * myViewportSize.x + x] = Tga::Vector3f(nx, ny, nz);
        }
    }
    Tga::DX11::Context->Unmap(source, 0);
}

Preview Mode

Preview mode came with a lot of challenges. We now needed to save a version of the object that could be updated manually by the user if they wished to do so. For my first iteration, I did not need to care since the assets got updated between each brush stroke in regards to the settings chosen by the user. Now, we needed to keep the position/rotation/scale for the object, or some combination of them, based on the settings.

For this, I created a struct related to the already existing scene object. By caching the current previews of the current stroke and batch of that stroke, I was able to update it cleanly as the user dragged the mouse across the screen. This also introduced the "Update on move" and "Manual update on R" settings, since the need for more control arose as a product of the preview tool.

struct PreviewObject
{
    std::shared_ptr<Tga::SceneObject> sceneObject;
    bool isValid;

    Tga::Vector2f cachedOffsetTangent;
    Tga::Quaternionf cachedRotation;
    Tga::Vector3f currentTerrainNormal;
    Tga::Vector3f cachedScale;
    Tga::StringId assetPath;
    float pivotOffset = 0.f;
};

Tangent & Bitangent

An interesting geometric problem arose from the fact that the brush uses a flat 2D disc as a representation of the area the assets can be placed in. Since the brush originally scattered objects in the XZ plane, it completely broke when trying to scatter on vertical or very steep surfaces, placing the objects in a line rather than spreading them out. This was solved by finding the tangent and bitangent of the surface normal and using those as the up/down and left/right directions for the position offset.

One problem that still persists is that the scatter area is in screen space, occasionally placing objects far away if the disc is outside the intended area but where valid terrain still exists. This can be solved by doing a simple world position check as well, so that the asset is not too far from the world position of the brush center — a feature I am looking to add.

void AssetBrush::GetSurfaceTangentFrame(
    const Tga::Vector3f& normal,
    Tga::Vector3f& outTangent,
    Tga::Vector3f& outBitangent)
{
    // Pick a reference vector that isn't parallel to the normal
    Tga::Vector3f reference = (fabsf(normal.y) < 0.99f)
        ? Tga::Vector3f(0, 1, 0)
        : Tga::Vector3f(1, 0, 0);

    outTangent = reference.Cross(normal);
    outTangent.Normalize();
    outBitangent = normal.Cross(outTangent);
    outBitangent.Normalize();
}

Pivot

Another problem that arose was the pivot of the object not always aligning with the actual surface we are placing our asset on. Some of our assets had their pivot in the center which made the object clip through the surface rather than sitting on top of it. This was solved by subtracting the difference between the object's Y boundary and the object's center if a difference was found. I made this feature a toggle as well, so that the artist was able to control the pivot offset themselves.

Undo Integration

One problem I encountered was that the undo action was a bit inconsistent. Every user expects a clean and functional undo action, which is easy to deprioritize during development since focus lies elsewhere. At first, the undo action in our editor meant undoing the previous command. When placing many assets this became tedious since one undo meant removing only one asset. I fixed this to undo by batch and brush stroke instead, by adding all assets painted in that stroke into one single command.