Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ add_library(render_renderer STATIC
engine/render/shader_types.hpp
engine/render/shader_library.hpp
engine/render/shader_library.cpp
engine/render/lighting.hpp
engine/render/lighting.cpp
engine/render/texture_types.hpp
engine/render/bgfx/renderer_bgfx.cpp
)
Expand Down Expand Up @@ -212,6 +214,11 @@ if(RENDER_BUILD_TESTS)
render_apply_project_options(render_geometry_submission_tests)
render_apply_warnings(render_geometry_submission_tests)

add_executable(render_lighting_pipeline_tests tests/render/lighting_pipeline_tests.cpp)
target_link_libraries(render_lighting_pipeline_tests PRIVATE render::renderer)
render_apply_project_options(render_lighting_pipeline_tests)
render_apply_warnings(render_lighting_pipeline_tests)

add_executable(render_scene_tests tests/scene/scene_tests.cpp)
target_link_libraries(render_scene_tests PRIVATE render::scene)
render_apply_project_options(render_scene_tests)
Expand All @@ -231,14 +238,16 @@ if(RENDER_BUILD_TESTS)
set_tests_properties(unit.renderer.shader_pipeline PROPERTIES LABELS "unit;renderer;shader")
add_test(NAME unit.renderer.geometry_submission COMMAND render_geometry_submission_tests)
set_tests_properties(unit.renderer.geometry_submission PROPERTIES LABELS "unit;renderer;geometry")
add_test(NAME unit.renderer.lighting_pipeline COMMAND render_lighting_pipeline_tests)
set_tests_properties(unit.renderer.lighting_pipeline PROPERTIES LABELS "unit;renderer;lighting")
add_test(NAME unit.scene.runtime COMMAND render_scene_tests)
set_tests_properties(unit.scene.runtime PROPERTIES LABELS "unit;scene")
add_test(NAME headless.smoke.startup COMMAND render_headless_validation)
set_tests_properties(headless.smoke.startup PROPERTIES LABELS "headless;smoke")

add_custom_target(render_test_unit
COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure --label-regex unit
DEPENDS render_platform_types_tests render_core_runtime_tests render_serialization_tests render_filesystem_tests render_renderer_lifecycle_tests render_shader_pipeline_tests render_geometry_submission_tests render_scene_tests
DEPENDS render_platform_types_tests render_core_runtime_tests render_serialization_tests render_filesystem_tests render_renderer_lifecycle_tests render_shader_pipeline_tests render_geometry_submission_tests render_lighting_pipeline_tests render_scene_tests
WORKING_DIRECTORY "${CMAKE_BINARY_DIR}"
COMMENT "Running render unit tests"
)
Expand Down
47 changes: 46 additions & 1 deletion docs/rendering.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
# Rendering + Shader Pipeline (Statements 9-10)
# Rendering + Shader Pipeline (Statements 9-13)

## Scope

- Statement 9: renderer lifecycle hardening (startup/frame/resize/recovery).
- Statement 10: canonical shader source/build/runtime pipeline with variants, metadata, staging, and hot reload.
- Statement 11: scene graph integration (camera/light/renderable extraction).
- Statement 12: geometry submission + batching foundation.
- Statement 13: forward-plus style lighting data path (light selection, fog/bloom/shadow/outline settings, and diagnostics).

## Engine-owned shader architecture

Expand Down Expand Up @@ -156,3 +159,45 @@ These diagnostics are intended for profiling/debug overlays and frame-level inst
- Dynamic GPU buffer update path currently uses a placeholder API contract and is staged for a follow-up statement.
- Full material parameter/uniform binding and texture set binding beyond fixed slots is deferred.
- GPU-driven culling/indirect draw remains deferred by design.

## Forward-plus style lighting path (Statement 13)

Statement 13 introduces the first engine-owned lighting frame builder under `engine/render/lighting.*`.

### What \"forward-plus style\" means in this repo right now

This is a **forward renderer with explicit light selection**, not a full clustered/tiled implementation yet.

Current strategy:

1. Collect scene lights per-view (`Scene::collect_visible_lights`).
2. Rank point lights by camera-relative importance (intensity + range-weighted distance).
3. Keep only a capped point-light set (`LightingSelectionConfig::max_point_lights`, hard-clamped by `kMaxPointLights`).
4. Build per-object point-light lists capped by `max_lights_per_object`.
5. Upload deterministic packed arrays (`DirectionalLightGpu`, `PointLightGpu`, `ObjectLightList`) to the rendering path.

This avoids unbounded \"every object loops every light\" behavior while preserving a clean extension path toward tiled/clustered lists later.

### Implemented engine-facing controls

- Directional lights (color/intensity/direction, shadow flag).
- Point lights (position/color/intensity/range, shadow flag placeholder).
- Fog settings (`FogSettings`) with linear/exp/exp2 modes and validation.
- Bloom settings (`BloomSettings`) with threshold/intensity/downsample controls and validation.
- Directional shadow settings (`ShadowSettings`) with map size + bias/filter defaults and validation.
- Outline settings (`OutlineSettings`) for stylized highlight composition controls.
- Diagnostics (`LightingDiagnostics`) for selected/culled counts and highlight/shadow stats.

### Current integration state

- Scene components now include `RenderableComponent` emissive/highlight fields and `LightComponent::casts_shadows`.
- Scene-level lighting knobs are stored in `SceneLightingSettings` (`fog` + `bloom`) and consumed by the shell.
- `render_shell` now builds a stylized validation setup with directional + multiple point lights, emissive/highlighted renderables, and lighting frame extraction each frame.

### Deferred to follow-up statements

- GPU-side uniform/buffer binding of packed lighting data to lit shaders.
- Dedicated shadow-map render passes and shader sampling.
- Bloom extraction/blur/composite and outline composition passes.
- Full lit material shader programs (current shell still renders via debug triangle program).
- Debug view rendering modes (normals, emissive-only, bloom extraction, shadow atlas visualization).
12 changes: 12 additions & 0 deletions engine/render/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,15 @@ New engine-owned geometry submission files:
`Renderer` now exposes mesh/index/instance buffer creation + update hooks and both unique and instanced submission entry points.

bgfx handles remain internal to backend implementation (`engine/render/bgfx/renderer_bgfx.cpp`).

## Lighting frame builder (Statement 13)

`lighting.hpp/.cpp` defines the engine-owned forward-plus style lighting planning layer.

- Input: visible directional lights, point lights, and renderable bounds/flags.
- Planning: capped light ranking and per-object light list generation.
- Output: deterministic packed CPU structures suitable for GPU upload in future passes.
- Controls: fog, bloom, shadows, and outlines with validation helpers.
- Diagnostics: selected/culled light counts and highlighted/shadowed object metrics.

This module intentionally keeps bgfx API details out of gameplay-facing code while preparing a scalable path to tiled/clustered improvements later.
208 changes: 208 additions & 0 deletions engine/render/lighting.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
#include "engine/render/lighting.hpp"

#include "engine/core/math.hpp"

#include <algorithm>
#include <cmath>
#include <limits>
#include <utility>
#include <vector>

namespace render::rendering {
namespace {

struct RankedPointLight {
std::uint32_t source_index{0};
float score{0.0F};
};

float saturate(const float value) {
return core::clamp(value, 0.0F, 1.0F);
}

float point_light_importance(const PointLightInput& light, const core::Vec3 camera_position) {
const core::Vec3 to_light = light.position - camera_position;
const float distance = core::length(to_light);
const float normalized = distance / std::max(light.range, core::kEpsilon);
const float attenuation = 1.0F / (1.0F + normalized * normalized);
return std::max(0.0F, light.intensity) * attenuation;
}

float light_to_object_score(const PointLightGpu& light, const RenderableLightingInput& renderable) {
const core::Vec3 light_position{light.position_range[0], light.position_range[1], light.position_range[2]};
const core::Vec3 to_object = renderable.position - light_position;
const float distance = core::length(to_object);
const float effective_range = light.position_range[3] + std::max(0.0F, renderable.bounding_radius);
if (distance > effective_range) {
return 0.0F;
}

const float normalized = distance / std::max(effective_range, core::kEpsilon);
const float falloff = 1.0F - saturate(normalized * normalized);
return light.color_intensity[3] * falloff;
}

DirectionalLightGpu pack_directional(const DirectionalLightInput& light) {
const core::Vec3 normalized = core::normalize(light.direction);
return DirectionalLightGpu{
.direction_intensity = {normalized.x, normalized.y, normalized.z, std::max(0.0F, light.intensity)},
.color_shadow = {
std::max(0.0F, light.color.x),
std::max(0.0F, light.color.y),
std::max(0.0F, light.color.z),
light.casts_shadows ? 1.0F : 0.0F,
},
};
}

PointLightGpu pack_point(const PointLightInput& light) {
const float clamped_range = std::max(light.range, 0.01F);
const float inv_range2 = 1.0F / (clamped_range * clamped_range);
return PointLightGpu{
.position_range = {light.position.x, light.position.y, light.position.z, clamped_range},
.color_intensity = {
std::max(0.0F, light.color.x),
std::max(0.0F, light.color.y),
std::max(0.0F, light.color.z),
std::max(0.0F, light.intensity),
},
.metadata = {inv_range2, light.casts_shadows ? 1.0F : 0.0F, 0.0F, 0.0F},
};
}

} // namespace

bool validate_fog_settings(const FogSettings& settings) {
if (!settings.enabled) {
return true;
}

if (settings.mode == FogMode::Linear) {
return settings.far_distance > settings.near_distance;
}

return settings.density >= 0.0F;
}

bool validate_bloom_settings(const BloomSettings& settings) {
if (!settings.enabled) {
return true;
}

return settings.threshold >= 0.0F && settings.intensity >= 0.0F && settings.downsample_steps > 0;
}

bool validate_shadow_settings(const ShadowSettings& settings) {
if (!settings.enabled) {
return true;
}

return settings.map_size >= 256U && settings.depth_bias >= 0.0F && settings.normal_bias >= 0.0F && settings.filter_radius >= 0.0F;
}

LightingFrameData build_forward_plus_frame_data(
const core::Vec3& camera_position,
const std::span<const DirectionalLightInput> directional_lights,
const std::span<const PointLightInput> point_lights,
const std::span<const RenderableLightingInput> renderables,
const LightingSelectionConfig& config,
FogSettings fog,
BloomSettings bloom,
ShadowSettings shadows,
OutlineSettings outlines) {
LightingFrameData result{};
result.fog = fog;
result.bloom = bloom;
result.shadows = shadows;
result.outlines = outlines;
result.diagnostics.input_directional_lights = static_cast<std::uint32_t>(directional_lights.size());
result.diagnostics.input_point_lights = static_cast<std::uint32_t>(point_lights.size());

const std::uint32_t directional_cap = std::min(config.max_directional_lights, kMaxDirectionalLights);
for (std::uint32_t i = 0; i < directional_cap && i < directional_lights.size(); ++i) {
const DirectionalLightInput& source = directional_lights[i];
if (source.intensity <= 0.0F) {
continue;
}
result.directional_lights.push_back(pack_directional(source));
if (source.casts_shadows) {
result.diagnostics.shadowed_directional_lights += 1;
}
}

std::vector<RankedPointLight> ranked;
ranked.reserve(point_lights.size());
for (std::uint32_t i = 0; i < point_lights.size(); ++i) {
const PointLightInput& source = point_lights[i];
if (source.intensity <= 0.0F || source.range <= 0.0F) {
continue;
}
ranked.push_back({.source_index = i, .score = point_light_importance(source, camera_position)});
}
std::sort(ranked.begin(), ranked.end(), [](const RankedPointLight& a, const RankedPointLight& b) {
if (a.score == b.score) {
return a.source_index < b.source_index;
}
return a.score > b.score;
});

const std::uint32_t point_cap = std::min(config.max_point_lights, kMaxPointLights);
for (std::uint32_t i = 0; i < point_cap && i < ranked.size(); ++i) {
result.point_lights.push_back(pack_point(point_lights[ranked[i].source_index]));
}

result.per_object_lights.reserve(renderables.size());
for (const RenderableLightingInput& renderable : renderables) {
ObjectLightList object_list{};
object_list.object_id = renderable.object_id;
object_list.point_light_indices.fill(kInvalidLightIndex);

std::vector<std::pair<std::uint32_t, float>> candidates;
candidates.reserve(result.point_lights.size());
for (std::uint32_t i = 0; i < result.point_lights.size(); ++i) {
const float score = light_to_object_score(result.point_lights[i], renderable);
if (score > 0.0F) {
candidates.push_back({i, score});
}
}
std::sort(candidates.begin(), candidates.end(), [](const auto& a, const auto& b) {
if (a.second == b.second) {
return a.first < b.first;
}
return a.second > b.second;
});

const std::uint32_t per_object_cap = std::min(config.max_lights_per_object, kMaxLightsPerObject);
const std::uint32_t selected_count = std::min(per_object_cap, static_cast<std::uint32_t>(candidates.size()));
object_list.point_light_count = selected_count;
for (std::uint32_t i = 0; i < selected_count; ++i) {
object_list.point_light_indices[i] = candidates[i].first;
}

if (renderable.highlighted) {
result.diagnostics.highlighted_objects += 1;
}
result.per_object_lights.push_back(object_list);
}

result.diagnostics.selected_directional_lights = static_cast<std::uint32_t>(result.directional_lights.size());
result.diagnostics.selected_point_lights = static_cast<std::uint32_t>(result.point_lights.size());
result.diagnostics.culled_point_lights =
result.diagnostics.input_point_lights > result.diagnostics.selected_point_lights
? (result.diagnostics.input_point_lights - result.diagnostics.selected_point_lights)
: 0;

if (!validate_fog_settings(result.fog)) {
result.fog.enabled = false;
}
if (!validate_bloom_settings(result.bloom)) {
result.bloom.enabled = false;
}
if (!validate_shadow_settings(result.shadows)) {
result.shadows.enabled = false;
}

return result;
}

} // namespace render::rendering
Loading
Loading