memlnaut-nisps/nisps/pipeline/input_chain.hpp

274 lines
11 KiB
C++
Raw Permalink Normal View History

// nisps/pipeline/input_chain.hpp — the 2-axis input-processing chain
// (one-core-engine-refactor P4). Faithful C++ port of the retired
// manifold/src/engine/input-pipeline.ts (itself a bit-for-bit port of the
// legacy js/ui/input-pipeline.js), which is the behaviour contract pinned by
// manifold/tests/fixtures/input-pipeline-golden.json.
//
// Stages (in order), each axis in [0,1]:
// 0. Invert (per-axis flip)
// 1. Deadzone (suppress jitter near centre, remap live zone to [0,1])
// 2. Circular clamp (constrain to unit disk centred at 0.5,0.5)
// 3. Zoom (narrow window around anchor, modulated by momentum)
// 4. Centred power curve (per-axis exponent)
// 5. EMA smoothing (frame-rate-independent)
// 6. Momentum-as-zoom update (consumed next frame)
//
// TIME MODEL: the caller passes dt in SECONDS per call; the chain accumulates
// its own clock for the momentum velocity window (the TS original read
// performance.now() — the fixtures pin the equivalent clock contract). No
// wall clock in core: fully deterministic.
//
// PERF CONTRACT: no heap, no virtual dispatch, `.f` literals, fixed-capacity
// velocity ring. Control-rate (per pointer event / per control tick), not the
// audio ISR.
#pragma once
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <span>
#include "../core/math.hpp"
#include "../core/perf.hpp"
namespace nisps::pipeline {
inline constexpr float kZoomMin = 0.01f;
inline constexpr float kZoomMax = 1.0f;
inline constexpr float kFreezeThreshold = kZoomMin;
inline constexpr float kReferenceDt = 1.f / 60.f;
inline constexpr float kVelocityWindowDefaultS = 0.150f;
// Sentinel for "null" per-axis overrides (valid zooms are [0.01, 1], valid
// curves [0.2, 5] — zero is outside both ranges).
inline constexpr float kUnsetOverride = 0.f;
enum class AnchorMode : std::uint8_t { Auto = 0, Sticky = 1, Center = 2 };
enum class MomentumMode : std::uint8_t { Off = 0, Gentle = 1, Strong = 2 };
struct InputChainConfig {
float zoom = 1.0f; // [0.01, 1]
float zoom_x = kUnsetOverride; // 0 ⇒ use zoom
float zoom_y = kUnsetOverride;
float anchor_x = 0.5f;
float anchor_y = 0.5f;
AnchorMode anchor_mode = AnchorMode::Center;
float deadzone = 0.f; // [0, 0.4]
float input_curve = 1.0f; // [0.2, 5]
float curve_x = kUnsetOverride; // 0 ⇒ use input_curve
float curve_y = kUnsetOverride;
float smoothing = 0.f; // [0, 0.95]
MomentumMode momentum_mode = MomentumMode::Off;
float velocity_window_s = kVelocityWindowDefaultS;
bool invert_x = false;
bool invert_y = false;
};
struct InputChainResult {
float x;
float y;
bool frozen;
};
class InputChain {
public:
// Velocity-history capacity. The TS original kept an unbounded window-
// trimmed list; at real pointer rates (≤240 Hz) a 150 ms window holds
// ≤36 entries. When full, the oldest entry is dropped (it would be the
// first trimmed anyway).
static constexpr std::size_t kHistoryCap = 64u;
InputChain() noexcept = default;
void set_config(const InputChainConfig& c) noexcept { cfg_ = c; }
const InputChainConfig& config() const noexcept { return cfg_; }
void reset() noexcept {
smoothed_x_ = 0.5f;
smoothed_y_ = 0.5f;
momentum_multiplier_ = 1.f;
frozen_ = false;
now_s_ = 0.f;
hist_count_ = 0u;
hist_head_ = 0u;
}
// Serialisable per-instance state (persistence): [smoothed_x, smoothed_y,
// momentum_multiplier]. The velocity history is transient by design.
static constexpr std::size_t state_size() noexcept { return 3u; }
void save_state(std::span<float> out) const noexcept {
if (out.size() < state_size()) return;
out[0] = smoothed_x_;
out[1] = smoothed_y_;
out[2] = momentum_multiplier_;
}
void load_state(std::span<const float> in) noexcept {
if (in.size() < state_size()) return;
smoothed_x_ = in[0];
smoothed_y_ = in[1];
momentum_multiplier_ = in[2];
hist_count_ = 0u;
hist_head_ = 0u;
}
// Process one raw 2D sample. `dt_s` = seconds since the previous call
// (clamped at 0; 0 falls back to the 1/60 reference inside smoothing).
InputChainResult process(float raw_x, float raw_y, float dt_s) noexcept {
const float safe_dt = (dt_s > 0.f) ? dt_s : 0.f;
now_s_ += safe_dt;
const float base_zoom_x = (cfg_.zoom_x != kUnsetOverride) ? cfg_.zoom_x : cfg_.zoom;
const float base_zoom_y = (cfg_.zoom_y != kUnsetOverride) ? cfg_.zoom_y : cfg_.zoom;
const bool frozen_x = base_zoom_x <= kFreezeThreshold;
const bool frozen_y = base_zoom_y <= kFreezeThreshold;
if (frozen_x && frozen_y) {
frozen_ = true;
return {smoothed_x_, smoothed_y_, true};
}
// 0. Invert
float x = cfg_.invert_x ? (1.f - raw_x) : raw_x;
float y = cfg_.invert_y ? (1.f - raw_y) : raw_y;
// 1. Deadzone
x = apply_deadzone_(x, cfg_.deadzone);
y = apply_deadzone_(y, cfg_.deadzone);
// 2. Circular clamp to the unit disk centred at (0.5, 0.5)
{
const float cx = x - 0.5f;
const float cy = y - 0.5f;
const float dist = std::sqrt(cx * cx + cy * cy);
if (dist > 0.5f && dist > 1e-12f) {
const float scale = 0.5f / dist;
x = 0.5f + cx * scale;
y = 0.5f + cy * scale;
}
}
// 3. Zoom around the anchor (with momentum modulation)
const float anchor_x = (cfg_.anchor_mode == AnchorMode::Center) ? 0.5f : cfg_.anchor_x;
const float anchor_y = (cfg_.anchor_mode == AnchorMode::Center) ? 0.5f : cfg_.anchor_y;
const float eff_zoom_x = frozen_x
? kFreezeThreshold
: nisps::clamp(base_zoom_x * momentum_multiplier_, kZoomMin, kZoomMax);
const float eff_zoom_y = frozen_y
? kFreezeThreshold
: nisps::clamp(base_zoom_y * momentum_multiplier_, kZoomMin, kZoomMax);
x = frozen_x ? smoothed_x_ : apply_zoom_(x, anchor_x, eff_zoom_x);
y = frozen_y ? smoothed_y_ : apply_zoom_(y, anchor_y, eff_zoom_y);
// 4. Centred power curve
const float curve_x = (cfg_.curve_x != kUnsetOverride) ? cfg_.curve_x : cfg_.input_curve;
const float curve_y = (cfg_.curve_y != kUnsetOverride) ? cfg_.curve_y : cfg_.input_curve;
if (!frozen_x) x = nisps::centered_power(x, curve_x);
if (!frozen_y) y = nisps::centered_power(y, curve_y);
// 5. EMA smoothing
if (!frozen_x) smoothed_x_ = ema_smooth_(smoothed_x_, x, cfg_.smoothing, safe_dt);
if (!frozen_y) smoothed_y_ = ema_smooth_(smoothed_y_, y, cfg_.smoothing, safe_dt);
// 6. Update momentum-zoom for the next frame (uses the RAW sample,
// pre-pipeline, like the TS original).
update_momentum_(raw_x, raw_y, safe_dt);
frozen_ = false;
return {smoothed_x_, smoothed_y_, false};
}
bool frozen() const noexcept { return frozen_; }
float momentum_multiplier() const noexcept { return momentum_multiplier_; }
private:
static float apply_deadzone_(float input, float deadzone) noexcept {
if (deadzone <= 0.f) return input;
const float offset = input - 0.5f;
const float abs_off = std::fabs(offset);
const float half_dz = deadzone * 0.5f;
if (abs_off <= half_dz) return 0.5f;
const float sign = (offset < 0.f) ? -1.f : 1.f;
const float remapped = ((abs_off - half_dz) / (0.5f - half_dz)) * 0.5f;
return 0.5f + sign * remapped;
}
static float apply_zoom_(float input, float anchor, float zoom_level) noexcept {
return nisps::clamp(anchor + (input - 0.5f) * zoom_level, 0.f, 1.f);
}
static float ema_smooth_(float prev, float raw, float smoothing, float dt) noexcept {
if (smoothing <= 0.f) return raw;
const float effective_dt = (dt > 0.f) ? dt : kReferenceDt;
const float alpha = 1.f - smoothing;
const float alpha_eff = 1.f - std::pow(1.f - alpha, effective_dt / kReferenceDt);
return prev + alpha_eff * (raw - prev);
}
void update_momentum_(float raw_x, float raw_y, float dt) noexcept {
float factor, min_mul, max_mul;
switch (cfg_.momentum_mode) {
case MomentumMode::Gentle: factor = 0.6f; min_mul = 0.3f; max_mul = 1.0f; break;
case MomentumMode::Strong: factor = 1.5f; min_mul = 0.15f; max_mul = 1.0f; break;
case MomentumMode::Off:
default:
momentum_multiplier_ = 1.f;
hist_count_ = 0u;
hist_head_ = 0u;
return;
}
// Trim entries older than the window, then append (bounded ring).
const float window = cfg_.velocity_window_s;
while (hist_count_ > 0u) {
const HistEntry& oldest = hist_[hist_head_];
if (now_s_ - oldest.t <= window) break;
hist_head_ = (hist_head_ + 1u) % kHistoryCap;
--hist_count_;
}
if (hist_count_ == kHistoryCap) {
hist_head_ = (hist_head_ + 1u) % kHistoryCap;
--hist_count_;
}
hist_[(hist_head_ + hist_count_) % kHistoryCap] = {raw_x, raw_y, now_s_};
++hist_count_;
if (hist_count_ < 2u) {
momentum_multiplier_ = 1.f;
return;
}
const HistEntry& a = hist_[hist_head_];
const HistEntry& b = hist_[(hist_head_ + hist_count_ - 1u) % kHistoryCap];
const float dt_hist = b.t - a.t;
if (dt_hist <= 0.f) return; // keep the previous multiplier
const float dx = b.x - a.x;
const float dy = b.y - a.y;
const float dist = std::sqrt(dx * dx + dy * dy);
const float speed = dist / dt_hist; // [0,1]-space units per second
const float norm_speed = nisps::clamp(speed * factor, 0.f, 1.f);
// Higher speed → smaller multiplier (zoom out faster movements).
const float target = max_mul - (max_mul - min_mul) * norm_speed;
// Smooth toward the target so the zoom doesn't jitter.
const float smooth_coeff = nisps::clamp(dt * 6.f, 0.f, 1.f);
momentum_multiplier_ += smooth_coeff * (target - momentum_multiplier_);
}
struct HistEntry {
float x;
float y;
float t;
};
InputChainConfig cfg_{};
float smoothed_x_ = 0.5f;
float smoothed_y_ = 0.5f;
float momentum_multiplier_ = 1.f;
bool frozen_ = false;
float now_s_ = 0.f;
HistEntry hist_[kHistoryCap]{};
std::size_t hist_head_ = 0u;
std::size_t hist_count_ = 0u;
};
} // namespace nisps::pipeline