364 lines
18 KiB
C++
364 lines
18 KiB
C++
#include "core/rendering/lighting.hpp"
|
||
|
||
#include <SDL3/SDL.h>
|
||
|
||
#include <algorithm>
|
||
#include <cmath>
|
||
#include <cstddef>
|
||
#include <numbers>
|
||
#include <vector>
|
||
|
||
#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<std::uint8_t>& {
|
||
static std::vector<std::uint8_t> buf_;
|
||
return buf_;
|
||
}
|
||
|
||
// Corba activa del mode subtractive. loadConfig() la reemplaça
|
||
// si troba el bloc `gradient:` al YAML.
|
||
auto activeSubtractiveConfig() -> 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_;
|
||
}
|
||
|
||
// Corba activa del mode additive. Defaults baixos a propòsit:
|
||
// en additive la intensitat és l'alfa del color afegit, així
|
||
// que valors petits (0.05..0.15) donen el "halo subtil" que
|
||
// sol voler-se. loadConfig() la reemplaça si troba el bloc
|
||
// `gradient_additive:` al YAML.
|
||
auto activeAdditiveConfig() -> GradientConfig& {
|
||
static GradientConfig cfg_{
|
||
.inner_ratio = Defaults::Lighting::INNER_RATIO_ADDITIVE,
|
||
.mid_ratio = Defaults::Lighting::MID_RATIO_ADDITIVE,
|
||
.inner_intensity = Defaults::Lighting::INNER_INTENSITY_ADDITIVE,
|
||
.mid_intensity = Defaults::Lighting::MID_INTENSITY_ADDITIVE,
|
||
.outer_intensity = Defaults::Lighting::OUTER_INTENSITY_ADDITIVE,
|
||
};
|
||
return cfg_;
|
||
}
|
||
|
||
auto activeFlicker() -> FlickerConfig& {
|
||
static FlickerConfig fc_{
|
||
.wave_amplitude_px = Defaults::Lighting::FLICKER_WAVE_AMPLITUDE_PX,
|
||
.wave_frequency_hz = Defaults::Lighting::FLICKER_WAVE_FREQUENCY_HZ,
|
||
.noise_amplitude_px = Defaults::Lighting::FLICKER_NOISE_AMPLITUDE_PX,
|
||
.noise_frequency_hz = Defaults::Lighting::FLICKER_NOISE_FREQUENCY_HZ,
|
||
};
|
||
return fc_;
|
||
}
|
||
|
||
// Modula el radi base d'un emissor amb la suma de dues sinusoides
|
||
// (`wave` + `noise`) i una fase derivada de `seed`. La seed és un
|
||
// identificador estable de l'emissor (no canvia amb la posició) —
|
||
// així el patró no "deriva" quan l'emissor es mou. El factor
|
||
// ~φ (golden ratio) dispersa les fases per a seeds consecutives.
|
||
auto computeFlickerRadius(int base_radius, int seed, float t_s, const FlickerConfig& fc) -> int {
|
||
constexpr float TWO_PI = 2.0F * std::numbers::pi_v<float>;
|
||
constexpr float GOLDEN_FRACTION = 0.6180339F;
|
||
const float PHASE = static_cast<float>(seed) * GOLDEN_FRACTION;
|
||
const float W1 = std::sin((TWO_PI * fc.wave_frequency_hz * t_s) + PHASE);
|
||
const float W2 = std::sin((TWO_PI * fc.noise_frequency_hz * t_s) + (PHASE * 2.7F));
|
||
const float DELTA = (fc.wave_amplitude_px * W1) + (fc.noise_amplitude_px * W2);
|
||
const int R = base_radius + static_cast<int>(std::lround(DELTA));
|
||
return std::max(1, R);
|
||
}
|
||
|
||
// 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::uint8_t>(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<float>(); }
|
||
}
|
||
|
||
// 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<float>(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<int>(R_MID * R_MID),
|
||
.inner_sq = static_cast<int>(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<int>(e.cx);
|
||
const int CY = static_cast<int>(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<std::ptrdiff_t>(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 del mode subtractive: 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<std::ptrdiff_t>(y) * width);
|
||
const std::uint8_t* lrow = lum + (static_cast<std::ptrdiff_t>(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<std::uint8_t>((SR * L) / 255);
|
||
const auto G = static_cast<std::uint8_t>((SG * L) / 255);
|
||
const auto B = static_cast<std::uint8_t>((SB * L) / 255);
|
||
px[x] = (SRC & 0xFF000000U) |
|
||
(static_cast<std::uint32_t>(R) << 16) |
|
||
(static_cast<std::uint32_t>(G) << 8) |
|
||
static_cast<std::uint32_t>(B);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Atenuació ambient del mode additive: aplica `pixel × level` per
|
||
// canvas abans del bucle d'emissors. Si `level == 255` (sala a
|
||
// 1.0) salta com a no-op — el cas comú quan només volem el tint
|
||
// dels emissors sense oscurir la sala.
|
||
void darkenCanvas(std::uint32_t* canvas, int width, int height, std::uint8_t level) {
|
||
if (level == 255) { return; }
|
||
for (int y = 0; y < height; ++y) {
|
||
std::uint32_t* px = canvas + (static_cast<std::ptrdiff_t>(y) * width);
|
||
for (int x = 0; x < width; ++x) {
|
||
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<std::uint8_t>((SR * level) / 255);
|
||
const auto G = static_cast<std::uint8_t>((SG * level) / 255);
|
||
const auto B = static_cast<std::uint8_t>((SB * level) / 255);
|
||
px[x] = (SRC & 0xFF000000U) |
|
||
(static_cast<std::uint32_t>(R) << 16) |
|
||
(static_cast<std::uint32_t>(G) << 8) |
|
||
static_cast<std::uint32_t>(B);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pasada del mode additive per a un emissor: suma `color × intensity`
|
||
// a cada píxel cobert pel cercle, saturant a 255 per canal. A
|
||
// diferència del subtractive, NO usa buffer intermig (no cal max())
|
||
// perquè la suma és commutativa; emissors solapats acumulen
|
||
// contribucions de manera natural. Si dues llums de colors diferents
|
||
// es solapen, el resultat tendeix al blanc en la zona de coincidència
|
||
// (suma física): el solape s'aclareix més que un sol emissor.
|
||
void rasterizeEmitterAdditive(const Emitter& e, const EmitterRings& rings, std::uint32_t* canvas, int width, int height) {
|
||
const int R = e.radius;
|
||
const int CX = static_cast<int>(e.cx);
|
||
const int CY = static_cast<int>(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::uint32_t* row = canvas + (static_cast<std::ptrdiff_t>(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;
|
||
}
|
||
// contrib_channel = emitter_color_channel × intensity / 255
|
||
// (intensity ja és 0..255 quantitzat a `toByte`).
|
||
const int CR = (e.color.r * intensity) / 255;
|
||
const int CG = (e.color.g * intensity) / 255;
|
||
const int CB = (e.color.b * intensity) / 255;
|
||
const std::uint32_t SRC = row[x];
|
||
const int SR = static_cast<int>((SRC >> 16) & 0xFF);
|
||
const int SG = static_cast<int>((SRC >> 8) & 0xFF);
|
||
const int SB = static_cast<int>(SRC & 0xFF);
|
||
const auto NR = static_cast<std::uint32_t>(std::min(255, SR + CR));
|
||
const auto NG = static_cast<std::uint32_t>(std::min(255, SG + CG));
|
||
const auto NB = static_cast<std::uint32_t>(std::min(255, SB + CB));
|
||
row[x] = (SRC & 0xFF000000U) | (NR << 16) | (NG << 8) | NB;
|
||
}
|
||
}
|
||
}
|
||
|
||
} // namespace
|
||
|
||
auto config() -> const GradientConfig& {
|
||
return activeSubtractiveConfig();
|
||
}
|
||
|
||
namespace {
|
||
// Llegeix els 5 camps de `GradientConfig` d'un node YAML — qualsevol
|
||
// camp absent conserva el valor compilat dels defaults. Compartit
|
||
// entre els blocs `gradient:` i `gradient_additive:`.
|
||
void readGradient(const fkyaml::node& g, GradientConfig& cfg) {
|
||
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);
|
||
}
|
||
} // namespace
|
||
|
||
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<const char*>(bytes.data());
|
||
const auto* end = begin + bytes.size();
|
||
auto yaml = fkyaml::node::deserialize(begin, end);
|
||
if (yaml.contains("gradient")) {
|
||
readGradient(yaml["gradient"], activeSubtractiveConfig());
|
||
}
|
||
if (yaml.contains("gradient_additive")) {
|
||
readGradient(yaml["gradient_additive"], activeAdditiveConfig());
|
||
}
|
||
if (yaml.contains("flicker")) {
|
||
const auto& f = yaml["flicker"];
|
||
auto& fc = activeFlicker();
|
||
readField(f, "wave_amplitude_px", fc.wave_amplitude_px);
|
||
readField(f, "wave_frequency_hz", fc.wave_frequency_hz);
|
||
readField(f, "noise_amplitude_px", fc.noise_amplitude_px);
|
||
readField(f, "noise_frequency_hz", fc.noise_frequency_hz);
|
||
}
|
||
} catch (const std::exception& ex) {
|
||
Log::warn("Lighting", "error parsejant {}: {} — s'usaran els defaults compilats", yaml_path, ex.what());
|
||
}
|
||
}
|
||
|
||
namespace {
|
||
|
||
// Helper: emissor "patxat" amb el radi efectiu (post-flicker) per
|
||
// a aquest frame. Mantenim l'`Emitter` del caller intacte i la
|
||
// resta de camps (color, seed, ...) propagats.
|
||
auto withEffectiveRadius(const Emitter& e, int r_eff) -> Emitter {
|
||
return Emitter{.cx = e.cx, .cy = e.cy, .radius = r_eff, .seed = e.seed, .flicker = e.flicker, .color = e.color};
|
||
}
|
||
|
||
// Camí del mode subtractive (històric): omple un buffer de
|
||
// luminància amb `max(room, emitter_intensity)` i finalment
|
||
// multiplica el canvas per ell. Color de l'emissor ignorat.
|
||
void applySubtractive(std::uint32_t* canvas, int width, int height, std::uint8_t room, std::span<const Emitter> emitters) {
|
||
auto& lum = luminanceBuffer();
|
||
const std::size_t TOTAL = static_cast<std::size_t>(width) * static_cast<std::size_t>(height);
|
||
if (lum.size() != TOTAL) { lum.resize(TOTAL); }
|
||
std::ranges::fill(lum, room);
|
||
|
||
const auto& cfg = activeSubtractiveConfig();
|
||
const auto& fc = activeFlicker();
|
||
const float T_S = static_cast<float>(SDL_GetTicks()) / 1000.0F;
|
||
for (const auto& e : emitters) {
|
||
if (e.radius <= 0) { continue; }
|
||
const int R_EFF = e.flicker ? computeFlickerRadius(e.radius, e.seed, T_S, fc) : e.radius;
|
||
const auto RINGS = computeRings(R_EFF, cfg);
|
||
const Emitter EFF = withEffectiveRadius(e, R_EFF);
|
||
rasterizeEmitter(EFF, RINGS, lum.data(), width, height);
|
||
}
|
||
modulateCanvas(canvas, lum.data(), width, height);
|
||
}
|
||
|
||
// Camí del mode additive: atenua el canvas per `room` (si <255)
|
||
// i després suma `color × intensity` per emissor directament al
|
||
// canvas, sense buffer intermig.
|
||
void applyAdditive(std::uint32_t* canvas, int width, int height, std::uint8_t room, std::span<const Emitter> emitters) {
|
||
darkenCanvas(canvas, width, height, room);
|
||
const auto& cfg = activeAdditiveConfig();
|
||
const auto& fc = activeFlicker();
|
||
const float T_S = static_cast<float>(SDL_GetTicks()) / 1000.0F;
|
||
for (const auto& e : emitters) {
|
||
if (e.radius <= 0) { continue; }
|
||
const int R_EFF = e.flicker ? computeFlickerRadius(e.radius, e.seed, T_S, fc) : e.radius;
|
||
const auto RINGS = computeRings(R_EFF, cfg);
|
||
const Emitter EFF = withEffectiveRadius(e, R_EFF);
|
||
rasterizeEmitterAdditive(EFF, RINGS, canvas, width, height);
|
||
}
|
||
}
|
||
|
||
} // namespace
|
||
|
||
void apply(std::uint32_t* canvas, int width, int height, Mode mode, float room_light, std::span<const Emitter> emitters) {
|
||
if (canvas == nullptr || width <= 0 || height <= 0) { return; }
|
||
|
||
// Atajo per a tots dos modes: sala totalment "neutra" + cap
|
||
// emissor → la pasada no canviaria res al canvas.
|
||
// subtractive: room=1.0 i sense emissors → pixel * 1.0 global.
|
||
// additive: room=1.0 i sense emissors → cap aportació.
|
||
const std::uint8_t ROOM = toByte(room_light);
|
||
if (ROOM == 255 && emitters.empty()) { return; }
|
||
|
||
if (mode == Mode::ADDITIVE) {
|
||
applyAdditive(canvas, width, height, ROOM, emitters);
|
||
} else {
|
||
applySubtractive(canvas, width, height, ROOM, emitters);
|
||
}
|
||
}
|
||
|
||
} // namespace Lighting
|