diff --git a/docs/dev/2D渲染API设计.md b/docs/dev/2D渲染API设计.md index 5f96f195..98fb3913 100644 --- a/docs/dev/2D渲染API设计.md +++ b/docs/dev/2D渲染API设计.md @@ -216,6 +216,16 @@ using PrimitiveHandle = RuntimeHandle; RenderSystem 每帧只读快照并投影到 DrawList。句柄在删除、clear、reload 后用 generation/owner epoch 检测 stale。它不创建 ECS Entity;若图形是游戏实体表现,由对应 ECS component 做权威 owner。 +已实现的长期缓存是每个 slot 的 owning CPU 命令缓存,不是 GPU 常驻网格或实例化缓冲。 +第一次绘制展开固定细分图形;未修改的后续帧直接复制命令,相机变化不使缓存失效, +裁剪、虚线和屏幕线宽仍使用当前帧 context 求解。成功 update/updateMany 使对应 slot +失效(包括仅修改颜色或变换);失败更新保持缓存。remove/clear 释放缓存,旧帧持有独立副本。 +render 包括 const 调用均限 owner 线程,不保留调用方 Canvas,也不调用回调。 +目标 Canvas 剩余预算不足时整图形不提交,遗漏命令数记入 droppedCommands; +cacheHits 对长期场景按复用的可见 slot 计数。该实现省去几何重复展开,但仍有每帧命令复制和 GPU 上传。 +Vulkan 单次提交、WebGPU 单次 flush 均将排序后的各组顶点合并为一次上传,再按组设置绘制状态。 +共享 clip 坐标采用引擎 RH/ZO/Y-down 约定;WebGPU 顶点阶段适配 Y 方向,与现有 Mesh 路径一致。 + 脚本首期只暴露长期 handle API,避免 Squirrel 每帧逐图形调用;即时 Canvas 服务 C++、编辑器和 DevTools。脚本 proxy 不持有 Canvas 裸指针。 diff --git a/src/modules/graphics/PrimitiveDrawList.h b/src/modules/graphics/PrimitiveDrawList.h index 84e79825..83c65668 100644 --- a/src/modules/graphics/PrimitiveDrawList.h +++ b/src/modules/graphics/PrimitiveDrawList.h @@ -155,6 +155,7 @@ public: [[nodiscard]] const PrimitiveDrawStatistics& statistics() const noexcept { return statistics_; } private: + friend class PrimitiveScene; SceneDrawContext context_; std::size_t hardCommandLimit_; glm::mat4 transform_{1.f}; diff --git a/src/modules/graphics/PrimitiveScene.cpp b/src/modules/graphics/PrimitiveScene.cpp index dbb18263..fd5f7deb 100644 --- a/src/modules/graphics/PrimitiveScene.cpp +++ b/src/modules/graphics/PrimitiveScene.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -146,7 +147,7 @@ eve::Result PrimitiveScene::add(PrimitiveDescriptor3D descripto "primitive scene slot capacity exceeded"); } index = static_cast(slots_.size()); - slots_.push_back(Slot{std::move(descriptor), 1, false}); + slots_.push_back(Slot{std::move(descriptor), 1, false, {}}); } ++liveCount_; return eve::Result::success(PrimitiveHandle(owner_, index, slots_[index].generation)); @@ -159,6 +160,7 @@ eve::Result PrimitiveScene::update(PrimitiveHandle handle auto validation = validateDescriptor(descriptor); if (!validation) return eve::Result::failure(validation.status()); slots_[handle.index()].descriptor = std::move(descriptor); + slots_[handle.index()].cache.reset(); return eve::Result::success(PrimitiveUpdateStatus::Updated); } @@ -168,6 +170,7 @@ eve::Result PrimitiveScene::remove(PrimitiveHandle handle } Slot& slot = slots_[handle.index()]; slot.descriptor.reset(); + slot.cache.reset(); --liveCount_; const auto next = PrimitiveHandle::nextGeneration(slot.generation); if (next) { @@ -191,8 +194,10 @@ eve::Result PrimitiveScene::updateMany(std::span prepared(updates.begin(), updates.end()); static_assert(std::is_nothrow_move_assignable_v); - for (PrimitiveBatchUpdate& update : prepared) + for (PrimitiveBatchUpdate& update : prepared) { *slots_[update.handle.index()].descriptor = std::move(update.descriptor); + slots_[update.handle.index()].cache.reset(); + } return eve::Result::success(updates.size()); } @@ -209,6 +214,7 @@ void PrimitiveScene::clear() { for (PrimitiveHandle::index_type index = 0; index < slots_.size(); ++index) { Slot& slot = slots_[index]; slot.descriptor.reset(); + slot.cache.reset(); if (slot.retired) continue; const auto next = PrimitiveHandle::nextGeneration(slot.generation); if (next) { @@ -220,48 +226,76 @@ void PrimitiveScene::clear() { } } -void PrimitiveScene::render(PrimitiveSceneCanvas3D& canvas) const { +void PrimitiveScene::render(PrimitiveSceneCanvas3D& destination) const { for (const Slot& slot : slots_) { if (!slot.descriptor || !slot.descriptor->visible) continue; const PrimitiveDescriptor3D& descriptor = *slot.descriptor; - canvas.save(); - canvas.concat(descriptor.transform); - std::visit( - [&](const auto& geometry) { - using Geometry = std::decay_t; - if constexpr (std::is_same_v) { - canvas.drawPolyline(geometry.points, geometry.closed, descriptor.paint); - } else if constexpr (std::is_same_v) { - canvas.drawAabb(geometry.minimum, geometry.maximum, descriptor.paint); - } else if constexpr (std::is_same_v) { - canvas.drawObb(geometry.center, geometry.halfAxes, descriptor.paint); - } else if constexpr (std::is_same_v) { - canvas.drawDisk(geometry.center, geometry.normal, geometry.radius, descriptor.paint, - geometry.segments); - } else if constexpr (std::is_same_v) { - canvas.drawArc(geometry.center, geometry.normal, geometry.zeroDirection, geometry.radius, - geometry.startRadians, geometry.sweepRadians, descriptor.paint, geometry.segments); - } else if constexpr (std::is_same_v) { - canvas.drawSphere(geometry.center, geometry.radius, descriptor.paint, geometry.segments); - } else if constexpr (std::is_same_v) { - canvas.drawCapsule(geometry.a, geometry.b, geometry.radius, descriptor.paint, geometry.segments); - } else if constexpr (std::is_same_v) { - canvas.drawCylinder(geometry.a, geometry.b, geometry.radius, descriptor.paint, geometry.segments); - } else if constexpr (std::is_same_v) { - canvas.drawCone(geometry.apex, geometry.axis, geometry.height, geometry.radius, descriptor.paint, - geometry.segments); - } else if constexpr (std::is_same_v) { - canvas.drawGrid(geometry.origin, geometry.axisU, geometry.axisV, geometry.cellsU, geometry.cellsV, - descriptor.paint); - } else if constexpr (std::is_same_v) { - canvas.drawArrow(geometry.from, geometry.to, geometry.headLength, geometry.headRadius, - descriptor.paint); - } else if constexpr (std::is_same_v) { - canvas.drawFrustum(geometry.corners, descriptor.paint); - } - }, - descriptor.geometry); - canvas.restore(); + const bool hit = slot.cache.has_value(); + if (!hit) { + PrimitiveSceneCanvas3D canvas(destination.context_, std::numeric_limits::max()); + canvas.concat(descriptor.transform); + std::visit( + [&](const auto& geometry) { + using Geometry = std::decay_t; + if constexpr (std::is_same_v) { + canvas.drawPolyline(geometry.points, geometry.closed, descriptor.paint); + } else if constexpr (std::is_same_v) { + canvas.drawAabb(geometry.minimum, geometry.maximum, descriptor.paint); + } else if constexpr (std::is_same_v) { + canvas.drawObb(geometry.center, geometry.halfAxes, descriptor.paint); + } else if constexpr (std::is_same_v) { + canvas.drawDisk(geometry.center, geometry.normal, geometry.radius, descriptor.paint, + geometry.segments); + } else if constexpr (std::is_same_v) { + canvas.drawArc(geometry.center, geometry.normal, geometry.zeroDirection, geometry.radius, + geometry.startRadians, geometry.sweepRadians, descriptor.paint, + geometry.segments); + } else if constexpr (std::is_same_v) { + canvas.drawSphere(geometry.center, geometry.radius, descriptor.paint, geometry.segments); + } else if constexpr (std::is_same_v) { + canvas.drawCapsule(geometry.a, geometry.b, geometry.radius, descriptor.paint, + geometry.segments); + } else if constexpr (std::is_same_v) { + canvas.drawCylinder(geometry.a, geometry.b, geometry.radius, descriptor.paint, + geometry.segments); + } else if constexpr (std::is_same_v) { + canvas.drawCone(geometry.apex, geometry.axis, geometry.height, geometry.radius, + descriptor.paint, geometry.segments); + } else if constexpr (std::is_same_v) { + canvas.drawGrid(geometry.origin, geometry.axisU, geometry.axisV, geometry.cellsU, + geometry.cellsV, descriptor.paint); + } else if constexpr (std::is_same_v) { + canvas.drawArrow(geometry.from, geometry.to, geometry.headLength, geometry.headRadius, + descriptor.paint); + } else if constexpr (std::is_same_v) { + canvas.drawFrustum(geometry.corners, descriptor.paint); + } + }, + descriptor.geometry); + slot.cache = std::move(canvas); + } + const auto& cached = *slot.cache; + const auto count = cached.commands_.size() + cached.triangles_.size(); + const auto used = destination.commands_.size() + destination.triangles_.size(); + if (used > destination.hardCommandLimit_ || count > destination.hardCommandLimit_ - used) { + destination.statistics_.droppedCommands += count + cached.statistics_.droppedCommands; + continue; + } + // Prepare all owning payloads and capacity before publishing this slot. + auto lines = cached.commands_; + auto triangles = cached.triangles_; + for (auto& command : lines) command.transform = destination.transform_ * command.transform; + for (auto& command : triangles) command.transform = destination.transform_ * command.transform; + destination.commands_.reserve(destination.commands_.size() + lines.size()); + destination.triangles_.reserve(destination.triangles_.size() + triangles.size()); + static_assert(std::is_nothrow_move_constructible_v); + static_assert(std::is_nothrow_move_constructible_v); + for (auto& command : lines) destination.commands_.push_back(std::move(command)); + for (auto& command : triangles) destination.triangles_.push_back(std::move(command)); + destination.statistics_.commandCount += cached.statistics_.commandCount; + destination.statistics_.segmentCount += cached.statistics_.segmentCount; + destination.statistics_.triangleCount += cached.statistics_.triangleCount; + destination.statistics_.cacheHits += hit ? 1u : 0u; } } diff --git a/src/modules/graphics/PrimitiveScene.h b/src/modules/graphics/PrimitiveScene.h index 4935c945..3e236d17 100644 --- a/src/modules/graphics/PrimitiveScene.h +++ b/src/modules/graphics/PrimitiveScene.h @@ -134,8 +134,11 @@ struct PrimitiveBatchUpdate { /** * @brief Authoritative owner for primitives that persist across frames. * - * Mutation is owner-thread affine. render() is const and synchronously projects - * descriptors into a caller-owned frame Canvas without retaining it. + * All operations, including render(), are owner-thread affine and invoke no callbacks. + * Descriptors are authoritative; cached CPU commands are disposable derived data. + * @ownership Owns descriptors and caches; borrows the destination Canvas only during rendering. + * @lifetime Cached data lives until successful replacement, removal, clear, or scene destruction. + * Rendering synchronously copies commands into a caller-owned Canvas without retaining it. */ class PrimitiveScene { public: @@ -163,7 +166,18 @@ public: [[nodiscard]] bool isStale(PrimitiveHandle handle) const noexcept; /** @brief Invalidates all live handles while retaining slot capacity. */ void clear(); - /** @brief Projects visible descriptors in stable slot order. */ + /** + * @brief Projects visible descriptors in stable slot order using per-slot CPU caches. + * Successful updates invalidate the affected cache; removal/clear release it. + * Camera changes reuse geometry, but clipping and screen-space stroke resolution + * remain frame-local. No GPU allocation is retained by this cache. + * A primitive exceeding the remaining command budget is omitted as a whole; + * statistics().droppedCommands reports omitted commands. Allocation failure throws + * before publishing that primitive; already recorded primitives remain valid. + * @param canvas Borrowed for this call only; receives independent owning commands. + * @thread Owner-thread affine, including concurrent const calls. + * @reentrancy Does not invoke callbacks. + */ void render(PrimitiveSceneCanvas3D& canvas) const; [[nodiscard]] std::size_t size() const noexcept { return liveCount_; } @@ -172,6 +186,7 @@ private: std::optional descriptor; PrimitiveHandle::generation_type generation = 1; bool retired = false; + mutable std::optional cache; }; [[nodiscard]] bool matches(PrimitiveHandle handle) const noexcept; diff --git a/src/modules/graphics/PrimitiveTypes.h b/src/modules/graphics/PrimitiveTypes.h index 37cd063a..50086872 100644 --- a/src/modules/graphics/PrimitiveTypes.h +++ b/src/modules/graphics/PrimitiveTypes.h @@ -86,7 +86,11 @@ struct ScenePrimitivePaint : PrimitivePaint { std::uint64_t objectId = 0; }; -/** @brief Immutable camera and viewport facts used to resolve one 3D draw list. */ +/** + * @brief Immutable camera and viewport facts used to resolve one 3D draw list. + * Projection uses the engine's RH, zero-to-one depth, Vulkan Y-down convention + * (see ClipSpace.h). Backends adapt these shared clip coordinates at submission. + */ struct SceneDrawContext { glm::mat4 view{1.f}; glm::mat4 projection{1.f}; diff --git a/src/modules/graphics/vulkan/Graphics.h b/src/modules/graphics/vulkan/Graphics.h index 780cb7f8..e90947f2 100644 --- a/src/modules/graphics/vulkan/Graphics.h +++ b/src/modules/graphics/vulkan/Graphics.h @@ -934,6 +934,7 @@ private: std::array offscreenPrimitive3DPipelines{}; std::array hdrOffscreenPrimitive3DPipelines{}; std::vector offscreenPrimitive3DBufs; + std::size_t offscreenPrimitive3DDrawIndex = 0; // One UBO (+ per-texture descriptor sets) per draw in the current 3D frame. // Avoids vkUpdateDescriptorSets on a set already bound in a recording / // executable command buffer (which invalidates the CB). @@ -1545,6 +1546,7 @@ private: std::vector texBufs; std::vector uiTexBufs; std::vector primitive3DBufs; + std::size_t primitive3DDrawIndex = 0; }; std::vector frame2dBuffers; // per swapchain frame slot Frame2DBuffers offscreenBuffers; // synchronous offscreen path diff --git a/src/modules/graphics/vulkan/Graphics3D.cpp b/src/modules/graphics/vulkan/Graphics3D.cpp index 78be7179..6fe2d4ae 100644 --- a/src/modules/graphics/vulkan/Graphics3D.cpp +++ b/src/modules/graphics/vulkan/Graphics3D.cpp @@ -76,6 +76,7 @@ void Graphics::begin3DFrame() { // settling after orientation change; throwing would abort the whole script. if (!beginPresentCommandBuffer()) return; + currentFrame2DBuffers().primitive3DDrawIndex = 0; decalLayerFresh = false; recordDeferredFrameGraph(); recordDecalPass(); @@ -127,80 +128,6 @@ void Graphics::begin3DFrame() { hasPendingClear = false; } -void Graphics::drawPrimitiveScene(const PrimitiveSceneCanvas3D &canvas) { - ASSERT(initialized); - if (!initialized) throw Exception("drawPrimitiveScene: graphics not initialized"); - const bool offscreen = offscreen3DPassOpen; - if (!offscreen && gpuDrivenScenePassPending_) gpuDrivenOpenScenePass(); - if (!offscreen && !swapchainPassOpen) throw Exception("drawPrimitiveScene: call begin3DFrame first"); - - const ResolvedPrimitiveTriangles resolved = resolvePrimitiveStrokes3D(canvas); - if (resolved.vertices.empty()) return; - - struct DrawGroup { - ScenePrimitivePaint paint; - std::size_t sequence = 0; - float averageDepth = 0.f; - std::size_t firstVertex = 0; - std::size_t vertexCount = 0; - }; - std::vector groups; - groups.reserve(resolved.batches3D.size()); - for (const ResolvedPrimitiveBatch3D &batch : resolved.batches3D) { - DrawGroup group; - group.paint = batch.paint; - group.sequence = batch.sequence; - group.averageDepth = batch.averageDepth; - group.firstVertex = batch.firstVertex; - group.vertexCount = batch.vertexCount; - groups.push_back(std::move(group)); - } - std::stable_sort(groups.begin(), groups.end(), [&](const DrawGroup &a, const DrawGroup &b) { - const ScenePrimitivePaint &paintA = a.paint; - const ScenePrimitivePaint &paintB = b.paint; - const bool ignoreA = paintA.depth == PrimitiveDepthMode::Ignore; - const bool ignoreB = paintB.depth == PrimitiveDepthMode::Ignore; - if (ignoreA != ignoreB) return !ignoreA; - if (paintA.layer != paintB.layer) return paintA.layer < paintB.layer; - const bool transparentA = paintA.blend != BlendMode::Opaque || paintA.color.a < 1.f; - const bool transparentB = paintB.blend != BlendMode::Opaque || paintB.color.a < 1.f; - if (transparentA != transparentB) return !transparentA; - if (transparentA && a.averageDepth != b.averageDepth) return a.averageDepth > b.averageDepth; - return a.sequence < b.sequence; - }); - - auto &buffers = offscreen ? offscreenPrimitive3DBufs : currentFrame2DBuffers().primitive3DBufs; - if (buffers.empty()) buffers.resize(1); - const vk::CommandBuffer cb = offscreen ? offscreen3DCB : currentPresentCb(); - const uint32_t targetWidth = - offscreen ? static_cast(offscreen3DCanvas->getWidth()) : swapchain.extent.width; - const uint32_t targetHeight = - offscreen ? static_cast(offscreen3DCanvas->getHeight()) : swapchain.extent.height; - auto &pipelines = offscreen - ? (offscreen3DHDRActive ? hdrOffscreenPrimitive3DPipelines : offscreenPrimitive3DPipelines) - : primitive3DPipelines; - setViewportAndScissor(cb, targetWidth, targetHeight); - std::vector vertices; - vertices.reserve(resolved.vertices.size()); - for (const DrawGroup &group : groups) { - for (std::size_t vertex = group.firstVertex; vertex < group.firstVertex + group.vertexCount; ++vertex) - vertices.push_back({resolved.vertices[vertex].clipPosition, resolved.vertices[vertex].color}); - } - buffers.front().allocate(frameToken(), device, vertices); - const vk::DeviceSize offset = 0; - cb.bindVertexBuffers(0, 1, buffers.front(), &offset); - std::uint32_t firstVertex = 0; - for (std::size_t groupIndex = 0; groupIndex < groups.size(); ++groupIndex) { - const DrawGroup &group = groups[groupIndex]; - const ScenePrimitivePaint &paint = group.paint; - const std::size_t pipelineIndex = primitive3DPipelineIndex(paint.depth, paint.blend, paint.cull); - const vk::Pipeline primitivePipeline = pipelines[pipelineIndex]; - if (!primitivePipeline) throw Exception("drawPrimitiveScene: primitive pipeline unavailable"); - cb.bindPipeline(vk::PipelineBindPoint::eGraphics, primitivePipeline); - cb.draw(static_cast(group.vertexCount), 1, firstVertex, 0); - firstVertex += static_cast(group.vertexCount); - } -} void Graphics::ensureOffscreen3DResources() { auto &dev = getDevice(); @@ -340,6 +267,7 @@ void Graphics::begin3DFrameToCanvas(Canvas *canvas) { offscreen3DHDRActive = oc->isHDR(); if (offscreen3DFence) (void)device->waitForFences(1, &offscreen3DFence, VK_TRUE, UINT64_MAX); + offscreenPrimitive3DDrawIndex = 0; vk::CommandBufferBeginInfo beginInfo{}; offscreen3DCB.begin(beginInfo); lastOffscreen3DGpuDurationMs = 0.f; diff --git a/src/modules/graphics/vulkan/GraphicsPrimitives.cpp b/src/modules/graphics/vulkan/GraphicsPrimitives.cpp new file mode 100644 index 00000000..c87c6419 --- /dev/null +++ b/src/modules/graphics/vulkan/GraphicsPrimitives.cpp @@ -0,0 +1,87 @@ +#include "graphics/PrimitiveDrawList.h" +#include "graphics/PrimitiveTessellator.h" +#include "graphics/vulkan/Canvas.h" +#include "graphics/vulkan/Graphics.h" +#include "graphics/vulkan/GraphicsInternal.h" + +namespace eve::graphics::vulkan { + +void Graphics::drawPrimitiveScene(const PrimitiveSceneCanvas3D &canvas) { + ASSERT(initialized); + if (!initialized) throw Exception("drawPrimitiveScene: graphics not initialized"); + const bool offscreen = offscreen3DPassOpen; + if (!offscreen && gpuDrivenScenePassPending_) gpuDrivenOpenScenePass(); + if (!offscreen && !swapchainPassOpen) throw Exception("drawPrimitiveScene: call begin3DFrame first"); + + const ResolvedPrimitiveTriangles resolved = resolvePrimitiveStrokes3D(canvas); + if (resolved.vertices.empty()) return; + + struct DrawGroup { + ScenePrimitivePaint paint; + std::size_t sequence = 0; + float averageDepth = 0.f; + std::size_t firstVertex = 0; + std::size_t vertexCount = 0; + }; + std::vector groups; + groups.reserve(resolved.batches3D.size()); + for (const ResolvedPrimitiveBatch3D &batch : resolved.batches3D) { + DrawGroup group; + group.paint = batch.paint; + group.sequence = batch.sequence; + group.averageDepth = batch.averageDepth; + group.firstVertex = batch.firstVertex; + group.vertexCount = batch.vertexCount; + groups.push_back(std::move(group)); + } + std::stable_sort(groups.begin(), groups.end(), [&](const DrawGroup &a, const DrawGroup &b) { + const ScenePrimitivePaint &paintA = a.paint; + const ScenePrimitivePaint &paintB = b.paint; + const bool ignoreA = paintA.depth == PrimitiveDepthMode::Ignore; + const bool ignoreB = paintB.depth == PrimitiveDepthMode::Ignore; + if (ignoreA != ignoreB) return !ignoreA; + if (paintA.layer != paintB.layer) return paintA.layer < paintB.layer; + const bool transparentA = paintA.blend != BlendMode::Opaque || paintA.color.a < 1.f; + const bool transparentB = paintB.blend != BlendMode::Opaque || paintB.color.a < 1.f; + if (transparentA != transparentB) return !transparentA; + if (transparentA && a.averageDepth != b.averageDepth) return a.averageDepth > b.averageDepth; + return a.sequence < b.sequence; + }); + + auto &buffers = offscreen ? offscreenPrimitive3DBufs : currentFrame2DBuffers().primitive3DBufs; + auto &drawIndex = offscreen ? offscreenPrimitive3DDrawIndex : currentFrame2DBuffers().primitive3DDrawIndex; + if (drawIndex == buffers.size()) buffers.emplace_back(); + // Earlier submissions still reference their buffers until this frame's fence. + auto &buffer = buffers[drawIndex++]; + const vk::CommandBuffer cb = offscreen ? offscreen3DCB : currentPresentCb(); + const uint32_t targetWidth = + offscreen ? static_cast(offscreen3DCanvas->getWidth()) : swapchain.extent.width; + const uint32_t targetHeight = + offscreen ? static_cast(offscreen3DCanvas->getHeight()) : swapchain.extent.height; + auto &pipelines = offscreen + ? (offscreen3DHDRActive ? hdrOffscreenPrimitive3DPipelines : offscreenPrimitive3DPipelines) + : primitive3DPipelines; + setViewportAndScissor(cb, targetWidth, targetHeight); + std::vector vertices; + vertices.reserve(resolved.vertices.size()); + for (const DrawGroup &group : groups) { + for (std::size_t vertex = group.firstVertex; vertex < group.firstVertex + group.vertexCount; ++vertex) + vertices.push_back({resolved.vertices[vertex].clipPosition, resolved.vertices[vertex].color}); + } + buffer.allocate(frameToken(), device, vertices); + const vk::DeviceSize offset = 0; + cb.bindVertexBuffers(0, 1, buffer, &offset); + std::uint32_t firstVertex = 0; + for (std::size_t groupIndex = 0; groupIndex < groups.size(); ++groupIndex) { + const DrawGroup &group = groups[groupIndex]; + const ScenePrimitivePaint &paint = group.paint; + const std::size_t pipelineIndex = primitive3DPipelineIndex(paint.depth, paint.blend, paint.cull); + const vk::Pipeline primitivePipeline = pipelines[pipelineIndex]; + if (!primitivePipeline) throw Exception("drawPrimitiveScene: primitive pipeline unavailable"); + cb.bindPipeline(vk::PipelineBindPoint::eGraphics, primitivePipeline); + cb.draw(static_cast(group.vertexCount), 1, firstVertex, 0); + firstVertex += static_cast(group.vertexCount); + } +} + +} // namespace eve::graphics::vulkan diff --git a/src/modules/graphics/webgpu/Graphics.cpp b/src/modules/graphics/webgpu/Graphics.cpp index 0063c0dc..4dad2930 100644 --- a/src/modules/graphics/webgpu/Graphics.cpp +++ b/src/modules/graphics/webgpu/Graphics.cpp @@ -1148,52 +1148,6 @@ wgpu::RenderPipeline Graphics::getMesh3DPipeline(BlendMode blend, bool depthWrit return pipeline; } -wgpu::RenderPipeline Graphics::getPrimitive3DPipeline(PrimitiveDepthMode depth, BlendMode blend, PrimitiveCullMode cull, - WGPUTextureFormat format, uint32_t sampleCount) { - const uint64_t key = static_cast(depth) | (static_cast(blend) << 2u) | - (static_cast(cull) << 5u) | (static_cast(uint32_t(format)) << 8u) | - (static_cast(sampleCount) << 40u); - if (auto found = primitive3DPipelines.find(key); found != primitive3DPipelines.end()) return found->second; - - static constexpr const char *kPrimitiveVert = R"wgsl( -struct VSIn { @location(0) clipPosition: vec4f, @location(1) color: vec4f }; -struct VSOut { @builtin(position) position: vec4f, @location(0) color: vec4f }; -@vertex fn vs_main(input: VSIn) -> VSOut { - var output: VSOut; - output.position = input.clipPosition; - output.color = input.color; - return output; -} -)wgsl"; - static constexpr const char *kPrimitiveFrag = R"wgsl( -@fragment fn fs_main(@location(0) color: vec4f) -> @location(0) vec4f { return color; } -)wgsl"; - wgpu::VertexAttribute attributes[2]{}; - attributes[0].format = wgpu::VertexFormat::Float32x4; - attributes[0].offset = offsetof(Primitive3DVertex, clipPosition); - attributes[0].shaderLocation = 0; - attributes[1].format = wgpu::VertexFormat::Float32x4; - attributes[1].offset = offsetof(Primitive3DVertex, color); - attributes[1].shaderLocation = 1; - PipelineBuilder builder; - builder.label("eve_primitive3d") - .vertexLayout(sizeof(Primitive3DVertex), wgpu::VertexStepMode::Vertex, attributes, 2) - .shader(makeWgslModule(device, kPrimitiveVert), "vs_main", makeWgslModule(device, kPrimitiveFrag), "fs_main") - .colorTarget(format, blend) - .depth(WGPUTextureFormat_Depth32Float, - depth == PrimitiveDepthMode::Ignore ? static_cast(WGPUCompareFunction_Always) - : wgpu::CompareFunction::LessEqual, - depth == PrimitiveDepthMode::TestAndWrite) - .sampleCount(sampleCount) - .frontFace(wgpu::FrontFace::CCW) - .cull(cull == PrimitiveCullMode::Back - ? wgpu::CullMode::Back - : (cull == PrimitiveCullMode::Front ? wgpu::CullMode::Front - : static_cast(WGPUCullMode_None))); - wgpu::RenderPipeline pipeline = builder.build(device); - primitive3DPipelines.emplace(key, pipeline); - return pipeline; -} void Graphics::createMesh3DClusteredPipeline() { mesh3dClusteredSetLayout = makeMesh3DClusteredBindGroupLayout(); @@ -3598,24 +3552,6 @@ void Graphics::begin3DFrame() { rebuildSwapchainIfNeeded(); } -void Graphics::drawPrimitiveScene(const PrimitiveSceneCanvas3D &canvas) { - if (!frame3DStarted) throw Exception("drawPrimitiveScene: call begin3DFrame first"); - const ResolvedPrimitiveTriangles resolved = resolvePrimitiveStrokes3D(canvas); - if (resolved.vertices.empty()) return; - - const std::size_t firstSequence = primitive3DDraws.size(); - primitive3DDraws.reserve(firstSequence + resolved.batches3D.size()); - for (const ResolvedPrimitiveBatch3D &batch : resolved.batches3D) { - Primitive3DDraw draw; - draw.paint = batch.paint; - draw.averageDepth = batch.averageDepth; - draw.sequence = firstSequence + batch.sequence; - draw.vertices.reserve(batch.vertexCount); - for (std::size_t vertex = batch.firstVertex; vertex < batch.firstVertex + batch.vertexCount; ++vertex) - draw.vertices.push_back({resolved.vertices[vertex].clipPosition, resolved.vertices[vertex].color}); - primitive3DDraws.push_back(std::move(draw)); - } -} void Graphics::begin3DFrameToCanvas(Canvas *canvas) { if (!canvas) throw Exception("begin3DFrameToCanvas: null canvas"); @@ -4390,36 +4326,6 @@ void Graphics::destroyDecalResources() { // Flush helpers // --------------------------------------------------------------------------- -void Graphics::flushPrimitive3D(wgpu::RenderPassEncoder pass, WGPUTextureFormat format, uint32_t sampleCount) { - if (primitive3DDraws.empty()) return; - std::stable_sort(primitive3DDraws.begin(), primitive3DDraws.end(), - [](const Primitive3DDraw &a, const Primitive3DDraw &b) { - const bool ignoreA = a.paint.depth == PrimitiveDepthMode::Ignore; - const bool ignoreB = b.paint.depth == PrimitiveDepthMode::Ignore; - if (ignoreA != ignoreB) return !ignoreA; - if (a.paint.layer != b.paint.layer) return a.paint.layer < b.paint.layer; - const bool transparentA = a.paint.blend != BlendMode::Opaque || a.paint.color.a < 1.f; - const bool transparentB = b.paint.blend != BlendMode::Opaque || b.paint.color.a < 1.f; - if (transparentA != transparentB) return !transparentA; - if (transparentA && a.averageDepth != b.averageDepth) return a.averageDepth > b.averageDepth; - return a.sequence < b.sequence; - }); - auto &arena = currentVertexArena(); - std::size_t totalBytes = 0; - for (const Primitive3DDraw &draw : primitive3DDraws) totalBytes += draw.vertices.size() * sizeof(Primitive3DVertex); - ensureVertexArena(arena, arena.used + totalBytes); - for (const Primitive3DDraw &draw : primitive3DDraws) { - if (draw.vertices.empty()) continue; - const uint64_t bytes = draw.vertices.size() * sizeof(Primitive3DVertex); - const uint64_t offset = arena.alloc(bytes); - queue.WriteBuffer(arena.buffer, offset, draw.vertices.data(), bytes); - pass.SetPipeline( - getPrimitive3DPipeline(draw.paint.depth, draw.paint.blend, draw.paint.cull, format, sampleCount)); - pass.SetVertexBuffer(0, arena.buffer, offset, bytes); - pass.Draw(static_cast(draw.vertices.size()), 1, 0, 0); - } - primitive3DDraws.clear(); -} void Graphics::flushMesh3D(wgpu::RenderPassEncoder pass, WGPUTextureFormat format, bool canvasTarget) { diff --git a/src/modules/graphics/webgpu/GraphicsPrimitives.cpp b/src/modules/graphics/webgpu/GraphicsPrimitives.cpp new file mode 100644 index 00000000..d40b3d2b --- /dev/null +++ b/src/modules/graphics/webgpu/GraphicsPrimitives.cpp @@ -0,0 +1,126 @@ +#include "common/Exception.h" +#include "graphics/PrimitiveDrawList.h" +#include "graphics/PrimitiveTessellator.h" +#include "graphics/webgpu/Graphics.h" +#include "graphics/webgpu/PipelineBuilder.h" + +#include + +namespace eve::graphics::webgpu { +namespace { + +wgpu::ShaderModule primitiveShader(wgpu::Device &device, const char *source) { + wgpu::ShaderSourceWGSL wgsl{}; + wgsl.code = source; + wgpu::ShaderModuleDescriptor descriptor{}; + descriptor.nextInChain = &wgsl; + return device.CreateShaderModule(&descriptor); +} + +} // namespace + +wgpu::RenderPipeline Graphics::getPrimitive3DPipeline(PrimitiveDepthMode depth, BlendMode blend, PrimitiveCullMode cull, + WGPUTextureFormat format, uint32_t sampleCount) { + const uint64_t key = static_cast(depth) | (static_cast(blend) << 2u) | + (static_cast(cull) << 5u) | (static_cast(uint32_t(format)) << 8u) | + (static_cast(sampleCount) << 40u); + if (auto found = primitive3DPipelines.find(key); found != primitive3DPipelines.end()) return found->second; + + static constexpr const char *kPrimitiveVert = R"wgsl( +struct VSIn { @location(0) clipPosition: vec4f, @location(1) color: vec4f }; +struct VSOut { @builtin(position) position: vec4f, @location(0) color: vec4f }; +@vertex fn vs_main(input: VSIn) -> VSOut { + var output: VSOut; + output.position = input.clipPosition; + // Match the Vulkan-convention clip coordinates used by the shared resolver. + output.position.y = -output.position.y; + output.color = input.color; + return output; +} +)wgsl"; + static constexpr const char *kPrimitiveFrag = R"wgsl( +@fragment fn fs_main(@location(0) color: vec4f) -> @location(0) vec4f { return color; } +)wgsl"; + wgpu::VertexAttribute attributes[2]{}; + attributes[0].format = wgpu::VertexFormat::Float32x4; + attributes[0].offset = offsetof(Primitive3DVertex, clipPosition); + attributes[0].shaderLocation = 0; + attributes[1].format = wgpu::VertexFormat::Float32x4; + attributes[1].offset = offsetof(Primitive3DVertex, color); + attributes[1].shaderLocation = 1; + PipelineBuilder builder; + builder.label("eve_primitive3d") + .vertexLayout(sizeof(Primitive3DVertex), wgpu::VertexStepMode::Vertex, attributes, 2) + .shader(primitiveShader(device, kPrimitiveVert), "vs_main", primitiveShader(device, kPrimitiveFrag), "fs_main") + .colorTarget(format, blend) + .depth(WGPUTextureFormat_Depth32Float, + depth == PrimitiveDepthMode::Ignore ? static_cast(WGPUCompareFunction_Always) + : wgpu::CompareFunction::LessEqual, + depth == PrimitiveDepthMode::TestAndWrite) + .sampleCount(sampleCount) + .frontFace(wgpu::FrontFace::CCW) + .cull(cull == PrimitiveCullMode::Back + ? wgpu::CullMode::Back + : (cull == PrimitiveCullMode::Front ? wgpu::CullMode::Front + : static_cast(WGPUCullMode_None))); + wgpu::RenderPipeline pipeline = builder.build(device); + primitive3DPipelines.emplace(key, pipeline); + return pipeline; +} + +void Graphics::drawPrimitiveScene(const PrimitiveSceneCanvas3D &canvas) { + if (!frame3DStarted) throw Exception("drawPrimitiveScene: call begin3DFrame first"); + const ResolvedPrimitiveTriangles resolved = resolvePrimitiveStrokes3D(canvas); + if (resolved.vertices.empty()) return; + + const std::size_t firstSequence = primitive3DDraws.size(); + primitive3DDraws.reserve(firstSequence + resolved.batches3D.size()); + for (const ResolvedPrimitiveBatch3D &batch : resolved.batches3D) { + Primitive3DDraw draw; + draw.paint = batch.paint; + draw.averageDepth = batch.averageDepth; + draw.sequence = firstSequence + batch.sequence; + draw.vertices.reserve(batch.vertexCount); + for (std::size_t vertex = batch.firstVertex; vertex < batch.firstVertex + batch.vertexCount; ++vertex) + draw.vertices.push_back({resolved.vertices[vertex].clipPosition, resolved.vertices[vertex].color}); + primitive3DDraws.push_back(std::move(draw)); + } +} + +void Graphics::flushPrimitive3D(wgpu::RenderPassEncoder pass, WGPUTextureFormat format, uint32_t sampleCount) { + if (primitive3DDraws.empty()) return; + std::stable_sort(primitive3DDraws.begin(), primitive3DDraws.end(), + [](const Primitive3DDraw &a, const Primitive3DDraw &b) { + const bool ignoreA = a.paint.depth == PrimitiveDepthMode::Ignore; + const bool ignoreB = b.paint.depth == PrimitiveDepthMode::Ignore; + if (ignoreA != ignoreB) return !ignoreA; + if (a.paint.layer != b.paint.layer) return a.paint.layer < b.paint.layer; + const bool transparentA = a.paint.blend != BlendMode::Opaque || a.paint.color.a < 1.f; + const bool transparentB = b.paint.blend != BlendMode::Opaque || b.paint.color.a < 1.f; + if (transparentA != transparentB) return !transparentA; + if (transparentA && a.averageDepth != b.averageDepth) return a.averageDepth > b.averageDepth; + return a.sequence < b.sequence; + }); + auto &arena = currentVertexArena(); + std::size_t totalBytes = 0; + for (const Primitive3DDraw &draw : primitive3DDraws) totalBytes += draw.vertices.size() * sizeof(Primitive3DVertex); + ensureVertexArena(arena, arena.used + totalBytes); + std::vector vertices; + vertices.reserve(totalBytes / sizeof(Primitive3DVertex)); + for (const Primitive3DDraw &draw : primitive3DDraws) + vertices.insert(vertices.end(), draw.vertices.begin(), draw.vertices.end()); + const uint64_t offset = arena.alloc(totalBytes); + queue.WriteBuffer(arena.buffer, offset, vertices.data(), totalBytes); + pass.SetVertexBuffer(0, arena.buffer, offset, totalBytes); + uint32_t firstVertex = 0; + for (const Primitive3DDraw &draw : primitive3DDraws) { + if (draw.vertices.empty()) continue; + pass.SetPipeline( + getPrimitive3DPipeline(draw.paint.depth, draw.paint.blend, draw.paint.cull, format, sampleCount)); + pass.Draw(static_cast(draw.vertices.size()), 1, firstVertex, 0); + firstVertex += static_cast(draw.vertices.size()); + } + primitive3DDraws.clear(); +} + +} // namespace eve::graphics::webgpu diff --git a/test/graphics_backend_parity.cpp b/test/graphics_backend_parity.cpp index 11bc12ee..71e8887e 100644 --- a/test/graphics_backend_parity.cpp +++ b/test/graphics_backend_parity.cpp @@ -4,6 +4,7 @@ #include "filesystem/FileData.h" #include "graphics/AlphaMask.h" #include "graphics/Canvas.h" +#include "graphics/ClipSpace.h" #include "graphics/Graphics.h" #include "graphics/Light.h" #include "graphics/Mesh.h" @@ -149,13 +150,14 @@ TEST_CASE("graphics.backendParity.primitive2DAnd3DReadback") { context.viewportSize = {64, 64}; context.nearPlane = 0.1f; context.farPlane = 10.f; - context.projection = glm::perspectiveRH_ZO(glm::radians(60.f), 1.f, context.nearPlane, context.farPlane); + context.projection = perspectiveVulkanRH_ZO(glm::radians(60.f), 1.f, context.nearPlane, context.farPlane); PrimitiveSceneCanvas3D spatial(context); ScenePrimitivePaint spatialPaint; spatialPaint.mode = PaintMode::FillAndStroke; spatialPaint.color = Color(0.05f, 0.9f, 0.2f, 1.f); spatialPaint.stroke.width = 3.f; spatialPaint.depth = PrimitiveDepthMode::TestOnly; + spatialPaint.cull = PrimitiveCullMode::Back; spatial.drawDisk({0.f, 0.f, -2.f}, {0.f, 0.f, 1.f}, 0.55f, spatialPaint, 32); ScenePrimitivePaint spatialDash; spatialDash.mode = PaintMode::Stroke; @@ -177,12 +179,36 @@ TEST_CASE("graphics.backendParity.primitive2DAnd3DReadback") { spatial.drawLine({-0.75f, -0.7f, -0.05f}, {0.75f, -0.7f, -2.5f}, clipped); gfx->begin3DFrameToCanvas(spatialTarget); gfx->drawPrimitiveScene(spatial); + PrimitiveSceneCanvas3D secondSubmission(context); + ScenePrimitivePaint secondPaint; + secondPaint.mode = PaintMode::Stroke; + secondPaint.color = Color(1.f, 0.f, 1.f, 1.f); + secondPaint.depth = PrimitiveDepthMode::Ignore; + secondPaint.stroke.width = 3.f; + secondSubmission.drawLine({-0.95f, 0.25f, -2.f}, {-0.65f, 0.25f, -2.f}, secondPaint); + gfx->drawPrimitiveScene(secondSubmission); gfx->end3DFrameToCanvas(); std::unique_ptr spatialImage(spatialTarget->newImageData()); REQUIRE(spatialImage.get() != nullptr); const uint8_t *center = pixel(*spatialImage, 32, 32); REQUIRE(center[1] > 140); REQUIRE(center[1] > center[0] + 60); + // An asymmetric fixture catches clip-Y inversions that a centered disk hides. + std::size_t blueAbove = 0; + std::size_t blueBelow = 0; + for (int y = 0; y < 64; ++y) { + for (int x = 0; x < 64; ++x) { + const auto *sample = pixel(*spatialImage, x, y); + if (sample[2] > 100 && sample[2] > sample[0] + 60) { + if (y < 32) + ++blueAbove; + else + ++blueBelow; + } + } + } + REQUIRE(blueAbove > 20u); + REQUIRE_EQ(blueBelow, 0u); writeParityArtifact(*spatialImage, "primitive_3d_depth_fill_stroke", gfx->getBackendName()); } diff --git a/test/graphics_primitive_cache.cpp b/test/graphics_primitive_cache.cpp new file mode 100644 index 00000000..b387ad3c --- /dev/null +++ b/test/graphics_primitive_cache.cpp @@ -0,0 +1,137 @@ +#include "graphics/PrimitiveScene.h" +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +using namespace eve::graphics; + +TEST_CASE("GraphicsPrimitives.sceneCacheOnlyRebuildsUpdatedSlots") { + PrimitiveScene scene; + PrimitiveDescriptor3D descriptor; + descriptor.geometry = PrimitiveSphere3D{}; + descriptor.paint.mode = PaintMode::Stroke; + std::vector handles; + for (int index = 0; index < 1024; ++index) { + auto added = scene.add(descriptor); + REQUIRE(added.ok()); + handles.push_back(added.value()); + } + SceneDrawContext context; + context.viewportSize = {800, 600}; + PrimitiveSceneCanvas3D canvas(context); + scene.render(canvas); + REQUIRE_EQ(canvas.statistics().cacheHits, 0u); + REQUIRE_EQ(canvas.commands().size(), 3072u); + canvas.reset(); + scene.render(canvas); + REQUIRE_EQ(canvas.statistics().cacheHits, 1024u); + descriptor.geometry = PrimitiveSphere3D{{0.f, 0.f, 0.f}, 2.f, 16}; + REQUIRE(scene.update(handles[500], descriptor).ok()); + canvas.reset(); + scene.render(canvas); + REQUIRE_EQ(canvas.statistics().cacheHits, 1023u); + REQUIRE_EQ(canvas.commands()[1500].points.size(), 16u); +} + +TEST_CASE("GraphicsPrimitives.sceneCacheReusesGeometryAcrossCameras") { + PrimitiveScene scene; + PrimitiveDescriptor3D descriptor; + descriptor.geometry = PrimitiveSphere3D{}; + descriptor.paint.mode = PaintMode::Stroke; + descriptor.transform[3].x = 2.f; + auto handle = scene.add(descriptor); + REQUIRE(handle.ok()); + SceneDrawContext context; + context.viewportSize = {800, 600}; + PrimitiveSceneCanvas3D first(context); + scene.render(first); + CHECK_EQ(first.statistics().cacheHits, 0u); + context.viewportSize = {1600, 900}; + PrimitiveSceneCanvas3D second(context); + glm::mat4 parent(1.f); + parent[3].x = 3.f; + second.concat(parent); + scene.render(second); + CHECK_EQ(second.statistics().cacheHits, 1u); + REQUIRE_EQ(second.commands().size(), first.commands().size()); + REQUIRE(!second.commands().empty()); + CHECK_EQ(second.commands()[0].points, first.commands()[0].points); + CHECK_EQ(second.commands()[0].transform[3].x, 5.f); + CHECK_EQ(first.commands()[0].transform[3].x, 2.f); + // Removing the owner cannot invalidate a frame's copied geometry. + REQUIRE(scene.remove(handle.value()).ok()); + CHECK(!second.commands()[0].points.empty()); +} + +TEST_CASE("GraphicsPrimitives.sceneCacheInvalidationIsTransactional") { + PrimitiveScene scene; + PrimitiveDescriptor3D descriptor; + descriptor.geometry = PrimitiveSphere3D{}; + descriptor.paint.mode = PaintMode::Stroke; + auto result = scene.add(descriptor); + REQUIRE(result.ok()); + const auto handle = result.value(); + SceneDrawContext context; + context.viewportSize = {800, 600}; + PrimitiveSceneCanvas3D canvas(context); + scene.render(canvas); + auto invalid = descriptor; + invalid.paint.stroke.width = -1.f; + REQUIRE(!scene.update(handle, invalid).ok()); + const std::array rejected{PrimitiveBatchUpdate{handle, descriptor}, + PrimitiveBatchUpdate{PrimitiveHandle{}, descriptor}}; + REQUIRE(!scene.updateMany(rejected).ok()); + canvas.reset(); + scene.render(canvas); + CHECK_EQ(canvas.statistics().cacheHits, 1u); + descriptor.transform[3].x = 7.f; + const std::array accepted{PrimitiveBatchUpdate{handle, descriptor}}; + REQUIRE(scene.updateMany(accepted).ok()); + canvas.reset(); + scene.render(canvas); + CHECK_EQ(canvas.statistics().cacheHits, 0u); + REQUIRE(!canvas.commands().empty()); + CHECK_EQ(canvas.commands()[0].transform[3].x, 7.f); + descriptor.visible = false; + REQUIRE(scene.update(handle, descriptor).ok()); + canvas.reset(); + scene.render(canvas); + CHECK(canvas.commands().empty()); + scene.clear(); + descriptor.visible = true; + auto replacement = scene.add(descriptor); + REQUIRE(replacement.ok()); + CHECK(scene.isStale(handle)); + scene.render(canvas); + CHECK_EQ(canvas.statistics().cacheHits, 0u); +} + +TEST_CASE("GraphicsPrimitives.sceneCacheRespectsWholePrimitiveBudget") { + PrimitiveScene scene; + PrimitiveDescriptor3D descriptor; + descriptor.geometry = PrimitiveSphere3D{}; + descriptor.paint.mode = PaintMode::Stroke; + auto handle = scene.add(descriptor); + REQUIRE(handle.ok()); + SceneDrawContext context; + context.viewportSize = {800, 600}; + PrimitiveSceneCanvas3D small(context, 2); + scene.render(small); + CHECK(small.commands().empty()); + CHECK_EQ(small.statistics().droppedCommands, 3u); + PrimitiveSceneCanvas3D enough(context, 3); + scene.render(enough); + CHECK_EQ(enough.commands().size(), 3u); + CHECK_EQ(enough.statistics().cacheHits, 1u); + scene.render(enough); + CHECK_EQ(enough.commands().size(), 3u); + CHECK_EQ(enough.statistics().droppedCommands, 3u); + descriptor.paint.mode = PaintMode::Fill; + REQUIRE(scene.update(handle.value(), descriptor).ok()); + PrimitiveSceneCanvas3D filled(context); + scene.render(filled); + REQUIRE(!filled.triangles().empty()); + PrimitiveSceneCanvas3D replay(context); + scene.render(replay); + CHECK_EQ(replay.statistics().cacheHits, 1u); + CHECK_EQ(replay.triangles().size(), filled.triangles().size()); +}