diff --git a/CMakeLists.txt b/CMakeLists.txt index 233d03d..0364b04 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -81,6 +81,7 @@ set(APP_SOURCES source/core/input/mouse.cpp source/core/rendering/screen.cpp source/core/rendering/canvas.cpp + source/core/rendering/lighting.cpp source/core/rendering/notifier.cpp source/core/rendering/render_info.cpp source/core/rendering/gif.cpp diff --git a/data/config/lighting.yaml b/data/config/lighting.yaml new file mode 100644 index 0000000..68c8a2e --- /dev/null +++ b/data/config/lighting.yaml @@ -0,0 +1,22 @@ +# Configuració global del sistema d'il·luminació. +# +# Cada llum emissora (player, torxa, mòmia, ...) té un únic paràmetre +# propi: el radi en píxels. La forma de la llum dins d'aquest radi +# (els tres anells del gradient) viu en aquest fitxer perquè és una +# decisió de "feel" comuna a totes les entitats — així pots tocar la +# corba aquí sense haver de modificar 10 YAMLs. +# +# Forma del gradient (centre → vora): +# 0..inner_ratio del radi → inner_intensity (zona totalment il·luminada) +# inner_ratio..mid_ratio → mid_intensity (anell mig) +# mid_ratio..1.0 → outer_intensity (anell exterior) +# fora del radi → light_level de la sala +# +# Les intensitats són factors 0..1 que es multipliquen sobre el píxel +# final del canvas. inner=1.0 vol dir "píxel sense alterar". +gradient: + inner_ratio: 0.8 + mid_ratio: 0.9 + inner_intensity: 1.0 + mid_intensity: 0.9 + outer_intensity: 0.8 diff --git a/source/core/rendering/lighting.cpp b/source/core/rendering/lighting.cpp new file mode 100644 index 0000000..ed9076a --- /dev/null +++ b/source/core/rendering/lighting.cpp @@ -0,0 +1,187 @@ +#include "core/rendering/lighting.hpp" + +#include +#include +#include +#include + +#include "core/resources/resource_helper.hpp" +#include "external/fkyaml_node.hpp" +#include "utils/defaults.hpp" +#include "utils/log.hpp" + +namespace Lighting { + + namespace { + + // Buffer de luminància compartit entre frames. Es redimensiona + // només si canvia la mida del canvas (mai en un run normal: el + // canvas del joc és 320×240 fix). El reusem perquè una `apply` + // allotjant cada frame seria un cost evitable. + auto luminanceBuffer() -> std::vector& { + static std::vector buf_; + return buf_; + } + + // Configuració global, inicialitzada amb els defaults compilats. + // loadConfig() la reemplaça si troba el fitxer. + auto activeConfig() -> GradientConfig& { + static GradientConfig cfg_{ + .inner_ratio = Defaults::Lighting::INNER_RATIO, + .mid_ratio = Defaults::Lighting::MID_RATIO, + .inner_intensity = Defaults::Lighting::INNER_INTENSITY, + .mid_intensity = Defaults::Lighting::MID_INTENSITY, + .outer_intensity = Defaults::Lighting::OUTER_INTENSITY, + }; + return cfg_; + } + + // Quantitza un factor 0..1 a 0..255. `clamp` per defensar-nos de + // YAMLs que entreguen valors fora de rang sense forçar la mà. + auto toByte(float v) -> std::uint8_t { + const float CLAMPED = std::clamp(v, 0.0F, 1.0F); + return static_cast(std::lround(CLAMPED * 255.0F)); + } + + void readField(const fkyaml::node& node, const char* key, float& dst) { + if (node.contains(key)) { dst = node[key].get_value(); } + } + + // Pre-calcula els 3 quadrats de radi i les intensitats que farem + // servir dins del bucle per píxel. Es resol una sola vegada per + // emissor i s'evita arrastrar 6 paràmetres a `rasterizeEmitter`. + struct EmitterRings { + int radius_sq{0}; + int mid_sq{0}; + int inner_sq{0}; + std::uint8_t inner{0}; + std::uint8_t mid{0}; + std::uint8_t outer{0}; + }; + + auto computeRings(int radius, const GradientConfig& cfg) -> EmitterRings { + const auto R_F = static_cast(radius); + const float R_INNER = R_F * cfg.inner_ratio; + const float R_MID = R_F * cfg.mid_ratio; + return EmitterRings{ + .radius_sq = radius * radius, + .mid_sq = static_cast(R_MID * R_MID), + .inner_sq = static_cast(R_INNER * R_INNER), + .inner = toByte(cfg.inner_intensity), + .mid = toByte(cfg.mid_intensity), + .outer = toByte(cfg.outer_intensity), + }; + } + + // Itera la bounding box de l'emissor clipada al canvas i actualitza + // la fila de luminància corresponent amb `max(actual, ring)`. Es + // crida un cop per emissor, així extreure-la deixa `apply` simple. + void rasterizeEmitter(const Emitter& e, const EmitterRings& rings, std::uint8_t* lum, int width, int height) { + const int R = e.radius; + const int CX = static_cast(e.cx); + const int CY = static_cast(e.cy); + const int X0 = std::max(0, CX - R); + const int Y0 = std::max(0, CY - R); + const int X1 = std::min(width - 1, CX + R); + const int Y1 = std::min(height - 1, CY + R); + for (int y = Y0; y <= Y1; ++y) { + const int DY = y - CY; + const int DY_SQ = DY * DY; + if (DY_SQ > rings.radius_sq) { continue; } + std::uint8_t* row = lum + (static_cast(y) * width); + for (int x = X0; x <= X1; ++x) { + const int DX = x - CX; + const int DIST_SQ = (DX * DX) + DY_SQ; + if (DIST_SQ > rings.radius_sq) { continue; } + std::uint8_t intensity = rings.outer; + if (DIST_SQ <= rings.inner_sq) { + intensity = rings.inner; + } else if (DIST_SQ <= rings.mid_sq) { + intensity = rings.mid; + } + row[x] = std::max(intensity, row[x]); + } + } + } + + // Pasada final: pixel = pixel * lum / 255 per canal. El canal alpha + // es preserva (la resta del pipeline assumeix opacitat total al + // canvas; veure Rendering::OPAQUE_ALPHA). + void modulateCanvas(std::uint32_t* canvas, const std::uint8_t* lum, int width, int height) { + for (int y = 0; y < height; ++y) { + std::uint32_t* px = canvas + (static_cast(y) * width); + const std::uint8_t* lrow = lum + (static_cast(y) * width); + for (int x = 0; x < width; ++x) { + const std::uint8_t L = lrow[x]; + if (L == 255) { continue; } // píxel intacte. + const std::uint32_t SRC = px[x]; + const std::uint8_t SR = (SRC >> 16) & 0xFF; + const std::uint8_t SG = (SRC >> 8) & 0xFF; + const std::uint8_t SB = SRC & 0xFF; + const auto R = static_cast((SR * L) / 255); + const auto G = static_cast((SG * L) / 255); + const auto B = static_cast((SB * L) / 255); + px[x] = (SRC & 0xFF000000U) | + (static_cast(R) << 16) | + (static_cast(G) << 8) | + static_cast(B); + } + } + } + + } // namespace + + auto config() -> const GradientConfig& { + return activeConfig(); + } + + void loadConfig(const std::string& yaml_path) { + auto bytes = Resource::Helper::loadFile(yaml_path); + if (bytes.empty()) { + Log::warn("Lighting", "no s'ha pogut obrir {} — s'usaran els defaults compilats", yaml_path); + return; + } + try { + const auto* begin = reinterpret_cast(bytes.data()); + const auto* end = begin + bytes.size(); + auto yaml = fkyaml::node::deserialize(begin, end); + if (yaml.contains("gradient")) { + const auto& g = yaml["gradient"]; + auto& cfg = activeConfig(); + readField(g, "inner_ratio", cfg.inner_ratio); + readField(g, "mid_ratio", cfg.mid_ratio); + readField(g, "inner_intensity", cfg.inner_intensity); + readField(g, "mid_intensity", cfg.mid_intensity); + readField(g, "outer_intensity", cfg.outer_intensity); + } + } catch (const std::exception& ex) { + Log::warn("Lighting", "error parsejant {}: {} — s'usaran els defaults compilats", yaml_path, ex.what()); + } + } + + void apply(std::uint32_t* canvas, int width, int height, float room_light, std::span emitters) { + if (canvas == nullptr || width <= 0 || height <= 0) { return; } + + // Atajo: si la sala està totalment il·luminada i no hi ha + // emissors, la pasada seria un `pixel * 1.0` global — no cal + // tocar res. És el camí curt habitual mentre no posem + // `light_level` a les sales. + const std::uint8_t ROOM = toByte(room_light); + if (ROOM == 255 && emitters.empty()) { return; } + + auto& lum = luminanceBuffer(); + const std::size_t TOTAL = static_cast(width) * static_cast(height); + if (lum.size() != TOTAL) { lum.resize(TOTAL); } + std::ranges::fill(lum, ROOM); + + const auto& cfg = activeConfig(); + for (const auto& e : emitters) { + if (e.radius <= 0) { continue; } + const auto RINGS = computeRings(e.radius, cfg); + rasterizeEmitter(e, RINGS, lum.data(), width, height); + } + + modulateCanvas(canvas, lum.data(), width, height); + } + +} // namespace Lighting diff --git a/source/core/rendering/lighting.hpp b/source/core/rendering/lighting.hpp new file mode 100644 index 0000000..54781d5 --- /dev/null +++ b/source/core/rendering/lighting.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include + +// Sistema d'il·luminació CPU. Treballa sobre el mateix canvas ARGB del +// joc (320×240) que tot el pipeline de render: després que GameScene +// haja pintat tilemap, paint layer, sombres i actors, una pasada final +// multiplica cada píxel del canvas per un factor de luminància 0..1 +// derivat de la combinació de "llum de la sala" + cercles emissors al +// voltant de certes entitats. +// +// Forma de cada emissor (radial, 3 anells configurables al fitxer +// data/config/lighting.yaml; veure `GradientConfig`): +// 0..inner_ratio del radi → inner_intensity +// inner_ratio..mid_ratio → mid_intensity +// mid_ratio..1.0 → outer_intensity +// fora del radi → light_level de la sala +// +// Combinació entre emissors: per a cada píxel es pren el màxim del +// llum de sala i de la intensitat aportada per cada emissor que el +// solapa — no se "sumen" intensitats. Coherent amb la idea de "centre +// 1.0": el píxel que cau dins de qualsevol nucli no s'enfosqueix. +namespace Lighting { + + // Posició al canvas (en píxels, mateixa unitat que la resta del + // motor) i radi efectiu de l'emissor. El centre és el del sprite + // visible: és l'entitat qui sap on cau (veure `Entity::getLightCenter`). + struct Emitter { + float cx{0.0F}; + float cy{0.0F}; + int radius{0}; + }; + + // Forma del gradient. Compartida per tots els emissors per evitar + // duplicar la corba a cada YAML d'entitat. Càrrega via loadConfig. + struct GradientConfig { + float inner_ratio{0.8F}; + float mid_ratio{0.9F}; + float inner_intensity{1.0F}; + float mid_intensity{0.9F}; + float outer_intensity{0.8F}; + }; + + // Llegeix `yaml_path` (data/config/lighting.yaml) i pobla la + // configuració global. Qualsevol clau absent cau als Defaults::Lighting. + // No es crida més d'una vegada en un run normal; tornar-la a cridar + // simplement reemplaça la configuració activa. + void loadConfig(const std::string& yaml_path); + + // Configuració activa (per a debug/inspectors). Inicialitzada amb els + // Defaults::Lighting fins que loadConfig() es crida. + [[nodiscard]] auto config() -> const GradientConfig&; + + // Aplica la pasada d'il·luminació al canvas in-place. `room_light` + // és l'atenuació base de la sala (0..1, 1 = sense atenuació). El + // span d'emissors pot ser buit — en aquest cas tota la sala queda + // multiplicada per `room_light`. Aquesta funció no fa cap allocació + // si es crida amb la mateixa mida de canvas múltiples cops: manté + // un buffer intern reutilitzable. + void apply(std::uint32_t* canvas, int width, int height, float room_light, std::span emitters); + +} // namespace Lighting diff --git a/source/core/system/director.cpp b/source/core/system/director.cpp index bb42584..86655cd 100644 --- a/source/core/system/director.cpp +++ b/source/core/system/director.cpp @@ -19,6 +19,7 @@ #include "core/input/mouse.hpp" #include "core/locale/locale.hpp" #include "core/rendering/effects/shadow.hpp" +#include "core/rendering/lighting.hpp" #include "core/rendering/notifier.hpp" #include "core/rendering/render_info.hpp" #include "core/rendering/screen.hpp" @@ -131,6 +132,10 @@ Director::Director() { // Tema d'UI (colors, timings, fonts, flags). Qualsevol clau absent a // data/config/ui.yaml cau al valor compilat a Defaults::UI. UiConfig::get().load(Defaults::Paths::UI_YAML); + // Forma del gradient d'il·luminació (3 anells radials). El radi + // per emissor i el `light_level` per sala viuen als YAMLs propis; + // ací només la corba compartida per a tots els emissors. + Lighting::loadConfig(Defaults::Paths::LIGHTING_YAML); // Configuració específica de les escenes TITLE i LOGO. Es carreguen // additivament sobre el mateix UiConfig: cada fitxer només conté la // seua subclau (`title:` o `logo:`), i les claus absents a la base diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index a81fee9..9eb0a70 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -106,6 +106,21 @@ namespace Defaults { constexpr float PUSH_FACTOR = 0.30F; } // namespace Player + // Fallbacks del sistema d'il·luminació. El gradient en viu viu a + // data/config/lighting.yaml; aquests valors només s'apliquen si el + // fitxer falta o li manca alguna clau. La forma final (3 anells) es + // descriu al YAML; veure `Lighting::GradientConfig` per a la semàntica. + namespace Lighting { + constexpr float INNER_RATIO = 0.8F; + constexpr float MID_RATIO = 0.9F; + constexpr float INNER_INTENSITY = 1.0F; + constexpr float MID_INTENSITY = 0.9F; + constexpr float OUTER_INTENSITY = 0.8F; + // Nivell d'il·luminació de sala per defecte. Una sala sense la + // clau `light_level` queda totalment il·luminada (com hui). + constexpr float ROOM_LIGHT_DEFAULT = 1.0F; + } // namespace Lighting + // Fallbacks del tema d'UI: índexs de paleta, timings (ms) i fonts. // El tema en viu és a data/config/ui.yaml, carregat per UiConfig a // l'arrencada; aquests valors només s'apliquen si el fitxer falta o @@ -498,6 +513,7 @@ namespace Defaults { constexpr const char* SOUNDS_YAML = "data/config/sounds.yaml"; constexpr const char* INPUT_YAML = "data/config/input.yaml"; constexpr const char* EDITOR_YAML = "data/config/editor.yaml"; + constexpr const char* LIGHTING_YAML = "data/config/lighting.yaml"; constexpr const char* UI_PALETTE = "data/palettes/uzebox.pal"; // Configuració d'escenes (yaml). El de la GameScene viu a