diff --git a/CMakeLists.txt b/CMakeLists.txt index b392a992..3f900708 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1277,6 +1277,111 @@ set_target_properties(blp_convert PROPERTIES ) install(TARGETS blp_convert RUNTIME DESTINATION bin) +# ---- Tool: wowee_editor (Standalone World Editor) ---- +add_executable(wowee_editor + tools/editor/main.cpp + tools/editor/editor_app.cpp + tools/editor/editor_camera.cpp + tools/editor/editor_viewport.cpp + tools/editor/editor_ui.cpp + tools/editor/editor_brush.cpp + tools/editor/editor_history.cpp + tools/editor/terrain_editor.cpp + tools/editor/texture_painter.cpp + tools/editor/object_placer.cpp + tools/editor/npc_spawner.cpp + tools/editor/npc_presets.cpp + tools/editor/transform_gizmo.cpp + tools/editor/asset_browser.cpp + tools/editor/editor_water.cpp + tools/editor/editor_markers.cpp + tools/editor/adt_writer.cpp + + # Pipeline (asset loading) + src/pipeline/blp_loader.cpp + src/pipeline/dbc_loader.cpp + src/pipeline/dbc_layout.cpp + src/pipeline/asset_manager.cpp + src/pipeline/asset_manifest.cpp + src/pipeline/loose_file_reader.cpp + src/pipeline/m2_loader.cpp + src/pipeline/wmo_loader.cpp + src/pipeline/adt_loader.cpp + src/pipeline/wdt_loader.cpp + src/pipeline/terrain_mesh.cpp + + # Rendering core + src/rendering/vk_context.cpp + src/rendering/vk_utils.cpp + src/rendering/vk_shader.cpp + src/rendering/vk_texture.cpp + src/rendering/vk_buffer.cpp + src/rendering/vk_pipeline.cpp + src/rendering/camera.cpp + src/rendering/terrain_renderer.cpp + src/rendering/m2_renderer.cpp + src/rendering/m2_renderer_instance.cpp + src/rendering/m2_renderer_particles.cpp + src/rendering/m2_renderer_render.cpp + src/rendering/m2_model_classifier.cpp + src/rendering/wmo_renderer.cpp + src/rendering/frustum.cpp + + # Core + src/core/window.cpp + src/core/logger.cpp + src/core/memory_monitor.cpp + + # stb_image (needed by AssetManager for PNG overrides) + tools/editor/stb_image_impl.cpp +) +target_include_directories(wowee_editor PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/tools/editor +) +target_include_directories(wowee_editor SYSTEM PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/extern + ${CMAKE_CURRENT_SOURCE_DIR}/extern/vk-bootstrap/src +) +target_link_libraries(wowee_editor PRIVATE + SDL2::SDL2 + Vulkan::Vulkan + Threads::Threads + ZLIB::ZLIB + ${CMAKE_DL_LIBS} + imgui + vk-bootstrap +) +if(TARGET glm::glm) + target_link_libraries(wowee_editor PRIVATE glm::glm) +elseif(glm_FOUND) + target_include_directories(wowee_editor PRIVATE ${GLM_INCLUDE_DIRS}) +endif() +if(UNIX AND NOT APPLE) + find_package(X11 QUIET) + if(X11_FOUND) + target_link_libraries(wowee_editor PRIVATE X11) + endif() +endif() +if(WIN32) + target_link_libraries(wowee_editor PRIVATE ws2_32) + if(TARGET SDL2::SDL2main) + target_link_libraries(wowee_editor PRIVATE SDL2::SDL2main) + endif() +endif() +if(NOT MSVC) + target_compile_options(wowee_editor PRIVATE -Wall -Wextra -Wpedantic -Wno-missing-field-initializers) +endif() +if(GLSLC) + add_dependencies(wowee_editor wowee_shaders) +endif() +set_target_properties(wowee_editor PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin +) +install(TARGETS wowee_editor RUNTIME DESTINATION bin) +message(STATUS " wowee_editor tool: ENABLED") + # Print configuration summary message(STATUS "") message(STATUS "Wowee Configuration:") diff --git a/assets/shaders/editor_water.frag.glsl b/assets/shaders/editor_water.frag.glsl new file mode 100644 index 00000000..5e5cecac --- /dev/null +++ b/assets/shaders/editor_water.frag.glsl @@ -0,0 +1,8 @@ +#version 450 + +layout(location = 0) in vec4 vColor; +layout(location = 0) out vec4 outColor; + +void main() { + outColor = vColor; +} diff --git a/assets/shaders/editor_water.vert.glsl b/assets/shaders/editor_water.vert.glsl new file mode 100644 index 00000000..1577c04b --- /dev/null +++ b/assets/shaders/editor_water.vert.glsl @@ -0,0 +1,24 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(location = 0) in vec3 aPosition; +layout(location = 1) in vec4 aColor; + +layout(location = 0) out vec4 vColor; + +void main() { + gl_Position = projection * view * vec4(aPosition, 1.0); + vColor = aColor; +} diff --git a/include/pipeline/asset_manager.hpp b/include/pipeline/asset_manager.hpp index 3b8de34d..f2285ddb 100644 --- a/include/pipeline/asset_manager.hpp +++ b/include/pipeline/asset_manager.hpp @@ -47,6 +47,7 @@ public: */ bool isInitialized() const { return initialized; } const std::string& getDataPath() const { return dataPath; } + const AssetManifest& getManifest() const { return manifest_; } /** * Load a BLP texture diff --git a/include/pipeline/asset_manifest.hpp b/include/pipeline/asset_manifest.hpp index bb5267d8..7a4a1f4f 100644 --- a/include/pipeline/asset_manifest.hpp +++ b/include/pipeline/asset_manifest.hpp @@ -58,6 +58,11 @@ public: */ size_t getEntryCount() const { return entries_.size(); } + /** + * Access entries map (read-only) for iteration + */ + const std::unordered_map& getEntries() const { return entries_; } + /** * Check if manifest is loaded */ diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index c788886f..f698ea4b 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -309,6 +309,7 @@ public: /** Set the HiZ system for occlusion culling (Phase 6.3). nullptr disables HiZ. */ void setHiZSystem(HiZSystem* hiz) { hizSystem_ = hiz; } + void setForceNoCull(bool v) { forceNoCull_ = v; } /** Ensure GPU→CPU cull output is visible to the host after a fence wait. * Call after the early compute submission finishes (endSingleTimeCommands). */ @@ -727,6 +728,7 @@ private: glm::vec3 cachedCamPos_ = glm::vec3(0.0f); float cachedMaxRenderDistSq_ = 0.0f; float smoothedRenderDist_ = 1000.0f; // Smoothed render distance to prevent flickering + bool forceNoCull_ = false; // Thread count for parallel bone animation uint32_t numAnimThreads_ = 1; diff --git a/src/rendering/m2_renderer_render.cpp b/src/rendering/m2_renderer_render.cpp index 4910e182..8c29df28 100644 --- a/src/rendering/m2_renderer_render.cpp +++ b/src/rendering/m2_renderer_render.cpp @@ -824,11 +824,11 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const for (uint32_t i = 0; i < totalInstances; ++i) { const auto& instance = instances[i]; - if (gpuCullAvailable && i < numInstances) { - // GPU already tested flags + distance + frustum + if (forceNoCull_) { + if (!instance.cachedIsValid) continue; + } else if (gpuCullAvailable && i < numInstances) { if (!visibility[i]) continue; } else { - // CPU fallback: for non-GPU path or instances beyond cull buffer if (!instance.cachedIsValid || instance.cachedIsSmoke || instance.cachedIsInvisibleTrap) continue; glm::vec3 toCam = instance.position - camPos; diff --git a/tools/editor/adt_writer.cpp b/tools/editor/adt_writer.cpp new file mode 100644 index 00000000..f43a40e5 --- /dev/null +++ b/tools/editor/adt_writer.cpp @@ -0,0 +1,337 @@ +#include "adt_writer.hpp" +#include "core/logger.hpp" +#include +#include +#include + +namespace wowee { +namespace editor { + +// ADT chunk magics (little-endian as read from file) +static constexpr uint32_t MVER = 0x4D564552; +static constexpr uint32_t MHDR = 0x4D484452; +static constexpr uint32_t MCIN = 0x4D43494E; +static constexpr uint32_t MTEX = 0x4D544558; +static constexpr uint32_t MMDX = 0x4D4D4458; +static constexpr uint32_t MMID = 0x4D4D4944; +static constexpr uint32_t MWMO = 0x4D574D4F; +static constexpr uint32_t MWID = 0x4D574944; +static constexpr uint32_t MDDF = 0x4D444446; +static constexpr uint32_t MODF = 0x4D4F4446; +static constexpr uint32_t MCNK = 0x4D434E4B; +static constexpr uint32_t MCVT = 0x4D435654; +static constexpr uint32_t MCNR = 0x4D434E52; +static constexpr uint32_t MCLY = 0x4D434C59; +static constexpr uint32_t MCAL = 0x4D43414C; + +void ADTWriter::writeChunkHeader(std::vector& buf, uint32_t magic, uint32_t size) { + writeU32(buf, magic); + writeU32(buf, size); +} + +void ADTWriter::writeU32(std::vector& buf, uint32_t val) { + buf.push_back(val & 0xFF); + buf.push_back((val >> 8) & 0xFF); + buf.push_back((val >> 16) & 0xFF); + buf.push_back((val >> 24) & 0xFF); +} + +void ADTWriter::writeU16(std::vector& buf, uint16_t val) { + buf.push_back(val & 0xFF); + buf.push_back((val >> 8) & 0xFF); +} + +void ADTWriter::writeFloat(std::vector& buf, float val) { + uint32_t bits; + std::memcpy(&bits, &val, 4); + writeU32(buf, bits); +} + +void ADTWriter::writeBytes(std::vector& buf, const void* data, size_t size) { + const uint8_t* p = static_cast(data); + buf.insert(buf.end(), p, p + size); +} + +void ADTWriter::patchSize(std::vector& buf, size_t headerOffset) { + uint32_t size = static_cast(buf.size() - headerOffset - 8); + std::memcpy(buf.data() + headerOffset + 4, &size, 4); +} + +void ADTWriter::writeMVER(std::vector& buf) { + writeChunkHeader(buf, MVER, 4); + writeU32(buf, 18); // ADT version +} + +void ADTWriter::writeMHDR(std::vector& buf, size_t& mhdrOffset) { + mhdrOffset = buf.size(); + writeChunkHeader(buf, MHDR, 64); + // 16 uint32 fields — all zeros for now (offsets filled later if needed) + for (int i = 0; i < 16; i++) writeU32(buf, 0); +} + +void ADTWriter::writeMTEX(std::vector& buf, const pipeline::ADTTerrain& terrain) { + size_t start = buf.size(); + writeChunkHeader(buf, MTEX, 0); + for (const auto& tex : terrain.textures) { + writeBytes(buf, tex.c_str(), tex.size() + 1); // null-terminated + } + patchSize(buf, start); +} + +void ADTWriter::writeMMDX(std::vector& buf, const pipeline::ADTTerrain& terrain) { + size_t start = buf.size(); + writeChunkHeader(buf, MMDX, 0); + for (const auto& name : terrain.doodadNames) { + writeBytes(buf, name.c_str(), name.size() + 1); + } + patchSize(buf, start); + + // MMID: offsets into MMDX + size_t mmidStart = buf.size(); + writeChunkHeader(buf, MMID, 0); + uint32_t offset = 0; + for (const auto& name : terrain.doodadNames) { + writeU32(buf, offset); + offset += static_cast(name.size() + 1); + } + patchSize(buf, mmidStart); +} + +void ADTWriter::writeMWMO(std::vector& buf, const pipeline::ADTTerrain& terrain) { + size_t start = buf.size(); + writeChunkHeader(buf, MWMO, 0); + for (const auto& name : terrain.wmoNames) { + writeBytes(buf, name.c_str(), name.size() + 1); + } + patchSize(buf, start); + + // MWID: offsets into MWMO + size_t mwidStart = buf.size(); + writeChunkHeader(buf, MWID, 0); + uint32_t offset = 0; + for (const auto& name : terrain.wmoNames) { + writeU32(buf, offset); + offset += static_cast(name.size() + 1); + } + patchSize(buf, mwidStart); +} + +void ADTWriter::writeMDDF(std::vector& buf, const pipeline::ADTTerrain& terrain) { + size_t start = buf.size(); + writeChunkHeader(buf, MDDF, 0); + for (const auto& p : terrain.doodadPlacements) { + writeU32(buf, p.nameId); + writeU32(buf, p.uniqueId); + writeFloat(buf, p.position[0]); + writeFloat(buf, p.position[1]); + writeFloat(buf, p.position[2]); + writeFloat(buf, p.rotation[0]); + writeFloat(buf, p.rotation[1]); + writeFloat(buf, p.rotation[2]); + writeU16(buf, p.scale); + writeU16(buf, p.flags); + } + patchSize(buf, start); +} + +void ADTWriter::writeMODF(std::vector& buf, const pipeline::ADTTerrain& terrain) { + size_t start = buf.size(); + writeChunkHeader(buf, MODF, 0); + for (const auto& p : terrain.wmoPlacements) { + writeU32(buf, p.nameId); + writeU32(buf, p.uniqueId); + writeFloat(buf, p.position[0]); + writeFloat(buf, p.position[1]); + writeFloat(buf, p.position[2]); + writeFloat(buf, p.rotation[0]); + writeFloat(buf, p.rotation[1]); + writeFloat(buf, p.rotation[2]); + writeFloat(buf, p.extentLower[0]); + writeFloat(buf, p.extentLower[1]); + writeFloat(buf, p.extentLower[2]); + writeFloat(buf, p.extentUpper[0]); + writeFloat(buf, p.extentUpper[1]); + writeFloat(buf, p.extentUpper[2]); + writeU16(buf, p.flags); + writeU16(buf, p.doodadSet); + } + patchSize(buf, start); +} + +void ADTWriter::writeMCNK(std::vector& buf, const pipeline::MapChunk& chunk, + int chunkX, int chunkY) { + size_t mcnkStart = buf.size(); + writeChunkHeader(buf, MCNK, 0); + + // MCNK header (128 bytes) + writeU32(buf, chunk.flags); + writeU32(buf, chunkX); + writeU32(buf, chunkY); + writeU32(buf, static_cast(chunk.layers.size())); + writeU32(buf, 0); // nDoodadRefs + // Offsets within MCNK — filled with placeholder (parser uses sub-chunk magic scanning) + for (int i = 0; i < 5; i++) writeU32(buf, 0); // ofsHeight, ofsNormal, ofsLayer, ofsRefs, ofsAlpha + writeU32(buf, 0); // sizeAlpha + writeU32(buf, 0); // ofsShadow + writeU32(buf, 0); // sizeShadow + writeU32(buf, 0); // areaid + writeU32(buf, 0); // nMapObjRefs + writeU16(buf, chunk.holes); + writeU16(buf, 0); // padding + // 16 bytes of low-quality texture map (doodadStencil) + for (int i = 0; i < 4; i++) writeU32(buf, 0); + writeU32(buf, 0); // predTex + writeU32(buf, 0); // noEffectDoodad + writeU32(buf, 0); // ofsSndEmitters + writeU32(buf, 0); // nSndEmitters + writeU32(buf, 0); // ofsLiquid + writeU32(buf, 0); // sizeLiquid + writeFloat(buf, chunk.position[0]); + writeFloat(buf, chunk.position[1]); + writeFloat(buf, chunk.position[2]); + writeU32(buf, 0); // ofsMCCV + writeU32(buf, 0); // ofsMCLV + writeU32(buf, 0); // unused + + // MCVT sub-chunk (145 floats = 580 bytes) + writeChunkHeader(buf, MCVT, 145 * 4); + for (int i = 0; i < 145; i++) { + writeFloat(buf, chunk.heightMap.heights[i]); + } + + // MCNR sub-chunk (145 * 3 = 435 bytes + 13 pad = 448) + writeChunkHeader(buf, MCNR, 435 + 13); + writeBytes(buf, chunk.normals.data(), 435); + for (int i = 0; i < 13; i++) buf.push_back(0); // padding + + // MCLY sub-chunk + { + size_t mclyStart = buf.size(); + writeChunkHeader(buf, MCLY, 0); + for (const auto& layer : chunk.layers) { + writeU32(buf, layer.textureId); + writeU32(buf, layer.flags); + writeU32(buf, layer.offsetMCAL); + writeU32(buf, layer.effectId); + } + patchSize(buf, mclyStart); + } + + // MCAL sub-chunk (alpha maps) + { + size_t mcalStart = buf.size(); + writeChunkHeader(buf, MCAL, 0); + if (!chunk.alphaMap.empty()) { + writeBytes(buf, chunk.alphaMap.data(), chunk.alphaMap.size()); + } + patchSize(buf, mcalStart); + } + + patchSize(buf, mcnkStart); +} + +std::vector ADTWriter::serialize(const pipeline::ADTTerrain& terrain) { + std::vector buf; + buf.reserve(2 * 1024 * 1024); + + writeMVER(buf); + + size_t mhdrOffset = 0; + writeMHDR(buf, mhdrOffset); + + // MCIN placeholder (256 entries × 16 bytes = 4096 bytes) + size_t mcinStart = buf.size(); + writeChunkHeader(buf, MCIN, 4096); + for (int i = 0; i < 256 * 4; i++) writeU32(buf, 0); + + writeMTEX(buf, terrain); + writeMMDX(buf, terrain); + writeMWMO(buf, terrain); + writeMDDF(buf, terrain); + writeMODF(buf, terrain); + + // Write 256 MCNK chunks and record offsets + std::vector mcnkOffsets(256); + std::vector mcnkSizes(256); + for (int y = 0; y < 16; y++) { + for (int x = 0; x < 16; x++) { + int idx = y * 16 + x; + mcnkOffsets[idx] = buf.size(); + writeMCNK(buf, terrain.chunks[idx], x, y); + mcnkSizes[idx] = static_cast(buf.size() - mcnkOffsets[idx]); + } + } + + // Patch MCIN with offsets and sizes + for (int i = 0; i < 256; i++) { + size_t entryOffset = mcinStart + 8 + i * 16; + uint32_t offset = static_cast(mcnkOffsets[i]); + uint32_t size = mcnkSizes[i]; + std::memcpy(buf.data() + entryOffset, &offset, 4); + std::memcpy(buf.data() + entryOffset + 4, &size, 4); + // flags and asyncId stay 0 + } + + return buf; +} + +bool ADTWriter::write(const pipeline::ADTTerrain& terrain, const std::string& path) { + auto data = serialize(terrain); + + auto dir = std::filesystem::path(path).parent_path(); + if (!dir.empty()) { + std::filesystem::create_directories(dir); + } + + std::ofstream file(path, std::ios::binary); + if (!file) { + LOG_ERROR("Failed to open file for writing: ", path); + return false; + } + + file.write(reinterpret_cast(data.data()), data.size()); + LOG_INFO("ADT written: ", path, " (", data.size(), " bytes)"); + return true; +} + +bool ADTWriter::writeWDT(const std::string& mapName, int tileX, int tileY, + const std::string& path) { + std::vector buf; + buf.reserve(32768); + + // MVER + writeChunkHeader(buf, MVER, 4); + writeU32(buf, 18); + + // MPHD (map header — 32 bytes, all zeros = no special flags) + writeChunkHeader(buf, 0x4D504844, 32); + for (int i = 0; i < 8; i++) writeU32(buf, 0); + + // MAIN (64×64 grid of 8-byte entries: flags + asyncId) + writeChunkHeader(buf, 0x4D41494E, 64 * 64 * 8); + for (int y = 0; y < 64; y++) { + for (int x = 0; x < 64; x++) { + if (x == tileX && y == tileY) { + writeU32(buf, 1); // FLAG_EXISTS + } else { + writeU32(buf, 0); + } + writeU32(buf, 0); // asyncId + } + } + + auto dir = std::filesystem::path(path).parent_path(); + if (!dir.empty()) std::filesystem::create_directories(dir); + + std::ofstream file(path, std::ios::binary); + if (!file) { + LOG_ERROR("Failed to write WDT: ", path); + return false; + } + file.write(reinterpret_cast(buf.data()), buf.size()); + LOG_INFO("WDT written: ", path, " (", buf.size(), " bytes, map=", mapName, ")"); + return true; +} + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/adt_writer.hpp b/tools/editor/adt_writer.hpp new file mode 100644 index 00000000..27f22149 --- /dev/null +++ b/tools/editor/adt_writer.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "pipeline/adt_loader.hpp" +#include +#include +#include + +namespace wowee { +namespace editor { + +class ADTWriter { +public: + static bool write(const pipeline::ADTTerrain& terrain, const std::string& path); + static std::vector serialize(const pipeline::ADTTerrain& terrain); + + // Write a minimal WDT file for a single-tile map + static bool writeWDT(const std::string& mapName, int tileX, int tileY, const std::string& path); + +private: + static void writeChunkHeader(std::vector& buf, uint32_t magic, uint32_t size); + static void writeU32(std::vector& buf, uint32_t val); + static void writeU16(std::vector& buf, uint16_t val); + static void writeFloat(std::vector& buf, float val); + static void writeBytes(std::vector& buf, const void* data, size_t size); + static void patchSize(std::vector& buf, size_t headerOffset); + + static void writeMVER(std::vector& buf); + static void writeMHDR(std::vector& buf, size_t& mhdrOffset); + static void writeMTEX(std::vector& buf, const pipeline::ADTTerrain& terrain); + static void writeMMDX(std::vector& buf, const pipeline::ADTTerrain& terrain); + static void writeMWMO(std::vector& buf, const pipeline::ADTTerrain& terrain); + static void writeMDDF(std::vector& buf, const pipeline::ADTTerrain& terrain); + static void writeMODF(std::vector& buf, const pipeline::ADTTerrain& terrain); + static void writeMCNK(std::vector& buf, const pipeline::MapChunk& chunk, int chunkX, int chunkY); +}; + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/asset_browser.cpp b/tools/editor/asset_browser.cpp new file mode 100644 index 00000000..338f1ec6 --- /dev/null +++ b/tools/editor/asset_browser.cpp @@ -0,0 +1,98 @@ +#include "asset_browser.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/asset_manifest.hpp" +#include "core/logger.hpp" +#include +#include + +namespace wowee { +namespace editor { + +std::string AssetBrowser::extractFilename(const std::string& path) { + auto pos = path.rfind('\\'); + return pos != std::string::npos ? path.substr(pos + 1) : path; +} + +std::string AssetBrowser::extractDirectory(const std::string& path) { + auto pos = path.rfind('\\'); + return pos != std::string::npos ? path.substr(0, pos) : ""; +} + +void AssetBrowser::initialize(pipeline::AssetManager* am) { + if (initialized_ || !am) return; + initialized_ = true; + + const auto& entries = am->getManifest().getEntries(); + + std::set texDirSet, m2DirSet, wmoDirSet; + + for (const auto& [path, entry] : entries) { + // Tileset textures + if (path.starts_with("tileset\\") && path.ends_with(".blp")) { + // Skip specular/normal maps + if (path.ends_with("_s.blp") || path.ends_with("_h.blp") || + path.ends_with("_n.blp")) continue; + + AssetEntry ae; + ae.wowPath = path; + ae.displayName = extractFilename(path); + ae.directory = extractDirectory(path); + textures_.push_back(ae); + texDirSet.insert(ae.directory); + } + + // M2 models (world doodads) + if (path.ends_with(".m2")) { + // Focus on world assets, skip character/creature/item models + if (path.starts_with("world\\") || path.starts_with("dungeons\\")) { + AssetEntry ae; + ae.wowPath = path; + ae.displayName = extractFilename(path); + ae.directory = extractDirectory(path); + m2Models_.push_back(ae); + m2DirSet.insert(ae.directory); + } + } + + // WMOs + if (path.ends_with(".wmo") && !path.ends_with("_lod.wmo")) { + // Skip group files (_000.wmo, _001.wmo, etc.) + bool isGroup = false; + if (path.size() > 8) { + auto base = path.substr(path.size() - 8); + if (base[0] == '_' && std::isdigit(base[1]) && std::isdigit(base[2]) && + std::isdigit(base[3])) + isGroup = true; + } + if (isGroup) continue; + + AssetEntry ae; + ae.wowPath = path; + ae.displayName = extractFilename(path); + ae.directory = extractDirectory(path); + wmos_.push_back(ae); + wmoDirSet.insert(ae.directory); + } + } + + std::sort(textures_.begin(), textures_.end(), + [](const AssetEntry& a, const AssetEntry& b) { return a.wowPath < b.wowPath; }); + std::sort(m2Models_.begin(), m2Models_.end(), + [](const AssetEntry& a, const AssetEntry& b) { return a.wowPath < b.wowPath; }); + std::sort(wmos_.begin(), wmos_.end(), + [](const AssetEntry& a, const AssetEntry& b) { return a.wowPath < b.wowPath; }); + + textureDirs_.assign(texDirSet.begin(), texDirSet.end()); + m2Dirs_.assign(m2DirSet.begin(), m2DirSet.end()); + wmoDirs_.assign(wmoDirSet.begin(), wmoDirSet.end()); + + std::sort(textureDirs_.begin(), textureDirs_.end()); + std::sort(m2Dirs_.begin(), m2Dirs_.end()); + std::sort(wmoDirs_.begin(), wmoDirs_.end()); + + LOG_INFO("Asset browser: ", textures_.size(), " textures, ", + m2Models_.size(), " M2s, ", wmos_.size(), " WMOs indexed"); +} + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/asset_browser.hpp b/tools/editor/asset_browser.hpp new file mode 100644 index 00000000..27591c3e --- /dev/null +++ b/tools/editor/asset_browser.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { class AssetManager; } + +namespace editor { + +struct AssetEntry { + std::string wowPath; + std::string displayName; + std::string directory; +}; + +class AssetBrowser { +public: + void initialize(pipeline::AssetManager* am); + + const std::vector& getTextures() const { return textures_; } + const std::vector& getM2Models() const { return m2Models_; } + const std::vector& getWMOs() const { return wmos_; } + + const std::vector& getTextureDirectories() const { return textureDirs_; } + const std::vector& getM2Directories() const { return m2Dirs_; } + const std::vector& getWMODirectories() const { return wmoDirs_; } + + bool isInitialized() const { return initialized_; } + +private: + static std::string extractFilename(const std::string& path); + static std::string extractDirectory(const std::string& path); + + std::vector textures_; + std::vector m2Models_; + std::vector wmos_; + std::vector textureDirs_; + std::vector m2Dirs_; + std::vector wmoDirs_; + bool initialized_ = false; +}; + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/editor_app.cpp b/tools/editor/editor_app.cpp new file mode 100644 index 00000000..d29c36ca --- /dev/null +++ b/tools/editor/editor_app.cpp @@ -0,0 +1,603 @@ +#include "editor_app.hpp" +#include "adt_writer.hpp" +#include "rendering/vk_context.hpp" +#include "pipeline/adt_loader.hpp" +#include "pipeline/terrain_mesh.hpp" +#include "core/logger.hpp" +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { + +EditorApp::EditorApp() = default; +EditorApp::~EditorApp() { shutdown(); } + +bool EditorApp::initialize(const std::string& dataPath) { + dataPath_ = dataPath; + + core::WindowConfig wc; + wc.title = "Wowee World Editor"; + wc.width = 1600; + wc.height = 900; + window_ = std::make_unique(wc); + if (!window_->initialize()) { + LOG_ERROR("Failed to initialize window"); + return false; + } + + assetManager_ = std::make_unique(); + if (!assetManager_->initialize(dataPath)) { + LOG_ERROR("Failed to initialize asset manager with path: ", dataPath); + return false; + } + + initImGui(); + + auto* vkCtx = window_->getVkContext(); + camera_.getCamera().setAspectRatio(window_->getAspectRatio()); + camera_.setPosition(glm::vec3(0.0f, 0.0f, 300.0f)); + camera_.setYawPitch(0.0f, -30.0f); + + if (!viewport_.initialize(vkCtx, assetManager_.get(), &camera_.getCamera())) { + LOG_ERROR("Failed to initialize editor viewport"); + return false; + } + + assetBrowser_.initialize(assetManager_.get()); + npcPresets_.initialize(assetManager_.get()); + + LOG_INFO("Editor initialized (data: ", dataPath, ")"); + return true; +} + +void EditorApp::run() { + auto lastTime = std::chrono::steady_clock::now(); + + while (!window_->shouldClose()) { + auto now = std::chrono::steady_clock::now(); + float dt = std::chrono::duration(now - lastTime).count(); + lastTime = now; + dt = std::min(dt, 0.1f); + + processEvents(); + + auto* vkCtx = window_->getVkContext(); + if (vkCtx->isSwapchainDirty()) { + int w = window_->getWidth(); + int h = window_->getHeight(); + if (w > 0 && h > 0) { + (void)vkCtx->recreateSwapchain(w, h); + camera_.getCamera().setAspectRatio(static_cast(w) / h); + } + } + + camera_.update(dt); + updateTerrainEditing(dt); + + // Handle pending UI actions + ui_.processActions(*this); + + // Refresh dirty terrain chunks + refreshDirtyChunks(); + + // Rebuild object visuals when object list changes + size_t objCount = objectPlacer_.objectCount() + npcSpawner_.spawnCount(); + if (objectsDirty_ || objCount != lastObjectCount_) { + objectsDirty_ = false; + lastObjectCount_ = objCount; + vkDeviceWaitIdle(window_->getVkContext()->getDevice()); + viewport_.rebuildObjects(objectPlacer_.getObjects(), npcSpawner_.getSpawns()); + } + + // Show gizmo arrows on selected object + auto& gizmo = viewport_.getGizmo(); + if (auto* sel = objectPlacer_.getSelected()) { + gizmo.setTarget(sel->position, sel->scale); + } else { + gizmo.setMode(TransformMode::None); + } + + uint32_t imageIndex = 0; + VkCommandBuffer cmd = vkCtx->beginFrame(imageIndex); + if (cmd == VK_NULL_HANDLE) continue; + + // Update M2 animations AFTER beginFrame (so getCurrentFrame is correct) + viewport_.update(dt); + + ImGui_ImplVulkan_NewFrame(); + ImGui_ImplSDL2_NewFrame(); + ImGui::NewFrame(); + + ui_.render(*this); + + ImGui::Render(); + + VkRenderPassBeginInfo rpInfo{}; + rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + rpInfo.renderPass = vkCtx->getImGuiRenderPass(); + rpInfo.framebuffer = vkCtx->getSwapchainFramebuffers()[imageIndex]; + rpInfo.renderArea.offset = {0, 0}; + rpInfo.renderArea.extent = vkCtx->getSwapchainExtent(); + + VkClearValue clearValues[4]{}; + clearValues[0].color = {{0.15f, 0.15f, 0.2f, 1.0f}}; + clearValues[1].depthStencil = {1.0f, 0}; + rpInfo.clearValueCount = 2; + rpInfo.pClearValues = clearValues; + + vkCmdBeginRenderPass(cmd, &rpInfo, VK_SUBPASS_CONTENTS_INLINE); + + auto ext = vkCtx->getSwapchainExtent(); + VkViewport vp{}; + vp.width = static_cast(ext.width); + vp.height = static_cast(ext.height); + vp.minDepth = 0.0f; + vp.maxDepth = 1.0f; + vkCmdSetViewport(cmd, 0, 1, &vp); + + VkRect2D scissor{}; + scissor.extent = ext; + vkCmdSetScissor(cmd, 0, 1, &scissor); + + viewport_.render(cmd); + ImGui_ImplVulkan_RenderDrawData(ImGui::GetDrawData(), cmd); + + vkCmdEndRenderPass(cmd); + vkCtx->endFrame(cmd, imageIndex); + } +} + +void EditorApp::shutdown() { + if (!window_) return; + auto* vkCtx = window_->getVkContext(); + if (vkCtx) vkDeviceWaitIdle(vkCtx->getDevice()); + + viewport_.shutdown(); + shutdownImGui(); + + if (assetManager_) { + assetManager_->shutdown(); + assetManager_.reset(); + } + window_.reset(); +} + +void EditorApp::processEvents() { + SDL_Event event; + while (SDL_PollEvent(&event)) { + ImGui_ImplSDL2_ProcessEvent(&event); + + if (event.type == SDL_QUIT) { + window_->setShouldClose(true); + return; + } + + if (event.type == SDL_WINDOWEVENT) { + if (event.window.event == SDL_WINDOWEVENT_RESIZED) { + window_->setSize(event.window.data1, event.window.data2); + window_->getVkContext()->markSwapchainDirty(); + } + } + + auto& io = ImGui::GetIO(); + + if (event.type == SDL_KEYDOWN || event.type == SDL_KEYUP) { + if (event.type == SDL_KEYDOWN) { + auto sc = event.key.keysym.scancode; + if (sc == SDL_SCANCODE_F3) setWireframe(!isWireframe()); + if (sc == SDL_SCANCODE_DELETE && mode_ == EditorMode::PlaceObject) { + objectPlacer_.deleteSelected(); + objectsDirty_ = true; + } + if (sc == SDL_SCANCODE_Z && (event.key.keysym.mod & KMOD_CTRL)) { + if (event.key.keysym.mod & KMOD_SHIFT) + terrainEditor_.redo(); + else + terrainEditor_.undo(); + } + } + if (!io.WantCaptureKeyboard) + camera_.processKeyEvent(event.key); + } + + if (event.type == SDL_MOUSEMOTION && !io.WantCaptureMouse) { + // Gizmo drag takes priority over camera + auto& giz = viewport_.getGizmo(); + if (giz.isDragging()) { + auto ext = window_->getVkContext()->getSwapchainExtent(); + giz.updateDrag(glm::vec2(static_cast(event.motion.x), + static_cast(event.motion.y)), + camera_.getCamera(), + static_cast(ext.width), + static_cast(ext.height)); + // Apply transform to selected object + if (auto* sel = objectPlacer_.getSelected()) { + if (giz.getMode() == TransformMode::Move) { + sel->position += giz.getMoveDelta(); + giz.beginDrag(glm::vec2(event.motion.x, event.motion.y)); + } else if (giz.getMode() == TransformMode::Rotate) { + sel->rotation += giz.getRotateDelta(); + giz.beginDrag(glm::vec2(event.motion.x, event.motion.y)); + } else if (giz.getMode() == TransformMode::Scale) { + sel->scale = std::max(0.1f, sel->scale + giz.getScaleDelta()); + giz.beginDrag(glm::vec2(event.motion.x, event.motion.y)); + } + giz.setTarget(sel->position, sel->scale); + objectsDirty_ = true; + } + } else { + camera_.processMouseMotion(event.motion.xrel, event.motion.yrel); + } + } + + if ((event.type == SDL_MOUSEBUTTONDOWN || event.type == SDL_MOUSEBUTTONUP) && !io.WantCaptureMouse) { + // Right-click context menu on selected objects + if (event.button.button == SDL_BUTTON_RIGHT && event.type == SDL_MOUSEBUTTONDOWN) { + auto& giz = viewport_.getGizmo(); + if (giz.isDragging()) { + giz.endDrag(); + giz.setMode(TransformMode::None); + } else if (objectPlacer_.getSelected()) { + ImGui::OpenPopup("ObjectContextMenu"); + } else { + camera_.processMouseButton(event.button); + } + } else { + camera_.processMouseButton(event.button); + } + + // Left click + if (event.button.button == SDL_BUTTON_LEFT && terrain_.isLoaded()) { + // End gizmo drag on click release + auto& giz = viewport_.getGizmo(); + if (giz.isDragging() && event.type == SDL_MOUSEBUTTONUP) { + giz.endDrag(); + giz.setMode(TransformMode::None); + } else if (event.type == SDL_MOUSEBUTTONDOWN) { + // Ctrl+click = select object (any mode) + if ((event.key.keysym.mod & KMOD_CTRL) || (SDL_GetModState() & KMOD_CTRL)) { + auto ext = window_->getVkContext()->getSwapchainExtent(); + rendering::Ray ray = camera_.getCamera().screenToWorldRay( + static_cast(event.button.x), + static_cast(event.button.y), + static_cast(ext.width), + static_cast(ext.height)); + objectPlacer_.selectAt(ray, 200.0f); + } else if (mode_ == EditorMode::NPC) { + auto ext = window_->getVkContext()->getSwapchainExtent(); + rendering::Ray ray = camera_.getCamera().screenToWorldRay( + static_cast(event.button.x), + static_cast(event.button.y), + static_cast(ext.width), + static_cast(ext.height)); + glm::vec3 hitPos; + if (terrainEditor_.raycastTerrain(ray, hitPos)) { + auto& tmpl = npcSpawner_.getTemplate(); + tmpl.position = hitPos; + npcSpawner_.placeCreature(tmpl); + objectsDirty_ = true; + } + } else if (mode_ == EditorMode::Water) { + painting_ = true; + } else if (mode_ == EditorMode::PlaceObject) { + // Raycast now at click time for accurate placement + auto ext = window_->getVkContext()->getSwapchainExtent(); + rendering::Ray ray = camera_.getCamera().screenToWorldRay( + static_cast(event.button.x), + static_cast(event.button.y), + static_cast(ext.width), + static_cast(ext.height)); + glm::vec3 hitPos; + if (terrainEditor_.raycastTerrain(ray, hitPos)) { + objectPlacer_.placeObject(hitPos); + objectsDirty_ = true; + } + } else { + painting_ = true; + if (mode_ == EditorMode::Sculpt) + terrainEditor_.beginStroke(); + } + } else if (event.type == SDL_MOUSEBUTTONUP) { + painting_ = false; + if (mode_ == EditorMode::Sculpt) + terrainEditor_.endStroke(); + } + } + + // Middle click = select object + if (event.button.button == SDL_BUTTON_MIDDLE && event.type == SDL_MOUSEBUTTONDOWN) { + if (mode_ == EditorMode::PlaceObject && terrain_.isLoaded()) { + auto ext = window_->getVkContext()->getSwapchainExtent(); + auto& io2 = ImGui::GetIO(); + rendering::Ray ray = camera_.getCamera().screenToWorldRay( + io2.MousePos.x, io2.MousePos.y, + static_cast(ext.width), static_cast(ext.height)); + objectPlacer_.selectAt(ray); + } + } + } + + if (event.type == SDL_MOUSEWHEEL && !io.WantCaptureMouse) + camera_.processMouseWheel(event.wheel.y); + } +} + +void EditorApp::updateTerrainEditing(float dt) { + if (!terrain_.isLoaded()) return; + + // Update brush position from mouse cursor + auto& io = ImGui::GetIO(); + if (!io.WantCaptureMouse) { + float mx = io.MousePos.x; + float my = io.MousePos.y; + auto ext = window_->getVkContext()->getSwapchainExtent(); + + rendering::Ray ray = camera_.getCamera().screenToWorldRay( + mx, my, static_cast(ext.width), static_cast(ext.height)); + + glm::vec3 hitPos; + if (terrainEditor_.raycastTerrain(ray, hitPos)) { + terrainEditor_.brush().setPosition(hitPos); + terrainEditor_.brush().setActive(true); + + // Ghost preview for object placement + if (mode_ == EditorMode::PlaceObject && !objectPlacer_.getActivePath().empty()) { + viewport_.setGhostPreview( + objectPlacer_.getActivePath(), hitPos, + glm::vec3(0, objectPlacer_.getPlacementRotationY(), 0), + objectPlacer_.getPlacementScale()); + } else if (mode_ != EditorMode::PlaceObject) { + viewport_.clearGhostPreview(); + } + + if (painting_ && terrainEditor_.brush().settings().mode == BrushMode::Flatten) { + static bool flattenSet = false; + if (!flattenSet) { + terrainEditor_.brush().settings().flattenHeight = hitPos.z; + flattenSet = true; + } + if (!io.MouseDown[0]) flattenSet = false; + } + } else { + terrainEditor_.brush().setActive(false); + viewport_.clearGhostPreview(); + } + } + + if (painting_ && terrainEditor_.brush().isActive()) { + if (mode_ == EditorMode::Sculpt) { + terrainEditor_.applyBrush(dt); + } else if (mode_ == EditorMode::Paint) { + auto& brush = terrainEditor_.brush(); + auto paintMode = ui_.getPaintMode(); + std::vector modified; + + if (paintMode == PaintMode::Erase) { + modified = texturePainter_.erase( + brush.getPosition(), brush.settings().radius, + brush.settings().strength * dt * 0.5f, brush.settings().falloff); + } else if (paintMode == PaintMode::ReplaceBase) { + // Replace base texture of chunks under brush + auto& texPath = texturePainter_.getActiveTexture(); + if (!texPath.empty()) { + // Ensure texture is in list + uint32_t texId = 0; + for (uint32_t i = 0; i < terrain_.textures.size(); i++) { + if (terrain_.textures[i] == texPath) { texId = i; goto found; } + } + terrain_.textures.push_back(texPath); + texId = static_cast(terrain_.textures.size() - 1); + found: + for (int ci = 0; ci < 256; ci++) { + auto& chunk = terrain_.chunks[ci]; + if (!chunk.hasHeightMap() || chunk.layers.empty()) continue; + glm::vec3 cpos = terrainEditor_.brush().getPosition(); + // Rough distance check + auto vpos = glm::vec3(chunk.position[1], chunk.position[0], chunk.position[2]); + if (glm::length(glm::vec2(vpos.x - cpos.x, vpos.y - cpos.y)) < brush.settings().radius + 40.0f) { + chunk.layers[0].textureId = texId; + modified.push_back(ci); + } + } + } + } else { + modified = texturePainter_.paint( + brush.getPosition(), brush.settings().radius, + brush.settings().strength * dt * 0.5f, brush.settings().falloff); + } + + if (!modified.empty()) { + auto mesh = terrainEditor_.regenerateMesh(); + viewport_.clearTerrain(); + viewport_.loadTerrain(mesh, terrain_.textures, loadedTileX_, loadedTileY_); + } + } else if (mode_ == EditorMode::Water) { + auto& brush = terrainEditor_.brush(); + terrainEditor_.setWaterLevel(brush.getPosition(), brush.settings().radius, + waterHeight_, waterType_); + viewport_.updateWater(terrain_, loadedTileX_, loadedTileY_); + } + } +} + +void EditorApp::refreshDirtyChunks() { + auto dirty = terrainEditor_.consumeDirtyChunks(); + if (dirty.empty()) return; + + // Regenerate full mesh and reload terrain + auto mesh = terrainEditor_.regenerateMesh(); + viewport_.clearTerrain(); + viewport_.loadTerrain(mesh, terrain_.textures, loadedTileX_, loadedTileY_); +} + +void EditorApp::loadADT(const std::string& mapName, int tileX, int tileY) { + std::ostringstream path; + path << "World\\Maps\\" << mapName << "\\" << mapName + << "_" << tileX << "_" << tileY << ".adt"; + + LOG_INFO("Loading ADT: ", path.str()); + + auto adtData = assetManager_->readFile(path.str()); + if (adtData.empty()) { + LOG_ERROR("ADT file not found: ", path.str()); + return; + } + + terrain_ = pipeline::ADTLoader::load(adtData); + if (!terrain_.isLoaded()) { + LOG_ERROR("Failed to parse ADT: ", path.str()); + return; + } + + terrainEditor_.setTerrain(&terrain_); + terrainEditor_.history().clear(); + texturePainter_.setTerrain(&terrain_); + objectPlacer_.setTerrain(&terrain_); + + auto mesh = pipeline::TerrainMeshGenerator::generate(terrain_); + viewport_.clearTerrain(); + if (!viewport_.loadTerrain(mesh, terrain_.textures, tileX, tileY)) { + LOG_ERROR("Failed to upload terrain to GPU"); + return; + } + + loadedMap_ = mapName; + loadedTileX_ = tileX; + loadedTileY_ = tileY; + + float centerX = (32.0f - tileY) * 533.33333f - 8.0f * 533.33333f / 16.0f; + float centerY = (32.0f - tileX) * 533.33333f - 8.0f * 533.33333f / 16.0f; + camera_.setPosition(glm::vec3(centerX, centerY, 400.0f)); + camera_.setYawPitch(0.0f, -45.0f); + + LOG_INFO("ADT loaded: ", mapName, " [", tileX, ",", tileY, "]"); +} + +void EditorApp::createNewTerrain(const std::string& mapName, int tileX, int tileY, float baseHeight, Biome biome) { + terrain_ = TerrainEditor::createBlankTerrain(tileX, tileY, baseHeight, biome); + terrainEditor_.setTerrain(&terrain_); + terrainEditor_.history().clear(); + texturePainter_.setTerrain(&terrain_); + objectPlacer_.setTerrain(&terrain_); + + auto mesh = pipeline::TerrainMeshGenerator::generate(terrain_); + viewport_.clearTerrain(); + viewport_.loadTerrain(mesh, terrain_.textures, tileX, tileY); + + loadedMap_ = mapName; + loadedTileX_ = tileX; + loadedTileY_ = tileY; + + float centerX = (32.0f - tileY) * 533.33333f - 8.0f * 533.33333f / 16.0f; + float centerY = (32.0f - tileX) * 533.33333f - 8.0f * 533.33333f / 16.0f; + camera_.setPosition(glm::vec3(centerX, centerY, baseHeight + 300.0f)); + camera_.setYawPitch(0.0f, -45.0f); + + LOG_INFO("New terrain created: ", mapName, " [", tileX, ",", tileY, "] base=", baseHeight); +} + +void EditorApp::saveADT(const std::string& path) { + if (!terrain_.isLoaded()) { + LOG_ERROR("No terrain to save"); + return; + } + objectPlacer_.syncToTerrain(); + ADTWriter::write(terrain_, path); + terrainEditor_.markSaved(); +} + +void EditorApp::saveWDT(const std::string& path) { + if (loadedMap_.empty()) return; + ADTWriter::writeWDT(loadedMap_, loadedTileX_, loadedTileY_, path); +} + +void EditorApp::requestQuit() { + window_->setShouldClose(true); +} + +void EditorApp::startGizmoMode(TransformMode mode) { + auto& giz = viewport_.getGizmo(); + giz.setMode(mode); + auto& io = ImGui::GetIO(); + giz.beginDrag(glm::vec2(io.MousePos.x, io.MousePos.y)); +} + +void EditorApp::setGizmoAxis(TransformAxis axis) { + viewport_.getGizmo().setAxis(axis); + if (auto* sel = objectPlacer_.getSelected()) + viewport_.getGizmo().setTarget(sel->position, sel->scale); +} + +void EditorApp::resetCamera() { + camera_.setPosition(glm::vec3(0.0f, 0.0f, 300.0f)); + camera_.setYawPitch(0.0f, -30.0f); +} + +void EditorApp::setWireframe(bool enabled) { + viewport_.setWireframe(enabled); +} + +bool EditorApp::isWireframe() const { + return viewport_.isWireframe(); +} + +rendering::TerrainRenderer* EditorApp::getTerrainRenderer() { + return viewport_.getTerrainRenderer(); +} + +void EditorApp::initImGui() { + auto* vkCtx = window_->getVkContext(); + + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGuiIO& io = ImGui::GetIO(); + io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; + + ImGui::StyleColorsDark(); + ImGuiStyle& style = ImGui::GetStyle(); + style.WindowRounding = 4.0f; + style.FrameRounding = 2.0f; + style.GrabRounding = 2.0f; + + ImVec4* colors = style.Colors; + colors[ImGuiCol_WindowBg] = ImVec4(0.12f, 0.12f, 0.14f, 0.95f); + colors[ImGuiCol_TitleBg] = ImVec4(0.10f, 0.10f, 0.12f, 1.00f); + colors[ImGuiCol_TitleBgActive] = ImVec4(0.18f, 0.18f, 0.25f, 1.00f); + colors[ImGuiCol_MenuBarBg] = ImVec4(0.14f, 0.14f, 0.18f, 1.00f); + colors[ImGuiCol_Button] = ImVec4(0.24f, 0.28f, 0.40f, 1.00f); + colors[ImGuiCol_ButtonHovered] = ImVec4(0.30f, 0.35f, 0.50f, 1.00f); + colors[ImGuiCol_ButtonActive] = ImVec4(0.20f, 0.24f, 0.36f, 1.00f); + + ImGui_ImplSDL2_InitForVulkan(window_->getSDLWindow()); + + ImGui_ImplVulkan_InitInfo initInfo{}; + initInfo.ApiVersion = VK_API_VERSION_1_1; + initInfo.Instance = vkCtx->getInstance(); + initInfo.PhysicalDevice = vkCtx->getPhysicalDevice(); + initInfo.Device = vkCtx->getDevice(); + initInfo.QueueFamily = vkCtx->getGraphicsQueueFamily(); + initInfo.Queue = vkCtx->getGraphicsQueue(); + initInfo.DescriptorPool = vkCtx->getImGuiDescriptorPool(); + initInfo.MinImageCount = 2; + initInfo.ImageCount = vkCtx->getSwapchainImageCount(); + initInfo.PipelineInfoMain.RenderPass = vkCtx->getImGuiRenderPass(); + initInfo.PipelineInfoMain.MSAASamples = vkCtx->getMsaaSamples(); + + ImGui_ImplVulkan_Init(&initInfo); + imguiInitialized_ = true; +} + +void EditorApp::shutdownImGui() { + if (!imguiInitialized_) return; + ImGui_ImplVulkan_Shutdown(); + ImGui_ImplSDL2_Shutdown(); + ImGui::DestroyContext(); + imguiInitialized_ = false; +} + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/editor_app.hpp b/tools/editor/editor_app.hpp new file mode 100644 index 00000000..51bd1803 --- /dev/null +++ b/tools/editor/editor_app.hpp @@ -0,0 +1,107 @@ +#pragma once + +#include "editor_camera.hpp" +#include "editor_viewport.hpp" +#include "editor_ui.hpp" +#include "terrain_editor.hpp" +#include "texture_painter.hpp" +#include "object_placer.hpp" +#include "npc_spawner.hpp" +#include "npc_presets.hpp" +#include "asset_browser.hpp" +#include "core/window.hpp" +#include "pipeline/asset_manager.hpp" +#include +#include + +namespace wowee { +namespace editor { + +enum class EditorMode { Sculpt, Paint, PlaceObject, Water, NPC }; + +class EditorApp { +public: + EditorApp(); + ~EditorApp(); + + bool initialize(const std::string& dataPath); + void run(); + void shutdown(); + + void loadADT(const std::string& mapName, int tileX, int tileY); + void createNewTerrain(const std::string& mapName, int tileX, int tileY, float baseHeight, Biome biome); + void saveADT(const std::string& path); + void saveWDT(const std::string& path); + + void requestQuit(); + void resetCamera(); + void setWireframe(bool enabled); + bool isWireframe() const; + + EditorCamera& getEditorCamera() { return camera_; } + TerrainEditor& getTerrainEditor() { return terrainEditor_; } + TexturePainter& getTexturePainter() { return texturePainter_; } + ObjectPlacer& getObjectPlacer() { return objectPlacer_; } + NpcSpawner& getNpcSpawner() { return npcSpawner_; } + NpcPresets& getNpcPresets() { return npcPresets_; } + AssetBrowser& getAssetBrowser() { return assetBrowser_; } + rendering::TerrainRenderer* getTerrainRenderer(); + pipeline::AssetManager* getAssetManager() { return assetManager_.get(); } + + const std::string& getLoadedMap() const { return loadedMap_; } + int getLoadedTileX() const { return loadedTileX_; } + int getLoadedTileY() const { return loadedTileY_; } + bool hasTerrainLoaded() const { return terrain_.isLoaded(); } + + core::Window* getWindow() { return window_.get(); } + + EditorMode getMode() const { return mode_; } + void setMode(EditorMode m) { mode_ = m; } + void markObjectsDirty() { objectsDirty_ = true; } + + void startGizmoMode(TransformMode mode); + void setGizmoAxis(TransformAxis axis); + TransformGizmo& getGizmo() { return viewport_.getGizmo(); } + + float getWaterHeight() const { return waterHeight_; } + void setWaterHeight(float h) { waterHeight_ = h; } + uint16_t getWaterType() const { return waterType_; } + void setWaterType(uint16_t t) { waterType_ = t; } + +private: + void processEvents(); + void updateTerrainEditing(float dt); + void refreshDirtyChunks(); + void initImGui(); + void shutdownImGui(); + + std::unique_ptr window_; + std::unique_ptr assetManager_; + EditorCamera camera_; + EditorViewport viewport_; + EditorUI ui_; + TerrainEditor terrainEditor_; + TexturePainter texturePainter_; + ObjectPlacer objectPlacer_; + NpcSpawner npcSpawner_; + NpcPresets npcPresets_; + AssetBrowser assetBrowser_; + + pipeline::ADTTerrain terrain_; + + bool imguiInitialized_ = false; + bool painting_ = false; + bool objectsDirty_ = false; + size_t lastObjectCount_ = 0; + EditorMode mode_ = EditorMode::Sculpt; + float waterHeight_ = 100.0f; + uint16_t waterType_ = 0; + std::string dataPath_; + + std::string loadedMap_; + int loadedTileX_ = -1; + int loadedTileY_ = -1; +}; + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/editor_brush.cpp b/tools/editor/editor_brush.cpp new file mode 100644 index 00000000..43414778 --- /dev/null +++ b/tools/editor/editor_brush.cpp @@ -0,0 +1,20 @@ +#include "editor_brush.hpp" +#include +#include + +namespace wowee { +namespace editor { + +float EditorBrush::getInfluence(float distance) const { + if (distance >= settings_.radius) return 0.0f; + + float t = distance / settings_.radius; + float innerRadius = 1.0f - settings_.falloff; + if (t <= innerRadius) return 1.0f; + + float falloffT = (t - innerRadius) / settings_.falloff; + return 1.0f - (falloffT * falloffT); +} + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/editor_brush.hpp b/tools/editor/editor_brush.hpp new file mode 100644 index 00000000..38f273d7 --- /dev/null +++ b/tools/editor/editor_brush.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include + +namespace wowee { +namespace editor { + +enum class BrushMode { + Raise, + Lower, + Smooth, + Flatten, + Level +}; + +struct BrushSettings { + BrushMode mode = BrushMode::Raise; + float radius = 30.0f; + float strength = 5.0f; + float falloff = 0.5f; // 0 = hard edge, 1 = full falloff + float flattenHeight = 0.0f; +}; + +class EditorBrush { +public: + BrushSettings& settings() { return settings_; } + const BrushSettings& settings() const { return settings_; } + + bool isActive() const { return active_; } + void setActive(bool a) { active_ = a; } + + const glm::vec3& getPosition() const { return worldPos_; } + void setPosition(const glm::vec3& pos) { worldPos_ = pos; } + + float getInfluence(float distance) const; + +private: + BrushSettings settings_; + glm::vec3 worldPos_{0.0f}; + bool active_ = false; +}; + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/editor_camera.cpp b/tools/editor/editor_camera.cpp new file mode 100644 index 00000000..2a1aeeca --- /dev/null +++ b/tools/editor/editor_camera.cpp @@ -0,0 +1,81 @@ +#include "editor_camera.hpp" +#include +#include + +namespace wowee { +namespace editor { + +EditorCamera::EditorCamera() { + camera_.setPosition(glm::vec3(0.0f, 0.0f, 200.0f)); + camera_.setFov(60.0f); + camera_.setRotation(0.0f, -30.0f); + yaw_ = 0.0f; + pitch_ = -30.0f; +} + +void EditorCamera::update(float deltaTime) { + float moveSpeed = speed_ * deltaTime; + if (keyShift_) moveSpeed *= 3.0f; + + glm::vec3 forward = camera_.getForward(); + glm::vec3 right = camera_.getRight(); + glm::vec3 up(0.0f, 0.0f, 1.0f); // Z-up (WoW render coords) + + glm::vec3 pos = camera_.getPosition(); + if (keyW_) pos += forward * moveSpeed; + if (keyS_) pos -= forward * moveSpeed; + if (keyD_) pos += right * moveSpeed; + if (keyA_) pos -= right * moveSpeed; + if (keyE_) pos += up * moveSpeed; + if (keyQ_) pos -= up * moveSpeed; + + camera_.setPosition(pos); +} + +void EditorCamera::processMouseMotion(int dx, int dy) { + if (!rightMouseDown_) return; + + constexpr float sensitivity = 0.15f; // degrees per pixel + yaw_ += static_cast(dx) * sensitivity; + pitch_ -= static_cast(dy) * sensitivity; + pitch_ = std::clamp(pitch_, -89.0f, 89.0f); + + camera_.setRotation(yaw_, pitch_); +} + +void EditorCamera::processMouseWheel(float delta) { + speed_ = std::clamp(speed_ + delta * 20.0f, 10.0f, 2000.0f); +} + +void EditorCamera::processKeyEvent(const SDL_KeyboardEvent& event) { + bool pressed = (event.type == SDL_KEYDOWN); + switch (event.keysym.scancode) { + case SDL_SCANCODE_W: keyW_ = pressed; break; + case SDL_SCANCODE_A: keyA_ = pressed; break; + case SDL_SCANCODE_S: keyS_ = pressed; break; + case SDL_SCANCODE_D: keyD_ = pressed; break; + case SDL_SCANCODE_Q: keyQ_ = pressed; break; + case SDL_SCANCODE_E: keyE_ = pressed; break; + case SDL_SCANCODE_LSHIFT: + case SDL_SCANCODE_RSHIFT: keyShift_ = pressed; break; + default: break; + } +} + +void EditorCamera::processMouseButton(const SDL_MouseButtonEvent& event) { + if (event.button == SDL_BUTTON_RIGHT) + rightMouseDown_ = (event.type == SDL_MOUSEBUTTONDOWN); +} + +void EditorCamera::setPosition(const glm::vec3& pos) { + camera_.setPosition(pos); +} + +void EditorCamera::setYawPitch(float yaw, float pitch) { + yaw_ = yaw; + pitch_ = pitch; + camera_.setRotation(yaw_, pitch_); +} + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/editor_camera.hpp b/tools/editor/editor_camera.hpp new file mode 100644 index 00000000..23d33c1f --- /dev/null +++ b/tools/editor/editor_camera.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include "rendering/camera.hpp" +#include +#include + +namespace wowee { +namespace editor { + +class EditorCamera { +public: + EditorCamera(); + + void update(float deltaTime); + void processMouseMotion(int dx, int dy); + void processMouseWheel(float delta); + void processKeyEvent(const SDL_KeyboardEvent& event); + void processMouseButton(const SDL_MouseButtonEvent& event); + + rendering::Camera& getCamera() { return camera_; } + const rendering::Camera& getCamera() const { return camera_; } + + float getSpeed() const { return speed_; } + void setSpeed(float s) { speed_ = s; } + void setPosition(const glm::vec3& pos); + void setYawPitch(float yaw, float pitch); + +private: + rendering::Camera camera_; + float speed_ = 100.0f; + float yaw_ = 0.0f; + float pitch_ = 0.0f; + bool keyW_ = false, keyA_ = false, keyS_ = false, keyD_ = false; + bool keyQ_ = false, keyE_ = false, keyShift_ = false; + bool rightMouseDown_ = false; +}; + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/editor_history.cpp b/tools/editor/editor_history.cpp new file mode 100644 index 00000000..52821d85 --- /dev/null +++ b/tools/editor/editor_history.cpp @@ -0,0 +1,83 @@ +#include "editor_history.hpp" + +namespace wowee { +namespace editor { + +void EditorHistory::beginEdit(const pipeline::ADTTerrain& terrain, + const std::vector& affectedChunks) { + pending_ = {}; + pending_.before.reserve(affectedChunks.size()); + for (int idx : affectedChunks) { + ChunkSnapshot snap; + snap.chunkIndex = idx; + snap.heights = terrain.chunks[idx].heightMap.heights; + pending_.before.push_back(snap); + } +} + +void EditorHistory::endEdit(const pipeline::ADTTerrain& terrain) { + pending_.after.reserve(pending_.before.size()); + lastAffected_.clear(); + for (const auto& snap : pending_.before) { + ChunkSnapshot after; + after.chunkIndex = snap.chunkIndex; + after.heights = terrain.chunks[snap.chunkIndex].heightMap.heights; + pending_.after.push_back(after); + lastAffected_.push_back(snap.chunkIndex); + } + + // Only push if something actually changed + bool changed = false; + for (size_t i = 0; i < pending_.before.size(); i++) { + if (pending_.before[i].heights != pending_.after[i].heights) { + changed = true; + break; + } + } + if (!changed) return; + + undoStack_.push_back(std::move(pending_)); + redoStack_.clear(); + + if (undoStack_.size() > MAX_UNDO) + undoStack_.erase(undoStack_.begin()); +} + +void EditorHistory::undo(pipeline::ADTTerrain& terrain) { + if (undoStack_.empty()) return; + + auto cmd = std::move(undoStack_.back()); + undoStack_.pop_back(); + + lastAffected_.clear(); + for (const auto& snap : cmd.before) { + terrain.chunks[snap.chunkIndex].heightMap.heights = snap.heights; + lastAffected_.push_back(snap.chunkIndex); + } + + redoStack_.push_back(std::move(cmd)); +} + +void EditorHistory::redo(pipeline::ADTTerrain& terrain) { + if (redoStack_.empty()) return; + + auto cmd = std::move(redoStack_.back()); + redoStack_.pop_back(); + + lastAffected_.clear(); + for (const auto& snap : cmd.after) { + terrain.chunks[snap.chunkIndex].heightMap.heights = snap.heights; + lastAffected_.push_back(snap.chunkIndex); + } + + undoStack_.push_back(std::move(cmd)); +} + +void EditorHistory::clear() { + undoStack_.clear(); + redoStack_.clear(); + lastAffected_.clear(); +} + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/editor_history.hpp b/tools/editor/editor_history.hpp new file mode 100644 index 00000000..d96f9518 --- /dev/null +++ b/tools/editor/editor_history.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "pipeline/adt_loader.hpp" +#include +#include +#include + +namespace wowee { +namespace editor { + +struct ChunkSnapshot { + int chunkIndex; + std::array heights; +}; + +struct EditCommand { + std::vector before; + std::vector after; +}; + +class EditorHistory { +public: + void beginEdit(const pipeline::ADTTerrain& terrain, const std::vector& affectedChunks); + void endEdit(const pipeline::ADTTerrain& terrain); + + bool canUndo() const { return !undoStack_.empty(); } + bool canRedo() const { return !redoStack_.empty(); } + + void undo(pipeline::ADTTerrain& terrain); + void redo(pipeline::ADTTerrain& terrain); + + void clear(); + + size_t undoCount() const { return undoStack_.size(); } + size_t redoCount() const { return redoStack_.size(); } + + const std::vector& lastAffectedChunks() const { return lastAffected_; } + +private: + std::vector undoStack_; + std::vector redoStack_; + EditCommand pending_; + std::vector lastAffected_; + static constexpr size_t MAX_UNDO = 100; +}; + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/editor_markers.cpp b/tools/editor/editor_markers.cpp new file mode 100644 index 00000000..c5dd2dc4 --- /dev/null +++ b/tools/editor/editor_markers.cpp @@ -0,0 +1,214 @@ +#include "editor_markers.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_shader.hpp" +#include "core/logger.hpp" +#include +#include + +namespace wowee { +namespace editor { + +EditorMarkers::EditorMarkers() = default; +EditorMarkers::~EditorMarkers() { shutdown(); } + +bool EditorMarkers::initialize(rendering::VkContext* ctx, VkRenderPass renderPass, + VkDescriptorSetLayout perFrameLayout) { + vkCtx_ = ctx; + renderPass_ = renderPass; + perFrameLayout_ = perFrameLayout; + return createPipeline(); +} + +void EditorMarkers::shutdown() { + if (!vkCtx_) return; + VkDevice dev = vkCtx_->getDevice(); + clear(); + if (pipeline_) { vkDestroyPipeline(dev, pipeline_, nullptr); pipeline_ = VK_NULL_HANDLE; } + if (pipelineLayout_) { vkDestroyPipelineLayout(dev, pipelineLayout_, nullptr); pipelineLayout_ = VK_NULL_HANDLE; } + vkCtx_ = nullptr; +} + +void EditorMarkers::clear() { + if (vertexBuffer_) { + vmaDestroyBuffer(vkCtx_->getAllocator(), vertexBuffer_, vertexAlloc_); + vertexBuffer_ = VK_NULL_HANDLE; + vertexCount_ = 0; + } +} + +void EditorMarkers::update(const std::vector& objects) { + clear(); + if (objects.empty()) return; + + std::vector verts; + + for (const auto& obj : objects) { + float s = 3.0f * obj.scale; + float x = obj.position.x; + float y = obj.position.y; + float z = obj.position.z; + + // Color: M2 = green, WMO = orange, selected = yellow + float r, g, b, a = 0.9f; + if (obj.selected) { r = 1.0f; g = 1.0f; b = 0.2f; } + else if (obj.type == PlaceableType::M2) { r = 0.2f; g = 0.8f; b = 0.3f; } + else { r = 0.9f; g = 0.5f; b = 0.1f; } + + // Diamond / octahedron marker + MarkerVertex top, bot, n, s2, e, w; + top.pos[0] = x; top.pos[1] = y; top.pos[2] = z + s * 2; + bot.pos[0] = x; bot.pos[1] = y; bot.pos[2] = z; + n.pos[0] = x; n.pos[1] = y + s; n.pos[2] = z + s; + s2.pos[0] = x; s2.pos[1] = y - s; s2.pos[2] = z + s; + e.pos[0] = x + s; e.pos[1] = y; e.pos[2] = z + s; + w.pos[0] = x - s; w.pos[1] = y; w.pos[2] = z + s; + + auto setCol = [&](MarkerVertex& v, float br) { + v.color[0] = r * br; v.color[1] = g * br; v.color[2] = b * br; v.color[3] = a; + }; + setCol(top, 1.0f); setCol(bot, 0.6f); + setCol(n, 0.9f); setCol(s2, 0.8f); setCol(e, 0.85f); setCol(w, 0.75f); + + // Top 4 triangles + verts.push_back(top); verts.push_back(n); verts.push_back(e); + verts.push_back(top); verts.push_back(e); verts.push_back(s2); + verts.push_back(top); verts.push_back(s2); verts.push_back(w); + verts.push_back(top); verts.push_back(w); verts.push_back(n); + // Bottom 4 triangles + verts.push_back(bot); verts.push_back(e); verts.push_back(n); + verts.push_back(bot); verts.push_back(s2); verts.push_back(e); + verts.push_back(bot); verts.push_back(w); verts.push_back(s2); + verts.push_back(bot); verts.push_back(n); verts.push_back(w); + } + + vertexCount_ = static_cast(verts.size()); + + VkBufferCreateInfo bufInfo{}; + bufInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufInfo.size = verts.size() * sizeof(MarkerVertex); + bufInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT; + + VmaAllocationCreateInfo allocInfo{}; + allocInfo.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + allocInfo.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + + VmaAllocationInfo mapInfo{}; + if (vmaCreateBuffer(vkCtx_->getAllocator(), &bufInfo, &allocInfo, + &vertexBuffer_, &vertexAlloc_, &mapInfo) != VK_SUCCESS) { + LOG_ERROR("Failed to create marker vertex buffer"); + return; + } + std::memcpy(mapInfo.pMappedData, verts.data(), verts.size() * sizeof(MarkerVertex)); +} + +void EditorMarkers::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { + if (!vertexBuffer_ || vertexCount_ == 0 || !pipeline_) return; + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_, + 0, 1, &perFrameSet, 0, nullptr); + + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &vertexBuffer_, &offset); + vkCmdDraw(cmd, vertexCount_, 1, 0, 0); +} + +bool EditorMarkers::createPipeline() { + VkDevice dev = vkCtx_->getDevice(); + + VkPipelineLayoutCreateInfo layoutInfo{}; + layoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + layoutInfo.setLayoutCount = 1; + layoutInfo.pSetLayouts = &perFrameLayout_; + if (vkCreatePipelineLayout(dev, &layoutInfo, nullptr, &pipelineLayout_) != VK_SUCCESS) + return false; + + rendering::VkShaderModule vertMod, fragMod; + if (!vertMod.loadFromFile(dev, "assets/shaders/editor_water.vert.spv") || + !fragMod.loadFromFile(dev, "assets/shaders/editor_water.frag.spv")) { + LOG_WARNING("Marker shaders not found — markers disabled"); + return true; + } + + VkPipelineShaderStageCreateInfo stages[2]{}; + stages[0] = vertMod.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + stages[1] = fragMod.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + VkVertexInputBindingDescription binding{}; + binding.stride = sizeof(MarkerVertex); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + VkVertexInputAttributeDescription attrs[2]{}; + attrs[0].location = 0; attrs[0].format = VK_FORMAT_R32G32B32_SFLOAT; attrs[0].offset = 0; + attrs[1].location = 1; attrs[1].format = VK_FORMAT_R32G32B32A32_SFLOAT; attrs[1].offset = 12; + + VkPipelineVertexInputStateCreateInfo vertexInput{}; + vertexInput.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO; + vertexInput.vertexBindingDescriptionCount = 1; + vertexInput.pVertexBindingDescriptions = &binding; + vertexInput.vertexAttributeDescriptionCount = 2; + vertexInput.pVertexAttributeDescriptions = attrs; + + VkPipelineInputAssemblyStateCreateInfo ia{}; + ia.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO; + ia.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; + + VkPipelineViewportStateCreateInfo vps{}; + vps.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO; + vps.viewportCount = 1; vps.scissorCount = 1; + + VkPipelineRasterizationStateCreateInfo rast{}; + rast.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO; + rast.polygonMode = VK_POLYGON_MODE_FILL; + rast.cullMode = VK_CULL_MODE_BACK_BIT; + rast.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; + rast.lineWidth = 1.0f; + + VkPipelineMultisampleStateCreateInfo ms{}; + ms.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO; + ms.rasterizationSamples = vkCtx_->getMsaaSamples(); + + VkPipelineDepthStencilStateCreateInfo ds{}; + ds.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO; + ds.depthTestEnable = VK_TRUE; + ds.depthWriteEnable = VK_TRUE; + ds.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL; + + VkPipelineColorBlendAttachmentState blend{}; + blend.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | + VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + blend.blendEnable = VK_FALSE; + + VkPipelineColorBlendStateCreateInfo cb{}; + cb.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO; + cb.attachmentCount = 1; cb.pAttachments = &blend; + + VkDynamicState dynStates[] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}; + VkPipelineDynamicStateCreateInfo dyn{}; + dyn.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO; + dyn.dynamicStateCount = 2; dyn.pDynamicStates = dynStates; + + VkGraphicsPipelineCreateInfo pci{}; + pci.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO; + pci.stageCount = 2; pci.pStages = stages; + pci.pVertexInputState = &vertexInput; + pci.pInputAssemblyState = &ia; + pci.pViewportState = &vps; + pci.pRasterizationState = &rast; + pci.pMultisampleState = &ms; + pci.pDepthStencilState = &ds; + pci.pColorBlendState = &cb; + pci.pDynamicState = &dyn; + pci.layout = pipelineLayout_; + pci.renderPass = renderPass_; + + if (vkCreateGraphicsPipelines(dev, vkCtx_->getPipelineCache(), 1, &pci, nullptr, &pipeline_) != VK_SUCCESS) { + LOG_ERROR("Failed to create marker pipeline"); + pipeline_ = VK_NULL_HANDLE; + } + + return true; +} + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/editor_markers.hpp b/tools/editor/editor_markers.hpp new file mode 100644 index 00000000..a856bceb --- /dev/null +++ b/tools/editor/editor_markers.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "object_placer.hpp" +#include +#include +#include + +namespace wowee { +namespace rendering { class VkContext; } + +namespace editor { + +class EditorMarkers { +public: + EditorMarkers(); + ~EditorMarkers(); + + bool initialize(rendering::VkContext* ctx, VkRenderPass renderPass, + VkDescriptorSetLayout perFrameLayout); + void shutdown(); + + void update(const std::vector& objects); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); + + void clear(); + +private: + bool createPipeline(); + + rendering::VkContext* vkCtx_ = nullptr; + VkRenderPass renderPass_ = VK_NULL_HANDLE; + VkDescriptorSetLayout perFrameLayout_ = VK_NULL_HANDLE; + + VkPipeline pipeline_ = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout_ = VK_NULL_HANDLE; + + VkBuffer vertexBuffer_ = VK_NULL_HANDLE; + VmaAllocation vertexAlloc_ = VK_NULL_HANDLE; + uint32_t vertexCount_ = 0; + + struct MarkerVertex { + float pos[3]; + float color[4]; + }; +}; + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/editor_ui.cpp b/tools/editor/editor_ui.cpp new file mode 100644 index 00000000..fc553e52 --- /dev/null +++ b/tools/editor/editor_ui.cpp @@ -0,0 +1,682 @@ +#include "editor_ui.hpp" +#include "editor_app.hpp" +#include "terrain_editor.hpp" +#include "texture_painter.hpp" +#include "object_placer.hpp" +#include "npc_spawner.hpp" +#include "npc_presets.hpp" +#include "asset_browser.hpp" +#include "transform_gizmo.hpp" +#include "terrain_biomes.hpp" +#include "rendering/terrain_renderer.hpp" +#include "rendering/camera.hpp" +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { + +EditorUI::EditorUI() = default; + +static bool matchesFilter(const std::string& text, const std::string& filter) { + if (filter.empty()) return true; + std::string lower = text; + std::transform(lower.begin(), lower.end(), lower.begin(), + [](unsigned char c) { return std::tolower(c); }); + return lower.find(filter) != std::string::npos; +} + +void EditorUI::render(EditorApp& app) { + renderMenuBar(app); + renderToolbar(app); + if (showNewDialog_) renderNewTerrainDialog(app); + if (showLoadDialog_) renderLoadDialog(app); + if (showSaveDialog_) renderSaveDialog(app); + + switch (app.getMode()) { + case EditorMode::Sculpt: renderBrushPanel(app); break; + case EditorMode::Paint: renderTexturePaintPanel(app); break; + case EditorMode::PlaceObject: renderObjectPanel(app); break; + case EditorMode::Water: renderWaterPanel(app); break; + case EditorMode::NPC: renderNpcPanel(app); break; + } + + renderContextMenu(app); + renderPropertiesPanel(app); + renderStatusBar(app); +} + +void EditorUI::processActions(EditorApp& app) { + if (newRequested_) { + newRequested_ = false; + app.createNewTerrain(newMapNameBuf_, newTileX_, newTileY_, newBaseHeight_, + static_cast(newBiomeIdx_)); + } + if (loadRequested_) { + loadRequested_ = false; + app.loadADT(loadMapNameBuf_, loadTileX_, loadTileY_); + } + if (saveAdtRequested_) { + saveAdtRequested_ = false; + app.saveADT(savePathBuf_); + } + if (saveWdtRequested_) { + saveWdtRequested_ = false; + app.saveWDT(std::string(savePathBuf_)); + } +} + +void EditorUI::renderMenuBar(EditorApp& app) { + if (ImGui::BeginMainMenuBar()) { + if (ImGui::BeginMenu("File")) { + if (ImGui::MenuItem("New Terrain...", "Ctrl+N")) showNewDialog_ = true; + if (ImGui::MenuItem("Load ADT...", "Ctrl+O")) showLoadDialog_ = true; + ImGui::Separator(); + if (ImGui::MenuItem("Save ADT...", "Ctrl+S", false, app.hasTerrainLoaded())) + showSaveDialog_ = true; + ImGui::Separator(); + if (ImGui::MenuItem("Quit", "Alt+F4")) app.requestQuit(); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Edit")) { + auto& te = app.getTerrainEditor(); + if (ImGui::MenuItem("Undo", "Ctrl+Z", false, te.history().canUndo())) te.undo(); + if (ImGui::MenuItem("Redo", "Ctrl+Shift+Z", false, te.history().canRedo())) te.redo(); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("View")) { + bool wf = app.isWireframe(); + if (ImGui::MenuItem("Wireframe", "F3", &wf)) app.setWireframe(wf); + if (ImGui::MenuItem("Reset Camera")) app.resetCamera(); + ImGui::EndMenu(); + } + ImGui::EndMainMenuBar(); + } +} + +void EditorUI::renderToolbar(EditorApp& app) { + ImGui::SetNextWindowPos(ImVec2(300, 30), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(400, 50), ImGuiCond_FirstUseEver); + ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoScrollbar; + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 8)); + if (ImGui::Begin("##Toolbar", nullptr, flags)) { + auto mode = app.getMode(); + if (ImGui::RadioButton("Sculpt", mode == EditorMode::Sculpt)) + app.setMode(EditorMode::Sculpt); + ImGui::SameLine(); + if (ImGui::RadioButton("Paint", mode == EditorMode::Paint)) + app.setMode(EditorMode::Paint); + ImGui::SameLine(); + if (ImGui::RadioButton("Objects", mode == EditorMode::PlaceObject)) + app.setMode(EditorMode::PlaceObject); + ImGui::SameLine(); + if (ImGui::RadioButton("Water", mode == EditorMode::Water)) + app.setMode(EditorMode::Water); + ImGui::SameLine(); + if (ImGui::RadioButton("NPCs", mode == EditorMode::NPC)) + app.setMode(EditorMode::NPC); + } + ImGui::End(); + ImGui::PopStyleVar(); +} + +void EditorUI::renderNewTerrainDialog(EditorApp& /*app*/) { + ImGui::SetNextWindowSize(ImVec2(400, 300), ImGuiCond_FirstUseEver); + if (ImGui::Begin("New Terrain", &showNewDialog_)) { + ImGui::InputText("Map Name", newMapNameBuf_, sizeof(newMapNameBuf_)); + ImGui::InputInt("Tile X", &newTileX_); + ImGui::InputInt("Tile Y", &newTileY_); + ImGui::SliderFloat("Base Height", &newBaseHeight_, 0.0f, 500.0f); + newTileX_ = std::max(0, std::min(63, newTileX_)); + newTileY_ = std::max(0, std::min(63, newTileY_)); + + ImGui::Separator(); + const char* biomeNames[] = { + "Grassland", "Forest", "Jungle", "Desert", "Barrens", + "Snow", "Swamp", "Rocky", "Beach", "Volcanic" + }; + ImGui::Combo("Biome", &newBiomeIdx_, biomeNames, 10); + const auto& bt = getBiomeTextures(static_cast(newBiomeIdx_)); + ImGui::TextColored(ImVec4(0.5f, 0.7f, 0.5f, 1.0f), "%s", bt.base); + + ImGui::Spacing(); + if (ImGui::Button("Create", ImVec2(120, 0))) { newRequested_ = true; showNewDialog_ = false; } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(120, 0))) showNewDialog_ = false; + } + ImGui::End(); +} + +void EditorUI::renderLoadDialog(EditorApp& /*app*/) { + ImGui::SetNextWindowSize(ImVec2(350, 180), ImGuiCond_FirstUseEver); + if (ImGui::Begin("Load ADT", &showLoadDialog_)) { + ImGui::InputText("Map Name", loadMapNameBuf_, sizeof(loadMapNameBuf_)); + ImGui::InputInt("Tile X", &loadTileX_); + ImGui::InputInt("Tile Y", &loadTileY_); + loadTileX_ = std::max(0, std::min(63, loadTileX_)); + loadTileY_ = std::max(0, std::min(63, loadTileY_)); + ImGui::Spacing(); + if (ImGui::Button("Load", ImVec2(120, 0))) { loadRequested_ = true; showLoadDialog_ = false; } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(120, 0))) showLoadDialog_ = false; + } + ImGui::End(); +} + +void EditorUI::renderSaveDialog(EditorApp& app) { + ImGui::SetNextWindowSize(ImVec2(500, 200), ImGuiCond_FirstUseEver); + if (ImGui::Begin("Save", &showSaveDialog_)) { + if (savePathBuf_[0] == '\0' && app.hasTerrainLoaded()) + std::snprintf(savePathBuf_, sizeof(savePathBuf_), "output/%s/%s_%d_%d.adt", + app.getLoadedMap().c_str(), app.getLoadedMap().c_str(), + app.getLoadedTileX(), app.getLoadedTileY()); + ImGui::InputText("Path", savePathBuf_, sizeof(savePathBuf_)); + ImGui::Spacing(); + if (ImGui::Button("Save ADT", ImVec2(140, 0))) { saveAdtRequested_ = true; showSaveDialog_ = false; } + ImGui::SameLine(); + if (ImGui::Button("Save ADT + WDT", ImVec2(140, 0))) { + saveAdtRequested_ = true; saveWdtRequested_ = true; showSaveDialog_ = false; + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(80, 0))) showSaveDialog_ = false; + } + ImGui::End(); +} + +void EditorUI::renderBrushPanel(EditorApp& app) { + ImGui::SetNextWindowPos(ImVec2(10, 90), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(280, 260), ImGuiCond_FirstUseEver); + if (ImGui::Begin("Sculpt Brush")) { + if (!app.hasTerrainLoaded()) { + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Load or create terrain first"); + ImGui::End(); return; + } + auto& s = app.getTerrainEditor().brush().settings(); + const char* modes[] = {"Raise", "Lower", "Smooth", "Flatten", "Level"}; + int idx = static_cast(s.mode); + if (ImGui::Combo("Mode", &idx, modes, 5)) s.mode = static_cast(idx); + ImGui::SliderFloat("Radius", &s.radius, 5.0f, 200.0f, "%.0f"); + ImGui::SliderFloat("Strength", &s.strength, 0.5f, 50.0f, "%.1f"); + ImGui::SliderFloat("Falloff", &s.falloff, 0.0f, 1.0f, "%.2f"); + if (s.mode == BrushMode::Flatten || s.mode == BrushMode::Level) + ImGui::SliderFloat("Target Height", &s.flattenHeight, -500.0f, 1000.0f, "%.1f"); + ImGui::Separator(); + auto& hist = app.getTerrainEditor().history(); + ImGui::Text("Undo: %zu Redo: %zu", hist.undoCount(), hist.redoCount()); + } + ImGui::End(); +} + +void EditorUI::renderTexturePaintPanel(EditorApp& app) { + ImGui::SetNextWindowPos(ImVec2(10, 90), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(340, 550), ImGuiCond_FirstUseEver); + if (ImGui::Begin("Texture Paint")) { + if (!app.hasTerrainLoaded()) { + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Load or create terrain first"); + ImGui::End(); return; + } + + auto& s = app.getTerrainEditor().brush().settings(); + ImGui::SliderFloat("Radius", &s.radius, 5.0f, 200.0f, "%.0f"); + ImGui::SliderFloat("Strength", &s.strength, 0.5f, 20.0f, "%.1f"); + ImGui::SliderFloat("Falloff", &s.falloff, 0.0f, 1.0f, "%.2f"); + + ImGui::Separator(); + const char* paintModes[] = {"Paint", "Erase", "Replace Base"}; + int pm = static_cast(paintMode_); + if (ImGui::Combo("Paint Mode", &pm, paintModes, 3)) + paintMode_ = static_cast(pm); + + ImGui::Separator(); + + // Directory filter + auto& browser = app.getAssetBrowser(); + auto& dirs = browser.getTextureDirectories(); + if (ImGui::BeginCombo("Zone", texDirIdx_ < 0 ? "All" : + dirs[texDirIdx_].c_str())) { + if (ImGui::Selectable("All", texDirIdx_ < 0)) texDirIdx_ = -1; + for (int i = 0; i < static_cast(dirs.size()); i++) { + // Show just the zone name part + std::string label = dirs[i]; + auto slash = label.rfind('\\'); + if (slash != std::string::npos) label = label.substr(slash + 1); + if (ImGui::Selectable(label.c_str(), i == texDirIdx_)) + texDirIdx_ = i; + } + ImGui::EndCombo(); + } + + ImGui::InputText("Filter", texFilterBuf_, sizeof(texFilterBuf_)); + std::string filter(texFilterBuf_); + std::transform(filter.begin(), filter.end(), filter.begin(), + [](unsigned char c) { return std::tolower(c); }); + + float listHeight = ImGui::GetContentRegionAvail().y - 60; + ImGui::BeginChild("TexList", ImVec2(0, listHeight), true); + + const auto& textures = browser.getTextures(); + int shown = 0; + for (const auto& tex : textures) { + if (texDirIdx_ >= 0 && tex.directory != dirs[texDirIdx_]) continue; + if (!matchesFilter(tex.wowPath, filter)) continue; + if (++shown > 500) { ImGui::Text("... %zu more (refine filter)", textures.size()); break; } + + bool selected = (tex.wowPath == selectedTexture_); + if (ImGui::Selectable(tex.displayName.c_str(), selected)) { + selectedTexture_ = tex.wowPath; + app.getTexturePainter().setActiveTexture(tex.wowPath); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", tex.wowPath.c_str()); + } + ImGui::EndChild(); + + if (!selectedTexture_.empty()) + ImGui::TextColored(ImVec4(0.5f, 0.9f, 0.5f, 1.0f), "Active: %s", + selectedTexture_.c_str()); + } + ImGui::End(); +} + +void EditorUI::renderObjectPanel(EditorApp& app) { + ImGui::SetNextWindowPos(ImVec2(10, 90), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(380, 550), ImGuiCond_FirstUseEver); + if (ImGui::Begin("Object Placement")) { + if (!app.hasTerrainLoaded()) { + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Load or create terrain first"); + ImGui::End(); return; + } + + auto& placer = app.getObjectPlacer(); + + // Placement settings for new objects + ImGui::Text("New Object Settings:"); + float rot = placer.getPlacementRotationY(); + if (ImGui::SliderFloat("Y Rotation", &rot, 0.0f, 360.0f, "%.0f deg")) + placer.setPlacementRotationY(rot); + float scale = placer.getPlacementScale(); + if (ImGui::SliderFloat("Scale", &scale, 0.1f, 10.0f, "%.2f")) + placer.setPlacementScale(scale); + + ImGui::Separator(); + ImGui::Checkbox("M2 Models", &showM2s_); + ImGui::SameLine(); + ImGui::Checkbox("WMO Buildings", &showWMOs_); + + ImGui::InputText("Filter", objFilterBuf_, sizeof(objFilterBuf_)); + std::string filter(objFilterBuf_); + std::transform(filter.begin(), filter.end(), filter.begin(), + [](unsigned char c) { return std::tolower(c); }); + + auto& browser = app.getAssetBrowser(); + float listHeight = ImGui::GetContentRegionAvail().y - 100; + ImGui::BeginChild("ObjList", ImVec2(0, listHeight), true); + + int shown = 0; + if (showM2s_) { + for (const auto& m2 : browser.getM2Models()) { + if (!matchesFilter(m2.wowPath, filter)) continue; + if (++shown > 500) { ImGui::Text("... refine filter"); break; } + + bool selected = (m2.wowPath == placer.getActivePath() && + placer.getActiveType() == PlaceableType::M2); + if (ImGui::Selectable(m2.displayName.c_str(), selected)) + placer.setActivePath(m2.wowPath, PlaceableType::M2); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", m2.wowPath.c_str()); + } + } + if (showWMOs_) { + if (showM2s_ && shown > 0) ImGui::Separator(); + for (const auto& wmo : browser.getWMOs()) { + if (!matchesFilter(wmo.wowPath, filter)) continue; + if (++shown > 500) { ImGui::Text("... refine filter"); break; } + + bool selected = (wmo.wowPath == placer.getActivePath() && + placer.getActiveType() == PlaceableType::WMO); + if (ImGui::Selectable(wmo.displayName.c_str(), selected)) + placer.setActivePath(wmo.wowPath, PlaceableType::WMO); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", wmo.wowPath.c_str()); + } + } + ImGui::EndChild(); + + ImGui::Separator(); + ImGui::Text("Placed: %zu objects", placer.objectCount()); + if (auto* sel = placer.getSelected()) { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1, 0.9f, 0.3f, 1)); + ImGui::Text("Selected: %s", sel->path.c_str()); + ImGui::PopStyleColor(); + + bool changed = false; + changed |= ImGui::DragFloat3("Position", &sel->position.x, 1.0f); + changed |= ImGui::DragFloat3("Rotation", &sel->rotation.x, 1.0f, 0.0f, 360.0f, "%.1f deg"); + changed |= ImGui::DragFloat("Obj Scale", &sel->scale, 0.05f, 0.1f, 50.0f, "%.2f"); + + if (changed) app.markObjectsDirty(); + + if (ImGui::Button("Delete", ImVec2(100, 0))) placer.deleteSelected(); + ImGui::SameLine(); + if (ImGui::Button("Duplicate", ImVec2(100, 0))) { + PlacedObject copy = *sel; + copy.uniqueId = 0; + copy.position += glm::vec3(5.0f, 5.0f, 0.0f); + copy.selected = false; + placer.clearSelection(); + // Can't easily push from here, but move slightly signals intent + } + ImGui::SameLine(); + if (ImGui::Button("Deselect", ImVec2(100, 0))) + placer.clearSelection(); + } + } + ImGui::End(); +} + +void EditorUI::renderNpcPanel(EditorApp& app) { + ImGuiViewport* vp = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(ImVec2(vp->Size.x - 400, 90), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(390, 700), ImGuiCond_FirstUseEver); + if (ImGui::Begin("NPC / Monsters")) { + if (!app.hasTerrainLoaded()) { + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1), "Load terrain first"); + ImGui::End(); return; + } + + auto& spawner = app.getNpcSpawner(); + auto& presets = app.getNpcPresets(); + auto& tmpl = spawner.getTemplate(); + + // ---- Creature Browser ---- + if (ImGui::CollapsingHeader("Creature Browser", ImGuiTreeNodeFlags_DefaultOpen)) { + // Category filter + static int catIdx = -1; + if (ImGui::BeginCombo("Category", catIdx < 0 ? "All" : + NpcPresets::getCategoryName(static_cast(catIdx)))) { + if (ImGui::Selectable("All", catIdx < 0)) catIdx = -1; + for (int i = 0; i < static_cast(CreatureCategory::COUNT); i++) { + auto cat = static_cast(i); + auto& list = presets.getByCategory(cat); + if (list.empty()) continue; + char label[64]; + std::snprintf(label, sizeof(label), "%s (%zu)", + NpcPresets::getCategoryName(cat), list.size()); + if (ImGui::Selectable(label, catIdx == i)) catIdx = i; + } + ImGui::EndCombo(); + } + + static char npcFilter[128] = ""; + ImGui::InputText("Search##npc", npcFilter, sizeof(npcFilter)); + std::string filter(npcFilter); + std::transform(filter.begin(), filter.end(), filter.begin(), + [](unsigned char c) { return std::tolower(c); }); + + ImGui::BeginChild("CreatureList", ImVec2(0, 150), true); + const auto& list = (catIdx < 0) ? presets.getPresets() + : presets.getByCategory(static_cast(catIdx)); + int shown = 0; + for (const auto& p : list) { + if (!filter.empty()) { + std::string lower = p.name; + std::transform(lower.begin(), lower.end(), lower.begin(), + [](unsigned char c) { return std::tolower(c); }); + if (lower.find(filter) == std::string::npos) continue; + } + if (++shown > 200) { ImGui::Text("... refine search"); break; } + + bool selected = (tmpl.modelPath == p.modelPath); + if (ImGui::Selectable(p.name.c_str(), selected)) { + tmpl.name = p.name; + tmpl.modelPath = p.modelPath; + tmpl.level = p.defaultLevel; + tmpl.health = p.defaultHealth; + tmpl.hostile = p.defaultHostile; + tmpl.minDamage = 3 + p.defaultLevel * 2; + tmpl.maxDamage = 5 + p.defaultLevel * 3; + tmpl.armor = p.defaultLevel * 10; + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", p.modelPath.c_str()); + } + ImGui::EndChild(); + + if (!tmpl.modelPath.empty()) { + ImGui::TextColored(ImVec4(0.5f, 0.9f, 0.5f, 1), "Selected: %s", tmpl.name.c_str()); + } + } + + // ---- Stats & Behavior ---- + if (ImGui::CollapsingHeader("Stats & Behavior", ImGuiTreeNodeFlags_DefaultOpen)) { + static char nameBuf[128] = ""; + if (nameBuf[0] == '\0') std::strncpy(nameBuf, tmpl.name.c_str(), sizeof(nameBuf) - 1); + if (ImGui::InputText("Name##tmpl", nameBuf, sizeof(nameBuf))) + tmpl.name = nameBuf; + + int lvl = tmpl.level; + if (ImGui::SliderInt("Level", &lvl, 1, 83)) { + tmpl.level = lvl; + tmpl.health = 50 + lvl * 80; + tmpl.minDamage = 3 + lvl * 2; + tmpl.maxDamage = 5 + lvl * 3; + tmpl.armor = lvl * 10; + } + + int hp = tmpl.health; + if (ImGui::InputInt("Health", &hp)) tmpl.health = std::max(1, hp); + int mp = tmpl.mana; + if (ImGui::InputInt("Mana", &mp)) tmpl.mana = std::max(0, mp); + + int dmin = tmpl.minDamage, dmax = tmpl.maxDamage; + ImGui::InputInt("Min Dmg", &dmin); tmpl.minDamage = std::max(0, dmin); + ImGui::InputInt("Max Dmg", &dmax); tmpl.maxDamage = std::max(0, dmax); + int arm = tmpl.armor; + if (ImGui::InputInt("Armor", &arm)) tmpl.armor = std::max(0, arm); + + const char* behaviors[] = {"Stationary", "Patrol", "Wander", "Scripted"}; + int bIdx = static_cast(tmpl.behavior); + if (ImGui::Combo("Behavior", &bIdx, behaviors, 4)) + tmpl.behavior = static_cast(bIdx); + + if (tmpl.behavior == CreatureBehavior::Wander) + ImGui::SliderFloat("Wander Dist", &tmpl.wanderRadius, 1.0f, 100.0f); + ImGui::SliderFloat("Aggro Range", &tmpl.aggroRadius, 0.0f, 100.0f); + + ImGui::Checkbox("Hostile", &tmpl.hostile); + ImGui::SameLine(); ImGui::Checkbox("Questgiver", &tmpl.questgiver); + ImGui::Checkbox("Vendor", &tmpl.vendor); + ImGui::SameLine(); ImGui::Checkbox("Innkeeper", &tmpl.innkeeper); + + // Update nameBuf when preset selection changes it + if (tmpl.name.c_str() != std::string(nameBuf)) + std::strncpy(nameBuf, tmpl.name.c_str(), sizeof(nameBuf) - 1); + } + + ImGui::Separator(); + + // ---- Spawned list ---- + if (ImGui::CollapsingHeader("Spawned Creatures", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Text("%zu placed", spawner.spawnCount()); + ImGui::BeginChild("SpawnList", ImVec2(0, 100), true); + for (int i = 0; i < static_cast(spawner.spawnCount()); i++) { + auto& s = spawner.getSpawns()[i]; + bool sel = (i == spawner.getSelectedIndex()); + char label[128]; + std::snprintf(label, sizeof(label), "%s Lv%u (%.0f,%.0f,%.0f)", + s.name.c_str(), s.level, + s.position.x, s.position.y, s.position.z); + if (ImGui::Selectable(label, sel)) + spawner.selectAt(s.position, 10000.0f); + } + ImGui::EndChild(); + } + + // ---- Selected creature editor ---- + if (auto* sel = spawner.getSelected()) { + ImGui::Separator(); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1, 0.9f, 0.3f, 1)); + ImGui::Text("Editing: %s", sel->name.c_str()); + ImGui::PopStyleColor(); + + ImGui::DragFloat3("Pos##npc", &sel->position.x, 1.0f); + ImGui::SliderFloat("Facing", &sel->orientation, 0.0f, 360.0f, "%.0f"); + int hp2 = sel->health; if (ImGui::InputInt("HP##s", &hp2)) sel->health = std::max(1, hp2); + int lv2 = sel->level; if (ImGui::InputInt("Lv##s", &lv2)) sel->level = std::max(1, lv2); + + const char* beh2[] = {"Stationary", "Patrol", "Wander", "Scripted"}; + int bi2 = static_cast(sel->behavior); + if (ImGui::Combo("AI##s", &bi2, beh2, 4)) sel->behavior = static_cast(bi2); + + if (ImGui::Button("Delete##npc")) spawner.removeCreature(spawner.getSelectedIndex()); + ImGui::SameLine(); + if (ImGui::Button("Deselect##npc")) spawner.clearSelection(); + } + + ImGui::Separator(); + static char npcPath[256] = "output/creatures.json"; + ImGui::InputText("File##npc", npcPath, sizeof(npcPath)); + if (ImGui::Button("Save NPCs")) spawner.saveToFile(npcPath); + + ImGui::Separator(); + ImGui::TextColored(ImVec4(0.7f, 0.9f, 0.7f, 1), "Click terrain to place selected creature"); + } + ImGui::End(); +} + +void EditorUI::renderWaterPanel(EditorApp& app) { + ImGui::SetNextWindowPos(ImVec2(10, 90), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(280, 250), ImGuiCond_FirstUseEver); + if (ImGui::Begin("Water")) { + if (!app.hasTerrainLoaded()) { + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Load or create terrain first"); + ImGui::End(); return; + } + + auto& s = app.getTerrainEditor().brush().settings(); + ImGui::SliderFloat("Radius", &s.radius, 5.0f, 200.0f, "%.0f"); + + float wh = app.getWaterHeight(); + if (ImGui::SliderFloat("Water Height", &wh, -200.0f, 500.0f, "%.1f")) + app.setWaterHeight(wh); + + const char* types[] = {"Water", "Ocean", "Magma", "Slime"}; + int typeIdx = app.getWaterType(); + if (ImGui::Combo("Liquid Type", &typeIdx, types, 4)) + app.setWaterType(static_cast(typeIdx)); + + ImGui::Separator(); + if (ImGui::Button("Remove Water Under Brush", ImVec2(-1, 0))) { + auto& brush = app.getTerrainEditor().brush(); + if (brush.isActive()) { + app.getTerrainEditor().removeWater(brush.getPosition(), s.radius); + app.getEditorCamera(); // trigger dirty + } + } + + ImGui::Separator(); + ImGui::TextColored(ImVec4(0.5f, 0.8f, 1.0f, 1.0f), "Left-click to place water"); + ImGui::TextColored(ImVec4(0.5f, 0.8f, 1.0f, 1.0f), "Rendered as translucent overlay"); + ImGui::Separator(); + ImGui::TextColored(ImVec4(0.7f, 0.9f, 0.7f, 1), "Left-click: place"); + ImGui::TextColored(ImVec4(0.7f, 0.9f, 0.7f, 1), "Ctrl+click: select"); + ImGui::TextColored(ImVec4(0.7f, 0.9f, 0.7f, 1), "Del: remove selected"); + } + ImGui::End(); +} + +void EditorUI::renderContextMenu(EditorApp& app) { + if (ImGui::BeginPopup("ObjectContextMenu")) { + auto* sel = app.getObjectPlacer().getSelected(); + if (!sel) { ImGui::EndPopup(); return; } + + std::string display = sel->path; + auto slash = display.rfind('\\'); + if (slash != std::string::npos) display = display.substr(slash + 1); + ImGui::TextColored(ImVec4(1, 0.9f, 0.3f, 1), "%s", display.c_str()); + ImGui::Separator(); + + if (ImGui::MenuItem("Move (left-drag)")) + app.startGizmoMode(TransformMode::Move); + if (ImGui::MenuItem("Rotate (left-drag)")) + app.startGizmoMode(TransformMode::Rotate); + if (ImGui::MenuItem("Scale (left-drag)")) + app.startGizmoMode(TransformMode::Scale); + + ImGui::Separator(); + if (ImGui::BeginMenu("Constrain Axis")) { + if (ImGui::MenuItem("All Axes")) app.setGizmoAxis(TransformAxis::All); + if (ImGui::MenuItem("X (Red)")) app.setGizmoAxis(TransformAxis::X); + if (ImGui::MenuItem("Y (Green)")) app.setGizmoAxis(TransformAxis::Y); + if (ImGui::MenuItem("Z (Blue)")) app.setGizmoAxis(TransformAxis::Z); + ImGui::EndMenu(); + } + + ImGui::Separator(); + if (ImGui::MenuItem("Delete")) { + app.getObjectPlacer().deleteSelected(); + app.markObjectsDirty(); + } + if (ImGui::MenuItem("Deselect")) + app.getObjectPlacer().clearSelection(); + + ImGui::EndPopup(); + } +} + +void EditorUI::renderPropertiesPanel(EditorApp& app) { + ImGuiViewport* vp = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(ImVec2(vp->Size.x - 280, 90), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(270, 180), ImGuiCond_FirstUseEver); + if (ImGui::Begin("Properties")) { + auto* tr = app.getTerrainRenderer(); + if (tr && tr->getChunkCount() > 0) { + ImGui::Text("Map: %s [%d, %d]", app.getLoadedMap().c_str(), + app.getLoadedTileX(), app.getLoadedTileY()); + ImGui::Text("Chunks: %d Tris: %d", tr->getChunkCount(), tr->getTriangleCount()); + ImGui::Text("Objects: %zu", app.getObjectPlacer().objectCount()); + } else { + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "No terrain loaded"); + } + ImGui::Separator(); + auto pos = app.getEditorCamera().getCamera().getPosition(); + ImGui::Text("Camera: %.0f, %.0f, %.0f", pos.x, pos.y, pos.z); + ImGui::Text("Speed: %.0f (scroll)", app.getEditorCamera().getSpeed()); + if (app.getTerrainEditor().hasUnsavedChanges()) + ImGui::TextColored(ImVec4(1, 0.8f, 0.3f, 1), "* Unsaved changes"); + } + ImGui::End(); +} + +void EditorUI::renderStatusBar(EditorApp& app) { + ImGuiViewport* vp = ImGui::GetMainViewport(); + float h = 24.0f; + ImGui::SetNextWindowPos(ImVec2(vp->Pos.x, vp->Pos.y + vp->Size.y - h)); + ImGui::SetNextWindowSize(ImVec2(vp->Size.x, h)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 3)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoFocusOnAppearing; + if (ImGui::Begin("##StatusBar", nullptr, flags)) { + const char* ms[] = {"Sculpt", "Paint", "Objects", "Water", "NPCs"}; + const char* m = ms[static_cast(app.getMode())]; + if (app.hasTerrainLoaded()) + ImGui::Text("[%s] %s [%d,%d]%s", m, app.getLoadedMap().c_str(), + app.getLoadedTileX(), app.getLoadedTileY(), + app.getTerrainEditor().hasUnsavedChanges() ? " *" : ""); + else + ImGui::Text("[%s] Wowee World Editor", m); + ImGui::SameLine(vp->Size.x - 120); + ImGui::Text("%.1f FPS", ImGui::GetIO().Framerate); + } + ImGui::End(); + ImGui::PopStyleVar(2); +} + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/editor_ui.hpp b/tools/editor/editor_ui.hpp new file mode 100644 index 00000000..667dd436 --- /dev/null +++ b/tools/editor/editor_ui.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include "terrain_biomes.hpp" +#include +#include + +namespace wowee { +namespace editor { + +class EditorApp; + +enum class PaintMode { Paint, Erase, ReplaceBase }; + +class EditorUI { +public: + EditorUI(); + + void render(EditorApp& app); + void processActions(EditorApp& app); + + PaintMode getPaintMode() const { return paintMode_; } + +private: + void renderMenuBar(EditorApp& app); + void renderToolbar(EditorApp& app); + void renderNewTerrainDialog(EditorApp& app); + void renderLoadDialog(EditorApp& app); + void renderSaveDialog(EditorApp& app); + void renderBrushPanel(EditorApp& app); + void renderTexturePaintPanel(EditorApp& app); + void renderObjectPanel(EditorApp& app); + void renderWaterPanel(EditorApp& app); + void renderNpcPanel(EditorApp& app); + void renderContextMenu(EditorApp& app); + void renderPropertiesPanel(EditorApp& app); + void renderStatusBar(EditorApp& app); + + bool showNewDialog_ = false; + bool showLoadDialog_ = false; + bool showSaveDialog_ = false; + + char newMapNameBuf_[256] = "CustomZone"; + int newTileX_ = 32; + int newTileY_ = 32; + float newBaseHeight_ = 100.0f; + int newBiomeIdx_ = 0; + bool newRequested_ = false; + + char loadMapNameBuf_[256] = "Azeroth"; + int loadTileX_ = 32; + int loadTileY_ = 48; + bool loadRequested_ = false; + + char savePathBuf_[512] = ""; + bool saveAdtRequested_ = false; + bool saveWdtRequested_ = false; + + // Paint panel + PaintMode paintMode_ = PaintMode::Paint; + char texFilterBuf_[128] = ""; + int texDirIdx_ = -1; // -1 = all + std::string selectedTexture_; + + // Object panel + char objFilterBuf_[128] = ""; + int objDirIdx_ = -1; + bool showM2s_ = true; + bool showWMOs_ = true; +}; + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/editor_viewport.cpp b/tools/editor/editor_viewport.cpp new file mode 100644 index 00000000..c8b33d70 --- /dev/null +++ b/tools/editor/editor_viewport.cpp @@ -0,0 +1,505 @@ +#include "editor_viewport.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_texture.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/m2_loader.hpp" +#include "pipeline/wmo_loader.hpp" +#include "core/logger.hpp" +#include +#include +#include + +namespace wowee { +namespace editor { + +EditorViewport::EditorViewport() = default; +EditorViewport::~EditorViewport() { shutdown(); } + +bool EditorViewport::initialize(rendering::VkContext* ctx, pipeline::AssetManager* am, + rendering::Camera* cam) { + vkCtx_ = ctx; + assetManager_ = am; + camera_ = cam; + + if (!createPerFrameResources()) return false; + + terrainRenderer_ = std::make_unique(); + if (!terrainRenderer_->initialize(ctx, perFrameSetLayout_, am)) { + LOG_ERROR("Failed to initialize terrain renderer"); + return false; + } + terrainRenderer_->setFogEnabled(false); + + m2Renderer_ = std::make_unique(); + if (!m2Renderer_->initialize(ctx, perFrameSetLayout_, am)) { + LOG_WARNING("M2 renderer init failed — object rendering disabled"); + m2Renderer_.reset(); + } else { + m2Renderer_->setForceNoCull(true); + } + + wmoRenderer_ = std::make_unique(); + if (!wmoRenderer_->initialize(ctx, perFrameSetLayout_, am)) { + LOG_WARNING("WMO renderer init failed — building rendering disabled"); + wmoRenderer_.reset(); + } + + waterRenderer_.initialize(ctx, ctx->getImGuiRenderPass(), perFrameSetLayout_); + markerRenderer_.initialize(ctx, ctx->getImGuiRenderPass(), perFrameSetLayout_); + gizmo_.initialize(ctx, ctx->getImGuiRenderPass(), perFrameSetLayout_); + + LOG_INFO("Editor viewport initialized"); + return true; +} + +void EditorViewport::shutdown() { + if (!vkCtx_) return; + vkDeviceWaitIdle(vkCtx_->getDevice()); + + gizmo_.shutdown(); + markerRenderer_.shutdown(); + waterRenderer_.shutdown(); + + if (wmoRenderer_) { wmoRenderer_->shutdown(); wmoRenderer_.reset(); } + if (m2Renderer_) { m2Renderer_->shutdown(); m2Renderer_.reset(); } + if (terrainRenderer_) { terrainRenderer_->shutdown(); terrainRenderer_.reset(); } + + destroyPerFrameResources(); + vkCtx_ = nullptr; +} + +bool EditorViewport::loadTerrain(const pipeline::TerrainMesh& mesh, + const std::vector& texturePaths, + int tileX, int tileY) { + return terrainRenderer_->loadTerrain(mesh, texturePaths, tileX, tileY); +} + +void EditorViewport::clearTerrain() { + if (terrainRenderer_) terrainRenderer_->clear(); +} + +void EditorViewport::updateWater(const pipeline::ADTTerrain& terrain, int tileX, int tileY) { + waterRenderer_.update(terrain, tileX, tileY); +} + +void EditorViewport::updateMarkers(const std::vector& objects) { + markerRenderer_.update(objects); +} + +void EditorViewport::placeM2(const std::string& path, const glm::vec3& pos, + const glm::vec3& rot, float scale) { + (void)path; (void)pos; (void)rot; (void)scale; +} + +void EditorViewport::placeWMO(const std::string& path, const glm::vec3& pos, + const glm::vec3& rot) { + (void)path; (void)pos; (void)rot; +} + +void EditorViewport::clearObjects() { + if (m2Renderer_) { + vkCtx_->waitAllUploads(); + m2Renderer_->clear(); + } + if (wmoRenderer_) { + wmoRenderer_->clearAll(); + } + markerRenderer_.clear(); +} + +void EditorViewport::rebuildObjects(const std::vector& objects, + const std::vector& npcs) { + clearObjects(); + if (objects.empty() && npcs.empty()) return; + + uint32_t nextModelId = 1; + std::unordered_map m2ModelIds, wmoModelIds; + + for (const auto& obj : objects) { + if (obj.type == PlaceableType::M2 && m2Renderer_) { + uint32_t modelId; + auto it = m2ModelIds.find(obj.path); + if (it != m2ModelIds.end()) { + modelId = it->second; + } else { + auto data = assetManager_->readFile(obj.path); + if (data.empty()) { + LOG_WARNING("M2 file not found in manifest: ", obj.path); + continue; + } + auto model = pipeline::M2Loader::load(data); + + // WotLK M2s need a separate .skin file for geometry + if (!model.isValid()) { + std::string skinPath = obj.path; + auto dotPos = skinPath.rfind('.'); + if (dotPos != std::string::npos) + skinPath = skinPath.substr(0, dotPos) + "00.skin"; + auto skinData = assetManager_->readFile(skinPath); + if (!skinData.empty()) + pipeline::M2Loader::loadSkin(skinData, model); + } + + if (!model.isValid()) { + LOG_WARNING("M2 failed to parse (", data.size(), " bytes): ", obj.path); + continue; + } + + // Ensure boundRadius is reasonable for culling + if (model.boundRadius < 1.0f) model.boundRadius = 50.0f; + + modelId = nextModelId++; + if (!m2Renderer_->loadModel(model, modelId)) { + LOG_WARNING("M2 failed to upload to GPU: ", obj.path); + continue; + } + // Wait for async texture uploads to complete before rendering + vkCtx_->waitAllUploads(); + vkCtx_->pollUploadBatches(); + LOG_INFO("M2 loaded: ", obj.path, " (modelId=", modelId, ", ", + model.vertices.size(), " verts)"); + m2ModelIds[obj.path] = modelId; + } + glm::vec3 rotRad = glm::radians(obj.rotation); + m2Renderer_->createInstance(modelId, obj.position, rotRad, obj.scale); + + } else if (obj.type == PlaceableType::WMO && wmoRenderer_) { + uint32_t modelId; + auto it = wmoModelIds.find(obj.path); + if (it != wmoModelIds.end()) { + modelId = it->second; + } else { + auto data = assetManager_->readFile(obj.path); + if (data.empty()) { + LOG_WARNING("WMO file not found in manifest: ", obj.path); + continue; + } + auto model = pipeline::WMOLoader::load(data); + + // Load WMO group files (_000.wmo, _001.wmo, etc.) + std::string basePath = obj.path; + auto dotPos = basePath.rfind('.'); + if (dotPos != std::string::npos) basePath = basePath.substr(0, dotPos); + for (uint32_t gi = 0; gi < model.nGroups; gi++) { + char groupSuffix[16]; + std::snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.wmo", gi); + std::string groupPath = basePath + groupSuffix; + auto groupData = assetManager_->readFile(groupPath); + if (!groupData.empty()) { + pipeline::WMOLoader::loadGroup(groupData, model, gi); + } + } + + if (!model.isValid()) { + LOG_WARNING("WMO failed to parse (", data.size(), " bytes, ", + model.nGroups, " groups expected): ", obj.path); + continue; + } + + modelId = nextModelId++; + if (!wmoRenderer_->loadModel(model, modelId)) { + LOG_WARNING("WMO failed to upload to GPU: ", obj.path); + continue; + } + vkCtx_->waitAllUploads(); + vkCtx_->pollUploadBatches(); + LOG_INFO("WMO loaded: ", obj.path, " (modelId=", modelId, ", ", + model.groups.size(), " groups)"); + wmoModelIds[obj.path] = modelId; + } + glm::vec3 wmoRotRad = glm::radians(obj.rotation); + wmoRenderer_->createInstance(modelId, obj.position, wmoRotRad); + } + } + + // Render NPC creatures as M2 instances + if (m2Renderer_) { + for (const auto& npc : npcs) { + if (npc.modelPath.empty()) continue; + uint32_t modelId; + auto it = m2ModelIds.find(npc.modelPath); + if (it != m2ModelIds.end()) { + modelId = it->second; + } else { + auto data = assetManager_->readFile(npc.modelPath); + if (data.empty()) continue; + auto model = pipeline::M2Loader::load(data); + if (!model.isValid()) { + std::string skinPath = npc.modelPath; + auto dotPos = skinPath.rfind('.'); + if (dotPos != std::string::npos) + skinPath = skinPath.substr(0, dotPos) + "00.skin"; + auto skinData = assetManager_->readFile(skinPath); + if (!skinData.empty()) + pipeline::M2Loader::loadSkin(skinData, model); + } + if (!model.isValid()) continue; + if (model.boundRadius < 1.0f) model.boundRadius = 50.0f; + modelId = nextModelId++; + if (!m2Renderer_->loadModel(model, modelId)) continue; + vkCtx_->waitAllUploads(); + vkCtx_->pollUploadBatches(); + m2ModelIds[npc.modelPath] = modelId; + } + glm::vec3 rotRad = glm::radians(glm::vec3(0, 0, npc.orientation)); + m2Renderer_->createInstance(modelId, npc.position, rotRad, 1.0f); + } + } + + vkCtx_->waitAllUploads(); + vkCtx_->pollUploadBatches(); +} + +void EditorViewport::update(float deltaTime) { + if (m2Renderer_) + m2Renderer_->update(deltaTime, camera_->getPosition(), camera_->getViewProjectionMatrix()); +} + +void EditorViewport::setGhostPreview(const std::string& path, const glm::vec3& pos, + const glm::vec3& rotDeg, float scale) { + if (!m2Renderer_) return; + + // Load model if path changed + if (path != ghostModelPath_ || ghostModelId_ == 0) { + clearGhostPreview(); + auto data = assetManager_->readFile(path); + if (data.empty()) return; + auto model = pipeline::M2Loader::load(data); + if (!model.isValid()) { + std::string skinPath = path; + auto dotPos = skinPath.rfind('.'); + if (dotPos != std::string::npos) + skinPath = skinPath.substr(0, dotPos) + "00.skin"; + auto skinData = assetManager_->readFile(skinPath); + if (!skinData.empty()) + pipeline::M2Loader::loadSkin(skinData, model); + } + if (!model.isValid()) return; + if (model.boundRadius < 1.0f) model.boundRadius = 50.0f; + + ghostModelId_ = 60000; // Use a high ID to avoid collision with placed objects + m2Renderer_->loadModel(model, ghostModelId_); + vkCtx_->waitAllUploads(); + vkCtx_->pollUploadBatches(); + ghostModelPath_ = path; + } + + // Create or update ghost instance + glm::vec3 rotRad = glm::radians(rotDeg); + if (!ghostActive_) { + ghostInstanceId_ = m2Renderer_->createInstance(ghostModelId_, pos, rotRad, scale); + ghostActive_ = (ghostInstanceId_ != 0); + } else { + m2Renderer_->setInstancePosition(ghostInstanceId_, pos); + // Rebuild transform with new rotation/scale + glm::mat4 mat = glm::mat4(1.0f); + mat = glm::translate(mat, pos); + mat = glm::rotate(mat, rotRad.x, glm::vec3(1, 0, 0)); + mat = glm::rotate(mat, rotRad.y, glm::vec3(0, 1, 0)); + mat = glm::rotate(mat, rotRad.z, glm::vec3(0, 0, 1)); + mat = glm::scale(mat, glm::vec3(scale)); + m2Renderer_->setInstanceTransform(ghostInstanceId_, mat); + } +} + +void EditorViewport::clearGhostPreview() { + if (ghostActive_ && m2Renderer_) { + m2Renderer_->removeInstance(ghostInstanceId_); + ghostActive_ = false; + ghostInstanceId_ = 0; + } + if (ghostModelId_ != 0 && m2Renderer_) { + // Don't unload the model — it might be used by placed objects too + ghostModelId_ = 0; + ghostModelPath_.clear(); + } +} + +void EditorViewport::render(VkCommandBuffer cmd) { + updatePerFrameUBO(); + + uint32_t frame = vkCtx_->getCurrentFrame(); + VkDescriptorSet perFrameSet = perFrameDescSets_[frame]; + + terrainRenderer_->render(cmd, perFrameSet, *camera_); + + if (m2Renderer_) + m2Renderer_->render(cmd, perFrameSet, *camera_); + if (wmoRenderer_) + wmoRenderer_->render(cmd, perFrameSet, *camera_); + + waterRenderer_.render(cmd, perFrameSet); + gizmo_.render(cmd, perFrameSet); +} + +void EditorViewport::setWireframe(bool enabled) { + wireframe_ = enabled; + if (terrainRenderer_) terrainRenderer_->setWireframe(enabled); +} + +bool EditorViewport::createPerFrameResources() { + VkDevice device = vkCtx_->getDevice(); + + VkDescriptorSetLayoutBinding bindings[2]{}; + bindings[0].binding = 0; + bindings[0].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + bindings[0].descriptorCount = 1; + bindings[0].stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + bindings[1].binding = 1; + bindings[1].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + bindings[1].descriptorCount = 1; + bindings[1].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + + VkDescriptorSetLayoutCreateInfo layoutInfo{}; + layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + layoutInfo.bindingCount = 2; + layoutInfo.pBindings = bindings; + + if (vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &perFrameSetLayout_) != VK_SUCCESS) + return false; + + VkDescriptorPoolSize poolSizes[2]{}; + poolSizes[0].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + poolSizes[0].descriptorCount = MAX_FRAMES; + poolSizes[1].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + poolSizes[1].descriptorCount = MAX_FRAMES; + + VkDescriptorPoolCreateInfo poolInfo{}; + poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.maxSets = MAX_FRAMES; + poolInfo.poolSizeCount = 2; + poolInfo.pPoolSizes = poolSizes; + + if (vkCreateDescriptorPool(device, &poolInfo, nullptr, &sceneDescPool_) != VK_SUCCESS) + return false; + + dummyShadowTexture_ = std::make_unique(); + if (!dummyShadowTexture_->createDepth(*vkCtx_, 1, 1)) return false; + + VkSamplerCreateInfo sampCI{}; + sampCI.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; + sampCI.magFilter = VK_FILTER_LINEAR; + sampCI.minFilter = VK_FILTER_LINEAR; + sampCI.mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST; + sampCI.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER; + sampCI.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER; + sampCI.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER; + sampCI.borderColor = VK_BORDER_COLOR_FLOAT_OPAQUE_WHITE; + sampCI.compareEnable = VK_TRUE; + sampCI.compareOp = VK_COMPARE_OP_LESS_OR_EQUAL; + shadowSampler_ = vkCtx_->getOrCreateSampler(sampCI); + + vkCtx_->immediateSubmit([this](VkCommandBuffer cmd) { + VkImageMemoryBarrier barrier{}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = dummyShadowTexture_->getImage(); + barrier.subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1}; + barrier.srcAccessMask = 0; + barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + vkCmdPipelineBarrier(cmd, + VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, + 0, 0, nullptr, 0, nullptr, 1, &barrier); + }); + + for (uint32_t i = 0; i < MAX_FRAMES; i++) { + VkBufferCreateInfo bufInfo{}; + bufInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufInfo.size = sizeof(rendering::GPUPerFrameData); + bufInfo.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; + + VmaAllocationCreateInfo allocInfo{}; + allocInfo.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + allocInfo.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + + VmaAllocationInfo mapInfo{}; + if (vmaCreateBuffer(vkCtx_->getAllocator(), &bufInfo, &allocInfo, + &perFrameUBOs_[i], &perFrameUBOAllocs_[i], &mapInfo) != VK_SUCCESS) + return false; + perFrameUBOMapped_[i] = mapInfo.pMappedData; + + VkDescriptorSetAllocateInfo setAlloc{}; + setAlloc.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + setAlloc.descriptorPool = sceneDescPool_; + setAlloc.descriptorSetCount = 1; + setAlloc.pSetLayouts = &perFrameSetLayout_; + if (vkAllocateDescriptorSets(device, &setAlloc, &perFrameDescSets_[i]) != VK_SUCCESS) + return false; + + VkDescriptorBufferInfo descBuf{}; + descBuf.buffer = perFrameUBOs_[i]; + descBuf.offset = 0; + descBuf.range = sizeof(rendering::GPUPerFrameData); + + VkDescriptorImageInfo shadowImgInfo{}; + shadowImgInfo.sampler = shadowSampler_; + shadowImgInfo.imageView = dummyShadowTexture_->getImageView(); + shadowImgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + + VkWriteDescriptorSet writes[2]{}; + writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[0].dstSet = perFrameDescSets_[i]; + writes[0].dstBinding = 0; + writes[0].descriptorCount = 1; + writes[0].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + writes[0].pBufferInfo = &descBuf; + writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[1].dstSet = perFrameDescSets_[i]; + writes[1].dstBinding = 1; + writes[1].descriptorCount = 1; + writes[1].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[1].pImageInfo = &shadowImgInfo; + + vkUpdateDescriptorSets(device, 2, writes, 0, nullptr); + } + + return true; +} + +void EditorViewport::destroyPerFrameResources() { + if (!vkCtx_) return; + VkDevice device = vkCtx_->getDevice(); + + for (uint32_t i = 0; i < MAX_FRAMES; i++) { + if (perFrameUBOs_[i]) { + vmaDestroyBuffer(vkCtx_->getAllocator(), perFrameUBOs_[i], perFrameUBOAllocs_[i]); + perFrameUBOs_[i] = VK_NULL_HANDLE; + } + } + if (dummyShadowTexture_) { + dummyShadowTexture_->destroy(device, vkCtx_->getAllocator()); + dummyShadowTexture_.reset(); + } + if (sceneDescPool_) { + vkDestroyDescriptorPool(device, sceneDescPool_, nullptr); + sceneDescPool_ = VK_NULL_HANDLE; + } + if (perFrameSetLayout_) { + vkDestroyDescriptorSetLayout(device, perFrameSetLayout_, nullptr); + perFrameSetLayout_ = VK_NULL_HANDLE; + } +} + +void EditorViewport::updatePerFrameUBO() { + uint32_t frame = vkCtx_->getCurrentFrame(); + + rendering::GPUPerFrameData data{}; + data.view = camera_->getViewMatrix(); + data.projection = camera_->getProjectionMatrix(); + data.lightSpaceMatrix = glm::mat4(1.0f); + data.lightDir = glm::vec4(glm::normalize(glm::vec3(0.5f, -1.0f, 0.3f)), 0.0f); + data.lightColor = glm::vec4(1.0f, 0.95f, 0.85f, 0.0f); + data.ambientColor = glm::vec4(0.3f, 0.3f, 0.35f, 0.0f); + data.viewPos = glm::vec4(camera_->getPosition(), 0.0f); + data.fogColor = glm::vec4(0.6f, 0.7f, 0.8f, 0.0f); + data.fogParams = glm::vec4(5000.0f, 10000.0f, 0.0f, 0.0f); + data.shadowParams = glm::vec4(0.0f, 0.0f, 0.0f, 0.0f); + + std::memcpy(perFrameUBOMapped_[frame], &data, sizeof(data)); +} + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/editor_viewport.hpp b/tools/editor/editor_viewport.hpp new file mode 100644 index 00000000..1022c060 --- /dev/null +++ b/tools/editor/editor_viewport.hpp @@ -0,0 +1,96 @@ +#pragma once + +#include "rendering/vk_frame_data.hpp" +#include "rendering/terrain_renderer.hpp" +#include "rendering/m2_renderer.hpp" +#include "rendering/wmo_renderer.hpp" +#include "rendering/camera.hpp" +#include "editor_water.hpp" +#include "editor_markers.hpp" +#include "transform_gizmo.hpp" +#include "object_placer.hpp" +#include "npc_spawner.hpp" +#include +#include +#include + +namespace wowee { +namespace pipeline { class AssetManager; } +namespace rendering { class VkContext; class VkTexture; } + +namespace editor { + +class EditorViewport { +public: + EditorViewport(); + ~EditorViewport(); + + bool initialize(rendering::VkContext* ctx, pipeline::AssetManager* am, rendering::Camera* cam); + void shutdown(); + + bool loadTerrain(const pipeline::TerrainMesh& mesh, + const std::vector& texturePaths, + int tileX, int tileY); + void clearTerrain(); + + void updateWater(const pipeline::ADTTerrain& terrain, int tileX, int tileY); + void updateMarkers(const std::vector& objects); + void placeM2(const std::string& path, const glm::vec3& pos, const glm::vec3& rot, float scale); + void placeWMO(const std::string& path, const glm::vec3& pos, const glm::vec3& rot); + void clearObjects(); + void rebuildObjects(const std::vector& objects, + const std::vector& npcs = {}); + + void update(float deltaTime); + void render(VkCommandBuffer cmd); + + // Ghost preview for placement + void setGhostPreview(const std::string& path, const glm::vec3& pos, + const glm::vec3& rotDeg, float scale); + void clearGhostPreview(); + + TransformGizmo& getGizmo() { return gizmo_; } + + void setWireframe(bool enabled); + bool isWireframe() const { return wireframe_; } + + rendering::TerrainRenderer* getTerrainRenderer() { return terrainRenderer_.get(); } + +private: + bool createPerFrameResources(); + void destroyPerFrameResources(); + void updatePerFrameUBO(); + + rendering::VkContext* vkCtx_ = nullptr; + pipeline::AssetManager* assetManager_ = nullptr; + rendering::Camera* camera_ = nullptr; + + std::unique_ptr terrainRenderer_; + std::unique_ptr m2Renderer_; + std::unique_ptr wmoRenderer_; + EditorWater waterRenderer_; + EditorMarkers markerRenderer_; + TransformGizmo gizmo_; + + static constexpr uint32_t MAX_FRAMES = 2; + VkDescriptorSetLayout perFrameSetLayout_ = VK_NULL_HANDLE; + VkDescriptorPool sceneDescPool_ = VK_NULL_HANDLE; + VkDescriptorSet perFrameDescSets_[MAX_FRAMES] = {}; + VkBuffer perFrameUBOs_[MAX_FRAMES] = {}; + VmaAllocation perFrameUBOAllocs_[MAX_FRAMES] = {}; + void* perFrameUBOMapped_[MAX_FRAMES] = {}; + + std::unique_ptr dummyShadowTexture_; + VkSampler shadowSampler_ = VK_NULL_HANDLE; + + bool wireframe_ = false; + + // Ghost preview state + std::string ghostModelPath_; + uint32_t ghostModelId_ = 0; + uint32_t ghostInstanceId_ = 0; + bool ghostActive_ = false; +}; + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/editor_water.cpp b/tools/editor/editor_water.cpp new file mode 100644 index 00000000..8a69eddf --- /dev/null +++ b/tools/editor/editor_water.cpp @@ -0,0 +1,228 @@ +#include "editor_water.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_shader.hpp" +#include "core/logger.hpp" +#include + +namespace wowee { +namespace editor { + +static constexpr float TILE_SIZE = 533.33333f; +static constexpr float CHUNK_SIZE = TILE_SIZE / 16.0f; + +EditorWater::EditorWater() = default; +EditorWater::~EditorWater() { shutdown(); } + +bool EditorWater::initialize(rendering::VkContext* ctx, VkRenderPass renderPass, + VkDescriptorSetLayout perFrameLayout) { + vkCtx_ = ctx; + renderPass_ = renderPass; + perFrameLayout_ = perFrameLayout; + return createPipeline(); +} + +void EditorWater::shutdown() { + if (!vkCtx_) return; + VkDevice dev = vkCtx_->getDevice(); + + if (vertexBuffer_) { + vmaDestroyBuffer(vkCtx_->getAllocator(), vertexBuffer_, vertexAlloc_); + vertexBuffer_ = VK_NULL_HANDLE; + } + if (pipeline_) { vkDestroyPipeline(dev, pipeline_, nullptr); pipeline_ = VK_NULL_HANDLE; } + if (pipelineLayout_) { vkDestroyPipelineLayout(dev, pipelineLayout_, nullptr); pipelineLayout_ = VK_NULL_HANDLE; } + vkCtx_ = nullptr; +} + +void EditorWater::clear() { + if (vertexBuffer_) { + vmaDestroyBuffer(vkCtx_->getAllocator(), vertexBuffer_, vertexAlloc_); + vertexBuffer_ = VK_NULL_HANDLE; + vertexCount_ = 0; + } +} + +void EditorWater::update(const pipeline::ADTTerrain& terrain, int tileX, int tileY) { + clear(); + + std::vector verts; + + for (int cy = 0; cy < 16; cy++) { + for (int cx = 0; cx < 16; cx++) { + int idx = cy * 16 + cx; + const auto& water = terrain.waterData[idx]; + if (!water.hasWater()) continue; + + float tileNW_X = (32.0f - static_cast(tileY)) * TILE_SIZE; + float tileNW_Y = (32.0f - static_cast(tileX)) * TILE_SIZE; + float x0 = tileNW_X - static_cast(cy) * CHUNK_SIZE; + float y0 = tileNW_Y - static_cast(cx) * CHUNK_SIZE; + float x1 = x0 - CHUNK_SIZE; + float y1 = y0 - CHUNK_SIZE; + + float h = water.layers[0].maxHeight; + + // Water color by type + float r = 0.1f, g = 0.3f, b = 0.7f, a = 0.45f; + uint16_t lt = water.layers[0].liquidType; + if (lt == 2) { r = 0.8f; g = 0.2f; b = 0.05f; a = 0.7f; } // magma + if (lt == 3) { r = 0.2f; g = 0.6f; b = 0.1f; a = 0.6f; } // slime + + // Two triangles per chunk + WaterVertex v; + v.color[0] = r; v.color[1] = g; v.color[2] = b; v.color[3] = a; + + v.pos[0] = x0; v.pos[1] = y0; v.pos[2] = h; verts.push_back(v); + v.pos[0] = x1; v.pos[1] = y0; v.pos[2] = h; verts.push_back(v); + v.pos[0] = x1; v.pos[1] = y1; v.pos[2] = h; verts.push_back(v); + + v.pos[0] = x0; v.pos[1] = y0; v.pos[2] = h; verts.push_back(v); + v.pos[0] = x1; v.pos[1] = y1; v.pos[2] = h; verts.push_back(v); + v.pos[0] = x0; v.pos[1] = y1; v.pos[2] = h; verts.push_back(v); + } + } + + if (verts.empty()) return; + vertexCount_ = static_cast(verts.size()); + + VkBufferCreateInfo bufInfo{}; + bufInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufInfo.size = verts.size() * sizeof(WaterVertex); + bufInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT; + + VmaAllocationCreateInfo allocInfo{}; + allocInfo.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + allocInfo.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + + VmaAllocationInfo mapInfo{}; + if (vmaCreateBuffer(vkCtx_->getAllocator(), &bufInfo, &allocInfo, + &vertexBuffer_, &vertexAlloc_, &mapInfo) != VK_SUCCESS) { + LOG_ERROR("Failed to create water vertex buffer"); + return; + } + std::memcpy(mapInfo.pMappedData, verts.data(), verts.size() * sizeof(WaterVertex)); +} + +void EditorWater::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { + if (!vertexBuffer_ || vertexCount_ == 0 || !pipeline_) return; + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_, + 0, 1, &perFrameSet, 0, nullptr); + + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &vertexBuffer_, &offset); + vkCmdDraw(cmd, vertexCount_, 1, 0, 0); +} + +bool EditorWater::createPipeline() { + VkDevice dev = vkCtx_->getDevice(); + + // Pipeline layout: set 0 = per-frame UBO (reuse terrain's layout) + VkPipelineLayoutCreateInfo layoutInfo{}; + layoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + layoutInfo.setLayoutCount = 1; + layoutInfo.pSetLayouts = &perFrameLayout_; + if (vkCreatePipelineLayout(dev, &layoutInfo, nullptr, &pipelineLayout_) != VK_SUCCESS) + return false; + + rendering::VkShaderModule vertMod, fragMod; + if (!vertMod.loadFromFile(dev, "assets/shaders/editor_water.vert.spv") || + !fragMod.loadFromFile(dev, "assets/shaders/editor_water.frag.spv")) { + LOG_WARNING("Water shaders not found — water rendering disabled"); + return true; + } + + VkPipelineShaderStageCreateInfo stages[2]{}; + stages[0] = vertMod.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + stages[1] = fragMod.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + // Vertex input: pos(3f) + color(4f) = 28 bytes + VkVertexInputBindingDescription binding{}; + binding.stride = sizeof(WaterVertex); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + VkVertexInputAttributeDescription attrs[2]{}; + attrs[0].location = 0; attrs[0].format = VK_FORMAT_R32G32B32_SFLOAT; attrs[0].offset = 0; + attrs[1].location = 1; attrs[1].format = VK_FORMAT_R32G32B32A32_SFLOAT; attrs[1].offset = 12; + + VkPipelineVertexInputStateCreateInfo vertexInput{}; + vertexInput.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO; + vertexInput.vertexBindingDescriptionCount = 1; + vertexInput.pVertexBindingDescriptions = &binding; + vertexInput.vertexAttributeDescriptionCount = 2; + vertexInput.pVertexAttributeDescriptions = attrs; + + VkPipelineInputAssemblyStateCreateInfo ia{}; + ia.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO; + ia.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; + + VkPipelineViewportStateCreateInfo vps{}; + vps.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO; + vps.viewportCount = 1; + vps.scissorCount = 1; + + VkPipelineRasterizationStateCreateInfo rast{}; + rast.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO; + rast.polygonMode = VK_POLYGON_MODE_FILL; + rast.cullMode = VK_CULL_MODE_NONE; + rast.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; + rast.lineWidth = 1.0f; + + VkPipelineMultisampleStateCreateInfo ms{}; + ms.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO; + ms.rasterizationSamples = vkCtx_->getMsaaSamples(); + + VkPipelineDepthStencilStateCreateInfo ds{}; + ds.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO; + ds.depthTestEnable = VK_TRUE; + ds.depthWriteEnable = VK_FALSE; // Transparent — don't write depth + ds.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL; + + VkPipelineColorBlendAttachmentState blend{}; + blend.blendEnable = VK_TRUE; + blend.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA; + blend.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + blend.colorBlendOp = VK_BLEND_OP_ADD; + blend.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + blend.dstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO; + blend.alphaBlendOp = VK_BLEND_OP_ADD; + blend.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | + VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + + VkPipelineColorBlendStateCreateInfo cb{}; + cb.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO; + cb.attachmentCount = 1; + cb.pAttachments = &blend; + + VkDynamicState dynStates[] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}; + VkPipelineDynamicStateCreateInfo dyn{}; + dyn.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO; + dyn.dynamicStateCount = 2; + dyn.pDynamicStates = dynStates; + + VkGraphicsPipelineCreateInfo pci{}; + pci.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO; + pci.stageCount = 2; + pci.pStages = stages; + pci.pVertexInputState = &vertexInput; + pci.pInputAssemblyState = &ia; + pci.pViewportState = &vps; + pci.pRasterizationState = &rast; + pci.pMultisampleState = &ms; + pci.pDepthStencilState = &ds; + pci.pColorBlendState = &cb; + pci.pDynamicState = &dyn; + pci.layout = pipelineLayout_; + pci.renderPass = renderPass_; + + if (vkCreateGraphicsPipelines(dev, vkCtx_->getPipelineCache(), 1, &pci, nullptr, &pipeline_) != VK_SUCCESS) { + LOG_ERROR("Failed to create water pipeline"); + pipeline_ = VK_NULL_HANDLE; + } + + return true; +} + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/editor_water.hpp b/tools/editor/editor_water.hpp new file mode 100644 index 00000000..a2e0b178 --- /dev/null +++ b/tools/editor/editor_water.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "pipeline/adt_loader.hpp" +#include +#include +#include + +namespace wowee { +namespace rendering { class VkContext; } + +namespace editor { + +class EditorWater { +public: + EditorWater(); + ~EditorWater(); + + bool initialize(rendering::VkContext* ctx, VkRenderPass renderPass, + VkDescriptorSetLayout perFrameLayout); + void shutdown(); + + void update(const pipeline::ADTTerrain& terrain, int tileX, int tileY); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); + + void clear(); + +private: + bool createPipeline(); + + rendering::VkContext* vkCtx_ = nullptr; + VkRenderPass renderPass_ = VK_NULL_HANDLE; + VkDescriptorSetLayout perFrameLayout_ = VK_NULL_HANDLE; + + VkPipeline pipeline_ = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout_ = VK_NULL_HANDLE; + + VkBuffer vertexBuffer_ = VK_NULL_HANDLE; + VmaAllocation vertexAlloc_ = VK_NULL_HANDLE; + uint32_t vertexCount_ = 0; + + struct WaterVertex { + float pos[3]; + float color[4]; + }; +}; + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/main.cpp b/tools/editor/main.cpp new file mode 100644 index 00000000..a76b550f --- /dev/null +++ b/tools/editor/main.cpp @@ -0,0 +1,49 @@ +#include "editor_app.hpp" +#include "core/logger.hpp" +#include +#include + +static void printUsage(const char* argv0) { + LOG_INFO("Usage: ", argv0, " --data [--adt ]"); + LOG_INFO(" --data Path to extracted WoW data (contains manifest.json)"); + LOG_INFO(" --adt Load an ADT tile on startup"); +} + +int main(int argc, char* argv[]) { + std::string dataPath; + std::string adtMap; + int adtX = -1, adtY = -1; + + for (int i = 1; i < argc; i++) { + if (std::strcmp(argv[i], "--data") == 0 && i + 1 < argc) { + dataPath = argv[++i]; + } else if (std::strcmp(argv[i], "--adt") == 0 && i + 3 < argc) { + adtMap = argv[++i]; + adtX = std::atoi(argv[++i]); + adtY = std::atoi(argv[++i]); + } else if (std::strcmp(argv[i], "--help") == 0 || std::strcmp(argv[i], "-h") == 0) { + printUsage(argv[0]); + return 0; + } + } + + if (dataPath.empty()) { + dataPath = "Data"; + LOG_INFO("No --data path specified, using default: ", dataPath); + } + + wowee::editor::EditorApp app; + if (!app.initialize(dataPath)) { + LOG_ERROR("Failed to initialize editor"); + return 1; + } + + if (!adtMap.empty()) { + app.loadADT(adtMap, adtX, adtY); + } + + app.run(); + app.shutdown(); + + return 0; +} diff --git a/tools/editor/npc_presets.cpp b/tools/editor/npc_presets.cpp new file mode 100644 index 00000000..ae7fc49a --- /dev/null +++ b/tools/editor/npc_presets.cpp @@ -0,0 +1,191 @@ +#include "npc_presets.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/asset_manifest.hpp" +#include "core/logger.hpp" +#include +#include +#include + +namespace wowee { +namespace editor { + +const char* NpcPresets::getCategoryName(CreatureCategory cat) { + static const char* names[] = { + "Critters", "Beasts", "Humanoids", "Undead", "Demons", + "Elementals", "Dragonkin", "Giants", "Mechanical", "Mounts", "Bosses", "Other" + }; + return names[static_cast(cat)]; +} + +std::string NpcPresets::prettifyName(const std::string& dirName) const { + std::string result; + for (size_t i = 0; i < dirName.size(); i++) { + char c = dirName[i]; + if (i == 0) { + result += static_cast(std::toupper(c)); + } else if (std::isupper(c) && i > 0 && std::islower(dirName[i-1])) { + result += ' '; + result += c; + } else { + result += c; + } + } + return result; +} + +CreatureCategory NpcPresets::classifyCreature(const std::string& name) const { + // Critters + static const char* critters[] = { + "rabbit", "rat", "chicken", "frog", "snake", "squirrel", "deer", "sheep", + "cow", "pig", "parrot", "seagull", "beetle", "cockroach", "crab", "prairie", + "butterfly", "firefly", "maggot", "toad", "mouse", "hare", "penguin", + "babycrocodile", "babyelekk", "bearcub", "cat", "smallfish", + "kitten", "skunk", "ladybug", "gazelle", "gilamonster" + }; + // Beasts + static const char* beasts[] = { + "bear", "boar", "wolf", "lion", "tiger", "raptor", "gorilla", "hyena", + "scorpid", "spider", "bat", "vulture", "crocolisk", "tallstrider", + "kodo", "elekk", "warp", "ravager", "serpent", "devilsaur", "crochet", + "plainstrider", "stag", "moose", "worg", "rhino", "mammoth", "jormungar", + "shoveltusk", "basilisk", "carrionbird", "condor", "hippogryph", + "windserpent", "thunderlizard", "turtle", "silithid", "wasp", "moth", + "nether", "cat", "arcticcondor" + }; + // Humanoids + static const char* humanoids[] = { + "human", "orc", "dwarf", "nightelf", "undead", "tauren", "gnome", "troll", + "bloodelf", "draenei", "goblin", "ogre", "murloc", "naga", "satyr", + "centaur", "furbolg", "gnoll", "kobold", "trogg", "harpy", "pirate", + "bandit", "vrykul", "tuskarr", "wolvar", "arakkoa", "ethereal", + "broken", "fleshgiant", "kvaldir", "pygmy", "taunka" + }; + // Undead + static const char* undead[] = { + "skeleton", "zombie", "ghoul", "ghost", "banshee", "lich", "wraith", + "abomination", "geist", "shade", "spectre", "boneguard", "bonespider", + "bonegolem", "crypt", "necro", "plague", "scourge", "val" + }; + // Demons + static const char* demons[] = { + "demon", "felguard", "imp", "infernal", "doomguard", "succubus", + "voidwalker", "felhound", "eredar", "pitlord", "dreadlord", + "abyssal", "felboar", "darkhound", "terrorfiend" + }; + // Elementals + static const char* elementals[] = { + "elemental", "fire", "water", "air", "earth", "arcane", "storm", + "lava", "bog", "ooze", "slime", "revenant", "totem" + }; + // Dragonkin + static const char* dragonkin[] = { + "dragon", "drake", "whelp", "wyrm", "dragonspawn", "drakonid", + "nether", "proto", "celestialdragon" + }; + // Giants + static const char* giants[] = { + "giant", "ettin", "gronn", "colossus", "titan", "mountain", "sea" + }; + // Mechanical + static const char* mechanical[] = { + "mechanical", "robot", "golem", "harvest", "shredder", "gyro", + "bomber", "tank", "turret", "cannon", "siege" + }; + // Mounts + static const char* mounts[] = { + "mount", "horse", "hawkstrider", "raptor", "mechanostrider", + "nightsaber", "ram", "kodo", "skeletal", "broom", "carpet", + "gryphon", "wyvern", "hippogryph", "netherdrake", "protodrake" + }; + // Boss + static const char* bosses[] = { + "arthas", "illidan", "kelthuzad", "ragnaros", "onyxia", "nefarian", + "alexstrasza", "malygos", "sartharion", "yoggsaron", "lichking", + "brutallus", "bloodqueen", "anubarak" + }; + + auto matches = [&](const char* list[], size_t count) { + for (size_t i = 0; i < count; i++) { + if (name.find(list[i]) != std::string::npos) return true; + } + return false; + }; + + if (matches(critters, sizeof(critters)/sizeof(critters[0]))) return CreatureCategory::Critter; + if (matches(mounts, sizeof(mounts)/sizeof(mounts[0]))) return CreatureCategory::Mount; + if (matches(bosses, sizeof(bosses)/sizeof(bosses[0]))) return CreatureCategory::Boss; + if (matches(undead, sizeof(undead)/sizeof(undead[0]))) return CreatureCategory::Undead; + if (matches(demons, sizeof(demons)/sizeof(demons[0]))) return CreatureCategory::Demon; + if (matches(dragonkin, sizeof(dragonkin)/sizeof(dragonkin[0]))) return CreatureCategory::Dragonkin; + if (matches(elementals, sizeof(elementals)/sizeof(elementals[0]))) return CreatureCategory::Elemental; + if (matches(giants, sizeof(giants)/sizeof(giants[0]))) return CreatureCategory::Giant; + if (matches(mechanical, sizeof(mechanical)/sizeof(mechanical[0]))) return CreatureCategory::Mechanical; + if (matches(humanoids, sizeof(humanoids)/sizeof(humanoids[0]))) return CreatureCategory::Humanoid; + if (matches(beasts, sizeof(beasts)/sizeof(beasts[0]))) return CreatureCategory::Beast; + + return CreatureCategory::Other; +} + +uint32_t NpcPresets::estimateLevel(const std::string& /*dirName*/) const { + return 10; +} + +uint32_t NpcPresets::estimateHealth(uint32_t level) const { + return 50 + level * 80; +} + +void NpcPresets::initialize(pipeline::AssetManager* am) { + if (initialized_ || !am) return; + initialized_ = true; + + byCategory_.resize(static_cast(CreatureCategory::COUNT)); + + const auto& entries = am->getManifest().getEntries(); + std::set seen; + + for (const auto& [path, entry] : entries) { + if (!path.starts_with("creature\\")) continue; + if (!path.ends_with(".m2")) continue; + + // Extract directory name (creature type) + auto firstSlash = path.find('\\'); + auto secondSlash = path.find('\\', firstSlash + 1); + if (secondSlash == std::string::npos) continue; + + std::string dirName = path.substr(firstSlash + 1, secondSlash - firstSlash - 1); + if (seen.count(dirName)) continue; + seen.insert(dirName); + + // Get the actual M2 file path + std::string modelFile = path; + + NpcPreset preset; + preset.name = prettifyName(dirName); + preset.modelPath = modelFile; + preset.category = classifyCreature(dirName); + preset.defaultLevel = estimateLevel(dirName); + preset.defaultHealth = estimateHealth(preset.defaultLevel); + preset.defaultHostile = (preset.category != CreatureCategory::Critter && + preset.category != CreatureCategory::Mount); + + presets_.push_back(preset); + byCategory_[static_cast(preset.category)].push_back(preset); + } + + // Sort each category alphabetically + for (auto& cat : byCategory_) { + std::sort(cat.begin(), cat.end(), + [](const NpcPreset& a, const NpcPreset& b) { return a.name < b.name; }); + } + std::sort(presets_.begin(), presets_.end(), + [](const NpcPreset& a, const NpcPreset& b) { return a.name < b.name; }); + + LOG_INFO("NPC presets: ", presets_.size(), " creatures in ", seen.size(), " types"); +} + +const std::vector& NpcPresets::getByCategory(CreatureCategory cat) const { + return byCategory_[static_cast(cat)]; +} + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/npc_presets.hpp b/tools/editor/npc_presets.hpp new file mode 100644 index 00000000..81ac6c83 --- /dev/null +++ b/tools/editor/npc_presets.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include "npc_spawner.hpp" +#include +#include + +namespace wowee { +namespace pipeline { class AssetManager; } + +namespace editor { + +enum class CreatureCategory { + Critter, + Beast, + Humanoid, + Undead, + Demon, + Elemental, + Dragonkin, + Giant, + Mechanical, + Mount, + Boss, + Other, + COUNT +}; + +struct NpcPreset { + std::string name; + std::string modelPath; + CreatureCategory category; + uint32_t defaultLevel; + uint32_t defaultHealth; + bool defaultHostile; +}; + +class NpcPresets { +public: + void initialize(pipeline::AssetManager* am); + + const std::vector& getPresets() const { return presets_; } + const std::vector& getByCategory(CreatureCategory cat) const; + + static const char* getCategoryName(CreatureCategory cat); + bool isInitialized() const { return initialized_; } + +private: + CreatureCategory classifyCreature(const std::string& dirName) const; + std::string prettifyName(const std::string& dirName) const; + uint32_t estimateLevel(const std::string& dirName) const; + uint32_t estimateHealth(uint32_t level) const; + + std::vector presets_; + std::vector> byCategory_; + bool initialized_ = false; +}; + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/npc_spawner.cpp b/tools/editor/npc_spawner.cpp new file mode 100644 index 00000000..5cf2e376 --- /dev/null +++ b/tools/editor/npc_spawner.cpp @@ -0,0 +1,111 @@ +#include "npc_spawner.hpp" +#include "core/logger.hpp" +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { + +uint32_t NpcSpawner::nextId() { return idCounter_++; } + +void NpcSpawner::placeCreature(const CreatureSpawn& spawn) { + CreatureSpawn s = spawn; + s.id = nextId(); + s.selected = false; + spawns_.push_back(s); + LOG_INFO("Creature placed: ", s.name, " (id=", s.id, ") at (", + s.position.x, ",", s.position.y, ",", s.position.z, ")"); +} + +void NpcSpawner::removeCreature(int index) { + if (index < 0 || index >= static_cast(spawns_.size())) return; + spawns_.erase(spawns_.begin() + index); + if (selectedIdx_ == index) selectedIdx_ = -1; + else if (selectedIdx_ > index) selectedIdx_--; +} + +int NpcSpawner::selectAt(const glm::vec3& worldPos, float maxDist) { + clearSelection(); + float bestDist = maxDist; + int bestIdx = -1; + for (int i = 0; i < static_cast(spawns_.size()); i++) { + float dist = glm::length(spawns_[i].position - worldPos); + if (dist < bestDist) { bestDist = dist; bestIdx = i; } + } + if (bestIdx >= 0) { + selectedIdx_ = bestIdx; + spawns_[bestIdx].selected = true; + } + return bestIdx; +} + +void NpcSpawner::clearSelection() { + if (selectedIdx_ >= 0 && selectedIdx_ < static_cast(spawns_.size())) + spawns_[selectedIdx_].selected = false; + selectedIdx_ = -1; +} + +CreatureSpawn* NpcSpawner::getSelected() { + if (selectedIdx_ < 0 || selectedIdx_ >= static_cast(spawns_.size())) return nullptr; + return &spawns_[selectedIdx_]; +} + +bool NpcSpawner::saveToFile(const std::string& path) const { + auto dir = std::filesystem::path(path).parent_path(); + if (!dir.empty()) std::filesystem::create_directories(dir); + + std::ofstream f(path); + if (!f) { LOG_ERROR("Failed to write NPC file: ", path); return false; } + + f << "[\n"; + for (size_t i = 0; i < spawns_.size(); i++) { + const auto& s = spawns_[i]; + f << " {\n"; + f << " \"name\": \"" << s.name << "\",\n"; + f << " \"model\": \"" << s.modelPath << "\",\n"; + f << " \"displayId\": " << s.displayId << ",\n"; + f << " \"position\": [" << s.position.x << "," << s.position.y << "," << s.position.z << "],\n"; + f << " \"orientation\": " << s.orientation << ",\n"; + f << " \"level\": " << s.level << ",\n"; + f << " \"health\": " << s.health << ",\n"; + f << " \"mana\": " << s.mana << ",\n"; + f << " \"minDamage\": " << s.minDamage << ",\n"; + f << " \"maxDamage\": " << s.maxDamage << ",\n"; + f << " \"armor\": " << s.armor << ",\n"; + f << " \"faction\": " << s.faction << ",\n"; + f << " \"behavior\": " << static_cast(s.behavior) << ",\n"; + f << " \"wanderRadius\": " << s.wanderRadius << ",\n"; + f << " \"aggroRadius\": " << s.aggroRadius << ",\n"; + f << " \"leashRadius\": " << s.leashRadius << ",\n"; + f << " \"respawnTimeMs\": " << s.respawnTimeMs << ",\n"; + f << " \"hostile\": " << (s.hostile ? "true" : "false") << ",\n"; + f << " \"questgiver\": " << (s.questgiver ? "true" : "false") << ",\n"; + f << " \"vendor\": " << (s.vendor ? "true" : "false") << ",\n"; + f << " \"flightmaster\": " << (s.flightmaster ? "true" : "false") << ",\n"; + f << " \"innkeeper\": " << (s.innkeeper ? "true" : "false") << ",\n"; + f << " \"patrol\": ["; + for (size_t p = 0; p < s.patrolPath.size(); p++) { + f << "[" << s.patrolPath[p].position.x << "," << s.patrolPath[p].position.y + << "," << s.patrolPath[p].position.z << "," << s.patrolPath[p].waitTimeMs << "]"; + if (p + 1 < s.patrolPath.size()) f << ","; + } + f << "]\n"; + f << " }" << (i + 1 < spawns_.size() ? "," : "") << "\n"; + } + f << "]\n"; + + LOG_INFO("NPC spawns saved: ", path, " (", spawns_.size(), " creatures)"); + return true; +} + +bool NpcSpawner::loadFromFile(const std::string& path) { + // Simple JSON-ish parser for our format — full JSON parsing would need a library + LOG_INFO("NPC spawn loading not yet implemented for: ", path); + return false; +} + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/npc_spawner.hpp b/tools/editor/npc_spawner.hpp new file mode 100644 index 00000000..eed6d13b --- /dev/null +++ b/tools/editor/npc_spawner.hpp @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include +#include + +namespace wowee { +namespace editor { + +enum class CreatureBehavior { + Stationary, + Patrol, + Wander, + Scripted +}; + +struct PatrolPoint { + glm::vec3 position; + float waitTimeMs = 2000.0f; +}; + +struct CreatureSpawn { + uint32_t id = 0; + std::string name = "Creature"; + std::string modelPath; + uint32_t displayId = 0; + + // Position + glm::vec3 position{0}; + float orientation = 0.0f; // degrees + + // Stats + uint32_t level = 1; + uint32_t health = 100; + uint32_t mana = 0; + uint32_t minDamage = 5; + uint32_t maxDamage = 10; + uint32_t armor = 0; + uint32_t faction = 0; // 0 = neutral + + // Behavior + CreatureBehavior behavior = CreatureBehavior::Stationary; + float wanderRadius = 10.0f; + float aggroRadius = 20.0f; + float leashRadius = 40.0f; + uint32_t respawnTimeMs = 300000; + std::vector patrolPath; + + // Flags + bool hostile = false; + bool questgiver = false; + bool vendor = false; + bool flightmaster = false; + bool innkeeper = false; + + bool selected = false; +}; + +class NpcSpawner { +public: + void placeCreature(const CreatureSpawn& spawn); + void removeCreature(int index); + + int selectAt(const glm::vec3& worldPos, float maxDist = 30.0f); + void clearSelection(); + CreatureSpawn* getSelected(); + int getSelectedIndex() const { return selectedIdx_; } + + const std::vector& getSpawns() const { return spawns_; } + std::vector& getSpawns() { return spawns_; } + size_t spawnCount() const { return spawns_.size(); } + + // Serialize to/from JSON + bool saveToFile(const std::string& path) const; + bool loadFromFile(const std::string& path); + + // Template creature for placement + CreatureSpawn& getTemplate() { return template_; } + +private: + uint32_t nextId(); + std::vector spawns_; + int selectedIdx_ = -1; + uint32_t idCounter_ = 1; + CreatureSpawn template_; +}; + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/object_placer.cpp b/tools/editor/object_placer.cpp new file mode 100644 index 00000000..9ae37555 --- /dev/null +++ b/tools/editor/object_placer.cpp @@ -0,0 +1,160 @@ +#include "object_placer.hpp" +#include "core/logger.hpp" +#include +#include + +namespace wowee { +namespace editor { + +void ObjectPlacer::setActivePath(const std::string& path, PlaceableType type) { + activePath_ = path; + activeType_ = type; +} + +uint32_t ObjectPlacer::nextUniqueId() { + return uniqueIdCounter_++; +} + +void ObjectPlacer::placeObject(const glm::vec3& position) { + if (activePath_.empty()) return; + + PlacedObject obj; + obj.type = activeType_; + obj.path = activePath_; + obj.nameId = 0; + obj.uniqueId = nextUniqueId(); + obj.position = position; + obj.rotation = glm::vec3(0.0f, placementRotY_, 0.0f); + obj.scale = placementScale_; + obj.selected = false; + + objects_.push_back(obj); + LOG_INFO("Placed ", (activeType_ == PlaceableType::M2 ? "M2" : "WMO"), + ": ", activePath_, " at (", position.x, ",", position.y, ",", position.z, ")"); +} + +int ObjectPlacer::selectAt(const rendering::Ray& ray, float maxDist) { + clearSelection(); + + float bestDist = maxDist; + int bestIdx = -1; + + for (int i = 0; i < static_cast(objects_.size()); i++) { + // Simple sphere test (radius based on scale) + float radius = 5.0f * objects_[i].scale; + glm::vec3 oc = ray.origin - objects_[i].position; + float b = glm::dot(oc, ray.direction); + float c = glm::dot(oc, oc) - radius * radius; + float disc = b * b - c; + if (disc < 0) continue; + + float t = -b - std::sqrt(disc); + if (t < 0) t = -b + std::sqrt(disc); + if (t > 0 && t < bestDist) { + bestDist = t; + bestIdx = i; + } + } + + if (bestIdx >= 0) { + selectedIdx_ = bestIdx; + objects_[bestIdx].selected = true; + } + return bestIdx; +} + +void ObjectPlacer::clearSelection() { + if (selectedIdx_ >= 0 && selectedIdx_ < static_cast(objects_.size())) + objects_[selectedIdx_].selected = false; + selectedIdx_ = -1; +} + +PlacedObject* ObjectPlacer::getSelected() { + if (selectedIdx_ < 0 || selectedIdx_ >= static_cast(objects_.size())) return nullptr; + return &objects_[selectedIdx_]; +} + +void ObjectPlacer::moveSelected(const glm::vec3& delta) { + if (auto* obj = getSelected()) obj->position += delta; +} + +void ObjectPlacer::rotateSelected(const glm::vec3& deltaDeg) { + if (auto* obj = getSelected()) obj->rotation += deltaDeg; +} + +void ObjectPlacer::scaleSelected(float delta) { + if (auto* obj = getSelected()) + obj->scale = std::max(0.1f, obj->scale + delta); +} + +void ObjectPlacer::deleteSelected() { + if (selectedIdx_ < 0 || selectedIdx_ >= static_cast(objects_.size())) return; + objects_.erase(objects_.begin() + selectedIdx_); + selectedIdx_ = -1; +} + +void ObjectPlacer::syncToTerrain() { + if (!terrain_) return; + + terrain_->doodadNames.clear(); + terrain_->doodadPlacements.clear(); + terrain_->wmoNames.clear(); + terrain_->wmoPlacements.clear(); + + // Build name lists and placements + std::vector m2Names, wmoNames; + + for (const auto& obj : objects_) { + if (obj.type == PlaceableType::M2) { + // Find or add name + uint32_t nameId = 0; + for (uint32_t i = 0; i < m2Names.size(); i++) { + if (m2Names[i] == obj.path) { nameId = i; goto foundM2; } + } + nameId = static_cast(m2Names.size()); + m2Names.push_back(obj.path); + foundM2: + + pipeline::ADTTerrain::DoodadPlacement dp{}; + dp.nameId = nameId; + dp.uniqueId = obj.uniqueId; + dp.position[0] = obj.position.x; + dp.position[1] = obj.position.y; + dp.position[2] = obj.position.z; + dp.rotation[0] = obj.rotation.x; + dp.rotation[1] = obj.rotation.y; + dp.rotation[2] = obj.rotation.z; + dp.scale = static_cast(obj.scale * 1024.0f); + dp.flags = 0; + terrain_->doodadPlacements.push_back(dp); + + } else { + uint32_t nameId = 0; + for (uint32_t i = 0; i < wmoNames.size(); i++) { + if (wmoNames[i] == obj.path) { nameId = i; goto foundWMO; } + } + nameId = static_cast(wmoNames.size()); + wmoNames.push_back(obj.path); + foundWMO: + + pipeline::ADTTerrain::WMOPlacement wp{}; + wp.nameId = nameId; + wp.uniqueId = obj.uniqueId; + wp.position[0] = obj.position.x; + wp.position[1] = obj.position.y; + wp.position[2] = obj.position.z; + wp.rotation[0] = obj.rotation.x; + wp.rotation[1] = obj.rotation.y; + wp.rotation[2] = obj.rotation.z; + wp.flags = 0; + wp.doodadSet = 0; + terrain_->wmoPlacements.push_back(wp); + } + } + + terrain_->doodadNames = std::move(m2Names); + terrain_->wmoNames = std::move(wmoNames); +} + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/object_placer.hpp b/tools/editor/object_placer.hpp new file mode 100644 index 00000000..f01945da --- /dev/null +++ b/tools/editor/object_placer.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include "pipeline/adt_loader.hpp" +#include "rendering/camera.hpp" +#include +#include +#include +#include + +namespace wowee { +namespace editor { + +enum class PlaceableType { M2, WMO }; + +struct PlacedObject { + PlaceableType type; + std::string path; + uint32_t nameId; + uint32_t uniqueId; + glm::vec3 position; + glm::vec3 rotation; // degrees + float scale; // 1.0 = normal + bool selected = false; +}; + +class ObjectPlacer { +public: + void setTerrain(pipeline::ADTTerrain* terrain) { terrain_ = terrain; } + + void setActivePath(const std::string& path, PlaceableType type); + const std::string& getActivePath() const { return activePath_; } + PlaceableType getActiveType() const { return activeType_; } + + // Place object at world position + void placeObject(const glm::vec3& position); + + // Select object nearest to ray + int selectAt(const rendering::Ray& ray, float maxDist = 50.0f); + void clearSelection(); + int getSelectedIndex() const { return selectedIdx_; } + PlacedObject* getSelected(); + + // Transform selected + void moveSelected(const glm::vec3& delta); + void rotateSelected(const glm::vec3& deltaDeg); + void scaleSelected(float delta); + void deleteSelected(); + + // Sync placed objects back to ADTTerrain structs + void syncToTerrain(); + + const std::vector& getObjects() const { return objects_; } + size_t objectCount() const { return objects_.size(); } + + float getPlacementRotationY() const { return placementRotY_; } + void setPlacementRotationY(float deg) { placementRotY_ = deg; } + float getPlacementScale() const { return placementScale_; } + void setPlacementScale(float s) { placementScale_ = s; } + +private: + uint32_t nextUniqueId(); + + pipeline::ADTTerrain* terrain_ = nullptr; + std::string activePath_; + PlaceableType activeType_ = PlaceableType::M2; + + std::vector objects_; + int selectedIdx_ = -1; + uint32_t uniqueIdCounter_ = 1; + float placementRotY_ = 0.0f; + float placementScale_ = 1.0f; +}; + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/stb_image_impl.cpp b/tools/editor/stb_image_impl.cpp new file mode 100644 index 00000000..8ddfd1f5 --- /dev/null +++ b/tools/editor/stb_image_impl.cpp @@ -0,0 +1,2 @@ +#define STB_IMAGE_IMPLEMENTATION +#include "stb_image.h" diff --git a/tools/editor/terrain_biomes.hpp b/tools/editor/terrain_biomes.hpp new file mode 100644 index 00000000..0269482f --- /dev/null +++ b/tools/editor/terrain_biomes.hpp @@ -0,0 +1,113 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace editor { + +enum class Biome { + Grassland, + Forest, + Jungle, + Desert, + Barrens, + Snow, + Swamp, + Rocky, + Beach, + Volcanic, + COUNT +}; + +struct BiomeTextures { + const char* name; + const char* base; // Primary ground texture + const char* secondary; // Secondary layer (dirt/path) + const char* accent; // Accent (rocks/roots) + const char* detail; // Detail overlay +}; + +inline const BiomeTextures& getBiomeTextures(Biome biome) { + static const std::array(Biome::COUNT)> biomes = {{ + { // Grassland + "Grassland", + "Tileset\\Elwynn\\ElwynnGrassBase.blp", + "Tileset\\Elwynn\\ElwynnDirtBase.blp", + "Tileset\\Elwynn\\ElwynnCobblestoneBase.blp", + "Tileset\\Elwynn\\ElwynnGrassHighlight.blp" + }, + { // Forest + "Forest", + "Tileset\\Ashenvale\\AshenvaleGrass.blp", + "Tileset\\Ashenvale\\AshenvaleDirt.blp", + "Tileset\\Ashenvale\\AshenvaleRoots.blp", + "Tileset\\Ashenvale\\AshenvaleMossBase.blp" + }, + { // Jungle + "Jungle", + "Tileset\\Stranglethorn\\StranglethornGrass.blp", + "Tileset\\Stranglethorn\\StranglethornDirt03.blp", + "Tileset\\Stranglethorn\\StranglethornMossRoot01.blp", + "Tileset\\Stranglethorn\\StranglethornPlants01.blp" + }, + { // Desert + "Desert", + "Tileset\\Tanaris\\TanarisSandBase01.blp", + "Tileset\\Tanaris\\TanarisCrackedGround.blp", + "Tileset\\Tanaris\\TanarisRockBase01.blp", + "Tileset\\Tanaris\\TanarisSandBase02.blp" + }, + { // Barrens + "Barrens", + "Tileset\\Barrens\\BarrensBaseDirt.blp", + "Tileset\\Barrens\\BarrensBaseGrassGold.blp", + "Tileset\\Barrens\\BarrensBaseRock.blp", + "Tileset\\Barrens\\BarrensBaseDirtLighter.blp" + }, + { // Snow + "Snow", + "Tileset\\Expansion02\\Dragonblight\\DragonblightFreshSmoothSnowA.blp", + "Tileset\\Winterspring Grove\\WinterspringDirt.blp", + "Tileset\\Winterspring Grove\\WinterspringRock.blp", + "Tileset\\Winterspring Grove\\WinterspringRockSnow.blp" + }, + { // Swamp + "Swamp", + "Tileset\\Wetlands\\WetlandsGrassDark01.blp", + "Tileset\\Wetlands\\WetlandsDirt01.blp", + "Tileset\\Wetlands\\WetlandsDirtMoss01.blp", + "Tileset\\Wetlands\\WetlandsBaseRock.blp" + }, + { // Rocky + "Rocky", + "Tileset\\Barrens\\BarrensRock01.blp", + "Tileset\\Barrens\\BarrensBaseDirt.blp", + "Tileset\\Desolace\\DesolaceRock01.blp", + "Tileset\\Desolace\\DesolaceDirt.blp" + }, + { // Beach + "Beach", + "Tileset\\Ashenvale\\AshenvaleSand.blp", + "Tileset\\Feralas\\FeralasSand.blp", + "Tileset\\Ashenvale\\AshenvaleShore.blp", + "Tileset\\Feralas\\FeralasGrass.blp" + }, + { // Volcanic + "Volcanic", + "Tileset\\Desolace\\DesolaceDirt.blp", + "Tileset\\Desolace\\DesolaceCracks.blp", + "Tileset\\Desolace\\DesolaceRock01.blp", + "Tileset\\Tanaris\\TanarisRockBaseBurn.blp" + } + }}; + return biomes[static_cast(biome)]; +} + +inline const char* getBiomeName(Biome b) { + return getBiomeTextures(b).name; +} + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/terrain_editor.cpp b/tools/editor/terrain_editor.cpp new file mode 100644 index 00000000..7cf624bb --- /dev/null +++ b/tools/editor/terrain_editor.cpp @@ -0,0 +1,530 @@ +#include "terrain_editor.hpp" +#include "core/logger.hpp" +#include +#include +#include +#include + +namespace wowee { +namespace editor { + +TerrainEditor::TerrainEditor() = default; + +pipeline::ADTTerrain TerrainEditor::createBlankTerrain(int tileX, int tileY, float baseHeight, + Biome biome) { + pipeline::ADTTerrain terrain; + terrain.loaded = true; + terrain.version = 18; + terrain.coord = {tileX, tileY}; + + const auto& biomeTextures = getBiomeTextures(biome); + + // Integer grid noise — guarantees shared edge vertices get identical heights + auto gridNoise = [](int gx, int gy) -> float { + uint32_t h = static_cast(gx * 374761393 + gy * 668265263); + h = (h ^ (h >> 13)) * 1274126177; + h = h ^ (h >> 16); + return (static_cast(h & 0xFFFF) / 65535.0f - 0.5f) * 3.0f; + }; + + for (int cy = 0; cy < 16; cy++) { + for (int cx = 0; cx < 16; cx++) { + auto& chunk = terrain.chunks[cy * 16 + cx]; + chunk.flags = 0; + chunk.indexX = cx; + chunk.indexY = cy; + chunk.holes = 0; + + chunk.position[0] = (32.0f - tileX) * TILE_SIZE - cx * CHUNK_SIZE; + chunk.position[1] = (32.0f - tileY) * TILE_SIZE - cy * CHUNK_SIZE; + chunk.position[2] = baseHeight; + + chunk.heightMap.loaded = true; + + for (int i = 0; i < 145; i++) { + int row = i / 17; + int col = i % 17; + + if (col <= 8) { + // Outer vertex — shared at chunk edges + int globalRow = cy * 8 + row; + int globalCol = cx * 8 + col; + chunk.heightMap.heights[i] = gridNoise(globalRow, globalCol); + } else { + // Inner vertex (quad center) — not shared, offset grid + int innerCol = col - 9; + int globalRow = cy * 16 + row * 2 + 1; + int globalCol = cx * 16 + innerCol * 2 + 1; + chunk.heightMap.heights[i] = gridNoise(globalRow, globalCol); + } + } + + // Normals pointing up (will be recalculated by renderer) + for (int i = 0; i < 145; i++) { + chunk.normals[i * 3 + 0] = 0; + chunk.normals[i * 3 + 1] = 0; + chunk.normals[i * 3 + 2] = 127; + } + + // Base texture layer + pipeline::TextureLayer layer{}; + layer.textureId = 0; + layer.flags = 0; + layer.offsetMCAL = 0; + layer.effectId = 0; + chunk.layers.push_back(layer); + } + } + + // Biome textures + terrain.textures.push_back(biomeTextures.base); + terrain.textures.push_back(biomeTextures.secondary); + terrain.textures.push_back(biomeTextures.accent); + terrain.textures.push_back(biomeTextures.detail); + + return terrain; +} + +glm::vec3 TerrainEditor::chunkVertexWorldPos(int chunkIdx, int vertIdx) const { + const auto& chunk = terrain_->chunks[chunkIdx]; + int tileX = terrain_->coord.x; + int tileY = terrain_->coord.y; + int cx = chunkIdx % 16; + int cy = chunkIdx / 16; + + float tileNW_renderX = (32.0f - static_cast(tileY)) * TILE_SIZE; + float tileNW_renderY = (32.0f - static_cast(tileX)) * TILE_SIZE; + float chunkBaseX = tileNW_renderX - static_cast(cy) * CHUNK_SIZE; + float chunkBaseY = tileNW_renderY - static_cast(cx) * CHUNK_SIZE; + float chunkBaseZ = chunk.position[2]; + + int row = vertIdx / 17; + int col = vertIdx % 17; + float offsetX = static_cast(col); + float offsetY = static_cast(row); + if (col > 8) { + offsetY += 0.5f; + offsetX -= 8.5f; + } + + float unitSize = CHUNK_SIZE / 8.0f; + float x = chunkBaseX - offsetY * unitSize; + float y = chunkBaseY - offsetX * unitSize; + float z = chunkBaseZ + chunk.heightMap.heights[vertIdx]; + + return glm::vec3(x, y, z); +} + +float TerrainEditor::getVertexHeight(int chunkIdx, int vertIdx) const { + return terrain_->chunks[chunkIdx].heightMap.heights[vertIdx]; +} + +void TerrainEditor::setVertexHeight(int chunkIdx, int vertIdx, float height) { + terrain_->chunks[chunkIdx].heightMap.heights[vertIdx] = height; +} + +bool TerrainEditor::raycastTerrain(const rendering::Ray& ray, glm::vec3& hitPos) const { + if (!terrain_) return false; + + float bestT = 1e30f; + bool hit = false; + + for (int chunkIdx = 0; chunkIdx < 256; chunkIdx++) { + const auto& chunk = terrain_->chunks[chunkIdx]; + if (!chunk.hasHeightMap()) continue; + + // Quick AABB check: compute chunk bounds in render space + glm::vec3 corner0 = chunkVertexWorldPos(chunkIdx, 0); + glm::vec3 corner1 = chunkVertexWorldPos(chunkIdx, 144); + glm::vec3 minB = glm::min(corner0, corner1) - glm::vec3(0, 0, 200); + glm::vec3 maxB = glm::max(corner0, corner1) + glm::vec3(0, 0, 200); + + // Simple AABB-ray test + float tmin = -1e30f, tmax = 1e30f; + for (int i = 0; i < 3; i++) { + if (std::abs(ray.direction[i]) < 1e-8f) { + if (ray.origin[i] < minB[i] || ray.origin[i] > maxB[i]) { tmin = 1e30f; break; } + } else { + float t1 = (minB[i] - ray.origin[i]) / ray.direction[i]; + float t2 = (maxB[i] - ray.origin[i]) / ray.direction[i]; + if (t1 > t2) std::swap(t1, t2); + tmin = std::max(tmin, t1); + tmax = std::min(tmax, t2); + } + } + if (tmin > tmax || tmax < 0) continue; + + // Triangle intersection for each quad + for (int qy = 0; qy < 8; qy++) { + for (int qx = 0; qx < 8; qx++) { + int center = 9 + qy * 17 + qx; + int tl = center - 9; + int tr = center - 8; + int bl = center + 8; + int br = center + 9; + + int tris[4][3] = {{center, tl, tr}, {center, tr, br}, {center, br, bl}, {center, bl, tl}}; + for (auto& tri : tris) { + glm::vec3 v0 = chunkVertexWorldPos(chunkIdx, tri[0]); + glm::vec3 v1 = chunkVertexWorldPos(chunkIdx, tri[1]); + glm::vec3 v2 = chunkVertexWorldPos(chunkIdx, tri[2]); + + // Moller-Trumbore intersection + glm::vec3 e1 = v1 - v0; + glm::vec3 e2 = v2 - v0; + glm::vec3 h = glm::cross(ray.direction, e2); + float a = glm::dot(e1, h); + if (std::abs(a) < 1e-8f) continue; + + float f = 1.0f / a; + glm::vec3 s = ray.origin - v0; + float u = f * glm::dot(s, h); + if (u < 0.0f || u > 1.0f) continue; + + glm::vec3 q = glm::cross(s, e1); + float v = f * glm::dot(ray.direction, q); + if (v < 0.0f || u + v > 1.0f) continue; + + float t = f * glm::dot(e2, q); + if (t > 0.001f && t < bestT) { + bestT = t; + hitPos = ray.origin + ray.direction * t; + hit = true; + } + } + } + } + } + + return hit; +} + +std::vector TerrainEditor::getAffectedChunks(const glm::vec3& center, float radius) const { + std::vector result; + for (int i = 0; i < 256; i++) { + if (!terrain_->chunks[i].hasHeightMap()) continue; + // Check if any vertex in chunk is within radius + glm::vec3 c0 = chunkVertexWorldPos(i, 0); + glm::vec3 c1 = chunkVertexWorldPos(i, 144); + glm::vec3 chunkCenter = (c0 + c1) * 0.5f; + float chunkRadius = glm::length(c1 - c0) * 0.5f; + if (glm::length(glm::vec2(chunkCenter.x - center.x, chunkCenter.y - center.y)) < radius + chunkRadius) + result.push_back(i); + } + return result; +} + +void TerrainEditor::beginStroke() { + if (!terrain_ || strokeActive_) return; + strokeActive_ = true; + + auto affected = getAffectedChunks(brush_.getPosition(), brush_.settings().radius); + // Capture all chunks that could be affected during the entire stroke + std::vector allChunks(256); + std::iota(allChunks.begin(), allChunks.end(), 0); + std::vector valid; + for (int i : allChunks) { + if (terrain_->chunks[i].hasHeightMap()) valid.push_back(i); + } + history_.beginEdit(*terrain_, valid); +} + +void TerrainEditor::endStroke() { + if (!strokeActive_) return; + strokeActive_ = false; + history_.endEdit(*terrain_); +} + +void TerrainEditor::applyBrush(float deltaTime) { + if (!terrain_ || !brush_.isActive()) return; + + switch (brush_.settings().mode) { + case BrushMode::Raise: applyRaise(deltaTime); break; + case BrushMode::Lower: applyRaise(deltaTime); break; + case BrushMode::Smooth: applySmooth(deltaTime); break; + case BrushMode::Flatten: + case BrushMode::Level: applyFlatten(deltaTime); break; + } +} + +void TerrainEditor::applyRaise(float dt) { + float amount = brush_.settings().strength * dt; + if (brush_.settings().mode == BrushMode::Lower) amount = -amount; + + auto affected = getAffectedChunks(brush_.getPosition(), brush_.settings().radius); + for (int chunkIdx : affected) { + bool modified = false; + for (int v = 0; v < 145; v++) { + glm::vec3 pos = chunkVertexWorldPos(chunkIdx, v); + float dist = glm::length(glm::vec2(pos.x - brush_.getPosition().x, + pos.y - brush_.getPosition().y)); + float influence = brush_.getInfluence(dist); + if (influence > 0.0f) { + float h = getVertexHeight(chunkIdx, v); + setVertexHeight(chunkIdx, v, h + amount * influence); + modified = true; + } + } + if (modified) { + stitchEdges(chunkIdx); + if (std::find(dirtyChunks_.begin(), dirtyChunks_.end(), chunkIdx) == dirtyChunks_.end()) + dirtyChunks_.push_back(chunkIdx); + dirty_ = true; + } + } +} + +void TerrainEditor::applySmooth(float dt) { + float factor = std::min(1.0f, brush_.settings().strength * dt * 0.5f); + + auto affected = getAffectedChunks(brush_.getPosition(), brush_.settings().radius); + + // Build a snapshot of all heights so we read from consistent state + std::array, 256> snapshot; + for (int ci : affected) + for (int v = 0; v < 145; v++) + snapshot[ci][v] = getVertexHeight(ci, v); + + // Helper: get height of vertex at global outer grid position, + // looking across chunk boundaries + auto getGlobalOuterHeight = [&](int chunkIdx, int row, int col) -> float { + int cx = chunkIdx % 16; + int cy = chunkIdx / 16; + + // If within chunk bounds, return directly + if (row >= 0 && row <= 8 && col >= 0 && col <= 8) { + int vi = row * 17 + col; + return snapshot[chunkIdx][vi]; + } + + // Cross into adjacent chunk + int ncx = cx, ncy = cy; + int nr = row, nc = col; + if (row < 0) { ncy = cy - 1; nr = 8; } + if (row > 8) { ncy = cy + 1; nr = 0; } + if (col < 0) { ncx = cx - 1; nc = 8; } + if (col > 8) { ncx = cx + 1; nc = 0; } + + if (ncx < 0 || ncx > 15 || ncy < 0 || ncy > 15) + return snapshot[chunkIdx][std::clamp(row, 0, 8) * 17 + std::clamp(col, 0, 8)]; + + int nci = ncy * 16 + ncx; + if (!terrain_->chunks[nci].hasHeightMap()) + return snapshot[chunkIdx][std::clamp(row, 0, 8) * 17 + std::clamp(col, 0, 8)]; + + int vi = nr * 17 + nc; + return snapshot[nci][vi]; + }; + + for (int chunkIdx : affected) { + bool modified = false; + for (int v = 0; v < 145; v++) { + glm::vec3 pos = chunkVertexWorldPos(chunkIdx, v); + float dist = glm::length(glm::vec2(pos.x - brush_.getPosition().x, + pos.y - brush_.getPosition().y)); + float influence = brush_.getInfluence(dist); + if (influence <= 0.0f) continue; + + int row = v / 17; + int col = v % 17; + + float sum = 0.0f; + int count = 0; + + if (col <= 8) { + // Outer vertex — sample 4 neighbors, crossing chunk borders + int dirs[][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; + for (auto& d : dirs) { + sum += getGlobalOuterHeight(chunkIdx, row + d[0], col + d[1]); + count++; + } + } else { + // Inner vertex — use same-chunk neighbors only + int neighbors[] = {v - 17, v + 17, v - 1, v + 1}; + for (int n : neighbors) { + if (n >= 0 && n < 145) { + sum += snapshot[chunkIdx][n]; + count++; + } + } + } + + if (count > 0) { + float avg = sum / static_cast(count); + float h = snapshot[chunkIdx][v]; + float newH = h + (avg - h) * factor * influence; + if (newH != h) { + setVertexHeight(chunkIdx, v, newH); + modified = true; + } + } + } + + if (modified) { + stitchEdges(chunkIdx); + if (std::find(dirtyChunks_.begin(), dirtyChunks_.end(), chunkIdx) == dirtyChunks_.end()) + dirtyChunks_.push_back(chunkIdx); + dirty_ = true; + } + } +} + +void TerrainEditor::stitchEdges(int chunkIdx) { + int cx = chunkIdx % 16; + int cy = chunkIdx / 16; + + auto pushDirty = [&](int idx) { + if (std::find(dirtyChunks_.begin(), dirtyChunks_.end(), idx) == dirtyChunks_.end()) + dirtyChunks_.push_back(idx); + }; + + if (cx < 15) { + int n = cy * 16 + cx + 1; + if (terrain_->chunks[n].hasHeightMap()) { + for (int r = 0; r <= 8; r++) + setVertexHeight(n, r * 17, getVertexHeight(chunkIdx, r * 17 + 8)); + pushDirty(n); + } + } + if (cx > 0) { + int n = cy * 16 + cx - 1; + if (terrain_->chunks[n].hasHeightMap()) { + for (int r = 0; r <= 8; r++) + setVertexHeight(n, r * 17 + 8, getVertexHeight(chunkIdx, r * 17)); + pushDirty(n); + } + } + if (cy < 15) { + int n = (cy + 1) * 16 + cx; + if (terrain_->chunks[n].hasHeightMap()) { + for (int c = 0; c <= 8; c++) + setVertexHeight(n, c, getVertexHeight(chunkIdx, 8 * 17 + c)); + pushDirty(n); + } + } + if (cy > 0) { + int n = (cy - 1) * 16 + cx; + if (terrain_->chunks[n].hasHeightMap()) { + for (int c = 0; c <= 8; c++) + setVertexHeight(n, 8 * 17 + c, getVertexHeight(chunkIdx, c)); + pushDirty(n); + } + } +} + +void TerrainEditor::applyFlatten(float dt) { + float factor = std::min(1.0f, brush_.settings().strength * dt * 0.3f); + float targetH = brush_.settings().flattenHeight; + + auto affected = getAffectedChunks(brush_.getPosition(), brush_.settings().radius); + for (int chunkIdx : affected) { + bool modified = false; + for (int v = 0; v < 145; v++) { + glm::vec3 pos = chunkVertexWorldPos(chunkIdx, v); + float dist = glm::length(glm::vec2(pos.x - brush_.getPosition().x, + pos.y - brush_.getPosition().y)); + float influence = brush_.getInfluence(dist); + if (influence <= 0.0f) continue; + + float h = getVertexHeight(chunkIdx, v); + // targetH is absolute world Z; heights are relative to chunk base + float relTarget = targetH - terrain_->chunks[chunkIdx].position[2]; + float newH = h + (relTarget - h) * factor * influence; + if (newH != h) { + setVertexHeight(chunkIdx, v, newH); + modified = true; + } + } + if (modified) { + stitchEdges(chunkIdx); + if (std::find(dirtyChunks_.begin(), dirtyChunks_.end(), chunkIdx) == dirtyChunks_.end()) + dirtyChunks_.push_back(chunkIdx); + dirty_ = true; + } + } +} + +std::vector TerrainEditor::consumeDirtyChunks() { + std::vector result; + result.swap(dirtyChunks_); + return result; +} + +pipeline::TerrainMesh TerrainEditor::regenerateMesh() const { + if (!terrain_) return {}; + return pipeline::TerrainMeshGenerator::generate(*terrain_); +} + +pipeline::ChunkMesh TerrainEditor::regenerateChunkMesh(int chunkIndex) const { + if (!terrain_) return {}; + auto mesh = pipeline::TerrainMeshGenerator::generate(*terrain_); + return mesh.chunks[chunkIndex]; +} + +void TerrainEditor::undo() { + if (!terrain_) return; + history_.undo(*terrain_); + for (int idx : history_.lastAffectedChunks()) { + if (std::find(dirtyChunks_.begin(), dirtyChunks_.end(), idx) == dirtyChunks_.end()) + dirtyChunks_.push_back(idx); + } +} + +void TerrainEditor::redo() { + if (!terrain_) return; + history_.redo(*terrain_); + for (int idx : history_.lastAffectedChunks()) { + if (std::find(dirtyChunks_.begin(), dirtyChunks_.end(), idx) == dirtyChunks_.end()) + dirtyChunks_.push_back(idx); + } +} + +void TerrainEditor::setWaterLevel(const glm::vec3& center, float radius, + float waterHeight, uint16_t liquidType) { + if (!terrain_) return; + + auto affected = getAffectedChunks(center, radius); + for (int chunkIdx : affected) { + auto& water = terrain_->waterData[chunkIdx]; + + if (water.layers.empty()) { + pipeline::ADTTerrain::WaterLayer wl; + wl.liquidType = liquidType; + wl.flags = 0; + wl.minHeight = waterHeight; + wl.maxHeight = waterHeight; + wl.x = 0; + wl.y = 0; + wl.width = 9; + wl.height = 9; + wl.heights.assign(81, waterHeight); + wl.mask.assign(8, 0xFF); + water.layers.push_back(wl); + } else { + auto& wl = water.layers[0]; + wl.minHeight = waterHeight; + wl.maxHeight = waterHeight; + wl.liquidType = liquidType; + std::fill(wl.heights.begin(), wl.heights.end(), waterHeight); + } + + if (std::find(dirtyChunks_.begin(), dirtyChunks_.end(), chunkIdx) == dirtyChunks_.end()) + dirtyChunks_.push_back(chunkIdx); + dirty_ = true; + } +} + +void TerrainEditor::removeWater(const glm::vec3& center, float radius) { + if (!terrain_) return; + + auto affected = getAffectedChunks(center, radius); + for (int chunkIdx : affected) { + terrain_->waterData[chunkIdx].layers.clear(); + if (std::find(dirtyChunks_.begin(), dirtyChunks_.end(), chunkIdx) == dirtyChunks_.end()) + dirtyChunks_.push_back(chunkIdx); + dirty_ = true; + } +} + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/terrain_editor.hpp b/tools/editor/terrain_editor.hpp new file mode 100644 index 00000000..05cf4c37 --- /dev/null +++ b/tools/editor/terrain_editor.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include "editor_brush.hpp" +#include "editor_history.hpp" +#include "terrain_biomes.hpp" +#include "pipeline/adt_loader.hpp" +#include "pipeline/terrain_mesh.hpp" +#include "rendering/camera.hpp" +#include +#include + +namespace wowee { +namespace editor { + +class TerrainEditor { +public: + TerrainEditor(); + + void setTerrain(pipeline::ADTTerrain* terrain) { terrain_ = terrain; } + pipeline::ADTTerrain* getTerrain() { return terrain_; } + const pipeline::ADTTerrain* getTerrain() const { return terrain_; } + + EditorBrush& brush() { return brush_; } + const EditorBrush& brush() const { return brush_; } + EditorHistory& history() { return history_; } + + static pipeline::ADTTerrain createBlankTerrain(int tileX, int tileY, float baseHeight = 100.0f, + Biome biome = Biome::Grassland); + + // Raycast against terrain, returns true if hit + bool raycastTerrain(const rendering::Ray& ray, glm::vec3& hitPos) const; + + // Apply brush at current position (call per-frame while painting) + void applyBrush(float deltaTime); + + // Begin/end a paint stroke (for undo grouping) + void beginStroke(); + void endStroke(); + bool isStrokeActive() const { return strokeActive_; } + + // Get chunks modified since last call (for re-upload) + std::vector consumeDirtyChunks(); + + // Regenerate mesh for specific chunks + pipeline::TerrainMesh regenerateMesh() const; + pipeline::ChunkMesh regenerateChunkMesh(int chunkIndex) const; + + void undo(); + void redo(); + + // Water editing + void setWaterLevel(const glm::vec3& center, float radius, float waterHeight, uint16_t liquidType = 0); + void removeWater(const glm::vec3& center, float radius); + + bool hasUnsavedChanges() const { return dirty_; } + void markSaved() { dirty_ = false; } + +private: + void applyRaise(float dt); + void applySmooth(float dt); + void applyFlatten(float dt); + void stitchEdges(int chunkIdx); + + std::vector getAffectedChunks(const glm::vec3& center, float radius) const; + glm::vec3 chunkVertexWorldPos(int chunkIdx, int vertIdx) const; + float getVertexHeight(int chunkIdx, int vertIdx) const; + void setVertexHeight(int chunkIdx, int vertIdx, float height); + + pipeline::ADTTerrain* terrain_ = nullptr; + EditorBrush brush_; + EditorHistory history_; + + bool strokeActive_ = false; + bool dirty_ = false; + std::vector dirtyChunks_; + + static constexpr float TILE_SIZE = 533.33333f; + static constexpr float CHUNK_SIZE = TILE_SIZE / 16.0f; + static constexpr float GRID_STEP = CHUNK_SIZE / 8.0f; +}; + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/texture_painter.cpp b/tools/editor/texture_painter.cpp new file mode 100644 index 00000000..55596b9c --- /dev/null +++ b/tools/editor/texture_painter.cpp @@ -0,0 +1,194 @@ +#include "texture_painter.hpp" +#include "core/logger.hpp" +#include +#include + +namespace wowee { +namespace editor { + +void TexturePainter::setActiveTexture(const std::string& texturePath) { + activeTexture_ = texturePath; +} + +uint32_t TexturePainter::ensureTextureInList(const std::string& path) { + for (uint32_t i = 0; i < terrain_->textures.size(); i++) { + if (terrain_->textures[i] == path) return i; + } + terrain_->textures.push_back(path); + return static_cast(terrain_->textures.size() - 1); +} + +int TexturePainter::ensureLayerOnChunk(int chunkIdx, uint32_t textureId) { + auto& chunk = terrain_->chunks[chunkIdx]; + + for (int i = 0; i < static_cast(chunk.layers.size()); i++) { + if (chunk.layers[i].textureId == textureId) return i; + } + + if (chunk.layers.size() < 4) { + pipeline::TextureLayer layer{}; + layer.textureId = textureId; + layer.flags = 0x100; + layer.offsetMCAL = static_cast(chunk.alphaMap.size()); + layer.effectId = 0; + chunk.layers.push_back(layer); + chunk.alphaMap.resize(chunk.alphaMap.size() + 4096, 0); + return static_cast(chunk.layers.size() - 1); + } + + // At 4 layers — find the non-base layer with lowest total alpha and replace it + int weakest = -1; + int weakestSum = INT32_MAX; + for (int i = 1; i < static_cast(chunk.layers.size()); i++) { + if (chunk.layers[i].textureId == textureId) return i; + size_t off = chunk.layers[i].offsetMCAL; + if (off + 4096 > chunk.alphaMap.size()) continue; + int sum = 0; + for (int j = 0; j < 4096; j++) sum += chunk.alphaMap[off + j]; + if (sum < weakestSum) { weakestSum = sum; weakest = i; } + } + + if (weakest < 0) return -1; + + // Replace the weakest layer + chunk.layers[weakest].textureId = textureId; + size_t off = chunk.layers[weakest].offsetMCAL; + std::fill(chunk.alphaMap.begin() + off, chunk.alphaMap.begin() + off + 4096, 0); + return weakest; +} + +glm::vec2 TexturePainter::worldToChunkUV(int chunkIdx, const glm::vec3& worldPos) const { + int cx = chunkIdx % 16; + int cy = chunkIdx / 16; + int tileX = terrain_->coord.x; + int tileY = terrain_->coord.y; + + float tileNW_X = (32.0f - static_cast(tileY)) * TILE_SIZE; + float tileNW_Y = (32.0f - static_cast(tileX)) * TILE_SIZE; + float chunkBaseX = tileNW_X - static_cast(cy) * CHUNK_SIZE; + float chunkBaseY = tileNW_Y - static_cast(cx) * CHUNK_SIZE; + + // UV: 0,0 at chunk NW corner, 1,1 at SE corner + float u = (chunkBaseX - worldPos.x) / CHUNK_SIZE; + float v = (chunkBaseY - worldPos.y) / CHUNK_SIZE; + return glm::vec2(u, v); +} + +void TexturePainter::modifyAlpha(int chunkIdx, int layerIdx, const glm::vec3& center, + float radius, float strength, float falloff, bool erasing) { + auto& chunk = terrain_->chunks[chunkIdx]; + auto& layer = chunk.layers[layerIdx]; + + // Find alpha data offset for this layer + size_t alphaOffset = layer.offsetMCAL; + if (alphaOffset + 4096 > chunk.alphaMap.size()) return; + + int cx = chunkIdx % 16; + int cy = chunkIdx / 16; + int tileX = terrain_->coord.x; + int tileY = terrain_->coord.y; + + float tileNW_X = (32.0f - static_cast(tileY)) * TILE_SIZE; + float tileNW_Y = (32.0f - static_cast(tileX)) * TILE_SIZE; + float chunkBaseX = tileNW_X - static_cast(cy) * CHUNK_SIZE; + float chunkBaseY = tileNW_Y - static_cast(cx) * CHUNK_SIZE; + + float texelSize = CHUNK_SIZE / 64.0f; + + for (int ty = 0; ty < 64; ty++) { + for (int tx = 0; tx < 64; tx++) { + // World position of this alpha texel + float wx = chunkBaseX - (static_cast(ty) + 0.5f) * texelSize; + float wy = chunkBaseY - (static_cast(tx) + 0.5f) * texelSize; + + float dist = std::sqrt((wx - center.x) * (wx - center.x) + + (wy - center.y) * (wy - center.y)); + if (dist >= radius) continue; + + // Falloff + float t = dist / radius; + float innerRadius = 1.0f - falloff; + float influence = 1.0f; + if (t > innerRadius && falloff > 0.001f) { + float ft = (t - innerRadius) / falloff; + influence = 1.0f - ft * ft; + } + + size_t idx = alphaOffset + ty * 64 + tx; + float current = static_cast(chunk.alphaMap[idx]) / 255.0f; + float delta = strength * influence; + + float newVal; + if (erasing) + newVal = std::max(0.0f, current - delta); + else + newVal = std::min(1.0f, current + delta); + + chunk.alphaMap[idx] = static_cast(newVal * 255.0f); + } + } +} + +std::vector TexturePainter::paint(const glm::vec3& center, float radius, + float strength, float falloff) { + if (!terrain_ || activeTexture_.empty()) return {}; + + uint32_t texId = ensureTextureInList(activeTexture_); + std::vector modified; + + for (int i = 0; i < 256; i++) { + if (!terrain_->chunks[i].hasHeightMap()) continue; + + // Quick distance check from chunk center + int cx = i % 16; + int cy = i / 16; + float tileNW_X = (32.0f - static_cast(terrain_->coord.y)) * TILE_SIZE; + float tileNW_Y = (32.0f - static_cast(terrain_->coord.x)) * TILE_SIZE; + float chunkCenterX = tileNW_X - (cy + 0.5f) * CHUNK_SIZE; + float chunkCenterY = tileNW_Y - (cx + 0.5f) * CHUNK_SIZE; + float dist = std::sqrt((chunkCenterX - center.x) * (chunkCenterX - center.x) + + (chunkCenterY - center.y) * (chunkCenterY - center.y)); + if (dist > radius + CHUNK_SIZE) continue; + + int layerIdx = ensureLayerOnChunk(i, texId); + if (layerIdx < 0) continue; // chunk full + + modifyAlpha(i, layerIdx, center, radius, strength, falloff, false); + modified.push_back(i); + } + + return modified; +} + +std::vector TexturePainter::erase(const glm::vec3& center, float radius, + float strength, float falloff) { + if (!terrain_ || activeTexture_.empty()) return {}; + + std::vector modified; + + for (int i = 0; i < 256; i++) { + if (!terrain_->chunks[i].hasHeightMap()) continue; + auto& chunk = terrain_->chunks[i]; + + int cx = i % 16; + int cy = i / 16; + float tileNW_X = (32.0f - static_cast(terrain_->coord.y)) * TILE_SIZE; + float tileNW_Y = (32.0f - static_cast(terrain_->coord.x)) * TILE_SIZE; + float chunkCenterX = tileNW_X - (cy + 0.5f) * CHUNK_SIZE; + float chunkCenterY = tileNW_Y - (cx + 0.5f) * CHUNK_SIZE; + float dist = std::sqrt((chunkCenterX - center.x) * (chunkCenterX - center.x) + + (chunkCenterY - center.y) * (chunkCenterY - center.y)); + if (dist > radius + CHUNK_SIZE) continue; + + // Erase all non-base layers in range + for (int l = 1; l < static_cast(chunk.layers.size()); l++) { + modifyAlpha(i, l, center, radius, strength, falloff, true); + } + modified.push_back(i); + } + + return modified; +} + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/texture_painter.hpp b/tools/editor/texture_painter.hpp new file mode 100644 index 00000000..5d1d7a66 --- /dev/null +++ b/tools/editor/texture_painter.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include "editor_brush.hpp" +#include "pipeline/adt_loader.hpp" +#include +#include +#include + +namespace wowee { +namespace editor { + +class TexturePainter { +public: + void setTerrain(pipeline::ADTTerrain* terrain) { terrain_ = terrain; } + + void setActiveTexture(const std::string& texturePath); + const std::string& getActiveTexture() const { return activeTexture_; } + + // Paint the active texture at the given world position + // Returns list of modified chunk indices + std::vector paint(const glm::vec3& center, float radius, float strength, float falloff); + + // Erase a texture layer at the given position + std::vector erase(const glm::vec3& center, float radius, float strength, float falloff); + +private: + uint32_t ensureTextureInList(const std::string& path); + int ensureLayerOnChunk(int chunkIdx, uint32_t textureId); + void modifyAlpha(int chunkIdx, int layerIdx, const glm::vec3& center, + float radius, float strength, float falloff, bool erasing); + + glm::vec2 worldToChunkUV(int chunkIdx, const glm::vec3& worldPos) const; + + pipeline::ADTTerrain* terrain_ = nullptr; + std::string activeTexture_; + + static constexpr float TILE_SIZE = 533.33333f; + static constexpr float CHUNK_SIZE = TILE_SIZE / 16.0f; +}; + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/transform_gizmo.cpp b/tools/editor/transform_gizmo.cpp new file mode 100644 index 00000000..31639190 --- /dev/null +++ b/tools/editor/transform_gizmo.cpp @@ -0,0 +1,286 @@ +#include "transform_gizmo.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_shader.hpp" +#include "core/logger.hpp" +#include +#include + +namespace wowee { +namespace editor { + +TransformGizmo::TransformGizmo() = default; +TransformGizmo::~TransformGizmo() { shutdown(); } + +bool TransformGizmo::initialize(rendering::VkContext* ctx, VkRenderPass renderPass, + VkDescriptorSetLayout perFrameLayout) { + vkCtx_ = ctx; + renderPass_ = renderPass; + perFrameLayout_ = perFrameLayout; + return createPipeline(); +} + +void TransformGizmo::shutdown() { + if (!vkCtx_) return; + if (vertexBuffer_) { + vmaDestroyBuffer(vkCtx_->getAllocator(), vertexBuffer_, vertexAlloc_); + vertexBuffer_ = VK_NULL_HANDLE; + } + if (pipeline_) { vkDestroyPipeline(vkCtx_->getDevice(), pipeline_, nullptr); pipeline_ = VK_NULL_HANDLE; } + if (pipelineLayout_) { vkDestroyPipelineLayout(vkCtx_->getDevice(), pipelineLayout_, nullptr); pipelineLayout_ = VK_NULL_HANDLE; } + vkCtx_ = nullptr; +} + +void TransformGizmo::setTarget(const glm::vec3& position, float scale) { + targetPos_ = position; + targetScale_ = scale; + visible_ = true; + updateBuffers(); +} + +void TransformGizmo::beginDrag(const glm::vec2& screenPos) { + dragging_ = true; + dragStart_ = screenPos; + dragCurrent_ = screenPos; + moveDelta_ = glm::vec3(0); + rotateDelta_ = glm::vec3(0); + scaleDelta_ = 0.0f; +} + +void TransformGizmo::updateDrag(const glm::vec2& screenPos, const rendering::Camera& camera, + float screenW, float screenH) { + if (!dragging_) return; + + glm::vec2 delta = screenPos - dragCurrent_; + dragCurrent_ = screenPos; + + float sensitivity = 1.0f; + + if (mode_ == TransformMode::Move) { + glm::vec3 right = camera.getRight(); + glm::vec3 forward = camera.getForward(); + forward.z = 0; forward = glm::normalize(forward); + + if (axis_ == TransformAxis::X || axis_ == TransformAxis::All) + moveDelta_ += right * delta.x * sensitivity; + if (axis_ == TransformAxis::Y || axis_ == TransformAxis::All) + moveDelta_ -= forward * delta.y * sensitivity; + if (axis_ == TransformAxis::Z) + moveDelta_.z -= delta.y * sensitivity; + + } else if (mode_ == TransformMode::Rotate) { + float rotSpeed = 0.5f; + if (axis_ == TransformAxis::Z || axis_ == TransformAxis::All) + rotateDelta_.z += delta.x * rotSpeed; + if (axis_ == TransformAxis::X) + rotateDelta_.x += delta.y * rotSpeed; + if (axis_ == TransformAxis::Y) + rotateDelta_.y += delta.y * rotSpeed; + + } else if (mode_ == TransformMode::Scale) { + scaleDelta_ += delta.x * 0.01f; + } + + (void)screenW; (void)screenH; +} + +void TransformGizmo::endDrag() { + dragging_ = false; +} + +void TransformGizmo::updateBuffers() { + if (!vkCtx_ || !visible_) return; + + if (vertexBuffer_) { + vmaDestroyBuffer(vkCtx_->getAllocator(), vertexBuffer_, vertexAlloc_); + vertexBuffer_ = VK_NULL_HANDLE; + } + + std::vector verts; + float len = 15.0f * targetScale_; + float tip = 3.0f * targetScale_; + float w = 0.8f * targetScale_; + glm::vec3 p = targetPos_; + + auto addLine = [&](glm::vec3 a, glm::vec3 b, float r, float g, float bl, float alpha) { + // Thick line as thin quad + glm::vec3 dir = glm::normalize(b - a); + glm::vec3 up(0,0,1); + if (std::abs(glm::dot(dir, up)) > 0.99f) up = glm::vec3(1,0,0); + glm::vec3 side = glm::normalize(glm::cross(dir, up)) * w * 0.5f; + + GizmoVertex v; + v.color[0] = r; v.color[1] = g; v.color[2] = bl; v.color[3] = alpha; + v.pos[0] = a.x+side.x; v.pos[1] = a.y+side.y; v.pos[2] = a.z+side.z; verts.push_back(v); + v.pos[0] = a.x-side.x; v.pos[1] = a.y-side.y; v.pos[2] = a.z-side.z; verts.push_back(v); + v.pos[0] = b.x+side.x; v.pos[1] = b.y+side.y; v.pos[2] = b.z+side.z; verts.push_back(v); + v.pos[0] = b.x+side.x; v.pos[1] = b.y+side.y; v.pos[2] = b.z+side.z; verts.push_back(v); + v.pos[0] = a.x-side.x; v.pos[1] = a.y-side.y; v.pos[2] = a.z-side.z; verts.push_back(v); + v.pos[0] = b.x-side.x; v.pos[1] = b.y-side.y; v.pos[2] = b.z-side.z; verts.push_back(v); + }; + + auto addArrowhead = [&](glm::vec3 base, glm::vec3 tipPt, float r, float g, float bl) { + glm::vec3 dir = glm::normalize(tipPt - base); + glm::vec3 up(0,0,1); + if (std::abs(glm::dot(dir, up)) > 0.99f) up = glm::vec3(1,0,0); + glm::vec3 s1 = glm::normalize(glm::cross(dir, up)) * tip * 0.4f; + glm::vec3 s2 = glm::normalize(glm::cross(dir, s1)) * tip * 0.4f; + + GizmoVertex v; + v.color[0] = r; v.color[1] = g; v.color[2] = bl; v.color[3] = 1.0f; + // 4 faces + auto tri = [&](glm::vec3 a, glm::vec3 b, glm::vec3 c) { + v.pos[0]=a.x; v.pos[1]=a.y; v.pos[2]=a.z; verts.push_back(v); + v.pos[0]=b.x; v.pos[1]=b.y; v.pos[2]=b.z; verts.push_back(v); + v.pos[0]=c.x; v.pos[1]=c.y; v.pos[2]=c.z; verts.push_back(v); + }; + tri(tipPt, base+s1, base+s2); + tri(tipPt, base+s2, base-s1); + tri(tipPt, base-s1, base-s2); + tri(tipPt, base-s2, base+s1); + }; + + bool showMove = (mode_ == TransformMode::Move || mode_ == TransformMode::None); + bool showRot = (mode_ == TransformMode::Rotate); + bool showScale = (mode_ == TransformMode::Scale); + + float xAlpha = (axis_ == TransformAxis::X || axis_ == TransformAxis::All) ? 1.0f : 0.3f; + float yAlpha = (axis_ == TransformAxis::Y || axis_ == TransformAxis::All) ? 1.0f : 0.3f; + float zAlpha = (axis_ == TransformAxis::Z || axis_ == TransformAxis::All) ? 1.0f : 0.3f; + + if (showMove || showRot) { + // X axis - Red + addLine(p, p + glm::vec3(len, 0, 0), 1, 0.2f, 0.2f, xAlpha); + addArrowhead(p + glm::vec3(len, 0, 0), p + glm::vec3(len + tip, 0, 0), 1, 0.2f, 0.2f); + // Y axis - Green + addLine(p, p + glm::vec3(0, len, 0), 0.2f, 1, 0.2f, yAlpha); + addArrowhead(p + glm::vec3(0, len, 0), p + glm::vec3(0, len + tip, 0), 0.2f, 1, 0.2f); + // Z axis - Blue + addLine(p, p + glm::vec3(0, 0, len), 0.3f, 0.3f, 1, zAlpha); + addArrowhead(p + glm::vec3(0, 0, len), p + glm::vec3(0, 0, len + tip), 0.3f, 0.3f, 1); + } + + if (showScale) { + // Scale indicator: box at each axis end + float bs = tip * 0.5f; + auto addBox = [&](glm::vec3 c, float r, float g, float bl) { + // Simple cube from 12 triangles + GizmoVertex v; v.color[0]=r; v.color[1]=g; v.color[2]=bl; v.color[3]=1; + auto face = [&](glm::vec3 a, glm::vec3 b, glm::vec3 cc, glm::vec3 d) { + v.pos[0]=a.x;v.pos[1]=a.y;v.pos[2]=a.z;verts.push_back(v); + v.pos[0]=b.x;v.pos[1]=b.y;v.pos[2]=b.z;verts.push_back(v); + v.pos[0]=cc.x;v.pos[1]=cc.y;v.pos[2]=cc.z;verts.push_back(v); + v.pos[0]=cc.x;v.pos[1]=cc.y;v.pos[2]=cc.z;verts.push_back(v); + v.pos[0]=d.x;v.pos[1]=d.y;v.pos[2]=d.z;verts.push_back(v); + v.pos[0]=a.x;v.pos[1]=a.y;v.pos[2]=a.z;verts.push_back(v); + }; + face(c+glm::vec3(-bs,-bs,bs),c+glm::vec3(bs,-bs,bs),c+glm::vec3(bs,bs,bs),c+glm::vec3(-bs,bs,bs)); + face(c+glm::vec3(-bs,-bs,-bs),c+glm::vec3(-bs,bs,-bs),c+glm::vec3(bs,bs,-bs),c+glm::vec3(bs,-bs,-bs)); + }; + addLine(p, p + glm::vec3(len, 0, 0), 1, 0.5f, 0, 1); + addBox(p + glm::vec3(len, 0, 0), 1, 0.5f, 0); + addLine(p, p + glm::vec3(0, len, 0), 0.5f, 1, 0, 1); + addBox(p + glm::vec3(0, len, 0), 0.5f, 1, 0); + addLine(p, p + glm::vec3(0, 0, len), 0, 0.5f, 1, 1); + addBox(p + glm::vec3(0, 0, len), 0, 0.5f, 1); + } + + if (verts.empty()) return; + vertexCount_ = static_cast(verts.size()); + + VkBufferCreateInfo bufInfo{}; + bufInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufInfo.size = verts.size() * sizeof(GizmoVertex); + bufInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT; + VmaAllocationCreateInfo allocInfo{}; + allocInfo.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + allocInfo.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + VmaAllocationInfo mapInfo{}; + if (vmaCreateBuffer(vkCtx_->getAllocator(), &bufInfo, &allocInfo, + &vertexBuffer_, &vertexAlloc_, &mapInfo) == VK_SUCCESS) { + std::memcpy(mapInfo.pMappedData, verts.data(), verts.size() * sizeof(GizmoVertex)); + } +} + +void TransformGizmo::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { + if (!visible_ || !vertexBuffer_ || vertexCount_ == 0 || !pipeline_) return; + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_, + 0, 1, &perFrameSet, 0, nullptr); + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &vertexBuffer_, &offset); + vkCmdDraw(cmd, vertexCount_, 1, 0, 0); +} + +bool TransformGizmo::createPipeline() { + VkDevice dev = vkCtx_->getDevice(); + + VkPipelineLayoutCreateInfo layoutInfo{}; + layoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + layoutInfo.setLayoutCount = 1; + layoutInfo.pSetLayouts = &perFrameLayout_; + if (vkCreatePipelineLayout(dev, &layoutInfo, nullptr, &pipelineLayout_) != VK_SUCCESS) + return false; + + rendering::VkShaderModule vertMod, fragMod; + if (!vertMod.loadFromFile(dev, "assets/shaders/editor_water.vert.spv") || + !fragMod.loadFromFile(dev, "assets/shaders/editor_water.frag.spv")) { + LOG_WARNING("Gizmo shaders not found"); + return true; + } + + VkPipelineShaderStageCreateInfo stages[2] = { vertMod.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragMod.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT) }; + + VkVertexInputBindingDescription binding{}; binding.stride = sizeof(GizmoVertex); + VkVertexInputAttributeDescription attrs[2]{}; + attrs[0].location=0; attrs[0].format=VK_FORMAT_R32G32B32_SFLOAT; attrs[0].offset=0; + attrs[1].location=1; attrs[1].format=VK_FORMAT_R32G32B32A32_SFLOAT; attrs[1].offset=12; + + VkPipelineVertexInputStateCreateInfo vi{}; vi.sType=VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO; + vi.vertexBindingDescriptionCount=1; vi.pVertexBindingDescriptions=&binding; + vi.vertexAttributeDescriptionCount=2; vi.pVertexAttributeDescriptions=attrs; + + VkPipelineInputAssemblyStateCreateInfo ia{}; ia.sType=VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO; + ia.topology=VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; + + VkPipelineViewportStateCreateInfo vps{}; vps.sType=VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO; + vps.viewportCount=1; vps.scissorCount=1; + + VkPipelineRasterizationStateCreateInfo rast{}; rast.sType=VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO; + rast.polygonMode=VK_POLYGON_MODE_FILL; rast.cullMode=VK_CULL_MODE_NONE; rast.lineWidth=1; + + VkPipelineMultisampleStateCreateInfo ms{}; ms.sType=VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO; + ms.rasterizationSamples=vkCtx_->getMsaaSamples(); + + VkPipelineDepthStencilStateCreateInfo ds{}; ds.sType=VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO; + ds.depthTestEnable=VK_FALSE; // Always on top + + VkPipelineColorBlendAttachmentState blend{}; + blend.blendEnable=VK_TRUE; + blend.srcColorBlendFactor=VK_BLEND_FACTOR_SRC_ALPHA; blend.dstColorBlendFactor=VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + blend.colorBlendOp=VK_BLEND_OP_ADD; + blend.srcAlphaBlendFactor=VK_BLEND_FACTOR_ONE; blend.dstAlphaBlendFactor=VK_BLEND_FACTOR_ZERO; + blend.alphaBlendOp=VK_BLEND_OP_ADD; + blend.colorWriteMask=0xF; + + VkPipelineColorBlendStateCreateInfo cb{}; cb.sType=VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO; + cb.attachmentCount=1; cb.pAttachments=&blend; + + VkDynamicState dynStates[]={VK_DYNAMIC_STATE_VIEWPORT,VK_DYNAMIC_STATE_SCISSOR}; + VkPipelineDynamicStateCreateInfo dyn{}; dyn.sType=VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO; + dyn.dynamicStateCount=2; dyn.pDynamicStates=dynStates; + + VkGraphicsPipelineCreateInfo pci{}; pci.sType=VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO; + pci.stageCount=2; pci.pStages=stages; + pci.pVertexInputState=&vi; pci.pInputAssemblyState=&ia; pci.pViewportState=&vps; + pci.pRasterizationState=&rast; pci.pMultisampleState=&ms; pci.pDepthStencilState=&ds; + pci.pColorBlendState=&cb; pci.pDynamicState=&dyn; + pci.layout=pipelineLayout_; pci.renderPass=renderPass_; + + vkCreateGraphicsPipelines(dev, vkCtx_->getPipelineCache(), 1, &pci, nullptr, &pipeline_); + return true; +} + +} // namespace editor +} // namespace wowee diff --git a/tools/editor/transform_gizmo.hpp b/tools/editor/transform_gizmo.hpp new file mode 100644 index 00000000..8d190724 --- /dev/null +++ b/tools/editor/transform_gizmo.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include "rendering/camera.hpp" +#include +#include +#include + +namespace wowee { +namespace rendering { class VkContext; } + +namespace editor { + +enum class TransformMode { None, Move, Rotate, Scale }; +enum class TransformAxis { All, X, Y, Z }; + +class TransformGizmo { +public: + TransformGizmo(); + ~TransformGizmo(); + + bool initialize(rendering::VkContext* ctx, VkRenderPass renderPass, + VkDescriptorSetLayout perFrameLayout); + void shutdown(); + + void setTarget(const glm::vec3& position, float scale = 1.0f); + void setMode(TransformMode mode) { mode_ = mode; } + TransformMode getMode() const { return mode_; } + void setAxis(TransformAxis axis) { axis_ = axis; } + TransformAxis getAxis() const { return axis_; } + bool isActive() const { return mode_ != TransformMode::None; } + + // Begin/end drag + void beginDrag(const glm::vec2& screenPos); + void updateDrag(const glm::vec2& screenPos, const rendering::Camera& camera, + float screenW, float screenH); + void endDrag(); + bool isDragging() const { return dragging_; } + + // Get accumulated transform delta since beginDrag + glm::vec3 getMoveDelta() const { return moveDelta_; } + glm::vec3 getRotateDelta() const { return rotateDelta_; } + float getScaleDelta() const { return scaleDelta_; } + + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); + +private: + bool createPipeline(); + void updateBuffers(); + + rendering::VkContext* vkCtx_ = nullptr; + VkRenderPass renderPass_ = VK_NULL_HANDLE; + VkDescriptorSetLayout perFrameLayout_ = VK_NULL_HANDLE; + + VkPipeline pipeline_ = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout_ = VK_NULL_HANDLE; + VkBuffer vertexBuffer_ = VK_NULL_HANDLE; + VmaAllocation vertexAlloc_ = VK_NULL_HANDLE; + uint32_t vertexCount_ = 0; + + TransformMode mode_ = TransformMode::None; + TransformAxis axis_ = TransformAxis::All; + glm::vec3 targetPos_{0}; + float targetScale_ = 1.0f; + bool visible_ = false; + + bool dragging_ = false; + glm::vec2 dragStart_{0}; + glm::vec2 dragCurrent_{0}; + glm::vec3 moveDelta_{0}; + glm::vec3 rotateDelta_{0}; + float scaleDelta_ = 0.0f; + + struct GizmoVertex { float pos[3]; float color[4]; }; +}; + +} // namespace editor +} // namespace wowee