#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { struct TextureSlot { const char* label; ImageID Material::* member; TextureIntent intent; }; constexpr std::array 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(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 AcceptAssetDrop() { std::optional dropped; if (ImGui::BeginDragDropTarget()) { if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(ImGuiUtils::AssetPayload)) { dropped = static_cast(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 ""; } 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(component.get()); if (meshRenderer != nullptr && meshRenderer->GetMaterialID() == id) { ++users; } } } return users; } template 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() != nullptr; ImGui::BeginDisabled(skinned); if (PickerButton("Mesh", meshID == NULL_MESH_ID ? "" : meshKey)) { m_MeshSearch.clear(); ImGui::OpenPopup("MeshPicker"); } if (const auto dropped = AcceptAssetDrop(); dropped && AssetTypes::IsModel(*dropped)) { LoadDroppedModel(resources, *dropped); std::vector 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(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 ? "" : 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(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 filteringNames{"Nearest", "Linear", "Anisotropic"}; int filtering = static_cast(material.textureFilteringMode); if (ImGui::Combo("Filtering", &filtering, filteringNames.data(), static_cast(filteringNames.size()))) { material.textureFilteringMode = static_cast(filtering); changed = finished = true; } constexpr std::array blendModeNames{"Opaque", "Transparent"}; int blendMode = static_cast(material.blendMode); if (ImGui::Combo("Blend", &blendMode, blendModeNames.data(), static_cast(blendModeNames.size()))) { material.blendMode = static_cast(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(); }