Activate WMO/char/M2 render loop, purge dead GL block, add underwater overlay

- renderWorld() now calls wmoRenderer, characterRenderer, m2Renderer (+smoke,
  particles) in the correct opaque→transparent order; water moved after all
  opaques; per-subsystem timing active in live path
- Deleted the 310-line #if 0 GL stub block and removed #include <GL/glew.h>
  (last GL reference in renderer.cpp)
- Full-screen overlay pipeline (postprocess.vert + overlay.frag, alpha blend,
  no depth test/write) for underwater tint; lazily initialized, renders a blue
  tint when camera is meaningfully below the water surface; canal vs open-water
  tint colours preserved from original design
- overlay.frag.glsl / overlay.frag.spv added
This commit is contained in:
Kelsi 2026-02-21 20:01:01 -08:00
parent dea52744a4
commit 69cf39ba02
4 changed files with 141 additions and 328 deletions

View file

@ -51,7 +51,6 @@
#include "audio/combat_sound_manager.hpp"
#include "audio/spell_sound_manager.hpp"
#include "audio/movement_sound_manager.hpp"
#include <GL/glew.h> // TODO: Remove in Phase 7 (unconverted sub-renderers still reference GL types)
#include "rendering/vk_context.hpp"
#include "rendering/vk_frame_data.hpp"
#include "rendering/vk_shader.hpp"
@ -708,6 +707,8 @@ void Renderer::shutdown() {
if (selCirclePipelineLayout) { vkDestroyPipelineLayout(device, selCirclePipelineLayout, nullptr); selCirclePipelineLayout = VK_NULL_HANDLE; }
if (selCircleVertBuf) { vmaDestroyBuffer(vkCtx->getAllocator(), selCircleVertBuf, selCircleVertAlloc); selCircleVertBuf = VK_NULL_HANDLE; selCircleVertAlloc = VK_NULL_HANDLE; }
if (selCircleIdxBuf) { vmaDestroyBuffer(vkCtx->getAllocator(), selCircleIdxBuf, selCircleIdxAlloc); selCircleIdxBuf = VK_NULL_HANDLE; selCircleIdxAlloc = VK_NULL_HANDLE; }
if (overlayPipeline) { vkDestroyPipeline(device, overlayPipeline, nullptr); overlayPipeline = VK_NULL_HANDLE; }
if (overlayPipelineLayout) { vkDestroyPipelineLayout(device, overlayPipelineLayout, nullptr); overlayPipelineLayout = VK_NULL_HANDLE; }
}
destroyPerFrameResources();
@ -2857,8 +2858,63 @@ void Renderer::renderSelectionCircle(const glm::mat4& view, const glm::mat4& pro
vkCmdDrawIndexed(currentCmd, static_cast<uint32_t>(selCircleVertCount), 1, 0, 0, 0);
}
// ──────────────────────────────────────────────────────────────
// Fullscreen overlay pipeline (underwater tint, etc.)
// ──────────────────────────────────────────────────────────────
void Renderer::initOverlayPipeline() {
if (!vkCtx) return;
VkDevice device = vkCtx->getDevice();
// Push constant: vec4 color (16 bytes), visible to both stages
VkPushConstantRange pc{};
pc.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
pc.offset = 0;
pc.size = 16;
VkPipelineLayoutCreateInfo plCI{};
plCI.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
plCI.pushConstantRangeCount = 1;
plCI.pPushConstantRanges = &pc;
vkCreatePipelineLayout(device, &plCI, nullptr, &overlayPipelineLayout);
VkShaderModule vertMod, fragMod;
if (!vertMod.loadFromFile(device, "assets/shaders/postprocess.vert.spv") ||
!fragMod.loadFromFile(device, "assets/shaders/overlay.frag.spv")) {
LOG_ERROR("Renderer: failed to load overlay shaders");
vertMod.destroy(); fragMod.destroy();
return;
}
overlayPipeline = PipelineBuilder()
.setShaders(vertMod.stageInfo(VK_SHADER_STAGE_VERTEX_BIT),
fragMod.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT))
.setVertexInput({}, {}) // fullscreen triangle, no VBOs
.setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST)
.setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE)
.setNoDepthTest()
.setColorBlendAttachment(PipelineBuilder::blendAlpha())
.setLayout(overlayPipelineLayout)
.setRenderPass(vkCtx->getImGuiRenderPass())
.setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR})
.build(device);
vertMod.destroy(); fragMod.destroy();
if (overlayPipeline) LOG_INFO("Renderer: overlay pipeline initialized");
}
void Renderer::renderOverlay(const glm::vec4& color) {
if (!overlayPipeline) initOverlayPipeline();
if (!overlayPipeline || currentCmd == VK_NULL_HANDLE) return;
vkCmdBindPipeline(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, overlayPipeline);
vkCmdPushConstants(currentCmd, overlayPipelineLayout,
VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, &color[0]);
vkCmdDraw(currentCmd, 3, 1, 0, 0); // fullscreen triangle
}
void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
(void)world; // Used later in Phases 4-5
(void)world;
auto renderStart = std::chrono::steady_clock::now();
lastTerrainRenderMs = 0.0;
@ -2867,6 +2923,8 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
uint32_t frameIdx = vkCtx->getCurrentFrame();
VkDescriptorSet perFrameSet = perFrameDescSets[frameIdx];
const glm::mat4& view = camera ? camera->getViewMatrix() : glm::mat4(1.0f);
const glm::mat4& projection = camera ? camera->getProjectionMatrix() : glm::mat4(1.0f);
// Get time of day for sky-related rendering
float timeOfDay = (skySystem && skySystem->getSkybox()) ? skySystem->getSkybox()->getTimeOfDay() : 12.0f;
@ -2896,46 +2954,95 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
skySystem->render(currentCmd, perFrameSet, *camera, skyParams);
}
// Terrain rendering
// Terrain (opaque pass)
if (terrainRenderer && camera && terrainEnabled) {
auto terrainStart = std::chrono::steady_clock::now();
terrainRenderer->render(currentCmd, perFrameSet, *camera);
lastTerrainRenderMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - terrainStart).count();
}
// Water rendering (after terrain, transparent)
// WMO buildings (opaque, drawn before characters so selection circle sits on top)
if (wmoRenderer && camera) {
auto wmoStart = std::chrono::steady_clock::now();
wmoRenderer->render(currentCmd, perFrameSet, *camera);
lastWMORenderMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - wmoStart).count();
}
// Selection circle (drawn after WMO, before characters)
renderSelectionCircle(view, projection);
// Characters (after selection circle so units draw over the ring)
if (characterRenderer && camera) {
characterRenderer->render(currentCmd, perFrameSet, *camera);
}
// M2 doodads, creatures, glow sprites, particles
if (m2Renderer && camera) {
if (cameraController) {
m2Renderer->setInsideInterior(cameraController->isInsideWMO());
m2Renderer->setOnTaxi(cameraController->isOnTaxi());
}
auto m2Start = std::chrono::steady_clock::now();
m2Renderer->render(currentCmd, perFrameSet, *camera);
m2Renderer->renderSmokeParticles(currentCmd, perFrameSet);
m2Renderer->renderM2Particles(currentCmd, perFrameSet);
lastM2RenderMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - m2Start).count();
}
// Water (transparent, after all opaques)
if (waterRenderer && camera) {
waterRenderer->render(currentCmd, perFrameSet, *camera, globalTime);
}
// Render weather particles (after terrain/water, before characters)
// Weather particles
if (weather && camera) {
weather->render(currentCmd, perFrameSet);
}
// Render swim effects (ripples and bubbles)
// Swim effects (ripples, bubbles)
if (swimEffects && camera) {
swimEffects->render(currentCmd, perFrameSet);
}
// Render mount dust effects
// Mount dust
if (mountDust && camera) {
mountDust->render(currentCmd, perFrameSet);
}
// Render charge effect (red haze + dust)
// Charge effect
if (chargeEffect && camera) {
chargeEffect->render(currentCmd, perFrameSet);
}
// TODO Phase 5: WMO rendering
// TODO Phase 5: Character rendering
// TODO Phase 5: M2 rendering
// Render quest markers (billboards above NPCs)
// Quest markers (billboards above NPCs)
if (questMarkerRenderer && camera) {
questMarkerRenderer->render(currentCmd, perFrameSet, *camera);
}
// Minimap display overlay (screen-space quad with composite texture)
// Underwater tint overlay — detect camera position relative to water surface
if (overlayPipeline && cameraController && cameraController->isSwimming()
&& waterRenderer && camera) {
glm::vec3 camPos = camera->getPosition();
auto waterH = waterRenderer->getWaterHeightAt(camPos.x, camPos.y);
constexpr float UNDERWATER_EPS = 1.10f;
constexpr float MAX_DEPTH = 12.0f;
if (waterH && camPos.z < (*waterH - UNDERWATER_EPS)
&& (*waterH - camPos.z) <= MAX_DEPTH) {
// Check for canal (liquid type 5, 13, 17) vs open water
bool canal = false;
if (auto lt = waterRenderer->getWaterTypeAt(camPos.x, camPos.y))
canal = (*lt == 5 || *lt == 13 || *lt == 17);
glm::vec4 tint = canal
? glm::vec4(0.01f, 0.05f, 0.11f, 0.50f)
: glm::vec4(0.02f, 0.08f, 0.15f, 0.30f);
renderOverlay(tint);
}
}
// Minimap overlay
if (minimap && minimap->isEnabled() && camera && window) {
glm::vec3 minimapCenter = camera->getPosition();
if (cameraController && cameraController->isThirdPerson())
@ -2944,323 +3051,9 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
window->getWidth(), window->getHeight());
}
// TODO Phase 6: Post-process pipeline, shadow mapping, underwater overlay
auto renderEnd = std::chrono::steady_clock::now();
lastRenderMs = std::chrono::duration<double, std::milli>(renderEnd - renderStart).count();
// ===== STUBBED GL RENDERING (dead code — reference for Phases 4-6) =====
#if 0
auto renderStart = std::chrono::steady_clock::now();
lastTerrainRenderMs = 0.0;
lastWMORenderMs = 0.0;
lastM2RenderMs = 0.0;
// Shadow pass (before main scene) — update every frame to avoid temporal popping.
if (shadowsEnabled && shadowFBO && shadowShaderProgram && terrainLoaded) {
renderShadowPass();
} else {
// Clear shadow maps when disabled
if (terrainRenderer) terrainRenderer->clearShadowMap();
if (wmoRenderer) wmoRenderer->clearShadowMap();
if (m2Renderer) m2Renderer->clearShadowMap();
if (characterRenderer) characterRenderer->clearShadowMap();
}
// Bind HDR scene framebuffer for world rendering
glBindFramebuffer(GL_FRAMEBUFFER, sceneFBO);
glViewport(0, 0, fbWidth, fbHeight);
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
(void)world; // Unused for now
// Get time of day for sky-related rendering
float timeOfDay = skybox ? skybox->getTimeOfDay() : 12.0f;
bool underwater = false;
bool canalUnderwater = false;
// Render sky system (unified coordinator for skybox, stars, celestial, clouds, lens flare)
if (skySystem && camera) {
// Populate SkyParams from lighting manager
rendering::SkyParams skyParams;
skyParams.timeOfDay = timeOfDay;
skyParams.gameTime = gameHandler ? gameHandler->getGameTime() : -1.0f;
if (lightingManager) {
const auto& lighting = lightingManager->getLightingParams();
skyParams.directionalDir = lighting.directionalDir;
skyParams.sunColor = lighting.diffuseColor;
skyParams.skyTopColor = lighting.skyTopColor;
skyParams.skyMiddleColor = lighting.skyMiddleColor;
skyParams.skyBand1Color = lighting.skyBand1Color;
skyParams.skyBand2Color = lighting.skyBand2Color;
skyParams.cloudDensity = lighting.cloudDensity;
skyParams.fogDensity = lighting.fogDensity;
skyParams.horizonGlow = lighting.horizonGlow;
}
// TODO: Set skyboxModelId from LightSkybox.dbc (future)
skyParams.skyboxModelId = 0;
skyParams.skyboxHasStars = false; // Gradient skybox has no baked stars
skySystem->render(currentCmd, perFrameDescSets[vkCtx->getCurrentFrame()], *camera, skyParams);
} else {
// Fallback: render individual components (backwards compatibility)
if (skybox && camera) {
skybox->render(currentCmd, perFrameDescSets[vkCtx->getCurrentFrame()], timeOfDay);
}
// Get lighting parameters for celestial rendering
const glm::vec3* sunDir = nullptr;
const glm::vec3* sunColor = nullptr;
float cloudDensity = 0.0f;
float fogDensity = 0.0f;
if (lightingManager) {
const auto& lighting = lightingManager->getLightingParams();
sunDir = &lighting.directionalDir;
sunColor = &lighting.diffuseColor;
cloudDensity = lighting.cloudDensity;
fogDensity = lighting.fogDensity;
}
if (starField && camera) {
starField->render(currentCmd, perFrameDescSets[vkCtx->getCurrentFrame()], timeOfDay, cloudDensity, fogDensity);
}
if (celestial && camera) {
celestial->render(currentCmd, perFrameDescSets[vkCtx->getCurrentFrame()], timeOfDay, sunDir, sunColor);
}
if (clouds && camera) {
clouds->render(currentCmd, perFrameDescSets[vkCtx->getCurrentFrame()], timeOfDay);
}
if (lensFlare && camera && celestial) {
glm::vec3 sunPosition;
if (sunDir) {
const float sunDistance = 800.0f;
sunPosition = -*sunDir * sunDistance;
} else {
sunPosition = celestial->getSunPosition(timeOfDay);
}
lensFlare->render(*camera, sunPosition, timeOfDay);
}
}
// Apply lighting and fog to all renderers
if (lightingManager) {
const auto& lighting = lightingManager->getLightingParams();
float lightDir[3] = {lighting.directionalDir.x, lighting.directionalDir.y, lighting.directionalDir.z};
float lightColor[3] = {lighting.diffuseColor.r, lighting.diffuseColor.g, lighting.diffuseColor.b};
float ambientColor[3] = {lighting.ambientColor.r, lighting.ambientColor.g, lighting.ambientColor.b};
float fogColorArray[3] = {lighting.fogColor.r, lighting.fogColor.g, lighting.fogColor.b};
if (wmoRenderer) {
wmoRenderer->setLighting(lightDir, lightColor, ambientColor);
wmoRenderer->setFog(glm::vec3(fogColorArray[0], fogColorArray[1], fogColorArray[2]),
lighting.fogStart, lighting.fogEnd);
}
if (m2Renderer) {
m2Renderer->setLighting(lightDir, lightColor, ambientColor);
m2Renderer->setFog(glm::vec3(fogColorArray[0], fogColorArray[1], fogColorArray[2]),
lighting.fogStart, lighting.fogEnd);
}
if (characterRenderer) {
characterRenderer->setLighting(lightDir, lightColor, ambientColor);
characterRenderer->setFog(glm::vec3(fogColorArray[0], fogColorArray[1], fogColorArray[2]),
lighting.fogStart, lighting.fogEnd);
}
} else if (skybox) {
// Fallback to skybox-based fog if no lighting manager
glm::vec3 horizonColor = skybox->getHorizonColor(timeOfDay);
if (wmoRenderer) wmoRenderer->setFog(horizonColor, 100.0f, 600.0f);
if (m2Renderer) m2Renderer->setFog(horizonColor, 100.0f, 600.0f);
if (characterRenderer) characterRenderer->setFog(horizonColor, 100.0f, 600.0f);
}
// Render terrain if loaded and enabled
if (terrainEnabled && terrainLoaded && terrainRenderer && camera) {
// Check if camera/character is underwater for fog override
if (cameraController && cameraController->isSwimming() && waterRenderer && camera) {
glm::vec3 camPos = camera->getPosition();
auto waterH = waterRenderer->getWaterHeightAt(camPos.x, camPos.y);
constexpr float MAX_UNDERWATER_DEPTH = 12.0f;
// Require camera to be meaningfully below the surface before
// underwater fog/tint kicks in (avoids "wrong plane" near surface).
constexpr float UNDERWATER_ENTER_EPS = 1.10f;
if (waterH &&
camPos.z < (*waterH - UNDERWATER_ENTER_EPS) &&
(*waterH - camPos.z) <= MAX_UNDERWATER_DEPTH) {
underwater = true;
}
}
if (underwater) {
glm::vec3 camPos = camera->getPosition();
std::optional<uint16_t> liquidType = waterRenderer ? waterRenderer->getWaterTypeAt(camPos.x, camPos.y) : std::nullopt;
if (!liquidType && cameraController) {
const glm::vec3* followTarget = cameraController->getFollowTarget();
if (followTarget && waterRenderer) {
liquidType = waterRenderer->getWaterTypeAt(followTarget->x, followTarget->y);
}
}
canalUnderwater = liquidType && (*liquidType == 5 || *liquidType == 13 || *liquidType == 17);
}
// Apply lighting from lighting manager
if (lightingManager) {
const auto& lighting = lightingManager->getLightingParams();
// Set lighting (direction, color, ambient)
float lightDir[3] = {lighting.directionalDir.x, lighting.directionalDir.y, lighting.directionalDir.z};
float lightColor[3] = {lighting.diffuseColor.r, lighting.diffuseColor.g, lighting.diffuseColor.b};
float ambientColor[3] = {lighting.ambientColor.r, lighting.ambientColor.g, lighting.ambientColor.b};
terrainRenderer->setLighting(lightDir, lightColor, ambientColor);
// Set fog
float fogColor[3] = {lighting.fogColor.r, lighting.fogColor.g, lighting.fogColor.b};
terrainRenderer->setFog(fogColor, lighting.fogStart, lighting.fogEnd);
} else if (skybox) {
// Fallback to skybox-based fog if no lighting manager
glm::vec3 horizonColor = skybox->getHorizonColor(timeOfDay);
float fogColorArray[3] = {horizonColor.r, horizonColor.g, horizonColor.b};
terrainRenderer->setFog(fogColorArray, 400.0f, 1200.0f);
}
auto terrainStart = std::chrono::steady_clock::now();
terrainRenderer->render(*camera);
auto terrainEnd = std::chrono::steady_clock::now();
lastTerrainRenderMs = std::chrono::duration<double, std::milli>(terrainEnd - terrainStart).count();
}
// Render weather particles (after terrain/water, before characters)
if (weather && camera) {
weather->render(currentCmd, perFrameDescSets[vkCtx->getCurrentFrame()]);
}
// Render swim effects (ripples and bubbles)
if (swimEffects && camera) {
swimEffects->render(currentCmd, perFrameDescSets[vkCtx->getCurrentFrame()]);
}
// Render mount dust effects
if (mountDust && camera) {
mountDust->render(currentCmd, perFrameDescSets[vkCtx->getCurrentFrame()]);
}
// Render charge effect (red haze + dust)
if (chargeEffect && camera) {
chargeEffect->render(currentCmd, perFrameDescSets[vkCtx->getCurrentFrame()]);
}
// Compute view/projection once for all sub-renderers
const glm::mat4& view = camera ? camera->getViewMatrix() : glm::mat4(1.0f);
const glm::mat4& projection = camera ? camera->getProjectionMatrix() : glm::mat4(1.0f);
// Render WMO buildings first so selection circle can be drawn above WMO depth.
if (wmoRenderer && camera) {
auto wmoStart = std::chrono::steady_clock::now();
wmoRenderer->render(currentCmd, perFrameDescSets[vkCtx->getCurrentFrame()], *camera);
auto wmoEnd = std::chrono::steady_clock::now();
lastWMORenderMs = std::chrono::duration<double, std::milli>(wmoEnd - wmoStart).count();
}
// Render selection circle after WMO so interiors/shafts do not hide it.
// It remains before character/M2 passes so units still draw over the ring.
renderSelectionCircle(view, projection);
// Render characters (after selection circle)
if (characterRenderer && camera) {
characterRenderer->render(currentCmd, perFrameDescSets[vkCtx->getCurrentFrame()], *camera);
}
// Render M2 doodads (trees, rocks, etc.)
if (m2Renderer && camera) {
// Dim M2 lighting when player is inside a WMO
if (cameraController) {
m2Renderer->setInsideInterior(cameraController->isInsideWMO());
m2Renderer->setOnTaxi(cameraController->isOnTaxi());
}
auto m2Start = std::chrono::steady_clock::now();
uint32_t frame = vkCtx->getCurrentFrame();
VkDescriptorSet pfSet = perFrameDescSets[frame];
m2Renderer->render(currentCmd, pfSet, *camera);
m2Renderer->renderSmokeParticles(currentCmd, pfSet);
m2Renderer->renderM2Particles(currentCmd, pfSet);
auto m2End = std::chrono::steady_clock::now();
lastM2RenderMs = std::chrono::duration<double, std::milli>(m2End - m2Start).count();
}
// Render water after opaque terrain/WMO/M2 so transparent surfaces remain visible.
if (waterRenderer && camera) {
static float time = 0.0f;
time += 0.016f; // Approximate frame time
waterRenderer->render(*camera, time);
}
// Render quest markers (billboards above NPCs)
if (questMarkerRenderer && camera) {
questMarkerRenderer->render(*camera);
}
// Full-screen underwater tint so WMO/M2/characters also feel submerged.
if (false && underwater && underwaterOverlayShader && underwaterOverlayVAO) {
glDisable(GL_DEPTH_TEST);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
underwaterOverlayShader->use();
if (canalUnderwater) {
underwaterOverlayShader->setUniform("uTint", glm::vec4(0.01f, 0.05f, 0.11f, 0.50f));
} else {
underwaterOverlayShader->setUniform("uTint", glm::vec4(0.02f, 0.08f, 0.15f, 0.30f));
}
glBindVertexArray(underwaterOverlayVAO);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
glDisable(GL_BLEND);
glEnable(GL_DEPTH_TEST);
}
// --- Resolve MSAA → non-MSAA texture ---
glBindFramebuffer(GL_READ_FRAMEBUFFER, sceneFBO);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, resolveFBO);
glBlitFramebuffer(0, 0, fbWidth, fbHeight, 0, 0, fbWidth, fbHeight,
GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT, GL_NEAREST);
// --- Post-process: tonemap via fullscreen quad ---
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glViewport(0, 0, window->getWidth(), window->getHeight());
glDisable(GL_DEPTH_TEST);
glClear(GL_COLOR_BUFFER_BIT);
if (postProcessShader && screenQuadVAO) {
postProcessShader->use();
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, resolveColorTex);
postProcessShader->setUniform("uScene", 0);
glBindVertexArray(screenQuadVAO);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
postProcessShader->unuse();
}
// Render minimap overlay (after post-process so it's not overwritten)
if (minimap && camera && window) {
glm::vec3 minimapCenter = camera->getPosition();
if (cameraController && cameraController->isThirdPerson()) {
minimapCenter = characterPosition;
}
minimap->render(*camera, minimapCenter, window->getWidth(), window->getHeight());
}
glEnable(GL_DEPTH_TEST);
auto renderEnd = std::chrono::steady_clock::now();
lastRenderMs = std::chrono::duration<double, std::milli>(renderEnd - renderStart).count();
#endif // Stubbed GL rendering
}
// initPostProcess(), resizePostProcess(), shutdownPostProcess() removed —