diff --git a/gameengine/CMakeLists.txt b/gameengine/CMakeLists.txt index 457e47c78..a4a5e37d8 100644 --- a/gameengine/CMakeLists.txt +++ b/gameengine/CMakeLists.txt @@ -289,6 +289,7 @@ if(BUILD_SDL3_APP) src/services/impl/workflow/rendering/workflow_overlay_sw_begin_step.cpp src/services/impl/workflow/rendering/workflow_overlay_sw_end_step.cpp src/services/impl/workflow/quake3/workflow_q3_hud_step.cpp + src/services/impl/workflow/quake3/workflow_q3_hud_head_step.cpp src/services/impl/workflow/quake3/workflow_q3_crosshair_step.cpp src/services/impl/workflow/quake3/workflow_q3_hitmarker_step.cpp src/services/impl/workflow/quake3/workflow_q3_menu_frame_step.cpp diff --git a/gameengine/packages/quake3/workflows/q3_overlay.json b/gameengine/packages/quake3/workflows/q3_overlay.json index 8b09b81bf..6caf20c24 100644 --- a/gameengine/packages/quake3/workflows/q3_overlay.json +++ b/gameengine/packages/quake3/workflows/q3_overlay.json @@ -16,6 +16,10 @@ "frag_shader_path_spirv": "packages/quake3/shaders/spirv/overlay.frag.spv" } }, + { + "id": "q3_hud_head", + "plugin": "q3.hud_head_render" + }, { "id": "q3_hud", "plugin": "q3.hud" diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_hud_head_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_hud_head_step.cpp new file mode 100644 index 000000000..6f4e838a4 --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_hud_head_step.cpp @@ -0,0 +1,191 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_hud_head_step.hpp" +#include "services/interfaces/workflow/rendering/rendering_types.hpp" +#include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace sdl3cpp::services::impl { + +using namespace q3overlay; + +WorkflowQ3HudHeadStep::WorkflowQ3HudHeadStep(std::shared_ptr l) + : logger_(std::move(l)) {} + +WorkflowQ3HudHeadStep::~WorkflowQ3HudHeadStep() { + if (device_) { + if (color_rt_) { SDL_ReleaseGPUTexture(device_, color_rt_); color_rt_ = nullptr; } + if (depth_rt_) { SDL_ReleaseGPUTexture(device_, depth_rt_); depth_rt_ = nullptr; } + } +} + +std::string WorkflowQ3HudHeadStep::GetPluginId() const { return "q3.hud_head_render"; } + +bool WorkflowQ3HudHeadStep::TryInitRT(SDL_GPUDevice* device) { + device_ = device; + + SDL_GPUTextureCreateInfo ci{}; + ci.type = SDL_GPU_TEXTURETYPE_2D; + ci.width = kHeadSz; + ci.height = kHeadSz; + ci.layer_count_or_depth = 1; + ci.num_levels = 1; + + ci.format = SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM; + ci.usage = SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | SDL_GPU_TEXTUREUSAGE_SAMPLER; + color_rt_ = SDL_CreateGPUTexture(device, &ci); + + ci.format = SDL_GPU_TEXTUREFORMAT_D32_FLOAT; + ci.usage = SDL_GPU_TEXTUREUSAGE_DEPTH_STENCIL_TARGET; + depth_rt_ = SDL_CreateGPUTexture(device, &ci); + + ready_ = color_rt_ && depth_rt_; + if (logger_) logger_->Info("q3.hud_head_render: render target " + + std::string(ready_ ? "ready" : "FAILED")); + return ready_; +} + +// --------------------------------------------------------------------------- +// Draw all surfaces of a named MD3 into an already-bound render pass. +// --------------------------------------------------------------------------- +static void DrawHeadMd3(const std::string& prefix, + const glm::mat4& mvp, const glm::mat4& model, + const rendering::FragmentUniformData& fu, + SDL_GPURenderPass* pass, SDL_GPUCommandBuffer* cmd, + WorkflowContext& context) { + const int nSurfs = context.Get("q3.md3." + prefix + "_num_surfs", 0); + if (nSurfs <= 0) return; + + rendering::VertexUniformData vu{}; + std::memcpy(vu.mvp, glm::value_ptr(mvp), sizeof(float) * 16); + std::memcpy(vu.model_mat, glm::value_ptr(model), sizeof(float) * 16); + vu.normal[1] = 1.0f; + vu.uv_scale[0] = 1.0f; vu.uv_scale[1] = 1.0f; + + // Identity shadow VP — no shadows in portrait + const glm::mat4 noShadow(1.0f); + std::memcpy(vu.shadow_vp, glm::value_ptr(noShadow), sizeof(float) * 16); + + for (int s = 0; s < nSurfs; ++s) { + const std::string sk = "q3.md3." + prefix + "_surf" + std::to_string(s); + auto* vb = context.Get(sk + "_f0_vb", nullptr); // frame 0 + auto* ib = context.Get(sk + "_ib", nullptr); + const int nIdx = context.Get(sk + "_num_idx", 0); + if (!vb || !ib || nIdx <= 0) continue; + + auto* tex = context.Get(sk + "_tex", nullptr); + auto* samp = context.Get(sk + "_samp", nullptr); + if (!tex || !samp) continue; + + // Bind albedo twice — slot 1 used as shadow map placeholder + SDL_GPUTextureSamplerBinding bindings[2] = {{tex, samp}, {tex, samp}}; + SDL_BindGPUFragmentSamplers(pass, 0, bindings, 2); + + SDL_GPUBufferBinding vbb{vb, 0}; SDL_BindGPUVertexBuffers(pass, 0, &vbb, 1); + SDL_GPUBufferBinding ibb{ib, 0}; SDL_BindGPUIndexBuffer(pass, &ibb, SDL_GPU_INDEXELEMENTSIZE_16BIT); + SDL_PushGPUVertexUniformData(cmd, 0, &vu, sizeof(vu)); + SDL_PushGPUFragmentUniformData(cmd, 0, &fu, sizeof(fu)); + SDL_DrawGPUIndexedPrimitives(pass, (uint32_t)nIdx, 1, 0, 0, 0); + } +} + +// --------------------------------------------------------------------------- +void WorkflowQ3HudHeadStep::Execute( + const WorkflowStepDefinition&, WorkflowContext& context) { + if (context.GetBool("frame_skip", false)) return; + if (context.GetBool("q3.menu_open", false)) return; + + auto* device = context.Get("gpu_device", nullptr); + auto* cmd = context.Get("gpu_command_buffer", nullptr); + if (!device || !cmd) return; + + // Head model must be loaded + if (context.Get("q3.md3.head_num_surfs", 0) <= 0) return; + + auto* pipeline = context.Get("gpu_pipeline_textured", nullptr); + if (!pipeline) return; + + if (!ready_ && !TryInitRT(device)) return; + + // ── Build portrait camera ──────────────────────────────────────────────── + // Slow Y-axis rotation so the head gently spins — a quarter turn per ~300 frames. + yaw_ += 0.021f; + + // The head model is centred near the origin. Place a close-up camera + // slightly above and in front; the head radius is ~0.15 world units. + const float camDist = 0.45f; + const glm::vec3 camPos( + std::sin(yaw_) * camDist, + 0.06f, + std::cos(yaw_) * camDist); + + const glm::mat4 view = glm::lookAt(camPos, + glm::vec3(0.0f, 0.04f, 0.0f), + glm::vec3(0.0f, 1.0f, 0.0f)); + const glm::mat4 proj = glm::perspective( + glm::radians(55.0f), 1.0f /*square aspect*/, 0.01f, 10.0f); + const glm::mat4 model(1.0f); // head at world origin + const glm::mat4 mvp = proj * view * model; + + rendering::FragmentUniformData fu{}; + fu.material[0] = 0.7f; // roughness + fu.material[1] = 0.0f; // metallic + // Soft front-right key light + fu.light_dir[0] = 0.5f; fu.light_dir[1] = -0.7f; fu.light_dir[2] = -0.5f; + fu.light_color[0] = 1.2f; fu.light_color[1] = 1.1f; fu.light_color[2] = 1.0f; + fu.ambient[0] = fu.ambient[1] = fu.ambient[2] = 0.35f; + + // ── Offscreen render pass → color_rt_ ─────────────────────────────────── + SDL_GPUColorTargetInfo cti{}; + cti.texture = color_rt_; + cti.clear_color = {0.05f, 0.05f, 0.06f, 1.0f}; // near-black background + cti.load_op = SDL_GPU_LOADOP_CLEAR; + cti.store_op = SDL_GPU_STOREOP_STORE; + + SDL_GPUDepthStencilTargetInfo dti{}; + dti.texture = depth_rt_; + dti.clear_depth = 1.0f; + dti.load_op = SDL_GPU_LOADOP_CLEAR; + dti.store_op = SDL_GPU_STOREOP_DONT_CARE; + + SDL_GPURenderPass* pass = SDL_BeginGPURenderPass(cmd, &cti, 1, &dti); + if (!pass) return; + + SDL_GPUViewport vp{}; + vp.x = 0; vp.y = 0; + vp.w = kHeadSz; vp.h = kHeadSz; + vp.min_depth = 0.0f; vp.max_depth = 1.0f; + SDL_SetGPUViewport(pass, &vp); + + SDL_BindGPUGraphicsPipeline(pass, pipeline); + DrawHeadMd3("head", mvp, model, fu, pass, cmd, context); + + SDL_EndGPURenderPass(pass); + + // ── Expose the render target so overlay.sw.end can blit it ────────────── + context.Set("overlay.head_gpu_tex", color_rt_); + + // ── Face rect in overlay-space (matches q3.hud layout) ────────────────── + constexpr float kNS = 1.0f; + constexpr float kNH = 32.f * kNS; + + const int health = context.Get("q3.player_health", 100); + const float hNumW = (health >= 100 ? 3 : health >= 10 ? 2 : 1) * 32.f * kNS; + const float cluster = hNumW + 4.f + kNH; + const float hx = kW * 0.5f - cluster * 0.5f; + + context.Set("hud.face_rect_x", hx + hNumW + 4.f); + context.Set("hud.face_rect_y", kH - kNH - 6.f); + context.Set("hud.face_rect_w", kNH); + context.Set("hud.face_rect_h", kNH); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_hud_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_hud_step.cpp index 995535f99..daaea4138 100644 --- a/gameengine/src/services/impl/workflow/quake3/workflow_q3_hud_step.cpp +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_hud_step.cpp @@ -1,6 +1,7 @@ #include "services/interfaces/workflow/quake3/workflow_q3_hud_step.hpp" #include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp" #include +#include namespace sdl3cpp::services::impl { using namespace q3overlay; @@ -48,14 +49,18 @@ void WorkflowQ3HudStep::Execute( cx = DrawHudNumber(r, digits, cx, kHudY, armor, kNS); drawIcon(iArmor, cx + 4.f, kHudY, kNH, kNH); - // ── Center cluster: [health#] [face icon] ─────────────────────────────── + // ── Center cluster: [health#] [face placeholder] ──────────────────────── // Real Q3A: health number + mugshot centered on screen. + // The actual face is rendered as a live 3D head by q3.hud_head_render and + // blitted on the GPU by overlay.sw.end — we only draw the number here. { const float hNumW = digitWidth(health); const float cluster = hNumW + 4.f + kNH; const float hx = kW * 0.5f - cluster * 0.5f; DrawHudNumber(r, digits, hx, kHudY, health, kNS); - drawIcon(iFace, hx + hNumW + 4.f, kHudY, kNH, kNH); + // Face icon drawn as fallback if head render is unavailable + if (!context.Get("overlay.head_gpu_tex", nullptr)) + drawIcon(iFace, hx + hNumW + 4.f, kHudY, kNH, kNH); } // ── Right cluster: [ammo#] [weapon icon] ───────────────────────────────── diff --git a/gameengine/src/services/impl/workflow/rendering/workflow_overlay_sw_end_step.cpp b/gameengine/src/services/impl/workflow/rendering/workflow_overlay_sw_end_step.cpp index 751c4223f..2861084c8 100644 --- a/gameengine/src/services/impl/workflow/rendering/workflow_overlay_sw_end_step.cpp +++ b/gameengine/src/services/impl/workflow/rendering/workflow_overlay_sw_end_step.cpp @@ -22,6 +22,8 @@ WorkflowOverlaySwEndStep::WorkflowOverlaySwEndStep(std::shared_ptr l) WorkflowOverlaySwEndStep::~WorkflowOverlaySwEndStep() { if (device_) { + if (head_sampler_) SDL_ReleaseGPUSampler(device_, head_sampler_); + if (head_vtx_buf_) SDL_ReleaseGPUBuffer(device_, head_vtx_buf_); if (sampler_) SDL_ReleaseGPUSampler(device_, sampler_); if (vtx_buf_) SDL_ReleaseGPUBuffer(device_, vtx_buf_); if (transfer_) SDL_ReleaseGPUTransferBuffer(device_, transfer_); @@ -107,6 +109,93 @@ void WorkflowOverlaySwEndStep::TryInit( ready_ = tex_ && transfer_ && vtx_buf_ && sampler_ && pipeline_; } +// --------------------------------------------------------------------------- +// Blit the 3D head portrait GPU texture into the face-rect position. +// Uses the same pipeline as the overlay quad but with a small dynamic quad. +// --------------------------------------------------------------------------- +void WorkflowOverlaySwEndStep::BlitHeadPortrait( + SDL_GPUCommandBuffer* cmd, SDL_GPUTexture* swapchain, + SDL_GPUTexture* headTex, WorkflowContext& context) { + if (!pipeline_ || !headTex) return; + + // Lazily create a nearest-neighbour sampler for the head portrait + if (!head_sampler_) { + SDL_GPUSamplerCreateInfo sci{}; + sci.min_filter = SDL_GPU_FILTER_LINEAR; + sci.mag_filter = SDL_GPU_FILTER_LINEAR; + sci.mipmap_mode = SDL_GPU_SAMPLERMIPMAPMODE_LINEAR; + sci.address_mode_u = SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; + sci.address_mode_v = SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; + head_sampler_ = SDL_CreateGPUSampler(device_, &sci); + if (!head_sampler_) return; + } + + // Build a 6-vertex quad from the face rect stored in context by q3.hud_head_render. + // Positions are in overlay NDC: x ∈ [-1,1], y ∈ [-1,1] (Y-up, same as the fullscreen quad). + const float fx = context.Get("hud.face_rect_x", 354.f); + const float fy = context.Get("hud.face_rect_y", 322.f); + const float fw = context.Get("hud.face_rect_w", 32.f); + const float fh = context.Get("hud.face_rect_h", 32.f); + + // Pixel → NDC using the fixed overlay resolution (640×360) + const float x0 = 2.f * fx / kW - 1.f; + const float x1 = 2.f * (fx + fw) / kW - 1.f; + const float y1 = 1.f - 2.f * fy / kH; // top edge (larger y in NDC) + const float y0 = 1.f - 2.f * (fy + fh) / kH; // bottom edge + + const float verts[6][5] = { + {x0, y1, 0.f, 0.f, 0.f}, + {x1, y1, 0.f, 1.f, 0.f}, + {x1, y0, 0.f, 1.f, 1.f}, + {x0, y1, 0.f, 0.f, 0.f}, + {x1, y0, 0.f, 1.f, 1.f}, + {x0, y0, 0.f, 0.f, 1.f}, + }; + const uint32_t sz = sizeof(verts); + + // Lazily create / reallocate head vertex buffer (created once — rect is stable) + if (!head_vtx_buf_) { + SDL_GPUBufferCreateInfo bci{}; + bci.usage = SDL_GPU_BUFFERUSAGE_VERTEX; + bci.size = sz; + head_vtx_buf_ = SDL_CreateGPUBuffer(device_, &bci); + if (!head_vtx_buf_) return; + } + + // Upload quad vertices via a transient transfer buffer + SDL_GPUTransferBufferCreateInfo tbci{}; + tbci.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD; + tbci.size = sz; + auto* tmp = SDL_CreateGPUTransferBuffer(device_, &tbci); + if (!tmp) return; + void* ptr = SDL_MapGPUTransferBuffer(device_, tmp, false); + if (ptr) { std::memcpy(ptr, verts, sz); SDL_UnmapGPUTransferBuffer(device_, tmp); } + auto* cp = SDL_BeginGPUCopyPass(cmd); + if (cp) { + SDL_GPUTransferBufferLocation src{tmp, 0}; + SDL_GPUBufferRegion dst{head_vtx_buf_, 0, sz}; + SDL_UploadToGPUBuffer(cp, &src, &dst, false); + SDL_EndGPUCopyPass(cp); + } + SDL_ReleaseGPUTransferBuffer(device_, tmp); + + // Render pass on swapchain (LOADOP_LOAD preserves previous content) + SDL_GPUColorTargetInfo target{}; + target.texture = swapchain; + target.load_op = SDL_GPU_LOADOP_LOAD; + target.store_op = SDL_GPU_STOREOP_STORE; + auto* pass = SDL_BeginGPURenderPass(cmd, &target, 1, nullptr); + if (!pass) return; + + SDL_BindGPUGraphicsPipeline(pass, pipeline_); + SDL_GPUBufferBinding vb{head_vtx_buf_, 0}; + SDL_BindGPUVertexBuffers(pass, 0, &vb, 1); + SDL_GPUTextureSamplerBinding ts{headTex, head_sampler_}; + SDL_BindGPUFragmentSamplers(pass, 0, &ts, 1); + SDL_DrawGPUPrimitives(pass, 6, 1, 0, 0); + SDL_EndGPURenderPass(pass); +} + void WorkflowOverlaySwEndStep::Execute( const WorkflowStepDefinition& step, WorkflowContext& context) { if (context.GetBool("frame_skip", false)) return; @@ -191,6 +280,10 @@ void WorkflowOverlaySwEndStep::Execute( SDL_DrawGPUPrimitives(pass,6,1,0,0); SDL_EndGPURenderPass(pass); + // Blit 3D head portrait (rendered by q3.hud_head_render) over the HUD face rect + auto* headTex = context.Get("overlay.head_gpu_tex", nullptr); + if (headTex) BlitHeadPortrait(cmd, swapchain, headTex, context); + // Screenshot const auto* ssPath = context.TryGet("screenshot_output_path"); if (ssPath && !ssPath->empty()) { diff --git a/gameengine/src/services/impl/workflow/workflow_registrar.cpp b/gameengine/src/services/impl/workflow/workflow_registrar.cpp index 4ee2f14d9..e90894023 100644 --- a/gameengine/src/services/impl/workflow/workflow_registrar.cpp +++ b/gameengine/src/services/impl/workflow/workflow_registrar.cpp @@ -117,6 +117,7 @@ #include "services/interfaces/workflow/quake3/workflow_q3_menu_update_step.hpp" #include "services/interfaces/workflow/quake3/workflow_q3_weapon_update_step.hpp" #include "services/interfaces/workflow/quake3/workflow_q3_hud_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_hud_head_step.hpp" #include "services/interfaces/workflow/quake3/workflow_q3_crosshair_step.hpp" #include "services/interfaces/workflow/quake3/workflow_q3_hitmarker_step.hpp" #include "services/interfaces/workflow/quake3/workflow_q3_menu_frame_step.hpp" @@ -339,6 +340,7 @@ void WorkflowRegistrar::RegisterSteps(std::shared_ptr reg registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_hud_head_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_hud_head_step.hpp new file mode 100644 index 000000000..6392559b2 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_hud_head_step.hpp @@ -0,0 +1,34 @@ +#pragma once +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" +#include "services/interfaces/workflow_context.hpp" +#include +#include +#include + +namespace sdl3cpp::services::impl { + +// Renders the player head MD3 model to a small (kHeadSz x kHeadSz) GPU render +// target each frame and stores it in context as "overlay.head_gpu_tex". +// overlay.sw.end reads that texture and blits it at "hud.face_rect_*" position. +class WorkflowQ3HudHeadStep final : public IWorkflowStep { +public: + explicit WorkflowQ3HudHeadStep(std::shared_ptr logger); + ~WorkflowQ3HudHeadStep(); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; + + static constexpr int kHeadSz = 64; // square render target size + +private: + bool TryInitRT(SDL_GPUDevice* device); + + std::shared_ptr logger_; + SDL_GPUDevice* device_ = nullptr; + SDL_GPUTexture* color_rt_ = nullptr; + SDL_GPUTexture* depth_rt_ = nullptr; + bool ready_ = false; + float yaw_ = 0.0f; // slow portrait rotation (radians) +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/rendering/workflow_overlay_sw_end_step.hpp b/gameengine/src/services/interfaces/workflow/rendering/workflow_overlay_sw_end_step.hpp index 46eb67e0f..d550828a1 100644 --- a/gameengine/src/services/interfaces/workflow/rendering/workflow_overlay_sw_end_step.hpp +++ b/gameengine/src/services/interfaces/workflow/rendering/workflow_overlay_sw_end_step.hpp @@ -17,6 +17,9 @@ private: void TryInit(SDL_GPUDevice* device, SDL_Window* window, const std::string& vertPath, const std::string& fragPath); std::shared_ptr logger_; + void BlitHeadPortrait(SDL_GPUCommandBuffer* cmd, SDL_GPUTexture* swapchain, + SDL_GPUTexture* headTex, WorkflowContext& context); + bool ready_ = false; bool disabled_ = false; bool vbuf_uploaded_ = false; @@ -26,6 +29,9 @@ private: SDL_GPUTransferBuffer* transfer_ = nullptr; SDL_GPUBuffer* vtx_buf_ = nullptr; SDL_GPUSampler* sampler_ = nullptr; + // Head portrait blit resources (created lazily, same pipeline/sampler) + SDL_GPUBuffer* head_vtx_buf_ = nullptr; + SDL_GPUSampler* head_sampler_ = nullptr; static constexpr int kW = 640, kH = 360; }; } // namespace sdl3cpp::services::impl