diff --git a/CLAUDE_NEXT_SESSION.md b/CLAUDE_NEXT_SESSION.md index 954c47c..1d6c701 100644 --- a/CLAUDE_NEXT_SESSION.md +++ b/CLAUDE_NEXT_SESSION.md @@ -1,102 +1,112 @@ # GroveEngine - Session Successor Prompt -## Contexte Rapide +## Context +GroveEngine is a C++17 hot-reload game engine with a 2D bgfx-based renderer module. -GroveEngine est un moteur de jeu C++17 avec hot-reload de modules. On développe le module **BgfxRenderer** pour le rendu 2D. +## Current State - BgfxRenderer (27 Nov 2025) -## État Actuel (27 Nov 2025) +### Completed Phases -### Phases Complétées ✅ - -**Phase 1-4** - Squelette, RHI, RenderGraph, ShaderManager -- Tout fonctionne, voir commits précédents - -**Phase 5** - Pipeline IIO → Rendu ✅ (PARTIEL) -- `test_23_bgfx_sprites_visual.cpp` : test complet SDL2 + IIO + Module -- Pipeline vérifié fonctionnel : - - Module charge avec Vulkan (~500 FPS) - - IIO route les messages (sprites, camera, clear) - - SceneCollector collecte et crée FramePacket - - RenderGraph exécute les passes -- **MAIS** : Les sprites ne s'affichent PAS visuellement - -### Problème à Résoudre - -Le shader actuel (`vs_color`/`fs_color` de bgfx drawstress) est un shader position+couleur simple. Il ne supporte pas l'instancing nécessaire pour les sprites. - -**Ce qui manque :** -1. Un shader sprite avec instancing qui lit les données d'instance (position, scale, rotation, color, UV) -2. Le vertex layout correct pour le quad (pos.xy, uv.xy) -3. L'instance layout correct pour SpriteInstance - -### Fichiers Clés +| Phase | Feature | Status | +|-------|---------|--------| +| 5 | IIO Pipeline (messages → SceneCollector → RenderGraph) | Done | +| 5.5 | Sprite shader with GPU instancing (80-byte SpriteInstance) | Done | +| 6 | Texture loading via stb_image | Done | +| 6.5 | Debug overlay (FPS/stats via bgfx debug text) | Done | +| 7 | Text rendering with embedded 8x8 bitmap font | Done | +| 8A | Multi-texture support (sorted batching by textureId) | Done | +| 8B | Tilemap rendering (TilemapPass with instanced tiles) | Done | +### Key Files ``` modules/BgfxRenderer/ +├── BgfxRendererModule.cpp # Main module entry ├── Shaders/ -│ ├── ShaderManager.cpp # Charge les shaders embedded -│ ├── vs_color.bin.h # Shader actuel (PAS d'instancing) -│ └── fs_color.bin.h +│ ├── vs_sprite.sc, fs_sprite.sc # Instanced sprite shader +│ ├── varying.def.sc # Shader inputs/outputs +│ └── *.bin.h # Compiled shader bytecode ├── Passes/ -│ └── SpritePass.cpp # Execute avec instance buffer -├── Frame/ -│ └── FramePacket.h # SpriteInstance struct -└── RHI/ - └── BgfxDevice.cpp # createBuffer avec VertexLayout +│ ├── ClearPass.cpp # Clear framebuffer +│ ├── TilemapPass.cpp # Tilemap grid rendering +│ ├── SpritePass.cpp # Instanced sprite rendering (sorted by texture) +│ ├── TextPass.cpp # Text rendering (glyph quads) +│ └── DebugPass.cpp # Debug lines/shapes +├── Text/ +│ └── BitmapFont.h/.cpp # Embedded 8x8 CP437-style font +├── Resources/ +│ ├── TextureLoader.cpp # stb_image PNG/JPG loading +│ └── ResourceCache.cpp # Texture cache with numeric IDs +├── Scene/ +│ └── SceneCollector.cpp # IIO message parsing (render:*) +├── Debug/ +│ └── DebugOverlay.cpp # FPS/stats display +└── Frame/ + └── FramePacket.h # SpriteInstance, TextCommand, TilemapChunk ``` -### Structure SpriteInstance (à matcher dans le shader) - +### ResourceCache - Texture ID System ```cpp -struct SpriteInstance { - float x, y; // Position - float scaleX, scaleY; // Scale - float rotation; // Rotation en radians - float u0, v0, u1, v1; // UV coords - uint32_t color; // ABGR - uint16_t textureId; // ID texture - uint16_t layer; // Layer de tri -}; +// Load texture and get numeric ID +uint16_t texId = resourceCache->loadTextureWithId(device, "path/to/image.png"); + +// Get texture by ID (for sprite rendering) +rhi::TextureHandle tex = resourceCache->getTextureById(texId); ``` -## Prochaine Étape : Shader Sprite Instancing +### IIO Message Formats -### Option A : Shader BGFX natif (.sc) +**Sprite** (`render:sprite`) +```cpp +{ "x": 100.0, "y": 50.0, "scaleX": 32.0, "scaleY": 32.0, + "rotation": 0.0, "u0": 0.0, "v0": 0.0, "u1": 1.0, "v1": 1.0, + "textureId": 1, "color": 0xFFFFFFFF, "layer": 0 } +``` -Créer `vs_sprite.sc` et `fs_sprite.sc` avec : -- Vertex input : position (vec2), uv (vec2) -- Instance input : transform, color, uvRect -- Compiler avec shaderc pour toutes les plateformes +**Text** (`render:text`) +```cpp +{ "x": 10.0, "y": 10.0, "text": "Hello", + "fontSize": 16, "color": 0xFFFFFFFF, "layer": 100 } +``` -### Option B : Simplifier temporairement +**Tilemap** (`render:tilemap`) +```cpp +{ "x": 0.0, "y": 0.0, "width": 10, "height": 10, + "tileW": 16, "tileH": 16, "textureId": 0, + "tileData": "1,0,1,0,1,0,..." } // comma-separated tile indices +``` -Dessiner chaque sprite comme un quad individuel sans instancing : -- Plus lent mais fonctionne avec le shader actuel -- Modifier SpritePass pour soumettre un draw par sprite +## Next Task: Phase 9 - Choose One + +### Option A: Layer Sorting +- Currently sprites are sorted by textureId only +- Add proper layer sorting (render back-to-front) +- Sort key: `(layer << 16) | textureId` for efficient batching + +### Option B: Dynamic Texture Loading via IIO +- `render:texture:load` message to load textures at runtime +- Returns textureId that can be used in sprites +- Useful for dynamically loaded assets + +### Option C: Particle System +- ParticlePass for particle effects +- GPU instanced particles with lifetime/velocity +- FramePacket already has ParticleInstance struct + +### Option D: Camera Features +- Zoom and pan support (already in ViewInfo) +- Screen shake effects +- Smooth camera following ### Build & Test - ```bash -# Build -cmake -DGROVE_BUILD_BGFX_RENDERER=ON -B build-bgfx -cmake --build build-bgfx -j4 - -# Tests -./build-bgfx/tests/test_21_bgfx_triangle # Triangle coloré ✅ -./build-bgfx/tests/test_23_bgfx_sprites_visual # Pipeline OK, sprites invisibles - -# Tous les tests -cd build-bgfx && ctest --output-on-failure +cd build-bgfx +cmake --build . -j4 +cd tests && ./test_23_bgfx_sprites_visual ``` -## Notes Importantes - -- **Cross-Platform** : Linux + Windows (MinGW) -- **WSL2** : Vulkan fonctionne, pas OpenGL -- **Shaders embedded** : Pré-compilés dans .bin.h, pas de shaderc runtime -- **100% tests passent** : 20/20 - -## Questions pour la prochaine session - -1. Option A ou B pour les sprites ? -2. Priorité : voir quelque chose à l'écran vs architecture propre ? +## Notes +- Shaders are pre-compiled (embedded in .bin.h) +- shaderc at: `build-bgfx/_deps/bgfx-build/cmake/bgfx/shaderc` +- All passes reuse sprite shader (same instancing layout) +- TilemapPass: tile index 0 = empty, 1+ = actual tiles +- SpritePass: stable_sort by textureId preserves layer order within same texture diff --git a/modules/BgfxRenderer/BgfxRendererModule.cpp b/modules/BgfxRenderer/BgfxRendererModule.cpp index 7030fec..95427fe 100644 --- a/modules/BgfxRenderer/BgfxRendererModule.cpp +++ b/modules/BgfxRenderer/BgfxRendererModule.cpp @@ -8,7 +8,9 @@ #include "Resources/ResourceCache.h" #include "Debug/DebugOverlay.h" #include "Passes/ClearPass.h" +#include "Passes/TilemapPass.h" #include "Passes/SpritePass.h" +#include "Passes/TextPass.h" #include "Passes/DebugPass.h" #include @@ -84,12 +86,26 @@ void BgfxRendererModule::setConfiguration(const IDataNode& config, IIO* io, ITas m_renderGraph->addPass(std::make_unique()); m_logger->info("Added ClearPass"); + // Setup resource cache first (needed by passes) + m_resourceCache = std::make_unique(); + + // Create TilemapPass (renders before sprites) + auto tilemapPass = std::make_unique(spriteShader); + tilemapPass->setResourceCache(m_resourceCache.get()); + m_renderGraph->addPass(std::move(tilemapPass)); + m_logger->info("Added TilemapPass"); + // Create SpritePass and keep reference for texture binding auto spritePass = std::make_unique(spriteShader); m_spritePass = spritePass.get(); // Non-owning reference + m_spritePass->setResourceCache(m_resourceCache.get()); m_renderGraph->addPass(std::move(spritePass)); m_logger->info("Added SpritePass"); + // Create TextPass (uses sprite shader for glyph quads) + m_renderGraph->addPass(std::make_unique(spriteShader)); + m_logger->info("Added TextPass"); + m_renderGraph->addPass(std::make_unique(debugShader)); m_logger->info("Added DebugPass"); m_renderGraph->setup(*m_device); @@ -102,9 +118,6 @@ void BgfxRendererModule::setConfiguration(const IDataNode& config, IIO* io, ITas m_sceneCollector->setup(io); m_logger->info("SceneCollector setup complete"); - // Setup resource cache - m_resourceCache = std::make_unique(); - // Setup debug overlay m_debugOverlay = std::make_unique(); bool debugEnabled = config.getBool("debugOverlay", false); @@ -132,6 +145,17 @@ void BgfxRendererModule::process(const IDataNode& input) { // Read deltaTime from input (provided by ModuleSystem) float deltaTime = static_cast(input.getDouble("deltaTime", 0.016)); + // Check for resize in input + int newWidth = input.getInt("windowWidth", 0); + int newHeight = input.getInt("windowHeight", 0); + if (newWidth > 0 && newHeight > 0 && + (static_cast(newWidth) != m_width || static_cast(newHeight) != m_height)) { + m_width = static_cast(newWidth); + m_height = static_cast(newHeight); + m_device->reset(m_width, m_height); + m_logger->info("Window resized to {}x{}", m_width, m_height); + } + // 1. Collect IIO messages (pull-based) m_sceneCollector->collect(m_io, deltaTime); diff --git a/modules/BgfxRenderer/CMakeLists.txt b/modules/BgfxRenderer/CMakeLists.txt index 40161a9..0512d53 100644 --- a/modules/BgfxRenderer/CMakeLists.txt +++ b/modules/BgfxRenderer/CMakeLists.txt @@ -49,9 +49,14 @@ add_library(BgfxRenderer SHARED # Passes Passes/ClearPass.cpp + Passes/TilemapPass.cpp Passes/SpritePass.cpp + Passes/TextPass.cpp Passes/DebugPass.cpp + # Text + Text/BitmapFont.cpp + # Scene Scene/SceneCollector.cpp diff --git a/modules/BgfxRenderer/Passes/SpritePass.cpp b/modules/BgfxRenderer/Passes/SpritePass.cpp index 73d1cfc..2e96c40 100644 --- a/modules/BgfxRenderer/Passes/SpritePass.cpp +++ b/modules/BgfxRenderer/Passes/SpritePass.cpp @@ -1,12 +1,15 @@ #include "SpritePass.h" #include "../RHI/RHIDevice.h" #include "../Frame/FramePacket.h" +#include "../Resources/ResourceCache.h" +#include namespace grove { SpritePass::SpritePass(rhi::ShaderHandle shader) : m_shader(shader) { + m_sortedIndices.reserve(MAX_SPRITES_PER_BATCH); } void SpritePass::setup(rhi::IRHIDevice& device) { @@ -73,6 +76,18 @@ void SpritePass::shutdown(rhi::IRHIDevice& device) { // Note: m_shader is owned by ShaderManager, not destroyed here } +void SpritePass::flushBatch(rhi::IRHIDevice& device, rhi::RHICommandBuffer& cmd, + rhi::TextureHandle texture, uint32_t count) { + if (count == 0) return; + + cmd.setVertexBuffer(m_quadVB); + cmd.setIndexBuffer(m_quadIB); + cmd.setInstanceBuffer(m_instanceBuffer, 0, count); + cmd.setTexture(0, texture, m_textureSampler); + cmd.drawInstanced(6, count); + cmd.submit(0, m_shader, 0); +} + void SpritePass::execute(const FramePacket& frame, rhi::IRHIDevice& device, rhi::RHICommandBuffer& cmd) { if (frame.spriteCount == 0) { return; @@ -86,34 +101,71 @@ void SpritePass::execute(const FramePacket& frame, rhi::IRHIDevice& device, rhi: state.depthWrite = false; cmd.setState(state); - // Process sprites in batches - size_t remaining = frame.spriteCount; - size_t offset = 0; + // Build sorted indices by textureId for batching + m_sortedIndices.clear(); + m_sortedIndices.reserve(frame.spriteCount); + for (size_t i = 0; i < frame.spriteCount; ++i) { + m_sortedIndices.push_back(static_cast(i)); + } - while (remaining > 0) { - size_t batchSize = (remaining > MAX_SPRITES_PER_BATCH) - ? MAX_SPRITES_PER_BATCH : remaining; + // Sort by textureId (stable sort to preserve layer order within same texture) + std::stable_sort(m_sortedIndices.begin(), m_sortedIndices.end(), + [&frame](uint32_t a, uint32_t b) { + return frame.sprites[a].textureId < frame.sprites[b].textureId; + }); - // Update instance buffer with sprite data - // The SpriteInstance struct matches what we send to GPU - const SpriteInstance* batchData = frame.sprites + offset; - device.updateBuffer(m_instanceBuffer, batchData, - static_cast(batchSize * sizeof(SpriteInstance))); + // Process sprites in batches by texture + std::vector batchData; + batchData.reserve(MAX_SPRITES_PER_BATCH); - cmd.setVertexBuffer(m_quadVB); - cmd.setIndexBuffer(m_quadIB); - cmd.setInstanceBuffer(m_instanceBuffer, 0, static_cast(batchSize)); + uint16_t currentTextureId = UINT16_MAX; + rhi::TextureHandle currentTexture; - // Bind texture (use active texture if set, otherwise default white) - rhi::TextureHandle texToUse = m_activeTexture.isValid() ? m_activeTexture : m_defaultTexture; - cmd.setTexture(0, texToUse, m_textureSampler); + for (uint32_t idx : m_sortedIndices) { + const SpriteInstance& sprite = frame.sprites[idx]; + uint16_t texId = static_cast(sprite.textureId); - // Submit draw call - cmd.drawInstanced(6, static_cast(batchSize)); // 6 indices per quad - cmd.submit(0, m_shader, 0); + // Check if texture changed + if (texId != currentTextureId) { + // Flush previous batch + if (!batchData.empty()) { + device.updateBuffer(m_instanceBuffer, batchData.data(), + static_cast(batchData.size() * sizeof(SpriteInstance))); + flushBatch(device, cmd, currentTexture, static_cast(batchData.size())); + batchData.clear(); + } - offset += batchSize; - remaining -= batchSize; + // Update current texture + currentTextureId = texId; + if (texId == 0 || !m_resourceCache) { + // Use default/active texture for textureId=0 + currentTexture = m_activeTexture.isValid() ? m_activeTexture : m_defaultTexture; + } else { + // Look up texture by ID + currentTexture = m_resourceCache->getTextureById(texId); + if (!currentTexture.isValid()) { + currentTexture = m_activeTexture.isValid() ? m_activeTexture : m_defaultTexture; + } + } + } + + // Add sprite to batch + batchData.push_back(sprite); + + // Flush if batch is full + if (batchData.size() >= MAX_SPRITES_PER_BATCH) { + device.updateBuffer(m_instanceBuffer, batchData.data(), + static_cast(batchData.size() * sizeof(SpriteInstance))); + flushBatch(device, cmd, currentTexture, static_cast(batchData.size())); + batchData.clear(); + } + } + + // Flush remaining sprites + if (!batchData.empty()) { + device.updateBuffer(m_instanceBuffer, batchData.data(), + static_cast(batchData.size() * sizeof(SpriteInstance))); + flushBatch(device, cmd, currentTexture, static_cast(batchData.size())); } } diff --git a/modules/BgfxRenderer/Passes/SpritePass.h b/modules/BgfxRenderer/Passes/SpritePass.h index adc88c9..9f14426 100644 --- a/modules/BgfxRenderer/Passes/SpritePass.h +++ b/modules/BgfxRenderer/Passes/SpritePass.h @@ -2,11 +2,14 @@ #include "../RenderGraph/RenderPass.h" #include "../RHI/RHITypes.h" +#include namespace grove { +class ResourceCache; + // ============================================================================ -// Sprite Pass - Renders 2D sprites with batching +// Sprite Pass - Renders 2D sprites with batching by texture // ============================================================================ class SpritePass : public RenderPass { @@ -26,21 +29,37 @@ public: void execute(const FramePacket& frame, rhi::IRHIDevice& device, rhi::RHICommandBuffer& cmd) override; /** - * @brief Set a texture to use for all sprites (temporary API) - * @param texture The texture handle to use (must be valid) - * - * TODO: Replace with proper texture array / per-sprite texture support + * @brief Set resource cache for texture lookup by ID + */ + void setResourceCache(ResourceCache* cache) { m_resourceCache = cache; } + + /** + * @brief Set fallback texture when textureId=0 or texture not found + */ + void setDefaultTexture(rhi::TextureHandle texture) { m_activeTexture = texture; } + + /** + * @brief Legacy: Set a single texture for all sprites (backward compat) + * @deprecated Use setResourceCache for multi-texture support */ void setTexture(rhi::TextureHandle texture) { m_activeTexture = texture; } private: + void flushBatch(rhi::IRHIDevice& device, rhi::RHICommandBuffer& cmd, + rhi::TextureHandle texture, uint32_t count); + rhi::ShaderHandle m_shader; rhi::BufferHandle m_quadVB; rhi::BufferHandle m_quadIB; rhi::BufferHandle m_instanceBuffer; rhi::UniformHandle m_textureSampler; rhi::TextureHandle m_defaultTexture; // White 1x1 texture fallback - rhi::TextureHandle m_activeTexture; // Currently active texture (if set) + rhi::TextureHandle m_activeTexture; // Default texture for textureId=0 + + ResourceCache* m_resourceCache = nullptr; + + // Sorted sprite indices for batching + std::vector m_sortedIndices; static constexpr uint32_t MAX_SPRITES_PER_BATCH = 10000; }; diff --git a/modules/BgfxRenderer/Passes/TextPass.cpp b/modules/BgfxRenderer/Passes/TextPass.cpp new file mode 100644 index 0000000..b21a563 --- /dev/null +++ b/modules/BgfxRenderer/Passes/TextPass.cpp @@ -0,0 +1,193 @@ +#include "TextPass.h" +#include "../RHI/RHIDevice.h" +#include "../Frame/FramePacket.h" + +namespace grove { + +TextPass::TextPass(rhi::ShaderHandle shader) + : m_shader(shader) +{ + m_glyphInstances.reserve(MAX_GLYPHS_PER_BATCH); +} + +void TextPass::setup(rhi::IRHIDevice& device) { + // Initialize default bitmap font + if (!m_font.initDefault(device)) { + // Font init failed - text rendering will be disabled + return; + } + + // Create quad vertex buffer (unit quad, instanced) - same as SpritePass + float quadVertices[] = { + // pos.x, pos.y, pos.z, r, g, b, a + 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, // bottom-left + 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, // bottom-right + 1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, // top-right + 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, // top-left + }; + + rhi::BufferDesc vbDesc; + vbDesc.type = rhi::BufferDesc::Vertex; + vbDesc.size = sizeof(quadVertices); + vbDesc.data = quadVertices; + vbDesc.dynamic = false; + vbDesc.layout = rhi::BufferDesc::PosColor; + m_quadVB = device.createBuffer(vbDesc); + + // Create index buffer + uint16_t quadIndices[] = { + 0, 1, 2, + 0, 2, 3 + }; + + rhi::BufferDesc ibDesc; + ibDesc.type = rhi::BufferDesc::Index; + ibDesc.size = sizeof(quadIndices); + ibDesc.data = quadIndices; + ibDesc.dynamic = false; + m_quadIB = device.createBuffer(ibDesc); + + // Create dynamic instance buffer for glyphs + rhi::BufferDesc instDesc; + instDesc.type = rhi::BufferDesc::Instance; + instDesc.size = MAX_GLYPHS_PER_BATCH * sizeof(SpriteInstance); + instDesc.data = nullptr; + instDesc.dynamic = true; + m_instanceBuffer = device.createBuffer(instDesc); + + // Create texture sampler uniform + m_textureSampler = device.createUniform("s_texColor", 1); +} + +void TextPass::shutdown(rhi::IRHIDevice& device) { + device.destroy(m_quadVB); + device.destroy(m_quadIB); + device.destroy(m_instanceBuffer); + device.destroy(m_textureSampler); + m_font.shutdown(device); +} + +void TextPass::execute(const FramePacket& frame, rhi::IRHIDevice& device, rhi::RHICommandBuffer& cmd) { + if (frame.textCount == 0 || !m_font.isValid()) { + return; + } + + // Set render state for text (alpha blending, no depth) + rhi::RenderState state; + state.blend = rhi::BlendMode::Alpha; + state.cull = rhi::CullMode::None; + state.depthTest = false; + state.depthWrite = false; + cmd.setState(state); + + m_glyphInstances.clear(); + + // Convert each TextCommand into glyph instances + for (size_t i = 0; i < frame.textCount; ++i) { + const TextCommand& textCmd = frame.texts[i]; + + if (!textCmd.text) continue; + + // Calculate scale factor based on font size + float scale = static_cast(textCmd.fontSize) / m_font.getBaseSize(); + + // Extract color components + uint32_t color = textCmd.color; + float r = static_cast((color >> 24) & 0xFF) / 255.0f; + float g = static_cast((color >> 16) & 0xFF) / 255.0f; + float b = static_cast((color >> 8) & 0xFF) / 255.0f; + float a = static_cast(color & 0xFF) / 255.0f; + + float cursorX = textCmd.x; + float cursorY = textCmd.y; + + const char* ptr = textCmd.text; + while (*ptr) { + char ch = *ptr++; + + // Handle newline + if (ch == '\n') { + cursorX = textCmd.x; + cursorY += m_font.getLineHeight() * scale; + continue; + } + + const GlyphInfo& glyph = m_font.getGlyph(static_cast(ch)); + + // Create sprite instance for this glyph + SpriteInstance inst; + + // Position (top-left of glyph) + inst.x = cursorX + glyph.offsetX * scale; + inst.y = cursorY + glyph.offsetY * scale; + + // Scale to glyph size + inst.scaleX = glyph.width * scale; + inst.scaleY = glyph.height * scale; + + // No rotation + inst.rotation = 0.0f; + + // UVs from font atlas + inst.u0 = glyph.u0; + inst.v0 = glyph.v0; + inst.u1 = glyph.u1; + inst.v1 = glyph.v1; + + // Texture ID (font atlas = 0) + inst.textureId = 0.0f; + + // Layer + inst.layer = static_cast(textCmd.layer); + + // Padding/reserved + inst.padding0 = 0.0f; + inst.reserved[0] = 0.0f; + inst.reserved[1] = 0.0f; + inst.reserved[2] = 0.0f; + inst.reserved[3] = 0.0f; + + // Color + inst.r = r; + inst.g = g; + inst.b = b; + inst.a = a; + + m_glyphInstances.push_back(inst); + + // Advance cursor + cursorX += glyph.advance * scale; + + // Check batch limit + if (m_glyphInstances.size() >= MAX_GLYPHS_PER_BATCH) { + // Flush current batch + device.updateBuffer(m_instanceBuffer, m_glyphInstances.data(), + static_cast(m_glyphInstances.size() * sizeof(SpriteInstance))); + + cmd.setVertexBuffer(m_quadVB); + cmd.setIndexBuffer(m_quadIB); + cmd.setInstanceBuffer(m_instanceBuffer, 0, static_cast(m_glyphInstances.size())); + cmd.setTexture(0, m_font.getTexture(), m_textureSampler); + cmd.drawInstanced(6, static_cast(m_glyphInstances.size())); + cmd.submit(0, m_shader, 0); + + m_glyphInstances.clear(); + } + } + } + + // Submit remaining glyphs + if (!m_glyphInstances.empty()) { + device.updateBuffer(m_instanceBuffer, m_glyphInstances.data(), + static_cast(m_glyphInstances.size() * sizeof(SpriteInstance))); + + cmd.setVertexBuffer(m_quadVB); + cmd.setIndexBuffer(m_quadIB); + cmd.setInstanceBuffer(m_instanceBuffer, 0, static_cast(m_glyphInstances.size())); + cmd.setTexture(0, m_font.getTexture(), m_textureSampler); + cmd.drawInstanced(6, static_cast(m_glyphInstances.size())); + cmd.submit(0, m_shader, 0); + } +} + +} // namespace grove diff --git a/modules/BgfxRenderer/Passes/TextPass.h b/modules/BgfxRenderer/Passes/TextPass.h new file mode 100644 index 0000000..2bc8c90 --- /dev/null +++ b/modules/BgfxRenderer/Passes/TextPass.h @@ -0,0 +1,50 @@ +#pragma once + +#include "../RenderGraph/RenderPass.h" +#include "../RHI/RHITypes.h" +#include "../Text/BitmapFont.h" + +namespace grove { + +// ============================================================================ +// Text Pass - Renders 2D text with instanced quads +// ============================================================================ + +class TextPass : public RenderPass { +public: + /** + * @brief Construct TextPass with required shader + * @param shader The shader program to use (sprite shader works) + */ + explicit TextPass(rhi::ShaderHandle shader); + + const char* getName() const override { return "Text"; } + uint32_t getSortOrder() const override { return 150; } // After sprites, before debug + std::vector getDependencies() const override { return {"Sprites"}; } + + void setup(rhi::IRHIDevice& device) override; + void shutdown(rhi::IRHIDevice& device) override; + void execute(const FramePacket& frame, rhi::IRHIDevice& device, rhi::RHICommandBuffer& cmd) override; + + /** + * @brief Get the bitmap font (for external texture loading) + */ + BitmapFont& getFont() { return m_font; } + const BitmapFont& getFont() const { return m_font; } + +private: + rhi::ShaderHandle m_shader; + rhi::BufferHandle m_quadVB; + rhi::BufferHandle m_quadIB; + rhi::BufferHandle m_instanceBuffer; + rhi::UniformHandle m_textureSampler; + + BitmapFont m_font; + + // Reusable buffer for glyph instances + std::vector m_glyphInstances; + + static constexpr uint32_t MAX_GLYPHS_PER_BATCH = 4096; +}; + +} // namespace grove diff --git a/modules/BgfxRenderer/Passes/TilemapPass.cpp b/modules/BgfxRenderer/Passes/TilemapPass.cpp new file mode 100644 index 0000000..b6a1a76 --- /dev/null +++ b/modules/BgfxRenderer/Passes/TilemapPass.cpp @@ -0,0 +1,197 @@ +#include "TilemapPass.h" +#include "../RHI/RHIDevice.h" +#include "../Frame/FramePacket.h" +#include "../Resources/ResourceCache.h" + +namespace grove { + +TilemapPass::TilemapPass(rhi::ShaderHandle shader) + : m_shader(shader) +{ + m_tileInstances.reserve(MAX_TILES_PER_BATCH); +} + +void TilemapPass::setup(rhi::IRHIDevice& device) { + // Create quad vertex buffer (unit quad, instanced) - same as SpritePass + float quadVertices[] = { + // pos.x, pos.y, pos.z, r, g, b, a + 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, + 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, + 1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, + 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, + }; + + rhi::BufferDesc vbDesc; + vbDesc.type = rhi::BufferDesc::Vertex; + vbDesc.size = sizeof(quadVertices); + vbDesc.data = quadVertices; + vbDesc.dynamic = false; + vbDesc.layout = rhi::BufferDesc::PosColor; + m_quadVB = device.createBuffer(vbDesc); + + // Create index buffer + uint16_t quadIndices[] = { + 0, 1, 2, + 0, 2, 3 + }; + + rhi::BufferDesc ibDesc; + ibDesc.type = rhi::BufferDesc::Index; + ibDesc.size = sizeof(quadIndices); + ibDesc.data = quadIndices; + ibDesc.dynamic = false; + m_quadIB = device.createBuffer(ibDesc); + + // Create dynamic instance buffer + rhi::BufferDesc instDesc; + instDesc.type = rhi::BufferDesc::Instance; + instDesc.size = MAX_TILES_PER_BATCH * sizeof(SpriteInstance); + instDesc.data = nullptr; + instDesc.dynamic = true; + m_instanceBuffer = device.createBuffer(instDesc); + + // Create texture sampler uniform + m_textureSampler = device.createUniform("s_texColor", 1); + + // Create default white texture + uint32_t whitePixel = 0xFFFFFFFF; + rhi::TextureDesc texDesc; + texDesc.width = 1; + texDesc.height = 1; + texDesc.format = rhi::TextureDesc::RGBA8; + texDesc.data = &whitePixel; + texDesc.dataSize = sizeof(whitePixel); + m_defaultTexture = device.createTexture(texDesc); +} + +void TilemapPass::shutdown(rhi::IRHIDevice& device) { + device.destroy(m_quadVB); + device.destroy(m_quadIB); + device.destroy(m_instanceBuffer); + device.destroy(m_textureSampler); + device.destroy(m_defaultTexture); +} + +void TilemapPass::execute(const FramePacket& frame, rhi::IRHIDevice& device, rhi::RHICommandBuffer& cmd) { + if (frame.tilemapCount == 0) { + return; + } + + // Set render state for tilemaps (alpha blending, no depth) + rhi::RenderState state; + state.blend = rhi::BlendMode::Alpha; + state.cull = rhi::CullMode::None; + state.depthTest = false; + state.depthWrite = false; + cmd.setState(state); + + // Process each tilemap chunk + for (size_t i = 0; i < frame.tilemapCount; ++i) { + const TilemapChunk& chunk = frame.tilemaps[i]; + + if (!chunk.tiles || chunk.tileCount == 0) { + continue; + } + + // Get tileset texture + rhi::TextureHandle tileset; + if (chunk.textureId > 0 && m_resourceCache) { + tileset = m_resourceCache->getTextureById(chunk.textureId); + } + if (!tileset.isValid()) { + tileset = m_defaultTileset.isValid() ? m_defaultTileset : m_defaultTexture; + } + + // Calculate UV size per tile in tileset + float tileU = 1.0f / m_tilesPerRow; + float tileV = 1.0f / m_tilesPerCol; + + m_tileInstances.clear(); + + // Generate sprite instances for each tile + for (size_t t = 0; t < chunk.tileCount; ++t) { + uint16_t tileIndex = chunk.tiles[t]; + + // Skip empty tiles (index 0 is typically empty) + if (tileIndex == 0) { + continue; + } + + // Calculate tile position in grid + size_t tileX = t % chunk.width; + size_t tileY = t / chunk.width; + + // Calculate world position + float worldX = chunk.x + tileX * chunk.tileWidth; + float worldY = chunk.y + tileY * chunk.tileHeight; + + // Calculate UV coords from tile index + // tileIndex-1 because 0 is empty, actual tiles start at 1 + uint16_t actualIndex = tileIndex - 1; + uint16_t tileCol = actualIndex % m_tilesPerRow; + uint16_t tileRow = actualIndex / m_tilesPerRow; + + float u0 = tileCol * tileU; + float v0 = tileRow * tileV; + float u1 = u0 + tileU; + float v1 = v0 + tileV; + + // Create sprite instance for this tile + SpriteInstance inst; + inst.x = worldX; + inst.y = worldY; + inst.scaleX = static_cast(chunk.tileWidth); + inst.scaleY = static_cast(chunk.tileHeight); + inst.rotation = 0.0f; + inst.u0 = u0; + inst.v0 = v0; + inst.u1 = u1; + inst.v1 = v1; + inst.textureId = 0.0f; // Using tileset bound directly + inst.layer = -100.0f; // Tilemaps render behind sprites + inst.padding0 = 0.0f; + inst.reserved[0] = 0.0f; + inst.reserved[1] = 0.0f; + inst.reserved[2] = 0.0f; + inst.reserved[3] = 0.0f; + inst.r = 1.0f; + inst.g = 1.0f; + inst.b = 1.0f; + inst.a = 1.0f; + + m_tileInstances.push_back(inst); + + // Flush batch if full + if (m_tileInstances.size() >= MAX_TILES_PER_BATCH) { + device.updateBuffer(m_instanceBuffer, m_tileInstances.data(), + static_cast(m_tileInstances.size() * sizeof(SpriteInstance))); + + cmd.setVertexBuffer(m_quadVB); + cmd.setIndexBuffer(m_quadIB); + cmd.setInstanceBuffer(m_instanceBuffer, 0, static_cast(m_tileInstances.size())); + cmd.setTexture(0, tileset, m_textureSampler); + cmd.drawInstanced(6, static_cast(m_tileInstances.size())); + cmd.submit(0, m_shader, 0); + + m_tileInstances.clear(); + } + } + + // Flush remaining tiles for this chunk + if (!m_tileInstances.empty()) { + device.updateBuffer(m_instanceBuffer, m_tileInstances.data(), + static_cast(m_tileInstances.size() * sizeof(SpriteInstance))); + + cmd.setVertexBuffer(m_quadVB); + cmd.setIndexBuffer(m_quadIB); + cmd.setInstanceBuffer(m_instanceBuffer, 0, static_cast(m_tileInstances.size())); + cmd.setTexture(0, tileset, m_textureSampler); + cmd.drawInstanced(6, static_cast(m_tileInstances.size())); + cmd.submit(0, m_shader, 0); + + m_tileInstances.clear(); + } + } +} + +} // namespace grove diff --git a/modules/BgfxRenderer/Passes/TilemapPass.h b/modules/BgfxRenderer/Passes/TilemapPass.h new file mode 100644 index 0000000..5c086cf --- /dev/null +++ b/modules/BgfxRenderer/Passes/TilemapPass.h @@ -0,0 +1,70 @@ +#pragma once + +#include "../RenderGraph/RenderPass.h" +#include "../RHI/RHITypes.h" +#include + +namespace grove { + +class ResourceCache; + +// ============================================================================ +// Tilemap Pass - Renders 2D tilemaps efficiently +// ============================================================================ + +class TilemapPass : public RenderPass { +public: + /** + * @brief Construct TilemapPass with required shader + * @param shader The shader program to use (sprite shader) + */ + explicit TilemapPass(rhi::ShaderHandle shader); + + const char* getName() const override { return "Tilemaps"; } + uint32_t getSortOrder() const override { return 50; } // Before sprites + std::vector getDependencies() const override { return {"Clear"}; } + + void setup(rhi::IRHIDevice& device) override; + void shutdown(rhi::IRHIDevice& device) override; + void execute(const FramePacket& frame, rhi::IRHIDevice& device, rhi::RHICommandBuffer& cmd) override; + + /** + * @brief Set resource cache for texture lookup + */ + void setResourceCache(ResourceCache* cache) { m_resourceCache = cache; } + + /** + * @brief Set default tileset texture + */ + void setDefaultTileset(rhi::TextureHandle texture) { m_defaultTileset = texture; } + + /** + * @brief Set tileset dimensions (tiles per row/column in atlas) + */ + void setTilesetLayout(uint16_t tilesPerRow, uint16_t tilesPerCol) { + m_tilesPerRow = tilesPerRow; + m_tilesPerCol = tilesPerCol; + } + +private: + rhi::ShaderHandle m_shader; + rhi::BufferHandle m_quadVB; + rhi::BufferHandle m_quadIB; + rhi::BufferHandle m_instanceBuffer; + rhi::UniformHandle m_textureSampler; + rhi::TextureHandle m_defaultTexture; + rhi::TextureHandle m_defaultTileset; + + ResourceCache* m_resourceCache = nullptr; + + // Tileset layout (for UV calculation) + uint16_t m_tilesPerRow = 16; + uint16_t m_tilesPerCol = 16; + + // Reusable buffer for tile instances + std::vector m_tileInstances; + + static constexpr uint32_t MAX_TILES_PER_BATCH = 16384; +}; + +} // namespace grove diff --git a/modules/BgfxRenderer/Resources/ResourceCache.cpp b/modules/BgfxRenderer/Resources/ResourceCache.cpp index c4c93b1..f8e9024 100644 --- a/modules/BgfxRenderer/Resources/ResourceCache.cpp +++ b/modules/BgfxRenderer/Resources/ResourceCache.cpp @@ -24,6 +24,68 @@ rhi::ShaderHandle ResourceCache::getShader(const std::string& name) const { return rhi::ShaderHandle{}; // Invalid handle } +rhi::TextureHandle ResourceCache::getTextureById(uint16_t id) const { + std::shared_lock lock(m_mutex); + if (id < m_textureById.size()) { + return m_textureById[id]; + } + return rhi::TextureHandle{}; // Invalid handle +} + +uint16_t ResourceCache::getTextureId(const std::string& path) const { + std::shared_lock lock(m_mutex); + auto it = m_pathToTextureId.find(path); + if (it != m_pathToTextureId.end()) { + return it->second; + } + return 0; // Invalid ID +} + +uint16_t ResourceCache::loadTextureWithId(rhi::IRHIDevice& device, const std::string& path) { + // Check if already loaded + { + std::shared_lock lock(m_mutex); + auto it = m_pathToTextureId.find(path); + if (it != m_pathToTextureId.end()) { + return it->second; + } + } + + // Load texture from file using TextureLoader (stb_image) + auto result = TextureLoader::loadFromFile(device, path); + + if (!result.success) { + return 0; // Invalid ID + } + + // Store in cache with new ID + { + std::unique_lock lock(m_mutex); + + // Double-check after acquiring exclusive lock + auto it = m_pathToTextureId.find(path); + if (it != m_pathToTextureId.end()) { + // Another thread loaded it, destroy our copy + device.destroy(result.handle); + return it->second; + } + + // Assign new ID (1-based, 0 = invalid) + uint16_t newId = static_cast(m_textureById.size()); + if (newId == 0) { + // Reserve index 0 as invalid/default + m_textureById.push_back(rhi::TextureHandle{}); + newId = 1; + } + + m_textureById.push_back(result.handle); + m_pathToTextureId[path] = newId; + m_textures[path] = result.handle; + + return newId; + } +} + rhi::TextureHandle ResourceCache::loadTexture(rhi::IRHIDevice& device, const std::string& path) { // Check if already loaded { @@ -42,10 +104,27 @@ rhi::TextureHandle ResourceCache::loadTexture(rhi::IRHIDevice& device, const std return rhi::TextureHandle{}; } - // Store in cache + // Store in cache (also register with ID system) { std::unique_lock lock(m_mutex); + + // Double check + auto it = m_textures.find(path); + if (it != m_textures.end()) { + device.destroy(result.handle); + return it->second; + } + m_textures[path] = result.handle; + + // Also add to ID system + uint16_t newId = static_cast(m_textureById.size()); + if (newId == 0) { + m_textureById.push_back(rhi::TextureHandle{}); + newId = 1; + } + m_textureById.push_back(result.handle); + m_pathToTextureId[path] = newId; } return result.handle; @@ -97,6 +176,8 @@ void ResourceCache::clear(rhi::IRHIDevice& device) { device.destroy(handle); } m_textures.clear(); + m_textureById.clear(); + m_pathToTextureId.clear(); for (auto& [name, handle] : m_shaders) { device.destroy(handle); diff --git a/modules/BgfxRenderer/Resources/ResourceCache.h b/modules/BgfxRenderer/Resources/ResourceCache.h index 779ce3b..2cea40f 100644 --- a/modules/BgfxRenderer/Resources/ResourceCache.h +++ b/modules/BgfxRenderer/Resources/ResourceCache.h @@ -2,6 +2,7 @@ #include "../RHI/RHITypes.h" #include +#include #include #include @@ -10,7 +11,7 @@ namespace grove { namespace rhi { class IRHIDevice; } // ============================================================================ -// Resource Cache - Thread-safe texture and shader cache +// Resource Cache - Thread-safe texture and shader cache with numeric IDs // ============================================================================ class ResourceCache { @@ -21,7 +22,16 @@ public: rhi::TextureHandle getTexture(const std::string& path) const; rhi::ShaderHandle getShader(const std::string& name) const; - // Loading (called from main thread) + // Get texture by numeric ID (for sprite rendering) + rhi::TextureHandle getTextureById(uint16_t id) const; + + // Get texture ID from path (returns 0 if not found) + uint16_t getTextureId(const std::string& path) const; + + // Loading (called from main thread) - returns texture ID + uint16_t loadTextureWithId(rhi::IRHIDevice& device, const std::string& path); + + // Legacy loading (returns handle directly) rhi::TextureHandle loadTexture(rhi::IRHIDevice& device, const std::string& path); rhi::ShaderHandle loadShader(rhi::IRHIDevice& device, const std::string& name, const void* vsData, uint32_t vsSize, @@ -39,8 +49,14 @@ public: size_t getShaderCount() const; private: + // Path-based lookup std::unordered_map m_textures; std::unordered_map m_shaders; + + // ID-based lookup for textures (index = textureId, 0 = invalid/default) + std::vector m_textureById; + std::unordered_map m_pathToTextureId; + mutable std::shared_mutex m_mutex; }; diff --git a/modules/BgfxRenderer/Scene/SceneCollector.cpp b/modules/BgfxRenderer/Scene/SceneCollector.cpp index 11b6b4a..ed10c21 100644 --- a/modules/BgfxRenderer/Scene/SceneCollector.cpp +++ b/modules/BgfxRenderer/Scene/SceneCollector.cpp @@ -77,11 +77,26 @@ FramePacket SceneCollector::finalize(FrameAllocator& allocator) { packet.spriteCount = 0; } - // Copy tilemaps + // Copy tilemaps (with tile data) if (!m_tilemaps.empty()) { TilemapChunk* tilemaps = allocator.allocateArray(m_tilemaps.size()); if (tilemaps) { std::memcpy(tilemaps, m_tilemaps.data(), m_tilemaps.size() * sizeof(TilemapChunk)); + + // Copy tile data to frame allocator and fix up pointers + for (size_t i = 0; i < m_tilemaps.size() && i < m_tilemapTiles.size(); ++i) { + const std::vector& tiles = m_tilemapTiles[i]; + if (!tiles.empty()) { + uint16_t* tilesCopy = static_cast( + allocator.allocate(tiles.size() * sizeof(uint16_t), alignof(uint16_t))); + if (tilesCopy) { + std::memcpy(tilesCopy, tiles.data(), tiles.size() * sizeof(uint16_t)); + tilemaps[i].tiles = tilesCopy; + tilemaps[i].tileCount = tiles.size(); + } + } + } + packet.tilemaps = tilemaps; packet.tilemapCount = m_tilemaps.size(); } @@ -90,11 +105,25 @@ FramePacket SceneCollector::finalize(FrameAllocator& allocator) { packet.tilemapCount = 0; } - // Copy texts + // Copy texts (with string data) if (!m_texts.empty()) { TextCommand* texts = allocator.allocateArray(m_texts.size()); if (texts) { std::memcpy(texts, m_texts.data(), m_texts.size() * sizeof(TextCommand)); + + // Copy string data to frame allocator and fix up pointers + for (size_t i = 0; i < m_texts.size() && i < m_textStrings.size(); ++i) { + const std::string& str = m_textStrings[i]; + if (!str.empty()) { + // Allocate string + null terminator + char* textCopy = static_cast(allocator.allocate(str.size() + 1, 1)); + if (textCopy) { + std::memcpy(textCopy, str.c_str(), str.size() + 1); + texts[i].text = textCopy; + } + } + } + packet.texts = texts; packet.textCount = m_texts.size(); } @@ -148,7 +177,9 @@ FramePacket SceneCollector::finalize(FrameAllocator& allocator) { void SceneCollector::clear() { m_sprites.clear(); m_tilemaps.clear(); + m_tilemapTiles.clear(); m_texts.clear(); + m_textStrings.clear(); m_particles.clear(); m_debugLines.clear(); m_debugRects.clear(); @@ -214,7 +245,41 @@ void SceneCollector::parseTilemap(const IDataNode& data) { chunk.tileWidth = static_cast(data.getInt("tileW", 16)); chunk.tileHeight = static_cast(data.getInt("tileH", 16)); chunk.textureId = static_cast(data.getInt("textureId", 0)); - chunk.tiles = nullptr; // TODO: Parse tile array + + // Parse tile array from "tiles" child node + std::vector tiles; + IDataNode* tilesNode = const_cast(data).getChildReadOnly("tiles"); + if (tilesNode) { + // Each child is a tile index + for (const auto& name : tilesNode->getChildNames()) { + IDataNode* tileNode = tilesNode->getChildReadOnly(name); + if (tileNode) { + // Try to get as int (direct value) + tiles.push_back(static_cast(tileNode->getInt("v", 0))); + } + } + } + + // Alternative: parse from comma-separated string "tileData" + if (tiles.empty()) { + std::string tileData = data.getString("tileData", ""); + if (!tileData.empty()) { + size_t pos = 0; + while (pos < tileData.size()) { + size_t end = tileData.find(',', pos); + if (end == std::string::npos) end = tileData.size(); + std::string numStr = tileData.substr(pos, end - pos); + if (!numStr.empty()) { + tiles.push_back(static_cast(std::stoi(numStr))); + } + pos = end + 1; + } + } + } + + // Store tiles - pointer will be fixed in finalize + m_tilemapTiles.push_back(std::move(tiles)); + chunk.tiles = nullptr; chunk.tileCount = 0; m_tilemaps.push_back(chunk); @@ -224,12 +289,16 @@ void SceneCollector::parseText(const IDataNode& data) { TextCommand text; text.x = static_cast(data.getDouble("x", 0.0)); text.y = static_cast(data.getDouble("y", 0.0)); - text.text = nullptr; // TODO: Copy string to frame allocator text.fontId = static_cast(data.getInt("fontId", 0)); text.fontSize = static_cast(data.getInt("fontSize", 16)); text.color = static_cast(data.getInt("color", 0xFFFFFFFF)); text.layer = static_cast(data.getInt("layer", 0)); + // Store text string - pointer will be fixed up in finalize() + std::string textStr = data.getString("text", ""); + m_textStrings.push_back(std::move(textStr)); + text.text = nullptr; // Will be set in finalize() + m_texts.push_back(text); } diff --git a/modules/BgfxRenderer/Scene/SceneCollector.h b/modules/BgfxRenderer/Scene/SceneCollector.h index e706a78..f730575 100644 --- a/modules/BgfxRenderer/Scene/SceneCollector.h +++ b/modules/BgfxRenderer/Scene/SceneCollector.h @@ -35,7 +35,9 @@ private: // Staging buffers (filled during collect, copied to FramePacket in finalize) std::vector m_sprites; std::vector m_tilemaps; + std::vector> m_tilemapTiles; // Owns tile data until finalize std::vector m_texts; + std::vector m_textStrings; // Owns text data until finalize std::vector m_particles; std::vector m_debugLines; std::vector m_debugRects; diff --git a/modules/BgfxRenderer/Text/BitmapFont.cpp b/modules/BgfxRenderer/Text/BitmapFont.cpp new file mode 100644 index 0000000..a369b06 --- /dev/null +++ b/modules/BgfxRenderer/Text/BitmapFont.cpp @@ -0,0 +1,330 @@ +#include "BitmapFont.h" +#include "../RHI/RHIDevice.h" +#include +#include + +namespace grove { + +// ============================================================================ +// Embedded 8x8 Monospace Font Data +// Classic CP437 style bitmap font covering ASCII 32-126 +// Each character is 8x8 pixels, stored as 8 bytes (1 bit per pixel) +// ============================================================================ + +// clang-format off +static const uint8_t g_fontData8x8[] = { + // 32 SPACE + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // 33 ! + 0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00, + // 34 " + 0x6C, 0x6C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // 35 # + 0x6C, 0x6C, 0xFE, 0x6C, 0xFE, 0x6C, 0x6C, 0x00, + // 36 $ + 0x18, 0x7E, 0xC0, 0x7C, 0x06, 0xFC, 0x18, 0x00, + // 37 % + 0x00, 0xC6, 0xCC, 0x18, 0x30, 0x66, 0xC6, 0x00, + // 38 & + 0x38, 0x6C, 0x38, 0x76, 0xDC, 0xCC, 0x76, 0x00, + // 39 ' + 0x30, 0x30, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, + // 40 ( + 0x0C, 0x18, 0x30, 0x30, 0x30, 0x18, 0x0C, 0x00, + // 41 ) + 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x18, 0x30, 0x00, + // 42 * + 0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00, + // 43 + + 0x00, 0x18, 0x18, 0x7E, 0x18, 0x18, 0x00, 0x00, + // 44 , + 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x18, 0x30, + // 45 - + 0x00, 0x00, 0x00, 0x7E, 0x00, 0x00, 0x00, 0x00, + // 46 . + 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x18, 0x00, + // 47 / + 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC0, 0x80, 0x00, + // 48 0 + 0x7C, 0xCE, 0xDE, 0xF6, 0xE6, 0xC6, 0x7C, 0x00, + // 49 1 + 0x18, 0x38, 0x18, 0x18, 0x18, 0x18, 0x7E, 0x00, + // 50 2 + 0x7C, 0xC6, 0x06, 0x7C, 0xC0, 0xC0, 0xFE, 0x00, + // 51 3 + 0xFC, 0x06, 0x06, 0x3C, 0x06, 0x06, 0xFC, 0x00, + // 52 4 + 0x0C, 0xCC, 0xCC, 0xCC, 0xFE, 0x0C, 0x0C, 0x00, + // 53 5 + 0xFE, 0xC0, 0xFC, 0x06, 0x06, 0xC6, 0x7C, 0x00, + // 54 6 + 0x7C, 0xC0, 0xC0, 0xFC, 0xC6, 0xC6, 0x7C, 0x00, + // 55 7 + 0xFE, 0x06, 0x06, 0x0C, 0x18, 0x18, 0x18, 0x00, + // 56 8 + 0x7C, 0xC6, 0xC6, 0x7C, 0xC6, 0xC6, 0x7C, 0x00, + // 57 9 + 0x7C, 0xC6, 0xC6, 0x7E, 0x06, 0x06, 0x7C, 0x00, + // 58 : + 0x00, 0x18, 0x18, 0x00, 0x00, 0x18, 0x18, 0x00, + // 59 ; + 0x00, 0x18, 0x18, 0x00, 0x00, 0x18, 0x18, 0x30, + // 60 < + 0x06, 0x0C, 0x18, 0x30, 0x18, 0x0C, 0x06, 0x00, + // 61 = + 0x00, 0x00, 0x7E, 0x00, 0x7E, 0x00, 0x00, 0x00, + // 62 > + 0x60, 0x30, 0x18, 0x0C, 0x18, 0x30, 0x60, 0x00, + // 63 ? + 0x7C, 0xC6, 0x0C, 0x18, 0x18, 0x00, 0x18, 0x00, + // 64 @ + 0x7C, 0xC6, 0xDE, 0xDE, 0xDE, 0xC0, 0x7C, 0x00, + // 65 A + 0x38, 0x6C, 0xC6, 0xC6, 0xFE, 0xC6, 0xC6, 0x00, + // 66 B + 0xFC, 0xC6, 0xC6, 0xFC, 0xC6, 0xC6, 0xFC, 0x00, + // 67 C + 0x7C, 0xC6, 0xC0, 0xC0, 0xC0, 0xC6, 0x7C, 0x00, + // 68 D + 0xF8, 0xCC, 0xC6, 0xC6, 0xC6, 0xCC, 0xF8, 0x00, + // 69 E + 0xFE, 0xC0, 0xC0, 0xF8, 0xC0, 0xC0, 0xFE, 0x00, + // 70 F + 0xFE, 0xC0, 0xC0, 0xF8, 0xC0, 0xC0, 0xC0, 0x00, + // 71 G + 0x7C, 0xC6, 0xC0, 0xCE, 0xC6, 0xC6, 0x7E, 0x00, + // 72 H + 0xC6, 0xC6, 0xC6, 0xFE, 0xC6, 0xC6, 0xC6, 0x00, + // 73 I + 0x7E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x7E, 0x00, + // 74 J + 0x06, 0x06, 0x06, 0x06, 0xC6, 0xC6, 0x7C, 0x00, + // 75 K + 0xC6, 0xCC, 0xD8, 0xF0, 0xD8, 0xCC, 0xC6, 0x00, + // 76 L + 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xFE, 0x00, + // 77 M + 0xC6, 0xEE, 0xFE, 0xD6, 0xC6, 0xC6, 0xC6, 0x00, + // 78 N + 0xC6, 0xE6, 0xF6, 0xDE, 0xCE, 0xC6, 0xC6, 0x00, + // 79 O + 0x7C, 0xC6, 0xC6, 0xC6, 0xC6, 0xC6, 0x7C, 0x00, + // 80 P + 0xFC, 0xC6, 0xC6, 0xFC, 0xC0, 0xC0, 0xC0, 0x00, + // 81 Q + 0x7C, 0xC6, 0xC6, 0xC6, 0xD6, 0xDE, 0x7C, 0x06, + // 82 R + 0xFC, 0xC6, 0xC6, 0xFC, 0xD8, 0xCC, 0xC6, 0x00, + // 83 S + 0x7C, 0xC6, 0xC0, 0x7C, 0x06, 0xC6, 0x7C, 0x00, + // 84 T + 0xFE, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x00, + // 85 U + 0xC6, 0xC6, 0xC6, 0xC6, 0xC6, 0xC6, 0x7C, 0x00, + // 86 V + 0xC6, 0xC6, 0xC6, 0xC6, 0x6C, 0x38, 0x10, 0x00, + // 87 W + 0xC6, 0xC6, 0xC6, 0xD6, 0xFE, 0xEE, 0xC6, 0x00, + // 88 X + 0xC6, 0xC6, 0x6C, 0x38, 0x6C, 0xC6, 0xC6, 0x00, + // 89 Y + 0xC6, 0xC6, 0x6C, 0x38, 0x18, 0x18, 0x18, 0x00, + // 90 Z + 0xFE, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xFE, 0x00, + // 91 [ + 0x3C, 0x30, 0x30, 0x30, 0x30, 0x30, 0x3C, 0x00, + // 92 backslash + 0xC0, 0x60, 0x30, 0x18, 0x0C, 0x06, 0x02, 0x00, + // 93 ] + 0x3C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x3C, 0x00, + // 94 ^ + 0x10, 0x38, 0x6C, 0xC6, 0x00, 0x00, 0x00, 0x00, + // 95 _ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFE, + // 96 ` + 0x18, 0x18, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, + // 97 a + 0x00, 0x00, 0x7C, 0x06, 0x7E, 0xC6, 0x7E, 0x00, + // 98 b + 0xC0, 0xC0, 0xFC, 0xC6, 0xC6, 0xC6, 0xFC, 0x00, + // 99 c + 0x00, 0x00, 0x7C, 0xC6, 0xC0, 0xC6, 0x7C, 0x00, + // 100 d + 0x06, 0x06, 0x7E, 0xC6, 0xC6, 0xC6, 0x7E, 0x00, + // 101 e + 0x00, 0x00, 0x7C, 0xC6, 0xFE, 0xC0, 0x7C, 0x00, + // 102 f + 0x1C, 0x36, 0x30, 0x78, 0x30, 0x30, 0x30, 0x00, + // 103 g + 0x00, 0x00, 0x7E, 0xC6, 0xC6, 0x7E, 0x06, 0x7C, + // 104 h + 0xC0, 0xC0, 0xFC, 0xC6, 0xC6, 0xC6, 0xC6, 0x00, + // 105 i + 0x18, 0x00, 0x38, 0x18, 0x18, 0x18, 0x3C, 0x00, + // 106 j + 0x0C, 0x00, 0x0C, 0x0C, 0x0C, 0x0C, 0xCC, 0x78, + // 107 k + 0xC0, 0xC0, 0xCC, 0xD8, 0xF0, 0xD8, 0xCC, 0x00, + // 108 l + 0x38, 0x18, 0x18, 0x18, 0x18, 0x18, 0x3C, 0x00, + // 109 m + 0x00, 0x00, 0xEC, 0xFE, 0xD6, 0xC6, 0xC6, 0x00, + // 110 n + 0x00, 0x00, 0xFC, 0xC6, 0xC6, 0xC6, 0xC6, 0x00, + // 111 o + 0x00, 0x00, 0x7C, 0xC6, 0xC6, 0xC6, 0x7C, 0x00, + // 112 p + 0x00, 0x00, 0xFC, 0xC6, 0xC6, 0xFC, 0xC0, 0xC0, + // 113 q + 0x00, 0x00, 0x7E, 0xC6, 0xC6, 0x7E, 0x06, 0x06, + // 114 r + 0x00, 0x00, 0xDC, 0xE6, 0xC0, 0xC0, 0xC0, 0x00, + // 115 s + 0x00, 0x00, 0x7E, 0xC0, 0x7C, 0x06, 0xFC, 0x00, + // 116 t + 0x30, 0x30, 0x7C, 0x30, 0x30, 0x36, 0x1C, 0x00, + // 117 u + 0x00, 0x00, 0xC6, 0xC6, 0xC6, 0xC6, 0x7E, 0x00, + // 118 v + 0x00, 0x00, 0xC6, 0xC6, 0xC6, 0x6C, 0x38, 0x00, + // 119 w + 0x00, 0x00, 0xC6, 0xC6, 0xD6, 0xFE, 0x6C, 0x00, + // 120 x + 0x00, 0x00, 0xC6, 0x6C, 0x38, 0x6C, 0xC6, 0x00, + // 121 y + 0x00, 0x00, 0xC6, 0xC6, 0xC6, 0x7E, 0x06, 0x7C, + // 122 z + 0x00, 0x00, 0xFE, 0x0C, 0x38, 0x60, 0xFE, 0x00, + // 123 { + 0x0E, 0x18, 0x18, 0x70, 0x18, 0x18, 0x0E, 0x00, + // 124 | + 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x00, + // 125 } + 0x70, 0x18, 0x18, 0x0E, 0x18, 0x18, 0x70, 0x00, + // 126 ~ + 0x76, 0xDC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; +// clang-format on + +static constexpr int FONT_FIRST_CHAR = 32; // Space +static constexpr int FONT_CHAR_COUNT = 95; // 32-126 +static constexpr int FONT_GLYPH_SIZE = 8; // 8x8 pixels + +bool BitmapFont::initDefault(rhi::IRHIDevice& device) { + // Create 128x64 atlas (16 chars per row, 8 rows = 128 chars max) + // We only use 95 chars (ASCII 32-126) + m_atlasWidth = 128; // 16 chars * 8 pixels + m_atlasHeight = 64; // 8 rows * 8 pixels + + // Create RGBA texture data + const int atlasPixels = m_atlasWidth * m_atlasHeight; + std::vector atlasData(atlasPixels, 0); + + // Render each character into the atlas + for (int charIdx = 0; charIdx < FONT_CHAR_COUNT; ++charIdx) { + int col = charIdx % 16; + int row = charIdx / 16; + int baseX = col * FONT_GLYPH_SIZE; + int baseY = row * FONT_GLYPH_SIZE; + + const uint8_t* glyphData = &g_fontData8x8[charIdx * 8]; + + for (int y = 0; y < 8; ++y) { + uint8_t rowBits = glyphData[y]; + for (int x = 0; x < 8; ++x) { + // MSB first: bit 7 is leftmost pixel + bool pixel = (rowBits & (0x80 >> x)) != 0; + int atlasX = baseX + x; + int atlasY = baseY + y; + int idx = atlasY * m_atlasWidth + atlasX; + // White pixel with alpha = 255 if set, 0 if not + atlasData[idx] = pixel ? 0xFFFFFFFF : 0x00000000; + } + } + } + + // Create GPU texture + rhi::TextureDesc texDesc; + texDesc.width = m_atlasWidth; + texDesc.height = m_atlasHeight; + texDesc.format = rhi::TextureDesc::RGBA8; + texDesc.data = atlasData.data(); + texDesc.dataSize = atlasPixels * sizeof(uint32_t); + m_texture = device.createTexture(texDesc); + + if (!m_texture.isValid()) { + return false; + } + + // Generate glyph info + generateDefaultGlyphs(); + + m_baseSize = 8.0f; + m_lineHeight = 8.0f; + + return true; +} + +void BitmapFont::generateDefaultGlyphs() { + float invW = 1.0f / m_atlasWidth; + float invH = 1.0f / m_atlasHeight; + + for (int charIdx = 0; charIdx < FONT_CHAR_COUNT; ++charIdx) { + uint32_t codepoint = FONT_FIRST_CHAR + charIdx; + + int col = charIdx % 16; + int row = charIdx / 16; + + GlyphInfo glyph; + glyph.u0 = col * FONT_GLYPH_SIZE * invW; + glyph.v0 = row * FONT_GLYPH_SIZE * invH; + glyph.u1 = (col + 1) * FONT_GLYPH_SIZE * invW; + glyph.v1 = (row + 1) * FONT_GLYPH_SIZE * invH; + glyph.width = FONT_GLYPH_SIZE; + glyph.height = FONT_GLYPH_SIZE; + glyph.offsetX = 0.0f; + glyph.offsetY = 0.0f; + glyph.advance = FONT_GLYPH_SIZE; // Monospace: fixed advance + + m_glyphs[codepoint] = glyph; + } + + // Default glyph for unknown characters (use '?' which is ASCII 63) + m_defaultGlyph = m_glyphs['?']; +} + +bool BitmapFont::loadBMFont(rhi::IRHIDevice& device, const std::string& fntPath, const std::string& pngPath) { + // TODO: Implement BMFont loader if needed + // For now, fall back to default font + return initDefault(device); +} + +void BitmapFont::shutdown(rhi::IRHIDevice& device) { + if (m_texture.isValid()) { + device.destroy(m_texture); + m_texture = rhi::TextureHandle(); + } + m_glyphs.clear(); +} + +const GlyphInfo& BitmapFont::getGlyph(uint32_t codepoint) const { + auto it = m_glyphs.find(codepoint); + if (it != m_glyphs.end()) { + return it->second; + } + return m_defaultGlyph; +} + +float BitmapFont::measureWidth(const char* text) const { + if (!text) return 0.0f; + + float width = 0.0f; + while (*text) { + const GlyphInfo& glyph = getGlyph(static_cast(*text)); + width += glyph.advance; + ++text; + } + return width; +} + +} // namespace grove diff --git a/modules/BgfxRenderer/Text/BitmapFont.h b/modules/BgfxRenderer/Text/BitmapFont.h new file mode 100644 index 0000000..f2e0151 --- /dev/null +++ b/modules/BgfxRenderer/Text/BitmapFont.h @@ -0,0 +1,101 @@ +#pragma once + +#include "../RHI/RHITypes.h" +#include +#include +#include + +namespace grove { + +namespace rhi { class IRHIDevice; } + +// ============================================================================ +// Glyph Info - Metrics for a single character +// ============================================================================ + +struct GlyphInfo { + float u0, v0, u1, v1; // UV coordinates in atlas + float width, height; // Glyph size in pixels + float offsetX, offsetY; // Offset from cursor position + float advance; // Cursor advance after this glyph +}; + +// ============================================================================ +// BitmapFont - Simple bitmap font for text rendering +// ============================================================================ + +class BitmapFont { +public: + BitmapFont() = default; + ~BitmapFont() = default; + + // Non-copyable + BitmapFont(const BitmapFont&) = delete; + BitmapFont& operator=(const BitmapFont&) = delete; + + /** + * @brief Initialize with embedded 8x8 monospace font + * @param device RHI device for texture creation + * @return true on success + */ + bool initDefault(rhi::IRHIDevice& device); + + /** + * @brief Load font from BMFont format (.fnt + .png) + * @param device RHI device for texture creation + * @param fntPath Path to .fnt file + * @param pngPath Path to .png atlas + * @return true on success + */ + bool loadBMFont(rhi::IRHIDevice& device, const std::string& fntPath, const std::string& pngPath); + + /** + * @brief Cleanup GPU resources + */ + void shutdown(rhi::IRHIDevice& device); + + /** + * @brief Get glyph info for a character + * @param codepoint Unicode codepoint (ASCII for now) + * @return Glyph info, or default space if not found + */ + const GlyphInfo& getGlyph(uint32_t codepoint) const; + + /** + * @brief Get font atlas texture + */ + rhi::TextureHandle getTexture() const { return m_texture; } + + /** + * @brief Get line height (for multi-line text) + */ + float getLineHeight() const { return m_lineHeight; } + + /** + * @brief Get base font size (pixels) + */ + float getBaseSize() const { return m_baseSize; } + + /** + * @brief Calculate text width in pixels + */ + float measureWidth(const char* text) const; + + /** + * @brief Check if font is loaded + */ + bool isValid() const { return m_texture.isValid(); } + +private: + void generateDefaultGlyphs(); + + rhi::TextureHandle m_texture; + std::unordered_map m_glyphs; + GlyphInfo m_defaultGlyph; + float m_lineHeight = 8.0f; + float m_baseSize = 8.0f; + uint16_t m_atlasWidth = 128; + uint16_t m_atlasHeight = 64; +}; + +} // namespace grove diff --git a/tests/visual/test_23_bgfx_sprites_visual.cpp b/tests/visual/test_23_bgfx_sprites_visual.cpp index 1afb2c7..8d7b426 100644 --- a/tests/visual/test_23_bgfx_sprites_visual.cpp +++ b/tests/visual/test_23_bgfx_sprites_visual.cpp @@ -29,8 +29,8 @@ int main(int argc, char* argv[]) { } // Create window - const int width = 800; - const int height = 600; + int width = 800; + int height = 600; SDL_Window* window = SDL_CreateWindow( "BgfxRenderer Sprites Test - Press ESC to exit", @@ -152,6 +152,10 @@ int main(int argc, char* argv[]) { if (event.type == SDL_KEYDOWN && event.key.keysym.sym == SDLK_ESCAPE) { running = false; } + if (event.type == SDL_WINDOWEVENT && event.window.event == SDL_WINDOWEVENT_RESIZED) { + width = event.window.data1; + height = event.window.data2; + } } // Check timeout @@ -194,7 +198,37 @@ int main(int argc, char* argv[]) { gameIO->publish("render:clear", std::move(data)); } + // ======================================== + // Test tilemap rendering (simple checkerboard) + // Must be sent every frame (like sprites) + // ======================================== + { + auto tilemap = std::make_unique("tilemap"); + tilemap->setDouble("x", 50.0); + tilemap->setDouble("y", 450.0); + tilemap->setInt("width", 20); + tilemap->setInt("height", 3); + tilemap->setInt("tileW", 32); + tilemap->setInt("tileH", 32); + tilemap->setInt("textureId", 0); // Uses default texture + + // Checkerboard pattern: 1 = tile, 0 = empty + std::string tileData; + for (int row = 0; row < 3; ++row) { + for (int col = 0; col < 20; ++col) { + int tileIndex = ((row + col) % 2 == 0) ? 1 : 0; + if (!tileData.empty()) tileData += ","; + tileData += std::to_string(tileIndex); + } + } + tilemap->setString("tileData", tileData); + + std::unique_ptr data = std::move(tilemap); + gameIO->publish("render:tilemap", std::move(data)); + } + // Send animated sprites in a circle + // Using different textureIds to test batching (all will use default texture fallback) for (int i = 0; i < 5; ++i) { auto sprite = std::make_unique("sprite"); @@ -214,8 +248,11 @@ int main(int argc, char* argv[]) { sprite->setDouble("u1", 1.0); sprite->setDouble("v1", 1.0); - // All sprites white to show texture without tint - sprite->setInt("color", static_cast(0xFFFFFFFF)); + // Different colors per sprite to verify rendering + uint32_t colors[] = {0xFF0000FF, 0x00FF00FF, 0x0000FFFF, 0xFFFF00FF, 0xFF00FFFF}; + sprite->setInt("color", static_cast(colors[i])); + + // Use textureId=0 for all (default texture) sprite->setInt("textureId", 0); sprite->setInt("layer", i); @@ -238,6 +275,74 @@ int main(int argc, char* argv[]) { gameIO->publish("render:sprite", std::move(data)); } + // ======================================== + // Test text rendering + // ======================================== + + // Title text (large, white) + { + auto text = std::make_unique("text"); + text->setDouble("x", 10.0); + text->setDouble("y", 10.0); + text->setString("text", "GroveEngine - Text Rendering Test"); + text->setInt("fontSize", 16); + text->setInt("color", static_cast(0xFFFFFFFF)); + text->setInt("layer", 100); + + std::unique_ptr data = std::move(text); + gameIO->publish("render:text", std::move(data)); + } + + // Frame counter (yellow) + { + auto text = std::make_unique("text"); + text->setDouble("x", 10.0); + text->setDouble("y", 30.0); + text->setString("text", "Frame: " + std::to_string(frameCount)); + text->setInt("fontSize", 8); + text->setInt("color", static_cast(0xFFFF00FF)); // Yellow + text->setInt("layer", 100); + + std::unique_ptr data = std::move(text); + gameIO->publish("render:text", std::move(data)); + } + + // Instructions (green) + { + auto text = std::make_unique("text"); + text->setDouble("x", 10.0); + text->setDouble("y", height - 20.0); + text->setString("text", "Press ESC to exit"); + text->setInt("fontSize", 8); + text->setInt("color", static_cast(0x00FF00FF)); // Green + text->setInt("layer", 100); + + std::unique_ptr data = std::move(text); + gameIO->publish("render:text", std::move(data)); + } + + // Center animated text + { + auto text = std::make_unique("text"); + float textX = width / 2.0f - 100.0f + std::sin(time * 2.0f) * 50.0f; + float textY = height / 2.0f + 80.0f; + text->setDouble("x", textX); + text->setDouble("y", textY); + text->setString("text", "Hello, World!"); + text->setInt("fontSize", 24); + + // Cycle through colors + uint8_t r = static_cast(128 + 127 * std::sin(time * 3.0f)); + uint8_t g = static_cast(128 + 127 * std::sin(time * 3.0f + 2.0f)); + uint8_t b = static_cast(128 + 127 * std::sin(time * 3.0f + 4.0f)); + uint32_t color = (r << 24) | (g << 16) | (b << 8) | 0xFF; + text->setInt("color", static_cast(color)); + text->setInt("layer", 100); + + std::unique_ptr data = std::move(text); + gameIO->publish("render:text", std::move(data)); + } + // ======================================== // Process frame (renderer pulls IIO messages) // ======================================== @@ -245,6 +350,8 @@ int main(int argc, char* argv[]) { grove::JsonDataNode input("input"); input.setDouble("deltaTime", 1.0 / 60.0); input.setInt("frameNumber", static_cast(frameCount)); + input.setInt("windowWidth", width); + input.setInt("windowHeight", height); module->process(input); frameCount++;