Port FluidSim core and scenarios to C++20
This commit is contained in:
36
src/audio_output_stream.cpp
Normal file
36
src/audio_output_stream.cpp
Normal file
@@ -0,0 +1,36 @@
|
||||
#include "FluidSim/AudioOutputStream.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace fluidsim::port {
|
||||
|
||||
AudioOutputStream::AudioOutputStream(SimulationRingBuffer& ring, unsigned int sample_rate)
|
||||
: ring_(ring) {
|
||||
initialize(2, sample_rate, {sf::SoundChannel::FrontLeft, sf::SoundChannel::FrontRight});
|
||||
}
|
||||
|
||||
bool AudioOutputStream::onGetData(Chunk& data) {
|
||||
const std::size_t read = ring_.read_interpolated(temp_left_, temp_right_, speed());
|
||||
constexpr float dc_coefficient = 0.9995f;
|
||||
for (std::size_t i = 0; i < read; ++i) {
|
||||
const float left = temp_left_[i] - previous_input_left_ + dc_coefficient * previous_output_left_;
|
||||
const float right = temp_right_[i] - previous_input_right_ + dc_coefficient * previous_output_right_;
|
||||
previous_input_left_ = temp_left_[i];
|
||||
previous_input_right_ = temp_right_[i];
|
||||
previous_output_left_ = left;
|
||||
previous_output_right_ = right;
|
||||
const float finite_left = std::isfinite(left) ? std::clamp(left, -1.0f, 1.0f) : 0.0f;
|
||||
const float finite_right = std::isfinite(right) ? std::clamp(right, -1.0f, 1.0f) : 0.0f;
|
||||
output_[i * 2] = static_cast<std::int16_t>(finite_left * 32767.0f);
|
||||
output_[i * 2 + 1] = static_cast<std::int16_t>(finite_right * 32767.0f);
|
||||
}
|
||||
std::fill(output_.begin() + static_cast<std::ptrdiff_t>(read * 2), output_.end(), 0);
|
||||
data.samples = output_.data();
|
||||
data.sampleCount = output_.size();
|
||||
return true;
|
||||
}
|
||||
|
||||
void AudioOutputStream::onSeek(sf::Time /*time_offset*/) {}
|
||||
|
||||
} // namespace fluidsim::port
|
||||
88
src/audio_processing.cpp
Normal file
88
src/audio_processing.cpp
Normal file
@@ -0,0 +1,88 @@
|
||||
#include "FluidSim/AudioProcessing.hpp"
|
||||
#include "FluidSim/Engine.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace fluidsim::port {
|
||||
|
||||
SoundProcessor::SoundProcessor(int sample_rate, float listener_distance) {
|
||||
const int safe_rate = std::max(sample_rate, 1);
|
||||
dt_ = 1.0f / static_cast<float>(safe_rate);
|
||||
scale_factor_ = 1.0f / (4.0f * pi * std::max(listener_distance, 1e-3f));
|
||||
derivative_alpha_ = std::exp(-dt_ / 0.020f);
|
||||
lowpass_alpha_ = std::exp(-dt_ / 0.005f);
|
||||
}
|
||||
|
||||
float SoundProcessor::process(float mass_flow_out) {
|
||||
flow_lowpass_ = lowpass_alpha_ * flow_lowpass_ + (1.0f - lowpass_alpha_) * mass_flow_out;
|
||||
const float raw_derivative = (flow_lowpass_ - previous_flow_) / dt_;
|
||||
previous_flow_ = flow_lowpass_;
|
||||
smooth_derivative_ = derivative_alpha_ * smooth_derivative_ +
|
||||
(1.0f - derivative_alpha_) * raw_derivative;
|
||||
return std::tanh(smooth_derivative_ * scale_factor_ * gain);
|
||||
}
|
||||
|
||||
RadiationSource::RadiationSource(int sample_rate)
|
||||
: sample_rate_(std::max(sample_rate, 1)), dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))) {
|
||||
const std::size_t max_delay = static_cast<std::size_t>(std::max(1.0f,
|
||||
0.5f / std::max(speed_of_sound, 1.0f) * static_cast<float>(sample_rate_)));
|
||||
left_delay_.resize(max_delay);
|
||||
right_delay_.resize(max_delay);
|
||||
}
|
||||
|
||||
AudioFrame RadiationSource::process(float mass_flow) {
|
||||
const float flow_alpha = 1.0f - std::exp(-dt_ / std::max(flow_tau, 1e-5f));
|
||||
const float derivative_decay = std::exp(-dt_ / std::max(smooth_tau, 1e-5f));
|
||||
flow_lp_ += flow_alpha * (mass_flow - flow_lp_);
|
||||
const float raw_derivative = (flow_lp_ - previous_flow_lp_) / dt_;
|
||||
previous_flow_lp_ = flow_lp_;
|
||||
smooth_derivative_ = derivative_decay * smooth_derivative_ + (1.0f - derivative_decay) * raw_derivative;
|
||||
|
||||
const Vec3 to_listener = subtract(listener, pipe_end);
|
||||
const float distance = std::max(length(to_listener), 0.1f);
|
||||
const Vec3 direction{to_listener.x / distance, to_listener.y / distance, to_listener.z / distance};
|
||||
const float cosine = dot(pipe_axis, direction);
|
||||
const float directivity = (1.0f - directivity_balance) + directivity_balance * cosine;
|
||||
last_mono_pressure_ = std::tanh(smooth_derivative_ * gain * directivity / (4.0f * 3.14159265358979323846f * distance));
|
||||
|
||||
const float air_cutoff = std::max(min_air_cutoff,
|
||||
20000.0f * std::exp(-distance * air_absorption_exponent));
|
||||
const float filtered = one_pole(last_mono_pressure_, air_cutoff, air_filter_);
|
||||
|
||||
const Vec3 ear_offset{listener_right.x * ear_spacing * 0.5f,
|
||||
listener_right.y * ear_spacing * 0.5f,
|
||||
listener_right.z * ear_spacing * 0.5f};
|
||||
const Vec3 left_ear = subtract(listener, ear_offset);
|
||||
const Vec3 right_ear{listener.x + ear_offset.x, listener.y + ear_offset.y, listener.z + ear_offset.z};
|
||||
const float left_distance = std::max(length(subtract(left_ear, pipe_end)), 0.1f);
|
||||
const float right_distance = std::max(length(subtract(right_ear, pipe_end)), 0.1f);
|
||||
left_offset_ = std::min(left_delay_.size() - 1, static_cast<std::size_t>(left_distance / speed_of_sound / dt_));
|
||||
right_offset_ = std::min(right_delay_.size() - 1, static_cast<std::size_t>(right_distance / speed_of_sound / dt_));
|
||||
|
||||
const auto delay_sample = [this, filtered](std::vector<float>& buffer, std::size_t offset, float ear_distance) {
|
||||
const std::size_t read = (write_position_ + buffer.size() - offset) % buffer.size();
|
||||
const float value = buffer[read];
|
||||
buffer[write_position_] = filtered * (length(subtract(listener, pipe_end)) / ear_distance);
|
||||
return value;
|
||||
};
|
||||
float left = delay_sample(left_delay_, left_offset_, left_distance);
|
||||
float right = delay_sample(right_delay_, right_offset_, right_distance);
|
||||
write_position_ = (write_position_ + 1) % left_delay_.size();
|
||||
if (left_distance > right_distance) left = one_pole(left, head_shadow_cutoff, shadow_left_);
|
||||
else if (right_distance > left_distance) right = one_pole(right, head_shadow_cutoff, shadow_right_);
|
||||
return {left, right};
|
||||
}
|
||||
|
||||
float RadiationSource::dot(Vec3 a, Vec3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
|
||||
float RadiationSource::length(Vec3 v) { return std::sqrt(v.x * v.x + v.y * v.y + v.z * v.z); }
|
||||
Vec3 RadiationSource::subtract(Vec3 a, Vec3 b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; }
|
||||
|
||||
float RadiationSource::one_pole(float input, float cutoff, float& state) const {
|
||||
const float angular = 2.0f * 3.14159265358979323846f * cutoff / static_cast<float>(sample_rate_);
|
||||
const float alpha = 1.0f - std::exp(-angular);
|
||||
state += alpha * (input - state);
|
||||
return state;
|
||||
}
|
||||
|
||||
} // namespace fluidsim::port
|
||||
102
src/crankcase.cpp
Normal file
102
src/crankcase.cpp
Normal file
@@ -0,0 +1,102 @@
|
||||
#include "FluidSim/Crankcase.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace fluidsim::port {
|
||||
namespace {
|
||||
constexpr float cv = gas_constant / (gamma - 1.0f);
|
||||
}
|
||||
|
||||
Crankcase::Crankcase(Crankshaft& crankshaft, float crank_radius, float connecting_rod_length,
|
||||
float bore, float clearance_volume, float initial_pressure,
|
||||
float initial_temperature)
|
||||
: crankshaft_(crankshaft), crank_radius_(crank_radius),
|
||||
connecting_rod_length_(connecting_rod_length),
|
||||
piston_area_(pi * 0.25f * bore * bore), clearance_volume_(clearance_volume),
|
||||
obliquity_(crank_radius / connecting_rod_length) {
|
||||
const float theta = crankshaft_.angle;
|
||||
const float sine = std::sin(theta), cosine = std::cos(theta);
|
||||
const float term = std::sqrt(std::max(1.0f - obliquity_ * obliquity_ * sine * sine, 0.0f));
|
||||
const float displacement = crank_radius_ * (1.0f - cosine) + connecting_rod_length_ * (1.0f - term);
|
||||
const float maximum_volume = clearance_volume_ + piston_area_ * 2.0f * crank_radius_;
|
||||
volume_ = maximum_volume - piston_area_ * displacement;
|
||||
previous_volume_ = volume_;
|
||||
pressure_ = initial_pressure;
|
||||
temperature_ = initial_temperature;
|
||||
mass_ = initial_pressure * volume_ / (gas_constant * initial_temperature);
|
||||
energy_ = mass_ * cv * initial_temperature;
|
||||
intake_port.owner_air_mass = transfer_port.owner_air_mass = &mass_;
|
||||
sync_ports();
|
||||
}
|
||||
|
||||
void Crankcase::pre_step(float /*dt*/) {
|
||||
previous_volume_ = volume_;
|
||||
const float theta = std::fmod(crankshaft_.angle, 2.0f * pi);
|
||||
const float sine = std::sin(theta), cosine = std::cos(theta);
|
||||
const float term = std::sqrt(std::max(1.0f - obliquity_ * obliquity_ * sine * sine, 0.0f));
|
||||
const float displacement = crank_radius_ * (1.0f - cosine) + connecting_rod_length_ * (1.0f - term);
|
||||
const float maximum_volume = clearance_volume_ + piston_area_ * 2.0f * crank_radius_;
|
||||
volume_ = std::max(maximum_volume - piston_area_ * displacement, 1e-12f);
|
||||
if (mass_ > 1e-12f) {
|
||||
temperature_ = energy_ / (mass_ * cv);
|
||||
pressure_ = mass_ * gas_constant * temperature_ / volume_;
|
||||
}
|
||||
sync_ports();
|
||||
}
|
||||
|
||||
void Crankcase::update_state(float dt) {
|
||||
float delta_mass = 0.0f, delta_air_mass = 0.0f, delta_energy = 0.0f;
|
||||
for (Port* port : {&intake_port, &transfer_port}) {
|
||||
const float flow = port->mass_flow;
|
||||
const float fraction = flow >= 0.0f ? port->air_fraction : air_fraction_;
|
||||
delta_mass += flow * dt;
|
||||
delta_air_mass += flow * fraction * dt;
|
||||
delta_energy += flow * port->enthalpy * dt;
|
||||
}
|
||||
const float old_air_mass = mass_ * air_fraction_;
|
||||
mass_ += delta_mass;
|
||||
energy_ += delta_energy;
|
||||
if (mass_ > 1e-12f) air_fraction_ = std::clamp((old_air_mass + delta_air_mass) / mass_, 0.0f, 1.0f);
|
||||
|
||||
const float volume_change = volume_ - previous_volume_;
|
||||
energy_ -= pressure_ * volume_change;
|
||||
if (mass_ < 1e-9f) {
|
||||
mass_ = 1e-9f;
|
||||
energy_ = mass_ * cv * 300.0f;
|
||||
air_fraction_ = 1.0f;
|
||||
}
|
||||
if (energy_ < 0.0f) energy_ = mass_ * cv * 300.0f;
|
||||
|
||||
temperature_ = energy_ / (mass_ * cv);
|
||||
pressure_ = mass_ * gas_constant * temperature_ / std::max(volume_, 1e-12f);
|
||||
if (pressure_ < 1.0f) {
|
||||
temperature_ = volume_ / (mass_ * gas_constant);
|
||||
energy_ = mass_ * cv * temperature_;
|
||||
pressure_ = 1.0f;
|
||||
}
|
||||
constexpr float max_pressure = 5.0e5f;
|
||||
if (pressure_ > max_pressure) {
|
||||
const float target_mass = max_pressure * volume_ / (gas_constant * temperature_);
|
||||
if (mass_ > target_mass) {
|
||||
mass_ = target_mass;
|
||||
energy_ = mass_ * cv * temperature_;
|
||||
pressure_ = max_pressure;
|
||||
}
|
||||
}
|
||||
sync_ports();
|
||||
}
|
||||
|
||||
void Crankcase::sync_ports() {
|
||||
const float rho = mass_ / std::max(volume_, 1e-12f);
|
||||
const float enthalpy = gamma / (gamma - 1.0f) * pressure_ / std::max(rho, 1e-12f);
|
||||
for (Port* port : {&intake_port, &transfer_port}) {
|
||||
port->pressure = pressure_;
|
||||
port->density = rho;
|
||||
port->temperature = temperature_;
|
||||
port->enthalpy = enthalpy;
|
||||
port->air_fraction = air_fraction_;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace fluidsim::port
|
||||
402
src/desktop_main.cpp
Normal file
402
src/desktop_main.cpp
Normal 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;
|
||||
}
|
||||
261
src/engine.cpp
Normal file
261
src/engine.cpp
Normal file
@@ -0,0 +1,261 @@
|
||||
#include "FluidSim/Engine.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace fluidsim::port {
|
||||
|
||||
void Crankshaft::add_torque(float torque) {
|
||||
external_torque_ += torque;
|
||||
}
|
||||
|
||||
void Crankshaft::step(float dt) {
|
||||
if (!std::isfinite(omega)) omega = 0.0f;
|
||||
if (!std::isfinite(external_torque_)) external_torque_ = 0.0f;
|
||||
previous_angle = angle;
|
||||
const float sign = omega > 0.0f ? 1.0f : omega < 0.0f ? -1.0f : 0.0f;
|
||||
const float friction = friction_constant * sign + friction_viscous * omega;
|
||||
const float net_torque = external_torque_ - friction;
|
||||
const float total_torque = net_torque - std::max(load_torque, 0.0f) + drivetrain_torque;
|
||||
omega = std::max(omega + total_torque / std::max(inertia, 1e-6f) * dt, 0.0f);
|
||||
angle = std::fmod(angle + omega * dt, 4.0f * pi);
|
||||
const float instant_power = total_torque * omega;
|
||||
if (power_count_ == power_buffer_.size()) power_sum_ -= power_buffer_[power_index_];
|
||||
else ++power_count_;
|
||||
power_buffer_[power_index_] = instant_power;
|
||||
power_sum_ += instant_power;
|
||||
power_index_ = (power_index_ + 1) % power_buffer_.size();
|
||||
average_power = power_sum_ / static_cast<float>(power_count_);
|
||||
|
||||
if (torque_count_ == torque_buffer_.size()) torque_sum_ -= torque_buffer_[torque_index_];
|
||||
else ++torque_count_;
|
||||
torque_buffer_[torque_index_] = total_torque;
|
||||
torque_sum_ += total_torque;
|
||||
torque_index_ = (torque_index_ + 1) % torque_buffer_.size();
|
||||
average_torque = torque_sum_ / static_cast<float>(torque_count_);
|
||||
external_torque_ = 0.0f;
|
||||
drivetrain_torque = 0.0f;
|
||||
}
|
||||
|
||||
float EngineCylinder::swept_volume() const {
|
||||
return pi * 0.25f * bore * bore * stroke;
|
||||
}
|
||||
|
||||
float EngineCylinder::clearance_volume() const {
|
||||
return swept_volume() / (compression - 1.0f);
|
||||
}
|
||||
|
||||
float EngineCylinder::total_mass() const {
|
||||
return air_mass + exhaust_mass;
|
||||
}
|
||||
|
||||
float EngineCylinder::temperature() const {
|
||||
const float density = total_mass() / std::max(volume, 1e-12f);
|
||||
return pressure / std::max(density * gas_constant, 1e-12f);
|
||||
}
|
||||
|
||||
float EngineCylinder::air_fraction() const {
|
||||
return air_mass / std::max(total_mass(), 1e-12f);
|
||||
}
|
||||
|
||||
float EngineCylinder::compute_volume(float theta) const {
|
||||
const float radius = stroke * 0.5f;
|
||||
const float obliquity = radius / rod;
|
||||
const float sine = std::sin(theta), cosine = std::cos(theta);
|
||||
const float term = std::sqrt(std::max(1.0f - obliquity * obliquity * sine * sine, 0.0f));
|
||||
const float piston_travel = radius * (1.0f - cosine) + rod * (1.0f - term);
|
||||
return clearance_volume() + pi * 0.25f * bore * bore * piston_travel;
|
||||
}
|
||||
|
||||
void EngineCylinder::initialize(const Crankshaft& /*crank*/) {
|
||||
// The C# EngineCylinder constructor starts every cylinder at TDC with its
|
||||
// clearance volume. PhaseOffset is applied on the first PreStep, after the
|
||||
// conserved state has been initialized; using phase here would silently
|
||||
// change the initial state of phased cylinders (notably the second twin).
|
||||
volume = clearance_volume();
|
||||
air_mass = 1.225f * volume;
|
||||
exhaust_mass = 0.0f;
|
||||
energy = 101325.0f * volume / (gamma - 1.0f);
|
||||
pressure = 101325.0f;
|
||||
intake.owner_air_mass = exhaust.owner_air_mass = &air_mass;
|
||||
intake.owner_exhaust_mass = exhaust.owner_exhaust_mass = &exhaust_mass;
|
||||
sync_ports();
|
||||
}
|
||||
|
||||
float FourStrokeCylinder::valve_area(float cycle_degrees, bool inlet) const {
|
||||
const float opens = inlet ? intake_open : exhaust_open;
|
||||
float closes = inlet ? intake_close : exhaust_close;
|
||||
const float peak_lift = inlet ? intake_valve_lift : exhaust_valve_lift;
|
||||
const float diameter = inlet ? intake_valve_diameter : exhaust_valve_diameter;
|
||||
if (closes < opens) closes += 720.0f;
|
||||
float mapped = std::fmod(cycle_degrees, 720.0f);
|
||||
if (mapped < 0.0f) mapped += 720.0f;
|
||||
if (mapped < opens) mapped += 720.0f;
|
||||
const float duration = closes - opens;
|
||||
if (mapped < opens || mapped > closes || duration <= 0.0f) return 0.0f;
|
||||
|
||||
const float ramp = duration * 0.25f;
|
||||
const float hold = duration - 2.0f * ramp;
|
||||
float lift = 0.0f;
|
||||
if (mapped < opens + ramp) {
|
||||
const float t = (mapped - opens) / ramp;
|
||||
lift = peak_lift * t * t * (3.0f - 2.0f * t);
|
||||
} else if (mapped < opens + ramp + hold) {
|
||||
lift = peak_lift;
|
||||
} else if (ramp > 0.0f) {
|
||||
const float t = (mapped - (opens + ramp + hold)) / ramp;
|
||||
lift = peak_lift * (1.0f - t) * (1.0f - t) * (1.0f + 2.0f * t);
|
||||
}
|
||||
return pi * diameter * lift;
|
||||
}
|
||||
|
||||
void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*/) {
|
||||
const float rpm = crank.omega * 60.0f / (2.0f * pi);
|
||||
spark_advance = std::clamp(10.0f + rpm * 0.002f, 5.0f, 40.0f);
|
||||
update_rev_limiter(rpm, dt);
|
||||
|
||||
const float theta = crank.angle + phase;
|
||||
const float previous_volume = volume;
|
||||
volume = compute_volume(theta);
|
||||
pressure = (gamma - 1.0f) * energy / std::max(volume, 1e-12f);
|
||||
const float dvolume = volume - previous_volume;
|
||||
const float radius = stroke * 0.5f, obliquity = radius / rod;
|
||||
const float sine = std::sin(theta), cosine = std::cos(theta);
|
||||
const float term = std::sqrt(std::max(1.0f - obliquity * obliquity * sine * sine, 1e-12f));
|
||||
const float dx_dtheta = radius * sine * (1.0f + obliquity * cosine / term);
|
||||
const float piston_area = pi * 0.25f * bore * bore;
|
||||
crank.add_torque((pressure - 101325.0f) * piston_area * dx_dtheta);
|
||||
energy -= pressure * dvolume;
|
||||
|
||||
const float previous_degrees = wrap_degrees((crank.previous_angle + phase) * 180.0f / pi);
|
||||
const float current_degrees = wrap_degrees(theta * 180.0f / pi);
|
||||
if (crossed_window_close(previous_degrees, current_degrees, intake_open, intake_close)) {
|
||||
fuel_mass = air_mass / std::max(afr, 1e-6f);
|
||||
fuel_injected = true;
|
||||
}
|
||||
|
||||
const float spark_angle = wrap_degrees(720.0f - spark_advance);
|
||||
const bool crossed_spark = (previous_degrees < spark_angle && current_degrees >= spark_angle) ||
|
||||
(previous_degrees > spark_angle && current_degrees < spark_angle);
|
||||
if (crossed_spark && fuel_injected && !combustion_active && !rev_limit_active && random_unit() >= misfire_probability) {
|
||||
combustion_active = true;
|
||||
burn_fraction = 0.0f;
|
||||
energy_factor_ = 1.0f + energy_variation * (2.0f * random_unit() - 1.0f);
|
||||
}
|
||||
|
||||
if (combustion_active) {
|
||||
float angle_since_spark = current_degrees - spark_angle;
|
||||
if (angle_since_spark < 0.0f) angle_since_spark += 720.0f;
|
||||
const float phi = (angle_since_spark - burn_start) / std::max(burn_duration, 1e-6f);
|
||||
const float burned = phi <= 0.0f ? 0.0f : 1.0f - std::exp(-wiebe_a * std::pow(phi, wiebe_m + 1.0f));
|
||||
float fraction = std::max(burn_fraction, std::clamp(burned, 0.0f, 1.0f));
|
||||
if (fraction >= 1.0f || angle_since_spark > burn_duration + burn_start + spark_advance) {
|
||||
fraction = 1.0f;
|
||||
combustion_active = false;
|
||||
exhaust_mass += air_mass;
|
||||
air_mass = 0.0f;
|
||||
}
|
||||
fuel_injected = false;
|
||||
const float increment = fraction - burn_fraction;
|
||||
if (increment > 0.0f) {
|
||||
energy += fuel_mass * fuel_lhv * energy_factor_ * increment;
|
||||
exhaust_mass += fuel_mass * increment;
|
||||
burn_fraction = fraction;
|
||||
}
|
||||
}
|
||||
|
||||
if (rev_limit_active) {
|
||||
fuel_mass = 0.0f;
|
||||
fuel_injected = false;
|
||||
combustion_active = false;
|
||||
}
|
||||
const float heat_loss = heat_transfer * wall_area * (temperature() - ambient_temperature) * dt;
|
||||
energy -= heat_loss;
|
||||
sync_ports();
|
||||
}
|
||||
|
||||
void EngineCylinder::update_state(float dt) {
|
||||
float delta_air = 0.0f, delta_exhaust = 0.0f, delta_energy = 0.0f;
|
||||
for (Port* port : {&intake, &exhaust}) {
|
||||
const float mass_flow = port->mass_flow;
|
||||
const float fraction = mass_flow >= 0.0f ? port->air_fraction : air_fraction();
|
||||
delta_air += mass_flow * fraction * dt;
|
||||
delta_exhaust += mass_flow * (1.0f - fraction) * dt;
|
||||
delta_energy += mass_flow * port->enthalpy * dt;
|
||||
}
|
||||
air_mass += delta_air;
|
||||
exhaust_mass += delta_exhaust;
|
||||
energy += delta_energy;
|
||||
|
||||
const float current_volume = std::max(volume, 1e-12f);
|
||||
float current_pressure = (gamma - 1.0f) * energy / current_volume;
|
||||
if (current_pressure > 200.0e5f) {
|
||||
energy = 200.0e5f * current_volume / (gamma - 1.0f);
|
||||
current_pressure = 200.0e5f;
|
||||
}
|
||||
const float current_density = total_mass() / current_volume;
|
||||
const float current_temperature = current_pressure / std::max(current_density * gas_constant, 1e-12f);
|
||||
if (current_temperature > 3500.0f) {
|
||||
const float pressure_at_limit = current_density * gas_constant * 3500.0f;
|
||||
energy = pressure_at_limit * current_volume / (gamma - 1.0f);
|
||||
}
|
||||
if (total_mass() < 1e-9f) {
|
||||
air_mass = 1e-9f;
|
||||
exhaust_mass = 0.0f;
|
||||
energy = 101325.0f * current_volume / (gamma - 1.0f);
|
||||
} else if (energy < 0.0f) {
|
||||
energy = 101325.0f * current_volume / (gamma - 1.0f);
|
||||
}
|
||||
air_mass = std::max(air_mass, 0.0f);
|
||||
exhaust_mass = std::max(exhaust_mass, 0.0f);
|
||||
pressure = (gamma - 1.0f) * energy / current_volume;
|
||||
sync_ports();
|
||||
}
|
||||
|
||||
float EngineCylinder::random_unit() {
|
||||
return unit_(random_);
|
||||
}
|
||||
|
||||
float EngineCylinder::wrap_degrees(float value, float cycle_degrees) {
|
||||
value = std::fmod(value, cycle_degrees);
|
||||
return value < 0.0f ? value + cycle_degrees : value;
|
||||
}
|
||||
|
||||
bool EngineCylinder::crossed_window_close(float previous, float current, float open, float close) {
|
||||
return previous >= open && previous < close && current >= close;
|
||||
}
|
||||
|
||||
void EngineCylinder::update_rev_limiter(float rpm, float dt) {
|
||||
if (!rev_limit_enabled) {
|
||||
rev_limit_active = false;
|
||||
rev_limit_timer_ = 0.0f;
|
||||
return;
|
||||
}
|
||||
if (rev_limit_active) {
|
||||
rev_limit_timer_ -= dt;
|
||||
if (rev_limit_timer_ <= 0.0f) {
|
||||
if (rpm >= rev_limit_rpm - rev_limit_hysteresis) rev_limit_timer_ = rev_limit_cut_time;
|
||||
else rev_limit_active = false;
|
||||
}
|
||||
} else if (rpm >= rev_limit_rpm) {
|
||||
rev_limit_active = true;
|
||||
rev_limit_timer_ = rev_limit_cut_time;
|
||||
}
|
||||
}
|
||||
|
||||
void EngineCylinder::sync_ports() {
|
||||
const float density = total_mass() / std::max(volume, 1e-12f);
|
||||
pressure = (gamma - 1.0f) * energy / std::max(volume, 1e-12f);
|
||||
const float temp = pressure / std::max(density * gas_constant, 1e-12f);
|
||||
const float fraction = air_fraction();
|
||||
const float enthalpy = gamma / (gamma - 1.0f) * pressure / std::max(density, 1e-12f);
|
||||
for (Port* port : {&intake, &exhaust}) {
|
||||
port->pressure = pressure;
|
||||
port->density = density;
|
||||
port->temperature = temp;
|
||||
port->air_fraction = fraction;
|
||||
port->enthalpy = enthalpy;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace fluidsim::port
|
||||
25
src/helmholtz_scenario.cpp
Normal file
25
src/helmholtz_scenario.cpp
Normal file
@@ -0,0 +1,25 @@
|
||||
#include "FluidSim/HelmholtzScenario.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace fluidsim::port {
|
||||
|
||||
HelmholtzScenario::HelmholtzScenario(int sample_rate)
|
||||
: pipes_{}, dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))),
|
||||
neck_area_(pi * 0.25f * 0.020f * 0.020f), cavity_orifice_area_(neck_area_),
|
||||
boundaries_(pipes_), solver_(pipes_, boundaries_), sound_(sample_rate, 1.0f) {
|
||||
pipes_.emplace_back(neck_area_, 0.050f, 20);
|
||||
cavity_port_ = &cavity_.create_port();
|
||||
boundaries_.add_orifice_with_inertance(*cavity_port_, 0, true, cavity_orifice_area_,
|
||||
0.9f, 0.050f);
|
||||
boundaries_.add_open_end(0, false, 101325.0f, neck_area_);
|
||||
solver_.minimum_substeps = 8;
|
||||
solver_.add_component(cavity_);
|
||||
}
|
||||
|
||||
float HelmholtzScenario::process() {
|
||||
solver_.step(dt_);
|
||||
return sound_.process(boundaries_.open_end_mass_flow(0));
|
||||
}
|
||||
|
||||
} // namespace fluidsim::port
|
||||
95
src/main.cpp
Normal file
95
src/main.cpp
Normal file
@@ -0,0 +1,95 @@
|
||||
#include "FluidSim/Scenario.hpp"
|
||||
#include "FluidSim/HelmholtzScenario.hpp"
|
||||
#include "FluidSim/PulseTestScenario.hpp"
|
||||
#include "FluidSim/TwoStrokeScenario.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
using namespace fluidsim::port;
|
||||
float duration_seconds = 1.0f;
|
||||
float throttle = 0.35f;
|
||||
std::string scenario_name = "twin";
|
||||
for (int i = 1; i + 1 < argc; ++i) {
|
||||
const std::string option = argv[i];
|
||||
if (option == "--seconds") duration_seconds = std::max(0.0f, std::strtof(argv[++i], nullptr));
|
||||
else if (option == "--throttle") throttle = std::clamp(std::strtof(argv[++i], nullptr), 0.0f, 1.0f);
|
||||
else if (option == "--scenario") scenario_name = argv[++i];
|
||||
}
|
||||
|
||||
constexpr int sample_rate = 44100;
|
||||
const auto total_samples = static_cast<std::uint64_t>(duration_seconds * sample_rate);
|
||||
const auto report_interval = static_cast<std::uint64_t>(sample_rate / 10);
|
||||
if (scenario_name == "twin") {
|
||||
FourStrokeTwinScenario scenario;
|
||||
scenario.throttle = throttle;
|
||||
std::cout << "FluidSimCpp | 600 cc parallel twin | throttle " << std::fixed << std::setprecision(0)
|
||||
<< throttle * 100.0f << "%\n";
|
||||
for (std::uint64_t i = 0; i < total_samples; ++i) {
|
||||
scenario.process();
|
||||
if ((i + 1) % report_interval == 0 || i + 1 == total_samples) {
|
||||
std::cout << std::fixed << std::setprecision(1)
|
||||
<< "t=" << static_cast<float>(i + 1) / sample_rate << " s"
|
||||
<< " rpm=" << scenario.crankshaft.omega * 60.0f / (2.0f * pi)
|
||||
<< " speed=" << scenario.vehicle.speed * 3.6f << " km/h"
|
||||
<< " gear=" << scenario.vehicle.current_gear
|
||||
<< " cyl=" << scenario.cylinder1.pressure / 1000.0f << "/"
|
||||
<< scenario.cylinder2.pressure / 1000.0f << " kPa"
|
||||
<< " intake=" << scenario.intake_plenum.pressure() / 1000.0f << " kPa"
|
||||
<< " exhaust=" << scenario.exhaust_collector.pressure() / 1000.0f << " kPa\n";
|
||||
}
|
||||
}
|
||||
} else if (scenario_name == "two-stroke") {
|
||||
TwoStrokeScenario scenario;
|
||||
scenario.set_throttle(throttle);
|
||||
std::cout << "FluidSimCpp | 125 cc crankcase-scavenged two-stroke | throttle "
|
||||
<< std::fixed << std::setprecision(0) << throttle * 100.0f << "%\n";
|
||||
for (std::uint64_t i = 0; i < total_samples; ++i) {
|
||||
scenario.process();
|
||||
if ((i + 1) % report_interval == 0 || i + 1 == total_samples) {
|
||||
const auto state = scenario.snapshot();
|
||||
std::cout << std::fixed << std::setprecision(1)
|
||||
<< "t=" << static_cast<float>(i + 1) / sample_rate << " s rpm=" << state.rpm
|
||||
<< " cyl=" << state.cylinder_pressure / 100000.0f << " bar"
|
||||
<< " crankcase=" << state.crankcase_pressure / 1000.0f << " kPa"
|
||||
<< " intake=" << state.intake_pressure / 1000.0f << " kPa"
|
||||
<< " reed=" << (state.reed_open ? "open" : "closed") << '\n';
|
||||
}
|
||||
}
|
||||
} else if (scenario_name == "helmholtz") {
|
||||
HelmholtzScenario scenario;
|
||||
std::cout << "FluidSimCpp | Helmholtz resonator | initial cavity 1.1 bar\n";
|
||||
for (std::uint64_t i = 0; i < total_samples; ++i) {
|
||||
const float sample = scenario.process();
|
||||
if ((i + 1) % report_interval == 0 || i + 1 == total_samples)
|
||||
std::cout << std::fixed << std::setprecision(1)
|
||||
<< "t=" << static_cast<float>(i + 1) / sample_rate << " s"
|
||||
<< " cavity=" << scenario.cavity_pressure() / 1000.0f << " kPa"
|
||||
<< " flow=" << scenario.open_end_flow() << " kg/s"
|
||||
<< " audio=" << sample << '\n';
|
||||
}
|
||||
} else if (scenario_name == "pulse") {
|
||||
PulseTestScenario scenario;
|
||||
std::cout << "FluidSimCpp | pressure pulse in a 2 m pipe\n";
|
||||
for (std::uint64_t i = 0; i < total_samples; ++i) {
|
||||
const float sample = scenario.process();
|
||||
if ((i + 1) % report_interval == 0 || i + 1 == total_samples)
|
||||
std::cout << std::fixed << std::setprecision(1)
|
||||
<< "t=" << static_cast<float>(i + 1) / sample_rate << " s"
|
||||
<< " left=" << scenario.pipe().state(0).pressure / 1000.0f << " kPa"
|
||||
<< " right=" << scenario.pipe().state(199).pressure / 1000.0f << " kPa"
|
||||
<< " flow=" << scenario.pipe().state(199).velocity << " m/s"
|
||||
<< " audio=" << sample << '\n';
|
||||
}
|
||||
} else {
|
||||
std::cerr << "Unknown scenario '" << scenario_name
|
||||
<< "'. Choose twin, two-stroke, helmholtz, or pulse.\n";
|
||||
return 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
132
src/mechanical_sound_processor.cpp
Normal file
132
src/mechanical_sound_processor.cpp
Normal file
@@ -0,0 +1,132 @@
|
||||
#include "FluidSim/MechanicalSoundProcessor.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace fluidsim::port {
|
||||
namespace {
|
||||
constexpr float two_pi = 2.0f * pi;
|
||||
constexpr float closed_threshold = 1e-7f;
|
||||
}
|
||||
|
||||
MechanicalSoundProcessor::Resonator::Resonator(float frequency_hz, float q, float sample_rate) {
|
||||
const float omega = two_pi * frequency_hz / sample_rate;
|
||||
const float radius = std::exp(-omega / (2.0f * q));
|
||||
b1_ = 2.0f * radius * std::cos(omega);
|
||||
b2_ = -radius * radius;
|
||||
gain_ = std::sin(omega);
|
||||
}
|
||||
float MechanicalSoundProcessor::Resonator::process(float input) {
|
||||
const float output = input * gain_ + b1_ * y1_ + b2_ * y2_;
|
||||
y2_ = y1_;
|
||||
y1_ = output;
|
||||
return output;
|
||||
}
|
||||
|
||||
MechanicalSoundProcessor::OnePoleLowPass::OnePoleLowPass(float cutoff_hz, float sample_rate) {
|
||||
alpha_ = 1.0f - std::exp(-(two_pi * cutoff_hz / sample_rate));
|
||||
}
|
||||
float MechanicalSoundProcessor::OnePoleLowPass::process(float input) {
|
||||
state_ += alpha_ * (input - state_);
|
||||
return state_;
|
||||
}
|
||||
|
||||
MechanicalSoundProcessor::MechanicalSoundProcessor(int sample_rate)
|
||||
: dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))),
|
||||
intake_tick_(4200.0f, 20.0f, static_cast<float>(std::max(sample_rate, 1))),
|
||||
exhaust_tick_(3400.0f, 20.0f, static_cast<float>(std::max(sample_rate, 1))),
|
||||
slap_filter_(450.0f, static_cast<float>(std::max(sample_rate, 1))),
|
||||
combustion_filter_(600.0f, static_cast<float>(std::max(sample_rate, 1))),
|
||||
chain_filter_(1500.0f, static_cast<float>(std::max(sample_rate, 1))) {
|
||||
slap_decay_ = std::exp(-dt_ / 0.018f);
|
||||
combustion_decay_ = std::exp(-dt_ / 0.025f);
|
||||
chain_decay_ = std::exp(-dt_ / 0.005f);
|
||||
}
|
||||
|
||||
float MechanicalSoundProcessor::process(const Crankshaft& crank,
|
||||
const EngineCylinder& cylinder, float load) {
|
||||
const float rpm = crank.omega * 60.0f / two_pi;
|
||||
const float rpm_norm = std::clamp(std::abs(rpm) / 7000.0f, 0.0f, 1.5f);
|
||||
const float normalized_load = std::clamp(load, 0.0f, 1.0f);
|
||||
const float degrees = std::fmod((crank.angle + cylinder.phase) * 180.0f / pi, 720.0f);
|
||||
const float intake_area = cylinder.valve_area(degrees, true);
|
||||
const float exhaust_area = cylinder.valve_area(degrees, false);
|
||||
const float pressure = cylinder.pressure;
|
||||
const float pressure_rate = (pressure - previous_pressure_) / dt_;
|
||||
previous_pressure_ = pressure;
|
||||
|
||||
float cycle_position = (crank.angle + cylinder.phase) / (4.0f * pi);
|
||||
cycle_position -= std::floor(cycle_position);
|
||||
const float previous_cycle = previous_cycle_position_ < 0.0f ? cycle_position : previous_cycle_position_;
|
||||
float crank_fraction = crank.angle / two_pi;
|
||||
crank_fraction -= std::floor(crank_fraction);
|
||||
const float previous_crank = previous_crank_fraction_ < 0.0f ? crank_fraction : previous_crank_fraction_;
|
||||
|
||||
float sample = 0.0f;
|
||||
float intake_impulse = 0.0f, exhaust_impulse = 0.0f;
|
||||
const bool intake_opened = previous_intake_area_ <= closed_threshold && intake_area > closed_threshold;
|
||||
const bool intake_closed = previous_intake_area_ > closed_threshold && intake_area <= closed_threshold;
|
||||
const bool exhaust_opened = previous_exhaust_area_ <= closed_threshold && exhaust_area > closed_threshold;
|
||||
const bool exhaust_closed = previous_exhaust_area_ > closed_threshold && exhaust_area <= closed_threshold;
|
||||
const float open_amplitude = 0.20f + 0.30f * rpm_norm;
|
||||
const float close_amplitude = 0.45f + 0.85f * rpm_norm;
|
||||
if (intake_opened) intake_impulse += random_sample() * open_amplitude;
|
||||
if (intake_closed) intake_impulse += random_sample() * close_amplitude * 1.15f;
|
||||
if (exhaust_opened) exhaust_impulse += random_sample() * open_amplitude * 1.10f;
|
||||
if (exhaust_closed) exhaust_impulse += random_sample() * close_amplitude * 1.30f;
|
||||
sample += intake_tick_.process(intake_impulse) * valve_tick_gain;
|
||||
sample += exhaust_tick_.process(exhaust_impulse) * valve_tick_gain;
|
||||
|
||||
if (previous_cycle_position_ >= 0.0f) {
|
||||
float amplitude = 0.0f;
|
||||
if (crossed_fraction(previous_cycle, cycle_position, 0.0f))
|
||||
amplitude = (0.35f + 0.65f * rpm_norm) * (0.4f + 0.6f * normalized_load);
|
||||
else if (crossed_fraction(previous_cycle, cycle_position, 0.5f))
|
||||
amplitude = (0.10f + 0.20f * rpm_norm) * 0.7f;
|
||||
else if (crossed_fraction(previous_cycle, cycle_position, 0.25f) ||
|
||||
crossed_fraction(previous_cycle, cycle_position, 0.75f))
|
||||
amplitude = (0.08f + 0.18f * rpm_norm) * 0.8f;
|
||||
slap_envelope_ = std::max(slap_envelope_, amplitude);
|
||||
}
|
||||
sample += slap_filter_.process(random_sample() * slap_envelope_) * piston_slap_gain;
|
||||
slap_envelope_ *= slap_decay_;
|
||||
|
||||
const bool power_stroke = cycle_position < 0.25f || cycle_position > 0.95f;
|
||||
if (power_stroke && pressure_rate > 2e5f) {
|
||||
const float drive = std::clamp(pressure_rate * 1.2e-7f, 0.0f, 3.0f);
|
||||
combustion_envelope_ = std::max(combustion_envelope_, drive * (0.3f + 0.7f * normalized_load));
|
||||
}
|
||||
sample += combustion_filter_.process(random_sample() * combustion_envelope_) * combustion_gain;
|
||||
combustion_envelope_ *= combustion_decay_;
|
||||
|
||||
if (previous_crank_fraction_ >= 0.0f &&
|
||||
(crossed_fraction(previous_crank, crank_fraction, 0.0f) ||
|
||||
crossed_fraction(previous_crank, crank_fraction, 0.5f)))
|
||||
chain_envelope_ += 0.15f + 0.25f * rpm_norm;
|
||||
sample += chain_filter_.process(random_sample() * chain_envelope_) * cam_chain_gain;
|
||||
chain_envelope_ *= chain_decay_;
|
||||
|
||||
const float crank_hz = std::abs(rpm) / 60.0f;
|
||||
gear_phase1_ = std::fmod(gear_phase1_ + two_pi * crank_hz * 15.0f * dt_, two_pi);
|
||||
gear_phase2_ = std::fmod(gear_phase2_ + two_pi * crank_hz * 30.2f * dt_, two_pi);
|
||||
gear_phase3_ = std::fmod(gear_phase3_ + two_pi * crank_hz * 45.4f * dt_, two_pi);
|
||||
const float gear_tone = std::sin(gear_phase1_) * 0.5f +
|
||||
std::sin(gear_phase2_) * 0.3f + std::sin(gear_phase3_) * 0.2f;
|
||||
sample += gear_tone * gear_whine_gain * rpm_norm;
|
||||
|
||||
previous_intake_area_ = intake_area;
|
||||
previous_exhaust_area_ = exhaust_area;
|
||||
previous_cycle_position_ = cycle_position;
|
||||
previous_crank_fraction_ = crank_fraction;
|
||||
return std::tanh(sample * master_gain);
|
||||
}
|
||||
|
||||
float MechanicalSoundProcessor::random_sample() { return unit_(random_); }
|
||||
|
||||
bool MechanicalSoundProcessor::crossed_fraction(float previous, float current, float target) {
|
||||
if (current == previous) return false;
|
||||
return current > previous ? previous < target && target <= current
|
||||
: previous < target || target <= current;
|
||||
}
|
||||
|
||||
} // namespace fluidsim::port
|
||||
127
src/outdoor_exhaust_reverb.cpp
Normal file
127
src/outdoor_exhaust_reverb.cpp
Normal file
@@ -0,0 +1,127 @@
|
||||
#include "FluidSim/OutdoorExhaustReverb.hpp"
|
||||
#include "FluidSim/Engine.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
|
||||
namespace fluidsim::port {
|
||||
|
||||
OutdoorExhaustReverb::DelayLine::DelayLine(std::size_t size) : buffer_(std::max<std::size_t>(size, 1)) {}
|
||||
float OutdoorExhaustReverb::DelayLine::read() const { return buffer_[position_]; }
|
||||
float OutdoorExhaustReverb::DelayLine::read_write(float value) {
|
||||
const float previous = buffer_[position_];
|
||||
buffer_[position_] = value;
|
||||
position_ = (position_ + 1) % buffer_.size();
|
||||
return previous;
|
||||
}
|
||||
void OutdoorExhaustReverb::DelayLine::write(float value) {
|
||||
buffer_[position_] = value;
|
||||
position_ = (position_ + 1) % buffer_.size();
|
||||
}
|
||||
|
||||
OutdoorExhaustReverb::OnePoleLowPass::OnePoleLowPass(float cutoff_hz, int sample_rate) {
|
||||
const float angular = 2.0f * pi * cutoff_hz / static_cast<float>(std::max(sample_rate, 1));
|
||||
alpha_ = 1.0f - std::exp(-angular);
|
||||
}
|
||||
float OutdoorExhaustReverb::OnePoleLowPass::process(float value) {
|
||||
state_ += alpha_ * (value - state_);
|
||||
return state_;
|
||||
}
|
||||
|
||||
OutdoorExhaustReverb::OutdoorExhaustReverb(int sample_rate)
|
||||
: sample_rate_(std::max(sample_rate, 1)), dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))),
|
||||
pre_delay_(delay_samples(sample_rate_, pre_delay_ms)),
|
||||
ground_left_(delay_samples(sample_rate_, ground_delay_ms)),
|
||||
ground_right_(delay_samples(sample_rate_, ground_delay_ms + 1.3f)),
|
||||
wall_left_(delay_samples(sample_rate_, wall_delay_ms)),
|
||||
wall_right_(delay_samples(sample_rate_, wall_delay_ms + 2.2f)),
|
||||
ground_filter_left_(1400.0f, sample_rate_), ground_filter_right_(1400.0f, sample_rate_),
|
||||
wall_filter_left_(3800.0f, sample_rate_), wall_filter_right_(3800.0f, sample_rate_) {
|
||||
constexpr std::array<int, 8> lengths{3203, 4027, 5521, 7027, 8521, 10007, 11503, 13009};
|
||||
const int maximum_delay = std::max(4, sample_rate_ / 4);
|
||||
float average_delay_seconds = 0.0f;
|
||||
fdn_.reserve(lengths.size());
|
||||
low_filters_.reserve(lengths.size());
|
||||
mid_filters_.reserve(lengths.size());
|
||||
for (const int requested : lengths) {
|
||||
const int size = std::min(requested, maximum_delay);
|
||||
fdn_.emplace_back(static_cast<std::size_t>(size));
|
||||
low_filters_.emplace_back(band_low_hz, sample_rate_);
|
||||
mid_filters_.emplace_back(band_high_hz, sample_rate_);
|
||||
average_delay_seconds += static_cast<float>(size) / static_cast<float>(sample_rate_);
|
||||
}
|
||||
average_delay_seconds /= static_cast<float>(lengths.size());
|
||||
gain_low_ = std::pow(10.0f, -3.0f * average_delay_seconds / low_decay_seconds);
|
||||
gain_mid_ = std::pow(10.0f, -3.0f * average_delay_seconds / mid_decay_seconds);
|
||||
gain_high_ = std::pow(10.0f, -3.0f * average_delay_seconds / high_decay_seconds);
|
||||
read_buffer_.resize(lengths.size());
|
||||
mix_buffer_.resize(lengths.size());
|
||||
|
||||
fast_attack_ = 1.0f - std::exp(-dt_ / 0.0015f);
|
||||
fast_release_ = 1.0f - std::exp(-dt_ / 0.015f);
|
||||
slow_attack_ = 1.0f - std::exp(-dt_ / 0.040f);
|
||||
slow_release_ = 1.0f - std::exp(-dt_ / 0.300f);
|
||||
bloom_decay_ = std::exp(-dt_ / (transient_decay_ms * 0.001f));
|
||||
}
|
||||
|
||||
AudioFrame OutdoorExhaustReverb::process_stereo(float input) {
|
||||
const float magnitude = std::abs(input);
|
||||
fast_envelope_ += (magnitude > fast_envelope_ ? fast_attack_ : fast_release_) * (magnitude - fast_envelope_);
|
||||
slow_envelope_ += (magnitude > slow_envelope_ ? slow_attack_ : slow_release_) * (magnitude - slow_envelope_);
|
||||
const float bang = std::tanh(std::max(0.0f, fast_envelope_ - slow_envelope_) * 4.0f);
|
||||
bloom_ = std::max(bloom_ * bloom_decay_, bang);
|
||||
|
||||
const float pre = pre_delay_.read_write(input);
|
||||
const float ground_left = ground_filter_left_.process(ground_left_.read_write(pre));
|
||||
const float ground_right = ground_filter_right_.process(ground_right_.read_write(pre));
|
||||
const float wall_left = wall_filter_left_.process(wall_left_.read_write(pre));
|
||||
const float wall_right = wall_filter_right_.process(wall_right_.read_write(pre));
|
||||
const float early_left = (ground_left * 0.85f + wall_left * 0.45f) * early_mix;
|
||||
const float early_right = (ground_right * 0.85f + wall_right * 0.45f) * early_mix;
|
||||
|
||||
float sum = 0.0f;
|
||||
for (std::size_t i = 0; i < fdn_.size(); ++i) {
|
||||
read_buffer_[i] = fdn_[i].read();
|
||||
sum += read_buffer_[i];
|
||||
}
|
||||
const float mix_factor = 2.0f / static_cast<float>(fdn_.size());
|
||||
for (std::size_t i = 0; i < fdn_.size(); ++i)
|
||||
mix_buffer_[i] = mix_factor * sum - read_buffer_[i];
|
||||
|
||||
const float feedback_boost = 1.0f + transient_feedback_boost * bloom_;
|
||||
const float hf_boost = 1.0f + transient_hf_boost * bloom_;
|
||||
const float effective_low = std::min(gain_low_ * feedback_boost, 0.97f);
|
||||
const float effective_mid = std::min(gain_mid_ * feedback_boost, 0.97f);
|
||||
const float effective_high = std::min(gain_high_ * hf_boost, 0.90f);
|
||||
const float injection = pre * 0.35355339f;
|
||||
const float drive = std::max(saturation_drive, 1e-5f);
|
||||
for (std::size_t i = 0; i < fdn_.size(); ++i) {
|
||||
const float value = mix_buffer_[i];
|
||||
const float low = low_filters_[i].process(value);
|
||||
const float low_mid = mid_filters_[i].process(value);
|
||||
const float mid = low_mid - low;
|
||||
const float high = value - low_mid;
|
||||
const float feedback = low * effective_low + mid * effective_mid + high * effective_high;
|
||||
fdn_[i].write(std::tanh(feedback * drive) / drive + injection);
|
||||
}
|
||||
|
||||
const float tail_left = (read_buffer_[0] + read_buffer_[2] + read_buffer_[4] + read_buffer_[6]) * 0.25f * tail_mix;
|
||||
const float tail_right = (read_buffer_[1] + read_buffer_[3] + read_buffer_[5] + read_buffer_[7]) * 0.25f * tail_mix;
|
||||
return {input * dry_mix + early_left + tail_left,
|
||||
input * dry_mix + early_right + tail_right};
|
||||
}
|
||||
|
||||
float OutdoorExhaustReverb::process(float input) {
|
||||
const AudioFrame result = process_stereo(input);
|
||||
const float value = 0.5f * (result.left + result.right);
|
||||
const float magnitude = std::abs(value);
|
||||
if (magnitude <= 1.0f) return value;
|
||||
return std::copysign(1.0f + (1.0f - std::exp(-(magnitude - 1.0f))), value);
|
||||
}
|
||||
|
||||
std::size_t OutdoorExhaustReverb::delay_samples(int sample_rate, float milliseconds) {
|
||||
return static_cast<std::size_t>(std::max(1, static_cast<int>(std::max(sample_rate, 1) * milliseconds * 0.001f)));
|
||||
}
|
||||
|
||||
} // namespace fluidsim::port
|
||||
34
src/pulse_test_scenario.cpp
Normal file
34
src/pulse_test_scenario.cpp
Normal file
@@ -0,0 +1,34 @@
|
||||
#include "FluidSim/PulseTestScenario.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace fluidsim::port {
|
||||
|
||||
PulseTestScenario::PulseTestScenario(int sample_rate)
|
||||
: pipes_{}, dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))),
|
||||
pipe_area_(pi * 0.25f * 0.020f * 0.020f),
|
||||
boundaries_(pipes_), solver_(pipes_, boundaries_), sound_(sample_rate, 1.0f) {
|
||||
pipes_.emplace_back(pipe_area_, 2.0f, 200);
|
||||
auto& pipe = pipes_.front();
|
||||
pipe.damping_multiplier = 0.0f;
|
||||
pipe.energy_relaxation_rate = 0.0f;
|
||||
pipe.ambient_pressure = 101325.0f;
|
||||
|
||||
const float bubble_pressure = 10.0f * 101325.0f;
|
||||
const float bubble_temperature = 2000.0f;
|
||||
const float bubble_density = bubble_pressure / (gas_constant * bubble_temperature);
|
||||
for (int i = 0; i <= 10; ++i)
|
||||
pipe.cells.at(static_cast<std::size_t>(i)) =
|
||||
{bubble_density, 0.0f, bubble_pressure / (gamma - 1.0f), 1.0f};
|
||||
|
||||
boundaries_.add_closed_end(0, true);
|
||||
boundaries_.add_open_end(0, false, 101325.0f, pipe_area_);
|
||||
solver_.minimum_substeps = 3;
|
||||
}
|
||||
|
||||
float PulseTestScenario::process() {
|
||||
solver_.step(dt_);
|
||||
return sound_.process(boundaries_.open_end_mass_flow(0));
|
||||
}
|
||||
|
||||
} // namespace fluidsim::port
|
||||
222
src/scenario.cpp
Normal file
222
src/scenario.cpp
Normal file
@@ -0,0 +1,222 @@
|
||||
#include "FluidSim/Scenario.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace fluidsim::port {
|
||||
|
||||
FourStrokeTwinScenario::FourStrokeTwinScenario(int sample_rate)
|
||||
: pipes(create_pipes()),
|
||||
dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))),
|
||||
boundaries_(pipes), solver_(pipes, boundaries_),
|
||||
intake_radiation_(sample_rate), exhaust_radiation_(sample_rate),
|
||||
reverb_(sample_rate), mechanical_sound_(sample_rate) {
|
||||
crankshaft.omega = 2000.0f * 2.0f * pi / 60.0f;
|
||||
crankshaft.inertia = 0.04f;
|
||||
crankshaft.friction_constant = 5.0f;
|
||||
crankshaft.friction_viscous = 0.003f;
|
||||
|
||||
vehicle.set_neutral();
|
||||
vehicle.clutch_input = clutch;
|
||||
|
||||
cylinder1.intake_valve_diameter = 0.030f;
|
||||
cylinder1.intake_valve_lift = 0.0080f;
|
||||
cylinder1.exhaust_valve_diameter = 0.026f;
|
||||
cylinder1.exhaust_valve_lift = 0.0075f;
|
||||
cylinder2.phase = pi;
|
||||
cylinder2.intake_valve_diameter = 0.030f;
|
||||
cylinder2.intake_valve_lift = 0.0080f;
|
||||
cylinder2.exhaust_valve_diameter = 0.026f;
|
||||
cylinder2.exhaust_valve_lift = 0.0075f;
|
||||
cylinder1.rev_limit_enabled = cylinder2.rev_limit_enabled = true;
|
||||
cylinder1.rev_limit_rpm = cylinder2.rev_limit_rpm = 10000.0f;
|
||||
cylinder1.rev_limit_cut_time = cylinder2.rev_limit_cut_time = 0.020f;
|
||||
cylinder1.rev_limit_hysteresis = cylinder2.rev_limit_hysteresis = 250.0f;
|
||||
cylinder1.initialize(crankshaft);
|
||||
cylinder2.initialize(crankshaft);
|
||||
|
||||
const float intake_area = area_for_diameter(0.0275f);
|
||||
const float exhaust_area = area_for_diameter(0.028f);
|
||||
runner_area_ = intake_area;
|
||||
header_area_ = tail_area_ = exhaust_area;
|
||||
for (auto& pipe : pipes) {
|
||||
pipe.damping_multiplier = 1.0f;
|
||||
pipe.energy_relaxation_rate = 0.5f;
|
||||
pipe.ambient_pressure = 101325.0f;
|
||||
}
|
||||
|
||||
throttle_port_ = &intake_plenum.create_port();
|
||||
runner_feed1_ = &intake_plenum.create_port();
|
||||
runner_feed2_ = &intake_plenum.create_port();
|
||||
collector_in1_ = &exhaust_collector.create_port();
|
||||
collector_in2_ = &exhaust_collector.create_port();
|
||||
collector_out_ = &exhaust_collector.create_port();
|
||||
|
||||
intake_radiation_.pipe_end = {-0.20f, 0.0f, 0.0f};
|
||||
intake_radiation_.pipe_axis = {-1.0f, 0.0f, 0.0f};
|
||||
intake_radiation_.gain = 30.0f;
|
||||
intake_radiation_.directivity_balance = 0.30f;
|
||||
intake_radiation_.flow_tau = 0.005f;
|
||||
intake_radiation_.smooth_tau = 0.020f;
|
||||
exhaust_radiation_.pipe_end = {0.55f, 0.0f, 0.0f};
|
||||
exhaust_radiation_.pipe_axis = {1.0f, 0.0f, 0.0f};
|
||||
exhaust_radiation_.gain = 25.0f;
|
||||
exhaust_radiation_.directivity_balance = 0.30f;
|
||||
exhaust_radiation_.flow_tau = 0.005f;
|
||||
exhaust_radiation_.smooth_tau = 0.020f;
|
||||
|
||||
reverb_.dry_mix = 0.0f;
|
||||
reverb_.early_mix = 0.30f;
|
||||
reverb_.tail_mix = 0.22f;
|
||||
reverb_.transient_feedback_boost = 0.40f;
|
||||
reverb_.transient_hf_boost = 3.5f;
|
||||
reverb_.saturation_drive = 1.5f;
|
||||
|
||||
solver_.add_component(cylinder1);
|
||||
solver_.add_component(cylinder2);
|
||||
solver_.add_component(intake_plenum);
|
||||
solver_.add_component(exhaust_collector);
|
||||
|
||||
boundaries_.add_open_end(0, true, 101325.0f, intake_area);
|
||||
boundaries_.add_open_end(5, false, 101325.0f, exhaust_area);
|
||||
boundaries_.add_orifice(*throttle_port_, 0, false, throttle_area_, 0.7f);
|
||||
boundaries_.add_orifice(*runner_feed1_, 1, true, runner_area_);
|
||||
boundaries_.add_orifice(*runner_feed2_, 2, true, runner_area_);
|
||||
boundaries_.add_orifice(cylinder1.intake, 1, false, valve1_in_);
|
||||
boundaries_.add_orifice(cylinder2.intake, 2, false, valve2_in_);
|
||||
boundaries_.add_orifice(cylinder1.exhaust, 3, true, valve1_out_);
|
||||
boundaries_.add_orifice(cylinder2.exhaust, 4, true, valve2_out_);
|
||||
boundaries_.add_orifice(*collector_in1_, 3, false, header_area_);
|
||||
boundaries_.add_orifice(*collector_in2_, 4, false, header_area_);
|
||||
boundaries_.add_orifice(*collector_out_, 5, true, tail_area_);
|
||||
}
|
||||
|
||||
AudioFrame FourStrokeTwinScenario::process() {
|
||||
const std::lock_guard<std::mutex> guard(mutex_);
|
||||
crankshaft.load_torque = std::clamp(load, 0.0f, 1.0f) * 30.0f;
|
||||
vehicle.clutch_input = std::clamp(clutch, 0.0f, 1.0f);
|
||||
crankshaft.step(dt_);
|
||||
crankshaft.drivetrain_torque = vehicle.update(crankshaft.omega, crankshaft.inertia, dt_);
|
||||
cylinder1.pre_step(crankshaft, dt_, throttle);
|
||||
cylinder2.pre_step(crankshaft, dt_, throttle);
|
||||
|
||||
const float throttle_fraction = std::clamp(throttle, 0.0f, 1.0f);
|
||||
throttle_area_ = throttle_fraction <= 0.001f ? 0.0f : area_for_diameter(0.026f) * throttle_fraction;
|
||||
const auto cycle_degrees = [this](float phase) {
|
||||
float value = std::fmod((crankshaft.angle + phase) * 180.0f / pi, 720.0f);
|
||||
return value < 0.0f ? value + 720.0f : value;
|
||||
};
|
||||
valve1_in_ = cylinder1.valve_area(cycle_degrees(cylinder1.phase), true);
|
||||
valve2_in_ = cylinder2.valve_area(cycle_degrees(cylinder2.phase), true);
|
||||
valve1_out_ = cylinder1.valve_area(cycle_degrees(cylinder1.phase), false);
|
||||
valve2_out_ = cylinder2.valve_area(cycle_degrees(cylinder2.phase), false);
|
||||
|
||||
solver_.step(dt_);
|
||||
intake_flow = boundaries_.open_end_mass_flow(0);
|
||||
exhaust_flow = boundaries_.open_end_mass_flow(1);
|
||||
const AudioFrame intake_audio = intake_radiation_.process(intake_flow);
|
||||
const AudioFrame exhaust_audio = exhaust_radiation_.process(exhaust_flow);
|
||||
const float mechanical = mechanical_sound_enabled
|
||||
? mechanical_sound_.process(crankshaft, cylinder1, load) * mechanical_mix : 0.0f;
|
||||
const float reverb_input = intake_radiation_.last_mono_pressure() * intake_send +
|
||||
exhaust_radiation_.last_mono_pressure() * exhaust_send +
|
||||
mechanical * mechanical_send;
|
||||
const AudioFrame wet = reverb_.process_stereo(reverb_input);
|
||||
const float flow_left = std::tanh(intake_audio.left + exhaust_audio.left + wet.left * reverb_send_gain);
|
||||
const float flow_right = std::tanh(intake_audio.right + exhaust_audio.right + wet.right * reverb_send_gain);
|
||||
last_mix_l = std::isfinite(flow_left + mechanical)
|
||||
? std::clamp(flow_left + mechanical, -1.0f, 1.0f) : 0.0f;
|
||||
last_mix_r = std::isfinite(flow_right + mechanical)
|
||||
? std::clamp(flow_right + mechanical, -1.0f, 1.0f) : 0.0f;
|
||||
++sample_count;
|
||||
return {last_mix_l, last_mix_r};
|
||||
}
|
||||
|
||||
void FourStrokeTwinScenario::set_controls(float throttle_value, float load_value, float clutch_value) {
|
||||
const std::lock_guard<std::mutex> guard(mutex_);
|
||||
throttle = std::clamp(throttle_value, 0.0f, 1.0f);
|
||||
load = std::clamp(load_value, 0.0f, 1.0f);
|
||||
clutch = std::clamp(clutch_value, 0.0f, 1.0f);
|
||||
}
|
||||
|
||||
void FourStrokeTwinScenario::toggle_mechanical_sound() {
|
||||
const std::lock_guard<std::mutex> guard(mutex_);
|
||||
mechanical_sound_enabled = !mechanical_sound_enabled;
|
||||
}
|
||||
|
||||
void FourStrokeTwinScenario::shift_up() {
|
||||
const std::lock_guard<std::mutex> guard(mutex_);
|
||||
vehicle.shift_up();
|
||||
}
|
||||
|
||||
void FourStrokeTwinScenario::shift_down() {
|
||||
const std::lock_guard<std::mutex> guard(mutex_);
|
||||
vehicle.shift_down();
|
||||
}
|
||||
|
||||
void FourStrokeTwinScenario::set_neutral() {
|
||||
const std::lock_guard<std::mutex> guard(mutex_);
|
||||
vehicle.set_neutral();
|
||||
}
|
||||
|
||||
void FourStrokeTwinScenario::set_first_gear() {
|
||||
const std::lock_guard<std::mutex> guard(mutex_);
|
||||
vehicle.set_first_gear();
|
||||
}
|
||||
|
||||
ScenarioSnapshot FourStrokeTwinScenario::snapshot() const {
|
||||
const std::lock_guard<std::mutex> guard(mutex_);
|
||||
ScenarioSnapshot result;
|
||||
result.rpm = crankshaft.omega * 60.0f / (2.0f * pi);
|
||||
result.speed_kmh = vehicle.speed * 3.6f;
|
||||
result.kinetic_energy = vehicle.kinetic_energy();
|
||||
result.average_power = crankshaft.average_power;
|
||||
result.average_torque = crankshaft.average_torque;
|
||||
result.cylinder_pressure1 = cylinder1.pressure;
|
||||
result.cylinder_pressure2 = cylinder2.pressure;
|
||||
result.cylinder_temperature1 = cylinder1.temperature();
|
||||
result.cylinder_temperature2 = cylinder2.temperature();
|
||||
result.cylinder_piston_fraction1 = (cylinder1.volume - cylinder1.clearance_volume()) / cylinder1.swept_volume();
|
||||
result.cylinder_piston_fraction2 = (cylinder2.volume - cylinder2.clearance_volume()) / cylinder2.swept_volume();
|
||||
result.valve1_intake_area = valve1_in_;
|
||||
result.valve1_exhaust_area = valve1_out_;
|
||||
result.valve2_intake_area = valve2_in_;
|
||||
result.valve2_exhaust_area = valve2_out_;
|
||||
result.intake_pressure = intake_plenum.pressure();
|
||||
result.exhaust_pressure = exhaust_collector.pressure();
|
||||
result.throttle = throttle;
|
||||
result.clutch = clutch;
|
||||
result.intake_flow = intake_flow;
|
||||
result.exhaust_flow = exhaust_flow;
|
||||
result.gear = vehicle.current_gear;
|
||||
result.rev_limit1 = cylinder1.rev_limit_active;
|
||||
result.rev_limit2 = cylinder2.rev_limit_active;
|
||||
result.mechanical_sound_enabled = mechanical_sound_enabled;
|
||||
for (std::size_t pipe_index = 0; pipe_index < pipes.size(); ++pipe_index) {
|
||||
auto& states = result.pipe_states[pipe_index];
|
||||
states.reserve(pipes[pipe_index].cells.size());
|
||||
for (std::size_t cell = 0; cell < pipes[pipe_index].cells.size(); ++cell)
|
||||
states.push_back(pipes[pipe_index].state(static_cast<int>(cell)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
float FourStrokeTwinScenario::area_for_diameter(float diameter) {
|
||||
return pi * diameter * diameter * 0.25f;
|
||||
}
|
||||
|
||||
std::vector<Pipe1D> FourStrokeTwinScenario::create_pipes() {
|
||||
const float intake_area = area_for_diameter(0.0275f);
|
||||
const float exhaust_area = area_for_diameter(0.028f);
|
||||
std::vector<Pipe1D> result;
|
||||
result.reserve(6);
|
||||
result.emplace_back(intake_area, 0.06f, 10);
|
||||
result.emplace_back(intake_area, 0.12f, 10);
|
||||
result.emplace_back(intake_area, 0.12f, 10);
|
||||
result.emplace_back(exhaust_area, 0.20f, 6);
|
||||
result.emplace_back(exhaust_area, 0.20f, 6);
|
||||
result.emplace_back(exhaust_area, 0.40f, 10);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace fluidsim::port
|
||||
84
src/simulation_ring_buffer.cpp
Normal file
84
src/simulation_ring_buffer.cpp
Normal file
@@ -0,0 +1,84 @@
|
||||
#include "FluidSim/SimulationRingBuffer.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace fluidsim::port {
|
||||
|
||||
SimulationRingBuffer::SimulationRingBuffer(std::size_t capacity)
|
||||
: left_(capacity), right_(capacity), mask_(capacity - 1) {
|
||||
if (capacity < 2 || (capacity & (capacity - 1)) != 0)
|
||||
throw std::invalid_argument("simulation audio ring capacity must be a power of two");
|
||||
}
|
||||
|
||||
std::size_t SimulationRingBuffer::available_samples() const {
|
||||
const auto written = write_head_.load(std::memory_order_acquire);
|
||||
const auto read = read_head_.load(std::memory_order_acquire);
|
||||
return static_cast<std::size_t>(written - read);
|
||||
}
|
||||
|
||||
std::size_t SimulationRingBuffer::free_space() const {
|
||||
return left_.size() - available_samples();
|
||||
}
|
||||
|
||||
bool SimulationRingBuffer::write(float left, float right, std::stop_token stop) {
|
||||
while (free_space() == 0) {
|
||||
if (stop.stop_requested()) return false;
|
||||
std::unique_lock lock(wait_mutex_);
|
||||
space_available_.wait_for(lock, std::chrono::milliseconds(10));
|
||||
}
|
||||
if (stop.stop_requested()) return false;
|
||||
const auto write = write_head_.load(std::memory_order_relaxed);
|
||||
left_[static_cast<std::size_t>(write) & mask_] = left;
|
||||
right_[static_cast<std::size_t>(write) & mask_] = right;
|
||||
write_head_.store(write + 1, std::memory_order_release);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SimulationRingBuffer::wait_for_space(std::size_t target_fill, std::stop_token stop) {
|
||||
while (available_samples() > target_fill) {
|
||||
if (stop.stop_requested()) return false;
|
||||
std::unique_lock lock(wait_mutex_);
|
||||
space_available_.wait_for(lock, std::chrono::milliseconds(10));
|
||||
}
|
||||
return !stop.stop_requested();
|
||||
}
|
||||
|
||||
void SimulationRingBuffer::reset_consumer() {
|
||||
consumer_initialized_ = false;
|
||||
}
|
||||
|
||||
std::size_t SimulationRingBuffer::read_interpolated(std::span<float> dst_left,
|
||||
std::span<float> dst_right,
|
||||
double speed) {
|
||||
const std::size_t count = std::min(dst_left.size(), dst_right.size());
|
||||
if (!consumer_initialized_) {
|
||||
read_position_ = static_cast<double>(read_head_.load(std::memory_order_acquire));
|
||||
consumer_initialized_ = true;
|
||||
}
|
||||
const auto write = write_head_.load(std::memory_order_acquire);
|
||||
std::size_t produced = 0;
|
||||
for (; produced < count; ++produced) {
|
||||
const auto floor_position = static_cast<std::uint64_t>(read_position_);
|
||||
const auto ceil_position = floor_position + 1;
|
||||
if (ceil_position >= write) break;
|
||||
const float left0 = left_[static_cast<std::size_t>(floor_position) & mask_];
|
||||
const float left1 = left_[static_cast<std::size_t>(ceil_position) & mask_];
|
||||
const float right0 = right_[static_cast<std::size_t>(floor_position) & mask_];
|
||||
const float right1 = right_[static_cast<std::size_t>(ceil_position) & mask_];
|
||||
const float fraction = static_cast<float>(read_position_ - static_cast<double>(floor_position));
|
||||
dst_left[produced] = left0 + (left1 - left0) * fraction;
|
||||
dst_right[produced] = right0 + (right1 - right0) * fraction;
|
||||
read_position_ += std::clamp(speed, 0.0001, 1.0);
|
||||
}
|
||||
const auto new_read_head = static_cast<std::uint64_t>(read_position_);
|
||||
const auto previous_read_head = read_head_.load(std::memory_order_relaxed);
|
||||
if (new_read_head > previous_read_head) {
|
||||
read_head_.store(new_read_head, std::memory_order_release);
|
||||
space_available_.notify_one();
|
||||
}
|
||||
return produced;
|
||||
}
|
||||
|
||||
} // namespace fluidsim::port
|
||||
27
src/solver.cpp
Normal file
27
src/solver.cpp
Normal file
@@ -0,0 +1,27 @@
|
||||
#include "FluidSim/Solver.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace fluidsim::port {
|
||||
|
||||
Solver::Solver(std::vector<Pipe1D>& pipes, BoundarySystem& boundaries)
|
||||
: pipes_(pipes), boundaries_(boundaries) {}
|
||||
|
||||
void Solver::add_component(Component& component) {
|
||||
components_.push_back(&component);
|
||||
}
|
||||
|
||||
void Solver::step(float dt) {
|
||||
if (dt <= 0.0f) return;
|
||||
int substeps = std::max(minimum_substeps, 1);
|
||||
for (const auto& pipe : pipes_) substeps = std::max(substeps, pipe.required_substeps(dt, 0.8f));
|
||||
const float sub_dt = dt / static_cast<float>(substeps);
|
||||
for (int i = 0; i < substeps; ++i) {
|
||||
boundaries_.resolve(sub_dt);
|
||||
for (auto& pipe : pipes_) pipe.advance(sub_dt);
|
||||
}
|
||||
for (Component* component : components_) component->update_state(dt);
|
||||
++step_count_;
|
||||
}
|
||||
|
||||
} // namespace fluidsim::port
|
||||
39
src/sound_engine.cpp
Normal file
39
src/sound_engine.cpp
Normal file
@@ -0,0 +1,39 @@
|
||||
#include "FluidSim/SoundEngine.hpp"
|
||||
|
||||
namespace fluidsim::port {
|
||||
|
||||
SoundEngine::SoundEngine(SimulationRingBuffer& source_buffer) : stream_(source_buffer) {}
|
||||
|
||||
SoundEngine::~SoundEngine() {
|
||||
stop();
|
||||
}
|
||||
|
||||
void SoundEngine::start() {
|
||||
if (is_playing_) return;
|
||||
stream_.play();
|
||||
is_playing_ = true;
|
||||
}
|
||||
|
||||
void SoundEngine::stop() {
|
||||
if (!is_playing_) return;
|
||||
stream_.stop();
|
||||
is_playing_ = false;
|
||||
}
|
||||
|
||||
void SoundEngine::set_speed(double speed_value) {
|
||||
stream_.set_speed(speed_value);
|
||||
}
|
||||
|
||||
double SoundEngine::speed() const {
|
||||
return stream_.speed();
|
||||
}
|
||||
|
||||
void SoundEngine::set_volume(float volume_value) {
|
||||
stream_.setVolume(volume_value);
|
||||
}
|
||||
|
||||
float SoundEngine::volume() const {
|
||||
return stream_.getVolume();
|
||||
}
|
||||
|
||||
} // namespace fluidsim::port
|
||||
123
src/two_stroke_cylinder.cpp
Normal file
123
src/two_stroke_cylinder.cpp
Normal file
@@ -0,0 +1,123 @@
|
||||
#include "FluidSim/TwoStrokeCylinder.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace fluidsim::port {
|
||||
|
||||
TwoStrokeCylinder::TwoStrokeCylinder(float bore_value, float stroke_value,
|
||||
float connecting_rod_length, float compression_ratio,
|
||||
float transfer_duration_degrees, float exhaust_duration_degrees)
|
||||
: transfer_duration_(transfer_duration_degrees), exhaust_duration_(exhaust_duration_degrees) {
|
||||
if (transfer_duration_ <= 0.0f || transfer_duration_ >= 360.0f ||
|
||||
exhaust_duration_ <= 0.0f || exhaust_duration_ >= 360.0f)
|
||||
throw std::invalid_argument("two-stroke port durations must be between 0 and 360 degrees");
|
||||
bore = bore_value;
|
||||
stroke = stroke_value;
|
||||
rod = connecting_rod_length;
|
||||
compression = compression_ratio;
|
||||
if (exhaust_open() >= transfer_open())
|
||||
throw std::invalid_argument("two-stroke exhaust must open before the transfer port");
|
||||
}
|
||||
|
||||
float TwoStrokeCylinder::lift(float angle, float opens, float closes, float peak_lift) {
|
||||
float mapped = std::fmod(angle, 360.0f);
|
||||
if (mapped < 0.0f) mapped += 360.0f;
|
||||
float effective_close = closes;
|
||||
if (effective_close < opens) effective_close += 360.0f;
|
||||
if (mapped < opens) mapped += 360.0f;
|
||||
const float duration = effective_close - opens;
|
||||
if (mapped < opens || mapped > effective_close || duration <= 0.0f) return 0.0f;
|
||||
|
||||
const float ramp = duration * 0.25f;
|
||||
const float hold = duration - 2.0f * ramp;
|
||||
if (mapped < opens + ramp) {
|
||||
const float t = (mapped - opens) / ramp;
|
||||
return peak_lift * t * t * (3.0f - 2.0f * t);
|
||||
}
|
||||
if (mapped < opens + ramp + hold) return peak_lift;
|
||||
const float t = (mapped - (opens + ramp + hold)) / ramp;
|
||||
return peak_lift * (1.0f - t) * (1.0f - t) * (1.0f + 2.0f * t);
|
||||
}
|
||||
|
||||
float TwoStrokeCylinder::valve_area(float cycle_degrees, bool inlet) const {
|
||||
const float opens = inlet ? transfer_open() : exhaust_open();
|
||||
const float closes = inlet ? transfer_close() : exhaust_close();
|
||||
const float diameter = inlet ? intake_valve_diameter : exhaust_valve_diameter;
|
||||
const float peak_lift = inlet ? intake_valve_lift : exhaust_valve_lift;
|
||||
return pi * diameter * lift(cycle_degrees, opens, closes, peak_lift);
|
||||
}
|
||||
|
||||
void TwoStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*/) {
|
||||
const float rpm = crank.omega * 60.0f / (2.0f * pi);
|
||||
spark_advance = std::clamp(10.0f + rpm * 0.002f, 5.0f, 40.0f);
|
||||
update_rev_limiter(rpm, dt);
|
||||
|
||||
const float theta = crank.angle + phase;
|
||||
const float previous_volume = volume;
|
||||
volume = compute_volume(theta);
|
||||
pressure = (gamma - 1.0f) * energy / std::max(volume, 1e-12f);
|
||||
const float dvolume = volume - previous_volume;
|
||||
const float radius = stroke * 0.5f;
|
||||
const float obliquity = radius / rod;
|
||||
const float sine = std::sin(theta), cosine = std::cos(theta);
|
||||
const float term = std::sqrt(std::max(1.0f - obliquity * obliquity * sine * sine, 1e-12f));
|
||||
const float dx_dtheta = radius * sine * (1.0f + obliquity * cosine / term);
|
||||
const float piston_area = pi * 0.25f * bore * bore;
|
||||
const float back_pressure = crankcase_ ? crankcase_->pressure() : 101325.0f;
|
||||
crank.add_torque((pressure - back_pressure) * piston_area * dx_dtheta);
|
||||
energy -= pressure * dvolume;
|
||||
|
||||
const float previous_degrees = wrap_degrees((crank.previous_angle + phase) * 180.0f / pi, 360.0f);
|
||||
const float current_degrees = wrap_degrees(theta * 180.0f / pi, 360.0f);
|
||||
handle_cycle_events(previous_degrees, current_degrees);
|
||||
|
||||
if (rev_limit_active) {
|
||||
fuel_mass = 0.0f;
|
||||
fuel_injected = false;
|
||||
combustion_active = false;
|
||||
}
|
||||
energy -= heat_transfer * wall_area * (temperature() - ambient_temperature) * dt;
|
||||
sync_ports();
|
||||
}
|
||||
|
||||
void TwoStrokeCylinder::handle_cycle_events(float previous_degrees, float current_degrees) {
|
||||
if (previous_degrees >= transfer_open() && previous_degrees < transfer_close() &&
|
||||
current_degrees >= transfer_close()) {
|
||||
fuel_mass = air_mass / std::max(afr, 1e-6f);
|
||||
fuel_injected = true;
|
||||
}
|
||||
|
||||
const float spark_angle = wrap_degrees(360.0f - spark_advance, 360.0f);
|
||||
const bool crossed_spark = (previous_degrees < spark_angle && current_degrees >= spark_angle) ||
|
||||
(previous_degrees > spark_angle && current_degrees < spark_angle);
|
||||
if (crossed_spark && fuel_injected && !combustion_active && !rev_limit_active &&
|
||||
random_unit() >= misfire_probability) {
|
||||
combustion_active = true;
|
||||
burn_fraction = 0.0f;
|
||||
energy_factor_ = 1.0f + energy_variation * (2.0f * random_unit() - 1.0f);
|
||||
}
|
||||
|
||||
if (!combustion_active) return;
|
||||
float angle_since_spark = current_degrees - spark_angle;
|
||||
if (angle_since_spark < 0.0f) angle_since_spark += 360.0f;
|
||||
const float phi = (angle_since_spark - burn_start) / std::max(burn_duration, 1e-6f);
|
||||
float fraction = phi <= 0.0f ? 0.0f : 1.0f - std::exp(-wiebe_a * std::pow(phi, wiebe_m + 1.0f));
|
||||
fraction = std::max(burn_fraction, std::clamp(fraction, 0.0f, 1.0f));
|
||||
if (fraction >= 1.0f || angle_since_spark > burn_duration + burn_start + spark_advance) {
|
||||
fraction = 1.0f;
|
||||
combustion_active = false;
|
||||
exhaust_mass += air_mass;
|
||||
air_mass = 0.0f;
|
||||
}
|
||||
fuel_injected = false;
|
||||
const float increment = fraction - burn_fraction;
|
||||
if (increment > 0.0f) {
|
||||
energy += fuel_mass * fuel_lhv * energy_factor_ * increment;
|
||||
exhaust_mass += fuel_mass * increment;
|
||||
burn_fraction = fraction;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace fluidsim::port
|
||||
146
src/two_stroke_scenario.cpp
Normal file
146
src/two_stroke_scenario.cpp
Normal file
@@ -0,0 +1,146 @@
|
||||
#include "FluidSim/TwoStrokeScenario.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace fluidsim::port {
|
||||
|
||||
TwoStrokeScenario::TwoStrokeScenario(int sample_rate)
|
||||
: cylinder(0.054f, 0.0545f, 0.110f, 7.2f, 140.0f, 195.0f),
|
||||
crankcase(crankshaft, 0.0545f * 0.5f, 0.110f, 0.054f, 150e-6f),
|
||||
pipes(create_pipes()), dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))),
|
||||
max_throttle_area_(area_for_diameter(0.042f)),
|
||||
reed_in_area_(area_for_diameter(0.040f)),
|
||||
reed_out_area_(0.0f), transfer_pipe_area_(area_for_diameter(0.040f)),
|
||||
transfer_valve_area_(0.0f), exhaust_valve_area_(0.0f), boundaries_(pipes), solver_(pipes, boundaries_),
|
||||
intake_sound_(sample_rate), exhaust_sound_(sample_rate), reverb_(sample_rate) {
|
||||
crankshaft.omega = 3000.0f * 2.0f * pi / 60.0f;
|
||||
crankshaft.inertia = 0.01f;
|
||||
crankshaft.friction_constant = 1.0f;
|
||||
crankshaft.friction_viscous = 0.002f;
|
||||
|
||||
cylinder.intake_valve_diameter = 0.030f;
|
||||
cylinder.intake_valve_lift = 0.010f;
|
||||
cylinder.exhaust_valve_diameter = 0.040f;
|
||||
cylinder.exhaust_valve_lift = 0.010f;
|
||||
cylinder.initialize(crankshaft);
|
||||
cylinder.set_crankcase(crankcase);
|
||||
|
||||
for (auto& pipe : pipes) {
|
||||
pipe.damping_multiplier = 0.8f;
|
||||
pipe.energy_relaxation_rate = 0.4f;
|
||||
pipe.ambient_pressure = 101325.0f;
|
||||
}
|
||||
plenum_inlet_ = &intake_plenum.create_port();
|
||||
plenum_outlet_ = &intake_plenum.create_port();
|
||||
|
||||
boundaries_.add_open_end(0, true, 101325.0f, pipes[0].area);
|
||||
boundaries_.add_open_end(3, false, 101325.0f, pipes[3].cell_area(static_cast<int>(pipes[3].cells.size()) - 1));
|
||||
boundaries_.add_orifice(*plenum_inlet_, 0, false, max_throttle_area_, 0.72f);
|
||||
boundaries_.add_orifice(*plenum_outlet_, 1, true, reed_in_area_);
|
||||
boundaries_.add_orifice(crankcase.intake_port, 1, false, reed_out_area_, 0.9f);
|
||||
boundaries_.add_orifice(crankcase.transfer_port, 2, true, transfer_pipe_area_);
|
||||
boundaries_.add_orifice(cylinder.intake, 2, false, transfer_valve_area_);
|
||||
boundaries_.add_orifice(cylinder.exhaust, 3, true, exhaust_valve_area_);
|
||||
solver_.minimum_substeps = 4;
|
||||
solver_.add_component(intake_plenum);
|
||||
solver_.add_component(crankcase);
|
||||
solver_.add_component(cylinder);
|
||||
|
||||
intake_sound_.gain = 4.5f;
|
||||
exhaust_sound_.gain = 4.5f;
|
||||
}
|
||||
|
||||
AudioFrame TwoStrokeScenario::process() {
|
||||
const float reed_margin = 200.0f;
|
||||
if (crankcase.pressure() < intake_plenum.pressure() - reed_margin) reed_open_ = true;
|
||||
else if (crankcase.pressure() > intake_plenum.pressure() + reed_margin) reed_open_ = false;
|
||||
|
||||
const float throttle_fraction = std::clamp(throttle, 0.0f, 1.0f);
|
||||
max_throttle_area_ = throttle_fraction < 0.001f ? 0.0f : area_for_diameter(0.042f) * throttle_fraction;
|
||||
reed_out_area_ = reed_open_ ? reed_in_area_ : 0.0f;
|
||||
if (step_count_ < 20000) crankshaft.add_torque(5.0f);
|
||||
crankshaft.step(dt_);
|
||||
crankcase.pre_step(dt_);
|
||||
cylinder.pre_step(crankshaft, dt_, throttle_fraction);
|
||||
transfer_valve_area_ = cylinder.valve_area(crankshaft.angle * 180.0f / pi, true);
|
||||
exhaust_valve_area_ = cylinder.valve_area(crankshaft.angle * 180.0f / pi, false);
|
||||
solver_.step(dt_);
|
||||
++step_count_;
|
||||
|
||||
const float intake = intake_sound_.process(boundaries_.open_end_mass_flow(0));
|
||||
const float exhaust = exhaust_sound_.process(boundaries_.open_end_mass_flow(1));
|
||||
const float reverb_input = 0.5f * (intake + exhaust);
|
||||
return reverb_.process_stereo(reverb_input);
|
||||
}
|
||||
|
||||
void TwoStrokeScenario::set_throttle(float value) {
|
||||
throttle = std::clamp(value, 0.0f, 1.0f);
|
||||
}
|
||||
|
||||
TwoStrokeSnapshot TwoStrokeScenario::snapshot() const {
|
||||
return {
|
||||
crankshaft.omega * 60.0f / (2.0f * pi), cylinder.pressure,
|
||||
crankcase.pressure(), intake_plenum.pressure(), throttle,
|
||||
cylinder.valve_area(crankshaft.angle * 180.0f / pi, true),
|
||||
cylinder.valve_area(crankshaft.angle * 180.0f / pi, false), reed_open_
|
||||
};
|
||||
}
|
||||
|
||||
float TwoStrokeScenario::area_for_diameter(float diameter) {
|
||||
return pi * diameter * diameter * 0.25f;
|
||||
}
|
||||
|
||||
std::vector<Pipe1D> TwoStrokeScenario::create_pipes() {
|
||||
const float intake_area = area_for_diameter(0.042f);
|
||||
const float reed_area = area_for_diameter(0.040f);
|
||||
const float transfer_area = area_for_diameter(0.040f);
|
||||
std::vector<Pipe1D> result;
|
||||
result.reserve(4);
|
||||
result.emplace_back(intake_area, 0.100f, 8);
|
||||
result.emplace_back(reed_area, 0.030f, 4);
|
||||
result.emplace_back(transfer_area, 0.200f, 8);
|
||||
|
||||
constexpr int exhaust_cells = 60;
|
||||
constexpr float header_length = 0.130f, diffuser_length = 0.250f;
|
||||
constexpr float belly_length = 0.220f, convergent_length = 0.160f;
|
||||
constexpr float stinger_length = 0.080f;
|
||||
constexpr float header_diameter = 0.040f, belly_diameter = 0.070f;
|
||||
constexpr float stinger_diameter = 0.028f;
|
||||
constexpr float total_length = header_length + diffuser_length + belly_length +
|
||||
convergent_length + stinger_length;
|
||||
const int header_cells = static_cast<int>(exhaust_cells * header_length / total_length);
|
||||
const int diffuser_cells = static_cast<int>(exhaust_cells * diffuser_length / total_length);
|
||||
const int belly_cells = static_cast<int>(exhaust_cells * belly_length / total_length);
|
||||
const int convergent_cells = static_cast<int>(exhaust_cells * convergent_length / total_length);
|
||||
const int stinger_cells = exhaust_cells - header_cells - diffuser_cells - belly_cells - convergent_cells;
|
||||
std::vector<float> exhaust_areas;
|
||||
std::vector<float> exhaust_lengths;
|
||||
exhaust_areas.reserve(exhaust_cells);
|
||||
exhaust_lengths.reserve(exhaust_cells);
|
||||
const auto append_uniform = [&](int count, float diameter, float length) {
|
||||
for (int i = 0; i < count; ++i) {
|
||||
exhaust_areas.push_back(area_for_diameter(diameter));
|
||||
exhaust_lengths.push_back(length / static_cast<float>(count));
|
||||
}
|
||||
};
|
||||
append_uniform(header_cells, header_diameter, header_length);
|
||||
for (int i = 0; i < diffuser_cells; ++i) {
|
||||
const float t = diffuser_cells > 1 ? static_cast<float>(i) / (diffuser_cells - 1) : 0.0f;
|
||||
const float diameter = header_diameter + (belly_diameter - header_diameter) * t;
|
||||
exhaust_areas.push_back(area_for_diameter(diameter));
|
||||
exhaust_lengths.push_back(diffuser_length / static_cast<float>(diffuser_cells));
|
||||
}
|
||||
append_uniform(belly_cells, belly_diameter, belly_length);
|
||||
for (int i = 0; i < convergent_cells; ++i) {
|
||||
const float t = convergent_cells > 1 ? static_cast<float>(i) / (convergent_cells - 1) : 0.0f;
|
||||
const float diameter = belly_diameter + (stinger_diameter - belly_diameter) * t;
|
||||
exhaust_areas.push_back(area_for_diameter(diameter));
|
||||
exhaust_lengths.push_back(convergent_length / static_cast<float>(convergent_cells));
|
||||
}
|
||||
append_uniform(stinger_cells, stinger_diameter, stinger_length);
|
||||
result.emplace_back(std::move(exhaust_areas), std::move(exhaust_lengths));
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace fluidsim::port
|
||||
61
src/vehicle.cpp
Normal file
61
src/vehicle.cpp
Normal file
@@ -0,0 +1,61 @@
|
||||
#include "FluidSim/Vehicle.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace fluidsim::port {
|
||||
|
||||
float Vehicle::total_gear_ratio() const {
|
||||
return current_gear == 0 ? 0.0f : gear_ratios.at(static_cast<std::size_t>(current_gear - 1)) * final_drive_ratio * primary_reduction;
|
||||
}
|
||||
|
||||
float Vehicle::kinetic_energy() const {
|
||||
return 0.5f * (mass + rotational_inertia / (wheel_radius * wheel_radius)) * speed * speed;
|
||||
}
|
||||
|
||||
float Vehicle::update(float engine_omega, float engine_inertia, float dt) {
|
||||
if (dt <= 0.0f || engine_inertia <= 0.0f || wheel_radius <= 0.0f || mass <= 0.0f) return 0.0f;
|
||||
const float target = current_gear == 0 || shift_cut_timer_ > 0.0f
|
||||
? 0.0f : std::clamp(1.0f - clutch_input, 0.0f, 1.0f);
|
||||
engagement += (target - engagement) * (1.0f - std::exp(-clutch_engagement_rate * dt));
|
||||
shift_cut_timer_ = std::max(0.0f, shift_cut_timer_ - dt);
|
||||
const float ratio = total_gear_ratio();
|
||||
if (ratio <= 0.0f || engagement <= 0.0f) {
|
||||
apply_road_resistance(dt);
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
const float vehicle_mass = mass + rotational_inertia / (wheel_radius * wheel_radius);
|
||||
const float wheel_inertia = vehicle_mass * wheel_radius * wheel_radius;
|
||||
const float reflected_engine = engine_inertia * ratio * ratio;
|
||||
const float wheel_omega = speed / wheel_radius;
|
||||
const float slip = engine_omega / ratio - wheel_omega;
|
||||
const float sync_impulse = slip * (wheel_inertia * reflected_engine / (wheel_inertia + reflected_engine));
|
||||
const float max_impulse = clutch_capacity * engagement * dt * ratio;
|
||||
float wheel_impulse = std::clamp(sync_impulse, -max_impulse, max_impulse);
|
||||
wheel_impulse = std::max(wheel_impulse, -wheel_omega * wheel_inertia);
|
||||
speed += wheel_impulse / (vehicle_mass * wheel_radius);
|
||||
apply_road_resistance(dt);
|
||||
return -wheel_impulse / (ratio * dt);
|
||||
}
|
||||
|
||||
void Vehicle::shift_up() {
|
||||
if (current_gear < static_cast<int>(gear_ratios.size())) { ++current_gear; shift_cut_timer_ = 0.15f; }
|
||||
}
|
||||
void Vehicle::shift_down() {
|
||||
if (current_gear > 1) { --current_gear; shift_cut_timer_ = 0.15f; }
|
||||
}
|
||||
void Vehicle::set_neutral() { current_gear = 0; shift_cut_timer_ = 0.0f; }
|
||||
void Vehicle::set_first_gear() {
|
||||
if (current_gear == 0) { current_gear = 1; shift_cut_timer_ = 0.15f; }
|
||||
}
|
||||
|
||||
void Vehicle::apply_road_resistance(float dt) {
|
||||
const float v = std::max(speed, 0.0f);
|
||||
const float rolling = rolling_resistance * mass * gravity;
|
||||
const float aerodynamic = 0.5f * air_density * drag_coefficient * frontal_area * v * v;
|
||||
const float effective_mass = mass + rotational_inertia / (wheel_radius * wheel_radius);
|
||||
speed = std::max(0.0f, speed - (rolling + aerodynamic) / std::max(effective_mass, 1e-6f) * dt);
|
||||
}
|
||||
|
||||
} // namespace fluidsim::port
|
||||
Reference in New Issue
Block a user