465 lines
18 KiB
C++
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();
|
|
}
|