feat: add selection to meshrendercomponent

This commit is contained in:
2026-10-22 22:24:59 +02:00
parent f99147b1a2
commit f055bdcbf1
16 changed files with 1103 additions and 68 deletions
+2
View File
@@ -40,6 +40,7 @@ set(SRC_FILES
"src/Components/CameraComponent.cpp"
"src/Components/LightComponent.cpp"
"src/Components/MeshRendererComponent.cpp"
"src/Components/MeshRendererInspector.cpp"
"src/Components/Rotator.cpp"
"src/Components/Spinner.cpp"
"src/Components/OrbitAndSpin.cpp"
@@ -128,6 +129,7 @@ set(SRC_FILES
"src/Util/TransformGizmo.cpp"
"src/Util/RectGizmo.cpp"
"src/Util/SceneInspector.cpp"
"src/Util/AssetBrowser.cpp"
"src/Physics/PhysicsWorld.cpp"
"src/Physics/PhysicsSceneBridge.cpp"
+4
View File
@@ -17,6 +17,7 @@
#include <destrum/Graphics/Renderer.h>
#include <destrum/Graphics/Pipelines/ImguiPass.h>
#include <destrum/Graphics/ResourcesInspector.h>
#include <destrum/Util/AssetBrowser.h>
#include <destrum/Input/InputManager.h>
@@ -53,6 +54,7 @@ public:
virtual void onWindowResize(int, int) {};
virtual void onViewportResize(int, int) {}
virtual void drawViewportOverlay() {}
virtual void onViewportAssetDropped(std::string_view) {}
virtual void onPlayModeChanged(bool) {}
@@ -107,10 +109,12 @@ protected:
bool physics{true};
bool inspector{true};
bool imageCache{false};
bool assets{true};
};
DebugWindows m_DebugWindows{};
ResourcesInspector m_ResourcesInspector;
AssetBrowser m_AssetBrowser;
void drawWindowsMenu();
void drawPhysicsPanel(float dt, float fixedDt, float accumulator);
@@ -4,6 +4,13 @@
#include <destrum/ObjectModel/Component.h>
#include <destrum/Graphics/ids.h>
#include <destrum/Graphics/Resources/PrimitiveMeshes.h>
#include <optional>
#include <string>
class GameRenderer;
class RenderResources;
class MeshRendererComponent final: public Component {
public:
@@ -30,6 +37,18 @@ public:
}
private:
void DrawMeshField(RenderResources& resources);
void DrawPrimitiveSettings(RenderResources& resources);
void DrawMaterialField(RenderResources& resources, GameRenderer& renderer);
void DrawMaterialEditor(RenderResources& resources, GameRenderer& renderer);
void AssignPrimitive(RenderResources& resources, const PrimitiveMeshes::Desc& desc);
void LoadDroppedModel(RenderResources& resources, const std::string& path);
std::string m_MeshSearch;
std::string m_MaterialSearch;
std::optional<PrimitiveMeshes::Desc> m_PrimitiveDraft;
std::string m_PrimitiveDraftKey;
MeshID meshID{NULL_MESH_ID};
MaterialID materialID{NULL_MATERIAL_ID};
std::string meshKey;
+2
View File
@@ -34,6 +34,8 @@ public:
[[nodiscard]] std::optional<std::string> ToVirtualPath(const std::filesystem::path& fullPath) const;
[[nodiscard]] const std::vector<FSMount>& GetMounts() const { return mounts; }
private:
static std::vector<uint8_t> ReadFile(const std::filesystem::path& fullPath);
@@ -34,6 +34,7 @@ public:
MaterialID addSimpleTextureMaterial(ImageID textureID);
[[nodiscard]] const Material& getMaterial(MaterialID id) const;
[[nodiscard]] const std::string& getMaterialKey(MaterialID id) const;
[[nodiscard]] std::size_t getMaterialCount() const { return materials.size(); }
[[nodiscard]] std::optional<MaterialID> findMaterialByKey(std::string_view key) const;
[[nodiscard]] std::optional<MaterialID> findMaterialByName(
std::string_view name,
@@ -1,6 +1,7 @@
#ifndef PRIMITIVEMESHES_H
#define PRIMITIVEMESHES_H
#include <array>
#include <optional>
#include <string>
#include <string_view>
@@ -10,9 +11,36 @@
#include <destrum/Graphics/Resources/CPUMesh.h>
namespace PrimitiveMeshes {
enum class Shape {
Box,
Plane,
Sphere,
Cylinder,
Capsule,
};
inline constexpr std::array<Shape, 5> AllShapes{
Shape::Box, Shape::Plane, Shape::Sphere, Shape::Cylinder, Shape::Capsule,
};
struct Desc {
Shape shape{Shape::Box};
glm::vec3 size{1.0f};
float radius{0.5f};
float height{2.0f};
bool operator==(const Desc&) const = default;
};
[[nodiscard]] const char* ShapeName(Shape shape);
[[nodiscard]] Desc DefaultDesc(Shape shape);
[[nodiscard]] CPUMesh Make(const Desc& desc);
[[nodiscard]] CPUMesh MakeBox(const glm::vec3& size);
[[nodiscard]] std::string MakeKey(const Desc& desc);
[[nodiscard]] std::string BoxKey(const glm::vec3& size);
[[nodiscard]] std::optional<Desc> ParseKey(std::string_view key);
[[nodiscard]] bool IsPrimitiveKey(std::string_view key);
[[nodiscard]] std::string_view StripDuplicateSuffix(std::string_view key);
[[nodiscard]] std::optional<CPUMesh> MakeFromKey(std::string_view key);
@@ -0,0 +1,32 @@
#ifndef DESTRUM_ASSETBROWSER_H
#define DESTRUM_ASSETBROWSER_H
#include <filesystem>
#include <string>
#include <string_view>
#include <vector>
class AssetBrowser final {
public:
void Draw(bool* open = nullptr);
[[nodiscard]] static bool IsModel(std::string_view path);
private:
struct Entry {
std::string name;
bool directory{false};
};
void Refresh();
void Navigate(const std::string& scheme, const std::filesystem::path& folder);
bool DrawEntry(const Entry& entry);
std::string m_Scheme;
std::filesystem::path m_Folder;
std::vector<Entry> m_Entries;
std::string m_Filter;
bool m_Dirty{true};
};
#endif // DESTRUM_ASSETBROWSER_H
@@ -24,6 +24,7 @@ namespace ImGuiUtils {
float speed = 0.1f);
inline constexpr const char* GameObjectPayload = "DESTRUM_GAMEOBJECT";
inline constexpr const char* AssetPayload = "DESTRUM_ASSET";
bool InputString(const char* label, std::string& value, ImGuiInputTextFlags flags = 0, const char* hint = nullptr);
bool InputStringMultiline(const char* label, std::string& value);
+17
View File
@@ -12,6 +12,7 @@
#include <destrum/Scene/SceneManager.h>
#include <destrum/Serialization/SceneSerializer.h>
#include <destrum/Util/DeltaTime.h>
#include <destrum/Util/ImGuiUtils.h>
#include "imgui.h"
#include "imgui_internal.h"
@@ -261,6 +262,11 @@ if (!isRunning) break;
drawPhysicsPanel(dt, fixedDt, accumulator);
}
if (m_DebugWindows.assets)
{
m_AssetBrowser.Draw(&m_DebugWindows.assets);
}
if (m_DebugWindows.imageCache)
{
m_ResourcesInspector.drawImageCacheWindow(&m_DebugWindows.imageCache, resources, imguiPass);
@@ -450,6 +456,7 @@ void App::drawWindowsMenu()
ImGui::MenuItem("Physics", nullptr, &m_DebugWindows.physics);
ImGui::MenuItem("Inspector", nullptr, &m_DebugWindows.inspector);
ImGui::MenuItem("Image Cache", nullptr, &m_DebugWindows.imageCache);
ImGui::MenuItem("Assets", nullptr, &m_DebugWindows.assets);
customWindowsMenu();
@@ -480,11 +487,13 @@ void App::drawDockSpace()
ImGuiID center = dockspaceId;
const ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.25f, nullptr, &center);
const ImGuiID left = ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.22f, nullptr, &center);
const ImGuiID bottom = ImGui::DockBuilderSplitNode(center, ImGuiDir_Down, 0.28f, nullptr, &center);
ImGui::DockBuilderDockWindow("Viewport", center);
ImGui::DockBuilderDockWindow("Inspector", right);
ImGui::DockBuilderDockWindow("Debug", left);
ImGui::DockBuilderDockWindow("Physics", left);
ImGui::DockBuilderDockWindow("Assets", bottom);
ImGui::DockBuilderFinish(dockspaceId);
}
@@ -520,6 +529,14 @@ void App::drawViewportWindow()
if (const ImTextureID texture = imguiPass.getFrameTexture(drawImage.imageView); texture != ImTextureID{})
{
ImGui::Image(texture, ImVec2{viewportRect.size.x, viewportRect.size.y});
if (ImGui::BeginDragDropTarget())
{
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(ImGuiUtils::AssetPayload))
{
onViewportAssetDropped(static_cast<const char*>(payload->Data));
}
ImGui::EndDragDropTarget();
}
}
viewportHovered = ImGui::IsWindowHovered();
@@ -9,7 +9,6 @@
#include "destrum/ObjectModel/GameObject.h"
#include "destrum/Util/GameState.h"
#include <imgui.h>
#include <filesystem>
#include <stdexcept>
@@ -179,30 +178,6 @@ void MeshRendererComponent::ResolveReferences(const ObjectMap&) {
}
}
void MeshRendererComponent::ImGuiInspector() {
if (meshID == NULL_MESH_ID) {
ImGui::TextDisabled("Mesh: <none>");
} else {
ImGui::Text("Mesh ID: %zu", meshID);
ImGui::TextWrapped(
"Mesh key: %s",
meshKey.empty() ? "<not resolved>" : meshKey.c_str());
}
if (materialID == NULL_MATERIAL_ID) {
ImGui::TextDisabled("Material: <none>");
} else {
ImGui::Text("Material ID: %u", materialID);
ImGui::TextWrapped(
"Material key: %s",
materialKey.empty() ? "<not resolved>" : materialKey.c_str());
}
ImGui::Text(
"Skinning: %s",
m_skinnedMesh != nullptr ? "active" : "static");
}
void MeshRendererComponent::SetMeshID(MeshID id) {
meshID = id;
meshKey.clear();
@@ -0,0 +1,423 @@
#include <destrum/Components/MeshRendererComponent.h>
#include <algorithm>
#include <array>
#include <cctype>
#include <filesystem>
#include <utility>
#include <vector>
#include <imgui.h>
#include <spdlog/spdlog.h>
#include <destrum/Components/Animator.h>
#include <destrum/FS/AssetFS.h>
#include <destrum/Graphics/Renderer.h>
#include <destrum/ObjectModel/GameObject.h>
#include <destrum/Scene/Scene.h>
#include <destrum/Util/AssetBrowser.h>
#include <destrum/Util/GameState.h>
#include <destrum/Util/ImGuiUtils.h>
namespace {
struct TextureSlot {
const char* label;
ImageID Material::* member;
TextureIntent intent;
};
constexpr std::array<TextureSlot, 6> TextureSlots{{
{"Diffuse", &Material::diffuseTexture, TextureIntent::ColorSrgb},
{"Normal", &Material::normalTexture, TextureIntent::DataLinear},
{"Metallic", &Material::metallicTexture, TextureIntent::DataLinear},
{"Roughness", &Material::roughnessTexture, TextureIntent::DataLinear},
{"Occlusion", &Material::occlusionTexture, TextureIntent::DataLinear},
{"Emissive", &Material::emissiveTexture, TextureIntent::ColorSrgb},
}};
[[nodiscard]] std::string Lowercase(std::string_view value)
{
std::string result{value};
std::ranges::transform(result, result.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return result;
}
[[nodiscard]] bool Matches(std::string_view text, const std::string& filter)
{
return filter.empty() || Lowercase(text).find(Lowercase(filter)) != std::string::npos;
}
[[nodiscard]] bool IsImage(std::string_view path)
{
const std::string extension = Lowercase(std::filesystem::path{path}.extension().string());
return extension == ".png" || extension == ".jpg" || extension == ".jpeg" || extension == ".tga" ||
extension == ".bmp";
}
[[nodiscard]] std::optional<std::string> AcceptAssetDrop()
{
std::optional<std::string> dropped;
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(ImGuiUtils::AssetPayload)) {
dropped = static_cast<const char*>(payload->Data);
}
ImGui::EndDragDropTarget();
}
return dropped;
}
[[nodiscard]] bool PickerButton(const char* label, const std::string& value)
{
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(label);
ImGui::SameLine(90.0f);
const bool clicked = ImGui::Button((value + "##" + label).c_str(), ImVec2{-FLT_MIN, 0.0f});
ImGui::SetItemTooltip("%s\nClick to pick, or drop a file from the Assets window", value.c_str());
return clicked;
}
[[nodiscard]] std::string TextureLabel(const RenderResources& resources, ImageID id)
{
if (id == NULL_IMAGE_ID) {
return "<none>";
}
if (const ImageCache::LoadedImageInfo* info = resources.images().findLoadedImageInfo(id)) {
const auto virtualPath = AssetFS::GetInstance().ToVirtualPath(info->path);
return virtualPath ? *virtualPath : info->path.filename().string();
}
return "texture " + std::to_string(id);
}
[[nodiscard]] int CountMaterialUsers(const GameObject* self, MaterialID id)
{
const Scene* scene = self != nullptr ? self->GetScene() : nullptr;
if (scene == nullptr) {
return 1;
}
int users = 0;
for (const auto& object : scene->GetObjects()) {
for (const auto& component : object->GetComponents()) {
const auto* meshRenderer = dynamic_cast<const MeshRendererComponent*>(component.get());
if (meshRenderer != nullptr && meshRenderer->GetMaterialID() == id) {
++users;
}
}
}
return users;
}
template <typename Fn>
void PickerList(const char* id, Fn&& drawItems)
{
const float height = std::min(ImGui::GetTextLineHeightWithSpacing() * 14.0f, 320.0f);
if (ImGui::BeginChild(id, ImVec2{420.0f, height}, ImGuiChildFlags_None)) {
drawItems();
}
ImGui::EndChild();
}
}
void MeshRendererComponent::ImGuiInspector()
{
if (!GameState::GetInstance().HasRenderer() || !GameState::GetInstance().HasGfxDevice()) {
ImGui::TextDisabled("Meshes and materials can only be picked with a renderer");
return;
}
GameRenderer& renderer = GameState::GetInstance().Renderer();
RenderResources* resources = renderer.getResources();
if (resources == nullptr) {
return;
}
DrawMeshField(*resources);
ImGui::Spacing();
DrawMaterialField(*resources, renderer);
}
void MeshRendererComponent::DrawMeshField(RenderResources& resources)
{
// The skinning buffer is sized for the mesh in Start, a different mesh would overrun it
const bool skinned = GetGameObject()->GetComponent<Animator>() != nullptr;
ImGui::BeginDisabled(skinned);
if (PickerButton("Mesh", meshID == NULL_MESH_ID ? "<none>" : meshKey)) {
m_MeshSearch.clear();
ImGui::OpenPopup("MeshPicker");
}
if (const auto dropped = AcceptAssetDrop(); dropped && AssetBrowser::IsModel(*dropped)) {
LoadDroppedModel(resources, *dropped);
std::vector<MeshID> parts;
for (MeshID id = 0; id < resources.meshes().getMeshes().size(); ++id) {
if (resources.meshes().getMeshKey(id).starts_with(*dropped + "#") && !resources.meshes().getMesh(id).hasSkeleton) {
parts.push_back(id);
}
}
if (parts.size() == 1) {
SetMeshID(parts.front());
} else if (!parts.empty()) {
m_MeshSearch = *dropped;
ImGui::OpenPopup("MeshPicker");
}
}
ImGui::EndDisabled();
if (skinned) {
ImGui::TextDisabled("Animated meshes can't be swapped here");
}
if (ImGui::BeginPopup("MeshPicker")) {
if (ImGui::IsWindowAppearing()) {
ImGui::SetKeyboardFocusHere();
}
ImGui::SetNextItemWidth(420.0f);
ImGuiUtils::InputString("##Search", m_MeshSearch, 0, "Search");
ImGui::SeparatorText("Primitives");
for (const PrimitiveMeshes::Shape shape : PrimitiveMeshes::AllShapes) {
const char* name = PrimitiveMeshes::ShapeName(shape);
if (Matches(name, m_MeshSearch) && ImGui::Selectable(name)) {
AssignPrimitive(resources, PrimitiveMeshes::DefaultDesc(shape));
ImGui::CloseCurrentPopup();
}
}
ImGui::SeparatorText("Loaded");
PickerList("LoadedMeshes", [&] {
const MeshCache& meshes = resources.meshes();
for (MeshID id = 0; id < meshes.getMeshes().size(); ++id) {
const std::string& key = meshes.getMeshKey(id);
if (meshes.getMesh(id).hasSkeleton || !Matches(key, m_MeshSearch)) {
continue;
}
ImGui::PushID(static_cast<int>(id));
if (ImGui::Selectable(key.c_str(), id == meshID)) {
SetMeshID(id);
ImGui::CloseCurrentPopup();
}
ImGui::PopID();
}
});
ImGui::EndPopup();
}
DrawPrimitiveSettings(resources);
}
void MeshRendererComponent::DrawPrimitiveSettings(RenderResources& resources)
{
if (meshID == NULL_MESH_ID || !PrimitiveMeshes::IsPrimitiveKey(meshKey)) {
m_PrimitiveDraft.reset();
return;
}
// Edits go into a draft and only build a mesh once the drag ends, every step would add one to the cache
if (!m_PrimitiveDraft || m_PrimitiveDraftKey != meshKey) {
m_PrimitiveDraft = PrimitiveMeshes::ParseKey(meshKey);
m_PrimitiveDraftKey = meshKey;
}
if (!m_PrimitiveDraft) {
return;
}
PrimitiveMeshes::Desc& draft = *m_PrimitiveDraft;
bool apply = false;
const auto edited = [&apply] { apply = apply || ImGui::IsItemDeactivatedAfterEdit(); };
constexpr float Min = 0.01f;
constexpr float Max = 1000.0f;
switch (draft.shape) {
case PrimitiveMeshes::Shape::Box:
ImGui::DragFloat3("Size", &draft.size.x, 0.05f, Min, Max, "%.2f m");
edited();
break;
case PrimitiveMeshes::Shape::Plane: {
float size[2]{draft.size.x, draft.size.z};
if (ImGui::DragFloat2("Size", size, 0.05f, Min, Max, "%.2f m")) {
draft.size.x = size[0];
draft.size.z = size[1];
}
edited();
break;
}
case PrimitiveMeshes::Shape::Sphere:
ImGui::DragFloat("Radius", &draft.radius, 0.01f, Min, Max, "%.2f m");
edited();
break;
case PrimitiveMeshes::Shape::Cylinder:
case PrimitiveMeshes::Shape::Capsule:
ImGui::DragFloat("Radius", &draft.radius, 0.01f, Min, Max, "%.2f m");
edited();
ImGui::DragFloat("Height", &draft.height, 0.05f, Min, Max, "%.2f m");
edited();
break;
}
if (apply) {
draft.size = glm::max(draft.size, glm::vec3{Min});
draft.radius = std::max(draft.radius, Min);
draft.height = std::max(draft.height, Min);
AssignPrimitive(resources, draft);
}
}
void MeshRendererComponent::AssignPrimitive(RenderResources& resources, const PrimitiveMeshes::Desc& desc)
{
const std::string key = PrimitiveMeshes::MakeKey(desc);
auto id = resources.meshes().findMeshByKey(key);
if (!id) {
id = resources.meshes().addMesh(GameState::GetInstance().Gfx(), PrimitiveMeshes::Make(desc));
}
SetMeshID(*id);
}
void MeshRendererComponent::LoadDroppedModel(RenderResources& resources, const std::string& path)
{
try {
resources.loadModelAsset(GameState::GetInstance().Gfx(), path);
} catch (const std::exception& exception) {
spdlog::error("Could not load {}: {}", path, exception.what());
}
}
void MeshRendererComponent::DrawMaterialField(RenderResources& resources, GameRenderer& renderer)
{
MaterialCache& materials = resources.materials();
if (PickerButton("Material", materialID == NULL_MATERIAL_ID ? "<none>" : materialKey)) {
m_MaterialSearch.clear();
ImGui::OpenPopup("MaterialPicker");
}
if (const auto dropped = AcceptAssetDrop(); dropped && AssetBrowser::IsModel(*dropped)) {
LoadDroppedModel(resources, *dropped);
m_MaterialSearch = *dropped;
ImGui::OpenPopup("MaterialPicker");
}
if (ImGui::BeginPopup("MaterialPicker")) {
if (ImGui::IsWindowAppearing()) {
ImGui::SetKeyboardFocusHere();
}
ImGui::SetNextItemWidth(420.0f);
ImGuiUtils::InputString("##Search", m_MaterialSearch, 0, "Search");
if (ImGui::Selectable("New Material")) {
SetMaterialID(materials.addMaterial(Material::SimpleColor(glm::vec3{1.0f}, "Material")));
ImGui::CloseCurrentPopup();
}
ImGui::SeparatorText("Loaded");
PickerList("LoadedMaterials", [&] {
for (MaterialID id = 0; id < materials.getMaterialCount(); ++id) {
const std::string& key = materials.getMaterialKey(id);
if (id == materials.getPlaceholderMaterialId() || !Matches(key, m_MaterialSearch)) {
continue;
}
ImGui::PushID(static_cast<int>(id));
if (ImGui::Selectable(key.c_str(), id == materialID)) {
SetMaterialID(id);
ImGui::CloseCurrentPopup();
}
ImGui::PopID();
}
});
ImGui::EndPopup();
}
if (materialID != NULL_MATERIAL_ID) {
DrawMaterialEditor(resources, renderer);
}
}
void MeshRendererComponent::DrawMaterialEditor(RenderResources& resources, GameRenderer& renderer)
{
if (!ImGui::TreeNodeEx("Material Settings", ImGuiTreeNodeFlags_DefaultOpen)) {
return;
}
MaterialCache& materials = resources.materials();
const bool placeholder = materialID == materials.getPlaceholderMaterialId();
const bool fileBacked = !materials.getMaterial(materialID).assetReference.empty();
const int users = CountMaterialUsers(GetGameObject(), materialID);
// A copy without a source file is saved inside the scene, so it can be edited freely
const auto copyForThisObject = [&] {
Material copy = materials.getMaterial(materialID);
copy.assetReference = {};
if (copy.name.empty() || placeholder) {
copy.name = "Material";
}
SetMaterialID(materials.addMaterial(std::move(copy)));
};
bool copied = false;
if (placeholder) {
ImGui::TextDisabled("The default material, pick New Material to edit one");
} else if (fileBacked) {
ImGui::TextDisabled("From %s, edits would not be saved", materials.getMaterial(materialID).assetReference.path.c_str());
if (ImGui::Button("Make Editable Copy")) {
copyForThisObject();
copied = true;
}
} else if (users > 1) {
ImGui::Text("Shared by %d objects, edits change all of them", users);
ImGui::SameLine();
if (ImGui::SmallButton("Make Unique")) {
copyForThisObject();
copied = true;
}
}
if (copied) {
// addMaterial may have moved the material storage, draw the copy next frame
ImGui::TreePop();
return;
}
ImGui::BeginDisabled(placeholder || fileBacked);
Material& material = materials.getMaterialMutable(materialID);
bool changed = false;
changed |= ImGui::ColorEdit3("Base Color", &material.baseColor.x);
changed |= ImGui::SliderFloat("Metallic", &material.metallicFactor, 0.0f, 1.0f);
changed |= ImGui::SliderFloat("Roughness", &material.roughnessFactor, 0.0f, 1.0f);
changed |= ImGui::ColorEdit3("Emissive", &material.emissiveColor.x, ImGuiColorEditFlags_HDR | ImGuiColorEditFlags_Float);
changed |= ImGui::DragFloat("Normal Scale", &material.normalScale, 0.01f, 0.0f, 4.0f);
changed |= ImGui::SliderFloat("Occlusion", &material.occlusionStrength, 0.0f, 1.0f);
constexpr std::array<const char*, 3> filteringNames{"Nearest", "Linear", "Anisotropic"};
int filtering = static_cast<int>(material.textureFilteringMode);
if (ImGui::Combo("Filtering", &filtering, filteringNames.data(), static_cast<int>(filteringNames.size()))) {
material.textureFilteringMode = static_cast<TextureFilteringMode>(filtering);
changed = true;
}
ImGui::SeparatorText("Textures");
for (const TextureSlot& slot : TextureSlots) {
ImGui::PushID(slot.label);
ImageID& texture = material.*slot.member;
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(slot.label);
ImGui::SameLine(90.0f);
const float clearWidth = ImGui::GetFrameHeight();
ImGui::Button(TextureLabel(resources, texture).c_str(),
ImVec2{ImGui::GetContentRegionAvail().x - clearWidth - ImGui::GetStyle().ItemSpacing.x, 0.0f});
ImGui::SetItemTooltip("Drop an image from the Assets window");
if (const auto dropped = AcceptAssetDrop(); dropped && IsImage(*dropped)) {
try {
texture = resources.loadImageFromFile(GameState::GetInstance().Gfx(), AssetFS::GetInstance().GetFullPath(*dropped),
VK_IMAGE_USAGE_SAMPLED_BIT, true, slot.intent);
changed = true;
} catch (const std::exception& exception) {
spdlog::error("Could not load {}: {}", *dropped, exception.what());
}
}
ImGui::SameLine();
ImGui::BeginDisabled(texture == NULL_IMAGE_ID);
if (ImGui::Button("x", ImVec2{clearWidth, 0.0f})) {
texture = NULL_IMAGE_ID;
changed = true;
}
ImGui::EndDisabled();
ImGui::PopID();
}
ImGui::EndDisabled();
if (changed) {
renderer.updateMaterialGPU(materialID);
}
ImGui::TreePop();
}
@@ -1,17 +1,28 @@
#include <destrum/Graphics/Resources/PrimitiveMeshes.h>
#include <algorithm>
#include <array>
#include <charconv>
#include <cmath>
#include <cstdint>
#include <format>
#include <limits>
#include <utility>
#include <vector>
CPUMesh PrimitiveMeshes::MakeBox(const glm::vec3& size)
{
struct Face {
#include <glm/gtc/constants.hpp>
namespace {
constexpr std::string_view PrimitiveScheme = "prim://";
constexpr std::uint32_t Segments = 32;
constexpr std::uint32_t Rings = 16;
struct BoxFace {
glm::vec3 normal;
glm::vec3 tangent;
};
constexpr std::array<Face, 6> faces{{
constexpr std::array<BoxFace, 6> BoxFaces{{
{{1.0f, 0.0f, 0.0f}, {0.0f, 0.0f, -1.0f}},
{{-1.0f, 0.0f, 0.0f}, {0.0f, 0.0f, 1.0f}},
{{0.0f, 1.0f, 0.0f}, {1.0f, 0.0f, 0.0f}},
@@ -19,17 +30,13 @@ CPUMesh PrimitiveMeshes::MakeBox(const glm::vec3& size)
{{0.0f, 0.0f, 1.0f}, {1.0f, 0.0f, 0.0f}},
{{0.0f, 0.0f, -1.0f}, {-1.0f, 0.0f, 0.0f}},
}};
void AddBoxFace(CPUMesh& mesh, const BoxFace& face, const glm::vec3& size)
{
constexpr std::array<glm::vec2, 4> corners{{
{-1.0f, -1.0f}, {1.0f, -1.0f}, {1.0f, 1.0f}, {-1.0f, 1.0f},
}};
CPUMesh mesh{};
// The name becomes the mesh cache key, saved scenes rebuild the box from it
mesh.name = BoxKey(size);
mesh.minPos = -size * 0.5f;
mesh.maxPos = size * 0.5f;
for (const Face& face : faces) {
const glm::vec3 bitangent = glm::cross(face.normal, face.tangent);
const float width = glm::dot(glm::abs(face.tangent), size);
const float height = glm::dot(glm::abs(bitangent), size);
@@ -51,17 +58,299 @@ CPUMesh PrimitiveMeshes::MakeBox(const glm::vec3& size)
}
}
// Same convention as the box faces: the shader's cross(N, T) * w points along increasing v
[[nodiscard]] float TangentSign(const glm::vec3& normal, const glm::vec3& tangent, const glm::vec3& alongV)
{
return glm::dot(glm::cross(normal, tangent), alongV) >= 0.0f ? 1.0f : -1.0f;
}
// Front faces are counter clockwise seen from outside, flip the ones a generator got backwards
void AddTriangle(CPUMesh& mesh, std::uint32_t a, std::uint32_t b, std::uint32_t c)
{
const auto& va = mesh.vertices[a];
const auto& vb = mesh.vertices[b];
const auto& vc = mesh.vertices[c];
const glm::vec3 faceNormal = glm::cross(vb.position - va.position, vc.position - va.position);
if (glm::dot(faceNormal, va.normal + vb.normal + vc.normal) < 0.0f) {
std::swap(b, c);
}
mesh.indices.insert(mesh.indices.end(), {a, b, c});
}
struct ProfilePoint {
float radius;
float y;
glm::vec2 normal;
};
// Spins a top to bottom outline around the y axis, used for spheres, capsules and cylinder sides
void AddLathe(CPUMesh& mesh, const std::vector<ProfilePoint>& profile, float uScale)
{
std::vector<float> v(profile.size(), 0.0f);
for (std::size_t k = 1; k < profile.size(); ++k) {
const glm::vec2 step{profile[k].radius - profile[k - 1].radius, profile[k].y - profile[k - 1].y};
v[k] = v[k - 1] + glm::length(step);
}
const auto first = static_cast<std::uint32_t>(mesh.vertices.size());
for (std::size_t k = 0; k < profile.size(); ++k) {
const ProfilePoint& point = profile[k];
const ProfilePoint& next = profile[k + 1 < profile.size() ? k + 1 : k];
const ProfilePoint& previous = profile[k + 1 < profile.size() ? k : k - 1];
for (std::uint32_t s = 0; s <= Segments; ++s) {
const float angle = glm::two_pi<float>() * static_cast<float>(s) / static_cast<float>(Segments);
const float c = std::cos(angle);
const float sn = std::sin(angle);
const glm::vec3 normal = glm::normalize(glm::vec3{point.normal.x * c, point.normal.y, point.normal.x * sn});
const glm::vec3 tangent{-sn, 0.0f, c};
const glm::vec3 alongV{(next.radius - previous.radius) * c, next.y - previous.y, (next.radius - previous.radius) * sn};
mesh.vertices.push_back(CPUMesh::Vertex{
.position = {point.radius * c, point.y, point.radius * sn},
.uv_x = angle * uScale,
.normal = normal,
.uv_y = v[k],
.tangent = glm::vec4{tangent, TangentSign(normal, tangent, alongV)},
});
}
}
const std::uint32_t stride = Segments + 1;
for (std::uint32_t k = 0; k + 1 < profile.size(); ++k) {
for (std::uint32_t s = 0; s < Segments; ++s) {
const std::uint32_t a = first + k * stride + s;
const std::uint32_t b = a + 1;
const std::uint32_t c = a + stride;
const std::uint32_t d = c + 1;
AddTriangle(mesh, a, c, b);
AddTriangle(mesh, b, c, d);
}
}
}
void AddDisc(CPUMesh& mesh, float y, float normalY, float radius)
{
const glm::vec3 normal{0.0f, normalY, 0.0f};
const glm::vec3 tangent{1.0f, 0.0f, 0.0f};
const glm::vec4 packedTangent{tangent, TangentSign(normal, tangent, {0.0f, 0.0f, 1.0f})};
const auto center = static_cast<std::uint32_t>(mesh.vertices.size());
mesh.vertices.push_back(CPUMesh::Vertex{
.position = {0.0f, y, 0.0f}, .uv_x = 0.0f, .normal = normal, .uv_y = 0.0f, .tangent = packedTangent,
});
for (std::uint32_t s = 0; s <= Segments; ++s) {
const float angle = glm::two_pi<float>() * static_cast<float>(s) / static_cast<float>(Segments);
const glm::vec3 position{radius * std::cos(angle), y, radius * std::sin(angle)};
mesh.vertices.push_back(CPUMesh::Vertex{
.position = position, .uv_x = position.x, .normal = normal, .uv_y = position.z, .tangent = packedTangent,
});
}
for (std::uint32_t s = 0; s < Segments; ++s) {
AddTriangle(mesh, center, center + 1 + s, center + 2 + s);
}
}
[[nodiscard]] std::vector<ProfilePoint> RoundProfile(float radius, float straightHalfHeight)
{
std::vector<ProfilePoint> profile;
for (std::uint32_t k = 0; k <= Rings; ++k) {
const float theta = glm::pi<float>() * static_cast<float>(k) / static_cast<float>(Rings);
const glm::vec2 normal{std::sin(theta), std::cos(theta)};
const float offset = k * 2 < Rings ? straightHalfHeight : -straightHalfHeight;
if (k * 2 == Rings && straightHalfHeight > 0.0f) {
// The equator twice, once per hemisphere, the band between them is the straight part
profile.push_back({radius, straightHalfHeight, normal});
profile.push_back({radius, -straightHalfHeight, normal});
continue;
}
profile.push_back({radius * normal.x, radius * normal.y + offset, normal});
}
return profile;
}
void SetBounds(CPUMesh& mesh)
{
mesh.minPos = glm::vec3{std::numeric_limits<float>::max()};
mesh.maxPos = glm::vec3{std::numeric_limits<float>::lowest()};
for (const auto& vertex : mesh.vertices) {
mesh.minPos = glm::min(mesh.minPos, vertex.position);
mesh.maxPos = glm::max(mesh.maxPos, vertex.position);
}
}
[[nodiscard]] std::optional<std::vector<float>> ParseFloats(std::string_view text)
{
std::vector<float> values;
while (true) {
float value = 0.0f;
const auto [next, error] = std::from_chars(text.data(), text.data() + text.size(), value);
if (error != std::errc{} || !(value > 0.0f) || value == std::numeric_limits<float>::infinity()) {
return std::nullopt;
}
values.push_back(value);
text.remove_prefix(static_cast<std::size_t>(next - text.data()));
if (text.empty()) {
return values;
}
if (!text.starts_with(',')) {
return std::nullopt;
}
text.remove_prefix(1);
}
}
}
const char* PrimitiveMeshes::ShapeName(Shape shape)
{
switch (shape) {
case Shape::Box: return "box";
case Shape::Plane: return "plane";
case Shape::Sphere: return "sphere";
case Shape::Cylinder: return "cylinder";
case Shape::Capsule: return "capsule";
}
return "box";
}
PrimitiveMeshes::Desc PrimitiveMeshes::DefaultDesc(Shape shape)
{
Desc desc{.shape = shape};
if (shape == Shape::Plane) {
desc.size = {10.0f, 1.0f, 10.0f};
}
return desc;
}
CPUMesh PrimitiveMeshes::Make(const Desc& desc)
{
CPUMesh mesh{};
// The name becomes the mesh cache key, saved scenes rebuild the mesh from it
mesh.name = MakeKey(desc);
switch (desc.shape) {
case Shape::Box:
for (const BoxFace& face : BoxFaces) {
AddBoxFace(mesh, face, desc.size);
}
break;
case Shape::Plane:
AddBoxFace(mesh, BoxFaces[2], {desc.size.x, 0.0f, desc.size.z});
break;
case Shape::Sphere:
AddLathe(mesh, RoundProfile(desc.radius, 0.0f), desc.radius);
break;
case Shape::Cylinder: {
const float half = desc.height * 0.5f;
AddLathe(mesh, {{desc.radius, half, {1.0f, 0.0f}}, {desc.radius, -half, {1.0f, 0.0f}}}, desc.radius);
AddDisc(mesh, half, 1.0f, desc.radius);
AddDisc(mesh, -half, -1.0f, desc.radius);
break;
}
case Shape::Capsule:
// height is the full length tip to tip, like the CapsuleCollider
AddLathe(mesh, RoundProfile(desc.radius, std::max(desc.height * 0.5f - desc.radius, 0.0f)), desc.radius);
break;
}
SetBounds(mesh);
return mesh;
}
namespace {
constexpr std::string_view PrimitiveScheme = "prim://";
constexpr std::string_view BoxPrefix = "prim://box?size=";
CPUMesh PrimitiveMeshes::MakeBox(const glm::vec3& size)
{
return Make({.shape = Shape::Box, .size = size});
}
std::string PrimitiveMeshes::MakeKey(const Desc& desc)
{
switch (desc.shape) {
case Shape::Box:
return std::format("{}box?size={},{},{}", PrimitiveScheme, desc.size.x, desc.size.y, desc.size.z);
case Shape::Plane:
return std::format("{}plane?size={},{}", PrimitiveScheme, desc.size.x, desc.size.z);
case Shape::Sphere:
return std::format("{}sphere?radius={}", PrimitiveScheme, desc.radius);
case Shape::Cylinder:
case Shape::Capsule:
return std::format("{}{}?radius={}&height={}", PrimitiveScheme, ShapeName(desc.shape), desc.radius, desc.height);
}
return {};
}
std::string PrimitiveMeshes::BoxKey(const glm::vec3& size)
{
return std::format("{}{},{},{}", BoxPrefix, size.x, size.y, size.z);
return MakeKey({.shape = Shape::Box, .size = size});
}
std::optional<PrimitiveMeshes::Desc> PrimitiveMeshes::ParseKey(std::string_view key)
{
key = StripDuplicateSuffix(key);
if (!key.starts_with(PrimitiveScheme)) {
return std::nullopt;
}
key.remove_prefix(PrimitiveScheme.size());
const auto query = key.find('?');
if (query == std::string_view::npos) {
return std::nullopt;
}
const std::string_view shapeName = key.substr(0, query);
std::optional<Shape> shape;
for (const Shape candidate : AllShapes) {
if (shapeName == ShapeName(candidate)) {
shape = candidate;
}
}
if (!shape) {
return std::nullopt;
}
std::optional<std::vector<float>> size;
std::optional<std::vector<float>> radius;
std::optional<std::vector<float>> height;
std::string_view params = key.substr(query + 1);
while (!params.empty()) {
const auto end = params.find('&');
const std::string_view param = params.substr(0, end);
params = end == std::string_view::npos ? std::string_view{} : params.substr(end + 1);
const auto equals = param.find('=');
if (equals == std::string_view::npos) {
return std::nullopt;
}
const std::string_view name = param.substr(0, equals);
auto values = ParseFloats(param.substr(equals + 1));
auto* target = name == "size" ? &size : name == "radius" ? &radius : name == "height" ? &height : nullptr;
if (target == nullptr || target->has_value() || !values) {
return std::nullopt;
}
*target = std::move(values);
}
const auto single = [](const std::optional<std::vector<float>>& values) {
return values.has_value() && values->size() == 1;
};
Desc desc{.shape = *shape};
switch (*shape) {
case Shape::Box:
if (!size || size->size() != 3 || radius || height) return std::nullopt;
desc.size = {(*size)[0], (*size)[1], (*size)[2]};
break;
case Shape::Plane:
if (!size || size->size() != 2 || radius || height) return std::nullopt;
desc.size = {(*size)[0], 1.0f, (*size)[1]};
break;
case Shape::Sphere:
if (!single(radius) || size || height) return std::nullopt;
desc.radius = radius->front();
break;
case Shape::Cylinder:
case Shape::Capsule:
if (!single(radius) || !single(height) || size) return std::nullopt;
desc.radius = radius->front();
desc.height = height->front();
break;
}
return desc;
}
bool PrimitiveMeshes::IsPrimitiveKey(std::string_view key)
@@ -86,32 +375,9 @@ std::string_view PrimitiveMeshes::StripDuplicateSuffix(std::string_view key)
std::optional<CPUMesh> PrimitiveMeshes::MakeFromKey(std::string_view key)
{
key = StripDuplicateSuffix(key);
if (!key.starts_with(BoxPrefix)) {
const auto desc = ParseKey(key);
if (!desc) {
return std::nullopt;
}
std::string_view rest = key.substr(BoxPrefix.size());
glm::vec3 size{0.0f};
for (int i = 0; i < 3; ++i) {
const char* end = rest.data() + rest.size();
float value = 0.0f;
const auto [next, error] = std::from_chars(rest.data(), end, value);
if (error != std::errc{} || !(value > 0.0f)) {
return std::nullopt;
}
size[i] = value;
rest.remove_prefix(static_cast<std::size_t>(next - rest.data()));
if (i < 2) {
if (!rest.starts_with(',')) {
return std::nullopt;
}
rest.remove_prefix(1);
}
}
if (!rest.empty()) {
return std::nullopt;
}
return MakeBox(size);
return Make(*desc);
}
+164
View File
@@ -0,0 +1,164 @@
#include <destrum/Util/AssetBrowser.h>
#include <algorithm>
#include <array>
#include <cctype>
#include <imgui.h>
#include <destrum/FS/AssetFS.h>
#include <destrum/Util/ImGuiUtils.h>
namespace {
[[nodiscard]] std::string Lowercase(std::string_view value) {
std::string result{value};
std::ranges::transform(result, result.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return result;
}
[[nodiscard]] bool HasExtension(std::string_view path, std::initializer_list<std::string_view> extensions) {
const std::string extension = Lowercase(std::filesystem::path{path}.extension().string());
return std::ranges::find(extensions, extension) != extensions.end();
}
// Cooker outputs and bookkeeping sit next to the sources in the runtime folders, nobody picks those
[[nodiscard]] bool IsHidden(const std::filesystem::path& path) {
return path.filename() == "manifest.json" || HasExtension(path.string(), {".dmodel", ".dtex", ".spv"});
}
[[nodiscard]] const FSMount* FindMount(const std::string& scheme) {
for (const FSMount& mount : AssetFS::GetInstance().GetMounts()) {
if (mount.scheme == scheme) {
return &mount;
}
}
return nullptr;
}
}
bool AssetBrowser::IsModel(std::string_view path) {
return HasExtension(path, {".fbx", ".glb", ".gltf", ".obj"});
}
void AssetBrowser::Draw(bool* open) {
if (!ImGui::Begin("Assets", open)) {
ImGui::End();
return;
}
const auto& mounts = AssetFS::GetInstance().GetMounts();
if (mounts.empty()) {
ImGui::TextDisabled("No asset folders are mounted");
ImGui::End();
return;
}
if (FindMount(m_Scheme) == nullptr) {
Navigate(mounts.front().scheme, {});
}
for (const FSMount& mount : mounts) {
if (ImGui::RadioButton(mount.scheme.c_str(), m_Scheme == mount.scheme)) {
Navigate(mount.scheme, {});
}
ImGui::SameLine();
}
if (ImGui::Button("Refresh")) {
m_Dirty = true;
}
ImGui::SameLine();
ImGui::SetNextItemWidth(-FLT_MIN);
ImGuiUtils::InputString("##Filter", m_Filter, 0, "Search this folder");
ImGui::BeginDisabled(m_Folder.empty());
if (ImGui::ArrowButton("##Up", ImGuiDir_Up)) {
Navigate(m_Scheme, m_Folder.parent_path());
}
ImGui::EndDisabled();
ImGui::SameLine();
ImGui::TextUnformatted((m_Scheme + "://" + m_Folder.generic_string()).c_str());
ImGui::Separator();
if (m_Dirty) {
Refresh();
}
if (ImGui::BeginChild("Entries")) {
const std::string filter = Lowercase(m_Filter);
for (const Entry& entry : m_Entries) {
if ((filter.empty() || Lowercase(entry.name).find(filter) != std::string::npos) && DrawEntry(entry)) {
// Entered a folder, the list is rebuilt next frame
break;
}
}
}
ImGui::EndChild();
ImGui::End();
}
bool AssetBrowser::DrawEntry(const Entry& entry) {
bool navigated = false;
const std::string virtualPath = m_Scheme + "://" + (m_Folder / entry.name).generic_string();
const std::string label = (entry.directory ? "[dir] " : IsModel(entry.name) ? "[model] " : "") + entry.name;
ImGui::PushID(entry.name.c_str());
if (ImGui::Selectable(label.c_str(), false, ImGuiSelectableFlags_AllowDoubleClick) &&
entry.directory && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
Navigate(m_Scheme, m_Folder / entry.name);
navigated = true;
}
if (!entry.directory && ImGui::BeginDragDropSource()) {
ImGui::SetDragDropPayload(ImGuiUtils::AssetPayload, virtualPath.c_str(), virtualPath.size() + 1);
ImGui::TextUnformatted(virtualPath.c_str());
if (IsModel(entry.name)) {
ImGui::TextDisabled("Drop it in the viewport to place it");
}
ImGui::EndDragDropSource();
}
if (ImGui::BeginPopupContextItem("EntryMenu")) {
if (ImGui::MenuItem("Copy Path")) {
ImGui::SetClipboardText(virtualPath.c_str());
}
ImGui::EndPopup();
}
ImGui::SetItemTooltip("%s", virtualPath.c_str());
ImGui::PopID();
return navigated;
}
void AssetBrowser::Navigate(const std::string& scheme, const std::filesystem::path& folder) {
m_Scheme = scheme;
m_Folder = folder;
m_Filter.clear();
m_Dirty = true;
}
void AssetBrowser::Refresh() {
m_Dirty = false;
m_Entries.clear();
const FSMount* mount = FindMount(m_Scheme);
if (mount == nullptr) {
return;
}
std::error_code error;
for (const auto& item : std::filesystem::directory_iterator(mount->root / m_Folder, error)) {
if (IsHidden(item.path())) {
continue;
}
m_Entries.push_back({
.name = item.path().filename().string(),
.directory = item.is_directory(error),
});
}
std::ranges::sort(m_Entries, [](const Entry& a, const Entry& b) {
if (a.directory != b.directory) {
return a.directory;
}
return Lowercase(a.name) < Lowercase(b.name);
});
}
+1
View File
@@ -29,6 +29,7 @@ public:
void onViewportResize(int newWidth, int newHeight) override;
void drawViewportOverlay() override;
void onViewportAssetDropped(std::string_view path) override;
void customWindowsMenu() override;
void onPlayModeChanged(bool nowPlaying) override;
+30
View File
@@ -10,7 +10,10 @@
#include <destrum/Graphics/Managers/Renderer2D.h>
#include <destrum/Input/InputManager.h>
#include <destrum/ObjectModel/GameObject.h>
#include <destrum/Scene/ModelSpawner.h>
#include <destrum/Scene/Scene.h>
#include <destrum/Util/AssetBrowser.h>
#include <destrum/Util/ModelDoc.h>
#include <destrum/Serialization/PrefabSerializer.h>
#include <destrum/Serialization/SceneSerializer.h>
@@ -23,6 +26,7 @@
#include <string_view>
#include "imgui.h"
#include "spdlog/spdlog.h"
namespace {
std::filesystem::path ResolveScenePath(
@@ -187,6 +191,32 @@ void LightKeeper::drawDebugMenuBar()
ImGui::EndMainMenuBar();
}
void LightKeeper::onViewportAssetDropped(std::string_view path)
{
if (!AssetBrowser::IsModel(path)) {
sceneStatus = "Only models can be dropped in the viewport: " + std::string{path};
return;
}
ModelDoc::LoadOptions options{};
options.meshImportMode = ModelDoc::MeshImportMode::MergedPerNode;
options.loadMaterials = true;
try {
Scene& scene = SceneManager::GetInstance().GetCurrentScene();
const std::string name = std::filesystem::path{path}.stem().string();
GameObject* model = ModelSpawner::SpawnStaticModel(scene, gfxDevice, resources, path, options, name);
const Camera& view = getViewCamera();
model->GetTransform().SetWorldPosition(view.GetPosition() + view.GetForward() * 5.0f);
scene.SetSelectedObject(model->GetId());
sceneStatus = "Placed " + std::string{path};
} catch (const std::exception& exception) {
spdlog::error("Could not place {}: {}", path, exception.what());
sceneStatus = "Could not place " + std::string{path};
}
}
void LightKeeper::drawViewportOverlay()
{
if (!showTransformGizmo || isPlayerViewActive()) {
+70
View File
@@ -1,4 +1,5 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <filesystem>
#include <string>
@@ -263,6 +264,74 @@ namespace {
"invalid primitive keys must not build a mesh");
}
void testPrimitiveShapes()
{
using PrimitiveMeshes::Shape;
const std::array<PrimitiveMeshes::Desc, 6> descs{{
{.shape = Shape::Box, .size = {2.0f, 0.5f, 3.0f}},
{.shape = Shape::Plane, .size = {4.0f, 1.0f, 6.0f}},
{.shape = Shape::Sphere, .radius = 0.75f},
{.shape = Shape::Cylinder, .radius = 0.5f, .height = 3.0f},
{.shape = Shape::Capsule, .radius = 0.4f, .height = 2.0f},
{.shape = Shape::Capsule, .radius = 1.0f, .height = 1.0f},
}};
for (const auto& desc : descs) {
const std::string key = PrimitiveMeshes::MakeKey(desc);
const auto parsed = PrimitiveMeshes::ParseKey(key + "#2");
check(parsed.has_value() && *parsed == desc, "every primitive must rebuild exactly from its key");
const CPUMesh mesh = PrimitiveMeshes::Make(desc);
check(mesh.name == key, "primitives must be named by their key");
check(!mesh.indices.empty() && mesh.indices.size() % 3 == 0, "primitives must be made of triangles");
bool valid = true;
for (const std::uint32_t index : mesh.indices) {
valid = valid && index < mesh.vertices.size();
}
check(valid, "primitive indices must point at their own vertices");
bool outward = true;
bool tangents = true;
for (const auto& vertex : mesh.vertices) {
outward = outward && close(glm::length(vertex.normal), 1.0f) && glm::dot(vertex.position, vertex.normal) > -0.001f;
tangents = tangents && close(glm::dot(glm::vec3{vertex.tangent}, vertex.normal), 0.0f) &&
close(std::abs(vertex.tangent.w), 1.0f);
}
check(outward, "primitive normals must be unit length and point away from the center");
check(tangents, "primitive tangents must lie in the surface with a handedness of +-1");
bool counterClockwise = true;
for (std::size_t i = 0; i < mesh.indices.size(); i += 3) {
const auto& a = mesh.vertices[mesh.indices[i]];
const auto& b = mesh.vertices[mesh.indices[i + 1]];
const auto& c = mesh.vertices[mesh.indices[i + 2]];
const glm::vec3 faceNormal = glm::cross(b.position - a.position, c.position - a.position);
counterClockwise = counterClockwise && glm::dot(faceNormal, a.normal + b.normal + c.normal) >= 0.0f;
}
check(counterClockwise, "primitive triangles must be counter clockwise seen from outside");
}
const CPUMesh sphere = PrimitiveMeshes::Make(descs[2]);
check(close(sphere.maxPos.x, 0.75f) && close(sphere.minPos.y, -0.75f), "a sphere must reach its radius");
const CPUMesh plane = PrimitiveMeshes::Make(descs[1]);
check(close(plane.maxPos.x, 2.0f) && close(plane.maxPos.z, 3.0f) && close(plane.maxPos.y, 0.0f),
"a plane must be flat on y = 0 with its size on x and z");
const CPUMesh capsule = PrimitiveMeshes::Make(descs[4]);
check(close(capsule.maxPos.y, 1.0f) && close(capsule.maxPos.x, 0.4f),
"a capsule height must be its full length, like the collider");
const CPUMesh cylinder = PrimitiveMeshes::Make(descs[3]);
check(close(cylinder.maxPos.y, 1.5f) && close(cylinder.minPos.y, -1.5f), "a cylinder must be centered on its height");
check(!PrimitiveMeshes::ParseKey("prim://sphere?radius=1&radius=2").has_value() &&
!PrimitiveMeshes::ParseKey("prim://cylinder?radius=1").has_value() &&
!PrimitiveMeshes::ParseKey("prim://plane?size=1,2,3").has_value() &&
!PrimitiveMeshes::ParseKey("prim://cone?radius=1").has_value() &&
!PrimitiveMeshes::ParseKey("prim://sphere?radius=-1").has_value() &&
!PrimitiveMeshes::ParseKey("prim://sphere").has_value(),
"malformed primitive keys must be rejected");
}
void testIndirectDrawListBatchesByMesh()
{
std::vector<GPUMesh> meshes(2);
@@ -357,6 +426,7 @@ void RunGraphicsTests()
testPrimitiveBoxMesh();
testSimpleColorMaterial();
testPrimitiveMeshKeys();
testPrimitiveShapes();
testDeletionQueueWaitsForFrame();
testIndirectDrawListBatchesByMesh();
}