Files
Destrum/destrum/src/Components/MeshRendererInspector.cpp
T

465 lines
18 KiB
C++

#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/Serialization/MaterialSerialization.h>
#include <destrum/Graphics/Renderer.h>
#include <destrum/ObjectModel/GameObject.h>
#include <destrum/Scene/Scene.h>
#include <destrum/Assets/AssetTypes.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]] 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 && AssetTypes::IsModel(*dropped)) {
LoadDroppedModel(resources, *dropped);
std::vector<MeshID> parts;
for (MeshID id = 0; id < resources.meshes().getMeshes().size(); ++id) {
if (resources.meshes().isValid(id) && 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.isValid(id) || 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()) {
if (AssetTypes::IsMaterial(*dropped)) {
try {
SetMaterialID(MaterialSerialization::LoadFile(resources, GameState::GetInstance().Gfx(), *dropped));
} catch (const std::exception& exception) {
spdlog::error("Could not load {}: {}", *dropped, exception.what());
}
} else if (AssetTypes::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 (!materials.isValid(id) || 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 std::string sourcePath = materials.getMaterial(materialID).assetReference.path;
const bool placeholder = materialID == materials.getPlaceholderMaterialId();
const bool materialFile = MaterialSerialization::IsFileKey(sourcePath);
const bool fromModel = !sourcePath.empty() && !materialFile;
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 replaced = false;
if (placeholder) {
ImGui::TextDisabled("The default material, pick New Material to edit one");
} else if (fromModel) {
ImGui::TextDisabled("From %s, edits would not be saved", sourcePath.c_str());
replaced = ImGui::Button("Make Editable Copy");
if (replaced) {
copyForThisObject();
}
} else if (materialFile) {
ImGui::TextDisabled("%s, used by %d objects here", sourcePath.c_str(), users);
if (ImGui::SmallButton("Make Unique")) {
copyForThisObject();
replaced = 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();
replaced = true;
}
}
if (!replaced && ImGui::SmallButton("Save as Material File")) {
const std::string name = materials.getMaterial(materialID).name.empty() ? "material" : materials.getMaterial(materialID).name;
std::string path = "game://materials/" + name + std::string{AssetTypes::MaterialExtension};
for (int suffix = 1; std::filesystem::exists(AssetFS::GetInstance().GetFullPath(path)); ++suffix) {
path = "game://materials/" + name + "_" + std::to_string(suffix) + std::string{AssetTypes::MaterialExtension};
}
if (MaterialSerialization::SaveFile(resources, materialID, path)) {
SetMaterialID(MaterialSerialization::LoadFile(resources, GameState::GetInstance().Gfx(), path));
replaced = true;
}
}
}
if (replaced) {
// addMaterial may have moved the material storage, draw the new one next frame
ImGui::TreePop();
return;
}
ImGui::BeginDisabled(placeholder || fromModel);
Material& material = materials.getMaterialMutable(materialID);
bool changed = false;
bool finished = false;
const auto track = [&](bool valueChanged) {
changed |= valueChanged;
finished |= ImGui::IsItemDeactivatedAfterEdit();
};
track(ImGui::ColorEdit3("Base Color", &material.baseColor.x));
track(ImGui::SliderFloat("Metallic", &material.metallicFactor, 0.0f, 1.0f));
track(ImGui::SliderFloat("Roughness", &material.roughnessFactor, 0.0f, 1.0f));
track(ImGui::ColorEdit3("Emissive", &material.emissiveColor.x, ImGuiColorEditFlags_HDR | ImGuiColorEditFlags_Float));
track(ImGui::DragFloat("Normal Scale", &material.normalScale, 0.01f, 0.0f, 4.0f));
track(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 = finished = true;
}
constexpr std::array<const char*, 2> blendModeNames{"Opaque", "Transparent"};
int blendMode = static_cast<int>(material.blendMode);
if (ImGui::Combo("Blend", &blendMode, blendModeNames.data(), static_cast<int>(blendModeNames.size()))) {
material.blendMode = static_cast<BlendMode>(blendMode);
changed = finished = true;
}
if (material.blendMode == BlendMode::Transparent) {
track(ImGui::SliderFloat("Opacity", &material.opacity, 0.0f, 1.0f));
}
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 && AssetTypes::IsImage(*dropped)) {
try {
texture = resources.loadMaterialTexture(GameState::GetInstance().Gfx(), AssetFS::GetInstance().GetFullPath(*dropped), slot.intent);
changed = finished = 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 = finished = true;
}
ImGui::EndDisabled();
ImGui::PopID();
}
ImGui::EndDisabled();
if (changed) {
renderer.updateMaterialGPU(materialID);
}
if (finished && materialFile) {
MaterialSerialization::SaveFile(resources, materialID, sourcePath);
}
ImGui::TreePop();
}