feat: add selection to meshrendercomponent
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user