Render 3D Q3 player head and blit to HUD

Add a new workflow step that renders the Quake3 MD3 player head to a small offscreen GPU render target each frame and exposes it to the overlay as "overlay.head_gpu_tex" with face rectangle metadata. Implement WorkflowQ3HudHeadStep (kHeadSz=64) and its header; register the step in the workflow registrar and add it to the CMake build and q3_overlay workflow JSON. Update q3 HUD drawing to use the live 3D portrait when available (fall back to the icon otherwise). Extend overlay.sw.end to blit the head texture into the HUD face rect (BlitHeadPortrait), with lazy allocation of a vertex buffer and sampler and proper cleanup. Also add resource release logic and small API includes where needed.
This commit is contained in:
2026-05-04 19:50:17 +01:00
parent 75d6051eca
commit 730baa8733
8 changed files with 338 additions and 2 deletions
+1
View File
@@ -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
@@ -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"
@@ -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 <SDL3/SDL_gpu.h>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
#include <nlohmann/json.hpp>
#include <cmath>
#include <cstring>
#include <string>
namespace sdl3cpp::services::impl {
using namespace q3overlay;
WorkflowQ3HudHeadStep::WorkflowQ3HudHeadStep(std::shared_ptr<ILogger> 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<int>("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<SDL_GPUBuffer*>(sk + "_f0_vb", nullptr); // frame 0
auto* ib = context.Get<SDL_GPUBuffer*>(sk + "_ib", nullptr);
const int nIdx = context.Get<int>(sk + "_num_idx", 0);
if (!vb || !ib || nIdx <= 0) continue;
auto* tex = context.Get<SDL_GPUTexture*>(sk + "_tex", nullptr);
auto* samp = context.Get<SDL_GPUSampler*>(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<SDL_GPUDevice*>("gpu_device", nullptr);
auto* cmd = context.Get<SDL_GPUCommandBuffer*>("gpu_command_buffer", nullptr);
if (!device || !cmd) return;
// Head model must be loaded
if (context.Get<int>("q3.md3.head_num_surfs", 0) <= 0) return;
auto* pipeline = context.Get<SDL_GPUGraphicsPipeline*>("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<SDL_GPUTexture*>("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<int>("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<float>("hud.face_rect_x", hx + hNumW + 4.f);
context.Set<float>("hud.face_rect_y", kH - kNH - 6.f);
context.Set<float>("hud.face_rect_w", kNH);
context.Set<float>("hud.face_rect_h", kNH);
}
} // namespace sdl3cpp::services::impl
@@ -1,6 +1,7 @@
#include "services/interfaces/workflow/quake3/workflow_q3_hud_step.hpp"
#include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp"
#include <SDL3/SDL_render.h>
#include <SDL3/SDL_gpu.h>
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<SDL_GPUTexture*>("overlay.head_gpu_tex", nullptr))
drawIcon(iFace, hx + hNumW + 4.f, kHudY, kNH, kNH);
}
// ── Right cluster: [ammo#] [weapon icon] ─────────────────────────────────
@@ -22,6 +22,8 @@ WorkflowOverlaySwEndStep::WorkflowOverlaySwEndStep(std::shared_ptr<ILogger> 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<float>("hud.face_rect_x", 354.f);
const float fy = context.Get<float>("hud.face_rect_y", 322.f);
const float fw = context.Get<float>("hud.face_rect_w", 32.f);
const float fh = context.Get<float>("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<SDL_GPUTexture*>("overlay.head_gpu_tex", nullptr);
if (headTex) BlitHeadPortrait(cmd, swapchain, headTex, context);
// Screenshot
const auto* ssPath = context.TryGet<std::string>("screenshot_output_path");
if (ssPath && !ssPath->empty()) {
@@ -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<IWorkflowStepRegistry> reg
registry->RegisterStep(std::make_shared<WorkflowQ3PickupsDrawStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowOverlaySwBeginStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3HudStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3HudHeadStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3CrosshairStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3HitmarkerStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3MenuFrameStep>(logger_));
@@ -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 <SDL3/SDL_gpu.h>
#include <memory>
#include <string>
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<ILogger> 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<ILogger> 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
@@ -17,6 +17,9 @@ private:
void TryInit(SDL_GPUDevice* device, SDL_Window* window,
const std::string& vertPath, const std::string& fragPath);
std::shared_ptr<ILogger> 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