feat: add more collider types + camera snapping fix

This commit is contained in:
2026-10-24 02:34:09 +02:00
parent 355dfa7851
commit 118016bcc7
26 changed files with 1525 additions and 354 deletions
+5
View File
@@ -54,6 +54,10 @@ set(SRC_FILES
"src/Components/Physics/BoxCollider.cpp"
"src/Components/Physics/CapsuleCollider.cpp"
"src/Components/Physics/Collider.cpp"
"src/Components/Physics/ConvexHullCollider.cpp"
"src/Components/Physics/CylinderCollider.cpp"
"src/Components/Physics/MeshCollider.cpp"
"src/Components/Physics/MeshShapeCollider.cpp"
"src/Components/Physics/SphereCollider.cpp"
"src/Components/UI/Canvas.cpp"
@@ -137,6 +141,7 @@ set(SRC_FILES
"src/Threading/JobSystem.cpp"
"src/Physics/PhysicsWorld.cpp"
"src/Physics/PhysicsMeshCache.cpp"
"src/Physics/PhysicsSceneBridge.cpp"
"src/Physics/JoltPhysicsWorld.cpp"
)
@@ -0,0 +1,41 @@
#pragma once
#include <algorithm>
#include <destrum/Components/Physics/MeshShapeCollider.h>
class ConvexHullCollider final : public MeshShapeCollider {
public:
static constexpr int MinVertices = 4;
static constexpr int MaxVertices = 256;
explicit ConvexHullCollider(GameObject& owner, int maxVertices = 64)
: MeshShapeCollider(owner)
, m_MaxVertices(std::clamp(maxVertices, MinVertices, MaxVertices)) {}
std::string GetTypeName() const override {
return "ConvexHullCollider";
}
nlohmann::json Serialize() const override;
void Deserialize(const nlohmann::json& data) override;
void ImGuiInspector() override;
void DrawDebugLines(const glm::vec4& color) const override;
[[nodiscard]] PhysicsShapeDesc BuildPhysicsShape() const override {
return BuildConvexHullShape(m_MaxVertices);
}
[[nodiscard]] int GetMaxVertices() const {
return m_MaxVertices;
}
void SetMaxVertices(int maxVertices) {
m_MaxVertices = std::clamp(maxVertices, MinVertices, MaxVertices);
NotifyPhysicsChanged();
}
private:
int m_MaxVertices{64};
};
@@ -0,0 +1,66 @@
#pragma once
#include <algorithm>
#include <destrum/Components/Physics/Collider.h>
class CylinderCollider final : public Collider {
public:
explicit CylinderCollider(GameObject& owner, float radius = 0.5f, float height = 1.0f)
: Collider(owner)
, m_Radius(std::max(radius, 0.0001f))
, m_Height(std::max(height, 0.0001f)) {}
void Update(float) override {}
std::string GetTypeName() const override {
return "CylinderCollider";
}
void ImGuiInspector() override;
nlohmann::json Serialize() const override {
auto data = SerializeCollider();
data["radius"] = m_Radius;
data["height"] = m_Height;
return data;
}
void Deserialize(const nlohmann::json& data) override {
DeserializeCollider(data);
if (data.contains("radius")) {
SetRadius(data.at("radius").get<float>());
}
if (data.contains("height")) {
SetHeight(data.at("height").get<float>());
}
}
void DrawDebugLines(const glm::vec4& color) const override;
[[nodiscard]] PhysicsShapeDesc BuildPhysicsShape() const override {
return PhysicsShapeDesc::Cylinder(m_Radius, m_Height, m_CenterOffset, m_IsTrigger);
}
[[nodiscard]] float GetRadius() const {
return m_Radius;
}
void SetRadius(float radius) {
m_Radius = std::max(radius, 0.0001f);
NotifyPhysicsChanged();
}
[[nodiscard]] float GetHeight() const {
return m_Height;
}
void SetHeight(float height) {
m_Height = std::max(height, 0.0001f);
NotifyPhysicsChanged();
}
private:
float m_Radius{0.5f};
float m_Height{1.0f};
};
@@ -0,0 +1,32 @@
#pragma once
#include <destrum/Components/Physics/MeshShapeCollider.h>
// Exact triangle mesh for static and kinematic bodies. Dynamic bodies can't use one, they get a convex hull instead.
class MeshCollider final : public MeshShapeCollider {
public:
static constexpr int DynamicHullVertices = 64;
explicit MeshCollider(GameObject& owner)
: MeshShapeCollider(owner) {}
std::string GetTypeName() const override {
return "MeshCollider";
}
nlohmann::json Serialize() const override {
return SerializeCollider();
}
void Deserialize(const nlohmann::json& data) override {
DeserializeCollider(data);
}
void ImGuiInspector() override;
void DrawDebugLines(const glm::vec4& color) const override;
[[nodiscard]] PhysicsShapeDesc BuildPhysicsShape() const override;
[[nodiscard]] bool UsesConvexFallback() const;
};
@@ -0,0 +1,36 @@
#pragma once
#include <memory>
#include <destrum/Components/Physics/Collider.h>
// Base for colliders shaped by the MeshRendererComponent on the same object. Unlike the primitive colliders,
// the mesh is in local units, so the transform's world scale is applied.
class MeshShapeCollider : public Collider {
public:
explicit MeshShapeCollider(GameObject& owner)
: Collider(owner) {}
void Update(float) override {}
void ImGuiInspector() override;
// Used instead of the MeshRenderer mesh, not serialized
void SetCustomMesh(std::shared_ptr<const PhysicsMeshData> mesh) {
m_CustomMesh = std::move(mesh);
NotifyPhysicsChanged();
}
[[nodiscard]] std::shared_ptr<const PhysicsMeshData> GetSourceMesh() const;
protected:
[[nodiscard]] glm::vec3 GetShapeScale() const;
[[nodiscard]] glm::mat4 GetShapeMatrix() const;
// Falls back to a box around the mesh when it has no volume
[[nodiscard]] PhysicsShapeDesc BuildConvexHullShape(int maxVertices) const;
void DrawConvexHull(int maxVertices, const glm::vec4& color) const;
private:
std::shared_ptr<const PhysicsMeshData> m_CustomMesh;
};
@@ -4,6 +4,9 @@
#include <glm/glm.hpp>
#include <glm/gtc/quaternion.hpp>
#include <cstdint>
#include <vector>
namespace DebugDraw {
void Arc(const glm::vec3& center, const glm::vec3& axisA, const glm::vec3& axisB, float radius,
float fromAngle, float toAngle, const glm::vec4& color, int segments = 16);
@@ -14,6 +17,12 @@ namespace DebugDraw {
void Sphere(const glm::vec3& center, float radius, const glm::vec4& color);
void Capsule(const glm::vec3& center, const glm::quat& rotation, float radius, float height, const glm::vec4& color);
void Cylinder(const glm::vec3& center, const glm::quat& rotation, float radius, float height, const glm::vec4& color);
// Wireframe of an indexed triangle list, stops after maxTriangles
void Triangles(const glm::mat4& transform, const std::vector<glm::vec3>& points,
const std::vector<std::uint32_t>& indices, const glm::vec4& color,
std::size_t maxTriangles = 20000);
}
#endif // DEBUGDRAW_H
@@ -0,0 +1,18 @@
#pragma once
#include <memory>
#include <string>
#include <destrum/Physics/PhysicsTypes.h>
struct CPUMesh;
// Shares collision data between colliders using the same mesh, so physics backends can share their shapes too.
// Main thread only.
namespace PhysicsMeshCache {
[[nodiscard]] std::shared_ptr<const PhysicsMeshData> FromCPUMesh(const std::string& key, const CPUMesh& mesh);
// Null when the points don't span a volume (e.g. a plane)
[[nodiscard]] std::shared_ptr<const PhysicsMeshData> ConvexHull(const std::shared_ptr<const PhysicsMeshData>& source,
int maxVertices);
}
+57 -2
View File
@@ -2,6 +2,8 @@
#include <cstdint>
#include <limits>
#include <memory>
#include <vector>
#include <glm/glm.hpp>
#include <glm/gtc/quaternion.hpp>
@@ -17,7 +19,10 @@ enum class PhysicsShapeType {
None,
Box,
Sphere,
Capsule
Capsule,
Cylinder,
ConvexHull,
Mesh
};
struct PhysicsBodyHandle {
@@ -50,6 +55,12 @@ struct PhysicsMaterialDesc {
float restitution{0.0f};
};
// Triangles share vertices through indices. A convex hull only uses the points, the indices are for drawing it.
struct PhysicsMeshData {
std::vector<glm::vec3> points;
std::vector<std::uint32_t> indices;
};
struct PhysicsShapeDesc {
PhysicsShapeType type{PhysicsShapeType::None};
@@ -59,9 +70,14 @@ struct PhysicsShapeDesc {
// Sphere / capsule
float radius{0.5f};
// Capsule full height, including the two hemispheres.
// Capsule full height, including the two hemispheres. Cylinder full height.
float height{2.0f};
// Convex hull / mesh, in the collider's local space before scale. Backends may cache
// shapes per mesh data, so it must not be modified once shared.
std::shared_ptr<const PhysicsMeshData> mesh;
glm::vec3 scale{1.0f};
// Local offset from the rigidbody transform.
glm::vec3 centerOffset{0.0f};
@@ -101,6 +117,45 @@ struct PhysicsShapeDesc {
desc.isTrigger = isTrigger;
return desc;
}
[[nodiscard]] static PhysicsShapeDesc Cylinder(float radius,
float height,
const glm::vec3& centerOffset = glm::vec3{0.0f},
bool isTrigger = false) {
PhysicsShapeDesc desc{};
desc.type = PhysicsShapeType::Cylinder;
desc.radius = radius;
desc.height = height;
desc.centerOffset = centerOffset;
desc.isTrigger = isTrigger;
return desc;
}
[[nodiscard]] static PhysicsShapeDesc ConvexHull(std::shared_ptr<const PhysicsMeshData> hull,
const glm::vec3& scale = glm::vec3{1.0f},
const glm::vec3& centerOffset = glm::vec3{0.0f},
bool isTrigger = false) {
PhysicsShapeDesc desc{};
desc.type = PhysicsShapeType::ConvexHull;
desc.mesh = std::move(hull);
desc.scale = scale;
desc.centerOffset = centerOffset;
desc.isTrigger = isTrigger;
return desc;
}
[[nodiscard]] static PhysicsShapeDesc Mesh(std::shared_ptr<const PhysicsMeshData> mesh,
const glm::vec3& scale = glm::vec3{1.0f},
const glm::vec3& centerOffset = glm::vec3{0.0f},
bool isTrigger = false) {
PhysicsShapeDesc desc{};
desc.type = PhysicsShapeType::Mesh;
desc.mesh = std::move(mesh);
desc.scale = scale;
desc.centerOffset = centerOffset;
desc.isTrigger = isTrigger;
return desc;
}
};
struct PhysicsBodyDesc {
@@ -12,6 +12,9 @@
#include <destrum/Components/Spinner.h>
#include <destrum/Components/Physics/BoxCollider.h>
#include <destrum/Components/Physics/CapsuleCollider.h>
#include <destrum/Components/Physics/ConvexHullCollider.h>
#include <destrum/Components/Physics/CylinderCollider.h>
#include <destrum/Components/Physics/MeshCollider.h>
#include <destrum/Components/Physics/Rigidbody.h>
#include <destrum/Components/Physics/SphereCollider.h>
#include <destrum/Components/UI/Canvas.h>
@@ -32,7 +35,7 @@
X(Rigidbody) \
X(BoxCollider) \
X(SphereCollider) \
X(CapsuleCollider) \
X(CapsuleCollider) X(CylinderCollider) X(ConvexHullCollider) X(MeshCollider) \
X(Canvas) \
X(RectTransform) \
X(UIImage) \
@@ -1,6 +1,7 @@
#include <destrum/Components/MeshRendererComponent.h>
#include <destrum/Assets/AssetDatabase.h>
#include <destrum/Assets/AssetReference.h>
#include <destrum/Components/Physics/MeshShapeCollider.h>
#include <destrum/Serialization/MaterialSerialization.h>
#include <destrum/Graphics/GfxDevice.h>
#include <destrum/Graphics/Resources/PrimitiveMeshes.h>
@@ -254,6 +255,9 @@ void MeshRendererComponent::SetMeshID(MeshID id) {
meshKey = resources->meshes().getMeshKey(id);
}
}
if (GetGameObject()->GetComponent<MeshShapeCollider>() != nullptr) {
NotifyPhysicsChanged();
}
}
void MeshRendererComponent::SetMaterialID(MaterialID id) {
@@ -0,0 +1,29 @@
#include <destrum/Components/Physics/ConvexHullCollider.h>
#include <imgui.h>
nlohmann::json ConvexHullCollider::Serialize() const {
auto data = SerializeCollider();
data["maxVertices"] = m_MaxVertices;
return data;
}
void ConvexHullCollider::Deserialize(const nlohmann::json& data) {
DeserializeCollider(data);
if (data.contains("maxVertices")) {
SetMaxVertices(data.at("maxVertices").get<int>());
}
}
void ConvexHullCollider::DrawDebugLines(const glm::vec4& color) const {
DrawConvexHull(m_MaxVertices, color);
}
void ConvexHullCollider::ImGuiInspector() {
MeshShapeCollider::ImGuiInspector();
int maxVertices = m_MaxVertices;
if (ImGui::SliderInt("Max vertices", &maxVertices, MinVertices, MaxVertices)) {
SetMaxVertices(maxVertices);
}
}
@@ -0,0 +1,23 @@
#include <destrum/Components/Physics/CylinderCollider.h>
#include <destrum/Graphics/DebugDraw.h>
#include <imgui.h>
void CylinderCollider::DrawDebugLines(const glm::vec4& color) const {
DebugDraw::Cylinder(GetWorldCenter(), GetWorldRotation(), m_Radius, m_Height, color);
}
void CylinderCollider::ImGuiInspector() {
Collider::ImGuiInspector();
float radius = m_Radius;
if (ImGui::DragFloat("Radius", &radius, 0.05f, 0.0001f)) {
SetRadius(radius);
}
float height = m_Height;
if (ImGui::DragFloat("Height", &height, 0.05f, 0.0001f)) {
SetHeight(height);
}
}
@@ -0,0 +1,43 @@
#include <destrum/Components/Physics/MeshCollider.h>
#include <imgui.h>
#include <destrum/Components/Physics/Rigidbody.h>
#include <destrum/Graphics/DebugDraw.h>
#include <destrum/ObjectModel/GameObject.h>
bool MeshCollider::UsesConvexFallback() const {
const auto* rigidbody = GetGameObject()->GetComponent<Rigidbody>();
return rigidbody != nullptr && rigidbody->GetType() == RigidbodyType::Dynamic;
}
PhysicsShapeDesc MeshCollider::BuildPhysicsShape() const {
if (UsesConvexFallback()) {
return BuildConvexHullShape(DynamicHullVertices);
}
auto source = GetSourceMesh();
if (!source || source->indices.empty()) {
return {};
}
return PhysicsShapeDesc::Mesh(std::move(source), GetShapeScale(), m_CenterOffset, m_IsTrigger);
}
void MeshCollider::DrawDebugLines(const glm::vec4& color) const {
if (UsesConvexFallback()) {
DrawConvexHull(DynamicHullVertices, color);
return;
}
if (const auto source = GetSourceMesh()) {
DebugDraw::Triangles(GetShapeMatrix(), source->points, source->indices, color);
}
}
void MeshCollider::ImGuiInspector() {
MeshShapeCollider::ImGuiInspector();
if (UsesConvexFallback()) {
ImGui::TextColored(ImVec4{1.0f, 0.6f, 0.2f, 1.0f}, "Dynamic rigidbody: using a convex hull");
}
}
@@ -0,0 +1,118 @@
#include <destrum/Components/Physics/MeshShapeCollider.h>
#include <cmath>
#include <limits>
#include <imgui.h>
#include <glm/gtc/matrix_transform.hpp>
#include <destrum/Components/MeshRendererComponent.h>
#include <destrum/Graphics/DebugDraw.h>
#include <destrum/Graphics/RenderResources.h>
#include <destrum/Graphics/Renderer.h>
#include <destrum/ObjectModel/GameObject.h>
#include <destrum/ObjectModel/Transform.h>
#include <destrum/Physics/PhysicsMeshCache.h>
#include <destrum/Util/GameState.h>
namespace {
struct Bounds {
glm::vec3 center{0.0f};
glm::vec3 halfExtents{0.0f};
};
Bounds ComputeBounds(const PhysicsMeshData& mesh) {
glm::vec3 min{std::numeric_limits<float>::max()};
glm::vec3 max{std::numeric_limits<float>::lowest()};
for (const glm::vec3& point : mesh.points) {
min = glm::min(min, point);
max = glm::max(max, point);
}
return {(min + max) * 0.5f, (max - min) * 0.5f};
}
}
std::shared_ptr<const PhysicsMeshData> MeshShapeCollider::GetSourceMesh() const {
if (m_CustomMesh) {
return m_CustomMesh;
}
const auto* meshRenderer = GetGameObject()->GetComponent<MeshRendererComponent>();
if (meshRenderer == nullptr || meshRenderer->GetMeshID() == NULL_MESH_ID || !GameState::GetInstance().HasRenderer()) {
return nullptr;
}
const RenderResources* resources = GameState::GetInstance().Renderer().getResources();
if (resources == nullptr || !resources->meshes().isValid(meshRenderer->GetMeshID())) {
return nullptr;
}
const MeshCache& meshes = resources->meshes();
return PhysicsMeshCache::FromCPUMesh(meshes.getMeshKey(meshRenderer->GetMeshID()),
meshes.getCPUMesh(meshRenderer->GetMeshID()));
}
glm::vec3 MeshShapeCollider::GetShapeScale() const {
glm::vec3 scale = GetTransform().GetWorldScale();
for (int axis = 0; axis < 3; ++axis) {
if (std::abs(scale[axis]) < 0.0001f) {
scale[axis] = scale[axis] < 0.0f ? -0.0001f : 0.0001f;
}
}
return scale;
}
glm::mat4 MeshShapeCollider::GetShapeMatrix() const {
return glm::translate(glm::mat4{1.0f}, GetWorldCenter()) *
glm::mat4_cast(GetWorldRotation()) *
glm::scale(glm::mat4{1.0f}, GetShapeScale());
}
PhysicsShapeDesc MeshShapeCollider::BuildConvexHullShape(int maxVertices) const {
const auto source = GetSourceMesh();
if (!source || source->points.empty()) {
return {};
}
const glm::vec3 scale = GetShapeScale();
if (auto hull = PhysicsMeshCache::ConvexHull(source, maxVertices)) {
return PhysicsShapeDesc::ConvexHull(std::move(hull), scale, m_CenterOffset, m_IsTrigger);
}
const Bounds bounds = ComputeBounds(*source);
return PhysicsShapeDesc::Box(glm::max(glm::abs(bounds.halfExtents * scale), glm::vec3{0.01f}),
m_CenterOffset + bounds.center * scale,
m_IsTrigger);
}
void MeshShapeCollider::DrawConvexHull(int maxVertices, const glm::vec4& color) const {
const auto source = GetSourceMesh();
if (!source || source->points.empty()) {
return;
}
if (const auto hull = PhysicsMeshCache::ConvexHull(source, maxVertices)) {
DebugDraw::Triangles(GetShapeMatrix(), hull->points, hull->indices, color);
return;
}
const Bounds bounds = ComputeBounds(*source);
const glm::vec3 scale = GetShapeScale();
DebugDraw::Box(GetWorldCenter() + GetWorldRotation() * (bounds.center * scale),
GetWorldRotation(),
glm::max(glm::abs(bounds.halfExtents * scale), glm::vec3{0.01f}),
color);
}
void MeshShapeCollider::ImGuiInspector() {
Collider::ImGuiInspector();
if (m_CustomMesh) {
ImGui::TextDisabled("Custom mesh: %zu points", m_CustomMesh->points.size());
} else if (const auto source = GetSourceMesh()) {
ImGui::TextDisabled("Mesh: %zu vertices, %zu triangles", source->points.size(), source->indices.size() / 3);
} else {
ImGui::TextColored(ImVec4{1.0f, 0.6f, 0.2f, 1.0f}, "Needs a MeshRendererComponent with a mesh");
}
}
+46
View File
@@ -94,3 +94,49 @@ void DebugDraw::Capsule(const glm::vec3& center, const glm::quat& rotation, floa
Arc(bottom, right, up, radius, pi, glm::two_pi<float>(), color);
Arc(bottom, forward, up, radius, pi, glm::two_pi<float>(), color);
}
void DebugDraw::Cylinder(const glm::vec3& center, const glm::quat& rotation, float radius, float height,
const glm::vec4& color)
{
const glm::vec3 right = rotation * glm::vec3{1.0f, 0.0f, 0.0f};
const glm::vec3 up = rotation * glm::vec3{0.0f, 1.0f, 0.0f};
const glm::vec3 forward = rotation * glm::vec3{0.0f, 0.0f, 1.0f};
const glm::vec3 top = center + up * (height * 0.5f);
const glm::vec3 bottom = center - up * (height * 0.5f);
Circle(top, right, forward, radius, color);
Circle(bottom, right, forward, radius, color);
auto& lines = LineRenderingManager::GetInstance();
for (const glm::vec3& side : {right, -right, forward, -forward}) {
lines.SubmitLine(top + side * radius, bottom + side * radius, color);
}
}
void DebugDraw::Triangles(const glm::mat4& transform, const std::vector<glm::vec3>& points,
const std::vector<std::uint32_t>& indices, const glm::vec4& color,
std::size_t maxTriangles)
{
auto& lines = LineRenderingManager::GetInstance();
const auto at = [&](std::uint32_t index) {
return glm::vec3(transform * glm::vec4(points[index], 1.0f));
};
const std::size_t count = std::min(indices.size() / 3, maxTriangles);
for (std::size_t triangle = 0; triangle < count; ++triangle) {
const std::uint32_t ia = indices[triangle * 3];
const std::uint32_t ib = indices[triangle * 3 + 1];
const std::uint32_t ic = indices[triangle * 3 + 2];
if (std::max({ia, ib, ic}) >= points.size()) {
continue;
}
const glm::vec3 a = at(ia);
const glm::vec3 b = at(ib);
const glm::vec3 c = at(ic);
lines.SubmitLine(a, b, color);
lines.SubmitLine(b, c, color);
lines.SubmitLine(c, a, color);
}
}
+115 -2
View File
@@ -16,7 +16,11 @@
#include <Jolt/Physics/Collision/RayCast.h>
#include <Jolt/Physics/Collision/Shape/BoxShape.h>
#include <Jolt/Physics/Collision/Shape/CapsuleShape.h>
#include <Jolt/Physics/Collision/Shape/ConvexHullShape.h>
#include <Jolt/Physics/Collision/Shape/CylinderShape.h>
#include <Jolt/Physics/Collision/Shape/MeshShape.h>
#include <Jolt/Physics/Collision/Shape/RotatedTranslatedShape.h>
#include <Jolt/Physics/Collision/Shape/ScaledShape.h>
#include <Jolt/Physics/Collision/Shape/SphereShape.h>
#include <Jolt/Physics/PhysicsSystem.h>
#include <Jolt/RegisterTypes.h>
@@ -34,7 +38,9 @@
#include <unordered_set>
#include <vector>
#include <glm/gtc/epsilon.hpp>
#include <glm/gtx/norm.hpp>
#include <spdlog/spdlog.h>
#include <destrum/Components/Physics/Rigidbody.h>
#include <destrum/ObjectModel/GameObject.h>
@@ -270,6 +276,46 @@ namespace
return type == RigidbodyType::Static ? Layers::NON_MOVING : Layers::MOVING;
}
[[nodiscard]] ShapeRefC CheckedCreate(const ShapeSettings& settings, const char* what)
{
ShapeSettings::ShapeResult result = settings.Create();
if (!result.IsValid())
{
throw std::runtime_error(
std::string("Failed to create Jolt ") + what + " shape: " + std::string(result.GetError()));
}
return result.Get();
}
[[nodiscard]] ShapeRefC CreateConvexHullShape(const PhysicsMeshData& hull)
{
Array<Vec3> points;
points.reserve(hull.points.size());
for (const glm::vec3& point : hull.points)
{
points.push_back(ToJoltVec3(point));
}
return CheckedCreate(ConvexHullShapeSettings(points), "convex hull");
}
[[nodiscard]] ShapeRefC CreateMeshShape(const PhysicsMeshData& mesh)
{
VertexList vertices;
vertices.reserve(mesh.points.size());
for (const glm::vec3& point : mesh.points)
{
vertices.push_back(Float3(point.x, point.y, point.z));
}
IndexedTriangleList triangles;
triangles.reserve(mesh.indices.size() / 3);
for (std::size_t i = 0; i + 2 < mesh.indices.size(); i += 3)
{
triangles.push_back(IndexedTriangle(mesh.indices[i], mesh.indices[i + 1], mesh.indices[i + 2]));
}
return CheckedCreate(MeshShapeSettings(std::move(vertices), std::move(triangles)), "mesh");
}
[[nodiscard]] ShapeRefC CreateJoltShape(const PhysicsShapeDesc& desc)
{
switch (desc.type)
@@ -298,6 +344,17 @@ namespace
return new CapsuleShape(halfCylinderHeight, desc.radius);
}
case PhysicsShapeType::Cylinder:
{
const float halfHeight = std::max(0.0001f, desc.height * 0.5f);
const float convexRadius = std::min({cDefaultConvexRadius, halfHeight, desc.radius});
return new CylinderShape(halfHeight, desc.radius, convexRadius);
}
case PhysicsShapeType::ConvexHull:
case PhysicsShapeType::Mesh:
throw std::runtime_error("Mesh based shapes are created through the shape cache.");
case PhysicsShapeType::None:
default:
throw std::runtime_error("Cannot create Jolt body without a valid shape.");
@@ -433,13 +490,14 @@ public:
}
m_Bodies.clear();
m_MeshShapeCache.clear();
ShutdownJoltGlobals();
}
PhysicsBodyHandle CreateBody(const PhysicsBodyDesc& desc)
{
ShapeRefC shape = CreateJoltShape(desc.shape);
ShapeRefC shape = desc.shape.mesh ? GetMeshBasedShape(desc.shape) : CreateJoltShape(desc.shape);
// Move the shape itself relative to the body origin, so the body transform stays aligned with
// the owning GameObject (an offset center of mass shape would only move the center of mass)
@@ -478,7 +536,16 @@ public:
}
settings.mUserData = reinterpret_cast<JPH::uint64>(desc.owner);
if (desc.type == RigidbodyType::Dynamic)
if (desc.shape.type == PhysicsShapeType::Mesh && desc.type != RigidbodyType::Static)
{
// Mesh shapes have no volume to derive mass from, approximate with their bounds
settings.mOverrideMassProperties = EOverrideMassProperties::MassAndInertiaProvided;
settings.mMassPropertiesOverride.SetMassAndInertiaOfSolidBox(
Vec3::sMax(shape->GetLocalBounds().GetSize(), Vec3::sReplicate(0.01f)),
1.0f);
settings.mMassPropertiesOverride.ScaleToMass(std::max(0.0001f, desc.mass));
}
else if (desc.type == RigidbodyType::Dynamic)
{
settings.mOverrideMassProperties = EOverrideMassProperties::CalculateInertia;
settings.mMassPropertiesOverride.mMass = std::max(0.0001f, desc.mass);
@@ -709,6 +776,50 @@ public:
}
private:
struct CachedMeshShape
{
std::weak_ptr<const PhysicsMeshData> data;
PhysicsShapeType type{PhysicsShapeType::None};
ShapeRefC shape;
};
// Building a hull or mesh BVH is expensive and bodies get recreated on every edit, so the
// unscaled shape is shared by every body using the same mesh data
ShapeRefC GetMeshBasedShape(const PhysicsShapeDesc& desc)
{
const PhysicsMeshData* key = desc.mesh.get();
ShapeRefC shape;
auto it = m_MeshShapeCache.find(key);
if (it != m_MeshShapeCache.end() && !it->second.data.expired() && it->second.type == desc.type)
{
shape = it->second.shape;
}
else
{
std::erase_if(m_MeshShapeCache, [](const auto& entry) { return entry.second.data.expired(); });
shape = desc.type == PhysicsShapeType::ConvexHull
? CreateConvexHullShape(*desc.mesh)
: CreateMeshShape(*desc.mesh);
m_MeshShapeCache.insert_or_assign(key, CachedMeshShape{desc.mesh, desc.type, shape});
}
if (glm::all(glm::epsilonEqual(desc.scale, glm::vec3{1.0f}, 0.0001f)))
{
return shape;
}
const Vec3 scale = ToJoltVec3(desc.scale);
if (!shape->IsValidScale(scale))
{
spdlog::warn("Physics: unsupported scale ({}, {}, {}) for mesh based shape, ignoring it",
desc.scale.x, desc.scale.y, desc.scale.z);
return shape;
}
return new ScaledShape(shape, scale);
}
struct BodyRecord
{
bool alive{false};
@@ -802,6 +913,8 @@ private:
std::unordered_map<std::uint32_t, BodyRecord> m_Bodies;
std::uint32_t m_NextHandle{0};
std::unordered_map<const PhysicsMeshData*, CachedMeshShape> m_MeshShapeCache;
SensorContactListener m_SensorListener;
std::unordered_map<SensorContactListener::BodyPair, SensorContactListener::PairData, SensorContactListener::PairHash> m_PreviousSensorOverlaps;
std::vector<TriggerEvent> m_TriggerEvents;
+151
View File
@@ -0,0 +1,151 @@
#include <destrum/Physics/PhysicsMeshCache.h>
#include <Jolt/Jolt.h>
#include <Jolt/Core/Memory.h>
#include <Jolt/Geometry/ConvexHullBuilder.h>
#include <algorithm>
#include <limits>
#include <map>
#include <unordered_map>
#include <utility>
#include <glm/gtx/component_wise.hpp>
#include <destrum/Graphics/Resources/CPUMesh.h>
namespace {
struct HullEntry {
std::weak_ptr<const PhysicsMeshData> source;
std::weak_ptr<const PhysicsMeshData> hull;
bool failed{false};
};
std::unordered_map<std::string, std::weak_ptr<const PhysicsMeshData>> g_Meshes;
std::map<std::pair<const PhysicsMeshData*, int>, HullEntry> g_Hulls;
std::shared_ptr<const PhysicsMeshData> BuildHull(const PhysicsMeshData& source, int maxVertices) {
// The builder allocates through Jolt, which may run before any physics world registered the allocator
if (JPH::Allocate == nullptr) {
JPH::RegisterDefaultAllocator();
}
JPH::ConvexHullBuilder::Positions positions;
positions.reserve(source.points.size());
for (const glm::vec3& point : source.points) {
positions.push_back(JPH::Vec3(point.x, point.y, point.z));
}
JPH::ConvexHullBuilder builder(positions);
const char* error = nullptr;
const auto result = builder.Initialize(maxVertices, 1.0e-3f, error);
if (result != JPH::ConvexHullBuilder::EResult::Success &&
result != JPH::ConvexHullBuilder::EResult::MaxVerticesReached) {
return nullptr;
}
// Coplanar points still build a flat hull, which can't be a convex shape
JPH::Vec3 centerOfMass;
float volume = 0.0f;
builder.GetCenterOfMassAndVolume(centerOfMass, volume);
glm::vec3 min{std::numeric_limits<float>::max()};
glm::vec3 max{std::numeric_limits<float>::lowest()};
for (const glm::vec3& point : source.points) {
min = glm::min(min, point);
max = glm::max(max, point);
}
const float extent = glm::compMax(max - min);
if (volume <= 1.0e-6f * extent * extent * extent) {
return nullptr;
}
auto hull = std::make_shared<PhysicsMeshData>();
std::unordered_map<int, std::uint32_t> remap;
const auto hullIndex = [&](int sourceIndex) {
auto [it, inserted] = remap.try_emplace(sourceIndex, static_cast<std::uint32_t>(hull->points.size()));
if (inserted) {
hull->points.push_back(source.points[static_cast<std::size_t>(sourceIndex)]);
}
return it->second;
};
std::vector<std::uint32_t> polygon;
for (const JPH::ConvexHullBuilder::Face* face : builder.GetFaces()) {
if (face->mRemoved) {
continue;
}
polygon.clear();
const JPH::ConvexHullBuilder::Edge* edge = face->mFirstEdge;
do {
polygon.push_back(hullIndex(edge->mStartIdx));
edge = edge->mNextEdge;
} while (edge != face->mFirstEdge);
for (std::size_t i = 1; i + 1 < polygon.size(); ++i) {
hull->indices.insert(hull->indices.end(), {polygon[0], polygon[i], polygon[i + 1]});
}
}
if (hull->points.size() < 4) {
return nullptr;
}
return hull;
}
}
std::shared_ptr<const PhysicsMeshData> PhysicsMeshCache::FromCPUMesh(const std::string& key, const CPUMesh& mesh) {
if (auto it = g_Meshes.find(key); it != g_Meshes.end()) {
// Same key with a different size means the mesh was reimported
if (auto cached = it->second.lock();
cached && cached->points.size() == mesh.vertices.size() && cached->indices.size() == mesh.indices.size()) {
return cached;
}
}
auto data = std::make_shared<PhysicsMeshData>();
data->points.reserve(mesh.vertices.size());
for (const CPUMesh::Vertex& vertex : mesh.vertices) {
data->points.push_back(vertex.position);
}
data->indices.reserve(mesh.indices.size() - mesh.indices.size() % 3);
for (std::size_t i = 0; i + 2 < mesh.indices.size(); i += 3) {
const std::uint32_t a = mesh.indices[i];
const std::uint32_t b = mesh.indices[i + 1];
const std::uint32_t c = mesh.indices[i + 2];
if (std::max({a, b, c}) < data->points.size()) {
data->indices.insert(data->indices.end(), {a, b, c});
}
}
std::erase_if(g_Meshes, [](const auto& entry) { return entry.second.expired(); });
g_Meshes.insert_or_assign(key, data);
return data;
}
std::shared_ptr<const PhysicsMeshData> PhysicsMeshCache::ConvexHull(const std::shared_ptr<const PhysicsMeshData>& source,
int maxVertices) {
if (!source || source->points.size() < 4) {
return nullptr;
}
const auto key = std::make_pair(source.get(), maxVertices);
if (auto it = g_Hulls.find(key); it != g_Hulls.end() && it->second.source.lock() == source) {
if (it->second.failed) {
return nullptr;
}
if (auto hull = it->second.hull.lock()) {
return hull;
}
}
auto hull = BuildHull(*source, maxVertices);
std::erase_if(g_Hulls, [](const auto& entry) {
return entry.second.source.expired() || (!entry.second.failed && entry.second.hull.expired());
});
g_Hulls.insert_or_assign(key, HullEntry{source, hull, hull == nullptr});
return hull;
}
+4
View File
@@ -61,6 +61,10 @@ void PhysicsWorld::RegisterRigidbody(Rigidbody& rigidbody) {
desc.transform.position = transform.GetWorldPosition();
desc.transform.rotation = transform.GetWorldRotation();
desc.shape = collider->BuildPhysicsShape();
if (desc.shape.type == PhysicsShapeType::None) {
// e.g. a mesh collider whose mesh isn't loaded, the next refresh retries
return;
}
// Collider dimensions are expressed in world units. Render meshes may
// use a transform scale to normalize imported asset units, so applying
+1
View File
@@ -136,6 +136,7 @@ private:
bool m_SaveAsDialog{false};
bool m_ItemWasActive{false};
bool m_EditPending{false};
bool m_CameraHadInput{false};
};
#endif // EDITOR_EDITOR_H
+11
View File
@@ -11,6 +11,17 @@
class Scene;
struct EditGestureState {
bool itemWasActive{false};
bool itemActive{false};
bool mouseReleased{false};
bool keyReleased{false};
bool keyboardDrivesCamera{false};
bool editPending{false};
};
[[nodiscard]] bool EndsEditGesture(const EditGestureState& state);
class UndoStack final {
public:
static constexpr std::size_t MaxSteps = 64;
+11 -4
View File
@@ -101,8 +101,9 @@ void Editor::onImGuiBegin(float dt)
ApplyPick();
HandleShortcuts();
m_CameraHadInput = UsesEditorCamera() && (m_Viewport.hovered || m_Viewport.focused || m_Camera.IsLooking());
if (UsesEditorCamera()) {
m_Camera.Update(dt, m_Viewport.hovered || m_Viewport.focused || m_Camera.IsLooking());
m_Camera.Update(dt, m_CameraHadInput);
}
if (m_ShaderHotReload.Poll(dt)) {
@@ -153,10 +154,16 @@ void Editor::onEndFrame()
}
const bool itemActive = ImGui::IsAnyItemActive() || ImGuizmo::IsUsing();
const bool gestureEnded = (m_ItemWasActive && !itemActive) || ImGui::IsMouseReleased(ImGuiMouseButton_Left) ||
AnyKeyReleased() || std::exchange(m_EditPending, false);
const bool gestureEnded = EndsEditGesture({
.itemWasActive = m_ItemWasActive,
.itemActive = itemActive,
.mouseReleased = ImGui::IsMouseReleased(ImGuiMouseButton_Left),
.keyReleased = AnyKeyReleased(),
.keyboardDrivesCamera = m_CameraHadInput,
.editPending = std::exchange(m_EditPending, false),
});
m_ItemWasActive = itemActive;
if (gestureEnded && !itemActive && m_PlayMode.IsEditing()) {
if (gestureEnded && m_PlayMode.IsEditing()) {
RecordEdits();
}
}
+10
View File
@@ -8,6 +8,16 @@
#include <destrum/Scene/Scene.h>
#include <destrum/Serialization/SceneSerializer.h>
bool EndsEditGesture(const EditGestureState& state)
{
if (state.itemActive) {
return false;
}
// Capturing a snapshot serializes the whole scene, doing that on every WASD release while flying hitches the camera
const bool keyGesture = state.keyReleased && !state.keyboardDrivesCamera;
return state.itemWasActive || state.mouseReleased || keyGesture || state.editPending;
}
std::optional<UndoStack::Snapshot> UndoStack::Capture(Scene& scene)
{
const auto root = SceneSerializer::Capture(scene);
File diff suppressed because it is too large Load Diff
+3
View File
@@ -178,6 +178,9 @@ namespace {
"BoxCollider",
"SphereCollider",
"CapsuleCollider",
"CylinderCollider",
"ConvexHullCollider",
"MeshCollider",
"Canvas",
"RectTransform",
"UIImage",
+14
View File
@@ -59,10 +59,24 @@ namespace {
SceneManager::GetInstance().Destroy();
}
void testCameraKeysDoNotEndEditGestures()
{
check(!EndsEditGesture({.keyReleased = true, .keyboardDrivesCamera = true}),
"releasing a key while flying the editor camera must not capture an undo snapshot");
check(EndsEditGesture({.keyReleased = true}), "releasing a key outside the viewport still ends an edit");
check(EndsEditGesture({.keyboardDrivesCamera = true, .editPending = true}),
"viewport shortcuts that edit (delete, duplicate) still end an edit");
check(EndsEditGesture({.mouseReleased = true, .keyboardDrivesCamera = true}),
"releasing the mouse after a viewport drag still ends an edit");
check(!EndsEditGesture({.itemWasActive = true, .itemActive = true, .mouseReleased = true}),
"nothing ends while an item is still being edited");
}
}
void RunEditorTests()
{
testUndoRedoRestoresEdits();
testUndoHistoryIsCapped();
testCameraKeysDoNotEndEditGestures();
}
+124
View File
@@ -10,6 +10,9 @@
#include <destrum/Components/Physics/BoxCollider.h>
#include <destrum/Components/Physics/CapsuleCollider.h>
#include <destrum/Components/Physics/Collider.h>
#include <destrum/Components/Physics/ConvexHullCollider.h>
#include <destrum/Components/Physics/CylinderCollider.h>
#include <destrum/Components/Physics/MeshCollider.h>
#include <destrum/Components/LightComponent.h>
#include <destrum/Components/Physics/Rigidbody.h>
@@ -357,6 +360,124 @@ namespace {
SceneManager::GetInstance().Destroy();
}
std::shared_ptr<const PhysicsMeshData> makeCubeMesh(float halfExtent)
{
auto mesh = std::make_shared<PhysicsMeshData>();
for (int corner = 0; corner < 8; ++corner) {
mesh->points.push_back({
corner & 1 ? halfExtent : -halfExtent,
corner & 2 ? halfExtent : -halfExtent,
corner & 4 ? halfExtent : -halfExtent});
}
mesh->points.push_back(glm::vec3{0.0f});
mesh->indices = {
0, 2, 1, 1, 2, 3, 4, 5, 6, 5, 7, 6,
0, 1, 4, 1, 5, 4, 2, 6, 3, 3, 6, 7,
0, 4, 2, 2, 4, 6, 1, 3, 5, 3, 7, 5};
return mesh;
}
std::shared_ptr<const PhysicsMeshData> makeQuadMesh(float halfExtent)
{
auto mesh = std::make_shared<PhysicsMeshData>();
mesh->points = {
{-halfExtent, 0.0f, -halfExtent},
{halfExtent, 0.0f, -halfExtent},
{-halfExtent, 0.0f, halfExtent},
{halfExtent, 0.0f, halfExtent}};
mesh->indices = {0, 2, 1, 1, 2, 3};
return mesh;
}
void testCylinderCollider()
{
JoltPhysicsWorld world{{.workerThreadCount = 1, .gravity = glm::vec3{0.0f}}};
GameObject object{"cylinder"};
object.AddComponent<CylinderCollider>(1.0f, 4.0f);
Rigidbody* rigidbody = object.AddComponent<Rigidbody>();
rigidbody->SetType(RigidbodyType::Static);
world.RegisterRigidbody(*rigidbody);
PhysicsRaycastHit hit;
check(world.Raycast({-3.0f, 1.5f, 0.0f}, {1.0f, 0.0f, 0.0f}, 5.0f, hit) && close(hit.distance, 2.0f),
"cylinder sides must be at its radius along the whole height");
check(world.Raycast({0.0f, 5.0f, 0.0f}, {0.0f, -1.0f, 0.0f}, 5.0f, hit) && close(hit.distance, 3.0f),
"cylinder caps must be flat at half its height");
}
void testConvexHullCollider()
{
JoltPhysicsWorld world{{.workerThreadCount = 1, .gravity = glm::vec3{0.0f}}};
GameObject object{"hull"};
object.GetTransform().SetLocalScale(glm::vec3{2.0f});
auto* collider = object.AddComponent<ConvexHullCollider>();
collider->SetCustomMesh(makeCubeMesh(1.0f));
check(collider->BuildPhysicsShape().type == PhysicsShapeType::ConvexHull,
"a cube mesh must build a convex hull");
check(collider->BuildPhysicsShape().mesh->points.size() == 8,
"interior points must not end up in the hull");
Rigidbody* rigidbody = object.AddComponent<Rigidbody>();
rigidbody->SetType(RigidbodyType::Static);
world.RegisterRigidbody(*rigidbody);
PhysicsRaycastHit hit;
check(world.Raycast({-5.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, 10.0f, hit) && close(hit.distance, 3.0f),
"mesh based colliders must apply the transform scale");
GameObject flat{"flat-hull"};
auto* flatCollider = flat.AddComponent<ConvexHullCollider>();
flatCollider->SetCustomMesh(makeQuadMesh(1.0f));
check(flatCollider->BuildPhysicsShape().type == PhysicsShapeType::Box,
"a mesh without volume must fall back to a box");
GameObject empty{"empty-hull"};
empty.AddComponent<ConvexHullCollider>();
Rigidbody* emptyBody = empty.AddComponent<Rigidbody>();
world.RegisterRigidbody(*emptyBody);
check(!emptyBody->HasPhysicsBody(), "a mesh collider without a mesh must not create a body");
}
void testMeshCollider()
{
JoltPhysicsWorld world{{.workerThreadCount = 1, .gravity = {0.0f, -9.81f, 0.0f}}};
GameObject ground{"mesh-ground"};
ground.AddComponent<MeshCollider>()->SetCustomMesh(makeQuadMesh(10.0f));
Rigidbody* groundBody = ground.AddComponent<Rigidbody>();
groundBody->SetType(RigidbodyType::Static);
world.RegisterRigidbody(*groundBody);
check(groundBody->HasPhysicsBody(), "a static mesh collider must create a body");
GameObject ball{"ball"};
ball.GetTransform().SetLocalPosition({0.0f, 2.0f, 0.0f});
ball.AddComponent<SphereCollider>(0.5f);
Rigidbody* ballBody = ball.AddComponent<Rigidbody>();
world.RegisterRigidbody(*ballBody);
for (int step = 0; step < 180; ++step) {
world.Step(1.0f / 60.0f);
world.SyncDynamicBodiesToTransforms();
}
const float restHeight = ball.GetTransform().GetWorldPosition().y;
check(restHeight > 0.4f && restHeight < 0.6f, "a sphere must come to rest on a mesh collider");
GameObject kinematic{"kinematic-mesh"};
kinematic.GetTransform().SetLocalPosition({50.0f, 0.0f, 0.0f});
kinematic.AddComponent<MeshCollider>()->SetCustomMesh(makeCubeMesh(1.0f));
Rigidbody* kinematicBody = kinematic.AddComponent<Rigidbody>();
kinematicBody->SetType(RigidbodyType::Kinematic);
world.RegisterRigidbody(*kinematicBody);
check(kinematicBody->HasPhysicsBody(), "a kinematic mesh collider must create a body");
GameObject dynamic{"dynamic-mesh"};
auto* dynamicCollider = dynamic.AddComponent<MeshCollider>();
dynamicCollider->SetCustomMesh(makeCubeMesh(1.0f));
dynamic.AddComponent<Rigidbody>();
check(dynamicCollider->BuildPhysicsShape().type == PhysicsShapeType::ConvexHull,
"dynamic mesh colliders must fall back to a convex hull");
}
}
void RunPhysicsTests()
@@ -368,5 +489,8 @@ void RunPhysicsTests()
testKinematicBodiesMoveWithVelocity();
testColliderCenterOffset();
testFrozenRotationCapsule();
testCylinderCollider();
testConvexHullCollider();
testMeshCollider();
testTriggerZone();
}