Port FluidSim core and scenarios to C++20

This commit is contained in:
maxwes08
2026-10-22 23:52:20 +02:00
commit a859740a95
47 changed files with 3851 additions and 0 deletions

402
src/desktop_main.cpp Normal file
View File

@@ -0,0 +1,402 @@
#include "FluidSim/Scenario.hpp"
#include "FluidSim/SoundEngine.hpp"
#include "FluidSim/DynoCurve.hpp"
#include <SFML/Graphics.hpp>
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <filesystem>
#include <iomanip>
#include <optional>
#include <sstream>
#include <thread>
namespace {
using namespace fluidsim::port;
constexpr unsigned int window_width = 1280;
constexpr unsigned int window_height = 720;
constexpr std::size_t ring_capacity = 8192;
constexpr std::size_t target_fill = 1024;
constexpr std::size_t preroll_samples = 1024;
constexpr float throttle_lerp_rate = 10.0f;
sf::Color pressure_color(float pressure) {
const float bar = pressure / 1.0e5f;
if (bar < 1.0f) {
const float f = std::clamp(bar, 0.0f, 1.0f);
return {0, static_cast<std::uint8_t>(255.0f * f), static_cast<std::uint8_t>(255.0f * (1.0f - f))};
}
const float f = std::min((bar - 1.0f) / 9.0f, 1.0f);
return {static_cast<std::uint8_t>(255.0f * f), static_cast<std::uint8_t>(255.0f * (1.0f - f)), 0};
}
sf::Color temperature_color(float temperature) {
const float t = std::clamp(temperature, 0.0f, 2000.0f);
if (t < 300.0f) {
const float f = t / 300.0f;
return {0, static_cast<std::uint8_t>(255.0f * f), static_cast<std::uint8_t>(255.0f * (1.0f - f))};
}
const float f = (t - 300.0f) / 1700.0f;
return {static_cast<std::uint8_t>(255.0f * f), static_cast<std::uint8_t>(255.0f * (1.0f - f)), 0};
}
void draw_label(sf::RenderWindow& window, const sf::Font* font, const std::string& text,
sf::Vector2f position, sf::Color color = sf::Color::White, unsigned int size = 14) {
if (!font) return;
sf::Text label(*font);
label.setString(text);
label.setPosition(position);
label.setFillColor(color);
label.setCharacterSize(size);
window.draw(label);
}
void draw_volume(sf::RenderWindow& window, float pressure, sf::FloatRect bounds) {
sf::RectangleShape body({bounds.size.x, bounds.size.y});
body.setPosition(bounds.position);
body.setFillColor(pressure_color(pressure));
body.setOutlineColor(sf::Color::White);
body.setOutlineThickness(1.0f);
window.draw(body);
}
void draw_pipe(sf::RenderWindow& window, const ScenarioSnapshot& snapshot, std::size_t pipe_index,
float center_y, float x0, float x1) {
const auto& cells = snapshot.pipe_states[pipe_index];
if (cells.empty()) return;
const float cell_width = (x1 - x0) / static_cast<float>(cells.size());
for (std::size_t i = 0; i < cells.size(); ++i) {
const auto& cell = cells[i];
const float thickness = std::clamp(6.0f + cell.pressure / 101325.0f * 0.7f, 6.0f, 20.0f);
sf::RectangleShape segment({cell_width + 1.0f, thickness});
segment.setPosition({x0 + static_cast<float>(i) * cell_width, center_y - thickness * 0.5f});
segment.setFillColor(temperature_color(cell.pressure / std::max(cell.rho * 287.0f, 1e-9f)));
window.draw(segment);
}
}
void draw_cylinder(sf::RenderWindow& window, const sf::Font* font, float pressure,
float piston_fraction, float top, const char* name, bool intake_open, bool exhaust_open) {
constexpr float x = 470.0f, width = 74.0f, height = 142.0f;
const float fraction = std::clamp(piston_fraction, 0.0f, 1.0f);
const float gas_height = height * fraction;
sf::RectangleShape wall({width, height});
wall.setPosition({x, top});
wall.setFillColor({52, 59, 68});
window.draw(wall);
sf::RectangleShape gas({width, gas_height});
gas.setPosition({x, top});
gas.setFillColor(pressure_color(pressure));
window.draw(gas);
sf::RectangleShape piston({width + 8.0f, 5.0f});
piston.setPosition({x - 4.0f, top + gas_height});
piston.setFillColor(sf::Color::White);
window.draw(piston);
sf::RectangleShape intake_valve({7.0f, 12.0f});
intake_valve.setPosition({x - 12.0f, top + 5.0f});
intake_valve.setFillColor(intake_open ? sf::Color::Green : sf::Color::Red);
window.draw(intake_valve);
sf::RectangleShape exhaust_valve({7.0f, 12.0f});
exhaust_valve.setPosition({x + width + 5.0f, top + 5.0f});
exhaust_valve.setFillColor(exhaust_open ? sf::Color::Green : sf::Color::Red);
window.draw(exhaust_valve);
sf::RectangleShape border({width, height});
border.setPosition({x, top});
border.setFillColor(sf::Color::Transparent);
border.setOutlineColor(sf::Color(190, 200, 210));
border.setOutlineThickness(1.0f);
window.draw(border);
draw_label(window, font, name, {x + 13.0f, top - 22.0f}, sf::Color::White, 14);
}
void draw_graph_line(sf::RenderWindow& window, sf::Vector2f start, sf::Vector2f end,
sf::Color color, float thickness = 1.5f) {
const float dx = end.x - start.x;
const float dy = end.y - start.y;
const float length = std::sqrt(dx * dx + dy * dy);
if (length <= 0.0f) return;
sf::RectangleShape line({length, thickness});
line.setPosition(start);
line.setRotation(sf::degrees(std::atan2(dy, dx) * 180.0f / pi));
line.setFillColor(color);
window.draw(line);
}
void draw_dyno_curve(sf::RenderWindow& window, const sf::Font* font, const DynoCurve& curve,
float graph_x, float graph_y, float graph_width, float graph_height,
float current_rpm, float current_power_kw) {
const auto& bins = curve.bins();
if (bins.empty()) return;
float max_power = 0.01f, max_torque = 0.01f, max_rpm = 1000.0f;
for (std::size_t index = 0; index < bins.size(); ++index) {
if (bins[index].power_kw > 0.0f || bins[index].torque_nm > 0.0f) {
max_power = std::max(max_power, bins[index].power_kw);
max_torque = std::max(max_torque, bins[index].torque_nm);
max_rpm = std::max(max_rpm, static_cast<float>(index) * DynoCurve::rpm_bin_size +
DynoCurve::rpm_bin_size * 0.5f);
}
}
max_power *= 1.1f;
max_torque *= 1.1f;
max_rpm = std::max(max_rpm * 1.05f, 1000.0f);
sf::RectangleShape background({graph_width, graph_height});
background.setPosition({graph_x, graph_y});
background.setFillColor({20, 20, 20, 220});
background.setOutlineColor({70, 78, 90});
background.setOutlineThickness(1.0f);
window.draw(background);
constexpr float left_margin = 50.0f, right_margin = 48.0f;
constexpr float top_margin = 24.0f, bottom_margin = 38.0f;
const float plot_x = graph_x + left_margin;
const float plot_y = graph_y + top_margin;
const float plot_width = graph_width - left_margin - right_margin;
const float plot_height = graph_height - top_margin - bottom_margin;
const sf::Color grid_color(50, 50, 50);
for (int index = 0; index <= 9; ++index) {
const float x = plot_x + static_cast<float>(index) / 9.0f * plot_width;
sf::RectangleShape grid({1.0f, plot_height});
grid.setPosition({x, plot_y});
grid.setFillColor(grid_color);
window.draw(grid);
}
for (int index = 0; index <= 5; ++index) {
const float y = plot_y + (1.0f - static_cast<float>(index) / 5.0f) * plot_height;
sf::RectangleShape grid({plot_width, 1.0f});
grid.setPosition({plot_x, y});
grid.setFillColor(grid_color);
window.draw(grid);
draw_label(window, font, std::to_string(static_cast<int>(max_power * index / 5.0f)),
{graph_x + 3.0f, y - 7.0f}, sf::Color(240, 100, 90), 9);
draw_label(window, font, std::to_string(static_cast<int>(max_torque * index / 5.0f)),
{graph_x + graph_width - 42.0f, y - 7.0f}, sf::Color(100, 150, 255), 9);
}
for (int index = 0; index <= 9; ++index) {
const float rpm = max_rpm * static_cast<float>(index) / 9.0f;
const float x = plot_x + static_cast<float>(index) / 9.0f * plot_width;
std::ostringstream tick;
tick << std::fixed << std::setprecision(1) << rpm / 1000.0f << 'k';
draw_label(window, font, tick.str(), {x - 12.0f, graph_y + graph_height - 31.0f},
sf::Color(200, 205, 215), 9);
}
draw_label(window, font, "DYNO", {graph_x + 8.0f, graph_y + 4.0f}, sf::Color::White, 11);
draw_label(window, font, "kW", {graph_x + 8.0f, graph_y + 17.0f}, sf::Color(255, 100, 90), 9);
draw_label(window, font, "Nm", {graph_x + graph_width - 30.0f, graph_y + 17.0f},
sf::Color(110, 160, 255), 9);
draw_label(window, font, "RPM", {graph_x + graph_width * 0.5f - 12.0f,
graph_y + graph_height - 15.0f}, sf::Color::White, 10);
const sf::Color power_color(255, 27, 27), torque_color(9, 90, 255);
for (std::size_t index = 1; index < bins.size(); ++index) {
const float rpm0 = (static_cast<float>(index - 1) + 0.5f) * DynoCurve::rpm_bin_size;
const float rpm1 = (static_cast<float>(index) + 0.5f) * DynoCurve::rpm_bin_size;
if (rpm0 > max_rpm || rpm1 > max_rpm) break;
const float x0 = plot_x + rpm0 / max_rpm * plot_width;
const float x1 = plot_x + rpm1 / max_rpm * plot_width;
if (bins[index - 1].power_kw > 0.0f && bins[index].power_kw > 0.0f) {
const float y0 = plot_y + (1.0f - bins[index - 1].power_kw / max_power) * plot_height;
const float y1 = plot_y + (1.0f - bins[index].power_kw / max_power) * plot_height;
draw_graph_line(window, {x0, y0}, {x1, y1}, power_color, 2.0f);
}
if (bins[index - 1].torque_nm > 0.0f && bins[index].torque_nm > 0.0f) {
const float y0 = plot_y + (1.0f - bins[index - 1].torque_nm / max_torque) * plot_height;
const float y1 = plot_y + (1.0f - bins[index].torque_nm / max_torque) * plot_height;
draw_graph_line(window, {x0, y0}, {x1, y1}, torque_color, 2.0f);
}
}
if (current_rpm > 0.0f && current_rpm <= max_rpm && current_power_kw > 0.0f) {
const float x = plot_x + current_rpm / max_rpm * plot_width;
const float y = plot_y + (1.0f - current_power_kw / max_power) * plot_height;
sf::CircleShape dot(3.0f);
dot.setPosition({x - 3.0f, y - 3.0f});
dot.setFillColor(sf::Color::White);
window.draw(dot);
}
}
void draw_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& state,
float throttle_target, float brake, double playback_speed, std::size_t buffer_fill,
std::size_t buffer_capacity, bool time_warp, DynoCurve& dyno_curve) {
constexpr float intake_y = 310.0f;
constexpr float upper_y = 152.0f;
constexpr float lower_y = 400.0f;
window.clear({9, 12, 17});
const float power_kw = state.average_power / 1000.0f;
dyno_curve.update(state.rpm, power_kw, state.average_torque);
// Intake: ambient pipe → throttle → plenum → two cylinder runners.
draw_pipe(window, state, 0, intake_y, 36.0f, 165.0f);
sf::RectangleShape throttle({8.0f, 28.0f});
throttle.setPosition({169.0f, intake_y - 14.0f});
throttle.setFillColor(sf::Color::Yellow);
window.draw(throttle);
draw_volume(window, state.intake_pressure, {{195.0f, intake_y - 45.0f}, {62.0f, 90.0f}});
draw_pipe(window, state, 1, upper_y + 71.0f, 263.0f, 458.0f);
draw_pipe(window, state, 2, lower_y + 71.0f, 263.0f, 458.0f);
draw_cylinder(window, font, state.cylinder_pressure1, state.cylinder_piston_fraction1,
upper_y, "CYL 1", state.valve1_intake_area > 1e-7f, state.valve1_exhaust_area > 1e-7f);
draw_cylinder(window, font, state.cylinder_pressure2, state.cylinder_piston_fraction2,
lower_y, "CYL 2", state.valve2_intake_area > 1e-7f, state.valve2_exhaust_area > 1e-7f);
// Two independent exhaust headers enter a shared collector and tailpipe.
draw_pipe(window, state, 3, upper_y + 71.0f, 556.0f, 820.0f);
draw_pipe(window, state, 4, lower_y + 71.0f, 556.0f, 820.0f);
draw_volume(window, state.exhaust_pressure, {{838.0f, 305.0f}, {70.0f, 82.0f}});
draw_pipe(window, state, 5, 346.0f, 915.0f, 1236.0f);
draw_label(window, font, "AMBIENT", {37.0f, intake_y + 22.0f}, {180, 190, 200}, 12);
draw_label(window, font, "THROTTLE", {151.0f, intake_y + 24.0f}, {240, 210, 90}, 11);
draw_label(window, font, "INTAKE PLENUM", {188.0f, intake_y - 62.0f}, sf::Color::White, 11);
draw_label(window, font, "SHARED EXHAUST", {818.0f, 285.0f}, sf::Color::White, 11);
draw_label(window, font, "TAILPIPE", {1084.0f, 362.0f}, {180, 190, 200}, 11);
std::ostringstream status;
status << (time_warp ? "[Space] normal speed" : "[Space] real time")
<< " Sim " << std::fixed << std::setprecision(3) << playback_speed << "x"
<< " Audio buffer " << buffer_fill << "/" << buffer_capacity
<< "\nBike " << std::setprecision(1) << state.speed_kmh << " km/h Gear "
<< (state.gear == 0 ? "N" : std::to_string(state.gear))
<< " Throttle " << state.throttle * 100.0f << "% / target " << throttle_target * 100.0f << "% [W, up/down]"
<< " Clutch " << state.clutch * 100.0f << "% [C] Brake " << brake * 100.0f << "% [left/right]"
<< " Shift [A/D], Neutral [Z] Mechanical [M]: "
<< (state.mechanical_sound_enabled ? "ON" : "OFF");
if (font) {
sf::RectangleShape panel({1260.0f, 92.0f});
panel.setPosition({10.0f, 10.0f});
panel.setFillColor({12, 18, 26, 236});
panel.setOutlineColor({80, 110, 130});
panel.setOutlineThickness(1.0f);
window.draw(panel);
draw_label(window, font, status.str(), {22.0f, 20.0f}, sf::Color::White, 14);
draw_label(window, font,
"RPM " + std::to_string(static_cast<int>(state.rpm)) +
" Power " + std::to_string(state.average_power / 1000.0f) + " kW" +
" Bike kinetic energy " + std::to_string(static_cast<int>(state.kinetic_energy)) + " J",
{24.0f, 122.0f}, {220, 230, 240}, 17);
const sf::Color rev = state.rev_limit1 || state.rev_limit2 ? sf::Color::Red : sf::Color(120, 220, 120);
draw_label(window, font, (state.rev_limit1 || state.rev_limit2) ? "REV CUT" : "REV LIMIT 10000 rpm",
{24.0f, 151.0f}, rev, 14);
draw_label(window, font,
"Cylinder pressure " + std::to_string(state.cylinder_pressure1 / 1e5f) + " / " +
std::to_string(state.cylinder_pressure2 / 1e5f) + " bar Intake " +
std::to_string(state.intake_pressure / 1000.0f) + " kPa Exhaust " +
std::to_string(state.exhaust_pressure / 1000.0f) + " kPa",
{24.0f, 176.0f}, {205, 215, 225}, 13);
}
draw_dyno_curve(window, font, dyno_curve, 860.0f, 455.0f, 400.0f, 250.0f,
state.rpm, power_kw);
window.display();
}
} // namespace
int main(int argc, char** argv) {
using namespace fluidsim::port;
sf::RenderWindow window(sf::VideoMode({window_width, window_height}), "FluidSim C++ - parallel twin");
window.setVerticalSyncEnabled(false);
window.setFramerateLimit(60);
window.setKeyRepeatEnabled(false);
sf::Font font;
std::filesystem::path font_path{"fonts/FiraCodeNerdFont-Medium.ttf"};
if (!std::filesystem::exists(font_path) && argc > 0) {
const auto executable_font = std::filesystem::path(argv[0]).parent_path() /
"fonts/FiraCodeNerdFont-Medium.ttf";
if (std::filesystem::exists(executable_font)) font_path = executable_font;
}
const bool has_font = font.openFromFile(font_path.string());
const sf::Font* font_ptr = has_font ? &font : nullptr;
FourStrokeTwinScenario scenario(44100);
SimulationRingBuffer ring(ring_capacity);
for (std::size_t i = 0; i < preroll_samples; ++i) {
const AudioFrame audio = scenario.process();
ring.write(audio.left, audio.right);
}
ring.reset_consumer();
SoundEngine audio(ring);
audio.set_volume(100.0f);
audio.start();
std::jthread simulation([&](std::stop_token stop) {
while (ring.wait_for_space(target_fill, stop)) {
const AudioFrame frame = scenario.process();
if (!ring.write(frame.left, frame.right, stop)) break;
}
});
double desired_speed = 0.001;
double current_speed = desired_speed;
double last_normal_speed = 0.1;
bool time_warp = false;
bool real_time = false;
float throttle_target = 1.0f, throttle_current = 0.0f;
float brake_target = 0.0f, brake_current = 0.0f;
float clutch_current = 0.0f;
bool throttle_held = false, clutch_held = false;
DynoCurve dyno_curve;
auto previous_frame = std::chrono::steady_clock::now();
while (window.isOpen()) {
while (const std::optional event = window.pollEvent()) {
if (event->is<sf::Event::Closed>()) window.close();
if (const auto* key = event->getIf<sf::Event::KeyPressed>()) {
switch (key->code) {
case sf::Keyboard::Key::Space:
time_warp = !time_warp;
if (!time_warp) { desired_speed = last_normal_speed; real_time = false; }
break;
case sf::Keyboard::Key::W: throttle_held = true; break;
case sf::Keyboard::Key::C: clutch_held = true; break;
case sf::Keyboard::Key::Up: throttle_target = std::min(1.0f, throttle_target + 0.05f); break;
case sf::Keyboard::Key::Down: throttle_target = std::max(0.0f, throttle_target - 0.05f); break;
case sf::Keyboard::Key::Left: brake_target = std::max(0.0f, brake_target - 0.05f); break;
case sf::Keyboard::Key::Right: brake_target = std::min(1.0f, brake_target + 0.05f); break;
case sf::Keyboard::Key::D: scenario.shift_up(); break;
case sf::Keyboard::Key::A: scenario.shift_down(); break;
case sf::Keyboard::Key::Z: scenario.set_neutral(); break;
case sf::Keyboard::Key::M: scenario.toggle_mechanical_sound(); break;
default: break;
}
}
if (const auto* key = event->getIf<sf::Event::KeyReleased>()) {
if (key->code == sf::Keyboard::Key::W) throttle_held = false;
if (key->code == sf::Keyboard::Key::C) clutch_held = false;
}
if (const auto* wheel = event->getIf<sf::Event::MouseWheelScrolled>(); wheel && !time_warp) {
desired_speed *= wheel->delta > 0.0f ? 1.1 : (wheel->delta < 0.0f ? 1.0 / 1.1 : 1.0);
desired_speed = std::clamp(desired_speed, 0.0001, 1.0);
last_normal_speed = desired_speed;
real_time = std::abs(desired_speed - 1.0) < 1e-6;
}
}
const auto now = std::chrono::steady_clock::now();
const float frame_dt = std::chrono::duration<float>(now - previous_frame).count();
previous_frame = now;
const double target_speed = time_warp ? 1.0 : desired_speed;
current_speed += (target_speed - current_speed) * (1.0 - std::exp(-8.0 * frame_dt));
audio.set_speed(current_speed);
const float wanted_throttle = throttle_held ? throttle_target : 0.0f;
if (wanted_throttle == 0.0f) throttle_current = 0.0f;
else throttle_current += (wanted_throttle - throttle_current) * (1.0f - std::exp(-throttle_lerp_rate * frame_dt));
brake_current += (brake_target - brake_current) * (1.0f - std::exp(-throttle_lerp_rate * frame_dt));
const float wanted_clutch = clutch_held ? 1.0f : 0.0f;
clutch_current += (wanted_clutch - clutch_current) * (1.0f - std::exp(-throttle_lerp_rate * frame_dt));
scenario.set_controls(throttle_current, brake_current, clutch_current);
draw_scene(window, font_ptr, scenario.snapshot(), throttle_target, brake_current,
current_speed, ring.available_samples(), ring_capacity, time_warp, dyno_curve);
}
simulation.request_stop();
audio.stop();
return 0;
}