feat: Unity-like object manipulation + physics fixes
- ObjectManipulator: left-click to pick, drag on camera-orthogonal plane - Screen-space movement (like Unity), not ground-plane - Skip dragged entity in SyncTransforms so physics doesn't fight drag - Zero velocity on grab to prevent momentum accumulation - ImGui selection sync with manipulator - Floor lowered to y=-0.5, visual thickness corrected - Physics shape sizes fixed (BoxShape uses half-extents) - Collision pairs explicitly enabled in ObjectLayerPairFilterTable
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
using System.Numerics;
|
||||
using Engine.Core;
|
||||
using Engine.Core.Components;
|
||||
using Engine.Physics;
|
||||
using Flecs.NET.Core;
|
||||
using Raylib_cs;
|
||||
using EngineMesh = Engine.Core.Components.Mesh;
|
||||
using EngineTransform = Engine.Core.Components.Transform;
|
||||
using EngineRigidBody = Engine.Core.Components.RigidBody;
|
||||
|
||||
namespace CortexEngine.App;
|
||||
|
||||
/// <summary>
|
||||
/// Unity-like object manipulation: left-click to pick, drag to move on ground plane.
|
||||
/// Only active when ImGui is not capturing the mouse.
|
||||
/// </summary>
|
||||
public sealed class ObjectManipulator
|
||||
{
|
||||
private Entity _selectedEntity;
|
||||
private bool _isDragging;
|
||||
private Vector3 _dragOffset;
|
||||
private float _dragDepth;
|
||||
|
||||
public string SelectedEntityName => _selectedEntity.IsValid() ? _selectedEntity.Name() : "";
|
||||
public bool IsDragging => _isDragging;
|
||||
|
||||
/// <summary>
|
||||
/// Process input for object picking and dragging.
|
||||
/// Returns true if input was consumed (ImGui should be ignored).
|
||||
/// </summary>
|
||||
public bool ProcessInput(World world, Camera camera, IInputState input)
|
||||
{
|
||||
// Skip if ImGui is capturing mouse
|
||||
if (ImGuiNET.ImGui.GetIO().WantCaptureMouse)
|
||||
{
|
||||
_isDragging = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
var mousePos = new Vector2(input.MouseX, input.MouseY);
|
||||
var ray = Raylib.GetScreenToWorldRay(mousePos, ToRaylibCamera(camera));
|
||||
|
||||
// Left click — pick or start drag
|
||||
if (input.MouseLeft && !_isDragging)
|
||||
{
|
||||
var hit = RaycastEntities(world, ray);
|
||||
if (hit.IsValid())
|
||||
{
|
||||
_selectedEntity = hit;
|
||||
_isDragging = true;
|
||||
// Store depth along camera forward axis and offset from object center
|
||||
var objPos = hit.Get<EngineTransform>().Position;
|
||||
var camForward = Vector3.Normalize(camera.Target - camera.Position);
|
||||
_dragDepth = Vector3.Dot(objPos - camera.Position, camForward);
|
||||
_dragOffset = objPos - ProjectToCameraPlane(ray, camera, _dragDepth);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Drag — move object on plane orthogonal to camera (screen-space movement)
|
||||
if (_isDragging)
|
||||
{
|
||||
if (input.MouseLeft)
|
||||
{
|
||||
var targetPos = ProjectToCameraPlane(ray, camera, _dragDepth) + _dragOffset;
|
||||
|
||||
if (_selectedEntity.IsValid() && _selectedEntity.Has<EngineTransform>())
|
||||
{
|
||||
var t = _selectedEntity.Get<EngineTransform>();
|
||||
t.Position = targetPos;
|
||||
_selectedEntity.Set(t);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_isDragging = false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After physics step, re-sync dragged entity position to physics body.
|
||||
/// </summary>
|
||||
public void SyncToPhysics(PhysicsWorld physicsWorld)
|
||||
{
|
||||
if (_isDragging && _selectedEntity.IsValid() && _selectedEntity.Has<EngineTransform>())
|
||||
{
|
||||
var t = _selectedEntity.Get<EngineTransform>();
|
||||
physicsWorld.SyncToPhysics(_selectedEntity, t);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the entity currently being dragged (for physics sync skip).
|
||||
/// </summary>
|
||||
public Entity? GetDraggedEntity() => _isDragging ? _selectedEntity : null;
|
||||
|
||||
private static Entity RaycastEntities(World world, Ray ray)
|
||||
{
|
||||
Entity closest = default;
|
||||
var closestDist = float.MaxValue;
|
||||
|
||||
world.Each((Entity e, ref EngineTransform t, ref EngineMesh _) =>
|
||||
{
|
||||
var name = e.Name();
|
||||
if (string.IsNullOrEmpty(name) || name == "Grid" || name == "Floor")
|
||||
return;
|
||||
|
||||
// Simple sphere intersection using position + approximate radius
|
||||
var radius = 1.0f;
|
||||
if (e.Has<EngineRigidBody>())
|
||||
{
|
||||
var rb = e.Get<EngineRigidBody>();
|
||||
radius = rb.ShapeSize.Length();
|
||||
}
|
||||
|
||||
var toCenter = t.Position - ray.Position;
|
||||
var proj = Vector3.Dot(toCenter, ray.Direction);
|
||||
if (proj < 0) return; // behind camera
|
||||
|
||||
var closestPoint = ray.Position + ray.Direction * proj;
|
||||
var dist = Vector3.Distance(closestPoint, t.Position);
|
||||
|
||||
if (dist <= radius)
|
||||
{
|
||||
var rayDist = Vector3.Distance(ray.Position, t.Position);
|
||||
if (rayDist < closestDist)
|
||||
{
|
||||
closestDist = rayDist;
|
||||
closest = e;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return closest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Project a screen ray onto a plane orthogonal to the camera at the given depth.
|
||||
/// This makes objects move in screen-space (like Unity's screen-space drag).
|
||||
/// </summary>
|
||||
private static Vector3 ProjectToCameraPlane(Ray ray, Camera camera, float depth)
|
||||
{
|
||||
var camForward = Vector3.Normalize(camera.Target - camera.Position);
|
||||
var planePoint = camera.Position + camForward * depth;
|
||||
|
||||
// Ray-plane intersection: plane through planePoint with normal = camForward
|
||||
var denom = Vector3.Dot(ray.Direction, camForward);
|
||||
if (MathF.Abs(denom) < 0.0001f)
|
||||
return planePoint;
|
||||
|
||||
var t = Vector3.Dot(planePoint - ray.Position, camForward) / denom;
|
||||
if (t < 0)
|
||||
return planePoint;
|
||||
|
||||
return ray.Position + ray.Direction * t;
|
||||
}
|
||||
|
||||
private static Camera3D ToRaylibCamera(Camera camera)
|
||||
{
|
||||
return new Camera3D
|
||||
{
|
||||
Position = camera.Position,
|
||||
Target = camera.Target,
|
||||
Up = camera.Up,
|
||||
FovY = camera.FieldOfView * 180.0f / MathF.PI,
|
||||
Projection = CameraProjection.Perspective
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ class Program
|
||||
|
||||
// ImGui editor layer
|
||||
var imGuiLayer = new ImGuiLayer();
|
||||
var objectManipulator = new ObjectManipulator();
|
||||
if (!cameraTour)
|
||||
{
|
||||
imGuiLayer.Initialize();
|
||||
@@ -240,6 +241,13 @@ class Program
|
||||
}
|
||||
}
|
||||
|
||||
// Object manipulation (Unity-like drag)
|
||||
if (!cameraTour)
|
||||
{
|
||||
var cam = cameraEntity.Get<Camera>();
|
||||
objectManipulator.ProcessInput(world, cam, input);
|
||||
}
|
||||
|
||||
// Physics: create bodies, step, sync transforms
|
||||
if (!cameraTour)
|
||||
{
|
||||
@@ -258,8 +266,13 @@ class Program
|
||||
e.Set(rb);
|
||||
}
|
||||
|
||||
// Sync dragged object to physics before stepping
|
||||
objectManipulator.SyncToPhysics(physicsWorld);
|
||||
|
||||
physicsWorld.Update((float)timing.DeltaTime);
|
||||
physicsWorld.SyncTransforms(world);
|
||||
|
||||
// Sync all transforms EXCEPT the dragged object
|
||||
physicsWorld.SyncTransforms(world, objectManipulator.IsDragging ? objectManipulator.GetDraggedEntity() : null);
|
||||
}
|
||||
|
||||
// Capture a demo screenshot after the scene warms up (non-tour mode only).
|
||||
@@ -272,6 +285,10 @@ class Program
|
||||
// Feed frame data to ImGui before rendering.
|
||||
imGuiLayer.SetFrameData(world, timing, currentFps);
|
||||
|
||||
// Sync selection between manipulator and ImGui
|
||||
if (objectManipulator.IsDragging && !string.IsNullOrEmpty(objectManipulator.SelectedEntityName))
|
||||
imGuiLayer.SetSelectedEntity(objectManipulator.SelectedEntityName);
|
||||
|
||||
renderer.RenderWorld(world);
|
||||
queue.CompletePendingScreenshots();
|
||||
|
||||
|
||||
@@ -26,6 +26,16 @@ public sealed class ImGuiLayer : IDisposable
|
||||
private Timing? _timing;
|
||||
private int _fps;
|
||||
|
||||
/// <summary>
|
||||
/// Set the selected entity name from external (e.g. ObjectManipulator).
|
||||
/// </summary>
|
||||
public void SetSelectedEntity(string name) { _selectedEntity = name; }
|
||||
|
||||
/// <summary>
|
||||
/// Get the currently selected entity name.
|
||||
/// </summary>
|
||||
public string GetSelectedEntity() => _selectedEntity;
|
||||
|
||||
/// <summary>
|
||||
/// Set per-frame data before calling RenderImGuiUI.
|
||||
/// </summary>
|
||||
|
||||
@@ -121,7 +121,7 @@ public sealed class PhysicsWorld : IDisposable
|
||||
_physicsSystem.Update(deltaTime, collisionSteps, _jobSystem);
|
||||
}
|
||||
|
||||
public void SyncTransforms(World world)
|
||||
public void SyncTransforms(World world, Entity? skipEntity = null)
|
||||
{
|
||||
// Collect updates first to avoid calling entity.Set() during dictionary iteration
|
||||
var updates = new List<(Entity entity, Vector3 pos, Quaternion rot)>();
|
||||
@@ -130,6 +130,9 @@ public sealed class PhysicsWorld : IDisposable
|
||||
if ((ulong)entity.Id == 0)
|
||||
continue;
|
||||
|
||||
if (skipEntity.HasValue && skipEntity.Value.Id == entity.Id)
|
||||
continue;
|
||||
|
||||
var pos = _bodyInterface.GetPosition(bodyId);
|
||||
var rot = _bodyInterface.GetRotation(bodyId);
|
||||
updates.Add((entity, pos, rot));
|
||||
@@ -154,6 +157,8 @@ public sealed class PhysicsWorld : IDisposable
|
||||
return;
|
||||
|
||||
_bodyInterface.SetPositionAndRotation(bodyId, transform.Position, transform.Rotation, Activation.Activate);
|
||||
// Zero out velocity so the object doesn't accumulate momentum while being dragged
|
||||
_bodyInterface.SetLinearAndAngularVelocity(bodyId, Vector3.Zero, Vector3.Zero);
|
||||
}
|
||||
|
||||
private static Shape CreateShape(RigidBody rb)
|
||||
|
||||
Reference in New Issue
Block a user