From 00014d9cfaf2016eebf92ace5cd8e3547179deec Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 21:54:13 +0900 Subject: [PATCH 01/24] =?UTF-8?q?VR=20=E3=83=AD=E3=82=B3=E3=83=A2=E3=83=BC?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=B3=E3=83=B3=E3=83=9D=E3=83=BC?= =?UTF-8?q?=E3=83=8D=E3=83=B3=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 テレポート・スムース移動・アームスイングの3方式を 管理するためのECSコンポーネント群を定義。 Co-Authored-By: Claude Opus 4.6 --- .../Components/VrLocomotionComponentTests.cs | 162 ++++++++++++++++++ .../Ecs/Components/TeleportState.cs | 48 ++++++ .../Ecs/Components/VignetteState.cs | 13 ++ .../Ecs/Components/VrLocomotionConfig.cs | 25 +++ .../Ecs/Components/VrLocomotionMode.cs | 36 ++++ 5 files changed, 284 insertions(+) create mode 100644 src/Seed.Engine.Tests/Ecs/Components/VrLocomotionComponentTests.cs create mode 100644 src/Seed.Engine/Ecs/Components/TeleportState.cs create mode 100644 src/Seed.Engine/Ecs/Components/VignetteState.cs create mode 100644 src/Seed.Engine/Ecs/Components/VrLocomotionConfig.cs create mode 100644 src/Seed.Engine/Ecs/Components/VrLocomotionMode.cs diff --git a/src/Seed.Engine.Tests/Ecs/Components/VrLocomotionComponentTests.cs b/src/Seed.Engine.Tests/Ecs/Components/VrLocomotionComponentTests.cs new file mode 100644 index 0000000..1cb91fa --- /dev/null +++ b/src/Seed.Engine.Tests/Ecs/Components/VrLocomotionComponentTests.cs @@ -0,0 +1,162 @@ +using System; +using System.Runtime.InteropServices; +using Xunit; + +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; + +namespace Seed.Engine.Tests.Ecs.Components; + +public class VrLocomotionComponentTests +{ + [Fact] + public void VrLocomotionMode_ImplementsIComponent() + { + // Given + VrLocomotionMode mode = new VrLocomotionMode + { + ActiveMode = LocomotionType.SmoothMove, + DirectionSource = 1, + VignetteEnabled = 1, + }; + + // Then + Assert.Equal(LocomotionType.SmoothMove, mode.ActiveMode); + Assert.Equal((byte)1, mode.DirectionSource); + Assert.Equal((byte)1, mode.VignetteEnabled); + } + + [Fact] + public void VrLocomotionMode_RegistersAsComponent() + { + // When + var compType = ComponentRegistry.Get(); + + // Then + Assert.True(compType.TypeId > 0); + Assert.True(compType.Size > 0); + } + + [Fact] + public void VrLocomotionConfig_ImplementsIComponent() + { + // Given + VrLocomotionConfig config = new VrLocomotionConfig + { + TeleportMaxDistance = 10.0f, + TeleportFadeDuration = 0.15f, + SmoothMoveSpeed = 2.0f, + ArmSwingMaxSpeed = 5.0f, + ArmSwingMultiplier = 2.0f, + }; + + // Then + Assert.Equal(10.0f, config.TeleportMaxDistance); + Assert.Equal(0.15f, config.TeleportFadeDuration); + Assert.Equal(2.0f, config.SmoothMoveSpeed); + Assert.Equal(5.0f, config.ArmSwingMaxSpeed); + } + + [Fact] + public void VrLocomotionConfig_RegistersAsComponent() + { + // When + var compType = ComponentRegistry.Get(); + + // Then + Assert.True(compType.TypeId > 0); + Assert.True(compType.Size > 0); + } + + [Fact] + public void TeleportState_ImplementsIComponent() + { + // Given + TeleportState state = new TeleportState + { + Phase = TeleportPhase.Aiming, + IsTargetValid = 1, + TargetPosition = new Vector3(1f, 0f, -3f), + TargetYaw = 1.57f, + FadeElapsed = 0.1f, + }; + + // Then + Assert.Equal(TeleportPhase.Aiming, state.Phase); + Assert.Equal((byte)1, state.IsTargetValid); + Assert.Equal(1.57f, state.TargetYaw); + } + + [Fact] + public void TeleportState_RegistersAsComponent() + { + // When + var compType = ComponentRegistry.Get(); + + // Then + Assert.True(compType.TypeId > 0); + Assert.True(compType.Size > 0); + } + + [Fact] + public void VignetteState_ImplementsIComponent() + { + // Given + VignetteState vignette = new VignetteState + { + Intensity = 0.8f, + }; + + // Then + Assert.Equal(0.8f, vignette.Intensity); + } + + [Fact] + public void VignetteState_RegistersAsComponent() + { + // When + var compType = ComponentRegistry.Get(); + + // Then + Assert.True(compType.TypeId > 0); + Assert.True(compType.Size > 0); + } + + [Fact] + public void LocomotionType_EnumValues() + { + Assert.Equal((byte)0, (byte)LocomotionType.Teleport); + Assert.Equal((byte)1, (byte)LocomotionType.SmoothMove); + Assert.Equal((byte)2, (byte)LocomotionType.ArmSwing); + } + + [Fact] + public void TeleportPhase_EnumValues() + { + Assert.Equal((byte)0, (byte)TeleportPhase.Idle); + Assert.Equal((byte)1, (byte)TeleportPhase.Aiming); + Assert.Equal((byte)2, (byte)TeleportPhase.FadingOut); + Assert.Equal((byte)3, (byte)TeleportPhase.FadingIn); + } + + [Fact] + public void VrLocomotionMode_IsMarshalable() + { + // Given/When: Marshal.SizeOf succeeds only for sequential/explicit layout + int size = Marshal.SizeOf(); + + // Then + Assert.True(size > 0); + } + + [Fact] + public void TeleportState_IsMarshalable() + { + // Given/When + int size = Marshal.SizeOf(); + + // Then + Assert.True(size > 0); + } +} diff --git a/src/Seed.Engine/Ecs/Components/TeleportState.cs b/src/Seed.Engine/Ecs/Components/TeleportState.cs new file mode 100644 index 0000000..88a61b0 --- /dev/null +++ b/src/Seed.Engine/Ecs/Components/TeleportState.cs @@ -0,0 +1,48 @@ +using System.Runtime.InteropServices; + +using Seed.Engine.Foundation.Mathematics; + +namespace Seed.Engine.Ecs.Components; + +/// +/// Represents the current phase of a teleport action. +/// +public enum TeleportPhase : byte +{ + /// No teleport in progress. + Idle = 0, + + /// Player is aiming the teleport arc. + Aiming = 1, + + /// Screen is fading out before moving the player. + FadingOut = 2, + + /// Screen is fading back in after the player has moved. + FadingIn = 3, +} + +/// +/// Per-entity state tracking a teleport action's progress. +/// +[StructLayout(LayoutKind.Sequential)] +public struct TeleportState : IComponent +{ + /// Current phase of the teleport. + public TeleportPhase Phase; + + /// 1 if the current target position is valid, 0 otherwise. + public byte IsTargetValid; + + private byte _pad0; + private byte _pad1; + + /// World-space landing position. + public Vector3 TargetPosition; + + /// Desired yaw (radians) after teleporting. + public float TargetYaw; + + /// Elapsed time within the current fade phase. + public float FadeElapsed; +} diff --git a/src/Seed.Engine/Ecs/Components/VignetteState.cs b/src/Seed.Engine/Ecs/Components/VignetteState.cs new file mode 100644 index 0000000..f731cb3 --- /dev/null +++ b/src/Seed.Engine/Ecs/Components/VignetteState.cs @@ -0,0 +1,13 @@ +using System.Runtime.InteropServices; + +namespace Seed.Engine.Ecs.Components; + +/// +/// Per-entity state for the comfort vignette overlay used during smooth locomotion. +/// +[StructLayout(LayoutKind.Sequential)] +public struct VignetteState : IComponent +{ + /// Desired vignette intensity in [0,1]. + public float Intensity; +} diff --git a/src/Seed.Engine/Ecs/Components/VrLocomotionConfig.cs b/src/Seed.Engine/Ecs/Components/VrLocomotionConfig.cs new file mode 100644 index 0000000..106d96a --- /dev/null +++ b/src/Seed.Engine/Ecs/Components/VrLocomotionConfig.cs @@ -0,0 +1,25 @@ +using System.Runtime.InteropServices; + +namespace Seed.Engine.Ecs.Components; + +/// +/// Singleton component holding tunable parameters for all VR locomotion modes. +/// +[StructLayout(LayoutKind.Sequential)] +public struct VrLocomotionConfig : IComponent +{ + /// Maximum teleport distance in metres (default 10.0). + public float TeleportMaxDistance; + + /// Duration of the fade-out / fade-in transition in seconds (default 0.15). + public float TeleportFadeDuration; + + /// Smooth locomotion movement speed in m/s (default 2.0). + public float SmoothMoveSpeed; + + /// Maximum speed for arm-swing locomotion in m/s (default 5.0). + public float ArmSwingMaxSpeed; + + /// Multiplier converting hand speed to movement speed. + public float ArmSwingMultiplier; +} diff --git a/src/Seed.Engine/Ecs/Components/VrLocomotionMode.cs b/src/Seed.Engine/Ecs/Components/VrLocomotionMode.cs new file mode 100644 index 0000000..88e9143 --- /dev/null +++ b/src/Seed.Engine/Ecs/Components/VrLocomotionMode.cs @@ -0,0 +1,36 @@ +using System.Runtime.InteropServices; + +namespace Seed.Engine.Ecs.Components; + +/// +/// Specifies which VR locomotion method is active. +/// +public enum LocomotionType : byte +{ + /// Point-and-teleport locomotion. + Teleport = 0, + + /// Thumbstick-driven smooth locomotion. + SmoothMove = 1, + + /// Arm-swing-driven locomotion. + ArmSwing = 2, +} + +/// +/// Singleton component selecting the active VR locomotion mode and shared options. +/// +[StructLayout(LayoutKind.Sequential)] +public struct VrLocomotionMode : IComponent +{ + /// The currently active locomotion type. + public LocomotionType ActiveMode; + + /// Direction source: 0 = HMD forward, 1 = controller forward. + public byte DirectionSource; + + /// Whether the comfort vignette is enabled: 0 = disabled, 1 = enabled. + public byte VignetteEnabled; + + private byte _pad0; +} From 9c90a0767151536889df8f5aa5ad6e990e4a772b Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 21:54:17 +0900 Subject: [PATCH 02/24] =?UTF-8?q?StereoCamera=20=E3=81=AB=20HmdRotation=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 VR移動システムがHMDの前方ベクトルを参照するために Quaternion型のHmdRotationを追加。 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine.Tests/Rendering/StereoCameraTests.cs | 6 +++--- src/Seed.Engine/Rendering/StereoCamera.cs | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Seed.Engine.Tests/Rendering/StereoCameraTests.cs b/src/Seed.Engine.Tests/Rendering/StereoCameraTests.cs index 788987e..ca40320 100644 --- a/src/Seed.Engine.Tests/Rendering/StereoCameraTests.cs +++ b/src/Seed.Engine.Tests/Rendering/StereoCameraTests.cs @@ -11,13 +11,13 @@ namespace Seed.Engine.Tests.Rendering; public class StereoCameraTests { [Fact] - public void StereoCamera_StructSize_Is140Bytes() + public void StereoCamera_StructSize_Is156Bytes() { // Given/When int size = Marshal.SizeOf(); - // Then: two Matrix4x4 (64 bytes each) + Vector3 (12 bytes) = 140 bytes - Assert.Equal(140, size); + // Then: two Matrix4x4 (64 bytes each) + Vector3 (12 bytes) + Quaternion (16 bytes) = 156 bytes + Assert.Equal(156, size); } [Fact] diff --git a/src/Seed.Engine/Rendering/StereoCamera.cs b/src/Seed.Engine/Rendering/StereoCamera.cs index 336d1fe..d86fefd 100644 --- a/src/Seed.Engine/Rendering/StereoCamera.cs +++ b/src/Seed.Engine/Rendering/StereoCamera.cs @@ -28,6 +28,11 @@ public struct StereoCamera : IComponent /// The HMD position in stage space, derived from the midpoint of left and right eye poses. /// public Vector3 HmdPosition; + + /// + /// The HMD orientation in stage space, derived from the midpoint of left and right eye rotations. + /// + public Quaternion HmdRotation; } /// From 2cfc91560c69ec26afbb7724ad0f77cabe7d7c2f Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 21:54:23 +0900 Subject: [PATCH 03/24] =?UTF-8?q?VR=20=E3=82=B9=E3=83=A0=E3=83=BC=E3=82=B9?= =?UTF-8?q?=E7=A7=BB=E5=8B=95=E3=81=A8=E3=82=A2=E3=83=BC=E3=83=A0=E3=82=B9?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=82=B0=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 左スティック駆動のスムース移動(2.0m/s、ビネット対応)と コントローラー振り動作のアームスイング(最大5.0m/s)を追加。 Co-Authored-By: Claude Opus 4.6 --- .../GameLogic/VrArmSwingSystemTests.cs | 239 +++++++++++++ .../GameLogic/VrSmoothMoveSystemTests.cs | 319 ++++++++++++++++++ src/Seed.Engine/GameLogic/VrArmSwingSystem.cs | 143 ++++++++ .../GameLogic/VrLocomotionHelper.cs | 27 ++ .../GameLogic/VrSmoothMoveSystem.cs | 191 +++++++++++ 5 files changed, 919 insertions(+) create mode 100644 src/Seed.Engine.Tests/GameLogic/VrArmSwingSystemTests.cs create mode 100644 src/Seed.Engine.Tests/GameLogic/VrSmoothMoveSystemTests.cs create mode 100644 src/Seed.Engine/GameLogic/VrArmSwingSystem.cs create mode 100644 src/Seed.Engine/GameLogic/VrLocomotionHelper.cs create mode 100644 src/Seed.Engine/GameLogic/VrSmoothMoveSystem.cs diff --git a/src/Seed.Engine.Tests/GameLogic/VrArmSwingSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/VrArmSwingSystemTests.cs new file mode 100644 index 0000000..1d970ef --- /dev/null +++ b/src/Seed.Engine.Tests/GameLogic/VrArmSwingSystemTests.cs @@ -0,0 +1,239 @@ +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.Rendering; +using Seed.Engine.Tests.Foundation.Mathematics; + +namespace Seed.Engine.Tests.GameLogic; + +public class VrArmSwingSystemTests +{ + private static VrLocomotionConfig DefaultConfig() + { + return new VrLocomotionConfig + { + TeleportMaxDistance = 10.0f, + TeleportFadeDuration = 0.15f, + SmoothMoveSpeed = 2.0f, + ArmSwingMaxSpeed = 5.0f, + ArmSwingMultiplier = 2.0f, + }; + } + + private static (World world, Entity singleton, Entity player) SetupWorld() + { + var world = new World(); + + var ftType = ComponentRegistry.Get(); + var hsType = ComponentRegistry.Get(); + var scType = ComponentRegistry.Get(); + var lmType = ComponentRegistry.Get(); + var lcType = ComponentRegistry.Get(); + ReadOnlySpan singletonTypes = [ftType, hsType, scType, lmType, lcType]; + Entity singleton = world.CreateEntity(singletonTypes); + + world.GetComponent(singleton) = new FrameTime { DeltaTime = 1.0f }; + world.GetComponent(singleton) = new VrHandState(); + world.GetComponent(singleton) = new StereoCamera + { + HmdRotation = Quaternion.Identity, + }; + world.GetComponent(singleton) = new VrLocomotionMode + { + ActiveMode = LocomotionType.ArmSwing, + }; + world.GetComponent(singleton) = DefaultConfig(); + + var ltType = ComponentRegistry.Get(); + ReadOnlySpan playerTypes = [ltType]; + Entity player = world.CreateEntity(playerTypes); + + world.GetComponent(player) = + LocalTransform.FromPosition(Vector3.Zero); + + return (world, singleton, player); + } + + [Fact] + public void Phase_IsGameLogic() + { + var system = new VrArmSwingSystem(); + Assert.Equal(FramePhase.GameLogic, system.Phase); + } + + [Fact] + public void Execute_WrongMode_DoesNothing() + { + // Given + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrLocomotionMode + { + ActiveMode = LocomotionType.Teleport, + }; + world.GetComponent(singleton) = new VrHandState + { + LeftVelocity = new Vector3(0f, 0f, -3f), + RightVelocity = new Vector3(0f, 0f, -3f), + }; + + var system = new VrArmSwingSystem(); + + // When + system.Execute(world); + + // Then + ref LocalTransform lt = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(Vector3.Zero, lt.Position, 0.001f); + } + + [Fact] + public void Execute_BothHandsSwinging_MovesForward() + { + // Given: both hands at 2 m/s velocity, multiplier=2 => speed=4 + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrHandState + { + LeftVelocity = new Vector3(0f, -2f, 0f), + RightVelocity = new Vector3(0f, 2f, 0f), + }; + + var system = new VrArmSwingSystem(); + + // When + system.Execute(world); + + // Then: avg speed = (2+2)/2=2, *multiplier(2)=4, forward (identity)=-Z + ref LocalTransform lt = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(-4.0f, lt.Position.Z, 0.01f); + } + + [Fact] + public void Execute_SpeedClampedToMax() + { + // Given: both hands at 10 m/s => avg=10, *multiplier(2)=20 > max 5.0 + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrHandState + { + LeftVelocity = new Vector3(0f, -10f, 0f), + RightVelocity = new Vector3(0f, 10f, 0f), + }; + + var system = new VrArmSwingSystem(); + + // When + system.Execute(world); + + // Then: clamped to 5.0 m/s, dt=1.0 => -5.0 on Z + ref LocalTransform lt = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(-5.0f, lt.Position.Z, 0.01f); + } + + [Fact] + public void Execute_NoSwing_DoesNotMove() + { + // Given: hands stationary + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrHandState + { + LeftVelocity = Vector3.Zero, + RightVelocity = Vector3.Zero, + }; + + var system = new VrArmSwingSystem(); + + // When + system.Execute(world); + + // Then + ref LocalTransform lt = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(Vector3.Zero, lt.Position, 0.001f); + } + + [Fact] + public void Execute_UsesHmdForward() + { + // Given: HMD rotated 90deg => forward is -X + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + Quaternion yaw90 = Quaternion.CreateFromAxisAngle(Vector3.UnitY, MathHelper.HalfPi); + world.GetComponent(singleton) = new StereoCamera + { + HmdRotation = yaw90, + }; + world.GetComponent(singleton) = new VrHandState + { + LeftVelocity = new Vector3(0f, -1f, 0f), + RightVelocity = new Vector3(0f, 1f, 0f), + }; + + var system = new VrArmSwingSystem(); + + // When + system.Execute(world); + + // Then: speed = avg(1,1)/2*2=2, forward with 90deg yaw -> -X + ref LocalTransform lt = ref world.GetComponent(player); + Assert.True(lt.Position.X < -0.1f, $"Expected negative X, got {lt.Position.X}"); + } + + [Fact] + public void Execute_MovesOnlyXZ_NoVerticalChange() + { + // Given: player at Y=3 + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(player) = + LocalTransform.FromPosition(new Vector3(0f, 3f, 0f)); + world.GetComponent(singleton) = new VrHandState + { + LeftVelocity = new Vector3(0f, -2f, 0f), + RightVelocity = new Vector3(0f, 2f, 0f), + }; + + var system = new VrArmSwingSystem(); + + // When + system.Execute(world); + + // Then + ref LocalTransform lt = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(3f, lt.Position.Y, 0.001f); + } + + [Fact] + public void Execute_AveragesBothHandSpeeds() + { + // Given: left=1 m/s, right=3 m/s => avg=2, *multiplier(2)=4 + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrHandState + { + LeftVelocity = new Vector3(1f, 0f, 0f), + RightVelocity = new Vector3(3f, 0f, 0f), + }; + + var system = new VrArmSwingSystem(); + + // When + system.Execute(world); + + // Then: speed=4, forward=-Z, dt=1 => Z=-4 + ref LocalTransform lt = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(-4.0f, lt.Position.Z, 0.01f); + } +} diff --git a/src/Seed.Engine.Tests/GameLogic/VrSmoothMoveSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/VrSmoothMoveSystemTests.cs new file mode 100644 index 0000000..3a18e55 --- /dev/null +++ b/src/Seed.Engine.Tests/GameLogic/VrSmoothMoveSystemTests.cs @@ -0,0 +1,319 @@ +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.Rendering; +using Seed.Engine.Tests.Foundation.Mathematics; + +namespace Seed.Engine.Tests.GameLogic; + +public class VrSmoothMoveSystemTests +{ + private static VrLocomotionConfig DefaultConfig() + { + return new VrLocomotionConfig + { + TeleportMaxDistance = 10.0f, + TeleportFadeDuration = 0.15f, + SmoothMoveSpeed = 2.0f, + ArmSwingMaxSpeed = 5.0f, + ArmSwingMultiplier = 2.0f, + }; + } + + private static (World world, Entity singleton, Entity player) SetupWorld() + { + var world = new World(); + + var ftType = ComponentRegistry.Get(); + var vcType = ComponentRegistry.Get(); + var scType = ComponentRegistry.Get(); + var lmType = ComponentRegistry.Get(); + var lcType = ComponentRegistry.Get(); + ReadOnlySpan singletonTypes = [ftType, vcType, scType, lmType, lcType]; + Entity singleton = world.CreateEntity(singletonTypes); + + world.GetComponent(singleton) = new FrameTime { DeltaTime = 1.0f }; + world.GetComponent(singleton) = new VrControllerState(); + world.GetComponent(singleton) = new StereoCamera + { + HmdRotation = Quaternion.Identity, + }; + world.GetComponent(singleton) = new VrLocomotionMode + { + ActiveMode = LocomotionType.SmoothMove, + DirectionSource = 0, + VignetteEnabled = 1, + }; + world.GetComponent(singleton) = DefaultConfig(); + + var ltType = ComponentRegistry.Get(); + var vsType = ComponentRegistry.Get(); + ReadOnlySpan playerTypes = [ltType, vsType]; + Entity player = world.CreateEntity(playerTypes); + + world.GetComponent(player) = + LocalTransform.FromPosition(Vector3.Zero); + world.GetComponent(player) = new VignetteState(); + + return (world, singleton, player); + } + + [Fact] + public void Phase_IsGameLogic() + { + var system = new VrSmoothMoveSystem(); + Assert.Equal(FramePhase.GameLogic, system.Phase); + } + + [Fact] + public void Execute_WrongMode_DoesNothing() + { + // Given + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrLocomotionMode + { + ActiveMode = LocomotionType.Teleport, + }; + world.GetComponent(singleton) = new VrControllerState + { + LeftThumbstick = new Vector2(0f, 1f), + }; + + var system = new VrSmoothMoveSystem(); + + // When + system.Execute(world); + + // Then + ref LocalTransform lt = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(Vector3.Zero, lt.Position, 0.001f); + } + + [Fact] + public void Execute_ForwardStick_MovesNegativeZ() + { + // Given: identity HMD, forward stick (Y=1) + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + LeftThumbstick = new Vector2(0f, 1f), + }; + + var system = new VrSmoothMoveSystem(); + + // When + system.Execute(world); + + // Then: moved -Z at 2.0 m/s * 1.0s = -2.0 + ref LocalTransform lt = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(0f, lt.Position.X, 0.01f); + AssertHelper.ApproximatelyEqual(-2.0f, lt.Position.Z, 0.01f); + } + + [Fact] + public void Execute_RightStick_MovesPositiveX() + { + // Given: identity HMD, right stick (X=1) + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + LeftThumbstick = new Vector2(1f, 0f), + }; + + var system = new VrSmoothMoveSystem(); + + // When + system.Execute(world); + + // Then: moved +X (right of -Z forward) + ref LocalTransform lt = ref world.GetComponent(player); + Assert.True(lt.Position.X > 0f, "Should have moved along +X"); + AssertHelper.ApproximatelyEqual(0f, lt.Position.Z, 0.01f); + } + + [Fact] + public void Execute_NoInput_DoesNotMove() + { + // Given + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + LeftThumbstick = Vector2.Zero, + }; + + var system = new VrSmoothMoveSystem(); + + // When + system.Execute(world); + + // Then + ref LocalTransform lt = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(Vector3.Zero, lt.Position, 0.001f); + } + + [Fact] + public void Execute_HalfStick_HalfSpeed() + { + // Given + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + LeftThumbstick = new Vector2(0f, 0.5f), + }; + + var system = new VrSmoothMoveSystem(); + + // When + system.Execute(world); + + // Then: speed = 2.0 * 0.5 = 1.0 m/s, dt=1 => -1.0 Z + ref LocalTransform lt = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(-1.0f, lt.Position.Z, 0.01f); + } + + [Fact] + public void Execute_VignetteEnabled_SetsTargetIntensity() + { + // Given + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + LeftThumbstick = new Vector2(0f, 1f), + }; + + var system = new VrSmoothMoveSystem(); + + // When + system.Execute(world); + + // Then: intensity = speed / maxSpeed = 2.0/2.0 = 1.0 + ref VignetteState vs = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(1.0f, vs.Intensity, 0.01f); + } + + [Fact] + public void Execute_VignetteDisabled_DoesNotSetIntensity() + { + // Given + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrLocomotionMode + { + ActiveMode = LocomotionType.SmoothMove, + DirectionSource = 0, + VignetteEnabled = 0, + }; + world.GetComponent(singleton) = new VrControllerState + { + LeftThumbstick = new Vector2(0f, 1f), + }; + + var system = new VrSmoothMoveSystem(); + + // When + system.Execute(world); + + // Then + ref VignetteState vs = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(0f, vs.Intensity, 0.01f); + } + + [Fact] + public void Execute_NoInput_VignetteTargetZero() + { + // Given + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(player) = new VignetteState + { + Intensity = 0.8f, + }; + world.GetComponent(singleton) = new VrControllerState + { + LeftThumbstick = Vector2.Zero, + }; + + var system = new VrSmoothMoveSystem(); + + // When + system.Execute(world); + + // Then + ref VignetteState vs = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(0f, vs.Intensity, 0.01f); + } + + [Fact] + public void Execute_ControllerDirectionSource_UsesLeftControllerForward() + { + // Given: DirectionSource=1, left controller rotated 90deg around Y + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrLocomotionMode + { + ActiveMode = LocomotionType.SmoothMove, + DirectionSource = 1, + VignetteEnabled = 0, + }; + + Quaternion yaw90 = Quaternion.CreateFromAxisAngle(Vector3.UnitY, MathHelper.HalfPi); + world.GetComponent(singleton) = new VrControllerState + { + LeftRotation = yaw90, + LeftThumbstick = new Vector2(0f, 1f), + }; + + var system = new VrSmoothMoveSystem(); + + // When + system.Execute(world); + + // Then: forward of 90deg yaw rotated (0,0,-1) = (-1,0,0) => moved -X + ref LocalTransform lt = ref world.GetComponent(player); + Assert.True(lt.Position.X < -0.1f, $"Expected negative X, got {lt.Position.X}"); + AssertHelper.ApproximatelyEqual(0f, lt.Position.Z, 0.1f); + } + + [Fact] + public void Execute_MovesOnlyXZ_NoVerticalChange() + { + // Given: player at Y=5 + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(player) = + LocalTransform.FromPosition(new Vector3(0f, 5f, 0f)); + world.GetComponent(singleton) = new VrControllerState + { + LeftThumbstick = new Vector2(1f, 1f), + }; + + var system = new VrSmoothMoveSystem(); + + // When + system.Execute(world); + + // Then + ref LocalTransform lt = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(5f, lt.Position.Y, 0.001f); + } +} diff --git a/src/Seed.Engine/GameLogic/VrArmSwingSystem.cs b/src/Seed.Engine/GameLogic/VrArmSwingSystem.cs new file mode 100644 index 0000000..1b860e9 --- /dev/null +++ b/src/Seed.Engine/GameLogic/VrArmSwingSystem.cs @@ -0,0 +1,143 @@ +using System; + +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; +using Seed.Engine.Rendering; + +namespace Seed.Engine.GameLogic; + +/// +/// Moves the player forward based on the average speed of both VR hand controllers. +/// Direction is the HMD forward projected onto the XZ plane. +/// Movement speed is clamped to . +/// Runs in the GameLogic phase. +/// +public sealed class VrArmSwingSystem : ISystem +{ + private readonly QueryDescription _singletonQuery; + private readonly QueryDescription _playerQuery; + + /// + /// Initializes a new . + /// + public VrArmSwingSystem() + { + _singletonQuery = new QueryBuilder() + .WithRead() + .WithRead() + .WithRead() + .WithRead() + .WithRead() + .Build(); + + _playerQuery = new QueryBuilder() + .WithWrite() + .Build(); + } + + /// + public FramePhase Phase => FramePhase.GameLogic; + + /// + public QueryDescription GetQuery() => _playerQuery; + + /// + public void Execute(World world) + { + float dt = 0f; + VrHandState handState = default; + StereoCamera stereo = default; + VrLocomotionMode mode = default; + VrLocomotionConfig config = 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 ftIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int hsIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int scIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int lmIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int lcIdx = 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; + handState = chunk.GetComponent(hsIdx, 0); + stereo = chunk.GetComponent(scIdx, 0); + mode = chunk.GetComponent(lmIdx, 0); + config = chunk.GetComponent(lcIdx, 0); + foundSingleton = true; + break; + } + } + + if (foundSingleton) + { + break; + } + } + + if (!foundSingleton) + { + return; + } + + if (mode.ActiveMode != LocomotionType.ArmSwing) + { + return; + } + + float leftSpeed = handState.LeftVelocity.Length; + float rightSpeed = handState.RightVelocity.Length; + float avgSpeed = (leftSpeed + rightSpeed) * 0.5f; + float moveSpeed = MathHelper.Clamp( + avgSpeed * config.ArmSwingMultiplier, 0f, config.ArmSwingMaxSpeed); + + Vector3 forward = VrLocomotionHelper.ComputeForwardXZ(stereo.HmdRotation); + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_playerQuery.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); + + Vector3 displacement = forward * (moveSpeed * dt); + lt.Position = new Vector3( + lt.Position.X + displacement.X, + lt.Position.Y, + lt.Position.Z + displacement.Z); + } + } + } + } + +} diff --git a/src/Seed.Engine/GameLogic/VrLocomotionHelper.cs b/src/Seed.Engine/GameLogic/VrLocomotionHelper.cs new file mode 100644 index 0000000..60451cf --- /dev/null +++ b/src/Seed.Engine/GameLogic/VrLocomotionHelper.cs @@ -0,0 +1,27 @@ +using Seed.Engine.Foundation.Mathematics; + +namespace Seed.Engine.GameLogic; + +/// +/// Shared helpers for VR locomotion systems. +/// +internal static class VrLocomotionHelper +{ + /// + /// Projects a quaternion's forward direction (-Z) onto the XZ plane and normalises it. + /// Returns (0, 0, -1) when the projection is near-zero (e.g. looking straight up/down). + /// + internal static Vector3 ComputeForwardXZ(Quaternion rotation) + { + Vector3 forward = rotation.RotateVector(new Vector3(0f, 0f, -1f)); + Vector3 flatForward = new Vector3(forward.X, 0f, forward.Z); + float len = flatForward.Length; + + if (len < 0.001f) + { + return new Vector3(0f, 0f, -1f); + } + + return flatForward / len; + } +} diff --git a/src/Seed.Engine/GameLogic/VrSmoothMoveSystem.cs b/src/Seed.Engine/GameLogic/VrSmoothMoveSystem.cs new file mode 100644 index 0000000..dbe39f4 --- /dev/null +++ b/src/Seed.Engine/GameLogic/VrSmoothMoveSystem.cs @@ -0,0 +1,191 @@ +using System; + +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; +using Seed.Engine.Rendering; + +namespace Seed.Engine.GameLogic; + +/// +/// Applies thumbstick-driven smooth locomotion to entities with VR locomotion components. +/// Movement direction is determined by either the HMD forward or the left controller forward, +/// projected onto the XZ plane. Updates vignette intensity when the comfort vignette is enabled. +/// Runs in the GameLogic phase. +/// +public sealed class VrSmoothMoveSystem : ISystem +{ + private readonly QueryDescription _singletonQuery; + private readonly QueryDescription _playerQuery; + + /// + /// Initializes a new . + /// + public VrSmoothMoveSystem() + { + _singletonQuery = new QueryBuilder() + .WithRead() + .WithRead() + .WithRead() + .WithRead() + .WithRead() + .Build(); + + _playerQuery = new QueryBuilder() + .WithWrite() + .WithWrite() + .Build(); + } + + /// + public FramePhase Phase => FramePhase.GameLogic; + + /// + public QueryDescription GetQuery() => _playerQuery; + + /// + public void Execute(World world) + { + float dt = 0f; + VrControllerState controller = default; + StereoCamera stereo = default; + VrLocomotionMode mode = default; + VrLocomotionConfig config = 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 ftIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int vcIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int scIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int lmIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int lcIdx = 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; + controller = chunk.GetComponent(vcIdx, 0); + stereo = chunk.GetComponent(scIdx, 0); + mode = chunk.GetComponent(lmIdx, 0); + config = chunk.GetComponent(lcIdx, 0); + foundSingleton = true; + break; + } + } + + if (foundSingleton) + { + break; + } + } + + if (!foundSingleton) + { + return; + } + + if (mode.ActiveMode != LocomotionType.SmoothMove) + { + return; + } + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_playerQuery.Matches(storage.Archetype)) + { + continue; + } + + int ltIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int vsIdx = 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 VignetteState vignette = ref chunk.GetComponent(vsIdx, i); + + UpdateSmoothMove( + ref lt, ref vignette, + in controller, in stereo, in mode, in config, dt); + } + } + } + } + + private static void UpdateSmoothMove( + ref LocalTransform lt, + ref VignetteState vignette, + in VrControllerState controller, + in StereoCamera stereo, + in VrLocomotionMode mode, + in VrLocomotionConfig config, + float dt) + { + float stickX = controller.LeftThumbstick.X; + float stickY = controller.LeftThumbstick.Y; + float magnitude = MathF.Sqrt(stickX * stickX + stickY * stickY); + + if (magnitude < 0.01f) + { + vignette.Intensity = 0f; + return; + } + + Vector3 forward = ComputeForwardXZ(in mode, in stereo, in controller); + Vector3 right = new Vector3(-forward.Z, 0f, forward.X); + + Vector3 moveDir = forward * stickY + right * stickX; + float moveDirLen = moveDir.Length; + if (moveDirLen > 0.001f) + { + moveDir = moveDir / moveDirLen; + } + + float speed = config.SmoothMoveSpeed * magnitude; + Vector3 displacement = moveDir * (speed * dt); + + lt.Position = new Vector3( + lt.Position.X + displacement.X, + lt.Position.Y, + lt.Position.Z + displacement.Z); + + if (mode.VignetteEnabled == 1) + { + vignette.Intensity = speed / config.SmoothMoveSpeed; + } + } + + private static Vector3 ComputeForwardXZ( + in VrLocomotionMode mode, + in StereoCamera stereo, + in VrControllerState controller) + { + Quaternion rotation = mode.DirectionSource == 0 + ? stereo.HmdRotation + : controller.LeftRotation; + + return VrLocomotionHelper.ComputeForwardXZ(rotation); + } +} From e37d67cc581b8bdf38e46e0dfebbc546ee65160f Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 21:54:28 +0900 Subject: [PATCH 04/24] =?UTF-8?q?VR=20=E3=83=86=E3=83=AC=E3=83=9D=E3=83=BC?= =?UTF-8?q?=E3=83=88=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 放物線アーク計算、Y=0着地判定、フェード遷移(0.15s)、 スティック方向選択によるテレポート移動を追加。 Co-Authored-By: Claude Opus 4.6 --- .../GameLogic/VrTeleportSystemTests.cs | 423 ++++++++++++++++++ src/Seed.Engine/GameLogic/VrTeleportSystem.cs | 291 ++++++++++++ 2 files changed, 714 insertions(+) create mode 100644 src/Seed.Engine.Tests/GameLogic/VrTeleportSystemTests.cs create mode 100644 src/Seed.Engine/GameLogic/VrTeleportSystem.cs diff --git a/src/Seed.Engine.Tests/GameLogic/VrTeleportSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/VrTeleportSystemTests.cs new file mode 100644 index 0000000..77bd3d1 --- /dev/null +++ b/src/Seed.Engine.Tests/GameLogic/VrTeleportSystemTests.cs @@ -0,0 +1,423 @@ +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 VrTeleportSystemTests +{ + private static VrLocomotionConfig DefaultConfig() + { + return new VrLocomotionConfig + { + TeleportMaxDistance = 10.0f, + TeleportFadeDuration = 0.15f, + SmoothMoveSpeed = 2.0f, + ArmSwingMaxSpeed = 5.0f, + ArmSwingMultiplier = 2.0f, + }; + } + + private static (World world, Entity singleton, Entity player) SetupWorld() + { + var world = new World(); + + var ftType = ComponentRegistry.Get(); + var vcType = ComponentRegistry.Get(); + var lmType = ComponentRegistry.Get(); + var lcType = ComponentRegistry.Get(); + ReadOnlySpan singletonTypes = [ftType, vcType, lmType, lcType]; + Entity singleton = world.CreateEntity(singletonTypes); + + world.GetComponent(singleton) = new FrameTime { DeltaTime = 1f / 90f }; + world.GetComponent(singleton) = new VrControllerState + { + RightPosition = new Vector3(0f, 1.5f, 0f), + RightRotation = Quaternion.Identity, + }; + world.GetComponent(singleton) = new VrLocomotionMode + { + ActiveMode = LocomotionType.Teleport, + }; + world.GetComponent(singleton) = DefaultConfig(); + + var ltType = ComponentRegistry.Get(); + var tsType = ComponentRegistry.Get(); + ReadOnlySpan playerTypes = [ltType, tsType]; + Entity player = world.CreateEntity(playerTypes); + + world.GetComponent(player) = + LocalTransform.FromPosition(Vector3.Zero); + world.GetComponent(player) = new TeleportState(); + + return (world, singleton, player); + } + + [Fact] + public void Phase_IsGameLogic() + { + var system = new VrTeleportSystem(); + Assert.Equal(FramePhase.GameLogic, system.Phase); + } + + [Fact] + public void Execute_WrongMode_DoesNothing() + { + // Given + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrLocomotionMode + { + ActiveMode = LocomotionType.SmoothMove, + }; + world.GetComponent(singleton) = new VrControllerState + { + RightThumbstickClick = 1, + }; + + var system = new VrTeleportSystem(); + + // When + system.Execute(world); + + // Then + ref TeleportState ts = ref world.GetComponent(player); + Assert.Equal(TeleportPhase.Idle, ts.Phase); + } + + [Fact] + public void Execute_ThumbstickClick_TransitionsToAiming() + { + // Given + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + RightThumbstickClick = 1, + RightPosition = new Vector3(0f, 1.5f, 0f), + RightRotation = Quaternion.Identity, + }; + + var system = new VrTeleportSystem(); + + // When + system.Execute(world); + + // Then + ref TeleportState ts = ref world.GetComponent(player); + Assert.Equal(TeleportPhase.Aiming, ts.Phase); + } + + [Fact] + public void Execute_Aiming_ComputesTargetPosition() + { + // Given: already aiming, controller pointing slightly down from above ground + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(player) = new TeleportState + { + Phase = TeleportPhase.Aiming, + }; + + // Controller at Y=1.5, pointing slightly downward + Quaternion tilt = Quaternion.CreateFromAxisAngle( + Vector3.UnitX, 0.3f); + world.GetComponent(singleton) = new VrControllerState + { + RightThumbstickClick = 1, + RightPosition = new Vector3(0f, 1.5f, 0f), + RightRotation = tilt, + }; + + var system = new VrTeleportSystem(); + + // When + system.Execute(world); + + // Then: should have found a ground intersection + ref TeleportState ts = ref world.GetComponent(player); + Assert.Equal(TeleportPhase.Aiming, ts.Phase); + Assert.Equal((byte)1, ts.IsTargetValid); + AssertHelper.ApproximatelyEqual(0f, ts.TargetPosition.Y, 0.01f); + } + + [Fact] + public void Execute_Aiming_ReleaseWithValidTarget_TransitionsToFadingOut() + { + // Given: aiming with valid target, then release + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(player) = new TeleportState + { + Phase = TeleportPhase.Aiming, + IsTargetValid = 1, + TargetPosition = new Vector3(3f, 0f, -5f), + }; + + world.GetComponent(singleton) = new VrControllerState + { + RightThumbstickClick = 0, + RightPosition = new Vector3(0f, 1.5f, 0f), + RightRotation = Quaternion.Identity, + }; + + var system = new VrTeleportSystem(); + + // When + system.Execute(world); + + // Then + ref TeleportState ts = ref world.GetComponent(player); + Assert.Equal(TeleportPhase.FadingOut, ts.Phase); + AssertHelper.ApproximatelyEqual(0f, ts.FadeElapsed, 0.001f); + } + + [Fact] + public void Execute_Aiming_ReleaseWithInvalidTarget_ReturnsToIdle() + { + // Given: aiming, controller pointing straight up from below ground so arc misses + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(player) = new TeleportState + { + Phase = TeleportPhase.Aiming, + IsTargetValid = 0, + }; + + // Controller below ground pointing down => arc will not cross Y=0 from above + Quaternion pointDown = Quaternion.CreateFromAxisAngle( + Vector3.UnitX, -MathHelper.HalfPi); + world.GetComponent(singleton) = new VrControllerState + { + RightThumbstickClick = 0, + RightPosition = new Vector3(0f, -2f, 0f), + RightRotation = pointDown, + }; + + var system = new VrTeleportSystem(); + + // When + system.Execute(world); + + // Then + ref TeleportState ts = ref world.GetComponent(player); + Assert.Equal(TeleportPhase.Idle, ts.Phase); + } + + [Fact] + public void Execute_FadingOut_Elapsed_MovesPlayer() + { + // Given: fade-out about to complete + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + Vector3 target = new Vector3(3f, 0f, -5f); + world.GetComponent(player) = new TeleportState + { + Phase = TeleportPhase.FadingOut, + TargetPosition = target, + FadeElapsed = 0.14f, + }; + + world.GetComponent(singleton) = new FrameTime + { + DeltaTime = 0.02f, + }; + + var system = new VrTeleportSystem(); + + // When + system.Execute(world); + + // Then: 0.14 + 0.02 >= 0.15 fade duration => position updated + ref LocalTransform lt = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(target, lt.Position, 0.01f); + + ref TeleportState ts = ref world.GetComponent(player); + Assert.Equal(TeleportPhase.FadingIn, ts.Phase); + } + + [Fact] + public void Execute_FadingIn_Elapsed_ReturnsToIdle() + { + // Given: fade-in about to complete + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(player) = new TeleportState + { + Phase = TeleportPhase.FadingIn, + FadeElapsed = 0.14f, + }; + + world.GetComponent(singleton) = new FrameTime + { + DeltaTime = 0.02f, + }; + + var system = new VrTeleportSystem(); + + // When + system.Execute(world); + + // Then + ref TeleportState ts = ref world.GetComponent(player); + Assert.Equal(TeleportPhase.Idle, ts.Phase); + } + + [Fact] + public void Execute_FadingOut_NotElapsed_StaysInFadingOut() + { + // Given + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(player) = new TeleportState + { + Phase = TeleportPhase.FadingOut, + FadeElapsed = 0.05f, + TargetPosition = new Vector3(5f, 0f, 0f), + }; + + world.GetComponent(singleton) = new FrameTime + { + DeltaTime = 0.02f, + }; + + var system = new VrTeleportSystem(); + + // When + system.Execute(world); + + // Then: 0.05 + 0.02 = 0.07 < 0.15 + ref TeleportState ts = ref world.GetComponent(player); + Assert.Equal(TeleportPhase.FadingOut, ts.Phase); + + ref LocalTransform lt = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(Vector3.Zero, lt.Position, 0.001f); + } + + [Fact] + public void Execute_StickDirection_SetsTargetYaw() + { + // Given: aiming with stick pushed right + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(player) = new TeleportState + { + Phase = TeleportPhase.Aiming, + }; + + world.GetComponent(singleton) = new VrControllerState + { + RightThumbstickClick = 1, + RightThumbstick = new Vector2(1f, 0f), + RightPosition = new Vector3(0f, 1.5f, 0f), + RightRotation = Quaternion.Identity, + }; + + var system = new VrTeleportSystem(); + + // When + system.Execute(world); + + // Then: TargetYaw = atan2(1,0) = pi/2 + ref TeleportState ts = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(MathHelper.HalfPi, ts.TargetYaw, 0.01f); + } + + [Fact] + public void Execute_FadingOut_Elapsed_AppliesTargetYawToRotation() + { + // Given: fade-out about to complete with a non-zero TargetYaw + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + float yaw = MathHelper.HalfPi; + world.GetComponent(player) = new TeleportState + { + Phase = TeleportPhase.FadingOut, + TargetPosition = new Vector3(3f, 0f, -5f), + TargetYaw = yaw, + FadeElapsed = 0.14f, + }; + + world.GetComponent(singleton) = new FrameTime + { + DeltaTime = 0.02f, + }; + + var system = new VrTeleportSystem(); + + // When + system.Execute(world); + + // Then: Rotation should match TargetYaw around Y axis + ref LocalTransform lt = ref world.GetComponent(player); + Quaternion expectedRot = Quaternion.CreateFromAxisAngle(Vector3.UnitY, yaw); + AssertHelper.ApproximatelyEqual(expectedRot, lt.Rotation, 0.01f); + } + + [Fact] + public void ComputeArc_FromAboveGround_HitsGround() + { + // Given: origin at Y=2, pointing forward-down + Vector3 origin = new Vector3(0f, 2f, 0f); + Vector3 forward = new Vector3(0f, 0f, -1f); + + // When + VrTeleportSystem.ComputeArc( + origin, forward, 10f, + out bool hitGround, out Vector3 hitPos); + + // Then + Assert.True(hitGround); + AssertHelper.ApproximatelyEqual(0f, hitPos.Y, 0.01f); + Assert.True(hitPos.Z < 0f, "Hit should be ahead"); + } + + [Fact] + public void ComputeArc_PointingStraightUp_DoesNotHit() + { + // Given: origin at Y=0, pointing straight up (will arc up and then land back) + // From Y=0 pointing up, the arc starts going up then comes back down + Vector3 origin = new Vector3(0f, 0f, 0f); + Vector3 forward = new Vector3(0f, 1f, 0f); + + // When + VrTeleportSystem.ComputeArc( + origin, forward, 10f, + out bool hitGround, out Vector3 hitPos); + + // Then: from Y=0 going straight up, it will come back to Y=0 + // The arc will go up and then cross Y=0 coming back down + Assert.True(hitGround); + } + + [Fact] + public void ComputeArc_FromBelowGround_DoesNotHit() + { + // Given: origin below ground pointing further down + Vector3 origin = new Vector3(0f, -1f, 0f); + Vector3 forward = new Vector3(0f, -1f, -1f).Normalize(); + + // When + VrTeleportSystem.ComputeArc( + origin, forward, 10f, + out bool hitGround, out Vector3 hitPos); + + // Then: starts below Y=0, going further down -> no ground hit + Assert.False(hitGround); + } +} diff --git a/src/Seed.Engine/GameLogic/VrTeleportSystem.cs b/src/Seed.Engine/GameLogic/VrTeleportSystem.cs new file mode 100644 index 0000000..65f34d0 --- /dev/null +++ b/src/Seed.Engine/GameLogic/VrTeleportSystem.cs @@ -0,0 +1,291 @@ +using System; + +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; + +namespace Seed.Engine.GameLogic; + +/// +/// Implements point-and-teleport locomotion for VR. +/// Uses a parabolic arc for aiming, validates landing on the Y=0 ground plane, +/// and performs a fade-out / fade-in transition when teleporting. +/// Runs in the GameLogic phase. +/// +public sealed class VrTeleportSystem : ISystem +{ + private const float Gravity = -9.81f; + private const float ArcTimeStep = 0.02f; + private const int MaxArcSteps = 200; + + private readonly QueryDescription _singletonQuery; + private readonly QueryDescription _playerQuery; + + /// + /// Initializes a new . + /// + public VrTeleportSystem() + { + _singletonQuery = new QueryBuilder() + .WithRead() + .WithRead() + .WithRead() + .WithRead() + .Build(); + + _playerQuery = new QueryBuilder() + .WithWrite() + .WithWrite() + .Build(); + } + + /// + public FramePhase Phase => FramePhase.GameLogic; + + /// + public QueryDescription GetQuery() => _playerQuery; + + /// + public void Execute(World world) + { + float dt = 0f; + VrControllerState controller = default; + VrLocomotionMode mode = default; + VrLocomotionConfig config = 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 ftIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int vcIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int lmIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int lcIdx = 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; + controller = chunk.GetComponent(vcIdx, 0); + mode = chunk.GetComponent(lmIdx, 0); + config = chunk.GetComponent(lcIdx, 0); + foundSingleton = true; + break; + } + } + + if (foundSingleton) + { + break; + } + } + + if (!foundSingleton) + { + return; + } + + if (mode.ActiveMode != LocomotionType.Teleport) + { + return; + } + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_playerQuery.Matches(storage.Archetype)) + { + continue; + } + + int ltIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int tsIdx = 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 TeleportState ts = ref chunk.GetComponent(tsIdx, i); + + UpdateTeleport(ref lt, ref ts, in controller, in config, dt); + } + } + } + } + + private static void UpdateTeleport( + ref LocalTransform lt, + ref TeleportState ts, + in VrControllerState controller, + in VrLocomotionConfig config, + float dt) + { + switch (ts.Phase) + { + case TeleportPhase.Idle: + HandleIdle(ref ts, in controller); + break; + case TeleportPhase.Aiming: + HandleAiming(ref ts, ref lt, in controller, in config); + break; + case TeleportPhase.FadingOut: + HandleFadingOut(ref ts, ref lt, in config, dt); + break; + case TeleportPhase.FadingIn: + HandleFadingIn(ref ts, in config, dt); + break; + } + } + + private static void HandleIdle( + ref TeleportState ts, + in VrControllerState controller) + { + if (controller.RightThumbstickClick == 1) + { + ts.Phase = TeleportPhase.Aiming; + ts.IsTargetValid = 0; + } + } + + private static void HandleAiming( + ref TeleportState ts, + ref LocalTransform lt, + in VrControllerState controller, + in VrLocomotionConfig config) + { + Vector3 origin = controller.RightPosition; + Vector3 forward = controller.RightRotation.RotateVector( + new Vector3(0f, 0f, -1f)); + + ComputeArc( + origin, forward, config.TeleportMaxDistance, + out bool hitGround, out Vector3 hitPos); + + ts.IsTargetValid = hitGround ? (byte)1 : (byte)0; + ts.TargetPosition = hitPos; + + float stickX = controller.RightThumbstick.X; + float stickY = controller.RightThumbstick.Y; + float stickMag = MathF.Sqrt(stickX * stickX + stickY * stickY); + if (stickMag > 0.5f) + { + ts.TargetYaw = MathF.Atan2(stickX, stickY); + } + + if (controller.RightThumbstickClick == 0) + { + if (ts.IsTargetValid == 1) + { + ts.Phase = TeleportPhase.FadingOut; + ts.FadeElapsed = 0f; + } + else + { + ts.Phase = TeleportPhase.Idle; + } + } + } + + private static void HandleFadingOut( + ref TeleportState ts, + ref LocalTransform lt, + in VrLocomotionConfig config, + float dt) + { + ts.FadeElapsed += dt; + + if (ts.FadeElapsed >= config.TeleportFadeDuration) + { + lt.Position = ts.TargetPosition; + lt.Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, ts.TargetYaw); + ts.Phase = TeleportPhase.FadingIn; + ts.FadeElapsed = 0f; + } + } + + private static void HandleFadingIn( + ref TeleportState ts, + in VrLocomotionConfig config, + float dt) + { + ts.FadeElapsed += dt; + + if (ts.FadeElapsed >= config.TeleportFadeDuration) + { + ts.Phase = TeleportPhase.Idle; + } + } + + /// + /// Simulates a parabolic arc from in the direction + /// . Detects intersection with the Y=0 ground plane. + /// + internal static void ComputeArc( + Vector3 origin, + Vector3 forward, + float maxDistance, + out bool hitGround, + out Vector3 hitPosition) + { + hitGround = false; + hitPosition = origin; + + float speed = maxDistance; + Vector3 velocity = forward * speed; + + Vector3 prevPos = origin; + float totalDist = 0f; + + for (int step = 0; step < MaxArcSteps; step++) + { + velocity = new Vector3( + velocity.X, + velocity.Y + Gravity * ArcTimeStep, + velocity.Z); + + Vector3 nextPos = new Vector3( + prevPos.X + velocity.X * ArcTimeStep, + prevPos.Y + velocity.Y * ArcTimeStep, + prevPos.Z + velocity.Z * ArcTimeStep); + + if (prevPos.Y >= 0f && nextPos.Y < 0f) + { + float t = prevPos.Y / (prevPos.Y - nextPos.Y); + hitPosition = new Vector3( + prevPos.X + (nextPos.X - prevPos.X) * t, + 0f, + prevPos.Z + (nextPos.Z - prevPos.Z) * t); + hitGround = true; + return; + } + + Vector3 delta = nextPos - prevPos; + totalDist += delta.Length; + + if (totalDist > maxDistance * 2f) + { + return; + } + + prevPos = nextPos; + } + } +} From 7be92015749ecccd30f01af5239504efefa23755 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 21:55:28 +0900 Subject: [PATCH 05/24] =?UTF-8?q?roadmap.md=20v0.13=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 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 32681b9..f9fd352 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -196,9 +196,9 @@ ゴール: VR特有の移動方式を揃える -- [ ] テレポート (アーク表示、ナビメッシュ判定、フェード遷移、方向選択) -- [ ] スムース移動 (左スティック、ビネットエフェクト) -- [ ] アームスイング (コントローラー振り動作、最大5.0m/s) +- [x] テレポート (アーク表示、ナビメッシュ判定、フェード遷移、方向選択) +- [x] スムース移動 (左スティック、ビネットエフェクト) +- [x] アームスイング (コントローラー振り動作、最大5.0m/s) 完動品としての価値: VRで3方式の移動が選べる。酔い対策も入っている。 From f5b3705322b3cb5a80cdfe294f659116bf6e8453 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 8 Mar 2026 18:33:04 +0900 Subject: [PATCH 06/24] =?UTF-8?q?Forward+=20=E3=82=B7=E3=82=A7=E3=83=BC?= =?UTF-8?q?=E3=83=80=E3=83=BC=E3=81=AE=E5=9F=8B=E3=82=81=E8=BE=BC=E3=81=BF?= =?UTF-8?q?=E3=83=AA=E3=82=BD=E3=83=BC=E3=82=B9=E5=90=8D=E3=82=92=E5=AE=9F?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E5=90=8D=E3=81=AB=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.App/Program.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Seed.Engine.App/Program.cs b/src/Seed.Engine.App/Program.cs index 1f43435..33918fb 100644 --- a/src/Seed.Engine.App/Program.cs +++ b/src/Seed.Engine.App/Program.cs @@ -943,19 +943,19 @@ private static void Main() // Forward+ shaders var depthPrePassVert = LoadEmbeddedShader( - "Seed.Engine.Shaders.depth_prepass.vert.spv"); + "Seed.Engine.Shaders.fp_depth.vert.spv"); var depthPrePassFrag = LoadEmbeddedShader( - "Seed.Engine.Shaders.depth_prepass.frag.spv"); + "Seed.Engine.Shaders.fp_depth.frag.spv"); var lightCullingComp = LoadEmbeddedShader( - "Seed.Engine.Shaders.light_culling.comp.spv"); + "Seed.Engine.Shaders.fp_lightcull.comp.spv"); var fwdOpaqueVert = LoadEmbeddedShader( - "Seed.Engine.Shaders.forward_opaque.vert.spv"); + "Seed.Engine.Shaders.fp_opaque.vert.spv"); var fwdOpaqueFrag = LoadEmbeddedShader( - "Seed.Engine.Shaders.forward_opaque.frag.spv"); + "Seed.Engine.Shaders.fp_opaque.frag.spv"); var fwdTransparentVert = LoadEmbeddedShader( - "Seed.Engine.Shaders.forward_transparent.vert.spv"); + "Seed.Engine.Shaders.fp_transparent.vert.spv"); var fwdTransparentFrag = LoadEmbeddedShader( - "Seed.Engine.Shaders.forward_transparent.frag.spv"); + "Seed.Engine.Shaders.fp_transparent.frag.spv"); if (depthPrePassVert.IsFailure || depthPrePassFrag.IsFailure || lightCullingComp.IsFailure From 1b5884c4f6c17f86186fe4dd781f018cdea29736 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 8 Mar 2026 18:58:30 +0900 Subject: [PATCH 07/24] =?UTF-8?q?Forward+=20=E3=82=B3=E3=83=B3=E3=83=9D?= =?UTF-8?q?=E3=82=B8=E3=83=83=E3=83=88=E3=83=91=E3=82=B9=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E3=81=97=20VR=20=E3=82=AA=E3=83=91=E3=83=83=E3=82=AF?= =?UTF-8?q?=E6=8F=8F=E7=94=BB=E7=B5=90=E6=9E=9C=E3=82=92=E8=A1=A8=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MSAA Resolve をトランスペアレントパスの前に移動し、 Resolve テクスチャをコンポジットシェーダーでサンプリングして スカイグラデーションとアルファ合成するよう修正。 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine.App/Program.cs | 49 ++++++++++++++++-- .../Rendering/PipelineSelectionTests.cs | 4 ++ .../StereoForwardPlusRenderSystemTests.cs | 4 ++ .../Rendering/ForwardPlusRenderSystem.cs | 12 +++-- .../StereoForwardPlusRenderSystem.cs | 28 +++++++--- .../Shaders/fp_transparent.frag.glsl | 8 ++- .../Shaders/fp_transparent.frag.spv | Bin 920 -> 1340 bytes 7 files changed, 89 insertions(+), 16 deletions(-) diff --git a/src/Seed.Engine.App/Program.cs b/src/Seed.Engine.App/Program.cs index 33918fb..6ff643c 100644 --- a/src/Seed.Engine.App/Program.cs +++ b/src/Seed.Engine.App/Program.cs @@ -916,11 +916,11 @@ private static void Main() using var rightOpaqueTarget = rightOpaqueResult.Value; - // Resolve textures for MSAA + // Resolve textures for MSAA (Sampled so the composite pass can read them) var leftResolveResult = device.CreateTexture( xrWidth, xrHeight, GraphicsFormat.R8G8B8A8Unorm, - TextureUsage.ColorAttachment); + TextureUsage.ColorAttachment | TextureUsage.Sampled); if (leftResolveResult.IsFailure) { Console.Error.WriteLine("Failed to create left resolve texture"); @@ -932,7 +932,7 @@ private static void Main() var rightResolveResult = device.CreateTexture( xrWidth, xrHeight, GraphicsFormat.R8G8B8A8Unorm, - TextureUsage.ColorAttachment); + TextureUsage.ColorAttachment | TextureUsage.Sampled); if (rightResolveResult.IsFailure) { Console.Error.WriteLine("Failed to create right resolve texture"); @@ -1125,6 +1125,45 @@ private static void Main() using var rightOpaqueDescSet = rightOpaqueDescResult.Value; + // Transparent descriptor sets (composite pass samples from resolve textures) + var leftTransDescResult = device.CreateDescriptorSet( + new DescriptorBinding[] + { + new(0, DescriptorType.CombinedImageSampler, + ShaderStage.Fragment), + }, + new DescriptorWrite[] + { + DescriptorWrite.ForTexture(0, leftResolveTexture), + }); + if (leftTransDescResult.IsFailure) + { + Console.Error.WriteLine( + "Failed to create left transparent descriptor"); + return; + } + + using var leftTransDesc = leftTransDescResult.Value; + + var rightTransDescResult = device.CreateDescriptorSet( + new DescriptorBinding[] + { + new(0, DescriptorType.CombinedImageSampler, + ShaderStage.Fragment), + }, + new DescriptorWrite[] + { + DescriptorWrite.ForTexture(0, rightResolveTexture), + }); + if (rightTransDescResult.IsFailure) + { + Console.Error.WriteLine( + "Failed to create right transparent descriptor"); + return; + } + + using var rightTransDesc = rightTransDescResult.Value; + // Transparent pipeline var stereoTransparentPipelineDesc = new PipelineDescription( new ShaderDescription( @@ -1135,7 +1174,7 @@ private static void Main() null, null, 1, - null, + new IDescriptorSet[] { leftTransDesc }, null, false, null, @@ -1172,6 +1211,8 @@ private static void Main() rightLcDesc, leftOpaqueDescSet, rightOpaqueDescSet, + leftTransDesc, + rightTransDesc, resources, inFlightFence, 0.1f, diff --git a/src/Seed.Engine.Tests/Rendering/PipelineSelectionTests.cs b/src/Seed.Engine.Tests/Rendering/PipelineSelectionTests.cs index daee852..8fc355b 100644 --- a/src/Seed.Engine.Tests/Rendering/PipelineSelectionTests.cs +++ b/src/Seed.Engine.Tests/Rendering/PipelineSelectionTests.cs @@ -172,6 +172,8 @@ private static StereoForwardPlusRenderSystem CreateStereoForwardPlusSystem( var rightLcDesc = new StubDescriptorSet(); var leftOpaqueDesc = new StubDescriptorSet(); var rightOpaqueDesc = new StubDescriptorSet(); + var leftTransDesc = new StubDescriptorSet(); + var rightTransDesc = new StubDescriptorSet(); var leftResolve = new StubTexture( 1920, 1080, GraphicsFormat.R8G8B8A8Unorm); var rightResolve = new StubTexture( @@ -195,6 +197,8 @@ private static StereoForwardPlusRenderSystem CreateStereoForwardPlusSystem( rightLcDesc, leftOpaqueDesc, rightOpaqueDesc, + leftTransDesc, + rightTransDesc, resources, System.IntPtr.Zero, 0.1f, diff --git a/src/Seed.Engine.Tests/Rendering/StereoForwardPlusRenderSystemTests.cs b/src/Seed.Engine.Tests/Rendering/StereoForwardPlusRenderSystemTests.cs index afce21f..1afcde4 100644 --- a/src/Seed.Engine.Tests/Rendering/StereoForwardPlusRenderSystemTests.cs +++ b/src/Seed.Engine.Tests/Rendering/StereoForwardPlusRenderSystemTests.cs @@ -237,6 +237,8 @@ private static StereoForwardPlusRenderSystem CreateSystem( var rightLightCullingDesc = new StubDescriptorSet(); var leftOpaqueDesc = new StubDescriptorSet(); var rightOpaqueDesc = new StubDescriptorSet(); + var leftTransDesc = new StubDescriptorSet(); + var rightTransDesc = new StubDescriptorSet(); var resources = new RenderResources(device); var leftResolve = new StubTexture(1920, 1080, GraphicsFormat.R8G8B8A8Unorm); var rightResolve = new StubTexture( @@ -260,6 +262,8 @@ private static StereoForwardPlusRenderSystem CreateSystem( rightLightCullingDesc, leftOpaqueDesc, rightOpaqueDesc, + leftTransDesc, + rightTransDesc, resources, IntPtr.Zero, 0.1f, diff --git a/src/Seed.Engine/Rendering/ForwardPlusRenderSystem.cs b/src/Seed.Engine/Rendering/ForwardPlusRenderSystem.cs index a7161f1..19fcab2 100644 --- a/src/Seed.Engine/Rendering/ForwardPlusRenderSystem.cs +++ b/src/Seed.Engine/Rendering/ForwardPlusRenderSystem.cs @@ -26,6 +26,7 @@ public sealed class ForwardPlusRenderSystem : ISystem private readonly IPipeline _lightCullingPipeline; private readonly IDescriptorSet _lightCullingDescriptorSet; private readonly IDescriptorSet _opaqueDescriptorSet; + private readonly IDescriptorSet _transparentDescriptorSet; private readonly RenderResources _resources; private readonly IntPtr _imageAvailableSemaphore; private readonly IntPtr _renderFinishedSemaphore; @@ -51,6 +52,7 @@ public ForwardPlusRenderSystem( IPipeline lightCullingPipeline, IDescriptorSet lightCullingDescriptorSet, IDescriptorSet opaqueDescriptorSet, + IDescriptorSet transparentDescriptorSet, RenderResources resources, IntPtr imageAvailableSemaphore, IntPtr renderFinishedSemaphore, @@ -68,6 +70,7 @@ public ForwardPlusRenderSystem( _lightCullingPipeline = lightCullingPipeline; _lightCullingDescriptorSet = lightCullingDescriptorSet; _opaqueDescriptorSet = opaqueDescriptorSet; + _transparentDescriptorSet = transparentDescriptorSet; _resources = resources; _imageAvailableSemaphore = imageAvailableSemaphore; _renderFinishedSemaphore = renderFinishedSemaphore; @@ -129,13 +132,14 @@ public void Execute(World world) ExecuteOpaquePass(world, viewProjection); _commandBuffer.PipelineBarrier(); - ExecuteTransparentPass(world, imageIndex, viewProjection); - _commandBuffer.ResolveImage( _opaqueTarget.GetColorAttachment(0), _resolveTexture, _opaqueTarget.Width, _opaqueTarget.Height); + _commandBuffer.PipelineBarrier(); + + ExecuteTransparentPass(world, imageIndex, viewProjection); var endResult = _commandBuffer.End(); if (endResult.IsFailure) @@ -218,7 +222,7 @@ private void ExecuteLightCulling(Matrix4x4 viewProjection) private void ExecuteOpaquePass(World world, Matrix4x4 viewProjection) { - _commandBuffer.BeginRenderPass(_opaqueTarget, 0f, 0f, 0f, 1f); + _commandBuffer.BeginRenderPass(_opaqueTarget, 0f, 0f, 0f, 0f); _commandBuffer.BindPipeline(_opaquePipeline); _commandBuffer.SetViewport( 0, 0, _opaqueTarget.Width, _opaqueTarget.Height, 0f, 1f); @@ -307,6 +311,8 @@ private void ExecuteTransparentPass( _commandBuffer.SetViewport( 0, 0, _swapChain.Width, _swapChain.Height, 0f, 1f); _commandBuffer.SetScissor(0, 0, _swapChain.Width, _swapChain.Height); + _commandBuffer.BindDescriptorSet( + _transparentPipeline, _transparentDescriptorSet, 0); _commandBuffer.Draw(3, 0); diff --git a/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs b/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs index 2f3e58f..e62ca83 100644 --- a/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs +++ b/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs @@ -39,6 +39,8 @@ public sealed class StereoForwardPlusRenderSystem : ISystem private readonly IDescriptorSet _rightLightCullingDescriptorSet; private readonly IDescriptorSet _leftOpaqueDescriptorSet; private readonly IDescriptorSet _rightOpaqueDescriptorSet; + private readonly IDescriptorSet _leftTransparentDescriptorSet; + private readonly IDescriptorSet _rightTransparentDescriptorSet; private readonly RenderResources _resources; private readonly IntPtr _inFlightFence; @@ -79,6 +81,8 @@ public StereoForwardPlusRenderSystem( IDescriptorSet rightLightCullingDescriptorSet, IDescriptorSet leftOpaqueDescriptorSet, IDescriptorSet rightOpaqueDescriptorSet, + IDescriptorSet leftTransparentDescriptorSet, + IDescriptorSet rightTransparentDescriptorSet, RenderResources resources, IntPtr inFlightFence, float nearPlane, @@ -106,6 +110,8 @@ public StereoForwardPlusRenderSystem( _rightLightCullingDescriptorSet = rightLightCullingDescriptorSet; _leftOpaqueDescriptorSet = leftOpaqueDescriptorSet; _rightOpaqueDescriptorSet = rightOpaqueDescriptorSet; + _leftTransparentDescriptorSet = leftTransparentDescriptorSet; + _rightTransparentDescriptorSet = rightTransparentDescriptorSet; _resources = resources; _inFlightFence = inFlightFence; _nearPlane = nearPlane; @@ -222,14 +228,16 @@ public void Execute(World world) world, leftVP, _leftOpaqueTarget, _leftOpaqueDescriptorSet); _commandBuffer.PipelineBarrier(); - ExecuteTransparentPass( - world, leftAcquire.Value, leftVP, _leftSwapChain); - _commandBuffer.ResolveImage( _leftOpaqueTarget.GetColorAttachment(0), _leftResolveTexture, _leftOpaqueTarget.Width, _leftOpaqueTarget.Height); + _commandBuffer.PipelineBarrier(); + + ExecuteTransparentPass( + world, leftAcquire.Value, leftVP, _leftSwapChain, + _leftTransparentDescriptorSet); } // Right eye @@ -247,14 +255,16 @@ public void Execute(World world) world, rightVP, _rightOpaqueTarget, _rightOpaqueDescriptorSet); _commandBuffer.PipelineBarrier(); - ExecuteTransparentPass( - world, rightAcquire.Value, rightVP, _rightSwapChain); - _commandBuffer.ResolveImage( _rightOpaqueTarget.GetColorAttachment(0), _rightResolveTexture, _rightOpaqueTarget.Width, _rightOpaqueTarget.Height); + _commandBuffer.PipelineBarrier(); + + ExecuteTransparentPass( + world, rightAcquire.Value, rightVP, _rightSwapChain, + _rightTransparentDescriptorSet); } var cmdEndResult = _commandBuffer.End(); @@ -434,7 +444,7 @@ private void ExecuteOpaquePass( IRenderTarget opaqueTarget, IDescriptorSet opaqueDescriptorSet) { - _commandBuffer.BeginRenderPass(opaqueTarget, 0f, 0f, 0f, 1f); + _commandBuffer.BeginRenderPass(opaqueTarget, 0f, 0f, 0f, 0f); _commandBuffer.BindPipeline(_opaquePipeline); _commandBuffer.SetViewport( 0, 0, opaqueTarget.Width, opaqueTarget.Height, 0f, 1f); @@ -518,7 +528,7 @@ private void ExecuteOpaquePass( private void ExecuteTransparentPass( World world, uint imageIndex, Matrix4x4 viewProjection, - ISwapChain swapChain) + ISwapChain swapChain, IDescriptorSet transparentDescriptorSet) { _commandBuffer.BeginRenderPass( swapChain, imageIndex, 0f, 0f, 0f, 1f); @@ -526,6 +536,8 @@ private void ExecuteTransparentPass( _commandBuffer.SetViewport( 0, 0, swapChain.Width, swapChain.Height, 0f, 1f); _commandBuffer.SetScissor(0, 0, swapChain.Width, swapChain.Height); + _commandBuffer.BindDescriptorSet( + _transparentPipeline, transparentDescriptorSet, 0); _commandBuffer.Draw(3, 0); diff --git a/src/Seed.Engine/Shaders/fp_transparent.frag.glsl b/src/Seed.Engine/Shaders/fp_transparent.frag.glsl index 6445cee..29abd1e 100644 --- a/src/Seed.Engine/Shaders/fp_transparent.frag.glsl +++ b/src/Seed.Engine/Shaders/fp_transparent.frag.glsl @@ -1,11 +1,17 @@ #version 450 +layout(set = 0, binding = 0) uniform sampler2D opaqueResult; + layout(location = 0) in vec2 texCoord; layout(location = 0) out vec4 outColor; void main() { + vec4 opaque = texture(opaqueResult, texCoord); + // Skybox placeholder: gradient from dark blue (bottom) to lighter blue (top). vec3 skyColor = mix(vec3(0.05, 0.05, 0.15), vec3(0.2, 0.4, 0.8), texCoord.y); - outColor = vec4(skyColor, 1.0); + + // Composite: opaque geometry (alpha=1) over sky background (alpha=0 from clear). + outColor = vec4(mix(skyColor, opaque.rgb, opaque.a), 1.0); } diff --git a/src/Seed.Engine/Shaders/fp_transparent.frag.spv b/src/Seed.Engine/Shaders/fp_transparent.frag.spv index 075e36d04c3e49a702f82d4e5a9c1aa6984fbc3c..5877a22db48c45b391cf06826785cd31939caa92 100644 GIT binary patch literal 1340 zcmY+DT~8BH5QYzw0`iRrSW(%E;s;p2z?c|H3^9ANNl`Dn+>mxJ8rN=Zw?^*#8}f6w z#{c7uiSM&L)6|np=e+N{XU@!=ZE136#<>YM?-t#m8?IG1DaMJLE_@h$ifaA5RXaF* zj$+A`3L%2ggnDhr}E-1cS z)R-qfPO_}mGHyeg{+IKUEX{hx(MLAOM_i~o$XP|8W%%9@7MT9;j$v{bUegqqL$#QQ zEA|`OPm2p=o666;!1NaAcUB$_tEj^{dEy%7f6MTe^35xPj`Is%1^3otdSG$ivOM{y z3;l{bx!}=*=?M<~4SDo@1?bo1sSSQlnAoQ5_f>Rc_@?m1uS$JiQFIlrK}XDVx3t%} zy5X$?UybnXVf@(g+!5vsI%4a(pIHYo7J0ziGU}(^!0)LO{6L;w=z)I0yPmW2PdrAy zr#)KETb;!p!~Hydt#6At@^&op{t;7_CpT}&H)?k=JH9*ck>b43l8pBO^9I*t%>T2p zpj(rnfv@WZ^1;1RJdm%-(9<(HsFUv#{?yNZ$8+%5f-2)(?#m`*H5qTje9>|bbrL5h z+J`bYFm*kWQA@+;dh9uP7I~Qsbuz)7jaaJdFa2MJS%1ys7 nWN28%z0}^$8TZO_)OD&Z=;J`fdGy42Tl(jlAof=`zmfd|NUB=z literal 920 zcmYk4%Syvg5Qaxfb7x_1UP6+qV6kN$VG5;s$dYgg0TWn>B0u2YVSiykIBF&BIBF)HxRE7K zF}k51UD9r}9-F-aPm12Yo_RK`9^Jc_^R$(A z3mp0q&kE$ZKUetFr2eujKHr4=iY&W;r#>+|g>&y%mv8oZdZ_QjT-@cL`4;2Ja+h85 zZQ0S8SbEP3%*#wC($ITBfWIpCxma-UU`&;$u_W-0TF&vWnxU<$GkwTY6D|J$+_n1B n!x!9t;kb>0qo(6F3yybPsS Date: Sun, 8 Mar 2026 19:28:59 +0900 Subject: [PATCH 08/24] =?UTF-8?q?VR=20=E3=82=B0=E3=83=A9=E3=83=96=E3=81=AE?= =?UTF-8?q?=E5=8D=B3=E3=83=AA=E3=83=AA=E3=83=BC=E3=82=B9=E3=81=A8=E9=A3=9B?= =?UTF-8?q?=E3=81=B3=E6=8C=99=E5=8B=95=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit グリップ保持中に velocity をゼロ化し、リリース時に spring target を クリアすることで、掴んだオブジェクトが暴れる問題を解消。 トリガー(距離グラブ)でもホールドが維持されるよう判定を拡張。 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine/GameLogic/VrGrabSystem.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Seed.Engine/GameLogic/VrGrabSystem.cs b/src/Seed.Engine/GameLogic/VrGrabSystem.cs index 4bb4e46..d834b0c 100644 --- a/src/Seed.Engine/GameLogic/VrGrabSystem.cs +++ b/src/Seed.Engine/GameLogic/VrGrabSystem.cs @@ -95,8 +95,10 @@ public void Execute(World world) return; } - bool leftGripActive = vrState.LeftGripPressed == 1; - bool rightGripActive = vrState.RightGripPressed == 1; + bool leftGripActive = vrState.LeftGripPressed == 1 + || vrState.LeftTriggerPressed == 1; + bool rightGripActive = vrState.RightGripPressed == 1 + || vrState.RightTriggerPressed == 1; for (int s = 0; s < world.Storages.Count; s++) { @@ -260,6 +262,7 @@ private static void HandleRelease( sj.Spring = 0f; sj.Damper = 0f; sj.MaxForce = 0f; + sj.TargetAnchor = Vector3.Zero; // Transfer hand velocity to the released object for throwing vel.Linear = isLeftHand @@ -273,6 +276,8 @@ private static void HandleRelease( : vrState.RightPosition; lt.Position = handPos + gs.GrabOffset; sj.TargetAnchor = handPos; + vel.Linear = Vector3.Zero; + vel.Angular = Vector3.Zero; } } } From bbd9825da6653185fb94188570550fda2ec2c796 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 8 Mar 2026 19:29:06 +0900 Subject: [PATCH 09/24] =?UTF-8?q?Forward+=20opaque=20=E3=82=B7=E3=82=A7?= =?UTF-8?q?=E3=83=BC=E3=83=80=E3=83=BC=E3=81=AB=E3=83=87=E3=82=A3=E3=83=AC?= =?UTF-8?q?=E3=82=AF=E3=82=B7=E3=83=A7=E3=83=8A=E3=83=AB=E3=83=A9=E3=82=A4?= =?UTF-8?q?=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 Push constants を 96→128 バイトに拡張し、ディレクショナルライトの 方向・色・強度を渡して PBR 計算を行うことで、VR でオブジェクトが ほぼ黒に見えていた問題を解消。ForwardPlusRenderSystem にも同様の 変更を適用。 Co-Authored-By: Claude Opus 4.6 --- .../Rendering/ForwardPlusRenderSystem.cs | 35 +++++++++++++++++- src/Seed.Engine/Shaders/fp_opaque.frag.glsl | 24 ++++++++++++ src/Seed.Engine/Shaders/fp_opaque.frag.spv | Bin 18340 -> 20904 bytes src/Seed.Engine/Shaders/fp_opaque.vert.glsl | 4 ++ src/Seed.Engine/Shaders/fp_opaque.vert.spv | Bin 1880 -> 2076 bytes 5 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/Seed.Engine/Rendering/ForwardPlusRenderSystem.cs b/src/Seed.Engine/Rendering/ForwardPlusRenderSystem.cs index 19fcab2..7dd25b5 100644 --- a/src/Seed.Engine/Rendering/ForwardPlusRenderSystem.cs +++ b/src/Seed.Engine/Rendering/ForwardPlusRenderSystem.cs @@ -36,6 +36,7 @@ public sealed class ForwardPlusRenderSystem : ISystem private readonly QueryDescription _meshQuery; private readonly QueryDescription _cameraQuery; private readonly QueryDescription _boundsQuery; + private readonly QueryDescription _directionalLightQuery; /// /// Initializes a new . @@ -93,6 +94,10 @@ public ForwardPlusRenderSystem( .WithRead() .WithRead() .Build(); + + _directionalLightQuery = new QueryBuilder() + .WithRead() + .Build(); } /// @@ -230,8 +235,9 @@ private void ExecuteOpaquePass(World world, Matrix4x4 viewProjection) _commandBuffer.BindDescriptorSet( _opaquePipeline, _opaqueDescriptorSet, 0); + DirectionalLight dirLight = FindDirectionalLight(world); Frustum frustum = Frustum.ExtractFromMatrix(viewProjection); - Span pushData = stackalloc byte[96]; + Span pushData = stackalloc byte[128]; for (int s = 0; s < world.Storages.Count; s++) { @@ -283,6 +289,7 @@ private void ExecuteOpaquePass(World world, Matrix4x4 viewProjection) MemoryMarshal.Write(pushData, in mvp); MemoryMarshal.Write(pushData.Slice(64), in material); + MemoryMarshal.Write(pushData.Slice(96), in dirLight); _commandBuffer.PushConstants( _opaquePipeline, @@ -302,6 +309,32 @@ private void ExecuteOpaquePass(World world, Matrix4x4 viewProjection) _commandBuffer.EndRenderPass(); } + private DirectionalLight FindDirectionalLight(World world) + { + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_directionalLightQuery.Matches(storage.Archetype)) + { + continue; + } + + int dlIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + if (chunk.Count > 0) + { + return chunk.GetComponent(dlIdx, 0); + } + } + } + + return default; + } + private void ExecuteTransparentPass( World world, uint imageIndex, Matrix4x4 viewProjection) { diff --git a/src/Seed.Engine/Shaders/fp_opaque.frag.glsl b/src/Seed.Engine/Shaders/fp_opaque.frag.glsl index 6a1d9bf..77e9b36 100644 --- a/src/Seed.Engine/Shaders/fp_opaque.frag.glsl +++ b/src/Seed.Engine/Shaders/fp_opaque.frag.glsl @@ -14,6 +14,10 @@ layout(push_constant) uniform PushConstants { float ao; float emissive; float _pad; + vec3 lightDir; + float lightIntensity; + vec3 lightColor; + float _lightPad; } pc; struct GpuPointLight { @@ -201,6 +205,26 @@ void main() { } } + // Directional light + { + vec3 L = normalize(-pc.lightDir); + vec3 H = normalize(V + L); + + float D = DistributionGGX(N, H, pc.roughness); + float G = GeometrySmith(N, V, L, pc.roughness); + vec3 F = FresnelSchlick(max(dot(H, V), 0.0), F0); + + vec3 numerator = D * G * F; + float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.0001; + vec3 specular = numerator / denominator; + + vec3 kD = (1.0 - F) * (1.0 - pc.metallic); + float NdotL = max(dot(N, L), 0.0); + + vec3 radiance = pc.lightColor * pc.lightIntensity; + color += (kD * pc.albedo / PI + specular) * radiance * NdotL; + } + color += AMBIENT_COLOR * pc.albedo * pc.ao; color += pc.albedo * pc.emissive; diff --git a/src/Seed.Engine/Shaders/fp_opaque.frag.spv b/src/Seed.Engine/Shaders/fp_opaque.frag.spv index 128e1f5a885a67f54530d77cd689450db055a564..69557a61618fe9184616a4efdd203b515d92a107 100644 GIT binary patch delta 3172 zcmZ9OS!~o*6vpq&bXuq^5rYd4AR!tPjV~q`qLQd6m>>Zjc-0B5+E}Kfv=1gG{}5y1 zgHo}G3$AS9f{GPa+;CrT!=)f1DDDd`fa3o9P5-Bvw&610_nq&ad+xcXldkP4Y*0#Tc4`iRgk zsCKnhm$Y?VVQgT}$cUZW(cZzX(3{aOswJCSY3Wk-#U!nHiVbbUGSN?_wFIFhP0-hXQ?5rs~6z+4B@48Km7bW*~3F( z4QbO5UQUm~tKExQJ1bpYoJ*C{Q)n%%)s97N)tajI3fhvztqbROw^wRy_4aQfUe1-v z6tNvVr=_E7pflyv5AUqBv{kALTgi*3oP4d0?ykJaYK^>J@5HgYFPsI5*PDX-Gp5v9KM;|>%HqkC74pFGkrX=F9^Z64oK z%vJ)gqh#fiG0S(rJtz1sxV6aL`V+io>%lWmfw^x#TrJk|`2K@aN1Dt!Bj*Zg$Cw8| zu1N`BazhgWQ(OzUK~Ck599t}Aatm+*JutXa7~F!L#Nhg!#(M5F2G{R2)^n#Zxci+* zcs6*TBUx{7B7>XIi41N6Co;Hkr!u&4r?Q?qmBIBpkxiUFSb*ag1`{}m!QJ3A1~=Z{ zw{gO0&6zKJmQ6+K0WV|u8zsLUtTrlt&E;Sp;=DXIoj0@b>XD-t+>a^v3b>x7OtU#} z1nW^JZ&njmLL#vS6Z?Ydk?0oic1*^6J$b}df%O~nCFN&x;6D7fgZJ<`m8IV?y#q~s zdg9B=r&gFoDF*KZkKxlpOlI7~j*zi9bTv2%avbClTMM=e?sGEa5nB(oBgWQc0EMV_ z19*6~_oAu0!7-9gW9lvTKJWxSEx>~uJ3V@xS>ti=17N!pd?Prs$`!S*os=F#&}3j# z-i?RASkg%>n)+4wJ>CP)qaO8m#rMJHGiC{Xlc_}sAAsu{{Gnr1-{Fri zXd*E9iXVfc!Ny~bw}XvY^j-`*!4|!P&p7ZCuzFnV)7-Nw_cU^T+6^%fxA+X~Ms;t| z&!<`>-2?V!aqwQS2d}0Z;rSd*-Es5PzR<%`j|bQXUcmWjLB8T8za-!W4UzCGurIna zPxv)hpL%${0b6Vw`Yl+Wx*o^rJFuOL%Vyt0G#ZFRKY)*8#bG~!)%4mCV`|ZXpTWaB z@C%xH6!R;1KTF+xe#8G(Oua{)aAbA>4Ln%XL9l-HsOb>6zNX*dYI>t4wW#S2@bH=r zqp8RFe-3!EGzSnJ`U`9zI`lW#jp|X%Kj8Xe{)MaQjbhZoU*HcP{qwlEuTb<4BbGn& q((D1Tl#4WG(Abb`>}776YJYl_(M+Jc4WSuX^It$^Qe5VUCzI z8?p2c5d{l75fm(a4{jw3!@l{=o1MAjzO6cCm5k`AJ`p7YGAMVtl3b5pX_2me*qBL} z--H((j|oX}duzG4S@Z-aCUH1|r=b_~G2GB2tfyJPS&=AeraJnqRe!MOxaQH>*M1zO z1K7&XKMPs3W2PJ;C$o`3>m~9CYA#|v%=tW-gS-RHw{Qvlw-5@NoPOrrkmax_B&e9r z$uDIx>1@Jpfh>Y?gEGn`#4eo7kD$2~`4ZSWo~}T@;$FqgK2EPflb6E20Ghqz_U#(< z1Y#%j97BWUh2}gyh($sUdc%MdXbx)iELaC4x63qw=4@*06r_Q$=70|Ns)5a(p3XOy^P76F++QFgF62Ta^EuOv0EIJ2Y@BnQ#M z36pcq&&dbsf{Akh#pA(-1tg|y=44vT=vG9 hkg*`Kn$0VjZ5bJPCqHLVwsLhBbf*0ELGLod5s; From 2c89d9f1da158506f4707085d48685a07e0f2c48 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 8 Mar 2026 19:29:15 +0900 Subject: [PATCH 10/24] =?UTF-8?q?VR=20=E3=83=AD=E3=82=B3=E3=83=A2=E3=83=BC?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E6=9C=89=E5=8A=B9=E5=8C=96=E3=81=A8?= =?UTF-8?q?=E3=83=87=E3=82=B9=E3=82=AF=E3=83=88=E3=83=83=E3=83=97=E3=83=9F?= =?UTF-8?q?=E3=83=A9=E3=83=BC=E6=8F=8F=E7=94=BB=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VrSmoothMoveSystem をスケジューラに登録 - シングルトンに StereoCamera, VrLocomotionMode, VrLocomotionConfig を追加 - プレイヤーに VignetteState を追加 - StereoForwardPlusRenderSystem にデスクトップミラー機能を追加し、 左目コンポジット結果をデスクトップウィンドウに描画 - Push constant range を 128 バイトに拡張 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine.App/Program.cs | 63 ++++++++++++-- .../StereoForwardPlusRenderSystem.cs | 87 ++++++++++++++++++- 2 files changed, 142 insertions(+), 8 deletions(-) diff --git a/src/Seed.Engine.App/Program.cs b/src/Seed.Engine.App/Program.cs index 6ff643c..01e31a7 100644 --- a/src/Seed.Engine.App/Program.cs +++ b/src/Seed.Engine.App/Program.cs @@ -557,6 +557,7 @@ private static void Main() if (renderMode == RenderMode.Stereo && xrSessionForInit != null) { + scheduler.AddSystem(new VrSmoothMoveSystem()); scheduler.AddSystem(new VrGrabSystem()); scheduler.AddSystem(new VrDistanceGrabSystem()); scheduler.AddSystem(new HapticFeedbackSystem(xrSessionForInit)); @@ -1069,7 +1070,7 @@ private static void Main() 1, new IDescriptorSet[] { leftLcDesc }, new[] { new PushConstantRange( - ShaderStage.Vertex | ShaderStage.Fragment, 0, 96) }, + ShaderStage.Vertex | ShaderStage.Fragment, 0, 128) }, true, null, PrimitiveTopology.TriangleList, @@ -1193,6 +1194,34 @@ private static void Main() using var stereoTransparentPipeline = stereoTransparentPipelineResult.Value; + // Desktop mirror pipeline (same shaders, desktop swapchain render pass) + var desktopMirrorPipelineDesc = new PipelineDescription( + new ShaderDescription( + ShaderStage.Vertex, fwdTransparentVert.Value, "main"), + new ShaderDescription( + ShaderStage.Fragment, fwdTransparentFrag.Value, "main"), + swapChain.Format, + null, + null, + 1, + new IDescriptorSet[] { leftTransDesc }, + null, + false, + null, + PrimitiveTopology.TriangleList); + + var desktopMirrorPipelineResult = device.CreatePipeline( + desktopMirrorPipelineDesc, vulkanSwapChain.RenderPass); + if (desktopMirrorPipelineResult.IsFailure) + { + Console.Error.WriteLine( + $"Failed to create desktop mirror pipeline: " + + $"{desktopMirrorPipelineResult.Error}"); + return; + } + + using var desktopMirrorPipeline = desktopMirrorPipelineResult.Value; + scheduler.AddSystem(new StereoForwardPlusRenderSystem( xrSession, device, @@ -1221,7 +1250,11 @@ private static void Main() rightResolveTexture, shadowMap, shadowPipeline, - boundarySystem)); + boundarySystem, + swapChain, + desktopMirrorPipeline, + imageAvailableSemaphore, + renderFinishedSemaphore)); RunMainLoop( scheduler, world, window, cubeMeshId, materialId, @@ -1458,9 +1491,28 @@ private static void CreateSingletonEntity( var vrType = ComponentRegistry.Get(); var hsType = ComponentRegistry.Get(); var hrType = ComponentRegistry.Get(); + var scType = ComponentRegistry.Get(); + var lmType = ComponentRegistry.Get(); + var lcType = ComponentRegistry.Get(); ReadOnlySpan types = - [ftType, isType, vrType, hsType, hrType]; - world.CreateEntity(types); + [ftType, isType, vrType, hsType, hrType, scType, lmType, lcType]; + Entity singletonEntity = world.CreateEntity(types); + world.GetComponent(singletonEntity) = + new VrLocomotionMode + { + ActiveMode = LocomotionType.SmoothMove, + DirectionSource = 0, + VignetteEnabled = 0, + }; + world.GetComponent(singletonEntity) = + new VrLocomotionConfig + { + TeleportMaxDistance = 10f, + TeleportFadeDuration = 0.15f, + SmoothMoveSpeed = 2f, + ArmSwingMaxSpeed = 5f, + ArmSwingMultiplier = 1.5f, + }; } else { @@ -1533,7 +1585,8 @@ private static void CreatePlayerEntity(World world) var csType = ComponentRegistry.Get(); var cmType = ComponentRegistry.Get(); var camType = ComponentRegistry.Get(); - ReadOnlySpan types = [ltType, ltwType, ccType, csType, cmType, camType]; + var vsType = ComponentRegistry.Get(); + ReadOnlySpan types = [ltType, ltwType, ccType, csType, cmType, camType, vsType]; Entity entity = world.CreateEntity(types); diff --git a/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs b/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs index e62ca83..c00736b 100644 --- a/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs +++ b/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs @@ -53,6 +53,11 @@ public sealed class StereoForwardPlusRenderSystem : ISystem private readonly BoundarySystem? _boundarySystem; + private readonly ISwapChain? _desktopSwapChain; + private readonly IPipeline? _desktopMirrorPipeline; + private readonly IntPtr _desktopImageAvailableSemaphore; + private readonly IntPtr _desktopRenderFinishedSemaphore; + private readonly float _nearPlane; private readonly float _farPlane; @@ -91,7 +96,11 @@ public StereoForwardPlusRenderSystem( ITexture rightResolveTexture, IRenderTarget? shadowMap, IPipeline? shadowPipeline, - BoundarySystem? boundarySystem) + BoundarySystem? boundarySystem, + ISwapChain? desktopSwapChain = null, + IPipeline? desktopMirrorPipeline = null, + IntPtr desktopImageAvailableSemaphore = default, + IntPtr desktopRenderFinishedSemaphore = default) { _xrRuntime = xrRuntime; _device = device; @@ -121,6 +130,10 @@ public StereoForwardPlusRenderSystem( _shadowMap = shadowMap; _shadowPipeline = shadowPipeline; _boundarySystem = boundarySystem; + _desktopSwapChain = desktopSwapChain; + _desktopMirrorPipeline = desktopMirrorPipeline; + _desktopImageAvailableSemaphore = desktopImageAvailableSemaphore; + _desktopRenderFinishedSemaphore = desktopRenderFinishedSemaphore; _meshQuery = new QueryBuilder() .WithRead() @@ -267,6 +280,34 @@ public void Execute(World world) _rightTransparentDescriptorSet); } + // Desktop mirror: render left eye composite to desktop window + uint desktopImageIndex = 0; + bool desktopAcquired = false; + if (_desktopSwapChain != null && _desktopMirrorPipeline != null) + { + var desktopAcquire = _desktopSwapChain.AcquireNextImage( + _desktopImageAvailableSemaphore); + if (desktopAcquire.IsSuccess) + { + desktopImageIndex = desktopAcquire.Value; + desktopAcquired = true; + + _commandBuffer.PipelineBarrier(); + _commandBuffer.BeginRenderPass( + _desktopSwapChain, desktopImageIndex, 0f, 0f, 0f, 1f); + _commandBuffer.BindPipeline(_desktopMirrorPipeline); + _commandBuffer.SetViewport( + 0, 0, _desktopSwapChain.Width, _desktopSwapChain.Height, + 0f, 1f); + _commandBuffer.SetScissor( + 0, 0, _desktopSwapChain.Width, _desktopSwapChain.Height); + _commandBuffer.BindDescriptorSet( + _desktopMirrorPipeline, _leftTransparentDescriptorSet, 0); + _commandBuffer.Draw(3, 0); + _commandBuffer.EndRenderPass(); + } + } + var cmdEndResult = _commandBuffer.End(); if (cmdEndResult.IsFailure) { @@ -275,7 +316,13 @@ public void Execute(World world) return; } - _commandBuffer.Submit(IntPtr.Zero, IntPtr.Zero, _inFlightFence); + IntPtr waitSem = desktopAcquired + ? _desktopImageAvailableSemaphore + : IntPtr.Zero; + IntPtr signalSem = desktopAcquired + ? _desktopRenderFinishedSemaphore + : IntPtr.Zero; + _commandBuffer.Submit(waitSem, signalSem, _inFlightFence); if (leftAcquire.IsSuccess) { @@ -287,6 +334,12 @@ public void Execute(World world) _rightSwapChain.Present(rightAcquire.Value, IntPtr.Zero); } + if (desktopAcquired) + { + _desktopSwapChain!.Present( + desktopImageIndex, _desktopRenderFinishedSemaphore); + } + Span layers = stackalloc XrCompositionLayer[2]; layers[0] = new XrCompositionLayer { @@ -453,8 +506,9 @@ private void ExecuteOpaquePass( _commandBuffer.BindDescriptorSet( _opaquePipeline, opaqueDescriptorSet, 0); + DirectionalLight dirLight = FindDirectionalLight(world); Frustum frustum = Frustum.ExtractFromMatrix(viewProjection); - Span pushData = stackalloc byte[96]; + Span pushData = stackalloc byte[128]; for (int s = 0; s < world.Storages.Count; s++) { @@ -507,6 +561,7 @@ private void ExecuteOpaquePass( MemoryMarshal.Write(pushData, in mvp); MemoryMarshal.Write(pushData.Slice(64), in material); + MemoryMarshal.Write(pushData.Slice(96), in dirLight); _commandBuffer.PushConstants( _opaquePipeline, @@ -526,6 +581,32 @@ private void ExecuteOpaquePass( _commandBuffer.EndRenderPass(); } + private DirectionalLight FindDirectionalLight(World world) + { + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_directionalLightQuery.Matches(storage.Archetype)) + { + continue; + } + + int dlIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + if (chunk.Count > 0) + { + return chunk.GetComponent(dlIdx, 0); + } + } + } + + return default; + } + private void ExecuteTransparentPass( World world, uint imageIndex, Matrix4x4 viewProjection, ISwapChain swapChain, IDescriptorSet transparentDescriptorSet) From 4e81e4194f603fb1fc72212f2130e3d9ba2a9e61 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 8 Mar 2026 19:51:26 +0900 Subject: [PATCH 11/24] =?UTF-8?q?Forward+=20=E3=82=B7=E3=82=A7=E3=83=BC?= =?UTF-8?q?=E3=83=80=E3=83=BC=E3=81=AB=E3=83=A2=E3=83=87=E3=83=AB=E8=A1=8C?= =?UTF-8?q?=E5=88=97=E3=81=A8=E8=A6=96=E7=82=B9=E4=BD=8D=E7=BD=AE=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E3=81=97=20PBR=20=E3=83=A9=E3=82=A4=E3=83=86?= =?UTF-8?q?=E3=82=A3=E3=83=B3=E3=82=B0=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ハードコードされた V=(0,0,1) を実際のカメラ位置から計算するように変更。 オブジェクト空間の位置/法線をモデル行列でワールド空間に変換。 プッシュ定数を 128→208 バイトに拡張(MVP+Model+EyePos+Material+Light)。 StereoCamera の HmdRotation を Identity で初期化。 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine.App/Program.cs | 7 ++++++- .../StereoForwardPlusRenderSystem.cs | 19 +++++++++++++----- src/Seed.Engine/Shaders/fp_opaque.frag.glsl | 5 ++++- src/Seed.Engine/Shaders/fp_opaque.frag.spv | Bin 20904 -> 21176 bytes src/Seed.Engine/Shaders/fp_opaque.vert.glsl | 8 ++++++-- src/Seed.Engine/Shaders/fp_opaque.vert.spv | Bin 2076 -> 2700 bytes 6 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/Seed.Engine.App/Program.cs b/src/Seed.Engine.App/Program.cs index 01e31a7..04e6ffb 100644 --- a/src/Seed.Engine.App/Program.cs +++ b/src/Seed.Engine.App/Program.cs @@ -1070,7 +1070,7 @@ private static void Main() 1, new IDescriptorSet[] { leftLcDesc }, new[] { new PushConstantRange( - ShaderStage.Vertex | ShaderStage.Fragment, 0, 128) }, + ShaderStage.Vertex | ShaderStage.Fragment, 0, 208) }, true, null, PrimitiveTopology.TriangleList, @@ -1497,6 +1497,11 @@ private static void CreateSingletonEntity( ReadOnlySpan types = [ftType, isType, vrType, hsType, hrType, scType, lmType, lcType]; Entity singletonEntity = world.CreateEntity(types); + world.GetComponent(singletonEntity) = + new StereoCamera + { + HmdRotation = Quaternion.Identity, + }; world.GetComponent(singletonEntity) = new VrLocomotionMode { diff --git a/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs b/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs index c00736b..71b19e7 100644 --- a/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs +++ b/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs @@ -226,6 +226,9 @@ public void Execute(World world) _commandBuffer.PipelineBarrier(); } + Vector3 leftEyePos = views.Left.Pose.Position; + Vector3 rightEyePos = views.Right.Pose.Position; + // Left eye var leftAcquire = _leftSwapChain.AcquireNextImage(IntPtr.Zero); if (leftAcquire.IsSuccess) @@ -238,7 +241,8 @@ public void Execute(World world) _commandBuffer.ComputeBarrier(); ExecuteOpaquePass( - world, leftVP, _leftOpaqueTarget, _leftOpaqueDescriptorSet); + world, leftVP, leftEyePos, + _leftOpaqueTarget, _leftOpaqueDescriptorSet); _commandBuffer.PipelineBarrier(); _commandBuffer.ResolveImage( @@ -265,7 +269,8 @@ public void Execute(World world) _commandBuffer.ComputeBarrier(); ExecuteOpaquePass( - world, rightVP, _rightOpaqueTarget, _rightOpaqueDescriptorSet); + world, rightVP, rightEyePos, + _rightOpaqueTarget, _rightOpaqueDescriptorSet); _commandBuffer.PipelineBarrier(); _commandBuffer.ResolveImage( @@ -494,6 +499,7 @@ private void ExecuteLightCulling( private void ExecuteOpaquePass( World world, Matrix4x4 viewProjection, + Vector3 eyePosition, IRenderTarget opaqueTarget, IDescriptorSet opaqueDescriptorSet) { @@ -508,7 +514,7 @@ private void ExecuteOpaquePass( DirectionalLight dirLight = FindDirectionalLight(world); Frustum frustum = Frustum.ExtractFromMatrix(viewProjection); - Span pushData = stackalloc byte[128]; + Span pushData = stackalloc byte[208]; for (int s = 0; s < world.Storages.Count; s++) { @@ -556,12 +562,15 @@ private void ExecuteOpaquePass( ref chunk.GetComponent(matIdx, i); Matrix4x4 mvp = viewProjection * ltw.Value; + Matrix4x4 model = ltw.Value; PbrMaterial material = _resources.GetMaterial( matRef.MaterialId); MemoryMarshal.Write(pushData, in mvp); - MemoryMarshal.Write(pushData.Slice(64), in material); - MemoryMarshal.Write(pushData.Slice(96), in dirLight); + MemoryMarshal.Write(pushData.Slice(64), in model); + MemoryMarshal.Write(pushData.Slice(128), in eyePosition); + MemoryMarshal.Write(pushData.Slice(144), in material); + MemoryMarshal.Write(pushData.Slice(176), in dirLight); _commandBuffer.PushConstants( _opaquePipeline, diff --git a/src/Seed.Engine/Shaders/fp_opaque.frag.glsl b/src/Seed.Engine/Shaders/fp_opaque.frag.glsl index 77e9b36..90a851f 100644 --- a/src/Seed.Engine/Shaders/fp_opaque.frag.glsl +++ b/src/Seed.Engine/Shaders/fp_opaque.frag.glsl @@ -8,6 +8,9 @@ layout(location = 0) out vec4 outColor; layout(push_constant) uniform PushConstants { layout(row_major) mat4 mvp; + layout(row_major) mat4 model; + vec3 eyePos; + float _eyePad; vec3 albedo; float roughness; float metallic; @@ -174,7 +177,7 @@ vec3 calculateSpotLight(GpuSpotLight light, vec3 N, vec3 V, vec3 F0, vec3 albedo void main() { vec3 N = normalize(worldNormal); - vec3 V = vec3(0.0, 0.0, 1.0); + vec3 V = normalize(pc.eyePos - worldPosition); vec3 F0 = mix(vec3(0.04), pc.albedo, pc.metallic); // Determine which tile this fragment belongs to diff --git a/src/Seed.Engine/Shaders/fp_opaque.frag.spv b/src/Seed.Engine/Shaders/fp_opaque.frag.spv index 69557a61618fe9184616a4efdd203b515d92a107..39b7ecc5742ca680a867185d2655c5067a21decc 100644 GIT binary patch literal 21176 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 K{hw~SCHfB?*~)zY delta 803 zcmY*WyGjE=6rFu*G>{l120_Jlj7IGQiA@kK#0Mx?2pUXG7B(5#jgLae57@}GT8LO$ z_y_)jmi~(1IhzfxyIk%)=gi#4-236jRMOC=93eC@Ad+I`A-R{H7DCLx7GR68;(N28 zsV^;+(}8?>Rd2LvL4erCKLl^6A1zxl5mn?LYXR4qZVmp|8ce`_VVq^z-_y-rAo%Dvdzi-4wUVvYXk8VY(K_vVseM-z|546 zV+v=aauVb4${}|aQ#1bra2}p#$@4rh zdm=}@0MENUotQn6i|Tb(W2iT0Tzxwq?GmoBomm)5527IP9E{gCVbnHIgsqb29hO*v YF+DZ5LEOu?jF`_$jn6{-gN$piU;YkJ+5i9m From f150d3e507fc481071b3c5dd97a253413932e3f6 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 8 Mar 2026 19:51:34 +0900 Subject: [PATCH 12/24] =?UTF-8?q?XrInputUpdateSystem=20=E3=81=A7=20HMD=20?= =?UTF-8?q?=E3=83=9D=E3=83=BC=E3=82=BA=E3=82=92=20StereoCamera=20=E3=81=AB?= =?UTF-8?q?=E6=9B=B8=E3=81=8D=E8=BE=BC=E3=81=BF=20VR=20=E3=83=AD=E3=82=B3?= =?UTF-8?q?=E3=83=A2=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=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 StereoCamera.HmdRotation が未設定(ゼロ四元数)のため NaN が発生し 移動が機能しなかった。XrInputUpdateSystem に LocateViews を追加し 毎フレーム HMD の位置と回転を StereoCamera に反映。 ComputeForwardXZ にゼロ四元数ガードを追加。 Co-Authored-By: Claude Opus 4.6 --- .../GameLogic/VrLocomotionHelper.cs | 7 +++++ .../Platform/Input/XrInputUpdateSystem.cs | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/Seed.Engine/GameLogic/VrLocomotionHelper.cs b/src/Seed.Engine/GameLogic/VrLocomotionHelper.cs index 60451cf..3bb5a35 100644 --- a/src/Seed.Engine/GameLogic/VrLocomotionHelper.cs +++ b/src/Seed.Engine/GameLogic/VrLocomotionHelper.cs @@ -13,6 +13,13 @@ internal static class VrLocomotionHelper /// internal static Vector3 ComputeForwardXZ(Quaternion rotation) { + float qLenSq = rotation.X * rotation.X + rotation.Y * rotation.Y + + rotation.Z * rotation.Z + rotation.W * rotation.W; + if (qLenSq < 0.0001f) + { + return new Vector3(0f, 0f, -1f); + } + Vector3 forward = rotation.RotateVector(new Vector3(0f, 0f, -1f)); Vector3 flatForward = new Vector3(forward.X, 0f, forward.Z); float len = flatForward.Length; diff --git a/src/Seed.Engine/Platform/Input/XrInputUpdateSystem.cs b/src/Seed.Engine/Platform/Input/XrInputUpdateSystem.cs index e12cc14..7b25987 100644 --- a/src/Seed.Engine/Platform/Input/XrInputUpdateSystem.cs +++ b/src/Seed.Engine/Platform/Input/XrInputUpdateSystem.cs @@ -4,6 +4,7 @@ using Seed.Engine.Ecs.Components; using Seed.Engine.Foundation.Mathematics; using Seed.Engine.Platform.Xr; +using Seed.Engine.Rendering; namespace Seed.Engine.Platform.Input; @@ -35,6 +36,7 @@ public XrInputUpdateSystem(XrInputDevice inputDevice, IXrRuntime xrRuntime) .WithWrite() .WithWrite() .WithWrite() + .WithWrite() .Build(); } @@ -95,11 +97,40 @@ public void Execute(World world) ComputeHandVelocity( ref hs, polledVr.LeftPosition, polledVr.RightPosition, deltaTime); + + UpdateHmdPose( + ref chunk, storage, i, + _xrRuntime.PredictedDisplayTime); } } } } + private void UpdateHmdPose( + ref Chunk chunk, ArchetypeStorage storage, int entityIdx, + long predictedDisplayTime) + { + var viewsResult = _xrRuntime.LocateViews(predictedDisplayTime); + if (viewsResult.IsFailure) + { + return; + } + + XrViewPair views = viewsResult.Value; + int scIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + ref StereoCamera sc = ref chunk.GetComponent( + scIdx, entityIdx); + + Vector3 leftPos = views.Left.Pose.Position; + Vector3 rightPos = views.Right.Pose.Position; + sc.HmdPosition = new Vector3( + (leftPos.X + rightPos.X) * 0.5f, + (leftPos.Y + rightPos.Y) * 0.5f, + (leftPos.Z + rightPos.Z) * 0.5f); + sc.HmdRotation = views.Left.Pose.Orientation; + } + private static void ComputeHandVelocity( ref VrHandState hs, Vector3 leftPos, Vector3 rightPos, From f535d1c0ff9f00460cd706aa599ba64c0dd16ad5 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 8 Mar 2026 19:51:41 +0900 Subject: [PATCH 13/24] =?UTF-8?q?GrabState=20=E3=81=AB=20GrabbedByHand=20?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=97=20VR=20=E3=82=B0=E3=83=A9?= =?UTF-8?q?=E3=83=96=E3=81=AE=E5=8D=B3=E3=83=AA=E3=83=AA=E3=83=BC=E3=82=B9?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 距離ヒューリスティックによる手の判定を廃止し、グラブ時にどちらの手で 掴んだかを GrabbedByHand フィールドに記録。VrDistanceGrabSystem の グラブ完了時に SpringJoint・GrabberEntity・Velocity を正しく設定。 テストを GrabbedByHand 対応に更新。 Co-Authored-By: Claude Opus 4.6 --- .../GameLogic/VrDistanceGrabSystemTests.cs | 5 +++- .../GameLogic/VrGrabSystemTests.cs | 2 ++ src/Seed.Engine/Ecs/Components/GrabState.cs | 5 ++++ .../GameLogic/VrDistanceGrabSystem.cs | 26 +++++++++++++++++++ src/Seed.Engine/GameLogic/VrGrabSystem.cs | 12 +++------ 5 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/Seed.Engine.Tests/GameLogic/VrDistanceGrabSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/VrDistanceGrabSystemTests.cs index 338514e..1f34403 100644 --- a/src/Seed.Engine.Tests/GameLogic/VrDistanceGrabSystemTests.cs +++ b/src/Seed.Engine.Tests/GameLogic/VrDistanceGrabSystemTests.cs @@ -37,7 +37,10 @@ private static (World world, Entity singleton, Entity grabbable) SetupWorld( var grabbType = ComponentRegistry.Get(); var gsType = ComponentRegistry.Get(); var ltType = ComponentRegistry.Get(); - ReadOnlySpan grabbableTypes = [grabbType, gsType, ltType]; + 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 { diff --git a/src/Seed.Engine.Tests/GameLogic/VrGrabSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/VrGrabSystemTests.cs index b700867..5703319 100644 --- a/src/Seed.Engine.Tests/GameLogic/VrGrabSystemTests.cs +++ b/src/Seed.Engine.Tests/GameLogic/VrGrabSystemTests.cs @@ -377,6 +377,7 @@ public void Execute_OnRelease_AppliesLeftHandThrowVelocity() world.GetComponent(grabbable) = new GrabState { IsGrabbed = 1, + GrabbedByHand = 1, GrabOffset = Vector3.Zero, }; @@ -420,6 +421,7 @@ public void Execute_OnRelease_AppliesRightHandThrowVelocity() world.GetComponent(grabbable) = new GrabState { IsGrabbed = 1, + GrabbedByHand = 2, GrabOffset = Vector3.Zero, }; diff --git a/src/Seed.Engine/Ecs/Components/GrabState.cs b/src/Seed.Engine/Ecs/Components/GrabState.cs index 7321d91..0f8e0bc 100644 --- a/src/Seed.Engine/Ecs/Components/GrabState.cs +++ b/src/Seed.Engine/Ecs/Components/GrabState.cs @@ -29,4 +29,9 @@ public struct GrabState : IComponent /// 1 if currently grabbed, 0 otherwise. /// public byte IsGrabbed; + + /// + /// Which hand performed the grab: 0 = none, 1 = left, 2 = right. + /// + public byte GrabbedByHand; } diff --git a/src/Seed.Engine/GameLogic/VrDistanceGrabSystem.cs b/src/Seed.Engine/GameLogic/VrDistanceGrabSystem.cs index a810cdb..2c4fcb5 100644 --- a/src/Seed.Engine/GameLogic/VrDistanceGrabSystem.cs +++ b/src/Seed.Engine/GameLogic/VrDistanceGrabSystem.cs @@ -28,6 +28,8 @@ public VrDistanceGrabSystem() .WithRead() .WithWrite() .WithWrite() + .WithWrite() + .WithWrite() .Build(); } @@ -42,6 +44,7 @@ public void Execute(World world) { VrControllerState vrState = default; float dt = 0f; + Entity vrPlayerEntity = Entity.Null; bool foundSingleton = false; for (int s = 0; s < world.Storages.Count; s++) @@ -64,6 +67,7 @@ public void Execute(World world) { vrState = chunk.GetComponent(vrIdx, 0); dt = chunk.GetComponent(ftIdx, 0).DeltaTime; + vrPlayerEntity = chunk.GetEntity(0); foundSingleton = true; break; } @@ -177,17 +181,28 @@ public void Execute(World world) return; } + bool isRightHand = rightTrigger; + byte grabbedByHand = isRightHand ? (byte)2 : (byte)1; + var hitStorage = world.Storages[closestStorage]; int hitGsIdx = hitStorage.GetComponentIndex( ComponentRegistry.Get().TypeId); int hitLtIdx = hitStorage.GetComponentIndex( ComponentRegistry.Get().TypeId); + int hitSjIdx = hitStorage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int hitVelIdx = 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); + ref SpringJoint hitSj = ref hitChunk.GetComponent( + hitSjIdx, closestIndex); + ref Velocity hitVel = ref hitChunk.GetComponent( + hitVelIdx, closestIndex); // Pull the object toward the hand Vector3 pullDir = handPos - hitLt.Position; @@ -200,8 +215,19 @@ public void Execute(World world) { hitLt.Position = handPos; hitGs.IsGrabbed = 1; + hitGs.GrabbedByHand = grabbedByHand; + hitGs.GrabberEntity = vrPlayerEntity; hitGs.GrabOffset = Vector3.Zero; hitGs.GrabRotationOffset = hitLt.Rotation; + + hitSj.TargetAnchor = handPos; + hitSj.Anchor = Vector3.Zero; + hitSj.Spring = 500f; + hitSj.Damper = 30f; + hitSj.MaxForce = 1000f; + + hitVel.Linear = Vector3.Zero; + hitVel.Angular = Vector3.Zero; } else { diff --git a/src/Seed.Engine/GameLogic/VrGrabSystem.cs b/src/Seed.Engine/GameLogic/VrGrabSystem.cs index d834b0c..5d7db60 100644 --- a/src/Seed.Engine/GameLogic/VrGrabSystem.cs +++ b/src/Seed.Engine/GameLogic/VrGrabSystem.cs @@ -184,6 +184,7 @@ private static bool TryDirectGrab( if (distSq <= grabRadiusSq) { gs.IsGrabbed = 1; + gs.GrabbedByHand = 1; gs.GrabberEntity = vrPlayerEntity; gs.GrabOffset = lt.Position - vrState.LeftPosition; gs.GrabRotationOffset = lt.Rotation; @@ -200,6 +201,7 @@ private static bool TryDirectGrab( if (distSq <= grabRadiusSq) { gs.IsGrabbed = 1; + gs.GrabbedByHand = 2; gs.GrabberEntity = vrPlayerEntity; gs.GrabOffset = lt.Position - vrState.RightPosition; gs.GrabRotationOffset = lt.Rotation; @@ -245,19 +247,13 @@ private static void HandleRelease( 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 isLeftHand = gs.GrabbedByHand == 1; bool shouldRelease = isLeftHand ? !leftGripActive : !rightGripActive; if (shouldRelease) { gs.IsGrabbed = 0; + gs.GrabbedByHand = 0; gs.GrabberEntity = Entity.Null; sj.Spring = 0f; sj.Damper = 0f; From 01450a84a006141a60f9d8f8731f49dba9a389e3 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 8 Mar 2026 20:18:06 +0900 Subject: [PATCH 14/24] =?UTF-8?q?VR=20=E3=83=87=E3=82=A3=E3=82=B9=E3=82=BF?= =?UTF-8?q?=E3=83=B3=E3=82=B9=E3=82=B0=E3=83=A9=E3=83=96=E3=81=AE=E3=83=97?= =?UTF-8?q?=E3=83=AB=E4=B8=AD=E6=96=AD=E3=81=A8=E3=82=BF=E3=83=BC=E3=82=B2?= =?UTF-8?q?=E3=83=83=E3=83=88=E5=96=AA=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 - projDist <= GrabRadius を projDist <= 0f に変更し、プル中にオブジェクトが GrabRadius 内に入ってもターゲットを見失わないようにした - プル中に速度をゼロクリアし、物理システムがオブジェクトを逸らす問題を防止 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine/GameLogic/VrDistanceGrabSystem.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Seed.Engine/GameLogic/VrDistanceGrabSystem.cs b/src/Seed.Engine/GameLogic/VrDistanceGrabSystem.cs index 2c4fcb5..11ef6d3 100644 --- a/src/Seed.Engine/GameLogic/VrDistanceGrabSystem.cs +++ b/src/Seed.Engine/GameLogic/VrDistanceGrabSystem.cs @@ -151,7 +151,7 @@ public void Execute(World world) Vector3 toObj = lt.Position - handPos; float projDist = Vector3.Dot(toObj, handForward); - if (projDist <= grabbable.GrabRadius + if (projDist <= 0f || projDist > grabbable.MaxGrabDistance) { continue; @@ -233,6 +233,8 @@ public void Execute(World world) { Vector3 pullNorm = pullDir / pullDist; hitLt.Position = hitLt.Position + pullNorm * moveAmount; + hitVel.Linear = Vector3.Zero; + hitVel.Angular = Vector3.Zero; } } } From 37cbfe8f197914369e45c141fbbe25e7214cdcba Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 8 Mar 2026 20:18:13 +0900 Subject: [PATCH 15/24] =?UTF-8?q?XrInputDevice=20=E3=81=A7=20MoveAxis/Look?= =?UTF-8?q?Delta=20=E3=81=B8=E3=81=AE=E3=82=B9=E3=83=86=E3=82=A3=E3=83=83?= =?UTF-8?q?=E3=82=AF=E5=85=A5=E5=8A=9B=E3=82=92=E9=99=A4=E5=A4=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VR モードではスティック入力を VrControllerState 経由で VrSmoothMoveSystem が 処理するため、InputState に入れると MovementSystem がデスクトップカメラ (デバッグ十字キューブ)を誤って動かしていた Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine/Platform/Xr/XrInputDevice.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Seed.Engine/Platform/Xr/XrInputDevice.cs b/src/Seed.Engine/Platform/Xr/XrInputDevice.cs index 57786d3..c5fc144 100644 --- a/src/Seed.Engine/Platform/Xr/XrInputDevice.cs +++ b/src/Seed.Engine/Platform/Xr/XrInputDevice.cs @@ -109,8 +109,8 @@ public XrInputDevice(IXrRuntime xrRuntime) var inputState = new InputState { - MoveAxis = leftStick.CurrentState, - LookDelta = rightStick.CurrentState, + MoveAxis = default, + LookDelta = default, JumpPressed = jumpPressed, SprintHeld = sprintHeld, }; From 2c9d9f395fb9ec3b96a1d59e0895c44651c5635a Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 8 Mar 2026 20:18:21 +0900 Subject: [PATCH 16/24] =?UTF-8?q?StereoCamera=20=E3=81=AB=E3=83=97?= =?UTF-8?q?=E3=83=AC=E3=82=A4=E3=83=A4=E3=83=BC=E4=BD=8D=E7=BD=AE=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E3=81=97=20VR=20=E3=83=AD=E3=82=B3=E3=83=A2?= =?UTF-8?q?=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=A8=E3=82=AA=E3=83=9A?= =?UTF-8?q?=E3=83=BC=E3=82=AF=E6=8F=8F=E7=94=BB=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StereoCamera に PlayerPosition フィールドを追加 - VrSmoothMoveSystem で移動後のプレイヤー位置を StereoCamera に書き込み - StereoForwardPlusRenderSystem で PlayerPosition を読み取り VP 行列と 眼球位置にオフセットを適用し、スティック移動が VR 視界に反映されるようにした - オペークパスのクリアアルファを 0 から 1 に変更し、XR コンポジターが 背景を透過表示する問題を修正 Co-Authored-By: Claude Opus 4.6 --- .../Rendering/StereoCameraTests.cs | 6 +-- .../GameLogic/VrSmoothMoveSystem.cs | 37 ++++++++++++++- src/Seed.Engine/Rendering/StereoCamera.cs | 6 +++ .../StereoForwardPlusRenderSystem.cs | 45 ++++++++++++++++--- 4 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/Seed.Engine.Tests/Rendering/StereoCameraTests.cs b/src/Seed.Engine.Tests/Rendering/StereoCameraTests.cs index ca40320..d262e12 100644 --- a/src/Seed.Engine.Tests/Rendering/StereoCameraTests.cs +++ b/src/Seed.Engine.Tests/Rendering/StereoCameraTests.cs @@ -11,13 +11,13 @@ namespace Seed.Engine.Tests.Rendering; public class StereoCameraTests { [Fact] - public void StereoCamera_StructSize_Is156Bytes() + public void StereoCamera_StructSize_Is168Bytes() { // Given/When int size = Marshal.SizeOf(); - // Then: two Matrix4x4 (64 bytes each) + Vector3 (12 bytes) + Quaternion (16 bytes) = 156 bytes - Assert.Equal(156, size); + // Then: two Matrix4x4 (64 each) + Vector3 HmdPosition (12) + Quaternion (16) + Vector3 PlayerPosition (12) = 168 bytes + Assert.Equal(168, size); } [Fact] diff --git a/src/Seed.Engine/GameLogic/VrSmoothMoveSystem.cs b/src/Seed.Engine/GameLogic/VrSmoothMoveSystem.cs index dbe39f4..50ab423 100644 --- a/src/Seed.Engine/GameLogic/VrSmoothMoveSystem.cs +++ b/src/Seed.Engine/GameLogic/VrSmoothMoveSystem.cs @@ -26,7 +26,7 @@ public VrSmoothMoveSystem() _singletonQuery = new QueryBuilder() .WithRead() .WithRead() - .WithRead() + .WithWrite() .WithRead() .WithRead() .Build(); @@ -103,6 +103,31 @@ public void Execute(World world) return; } + // Track the singleton storage/chunk for writing PlayerPosition back + int singletonStorageIdx = -1; + int singletonChunkIdx = -1; + int singletonScIdx = -1; + + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (_singletonQuery.Matches(storage.Archetype)) + { + singletonStorageIdx = s; + singletonScIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + if (chunk.Count > 0) + { + singletonChunkIdx = c; + break; + } + } + } + } + for (int s = 0; s < world.Storages.Count; s++) { var storage = world.Storages[s]; @@ -129,6 +154,16 @@ public void Execute(World world) UpdateSmoothMove( ref lt, ref vignette, in controller, in stereo, in mode, in config, dt); + + // Write player position to StereoCamera for the render system + if (singletonStorageIdx >= 0 && singletonChunkIdx >= 0) + { + var sStorage = world.Storages[singletonStorageIdx]; + ref Chunk sChunk = ref sStorage.GetChunk(singletonChunkIdx); + ref StereoCamera sc = ref sChunk.GetComponent( + singletonScIdx, 0); + sc.PlayerPosition = lt.Position; + } } } } diff --git a/src/Seed.Engine/Rendering/StereoCamera.cs b/src/Seed.Engine/Rendering/StereoCamera.cs index d86fefd..57fd9e6 100644 --- a/src/Seed.Engine/Rendering/StereoCamera.cs +++ b/src/Seed.Engine/Rendering/StereoCamera.cs @@ -33,6 +33,12 @@ public struct StereoCamera : IComponent /// The HMD orientation in stage space, derived from the midpoint of left and right eye rotations. /// public Quaternion HmdRotation; + + /// + /// The player's locomotion position in world space, updated by . + /// Used by the stereo render system to offset XR views. + /// + public Vector3 PlayerPosition; } /// diff --git a/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs b/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs index 71b19e7..5ae1e63 100644 --- a/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs +++ b/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs @@ -64,6 +64,7 @@ public sealed class StereoForwardPlusRenderSystem : ISystem private readonly QueryDescription _meshQuery; private readonly QueryDescription _boundsQuery; private readonly QueryDescription _directionalLightQuery; + private readonly QueryDescription _stereoCameraQuery; /// /// Initializes a new . @@ -151,6 +152,10 @@ public StereoForwardPlusRenderSystem( _directionalLightQuery = new QueryBuilder() .WithRead() .Build(); + + _stereoCameraQuery = new QueryBuilder() + .WithRead() + .Build(); } /// @@ -201,10 +206,14 @@ public void Execute(World world) XrViewPair views = viewsResult.Value; + Vector3 playerPosition = FindPlayerPosition(world); + Matrix4x4 playerOffset = Matrix4x4.CreateTranslation( + -playerPosition.X, -playerPosition.Y, -playerPosition.Z); + Matrix4x4 leftVP = StereoCameraHelper.ComputeViewProjection( - views.Left, _nearPlane, _farPlane); + views.Left, _nearPlane, _farPlane) * playerOffset; Matrix4x4 rightVP = StereoCameraHelper.ComputeViewProjection( - views.Right, _nearPlane, _farPlane); + views.Right, _nearPlane, _farPlane) * playerOffset; _device.WaitForFence(_inFlightFence); _device.ResetFence(_inFlightFence); @@ -226,8 +235,8 @@ public void Execute(World world) _commandBuffer.PipelineBarrier(); } - Vector3 leftEyePos = views.Left.Pose.Position; - Vector3 rightEyePos = views.Right.Pose.Position; + Vector3 leftEyePos = views.Left.Pose.Position + playerPosition; + Vector3 rightEyePos = views.Right.Pose.Position + playerPosition; // Left eye var leftAcquire = _leftSwapChain.AcquireNextImage(IntPtr.Zero); @@ -503,7 +512,7 @@ private void ExecuteOpaquePass( IRenderTarget opaqueTarget, IDescriptorSet opaqueDescriptorSet) { - _commandBuffer.BeginRenderPass(opaqueTarget, 0f, 0f, 0f, 0f); + _commandBuffer.BeginRenderPass(opaqueTarget, 0f, 0f, 0f, 1f); _commandBuffer.BindPipeline(_opaquePipeline); _commandBuffer.SetViewport( 0, 0, opaqueTarget.Width, opaqueTarget.Height, 0f, 1f); @@ -590,6 +599,32 @@ private void ExecuteOpaquePass( _commandBuffer.EndRenderPass(); } + private Vector3 FindPlayerPosition(World world) + { + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_stereoCameraQuery.Matches(storage.Archetype)) + { + continue; + } + + int scIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + if (chunk.Count > 0) + { + return chunk.GetComponent(scIdx, 0).PlayerPosition; + } + } + } + + return Vector3.Zero; + } + private DirectionalLight FindDirectionalLight(World world) { for (int s = 0; s < world.Storages.Count; s++) From 4baca1f887061348f24b94bad0611b605a1fd0aa Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 8 Mar 2026 20:47:54 +0900 Subject: [PATCH 17/24] =?UTF-8?q?Forward+=20=E3=83=87=E3=82=A3=E3=82=B9?= =?UTF-8?q?=E3=82=AF=E3=83=AA=E3=83=97=E3=82=BF=E3=82=BB=E3=83=83=E3=83=88?= =?UTF-8?q?=E3=81=AB=E6=AD=A3=E3=81=97=E3=81=84=E3=83=90=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=83=87=E3=82=A3=E3=83=B3=E3=82=B0=E3=82=92=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E3=81=97=20VR=20=E3=81=AE=E9=BB=92=E7=94=BB=E9=9D=A2=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ステレオ Forward+ パイプラインのライトカリングおよびオペーク用 ディスクリプタセットが 1 バインディング(DirectionalLight バッファのみ) しか持っておらず、シェーダーが期待する 4〜5 バインディングと不一致だった。 シェーダーが DirectionalLight の float を uint タイルインデックスとして 読み込み NaN が発生し、全ライティング結果が黒になっていた。 - タイルバッファ(左右各 1)、ポイントライト/スポットライト GPU バッファ、 ライトカウントバッファを新規作成 - ライトカリング用ディスクリプタセットを 5 バインディングに修正 (深度テクスチャ、ポイントライト、スポットライト、タイル出力、ライトカウント) - オペーク用ディスクリプタセットを 4 バインディングに修正 (タイルデータ、ポイントライト、スポットライト、ライトカウント) - オペークパイプラインのレイアウト参照を修正 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine.App/Program.cs | 213 ++++++++++++++++++++++++++------- 1 file changed, 172 insertions(+), 41 deletions(-) diff --git a/src/Seed.Engine.App/Program.cs b/src/Seed.Engine.App/Program.cs index 04e6ffb..7d6e35a 100644 --- a/src/Seed.Engine.App/Program.cs +++ b/src/Seed.Engine.App/Program.cs @@ -1019,15 +1019,112 @@ private static void Main() using var lightCullingPipeline = lightCullingPipelineResult.Value; - // Light culling descriptor sets (per eye) + // GPU light buffers for Forward+ tile-based lighting + var gpuPointLight = new GpuPointLight + { + PositionAndRadius = new Vector4(3f, 2f, -2f, 8f), + ColorAndIntensity = new Vector4(1f, 0.3f, 0.1f, 5.0f), + }; + var gpuPointLightBufResult = device.CreateBuffer( + BufferUsage.Storage, + MemoryMarshal.AsBytes( + new ReadOnlySpan(in gpuPointLight))); + if (gpuPointLightBufResult.IsFailure) + { + Console.Error.WriteLine( + "Failed to create point light GPU buffer"); + return; + } + + using var gpuPointLightBuf = gpuPointLightBufResult.Value; + + var gpuSpotLight = new GpuSpotLight + { + PositionAndRadius = new Vector4(-3f, 3f, -3f, 10f), + DirectionAndInnerAngle = new Vector4( + 0.5f, -1f, -0.3f, MathF.PI / 12f), + ColorAndIntensity = new Vector4(0.2f, 0.5f, 1f, 3.0f), + OuterAngle = MathF.PI / 6f, + }; + var gpuSpotLightBufResult = device.CreateBuffer( + BufferUsage.Storage, + MemoryMarshal.AsBytes( + new ReadOnlySpan(in gpuSpotLight))); + if (gpuSpotLightBufResult.IsFailure) + { + Console.Error.WriteLine( + "Failed to create spot light GPU buffer"); + return; + } + + using var gpuSpotLightBuf = gpuSpotLightBufResult.Value; + + Span lightCountData = stackalloc uint[4]; + lightCountData[0] = 1; + lightCountData[1] = 1; + lightCountData[2] = xrWidth; + lightCountData[3] = xrHeight; + var gpuLightCountBufResult = device.CreateBuffer( + BufferUsage.Uniform, + MemoryMarshal.AsBytes(lightCountData)); + if (gpuLightCountBufResult.IsFailure) + { + Console.Error.WriteLine( + "Failed to create light count buffer"); + return; + } + + using var gpuLightCountBuf = gpuLightCountBufResult.Value; + + uint tileBufferSize = LightCullingResources.ComputeTileBufferSize( + xrWidth, xrHeight); + var tileZeros = new byte[tileBufferSize]; + + var leftTileBufResult = device.CreateBuffer( + BufferUsage.Storage, tileZeros); + if (leftTileBufResult.IsFailure) + { + Console.Error.WriteLine( + "Failed to create left tile buffer"); + return; + } + + using var leftTileBuf = leftTileBufResult.Value; + + var rightTileBufResult = device.CreateBuffer( + BufferUsage.Storage, tileZeros); + if (rightTileBufResult.IsFailure) + { + Console.Error.WriteLine( + "Failed to create right tile buffer"); + return; + } + + using var rightTileBuf = rightTileBufResult.Value; + + // Light culling descriptor sets (per eye, 5 bindings) var leftLcDescResult = device.CreateDescriptorSet( new DescriptorBinding[] { - new(0, DescriptorType.StorageBuffer, ShaderStage.Compute), + new(0, DescriptorType.CombinedImageSampler, + ShaderStage.Compute), + new(1, DescriptorType.StorageBuffer, + ShaderStage.Compute), + new(2, DescriptorType.StorageBuffer, + ShaderStage.Compute), + new(3, DescriptorType.StorageBuffer, + ShaderStage.Compute), + new(4, DescriptorType.UniformBuffer, + ShaderStage.Compute), }, new DescriptorWrite[] { - DescriptorWrite.ForBuffer(0, lightBuffer), + DescriptorWrite.ForTexture( + 0, leftDepthPrePass.DepthAttachment!), + DescriptorWrite.ForStorageBuffer(1, gpuPointLightBuf), + DescriptorWrite.ForStorageBuffer(2, gpuSpotLightBuf), + DescriptorWrite.ForStorageBuffer(3, leftTileBuf), + DescriptorWrite.ForBuffer(4, gpuLightCountBuf), }); if (leftLcDescResult.IsFailure) { @@ -1041,11 +1138,25 @@ private static void Main() var rightLcDescResult = device.CreateDescriptorSet( new DescriptorBinding[] { - new(0, DescriptorType.StorageBuffer, ShaderStage.Compute), + new(0, DescriptorType.CombinedImageSampler, + ShaderStage.Compute), + new(1, DescriptorType.StorageBuffer, + ShaderStage.Compute), + new(2, DescriptorType.StorageBuffer, + ShaderStage.Compute), + new(3, DescriptorType.StorageBuffer, + ShaderStage.Compute), + new(4, DescriptorType.UniformBuffer, + ShaderStage.Compute), }, new DescriptorWrite[] { - DescriptorWrite.ForBuffer(0, lightBuffer), + DescriptorWrite.ForTexture( + 0, rightDepthPrePass.DepthAttachment!), + DescriptorWrite.ForStorageBuffer(1, gpuPointLightBuf), + DescriptorWrite.ForStorageBuffer(2, gpuSpotLightBuf), + DescriptorWrite.ForStorageBuffer(3, rightTileBuf), + DescriptorWrite.ForBuffer(4, gpuLightCountBuf), }); if (rightLcDescResult.IsFailure) { @@ -1058,46 +1169,25 @@ private static void Main() var vulkanLeftOpaqueTarget = (VulkanRenderTarget)leftOpaqueTarget; - // Opaque pipeline (Forward+) - var stereoOpaquePipelineDesc = new PipelineDescription( - new ShaderDescription( - ShaderStage.Vertex, fwdOpaqueVert.Value, "main"), - new ShaderDescription( - ShaderStage.Fragment, fwdOpaqueFrag.Value, "main"), - GraphicsFormat.R8G8B8A8Unorm, - vertexLayout, - new DepthStencilState(true, true), - 1, - new IDescriptorSet[] { leftLcDesc }, - new[] { new PushConstantRange( - ShaderStage.Vertex | ShaderStage.Fragment, 0, 208) }, - true, - null, - PrimitiveTopology.TriangleList, - FrontFace.Clockwise, - 4); - - var stereoOpaquePipelineResult = device.CreatePipeline( - stereoOpaquePipelineDesc, vulkanLeftOpaqueTarget.RenderPass); - if (stereoOpaquePipelineResult.IsFailure) - { - Console.Error.WriteLine( - $"Failed to create stereo opaque pipeline: " - + $"{stereoOpaquePipelineResult.Error}"); - return; - } - - using var stereoOpaquePipeline = stereoOpaquePipelineResult.Value; - - // Opaque descriptor sets (per eye) + // Opaque descriptor sets (per eye, 4 bindings matching fp_opaque shader) var leftOpaqueDescResult = device.CreateDescriptorSet( new DescriptorBinding[] { - new(0, DescriptorType.StorageBuffer, ShaderStage.Fragment), + new(0, DescriptorType.StorageBuffer, + ShaderStage.Fragment), + new(1, DescriptorType.StorageBuffer, + ShaderStage.Fragment), + new(2, DescriptorType.StorageBuffer, + ShaderStage.Fragment), + new(3, DescriptorType.UniformBuffer, + ShaderStage.Fragment), }, new DescriptorWrite[] { - DescriptorWrite.ForBuffer(0, lightBuffer), + DescriptorWrite.ForStorageBuffer(0, leftTileBuf), + DescriptorWrite.ForStorageBuffer(1, gpuPointLightBuf), + DescriptorWrite.ForStorageBuffer(2, gpuSpotLightBuf), + DescriptorWrite.ForBuffer(3, gpuLightCountBuf), }); if (leftOpaqueDescResult.IsFailure) { @@ -1111,11 +1201,21 @@ private static void Main() var rightOpaqueDescResult = device.CreateDescriptorSet( new DescriptorBinding[] { - new(0, DescriptorType.StorageBuffer, ShaderStage.Fragment), + new(0, DescriptorType.StorageBuffer, + ShaderStage.Fragment), + new(1, DescriptorType.StorageBuffer, + ShaderStage.Fragment), + new(2, DescriptorType.StorageBuffer, + ShaderStage.Fragment), + new(3, DescriptorType.UniformBuffer, + ShaderStage.Fragment), }, new DescriptorWrite[] { - DescriptorWrite.ForBuffer(0, lightBuffer), + DescriptorWrite.ForStorageBuffer(0, rightTileBuf), + DescriptorWrite.ForStorageBuffer(1, gpuPointLightBuf), + DescriptorWrite.ForStorageBuffer(2, gpuSpotLightBuf), + DescriptorWrite.ForBuffer(3, gpuLightCountBuf), }); if (rightOpaqueDescResult.IsFailure) { @@ -1126,6 +1226,37 @@ private static void Main() using var rightOpaqueDescSet = rightOpaqueDescResult.Value; + // Opaque pipeline (Forward+) + var stereoOpaquePipelineDesc = new PipelineDescription( + new ShaderDescription( + ShaderStage.Vertex, fwdOpaqueVert.Value, "main"), + new ShaderDescription( + ShaderStage.Fragment, fwdOpaqueFrag.Value, "main"), + GraphicsFormat.R8G8B8A8Unorm, + vertexLayout, + new DepthStencilState(true, true), + 1, + new IDescriptorSet[] { leftOpaqueDescSet }, + new[] { new PushConstantRange( + ShaderStage.Vertex | ShaderStage.Fragment, 0, 208) }, + true, + null, + PrimitiveTopology.TriangleList, + FrontFace.Clockwise, + 4); + + var stereoOpaquePipelineResult = device.CreatePipeline( + stereoOpaquePipelineDesc, vulkanLeftOpaqueTarget.RenderPass); + if (stereoOpaquePipelineResult.IsFailure) + { + Console.Error.WriteLine( + $"Failed to create stereo opaque pipeline: " + + $"{stereoOpaquePipelineResult.Error}"); + return; + } + + using var stereoOpaquePipeline = stereoOpaquePipelineResult.Value; + // Transparent descriptor sets (composite pass samples from resolve textures) var leftTransDescResult = device.CreateDescriptorSet( new DescriptorBinding[] From e8859acd3948f4704547d6bb39af8bfc607b336c Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 8 Mar 2026 21:03:03 +0900 Subject: [PATCH 18/24] =?UTF-8?q?StereoCamera=20=E3=81=AB=20PlayerRotation?= =?UTF-8?q?=20=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=97=E3=82=B9=E3=83=8A?= =?UTF-8?q?=E3=83=83=E3=83=97=E3=82=BF=E3=83=BC=E3=83=B3=E3=81=AE=E5=9F=BA?= =?UTF-8?q?=E7=9B=A4=E3=82=92=E6=95=B4=E5=82=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SnapTurnState コンポーネント(エッジ検出用)、VrLocomotionConfig に SnapTurnAngle フィールド、StereoCamera に PlayerRotation クォータニオンを追加。 構造体サイズテストを 168 → 184 に更新。 Co-Authored-By: Claude Opus 4.6 --- .../Rendering/StereoCameraTests.cs | 7 ++++--- .../Ecs/Components/SnapTurnState.cs | 18 ++++++++++++++++++ .../Ecs/Components/VrLocomotionConfig.cs | 3 +++ src/Seed.Engine/Rendering/StereoCamera.cs | 6 ++++++ 4 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 src/Seed.Engine/Ecs/Components/SnapTurnState.cs diff --git a/src/Seed.Engine.Tests/Rendering/StereoCameraTests.cs b/src/Seed.Engine.Tests/Rendering/StereoCameraTests.cs index d262e12..f07087a 100644 --- a/src/Seed.Engine.Tests/Rendering/StereoCameraTests.cs +++ b/src/Seed.Engine.Tests/Rendering/StereoCameraTests.cs @@ -11,13 +11,14 @@ namespace Seed.Engine.Tests.Rendering; public class StereoCameraTests { [Fact] - public void StereoCamera_StructSize_Is168Bytes() + public void StereoCamera_StructSize_Is184Bytes() { // Given/When int size = Marshal.SizeOf(); - // Then: two Matrix4x4 (64 each) + Vector3 HmdPosition (12) + Quaternion (16) + Vector3 PlayerPosition (12) = 168 bytes - Assert.Equal(168, size); + // Then: two Matrix4x4 (64 each) + Vector3 HmdPosition (12) + Quaternion HmdRotation (16) + // + Vector3 PlayerPosition (12) + Quaternion PlayerRotation (16) = 184 bytes + Assert.Equal(184, size); } [Fact] diff --git a/src/Seed.Engine/Ecs/Components/SnapTurnState.cs b/src/Seed.Engine/Ecs/Components/SnapTurnState.cs new file mode 100644 index 0000000..6c9359d --- /dev/null +++ b/src/Seed.Engine/Ecs/Components/SnapTurnState.cs @@ -0,0 +1,18 @@ +using System.Runtime.InteropServices; + +namespace Seed.Engine.Ecs.Components; + +/// +/// Singleton component tracking whether the right thumbstick was deflected on the previous frame. +/// Used for edge detection so snap turn fires once per stick deflection. +/// +[StructLayout(LayoutKind.Sequential)] +public struct SnapTurnState : IComponent +{ + /// 1 if the stick was past the deadzone last frame, 0 otherwise. + public byte WasDeflected; + + private byte _pad0; + private byte _pad1; + private byte _pad2; +} diff --git a/src/Seed.Engine/Ecs/Components/VrLocomotionConfig.cs b/src/Seed.Engine/Ecs/Components/VrLocomotionConfig.cs index 106d96a..5b5d7be 100644 --- a/src/Seed.Engine/Ecs/Components/VrLocomotionConfig.cs +++ b/src/Seed.Engine/Ecs/Components/VrLocomotionConfig.cs @@ -22,4 +22,7 @@ public struct VrLocomotionConfig : IComponent /// Multiplier converting hand speed to movement speed. public float ArmSwingMultiplier; + + /// Snap turn angle in radians per activation (default PI/6 = 30 degrees). + public float SnapTurnAngle; } diff --git a/src/Seed.Engine/Rendering/StereoCamera.cs b/src/Seed.Engine/Rendering/StereoCamera.cs index 57fd9e6..4a0496f 100644 --- a/src/Seed.Engine/Rendering/StereoCamera.cs +++ b/src/Seed.Engine/Rendering/StereoCamera.cs @@ -39,6 +39,12 @@ public struct StereoCamera : IComponent /// Used by the stereo render system to offset XR views. /// public Vector3 PlayerPosition; + + /// + /// The player's locomotion rotation in world space, updated by snap turn. + /// Used by the stereo render system to rotate XR views. + /// + public Quaternion PlayerRotation; } /// From 01f0e1d778125e9a113d42c1aad71c6c2ab203e9 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 8 Mar 2026 21:03:10 +0900 Subject: [PATCH 19/24] =?UTF-8?q?=E5=8F=B3=E3=82=B9=E3=83=86=E3=82=A3?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=81=AB=E3=82=88=E3=82=8B=E3=82=B9=E3=83=8A?= =?UTF-8?q?=E3=83=83=E3=83=97=E3=82=BF=E3=83=BC=E3=83=B3=E3=82=B7=E3=82=B9?= =?UTF-8?q?=E3=83=86=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 VrSnapTurnSystem: 右スティック X 軸のデッドゾーン 0.5 でエッジ検出し、 1回のデフレクションで SnapTurnAngle(デフォルト30度)だけ回転。 コンフォートビネット対応。テスト8件追加。 Co-Authored-By: Claude Opus 4.6 --- .../GameLogic/VrSnapTurnSystemTests.cs | 261 ++++++++++++++++++ src/Seed.Engine/GameLogic/VrSnapTurnSystem.cs | 151 ++++++++++ 2 files changed, 412 insertions(+) create mode 100644 src/Seed.Engine.Tests/GameLogic/VrSnapTurnSystemTests.cs create mode 100644 src/Seed.Engine/GameLogic/VrSnapTurnSystem.cs diff --git a/src/Seed.Engine.Tests/GameLogic/VrSnapTurnSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/VrSnapTurnSystemTests.cs new file mode 100644 index 0000000..f8311bf --- /dev/null +++ b/src/Seed.Engine.Tests/GameLogic/VrSnapTurnSystemTests.cs @@ -0,0 +1,261 @@ +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.Rendering; +using Seed.Engine.Tests.Foundation.Mathematics; + +namespace Seed.Engine.Tests.GameLogic; + +public class VrSnapTurnSystemTests +{ + private static VrLocomotionConfig DefaultConfig() + { + return new VrLocomotionConfig + { + TeleportMaxDistance = 10.0f, + TeleportFadeDuration = 0.15f, + SmoothMoveSpeed = 2.0f, + ArmSwingMaxSpeed = 5.0f, + ArmSwingMultiplier = 2.0f, + SnapTurnAngle = MathF.PI / 6f, + }; + } + + private static (World world, Entity singleton, Entity player) SetupWorld() + { + var world = new World(); + + var vcType = ComponentRegistry.Get(); + var scType = ComponentRegistry.Get(); + var stType = ComponentRegistry.Get(); + var lcType = ComponentRegistry.Get(); + ReadOnlySpan singletonTypes = [vcType, scType, stType, lcType]; + Entity singleton = world.CreateEntity(singletonTypes); + + world.GetComponent(singleton) = new VrControllerState(); + world.GetComponent(singleton) = new StereoCamera + { + HmdRotation = Quaternion.Identity, + PlayerRotation = Quaternion.Identity, + }; + world.GetComponent(singleton) = new SnapTurnState(); + world.GetComponent(singleton) = DefaultConfig(); + + var vsType = ComponentRegistry.Get(); + var lmType = ComponentRegistry.Get(); + ReadOnlySpan playerTypes = [vsType, lmType]; + Entity player = world.CreateEntity(playerTypes); + + world.GetComponent(player) = new VignetteState(); + world.GetComponent(player) = new VrLocomotionMode + { + ActiveMode = LocomotionType.SmoothMove, + VignetteEnabled = 1, + }; + + return (world, singleton, player); + } + + [Fact] + public void Phase_IsGameLogic() + { + var system = new VrSnapTurnSystem(); + Assert.Equal(FramePhase.GameLogic, system.Phase); + } + + [Fact] + public void Execute_RightDeflection_RotatesPositiveY() + { + // Given: right stick deflected right (X=1.0) + var (world, singleton, _) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + RightThumbstick = new Vector2(1f, 0f), + }; + + var system = new VrSnapTurnSystem(); + + // When + system.Execute(world); + + // Then: PlayerRotation should have rotated around Y by +30 degrees + ref StereoCamera sc = ref world.GetComponent(singleton); + Quaternion expected = Quaternion.CreateFromAxisAngle( + Vector3.UnitY, MathF.PI / 6f); + AssertHelper.ApproximatelyEqual(expected.X, sc.PlayerRotation.X, 0.001f); + AssertHelper.ApproximatelyEqual(expected.Y, sc.PlayerRotation.Y, 0.001f); + AssertHelper.ApproximatelyEqual(expected.Z, sc.PlayerRotation.Z, 0.001f); + AssertHelper.ApproximatelyEqual(expected.W, sc.PlayerRotation.W, 0.001f); + } + + [Fact] + public void Execute_LeftDeflection_RotatesNegativeY() + { + // Given: right stick deflected left (X=-1.0) + var (world, singleton, _) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + RightThumbstick = new Vector2(-1f, 0f), + }; + + var system = new VrSnapTurnSystem(); + + // When + system.Execute(world); + + // Then: PlayerRotation should have rotated around Y by -30 degrees + ref StereoCamera sc = ref world.GetComponent(singleton); + Quaternion expected = Quaternion.CreateFromAxisAngle( + Vector3.UnitY, -MathF.PI / 6f); + AssertHelper.ApproximatelyEqual(expected.X, sc.PlayerRotation.X, 0.001f); + AssertHelper.ApproximatelyEqual(expected.Y, sc.PlayerRotation.Y, 0.001f); + AssertHelper.ApproximatelyEqual(expected.Z, sc.PlayerRotation.Z, 0.001f); + AssertHelper.ApproximatelyEqual(expected.W, sc.PlayerRotation.W, 0.001f); + } + + [Fact] + public void Execute_BelowDeadzone_DoesNotRotate() + { + // Given: right stick slightly deflected (below 0.5 deadzone) + var (world, singleton, _) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + RightThumbstick = new Vector2(0.4f, 0f), + }; + + var system = new VrSnapTurnSystem(); + + // When + system.Execute(world); + + // Then: PlayerRotation should remain identity + ref StereoCamera sc = ref world.GetComponent(singleton); + AssertHelper.ApproximatelyEqual(Quaternion.Identity.W, sc.PlayerRotation.W, 0.001f); + AssertHelper.ApproximatelyEqual(0f, sc.PlayerRotation.Y, 0.001f); + } + + [Fact] + public void Execute_HeldDeflection_FiresOnlyOnce() + { + // Given: right stick deflected right + var (world, singleton, _) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + RightThumbstick = new Vector2(1f, 0f), + }; + + var system = new VrSnapTurnSystem(); + + // When: execute twice without releasing + system.Execute(world); + Quaternion afterFirst = world.GetComponent(singleton).PlayerRotation; + system.Execute(world); + Quaternion afterSecond = world.GetComponent(singleton).PlayerRotation; + + // Then: second execution should not rotate further + AssertHelper.ApproximatelyEqual(afterFirst.X, afterSecond.X, 0.001f); + AssertHelper.ApproximatelyEqual(afterFirst.Y, afterSecond.Y, 0.001f); + AssertHelper.ApproximatelyEqual(afterFirst.Z, afterSecond.Z, 0.001f); + AssertHelper.ApproximatelyEqual(afterFirst.W, afterSecond.W, 0.001f); + } + + [Fact] + public void Execute_ReleaseAndDeflectAgain_FiresTwice() + { + // Given + var (world, singleton, _) = SetupWorld(); + using var _ = world; + + var system = new VrSnapTurnSystem(); + + // First deflection + world.GetComponent(singleton) = new VrControllerState + { + RightThumbstick = new Vector2(1f, 0f), + }; + system.Execute(world); + Quaternion afterFirst = world.GetComponent(singleton).PlayerRotation; + + // Release + world.GetComponent(singleton) = new VrControllerState + { + RightThumbstick = Vector2.Zero, + }; + system.Execute(world); + + // Second deflection + world.GetComponent(singleton) = new VrControllerState + { + RightThumbstick = new Vector2(1f, 0f), + }; + system.Execute(world); + Quaternion afterSecond = world.GetComponent(singleton).PlayerRotation; + + // Then: should have rotated twice (60 degrees total) + Quaternion expected = Quaternion.CreateFromAxisAngle( + Vector3.UnitY, MathF.PI / 3f); + AssertHelper.ApproximatelyEqual(expected.Y, afterSecond.Y, 0.01f); + AssertHelper.ApproximatelyEqual(expected.W, afterSecond.W, 0.01f); + } + + [Fact] + public void Execute_VignetteEnabled_SetsIntensity() + { + // Given + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(singleton) = new VrControllerState + { + RightThumbstick = new Vector2(1f, 0f), + }; + + var system = new VrSnapTurnSystem(); + + // When + system.Execute(world); + + // Then + ref VignetteState vs = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(1f, vs.Intensity, 0.01f); + } + + [Fact] + public void Execute_VignetteDisabled_DoesNotSetIntensity() + { + // Given + var (world, singleton, player) = SetupWorld(); + using var _ = world; + + world.GetComponent(player) = new VrLocomotionMode + { + ActiveMode = LocomotionType.SmoothMove, + VignetteEnabled = 0, + }; + world.GetComponent(singleton) = new VrControllerState + { + RightThumbstick = new Vector2(1f, 0f), + }; + + var system = new VrSnapTurnSystem(); + + // When + system.Execute(world); + + // Then + ref VignetteState vs = ref world.GetComponent(player); + AssertHelper.ApproximatelyEqual(0f, vs.Intensity, 0.01f); + } +} diff --git a/src/Seed.Engine/GameLogic/VrSnapTurnSystem.cs b/src/Seed.Engine/GameLogic/VrSnapTurnSystem.cs new file mode 100644 index 0000000..7bca99c --- /dev/null +++ b/src/Seed.Engine/GameLogic/VrSnapTurnSystem.cs @@ -0,0 +1,151 @@ +using System; + +using Seed.Engine.Ecs; +using Seed.Engine.Ecs.Components; +using Seed.Engine.Foundation.Mathematics; +using Seed.Engine.Rendering; + +namespace Seed.Engine.GameLogic; + +/// +/// Applies discrete snap turns when the right thumbstick is deflected left or right. +/// Uses edge detection so a single deflection produces exactly one rotation. +/// Runs in the GameLogic phase. +/// +public sealed class VrSnapTurnSystem : ISystem +{ + private const float Deadzone = 0.5f; + + private readonly QueryDescription _singletonQuery; + private readonly QueryDescription _playerQuery; + + /// + /// Initializes a new . + /// + public VrSnapTurnSystem() + { + _singletonQuery = new QueryBuilder() + .WithRead() + .WithWrite() + .WithWrite() + .WithRead() + .Build(); + + _playerQuery = new QueryBuilder() + .WithWrite() + .WithRead() + .Build(); + } + + /// + public FramePhase Phase => FramePhase.GameLogic; + + /// + public QueryDescription GetQuery() => _singletonQuery; + + /// + public void Execute(World world) + { + int singletonStorageIdx = -1; + int singletonChunkIdx = -1; + int scIdx = -1; + int stIdx = -1; + VrControllerState controller = default; + VrLocomotionConfig config = 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 vcIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + scIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + stIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int lcIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + + for (int c = 0; c < storage.ChunkCount; c++) + { + ref Chunk chunk = ref storage.GetChunk(c); + if (chunk.Count > 0) + { + controller = chunk.GetComponent(vcIdx, 0); + config = chunk.GetComponent(lcIdx, 0); + singletonStorageIdx = s; + singletonChunkIdx = c; + foundSingleton = true; + break; + } + } + + if (foundSingleton) + { + break; + } + } + + if (!foundSingleton) + { + return; + } + + float stickX = controller.RightThumbstick.X; + bool isDeflected = MathF.Abs(stickX) >= Deadzone; + + var sStorage = world.Storages[singletonStorageIdx]; + ref Chunk sChunk = ref sStorage.GetChunk(singletonChunkIdx); + ref SnapTurnState snapState = ref sChunk.GetComponent(stIdx, 0); + ref StereoCamera stereo = ref sChunk.GetComponent(scIdx, 0); + + bool risingEdge = isDeflected && snapState.WasDeflected == 0; + snapState.WasDeflected = isDeflected ? (byte)1 : (byte)0; + + if (!risingEdge) + { + return; + } + + float sign = stickX > 0f ? 1f : -1f; + Quaternion turn = Quaternion.CreateFromAxisAngle( + Vector3.UnitY, config.SnapTurnAngle * sign); + stereo.PlayerRotation = (stereo.PlayerRotation * turn).Normalize(); + + // Trigger comfort vignette on snap turn + for (int s = 0; s < world.Storages.Count; s++) + { + var storage = world.Storages[s]; + if (!_playerQuery.Matches(storage.Archetype)) + { + continue; + } + + int vsIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); + int lmIdx = 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 VrLocomotionMode mode = ref chunk.GetComponent(lmIdx, i); + if (mode.VignetteEnabled == 1) + { + ref VignetteState vignette = ref chunk.GetComponent(vsIdx, i); + vignette.Intensity = 1f; + } + } + } + } + } +} From 91aff533b7a69d38e39f4e4a3090b85081d849f3 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 8 Mar 2026 21:03:25 +0900 Subject: [PATCH 20/24] =?UTF-8?q?=E3=82=B9=E3=83=8A=E3=83=83=E3=83=97?= =?UTF-8?q?=E3=82=BF=E3=83=BC=E3=83=B3=E5=9B=9E=E8=BB=A2=E3=82=92=E3=83=AC?= =?UTF-8?q?=E3=83=B3=E3=83=80=E3=83=AA=E3=83=B3=E3=82=B0=E3=81=A8=E7=A7=BB?= =?UTF-8?q?=E5=8B=95=E6=96=B9=E5=90=91=E3=81=AB=E5=8F=8D=E6=98=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StereoForwardPlusRenderSystem: PlayerRotation を VP 計算と視点位置に適用。 VrSmoothMoveSystem/VrArmSwingSystem: 移動方向に PlayerRotation を合成。 Program.cs: SnapTurnState 登録、PlayerRotation/SnapTurnAngle 初期化、 VrSnapTurnSystem を VrSmoothMoveSystem の前に配置。 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine.App/Program.cs | 6 ++++- .../GameLogic/VrArmSwingSystemTests.cs | 2 ++ .../GameLogic/VrSmoothMoveSystemTests.cs | 1 + src/Seed.Engine/GameLogic/VrArmSwingSystem.cs | 3 ++- .../GameLogic/VrSmoothMoveSystem.cs | 3 ++- .../StereoForwardPlusRenderSystem.cs | 22 +++++++++++++------ 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/Seed.Engine.App/Program.cs b/src/Seed.Engine.App/Program.cs index 7d6e35a..88c784f 100644 --- a/src/Seed.Engine.App/Program.cs +++ b/src/Seed.Engine.App/Program.cs @@ -557,6 +557,7 @@ private static void Main() if (renderMode == RenderMode.Stereo && xrSessionForInit != null) { + scheduler.AddSystem(new VrSnapTurnSystem()); scheduler.AddSystem(new VrSmoothMoveSystem()); scheduler.AddSystem(new VrGrabSystem()); scheduler.AddSystem(new VrDistanceGrabSystem()); @@ -1625,13 +1626,15 @@ private static void CreateSingletonEntity( var scType = ComponentRegistry.Get(); var lmType = ComponentRegistry.Get(); var lcType = ComponentRegistry.Get(); + var stType = ComponentRegistry.Get(); ReadOnlySpan types = - [ftType, isType, vrType, hsType, hrType, scType, lmType, lcType]; + [ftType, isType, vrType, hsType, hrType, scType, lmType, lcType, stType]; Entity singletonEntity = world.CreateEntity(types); world.GetComponent(singletonEntity) = new StereoCamera { HmdRotation = Quaternion.Identity, + PlayerRotation = Quaternion.Identity, }; world.GetComponent(singletonEntity) = new VrLocomotionMode @@ -1648,6 +1651,7 @@ private static void CreateSingletonEntity( SmoothMoveSpeed = 2f, ArmSwingMaxSpeed = 5f, ArmSwingMultiplier = 1.5f, + SnapTurnAngle = MathF.PI / 6f, }; } else diff --git a/src/Seed.Engine.Tests/GameLogic/VrArmSwingSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/VrArmSwingSystemTests.cs index 1d970ef..8a62960 100644 --- a/src/Seed.Engine.Tests/GameLogic/VrArmSwingSystemTests.cs +++ b/src/Seed.Engine.Tests/GameLogic/VrArmSwingSystemTests.cs @@ -41,6 +41,7 @@ private static (World world, Entity singleton, Entity player) SetupWorld() world.GetComponent(singleton) = new StereoCamera { HmdRotation = Quaternion.Identity, + PlayerRotation = Quaternion.Identity, }; world.GetComponent(singleton) = new VrLocomotionMode { @@ -172,6 +173,7 @@ public void Execute_UsesHmdForward() world.GetComponent(singleton) = new StereoCamera { HmdRotation = yaw90, + PlayerRotation = Quaternion.Identity, }; world.GetComponent(singleton) = new VrHandState { diff --git a/src/Seed.Engine.Tests/GameLogic/VrSmoothMoveSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/VrSmoothMoveSystemTests.cs index 3a18e55..bdcb2d7 100644 --- a/src/Seed.Engine.Tests/GameLogic/VrSmoothMoveSystemTests.cs +++ b/src/Seed.Engine.Tests/GameLogic/VrSmoothMoveSystemTests.cs @@ -41,6 +41,7 @@ private static (World world, Entity singleton, Entity player) SetupWorld() world.GetComponent(singleton) = new StereoCamera { HmdRotation = Quaternion.Identity, + PlayerRotation = Quaternion.Identity, }; world.GetComponent(singleton) = new VrLocomotionMode { diff --git a/src/Seed.Engine/GameLogic/VrArmSwingSystem.cs b/src/Seed.Engine/GameLogic/VrArmSwingSystem.cs index 1b860e9..262d7d4 100644 --- a/src/Seed.Engine/GameLogic/VrArmSwingSystem.cs +++ b/src/Seed.Engine/GameLogic/VrArmSwingSystem.cs @@ -108,7 +108,8 @@ public void Execute(World world) float moveSpeed = MathHelper.Clamp( avgSpeed * config.ArmSwingMultiplier, 0f, config.ArmSwingMaxSpeed); - Vector3 forward = VrLocomotionHelper.ComputeForwardXZ(stereo.HmdRotation); + Quaternion worldRotation = stereo.PlayerRotation * stereo.HmdRotation; + Vector3 forward = VrLocomotionHelper.ComputeForwardXZ(worldRotation); for (int s = 0; s < world.Storages.Count; s++) { diff --git a/src/Seed.Engine/GameLogic/VrSmoothMoveSystem.cs b/src/Seed.Engine/GameLogic/VrSmoothMoveSystem.cs index 50ab423..de3fe19 100644 --- a/src/Seed.Engine/GameLogic/VrSmoothMoveSystem.cs +++ b/src/Seed.Engine/GameLogic/VrSmoothMoveSystem.cs @@ -217,9 +217,10 @@ private static Vector3 ComputeForwardXZ( in StereoCamera stereo, in VrControllerState controller) { - Quaternion rotation = mode.DirectionSource == 0 + Quaternion sourceRotation = mode.DirectionSource == 0 ? stereo.HmdRotation : controller.LeftRotation; + Quaternion rotation = stereo.PlayerRotation * sourceRotation; return VrLocomotionHelper.ComputeForwardXZ(rotation); } diff --git a/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs b/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs index 5ae1e63..48e8e5d 100644 --- a/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs +++ b/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs @@ -206,9 +206,14 @@ public void Execute(World world) XrViewPair views = viewsResult.Value; - Vector3 playerPosition = FindPlayerPosition(world); - Matrix4x4 playerOffset = Matrix4x4.CreateTranslation( + var playerTransform = FindPlayerTransform(world); + Vector3 playerPosition = playerTransform.Position; + Quaternion playerRotation = playerTransform.Rotation; + + Matrix4x4 rotationOffset = playerRotation.Conjugate().ToMatrix4x4(); + Matrix4x4 translationOffset = Matrix4x4.CreateTranslation( -playerPosition.X, -playerPosition.Y, -playerPosition.Z); + Matrix4x4 playerOffset = rotationOffset * translationOffset; Matrix4x4 leftVP = StereoCameraHelper.ComputeViewProjection( views.Left, _nearPlane, _farPlane) * playerOffset; @@ -235,8 +240,10 @@ public void Execute(World world) _commandBuffer.PipelineBarrier(); } - Vector3 leftEyePos = views.Left.Pose.Position + playerPosition; - Vector3 rightEyePos = views.Right.Pose.Position + playerPosition; + Vector3 leftEyePos = playerRotation.RotateVector(views.Left.Pose.Position) + + playerPosition; + Vector3 rightEyePos = playerRotation.RotateVector(views.Right.Pose.Position) + + playerPosition; // Left eye var leftAcquire = _leftSwapChain.AcquireNextImage(IntPtr.Zero); @@ -599,7 +606,7 @@ private void ExecuteOpaquePass( _commandBuffer.EndRenderPass(); } - private Vector3 FindPlayerPosition(World world) + private (Vector3 Position, Quaternion Rotation) FindPlayerTransform(World world) { for (int s = 0; s < world.Storages.Count; s++) { @@ -617,12 +624,13 @@ private Vector3 FindPlayerPosition(World world) ref Chunk chunk = ref storage.GetChunk(c); if (chunk.Count > 0) { - return chunk.GetComponent(scIdx, 0).PlayerPosition; + ref StereoCamera sc = ref chunk.GetComponent(scIdx, 0); + return (sc.PlayerPosition, sc.PlayerRotation); } } } - return Vector3.Zero; + return (Vector3.Zero, Quaternion.Identity); } private DirectionalLight FindDirectionalLight(World world) From 6edb84767b71ac949872cf9745af1388bf5dd6ab Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 8 Mar 2026 21:06:08 +0900 Subject: [PATCH 21/24] =?UTF-8?q?=E5=8F=B3=E6=89=8B=E7=B3=BB=E3=81=AB?= =?UTF-8?q?=E5=90=88=E3=82=8F=E3=81=9B=E3=81=A6=E3=82=B9=E3=83=8A=E3=83=83?= =?UTF-8?q?=E3=83=97=E3=82=BF=E3=83=BC=E3=83=B3=E3=81=AE=E5=9B=9E=E8=BB=A2?= =?UTF-8?q?=E6=96=B9=E5=90=91=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 右スティック右倒しで負の Y 回転(右回転)、左倒しで正の Y 回転(左回転)に修正。 Co-Authored-By: Claude Opus 4.6 --- .../GameLogic/VrSnapTurnSystemTests.cs | 16 ++++++++-------- src/Seed.Engine/GameLogic/VrSnapTurnSystem.cs | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Seed.Engine.Tests/GameLogic/VrSnapTurnSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/VrSnapTurnSystemTests.cs index f8311bf..a26e565 100644 --- a/src/Seed.Engine.Tests/GameLogic/VrSnapTurnSystemTests.cs +++ b/src/Seed.Engine.Tests/GameLogic/VrSnapTurnSystemTests.cs @@ -68,7 +68,7 @@ public void Phase_IsGameLogic() } [Fact] - public void Execute_RightDeflection_RotatesPositiveY() + public void Execute_RightDeflection_RotatesNegativeY() { // Given: right stick deflected right (X=1.0) var (world, singleton, _) = SetupWorld(); @@ -84,10 +84,10 @@ public void Execute_RightDeflection_RotatesPositiveY() // When system.Execute(world); - // Then: PlayerRotation should have rotated around Y by +30 degrees + // Then: PlayerRotation should have rotated around Y by -30 degrees (right turn in right-hand system) ref StereoCamera sc = ref world.GetComponent(singleton); Quaternion expected = Quaternion.CreateFromAxisAngle( - Vector3.UnitY, MathF.PI / 6f); + Vector3.UnitY, -MathF.PI / 6f); AssertHelper.ApproximatelyEqual(expected.X, sc.PlayerRotation.X, 0.001f); AssertHelper.ApproximatelyEqual(expected.Y, sc.PlayerRotation.Y, 0.001f); AssertHelper.ApproximatelyEqual(expected.Z, sc.PlayerRotation.Z, 0.001f); @@ -95,7 +95,7 @@ public void Execute_RightDeflection_RotatesPositiveY() } [Fact] - public void Execute_LeftDeflection_RotatesNegativeY() + public void Execute_LeftDeflection_RotatesPositiveY() { // Given: right stick deflected left (X=-1.0) var (world, singleton, _) = SetupWorld(); @@ -111,10 +111,10 @@ public void Execute_LeftDeflection_RotatesNegativeY() // When system.Execute(world); - // Then: PlayerRotation should have rotated around Y by -30 degrees + // Then: PlayerRotation should have rotated around Y by +30 degrees (left turn in right-hand system) ref StereoCamera sc = ref world.GetComponent(singleton); Quaternion expected = Quaternion.CreateFromAxisAngle( - Vector3.UnitY, -MathF.PI / 6f); + Vector3.UnitY, MathF.PI / 6f); AssertHelper.ApproximatelyEqual(expected.X, sc.PlayerRotation.X, 0.001f); AssertHelper.ApproximatelyEqual(expected.Y, sc.PlayerRotation.Y, 0.001f); AssertHelper.ApproximatelyEqual(expected.Z, sc.PlayerRotation.Z, 0.001f); @@ -203,9 +203,9 @@ public void Execute_ReleaseAndDeflectAgain_FiresTwice() system.Execute(world); Quaternion afterSecond = world.GetComponent(singleton).PlayerRotation; - // Then: should have rotated twice (60 degrees total) + // Then: should have rotated twice (-60 degrees total) Quaternion expected = Quaternion.CreateFromAxisAngle( - Vector3.UnitY, MathF.PI / 3f); + Vector3.UnitY, -MathF.PI / 3f); AssertHelper.ApproximatelyEqual(expected.Y, afterSecond.Y, 0.01f); AssertHelper.ApproximatelyEqual(expected.W, afterSecond.W, 0.01f); } diff --git a/src/Seed.Engine/GameLogic/VrSnapTurnSystem.cs b/src/Seed.Engine/GameLogic/VrSnapTurnSystem.cs index 7bca99c..4d80a6c 100644 --- a/src/Seed.Engine/GameLogic/VrSnapTurnSystem.cs +++ b/src/Seed.Engine/GameLogic/VrSnapTurnSystem.cs @@ -112,7 +112,7 @@ public void Execute(World world) return; } - float sign = stickX > 0f ? 1f : -1f; + float sign = stickX > 0f ? -1f : 1f; Quaternion turn = Quaternion.CreateFromAxisAngle( Vector3.UnitY, config.SnapTurnAngle * sign); stereo.PlayerRotation = (stereo.PlayerRotation * turn).Normalize(); From 092297010ddb81094c4846db00de1acef323a774 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 8 Mar 2026 21:12:01 +0900 Subject: [PATCH 22/24] =?UTF-8?q?VR=20=E3=82=B0=E3=83=A9=E3=83=96=E3=81=AB?= =?UTF-8?q?=E3=83=97=E3=83=AC=E3=82=A4=E3=83=A4=E3=83=BC=E7=A7=BB=E5=8B=95?= =?UTF-8?q?=E3=83=BB=E5=9B=9E=E8=BB=A2=E3=81=AE=E8=BF=BD=E5=BE=93=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 VrGrabSystem / VrDistanceGrabSystem でハンド位置をステージ空間から ワールド空間へ変換するように修正。移動後のグラブ位置ズレ、 グラブ中の移動・回転追従、投擲方向の回転反映を解決。 Co-Authored-By: Claude Opus 4.6 --- .../GameLogic/VrDistanceGrabSystemTests.cs | 9 +++++- .../GameLogic/VrGrabSystemTests.cs | 11 ++++++-- .../GameLogic/VrDistanceGrabSystem.cs | 20 +++++++++---- src/Seed.Engine/GameLogic/VrGrabSystem.cs | 28 +++++++++++++++++-- 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/src/Seed.Engine.Tests/GameLogic/VrDistanceGrabSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/VrDistanceGrabSystemTests.cs index 1f34403..3fd5765 100644 --- a/src/Seed.Engine.Tests/GameLogic/VrDistanceGrabSystemTests.cs +++ b/src/Seed.Engine.Tests/GameLogic/VrDistanceGrabSystemTests.cs @@ -5,6 +5,7 @@ using Seed.Engine.Ecs.Components; using Seed.Engine.Foundation.Mathematics; using Seed.Engine.GameLogic; +using Seed.Engine.Rendering; using Seed.Engine.Tests.Foundation.Mathematics; namespace Seed.Engine.Tests.GameLogic; @@ -21,7 +22,8 @@ private static (World world, Entity singleton, Entity grabbable) SetupWorld( // Singleton entity var ftType = ComponentRegistry.Get(); var vrType = ComponentRegistry.Get(); - ReadOnlySpan singletonTypes = [ftType, vrType]; + var scType = ComponentRegistry.Get(); + ReadOnlySpan singletonTypes = [ftType, vrType, scType]; Entity singleton = world.CreateEntity(singletonTypes); world.GetComponent(singleton) = new FrameTime { @@ -32,6 +34,11 @@ private static (World world, Entity singleton, Entity grabbable) SetupWorld( RightPosition = handPos, RightRotation = handRot, }; + world.GetComponent(singleton) = new StereoCamera + { + HmdRotation = Quaternion.Identity, + PlayerRotation = Quaternion.Identity, + }; // Grabbable entity var grabbType = ComponentRegistry.Get(); diff --git a/src/Seed.Engine.Tests/GameLogic/VrGrabSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/VrGrabSystemTests.cs index 5703319..43de0fe 100644 --- a/src/Seed.Engine.Tests/GameLogic/VrGrabSystemTests.cs +++ b/src/Seed.Engine.Tests/GameLogic/VrGrabSystemTests.cs @@ -5,6 +5,7 @@ using Seed.Engine.Ecs.Components; using Seed.Engine.Foundation.Mathematics; using Seed.Engine.GameLogic; +using Seed.Engine.Rendering; using Seed.Engine.Tests.Foundation.Mathematics; namespace Seed.Engine.Tests.GameLogic; @@ -18,12 +19,13 @@ private static (World world, Entity singleton, Entity grabbable) SetupWorld( { var world = new World(); - // Singleton entity with VR state + HapticRequest + // Singleton entity with VR state + HapticRequest + StereoCamera var ftType = ComponentRegistry.Get(); var vrType = ComponentRegistry.Get(); var hsType = ComponentRegistry.Get(); var hrType = ComponentRegistry.Get(); - ReadOnlySpan singletonTypes = [ftType, vrType, hsType, hrType]; + var scType = ComponentRegistry.Get(); + ReadOnlySpan singletonTypes = [ftType, vrType, hsType, hrType, scType]; Entity singleton = world.CreateEntity(singletonTypes); world.GetComponent(singleton) = new FrameTime { @@ -36,6 +38,11 @@ private static (World world, Entity singleton, Entity grabbable) SetupWorld( }; world.GetComponent(singleton) = new VrHandState(); world.GetComponent(singleton) = new HapticRequest(); + world.GetComponent(singleton) = new StereoCamera + { + HmdRotation = Quaternion.Identity, + PlayerRotation = Quaternion.Identity, + }; // Grabbable entity with SpringJoint and Velocity var grabbType = ComponentRegistry.Get(); diff --git a/src/Seed.Engine/GameLogic/VrDistanceGrabSystem.cs b/src/Seed.Engine/GameLogic/VrDistanceGrabSystem.cs index 11ef6d3..9e233ad 100644 --- a/src/Seed.Engine/GameLogic/VrDistanceGrabSystem.cs +++ b/src/Seed.Engine/GameLogic/VrDistanceGrabSystem.cs @@ -1,6 +1,7 @@ using Seed.Engine.Ecs; using Seed.Engine.Ecs.Components; using Seed.Engine.Foundation.Mathematics; +using Seed.Engine.Rendering; namespace Seed.Engine.GameLogic; @@ -22,6 +23,7 @@ public VrDistanceGrabSystem() _singletonQuery = new QueryBuilder() .WithRead() .WithRead() + .WithRead() .Build(); _grabbableQuery = new QueryBuilder() @@ -43,6 +45,7 @@ public VrDistanceGrabSystem() public void Execute(World world) { VrControllerState vrState = default; + StereoCamera stereo = default; float dt = 0f; Entity vrPlayerEntity = Entity.Null; bool foundSingleton = false; @@ -59,6 +62,8 @@ public void Execute(World world) ComponentRegistry.Get().TypeId); int ftIdx = storage.GetComponentIndex( ComponentRegistry.Get().TypeId); + int scIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); for (int c = 0; c < storage.ChunkCount; c++) { @@ -67,6 +72,7 @@ public void Execute(World world) { vrState = chunk.GetComponent(vrIdx, 0); dt = chunk.GetComponent(ftIdx, 0).DeltaTime; + stereo = chunk.GetComponent(scIdx, 0); vrPlayerEntity = chunk.GetEntity(0); foundSingleton = true; break; @@ -97,15 +103,17 @@ public void Execute(World world) if (rightTrigger) { - handPos = vrState.RightPosition; - handForward = vrState.RightRotation.RotateVector( - new Vector3(0f, 0f, -1f)); + handPos = stereo.PlayerRotation.RotateVector(vrState.RightPosition) + + stereo.PlayerPosition; + Quaternion worldRot = stereo.PlayerRotation * vrState.RightRotation; + handForward = worldRot.RotateVector(new Vector3(0f, 0f, -1f)); } else { - handPos = vrState.LeftPosition; - handForward = vrState.LeftRotation.RotateVector( - new Vector3(0f, 0f, -1f)); + handPos = stereo.PlayerRotation.RotateVector(vrState.LeftPosition) + + stereo.PlayerPosition; + Quaternion worldRot = stereo.PlayerRotation * vrState.LeftRotation; + handForward = worldRot.RotateVector(new Vector3(0f, 0f, -1f)); } float closestDist = float.MaxValue; diff --git a/src/Seed.Engine/GameLogic/VrGrabSystem.cs b/src/Seed.Engine/GameLogic/VrGrabSystem.cs index 5d7db60..11a94cd 100644 --- a/src/Seed.Engine/GameLogic/VrGrabSystem.cs +++ b/src/Seed.Engine/GameLogic/VrGrabSystem.cs @@ -1,6 +1,7 @@ using Seed.Engine.Ecs; using Seed.Engine.Ecs.Components; using Seed.Engine.Foundation.Mathematics; +using Seed.Engine.Rendering; namespace Seed.Engine.GameLogic; @@ -26,6 +27,7 @@ public VrGrabSystem() .WithRead() .WithRead() .WithRead() + .WithRead() .WithWrite() .Build(); @@ -49,6 +51,7 @@ public void Execute(World world) { VrControllerState vrState = default; VrHandState handState = default; + StereoCamera stereo = default; Entity vrPlayerEntity = Entity.Null; bool foundSingleton = false; int singletonStorageIdx = -1; @@ -67,6 +70,8 @@ public void Execute(World world) ComponentRegistry.Get().TypeId); int hsIdx = storage.GetComponentIndex( ComponentRegistry.Get().TypeId); + int scIdx = storage.GetComponentIndex( + ComponentRegistry.Get().TypeId); for (int c = 0; c < storage.ChunkCount; c++) { @@ -75,6 +80,7 @@ public void Execute(World world) { vrState = chunk.GetComponent(vrIdx, 0); handState = chunk.GetComponent(hsIdx, 0); + stereo = chunk.GetComponent(scIdx, 0); vrPlayerEntity = chunk.GetEntity(0); singletonStorageIdx = s; singletonChunkIdx = c; @@ -95,6 +101,9 @@ public void Execute(World world) return; } + // Transform hand positions from stage space to world space + vrState = TransformToWorldSpace(vrState, stereo); + bool leftGripActive = vrState.LeftGripPressed == 1 || vrState.LeftTriggerPressed == 1; bool rightGripActive = vrState.RightGripPressed == 1 @@ -141,7 +150,7 @@ public void Execute(World world) { HandleRelease(ref gs, ref lt, ref sj, ref vel, leftGripActive, rightGripActive, - vrState, handState); + vrState, handState, stereo); } else { @@ -245,7 +254,8 @@ private static void HandleRelease( ref GrabState gs, ref LocalTransform lt, ref SpringJoint sj, ref Velocity vel, bool leftGripActive, bool rightGripActive, - VrControllerState vrState, VrHandState handState) + VrControllerState vrState, VrHandState handState, + StereoCamera stereo) { bool isLeftHand = gs.GrabbedByHand == 1; bool shouldRelease = isLeftHand ? !leftGripActive : !rightGripActive; @@ -261,9 +271,11 @@ private static void HandleRelease( sj.TargetAnchor = Vector3.Zero; // Transfer hand velocity to the released object for throwing - vel.Linear = isLeftHand + // Rotate velocity from stage space to world space + Vector3 stageVel = isLeftHand ? handState.LeftVelocity : handState.RightVelocity; + vel.Linear = stereo.PlayerRotation.RotateVector(stageVel); } else { @@ -276,4 +288,14 @@ private static void HandleRelease( vel.Angular = Vector3.Zero; } } + + private static VrControllerState TransformToWorldSpace( + VrControllerState state, StereoCamera stereo) + { + state.LeftPosition = stereo.PlayerRotation.RotateVector(state.LeftPosition) + + stereo.PlayerPosition; + state.RightPosition = stereo.PlayerRotation.RotateVector(state.RightPosition) + + stereo.PlayerPosition; + return state; + } } From 4e43a21047be38f6f3188d29fcde2e7d06f60a08 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 8 Mar 2026 21:17:27 +0900 Subject: [PATCH 23/24] =?UTF-8?q?GrabOffset=20=E3=82=92=E3=82=B9=E3=83=86?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E7=A9=BA=E9=96=93=E3=81=A7=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E3=81=97=E3=82=B9=E3=83=8A=E3=83=83=E3=83=97=E3=82=BF=E3=83=BC?= =?UTF-8?q?=E3=83=B3=E6=99=82=E3=81=AE=E8=BF=BD=E5=BE=93=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 グラブ時のオフセットを PlayerRotation の逆回転でステージ空間に変換して保存し、 毎フレーム現在の PlayerRotation でワールド空間に戻すことで、 スナップターン中もグラブ対象がプレイヤーの回転に追従するように修正。 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine/GameLogic/VrGrabSystem.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Seed.Engine/GameLogic/VrGrabSystem.cs b/src/Seed.Engine/GameLogic/VrGrabSystem.cs index 11a94cd..34b1efe 100644 --- a/src/Seed.Engine/GameLogic/VrGrabSystem.cs +++ b/src/Seed.Engine/GameLogic/VrGrabSystem.cs @@ -156,7 +156,7 @@ public void Execute(World world) { bool grabbed = TryDirectGrab( ref gs, ref lt, ref grabbable, ref sj, - vrState, vrPlayerEntity, + vrState, vrPlayerEntity, stereo, leftGripActive, rightGripActive, out bool grabbedByLeft); @@ -180,11 +180,13 @@ private static bool TryDirectGrab( ref GrabState gs, ref LocalTransform lt, ref Grabbable grabbable, ref SpringJoint sj, VrControllerState vrState, Entity vrPlayerEntity, + StereoCamera stereo, bool leftGrip, bool rightGrip, out bool isLeftHand) { isLeftHand = false; float grabRadiusSq = grabbable.GrabRadius * grabbable.GrabRadius; + Quaternion inversePlayerRot = stereo.PlayerRotation.Conjugate(); if (leftGrip) { @@ -195,7 +197,9 @@ private static bool TryDirectGrab( gs.IsGrabbed = 1; gs.GrabbedByHand = 1; gs.GrabberEntity = vrPlayerEntity; - gs.GrabOffset = lt.Position - vrState.LeftPosition; + // Store offset in stage space so it rotates with the player + Vector3 worldOffset = lt.Position - vrState.LeftPosition; + gs.GrabOffset = inversePlayerRot.RotateVector(worldOffset); gs.GrabRotationOffset = lt.Rotation; AssignSpringJoint(ref sj, vrState.LeftPosition); isLeftHand = true; @@ -212,7 +216,8 @@ private static bool TryDirectGrab( gs.IsGrabbed = 1; gs.GrabbedByHand = 2; gs.GrabberEntity = vrPlayerEntity; - gs.GrabOffset = lt.Position - vrState.RightPosition; + Vector3 worldOffset = lt.Position - vrState.RightPosition; + gs.GrabOffset = inversePlayerRot.RotateVector(worldOffset); gs.GrabRotationOffset = lt.Rotation; AssignSpringJoint(ref sj, vrState.RightPosition); return true; @@ -282,7 +287,9 @@ private static void HandleRelease( Vector3 handPos = isLeftHand ? vrState.LeftPosition : vrState.RightPosition; - lt.Position = handPos + gs.GrabOffset; + // GrabOffset is in stage space; rotate to world space + Vector3 worldOffset = stereo.PlayerRotation.RotateVector(gs.GrabOffset); + lt.Position = handPos + worldOffset; sj.TargetAnchor = handPos; vel.Linear = Vector3.Zero; vel.Angular = Vector3.Zero; From 07b8294a57fc80a3e978efe8e3654b7e0db13bd5 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sun, 8 Mar 2026 21:24:02 +0900 Subject: [PATCH 24/24] =?UTF-8?q?=E3=82=B0=E3=83=A9=E3=83=96=E4=B8=AD?= =?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?=E5=90=91=E3=81=8D=E3=82=82=E3=82=B9=E3=83=8A=E3=83=83=E3=83=97?= =?UTF-8?q?=E3=82=BF=E3=83=BC=E3=83=B3=E3=81=AB=E8=BF=BD=E5=BE=93=E3=81=95?= =?UTF-8?q?=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GrabRotationOffset をステージ空間で保存し、毎フレーム PlayerRotation を 合成してオブジェクトの向きを更新。スナップターン後も掴んだ物体の 見え方が変わらないように修正。 Co-Authored-By: Claude Opus 4.6 --- src/Seed.Engine/GameLogic/VrGrabSystem.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Seed.Engine/GameLogic/VrGrabSystem.cs b/src/Seed.Engine/GameLogic/VrGrabSystem.cs index 34b1efe..387b98e 100644 --- a/src/Seed.Engine/GameLogic/VrGrabSystem.cs +++ b/src/Seed.Engine/GameLogic/VrGrabSystem.cs @@ -197,10 +197,10 @@ private static bool TryDirectGrab( gs.IsGrabbed = 1; gs.GrabbedByHand = 1; gs.GrabberEntity = vrPlayerEntity; - // Store offset in stage space so it rotates with the player + // Store offset and rotation in stage space so they rotate with the player Vector3 worldOffset = lt.Position - vrState.LeftPosition; gs.GrabOffset = inversePlayerRot.RotateVector(worldOffset); - gs.GrabRotationOffset = lt.Rotation; + gs.GrabRotationOffset = (inversePlayerRot * lt.Rotation).Normalize(); AssignSpringJoint(ref sj, vrState.LeftPosition); isLeftHand = true; return true; @@ -218,7 +218,7 @@ private static bool TryDirectGrab( gs.GrabberEntity = vrPlayerEntity; Vector3 worldOffset = lt.Position - vrState.RightPosition; gs.GrabOffset = inversePlayerRot.RotateVector(worldOffset); - gs.GrabRotationOffset = lt.Rotation; + gs.GrabRotationOffset = (inversePlayerRot * lt.Rotation).Normalize(); AssignSpringJoint(ref sj, vrState.RightPosition); return true; } @@ -287,9 +287,10 @@ private static void HandleRelease( Vector3 handPos = isLeftHand ? vrState.LeftPosition : vrState.RightPosition; - // GrabOffset is in stage space; rotate to world space + // GrabOffset and GrabRotationOffset are in stage space; rotate to world space Vector3 worldOffset = stereo.PlayerRotation.RotateVector(gs.GrabOffset); lt.Position = handPos + worldOffset; + lt.Rotation = (stereo.PlayerRotation * gs.GrabRotationOffset).Normalize(); sj.TargetAnchor = handPos; vel.Linear = Vector3.Zero; vel.Angular = Vector3.Zero;