Leved Design Curve Tool

Key Achievements

  • Speeding up level design and set dressing process greatly

  • Tool integration in custom C++ scene editor

  • Deeper knowledge about Bezier curves

  • Deeper knowledge about Dear ImGui and ImGuizmos

Introduction & Motivation

After my team finished a Diablo 3 inspired action rpg, I sat down and looked at how many set dressing assets that had been placed in each scene. For each scene about 99 percent of all models placed were set dressing. We’re talking five to ten thousand objects, each one placed, offseted and rotated by hand. No wonder my level design and art colleagues felt stressed.

For our upcoming project I decided to make a tool that could speed things up for them, while letting me explore our custom C++ engines editor and a 3-dimensional Bezier curve. This tool took about 1-2 weeks to create.

Placing Objects Along the Curve

Since the game project that inspired me to make this tool had a world full of trees, rocks and other foliage, I knew that my tool should not only be able to place objects evenly along the curve. It should also be able to randomly offset and rotate the objects, to easily attain a natural look. It was also important that you could place many different objects at once, such as a tree followed by a flower, then a bush and so forth. Preferably in random order if you’d like. 

The new game we were making had it’s setting in a dirty cityscape. I therefore thought that it would benefit the tool to also be able to rotate objects along the curve so that they looked symmetric and industrially placed.

Code below handles the placement of multiple asset types at once

Show Code

void Forge::CurveTool::PlaceObjectsAlongCurve(std::vector<Tga::StringId>& objectDefinitionNames)
{
	Tga::SceneDocument* sceneDocument = Tga::Editor::GetEditor()->GetActiveSceneDocument();
	Tga::Scene* scene = nullptr;

	if (sceneDocument)
	{
		scene = sceneDocument->AccessScene();
	}

	if (scene)
	{
		const int typeAmount = static_cast<int>(objectDefinitionNames.size());
		int nameIndex = -1;

		for (int indexAlongCurve = 0; indexAlongCurve < myNumberOfObjectsToPlace; ++indexAlongCurve)
		{
			const float percentage = static_cast<float>(indexAlongCurve) / static_cast<float>(myNumberOfObjectsToPlace - 1);
			const Tga::Vector3f point = myCurve.GetPointOnCurve(percentage);
			auto object = std::make_shared<Tga::SceneObject>();

			if (myPlaceDifferentTypesInRandomOrder)
			{
				nameIndex = rand() % typeAmount;
			}
			else if (!myPlaceDifferentTypesInRandomOrder)
			{
				++nameIndex;
				if (nameIndex >= typeAmount)
				{
					nameIndex = 0;
				}
			}

			NameObject(scene, object, objectDefinitionNames[nameIndex]);
			TransformObject(object, point);
			AddObjectToScene(scene, object, sceneDocument);
		}
	}
}
    

Code below shows the randomization transform function

Show Code

void Forge::CurveTool::TransformObject(std::shared_ptr<Tga::SceneObject> object, const Tga::Vector3f& point)
{
	object->GetTRS().translation = point;
	RandomizeTRS(object->GetTRS());
}

void Forge::CurveTool::RandomizeTRS(Tga::TRS& aTrs)
{
	if (myHasRandomHorizontalOffset)
	{
		int degrees = rand() % 360;

		float radians = (FMath::Pi / 180) * degrees;

		float x = cosf(radians);
		float z = sinf(radians);

		Tga::Vector3f direction = { x, 0.f, z };

		aTrs.translation += direction * myHorizontalOffsetLength;
	}
	if (myHasRandomVerticalOffset)
	{
		int degrees = rand() % 360;

		float radians = (FMath::Pi / 180) * degrees;

		float dir = cosf(radians);

		Tga::Vector3f direction = { 0.f, dir, 0.f };

		aTrs.translation += direction * myVerticalOffsetLength;
	}
	if (myHasRandomRotation)
	{
		const Tga::Vector3<int> randRot = {
			rand() % (myRandomXRotation + 1),
			rand() % (myRandomYRotation + 1),
			rand() % (myRandomZRotation + 1)
		};
		aTrs.rotation = {
			static_cast<float>(randRot.x),
			static_cast<float>(randRot.y),
			static_cast<float>(randRot.z)
		};
	}
}

    

Code below handles placement of axially aligned objects

Show Code

void Forge::CurveTool::AlignObjectsAlongCurve(Tga::StringId& objectDefinitionName)
{
	Tga::SceneDocument* sceneDocument = Tga::Editor::GetEditor()->GetActiveSceneDocument();
	Tga::Scene* scene = nullptr;
	if (sceneDocument)
	{
		scene = sceneDocument->AccessScene();
	}
	if (scene)
	{
		for (int indexAlongCurve = 0; indexAlongCurve < myNumberOfObjectsToPlace; ++indexAlongCurve)
		{
			const float currentPercentage = static_cast<float>(indexAlongCurve) / static_cast<float>(myNumberOfObjectsToPlace - 1);
			const Tga::Vector3f point = myCurve.GetPointOnCurve(currentPercentage);

			auto object = std::make_shared<Tga::SceneObject>();
			NameObject(scene, object, objectDefinitionName);
			{
				if (indexAlongCurve < myNumberOfObjectsToPlace - 1)
				{
					const float nextPercentage = static_cast<float>(indexAlongCurve + 1) / static_cast<float>(myNumberOfObjectsToPlace - 1);
					object->SetTransform(GetAlignedTransform(nextPercentage, point));
				}
				else
				{
					const float priorPercentage = static_cast<float>(indexAlongCurve - 1) / static_cast<float>(
						myNumberOfObjectsToPlace - 1);
					Tga::Vector3f directionOfLast = (myCurve.GetPointOnCurve(currentPercentage) - myCurve.GetPointOnCurve(priorPercentage)).
						GetNormalized();
					object->SetTransform(GetAlignedTransformFromLast(directionOfLast, point));
				}
			}
			AddObjectToScene(scene, object, sceneDocument);
		}
	}
}
    

Code below show one of the alignment functions

Show Code

Tga::Matrix4x4f Forge::CurveTool::GetAlignedTransform(float nextPercentage, const Tga::Vector3f& posOfCurrentObject)
{
	Tga::Matrix4x4f alignedTransform;
	alignedTransform.SetPosition(posOfCurrentObject);

	//this is the un-normalized vector between the current object being placed, and the next one up.
	const Tga::Vector3f forward = (myCurve.GetPointOnCurve(nextPercentage) - posOfCurrentObject).GetNormalized();

	Tga::Vector3f worldUp = { 0.f, 1.f, 0.f };

	if (fabsf(forward.Dot(worldUp)) > 0.999f)
	{
		worldUp = { 1.f, 0.f, 0.f }; // fallback if near parallel
	}

	Tga::Vector3f right = worldUp.Cross(forward).GetNormalized();
	Tga::Vector3f up = forward.Cross(right); // already normalized

	switch (myAlignment)
	{
	case Alignment::Right:
	{
		alignedTransform.SetRight(forward * -1.f);
		alignedTransform.SetUp(up);
		alignedTransform.SetForward(right);
		break;
	}
	case Alignment::Up:
	{
		alignedTransform.SetRight(right);
		alignedTransform.SetUp(forward * -1.f);
		alignedTransform.SetForward(up);
		break;
	}
	case Alignment::Forward:
	{
		alignedTransform.SetRight(right);
		alignedTransform.SetUp(up);
		alignedTransform.SetForward(forward);
		break;
	}
	default:
	{
		break;
	}
	}

	return alignedTransform;
}
    

Code below shows the UI for the placing-of-assets window

Show Code

void Forge::CurveTool::PlacementUI()
{
	static Tga::StringId objectDefinitionName0 = Tga::StringRegistry::RegisterOrGetString("<Object type ...>");
	static const Tga::StringId resetName = Tga::StringRegistry::RegisterOrGetString("<Object type ...>");
	static bool placeNow = false;
	placeNow = false;

	if (ImGui::Button("Reset object data"))
	{
		objectDefinitionName0 = resetName;
		myNumberOfObjectsToPlace = 0;
		myHasRandomRotation = false;
		placeNow = false;
	}

	// drag and drop object(s) to places
	ImGui::Text(objectDefinitionName0.GetString());
	if (ImGui::BeginDragDropTarget())
	{
		if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(DRAG_PAYLOAD_TYPE_STR[static_cast<int>(DragPayloadType::TGO)]))
		{
			const std::string_view payloadView = static_cast<const char*>(payload->Data);
			objectDefinitionName0 = Tga::StringRegistry::RegisterOrGetString(fs::path(payloadView).stem().string());
		}
		ImGui::EndDragDropTarget();
	}

	// placed multiple assets types at once
	ImGui::Checkbox("Place more than one type", &myPlaceObjectsOfDifferentTypes);
	static std::vector<Tga::StringId> extraObjectDefinitionsNames;
	static int numberOfTypes = 0;
	ImGui::SameLine();
	if (ImGui::Button("Reset multi-objects"))
	{
		myPlaceObjectsOfDifferentTypes = false;
		extraObjectDefinitionsNames.clear();
	}

	if (myPlaceObjectsOfDifferentTypes)
	{
		ImGui::SameLine();
		ImGui::Checkbox("Place in random order", &myPlaceDifferentTypesInRandomOrder);
		ImGui::SameLine();
		ImGui::PushItemWidth(50.f);
		if (ImGui::DragInt("Number of types", &numberOfTypes, 1, 2, 10))
		{
			extraObjectDefinitionsNames.clear();
			extraObjectDefinitionsNames.resize(numberOfTypes, resetName);
		}
		ImGui::PopItemWidth();
		if (!extraObjectDefinitionsNames.empty())
		{
			ImGui::Text(extraObjectDefinitionsNames[0].GetString());
			if (ImGui::BeginDragDropTarget())
			{
				if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(DRAG_PAYLOAD_TYPE_STR[static_cast<int>(DragPayloadType::TGO)]))
				{
					const std::string_view payloadView = static_cast<const char*>(payload->Data);
					objectDefinitionName0 = Tga::StringRegistry::RegisterOrGetString(fs::path(payloadView).stem().string());
					extraObjectDefinitionsNames[0] = objectDefinitionName0;
				}
				ImGui::EndDragDropTarget();
			}
		}
		for (int defIndex = 1; defIndex < extraObjectDefinitionsNames.size(); ++defIndex)
		{
			ImGui::Text(extraObjectDefinitionsNames[defIndex].GetString());
			if (ImGui::BeginDragDropTarget())
			{
				if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(DRAG_PAYLOAD_TYPE_STR[static_cast<int>(DragPayloadType::TGO)]))
				{
					const std::string_view payloadView = static_cast<const char*>(payload->Data);
					extraObjectDefinitionsNames[defIndex] = Tga::StringRegistry::RegisterOrGetString(fs::path(payloadView).stem().string());
				}
				ImGui::EndDragDropTarget();
			}
		}
	}

	ImGui::PushItemWidth(50.f);
	ImGui::DragInt("Number of objects along curve", &myNumberOfObjectsToPlace, 1, 0, 100);
	ImGui::PopItemWidth();

	auto setRandomPreviewOffsets = [this]()
		{
			myRandomOffsetPreviews.clear();
			myRandomOffsetPreviews.resize(myNumberOfObjectsToPlace);

			for (Tga::TRS& trs : myRandomOffsetPreviews)
			{
				RandomizeTRS(trs);
			}
		};

	// randomize the rotation and translation of placed assets, for a more organic feel.
	if (ImGui::Checkbox("Place with random rotation", &myHasRandomRotation))
	{
		setRandomPreviewOffsets();
	}

	if (myHasRandomRotation)
	{
		ImGui::PushItemWidth(50.f);
		ImGui::SameLine();
		if (ImGui::DragInt("X", &myRandomXRotation, 1, 0, 359))
		{
			setRandomPreviewOffsets();
		}
		ImGui::SameLine();
		if (ImGui::DragInt("Y", &myRandomYRotation, 1, 0, 359))
		{
			setRandomPreviewOffsets();
		}
		ImGui::SameLine();
		if (ImGui::DragInt("Z", &myRandomZRotation, 1, 0, 359))
		{
			setRandomPreviewOffsets();
		}
		ImGui::PopItemWidth();
	}

	if (ImGui::Checkbox("Random Horizontal Offset", &myHasRandomHorizontalOffset))
	{
		setRandomPreviewOffsets();
	}

	if (myHasRandomHorizontalOffset)
	{
		ImGui::PushItemWidth(50.f);
		ImGui::SameLine();
		if (ImGui::DragFloat("Horizontal Offset length", &myHorizontalOffsetLength, 1.f, 0, 100000.f, "%1.f"))
		{
			setRandomPreviewOffsets();
		}
		ImGui::PopItemWidth();
	}

	if (ImGui::Checkbox("Random Vertical Offset", &myHasRandomVerticalOffset))
	{
		setRandomPreviewOffsets();
	}

	if (myHasRandomVerticalOffset)
	{
		ImGui::PushItemWidth(50.f);
		ImGui::SameLine();
		if (ImGui::DragFloat("Vertical Offset length", &myVerticalOffsetLength, 1.f, 0, 100000.f, "%1.f"))
		{
			setRandomPreviewOffsets();
		}
		ImGui::PopItemWidth();
	}

	// signal to user if it is possible to place objects or not
	if (objectDefinitionName0 == resetName || myNumberOfObjectsToPlace < 1)
	{
		ImGui::PushStyleColor(ImGuiCol_Button, static_cast<ImVec4>(ImColor::HSV(0.f / 7.0f, 0.6f, 0.6f)));
		ImGui::PushStyleColor(ImGuiCol_ButtonHovered, static_cast<ImVec4>(ImColor::HSV(0.f / 7.0f, 0.7f, 0.7f)));
		ImGui::PushStyleColor(ImGuiCol_ButtonActive, static_cast<ImVec4>(ImColor::HSV(0.f / 7.0f, 0.8f, 0.8f)));
		ImGui::Button("Not ready to place");
		ImGui::PopStyleColor(3);
	}
	else
	{
		ImGui::PushStyleColor(ImGuiCol_Button, static_cast<ImVec4>(ImColor::HSV(3.f / 7.0f, 0.6f, 0.6f)));
		ImGui::PushStyleColor(ImGuiCol_ButtonHovered, static_cast<ImVec4>(ImColor::HSV(3.f / 7.0f, 0.7f, 0.7f)));
		ImGui::PushStyleColor(ImGuiCol_ButtonActive, static_cast<ImVec4>(ImColor::HSV(3.f / 7.0f, 0.8f, 0.8f)));
		if (ImGui::Button("   Ready to place   "))
		{
			placeNow = true;
		}
		ImGui::PopStyleColor(3);
	}

	ImGui::SameLine();
	ImGui::PushStyleColor(ImGuiCol_Button, static_cast<ImVec4>(ImColor::HSV(1.f / 7.0f, 0.6f, 0.6f)));
	ImGui::PushStyleColor(ImGuiCol_ButtonHovered, static_cast<ImVec4>(ImColor::HSV(1.f / 7.0f, 0.7f, 0.7f)));
	ImGui::PushStyleColor(ImGuiCol_ButtonActive, static_cast<ImVec4>(ImColor::HSV(1.f / 7.0f, 0.8f, 0.8f)));

	// enable user to preview the placements. Placement preview is approximate if random setting is on.
	if (ImGui::Button("Preview placement"))
	{
		myWatchPlacementPreview = !myWatchPlacementPreview;
		if (myWatchPlacementPreview) { myWatchPlacementPreview = myNumberOfObjectsToPlace > 0; }
	}
	ImGui::PopStyleColor(3);

	// place objects along line, without axially aligning them 
	if (placeNow)
	{
		placeNow = false;
		myIdOfRecentlyAddedObjects.clear();
		if (objectDefinitionName0 != resetName && !myPlaceObjectsOfDifferentTypes)
		{
			PlaceObjectsAlongCurve(objectDefinitionName0);
		}
		else if (!extraObjectDefinitionsNames.empty() && myPlaceObjectsOfDifferentTypes)
		{
			bool allNamesOk = true;

			for (auto& nameOk : extraObjectDefinitionsNames)
			{
				if (nameOk == resetName)
				{
					allNamesOk = false;
					break;
				}
			}
			if (allNamesOk)
			{
				PlaceObjectsAlongCurve(extraObjectDefinitionsNames);
			}
		}
	}

	const bool hasRecentPlacement = !myIdOfRecentlyAddedObjects.empty();
	if (hasRecentPlacement)
	{
		ImGui::PushStyleColor(ImGuiCol_Button, static_cast<ImVec4>(ImColor::HSV(0.f / 7.0f, 0.6f, 0.6f)));
		ImGui::PushStyleColor(ImGuiCol_ButtonHovered, static_cast<ImVec4>(ImColor::HSV(0.f / 7.0f, 0.7f, 0.7f)));
		ImGui::PushStyleColor(ImGuiCol_ButtonActive, static_cast<ImVec4>(ImColor::HSV(0.f / 7.0f, 0.8f, 0.8f)));
	}
	else
	{
		ImGui::PushStyleColor(ImGuiCol_Button, static_cast<ImVec4>(ImColor::HSV(6.f / 7.0f, 0.3f, 0.3f)));
		ImGui::PushStyleColor(ImGuiCol_ButtonHovered, static_cast<ImVec4>(ImColor::HSV(6.f / 7.0f, 0.6f, 0.6f)));
		ImGui::PushStyleColor(ImGuiCol_ButtonActive, static_cast<ImVec4>(ImColor::HSV(6.f / 7.0f, 0.7f, 0.7f)));
	}

	ImGui::SameLine();
	// undo option
	auto undo = [this, hasRecentPlacement]()
		{
			if (!hasRecentPlacement)
			{
				return;
			}
			Tga::SceneDocument* sceneDocument = Tga::Editor::GetEditor()->GetActiveSceneDocument();
			Tga::Scene* scene = nullptr;
			if (sceneDocument)
			{
				scene = sceneDocument->AccessScene();
			}
			if (scene)
			{
				Tga::SetActiveScene(scene);
				// Tga::SceneSelection::GetActiveSceneSelection()->ClearSelection();
				for (uint32_t id : myIdOfRecentlyAddedObjects)
				{
					sceneDocument->AccessSceneSelection().RemoveFromSelection(id);
					scene->DeleteSceneObject(id);
				}
				myIdOfRecentlyAddedObjects.clear();
				sceneDocument->SetSceneDirty();
				Tga::SetActiveScene(nullptr);
			}

		};

	if (ImGui::Button("Undo recent placement"))
	{
		undo();
	}

	ImGui::PopStyleColor(3);

	static bool align = false;

	ImGui::PushFont(Tga::ImGuiInterface::GetIconFontLarge());
	ImGui::Checkbox(ICON_LC_TRAIN_TRACK, &align);
	ImGui::PopFont();
	ImGui::SetItemTooltip("Place objects along curve, axially aligned");

	if (align)
	{

		// align objects along curve, along a selected axis of the mode. 
		bool okToAlign = (myNumberOfObjectsToPlace > 0) && objectDefinitionName0 != resetName && myAlignment != Alignment::Invalid;

		if (!okToAlign)
		{
			ImGui::PushStyleColor(ImGuiCol_Button, static_cast<ImVec4>(ImColor::HSV(0.f / 7.0f, 0.6f, 0.6f)));
			ImGui::PushStyleColor(ImGuiCol_ButtonHovered, static_cast<ImVec4>(ImColor::HSV(0.f / 7.0f, 0.7f, 0.7f)));
			ImGui::PushStyleColor(ImGuiCol_ButtonActive, static_cast<ImVec4>(ImColor::HSV(0.f / 7.0f, 0.8f, 0.8f)));
		}
		else
		{
			ImGui::PushStyleColor(ImGuiCol_Button, static_cast<ImVec4>(ImColor::HSV(3.f / 7.0f, 0.6f, 0.6f)));
			ImGui::PushStyleColor(ImGuiCol_ButtonHovered, static_cast<ImVec4>(ImColor::HSV(3.f / 7.0f, 0.7f, 0.7f)));
			ImGui::PushStyleColor(ImGuiCol_ButtonActive, static_cast<ImVec4>(ImColor::HSV(3.f / 7.0f, 0.8f, 0.8f)));
		}
		if (ImGui::Button("Place objects with axis following curve") && okToAlign)
		{
			AlignObjectsAlongCurve(objectDefinitionName0);
		}
		ImGui::PopStyleColor(3);
		ImGui::SameLine();
		Alignment value = myAlignment;
		int item_current_idx = static_cast<int>(value);

		ImGui::PushItemWidth(100.f);
		if (ImGui::BeginCombo("Select axis to align", magic_enum::enum_name(myAlignment).data()))
		{
			for (int alignmentIndex = 0; alignmentIndex < static_cast<int>(Alignment::count); ++alignmentIndex)
			{
				const bool is_selected = (item_current_idx == alignmentIndex);
				Alignment label = static_cast<Alignment>(alignmentIndex);
				auto labelName = magic_enum::enum_name(label);

				if (ImGui::Selectable(std::string(labelName).c_str(), is_selected))
				{
					item_current_idx = alignmentIndex;
					Alignment newValue = static_cast<Alignment>(alignmentIndex);
					myAlignment = newValue;
				}
			}
			ImGui::EndCombo();
		}

		bool okToAutoAlign = objectDefinitionName0 != resetName && myAlignment != Alignment::Invalid;
		if (!okToAutoAlign)
		{
			ImGui::PushStyleColor(ImGuiCol_Button, static_cast<ImVec4>(ImColor::HSV(0.f / 7.0f, 0.6f, 0.6f)));
			ImGui::PushStyleColor(ImGuiCol_ButtonHovered, static_cast<ImVec4>(ImColor::HSV(0.f / 7.0f, 0.7f, 0.7f)));
			ImGui::PushStyleColor(ImGuiCol_ButtonActive, static_cast<ImVec4>(ImColor::HSV(0.f / 7.0f, 0.8f, 0.8f)));
		}
		else
		{
			ImGui::PushStyleColor(ImGuiCol_Button, static_cast<ImVec4>(ImColor::HSV(3.f / 7.0f, 0.6f, 0.6f)));
			ImGui::PushStyleColor(ImGuiCol_ButtonHovered, static_cast<ImVec4>(ImColor::HSV(3.f / 7.0f, 0.7f, 0.7f)));
			ImGui::PushStyleColor(ImGuiCol_ButtonActive, static_cast<ImVec4>(ImColor::HSV(3.f / 7.0f, 0.8f, 0.8f)));
		}

		ImGui::PushFont(Tga::ImGuiInterface::GetIconFontLarge());
		const char* botIcon = okToAutoAlign ? ICON_LC_BOT : ICON_LC_BOT_OFF;

		// align as many model that could fit along the curve.
		if (ImGui::Button(botIcon) && okToAutoAlign)
		{
			AutoAlignObjects(objectDefinitionName0);
		}
		ImGui::PopFont();
		ImGui::SetItemTooltip("Let the tool calculate the appropriate amount of objects to place");
		ImGui::PopStyleColor(3);
		ImGui::SameLine();
		ImGui::PushItemWidth(100.f);
		ImGui::DragFloat("Spacing when autoaligning", &mySpacing, 1.f, -2000.f, 2000.f, "%.1f");
		ImGui::PopItemWidth();
	}
}

    

The curve tool is easily accessed via the editor, with a tool tip welcoming the user.

Editing the Curve

First off I created the Bezier curve class and made sure it had all the necessary debug functions to be drawn and represented in 3D space. After that I set up a class for the actual tool and linked access to the tool via our own editor called Forge. 

Then it was time to create an interface for editing the curve. For this I used the Dear Imgui library together with gizmos from ImGuizmo. I made sure to think about readability and user friendliness. Getting feedback from colleagues and industry professionals was a big part in making sure that I was not the only one who could use the tool. I added lots of visibility features, such as color coding and tooltips.

A 3D modeling software interface with a scene containing a grid floor, a coordinate axis, and a curved path or object, along with a properties panel showing settings and parameters.

It is also possible to move the curve point by locking or snapping them to the camera using an aim.

A 3D modeling environment showing a gray textured ground and black walls, with a user interface window labeled 'Curve Tool Settings' and two curved lines in green and orange, along with their control points.

When the tool is opened an interface for manipulating the curve appears.

This image shows a 3D scene with a curve tool settings panel on the left and a workspace with 3D axes and control points on the right. The panel displays point positions and options for adjusting a curve with four points, along with lock and snap options for the points. The workspace contains three control points connected by a curve, with color-coded axes and gizmos for manipulating the curve.

The curve is edited with the mouse using gizmos, that either snap or moves smoothly. The points on the curve can be finetuned using a Vector3 XYZ-slider. The interface is color coded so that each point is easily distinguished.

Screenshot of a 3D modeling software interface showing curve tool settings and point coordinates.

If the level designer is after perfectly straight lines, that’s also possible.

Show Code

void Forge::CurveTool::EditPoints()
{
	Tga::SceneDocument* scene = Tga::Editor::GetEditor()->GetActiveSceneDocument();
	if (scene == nullptr)
	{
		return;
	}

	// Selection of gizmo to move
	static std::string pointName;
	pointName = "";

	ImGui::Text("Point Positions");
	ImGui::Separator();

	ImGui::PushFont(Tga::ImGuiInterface::GetIconFontLarge());
	ImGui::Text(ICON_LC_MOVE_3D);
	ImGui::PopFont();
	ImGui::SetItemTooltip("Move base points with gizmo");

	for (uint8_t indexToMove = 0; indexToMove < myCurve.AccessCurvePoints().size(); ++indexToMove)
	{
		pointName = "Gizmo to Point " + std::to_string(indexToMove + 1);
		const Tga::Vector4f blendedColor = FMath::Lerp(myCurve.GetColorA(), myCurve.GetColorB(),
			static_cast<float>(indexToMove) / static_cast<float>(myCurve.AccessCurvePoints().
				size()));

		ImVec4 baseColor;
		baseColor.x = blendedColor.x;
		baseColor.y = blendedColor.y;
		baseColor.z = blendedColor.z;
		baseColor.w = blendedColor.w;

		const ImVec4 hoveredColor = baseColor + ImVec4(.1f, .1f, .1f, 0.f);
		ImVec4 activeColor = baseColor + ImVec4(.2f, .2f, .2f, 0.f);
		if (indexToMove == myPointToEditIndex)
		{
			baseColor = baseColor + ImVec4(.3f, .3f, .3f, .3f);
		}

		ImGui::PushStyleColor(ImGuiCol_Button, baseColor);
		ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hoveredColor);
		ImGui::PushStyleColor(ImGuiCol_ButtonActive, activeColor);

		if (ImGui::Button(pointName.c_str()))
		{
			myPointToEditIndex = indexToMove;
		}
		ImGui::PopStyleColor(3);
		if (indexToMove < myCurve.AccessCurvePoints().size() - 1)
		{
			ImGui::SameLine();
		}
	}

	// select gizmo with numkeys
	if (ImGui::IsKeyPressed(ImGuiKey_1))
	{
		myPointToEditIndex = 0;
	}
	if (ImGui::IsKeyPressed(ImGuiKey_2))
	{
		myPointToEditIndex = 1;
	}
	if (ImGui::IsKeyPressed(ImGuiKey_3))
	{
		myPointToEditIndex = 2;
	}
	if (ImGui::IsKeyPressed(ImGuiKey_4))
	{
		myPointToEditIndex = 3;
	}

	ImGui::Text(" Gizmo Translation Snap: ");
	ImGui::SameLine();
	ImGui::Checkbox("Snap  Enabled", &mySnap.snapPos);
	ImGui::SameLine();
	ImGui::PushItemWidth(100.f);
	ImGui::DragFloat("Snap Amount", &mySnap.pos);
	ImGui::PopItemWidth();

	// Fine adjustment by XYZ-sliders
	ImGui::Separator();
	int pointNumber = 1;
	static std::vector<ImVec4> debugColors;
	debugColors.clear();

	for (auto& point : myCurve.AccessCurvePoints())
	{
		pointName = "Point " + std::to_string(pointNumber);
		const float percentage = static_cast<float>(pointNumber) / static_cast<float>(myCurve.AccessCurvePoints().size());
		const Tga::Vector4f color = FMath::Lerp(myCurve.GetColorA(), myCurve.GetColorB(), percentage);

		debugColors.emplace_back(color.x, color.y, color.z, color.w);
		ImGui::GetStyle().Colors[ImGuiCol_Text] = ImVec4(color.x, color.y, color.z, color.w);
		ImGui::DragFloat3(pointName.c_str(), &point.x, 1.f, -5000000.f, FLT_MAX, "%.1f");
		++pointNumber;
	}
	ImGui::GetStyle().Colors[ImGuiCol_Text] = ImVec4(1.f, 1.f, 1.f, 1.f);
	ImGui::Separator();

	// by snapping or locking  to camera 
	if (ImGui::BeginTable("Points in relation to camera", 2))
	{
		ImGui::TableSetupColumn("Snap to camera");
		ImGui::TableSetupColumn("Lock to camera");
		static bool boolLocks[4];

		for (int row = 0; row < myCurve.AccessCurvePoints().size(); row++)
		{
			ImGui::TableNextRow();
			for (int column = 0; column < 2; column++)
			{
				ImGui::TableSetColumnIndex(column);

				if (column == 1)
				{
					ImGui::GetStyle().Colors[ImGuiCol_Text] = debugColors[row];
					pointName = "Snap P" + std::to_string(row + 1) + " to camera";

					if (ImGui::Button(pointName.c_str()))
					{
						myCurve.AccessCurvePoints()[row] = GetPositionInFrontOfCamera(); 
					}
				}
				else
				{
					ImGui::GetStyle().Colors[ImGuiCol_Text] = debugColors[row];
					pointName = "Lock P" + std::to_string(row + 1) + " to camera";
					ImGui::Checkbox(pointName.c_str(), &boolLocks[row]);

					if (boolLocks[row])
					{
						myCurve.AccessCurvePoints()[row] = GetPositionInFrontOfCamera();
					}
				}
			}
		}
		ImGui::EndTable();
	}

	ImGui::GetStyle().Colors[ImGuiCol_Text] = ImVec4(1.f, 1.f, 1.f, 1.f);

	ImGui::Separator();

	// Snap whole structure in front of camera
	{
		ImGui::PushFont(Tga::ImGuiInterface::GetIconFontLarge());
		if (ImGui::Button(ICON_LC_SWITCH_CAMERA))
		{
			MoveCurveInFrontOfCamera();
		}
		ImGui::PopFont();
		ImGui::SetItemTooltip("Move whole intact curve in front of camera");
		ImGui::SameLine();
		ImGui::Checkbox("Render replacement-aim", &myRenderCameraAim);
		ImGui::SameLine();
		ImGui::SameLine();
		ImGui::PushItemWidth(100.f);
		ImGui::DragFloat("this much ahead", &myAheadOfCamera, 5.f, 0.f, 100000.f, "%.1f");
		ImGui::PopItemWidth();
	}

	// Flatten and/or make straight line.
	ImGui::PushFont(Tga::ImGuiInterface::GetIconFontLarge());
	if (ImGui::Button(ICON_LC_PLANE_LANDING))
	{
		for (auto& point : myCurve.AccessCurvePoints())
		{
			point.y = 0.f;
		}
	}
	ImGui::PopFont();
	ImGui::SetItemTooltip("Flatten vertically, and set all Y-coordinates to zero");

	ImGui::SameLine();
	ImGui::PushFont(Tga::ImGuiInterface::GetIconFontLarge());
	if (ImGui::Button(ICON_LC_PLANE_TAKEOFF))
	{
		const float newY = GetPositionInFrontOfCamera().y;

		for (auto& point : myCurve.AccessCurvePoints())
		{
			point.y = newY;
		}
	}
	ImGui::PopFont();
	ImGui::SetItemTooltip("Flatten vertically, and set all Y-coordinates to camera height");

	ImGui::SameLine();
	ImGui::PushFont(Tga::ImGuiInterface::GetIconFontLarge());
	if (ImGui::Button(ICON_LC_SLASH))
	{
		const Tga::Vector3f firstPoint = myCurve[0];
		const Tga::Vector3f endPoint = myCurve[3];

		for (int i = 1; i < myCurve.AccessCurvePoints().size() - 1; ++i)
		{
			const float percentage = static_cast<float>(i) / static_cast<float>(myCurve.AccessCurvePoints().size() - 1);
			myCurve[static_cast<uint8_t>(i)] = FMath::Lerp(firstPoint, endPoint, percentage);
		}
	}
	ImGui::PopFont();
	ImGui::SetItemTooltip("Make a straight line between first and last point");

	if (ImGui::Button("Reset Curve To World Origin"))
	{
		myCurve[0] = { 0.f, 0.f, 0.f };
		myCurve[1] = { 0.f, 500.f, 0.f };
		myCurve[2] = { 500.f, 500.f, 0.f };
		myCurve[3] = { 500.f, 0.f, 0.f };
	}
	ImGui::SetItemTooltip("Resets curve point close to world origin");
}
    

The code below draws the curve’s edit interface and takes input.

Show Code

void Forge::CurveTool::DrawAndEditGizmos(const Tga::Camera& camera, Tga::Vector2i aViewportPos, Tga::Vector2i aViewportSize)
{
	ImGuizmo::SetID(ImGui::GetID(0));
	ImGui::SetItemDefaultFocus();
	auto io = ImGui::GetIO();

	if (!ImGuizmo::IsUsing())
	{
		Tga::Vector3f curvePos = myCurve[myPointToEditIndex];

		if (mySnap.snapPos)
		{
			curvePos = curvePos / mySnap.pos;
			curvePos.x = round(curvePos.x);
			curvePos.y = round(curvePos.y);
			curvePos.z = round(curvePos.z);

			curvePos = mySnap.pos * curvePos;
		}

		myManipulationStartPos = curvePos;
	}

	Tga::Matrix4x4f cameraToWorld = camera.GetTransform();
	cameraToWorld.SetPosition(cameraToWorld.GetPosition() - myManipulationStartPos);
	Tga::Matrix4x4f view = Tga::Matrix4x4f::GetFastInverse(cameraToWorld);
	Tga::Matrix4x4f proj = camera.GetProjection();

	const float left = static_cast<float>(aViewportPos.x);
	const float top = static_cast<float>(aViewportPos.y);
	const float width = static_cast<float>(aViewportSize.x);
	const float height = static_cast<float>(aViewportSize.y);

	ImGuizmo::SetRect(left, top, width, height);
	ImGuizmo::SetOrthographic(false);
	ImGuizmo::SetDrawlist(ImGui::GetWindowDrawList());

	Tga::Matrix4x4f transformBefore = myManipulationCurrentTransform;
	Tga::Matrix4x4f transformAfter;

	float snap[3];
	snap[0] = snap[1] = snap[2] = (!mySnap.snapPos != !io.KeyCtrl) ? mySnap.pos : 0.f;

	transformBefore.SetPosition(myCurve[myPointToEditIndex] - myManipulationStartPos);

	transformAfter = transformBefore;

	ImGuizmo::SetDrawlist(ImGui::GetWindowDrawList());

	ImGuizmo::Manipulate(view.GetDataPtr(), proj.GetDataPtr(), ImGuizmo::TRANSLATE, ImGuizmo::WORLD, transformAfter.GetDataPtr(), nullptr,
		snap);

	myManipulationCurrentTransform = transformAfter;

	if (!io.KeyAlt)
	{
		if (ImGuizmo::IsOver() && ImGui::IsMouseClicked(ImGuiMouseButton_Left))
		{
			myManipulationInitialTransform = transformBefore;
			myIsManipulating = true;
		}

		if (ImGui::IsMouseDown(ImGuiMouseButton_Left) && myIsManipulating)
		{
			if (!ImGui::IsItemHovered())
			{
				int a = 2;
				a = 3;
			}
			Tga::Vector3f moveVec = transformAfter.GetPosition() - transformBefore.GetPosition();
			myCurve[myPointToEditIndex] += moveVec;
		}

		if (ImGui::IsMouseReleased(ImGuiMouseButton_Left))
		{
			Tga::Vector3f pos, scale;
			Tga::Quaternionf rot;
			transformAfter.DecomposeMatrix(pos, rot, scale);
			Tga::Vector3f euler = rot.GetYawPitchRoll();
			euler;
			myIsManipulating = false;
		}
	}
}
    

The code below shows how the gizmos are edited and drawn

Auto Align - work in progress

Finally I would like to showcase a feature that I’m still working on. The goal is to save level designers a lot of time when they want to create walls, railing and other stuff that should align perfectly along a given curve.

In the GIF below I show placing one fence piece at the time is very slow, compared to letting my tool calculate how many of that model type could fit along the line/curve. As demonstrated it works fine with straight lines, but has a harder time with curves. This, I think, has to do with the function not taking into account where the pivot point of the model is set. This will be something for me to investigate and improve upon in the future, as it is common that the pivot point differs from asset to asset.

3D modeling software interface displaying a curved fence on a gray surface, with property settings and placement options visible.

Code showing the Auto Align function that need improving

Show Code

void Forge::CurveTool::AutoAlignObjects(Tga::StringId& objectDefinitionName)
{
	auto sceneObject = std::make_shared<Tga::SceneObject>();
	sceneObject->SetSceneObjectDefinitionName(objectDefinitionName);

	Tga::SceneObjectDefinitionManager sceneObjectDefinitionManager{};
	sceneObjectDefinitionManager.Init(Tga::Settings::GameAssetRoot().string());

	std::vector<Tga::ScenePropertyDefinition> sceneObjectProperties;

	sceneObject->CalculateCombinedPropertySet(sceneObjectDefinitionManager, sceneObjectProperties);
	SceneLoading::ScenePropertyExtractor props(sceneObjectProperties);

	const Tga::SceneModel* sceneModel = props.GetCopyOnWriteWrapperByType<Tga::SceneModel>();
	const Tga::SceneVertexPaintableModel* paintableSceneModel = props.GetCopyOnWriteWrapperByType<Tga::SceneVertexPaintableModel>();

	Tga::AnimatedModelInstance animatedModelInstance;
	Tga::ModelInstance modelInstance; // { nullptr };
	Tga::BoxSphereBounds bounds;

	if (sceneModel != nullptr)
	{
		if (!sceneModel->isAnimated)
		{
			modelInstance = ReadModelInstance(*sceneModel);
			std::shared_ptr<Tga::Model> model = modelInstance.GetModel();
			bounds = model->GetMeshData(0).Bounds;
		}
		else
		{
			animatedModelInstance = ReadAnimatedModelInstance(*sceneModel);
			std::shared_ptr<Tga::Model> model = animatedModelInstance.GetModel();
			bounds = model->GetMeshData(0).Bounds;
		}
	}
	else if (paintableSceneModel != nullptr)
	{
		//I don't think vertex painted objects will be used on anything other than set dressing (at least, not yet)
		Tga::PaintableModelInstance paintable = ReadPaintableModelInstance(*paintableSceneModel);
		std::shared_ptr<Tga::Model> model = paintable.GetModel();
		bounds = model->GetMeshData(0).Bounds;
	}

	const float curveLength = myCurve.CalculateCurveLengthFastSample();

	float objectSize = 0.f;

	switch (myAlignment)
	{
	case Alignment::Right:
	{
		objectSize = bounds.BoxExtents.x;
		break;
	}
	case Alignment::Up:
	{
		objectSize = bounds.BoxExtents.y;
		break;
	}
	case Alignment::Forward:
	{
		objectSize = bounds.BoxExtents.z;
		break;
	}
	case Alignment::Invalid:
	{
		return;
	}
	case Alignment::count:
	{
		return;
	}
	default:
	{
		return;
	}
	}

	const int objectAmount = static_cast<int>(curveLength / (objectSize + myPreferredSpacing));

	Tga::SceneDocument* sceneDocument = Tga::Editor::GetEditor()->GetActiveSceneDocument();
	Tga::Scene* scene = nullptr;
	if (sceneDocument)
	{
		scene = sceneDocument->AccessScene();
	}
	if (scene)
	{
		for (int indexAlongCurve = 0; indexAlongCurve < objectAmount; ++indexAlongCurve)
		{
			const float currentPercentage = static_cast<float>(indexAlongCurve) / static_cast<float>(objectAmount - 1);
			const Tga::Vector3f point = myCurve.GetPointOnCurve(currentPercentage);

			auto object = std::make_shared<Tga::SceneObject>();

			NameObject(scene, object, objectDefinitionName);

			if (indexAlongCurve < myNumberOfObjectsToPlace - 1)
			{
				const float nextPercentage = static_cast<float>(indexAlongCurve + 1) / static_cast<float>(myNumberOfObjectsToPlace - 1);
				object->SetTransform(GetAlignedTransform(nextPercentage, point));
			}
			else
			{
				const float priorPercentage = static_cast<float>(indexAlongCurve - 1) / static_cast<float>(
					myNumberOfObjectsToPlace - 1);
				Tga::Vector3f directionOfLast = (myCurve.GetPointOnCurve(currentPercentage) - myCurve.GetPointOnCurve(priorPercentage)).
					GetNormalized();
				object->SetTransform(GetAlignedTransformFromLast(directionOfLast, point));
			}

			AddObjectToScene(scene, object, sceneDocument);
		}
	}

}