refactor(gameengine): extract spotlight.update step, distance fog, volumetric improvements

- Extract spotlight logic from render.prepare into dedicated spotlight.update step
- render.prepare now only handles camera, shadow, and lighting uniforms
- spotlight.update runs per-frame after render.prepare, reads spotlight.state from context
- Aim distance configurable via JSON (aim_distance parameter)
- Camera-local offset for spotlight origin (matches viewmodel position)
- Direction computed from torch position toward camera aim point (natural beam alignment)
- Add distance fog to whole room (exponential, dark blue-grey)
- Volumetric beam: 48 steps, UE4 interleaved gradient noise, cubic cone falloff
- Fog density increased for visible beam effect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 08:54:04 +00:00
parent 7ac5ef1d20
commit 487631458a
9 changed files with 138 additions and 55 deletions

View File

@@ -303,6 +303,7 @@ if(BUILD_SDL3_APP)
src/services/impl/workflow/rendering/workflow_shadow_pass_step.cpp
src/services/impl/workflow/rendering/workflow_shadow_setup_step.cpp
src/services/impl/workflow/rendering/workflow_spotlight_setup_step.cpp
src/services/impl/workflow/rendering/workflow_spotlight_update_step.cpp
src/services/impl/workflow/graphics/workflow_gpu_pipeline_create_step.cpp
src/services/impl/workflow/graphics/workflow_gpu_shader_compile_step.cpp
src/services/impl/workflow/graphics/workflow_graphics_buffer_create_index_step.cpp

View File

@@ -83,36 +83,39 @@ float SpotlightAtten(vec3 lightToFrag, vec3 spotDir, float cosInner, float cosOu
// Simulates light scattering through dust particles in the air
vec3 VolumetricBeam(vec3 camPos, vec3 fragPos, vec3 flashPos, vec3 flashDir,
float cosInner, float cosOuter, vec3 flashColor, float flashRange) {
const int NUM_STEPS = 16;
const float FOG_DENSITY = 0.007; // dust density in the air
const int NUM_STEPS = 48;
const float FOG_DENSITY = 0.05;
vec3 rayDir = fragPos - camPos;
float rayLen = length(rayDir);
vec3 rayNorm = rayDir / rayLen;
float stepSize = rayLen / float(NUM_STEPS);
// Dithered start offset to reduce banding artifacts
float dither = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453);
// Interleaved gradient noise - much smoother than sin-based dithering
// (same technique used by UE4/UE5 volumetric fog)
vec2 fc = gl_FragCoord.xy;
float dither = fract(52.9829189 * fract(0.06711056 * fc.x + 0.00583715 * fc.y));
// Mie-like forward scattering (compute once, constant along ray)
float viewDot = max(dot(rayNorm, flashDir), 0.0);
float scatter = mix(1.0, 2.5, viewDot * viewDot);
vec3 accumulated = vec3(0.0);
for (int i = 0; i < NUM_STEPS; ++i) {
float t = (float(i) + dither) * stepSize;
vec3 samplePos = camPos + normalize(rayDir) * t;
vec3 samplePos = camPos + rayNorm * t;
// Check if this point in space is inside the spotlight cone
vec3 toSample = samplePos - flashPos;
float dist = length(toSample);
// Distance attenuation
// Smooth distance falloff
float distAtten = clamp(1.0 - dist / flashRange, 0.0, 1.0);
distAtten *= distAtten;
// Cone attenuation
float cosAngle = dot(normalize(toSample), flashDir);
// Cone with smooth cubic falloff at edges
float cosAngle = dot(toSample / dist, flashDir);
float coneAtten = clamp((cosAngle - cosOuter) / max(cosInner - cosOuter, 0.001), 0.0, 1.0);
// Mie-like forward scattering: brighter when looking into the beam
float viewDot = max(dot(normalize(rayDir), flashDir), 0.0);
float scatter = mix(1.0, 3.0, viewDot * viewDot);
coneAtten *= coneAtten; // smoother edge
accumulated += flashColor * distAtten * coneAtten * scatter * FOG_DENSITY * stepSize;
}
@@ -190,7 +193,14 @@ void main() {
// === Ambient ===
vec3 ambient = u_ambient.rgb * albedo;
// Combine surface + volumetric fog (fog is additive, not affected by surface)
// Surface color
vec3 color = (Lo + ambient) * exposure + volumetric;
// === Distance fog (whole room) ===
float dist = length(v_worldPos - v_cameraPos);
float fogFactor = 1.0 - exp(-dist * 0.06); // exponential fog density
vec3 fogColor = vec3(0.02, 0.02, 0.03); // dark blue-grey fog
color = mix(color, fogColor, fogFactor);
o_color = vec4(color, 1.0);
}

View File

@@ -75,6 +75,13 @@
"typeVersion": 1,
"position": [750, 0]
},
{
"id": "spotlight_update",
"name": "Update Spotlight",
"type": "spotlight.update",
"typeVersion": 1,
"position": [760, 0]
},
{
"id": "frame_begin",
"name": "Begin Offscreen Frame",
@@ -472,6 +479,11 @@
}
},
"render_prepare": {
"main": {
"0": [{ "node": "spotlight_update", "type": "main", "index": 0 }]
}
},
"spotlight_update": {
"main": {
"0": [{ "node": "frame_begin", "type": "main", "index": 0 }]
}

View File

@@ -862,14 +862,16 @@
"position": [1125, 200],
"parameters": {
"attach": "camera",
"inner_cone": 6,
"outer_cone": 16,
"inner_cone": 3,
"outer_cone": 12,
"intensity": 3.0,
"range": 25,
"color_r": 1.0,
"color_g": 0.95,
"color_b": 0.85,
"offset_y": -0.15
"offset_x": 0.15,
"offset_y": -0.1,
"offset_z": -0.1
}
},
{

View File

@@ -52,17 +52,16 @@ void WorkflowRenderPrepareStep::Execute(const WorkflowStepDefinition& step, Work
// --- Fragment uniforms from lighting ---
rendering::FragmentUniformData fu = {};
// Defaults: downward white light, subtle ambient, full exposure
fu.light_dir[1] = -1.0f;
fu.light_color[0] = 1.0f;
fu.light_color[1] = 1.0f;
fu.light_color[2] = 1.0f;
fu.light_color[3] = 1.0f; // exposure
fu.light_color[3] = 1.0f;
fu.ambient[0] = 0.2f;
fu.ambient[1] = 0.2f;
fu.ambient[2] = 0.2f;
fu.material[0] = 0.8f; // roughness default
fu.material[1] = 0.0f; // metallic default
fu.material[0] = 0.8f;
fu.material[1] = 0.0f;
const auto* lighting = context.TryGet<nlohmann::json>("lighting.directional");
if (lighting) {
@@ -93,40 +92,6 @@ void WorkflowRenderPrepareStep::Execute(const WorkflowStepDefinition& step, Work
fu.light_color[3] = lighting->value("exposure", 1.0f);
}
// --- Spotlight / flashlight (reads from context, set by spotlight.setup step) ---
const auto* spot = context.TryGet<nlohmann::json>("spotlight.state");
if (spot) {
std::string attach = spot->value("attach", "camera");
glm::vec3 spotPos, spotDir;
auto offset = spot->value("offset", std::vector<float>{0,0,0});
glm::vec3 off(offset.size()>0?offset[0]:0, offset.size()>1?offset[1]:0, offset.size()>2?offset[2]:0);
if (attach == "camera") {
spotPos = cameraPos + off;
spotDir = -glm::vec3(viewMatrix[0][2], viewMatrix[1][2], viewMatrix[2][2]);
} else {
auto p = spot->value("position", std::vector<float>{0,0,0});
auto d = spot->value("direction", std::vector<float>{0,0,-1});
spotPos = glm::vec3(p[0], p[1], p[2]) + off;
spotDir = glm::normalize(glm::vec3(d[0], d[1], d[2]));
}
fu.flash_pos[0] = spotPos.x;
fu.flash_pos[1] = spotPos.y;
fu.flash_pos[2] = spotPos.z;
fu.flash_pos[3] = std::cos(glm::radians(spot->value("inner_cone", 12.0f)));
fu.flash_dir[0] = spotDir.x;
fu.flash_dir[1] = spotDir.y;
fu.flash_dir[2] = spotDir.z;
fu.flash_dir[3] = std::cos(glm::radians(spot->value("outer_cone", 25.0f)));
auto col = spot->value("color", std::vector<float>{1,1,1});
float spotIntensity = spot->value("intensity", 2.5f);
fu.flash_color[0] = (col.size() > 0 ? col[0] : 1.0f) * spotIntensity;
fu.flash_color[1] = (col.size() > 1 ? col[1] : 1.0f) * spotIntensity;
fu.flash_color[2] = (col.size() > 2 ? col[2] : 1.0f) * spotIntensity;
fu.flash_color[3] = spot->value("range", 20.0f);
}
context.Set<rendering::FragmentUniformData>("render.frag_uniforms", fu);
if (logger_) {

View File

@@ -0,0 +1,72 @@
#include "services/interfaces/workflow/rendering/workflow_spotlight_update_step.hpp"
#include "services/interfaces/workflow/rendering/rendering_types.hpp"
#include <glm/glm.hpp>
#include <glm/gtc/type_ptr.hpp>
#include <nlohmann/json.hpp>
#include <cmath>
namespace sdl3cpp::services::impl {
WorkflowSpotlightUpdateStep::WorkflowSpotlightUpdateStep(std::shared_ptr<ILogger> logger)
: logger_(std::move(logger)) {}
std::string WorkflowSpotlightUpdateStep::GetPluginId() const {
return "spotlight.update";
}
void WorkflowSpotlightUpdateStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) {
const auto* spot = context.TryGet<nlohmann::json>("spotlight.state");
if (!spot) return;
auto fu = context.Get<rendering::FragmentUniformData>("render.frag_uniforms", rendering::FragmentUniformData{});
std::string attach = spot->value("attach", "camera");
auto offset = spot->value("offset", std::vector<float>{0, 0, 0});
glm::vec3 off(offset.size() > 0 ? offset[0] : 0,
offset.size() > 1 ? offset[1] : 0,
offset.size() > 2 ? offset[2] : 0);
glm::vec3 spotPos, spotDir;
if (attach == "camera") {
auto viewMatrix = context.Get<glm::mat4>("render.view_matrix", glm::mat4(1.0f));
auto cameraPos = context.Get<glm::vec3>("render.camera_pos", glm::vec3(0.0f));
glm::vec3 camRight = glm::vec3(viewMatrix[0][0], viewMatrix[1][0], viewMatrix[2][0]);
glm::vec3 camUp = glm::vec3(viewMatrix[0][1], viewMatrix[1][1], viewMatrix[2][1]);
glm::vec3 camFwd = -glm::vec3(viewMatrix[0][2], viewMatrix[1][2], viewMatrix[2][2]);
spotPos = cameraPos + camRight * off.x + camUp * off.y + camFwd * (-off.z);
// Aim toward a far point on camera center axis (natural torch aim)
float aimDist = spot->value("aim_distance", 50.0f);
glm::vec3 aimTarget = cameraPos + camFwd * aimDist;
spotDir = glm::normalize(aimTarget - spotPos);
} else {
auto p = spot->value("position", std::vector<float>{0, 0, 0});
auto d = spot->value("direction", std::vector<float>{0, 0, -1});
spotPos = glm::vec3(p[0], p[1], p[2]) + off;
spotDir = glm::normalize(glm::vec3(d[0], d[1], d[2]));
}
fu.flash_pos[0] = spotPos.x;
fu.flash_pos[1] = spotPos.y;
fu.flash_pos[2] = spotPos.z;
fu.flash_pos[3] = std::cos(glm::radians(spot->value("inner_cone", 12.0f)));
fu.flash_dir[0] = spotDir.x;
fu.flash_dir[1] = spotDir.y;
fu.flash_dir[2] = spotDir.z;
fu.flash_dir[3] = std::cos(glm::radians(spot->value("outer_cone", 25.0f)));
auto col = spot->value("color", std::vector<float>{1, 1, 1});
float intensity = spot->value("intensity", 2.5f);
fu.flash_color[0] = (col.size() > 0 ? col[0] : 1.0f) * intensity;
fu.flash_color[1] = (col.size() > 1 ? col[1] : 1.0f) * intensity;
fu.flash_color[2] = (col.size() > 2 ? col[2] : 1.0f) * intensity;
fu.flash_color[3] = spot->value("range", 20.0f);
context.Set<rendering::FragmentUniformData>("render.frag_uniforms", fu);
}
} // namespace sdl3cpp::services::impl

View File

@@ -37,6 +37,7 @@
#include "services/interfaces/workflow/rendering/workflow_draw_textured_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_lighting_setup_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_spotlight_setup_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_spotlight_update_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_model_load_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_draw_viewmodel_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_geometry_create_flashlight_step.hpp"
@@ -271,6 +272,7 @@ void WorkflowRegistrar::RegisterSteps(std::shared_ptr<IWorkflowStepRegistry> reg
registry->RegisterStep(std::make_shared<WorkflowDrawTexturedBoxStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowLightingSetupStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowSpotlightSetupStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowSpotlightUpdateStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowModelLoadStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowDrawViewmodelStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowGeometryCreateFlashlightStep>(logger_));

View File

@@ -0,0 +1,19 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
namespace sdl3cpp::services::impl {
class WorkflowSpotlightUpdateStep : public IWorkflowStep {
public:
explicit WorkflowSpotlightUpdateStep(std::shared_ptr<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl