Files
aee-arcade/source/core/rendering/effects/scan_reveal.cpp
T

220 lines
10 KiB
C++

#include "core/rendering/effects/scan_reveal.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <numbers>
#include <random>
#include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp"
namespace Effects {
namespace {
// Hash determinista per a (row, time_bucket): retorna float a
// [-1, 1]. Usat per generar el jitter horitzontal de la banda
// sense necessitat d'un RNG d'estat (cada frame el resultat
// canvia perquè el time_bucket canvia, però dins d'un mateix
// bucket dues files diferents donen valors distints).
auto rowJitter(int row, int time_bucket) -> float {
const auto SEED = static_cast<std::uint32_t>((row * 73856093) ^ (time_bucket * 19349663));
// Mix barato (xorshift) per descorrelacionar bits.
std::uint32_t x = SEED;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
// Normalitzem a [-1, 1].
const float NORM = static_cast<float>(x) / static_cast<float>(UINT32_MAX);
return (NORM * 2.0F) - 1.0F;
}
// Bucket de temps per al jitter: agrupant en blocs de ~50ms el
// jitter "salta" 20 cops per segon — visualment "viu" sense
// ser caos pur a 60fps.
constexpr int JITTER_TIME_BUCKET_MS = 50;
} // namespace
ScanReveal::ScanReveal()
: rng_(std::random_device{}()) {}
void ScanReveal::begin(const Params& params) {
params_ = params;
elapsed_ms_ = 0;
flash_remaining_ms_ = 0;
flash_started_ = false;
burst_remaining_ms_ = 0;
burst_y_norm_ = 0.0F;
burst_dx_ = 0;
tail_started_ = false;
tail_remaining_ms_ = 0;
}
void ScanReveal::tick(int delta_ms) {
if (params_.duration_ms <= 0) { return; }
// 1) Avanç del scan (clampejat a duration_ms).
elapsed_ms_ = std::min(elapsed_ms_ + delta_ms, params_.duration_ms);
// 2) Tick del burst VHS actiu, si n'hi ha.
if (burst_remaining_ms_ > 0) {
burst_remaining_ms_ = std::max(0, burst_remaining_ms_ - delta_ms);
}
// 3) Spawn d'un nou burst. Permès durant el scan i durant el
// glitch tail post-flash; bloquejat exactament durant el
// flash perquè aquell és un punt visual de tancament net.
// Probabilitat per frame ≈ (per_sec/1000) · delta_ms.
const bool TAIL_ACTIVE = tail_started_ && tail_remaining_ms_ > 0;
const bool SCAN_ONGOING = elapsed_ms_ < params_.duration_ms;
if (burst_remaining_ms_ == 0 && (SCAN_ONGOING || TAIL_ACTIVE) && params_.vhs_burst_per_sec > 0 && params_.vhs_burst_duration_ms > 0) {
const float P = (static_cast<float>(params_.vhs_burst_per_sec) / 1000.0F) * static_cast<float>(delta_ms);
std::uniform_real_distribution<float> dist01(0.0F, 1.0F);
if (dist01(rng_) < P) {
burst_remaining_ms_ = params_.vhs_burst_duration_ms;
burst_y_norm_ = dist01(rng_);
std::uniform_int_distribution<int> dist_sign(0, 1);
const int SIGN = (dist_sign(rng_) == 0) ? 1 : -1;
burst_dx_ = SIGN * params_.vhs_burst_amplitude_px;
}
}
// 4) Dispara el flash quan el scan acaba (una sola vegada).
if (!flash_started_ && elapsed_ms_ >= params_.duration_ms && params_.flash_ms > 0) {
flash_started_ = true;
flash_remaining_ms_ = params_.flash_ms;
}
// Decrement del flash mentre estiga actiu.
if (flash_remaining_ms_ > 0) {
flash_remaining_ms_ = std::max(0, flash_remaining_ms_ - delta_ms);
}
// 5) Glitch tail: arranca un sol cop quan el scan ha acabat i el
// flash s'ha esgotat. Si no hi havia flash configurat, també
// arranca tot just acabar el scan.
if (!tail_started_ && elapsed_ms_ >= params_.duration_ms && flash_remaining_ms_ <= 0 && params_.tail_glitch_ms > 0) {
tail_started_ = true;
tail_remaining_ms_ = params_.tail_glitch_ms;
}
if (tail_remaining_ms_ > 0) {
tail_remaining_ms_ = std::max(0, tail_remaining_ms_ - delta_ms);
}
}
auto ScanReveal::isActive() const -> bool {
return params_.duration_ms > 0 && !isFinished();
}
auto ScanReveal::isFinished() const -> bool {
if (params_.duration_ms <= 0) { return true; }
// Acabat = scan al final + flash exhaurit + glitch tail
// exhaurit (o sense tail configurat).
const bool SCAN_DONE = elapsed_ms_ >= params_.duration_ms;
const bool FLASH_DONE = flash_remaining_ms_ <= 0;
const bool TAIL_DONE = (params_.tail_glitch_ms <= 0) || (tail_started_ && tail_remaining_ms_ <= 0);
return SCAN_DONE && FLASH_DONE && TAIL_DONE;
}
void ScanReveal::render(const Surface& src, int x, int y) const {
if (params_.duration_ms <= 0) {
// Sense efecte: render pla.
src.render(x, y);
return;
}
const int H = src.getHeight();
const auto W_F = static_cast<float>(src.getWidth());
const float T = static_cast<float>(elapsed_ms_) / static_cast<float>(params_.duration_ms);
// Banda: comença a y=-band_height i acaba a y=H. Així al t=0 cap
// fila és visible i al t=1 totes ho són.
const float SWEEP_F = -static_cast<float>(params_.band_height_px) + (T * static_cast<float>(H + params_.band_height_px));
const int BAND_TOP = static_cast<int>(std::floor(SWEEP_F));
const int BAND_BOT = BAND_TOP + params_.band_height_px;
const int TIME_BUCKET = elapsed_ms_ / JITTER_TIME_BUCKET_MS;
const int BURST_Y_PX = static_cast<int>(burst_y_norm_ * static_cast<float>(H));
const bool BURST_ACTIVE = burst_remaining_ms_ > 0;
// Durant el glitch tail post-flash, BAND_TOP queda just damunt
// del peu del logo, fet que (amb el codi de jitter que segueix)
// generaria jitter residual a les últimes files. Aquí el
// suprimim explícitament: el tail només viu de bursts VHS.
const bool TAIL_ACTIVE = tail_started_ && tail_remaining_ms_ > 0;
// Render fila a fila (només files revelades).
for (int row = 0; row <= BAND_BOT && row < H; ++row) {
int dx = 0;
// Jitter de banda: amplitud màxima a la banda, falloff
// lineal cap als marges del radi de glitch. Inactiu durant
// el glitch tail (no hi ha banda).
const int DIST_TO_BAND = std::abs(row - BAND_TOP);
if (!TAIL_ACTIVE && params_.glitch_band_radius_px > 0 && DIST_TO_BAND <= params_.glitch_band_radius_px && params_.glitch_amplitude_px > 0) {
const float FALLOFF = 1.0F - (static_cast<float>(DIST_TO_BAND) / static_cast<float>(params_.glitch_band_radius_px));
const float JIT = rowJitter(row, TIME_BUCKET);
dx += static_cast<int>(std::lround(
FALLOFF * static_cast<float>(params_.glitch_amplitude_px) * JIT));
}
// Burst VHS: només damunt la part ja revelada (row < BAND_TOP)
// perquè per sota encara no hi ha imatge "estable" que poder
// distorsionar visualment.
if (BURST_ACTIVE && row < BAND_TOP && row >= BURST_Y_PX && row < BURST_Y_PX + params_.vhs_burst_height_px) {
dx += burst_dx_;
}
SDL_FRect row_rect{0.0F, static_cast<float>(row), W_F, 1.0F};
src.render(x + dx, y + row, &row_rect);
}
auto& screen = Screen::get();
// Capa lluminosa de la banda. Verticalment només cobreix les
// files del logo (és on viu el scan), però horitzontalment
// travessa tot el canvas com un feix CRT, així sobre fons negre
// no es veuen les costures del rectangle del Surface. L'alpha
// base es modula amb un pulse sinusoidal a `band_pulse_hz` per
// simular el ritme de refresc d'un CRT.
if (params_.band_alpha > 0 && params_.band_height_px > 0) {
const int VISIBLE_TOP = std::clamp(BAND_TOP, 0, H);
const int VISIBLE_BOT = std::clamp(BAND_BOT, 0, H);
const int VISIBLE_H = VISIBLE_BOT - VISIBLE_TOP;
if (VISIBLE_H > 0) {
// Pulse: fracció ∈ [0,1] avançada a la freqüència donada,
// mapejada a un multiplicador ∈ [1-depth, 1] sobre l'alpha
// base. Amb depth=0 el pulse és inactiu (multiplicador 1).
const float PULSE_DEPTH = std::clamp(params_.band_pulse_depth, 0.0F, 1.0F);
const float TWO_PI = 2.0F * std::numbers::pi_v<float>;
const float PHASE = TWO_PI * params_.band_pulse_hz * (static_cast<float>(elapsed_ms_) / 1000.0F);
const float WAVE = 0.5F + (0.5F * std::sin(PHASE)); // 0..1
const float MULT = (1.0F - PULSE_DEPTH) + (PULSE_DEPTH * WAVE);
const float ALPHA_F = static_cast<float>(params_.band_alpha) * MULT;
const auto ALPHA = static_cast<std::uint8_t>(
std::clamp(static_cast<int>(std::round(ALPHA_F)), 0, 255));
if (ALPHA > 0) {
screen.blendRect(0, y + VISIBLE_TOP, screen.getCanvasWidth(), VISIBLE_H, 0xFFFFFFFFU, ALPHA);
}
}
}
// Flash final: capa blanca damunt de TOT el canvas (no només
// del rectangle del logo) — així el flash banya l'escena
// sencera com un CRT i no es veuen les costures del Surface
// sobre fons negre. Alpha proporcional a `flash_remaining_ms_
// / flash_ms` (decreix suau cap a 0 al final del flash).
if (flash_remaining_ms_ > 0 && params_.flash_ms > 0) {
const float FLASH_T = static_cast<float>(flash_remaining_ms_) / static_cast<float>(params_.flash_ms);
const auto ALPHA = static_cast<std::uint8_t>(
std::round(255.0F * std::clamp(FLASH_T, 0.0F, 1.0F)));
if (ALPHA > 0) {
screen.blendRect(0, 0, screen.getCanvasWidth(), screen.getCanvasHeight(), 0xFFFFFFFFU, ALPHA);
}
}
}
} // namespace Effects