diff --git a/app/test_scenarios.cpp b/app/test_scenarios.cpp index bb7cc4f..9c28393 100644 --- a/app/test_scenarios.cpp +++ b/app/test_scenarios.cpp @@ -578,11 +578,53 @@ namespace world.addBody(box); } + Camera spawn_heat_transfer_demo(PhysicsWorld &world) + { + world.thermal_settings.enabled = true; + world.thermal_settings.conduction_rate = 15.0f; + world.thermal_settings.radiation_rate = 0.02f; + world.thermal_settings.ambient_temperature = 295.0f; + world.thermal_settings.ambient_coupling = 0.03f; + world.thermal_settings.radiation_distance = 4.0f; + world.thermal_settings.min_visual_temperature = 240.0f; + world.thermal_settings.max_visual_temperature = 660.0f; + world.thermal_spawn_controls.enabled = true; + world.thermal_spawn_controls.lock_to_basic_shapes = true; + world.thermal_spawn_controls.spawn_temperature = 295.0f; + world.thermal_spawn_controls.spawn_heat_capacity = 930.0f; + world.thermal_spawn_controls.spawn_conductivity = 0.7f; + world.thermal_spawn_controls.spawn_emissivity = 0.9f; + + const int boxCount = 9; + const float spacing = 1.0f; + const float startX = -0.5f * spacing * (boxCount - 1); + const float coldTemp = 255.0f; + const float hotTemp = 650.0f; + + for (int i = 0; i < boxCount; ++i) + { + float lerp = (boxCount == 1) ? 0.0f : static_cast(i) / static_cast(boxCount - 1); + float temp = coldTemp + lerp * (hotTemp - coldTemp); + Vec3 pos(startX + i * spacing, 0.55f, 0.0f); + Rigidbody body(pos, Vec3(), &g_small_box, 1.8f); + body.thermal_enabled = true; + body.temperature = temp; + body.heat_capacity = 930.0f; + body.thermal_conductivity = 0.75f; + body.thermal_emissivity = 0.88f; + world.addBody(body); + } + + return Camera().setPosition(glm::vec3(0.0f, 4.8f, 22.0f)); + } + } Camera LoadSingleTestScenario(PhysicsWorld &world, TestCase test_case) { world.enable_buoyancy = false; // only when boyancy testcase + world.thermal_settings = PhysicsWorld::ThermalSettings(); + world.thermal_spawn_controls = PhysicsWorld::ThermalSpawnControls(); add_floor(world); @@ -628,6 +670,8 @@ Camera LoadSingleTestScenario(PhysicsWorld &world, TestCase test_case) world.addBody(Rigidbody(Vec3(0.0f, 5.0f, 0.0f), Vec3(), &g_small_sphere, 0.5f)); world.addBody(Rigidbody(Vec3(3.0f, 5.0f, 0.0f), Vec3(), &g_small_box, 0.6f)); return Camera(); + case TestCase::HeatTransferDemo: + return spawn_heat_transfer_demo(world); default: return spawn_projectile_demo(world); break; diff --git a/app/test_scenarios.hpp b/app/test_scenarios.hpp index 160afb5..63b6447 100644 --- a/app/test_scenarios.hpp +++ b/app/test_scenarios.hpp @@ -16,7 +16,8 @@ enum class TestCase AngularStack, CornerCollision, RollingFriction, - BuoyancyTest + BuoyancyTest, + HeatTransferDemo }; Camera LoadSingleTestScenario(PhysicsWorld &world, TestCase test_case); diff --git a/engine/core/rigidbody.cpp b/engine/core/rigidbody.cpp index 2ae2ad3..eda236e 100644 --- a/engine/core/rigidbody.cpp +++ b/engine/core/rigidbody.cpp @@ -98,3 +98,27 @@ void Rigidbody::clearAccum() force_accum = Vec3(); acctork = Vec3(); } + +float Rigidbody::getMass() const +{ + if (inverse_mass <= 0.0f) + { + return 0.0f; + } + return 1.0f / inverse_mass; +} + +float Rigidbody::getThermalMass() const +{ + if (!thermal_enabled) + { + return 0.0f; + } + float mass = getMass(); + if (mass <= 0.0f) + { + return 0.0f; + } + float cappedHeatCapacity = std::max(heat_capacity, PHYSICS_EPSILON); + return mass * cappedHeatCapacity; +} diff --git a/engine/core/rigidbody.hpp b/engine/core/rigidbody.hpp index c0d898b..a8cad69 100644 --- a/engine/core/rigidbody.hpp +++ b/engine/core/rigidbody.hpp @@ -22,6 +22,11 @@ class Rigidbody { Vec3 acctork; Mat3 inverse_inertia_body; //for local Mat3 inverse_inertia_world; //for world + bool thermal_enabled = false; + float temperature = 293.15f; + float heat_capacity = 900.0f; + float thermal_conductivity = 0.5f; + float thermal_emissivity = 0.85f; /* - Collider is a pointer cuz if we just write "Collider collider;" then the collider will always be a generic one @@ -46,4 +51,6 @@ class Rigidbody { void clearForces(); void clearAccum(); void updateworldinvinertia(); + float getMass() const; + float getThermalMass() const; }; \ No newline at end of file diff --git a/engine/world/physicsworld.cpp b/engine/world/physicsworld.cpp index 3a94f80..11b22b8 100644 --- a/engine/world/physicsworld.cpp +++ b/engine/world/physicsworld.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include "core/buoyancy.hpp" PhysicsWorld::PhysicsWorld() : gravity(0.0f, PHYSICS_GRAVITY, 0.0f), next_body_id(1) {} @@ -308,6 +309,11 @@ void PhysicsWorld::step(float dt) body.velocity = Vec3(); } } + + if (thermal_settings.enabled) + { + solve_thermal(dt); + } } void PhysicsWorld::generate_manifolds() @@ -843,4 +849,107 @@ void PhysicsWorld::warm_start_constraints() c.a->angvel -= c.a->inverse_inertia_world * rA.cross(impulse); c.b->angvel += c.b->inverse_inertia_world * rB.cross(impulse); } +} + +void PhysicsWorld::solve_thermal(float dt) +{ + applyThermalConduction(dt); + applyThermalRadiation(dt); + applyAmbientCooling(dt); +} + +void PhysicsWorld::applyHeat(Rigidbody &body, float energy) +{ + if (!thermal_settings.enabled || !body.thermal_enabled) + { + return; + } + float thermalMass = body.getThermalMass(); + if (thermalMass <= PHYSICS_EPSILON) + { + return; + } + body.temperature += energy / thermalMass; +} + +void PhysicsWorld::applyThermalConduction(float dt) +{ + if (!thermal_settings.enabled || thermal_settings.conduction_rate <= 0.0f) + { + return; + } + for (auto &m : manifolds) + { + if (!m.a || !m.b) + continue; + Rigidbody &a = *m.a; + Rigidbody &b = *m.b; + if (!a.thermal_enabled || !b.thermal_enabled) + continue; + float deltaT = b.temperature - a.temperature; + if (std::abs(deltaT) <= PHYSICS_EPSILON) + continue; + int contactCount = std::max(1, m.contact_count); + float conductivity = (a.thermal_conductivity + b.thermal_conductivity) * 0.5f; + float energy = deltaT * conductivity * thermal_settings.conduction_rate * dt * contactCount; + applyHeat(a, energy); + applyHeat(b, -energy); + } +} + +void PhysicsWorld::applyThermalRadiation(float dt) +{ + if (!thermal_settings.enabled || thermal_settings.radiation_rate <= 0.0f || thermal_settings.radiation_distance <= PHYSICS_EPSILON) + { + return; + } + const float maxDist = thermal_settings.radiation_distance; + const float maxDistSq = maxDist * maxDist; + for (std::size_t i = 0; i < bodies.size(); ++i) + { + Rigidbody &a = bodies[i]; + if (!a.thermal_enabled) + continue; + for (std::size_t j = i + 1; j < bodies.size(); ++j) + { + Rigidbody &b = bodies[j]; + if (!b.thermal_enabled) + continue; + Vec3 delta = b.position - a.position; + float distSq = delta.dot(delta); + if (distSq <= PHYSICS_EPSILON || distSq > maxDistSq) + continue; + float dist = std::sqrt(distSq); + float falloff = 1.0f - (dist / maxDist); + if (falloff <= 0.0f) + continue; + float deltaT = b.temperature - a.temperature; + if (std::abs(deltaT) <= PHYSICS_EPSILON) + continue; + float emissivity = (a.thermal_emissivity + b.thermal_emissivity) * 0.5f; + float energy = deltaT * emissivity * thermal_settings.radiation_rate * falloff * dt; + applyHeat(a, energy); + applyHeat(b, -energy); + } + } +} + +void PhysicsWorld::applyAmbientCooling(float dt) +{ + if (!thermal_settings.enabled || thermal_settings.ambient_coupling <= 0.0f) + { + return; + } + for (auto &body : bodies) + { + if (!body.thermal_enabled) + continue; + float deltaT = thermal_settings.ambient_temperature - body.temperature; + if (std::abs(deltaT) <= PHYSICS_EPSILON) + continue; + float inertia = std::max(1.0f, body.getThermalMass()); + float massFactor = 1.0f / (1.0f + 0.01f * inertia); + float energy = deltaT * thermal_settings.ambient_coupling * dt * inertia * massFactor; + applyHeat(body, energy); + } } \ No newline at end of file diff --git a/engine/world/physicsworld.hpp b/engine/world/physicsworld.hpp index c01ee2b..a097b0e 100644 --- a/engine/world/physicsworld.hpp +++ b/engine/world/physicsworld.hpp @@ -23,9 +23,36 @@ class PhysicsWorld { std::vector constraints; Vec3 gravity; BodyID next_body_id; + void solve_thermal(float dt); + void applyHeat(Rigidbody &body, float energy); + void applyThermalConduction(float dt); + void applyThermalRadiation(float dt); + void applyAmbientCooling(float dt); public: bool enable_buoyancy = false; Fluid water_fluid = Fluid(2.0f, 2.0f, 0.3f); + struct ThermalSettings + { + bool enabled = false; + float conduction_rate = 1.0f; + float radiation_rate = 0.02f; + float ambient_temperature = 295.0f; + float ambient_coupling = 0.05f; + float radiation_distance = 3.5f; + float min_visual_temperature = 250.0f; + float max_visual_temperature = 650.0f; + }; + struct ThermalSpawnControls + { + bool enabled = false; + bool lock_to_basic_shapes = false; + float spawn_temperature = 320.0f; + float spawn_heat_capacity = 900.0f; + float spawn_conductivity = 0.6f; + float spawn_emissivity = 0.85f; + }; + ThermalSettings thermal_settings; + ThermalSpawnControls thermal_spawn_controls; PhysicsWorld(); Rigidbody* getBodyByID(uint32_t body_id); diff --git a/renderer/bodymenu.cpp b/renderer/bodymenu.cpp index 75dd991..5ae160d 100644 --- a/renderer/bodymenu.cpp +++ b/renderer/bodymenu.cpp @@ -1,7 +1,9 @@ #include "bodymenu.hpp" #include +#include #include +#include #include #include #include @@ -12,6 +14,7 @@ #include "../engine/core/rigidbody.hpp" #include "../engine/core/sphere_collider.hpp" #include "../engine/math/vec3.hpp" +#include "thermal_palette.hpp" static int shapeIndex = 0; static int linkBodyAIndex = 0; @@ -51,6 +54,50 @@ static void RequestEngineToast(const std::string &text) g_toast_until = ImGui::GetTime() + 2.0; } +static void RenderThermalLegend(const PhysicsWorld &world) +{ + if (!world.thermal_settings.enabled) + return; + + ImGui::SeparatorText("Heat Scale"); + float legendWidth = ImGui::GetContentRegionAvail().x; + if (legendWidth <= 0.0f) + legendWidth = 1.0f; + const float barHeight = 18.0f; + ImVec2 pos = ImGui::GetCursorScreenPos(); + ImDrawList *drawList = ImGui::GetWindowDrawList(); + const int segments = 64; + for (int i = 0; i < segments; ++i) + { + float t0 = static_cast(i) / static_cast(segments); + float t1 = static_cast(i + 1) / static_cast(segments); + glm::vec3 c0 = SampleThermalGradient(t0); + glm::vec3 c1 = SampleThermalGradient(t1); + ImU32 col0 = ImColor(c0.r, c0.g, c0.b, 1.0f); + ImU32 col1 = ImColor(c1.r, c1.g, c1.b, 1.0f); + float x0 = pos.x + t0 * legendWidth; + float x1 = pos.x + t1 * legendWidth; + drawList->AddRectFilledMultiColor(ImVec2(x0, pos.y), ImVec2(x1, pos.y + barHeight), col0, col1, col1, col0); + } + drawList->AddRect(ImVec2(pos.x, pos.y), ImVec2(pos.x + legendWidth, pos.y + barHeight), ImGui::GetColorU32(ImGuiCol_Border)); + ImGui::Dummy(ImVec2(legendWidth, barHeight + 6.0f)); + + auto formatLabel = [](const char *prefix, float value) { + char buffer[32]; + std::snprintf(buffer, sizeof(buffer), "%s %.0fK", prefix, value); + return std::string(buffer); + }; + const std::string coldLabel = formatLabel("Cold", world.thermal_settings.min_visual_temperature); + const std::string hotLabel = formatLabel("Hot", world.thermal_settings.max_visual_temperature); + const float startX = ImGui::GetCursorPosX(); + ImGui::TextUnformatted(coldLabel.c_str()); + ImGui::SameLine(); + float hotWidth = ImGui::CalcTextSize(hotLabel.c_str()).x; + ImGui::SetCursorPosX(startX + legendWidth - hotWidth); + ImGui::TextUnformatted(hotLabel.c_str()); + ImGui::Spacing(); +} + void RenderEnginePopups() { if (g_toast_text.empty()) @@ -101,14 +148,20 @@ static float approx_radius_for_new_shape() static void spawn_body(PhysicsWorld &world) { Collider *collider_ptr = nullptr; + int shapeChoice = shapeIndex; + if (world.thermal_spawn_controls.lock_to_basic_shapes && shapeChoice > 1) + { + shapeChoice = std::min(shapeChoice, 1); + shapeIndex = shapeChoice; + } - if (shapeIndex == 0) // spawn sphere + if (shapeChoice == 0) // spawn sphere { auto c = std::make_unique(sphereRadius); collider_ptr = c.get(); ownedColliders.push_back(std::move(c)); } - else if (shapeIndex == 1) // spawn box + else if (shapeChoice == 1) // spawn box { auto c = std::make_unique(Vec3(boxHalfSize[0], boxHalfSize[1], boxHalfSize[2])); collider_ptr = c.get(); @@ -128,15 +181,35 @@ static void spawn_body(PhysicsWorld &world) collider_ptr, spawnMass); b.force_accum = Vec3(spawnForce[0], spawnForce[1], spawnForce[2]); + if (world.thermal_spawn_controls.enabled) + { + b.thermal_enabled = true; + b.temperature = world.thermal_spawn_controls.spawn_temperature; + b.heat_capacity = world.thermal_spawn_controls.spawn_heat_capacity; + b.thermal_conductivity = world.thermal_spawn_controls.spawn_conductivity; + b.thermal_emissivity = world.thermal_spawn_controls.spawn_emissivity; + } SetSelectedBodyId(world.addBody(std::move(b))); } void RenderAddBodyMenuContent(PhysicsWorld &world) { ImGui::SeparatorText("Spawn Body"); - const char *shapeNames[] = {"Sphere", "Box", "Ramp"}; - ImGui::Combo("Add Shape", &shapeIndex, shapeNames, 3); + const bool restrictShapes = world.thermal_spawn_controls.lock_to_basic_shapes; + const char *shapeNamesFull[] = {"Sphere", "Box", "Ramp"}; + const char *shapeNamesLimited[] = {"Sphere", "Box"}; + if (restrictShapes && shapeIndex > 1) + { + shapeIndex = 0; + } + const char **shapeNames = restrictShapes ? shapeNamesLimited : shapeNamesFull; + int shapeCount = restrictShapes ? 2 : 3; + ImGui::Combo("Add Shape", &shapeIndex, shapeNames, shapeCount); show_tooltip("Choose which collider type to create for the next body."); + if (restrictShapes) + { + ImGui::TextColored(ImVec4(0.95f, 0.65f, 0.25f, 1.0f), "Thermal scenario: only spheres and boxes are available."); + } ImGui::DragFloat3("Position", spawnPos, 0.1f); show_tooltip("Initial world-space position for the new body."); @@ -167,6 +240,19 @@ void RenderAddBodyMenuContent(PhysicsWorld &world) show_tooltip("Half-width of the ramp across the Z axis."); } + if (world.thermal_spawn_controls.enabled) + { + ImGui::SeparatorText("Thermal Properties"); + ImGui::DragFloat("Spawn Temperature (K)", &world.thermal_spawn_controls.spawn_temperature, 1.0f, 100.0f, 1000.0f); + show_tooltip("Temperature assigned to newly spawned bodies inside the heat transfer lab."); + ImGui::DragFloat("Spawn Heat Capacity", &world.thermal_spawn_controls.spawn_heat_capacity, 5.0f, 10.0f, 5000.0f); + show_tooltip("Higher heat capacity slows down temperature changes."); + ImGui::DragFloat("Spawn Conductivity", &world.thermal_spawn_controls.spawn_conductivity, 0.01f, 0.0f, 5.0f); + show_tooltip("Controls how quickly bodies exchange heat via contacts."); + ImGui::DragFloat("Spawn Emissivity", &world.thermal_spawn_controls.spawn_emissivity, 0.01f, 0.0f, 1.5f); + show_tooltip("Higher emissivity radiates heat faster to nearby bodies."); + } + if (ImGui::Button("Add Body")) { static float last_add_time = -1000.0f; @@ -314,6 +400,10 @@ void RenderBodyInspectorContent(PhysicsWorld &world, bool showCloseButton) ImGui::Separator(); ImGui::SeparatorText("Bodies"); + if (world.thermal_settings.enabled) + { + RenderThermalLegend(world); + } // show active bodies in the scene float listHeight = ImGui::GetContentRegionAvail().y; @@ -354,6 +444,11 @@ void RenderBodyInspectorContent(PhysicsWorld &world, bool showCloseButton) ImGui::Text("Pos: %.3f %.3f %.3f", body.position.x, body.position.y, body.position.z); ImGui::Text("Speed: %.3f %.3f %.3f", body.velocity.x, body.velocity.y, body.velocity.z); ImGui::Text("Force: %.3f %.3f %.3f", body.force_accum.x, body.force_accum.y, body.force_accum.z); + if (body.thermal_enabled) + { + ImGui::Text("Temp: %.1f K", body.temperature); + ImGui::Text("k: %.2f | emiss: %.2f", body.thermal_conductivity, body.thermal_emissivity); + } if (isSelected && isLive) { @@ -364,6 +459,14 @@ void RenderBodyInspectorContent(PhysicsWorld &world, bool showCloseButton) float editForce[3] = {body.force_accum.x, body.force_accum.y, body.force_accum.z}; if (ImGui::DragFloat3("Edit Force", editForce, 0.1f)) body.force_accum = Vec3(editForce[0], editForce[1], editForce[2]); + if (body.thermal_enabled) + { + float editTemp = body.temperature; + if (ImGui::DragFloat("Edit Temperature", &editTemp, 0.5f, 100.0f, 1000.0f)) + { + body.temperature = editTemp; + } + } } ImGui::Separator(); diff --git a/renderer/drawbodies.cpp b/renderer/drawbodies.cpp index 16f4133..8eb6bd4 100644 --- a/renderer/drawbodies.cpp +++ b/renderer/drawbodies.cpp @@ -13,6 +13,7 @@ #include "bodyselection.hpp" #include "bodyshaders.hpp" #include "drawconstraints.hpp" +#include "thermal_palette.hpp" #include #include #include @@ -247,6 +248,21 @@ static bool looksLikeFloor(const Rigidbody &body) // check for floor return hy <= 0.15f && hx >= 40.0f && hz >= 40.0f; } +static bool useThermalGradient(const PhysicsWorld &world, const Rigidbody &body) +{ + return world.thermal_settings.enabled && body.thermal_enabled && !looksLikeFloor(body); +} + +static glm::vec3 temperatureColor(const PhysicsWorld &world, const Rigidbody &body) +{ + const auto &settings = world.thermal_settings; + float minT = settings.min_visual_temperature; + float maxT = settings.max_visual_temperature; + float span = std::max(1.0f, maxT - minT); + float norm = (body.temperature - minT) / span; + return SampleThermalGradient(norm); +} + static bool getArrowOrigin(const Rigidbody &body, const glm::vec3 &dir, glm::vec3 &origin, float &sizeScale) { if (!body.collider) @@ -628,6 +644,13 @@ void RenderBodies(PhysicsWorld &world, const Camera &camera, float aspectRatio) r = 0.5f + 0.5f * r; g = 0.5f + 0.5f * g; b = 0.5f + 0.5f * b; + if (useThermalGradient(world, body)) + { + glm::vec3 thermal = temperatureColor(world, body); + r = thermal.r; + g = thermal.g; + b = thermal.b; + } applyBodyTint(r, g, b); drawSolidBody(body, 0.0f, r, g, b, 1.0f); } @@ -792,6 +815,13 @@ void RenderBodies(PhysicsWorld &world, const Camera &camera, float aspectRatio) float tr = r; float tg = g; float tb = b; + if (useThermalGradient(world, body)) + { + glm::vec3 thermal = temperatureColor(world, body); + tr = thermal.r; + tg = thermal.g; + tb = thermal.b; + } applyBodyTint(tr, tg, tb); glUniform4f(colorLoc, tr, tg, tb, 1.0f); } diff --git a/renderer/thermal_palette.hpp b/renderer/thermal_palette.hpp new file mode 100644 index 0000000..db56fdc --- /dev/null +++ b/renderer/thermal_palette.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include + +inline glm::vec3 SampleThermalGradient(float t) +{ + t = std::clamp(t, 0.0f, 1.0f); + struct Stop + { + float pos; + glm::vec3 color; + }; + static const Stop stops[] = { + {0.0f, glm::vec3(0.03f, 0.07f, 0.28f)}, + {0.25f, glm::vec3(0.00f, 0.45f, 0.95f)}, + {0.50f, glm::vec3(0.00f, 0.90f, 0.45f)}, + {0.75f, glm::vec3(0.98f, 0.68f, 0.12f)}, + {1.0f, glm::vec3(1.00f, 0.20f, 0.05f)}, + }; + + for (std::size_t i = 1; i < sizeof(stops) / sizeof(stops[0]); ++i) + { + if (t <= stops[i].pos) + { + float start = stops[i - 1].pos; + float end = stops[i].pos; + float span = std::max(1e-5f, end - start); + float local = (t - start) / span; + return glm::mix(stops[i - 1].color, stops[i].color, local); + } + } + + return stops[sizeof(stops) / sizeof(stops[0]) - 1].color; +} diff --git a/renderer/window.cpp b/renderer/window.cpp index 103f3db..398e9a5 100644 --- a/renderer/window.cpp +++ b/renderer/window.cpp @@ -38,7 +38,8 @@ namespace TestCase::AngularStack, TestCase::CornerCollision, TestCase::RollingFriction, - TestCase::BuoyancyTest + TestCase::BuoyancyTest, + TestCase::HeatTransferDemo }; constexpr const char *kTestCaseNames[] = { @@ -54,7 +55,8 @@ namespace "Angular Stack Push", "Corner Collision Torque", "Rolling vs Sliding", - "Buoyancy Test" + "Buoyancy Test", + "Heat Transfer Lab" }; constexpr const char *kTestCaseDescriptions[] = { @@ -70,7 +72,8 @@ namespace "Sequential pushes on a block tower highlight angular momentum build-up.", "Corner-to-corner impacts show how contact point offsets create torque.", "Contrast high-friction rolling with sliding motion on ramps and flats.", - "Observe how buoyant forces affect floating objects in simulated fluids." + "Observe how buoyant forces affect floating objects in simulated fluids.", + "Watch boxes and spheres equalize heat via conduction and radiation with temperature-driven colors." };