From 258da380a70938d488f890bc5cff24d37e871802 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 16:52:17 +0900 Subject: [PATCH 01/21] =?UTF-8?q?InputState=20=E3=81=AB=E3=83=9E=E3=82=A6?= =?UTF-8?q?=E3=82=B9=E3=83=9C=E3=82=BF=E3=83=B3=E5=85=A5=E5=8A=9B=E3=81=A8?= =?UTF-8?q?=E3=82=AB=E3=83=BC=E3=82=BD=E3=83=AB=E4=BD=8D=E7=BD=AE=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit glfwGetMouseButton P/Invoke を追加し、マウス左ボタンの 押下・保持・解放エッジ検出とカーソル位置取得を実装。 Co-Authored-By: Claude Opus 4.6 --- .../Platform/Input/InputStateTests.cs | 7 ++++--- src/Seed.Engine/Ecs/Components/InputState.cs | 20 +++++++++++++++++++ .../Platform/Input/GlfwInputDevice.cs | 15 ++++++++++++++ .../Platform/Window/Glfw/GlfwNative.cs | 7 +++++++ 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/Seed.Engine.Tests/Platform/Input/InputStateTests.cs b/src/Seed.Engine.Tests/Platform/Input/InputStateTests.cs index 46665e3..464edf8 100644 --- a/src/Seed.Engine.Tests/Platform/Input/InputStateTests.cs +++ b/src/Seed.Engine.Tests/Platform/Input/InputStateTests.cs @@ -17,11 +17,12 @@ public void InputState_ImplementsIComponent() } [Fact] - public void InputState_Size_Is20Bytes() + public void InputState_Size_Is32Bytes() { - // Vector2 MoveAxis (8) + Vector2 LookDelta (8) + 2 byte fields + 2 pad = 20 + // Vector2 MoveAxis (8) + Vector2 LookDelta (8) + 2 byte fields + 3 mouse byte fields + // + 1 pad + Vector2 CursorPosition (8) + 2 pad = 32 int size = Marshal.SizeOf(); - Assert.Equal(20, size); + Assert.Equal(32, size); } [Fact] diff --git a/src/Seed.Engine/Ecs/Components/InputState.cs b/src/Seed.Engine/Ecs/Components/InputState.cs index bad2b1f..0c5cede 100644 --- a/src/Seed.Engine/Ecs/Components/InputState.cs +++ b/src/Seed.Engine/Ecs/Components/InputState.cs @@ -29,4 +29,24 @@ public struct InputState : IComponent /// 1 while the sprint key is held, 0 otherwise. /// public byte SprintHeld; + + /// + /// 1 while the left mouse button is held, 0 otherwise. + /// + public byte MouseLeftHeld; + + /// + /// 1 on the rising edge of the left mouse button, 0 otherwise. + /// + public byte MouseLeftPressed; + + /// + /// 1 on the falling edge of the left mouse button, 0 otherwise. + /// + public byte MouseLeftReleased; + + /// + /// Cursor position in screen pixels. + /// + public Vector2 CursorPosition; } diff --git a/src/Seed.Engine/Platform/Input/GlfwInputDevice.cs b/src/Seed.Engine/Platform/Input/GlfwInputDevice.cs index 41cefc8..8960ed3 100644 --- a/src/Seed.Engine/Platform/Input/GlfwInputDevice.cs +++ b/src/Seed.Engine/Platform/Input/GlfwInputDevice.cs @@ -16,6 +16,7 @@ public sealed class GlfwInputDevice private double _prevCursorX; private double _prevCursorY; private byte _prevJumpState; + private byte _prevMouseLeftState; private bool _initialized; /// @@ -97,12 +98,26 @@ public unsafe InputState Poll() ? (byte)1 : (byte)0; + byte mouseLeftCurrent = GlfwNative.glfwGetMouseButton( + _window, GlfwNative.GLFW_MOUSE_BUTTON_LEFT) == GlfwNative.GLFW_PRESS + ? (byte)1 + : (byte)0; + byte mouseLeftPressed = (byte)((mouseLeftCurrent == 1 + && _prevMouseLeftState == 0) ? 1 : 0); + byte mouseLeftReleased = (byte)((mouseLeftCurrent == 0 + && _prevMouseLeftState == 1) ? 1 : 0); + _prevMouseLeftState = mouseLeftCurrent; + return new InputState { MoveAxis = new Vector2(moveX, moveY), LookDelta = new Vector2(deltaX, deltaY), JumpPressed = jumpPressed, SprintHeld = sprintHeld, + MouseLeftHeld = mouseLeftCurrent, + MouseLeftPressed = mouseLeftPressed, + MouseLeftReleased = mouseLeftReleased, + CursorPosition = new Vector2((float)cursorX, (float)cursorY), }; } } diff --git a/src/Seed.Engine/Platform/Window/Glfw/GlfwNative.cs b/src/Seed.Engine/Platform/Window/Glfw/GlfwNative.cs index 9dedc39..85f63a9 100644 --- a/src/Seed.Engine/Platform/Window/Glfw/GlfwNative.cs +++ b/src/Seed.Engine/Platform/Window/Glfw/GlfwNative.cs @@ -75,6 +75,13 @@ internal static unsafe class GlfwNative public const int GLFW_KEY_LEFT = 263; public const int GLFW_KEY_RIGHT = 262; + [DllImport(GlfwLib, EntryPoint = "glfwGetMouseButton")] + public static extern int glfwGetMouseButton(IntPtr window, int button); + + // Mouse button constants + public const int GLFW_MOUSE_BUTTON_LEFT = 0; + public const int GLFW_MOUSE_BUTTON_RIGHT = 1; + // Key state constants public const int GLFW_PRESS = 1; public const int GLFW_RELEASE = 0; From 2f554388aae1ee581044eaddfdf17742b7c5f979 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 16:52:21 +0900 Subject: [PATCH 02/21] =?UTF-8?q?Grabbable=20/=20GrabState=20=E3=82=B3?= =?UTF-8?q?=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=8D=E3=83=B3=E3=83=88=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit オブジェクトが掴み可能であることを示す Grabbable と、 現在の掴み状態を管理する GrabState コンポーネントを追加。 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine/Ecs/Components/GrabState.cs | 32 +++++++++++++++++++++ src/Seed.Engine/Ecs/Components/Grabbable.cs | 20 +++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/Seed.Engine/Ecs/Components/GrabState.cs create mode 100644 src/Seed.Engine/Ecs/Components/Grabbable.cs diff --git a/src/Seed.Engine/Ecs/Components/GrabState.cs b/src/Seed.Engine/Ecs/Components/GrabState.cs new file mode 100644 index 0000000..7321d91 --- /dev/null +++ b/src/Seed.Engine/Ecs/Components/GrabState.cs @@ -0,0 +1,32 @@ +using System.Runtime.InteropServices; + +using Seed.Engine.Foundation.Mathematics; + +namespace Seed.Engine.Ecs.Components; + +/// +/// Tracks the current grab state of a grabbable entity. +/// +[StructLayout(LayoutKind.Sequential)] +public struct GrabState : IComponent +{ + /// + /// The entity performing the grab (camera entity for desktop, hand entity for VR). + /// + public Entity GrabberEntity; + + /// + /// Local-space offset from the grabber to the grabbed object at grab start. + /// + public Vector3 GrabOffset; + + /// + /// Local-space rotation offset from the grabber at grab start. + /// + public Quaternion GrabRotationOffset; + + /// + /// 1 if currently grabbed, 0 otherwise. + /// + public byte IsGrabbed; +} diff --git a/src/Seed.Engine/Ecs/Components/Grabbable.cs b/src/Seed.Engine/Ecs/Components/Grabbable.cs new file mode 100644 index 0000000..c7ef876 --- /dev/null +++ b/src/Seed.Engine/Ecs/Components/Grabbable.cs @@ -0,0 +1,20 @@ +using System.Runtime.InteropServices; + +namespace Seed.Engine.Ecs.Components; + +/// +/// Marks an entity as grabbable and configures grab interaction parameters. +/// +[StructLayout(LayoutKind.Sequential)] +public struct Grabbable : IComponent +{ + /// + /// Radius within which a VR hand can initiate a direct grab. + /// + public float GrabRadius; + + /// + /// Maximum distance for a distance grab (VR ray or desktop raycast). + /// + public float MaxGrabDistance; +} From 40f5c0a9166f9c289c402811ad2016a17a9d7b74 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 16:52:27 +0900 Subject: [PATCH 03/21] =?UTF-8?q?=E3=83=87=E3=82=B9=E3=82=AF=E3=83=88?= =?UTF-8?q?=E3=83=83=E3=83=97=E6=8E=B4=E3=81=BF=E3=82=B7=E3=82=B9=E3=83=86?= =?UTF-8?q?=E3=83=A0=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit マウスレイキャストによるオブジェクト選択、クリック長押しで 掴み、ドラッグで追従、リリースで投げるシステムを実装。 Co-Authored-By: Claude Opus 4.6 --- .../DesktopGrabPhysicsSystemTests.cs | 144 +++++++++ .../GameLogic/DesktopGrabSystemTests.cs | 215 +++++++++++++ .../GameLogic/DesktopGrabPhysicsSystem.cs | 136 +++++++++ .../GameLogic/DesktopGrabSystem.cs | 286 ++++++++++++++++++ 4 files changed, 781 insertions(+) create mode 100644 src/Seed.Engine.Tests/GameLogic/DesktopGrabPhysicsSystemTests.cs create mode 100644 src/Seed.Engine.Tests/GameLogic/DesktopGrabSystemTests.cs create mode 100644 src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs create mode 100644 src/Seed.Engine/GameLogic/DesktopGrabSystem.cs diff --git a/src/Seed.Engine.Tests/GameLogic/DesktopGrabPhysicsSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/DesktopGrabPhysicsSystemTests.cs new file mode 100644 index 0000000..2dc951a --- /dev/null +++ b/src/Seed.Engine.Tests/GameLogic/DesktopGrabPhysicsSystemTests.cs @@ -0,0 +1,144 @@ +using System; +using Xunit; + +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; +using Seed.Engine.GameLogic; +using Seed.Engine.Tests.Foundation.Mathematics; + +namespace Seed.Engine.Tests.GameLogic; + +public class DesktopGrabPhysicsSystemTests +{ + private static (World world, Entity camera, Entity grabbable) SetupWorld() + { + var world = new World(); + + // Camera entity + var camType = ComponentRegistry.Get(); + var ltType = ComponentRegistry.Get(); + var csType = ComponentRegistry.Get(); + ReadOnlySpan cameraTypes = [camType, ltType, csType]; + Entity camera = world.CreateEntity(cameraTypes); + world.GetComponent(camera) = + LocalTransform.FromPosition(Vector3.Zero); + world.GetComponent(camera) = new CharacterState(); + + // Grabbable entity + var grabbType = ComponentRegistry.Get(); + var gsType = ComponentRegistry.Get(); + ReadOnlySpan grabbableTypes = [grabbType, gsType, ltType]; + Entity grabbable = world.CreateEntity(grabbableTypes); + world.GetComponent(grabbable) = new Grabbable + { + GrabRadius = 1.0f, + MaxGrabDistance = 10.0f, + }; + world.GetComponent(grabbable) = + LocalTransform.FromPosition(new Vector3(0f, 0f, -3f)); + + return (world, camera, grabbable); + } + + [Fact] + public void Phase_IsPhysics() + { + var system = new DesktopGrabPhysicsSystem(); + Assert.Equal(FramePhase.Physics, system.Phase); + } + + [Fact] + public void Execute_GrabbedObject_FollowsCameraPosition() + { + // Given + var (world, camera, grabbable) = SetupWorld(); + using var _ = world; + + Vector3 grabOffset = new Vector3(0f, 0f, -3f); + world.GetComponent(grabbable) = new GrabState + { + IsGrabbed = 1, + GrabberEntity = camera, + GrabOffset = grabOffset, + }; + + // Move camera to new position + world.GetComponent(camera) = + LocalTransform.FromPosition(new Vector3(5f, 0f, 0f)); + + var system = new DesktopGrabPhysicsSystem(); + + // When + system.Execute(world); + + // Then: object should follow camera to new position + offset + ref LocalTransform lt = ref world.GetComponent(grabbable); + AssertHelper.ApproximatelyEqual(5f, lt.Position.X, 0.01f); + AssertHelper.ApproximatelyEqual(0f, lt.Position.Y, 0.01f); + AssertHelper.ApproximatelyEqual(-3f, lt.Position.Z, 0.01f); + } + + [Fact] + public void Execute_NotGrabbed_DoesNotMoveObject() + { + // Given + var (world, camera, grabbable) = SetupWorld(); + using var _ = world; + + world.GetComponent(grabbable) = new GrabState + { + IsGrabbed = 0, + }; + + Vector3 originalPos = new Vector3(0f, 0f, -3f); + world.GetComponent(grabbable) = + LocalTransform.FromPosition(originalPos); + + // Move camera + world.GetComponent(camera) = + LocalTransform.FromPosition(new Vector3(10f, 0f, 0f)); + + var system = new DesktopGrabPhysicsSystem(); + + // When + system.Execute(world); + + // Then: object should stay at original position + ref LocalTransform lt = ref world.GetComponent(grabbable); + AssertHelper.ApproximatelyEqual(originalPos, lt.Position, 0.01f); + } + + [Fact] + public void Execute_GrabbedWithRotation_OffsetRotatesWithCamera() + { + // Given + var (world, camera, grabbable) = SetupWorld(); + using var _ = world; + + Vector3 grabOffset = new Vector3(0f, 0f, -3f); + world.GetComponent(grabbable) = new GrabState + { + IsGrabbed = 1, + GrabberEntity = camera, + GrabOffset = grabOffset, + }; + + // Rotate camera 90 degrees around Y axis (yaw = pi/2) + world.GetComponent(camera) = new CharacterState + { + Yaw = MathHelper.Pi / 2f, + }; + + var system = new DesktopGrabPhysicsSystem(); + + // When + system.Execute(world); + + // Then: offset (0,0,-3) rotated by yaw=-pi/2 around Y should yield (3,0,0) + ref LocalTransform lt = ref world.GetComponent(grabbable); + AssertHelper.ApproximatelyEqual(3f, lt.Position.X, 0.1f); + AssertHelper.ApproximatelyEqual(0f, lt.Position.Y, 0.1f); + AssertHelper.ApproximatelyEqual(0f, lt.Position.Z, 0.1f); + } +} diff --git a/src/Seed.Engine.Tests/GameLogic/DesktopGrabSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/DesktopGrabSystemTests.cs new file mode 100644 index 0000000..cb68bb7 --- /dev/null +++ b/src/Seed.Engine.Tests/GameLogic/DesktopGrabSystemTests.cs @@ -0,0 +1,215 @@ +using System; +using Xunit; + +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; +using Seed.Engine.GameLogic; +using Seed.Engine.Tests.Foundation.Mathematics; + +namespace Seed.Engine.Tests.GameLogic; + +public class DesktopGrabSystemTests +{ + private static (World world, Entity singleton, Entity camera, Entity grabbable) SetupWorld( + Vector3 grabbablePos) + { + var world = new World(); + + // Singleton entity + var isType = ComponentRegistry.Get(); + ReadOnlySpan singletonTypes = [isType]; + Entity singleton = world.CreateEntity(singletonTypes); + + // Camera entity + var camType = ComponentRegistry.Get(); + var ltType = ComponentRegistry.Get(); + var csType = ComponentRegistry.Get(); + ReadOnlySpan cameraTypes = [camType, ltType, csType]; + Entity camera = world.CreateEntity(cameraTypes); + world.GetComponent(camera) = + LocalTransform.FromPosition(Vector3.Zero); + world.GetComponent(camera) = new CharacterState(); + + // Grabbable entity + var grabbType = ComponentRegistry.Get(); + var gsType = ComponentRegistry.Get(); + var velType = ComponentRegistry.Get(); + ReadOnlySpan grabbableTypes = + [grabbType, gsType, ltType, velType]; + Entity grabbable = world.CreateEntity(grabbableTypes); + world.GetComponent(grabbable) = new Grabbable + { + GrabRadius = 1.0f, + MaxGrabDistance = 10.0f, + }; + world.GetComponent(grabbable) = new GrabState(); + world.GetComponent(grabbable) = new Velocity(); + world.GetComponent(grabbable) = + LocalTransform.FromPosition(grabbablePos); + + return (world, singleton, camera, grabbable); + } + + [Fact] + public void Phase_IsGameLogic() + { + var system = new DesktopGrabSystem(); + Assert.Equal(FramePhase.GameLogic, system.Phase); + } + + [Fact] + public void Execute_MousePressed_GrabsNearbyObject() + { + // Given: object at (0, 0, -3) directly in front of camera + var (world, singleton, camera, grabbable) = SetupWorld( + new Vector3(0f, 0f, -3f)); + using var _ = world; + + world.GetComponent(singleton) = new InputState + { + MouseLeftPressed = 1, + }; + + var system = new DesktopGrabSystem(); + + // When + system.Execute(world); + + // Then + ref GrabState gs = ref world.GetComponent(grabbable); + Assert.Equal((byte)1, gs.IsGrabbed); + Assert.Equal(camera, gs.GrabberEntity); + } + + [Fact] + public void Execute_MousePressed_ObjectTooFar_DoesNotGrab() + { + // Given: object at (0, 0, -20), beyond MaxGrabDistance of 10 + var (world, singleton, camera, grabbable) = SetupWorld( + new Vector3(0f, 0f, -20f)); + using var _ = world; + + world.GetComponent(singleton) = new InputState + { + MouseLeftPressed = 1, + }; + + var system = new DesktopGrabSystem(); + + // When + system.Execute(world); + + // Then + ref GrabState gs = ref world.GetComponent(grabbable); + Assert.Equal((byte)0, gs.IsGrabbed); + } + + [Fact] + public void Execute_MousePressed_ObjectBehindCamera_DoesNotGrab() + { + // Given: object at (0, 0, 3) behind camera + var (world, singleton, camera, grabbable) = SetupWorld( + new Vector3(0f, 0f, 3f)); + using var _ = world; + + world.GetComponent(singleton) = new InputState + { + MouseLeftPressed = 1, + }; + + var system = new DesktopGrabSystem(); + + // When + system.Execute(world); + + // Then + ref GrabState gs = ref world.GetComponent(grabbable); + Assert.Equal((byte)0, gs.IsGrabbed); + } + + [Fact] + public void Execute_MouseReleased_ReleasesGrabbedObject() + { + // Given: object already grabbed + var (world, singleton, camera, grabbable) = SetupWorld( + new Vector3(0f, 0f, -3f)); + using var _ = world; + + world.GetComponent(grabbable) = new GrabState + { + IsGrabbed = 1, + GrabberEntity = camera, + GrabOffset = new Vector3(0f, 0f, -3f), + }; + + world.GetComponent(singleton) = new InputState + { + MouseLeftReleased = 1, + }; + + var system = new DesktopGrabSystem(); + + // When + system.Execute(world); + + // Then + ref GrabState gs = ref world.GetComponent(grabbable); + Assert.Equal((byte)0, gs.IsGrabbed); + Assert.Equal(Entity.Null, gs.GrabberEntity); + } + + [Fact] + public void Execute_MouseReleased_AppliesThrowVelocity() + { + // Given: object already grabbed, camera facing -Z + var (world, singleton, camera, grabbable) = SetupWorld( + new Vector3(0f, 0f, -3f)); + using var _ = world; + + world.GetComponent(grabbable) = new GrabState + { + IsGrabbed = 1, + GrabberEntity = camera, + GrabOffset = new Vector3(0f, 0f, -3f), + }; + + world.GetComponent(singleton) = new InputState + { + MouseLeftReleased = 1, + }; + + var system = new DesktopGrabSystem(); + + // When + system.Execute(world); + + // Then: velocity should be set in camera forward direction (-Z) + ref Velocity vel = ref world.GetComponent(grabbable); + Assert.True(vel.Linear.Z < 0f); + Assert.True(vel.Linear.Length > 0f); + } + + [Fact] + public void Execute_MousePressed_ObjectOffAxis_DoesNotGrab() + { + // Given: object at (5, 0, -3) far off the camera forward axis + var (world, singleton, camera, grabbable) = SetupWorld( + new Vector3(5f, 0f, -3f)); + using var _ = world; + + world.GetComponent(singleton) = new InputState + { + MouseLeftPressed = 1, + }; + + var system = new DesktopGrabSystem(); + + // When + system.Execute(world); + + // Then + ref GrabState gs = ref world.GetComponent(grabbable); + Assert.Equal((byte)0, gs.IsGrabbed); + } +} diff --git a/src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs b/src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs new file mode 100644 index 0000000..c905f21 --- /dev/null +++ b/src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs @@ -0,0 +1,136 @@ +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; + +namespace Seed.Engine.GameLogic; + +/// +/// Moves grabbed objects to follow their grabber entity using the stored offset. +/// Runs in the Physics phase to ensure position is updated after game logic. +/// +public sealed class DesktopGrabPhysicsSystem : ISystem +{ + private readonly QueryDescription _grabbedQuery; + private readonly QueryDescription _cameraQuery; + + /// + /// Initializes a new . + /// + public DesktopGrabPhysicsSystem() + { + _grabbedQuery = new QueryBuilder() + .WithRead() + .WithRead() + .WithWrite() + .Build(); + + _cameraQuery = new QueryBuilder() + .WithRead() + .WithRead() + .WithRead() + .Build(); + } + + /// + public FramePhase Phase => FramePhase.Physics; + + /// + public QueryDescription GetQuery() => _grabbedQuery; + + /// + public void Execute(World world) + { + Vector3 grabberPos = Vector3.Zero; + Quaternion grabberRot = Quaternion.Identity; + Entity grabberEntity = Entity.Null; + bool foundGrabber = false; + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_cameraQuery.Matches(storage.Archetype)) + { + continue; + } + + int ltIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int csIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + if (chunk.Count > 0) + { + ref LocalTransform lt = ref chunk.GetComponent( + ltIdx, 0); + ref CharacterState cs = ref chunk.GetComponent( + csIdx, 0); + grabberPos = lt.Position; + Quaternion yawRot = Quaternion.CreateFromAxisAngle( + Vector3.UnitY, -cs.Yaw); + Quaternion pitchRot = Quaternion.CreateFromAxisAngle( + Vector3.UnitX, -cs.Pitch); + grabberRot = yawRot * pitchRot; + grabberEntity = chunk.GetEntity(0); + foundGrabber = true; + break; + } + } + + if (foundGrabber) + { + break; + } + } + + if (!foundGrabber) + { + return; + } + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_grabbedQuery.Matches(storage.Archetype)) + { + continue; + } + + int gsIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int ltIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + int count = chunk.Count; + + for (int i = 0; i < count; i++) + { + ref GrabState gs = ref chunk.GetComponent( + gsIdx, i); + + if (gs.IsGrabbed == 0) + { + continue; + } + + if (gs.GrabberEntity != grabberEntity) + { + continue; + } + + ref LocalTransform lt = ref chunk.GetComponent( + ltIdx, i); + + Vector3 rotatedOffset = grabberRot.RotateVector( + gs.GrabOffset); + lt.Position = grabberPos + rotatedOffset; + } + } + } + } +} diff --git a/src/Seed.Engine/GameLogic/DesktopGrabSystem.cs b/src/Seed.Engine/GameLogic/DesktopGrabSystem.cs new file mode 100644 index 0000000..6ee292e --- /dev/null +++ b/src/Seed.Engine/GameLogic/DesktopGrabSystem.cs @@ -0,0 +1,286 @@ +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; + +namespace Seed.Engine.GameLogic; + +/// +/// Handles desktop grab interaction via mouse click and camera raycast. +/// On mouse press, raycasts from the camera to find the nearest Grabbable entity. +/// On mouse release, clears the grab and applies throw velocity in the camera +/// forward direction. +/// Runs in the GameLogic phase. +/// +public sealed class DesktopGrabSystem : ISystem +{ + private const float ThrowSpeed = 8f; + + private readonly QueryDescription _singletonQuery; + private readonly QueryDescription _cameraQuery; + private readonly QueryDescription _grabbableQuery; + + /// + /// Initializes a new . + /// + public DesktopGrabSystem() + { + _singletonQuery = new QueryBuilder() + .WithRead() + .Build(); + + _cameraQuery = new QueryBuilder() + .WithRead() + .WithRead() + .WithRead() + .Build(); + + _grabbableQuery = new QueryBuilder() + .WithRead() + .WithWrite() + .WithWrite() + .WithWrite() + .Build(); + } + + /// + public FramePhase Phase => FramePhase.GameLogic; + + /// + public QueryDescription GetQuery() => _grabbableQuery; + + /// + public void Execute(World world) + { + InputState input = default; + bool foundSingleton = false; + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_singletonQuery.Matches(storage.Archetype)) + { + continue; + } + + int isIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + if (chunk.Count > 0) + { + input = chunk.GetComponent(isIdx, 0); + foundSingleton = true; + break; + } + } + + if (foundSingleton) + { + break; + } + } + + if (!foundSingleton) + { + return; + } + + Vector3 cameraPos = Vector3.Zero; + Quaternion cameraRot = Quaternion.Identity; + Entity cameraEntity = Entity.Null; + bool foundCamera = false; + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_cameraQuery.Matches(storage.Archetype)) + { + continue; + } + + int ltIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int csIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + if (chunk.Count > 0) + { + ref LocalTransform lt = ref chunk.GetComponent( + ltIdx, 0); + ref CharacterState cs = ref chunk.GetComponent( + csIdx, 0); + cameraPos = lt.Position; + Quaternion yawRot = Quaternion.CreateFromAxisAngle( + Vector3.UnitY, -cs.Yaw); + Quaternion pitchRot = Quaternion.CreateFromAxisAngle( + Vector3.UnitX, -cs.Pitch); + cameraRot = yawRot * pitchRot; + cameraEntity = chunk.GetEntity(0); + foundCamera = true; + break; + } + } + + if (foundCamera) + { + break; + } + } + + if (!foundCamera) + { + return; + } + + Vector3 cameraForward = cameraRot.RotateVector( + new Vector3(0f, 0f, -1f)); + + if (input.MouseLeftPressed == 1) + { + TryGrab(world, cameraPos, cameraForward, cameraEntity); + } + else if (input.MouseLeftReleased == 1) + { + ReleaseGrab(world, cameraForward); + } + } + + private void TryGrab( + World world, Vector3 cameraPos, Vector3 cameraForward, + Entity cameraEntity) + { + float closestDist = float.MaxValue; + int closestStorage = -1; + int closestChunk = -1; + int closestIndex = -1; + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_grabbableQuery.Matches(storage.Archetype)) + { + continue; + } + + int grabbableIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int grabStateIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int ltIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + int count = chunk.Count; + + for (int i = 0; i < count; i++) + { + ref Grabbable grabbable = ref chunk.GetComponent( + grabbableIdx, i); + ref GrabState grabState = ref chunk.GetComponent( + grabStateIdx, i); + + if (grabState.IsGrabbed == 1) + { + continue; + } + + ref LocalTransform lt = ref chunk.GetComponent( + ltIdx, i); + + Vector3 toObj = lt.Position - cameraPos; + float projDist = Vector3.Dot(toObj, cameraForward); + + if (projDist <= 0f + || projDist > grabbable.MaxGrabDistance) + { + continue; + } + + Vector3 closest = cameraPos + cameraForward * projDist; + float perpDist = Vector3.Distance(closest, lt.Position); + + if (perpDist > grabbable.GrabRadius) + { + continue; + } + + if (projDist < closestDist) + { + closestDist = projDist; + closestStorage = s; + closestChunk = c; + closestIndex = i; + } + } + } + } + + if (closestStorage < 0) + { + return; + } + + var hitStorage = world.Storages[closestStorage]; + int gsIdx = hitStorage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int hitLtIdx = hitStorage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + ref Chunk hitChunk = ref hitStorage.GetChunk(closestChunk); + ref GrabState gs = ref hitChunk.GetComponent( + gsIdx, closestIndex); + ref LocalTransform hitLt = ref hitChunk.GetComponent( + hitLtIdx, closestIndex); + + gs.IsGrabbed = 1; + gs.GrabberEntity = cameraEntity; + gs.GrabOffset = hitLt.Position - cameraPos; + gs.GrabRotationOffset = hitLt.Rotation; + } + + private void ReleaseGrab(World world, Vector3 cameraForward) + { + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_grabbableQuery.Matches(storage.Archetype)) + { + continue; + } + + int grabStateIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int velIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + int count = chunk.Count; + + for (int i = 0; i < count; i++) + { + ref GrabState gs = ref chunk.GetComponent( + grabStateIdx, i); + + if (gs.IsGrabbed == 1) + { + ref Velocity vel = ref chunk.GetComponent( + velIdx, i); + vel.Linear = cameraForward * ThrowSpeed; + + gs.IsGrabbed = 0; + gs.GrabberEntity = Entity.Null; + } + } + } + } + } +} From 564e7df276ed7222c75b2143814c9ea862e81598 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 16:52:32 +0900 Subject: [PATCH 04/21] =?UTF-8?q?VR=20=E6=89=8B=E3=81=AE=E9=80=9F=E5=BA=A6?= =?UTF-8?q?=E8=BF=BD=E8=B7=A1=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VrHandState コンポーネントを追加し、XrInputUpdateSystem で 前フレームとの位置差分から手の速度を算出。 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine/Ecs/Components/VrHandState.cs | 25 +++++++++++++++++++ .../Platform/Input/XrInputUpdateSystem.cs | 25 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 src/Seed.Engine/Ecs/Components/VrHandState.cs diff --git a/src/Seed.Engine/Ecs/Components/VrHandState.cs b/src/Seed.Engine/Ecs/Components/VrHandState.cs new file mode 100644 index 0000000..ec31d79 --- /dev/null +++ b/src/Seed.Engine/Ecs/Components/VrHandState.cs @@ -0,0 +1,25 @@ +using System.Runtime.InteropServices; + +using Seed.Engine.Foundation.Mathematics; + +namespace Seed.Engine.Ecs.Components; + +/// +/// Tracks VR hand velocities derived from frame-to-frame position deltas. +/// Updated each frame by . +/// +[StructLayout(LayoutKind.Sequential)] +public struct VrHandState : IComponent +{ + /// Computed linear velocity of the left hand in m/s. + public Vector3 LeftVelocity; + + /// Computed linear velocity of the right hand in m/s. + public Vector3 RightVelocity; + + /// Left hand position from the previous frame. + public Vector3 PrevLeftPosition; + + /// Right hand position from the previous frame. + public Vector3 PrevRightPosition; +} diff --git a/src/Seed.Engine/Platform/Input/XrInputUpdateSystem.cs b/src/Seed.Engine/Platform/Input/XrInputUpdateSystem.cs index 557b5f9..e12cc14 100644 --- a/src/Seed.Engine/Platform/Input/XrInputUpdateSystem.cs +++ b/src/Seed.Engine/Platform/Input/XrInputUpdateSystem.cs @@ -2,6 +2,7 @@ using Seed.Engine.Ecs; using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; using Seed.Engine.Platform.Xr; namespace Seed.Engine.Platform.Input; @@ -33,6 +34,7 @@ public XrInputUpdateSystem(XrInputDevice inputDevice, IXrRuntime xrRuntime) .WithWrite() .WithWrite() .WithWrite() + .WithWrite() .Build(); } @@ -66,6 +68,8 @@ public void Execute(World world) ComponentRegistry.Get().TypeId); int vrIdx = storage.GetComponentIndex( ComponentRegistry.Get().TypeId); + int hsIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); for (int c = 0; c < storage.ChunkCount; c++) { @@ -85,8 +89,29 @@ public void Execute(World world) ref VrControllerState vrState = ref chunk.GetComponent( vrIdx, i); vrState = polledVr; + + ref VrHandState hs = ref chunk.GetComponent( + hsIdx, i); + ComputeHandVelocity( + ref hs, polledVr.LeftPosition, polledVr.RightPosition, + deltaTime); } } } } + + private static void ComputeHandVelocity( + ref VrHandState hs, + Vector3 leftPos, Vector3 rightPos, + float deltaTime) + { + if (deltaTime > 0f) + { + hs.LeftVelocity = (leftPos - hs.PrevLeftPosition) / deltaTime; + hs.RightVelocity = (rightPos - hs.PrevRightPosition) / deltaTime; + } + + hs.PrevLeftPosition = leftPos; + hs.PrevRightPosition = rightPos; + } } From b46e215131f9549a28e731e4f5d4d5844428dc4e Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 16:52:38 +0900 Subject: [PATCH 05/21] =?UTF-8?q?VR=20=E3=83=8F=E3=83=B3=E3=83=89=E3=82=B0?= =?UTF-8?q?=E3=83=A9=E3=83=96=E3=81=A8=E3=82=B9=E3=83=97=E3=83=AA=E3=83=B3?= =?UTF-8?q?=E3=82=B0=E3=82=B8=E3=83=A7=E3=82=A4=E3=83=B3=E3=83=88=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit グリップ+近接判定による直接掴み、手の速度伝達による投げ、 スプリングジョイントによるハンド-オブジェクト間の接続を実装。 Co-Authored-By: Claude Opus 4.6 --- .../GameLogic/SpringJointSystemTests.cs | 167 +++++++ .../GameLogic/VrGrabSystemTests.cs | 452 ++++++++++++++++++ src/Seed.Engine/Ecs/Components/SpringJoint.cs | 43 ++ .../GameLogic/SpringJointSystem.cs | 167 +++++++ src/Seed.Engine/GameLogic/VrGrabSystem.cs | 278 +++++++++++ 5 files changed, 1107 insertions(+) create mode 100644 src/Seed.Engine.Tests/GameLogic/SpringJointSystemTests.cs create mode 100644 src/Seed.Engine.Tests/GameLogic/VrGrabSystemTests.cs create mode 100644 src/Seed.Engine/Ecs/Components/SpringJoint.cs create mode 100644 src/Seed.Engine/GameLogic/SpringJointSystem.cs create mode 100644 src/Seed.Engine/GameLogic/VrGrabSystem.cs diff --git a/src/Seed.Engine.Tests/GameLogic/SpringJointSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/SpringJointSystemTests.cs new file mode 100644 index 0000000..42ca0a6 --- /dev/null +++ b/src/Seed.Engine.Tests/GameLogic/SpringJointSystemTests.cs @@ -0,0 +1,167 @@ +using System; +using Xunit; + +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; +using Seed.Engine.GameLogic; +using Seed.Engine.Tests.Foundation.Mathematics; + +namespace Seed.Engine.Tests.GameLogic; + +public class SpringJointSystemTests +{ + private static (World world, Entity singleton, Entity springEntity, Entity target) SetupWorld( + Vector3 entityPos, Vector3 targetPos) + { + var world = new World(); + + // Singleton entity + var ftType = ComponentRegistry.Get(); + ReadOnlySpan singletonTypes = [ftType]; + Entity singleton = world.CreateEntity(singletonTypes); + world.GetComponent(singleton) = new FrameTime + { + DeltaTime = 1f / 60f, + }; + + // Target entity + var ltType = ComponentRegistry.Get(); + ReadOnlySpan targetTypes = [ltType]; + Entity target = world.CreateEntity(targetTypes); + world.GetComponent(target) = + LocalTransform.FromPosition(targetPos); + + // Spring entity + var sjType = ComponentRegistry.Get(); + var velType = ComponentRegistry.Get(); + ReadOnlySpan springTypes = [sjType, ltType, velType]; + Entity springEntity = world.CreateEntity(springTypes); + world.GetComponent(springEntity) = + LocalTransform.FromPosition(entityPos); + world.GetComponent(springEntity) = new Velocity(); + world.GetComponent(springEntity) = new SpringJoint + { + TargetEntity = target, + Anchor = Vector3.Zero, + TargetAnchor = Vector3.Zero, + Spring = 100f, + Damper = 10f, + MaxForce = 1000f, + }; + + return (world, singleton, springEntity, target); + } + + [Fact] + public void Phase_IsPhysics() + { + var system = new SpringJointSystem(); + Assert.Equal(FramePhase.Physics, system.Phase); + } + + [Fact] + public void Execute_DisplacedEntity_AcceleratesTowardTarget() + { + // Given: entity at origin, target at (5, 0, 0) + var (world, singleton, springEntity, target) = SetupWorld( + Vector3.Zero, new Vector3(5f, 0f, 0f)); + using var _ = world; + + var system = new SpringJointSystem(); + + // When + system.Execute(world); + + // Then: velocity should be positive along X + ref Velocity vel = ref world.GetComponent(springEntity); + Assert.True(vel.Linear.X > 0f, + $"Expected positive X velocity, got {vel.Linear.X}"); + } + + [Fact] + public void Execute_EntityAtTarget_NoVelocityChange() + { + // Given: entity and target at same position + var (world, singleton, springEntity, target) = SetupWorld( + new Vector3(3f, 0f, 0f), new Vector3(3f, 0f, 0f)); + using var _ = world; + + var system = new SpringJointSystem(); + + // When + system.Execute(world); + + // Then: velocity should remain zero + ref Velocity vel = ref world.GetComponent(springEntity); + AssertHelper.ApproximatelyEqual(0f, vel.Linear.X, 0.001f); + AssertHelper.ApproximatelyEqual(0f, vel.Linear.Y, 0.001f); + AssertHelper.ApproximatelyEqual(0f, vel.Linear.Z, 0.001f); + } + + [Fact] + public void Execute_WithExistingVelocity_DampingReducesSpeed() + { + // Given: entity at target position but with existing velocity + var (world, singleton, springEntity, target) = SetupWorld( + new Vector3(5f, 0f, 0f), new Vector3(5f, 0f, 0f)); + using var _ = world; + + world.GetComponent(springEntity) = new Velocity + { + Linear = new Vector3(10f, 0f, 0f), + }; + + // Create a spring with strong damping + world.GetComponent(springEntity) = new SpringJoint + { + TargetEntity = target, + Anchor = Vector3.Zero, + TargetAnchor = Vector3.Zero, + Spring = 0f, + Damper = 100f, + MaxForce = 10000f, + }; + + var system = new SpringJointSystem(); + + // When + system.Execute(world); + + // Then: velocity should be reduced by damping + ref Velocity vel = ref world.GetComponent(springEntity); + Assert.True(vel.Linear.X < 10f, + $"Expected velocity less than initial 10, got {vel.Linear.X}"); + } + + [Fact] + public void Execute_ForceClampedToMaxForce() + { + // Given: very large displacement with small MaxForce + var (world, singleton, springEntity, target) = SetupWorld( + Vector3.Zero, new Vector3(100f, 0f, 0f)); + using var _ = world; + + world.GetComponent(springEntity) = new SpringJoint + { + TargetEntity = target, + Anchor = Vector3.Zero, + TargetAnchor = Vector3.Zero, + Spring = 1000f, + Damper = 0f, + MaxForce = 5f, + }; + + var system = new SpringJointSystem(); + + // When + system.Execute(world); + + // Then: velocity should be limited by MaxForce + ref Velocity vel = ref world.GetComponent(springEntity); + float dt = 1f / 60f; + float maxVelocityChange = 5f * dt; + Assert.True(vel.Linear.X <= maxVelocityChange + 0.001f, + $"Expected velocity <= {maxVelocityChange}, got {vel.Linear.X}"); + } +} diff --git a/src/Seed.Engine.Tests/GameLogic/VrGrabSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/VrGrabSystemTests.cs new file mode 100644 index 0000000..b700867 --- /dev/null +++ b/src/Seed.Engine.Tests/GameLogic/VrGrabSystemTests.cs @@ -0,0 +1,452 @@ +using System; +using Xunit; + +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; +using Seed.Engine.GameLogic; +using Seed.Engine.Tests.Foundation.Mathematics; + +namespace Seed.Engine.Tests.GameLogic; + +public class VrGrabSystemTests +{ + private static (World world, Entity singleton, Entity grabbable) SetupWorld( + Vector3 grabbablePos, + Vector3 leftHandPos, + Vector3 rightHandPos) + { + var world = new World(); + + // Singleton entity with VR state + HapticRequest + var ftType = ComponentRegistry.Get(); + var vrType = ComponentRegistry.Get(); + var hsType = ComponentRegistry.Get(); + var hrType = ComponentRegistry.Get(); + ReadOnlySpan singletonTypes = [ftType, vrType, hsType, hrType]; + Entity singleton = world.CreateEntity(singletonTypes); + world.GetComponent(singleton) = new FrameTime + { + DeltaTime = 1f / 90f, + }; + world.GetComponent(singleton) = new VrControllerState + { + LeftPosition = leftHandPos, + RightPosition = rightHandPos, + }; + world.GetComponent(singleton) = new VrHandState(); + world.GetComponent(singleton) = new HapticRequest(); + + // Grabbable entity with SpringJoint and Velocity + var grabbType = ComponentRegistry.Get(); + var gsType = ComponentRegistry.Get(); + var ltType = ComponentRegistry.Get(); + var sjType = ComponentRegistry.Get(); + var velType = ComponentRegistry.Get(); + ReadOnlySpan grabbableTypes = + [grabbType, gsType, ltType, sjType, velType]; + Entity grabbable = world.CreateEntity(grabbableTypes); + world.GetComponent(grabbable) = new Grabbable + { + GrabRadius = 0.5f, + MaxGrabDistance = 5.0f, + }; + world.GetComponent(grabbable) = new GrabState(); + world.GetComponent(grabbable) = + LocalTransform.FromPosition(grabbablePos); + world.GetComponent(grabbable) = new SpringJoint(); + world.GetComponent(grabbable) = new Velocity(); + + return (world, singleton, grabbable); + } + + [Fact] + public void Phase_IsGameLogic() + { + var system = new VrGrabSystem(); + Assert.Equal(FramePhase.GameLogic, system.Phase); + } + + [Fact] + public void Execute_LeftGripPressed_NearObject_GrabsObject() + { + // Given: left hand at (0,1,0), object at (0.1,1,0) within grab radius + var (world, singleton, grabbable) = SetupWorld( + new Vector3(0.1f, 1f, 0f), + new Vector3(0f, 1f, 0f), + new Vector3(1f, 1f, 0f)); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + LeftPosition = new Vector3(0f, 1f, 0f), + RightPosition = new Vector3(1f, 1f, 0f), + LeftGripPressed = 1, + }; + + var system = new VrGrabSystem(); + + // When + system.Execute(world); + + // Then + ref GrabState gs = ref world.GetComponent(grabbable); + Assert.Equal((byte)1, gs.IsGrabbed); + } + + [Fact] + public void Execute_GripPressed_ObjectTooFar_DoesNotGrab() + { + // Given: left hand at (0,1,0), object at (5,1,0) beyond grab radius + var (world, singleton, grabbable) = SetupWorld( + new Vector3(5f, 1f, 0f), + new Vector3(0f, 1f, 0f), + new Vector3(1f, 1f, 0f)); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + LeftPosition = new Vector3(0f, 1f, 0f), + RightPosition = new Vector3(1f, 1f, 0f), + LeftGripPressed = 1, + }; + + var system = new VrGrabSystem(); + + // When + system.Execute(world); + + // Then + ref GrabState gs = ref world.GetComponent(grabbable); + Assert.Equal((byte)0, gs.IsGrabbed); + } + + [Fact] + public void Execute_RightGripPressed_NearObject_GrabsObject() + { + // Given: right hand near object + var (world, singleton, grabbable) = SetupWorld( + new Vector3(1.1f, 1f, 0f), + new Vector3(0f, 1f, 0f), + new Vector3(1f, 1f, 0f)); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + LeftPosition = new Vector3(0f, 1f, 0f), + RightPosition = new Vector3(1f, 1f, 0f), + RightGripPressed = 1, + }; + + var system = new VrGrabSystem(); + + // When + system.Execute(world); + + // Then + ref GrabState gs = ref world.GetComponent(grabbable); + Assert.Equal((byte)1, gs.IsGrabbed); + } + + [Fact] + public void Execute_GripReleased_ReleasesObject() + { + // Given: object already grabbed by left hand + var (world, singleton, grabbable) = SetupWorld( + new Vector3(0f, 1f, 0f), + new Vector3(0f, 1f, 0f), + new Vector3(1f, 1f, 0f)); + using var _ = world; + + world.GetComponent(grabbable) = new GrabState + { + IsGrabbed = 1, + GrabOffset = Vector3.Zero, + }; + + // Grip released (LeftGripPressed = 0) + world.GetComponent(singleton) = new VrControllerState + { + LeftPosition = new Vector3(0f, 1f, 0f), + RightPosition = new Vector3(1f, 1f, 0f), + LeftGripPressed = 0, + RightGripPressed = 0, + }; + + var system = new VrGrabSystem(); + + // When + system.Execute(world); + + // Then + ref GrabState gs = ref world.GetComponent(grabbable); + Assert.Equal((byte)0, gs.IsGrabbed); + } + + [Fact] + public void Execute_OnGrab_AssignsSpringJoint() + { + // Given: left hand at (0,1,0), object at (0.1,1,0) within grab radius + var (world, singleton, grabbable) = SetupWorld( + new Vector3(0.1f, 1f, 0f), + new Vector3(0f, 1f, 0f), + new Vector3(1f, 1f, 0f)); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + LeftPosition = new Vector3(0f, 1f, 0f), + RightPosition = new Vector3(1f, 1f, 0f), + LeftGripPressed = 1, + }; + + var system = new VrGrabSystem(); + + // When + system.Execute(world); + + // Then: SpringJoint should be assigned with stiffness values + ref SpringJoint sj = ref world.GetComponent(grabbable); + Assert.True(sj.Spring > 0f); + Assert.True(sj.Damper > 0f); + Assert.True(sj.MaxForce > 0f); + } + + [Fact] + public void Execute_OnGrab_IssuesHapticRequest() + { + // Given: left hand at (0,1,0), object at (0.1,1,0) within grab radius + var (world, singleton, grabbable) = SetupWorld( + new Vector3(0.1f, 1f, 0f), + new Vector3(0f, 1f, 0f), + new Vector3(1f, 1f, 0f)); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + LeftPosition = new Vector3(0f, 1f, 0f), + RightPosition = new Vector3(1f, 1f, 0f), + LeftGripPressed = 1, + }; + + var system = new VrGrabSystem(); + + // When + system.Execute(world); + + // Then: HapticRequest should be pending on the singleton + ref HapticRequest hr = ref world.GetComponent(singleton); + Assert.Equal((byte)1, hr.Pending); + Assert.True(hr.Amplitude > 0f); + } + + [Fact] + public void Execute_LeftGrab_SetsGrabberEntity() + { + // Given: left hand at (0,1,0), object at (0.1,1,0) within grab radius + var (world, singleton, grabbable) = SetupWorld( + new Vector3(0.1f, 1f, 0f), + new Vector3(0f, 1f, 0f), + new Vector3(1f, 1f, 0f)); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + LeftPosition = new Vector3(0f, 1f, 0f), + RightPosition = new Vector3(1f, 1f, 0f), + LeftGripPressed = 1, + }; + + var system = new VrGrabSystem(); + + // When + system.Execute(world); + + // Then: GrabberEntity should be set to the VR player entity + ref GrabState gs = ref world.GetComponent(grabbable); + Assert.Equal(singleton, gs.GrabberEntity); + } + + [Fact] + public void Execute_LeftGrab_HapticRequestTargetsLeftHand() + { + // Given: left hand at (0,1,0), object at (0.1,1,0) within grab radius + var (world, singleton, grabbable) = SetupWorld( + new Vector3(0.1f, 1f, 0f), + new Vector3(0f, 1f, 0f), + new Vector3(1f, 1f, 0f)); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + LeftPosition = new Vector3(0f, 1f, 0f), + RightPosition = new Vector3(1f, 1f, 0f), + LeftGripPressed = 1, + }; + + var system = new VrGrabSystem(); + + // When + system.Execute(world); + + // Then: HandMask should target left hand only (0x01) + ref HapticRequest hr = ref world.GetComponent(singleton); + Assert.Equal((byte)0x01, hr.HandMask); + } + + [Fact] + public void Execute_RightGrab_HapticRequestTargetsRightHand() + { + // Given: right hand near object + var (world, singleton, grabbable) = SetupWorld( + new Vector3(1.1f, 1f, 0f), + new Vector3(0f, 1f, 0f), + new Vector3(1f, 1f, 0f)); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + LeftPosition = new Vector3(0f, 1f, 0f), + RightPosition = new Vector3(1f, 1f, 0f), + RightGripPressed = 1, + }; + + var system = new VrGrabSystem(); + + // When + system.Execute(world); + + // Then: HandMask should target right hand only (0x02) + ref HapticRequest hr = ref world.GetComponent(singleton); + Assert.Equal((byte)0x02, hr.HandMask); + } + + [Fact] + public void Execute_OnRelease_ClearsSpringJoint() + { + // Given: object already grabbed with spring joint assigned + var (world, singleton, grabbable) = SetupWorld( + new Vector3(0f, 1f, 0f), + new Vector3(0f, 1f, 0f), + new Vector3(1f, 1f, 0f)); + using var _ = world; + + world.GetComponent(grabbable) = new GrabState + { + IsGrabbed = 1, + GrabOffset = Vector3.Zero, + }; + world.GetComponent(grabbable) = new SpringJoint + { + Spring = 500f, + Damper = 30f, + MaxForce = 1000f, + }; + + // Grip released + world.GetComponent(singleton) = new VrControllerState + { + LeftPosition = new Vector3(0f, 1f, 0f), + RightPosition = new Vector3(1f, 1f, 0f), + LeftGripPressed = 0, + RightGripPressed = 0, + }; + + var system = new VrGrabSystem(); + + // When + system.Execute(world); + + // Then: SpringJoint should be cleared + ref SpringJoint sj = ref world.GetComponent(grabbable); + Assert.Equal(0f, sj.Spring); + Assert.Equal(0f, sj.Damper); + Assert.Equal(0f, sj.MaxForce); + } + + [Fact] + public void Execute_OnRelease_AppliesLeftHandThrowVelocity() + { + // Given: object grabbed by left hand, hand moving upward at 2 m/s + var (world, singleton, grabbable) = SetupWorld( + new Vector3(0f, 1f, 0f), + new Vector3(0f, 1f, 0f), + new Vector3(1f, 1f, 0f)); + using var _ = world; + + world.GetComponent(grabbable) = new GrabState + { + IsGrabbed = 1, + GrabOffset = Vector3.Zero, + }; + + var throwVelocity = new Vector3(0f, 2f, 0f); + world.GetComponent(singleton) = new VrHandState + { + LeftVelocity = throwVelocity, + }; + + // Grip released + world.GetComponent(singleton) = new VrControllerState + { + LeftPosition = new Vector3(0f, 1f, 0f), + RightPosition = new Vector3(1f, 1f, 0f), + LeftGripPressed = 0, + RightGripPressed = 0, + }; + + var system = new VrGrabSystem(); + + // When + system.Execute(world); + + // Then: released object should have the left hand's velocity + ref Velocity vel = ref world.GetComponent(grabbable); + Assert.Equal(throwVelocity.X, vel.Linear.X); + Assert.Equal(throwVelocity.Y, vel.Linear.Y); + Assert.Equal(throwVelocity.Z, vel.Linear.Z); + } + + [Fact] + public void Execute_OnRelease_AppliesRightHandThrowVelocity() + { + // Given: object grabbed by right hand, hand moving forward at 3 m/s + var (world, singleton, grabbable) = SetupWorld( + new Vector3(1f, 1f, 0f), + new Vector3(0f, 1f, 0f), + new Vector3(1f, 1f, 0f)); + using var _ = world; + + world.GetComponent(grabbable) = new GrabState + { + IsGrabbed = 1, + GrabOffset = Vector3.Zero, + }; + + var throwVelocity = new Vector3(0f, 0f, -3f); + world.GetComponent(singleton) = new VrHandState + { + RightVelocity = throwVelocity, + }; + + // Grip released + world.GetComponent(singleton) = new VrControllerState + { + LeftPosition = new Vector3(0f, 1f, 0f), + RightPosition = new Vector3(1f, 1f, 0f), + LeftGripPressed = 0, + RightGripPressed = 0, + }; + + var system = new VrGrabSystem(); + + // When + system.Execute(world); + + // Then: released object should have the right hand's velocity + ref Velocity vel = ref world.GetComponent(grabbable); + Assert.Equal(throwVelocity.X, vel.Linear.X); + Assert.Equal(throwVelocity.Y, vel.Linear.Y); + Assert.Equal(throwVelocity.Z, vel.Linear.Z); + } +} diff --git a/src/Seed.Engine/Ecs/Components/SpringJoint.cs b/src/Seed.Engine/Ecs/Components/SpringJoint.cs new file mode 100644 index 0000000..b412e5a --- /dev/null +++ b/src/Seed.Engine/Ecs/Components/SpringJoint.cs @@ -0,0 +1,43 @@ +using System.Runtime.InteropServices; + +using Seed.Engine.Foundation.Mathematics; + +namespace Seed.Engine.Ecs.Components; + +/// +/// A spring joint connecting an entity to a target anchor point. +/// Used for VR grab to provide physically-based hand tracking with spring dynamics. +/// +[StructLayout(LayoutKind.Sequential)] +public struct SpringJoint : IComponent +{ + /// + /// The entity that the spring is connected to (the grabber). + /// + public Entity TargetEntity; + + /// + /// Local-space anchor on this entity where the spring attaches. + /// + public Vector3 Anchor; + + /// + /// Local-space anchor on the target entity. + /// + public Vector3 TargetAnchor; + + /// + /// Spring stiffness coefficient (force per unit displacement). + /// + public float Spring; + + /// + /// Damping coefficient (force per unit velocity). + /// + public float Damper; + + /// + /// Maximum force the spring can exert. + /// + public float MaxForce; +} diff --git a/src/Seed.Engine/GameLogic/SpringJointSystem.cs b/src/Seed.Engine/GameLogic/SpringJointSystem.cs new file mode 100644 index 0000000..934eb20 --- /dev/null +++ b/src/Seed.Engine/GameLogic/SpringJointSystem.cs @@ -0,0 +1,167 @@ +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; + +namespace Seed.Engine.GameLogic; + +/// +/// Applies spring forces to entities with a component. +/// Computes a damped spring force between the entity and its target anchor, +/// then applies the result as a velocity change on the entity. +/// Runs in the Physics phase. +/// +public sealed class SpringJointSystem : ISystem +{ + private readonly QueryDescription _singletonQuery; + private readonly QueryDescription _springQuery; + + /// + /// Initializes a new . + /// + public SpringJointSystem() + { + _singletonQuery = new QueryBuilder() + .WithRead() + .Build(); + + _springQuery = new QueryBuilder() + .WithRead() + .WithWrite() + .WithWrite() + .Build(); + } + + /// + public FramePhase Phase => FramePhase.Physics; + + /// + public QueryDescription GetQuery() => _springQuery; + + /// + public void Execute(World world) + { + float dt = 0f; + bool foundDt = false; + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_singletonQuery.Matches(storage.Archetype)) + { + continue; + } + + int ftIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + if (chunk.Count > 0) + { + dt = chunk.GetComponent(ftIdx, 0).DeltaTime; + foundDt = true; + break; + } + } + + if (foundDt) + { + break; + } + } + + if (!foundDt || dt <= 0f) + { + return; + } + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_springQuery.Matches(storage.Archetype)) + { + continue; + } + + int sjIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int ltIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int velIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + int count = chunk.Count; + + for (int i = 0; i < count; i++) + { + ref SpringJoint sj = ref chunk.GetComponent( + sjIdx, i); + ref LocalTransform lt = ref chunk.GetComponent( + ltIdx, i); + ref Velocity vel = ref chunk.GetComponent( + velIdx, i); + + ApplySpringForce(ref sj, ref lt, ref vel, dt, world); + } + } + } + } + + private static void ApplySpringForce( + ref SpringJoint sj, ref LocalTransform lt, + ref Velocity vel, float dt, World world) + { + Vector3 targetPos = sj.TargetAnchor; + + if (world.IsAlive(sj.TargetEntity)) + { + if (world.HasComponent(sj.TargetEntity)) + { + ref LocalTransform targetLt = ref world.GetComponent( + sj.TargetEntity); + targetPos = targetLt.Position + sj.TargetAnchor; + } + } + + Vector3 anchorWorld = lt.Position + sj.Anchor; + Vector3 displacement = targetPos - anchorWorld; + float distance = displacement.Length; + + if (distance < MathHelper.Epsilon) + { + // No spring force, but damping still applies to reduce velocity + float speed = vel.Linear.Length; + if (speed > MathHelper.Epsilon) + { + Vector3 velDir = vel.Linear / speed; + float dampingForce = -sj.Damper * speed; + dampingForce = MathHelper.Clamp( + dampingForce, -sj.MaxForce, sj.MaxForce); + vel.Linear = vel.Linear + velDir * dampingForce * dt; + } + + return; + } + + Vector3 direction = displacement / distance; + + // Spring force: F = k * x + float springForce = sj.Spring * distance; + + // Damping force: F = -c * v_along_spring + float velocityAlongSpring = Vector3.Dot(vel.Linear, direction); + float dampingForce2 = -sj.Damper * velocityAlongSpring; + + float totalForce = springForce + dampingForce2; + totalForce = MathHelper.Clamp(totalForce, -sj.MaxForce, sj.MaxForce); + + Vector3 forceVector = direction * totalForce; + + // Apply as velocity change: dv = F * dt (assuming unit mass) + vel.Linear = vel.Linear + forceVector * dt; + } +} diff --git a/src/Seed.Engine/GameLogic/VrGrabSystem.cs b/src/Seed.Engine/GameLogic/VrGrabSystem.cs new file mode 100644 index 0000000..4bb4e46 --- /dev/null +++ b/src/Seed.Engine/GameLogic/VrGrabSystem.cs @@ -0,0 +1,278 @@ +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; + +namespace Seed.Engine.GameLogic; + +/// +/// Handles VR direct grab interaction using grip button and proximity check. +/// When grip is pressed, checks if any Grabbable entity is within GrabRadius +/// of either hand. On grab, assigns a and issues a +/// . On grip release, clears the grab and applies +/// throw velocity from . +/// Runs in the GameLogic phase. +/// +public sealed class VrGrabSystem : ISystem +{ + private readonly QueryDescription _singletonQuery; + private readonly QueryDescription _grabbableQuery; + + /// + /// Initializes a new . + /// + public VrGrabSystem() + { + _singletonQuery = new QueryBuilder() + .WithRead() + .WithRead() + .WithRead() + .WithWrite() + .Build(); + + _grabbableQuery = new QueryBuilder() + .WithRead() + .WithWrite() + .WithWrite() + .WithWrite() + .WithWrite() + .Build(); + } + + /// + public FramePhase Phase => FramePhase.GameLogic; + + /// + public QueryDescription GetQuery() => _grabbableQuery; + + /// + public void Execute(World world) + { + VrControllerState vrState = default; + VrHandState handState = default; + Entity vrPlayerEntity = Entity.Null; + bool foundSingleton = false; + int singletonStorageIdx = -1; + int singletonChunkIdx = -1; + int singletonEntityIdx = -1; + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_singletonQuery.Matches(storage.Archetype)) + { + continue; + } + + int vrIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int hsIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + if (chunk.Count > 0) + { + vrState = chunk.GetComponent(vrIdx, 0); + handState = chunk.GetComponent(hsIdx, 0); + vrPlayerEntity = chunk.GetEntity(0); + singletonStorageIdx = s; + singletonChunkIdx = c; + singletonEntityIdx = 0; + foundSingleton = true; + break; + } + } + + if (foundSingleton) + { + break; + } + } + + if (!foundSingleton) + { + return; + } + + bool leftGripActive = vrState.LeftGripPressed == 1; + bool rightGripActive = vrState.RightGripPressed == 1; + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_grabbableQuery.Matches(storage.Archetype)) + { + continue; + } + + int grabbableIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int gsIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int ltIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int sjIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int velIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + int count = chunk.Count; + + for (int i = 0; i < count; i++) + { + ref Grabbable grabbable = ref chunk.GetComponent( + grabbableIdx, i); + ref GrabState gs = ref chunk.GetComponent( + gsIdx, i); + ref LocalTransform lt = ref chunk.GetComponent( + ltIdx, i); + ref SpringJoint sj = ref chunk.GetComponent( + sjIdx, i); + ref Velocity vel = ref chunk.GetComponent( + velIdx, i); + + if (gs.IsGrabbed == 1) + { + HandleRelease(ref gs, ref lt, ref sj, ref vel, + leftGripActive, rightGripActive, + vrState, handState); + } + else + { + bool grabbed = TryDirectGrab( + ref gs, ref lt, ref grabbable, ref sj, + vrState, vrPlayerEntity, + leftGripActive, rightGripActive, + out bool grabbedByLeft); + + if (grabbed) + { + byte handMask = grabbedByLeft + ? (byte)0x01 + : (byte)0x02; + IssueHapticRequest( + world, singletonStorageIdx, + singletonChunkIdx, singletonEntityIdx, + handMask); + } + } + } + } + } + } + + private static bool TryDirectGrab( + ref GrabState gs, ref LocalTransform lt, ref Grabbable grabbable, + ref SpringJoint sj, + VrControllerState vrState, Entity vrPlayerEntity, + bool leftGrip, bool rightGrip, + out bool isLeftHand) + { + isLeftHand = false; + float grabRadiusSq = grabbable.GrabRadius * grabbable.GrabRadius; + + if (leftGrip) + { + float distSq = Vector3.DistanceSquared( + lt.Position, vrState.LeftPosition); + if (distSq <= grabRadiusSq) + { + gs.IsGrabbed = 1; + gs.GrabberEntity = vrPlayerEntity; + gs.GrabOffset = lt.Position - vrState.LeftPosition; + gs.GrabRotationOffset = lt.Rotation; + AssignSpringJoint(ref sj, vrState.LeftPosition); + isLeftHand = true; + return true; + } + } + + if (rightGrip) + { + float distSq = Vector3.DistanceSquared( + lt.Position, vrState.RightPosition); + if (distSq <= grabRadiusSq) + { + gs.IsGrabbed = 1; + gs.GrabberEntity = vrPlayerEntity; + gs.GrabOffset = lt.Position - vrState.RightPosition; + gs.GrabRotationOffset = lt.Rotation; + AssignSpringJoint(ref sj, vrState.RightPosition); + return true; + } + } + + return false; + } + + private static void AssignSpringJoint( + ref SpringJoint sj, Vector3 handPosition) + { + sj.TargetAnchor = handPosition; + sj.Anchor = Vector3.Zero; + sj.Spring = 500f; + sj.Damper = 30f; + sj.MaxForce = 1000f; + } + + private static void IssueHapticRequest( + World world, int storageIdx, int chunkIdx, int entityIdx, + byte handMask) + { + var storage = world.Storages[storageIdx]; + int hrIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + ref Chunk chunk = ref storage.GetChunk(chunkIdx); + ref HapticRequest hr = ref chunk.GetComponent( + hrIdx, entityIdx); + + hr.Amplitude = 0.6f; + hr.Duration = 0.05f; + hr.Frequency = 0f; + hr.HandMask = handMask; + hr.Pending = 1; + } + + private static void HandleRelease( + ref GrabState gs, ref LocalTransform lt, ref SpringJoint sj, + ref Velocity vel, + bool leftGripActive, bool rightGripActive, + VrControllerState vrState, VrHandState handState) + { + float leftDist = Vector3.DistanceSquared( + lt.Position, + vrState.LeftPosition + gs.GrabOffset); + float rightDist = Vector3.DistanceSquared( + lt.Position, + vrState.RightPosition + gs.GrabOffset); + bool isLeftHand = leftDist <= rightDist; + + bool shouldRelease = isLeftHand ? !leftGripActive : !rightGripActive; + + if (shouldRelease) + { + gs.IsGrabbed = 0; + gs.GrabberEntity = Entity.Null; + sj.Spring = 0f; + sj.Damper = 0f; + sj.MaxForce = 0f; + + // Transfer hand velocity to the released object for throwing + vel.Linear = isLeftHand + ? handState.LeftVelocity + : handState.RightVelocity; + } + else + { + Vector3 handPos = isLeftHand + ? vrState.LeftPosition + : vrState.RightPosition; + lt.Position = handPos + gs.GrabOffset; + sj.TargetAnchor = handPos; + } + } +} From c67ce96dec6afe3001fee3cc0ad7f37417240098 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 16:52:42 +0900 Subject: [PATCH 06/21] =?UTF-8?q?VR=20=E8=B7=9D=E9=9B=A2=E3=82=B0=E3=83=A9?= =?UTF-8?q?=E3=83=96=E3=82=B7=E3=82=B9=E3=83=86=E3=83=A0=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit トリガー+レイキャストで離れたオブジェクトをポイントし、 手元に引き寄せて掴みに遷移する距離グラブを実装。 Co-Authored-By: Claude Opus 4.6 --- .../GameLogic/VrDistanceGrabSystemTests.cs | 162 +++++++++++++ .../GameLogic/VrDistanceGrabSystem.cs | 213 ++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 src/Seed.Engine.Tests/GameLogic/VrDistanceGrabSystemTests.cs create mode 100644 src/Seed.Engine/GameLogic/VrDistanceGrabSystem.cs diff --git a/src/Seed.Engine.Tests/GameLogic/VrDistanceGrabSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/VrDistanceGrabSystemTests.cs new file mode 100644 index 0000000..338514e --- /dev/null +++ b/src/Seed.Engine.Tests/GameLogic/VrDistanceGrabSystemTests.cs @@ -0,0 +1,162 @@ +using System; +using Xunit; + +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; +using Seed.Engine.GameLogic; +using Seed.Engine.Tests.Foundation.Mathematics; + +namespace Seed.Engine.Tests.GameLogic; + +public class VrDistanceGrabSystemTests +{ + private static (World world, Entity singleton, Entity grabbable) SetupWorld( + Vector3 grabbablePos, + Vector3 handPos, + Quaternion handRot) + { + var world = new World(); + + // Singleton entity + var ftType = ComponentRegistry.Get(); + var vrType = ComponentRegistry.Get(); + ReadOnlySpan singletonTypes = [ftType, vrType]; + Entity singleton = world.CreateEntity(singletonTypes); + world.GetComponent(singleton) = new FrameTime + { + DeltaTime = 1f / 60f, + }; + world.GetComponent(singleton) = new VrControllerState + { + RightPosition = handPos, + RightRotation = handRot, + }; + + // Grabbable entity + var grabbType = ComponentRegistry.Get(); + var gsType = ComponentRegistry.Get(); + var ltType = ComponentRegistry.Get(); + ReadOnlySpan grabbableTypes = [grabbType, gsType, ltType]; + Entity grabbable = world.CreateEntity(grabbableTypes); + world.GetComponent(grabbable) = new Grabbable + { + GrabRadius = 0.5f, + MaxGrabDistance = 10.0f, + }; + world.GetComponent(grabbable) = new GrabState(); + world.GetComponent(grabbable) = + LocalTransform.FromPosition(grabbablePos); + + return (world, singleton, grabbable); + } + + [Fact] + public void Phase_IsGameLogic() + { + var system = new VrDistanceGrabSystem(); + Assert.Equal(FramePhase.GameLogic, system.Phase); + } + + [Fact] + public void Execute_TriggerPressed_ObjectInRayPath_PullsObject() + { + // Given: hand pointing forward (-Z), object at (0,0,-5) + var (world, singleton, grabbable) = SetupWorld( + new Vector3(0f, 0f, -5f), + Vector3.Zero, + Quaternion.Identity); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + RightPosition = Vector3.Zero, + RightRotation = Quaternion.Identity, + RightTriggerPressed = 1, + }; + + var system = new VrDistanceGrabSystem(); + + // When + system.Execute(world); + + // Then: object should move closer to hand + ref LocalTransform lt = ref world.GetComponent(grabbable); + Assert.True(lt.Position.Z > -5f, + $"Object should have moved closer, Z={lt.Position.Z}"); + } + + [Fact] + public void Execute_TriggerNotPressed_DoesNothing() + { + // Given: no trigger pressed + var (world, singleton, grabbable) = SetupWorld( + new Vector3(0f, 0f, -5f), + Vector3.Zero, + Quaternion.Identity); + using var _ = world; + + var system = new VrDistanceGrabSystem(); + + // When + system.Execute(world); + + // Then: object stays at original position + ref LocalTransform lt = ref world.GetComponent(grabbable); + AssertHelper.ApproximatelyEqual(-5f, lt.Position.Z, 0.01f); + } + + [Fact] + public void Execute_ObjectTooFar_DoesNotPull() + { + // Given: object beyond MaxGrabDistance + var (world, singleton, grabbable) = SetupWorld( + new Vector3(0f, 0f, -20f), + Vector3.Zero, + Quaternion.Identity); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + RightPosition = Vector3.Zero, + RightRotation = Quaternion.Identity, + RightTriggerPressed = 1, + }; + + var system = new VrDistanceGrabSystem(); + + // When + system.Execute(world); + + // Then: object stays at original position + ref LocalTransform lt = ref world.GetComponent(grabbable); + AssertHelper.ApproximatelyEqual(-20f, lt.Position.Z, 0.01f); + } + + [Fact] + public void Execute_ObjectOffAxis_DoesNotPull() + { + // Given: object far off the ray axis + var (world, singleton, grabbable) = SetupWorld( + new Vector3(5f, 0f, -3f), + Vector3.Zero, + Quaternion.Identity); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + RightPosition = Vector3.Zero, + RightRotation = Quaternion.Identity, + RightTriggerPressed = 1, + }; + + var system = new VrDistanceGrabSystem(); + + // When + system.Execute(world); + + // Then: object stays at original position + ref LocalTransform lt = ref world.GetComponent(grabbable); + AssertHelper.ApproximatelyEqual(5f, lt.Position.X, 0.01f); + } +} diff --git a/src/Seed.Engine/GameLogic/VrDistanceGrabSystem.cs b/src/Seed.Engine/GameLogic/VrDistanceGrabSystem.cs new file mode 100644 index 0000000..a810cdb --- /dev/null +++ b/src/Seed.Engine/GameLogic/VrDistanceGrabSystem.cs @@ -0,0 +1,213 @@ +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; + +namespace Seed.Engine.GameLogic; + +/// +/// Handles VR distance grab: when trigger is pressed, raycasts from the controller +/// forward direction to find a distant Grabbable entity and pulls it toward the hand. +/// Runs in the GameLogic phase. +/// +public sealed class VrDistanceGrabSystem : ISystem +{ + private readonly QueryDescription _singletonQuery; + private readonly QueryDescription _grabbableQuery; + + /// + /// Initializes a new . + /// + public VrDistanceGrabSystem() + { + _singletonQuery = new QueryBuilder() + .WithRead() + .WithRead() + .Build(); + + _grabbableQuery = new QueryBuilder() + .WithRead() + .WithWrite() + .WithWrite() + .Build(); + } + + /// + public FramePhase Phase => FramePhase.GameLogic; + + /// + public QueryDescription GetQuery() => _grabbableQuery; + + /// + public void Execute(World world) + { + VrControllerState vrState = default; + float dt = 0f; + bool foundSingleton = false; + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_singletonQuery.Matches(storage.Archetype)) + { + continue; + } + + int vrIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int ftIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + if (chunk.Count > 0) + { + vrState = chunk.GetComponent(vrIdx, 0); + dt = chunk.GetComponent(ftIdx, 0).DeltaTime; + foundSingleton = true; + break; + } + } + + if (foundSingleton) + { + break; + } + } + + if (!foundSingleton) + { + return; + } + + bool leftTrigger = vrState.LeftTriggerPressed == 1; + bool rightTrigger = vrState.RightTriggerPressed == 1; + + if (!leftTrigger && !rightTrigger) + { + return; + } + + Vector3 handPos; + Vector3 handForward; + + if (rightTrigger) + { + handPos = vrState.RightPosition; + handForward = vrState.RightRotation.RotateVector( + new Vector3(0f, 0f, -1f)); + } + else + { + handPos = vrState.LeftPosition; + handForward = vrState.LeftRotation.RotateVector( + new Vector3(0f, 0f, -1f)); + } + + float closestDist = float.MaxValue; + int closestStorage = -1; + int closestChunk = -1; + int closestIndex = -1; + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_grabbableQuery.Matches(storage.Archetype)) + { + continue; + } + + int grabbableIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int gsIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int ltIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + int count = chunk.Count; + + for (int i = 0; i < count; i++) + { + ref Grabbable grabbable = ref chunk.GetComponent( + grabbableIdx, i); + ref GrabState gs = ref chunk.GetComponent( + gsIdx, i); + + if (gs.IsGrabbed == 1) + { + continue; + } + + ref LocalTransform lt = ref chunk.GetComponent( + ltIdx, i); + + Vector3 toObj = lt.Position - handPos; + float projDist = Vector3.Dot(toObj, handForward); + + if (projDist <= grabbable.GrabRadius + || projDist > grabbable.MaxGrabDistance) + { + continue; + } + + Vector3 closestOnRay = handPos + handForward * projDist; + float perpDist = Vector3.Distance(closestOnRay, lt.Position); + + if (perpDist > grabbable.GrabRadius) + { + continue; + } + + if (projDist < closestDist) + { + closestDist = projDist; + closestStorage = s; + closestChunk = c; + closestIndex = i; + } + } + } + } + + if (closestStorage < 0) + { + return; + } + + var hitStorage = world.Storages[closestStorage]; + int hitGsIdx = hitStorage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int hitLtIdx = hitStorage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + ref Chunk hitChunk = ref hitStorage.GetChunk(closestChunk); + ref GrabState hitGs = ref hitChunk.GetComponent( + hitGsIdx, closestIndex); + ref LocalTransform hitLt = ref hitChunk.GetComponent( + hitLtIdx, closestIndex); + + // Pull the object toward the hand + Vector3 pullDir = handPos - hitLt.Position; + float pullDist = pullDir.Length; + if (pullDist > MathHelper.Epsilon) + { + float pullSpeed = 5f; + float moveAmount = pullSpeed * dt; + if (moveAmount >= pullDist) + { + hitLt.Position = handPos; + hitGs.IsGrabbed = 1; + hitGs.GrabOffset = Vector3.Zero; + hitGs.GrabRotationOffset = hitLt.Rotation; + } + else + { + Vector3 pullNorm = pullDir / pullDist; + hitLt.Position = hitLt.Position + pullNorm * moveAmount; + } + } + } +} From 8f66f5275da8c9ae0d4f60901cb93c0a62fedd78 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 16:52:50 +0900 Subject: [PATCH 07/21] =?UTF-8?q?=E3=83=8F=E3=83=97=E3=83=86=E3=82=A3?= =?UTF-8?q?=E3=82=AF=E3=82=B9=E3=83=95=E3=82=A3=E3=83=BC=E3=83=89=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xrApplyHapticFeedback P/Invoke と VibrationOutput アクションを追加し、 HapticRequest コンポーネントで左右の手に振動を送信するシステムを実装。 Co-Authored-By: Claude Opus 4.6 --- .../GameLogic/HapticFeedbackSystemTests.cs | 180 ++++++++++++++++++ .../Platform/Xr/BoundarySystemTests.cs | 3 + .../Platform/Xr/XrInputDeviceTests.cs | 3 + .../StereoDeferredRenderSystemExecuteTests.cs | 3 + .../StereoForwardPlusRenderSystemTests.cs | 3 + .../Ecs/Components/HapticRequest.cs | 26 +++ .../GameLogic/HapticFeedbackSystem.cs | 81 ++++++++ src/Seed.Engine/Platform/Xr/IXrRuntime.cs | 5 + src/Seed.Engine/Platform/Xr/OpenXrInput.cs | 47 ++++- src/Seed.Engine/Platform/Xr/OpenXrNative.cs | 37 ++++ src/Seed.Engine/Platform/Xr/OpenXrSession.cs | 7 + src/Seed.Engine/Platform/Xr/OpenXrTypes.cs | 3 + 12 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 src/Seed.Engine.Tests/GameLogic/HapticFeedbackSystemTests.cs create mode 100644 src/Seed.Engine/Ecs/Components/HapticRequest.cs create mode 100644 src/Seed.Engine/GameLogic/HapticFeedbackSystem.cs diff --git a/src/Seed.Engine.Tests/GameLogic/HapticFeedbackSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/HapticFeedbackSystemTests.cs new file mode 100644 index 0000000..2efb0f2 --- /dev/null +++ b/src/Seed.Engine.Tests/GameLogic/HapticFeedbackSystemTests.cs @@ -0,0 +1,180 @@ +using System; +using Xunit; + +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Error; +using Seed.Engine.Foundation.Mathematics; +using Seed.Engine.GameLogic; +using Seed.Engine.Platform.Xr; + +namespace Seed.Engine.Tests.GameLogic; + +public class HapticFeedbackSystemTests +{ + [Fact] + public void Phase_IsGameLogic() + { + var runtime = new StubHapticRuntime(); + var system = new HapticFeedbackSystem(runtime); + Assert.Equal(FramePhase.GameLogic, system.Phase); + } + + [Fact] + public void Execute_PendingRequest_CallsApplyHaptic() + { + // Given + var runtime = new StubHapticRuntime(); + var world = new World(); + using var _ = world; + + var hrType = ComponentRegistry.Get(); + ReadOnlySpan types = [hrType]; + Entity entity = world.CreateEntity(types); + world.GetComponent(entity) = new HapticRequest + { + Amplitude = 0.8f, + Duration = 0.1f, + Frequency = 150f, + HandMask = 0x01, + Pending = 1, + }; + + var system = new HapticFeedbackSystem(runtime); + + // When + system.Execute(world); + + // Then + Assert.Equal(1, runtime.LeftCallCount); + Assert.Equal(0, runtime.RightCallCount); + ref HapticRequest hr = ref world.GetComponent(entity); + Assert.Equal((byte)0, hr.Pending); + } + + [Fact] + public void Execute_BothHands_CallsBothSides() + { + // Given + var runtime = new StubHapticRuntime(); + var world = new World(); + using var _ = world; + + var hrType = ComponentRegistry.Get(); + ReadOnlySpan types = [hrType]; + Entity entity = world.CreateEntity(types); + world.GetComponent(entity) = new HapticRequest + { + Amplitude = 0.5f, + Duration = 0.05f, + Frequency = 0f, + HandMask = 0x03, + Pending = 1, + }; + + var system = new HapticFeedbackSystem(runtime); + + // When + system.Execute(world); + + // Then + Assert.Equal(1, runtime.LeftCallCount); + Assert.Equal(1, runtime.RightCallCount); + } + + [Fact] + public void Execute_NotPending_DoesNotCallHaptic() + { + // Given + var runtime = new StubHapticRuntime(); + var world = new World(); + using var _ = world; + + var hrType = ComponentRegistry.Get(); + ReadOnlySpan types = [hrType]; + Entity entity = world.CreateEntity(types); + world.GetComponent(entity) = new HapticRequest + { + Pending = 0, + }; + + var system = new HapticFeedbackSystem(runtime); + + // When + system.Execute(world); + + // Then + Assert.Equal(0, runtime.LeftCallCount); + Assert.Equal(0, runtime.RightCallCount); + } + + private sealed class StubHapticRuntime : IXrRuntime + { + public int LeftCallCount { get; private set; } + public int RightCallCount { get; private set; } + + public bool IsAvailable => true; + public XrSessionState SessionState => XrSessionState.Focused; + public uint RecommendedWidth => 1920; + public uint RecommendedHeight => 1080; + public ulong LeftSwapchainHandle => 1; + public ulong RightSwapchainHandle => 2; + public long PredictedDisplayTime => 0; + public void PollEvents() { } + public OpenXrInput? Input => null; + public Result SyncActions() => Result.Ok(); + + public void ApplyHapticFeedback( + bool isLeft, float amplitude, float duration, float frequency) + { + if (isLeft) + { + LeftCallCount++; + } + else + { + RightCallCount++; + } + } + + public Result + InitializeAndGetVulkanRequirements() + => Result.Ok( + new XrVulkanInstanceRequirements + { + RequiredInstanceExtensions = Array.Empty(), + }); + + public Result GetVulkanDeviceRequirements( + IntPtr vkInstance) + => Result.Ok( + new XrVulkanDeviceRequirements + { + PhysicalDevice = IntPtr.Zero, + RequiredDeviceExtensions = Array.Empty(), + }); + + public Result CreateSession( + IntPtr vkInstance, IntPtr vkPhysicalDevice, + IntPtr vkDevice, uint queueFamilyIndex) + => Result.Ok(); + + public Result WaitFrame() + => Result.Fail(ErrorCodes.XrFrameSyncFailed); + + public Result BeginFrame() + => Result.Fail(ErrorCodes.XrFrameSyncFailed); + + public Result LocateViews(long predictedDisplayTime) + => Result.Fail(ErrorCodes.XrViewLocateFailed); + + public Result EndFrame( + long displayTime, ReadOnlySpan layers) + => Result.Ok(); + + public Result GetBoundary() + => Result.Ok(new XrBoundary()); + + public void Dispose() { } + } +} diff --git a/src/Seed.Engine.Tests/Platform/Xr/BoundarySystemTests.cs b/src/Seed.Engine.Tests/Platform/Xr/BoundarySystemTests.cs index 95e73a8..1758396 100644 --- a/src/Seed.Engine.Tests/Platform/Xr/BoundarySystemTests.cs +++ b/src/Seed.Engine.Tests/Platform/Xr/BoundarySystemTests.cs @@ -347,6 +347,9 @@ public Result EndFrame(long displayTime, ReadOnlySpan layers public OpenXrInput? Input => null; + public void ApplyHapticFeedback( + bool isLeft, float amplitude, float duration, float frequency) { } + public void Dispose() { } } diff --git a/src/Seed.Engine.Tests/Platform/Xr/XrInputDeviceTests.cs b/src/Seed.Engine.Tests/Platform/Xr/XrInputDeviceTests.cs index 5a2258a..3e8dd34 100644 --- a/src/Seed.Engine.Tests/Platform/Xr/XrInputDeviceTests.cs +++ b/src/Seed.Engine.Tests/Platform/Xr/XrInputDeviceTests.cs @@ -246,6 +246,9 @@ public Result EndFrame( public Result GetBoundary() => Result.Ok(new XrBoundary()); + public void ApplyHapticFeedback( + bool isLeft, float amplitude, float duration, float frequency) { } + public void Dispose() { } } } diff --git a/src/Seed.Engine.Tests/Rendering/StereoDeferredRenderSystemExecuteTests.cs b/src/Seed.Engine.Tests/Rendering/StereoDeferredRenderSystemExecuteTests.cs index 2ff286a..0f2bb06 100644 --- a/src/Seed.Engine.Tests/Rendering/StereoDeferredRenderSystemExecuteTests.cs +++ b/src/Seed.Engine.Tests/Rendering/StereoDeferredRenderSystemExecuteTests.cs @@ -252,6 +252,9 @@ public Result GetBoundary() public OpenXrInput? Input => null; + public void ApplyHapticFeedback( + bool isLeft, float amplitude, float duration, float frequency) { } + public void Dispose() { } } diff --git a/src/Seed.Engine.Tests/Rendering/StereoForwardPlusRenderSystemTests.cs b/src/Seed.Engine.Tests/Rendering/StereoForwardPlusRenderSystemTests.cs index e0e5947..afce21f 100644 --- a/src/Seed.Engine.Tests/Rendering/StereoForwardPlusRenderSystemTests.cs +++ b/src/Seed.Engine.Tests/Rendering/StereoForwardPlusRenderSystemTests.cs @@ -377,6 +377,9 @@ public Result EndFrame( public Result GetBoundary() => Result.Ok(new XrBoundary()); + public void ApplyHapticFeedback( + bool isLeft, float amplitude, float duration, float frequency) { } + public void Dispose() { } } diff --git a/src/Seed.Engine/Ecs/Components/HapticRequest.cs b/src/Seed.Engine/Ecs/Components/HapticRequest.cs new file mode 100644 index 0000000..4bf6897 --- /dev/null +++ b/src/Seed.Engine/Ecs/Components/HapticRequest.cs @@ -0,0 +1,26 @@ +using System.Runtime.InteropServices; + +namespace Seed.Engine.Ecs.Components; + +/// +/// Requests a haptic vibration event to be dispatched by the haptic feedback system. +/// Attach to a singleton entity; the system clears Pending after processing. +/// +[StructLayout(LayoutKind.Sequential)] +public struct HapticRequest : IComponent +{ + /// Vibration amplitude in [0,1]. + public float Amplitude; + + /// Vibration duration in seconds. + public float Duration; + + /// Vibration frequency in Hz. 0 for runtime default. + public float Frequency; + + /// Bitmask: bit 0 = left hand, bit 1 = right hand. + public byte HandMask; + + /// 1 if this request has not yet been processed, 0 otherwise. + public byte Pending; +} diff --git a/src/Seed.Engine/GameLogic/HapticFeedbackSystem.cs b/src/Seed.Engine/GameLogic/HapticFeedbackSystem.cs new file mode 100644 index 0000000..b4b20ce --- /dev/null +++ b/src/Seed.Engine/GameLogic/HapticFeedbackSystem.cs @@ -0,0 +1,81 @@ +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Platform.Xr; + +namespace Seed.Engine.GameLogic; + +/// +/// Processes pending components and dispatches +/// haptic vibration via . Clears the Pending flag +/// after processing. Runs in the GameLogic phase. +/// +public sealed class HapticFeedbackSystem : ISystem +{ + private readonly IXrRuntime _xrRuntime; + private readonly QueryDescription _query; + + /// + /// Initializes a new . + /// + public HapticFeedbackSystem(IXrRuntime xrRuntime) + { + _xrRuntime = xrRuntime; + + _query = new QueryBuilder() + .WithWrite() + .Build(); + } + + /// + public FramePhase Phase => FramePhase.GameLogic; + + /// + public QueryDescription GetQuery() => _query; + + /// + public void Execute(World world) + { + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_query.Matches(storage.Archetype)) + { + continue; + } + + int hrIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + int count = chunk.Count; + + for (int i = 0; i < count; i++) + { + ref HapticRequest hr = ref chunk.GetComponent( + hrIdx, i); + + if (hr.Pending == 0) + { + continue; + } + + if ((hr.HandMask & 0x01) != 0) + { + _xrRuntime.ApplyHapticFeedback( + true, hr.Amplitude, hr.Duration, hr.Frequency); + } + + if ((hr.HandMask & 0x02) != 0) + { + _xrRuntime.ApplyHapticFeedback( + false, hr.Amplitude, hr.Duration, hr.Frequency); + } + + hr.Pending = 0; + } + } + } + } +} diff --git a/src/Seed.Engine/Platform/Xr/IXrRuntime.cs b/src/Seed.Engine/Platform/Xr/IXrRuntime.cs index 2d94ba0..309d025 100644 --- a/src/Seed.Engine/Platform/Xr/IXrRuntime.cs +++ b/src/Seed.Engine/Platform/Xr/IXrRuntime.cs @@ -94,6 +94,11 @@ Result CreateSession( /// OpenXrInput? Input { get; } + /// + /// Applies haptic vibration feedback to the specified hand. + /// + void ApplyHapticFeedback(bool isLeft, float amplitude, float duration, float frequency); + /// /// Gets the recommended render width for each eye. /// diff --git a/src/Seed.Engine/Platform/Xr/OpenXrInput.cs b/src/Seed.Engine/Platform/Xr/OpenXrInput.cs index ca69465..a9c7f39 100644 --- a/src/Seed.Engine/Platform/Xr/OpenXrInput.cs +++ b/src/Seed.Engine/Platform/Xr/OpenXrInput.cs @@ -26,6 +26,7 @@ public sealed class OpenXrInput : IDisposable private ulong _secondaryButtonAction; private ulong _thumbstickClickAction; private ulong _menuButtonAction; + private ulong _hapticAction; private ulong _leftHandSpace; private ulong _rightHandSpace; @@ -280,6 +281,39 @@ public XrSpaceLocation LocateHandSpace(bool isLeft, long time) /// Gets the menu button action handle. public ulong MenuButtonAction => _menuButtonAction; + /// + /// Applies haptic vibration to the specified hand. + /// + public void ApplyHapticFeedback( + bool isLeft, float amplitude, float duration, float frequency) + { + unsafe + { + ulong subactionPath = isLeft ? _leftHandPath : _rightHandPath; + + var actionInfo = new OpenXrNative.XrHapticActionInfo + { + Type = OpenXrNative.XR_TYPE_HAPTIC_ACTION_INFO, + Next = null, + Action = _hapticAction, + SubactionPath = subactionPath, + }; + + var vibration = new OpenXrNative.XrHapticVibration + { + Type = OpenXrNative.XR_TYPE_HAPTIC_VIBRATION, + Next = null, + Duration = (long)(duration * 1_000_000_000), + Frequency = frequency, + Amplitude = amplitude, + }; + + OpenXrNative.ApplyHapticFeedback( + _session, &actionInfo, + (OpenXrNative.XrHapticBaseHeader*)&vibration); + } + } + /// public void Dispose() { @@ -418,6 +452,11 @@ private Result CreateActions() XrActionType.BooleanInput, out _menuButtonAction); if (r9.IsFailure) return r9; + var r10 = CreateTypedAction( + "haptic\0"u8, "Haptic\0"u8, + XrActionType.VibrationOutput, out _hapticAction); + if (r10.IsFailure) return r10; + return Result.Ok(); } @@ -513,9 +552,9 @@ private Result SuggestInteractionProfile() } } - // 16 bindings total + // 17 bindings total (15 input + 2 haptic output) OpenXrNative.XrActionSuggestedBinding* bindings = - stackalloc OpenXrNative.XrActionSuggestedBinding[16]; + stackalloc OpenXrNative.XrActionSuggestedBinding[17]; int idx = 0; idx = AddBinding(bindings, idx, _aimPoseActionLeft, @@ -550,6 +589,10 @@ private Result SuggestInteractionProfile() "/user/hand/left/input/menu/click\0"u8); // Menu button right hand: Oculus Touch does not have right menu button, // skip binding for right hand menu per plan + idx = AddBinding(bindings, idx, _hapticAction, + "/user/hand/left/output/haptic\0"u8); + idx = AddBinding(bindings, idx, _hapticAction, + "/user/hand/right/output/haptic\0"u8); var suggestedBindings = new OpenXrNative.XrInteractionProfileSuggestedBinding { diff --git a/src/Seed.Engine/Platform/Xr/OpenXrNative.cs b/src/Seed.Engine/Platform/Xr/OpenXrNative.cs index a1f6307..0701023 100644 --- a/src/Seed.Engine/Platform/Xr/OpenXrNative.cs +++ b/src/Seed.Engine/Platform/Xr/OpenXrNative.cs @@ -644,6 +644,43 @@ internal struct XrSpaceLocationNative internal const ulong XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO = 60; internal const ulong XR_TYPE_ACTIONS_SYNC_INFO = 61; + // Haptic feedback + + [LibraryImport(LibName, EntryPoint = "xrApplyHapticFeedback")] + internal static partial XrResult ApplyHapticFeedback( + ulong session, + XrHapticActionInfo* hapticActionInfo, + XrHapticBaseHeader* hapticFeedback); + + [StructLayout(LayoutKind.Sequential)] + internal struct XrHapticActionInfo + { + public ulong Type; + public void* Next; + public ulong Action; + public ulong SubactionPath; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct XrHapticBaseHeader + { + public ulong Type; + public void* Next; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct XrHapticVibration + { + public ulong Type; + public void* Next; + public long Duration; + public float Frequency; + public float Amplitude; + } + + internal const ulong XR_TYPE_HAPTIC_ACTION_INFO = 62; + internal const ulong XR_TYPE_HAPTIC_VIBRATION = 63; + // XrSpaceLocationFlags internal const ulong XR_SPACE_LOCATION_ORIENTATION_VALID_BIT = 0x00000001; internal const ulong XR_SPACE_LOCATION_POSITION_VALID_BIT = 0x00000002; diff --git a/src/Seed.Engine/Platform/Xr/OpenXrSession.cs b/src/Seed.Engine/Platform/Xr/OpenXrSession.cs index ce94617..c17e551 100644 --- a/src/Seed.Engine/Platform/Xr/OpenXrSession.cs +++ b/src/Seed.Engine/Platform/Xr/OpenXrSession.cs @@ -473,6 +473,13 @@ public Result SyncActions() return _input.SyncActions(); } + /// + public void ApplyHapticFeedback( + bool isLeft, float amplitude, float duration, float frequency) + { + _input?.ApplyHapticFeedback(isLeft, amplitude, duration, frequency); + } + /// public void Dispose() { diff --git a/src/Seed.Engine/Platform/Xr/OpenXrTypes.cs b/src/Seed.Engine/Platform/Xr/OpenXrTypes.cs index 252a858..a755e32 100644 --- a/src/Seed.Engine/Platform/Xr/OpenXrTypes.cs +++ b/src/Seed.Engine/Platform/Xr/OpenXrTypes.cs @@ -354,6 +354,9 @@ public enum XrActionType : int /// Pose input (controller tracking). PoseInput = 4, + + /// Vibration output (haptic feedback). + VibrationOutput = 100, } /// From fe7b262a370b3561a4b89e00bb062e7d22d3cd94 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 16:52:55 +0900 Subject: [PATCH 08/21] =?UTF-8?q?=E3=83=87=E3=83=A2=E3=82=A2=E3=83=97?= =?UTF-8?q?=E3=83=AA=E3=81=AB=E6=8E=B4=E3=81=BF=E3=82=A4=E3=83=B3=E3=82=BF?= =?UTF-8?q?=E3=83=A9=E3=82=AF=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=92=E7=B5=B1?= =?UTF-8?q?=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 掴めるキューブエンティティの配置、VrHandState/HapticRequest シングルトンの追加、全掴みシステムの登録を実装。 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine.App/Program.cs | 78 +++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/src/Seed.Engine.App/Program.cs b/src/Seed.Engine.App/Program.cs index 390ee59..199f45f 100644 --- a/src/Seed.Engine.App/Program.cs +++ b/src/Seed.Engine.App/Program.cs @@ -554,6 +554,20 @@ private static void Main() scheduler.AddSystem(new MovementSystem()); scheduler.AddSystem(new CharacterPhysicsSystem()); + + if (renderMode == RenderMode.Stereo && xrSessionForInit != null) + { + scheduler.AddSystem(new VrGrabSystem()); + scheduler.AddSystem(new VrDistanceGrabSystem()); + scheduler.AddSystem(new HapticFeedbackSystem(xrSessionForInit)); + } + else + { + scheduler.AddSystem(new DesktopGrabSystem()); + } + + scheduler.AddSystem(new DesktopGrabPhysicsSystem()); + scheduler.AddSystem(new SpringJointSystem()); scheduler.AddSystem(new ForceIntegrationSystem(1f / 60f)); scheduler.AddSystem(new BroadPhaseSystem()); scheduler.AddSystem(new NarrowPhaseSystem()); @@ -1285,6 +1299,60 @@ private static void CreateDynamicCubeEntity( world.GetComponent(entity) = new Velocity(); } + private static void CreateGrabbableCubeEntity( + World world, int meshId, int materialId, Vector3 position) + { + var ltType = ComponentRegistry.Get(); + var ltwType = ComponentRegistry.Get(); + var meshType = ComponentRegistry.Get(); + var matType = ComponentRegistry.Get(); + var boundsType = ComponentRegistry.Get(); + var ccType = ComponentRegistry.Get(); + var rbType = ComponentRegistry.Get(); + var velType = ComponentRegistry.Get(); + var grabbType = ComponentRegistry.Get(); + var gsType = ComponentRegistry.Get(); + var sjType = ComponentRegistry.Get(); + ReadOnlySpan types = + [ltType, ltwType, meshType, matType, boundsType, + ccType, rbType, velType, grabbType, gsType, sjType]; + + Vector3 he = new Vector3(0.3f, 0.3f, 0.3f); + Entity entity = world.CreateEntity(types); + world.GetComponent(entity) = + LocalTransform.FromPositionRotationScale( + position, Quaternion.Identity, he * 2f); + world.GetComponent(entity) = new LocalToWorld + { + Value = Matrix4x4.Identity, + }; + world.GetComponent(entity) = new MeshReference + { + MeshId = meshId, + }; + world.GetComponent(entity) = new MaterialReference + { + MaterialId = materialId, + }; + world.GetComponent(entity) = new WorldBounds + { + Value = new AABB(position - he, position + he), + }; + world.GetComponent(entity) = new ColliderComponent + { + Value = Collider.CreateBox(he), + }; + world.GetComponent(entity) = RigidBody.CreateDynamic(1.0f); + world.GetComponent(entity) = new Velocity(); + world.GetComponent(entity) = new Grabbable + { + GrabRadius = 1.0f, + MaxGrabDistance = 10.0f, + }; + world.GetComponent(entity) = new GrabState(); + world.GetComponent(entity) = new SpringJoint(); + } + private static void RunMainLoop( SystemScheduler scheduler, World world, @@ -1309,6 +1377,11 @@ private static void RunMainLoop( CreateDynamicCubeEntity(world, cubeMeshId, materialId, new Vector3(-2f, 1f, -5f)); + CreateGrabbableCubeEntity(world, cubeMeshId, materialId, + new Vector3(0f, 0.5f, -3f)); + CreateGrabbableCubeEntity(world, cubeMeshId, materialId, + new Vector3(1.5f, 0.5f, -4f)); + CreatePlayerEntity(world); CreateDirectionalLightEntity(world, lightData); @@ -1343,7 +1416,10 @@ private static void CreateSingletonEntity( if (renderMode == RenderMode.Stereo) { var vrType = ComponentRegistry.Get(); - ReadOnlySpan types = [ftType, isType, vrType]; + var hsType = ComponentRegistry.Get(); + var hrType = ComponentRegistry.Get(); + ReadOnlySpan types = + [ftType, isType, vrType, hsType, hrType]; world.CreateEntity(types); } else From c4e4f5b029159cbbf267870d539e706454695116 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 17:24:21 +0900 Subject: [PATCH 09/21] =?UTF-8?q?=E5=8B=95=E7=9A=84=E3=82=AA=E3=83=96?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E3=81=AE=20WorldBounds=20?= =?UTF-8?q?=E3=82=92=E6=AF=8E=E3=83=95=E3=83=AC=E3=83=BC=E3=83=A0=E5=86=8D?= =?UTF-8?q?=E8=A8=88=E7=AE=97=E3=81=97=E3=81=A6=E6=8F=8F=E7=94=BB=E6=B6=88?= =?UTF-8?q?=E5=A4=B1=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RigidBody や Grabbable で位置が変わるエンティティの WorldBounds が 初期位置のままだったため、カメラを振ると視錐台カリングで消えていた。 WorldBoundsUpdateSystem を Transform フェーズに追加し、 ColliderBounds.ComputeAABB で毎フレーム AABB を更新する。 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine.App/Program.cs | 1 + .../Rendering/WorldBoundsUpdateSystemTests.cs | 207 ++++++++++++++++++ .../Rendering/WorldBoundsUpdateSystem.cs | 78 +++++++ 3 files changed, 286 insertions(+) create mode 100644 src/Seed.Engine.Tests/Rendering/WorldBoundsUpdateSystemTests.cs create mode 100644 src/Seed.Engine/Rendering/WorldBoundsUpdateSystem.cs diff --git a/src/Seed.Engine.App/Program.cs b/src/Seed.Engine.App/Program.cs index 199f45f..35c448b 100644 --- a/src/Seed.Engine.App/Program.cs +++ b/src/Seed.Engine.App/Program.cs @@ -574,6 +574,7 @@ private static void Main() scheduler.AddSystem(new ConstraintSolverSystem(1f / 60f)); scheduler.AddSystem(new IntegrationSystem(1f / 60f)); scheduler.AddSystem(new TransformSystem()); + scheduler.AddSystem(new WorldBoundsUpdateSystem()); scheduler.AddSystem(new CameraViewSystem( (float)window.Width / window.Height)); diff --git a/src/Seed.Engine.Tests/Rendering/WorldBoundsUpdateSystemTests.cs b/src/Seed.Engine.Tests/Rendering/WorldBoundsUpdateSystemTests.cs new file mode 100644 index 0000000..f516903 --- /dev/null +++ b/src/Seed.Engine.Tests/Rendering/WorldBoundsUpdateSystemTests.cs @@ -0,0 +1,207 @@ +using System; +using Xunit; + +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; +using Seed.Engine.Physics.Collision; +using Seed.Engine.Rendering; +using Seed.Engine.Tests.Foundation.Mathematics; + +namespace Seed.Engine.Tests.Rendering; + +public class WorldBoundsUpdateSystemTests +{ + [Fact] + public void Execute_SphereAtOrigin_UpdatesWorldBounds() + { + // Given: entity with sphere collider at origin + using var world = new World(); + var ltType = ComponentRegistry.Get(); + var ccType = ComponentRegistry.Get(); + var wbType = ComponentRegistry.Get(); + ReadOnlySpan types = [ltType, ccType, wbType]; + + Entity entity = world.CreateEntity(types); + world.GetComponent(entity) = + LocalTransform.FromPosition(Vector3.Zero); + world.GetComponent(entity) = new ColliderComponent + { + Value = Collider.CreateSphere(1.0f), + }; + + // When + var system = new WorldBoundsUpdateSystem(); + system.Execute(world); + + // Then + ref WorldBounds wb = ref world.GetComponent(entity); + AssertHelper.ApproximatelyEqual(new Vector3(-1f, -1f, -1f), wb.Value.Min); + AssertHelper.ApproximatelyEqual(new Vector3(1f, 1f, 1f), wb.Value.Max); + } + + [Fact] + public void Execute_SphereTranslated_UpdatesWorldBoundsAtNewPosition() + { + // Given: entity with sphere collider translated to (5, 5, 5) + using var world = new World(); + var ltType = ComponentRegistry.Get(); + var ccType = ComponentRegistry.Get(); + var wbType = ComponentRegistry.Get(); + ReadOnlySpan types = [ltType, ccType, wbType]; + + Entity entity = world.CreateEntity(types); + world.GetComponent(entity) = + LocalTransform.FromPosition(new Vector3(5f, 5f, 5f)); + world.GetComponent(entity) = new ColliderComponent + { + Value = Collider.CreateSphere(1.0f), + }; + + // When + var system = new WorldBoundsUpdateSystem(); + system.Execute(world); + + // Then + ref WorldBounds wb = ref world.GetComponent(entity); + AssertHelper.ApproximatelyEqual(new Vector3(4f, 4f, 4f), wb.Value.Min); + AssertHelper.ApproximatelyEqual(new Vector3(6f, 6f, 6f), wb.Value.Max); + } + + [Fact] + public void Execute_BoxWithIdentityRotation_UpdatesWorldBounds() + { + // Given: entity with box collider at origin, no rotation + using var world = new World(); + var ltType = ComponentRegistry.Get(); + var ccType = ComponentRegistry.Get(); + var wbType = ComponentRegistry.Get(); + ReadOnlySpan types = [ltType, ccType, wbType]; + + Entity entity = world.CreateEntity(types); + world.GetComponent(entity) = + LocalTransform.FromPosition(Vector3.Zero); + world.GetComponent(entity) = new ColliderComponent + { + Value = Collider.CreateBox(new Vector3(1f, 2f, 3f)), + }; + + // When + var system = new WorldBoundsUpdateSystem(); + system.Execute(world); + + // Then + ref WorldBounds wb = ref world.GetComponent(entity); + AssertHelper.ApproximatelyEqual(new Vector3(-1f, -2f, -3f), wb.Value.Min); + AssertHelper.ApproximatelyEqual(new Vector3(1f, 2f, 3f), wb.Value.Max); + } + + [Fact] + public void Execute_MultipleEntities_UpdatesAll() + { + // Given: two entities with different positions + using var world = new World(); + var ltType = ComponentRegistry.Get(); + var ccType = ComponentRegistry.Get(); + var wbType = ComponentRegistry.Get(); + ReadOnlySpan types = [ltType, ccType, wbType]; + + Entity e1 = world.CreateEntity(types); + Entity e2 = world.CreateEntity(types); + + world.GetComponent(e1) = + LocalTransform.FromPosition(new Vector3(10f, 0f, 0f)); + world.GetComponent(e1) = new ColliderComponent + { + Value = Collider.CreateSphere(0.5f), + }; + + world.GetComponent(e2) = + LocalTransform.FromPosition(new Vector3(0f, 20f, 0f)); + world.GetComponent(e2) = new ColliderComponent + { + Value = Collider.CreateSphere(0.5f), + }; + + // When + var system = new WorldBoundsUpdateSystem(); + system.Execute(world); + + // Then + ref WorldBounds wb1 = ref world.GetComponent(e1); + AssertHelper.ApproximatelyEqual(new Vector3(9.5f, -0.5f, -0.5f), wb1.Value.Min); + AssertHelper.ApproximatelyEqual(new Vector3(10.5f, 0.5f, 0.5f), wb1.Value.Max); + + ref WorldBounds wb2 = ref world.GetComponent(e2); + AssertHelper.ApproximatelyEqual(new Vector3(-0.5f, 19.5f, -0.5f), wb2.Value.Min); + AssertHelper.ApproximatelyEqual(new Vector3(0.5f, 20.5f, 0.5f), wb2.Value.Max); + } + + [Fact] + public void Execute_SkipsEntitiesWithoutCollider() + { + // Given: one entity with all components, one without ColliderComponent + using var world = new World(); + var ltType = ComponentRegistry.Get(); + var ccType = ComponentRegistry.Get(); + var wbType = ComponentRegistry.Get(); + + ReadOnlySpan fullTypes = [ltType, ccType, wbType]; + Entity fullEntity = world.CreateEntity(fullTypes); + world.GetComponent(fullEntity) = + LocalTransform.FromPosition(new Vector3(3f, 0f, 0f)); + world.GetComponent(fullEntity) = new ColliderComponent + { + Value = Collider.CreateSphere(1.0f), + }; + + ReadOnlySpan partialTypes = [ltType, wbType]; + world.CreateEntity(partialTypes); + + // When: system runs without error + var system = new WorldBoundsUpdateSystem(); + system.Execute(world); + + // Then: full entity is updated correctly + ref WorldBounds wb = ref world.GetComponent(fullEntity); + AssertHelper.ApproximatelyEqual(new Vector3(2f, -1f, -1f), wb.Value.Min); + AssertHelper.ApproximatelyEqual(new Vector3(4f, 1f, 1f), wb.Value.Max); + } + + [Fact] + public void Execute_RotatedBox_ProducesExpandedBounds() + { + // Given: entity with box collider rotated 90 degrees around Y axis + using var world = new World(); + var ltType = ComponentRegistry.Get(); + var ccType = ComponentRegistry.Get(); + var wbType = ComponentRegistry.Get(); + ReadOnlySpan types = [ltType, ccType, wbType]; + + Entity entity = world.CreateEntity(types); + world.GetComponent(entity) = + LocalTransform.FromPositionRotationScale( + Vector3.Zero, + Quaternion.CreateFromAxisAngle(Vector3.UnitY, MathHelper.HalfPi), + Vector3.One); + world.GetComponent(entity) = new ColliderComponent + { + Value = Collider.CreateBox(new Vector3(2f, 0.5f, 0.5f)), + }; + + // When + var system = new WorldBoundsUpdateSystem(); + system.Execute(world); + + // Then: after 90-degree Y rotation, X extent should swap with Z extent + ref WorldBounds wb = ref world.GetComponent(entity); + Assert.True(wb.Value.Max.Z > 1.9f); + } + + [Fact] + public void Phase_IsTransform() + { + var system = new WorldBoundsUpdateSystem(); + Assert.Equal(FramePhase.Transform, system.Phase); + } +} diff --git a/src/Seed.Engine/Rendering/WorldBoundsUpdateSystem.cs b/src/Seed.Engine/Rendering/WorldBoundsUpdateSystem.cs new file mode 100644 index 0000000..07b0ba8 --- /dev/null +++ b/src/Seed.Engine/Rendering/WorldBoundsUpdateSystem.cs @@ -0,0 +1,78 @@ +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; +using Seed.Engine.Physics.Collision; + +namespace Seed.Engine.Rendering; + +/// +/// Recomputes from and +/// each frame so that frustum culling uses +/// up-to-date AABBs for moving objects. +/// Runs in the Transform phase (phase 5), after . +/// +public sealed class WorldBoundsUpdateSystem : ISystem +{ + private readonly QueryDescription _query; + + /// + /// Initializes a new . + /// + public WorldBoundsUpdateSystem() + { + _query = new QueryBuilder() + .WithRead() + .WithRead() + .WithWrite() + .Build(); + } + + /// + public FramePhase Phase => FramePhase.Transform; + + /// + public QueryDescription GetQuery() => _query; + + /// + public void Execute(World world) + { + var query = _query; + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!query.Matches(storage.Archetype)) + { + continue; + } + + int ltIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int ccIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int wbIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + int count = chunk.Count; + + for (int i = 0; i < count; i++) + { + ref LocalTransform lt = ref chunk.GetComponent(ltIdx, i); + ref ColliderComponent cc = ref chunk.GetComponent(ccIdx, i); + ref WorldBounds wb = ref chunk.GetComponent(wbIdx, i); + + var result = ColliderBounds.ComputeAABB( + cc.Value, lt.Position, lt.Rotation); + + if (result.IsSuccess) + { + wb.Value = result.Value; + } + } + } + } + } +} From d97e2fb5624a6ee5b524b7ac09b47c14cc45cf99 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 17:43:44 +0900 Subject: [PATCH 10/21] =?UTF-8?q?=E3=83=9E=E3=82=A6=E3=82=B9=20Pitch=20?= =?UTF-8?q?=E3=81=AE=E4=B8=8A=E4=B8=8B=E5=8F=8D=E8=BB=A2=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GLFW は Y 座標が下方向に増加するため、deltaY の符号を反転して マウスを下に動かしたときにカメラが下を向くようにする。 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine.Tests/GameLogic/MovementSystemTests.cs | 4 ++-- src/Seed.Engine/GameLogic/MovementSystem.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Seed.Engine.Tests/GameLogic/MovementSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/MovementSystemTests.cs index aaf7296..936b9ab 100644 --- a/src/Seed.Engine.Tests/GameLogic/MovementSystemTests.cs +++ b/src/Seed.Engine.Tests/GameLogic/MovementSystemTests.cs @@ -203,8 +203,8 @@ public void Execute_MouseLook_UpdatesYawAndPitch() ref CharacterState cs = ref world.GetComponent(player); // yaw += 100 * 1.0 * 0.001 = 0.1 AssertHelper.ApproximatelyEqual(0.1f, cs.Yaw, 1e-5f); - // pitch += 50 * 1.0 * 0.001 = 0.05 - AssertHelper.ApproximatelyEqual(0.05f, cs.Pitch, 1e-5f); + // pitch -= 50 * 1.0 * 0.001 = -0.05 (GLFW Y-down, negate for look-up) + AssertHelper.ApproximatelyEqual(-0.05f, cs.Pitch, 1e-5f); } [Fact] diff --git a/src/Seed.Engine/GameLogic/MovementSystem.cs b/src/Seed.Engine/GameLogic/MovementSystem.cs index 895752e..2466f4a 100644 --- a/src/Seed.Engine/GameLogic/MovementSystem.cs +++ b/src/Seed.Engine/GameLogic/MovementSystem.cs @@ -122,7 +122,7 @@ private static void UpdateCharacter( { // Mouse look cs.Yaw += input.LookDelta.X * cc.MouseSensitivity * 0.001f; - cs.Pitch += input.LookDelta.Y * cc.MouseSensitivity * 0.001f; + cs.Pitch -= input.LookDelta.Y * cc.MouseSensitivity * 0.001f; cs.Pitch = MathHelper.Clamp(cs.Pitch, cc.MinPitch, cc.MaxPitch); // Movement direction From 739b5926d0afa871ca0fba5e1adef26e81dc4d92 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 17:43:51 +0900 Subject: [PATCH 11/21] =?UTF-8?q?=E3=82=B0=E3=83=A9=E3=83=96=E3=83=AC?= =?UTF-8?q?=E3=82=A4=E3=81=AE=E5=8E=9F=E7=82=B9=E3=82=92=E7=9B=AE=E7=B7=9A?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E3=81=AB=E4=BF=AE=E6=AD=A3=E3=81=97=20Pitch?= =?UTF-8?q?=20=E7=AC=A6=E5=8F=B7=E3=82=92=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レイ原点がプレイヤー足元だったため目線で狙ったオブジェクトに 当たらなかった。CameraMode.FirstPersonOffset を加算して CameraViewSystem と同じ目線位置からレイを飛ばすように修正。 併せてクォータニオン Pitch の符号を CameraViewSystem の 三角関数計算と一致させた。 Co-Authored-By: Claude Opus 4.6 --- .../GameLogic/DesktopGrabPhysicsSystemTests.cs | 4 +++- .../GameLogic/DesktopGrabSystemTests.cs | 4 +++- src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs | 9 +++++++-- src/Seed.Engine/GameLogic/DesktopGrabSystem.cs | 9 +++++++-- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/Seed.Engine.Tests/GameLogic/DesktopGrabPhysicsSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/DesktopGrabPhysicsSystemTests.cs index 2dc951a..e864a8a 100644 --- a/src/Seed.Engine.Tests/GameLogic/DesktopGrabPhysicsSystemTests.cs +++ b/src/Seed.Engine.Tests/GameLogic/DesktopGrabPhysicsSystemTests.cs @@ -19,11 +19,13 @@ private static (World world, Entity camera, Entity grabbable) SetupWorld() var camType = ComponentRegistry.Get(); var ltType = ComponentRegistry.Get(); var csType = ComponentRegistry.Get(); - ReadOnlySpan cameraTypes = [camType, ltType, csType]; + var cmType = ComponentRegistry.Get(); + ReadOnlySpan cameraTypes = [camType, ltType, csType, cmType]; Entity camera = world.CreateEntity(cameraTypes); world.GetComponent(camera) = LocalTransform.FromPosition(Vector3.Zero); world.GetComponent(camera) = new CharacterState(); + world.GetComponent(camera) = new CameraMode(); // Grabbable entity var grabbType = ComponentRegistry.Get(); diff --git a/src/Seed.Engine.Tests/GameLogic/DesktopGrabSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/DesktopGrabSystemTests.cs index cb68bb7..4f23b19 100644 --- a/src/Seed.Engine.Tests/GameLogic/DesktopGrabSystemTests.cs +++ b/src/Seed.Engine.Tests/GameLogic/DesktopGrabSystemTests.cs @@ -25,11 +25,13 @@ private static (World world, Entity singleton, Entity camera, Entity grabbable) var camType = ComponentRegistry.Get(); var ltType = ComponentRegistry.Get(); var csType = ComponentRegistry.Get(); - ReadOnlySpan cameraTypes = [camType, ltType, csType]; + var cmType = ComponentRegistry.Get(); + ReadOnlySpan cameraTypes = [camType, ltType, csType, cmType]; Entity camera = world.CreateEntity(cameraTypes); world.GetComponent(camera) = LocalTransform.FromPosition(Vector3.Zero); world.GetComponent(camera) = new CharacterState(); + world.GetComponent(camera) = new CameraMode(); // Grabbable entity var grabbType = ComponentRegistry.Get(); diff --git a/src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs b/src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs index c905f21..5f56de0 100644 --- a/src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs +++ b/src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs @@ -28,6 +28,7 @@ public DesktopGrabPhysicsSystem() .WithRead() .WithRead() .WithRead() + .WithRead() .Build(); } @@ -57,6 +58,8 @@ public void Execute(World world) ComponentRegistry.Get().TypeId); int csIdx = storage.GetComponentIndex( ComponentRegistry.Get().TypeId); + int cmIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); for (int c = 0; c < storage.ChunkCount; c++) { @@ -67,11 +70,13 @@ public void Execute(World world) ltIdx, 0); ref CharacterState cs = ref chunk.GetComponent( csIdx, 0); - grabberPos = lt.Position; + ref CameraMode cm = ref chunk.GetComponent( + cmIdx, 0); + grabberPos = lt.Position + cm.FirstPersonOffset; Quaternion yawRot = Quaternion.CreateFromAxisAngle( Vector3.UnitY, -cs.Yaw); Quaternion pitchRot = Quaternion.CreateFromAxisAngle( - Vector3.UnitX, -cs.Pitch); + Vector3.UnitX, cs.Pitch); grabberRot = yawRot * pitchRot; grabberEntity = chunk.GetEntity(0); foundGrabber = true; diff --git a/src/Seed.Engine/GameLogic/DesktopGrabSystem.cs b/src/Seed.Engine/GameLogic/DesktopGrabSystem.cs index 6ee292e..155965d 100644 --- a/src/Seed.Engine/GameLogic/DesktopGrabSystem.cs +++ b/src/Seed.Engine/GameLogic/DesktopGrabSystem.cs @@ -32,6 +32,7 @@ public DesktopGrabSystem() .WithRead() .WithRead() .WithRead() + .WithRead() .Build(); _grabbableQuery = new QueryBuilder() @@ -104,6 +105,8 @@ public void Execute(World world) ComponentRegistry.Get().TypeId); int csIdx = storage.GetComponentIndex( ComponentRegistry.Get().TypeId); + int cmIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); for (int c = 0; c < storage.ChunkCount; c++) { @@ -114,11 +117,13 @@ public void Execute(World world) ltIdx, 0); ref CharacterState cs = ref chunk.GetComponent( csIdx, 0); - cameraPos = lt.Position; + ref CameraMode cm = ref chunk.GetComponent( + cmIdx, 0); + cameraPos = lt.Position + cm.FirstPersonOffset; Quaternion yawRot = Quaternion.CreateFromAxisAngle( Vector3.UnitY, -cs.Yaw); Quaternion pitchRot = Quaternion.CreateFromAxisAngle( - Vector3.UnitX, -cs.Pitch); + Vector3.UnitX, cs.Pitch); cameraRot = yawRot * pitchRot; cameraEntity = chunk.GetEntity(0); foundCamera = true; From 24a17230a3180ea907bdab0b085034d41e9f84a2 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 17:43:57 +0900 Subject: [PATCH 12/21] =?UTF-8?q?=E3=83=87=E3=83=90=E3=83=83=E3=82=B0?= =?UTF-8?q?=E7=94=A8=E3=82=AF=E3=83=AD=E3=82=B9=E3=83=98=E3=82=A2=E3=82=92?= =?UTF-8?q?=E3=82=AB=E3=83=A1=E3=83=A9=E5=89=8D=E6=96=B9=E3=81=AB=E8=A1=A8?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 小さなキューブをカメラ前方 3m に毎フレーム配置して 照準位置を可視化する仮実装。 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine.App/Program.cs | 32 +++++ .../Ecs/Components/DebugCrosshair.cs | 11 ++ .../Rendering/DebugCrosshairSystem.cs | 125 ++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 src/Seed.Engine/Ecs/Components/DebugCrosshair.cs create mode 100644 src/Seed.Engine/Rendering/DebugCrosshairSystem.cs diff --git a/src/Seed.Engine.App/Program.cs b/src/Seed.Engine.App/Program.cs index 35c448b..ddc8026 100644 --- a/src/Seed.Engine.App/Program.cs +++ b/src/Seed.Engine.App/Program.cs @@ -575,6 +575,7 @@ private static void Main() scheduler.AddSystem(new IntegrationSystem(1f / 60f)); scheduler.AddSystem(new TransformSystem()); scheduler.AddSystem(new WorldBoundsUpdateSystem()); + scheduler.AddSystem(new DebugCrosshairSystem()); scheduler.AddSystem(new CameraViewSystem( (float)window.Width / window.Height)); @@ -1384,6 +1385,7 @@ private static void RunMainLoop( new Vector3(1.5f, 0.5f, -4f)); CreatePlayerEntity(world); + CreateDebugCrosshairEntity(world, cubeMeshId, materialId); CreateDirectionalLightEntity(world, lightData); CreatePointLightEntity(world, @@ -1456,6 +1458,36 @@ private static void CreatePlatformTagEntity( } } + private static void CreateDebugCrosshairEntity( + World world, int meshId, int materialId) + { + var ltType = ComponentRegistry.Get(); + var ltwType = ComponentRegistry.Get(); + var meshType = ComponentRegistry.Get(); + var matType = ComponentRegistry.Get(); + var tagType = ComponentRegistry.Get(); + ReadOnlySpan types = + [ltType, ltwType, meshType, matType, tagType]; + + Entity entity = world.CreateEntity(types); + world.GetComponent(entity) = + LocalTransform.FromPositionRotationScale( + Vector3.Zero, Quaternion.Identity, + new Vector3(0.03f, 0.03f, 0.03f)); + world.GetComponent(entity) = new LocalToWorld + { + Value = Matrix4x4.Identity, + }; + world.GetComponent(entity) = new MeshReference + { + MeshId = meshId, + }; + world.GetComponent(entity) = new MaterialReference + { + MaterialId = materialId, + }; + } + private static void CreatePlayerEntity(World world) { var ltType = ComponentRegistry.Get(); diff --git a/src/Seed.Engine/Ecs/Components/DebugCrosshair.cs b/src/Seed.Engine/Ecs/Components/DebugCrosshair.cs new file mode 100644 index 0000000..8e9d95a --- /dev/null +++ b/src/Seed.Engine/Ecs/Components/DebugCrosshair.cs @@ -0,0 +1,11 @@ +using System.Runtime.InteropServices; + +namespace Seed.Engine.Ecs.Components; + +/// +/// Tag component marking an entity as the debug crosshair indicator. +/// +[StructLayout(LayoutKind.Sequential, Size = 1)] +public struct DebugCrosshair : IComponent +{ +} diff --git a/src/Seed.Engine/Rendering/DebugCrosshairSystem.cs b/src/Seed.Engine/Rendering/DebugCrosshairSystem.cs new file mode 100644 index 0000000..687e1bb --- /dev/null +++ b/src/Seed.Engine/Rendering/DebugCrosshairSystem.cs @@ -0,0 +1,125 @@ +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; + +namespace Seed.Engine.Rendering; + +/// +/// Positions the debug crosshair entity in front of the camera each frame. +/// +public sealed class DebugCrosshairSystem : ISystem +{ + private const float CrosshairDistance = 3f; + + private readonly QueryDescription _cameraQuery; + private readonly QueryDescription _crosshairQuery; + + /// + /// Initializes a new . + /// + public DebugCrosshairSystem() + { + _cameraQuery = new QueryBuilder() + .WithRead() + .WithRead() + .WithRead() + .WithRead() + .Build(); + + _crosshairQuery = new QueryBuilder() + .WithRead() + .WithWrite() + .Build(); + } + + /// + public FramePhase Phase => FramePhase.Transform; + + /// + public QueryDescription GetQuery() => _crosshairQuery; + + /// + public void Execute(World world) + { + Vector3 eyePos = Vector3.Zero; + Vector3 forward = new Vector3(0f, 0f, -1f); + bool foundCamera = false; + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_cameraQuery.Matches(storage.Archetype)) + { + continue; + } + + int ltIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int csIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int cmIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + if (chunk.Count > 0) + { + ref LocalTransform lt = ref chunk.GetComponent( + ltIdx, 0); + ref CharacterState cs = ref chunk.GetComponent( + csIdx, 0); + ref CameraMode cm = ref chunk.GetComponent( + cmIdx, 0); + + eyePos = lt.Position + cm.FirstPersonOffset; + Quaternion yawRot = Quaternion.CreateFromAxisAngle( + Vector3.UnitY, -cs.Yaw); + Quaternion pitchRot = Quaternion.CreateFromAxisAngle( + Vector3.UnitX, cs.Pitch); + Quaternion rot = yawRot * pitchRot; + forward = rot.RotateVector(new Vector3(0f, 0f, -1f)); + foundCamera = true; + break; + } + } + + if (foundCamera) + { + break; + } + } + + if (!foundCamera) + { + return; + } + + Vector3 crosshairPos = eyePos + forward * CrosshairDistance; + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_crosshairQuery.Matches(storage.Archetype)) + { + continue; + } + + int ltIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + int count = chunk.Count; + + for (int i = 0; i < count; i++) + { + ref LocalTransform lt = ref chunk.GetComponent( + ltIdx, i); + lt.Position = crosshairPos; + } + } + } + } +} From fc9cc2ddc5c2a13796c57b135284f1ed46b23940 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 17:46:21 +0900 Subject: [PATCH 13/21] =?UTF-8?q?=E6=8E=B4=E3=82=93=E3=81=A0=E3=82=AA?= =?UTF-8?q?=E3=83=96=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E3=82=92=E3=82=AB?= =?UTF-8?q?=E3=83=A1=E3=83=A9=E5=89=8D=E6=96=B9=202.5m=20=E3=81=AB?= =?UTF-8?q?=E4=BF=9D=E6=8C=81=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ワールド空間の生オフセットではなくカメラローカル空間の 固定距離を使い、オブジェクトが視界外に引き寄せられる 問題を解消。 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine/GameLogic/DesktopGrabSystem.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Seed.Engine/GameLogic/DesktopGrabSystem.cs b/src/Seed.Engine/GameLogic/DesktopGrabSystem.cs index 155965d..2d1fde6 100644 --- a/src/Seed.Engine/GameLogic/DesktopGrabSystem.cs +++ b/src/Seed.Engine/GameLogic/DesktopGrabSystem.cs @@ -14,6 +14,7 @@ namespace Seed.Engine.GameLogic; public sealed class DesktopGrabSystem : ISystem { private const float ThrowSpeed = 8f; + private const float HoldDistance = 2.5f; private readonly QueryDescription _singletonQuery; private readonly QueryDescription _cameraQuery; @@ -246,7 +247,7 @@ private void TryGrab( gs.IsGrabbed = 1; gs.GrabberEntity = cameraEntity; - gs.GrabOffset = hitLt.Position - cameraPos; + gs.GrabOffset = new Vector3(0f, 0f, -HoldDistance); gs.GrabRotationOffset = hitLt.Rotation; } From 3cfd2d82f70bf405b8d04841cd9f37a88084b02c Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 17:49:13 +0900 Subject: [PATCH 14/21] =?UTF-8?q?=E6=8E=B4=E3=81=BF=E4=B8=AD=E3=81=AE?= =?UTF-8?q?=E3=82=AA=E3=83=96=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E3=81=AE?= =?UTF-8?q?=20Velocity=20=E3=82=92=E3=82=BC=E3=83=AD=E3=81=AB=E3=81=97?= =?UTF-8?q?=E3=81=A6=E7=89=A9=E7=90=86=E5=B9=B2=E6=B8=89=E3=82=92=E9=98=B2?= =?UTF-8?q?=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DesktopGrabPhysicsSystem が位置をセットした後に ForceIntegrationSystem と IntegrationSystem が速度を積分して 位置をずらしていた。掴み中は Velocity を毎フレームゼロクリア することで視界追従の遅れを解消。 Co-Authored-By: Claude Opus 4.6 --- .../GameLogic/DesktopGrabPhysicsSystemTests.cs | 4 +++- src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Seed.Engine.Tests/GameLogic/DesktopGrabPhysicsSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/DesktopGrabPhysicsSystemTests.cs index e864a8a..6504b3e 100644 --- a/src/Seed.Engine.Tests/GameLogic/DesktopGrabPhysicsSystemTests.cs +++ b/src/Seed.Engine.Tests/GameLogic/DesktopGrabPhysicsSystemTests.cs @@ -30,8 +30,10 @@ private static (World world, Entity camera, Entity grabbable) SetupWorld() // Grabbable entity var grabbType = ComponentRegistry.Get(); var gsType = ComponentRegistry.Get(); - ReadOnlySpan grabbableTypes = [grabbType, gsType, ltType]; + var velType = ComponentRegistry.Get(); + ReadOnlySpan grabbableTypes = [grabbType, gsType, ltType, velType]; Entity grabbable = world.CreateEntity(grabbableTypes); + world.GetComponent(grabbable) = new Velocity(); world.GetComponent(grabbable) = new Grabbable { GrabRadius = 1.0f, diff --git a/src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs b/src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs index 5f56de0..7ab6417 100644 --- a/src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs +++ b/src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs @@ -22,6 +22,7 @@ public DesktopGrabPhysicsSystem() .WithRead() .WithRead() .WithWrite() + .WithWrite() .Build(); _cameraQuery = new QueryBuilder() @@ -107,6 +108,8 @@ public void Execute(World world) ComponentRegistry.Get().TypeId); int ltIdx = storage.GetComponentIndex( ComponentRegistry.Get().TypeId); + int velIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); for (int c = 0; c < storage.ChunkCount; c++) { @@ -130,10 +133,14 @@ public void Execute(World world) ref LocalTransform lt = ref chunk.GetComponent( ltIdx, i); + ref Velocity vel = ref chunk.GetComponent( + velIdx, i); Vector3 rotatedOffset = grabberRot.RotateVector( gs.GrabOffset); lt.Position = grabberPos + rotatedOffset; + vel.Linear = Vector3.Zero; + vel.Angular = Vector3.Zero; } } } From 90dbc5962a57cc9d3c83173eb24e098525f5cf5c Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 17:53:07 +0900 Subject: [PATCH 15/21] =?UTF-8?q?=E6=8E=B4=E3=82=93=E3=81=A0=E3=82=AA?= =?UTF-8?q?=E3=83=96=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E3=81=AE=E5=90=91?= =?UTF-8?q?=E3=81=8D=E3=82=92=E3=82=AB=E3=83=A1=E3=83=A9=E3=81=AB=E8=BF=BD?= =?UTF-8?q?=E5=BE=93=E3=81=95=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 掴み時にカメラとの相対回転を GrabRotationOffset に保存し、 毎フレーム grabberRot * GrabRotationOffset で回転を更新する ことで、視界を動かしても常に同じ面が見えるようにした。 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs | 1 + src/Seed.Engine/GameLogic/DesktopGrabSystem.cs | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs b/src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs index 7ab6417..49d5b04 100644 --- a/src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs +++ b/src/Seed.Engine/GameLogic/DesktopGrabPhysicsSystem.cs @@ -139,6 +139,7 @@ public void Execute(World world) Vector3 rotatedOffset = grabberRot.RotateVector( gs.GrabOffset); lt.Position = grabberPos + rotatedOffset; + lt.Rotation = grabberRot * gs.GrabRotationOffset; vel.Linear = Vector3.Zero; vel.Angular = Vector3.Zero; } diff --git a/src/Seed.Engine/GameLogic/DesktopGrabSystem.cs b/src/Seed.Engine/GameLogic/DesktopGrabSystem.cs index 2d1fde6..e3c86d4 100644 --- a/src/Seed.Engine/GameLogic/DesktopGrabSystem.cs +++ b/src/Seed.Engine/GameLogic/DesktopGrabSystem.cs @@ -148,7 +148,7 @@ public void Execute(World world) if (input.MouseLeftPressed == 1) { - TryGrab(world, cameraPos, cameraForward, cameraEntity); + TryGrab(world, cameraPos, cameraRot, cameraForward, cameraEntity); } else if (input.MouseLeftReleased == 1) { @@ -157,8 +157,8 @@ public void Execute(World world) } private void TryGrab( - World world, Vector3 cameraPos, Vector3 cameraForward, - Entity cameraEntity) + World world, Vector3 cameraPos, Quaternion cameraRot, + Vector3 cameraForward, Entity cameraEntity) { float closestDist = float.MaxValue; int closestStorage = -1; @@ -248,7 +248,7 @@ private void TryGrab( gs.IsGrabbed = 1; gs.GrabberEntity = cameraEntity; gs.GrabOffset = new Vector3(0f, 0f, -HoldDistance); - gs.GrabRotationOffset = hitLt.Rotation; + gs.GrabRotationOffset = cameraRot.Inverse() * hitLt.Rotation; } private void ReleaseGrab(World world, Vector3 cameraForward) From 07f484bacf1a53a7fbe00e54c7835b97ecee3c92 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 20:53:09 +0900 Subject: [PATCH 16/21] =?UTF-8?q?RigidBody=20=E3=81=AB=20InverseInertia=20?= =?UTF-8?q?=E3=83=95=E3=82=A3=E3=83=BC=E3=83=AB=E3=83=89=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 角速度の慣性計算に必要な逆慣性モーメント(スカラー、単位球近似)を RigidBody コンポーネントに追加。CreateDynamic は 5/(2*mass) を設定し、 CreateKinematic は 0 を設定する。AngularDamping のデフォルトを 0.1 に変更。 Co-Authored-By: Claude Opus 4.6 --- .../Physics/RigidBodyComponentTests.cs | 22 ++++++++++++++++++- src/Seed.Engine/Ecs/Components/RigidBody.cs | 7 +++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/Seed.Engine.Tests/Physics/RigidBodyComponentTests.cs b/src/Seed.Engine.Tests/Physics/RigidBodyComponentTests.cs index 2260f5b..5cec79b 100644 --- a/src/Seed.Engine.Tests/Physics/RigidBodyComponentTests.cs +++ b/src/Seed.Engine.Tests/Physics/RigidBodyComponentTests.cs @@ -38,7 +38,7 @@ public void CreateDynamic_DefaultsDampingToZero() // Then AssertHelper.ApproximatelyEqual(0f, rb.LinearDamping); - AssertHelper.ApproximatelyEqual(0f, rb.AngularDamping); + AssertHelper.ApproximatelyEqual(0.1f, rb.AngularDamping); } [Fact] @@ -73,6 +73,26 @@ public void CreateKinematic_SetsGravityScaleToZero() AssertHelper.ApproximatelyEqual(0f, rb.GravityScale); } + [Fact] + public void CreateDynamic_SetsCorrectInverseInertia() + { + // When + var rb = RigidBody.CreateDynamic(2.0f); + + // Then: I = 2/5 * m, InverseInertia = 5 / (2 * mass) = 5 / 4 = 1.25 + AssertHelper.ApproximatelyEqual(1.25f, rb.InverseInertia); + } + + [Fact] + public void CreateKinematic_SetsZeroInverseInertia() + { + // When + var rb = RigidBody.CreateKinematic(); + + // Then + AssertHelper.ApproximatelyEqual(0f, rb.InverseInertia); + } + [Fact] public void Velocity_DefaultsToZero() { diff --git a/src/Seed.Engine/Ecs/Components/RigidBody.cs b/src/Seed.Engine/Ecs/Components/RigidBody.cs index 258dae2..cc09624 100644 --- a/src/Seed.Engine/Ecs/Components/RigidBody.cs +++ b/src/Seed.Engine/Ecs/Components/RigidBody.cs @@ -28,6 +28,9 @@ public struct RigidBody : IComponent /// Whether this body is kinematic (infinite mass, unaffected by forces). public bool IsKinematic; + /// Precomputed inverse inertia (scalar, unit-sphere approximation). Zero for kinematic bodies. + public float InverseInertia; + /// /// Per-axis rotation freeze mask. 0.0 = free, 1.0 = frozen. /// @@ -43,9 +46,10 @@ public static RigidBody CreateDynamic(float mass) Mass = mass, InverseMass = 1f / mass, LinearDamping = 0f, - AngularDamping = 0f, + AngularDamping = 0.1f, GravityScale = 1f, IsKinematic = false, + InverseInertia = 5f / (2f * mass), FreezeRotation = Vector3.Zero, }; } @@ -63,6 +67,7 @@ public static RigidBody CreateKinematic() AngularDamping = 0f, GravityScale = 0f, IsKinematic = true, + InverseInertia = 0f, FreezeRotation = Vector3.Zero, }; } From 2272e8bcdae992f99ce4c2484e38c83c5c373046 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 20:53:20 +0900 Subject: [PATCH 17/21] =?UTF-8?q?PGS=20=E3=82=BD=E3=83=AB=E3=83=90?= =?UTF-8?q?=E3=83=BC=E3=81=AB=E8=A7=92=E6=85=A3=E6=80=A7=E3=82=B5=E3=83=9D?= =?UTF-8?q?=E3=83=BC=E3=83=88=E3=81=A8=E7=B7=9A=E5=BD=A2=E3=81=AE=E3=81=BF?= =?UTF-8?q?=E6=B3=95=E7=B7=9A=E3=82=A4=E3=83=B3=E3=83=91=E3=83=AB=E3=82=B9?= =?UTF-8?q?=E3=82=92=E5=B0=8E=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ComputeEffectiveMass に角慣性項を追加し、摩擦方向の有効質量に 回転慣性を反映。法線方向は線形のみの有効質量・相対速度・インパルス 適用とすることで、接触点のオフセットによる角速度の正帰還ループを防止。 Baumgarte 安定化は線形のみなので安全に復活。WarmStart メソッドを追加し 法線は線形のみ、摩擦は角速度込みでウォームスタートする。 Co-Authored-By: Claude Opus 4.6 --- .../Physics/Solver/PgsSolverTests.cs | 158 ++++++++++++++++-- src/Seed.Engine/Physics/Solver/PgsSolver.cs | 128 +++++++++----- 2 files changed, 235 insertions(+), 51 deletions(-) diff --git a/src/Seed.Engine.Tests/Physics/Solver/PgsSolverTests.cs b/src/Seed.Engine.Tests/Physics/Solver/PgsSolverTests.cs index a37ceed..8eed6bf 100644 --- a/src/Seed.Engine.Tests/Physics/Solver/PgsSolverTests.cs +++ b/src/Seed.Engine.Tests/Physics/Solver/PgsSolverTests.cs @@ -49,7 +49,7 @@ public void ComputeEffectiveMass_EqualMasses_ReturnsSymmetricResult() var normal = Vector3.UnitY; // When - float mass = PgsSolver.ComputeEffectiveMass(invMass, invMass, rA, rB, normal); + float mass = PgsSolver.ComputeEffectiveMass(invMass, invMass, 0f, 0f, rA, rB, normal); // Then Assert.True(mass > 0f); @@ -64,12 +64,33 @@ public void ComputeEffectiveMass_ZeroInverseMass_ReturnsZero() var normal = Vector3.UnitY; // When - float mass = PgsSolver.ComputeEffectiveMass(0f, 0f, rA, rB, normal); + float mass = PgsSolver.ComputeEffectiveMass(0f, 0f, 0f, 0f, rA, rB, normal); // Then AssertHelper.ApproximatelyEqual(0f, mass); } + [Fact] + public void ComputeEffectiveMass_WithAngularInertia_IncludesRotationalTerm() + { + // Given: off-center contact where rA x normal is non-zero + float invMass = 1.0f; + float invInertia = 2.5f; + var rA = new Vector3(1f, 0f, 0f); + var rB = new Vector3(-1f, 0f, 0f); + var normal = Vector3.UnitY; + + // When + float massWithInertia = PgsSolver.ComputeEffectiveMass( + invMass, invMass, invInertia, invInertia, rA, rB, normal); + float massWithoutInertia = PgsSolver.ComputeEffectiveMass( + invMass, invMass, 0f, 0f, rA, rB, normal); + + // Then: angular inertia makes effective mass smaller + Assert.True(massWithInertia < massWithoutInertia); + Assert.True(massWithInertia > 0f); + } + [Fact] public void Solve_CollidingBodies_SeparatesVelocities() { @@ -88,16 +109,23 @@ public void Solve_CollidingBodies_SeparatesVelocities() Friction = 0.3f, }; + float invInertia = 2.5f; + PgsSolver.ComputeTangents(constraints[0].Normal, out Vector3 t1, out Vector3 t2); constraints[0].Tangent1 = t1; constraints[0].Tangent2 = t2; + // Normal: linear-only (invInertia=0) matching PrepareConstraint behavior constraints[0].NormalMass = PgsSolver.ComputeEffectiveMass( - 1f, 1f, constraints[0].RelativeContactA, constraints[0].RelativeContactB, + 1f, 1f, 0f, 0f, + constraints[0].RelativeContactA, constraints[0].RelativeContactB, constraints[0].Normal); + // Friction: full angular inertia constraints[0].TangentMass1 = PgsSolver.ComputeEffectiveMass( - 1f, 1f, constraints[0].RelativeContactA, constraints[0].RelativeContactB, t1); + 1f, 1f, invInertia, invInertia, + constraints[0].RelativeContactA, constraints[0].RelativeContactB, t1); constraints[0].TangentMass2 = PgsSolver.ComputeEffectiveMass( - 1f, 1f, constraints[0].RelativeContactA, constraints[0].RelativeContactB, t2); + 1f, 1f, invInertia, invInertia, + constraints[0].RelativeContactA, constraints[0].RelativeContactB, t2); Span linVels = stackalloc Vector3[2]; linVels[0] = new Vector3(0f, -5f, 0f); @@ -111,13 +139,17 @@ public void Solve_CollidingBodies_SeparatesVelocities() invMasses[0] = 1f; invMasses[1] = 1f; + Span invInertias = stackalloc float[2]; + invInertias[0] = invInertia; + invInertias[1] = invInertia; + Span idxA = stackalloc int[1]; idxA[0] = 0; Span idxB = stackalloc int[1]; idxB[0] = 1; // When - PgsSolver.Solve(constraints, linVels, angVels, invMasses, idxA, idxB, 1f / 60f); + PgsSolver.Solve(constraints, linVels, angVels, invMasses, invInertias, idxA, idxB, 1f / 60f); // Then: bodies should be pushed apart (A upward, B downward relative to original) Assert.True(linVels[0].Y > -5f); @@ -144,12 +176,15 @@ public void Solve_NormalImpulse_NeverNegative() constraints[0].Tangent1 = t1; constraints[0].Tangent2 = t2; constraints[0].NormalMass = PgsSolver.ComputeEffectiveMass( - 1f, 1f, constraints[0].RelativeContactA, constraints[0].RelativeContactB, + 1f, 1f, 0f, 0f, + constraints[0].RelativeContactA, constraints[0].RelativeContactB, Vector3.UnitY); constraints[0].TangentMass1 = PgsSolver.ComputeEffectiveMass( - 1f, 1f, constraints[0].RelativeContactA, constraints[0].RelativeContactB, t1); + 1f, 1f, 0f, 0f, + constraints[0].RelativeContactA, constraints[0].RelativeContactB, t1); constraints[0].TangentMass2 = PgsSolver.ComputeEffectiveMass( - 1f, 1f, constraints[0].RelativeContactA, constraints[0].RelativeContactB, t2); + 1f, 1f, 0f, 0f, + constraints[0].RelativeContactA, constraints[0].RelativeContactB, t2); Span linVels = stackalloc Vector3[2]; linVels[0] = new Vector3(0f, 5f, 0f); @@ -160,11 +195,15 @@ public void Solve_NormalImpulse_NeverNegative() invMasses[0] = 1f; invMasses[1] = 1f; + Span invInertias = stackalloc float[2]; + invInertias[0] = 0f; + invInertias[1] = 0f; + Span idxA = stackalloc int[] { 0 }; Span idxB = stackalloc int[] { 1 }; // When - PgsSolver.Solve(constraints, linVels, angVels, invMasses, idxA, idxB, 1f / 60f); + PgsSolver.Solve(constraints, linVels, angVels, invMasses, invInertias, idxA, idxB, 1f / 60f); // Then: accumulated normal impulse should be >= 0 Assert.True(constraints[0].AccumulatedNormalImpulse >= 0f); @@ -188,6 +227,7 @@ public void PrepareConstraint_ComputesRelativeContacts() PgsSolver.PrepareConstraint( ref constraint, posA, posB, 1f, 1f, + 0f, 0f, Vector3.Zero, Vector3.Zero, Vector3.Zero, Vector3.Zero); @@ -196,4 +236,102 @@ public void PrepareConstraint_ComputesRelativeContacts() AssertHelper.ApproximatelyEqual(new Vector3(0f, -1f, 0f), constraint.RelativeContactB); Assert.True(constraint.NormalMass > 0f); } + + [Fact] + public void WarmStart_AppliesAccumulatedImpulse() + { + // Given + var constraint = new ContactConstraint + { + Normal = Vector3.UnitY, + ContactPoint = Vector3.Zero, + PenetrationDepth = 0.01f, + RelativeContactA = new Vector3(0f, -0.5f, 0f), + RelativeContactB = new Vector3(0f, 0.5f, 0f), + AccumulatedNormalImpulse = 10f, + }; + + PgsSolver.ComputeTangents(Vector3.UnitY, out Vector3 t1, out Vector3 t2); + constraint.Tangent1 = t1; + constraint.Tangent2 = t2; + + Vector3 linVelA = Vector3.Zero; + Vector3 angVelA = Vector3.Zero; + Vector3 linVelB = Vector3.Zero; + Vector3 angVelB = Vector3.Zero; + float invMass = 1f; + float invInertia = 2.5f; + + // When + PgsSolver.WarmStart(ref constraint, + ref linVelA, ref angVelA, invMass, invInertia, + ref linVelB, ref angVelB, invMass, invInertia); + + // Then: normal impulse of 10 along UnitY should push A up and B down + Assert.True(linVelA.Y > 0f); + Assert.True(linVelB.Y < 0f); + } + + [Fact] + public void Solve_OffCenterCollision_NormalImpulseDoesNotProduceAngularVelocity() + { + // Given: body A hits body B at an off-center point with purely normal approach velocity. + // Normal impulses are linear-only, so no angular velocity should be generated. + Span constraints = stackalloc ContactConstraint[1]; + constraints[0] = new ContactConstraint + { + EntityA = new Entity(1, 1), + EntityB = new Entity(2, 1), + Normal = Vector3.UnitY, + ContactPoint = new Vector3(1f, 0f, 0f), + PenetrationDepth = 0.01f, + Restitution = 0.5f, + Friction = 0.3f, + }; + + var posA = new Vector3(1f, -1f, 0f); + var posB = new Vector3(0f, 1f, 0f); + + float invMass = 1f; + float invInertia = 2.5f; + + PgsSolver.PrepareConstraint( + ref constraints[0], posA, posB, + invMass, invMass, + invInertia, invInertia, + Vector3.Zero, Vector3.Zero, + Vector3.Zero, Vector3.Zero); + + Span linVels = stackalloc Vector3[2]; + linVels[0] = new Vector3(0f, -5f, 0f); + linVels[1] = new Vector3(0f, 5f, 0f); + + Span angVels = stackalloc Vector3[2]; + angVels[0] = Vector3.Zero; + angVels[1] = Vector3.Zero; + + Span invMasses = stackalloc float[2]; + invMasses[0] = invMass; + invMasses[1] = invMass; + + Span invInertias = stackalloc float[2]; + invInertias[0] = invInertia; + invInertias[1] = invInertia; + + Span idxA = stackalloc int[] { 0 }; + Span idxB = stackalloc int[] { 1 }; + + // When + PgsSolver.Solve(constraints, linVels, angVels, invMasses, invInertias, idxA, idxB, 1f / 60f); + + // Then: no angular velocity (approach is purely along normal, no tangential component) + float angLenA = angVels[0].LengthSquared; + float angLenB = angVels[1].LengthSquared; + Assert.Equal(0f, angLenA); + Assert.Equal(0f, angLenB); + + // Linear velocity should still be corrected + Assert.True(linVels[0].Y > -5f); + Assert.True(linVels[1].Y < 5f); + } } diff --git a/src/Seed.Engine/Physics/Solver/PgsSolver.cs b/src/Seed.Engine/Physics/Solver/PgsSolver.cs index 17e2e02..ff3e6bc 100644 --- a/src/Seed.Engine/Physics/Solver/PgsSolver.cs +++ b/src/Seed.Engine/Physics/Solver/PgsSolver.cs @@ -48,31 +48,39 @@ public static void ComputeTangents(Vector3 normal, out Vector3 tangent1, out Vec } /// - /// Computes the effective mass for a contact constraint direction. - /// Uses point-mass approximation (no angular inertia) until a proper - /// inertia tensor is implemented. + /// Computes the effective mass for a contact constraint direction, + /// including rotational inertia contributions. /// public static float ComputeEffectiveMass( float inverseMassA, float inverseMassB, + float inverseInertiaA, float inverseInertiaB, Vector3 rA, Vector3 rB, Vector3 direction) { - float invMassSum = inverseMassA + inverseMassB; + Vector3 rAxN = Vector3.Cross(rA, direction); + Vector3 rBxN = Vector3.Cross(rB, direction); - if (invMassSum < MathHelper.Epsilon) + float k = inverseMassA + inverseMassB + + rAxN.LengthSquared * inverseInertiaA + + rBxN.LengthSquared * inverseInertiaB; + + if (k < MathHelper.Epsilon) { return 0f; } - return 1f / invMassSum; + return 1f / k; } /// /// Prepares a contact constraint with precomputed data for the solver. + /// Normal mass uses linear-only effective mass to prevent angular velocity + /// generation from normal impulses. Friction masses include angular inertia. /// public static void PrepareConstraint( ref ContactConstraint constraint, Vector3 posA, Vector3 posB, float inverseMassA, float inverseMassB, + float inverseInertiaA, float inverseInertiaB, Vector3 linearVelA, Vector3 angularVelA, Vector3 linearVelB, Vector3 angularVelB) { @@ -83,22 +91,55 @@ public static void PrepareConstraint( constraint.Tangent1 = t1; constraint.Tangent2 = t2; + // Normal: linear-only effective mass (no angular inertia contribution) + // This ensures normal impulse fully counteracts gravity without angular side effects. constraint.NormalMass = ComputeEffectiveMass( inverseMassA, inverseMassB, + 0f, 0f, constraint.RelativeContactA, constraint.RelativeContactB, constraint.Normal); + // Friction: full effective mass including angular inertia (correct for rolling) constraint.TangentMass1 = ComputeEffectiveMass( inverseMassA, inverseMassB, + inverseInertiaA, inverseInertiaB, constraint.RelativeContactA, constraint.RelativeContactB, t1); constraint.TangentMass2 = ComputeEffectiveMass( inverseMassA, inverseMassB, + inverseInertiaA, inverseInertiaB, constraint.RelativeContactA, constraint.RelativeContactB, t2); } + /// + /// Applies previous frame's accumulated impulse to seed the solver for faster convergence. + /// Normal impulse is applied to linear velocity only (no angular contribution). + /// Friction impulses are applied with full angular treatment. + /// Must be called after PrepareConstraint and before Solve. + /// + public static void WarmStart( + ref ContactConstraint c, + ref Vector3 linVelA, ref Vector3 angVelA, float invMassA, float invInertiaA, + ref Vector3 linVelB, ref Vector3 angVelB, float invMassB, float invInertiaB) + { + // Normal impulse: linear-only (no angular velocity change) + Vector3 normalImpulse = c.Normal * c.AccumulatedNormalImpulse; + linVelA = linVelA + normalImpulse * invMassA; + linVelB = linVelB - normalImpulse * invMassB; + + // Friction impulses: full angular treatment + Vector3 frictionImpulse = c.Tangent1 * c.AccumulatedTangentImpulse1 + + c.Tangent2 * c.AccumulatedTangentImpulse2; + + linVelA = linVelA + frictionImpulse * invMassA; + linVelB = linVelB - frictionImpulse * invMassB; + + angVelA = angVelA + Vector3.Cross(c.RelativeContactA, frictionImpulse) * invInertiaA; + angVelB = angVelB - Vector3.Cross(c.RelativeContactB, frictionImpulse) * invInertiaB; + } + /// /// Solves contact constraints iteratively, modifying velocities in place. /// @@ -107,6 +148,7 @@ public static void Solve( Span linearVelocities, Span angularVelocities, Span inverseMasses, + Span inverseInertias, Span entityIndexA, Span entityIndexB, float dt) @@ -120,35 +162,31 @@ public static void Solve( int idxB = entityIndexB[i]; SolveNormal(ref c, - ref linearVelocities[idxA], ref angularVelocities[idxA], inverseMasses[idxA], - ref linearVelocities[idxB], ref angularVelocities[idxB], inverseMasses[idxB], + ref linearVelocities[idxA], ref angularVelocities[idxA], + inverseMasses[idxA], inverseInertias[idxA], + ref linearVelocities[idxB], ref angularVelocities[idxB], + inverseMasses[idxB], inverseInertias[idxB], dt); SolveFriction(ref c, - ref linearVelocities[idxA], ref angularVelocities[idxA], inverseMasses[idxA], - ref linearVelocities[idxB], ref angularVelocities[idxB], inverseMasses[idxB]); + ref linearVelocities[idxA], ref angularVelocities[idxA], + inverseMasses[idxA], inverseInertias[idxA], + ref linearVelocities[idxB], ref angularVelocities[idxB], + inverseMasses[idxB], inverseInertias[idxB]); } } } private static void SolveNormal( ref ContactConstraint c, - ref Vector3 linVelA, ref Vector3 angVelA, float invMassA, - ref Vector3 linVelB, ref Vector3 angVelB, float invMassB, + ref Vector3 linVelA, ref Vector3 angVelA, float invMassA, float invInertiaA, + ref Vector3 linVelB, ref Vector3 angVelB, float invMassB, float invInertiaB, float dt) { - Vector3 relVel = ComputeRelativeVelocity( - linVelA, angVelA, c.RelativeContactA, - linVelB, angVelB, c.RelativeContactB); - - float vn = Vector3.Dot(relVel, c.Normal); - - // Baumgarte position correction - float baumgarte = 0f; - if (c.PenetrationDepth > PenetrationSlop) - { - baumgarte = BaumgarteBeta / dt * (c.PenetrationDepth - PenetrationSlop); - } + // Linear-only relative velocity (no angular contribution). + // This prevents angular velocity from feeding back into the normal solver, + // breaking the positive feedback loop that causes runaway rotation. + float vn = Vector3.Dot(linVelA - linVelB, c.Normal); // Restitution bias (skip for slow contacts to avoid jitter) float restitutionBias = 0f; @@ -157,21 +195,29 @@ private static void SolveNormal( restitutionBias = -c.Restitution * vn; } - float lambda = c.NormalMass * (-vn + baumgarte + restitutionBias); + // Baumgarte stabilization is safe here because normal impulse is linear-only. + float baumgarte = 0f; + if (c.PenetrationDepth > PenetrationSlop) + { + baumgarte = (BaumgarteBeta / dt) * (c.PenetrationDepth - PenetrationSlop); + } + + float lambda = c.NormalMass * (-vn + restitutionBias + baumgarte); float oldAccum = c.AccumulatedNormalImpulse; c.AccumulatedNormalImpulse = MathF.Max(0f, oldAccum + lambda); lambda = c.AccumulatedNormalImpulse - oldAccum; - ApplyImpulse(c.Normal, lambda, - ref linVelA, ref angVelA, invMassA, c.RelativeContactA, - ref linVelB, ref angVelB, invMassB, c.RelativeContactB); + // Apply normal impulse to LINEAR velocity only (no angular velocity change). + Vector3 impulse = c.Normal * lambda; + linVelA = linVelA + impulse * invMassA; + linVelB = linVelB - impulse * invMassB; } private static void SolveFriction( ref ContactConstraint c, - ref Vector3 linVelA, ref Vector3 angVelA, float invMassA, - ref Vector3 linVelB, ref Vector3 angVelB, float invMassB) + ref Vector3 linVelA, ref Vector3 angVelA, float invMassA, float invInertiaA, + ref Vector3 linVelB, ref Vector3 angVelB, float invMassB, float invInertiaB) { float maxFriction = c.Friction * c.AccumulatedNormalImpulse; @@ -190,8 +236,8 @@ private static void SolveFriction( lambda = c.AccumulatedTangentImpulse1 - oldAccum; ApplyImpulse(c.Tangent1, lambda, - ref linVelA, ref angVelA, invMassA, c.RelativeContactA, - ref linVelB, ref angVelB, invMassB, c.RelativeContactB); + ref linVelA, ref angVelA, invMassA, invInertiaA, c.RelativeContactA, + ref linVelB, ref angVelB, invMassB, invInertiaB, c.RelativeContactB); } // Tangent 2 @@ -209,8 +255,8 @@ private static void SolveFriction( lambda = c.AccumulatedTangentImpulse2 - oldAccum; ApplyImpulse(c.Tangent2, lambda, - ref linVelA, ref angVelA, invMassA, c.RelativeContactA, - ref linVelB, ref angVelB, invMassB, c.RelativeContactB); + ref linVelA, ref angVelA, invMassA, invInertiaA, c.RelativeContactA, + ref linVelB, ref angVelB, invMassB, invInertiaB, c.RelativeContactB); } } @@ -218,21 +264,21 @@ private static Vector3 ComputeRelativeVelocity( Vector3 linVelA, Vector3 angVelA, Vector3 rA, Vector3 linVelB, Vector3 angVelB, Vector3 rB) { - // Point-mass approximation: ignore angular velocity contribution - // until a proper inertia tensor is implemented. - return linVelA - linVelB; + return (linVelA + Vector3.Cross(angVelA, rA)) + - (linVelB + Vector3.Cross(angVelB, rB)); } private static void ApplyImpulse( Vector3 direction, float lambda, - ref Vector3 linVelA, ref Vector3 angVelA, float invMassA, Vector3 rA, - ref Vector3 linVelB, ref Vector3 angVelB, float invMassB, Vector3 rB) + ref Vector3 linVelA, ref Vector3 angVelA, float invMassA, float invInertiaA, Vector3 rA, + ref Vector3 linVelB, ref Vector3 angVelB, float invMassB, float invInertiaB, Vector3 rB) { Vector3 impulse = direction * lambda; - // Point-mass approximation: only modify linear velocity. - // Angular impulse requires a proper inertia tensor. linVelA = linVelA + impulse * invMassA; linVelB = linVelB - impulse * invMassB; + + angVelA = angVelA + Vector3.Cross(rA, impulse) * invInertiaA; + angVelB = angVelB - Vector3.Cross(rB, impulse) * invInertiaB; } } From b1f123688edc03582987eba3a69e2f99f3caf65d Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 20:53:29 +0900 Subject: [PATCH 18/21] =?UTF-8?q?=E6=B0=B8=E7=B6=9A=E6=8E=A5=E8=A7=A6?= =?UTF-8?q?=E3=83=9E=E3=83=8B=E3=83=95=E3=82=A9=E3=83=BC=E3=83=AB=E3=83=89?= =?UTF-8?q?=E3=81=A8=E3=82=A6=E3=82=A9=E3=83=BC=E3=83=A0=E3=82=B9=E3=82=BF?= =?UTF-8?q?=E3=83=BC=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit エンティティペアごとに最大4接触点をボディローカル座標で保持する PersistentManifold を導入。毎フレームの検証・マージ・最大面積維持の 接触点置換を実装。ConstraintSolverSystem をマニフォールドベースに 書き換え、フレーム間で累積インパルスを引き継ぐウォームスタートにより ソルバーの収束を改善。 Co-Authored-By: Claude Opus 4.6 --- .../Physics/Solver/PersistentManifoldTests.cs | 184 ++++++++++++ .../Physics/ConstraintSolverSystem.cs | 281 +++++++++++++----- .../Physics/Solver/PersistentManifold.cs | 230 ++++++++++++++ 3 files changed, 628 insertions(+), 67 deletions(-) create mode 100644 src/Seed.Engine.Tests/Physics/Solver/PersistentManifoldTests.cs create mode 100644 src/Seed.Engine/Physics/Solver/PersistentManifold.cs diff --git a/src/Seed.Engine.Tests/Physics/Solver/PersistentManifoldTests.cs b/src/Seed.Engine.Tests/Physics/Solver/PersistentManifoldTests.cs new file mode 100644 index 0000000..5cbdd02 --- /dev/null +++ b/src/Seed.Engine.Tests/Physics/Solver/PersistentManifoldTests.cs @@ -0,0 +1,184 @@ +using Xunit; + +using Seed.Engine.Ecs; +using Seed.Engine.Foundation.Mathematics; +using Seed.Engine.Physics.Solver; +using Seed.Engine.Tests.Foundation.Mathematics; + +namespace Seed.Engine.Tests.Physics.Solver; + +public class PersistentManifoldTests +{ + private static readonly Entity EntityA = new(1, 1); + private static readonly Entity EntityB = new(2, 1); + private static readonly Vector3 UpNormal = new(0f, 1f, 0f); + + // Penetrating contact convention (EPA): + // Normal = B→A = (0,1,0) + // PointOnA = support on A in -Normal direction (lowest point on A) + // PointOnB = support on B in +Normal direction (highest point on B) + // diff = PointOnA - PointOnB is opposite to Normal when penetrating + // PenetrationDepth > 0 + + [Fact] + public void AddContact_ToEmptyManifold_IncreasesCount() + { + // Given + var manifold = new PersistentManifold(EntityA, EntityB); + + // When + manifold.AddContact( + new Vector3(0f, -0.05f, 0f), new Vector3(0f, 0.05f, 0f), + UpNormal, 0.1f, + new Vector3(0f, 0.5f, 0f), Quaternion.Identity, + new Vector3(0f, -0.5f, 0f), Quaternion.Identity); + + // Then + Assert.Equal(1, manifold.Count); + } + + [Fact] + public void AddContact_MultipleDistinct_AccumulatesUpToMax() + { + // Given + var manifold = new PersistentManifold(EntityA, EntityB); + + // When: add 4 distinct contacts at different X positions + for (int i = 0; i < 4; i++) + { + float x = i * 0.1f; + manifold.AddContact( + new Vector3(x, -0.05f, 0f), new Vector3(x, 0.05f, 0f), + UpNormal, 0.1f, + new Vector3(0f, 0.5f, 0f), Quaternion.Identity, + new Vector3(0f, -0.5f, 0f), Quaternion.Identity); + } + + // Then + Assert.Equal(PersistentManifold.MaxContacts, manifold.Count); + } + + [Fact] + public void AddContact_MatchingContact_UpdatesWithoutIncreasingCount() + { + // Given + var manifold = new PersistentManifold(EntityA, EntityB); + + manifold.AddContact( + new Vector3(0f, -0.05f, 0f), new Vector3(0f, 0.05f, 0f), + UpNormal, 0.1f, + new Vector3(0f, 0.5f, 0f), Quaternion.Identity, + new Vector3(0f, -0.5f, 0f), Quaternion.Identity); + + // Set accumulated impulse to simulate solver output + manifold.Contacts[0].AccumulatedNormalImpulse = 5.0f; + + // When: add matching contact at nearly same position (within 5mm threshold) + manifold.AddContact( + new Vector3(0f, -0.051f, 0f), new Vector3(0f, 0.051f, 0f), + UpNormal, 0.15f, + new Vector3(0f, 0.5f, 0f), Quaternion.Identity, + new Vector3(0f, -0.5f, 0f), Quaternion.Identity); + + // Then: count unchanged, depth updated, impulse preserved + Assert.Equal(1, manifold.Count); + AssertHelper.ApproximatelyEqual(0.15f, manifold.Contacts[0].PenetrationDepth); + AssertHelper.ApproximatelyEqual(5.0f, manifold.Contacts[0].AccumulatedNormalImpulse); + } + + [Fact] + public void AddContact_FifthContact_ReplacesForMaxArea() + { + // Given: manifold at capacity with 4 corner contacts + var manifold = new PersistentManifold(EntityA, EntityB); + + manifold.AddContact( + new Vector3(-0.5f, -0.05f, -0.5f), new Vector3(-0.5f, 0.05f, -0.5f), + UpNormal, 0.1f, + new Vector3(0f, 0.5f, 0f), Quaternion.Identity, + new Vector3(0f, -0.5f, 0f), Quaternion.Identity); + manifold.AddContact( + new Vector3(0.5f, -0.05f, -0.5f), new Vector3(0.5f, 0.05f, -0.5f), + UpNormal, 0.1f, + new Vector3(0f, 0.5f, 0f), Quaternion.Identity, + new Vector3(0f, -0.5f, 0f), Quaternion.Identity); + manifold.AddContact( + new Vector3(-0.5f, -0.05f, 0.5f), new Vector3(-0.5f, 0.05f, 0.5f), + UpNormal, 0.1f, + new Vector3(0f, 0.5f, 0f), Quaternion.Identity, + new Vector3(0f, -0.5f, 0f), Quaternion.Identity); + manifold.AddContact( + new Vector3(0.5f, -0.05f, 0.5f), new Vector3(0.5f, 0.05f, 0.5f), + UpNormal, 0.1f, + new Vector3(0f, 0.5f, 0f), Quaternion.Identity, + new Vector3(0f, -0.5f, 0f), Quaternion.Identity); + + Assert.Equal(4, manifold.Count); + + // When: add 5th contact at center + manifold.AddContact( + new Vector3(0f, -0.05f, 0f), new Vector3(0f, 0.05f, 0f), + UpNormal, 0.1f, + new Vector3(0f, 0.5f, 0f), Quaternion.Identity, + new Vector3(0f, -0.5f, 0f), Quaternion.Identity); + + // Then: still at max + Assert.Equal(4, manifold.Count); + } + + [Fact] + public void Validate_StaleContact_IsRemoved() + { + // Given: manifold with a penetrating contact + var manifold = new PersistentManifold(EntityA, EntityB); + + manifold.AddContact( + new Vector3(0f, -0.05f, 0f), new Vector3(0f, 0.05f, 0f), + UpNormal, 0.1f, + new Vector3(0f, 0.5f, 0f), Quaternion.Identity, + new Vector3(0f, -0.5f, 0f), Quaternion.Identity); + + Assert.Equal(1, manifold.Count); + + // When: body A moves far up (fully separated) + manifold.Validate( + new Vector3(0f, 5f, 0f), Quaternion.Identity, + new Vector3(0f, -0.5f, 0f), Quaternion.Identity); + + // Then: contact removed + Assert.Equal(0, manifold.Count); + } + + [Fact] + public void Validate_ValidContact_PreservesContact() + { + // Given + var manifold = new PersistentManifold(EntityA, EntityB); + Vector3 posA = new(0f, 0.5f, 0f); + Vector3 posB = new(0f, -0.5f, 0f); + + manifold.AddContact( + new Vector3(0f, -0.05f, 0f), new Vector3(0f, 0.05f, 0f), + UpNormal, 0.1f, + posA, Quaternion.Identity, + posB, Quaternion.Identity); + + // When: validate with same positions + manifold.Validate(posA, Quaternion.Identity, posB, Quaternion.Identity); + + // Then: contact preserved + Assert.Equal(1, manifold.Count); + } + + [Fact] + public void Constructor_SetsEntities() + { + // When + var manifold = new PersistentManifold(EntityA, EntityB); + + // Then + Assert.Equal(EntityA, manifold.EntityA); + Assert.Equal(EntityB, manifold.EntityB); + Assert.Equal(0, manifold.Count); + } +} diff --git a/src/Seed.Engine/Physics/ConstraintSolverSystem.cs b/src/Seed.Engine/Physics/ConstraintSolverSystem.cs index fbcc5e8..6325d4b 100644 --- a/src/Seed.Engine/Physics/ConstraintSolverSystem.cs +++ b/src/Seed.Engine/Physics/ConstraintSolverSystem.cs @@ -13,6 +13,8 @@ namespace Seed.Engine.Physics; /// /// ECS system that builds contact constraints from narrow phase results /// and solves them using the PGS solver. +/// Maintains persistent contact manifolds across frames for multi-point +/// contact and warm starting. /// Runs in the Physics frame phase, after NarrowPhaseSystem. /// public sealed class ConstraintSolverSystem : ISystem @@ -23,11 +25,11 @@ public sealed class ConstraintSolverSystem : ISystem private const float PositionSlop = 0.005f; private readonly QueryDescription _narrowPhaseQuery; - private readonly QueryDescription _rigidBodyQuery; private readonly QueryDescription _solverResultQuery; private readonly QueryDescription _schedulingQuery; private readonly float _fixedDt; private NativeList _constraints; + private NativeList _manifolds; private bool _initialized; /// @@ -41,12 +43,6 @@ public ConstraintSolverSystem(float fixedDt) .WithRead() .Build(); - _rigidBodyQuery = new QueryBuilder() - .WithWrite() - .WithRead() - .WithRead() - .Build(); - _solverResultQuery = new QueryBuilder() .WithWrite() .Build(); @@ -72,11 +68,16 @@ public unsafe void Execute(World world) if (!_initialized) { _constraints = new NativeList(256); + _manifolds = new NativeList(64); _initialized = true; } _constraints.Clear(); + // 1. Validate existing manifolds against current transforms + ValidateManifolds(world); + + // 2. Read narrow phase contacts ContactPoint* contacts = null; CollisionPair* pairs = null; int contactCount = 0; @@ -107,94 +108,176 @@ public unsafe void Execute(World world) if (contactCount > 0) break; } - if (contactCount == 0 || contacts == null || pairs == null) + // 3. Merge new contacts into persistent manifolds + for (int i = 0; i < contactCount; i++) { - UpdateResult(world); - return; + CollisionPair pair = pairs[i]; + ContactPoint cp = contacts[i]; + + if (!world.IsAlive(pair.EntityA) || !world.IsAlive(pair.EntityB)) + { + continue; + } + + int mi = FindOrCreateManifold(pair.EntityA, pair.EntityB); + + Vector3 posA = GetPosition(world, pair.EntityA); + Quaternion rotA = GetRotation(world, pair.EntityA); + Vector3 posB = GetPosition(world, pair.EntityB); + Quaternion rotB = GetRotation(world, pair.EntityB); + + ref PersistentManifold m = ref _manifolds.AsSpan()[mi]; + m.AddContact( + cp.PointOnA, cp.PointOnB, + cp.Normal, cp.PenetrationDepth, + posA, rotA, posB, rotB); } - // Build constraints and collect entity velocity data - var linearVels = new NativeList(contactCount * 2); - var angularVels = new NativeList(contactCount * 2); - var invMasses = new NativeList(contactCount * 2); - var entityIndexA = new NativeList(contactCount); - var entityIndexB = new NativeList(contactCount); + // 4. Build constraints from all manifold contacts + int totalContacts = 0; + for (int i = 0; i < _manifolds.Length; i++) + { + totalContacts += _manifolds[i].Count; + } - // Map entities to velocity array indices - var entityMap = new NativeList(contactCount * 2); + if (totalContacts == 0) + { + UpdateResult(world); + return; + } - for (int i = 0; i < contactCount; i++) + int entityEstimate = totalContacts * 2; + var linearVels = new NativeList(entityEstimate); + var angularVels = new NativeList(entityEstimate); + var invMasses = new NativeList(entityEstimate); + var invInertias = new NativeList(entityEstimate); + var entityIndexA = new NativeList(totalContacts); + var entityIndexB = new NativeList(totalContacts); + var entityMap = new NativeList(entityEstimate); + var manifoldIndices = new NativeList(totalContacts); + var contactIndices = new NativeList(totalContacts); + + for (int mi = 0; mi < _manifolds.Length; mi++) { - CollisionPair pair = pairs[i]; - ContactPoint cp = contacts[i]; + ref PersistentManifold m = ref _manifolds.AsSpan()[mi]; + if (m.Count == 0) + { + continue; + } - if (!world.IsAlive(pair.EntityA) || !world.IsAlive(pair.EntityB)) + if (!world.IsAlive(m.EntityA) || !world.IsAlive(m.EntityB)) { continue; } - int idxA = FindOrAddEntity(ref entityMap, ref linearVels, ref angularVels, ref invMasses, - pair.EntityA, world); - int idxB = FindOrAddEntity(ref entityMap, ref linearVels, ref angularVels, ref invMasses, - pair.EntityB, world); + int idxA = FindOrAddEntity(ref entityMap, ref linearVels, ref angularVels, + ref invMasses, ref invInertias, m.EntityA, world); + int idxB = FindOrAddEntity(ref entityMap, ref linearVels, ref angularVels, + ref invMasses, ref invInertias, m.EntityB, world); - Vector3 midpoint = (cp.PointOnA + cp.PointOnB) * 0.5f; - Vector3 posA = world.HasComponent(pair.EntityA) - ? world.GetComponent(pair.EntityA).Position - : Vector3.Zero; - Vector3 posB = world.HasComponent(pair.EntityB) - ? world.GetComponent(pair.EntityB).Position - : Vector3.Zero; + Vector3 posA = GetPosition(world, m.EntityA); + Vector3 posB = GetPosition(world, m.EntityB); + Quaternion rotA = GetRotation(world, m.EntityA); + Quaternion rotB = GetRotation(world, m.EntityB); - var constraint = new ContactConstraint + for (int ci = 0; ci < m.Count; ci++) { - EntityA = pair.EntityA, - EntityB = pair.EntityB, - Normal = cp.Normal, - ContactPoint = midpoint, - PenetrationDepth = cp.PenetrationDepth, - Restitution = DefaultRestitution, - Friction = DefaultFriction, - }; - - PgsSolver.PrepareConstraint( - ref constraint, posA, posB, - invMasses[idxA], invMasses[idxB], - linearVels[idxA], angularVels[idxA], - linearVels[idxB], angularVels[idxB]); - - _constraints.Add(constraint); - entityIndexA.Add(idxA); - entityIndexB.Add(idxB); + ref ManifoldContact mc = ref m.Contacts[ci]; + + Vector3 worldA = rotA.RotateVector(mc.LocalPointA) + posA; + Vector3 worldB = rotB.RotateVector(mc.LocalPointB) + posB; + Vector3 worldContact = (worldA + worldB) * 0.5f; + + var constraint = new ContactConstraint + { + EntityA = m.EntityA, + EntityB = m.EntityB, + Normal = mc.Normal, + ContactPoint = worldContact, + PenetrationDepth = mc.PenetrationDepth, + Restitution = DefaultRestitution, + Friction = DefaultFriction, + AccumulatedNormalImpulse = mc.AccumulatedNormalImpulse, + AccumulatedTangentImpulse1 = mc.AccumulatedTangentImpulse1, + AccumulatedTangentImpulse2 = mc.AccumulatedTangentImpulse2, + }; + + PgsSolver.PrepareConstraint( + ref constraint, posA, posB, + invMasses[idxA], invMasses[idxB], + invInertias[idxA], invInertias[idxB], + linearVels[idxA], angularVels[idxA], + linearVels[idxB], angularVels[idxB]); + + _constraints.Add(constraint); + entityIndexA.Add(idxA); + entityIndexB.Add(idxB); + manifoldIndices.Add(mi); + contactIndices.Add(ci); + } } if (_constraints.Length > 0) { + var constraintSpan = _constraints.AsSpan(); + var linVelSpan = linearVels.AsSpan(); + var angVelSpan = angularVels.AsSpan(); + var invMassSpan = invMasses.AsSpan(); + var invInertiaSpan = invInertias.AsSpan(); + + // 5. Warm start: apply previous frame's accumulated impulses + for (int i = 0; i < constraintSpan.Length; i++) + { + ref ContactConstraint c = ref constraintSpan[i]; + int idxA = entityIndexA[i]; + int idxB = entityIndexB[i]; + + PgsSolver.WarmStart(ref c, + ref linVelSpan[idxA], ref angVelSpan[idxA], + invMassSpan[idxA], invInertiaSpan[idxA], + ref linVelSpan[idxB], ref angVelSpan[idxB], + invMassSpan[idxB], invInertiaSpan[idxB]); + } + + // 6. Solve constraints PgsSolver.Solve( - _constraints.AsSpan(), - linearVels.AsSpan(), - angularVels.AsSpan(), - invMasses.AsSpan(), + constraintSpan, + linVelSpan, + angVelSpan, + invMassSpan, + invInertiaSpan, entityIndexA.AsSpan(), entityIndexB.AsSpan(), _fixedDt); - // Write back solved velocities + // 7. Write back accumulated impulses to manifold contacts + for (int i = 0; i < constraintSpan.Length; i++) + { + ref ContactConstraint c = ref constraintSpan[i]; + int mi = manifoldIndices[i]; + int ci = contactIndices[i]; + ref PersistentManifold m = ref _manifolds.AsSpan()[mi]; + m.Contacts[ci].AccumulatedNormalImpulse = c.AccumulatedNormalImpulse; + m.Contacts[ci].AccumulatedTangentImpulse1 = c.AccumulatedTangentImpulse1; + m.Contacts[ci].AccumulatedTangentImpulse2 = c.AccumulatedTangentImpulse2; + } + + // 8. Write back solved velocities for (int i = 0; i < entityMap.Length; i++) { Entity entity = entityMap[i]; if (world.IsAlive(entity) && world.HasComponent(entity)) { ref Velocity vel = ref world.GetComponent(entity); - vel.Linear = linearVels[i]; - vel.Angular = angularVels[i]; + vel.Linear = linVelSpan[i]; + vel.Angular = angVelSpan[i]; } } - // Position correction: directly resolve penetration - for (int i = 0; i < _constraints.Length; i++) + // 9. Position correction: directly resolve penetration + for (int i = 0; i < constraintSpan.Length; i++) { - ref ContactConstraint c = ref _constraints.AsSpan()[i]; + ref ContactConstraint c = ref constraintSpan[i]; float correction = MathF.Max(0f, c.PenetrationDepth - PositionSlop) * PositionBeta; if (correction <= 0f) { @@ -203,7 +286,7 @@ public unsafe void Execute(World world) int idxA2 = entityIndexA[i]; int idxB2 = entityIndexB[i]; - float totalInvMass = invMasses[idxA2] + invMasses[idxB2]; + float totalInvMass = invMassSpan[idxA2] + invMassSpan[idxB2]; if (totalInvMass <= 0f) { continue; @@ -212,17 +295,19 @@ public unsafe void Execute(World world) Vector3 posCorrection = c.Normal * (correction / totalInvMass); Entity eA = c.EntityA; - if (world.IsAlive(eA) && world.HasComponent(eA) && invMasses[idxA2] > 0f) + if (world.IsAlive(eA) && world.HasComponent(eA) + && invMassSpan[idxA2] > 0f) { ref LocalTransform ltA = ref world.GetComponent(eA); - ltA.Position = ltA.Position + posCorrection * invMasses[idxA2]; + ltA.Position = ltA.Position + posCorrection * invMassSpan[idxA2]; } Entity eB = c.EntityB; - if (world.IsAlive(eB) && world.HasComponent(eB) && invMasses[idxB2] > 0f) + if (world.IsAlive(eB) && world.HasComponent(eB) + && invMassSpan[idxB2] > 0f) { ref LocalTransform ltB = ref world.GetComponent(eB); - ltB.Position = ltB.Position - posCorrection * invMasses[idxB2]; + ltB.Position = ltB.Position - posCorrection * invMassSpan[idxB2]; } } } @@ -230,18 +315,77 @@ public unsafe void Execute(World world) linearVels.Dispose(); angularVels.Dispose(); invMasses.Dispose(); + invInertias.Dispose(); entityIndexA.Dispose(); entityIndexB.Dispose(); entityMap.Dispose(); + manifoldIndices.Dispose(); + contactIndices.Dispose(); UpdateResult(world); } + private void ValidateManifolds(World world) + { + for (int i = _manifolds.Length - 1; i >= 0; i--) + { + ref PersistentManifold m = ref _manifolds.AsSpan()[i]; + + if (!world.IsAlive(m.EntityA) || !world.IsAlive(m.EntityB)) + { + _manifolds.RemoveAt(i); + continue; + } + + Vector3 posA = GetPosition(world, m.EntityA); + Quaternion rotA = GetRotation(world, m.EntityA); + Vector3 posB = GetPosition(world, m.EntityB); + Quaternion rotB = GetRotation(world, m.EntityB); + + m.Validate(posA, rotA, posB, rotB); + + if (m.Count == 0) + { + _manifolds.RemoveAt(i); + } + } + } + + private int FindOrCreateManifold(Entity entityA, Entity entityB) + { + var span = _manifolds.AsSpan(); + for (int i = 0; i < span.Length; i++) + { + if (span[i].EntityA.Equals(entityA) && span[i].EntityB.Equals(entityB)) + { + return i; + } + } + + _manifolds.Add(new PersistentManifold(entityA, entityB)); + return _manifolds.Length - 1; + } + + private static Vector3 GetPosition(World world, Entity entity) + { + return world.HasComponent(entity) + ? world.GetComponent(entity).Position + : Vector3.Zero; + } + + private static Quaternion GetRotation(World world, Entity entity) + { + return world.HasComponent(entity) + ? world.GetComponent(entity).Rotation + : Quaternion.Identity; + } + private static int FindOrAddEntity( ref NativeList entityMap, ref NativeList linearVels, ref NativeList angularVels, ref NativeList invMasses, + ref NativeList invInertias, Entity entity, World world) { for (int i = 0; i < entityMap.Length; i++) @@ -258,6 +402,7 @@ private static int FindOrAddEntity( Vector3 linVel = Vector3.Zero; Vector3 angVel = Vector3.Zero; float invMass = 0f; + float invInertia = 0f; if (world.HasComponent(entity)) { @@ -270,11 +415,13 @@ private static int FindOrAddEntity( { ref RigidBody rb = ref world.GetComponent(entity); invMass = rb.InverseMass; + invInertia = rb.InverseInertia; } linearVels.Add(linVel); angularVels.Add(angVel); invMasses.Add(invMass); + invInertias.Add(invInertia); return idx; } diff --git a/src/Seed.Engine/Physics/Solver/PersistentManifold.cs b/src/Seed.Engine/Physics/Solver/PersistentManifold.cs new file mode 100644 index 0000000..b226c0e --- /dev/null +++ b/src/Seed.Engine/Physics/Solver/PersistentManifold.cs @@ -0,0 +1,230 @@ +using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +using Seed.Engine.Ecs; +using Seed.Engine.Foundation.Mathematics; + +namespace Seed.Engine.Physics.Solver; + +/// +/// A single contact point stored in body-local coordinates for persistence across frames. +/// +[StructLayout(LayoutKind.Sequential)] +public struct ManifoldContact +{ + /// Contact point in body A's local space. + public Vector3 LocalPointA; + + /// Contact point in body B's local space. + public Vector3 LocalPointB; + + /// Contact normal in world space (B towards A). + public Vector3 Normal; + + /// Penetration depth (positive when overlapping). + public float PenetrationDepth; + + /// Accumulated normal impulse from the solver (for warm starting). + public float AccumulatedNormalImpulse; + + /// Accumulated tangent impulse in first friction direction. + public float AccumulatedTangentImpulse1; + + /// Accumulated tangent impulse in second friction direction. + public float AccumulatedTangentImpulse2; +} + +/// +/// Inline fixed-size buffer for manifold contacts. +/// +[InlineArray(PersistentManifold.MaxContacts)] +public struct ManifoldContactBuffer +{ + private ManifoldContact _element; +} + +/// +/// Persistent contact manifold storing up to 4 contacts between an entity pair. +/// Contacts are stored in body-local coordinates and validated each frame. +/// Based on Bullet Physics btPersistentManifold approach. +/// +[StructLayout(LayoutKind.Sequential)] +public struct PersistentManifold +{ + /// Maximum number of contact points per manifold. + public const int MaxContacts = 4; + + /// Distance threshold for removing stale contacts (meters). + public const float BreakingThreshold = 0.05f; + + /// Squared distance threshold for matching existing contacts. + public const float ContactMatchThresholdSq = 0.005f * 0.005f; + + /// First entity in the contact pair. + public Entity EntityA; + + /// Second entity in the contact pair. + public Entity EntityB; + + /// Inline buffer of up to 4 contacts. + public ManifoldContactBuffer Contacts; + + /// Current number of active contacts. + public int Count; + + /// + /// Creates a new empty manifold for the given entity pair. + /// + public PersistentManifold(Entity entityA, Entity entityB) + { + EntityA = entityA; + EntityB = entityB; + Contacts = default; + Count = 0; + } + + /// + /// Validates existing contacts against current transforms. + /// Removes contacts whose world-space positions have drifted beyond thresholds. + /// + public void Validate(Vector3 posA, Quaternion rotA, Vector3 posB, Quaternion rotB) + { + for (int i = Count - 1; i >= 0; i--) + { + Vector3 worldA = rotA.RotateVector(Contacts[i].LocalPointA) + posA; + Vector3 worldB = rotB.RotateVector(Contacts[i].LocalPointB) + posB; + + Vector3 diff = worldA - worldB; + float normalDist = Vector3.Dot(diff, Contacts[i].Normal); + Vector3 tangentDiff = diff - Contacts[i].Normal * normalDist; + + if (normalDist > BreakingThreshold + || tangentDiff.LengthSquared > BreakingThreshold * BreakingThreshold) + { + RemoveContact(i); + } + else + { + Contacts[i].PenetrationDepth = -normalDist; + } + } + } + + /// + /// Adds or merges a contact from the narrow phase. + /// If a matching contact exists (within threshold), its position is updated + /// while preserving accumulated impulses for warm starting. + /// + public void AddContact( + Vector3 worldPointOnA, Vector3 worldPointOnB, + Vector3 normal, float penetrationDepth, + Vector3 posA, Quaternion rotA, Vector3 posB, Quaternion rotB) + { + Quaternion invRotA = rotA.Conjugate(); + Quaternion invRotB = rotB.Conjugate(); + Vector3 localA = invRotA.RotateVector(worldPointOnA - posA); + Vector3 localB = invRotB.RotateVector(worldPointOnB - posB); + + for (int i = 0; i < Count; i++) + { + Vector3 diffA = Contacts[i].LocalPointA - localA; + if (diffA.LengthSquared < ContactMatchThresholdSq) + { + Contacts[i].LocalPointA = localA; + Contacts[i].LocalPointB = localB; + Contacts[i].Normal = normal; + Contacts[i].PenetrationDepth = penetrationDepth; + return; + } + } + + var newContact = new ManifoldContact + { + LocalPointA = localA, + LocalPointB = localB, + Normal = normal, + PenetrationDepth = penetrationDepth, + }; + + if (Count < MaxContacts) + { + Contacts[Count] = newContact; + Count++; + } + else + { + ReplaceForMaxArea(newContact, posA, rotA); + } + } + + private void RemoveContact(int index) + { + Count--; + if (index < Count) + { + Contacts[index] = Contacts[Count]; + } + } + + /// + /// When at capacity, replaces a contact to maximize the quadrilateral area. + /// Keeps the deepest penetration, then maximizes area from the remaining set. + /// + private void ReplaceForMaxArea(ManifoldContact newContact, Vector3 posA, Quaternion rotA) + { + int deepestIdx = -1; + float maxDepth = newContact.PenetrationDepth; + + for (int i = 0; i < MaxContacts; i++) + { + if (Contacts[i].PenetrationDepth > maxDepth) + { + maxDepth = Contacts[i].PenetrationDepth; + deepestIdx = i; + } + } + + float bestArea = -1f; + int replaceIdx = 0; + + for (int skip = 0; skip < MaxContacts; skip++) + { + if (skip == deepestIdx) + { + continue; + } + + float area = ComputeAreaExcluding(skip, newContact, posA, rotA); + if (area > bestArea) + { + bestArea = area; + replaceIdx = skip; + } + } + + Contacts[replaceIdx] = newContact; + } + + private readonly float ComputeAreaExcluding( + int skipIndex, ManifoldContact replacement, + Vector3 posA, Quaternion rotA) + { + Span points = stackalloc Vector3[MaxContacts]; + for (int i = 0; i < MaxContacts; i++) + { + if (i == skipIndex) + { + points[i] = rotA.RotateVector(replacement.LocalPointA) + posA; + } + else + { + points[i] = rotA.RotateVector(Contacts[i].LocalPointA) + posA; + } + } + + Vector3 d1 = points[2] - points[0]; + Vector3 d2 = points[3] - points[1]; + return Vector3.Cross(d1, d2).Length; + } +} From fe91b392515c0b0cedeb5ad8880a1dbde94b2443 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 20:53:48 +0900 Subject: [PATCH 19/21] =?UTF-8?q?=E8=A7=92=E9=80=9F=E5=BA=A6=E3=81=AE?= =?UTF-8?q?=E5=AE=89=E5=AE=9A=E5=8C=96=EF=BC=88MaxRotation=20=E3=82=AF?= =?UTF-8?q?=E3=83=A9=E3=83=B3=E3=83=97=E3=80=81=E3=82=B9=E3=83=AA=E3=83=BC?= =?UTF-8?q?=E3=83=97=E9=96=BE=E5=80=A4=E3=80=81=E9=9D=A2=E6=95=B4=E5=88=97?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrator に MaxRotation (π/2) クランプを追加し数値安定性を確保。 IntegrationSystem に角速度スリープ閾値を追加し、低速時に角速度をゼロ化。 RotationSettlingSystem を新規追加し、静止状態のオブジェクトを最寄りの 面整列方向にスムーズに回転。デモアプリに InverseInertia と RotationSettlingSystem を反映。 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine.App/Program.cs | 4 +- .../Physics/Integration/Integrator.cs | 17 ++- src/Seed.Engine/Physics/IntegrationSystem.cs | 16 +- .../Physics/RotationSettlingSystem.cs | 140 ++++++++++++++++++ 4 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 src/Seed.Engine/Physics/RotationSettlingSystem.cs diff --git a/src/Seed.Engine.App/Program.cs b/src/Seed.Engine.App/Program.cs index ddc8026..f5def4a 100644 --- a/src/Seed.Engine.App/Program.cs +++ b/src/Seed.Engine.App/Program.cs @@ -573,6 +573,7 @@ private static void Main() scheduler.AddSystem(new NarrowPhaseSystem()); scheduler.AddSystem(new ConstraintSolverSystem(1f / 60f)); scheduler.AddSystem(new IntegrationSystem(1f / 60f)); + scheduler.AddSystem(new RotationSettlingSystem(1f / 60f)); scheduler.AddSystem(new TransformSystem()); scheduler.AddSystem(new WorldBoundsUpdateSystem()); scheduler.AddSystem(new DebugCrosshairSystem()); @@ -1293,9 +1294,10 @@ private static void CreateDynamicCubeEntity( Mass = 1.0f, InverseMass = 1.0f, LinearDamping = 0.01f, - AngularDamping = 0.05f, + AngularDamping = 0.1f, GravityScale = 1.0f, IsKinematic = false, + InverseInertia = 5f / (2f * 1.0f), FreezeRotation = new Vector3(0f, 0f, 0f), }; world.GetComponent(entity) = new Velocity(); diff --git a/src/Seed.Engine/Physics/Integration/Integrator.cs b/src/Seed.Engine/Physics/Integration/Integrator.cs index a4ade77..a370a1d 100644 --- a/src/Seed.Engine/Physics/Integration/Integrator.cs +++ b/src/Seed.Engine/Physics/Integration/Integrator.cs @@ -12,6 +12,13 @@ public static class Integrator /// Standard gravitational acceleration. public static readonly Vector3 DefaultGravity = new(0f, -9.81f, 0f); + /// + /// Maximum angular displacement per time step (radians). + /// Prevents numerical instability from excessive rotation. + /// Reference: Box2D b2_maxRotation = π/2. + /// + public const float MaxRotation = MathF.PI * 0.5f; + /// /// Integrates linear velocity: applies gravity, damping, then updates position. /// @@ -83,7 +90,15 @@ public static void IntegrateAngular( angularVelocity.Y * (1f - freezeRotation.Y), angularVelocity.Z * (1f - freezeRotation.Z)); - if (angularVelocity.LengthSquared < MathHelper.Epsilon * MathHelper.Epsilon) + // Clamp angular displacement per step (Box2D b2_maxRotation approach) + float angSpeed = angularVelocity.Length; + if (angSpeed * dt > MaxRotation) + { + angularVelocity = angularVelocity * (MaxRotation / (angSpeed * dt)); + angSpeed = MaxRotation / dt; + } + + if (angSpeed < MathHelper.Epsilon) { return; } diff --git a/src/Seed.Engine/Physics/IntegrationSystem.cs b/src/Seed.Engine/Physics/IntegrationSystem.cs index 85696a0..b6c999f 100644 --- a/src/Seed.Engine/Physics/IntegrationSystem.cs +++ b/src/Seed.Engine/Physics/IntegrationSystem.cs @@ -12,6 +12,12 @@ namespace Seed.Engine.Physics; /// public sealed class IntegrationSystem : ISystem { + /// Angular speed threshold below which angular velocity is zeroed (rad/s). + private const float AngularSleepThreshold = 0.15f; + + /// Linear speed threshold for angular sleep eligibility (m/s). + private const float LinearSleepThreshold = 0.5f; + private readonly QueryDescription _query; private readonly float _fixedDt; @@ -74,8 +80,16 @@ public void Execute(World world) ref position, vel.Linear, rb.InverseMass, _fixedDt); - Quaternion rotation = lt.Rotation; Vector3 angularVel = vel.Angular; + + // Zero out sub-threshold angular velocity when nearly at rest + if (angularVel.LengthSquared < AngularSleepThreshold * AngularSleepThreshold + && vel.Linear.LengthSquared < LinearSleepThreshold * LinearSleepThreshold) + { + angularVel = Vector3.Zero; + } + + Quaternion rotation = lt.Rotation; Integrator.IntegrateAngular( ref rotation, ref angularVel, rb.AngularDamping, rb.FreezeRotation, _fixedDt); diff --git a/src/Seed.Engine/Physics/RotationSettlingSystem.cs b/src/Seed.Engine/Physics/RotationSettlingSystem.cs new file mode 100644 index 0000000..125585f --- /dev/null +++ b/src/Seed.Engine/Physics/RotationSettlingSystem.cs @@ -0,0 +1,140 @@ +using System; + +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; + +namespace Seed.Engine.Physics; + +/// +/// Smoothly settles dynamic objects onto the nearest face-aligned orientation +/// when they are approximately at rest. This corrects tilted landings that the +/// PGS solver does not resolve through angular impulses alone. +/// +public sealed class RotationSettlingSystem : ISystem +{ + private const float RestLinearThreshold = 0.5f; + private const float RestAngularThreshold = 0.5f; + private const float SettlingRate = 5f; + + private readonly QueryDescription _query; + private readonly float _fixedDt; + + /// + /// Initializes a new . + /// + public RotationSettlingSystem(float fixedDt) + { + _fixedDt = fixedDt; + _query = new QueryBuilder() + .WithWrite() + .WithRead() + .WithRead() + .Build(); + } + + /// + public FramePhase Phase => FramePhase.Physics; + + /// + public QueryDescription GetQuery() => _query; + + /// + public void Execute(World world) + { + int ltTypeId = ComponentRegistry.Get().TypeId; + int velTypeId = ComponentRegistry.Get().TypeId; + int rbTypeId = ComponentRegistry.Get().TypeId; + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_query.Matches(storage.Archetype)) + { + continue; + } + + int ltIdx = storage.GetComponentIndex(ltTypeId); + int velIdx = storage.GetComponentIndex(velTypeId); + int rbIdx = storage.GetComponentIndex(rbTypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + int count = chunk.Count; + + for (int i = 0; i < count; i++) + { + ref RigidBody rb = ref chunk.GetComponent(rbIdx, i); + if (rb.IsKinematic || rb.InverseMass <= 0f) + { + continue; + } + + ref Velocity vel = ref chunk.GetComponent(velIdx, i); + if (vel.Linear.LengthSquared > RestLinearThreshold * RestLinearThreshold + || vel.Angular.LengthSquared > RestAngularThreshold * RestAngularThreshold) + { + continue; + } + + ref LocalTransform lt = ref chunk.GetComponent(ltIdx, i); + SettleRotation(ref lt, _fixedDt); + } + } + } + } + + private static void SettleRotation(ref LocalTransform lt, float dt) + { + Quaternion rot = lt.Rotation; + Vector3 worldUp = Vector3.UnitY; + + // Find which local axis (±X, ±Y, ±Z) is most aligned with world up + Vector3 bestLocalAxis = Vector3.UnitY; + float bestDot = -2f; + + Span axes = stackalloc Vector3[6]; + axes[0] = Vector3.UnitX; + axes[1] = new Vector3(-1f, 0f, 0f); + axes[2] = Vector3.UnitY; + axes[3] = new Vector3(0f, -1f, 0f); + axes[4] = Vector3.UnitZ; + axes[5] = new Vector3(0f, 0f, -1f); + + for (int i = 0; i < 6; i++) + { + Vector3 worldAxis = rot.RotateVector(axes[i]); + float d = Vector3.Dot(worldAxis, worldUp); + if (d > bestDot) + { + bestDot = d; + bestLocalAxis = axes[i]; + } + } + + if (bestDot > 0.999f) + { + return; + } + + // Compute current world direction of the best local axis + Vector3 currentUp = rot.RotateVector(bestLocalAxis); + + // Rotation from currentUp to worldUp + Vector3 cross = Vector3.Cross(currentUp, worldUp); + float crossLen = cross.Length; + + if (crossLen < MathHelper.Epsilon) + { + return; + } + + Vector3 axis = cross * (1f / crossLen); + float angle = MathF.Acos(MathHelper.Clamp(bestDot, -1f, 1f)); + + float step = MathF.Min(1f, SettlingRate * dt); + Quaternion correction = Quaternion.CreateFromAxisAngle(axis, angle * step); + lt.Rotation = (correction * rot).Normalize(); + } +} From d5155321f0ef18b6933104ed6cf15b4a36641630 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 21:07:41 +0900 Subject: [PATCH 20/21] =?UTF-8?q?=E3=82=AD=E3=83=A5=E3=83=BC=E3=83=96?= =?UTF-8?q?=E3=81=AE=E8=B3=AA=E9=87=8F=E3=82=92=E4=BD=93=E7=A9=8D=E6=AF=94?= =?UTF-8?q?=E3=81=A7=E8=A8=AD=E5=AE=9A=E3=81=97=E3=83=80=E3=83=B3=E3=83=94?= =?UTF-8?q?=E3=83=B3=E3=82=B0=E3=82=92=E8=AA=BF=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 密度 8.0 を基準にキューブの質量をサイズに応じて設定 (大=8.0kg、小≈1.73kg)。CreateDynamic のデフォルトに LinearDamping=0.5、AngularDamping=1.0 を設定し、衝突後の 滑走・回転が速やかに収束するようにした。 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine.App/Program.cs | 18 ++++++------------ .../Physics/RigidBodyComponentTests.cs | 6 +++--- src/Seed.Engine/Ecs/Components/RigidBody.cs | 4 ++-- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/Seed.Engine.App/Program.cs b/src/Seed.Engine.App/Program.cs index f5def4a..1f43435 100644 --- a/src/Seed.Engine.App/Program.cs +++ b/src/Seed.Engine.App/Program.cs @@ -1289,17 +1289,9 @@ private static void CreateDynamicCubeEntity( { Value = Collider.CreateBox(new Vector3(0.5f, 0.5f, 0.5f)), }; - world.GetComponent(entity) = new RigidBody - { - Mass = 1.0f, - InverseMass = 1.0f, - LinearDamping = 0.01f, - AngularDamping = 0.1f, - GravityScale = 1.0f, - IsKinematic = false, - InverseInertia = 5f / (2f * 1.0f), - FreezeRotation = new Vector3(0f, 0f, 0f), - }; + // Volume = 1.0^3 = 1.0, density = 8.0 → mass = 8.0 + const float dynamicCubeMass = 8.0f; + world.GetComponent(entity) = RigidBody.CreateDynamic(dynamicCubeMass); world.GetComponent(entity) = new Velocity(); } @@ -1346,7 +1338,9 @@ private static void CreateGrabbableCubeEntity( { Value = Collider.CreateBox(he), }; - world.GetComponent(entity) = RigidBody.CreateDynamic(1.0f); + // Volume = 0.6^3 = 0.216, density = 8.0 → mass ≈ 1.73 + float grabbableCubeMass = 0.6f * 0.6f * 0.6f * 8.0f; + world.GetComponent(entity) = RigidBody.CreateDynamic(grabbableCubeMass); world.GetComponent(entity) = new Velocity(); world.GetComponent(entity) = new Grabbable { diff --git a/src/Seed.Engine.Tests/Physics/RigidBodyComponentTests.cs b/src/Seed.Engine.Tests/Physics/RigidBodyComponentTests.cs index 5cec79b..3d335e7 100644 --- a/src/Seed.Engine.Tests/Physics/RigidBodyComponentTests.cs +++ b/src/Seed.Engine.Tests/Physics/RigidBodyComponentTests.cs @@ -31,14 +31,14 @@ public void CreateDynamic_DefaultsGravityScaleToOne() } [Fact] - public void CreateDynamic_DefaultsDampingToZero() + public void CreateDynamic_SetsDefaultDamping() { // When var rb = RigidBody.CreateDynamic(1.0f); // Then - AssertHelper.ApproximatelyEqual(0f, rb.LinearDamping); - AssertHelper.ApproximatelyEqual(0.1f, rb.AngularDamping); + AssertHelper.ApproximatelyEqual(0.5f, rb.LinearDamping); + AssertHelper.ApproximatelyEqual(1.0f, rb.AngularDamping); } [Fact] diff --git a/src/Seed.Engine/Ecs/Components/RigidBody.cs b/src/Seed.Engine/Ecs/Components/RigidBody.cs index cc09624..a445ad8 100644 --- a/src/Seed.Engine/Ecs/Components/RigidBody.cs +++ b/src/Seed.Engine/Ecs/Components/RigidBody.cs @@ -45,8 +45,8 @@ public static RigidBody CreateDynamic(float mass) { Mass = mass, InverseMass = 1f / mass, - LinearDamping = 0f, - AngularDamping = 0.1f, + LinearDamping = 0.5f, + AngularDamping = 1.0f, GravityScale = 1f, IsKinematic = false, InverseInertia = 5f / (2f * mass), From 780c7b5eb25790f104b59682f786409849a173fe Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 21:09:59 +0900 Subject: [PATCH 21/21] =?UTF-8?q?roadmap.md=20v0.12=20=E3=82=92=E5=AE=8C?= =?UTF-8?q?=E4=BA=86=E3=81=AB=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/roadmap.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index ec85a7a..32681b9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -178,13 +178,13 @@ ゴール: 両プラットフォームで物理インタラクションが動く -- [ ] デスクトップ: マウスレイキャストでオブジェクト選択 -- [ ] デスクトップ: クリック長押し → ドラッグ → リリースで掴む・投げる -- [ ] VR: ハンドグラブ (手の形状に基づく掴みポーズ) -- [ ] VR: 距離グラブ (ポイントして引き寄せ) -- [ ] VR: 手の速度をオブジェクトに伝達して投げる -- [ ] VR: スプリングジョイント接続 -- [ ] VR: ハプティクスフィードバック +- [x] デスクトップ: マウスレイキャストでオブジェクト選択 +- [x] デスクトップ: クリック長押し → ドラッグ → リリースで掴む・投げる +- [x] VR: ハンドグラブ (手の形状に基づく掴みポーズ) +- [x] VR: 距離グラブ (ポイントして引き寄せ) +- [x] VR: 手の速度をオブジェクトに伝達して投げる +- [x] VR: スプリングジョイント接続 +- [x] VR: ハプティクスフィードバック 完動品としての価値: デスクトップでもVRでもオブジェクトを掴んで投げられる。VRでは手の動きが自然に反映される。