From a89953d7327f74122b21c8b74f72e7da4a36bfc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mois=C3=A9s?= Date: Mon, 20 Apr 2026 19:52:14 +0200 Subject: [PATCH] Add camera preview overlay on the 3D viewport for objects with a Camera component --- src/editor/pages/parts/viewport3D.cpp | 236 ++++++++++++++++++--- src/editor/pages/parts/viewport3D.h | 31 +++ src/project/component/components.h | 23 ++ src/project/component/types/compCamera.cpp | 61 ++++++ 4 files changed, 317 insertions(+), 34 deletions(-) diff --git a/src/editor/pages/parts/viewport3D.cpp b/src/editor/pages/parts/viewport3D.cpp index 3ec58b99..3ad05207 100644 --- a/src/editor/pages/parts/viewport3D.cpp +++ b/src/editor/pages/parts/viewport3D.cpp @@ -3,6 +3,7 @@ * @license MIT */ #include "viewport3D.h" +#include #include "imgui.h" #include "../../imgui/theme.h" @@ -26,6 +27,17 @@ namespace { constinit uint32_t nextPassId{0}; + // COMPONENT_ID constants declared to avoid magic numbers, just in case someone declares them in components.h, so they can be easily found and replaced here + constexpr int COMPONENT_ID_CAMERA = 3; // Component id of Camera + constexpr int COMPONENT_ID_MODEL_STATIC = 1; // Component id of camera Static model + constexpr int COMPONENT_ID_MODEL_ANIMATED = 10; // Component id of Animated model + constexpr float PREVIEW_SIZE_FACTOR = 0.3f; // Fraction of the viewport reserved for camera preview when space allows it + constexpr float PREVIEW_MIN_WIDTH = 160.0f; // Minimum preview width before overlay becomes too small to be useful + constexpr float PREVIEW_MIN_HEIGHT = 120.0f; // Minimum preview height before overlay becomes too small to be useful + constexpr float PREVIEW_MIN_SIZE = 64.0f; // Minimum width and height required to render the preview at all + constexpr float PREVIEW_VIEWPORT_PADDING = 24.0f; // Padding kept between the preview and the viewport edges while sizing it + constexpr float PREVIEW_MIN_ASPECT = 0.25f; // Lowest allowed aspect ratio so very narrow cameras do not produce unusable previews + constexpr float PREVIEW_DEFAULT_ASPECT = 16.0f / 9.0f; // Fallback aspect ratio used when the camera component does not define one constexpr ImGuizmo::OPERATION GIZMO_OPS[3] { ImGuizmo::OPERATION::TRANSLATE, @@ -150,6 +162,21 @@ namespace child->pos.resolve(child->propOverrides) = mat * glm::vec4(it->second, 1.0f); } } + + /** + * Returns the camera component attached to the given object or nullptr if it has none. + * @param obj Object to inspect. + * @return Pointer to the first camera component found in the object or nullptr if none exists. + */ + Project::Component::Entry* getCameraComponent(Project::Object &obj) + { + // We stop at the first camera because the preview only supports one source camera per focused object + for (auto &comp : obj.components) { + if (comp.id == COMPONENT_ID_CAMERA) + return ∁ + } + return nullptr; + } } Editor::Viewport3D::Viewport3D() @@ -193,35 +220,35 @@ Editor::Viewport3D::~Viewport3D() { } } -void Editor::Viewport3D::onRenderPass(SDL_GPUCommandBuffer* cmdBuff, Renderer::Scene& renderScene) +void Editor::Viewport3D::renderScenePass(SDL_GPUCommandBuffer* cmdBuff, Renderer::Scene& renderScene, Renderer::Framebuffer &targetFb, Renderer::UniformGlobal &targetUni, bool drawEditorHelpers) { - if(fb.getTexture() == nullptr)return; - meshLines->vertLines.clear(); - meshLines->indices.clear(); - - meshSprites->vertLines.clear(); - meshSprites->indices.clear(); - auto scene = ctx.project->getScenes().getLoadedScene(); + // No scene loaded --> Abort if (!scene)return; - ctx.sanitizeObjectSelection(scene); + // Is an editor-facing pass --> Rebuild helper meshes + if (drawEditorHelpers) { + meshLines->vertLines.clear(); + meshLines->indices.clear(); + + meshSprites->vertLines.clear(); + meshSprites->indices.clear(); + ctx.sanitizeObjectSelection(scene); + } SDL_GPURenderPass* renderPass3D = SDL_BeginGPURenderPass( - cmdBuff, fb.getTargetInfo(), fb.getTargetInfoCount(), &fb.getDepthTargetInfo() + cmdBuff, targetFb.getTargetInfo(), targetFb.getTargetInfoCount(), &targetFb.getDepthTargetInfo() ); renderScene.getPipeline("n64").bind(renderPass3D); - - camera.apply(uniGlobal); - uniGlobal.screenSize = glm::vec2{(float)fb.getWidth(), (float)fb.getHeight()}; - SDL_PushGPUVertexUniformData(cmdBuff, 0, &uniGlobal, sizeof(uniGlobal)); + SDL_PushGPUVertexUniformData(cmdBuff, 0, &targetUni, sizeof(targetUni)); auto &rootObj = scene->getRootObject(); bool hadDraw = false; iterateObjects(rootObj, [&](Project::Object &obj, Project::Component::Entry *comp) { if(!comp) { - if(!hadDraw) { + // No component provided custom visual --> Draw generic object sprite + if(drawEditorHelpers && !hadDraw) { glm::u8vec4 spriteCol{0xFF, 0xFF, 0xFF, 0xFF}; if (ctx.isObjectSelected(obj.uuid)) { spriteCol = Utils::Colors::kSelectionTint; @@ -234,8 +261,11 @@ void Editor::Viewport3D::onRenderPass(SDL_GPUCommandBuffer* cmdBuff, Renderer::S auto &def = Project::Component::TABLE[comp->id]; // @TODO: use flag in component + // Collision debug helpers stay hidden when the corresponding viewport toggles are disabled if(!showCollMesh && comp->id == 4)return; if(!showCollObj && comp->id == 5)return; + // Camera preview renders only gameplay-visible geometry + if(!drawEditorHelpers && comp->id != COMPONENT_ID_MODEL_STATIC && comp->id != COMPONENT_ID_MODEL_ANIMATED)return; if(def.funcDraw3D) { def.funcDraw3D(obj, *comp, *this, cmdBuff, renderPass3D); @@ -248,44 +278,178 @@ void Editor::Viewport3D::onRenderPass(SDL_GPUCommandBuffer* cmdBuff, Renderer::S auto &def = Project::Component::TABLE[comp->id]; // @TODO: use flag in component + // Post-draw helpers are editor-only overlays, so we skip them for the camera preview if(!showCollMesh && comp->id == 4)return; if(!showCollObj && comp->id == 5)return; + if(!drawEditorHelpers)return; if(def.funcDrawPost3D) { def.funcDrawPost3D(obj, *comp, *this, cmdBuff, renderPass3D); } }); - meshLines->recreate(renderScene); - meshSprites->recreate(renderScene); + // Must draw grids, helper lines and sprites + if (drawEditorHelpers) { + meshLines->recreate(renderScene); + meshSprites->recreate(renderScene); - renderScene.getPipeline("lines").bind(renderPass3D); + renderScene.getPipeline("lines").bind(renderPass3D); - if(showGrid)objGrid.draw(renderPass3D, cmdBuff); - objLines.draw(renderPass3D, cmdBuff); + if (showGrid) + objGrid.draw(renderPass3D, cmdBuff); + objLines.draw(renderPass3D, cmdBuff); - // hack to get thicker lines with AA, just draw again with a 1px offset in screen-space - if(ctx.prefs.renderFactorAA > 1.0f) { - auto oldMat = uniGlobal.projMat[2]; - uniGlobal.projMat[2][0] += 1.0f / uniGlobal.screenSize.x; - uniGlobal.projMat[2][1] -= 1.0f / uniGlobal.screenSize.y; - SDL_PushGPUVertexUniformData(cmdBuff, 0, &uniGlobal, sizeof(uniGlobal)); + // hack to get thicker lines with AA, just draw again with a 1px offset in screen-space + if (ctx.prefs.renderFactorAA > 1.0f) { + auto oldMat = uniGlobal.projMat[2]; + uniGlobal.projMat[2][0] += 1.0f / uniGlobal.screenSize.x; + uniGlobal.projMat[2][1] -= 1.0f / uniGlobal.screenSize.y; + SDL_PushGPUVertexUniformData(cmdBuff, 0, &uniGlobal, sizeof(uniGlobal)); - if(showGrid)objGrid.draw(renderPass3D, cmdBuff); - objLines.draw(renderPass3D, cmdBuff); + if (showGrid) + objGrid.draw(renderPass3D, cmdBuff); + objLines.draw(renderPass3D, cmdBuff); - uniGlobal.projMat[2] = oldMat; - SDL_PushGPUVertexUniformData(cmdBuff, 0, &uniGlobal, sizeof(uniGlobal)); - } + uniGlobal.projMat[2] = oldMat; + SDL_PushGPUVertexUniformData(cmdBuff, 0, &uniGlobal, sizeof(uniGlobal)); + } - renderScene.getPipeline("sprites").bind(renderPass3D); + renderScene.getPipeline("sprites").bind(renderPass3D); - sprites->bind(renderPass3D); - objSprites.draw(renderPass3D, cmdBuff); + sprites->bind(renderPass3D); + objSprites.draw(renderPass3D, cmdBuff); + } SDL_EndGPURenderPass(renderPass3D); } +void Editor::Viewport3D::drawCameraPreviewOverlay(const ImVec2 &currPos, const ImVec2 &currSize) +{ + // Must not preview or preview framebuffer wasn't rendered --> Abort + if (!showCameraPreview || !fbPreview.getTexture())return; + + // Draw camera preview as overlay after main viewport image + ImVec2 previewFramePadding = ImGui::GetStyle().WindowPadding; + ImVec2 previewMargin = previewFramePadding; + // Position outer frame at bottom-right corner so margin and frame padding are respected + ImVec2 framePos{ + currPos.x + currSize.x - previewScreenSize.x - previewMargin.x - (previewFramePadding.x * 2.0f), + currPos.y + currSize.y - previewScreenSize.y - previewMargin.y - (previewFramePadding.y * 2.0f) + }; + // Expand frame around preview image by the configured padding on all sides + ImVec2 frameEnd{ + framePos.x + previewScreenSize.x + (previewFramePadding.x * 2.0f), + framePos.y + previewScreenSize.y + (previewFramePadding.y * 2.0f) + }; + // Place the image inside the frame so it stays centered within the border + ImVec2 previewPos{ + framePos.x + previewFramePadding.x, + framePos.y + previewFramePadding.y + }; + // Use the preview render size directly for the image bounds inside the frame + ImVec2 previewEnd{ + previewPos.x + previewScreenSize.x, + previewPos.y + previewScreenSize.y + }; + + auto drawList = ImGui::GetWindowDrawList(); + // Draw frame first so the image appears on top of it + drawList->AddRectFilled( + framePos, + frameEnd, + ImGui::GetColorU32(ImGuiCol_WindowBg), + ImGui::GetStyle().WindowRounding + ); + // Draw camera render inside the padded frame area + drawList->AddImage(ImTextureID(fbPreview.getTexture()), previewPos, previewEnd); +} + +void Editor::Viewport3D::updateCameraPreviewState( + const std::shared_ptr &obj, + const ImVec2 &currSize, + Project::Scene *scene +) +{ + // Reset preview state each frame so it only appears while a camera object is focused + showCameraPreview = false; + previewCameraUUID = 0; + previewScreenSize = {}; + + if (!obj)return; + + auto *cameraComp = getCameraComponent(*obj); + if (!cameraComp)return; + + // Fit preview into viewport while preserving camera aspect ratio + float previewMaxWidth = std::max(currSize.x * PREVIEW_SIZE_FACTOR, PREVIEW_MIN_WIDTH); + previewMaxWidth = std::min(previewMaxWidth, std::max(currSize.x - PREVIEW_VIEWPORT_PADDING, PREVIEW_MIN_SIZE)); + + float aspect = Project::Component::Camera::getAspectRatio(*obj, *cameraComp, PREVIEW_DEFAULT_ASPECT); + aspect = std::max(aspect, PREVIEW_MIN_ASPECT); + + glm::vec2 previewSize{ + previewMaxWidth, + previewMaxWidth / aspect + }; + + float previewMaxHeight = std::max(currSize.y * PREVIEW_SIZE_FACTOR, PREVIEW_MIN_HEIGHT); + previewMaxHeight = std::min(previewMaxHeight, std::max(currSize.y - PREVIEW_VIEWPORT_PADDING, PREVIEW_MIN_SIZE)); + // Clamp by height as well so preview never spills outside the viewport + if (previewSize.y > previewMaxHeight) { + previewSize.y = previewMaxHeight; + previewSize.x = previewSize.y * aspect; + } + + // Size of preview is not useful --> Abort + if (previewSize.x < PREVIEW_MIN_SIZE || previewSize.y < PREVIEW_MIN_SIZE) + return; + + showCameraPreview = true; + previewCameraUUID = obj->uuid; + previewScreenSize = previewSize; + + // Render preview at same AA scale as main viewport + glm::vec2 previewRenderSize = previewSize * ctx.prefs.renderFactorAA; + fbPreview.setClearColor(scene->conf.clearColor.value); + fbPreview.resize((int)previewRenderSize.x, (int)previewRenderSize.y); +} + +void Editor::Viewport3D::onRenderPass(SDL_GPUCommandBuffer* cmdBuff, Renderer::Scene& renderScene) +{ + // The main framebuffer can be missing while the viewport is still being sized for the first frames + if(fb.getTexture() == nullptr)return; + + // Render main editor view first + camera.apply(uniGlobal); + uniGlobal.screenSize = glm::vec2{(float)fb.getWidth(), (float)fb.getHeight()}; + renderScenePass(cmdBuff, renderScene, fb, uniGlobal, true); + + // No valid preview target this frame --> abort + if(!showCameraPreview || fbPreview.getTexture() == nullptr)return; + + auto scene = ctx.project->getScenes().getLoadedScene(); + // Scene changed after UI state --> Abort + if (!scene)return; + + auto previewObj = scene->getObjectByUUID(previewCameraUUID); + // Previously focused camera object no longer available --> Abort + if (!previewObj)return; + + auto *cameraComp = getCameraComponent(*previewObj); + // Focused object has no camera component --> Abort + if (!cameraComp)return; + + // Re-render the scene from the focused camera into the preview framebuffer + Project::Component::Camera::applyToGlobalUniforms( + *previewObj, + *cameraComp, + previewUniGlobal, + (float)fbPreview.getWidth(), + (float)fbPreview.getHeight() + ); + renderScenePass(cmdBuff, renderScene, fbPreview, previewUniGlobal, false); +} + void Editor::Viewport3D::onCopyPass(SDL_GPUCommandBuffer* cmdBuff, SDL_GPUCopyPass *copyPass) { //vertBuff->upload(*copyPass); } @@ -625,6 +789,8 @@ void Editor::Viewport3D::draw() (float)fb.getHeight() / ctx.prefs.renderFactorAA }); + updateCameraPreviewState(obj, currSize, scene); + if (ImGui::BeginDragDropTarget()) { if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("ASSET")) @@ -649,6 +815,8 @@ void Editor::Viewport3D::draw() isMouseHover = ImGui::IsItemHovered(); + drawCameraPreviewOverlay(currPos, currSize); + if (selectionDragging) { glm::vec2 rectMin = glm::min(selectionStart, selectionEnd); glm::vec2 rectMax = glm::max(selectionStart, selectionEnd); diff --git a/src/editor/pages/parts/viewport3D.h b/src/editor/pages/parts/viewport3D.h index 872ba027..2f365ab1 100644 --- a/src/editor/pages/parts/viewport3D.h +++ b/src/editor/pages/parts/viewport3D.h @@ -10,6 +10,7 @@ #include "../../../renderer/framebuffer.h" #include "../../../renderer/mesh.h" #include "../../../renderer/object.h" +#include "../../../project/component/components.h" #include "../../../utils/container.h" namespace Editor @@ -19,6 +20,8 @@ namespace Editor private: Renderer::UniformGlobal uniGlobal{}; Renderer::Framebuffer fb{}; + Renderer::UniformGlobal previewUniGlobal{}; + Renderer::Framebuffer fbPreview{}; Renderer::Camera camera{}; uint32_t passId{}; @@ -49,10 +52,38 @@ namespace Editor bool showGrid{true}; bool showCollMesh{false}; bool showCollObj{true}; + bool showCameraPreview{false}; + uint32_t previewCameraUUID{0}; + glm::vec2 previewScreenSize{}; int gizmoOp{0}; bool gizmoTransformActive{false}; + /** + * Renders the scene into the provided framebuffer using either editor or in-game style overlays. + * @param cmdBuff GPU command buffer used for the render pass. + * @param renderScene Renderer scene that owns the active pipelines. + * @param targetFb Framebuffer that receives the rendered image. + * @param targetUni Global uniforms used for this pass. + * @param drawEditorHelpers True to draw editor-only helpers and overlays. + */ + void renderScenePass(SDL_GPUCommandBuffer* cmdBuff, Renderer::Scene& renderScene, Renderer::Framebuffer &targetFb, Renderer::UniformGlobal &targetUni, bool drawEditorHelpers); + + /** + * Updates the cached camera preview state for the currently focused object. + * @param obj Currently focused object in the viewport. + * @param currSize Visible size of the viewport image. + * @param scene Loaded scene used to configure the preview framebuffer. + */ + void updateCameraPreviewState(const std::shared_ptr &obj, const ImVec2 &currSize, Project::Scene *scene); + + /** + * Draws the camera preview overlay on top of the viewport when a preview framebuffer is available. + * @param currPos Screen position of the viewport image. + * @param currSize Visible size of the viewport image. + */ + void drawCameraPreviewOverlay(const ImVec2 &currPos, const ImVec2 &currSize); + void onRenderPass(SDL_GPUCommandBuffer* cmdBuff, Renderer::Scene& renderScene); void onCopyPass(SDL_GPUCommandBuffer* cmdBuff, SDL_GPUCopyPass *copyPass); void onPostRender(Renderer::Scene& renderScene); diff --git a/src/project/component/components.h b/src/project/component/components.h index e09d60bb..5bc9f275 100644 --- a/src/project/component/components.h +++ b/src/project/component/components.h @@ -21,6 +21,7 @@ struct SDL_GPUGraphicsPipeline; struct SDL_GPURenderPass; namespace Project { class Object; } +namespace Renderer { struct UniformGlobal; } namespace Project::Component { @@ -83,6 +84,28 @@ namespace Project::Component MAKE_COMP(NodeGraph) MAKE_COMP(AnimModel) + namespace Camera + { + /** + * Applies the component camera settings to a global uniform block for editor preview rendering. + * @param obj Object that owns the camera component. + * @param entry Camera component entry to read from. + * @param uniGlobal Uniform block to populate. + * @param screenWidth Target render width in pixels. + * @param screenHeight Target render height in pixels. + */ + void applyToGlobalUniforms(Object& obj, Entry &entry, Renderer::UniformGlobal &uniGlobal, float screenWidth, float screenHeight); + + /** + * Resolves the effective aspect ratio for the camera component using viewport data as fallback. + * @param obj Object that owns the camera component. + * @param entry Camera component entry to read from. + * @param fallbackAspect Aspect ratio used when the component does not define one. + * @return Effective aspect ratio used for rendering from this camera. + */ + float getAspectRatio(Object& obj, Entry &entry, float fallbackAspect); + } + constexpr std::array TABLE{ CompInfo{ .id = 0, diff --git a/src/project/component/types/compCamera.cpp b/src/project/component/types/compCamera.cpp index bcb3530d..5190e2a9 100644 --- a/src/project/component/types/compCamera.cpp +++ b/src/project/component/types/compCamera.cpp @@ -3,6 +3,7 @@ * @license MIT */ #include "../components.h" +#include #include "../../../context.h" #include "../../../editor/imgui/helper.h" #include "../../../utils/json.h" @@ -13,10 +14,14 @@ #include "../../assetManager.h" #include "../../../editor/pages/parts/viewport3D.h" #include "../../../renderer/scene.h" +#include "../../../renderer/uniforms.h" #include "../../../utils/meshGen.h" +#include "glm/ext/matrix_clip_space.hpp" +#include "glm/ext/matrix_transform.hpp" namespace { + constexpr float PREVIEW_SPRITE_SIZE = 7000.0f; // Sprite size used by the preview pass so editor billboard helpers keep their expected scale } namespace Project::Component::Camera @@ -89,6 +94,62 @@ namespace Project::Component::Camera Data &data = *static_cast(entry.data.get()); } + /** + * Resolves the camera aspect ratio while still supporting cameras that inherit it from their viewport size. + * @param obj Object that owns the camera component. + * @param entry Camera component entry to read from. + * @param fallbackAspect Aspect ratio used when the component does not define one. + * @return Effective aspect ratio used for rendering from this camera. + */ + float getAspectRatio(Object& obj, Entry &entry, float fallbackAspect) + { + Data &data = *static_cast(entry.data.get()); + float aspect = data.aspect.resolve(obj); + if (aspect > 0.0f) { + return aspect; + } + + auto vpSize = data.vpSize.resolve(obj); + if (vpSize.y > 0) { + return (float)vpSize.x / (float)vpSize.y; + } + + return fallbackAspect > 0.0f ? fallbackAspect : 1.0f; + } + + /** + * Builds the matrices used by the editor preview from the selected camera component. + * @param obj Object that owns the camera component. + * @param entry Camera component entry to read from. + * @param uniGlobal Uniform block to populate. + * @param screenWidth Target render width in pixels. + * @param screenHeight Target render height in pixels. + */ + void applyToGlobalUniforms(Object& obj, Entry &entry, Renderer::UniformGlobal &uniGlobal, float screenWidth, float screenHeight) + { + Data &data = *static_cast(entry.data.get()); + + float safeWidth = std::max(screenWidth, 1.0f); + float safeHeight = std::max(screenHeight, 1.0f); + float aspect = getAspectRatio(obj, entry, safeWidth / safeHeight); + + uniGlobal.screenSize = {safeWidth, safeHeight}; + uniGlobal.spriteSize = {PREVIEW_SPRITE_SIZE, PREVIEW_SPRITE_SIZE}; + uniGlobal.spriteSize *= ctx.prefs.renderFactorAA; + uniGlobal.projMat = glm::perspective( + glm::radians(data.fov.resolve(obj)), + aspect, + data.near.resolve(obj), + data.far.resolve(obj) + ); + + const glm::vec3 pos = obj.pos.resolve(obj.propOverrides); + const glm::quat rot = glm::normalize(obj.rot.resolve(obj.propOverrides)); + const glm::vec3 direction = glm::normalize(rot * glm::vec3{0,0,-1}); + const glm::vec3 dynamicUp = glm::normalize(rot * glm::vec3{0,1,0}); + uniGlobal.cameraMat = glm::lookAt(pos, pos + direction, dynamicUp); + } + void draw(Object &obj, Entry &entry) { Data &data = *static_cast(entry.data.get());