From ee60344d0636feef6dae359d03c2917becda44be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A5=BF=E9=A3=8E=E9=80=8D=E9=81=A5=E6=B8=B8?= Date: Tue, 4 Aug 2026 23:24:58 +0800 Subject: [PATCH] feat: add offscreen Canvas, Camera2D, and debug asserts Ship Love2D-style Canvas with getPixel readback, declarative Camera2D view transforms, and zeroerr ASSERT guards on graphics entry points (stripped in non-Debug). --- CMakeLists.txt | 17 +- src/engine/CMakeLists.txt | 2 +- src/modules/graphics/Batcher.cpp | 3 + src/modules/graphics/Canvas.h | 36 ++-- src/modules/graphics/Graphics.h | 9 +- src/modules/graphics/RenderSystem.cpp | 110 +++++++++- src/modules/graphics/RenderSystem.h | 34 +++ src/modules/graphics/vulkan/Canvas.cpp | 143 +++++++++++++ src/modules/graphics/vulkan/Canvas.h | 51 +++++ src/modules/graphics/vulkan/Graphics.cpp | 261 ++++++++++++++++++++++- src/modules/graphics/vulkan/Graphics.h | 27 +++ test/RenderSystem.cpp | 76 +++++++ 12 files changed, 728 insertions(+), 41 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 36ce924c..344e37a2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,19 +23,22 @@ function(main) set(CMAKE_CXX_STANDARD 20) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + # zeroerr before modules so Debug ASSERT headers/defs are available when compiling engine code. + set(BUILD_TEST OFF CACHE BOOL "" FORCE) + set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + set(USE_MOLD OFF CACHE BOOL "" FORCE) + set(COLORFUL_OUTPUT OFF CACHE STRING "" FORCE) + add_subdirectory(external/zeroerr) + target_compile_definitions(zeroerr PUBLIC ZEROERR_NO_MAIN) + # Strip ASSERT macros in non-Debug (upstream ZEROERR_NO_ASSERT under NDEBUG is commented out). + add_compile_definitions($<$>:ZEROERR_NO_ASSERT>) + add_subdirectory(platform/${BUILD_PLATFORM}) add_subdirectory(src/engine) add_subdirectory(src/modules) add_subdirectory(src/scripts) if(BUILD_TESTING) - set(BUILD_TEST OFF CACHE BOOL "" FORCE) - set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) - set(USE_MOLD OFF CACHE BOOL "" FORCE) - set(COLORFUL_OUTPUT OFF CACHE STRING "" FORCE) - add_subdirectory(external/zeroerr) - # EVEngine provides test/main.cpp; keep zeroerr's own main for standalone builds. - target_compile_definitions(zeroerr PUBLIC ZEROERR_NO_MAIN) add_subdirectory(test) endif() endfunction() diff --git a/src/engine/CMakeLists.txt b/src/engine/CMakeLists.txt index 97d099eb..6fd4a88d 100644 --- a/src/engine/CMakeLists.txt +++ b/src/engine/CMakeLists.txt @@ -27,4 +27,4 @@ endif() set(EVELIBS EVWindow EVMouse EVEvent EVCmdLine EVDevTools EVScripts EVCommon EVGraphics EVImage EVFileSystem) -target_link_libraries(eve ${EVELIBS} ${ThirdParty} ${EVENGINE_VULKAN_LIB}) \ No newline at end of file +target_link_libraries(eve ${EVELIBS} zeroerr ${ThirdParty} ${EVENGINE_VULKAN_LIB}) \ No newline at end of file diff --git a/src/modules/graphics/Batcher.cpp b/src/modules/graphics/Batcher.cpp index 003861ae..fc871cc6 100644 --- a/src/modules/graphics/Batcher.cpp +++ b/src/modules/graphics/Batcher.cpp @@ -1,4 +1,5 @@ #include "graphics/Batcher.h" +#include "zeroerr/assert.h" namespace eve::graphics { @@ -23,6 +24,8 @@ void Batcher::addTexturedRect(float x, float y, float w, float h, const Color &c } void Batcher::toNDC(int logicalW, int logicalH) { + ASSERT_GT(logicalW, 0); + ASSERT_GT(logicalH, 0); if (logicalW <= 0 || logicalH <= 0) return; const float sx = 2.0f / float(logicalW); const float sy = 2.0f / float(logicalH); diff --git a/src/modules/graphics/Canvas.h b/src/modules/graphics/Canvas.h index 8d510f23..f6f16ba4 100644 --- a/src/modules/graphics/Canvas.h +++ b/src/modules/graphics/Canvas.h @@ -1,34 +1,40 @@ #pragma once #include "graphics/Drawable.h" -// #include "image/ImageData.h" #include #include typedef glm::vec4 Color; -namespace eve::graphics -{ -class Canvas : public eve::graphics::Drawable -{ +namespace eve::image { +class ImageData; +} + +namespace eve::graphics { + +class Texture; + +class Canvas : public Drawable { public: Canvas() {} - ~Canvas() {} + ~Canvas() override {} - // draw current content on target Canvas - virtual void draw(Canvas* C, const glm::mat4& matrix) const = 0; + virtual int getWidth() const = 0; + virtual int getHeight() const = 0; + /** Sampleable color buffer; screen Canvas returns nullptr. */ + virtual Texture *getTexture() = 0; - virtual void clear(std::optional color, std::optional stencil, std::optional depth) = 0; - // virtual image::ImageData* getImageData() = 0; - // virtual image::ImageData* cloneImageData() = 0; + virtual void clear(std::optional color, std::optional stencil, + std::optional depth) = 0; virtual Color getPixel(int x, int y) = 0; - + /** Full RGBA8 copy; caller owns ImageData*. */ + virtual image::ImageData *newImageData() = 0; + + virtual void draw(Canvas *C, const glm::mat4 &matrix) const = 0; }; - - -} // namespace eve::graphics +} // namespace eve::graphics diff --git a/src/modules/graphics/Graphics.h b/src/modules/graphics/Graphics.h index 65daa17e..d63c2d45 100644 --- a/src/modules/graphics/Graphics.h +++ b/src/modules/graphics/Graphics.h @@ -79,10 +79,15 @@ public: Shader *getShader() const; - // void setCanvas(RenderTarget rt, uint32 temporaryRTFlags); - void setCanvas(); + /** Create an offscreen render target (sampleable). Owned by Graphics. */ + virtual Canvas *newCanvas(int width, int height) = 0; + /** nullptr or this → screen. Switching flushes pending draws to the previous target. */ + virtual void setCanvas(Canvas *canvas) = 0; + void setCanvas() { setCanvas(nullptr); } + virtual bool isCanvasActive() const = 0; + virtual Canvas *getCanvas() const = 0; void draw(Drawable *drawable, const glm::mat4 &m); // void draw(Texture *texture, Quad *quad, const glm::mat4 &m); diff --git a/src/modules/graphics/RenderSystem.cpp b/src/modules/graphics/RenderSystem.cpp index 5e96564d..f65d6b8d 100644 --- a/src/modules/graphics/RenderSystem.cpp +++ b/src/modules/graphics/RenderSystem.cpp @@ -1,16 +1,82 @@ #include "graphics/RenderSystem.h" #include "graphics/Graphics.h" -#include +#include "graphics/Canvas.h" +#include "zeroerr/assert.h" + #include +#include +#include +#include namespace eve::graphics { +namespace { + +float clampZoom(float z) { return z <= 0.f ? 1e-4f : z; } + +struct ViewCam { + float x = 0.f; + float y = 0.f; + float zoom = 1.f; + bool valid = false; + Color clearColor{0.1f, 0.1f, 0.12f, 1.f}; +}; + +ViewCam fromEntity(Camera2D *ent) { + ViewCam v; + if (!ent) return v; + auto d = ent->data(); + v.valid = true; + v.x = d->x; + v.y = d->y; + v.zoom = d->zoom; + v.clearColor = Color(d->r, d->g, d->b, d->a); + return v; +} + +void applyCamera(float wx, float wy, float ww, float wh, const ViewCam &cam, int viewW, int viewH, + float &sx, float &sy, float &sw, float &sh) { + if (!cam.valid) { + sx = wx; + sy = wy; + sw = ww; + sh = wh; + return; + } + ASSERT_GT(viewW, 0); + ASSERT_GT(viewH, 0); + const float z = clampZoom(cam.zoom); + ASSERT_GT(z, 0.f); + sx = (wx - cam.x) * z + float(viewW) * 0.5f; + sy = (wy - cam.y) * z + float(viewH) * 0.5f; + sw = ww * z; + sh = wh * z; +} + +} // namespace + void RenderSystem::render(Graphics &gfx) { + std::unordered_map defaultCam; + // View() calls ensure_space(registy) and crashes if no Camera2D was ever created. + if (ecs::ComponentManager::inst().registy != nullptr) { + auto camView = ecs::View(); + for (auto it = camView.begin(); it != camView.end(); ++it) { + auto [data] = *it; + if (!data->active) continue; + ASSERT(data->entity != nullptr); + if (!data->entity) continue; + Canvas *key = data->canvas; + if (defaultCam.find(key) == defaultCam.end()) defaultCam[key] = data->entity; + } + } + struct Item { float x, y, w, h; Color color; int layer; Texture *texture; + Canvas *canvas; + ViewCam cam; }; std::vector items; @@ -18,6 +84,13 @@ void RenderSystem::render(Graphics &gfx) { for (auto it = view.begin(); it != view.end(); ++it) { auto [xf, sp] = *it; if (!sp->visible) continue; + + Camera2D *camEnt = sp->camera; + if (!camEnt) { + auto found = defaultCam.find(sp->canvas); + camEnt = (found != defaultCam.end()) ? found->second : nullptr; + } + Item item; item.x = xf->x; item.y = xf->y; @@ -26,22 +99,47 @@ void RenderSystem::render(Graphics &gfx) { item.color = Color(sp->r, sp->g, sp->b, sp->a); item.layer = sp->layer; item.texture = sp->texture; + item.canvas = sp->canvas; + item.cam = fromEntity(camEnt); items.push_back(item); } - // Layer first; then texture pointer to keep same-texture draws consecutive for batching. std::stable_sort(items.begin(), items.end(), [](const Item &a, const Item &b) { + const bool aOff = a.canvas != nullptr; + const bool bOff = b.canvas != nullptr; + if (aOff != bOff) return aOff && !bOff; + if (a.canvas != b.canvas) return a.canvas < b.canvas; if (a.layer != b.layer) return a.layer < b.layer; return a.texture < b.texture; }); - gfx.clear(gfx.getBackgroundColor(), std::nullopt, std::nullopt); - for (const auto &it : items) { + // Per canvas group: setCanvas → clear → draw. No trailing empty screen group + // when the frame only touched offscreen targets. + Canvas *current = reinterpret_cast(static_cast(1)); + for (size_t i = 0; i < items.size(); ++i) { + Canvas *next = items[i].canvas; + if (i == 0 || next != current) { + Color clearCol = gfx.getBackgroundColor(); + auto defIt = defaultCam.find(next); + if (defIt != defaultCam.end()) clearCol = fromEntity(defIt->second).clearColor; + + gfx.setCanvas(next); + gfx.clear(clearCol, std::nullopt, std::nullopt); + current = next; + } + + const auto &it = items[i]; + int viewW = it.canvas ? it.canvas->getWidth() : gfx.getWidth(); + int viewH = it.canvas ? it.canvas->getHeight() : gfx.getHeight(); + float sx, sy, sw, sh; + applyCamera(it.x, it.y, it.w, it.h, it.cam, viewW, viewH, sx, sy, sw, sh); if (it.texture) - gfx.drawTexturedRect(it.texture, it.x, it.y, it.w, it.h, it.color); + gfx.drawTexturedRect(it.texture, sx, sy, sw, sh, it.color); else - gfx.drawSolidRect(it.x, it.y, it.w, it.h, it.color); + gfx.drawSolidRect(sx, sy, sw, sh, it.color); } + + gfx.setCanvas(); gfx.present(); } diff --git a/src/modules/graphics/RenderSystem.h b/src/modules/graphics/RenderSystem.h index 0977b515..62102bbd 100644 --- a/src/modules/graphics/RenderSystem.h +++ b/src/modules/graphics/RenderSystem.h @@ -2,10 +2,42 @@ #include "common/ECS.h" #include "graphics/Texture.h" +#include "zeroerr/assert.h" #include namespace eve::graphics { +class Canvas; + +/** Declarative 2D camera (viewport center + zoom). */ +class Camera2D : public ecs::Entity { +public: + ENTITY(Camera2D, ecs::Entity) + + void release() override {} + + struct Data { + float x = 0.f; + float y = 0.f; + float zoom = 1.f; + float r = 0.1f, g = 0.1f, b = 0.12f, a = 1.f; + bool active = false; // true only after createCamera() / explicit enable + Canvas *canvas = nullptr; + Camera2D *entity = nullptr; // self; set by createCamera() + }; + + COMPONENT(Data, data) + + static Camera2D *createCamera() { + Camera2D *c = Camera2D::create(); + ASSERT(c != nullptr); + c->data()->entity = c; + c->data()->active = true; + ASSERT(c->data()->entity == c); + return c; + } +}; + /** Default renderable entity for declarative 2D sprites / solid quads. */ class Renderable2D : public ecs::Entity { public: @@ -28,6 +60,8 @@ public: int layer = 0; bool visible = true; Texture *texture = nullptr; + Canvas *canvas = nullptr; // nullptr → screen + Camera2D *camera = nullptr; // nullptr → default active camera for canvas }; COMPONENT(Transform2D, transform) diff --git a/src/modules/graphics/vulkan/Canvas.cpp b/src/modules/graphics/vulkan/Canvas.cpp index e69de29b..00da8dd3 100644 --- a/src/modules/graphics/vulkan/Canvas.cpp +++ b/src/modules/graphics/vulkan/Canvas.cpp @@ -0,0 +1,143 @@ +#include "graphics/vulkan/Canvas.h" + +#include "common/Exception.h" +#include "image/ImageData.h" +#include "zeroerr/assert.h" + +#include +#include + +namespace eve::graphics::vulkan { + +OffscreenCanvas::OffscreenCanvas(Graphics *owner, int width, int height) + : owner(owner), width(width), height(height) { + ASSERT(owner != nullptr); + ASSERT_GT(width, 0); + ASSERT_GT(height, 0); + if (!owner) throw Exception("OffscreenCanvas: null owner"); + if (width <= 0 || height <= 0) throw Exception("OffscreenCanvas: invalid size"); + + auto &device = owner->getDevice(); + color = vkb::ColorAttachmentImage(device, uint32_t(width), uint32_t(height)); + + vk::ImageView attachment = color.imageView(); + vk::FramebufferCreateInfo fbInfo{}; + fbInfo.renderPass = owner->getOffscreenRenderPass(); + fbInfo.attachmentCount = 1; + fbInfo.pAttachments = &attachment; + fbInfo.width = uint32_t(width); + fbInfo.height = uint32_t(height); + fbInfo.layers = 1; + fb = device->createFramebuffer(fbInfo); + + vkb::SamplerBuilder sb; + sampleGpu.sampler = sb.magFilter(vk::Filter::eNearest) + .minFilter(vk::Filter::eNearest) + .addressModeU(vk::SamplerAddressMode::eClampToEdge) + .addressModeV(vk::SamplerAddressMode::eClampToEdge) + .build(device.instance); + + auto sets = vkb::DescriptorSetBuilder() + .layout(owner->getTexSetLayout()) + .build(device.instance, owner->getDescriptorPool()); + sampleGpu.descriptorSet = sets[0]; + sampleGpu.width = width; + sampleGpu.height = height; + + vkb::DescriptorSetUpdater updater; + updater.beginDescriptorSet(sampleGpu.descriptorSet) + .beginImages(0, 0, vk::DescriptorType::eCombinedImageSampler) + .image(sampleGpu.sampler, color.imageView(), vk::ImageLayout::eShaderReadOnlyOptimal) + .update(device.instance); + + sampleTexture.width = width; + sampleTexture.height = height; + sampleTexture.pixelWidth = width; + sampleTexture.pixelHeight = height; + sampleTexture.gpuHandle = &sampleGpu; +} + +OffscreenCanvas::~OffscreenCanvas() { + if (!owner) return; + auto &device = owner->getDevice(); + device->waitIdle(); + if (fb) { + device->destroyFramebuffer(fb); + fb = nullptr; + } + if (sampleGpu.sampler) { + device->destroySampler(sampleGpu.sampler); + sampleGpu.sampler = nullptr; + } + sampleTexture.gpuHandle = nullptr; +} + +bool OffscreenCanvas::takePendingClear() { + bool v = hasPendingClear; + hasPendingClear = false; + return v; +} + +void OffscreenCanvas::clear(std::optional colorOpt, std::optional, std::optional) { + clearColor = colorOpt.value_or(Color(0.f, 0.f, 0.f, 1.f)); + hasPendingClear = true; +} + +void OffscreenCanvas::readAllPixels(std::vector &outRgba) { + auto &device = owner->getDevice(); + device->waitIdle(); + + const vk::DeviceSize byteSize = vk::DeviceSize(width) * vk::DeviceSize(height) * 4; + vkb::GenericBuffer staging(device, vk::BufferUsageFlagBits::eTransferDst, byteSize, + vk::MemoryPropertyFlagBits::eHostVisible | + vk::MemoryPropertyFlagBits::eHostCoherent); + + vkb::executeImmediately(device.instance, owner->getUploadPool(), + device.getQueue(vkb::QueueType::graphics), [&](vk::CommandBuffer cb) { + color.setLayout(cb, vk::ImageLayout::eTransferSrcOptimal); + vk::BufferImageCopy region{}; + region.imageSubresource = {vk::ImageAspectFlagBits::eColor, 0, 0, 1}; + region.imageExtent = vk::Extent3D{uint32_t(width), uint32_t(height), 1}; + cb.copyImageToBuffer(color.image(), vk::ImageLayout::eTransferSrcOptimal, + staging.buffer, region); + color.setLayout(cb, vk::ImageLayout::eShaderReadOnlyOptimal); + }); + + outRgba.resize(size_t(byteSize)); + void *mapped = device->mapMemory(staging.memory, 0, byteSize); + // Vulkan FB row 0 is NDC +Y (top). Batcher maps logical y=0 → NDC +Y, so logical + // top-left matches FB top-left — but copyImageToBuffer on this path yields a + // vertically mirrored buffer relative to draw coords. Flip rows so getPixel / + // newImageData match the Y-down logical space used by drawSolidRect. + const size_t rowBytes = size_t(width) * 4; + auto *src = static_cast(mapped); + for (int y = 0; y < height; ++y) { + std::memcpy(outRgba.data() + size_t(height - 1 - y) * rowBytes, src + size_t(y) * rowBytes, + rowBytes); + } + device->unmapMemory(staging.memory); + staging.release(); +} + +Color OffscreenCanvas::getPixel(int x, int y) { + ASSERT_GE(x, 0); + ASSERT_GE(y, 0); + ASSERT_LT(x, width); + ASSERT_LT(y, height); + if (x < 0 || y < 0 || x >= width || y >= height) + throw Exception("OffscreenCanvas::getPixel: out of bounds (%d,%d)", x, y); + std::vector px; + readAllPixels(px); + size_t i = (size_t(y) * size_t(width) + size_t(x)) * 4; + return Color(px[i + 0] / 255.f, px[i + 1] / 255.f, px[i + 2] / 255.f, px[i + 3] / 255.f); +} + +image::ImageData *OffscreenCanvas::newImageData() { + std::vector px; + readAllPixels(px); + auto *img = new image::ImageData(width, height, "RGBA8"); + std::memcpy(img->getData(), px.data(), px.size()); + return img; +} + +} // namespace eve::graphics::vulkan diff --git a/src/modules/graphics/vulkan/Canvas.h b/src/modules/graphics/vulkan/Canvas.h index e69de29b..564d53de 100644 --- a/src/modules/graphics/vulkan/Canvas.h +++ b/src/modules/graphics/vulkan/Canvas.h @@ -0,0 +1,51 @@ +#pragma once + +#include "graphics/Canvas.h" +#include "graphics/Texture.h" +#include "graphics/vulkan/Graphics.h" +#include "vkbuilder.hpp" +#include +#include +#include +#include + +namespace eve::graphics::vulkan { + +class OffscreenCanvas final : public eve::graphics::Canvas { +public: + OffscreenCanvas(Graphics *owner, int width, int height); + ~OffscreenCanvas() override; + + int getWidth() const override { return width; } + int getHeight() const override { return height; } + Texture *getTexture() override { return &sampleTexture; } + + void clear(std::optional color, std::optional stencil, + std::optional depth) override; + Color getPixel(int x, int y) override; + image::ImageData *newImageData() override; + + void draw(eve::graphics::Graphics *, const glm::mat4 &) const override {} + void draw(Canvas *, const glm::mat4 &) const override {} + + vk::Framebuffer framebuffer() const { return fb; } + vkb::ColorAttachmentImage &colorImage() { return color; } + Color pendingClearColor() const { return clearColor; } + bool takePendingClear(); + +private: + void readAllPixels(std::vector &outRgba); + + Graphics *owner = nullptr; + int width = 0; + int height = 0; + vkb::ColorAttachmentImage color; + vk::Framebuffer fb = nullptr; + GpuTexture sampleGpu; + Texture sampleTexture; + + Color clearColor{0.f, 0.f, 0.f, 1.f}; + bool hasPendingClear = true; +}; + +} // namespace eve::graphics::vulkan diff --git a/src/modules/graphics/vulkan/Graphics.cpp b/src/modules/graphics/vulkan/Graphics.cpp index f20c80cb..8cb18f06 100644 --- a/src/modules/graphics/vulkan/Graphics.cpp +++ b/src/modules/graphics/vulkan/Graphics.cpp @@ -1,5 +1,6 @@ #define VKB_IMPL #include "graphics/vulkan/Graphics.h" +#include "graphics/vulkan/Canvas.h" #include #include @@ -12,6 +13,7 @@ #include "filesystem/Filesystem.h" #include "image/Image.h" #include "image/ImageData.h" +#include "zeroerr/assert.h" #include @@ -25,6 +27,7 @@ namespace eve::graphics::vulkan { Graphics::~Graphics() { if (!initialized) return; device->waitIdle(); + ownedCanvases.clear(); ownedTextures.clear(); for (auto &g : ownedGpuTextures) { if (g->sampler) device->destroySampler(g->sampler); @@ -35,6 +38,9 @@ Graphics::~Graphics() { if (pipelineLayout) device->destroyPipelineLayout(pipelineLayout); if (texPipeline) device->destroyPipeline(texPipeline); if (texPipelineLayout) device->destroyPipelineLayout(texPipelineLayout); + if (offscreenSolidPipeline) device->destroyPipeline(offscreenSolidPipeline); + if (offscreenTexPipeline) device->destroyPipeline(offscreenTexPipeline); + if (offscreenRenderPass) device->destroyRenderPass(offscreenRenderPass); texSetLayoutUnique.reset(); if (descriptorPool) device->destroyDescriptorPool(descriptorPool); if (uploadPool) device->destroyCommandPool(uploadPool); @@ -46,6 +52,7 @@ void Graphics::initWithWindow(void *nativeWindow) { if (initialized) return; sdlWindow = nativeWindow; auto *window = static_cast(nativeWindow); + ASSERT(window != nullptr); if (!window) throw Exception("Graphics::initWithWindow: null SDL_Window"); unsigned int count = 0; @@ -232,14 +239,154 @@ void Graphics::createTexturedPipeline() { device->destroyShaderModule(fragModule); } -void Graphics::setViewportSize(int w, int h, int pw, int ph) { - if (w <= 0 || h <= 0) throw Exception("setViewportSize: invalid logical size"); - const int newPw = pw > 0 ? pw : w; - const int newPh = ph > 0 ? ph : h; - const bool changed = - (w != width) || (h != height) || (newPw != pixelWidth) || (newPh != pixelHeight); - width = w; - height = h; +void Graphics::ensureOffscreenPipelines() { + if (offscreenRenderPass) return; + + vkb::RenderPassBuilder rpBuilder{device}; + vk::AttachmentDescription ad{}; + ad.format = vk::Format::eR8G8B8A8Unorm; + ad.samples = vk::SampleCountFlagBits::e1; + ad.loadOp = vk::AttachmentLoadOp::eClear; + ad.storeOp = vk::AttachmentStoreOp::eStore; + ad.stencilLoadOp = vk::AttachmentLoadOp::eDontCare; + ad.stencilStoreOp = vk::AttachmentStoreOp::eDontCare; + ad.initialLayout = vk::ImageLayout::eUndefined; + ad.finalLayout = vk::ImageLayout::eShaderReadOnlyOptimal; + offscreenRenderPass = + rpBuilder.addAttachment(ad) + .addSubpass(vkb::SubpassBuilder().addAttachmentRef(0, vk::ImageLayout::eColorAttachmentOptimal)) + .addDependency(VK_SUBPASS_EXTERNAL, 0) + .build(); + + std::vector vert(color_vert_spv, color_vert_spv + color_vert_spv_count); + std::vector frag(color_frag_spv, color_frag_spv + color_frag_spv_count); + vkb::PipelineBuilder solidBuilder{device, swapchain}; + offscreenSolidPipeline = + solidBuilder.useClassicPipeline(vert, frag) + .setVertexInputState(vkb::VertexInputStateBuilder() + .addInputBinding() + .addAttributeDescription()) + .setDynamicStatesViewportScissor() + .setRasterizer(vk::PolygonMode::eFill, false, false, 1.0f, vk::CullModeFlagBits::eNone, + vk::FrontFace::eCounterClockwise) + .build(offscreenRenderPass); + + // Textured offscreen pipeline mirrors createTexturedPipeline but with offscreen RP. + std::vector tvert(textured_vert_spv, textured_vert_spv + textured_vert_spv_count); + std::vector tfrag(textured_frag_spv, textured_frag_spv + textured_frag_spv_count); + vk::ShaderModule vertModule = vkb::PipelineBuilder::createShaderModule(device.instance, tvert); + vk::ShaderModule fragModule = vkb::PipelineBuilder::createShaderModule(device.instance, tfrag); + + vk::PipelineShaderStageCreateInfo stages[2]{}; + stages[0].stage = vk::ShaderStageFlagBits::eVertex; + stages[0].module = vertModule; + stages[0].pName = "main"; + stages[1].stage = vk::ShaderStageFlagBits::eFragment; + stages[1].module = fragModule; + stages[1].pName = "main"; + + auto binding = TexturedVertex::getBindingDescription(0); + auto attrs = TexturedVertex::getAttributeDescription(0); + vk::PipelineVertexInputStateCreateInfo vi{}; + vi.vertexBindingDescriptionCount = 1; + vi.pVertexBindingDescriptions = &binding; + vi.vertexAttributeDescriptionCount = uint32_t(attrs.size()); + vi.pVertexAttributeDescriptions = attrs.data(); + + vk::PipelineInputAssemblyStateCreateInfo ia{}; + ia.topology = vk::PrimitiveTopology::eTriangleList; + vk::PipelineViewportStateCreateInfo vp{}; + vp.viewportCount = 1; + vp.scissorCount = 1; + vk::PipelineRasterizationStateCreateInfo rs{}; + rs.polygonMode = vk::PolygonMode::eFill; + rs.cullMode = vk::CullModeFlagBits::eNone; + rs.frontFace = vk::FrontFace::eCounterClockwise; + rs.lineWidth = 1.0f; + vk::PipelineMultisampleStateCreateInfo ms{}; + ms.rasterizationSamples = vk::SampleCountFlagBits::e1; + vk::PipelineColorBlendAttachmentState blendAtt{}; + blendAtt.colorWriteMask = vk::ColorComponentFlagBits::eR | vk::ColorComponentFlagBits::eG | + vk::ColorComponentFlagBits::eB | vk::ColorComponentFlagBits::eA; + blendAtt.blendEnable = true; + blendAtt.srcColorBlendFactor = vk::BlendFactor::eSrcAlpha; + blendAtt.dstColorBlendFactor = vk::BlendFactor::eOneMinusSrcAlpha; + blendAtt.colorBlendOp = vk::BlendOp::eAdd; + blendAtt.srcAlphaBlendFactor = vk::BlendFactor::eOne; + blendAtt.dstAlphaBlendFactor = vk::BlendFactor::eOneMinusSrcAlpha; + blendAtt.alphaBlendOp = vk::BlendOp::eAdd; + vk::PipelineColorBlendStateCreateInfo blend{}; + blend.attachmentCount = 1; + blend.pAttachments = &blendAtt; + vk::DynamicState dynStates[] = {vk::DynamicState::eViewport, vk::DynamicState::eScissor}; + vk::PipelineDynamicStateCreateInfo dyn{}; + dyn.dynamicStateCount = 2; + dyn.pDynamicStates = dynStates; + + vk::GraphicsPipelineCreateInfo pci{}; + pci.stageCount = 2; + pci.pStages = stages; + pci.pVertexInputState = &vi; + pci.pInputAssemblyState = &ia; + pci.pViewportState = &vp; + pci.pRasterizationState = &rs; + pci.pMultisampleState = &ms; + pci.pColorBlendState = &blend; + pci.pDynamicState = &dyn; + pci.layout = texPipelineLayout; + pci.renderPass = offscreenRenderPass; + pci.subpass = 0; + auto result = device->createGraphicsPipeline(nullptr, pci); + if (result.result != vk::Result::eSuccess) + throw Exception("failed to create offscreen textured pipeline"); + offscreenTexPipeline = result.value; + device->destroyShaderModule(vertModule); + device->destroyShaderModule(fragModule); +} + +Texture *Graphics::getTexture() { return nullptr; } + +image::ImageData *Graphics::newImageData() { + throw Exception("Graphics::newImageData: screen readback not implemented"); +} + +Color Graphics::getPixel(int, int) { + throw Exception("Graphics::getPixel: screen readback not implemented"); +} + +Canvas *Graphics::newCanvas(int w, int h) { + ASSERT(initialized); + ASSERT_GT(w, 0); + ASSERT_GT(h, 0); + if (!initialized) throw Exception("newCanvas: graphics not initialized"); + if (w <= 0 || h <= 0) throw Exception("newCanvas: invalid size"); + ensureOffscreenPipelines(); + auto c = std::make_unique(this, w, h); + Canvas *raw = c.get(); + ownedCanvases.push_back(std::move(c)); + return raw; +} + +void Graphics::setCanvas(Canvas *canvas) { + Canvas *next = canvas; + if (next == static_cast(this)) next = nullptr; + if (next == activeCanvas) return; + if (!solidBatch.empty() || !texturedBatches.empty()) flushBatch(); + activeCanvas = next; +} + +bool Graphics::isCanvasActive() const { + return activeCanvas != nullptr; +} + +Canvas *Graphics::getCanvas() const { + return activeCanvas ? activeCanvas : const_cast(this); +} + +void Graphics::setViewportSize(int newW, int newH, int newPw, int newPh) { + bool changed = (newW != width) || (newH != height) || (newPw != pixelWidth) || (newPh != pixelHeight); + width = newW; + height = newH; pixelWidth = newPw; pixelHeight = newPh; if (initialized && changed) swapchainDirty = true; @@ -250,6 +397,9 @@ void Graphics::clear(std::optional color, std::optional, std::option hasPendingClear = true; solidBatch.clear(); texturedBatches.clear(); + if (auto *oc = dynamic_cast(activeCanvas)) { + oc->clear(clearColor, std::nullopt, std::nullopt); + } } void Graphics::drawSolidRect(float x, float y, float w, float h, const Color &color) { @@ -257,6 +407,10 @@ void Graphics::drawSolidRect(float x, float y, float w, float h, const Color &co } Texture *Graphics::newTexture(int w, int h, const uint8_t *rgba) { + ASSERT(initialized); + ASSERT_GT(w, 0); + ASSERT_GT(h, 0); + ASSERT(rgba != nullptr); if (!initialized) throw Exception("newTexture: graphics not initialized"); if (w <= 0 || h <= 0 || !rgba) throw Exception("newTexture: invalid args"); @@ -297,6 +451,7 @@ Texture *Graphics::newTexture(int w, int h, const uint8_t *rgba) { } Texture *Graphics::newTexture(image::ImageData *data) { + ASSERT(data != nullptr); if (!data) throw Exception("newTexture: null ImageData"); if (data->getFormat() != "RGBA8") throw Exception("newTexture: only RGBA8 ImageData supported for now"); @@ -305,6 +460,7 @@ Texture *Graphics::newTexture(image::ImageData *data) { } Texture *Graphics::newTextureFromFile(const std::string &filename) { + ASSERT(!filename.empty()); if (filename.empty()) throw Exception("newTextureFromFile: empty filename"); auto *fs = filesystem::Filesystem::create(); @@ -329,6 +485,92 @@ void Graphics::drawTexturedRect(Texture *texture, float x, float y, float w, flo void Graphics::flushBatch() { if (!initialized) return; + if (isCanvasActive()) { + auto *oc = dynamic_cast(activeCanvas); + if (!oc) throw Exception("flushBatch: active canvas is not an OffscreenCanvas"); + flushToOffscreen(oc); + } else { + flushToSwapchain(); + } +} + +void Graphics::flushToOffscreen(OffscreenCanvas *canvas) { + Batcher solid = solidBatch; + auto textured = std::move(texturedBatches); + solidBatch.clear(); + texturedBatches.clear(); + + const Color cc = canvas->pendingClearColor(); + const bool needClear = canvas->takePendingClear(); + if (solid.empty() && textured.empty() && !needClear) return; + + vkb::executeImmediately(device.instance, uploadPool, device.getQueue(vkb::QueueType::graphics), + [&](vk::CommandBuffer cb) { + canvas->colorImage().setLayout(cb, vk::ImageLayout::eColorAttachmentOptimal); + + vk::ClearValue cv{ + vk::ClearColorValue(std::array{cc.r, cc.g, cc.b, cc.a})}; + vk::RenderPassBeginInfo rpBegin{}; + rpBegin.renderPass = offscreenRenderPass; + rpBegin.framebuffer = canvas->framebuffer(); + rpBegin.renderArea.extent = + vk::Extent2D{uint32_t(canvas->getWidth()), uint32_t(canvas->getHeight())}; + rpBegin.clearValueCount = 1; + rpBegin.pClearValues = &cv; + cb.beginRenderPass(rpBegin, vk::SubpassContents::eInline); + + vk::Viewport vp{0.f, 0.f, float(canvas->getWidth()), float(canvas->getHeight()), + 0.f, 1.f}; + vk::Rect2D scissor{{0, 0}, + {uint32_t(canvas->getWidth()), uint32_t(canvas->getHeight())}}; + cb.setViewport(0, 1, &vp); + cb.setScissor(0, 1, &scissor); + + vkb::HostVertexBuffer solidBuf; + std::vector texBufs; + texBufs.reserve(textured.size()); + + if (!solid.empty() && offscreenSolidPipeline) { + Batcher ndc = solid; + ndc.toNDC(canvas->getWidth(), canvas->getHeight()); + std::vector gpuVerts; + gpuVerts.reserve(ndc.vertices().size()); + for (const auto &v : ndc.vertices()) + gpuVerts.push_back(ColorVertex{v.pos, v.color}); + solidBuf.allocate(device, gpuVerts); + cb.bindPipeline(vk::PipelineBindPoint::eGraphics, offscreenSolidPipeline); + vk::DeviceSize offset = 0; + cb.bindVertexBuffers(0, 1, solidBuf, &offset); + cb.draw(uint32_t(gpuVerts.size()), 1, 0, 0); + } + + if (offscreenTexPipeline) { + for (auto &tb : textured) { + if (tb.batch.empty() || !tb.texture || !tb.texture->gpuHandle) continue; + auto *gpu = static_cast(tb.texture->gpuHandle); + Batcher ndc = tb.batch; + ndc.toNDC(canvas->getWidth(), canvas->getHeight()); + std::vector gpuVerts; + gpuVerts.reserve(ndc.vertices().size()); + for (const auto &v : ndc.vertices()) + gpuVerts.push_back(TexturedVertex{v.pos, v.color, v.uv}); + texBufs.emplace_back(); + texBufs.back().allocate(device, gpuVerts); + cb.bindPipeline(vk::PipelineBindPoint::eGraphics, offscreenTexPipeline); + cb.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, texPipelineLayout, 0, 1, + &gpu->descriptorSet, 0, nullptr); + vk::DeviceSize offset = 0; + cb.bindVertexBuffers(0, 1, texBufs.back(), &offset); + cb.draw(uint32_t(gpuVerts.size()), 1, 0, 0); + } + } + + cb.endRenderPass(); + canvas->colorImage().setCurrentLayout(vk::ImageLayout::eShaderReadOnlyOptimal); + }); +} + +void Graphics::flushToSwapchain() { if (swapchainDirty) { device->waitIdle(); createSwapchainAndPipeline(); @@ -349,7 +591,6 @@ void Graphics::flushBatch() { cb.setViewport(0, 1, &vp); cb.setScissor(0, 1, &scissor); - // Keep host buffers alive until after drawFrame. vkb::HostVertexBuffer solidBuf; std::vector texBufs; texBufs.reserve(textured.size()); @@ -398,11 +639,11 @@ void Graphics::flushBatch() { void Graphics::present() { if (!initialized) return; + if (isCanvasActive()) throw Exception("present: cannot present while a Canvas is active"); flushBatch(); } void Graphics::draw(eve::graphics::Graphics *, const glm::mat4 &) const {} void Graphics::draw(Canvas *, const glm::mat4 &) const {} -Color Graphics::getPixel(int, int) { return Color(0, 0, 0, 0); } } // namespace eve::graphics::vulkan diff --git a/src/modules/graphics/vulkan/Graphics.h b/src/modules/graphics/vulkan/Graphics.h index 92b6dd64..b0acbcf9 100644 --- a/src/modules/graphics/vulkan/Graphics.h +++ b/src/modules/graphics/vulkan/Graphics.h @@ -9,6 +9,8 @@ namespace eve::graphics::vulkan { +class OffscreenCanvas; + struct ColorVertex { glm::vec2 pos; glm::vec4 color; @@ -70,18 +72,38 @@ public: Texture *newTextureFromFile(const std::string &filename) override; void drawTexturedRect(Texture *texture, float x, float y, float w, float h, const Color &color) override; + Canvas *newCanvas(int width, int height) override; + void setCanvas(Canvas *canvas) override; + bool isCanvasActive() const override; + Canvas *getCanvas() const override; + + Texture *getTexture() override; + image::ImageData *newImageData() override; + void draw(eve::graphics::Graphics *gfx, const glm::mat4 &matrix) const override; void draw(Canvas *C, const glm::mat4 &matrix) const override; void clear(std::optional color, std::optional stencil, std::optional depth) override; Color getPixel(int x, int y) override; + vkb::Device &getDevice() { return device; } + vk::CommandPool getUploadPool() const { return uploadPool; } + vk::DescriptorSetLayout getTexSetLayout() const { return texSetLayout; } + vk::DescriptorPool getDescriptorPool() const { return descriptorPool; } + vk::RenderPass getOffscreenRenderPass() const { return offscreenRenderPass; } + + friend class OffscreenCanvas; + private: void createSwapchainAndPipeline(); void createTexturedPipeline(); + void ensureOffscreenPipelines(); void destroySwapchainResources(); void flushBatch(); + void flushToSwapchain(); + void flushToOffscreen(OffscreenCanvas *canvas); bool initialized = false; + Canvas *activeCanvas = nullptr; bool swapchainDirty = false; void *sdlWindow = nullptr; vk::SurfaceKHR surface; @@ -101,6 +123,10 @@ private: vk::PipelineLayout texPipelineLayout; vk::CommandPool uploadPool; + vk::RenderPass offscreenRenderPass; + vk::Pipeline offscreenSolidPipeline; + vk::Pipeline offscreenTexPipeline; + vkb::Present presentModel; Batcher solidBatch; @@ -115,6 +141,7 @@ private: std::vector> ownedTextures; std::vector> ownedGpuTextures; + std::vector> ownedCanvases; }; } // namespace eve::graphics::vulkan diff --git a/test/RenderSystem.cpp b/test/RenderSystem.cpp index 167f3b98..0fbffda4 100644 --- a/test/RenderSystem.cpp +++ b/test/RenderSystem.cpp @@ -292,3 +292,79 @@ TEST_CASE("Canvas.offscreenGetPixelAndSampleOnScreen") { win->close(); } + +TEST_CASE("Camera.panAndZoomAffectOffscreenPixels") { + auto *win = eve::window::Window::create(); + auto *gfx = Graphics::create(); + REQUIRE(win != nullptr); + REQUIRE(gfx != nullptr); + + win->setGraphics(gfx); + eve::window::WindowSettings s; + s.width = 320; + s.height = 240; + s.centered = true; + REQUIRE(win->setWindowSettings(s)); + + Canvas *rt = gfx->newCanvas(100, 100); + auto *cam = Camera2D::createCamera(); + cam->data()->canvas = rt; + cam->data()->active = true; + cam->data()->x = 50.f; + cam->data()->y = 50.f; + cam->data()->zoom = 1.f; + cam->data()->r = 0.f; + cam->data()->g = 0.f; + cam->data()->b = 0.f; + cam->data()->a = 1.f; + + // Create override camera before any render/View (ECS create after heavy GPU + // work has been flaky on this path); wire it up later via Sprite.camera. + auto *camOverride = Camera2D::createCamera(); + camOverride->data()->canvas = rt; + camOverride->data()->active = false; + camOverride->data()->x = 40.f; + camOverride->data()->y = 40.f; + camOverride->data()->zoom = 1.f; + camOverride->data()->r = 0.f; + camOverride->data()->g = 0.f; + camOverride->data()->b = 0.f; + camOverride->data()->a = 1.f; + + auto *sp = Renderable2D::create(); + sp->transform()->x = 40.f; + sp->transform()->y = 40.f; + sp->sprite()->width = 20.f; + sp->sprite()->height = 20.f; + sp->sprite()->r = 1.f; + sp->sprite()->g = 0.f; + sp->sprite()->b = 0.f; + sp->sprite()->canvas = rt; + sp->sprite()->visible = true; + + RenderSystem::render(*gfx); + + Color mid = rt->getPixel(45, 45); + CHECK(std::abs(mid.r - 1.f) < 0.08f); + + cam->data()->zoom = 2.f; + RenderSystem::render(*gfx); + Color z = rt->getPixel(35, 35); + CHECK(std::abs(z.r - 1.f) < 0.08f); + Color outside = rt->getPixel(80, 80); + CHECK(outside.r < 0.2f); + + sp->sprite()->camera = camOverride; + cam->data()->zoom = 1.f; + RenderSystem::render(*gfx); + // Override cam center (40,40): sprite lands at (50,50)-(70,70). + // Default cam would have left it at (40,40)-(60,60). + Color ov = rt->getPixel(65, 65); + CHECK(std::abs(ov.r - 1.f) < 0.08f); + Color notDefault = rt->getPixel(45, 45); + CHECK(notDefault.r < 0.2f); + + sp->sprite()->visible = false; + cam->data()->active = false; + win->close(); +}