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方式の移動が選べる。酔い対策も入っている。 diff --git a/src/Seed.Engine.App/Program.cs b/src/Seed.Engine.App/Program.cs index 1f43435..88c784f 100644 --- a/src/Seed.Engine.App/Program.cs +++ b/src/Seed.Engine.App/Program.cs @@ -557,6 +557,8 @@ 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()); scheduler.AddSystem(new HapticFeedbackSystem(xrSessionForInit)); @@ -916,11 +918,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 +934,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"); @@ -943,19 +945,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 @@ -1018,15 +1020,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) { @@ -1040,11 +1139,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) { @@ -1057,6 +1170,63 @@ private static void Main() var vulkanLeftOpaqueTarget = (VulkanRenderTarget)leftOpaqueTarget; + // Opaque descriptor sets (per eye, 4 bindings matching fp_opaque shader) + var leftOpaqueDescResult = device.CreateDescriptorSet( + new DescriptorBinding[] + { + 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.ForStorageBuffer(0, leftTileBuf), + DescriptorWrite.ForStorageBuffer(1, gpuPointLightBuf), + DescriptorWrite.ForStorageBuffer(2, gpuSpotLightBuf), + DescriptorWrite.ForBuffer(3, gpuLightCountBuf), + }); + if (leftOpaqueDescResult.IsFailure) + { + Console.Error.WriteLine( + "Failed to create left opaque descriptor"); + return; + } + + using var leftOpaqueDescSet = leftOpaqueDescResult.Value; + + var rightOpaqueDescResult = device.CreateDescriptorSet( + new DescriptorBinding[] + { + 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.ForStorageBuffer(0, rightTileBuf), + DescriptorWrite.ForStorageBuffer(1, gpuPointLightBuf), + DescriptorWrite.ForStorageBuffer(2, gpuSpotLightBuf), + DescriptorWrite.ForBuffer(3, gpuLightCountBuf), + }); + if (rightOpaqueDescResult.IsFailure) + { + Console.Error.WriteLine( + "Failed to create right opaque descriptor"); + return; + } + + using var rightOpaqueDescSet = rightOpaqueDescResult.Value; + // Opaque pipeline (Forward+) var stereoOpaquePipelineDesc = new PipelineDescription( new ShaderDescription( @@ -1067,9 +1237,9 @@ private static void Main() vertexLayout, new DepthStencilState(true, true), 1, - new IDescriptorSet[] { leftLcDesc }, + new IDescriptorSet[] { leftOpaqueDescSet }, new[] { new PushConstantRange( - ShaderStage.Vertex | ShaderStage.Fragment, 0, 96) }, + ShaderStage.Vertex | ShaderStage.Fragment, 0, 208) }, true, null, PrimitiveTopology.TriangleList, @@ -1088,42 +1258,44 @@ private static void Main() using var stereoOpaquePipeline = stereoOpaquePipelineResult.Value; - // Opaque descriptor sets (per eye) - var leftOpaqueDescResult = device.CreateDescriptorSet( + // Transparent descriptor sets (composite pass samples from resolve textures) + var leftTransDescResult = device.CreateDescriptorSet( new DescriptorBinding[] { - new(0, DescriptorType.StorageBuffer, ShaderStage.Fragment), + new(0, DescriptorType.CombinedImageSampler, + ShaderStage.Fragment), }, new DescriptorWrite[] { - DescriptorWrite.ForBuffer(0, lightBuffer), + DescriptorWrite.ForTexture(0, leftResolveTexture), }); - if (leftOpaqueDescResult.IsFailure) + if (leftTransDescResult.IsFailure) { Console.Error.WriteLine( - "Failed to create left opaque descriptor"); + "Failed to create left transparent descriptor"); return; } - using var leftOpaqueDescSet = leftOpaqueDescResult.Value; + using var leftTransDesc = leftTransDescResult.Value; - var rightOpaqueDescResult = device.CreateDescriptorSet( + var rightTransDescResult = device.CreateDescriptorSet( new DescriptorBinding[] { - new(0, DescriptorType.StorageBuffer, ShaderStage.Fragment), + new(0, DescriptorType.CombinedImageSampler, + ShaderStage.Fragment), }, new DescriptorWrite[] { - DescriptorWrite.ForBuffer(0, lightBuffer), + DescriptorWrite.ForTexture(0, rightResolveTexture), }); - if (rightOpaqueDescResult.IsFailure) + if (rightTransDescResult.IsFailure) { Console.Error.WriteLine( - "Failed to create right opaque descriptor"); + "Failed to create right transparent descriptor"); return; } - using var rightOpaqueDescSet = rightOpaqueDescResult.Value; + using var rightTransDesc = rightTransDescResult.Value; // Transparent pipeline var stereoTransparentPipelineDesc = new PipelineDescription( @@ -1135,7 +1307,7 @@ private static void Main() null, null, 1, - null, + new IDescriptorSet[] { leftTransDesc }, null, false, null, @@ -1154,6 +1326,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, @@ -1172,6 +1372,8 @@ private static void Main() rightLcDesc, leftOpaqueDescSet, rightOpaqueDescSet, + leftTransDesc, + rightTransDesc, resources, inFlightFence, 0.1f, @@ -1180,7 +1382,11 @@ private static void Main() rightResolveTexture, shadowMap, shadowPipeline, - boundarySystem)); + boundarySystem, + swapChain, + desktopMirrorPipeline, + imageAvailableSemaphore, + renderFinishedSemaphore)); RunMainLoop( scheduler, world, window, cubeMeshId, materialId, @@ -1417,9 +1623,36 @@ 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(); + var stType = ComponentRegistry.Get(); ReadOnlySpan types = - [ftType, isType, vrType, hsType, hrType]; - world.CreateEntity(types); + [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 + { + ActiveMode = LocomotionType.SmoothMove, + DirectionSource = 0, + VignetteEnabled = 0, + }; + world.GetComponent(singletonEntity) = + new VrLocomotionConfig + { + TeleportMaxDistance = 10f, + TeleportFadeDuration = 0.15f, + SmoothMoveSpeed = 2f, + ArmSwingMaxSpeed = 5f, + ArmSwingMultiplier = 1.5f, + SnapTurnAngle = MathF.PI / 6f, + }; } else { @@ -1492,7 +1725,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.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.Tests/GameLogic/VrArmSwingSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/VrArmSwingSystemTests.cs new file mode 100644 index 0000000..8a62960 --- /dev/null +++ b/src/Seed.Engine.Tests/GameLogic/VrArmSwingSystemTests.cs @@ -0,0 +1,241 @@ +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, + PlayerRotation = 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, + PlayerRotation = Quaternion.Identity, + }; + 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/VrDistanceGrabSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/VrDistanceGrabSystemTests.cs index 338514e..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,12 +34,20 @@ 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(); 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..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(); @@ -377,6 +384,7 @@ public void Execute_OnRelease_AppliesLeftHandThrowVelocity() world.GetComponent(grabbable) = new GrabState { IsGrabbed = 1, + GrabbedByHand = 1, GrabOffset = Vector3.Zero, }; @@ -420,6 +428,7 @@ public void Execute_OnRelease_AppliesRightHandThrowVelocity() world.GetComponent(grabbable) = new GrabState { IsGrabbed = 1, + GrabbedByHand = 2, GrabOffset = Vector3.Zero, }; diff --git a/src/Seed.Engine.Tests/GameLogic/VrSmoothMoveSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/VrSmoothMoveSystemTests.cs new file mode 100644 index 0000000..bdcb2d7 --- /dev/null +++ b/src/Seed.Engine.Tests/GameLogic/VrSmoothMoveSystemTests.cs @@ -0,0 +1,320 @@ +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, + PlayerRotation = 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.Tests/GameLogic/VrSnapTurnSystemTests.cs b/src/Seed.Engine.Tests/GameLogic/VrSnapTurnSystemTests.cs new file mode 100644 index 0000000..a26e565 --- /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_RotatesNegativeY() + { + // 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 (right turn in right-hand system) + 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_RotatesPositiveY() + { + // 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 (left turn in right-hand system) + 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.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.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/StereoCameraTests.cs b/src/Seed.Engine.Tests/Rendering/StereoCameraTests.cs index 788987e..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_Is140Bytes() + public void StereoCamera_StructSize_Is184Bytes() { // 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 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.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/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/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/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..5b5d7be --- /dev/null +++ b/src/Seed.Engine/Ecs/Components/VrLocomotionConfig.cs @@ -0,0 +1,28 @@ +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; + + /// Snap turn angle in radians per activation (default PI/6 = 30 degrees). + public float SnapTurnAngle; +} 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; +} diff --git a/src/Seed.Engine/GameLogic/VrArmSwingSystem.cs b/src/Seed.Engine/GameLogic/VrArmSwingSystem.cs new file mode 100644 index 0000000..262d7d4 --- /dev/null +++ b/src/Seed.Engine/GameLogic/VrArmSwingSystem.cs @@ -0,0 +1,144 @@ +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); + + Quaternion worldRotation = stereo.PlayerRotation * stereo.HmdRotation; + Vector3 forward = VrLocomotionHelper.ComputeForwardXZ(worldRotation); + + 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/VrDistanceGrabSystem.cs b/src/Seed.Engine/GameLogic/VrDistanceGrabSystem.cs index a810cdb..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,12 +23,15 @@ public VrDistanceGrabSystem() _singletonQuery = new QueryBuilder() .WithRead() .WithRead() + .WithRead() .Build(); _grabbableQuery = new QueryBuilder() .WithRead() .WithWrite() .WithWrite() + .WithWrite() + .WithWrite() .Build(); } @@ -41,7 +45,9 @@ public VrDistanceGrabSystem() public void Execute(World world) { VrControllerState vrState = default; + StereoCamera stereo = default; float dt = 0f; + Entity vrPlayerEntity = Entity.Null; bool foundSingleton = false; for (int s = 0; s < world.Storages.Count; s++) @@ -56,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++) { @@ -64,6 +72,8 @@ 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; } @@ -93,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; @@ -147,7 +159,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; @@ -177,17 +189,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,13 +223,26 @@ 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 { Vector3 pullNorm = pullDir / pullDist; hitLt.Position = hitLt.Position + pullNorm * moveAmount; + hitVel.Linear = Vector3.Zero; + hitVel.Angular = Vector3.Zero; } } } diff --git a/src/Seed.Engine/GameLogic/VrGrabSystem.cs b/src/Seed.Engine/GameLogic/VrGrabSystem.cs index 4bb4e46..387b98e 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,8 +101,13 @@ public void Execute(World world) return; } - bool leftGripActive = vrState.LeftGripPressed == 1; - bool rightGripActive = vrState.RightGripPressed == 1; + // 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 + || vrState.RightTriggerPressed == 1; for (int s = 0; s < world.Storages.Count; s++) { @@ -139,13 +150,13 @@ public void Execute(World world) { HandleRelease(ref gs, ref lt, ref sj, ref vel, leftGripActive, rightGripActive, - vrState, handState); + vrState, handState, stereo); } else { bool grabbed = TryDirectGrab( ref gs, ref lt, ref grabbable, ref sj, - vrState, vrPlayerEntity, + vrState, vrPlayerEntity, stereo, leftGripActive, rightGripActive, out bool grabbedByLeft); @@ -169,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) { @@ -182,9 +195,12 @@ 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; + // 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 = (inversePlayerRot * lt.Rotation).Normalize(); AssignSpringJoint(ref sj, vrState.LeftPosition); isLeftHand = true; return true; @@ -198,9 +214,11 @@ 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; + Vector3 worldOffset = lt.Position - vrState.RightPosition; + gs.GrabOffset = inversePlayerRot.RotateVector(worldOffset); + gs.GrabRotationOffset = (inversePlayerRot * lt.Rotation).Normalize(); AssignSpringJoint(ref sj, vrState.RightPosition); return true; } @@ -241,38 +259,51 @@ 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) { - 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; sj.MaxForce = 0f; + 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 { Vector3 handPos = isLeftHand ? vrState.LeftPosition : vrState.RightPosition; - lt.Position = handPos + gs.GrabOffset; + // 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; } } + + 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; + } } diff --git a/src/Seed.Engine/GameLogic/VrLocomotionHelper.cs b/src/Seed.Engine/GameLogic/VrLocomotionHelper.cs new file mode 100644 index 0000000..3bb5a35 --- /dev/null +++ b/src/Seed.Engine/GameLogic/VrLocomotionHelper.cs @@ -0,0 +1,34 @@ +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) + { + 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; + + 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..de3fe19 --- /dev/null +++ b/src/Seed.Engine/GameLogic/VrSmoothMoveSystem.cs @@ -0,0 +1,227 @@ +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() + .WithWrite() + .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; + } + + // 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]; + 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); + + // 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; + } + } + } + } + } + + 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 sourceRotation = mode.DirectionSource == 0 + ? stereo.HmdRotation + : controller.LeftRotation; + Quaternion rotation = stereo.PlayerRotation * sourceRotation; + + return VrLocomotionHelper.ComputeForwardXZ(rotation); + } +} diff --git a/src/Seed.Engine/GameLogic/VrSnapTurnSystem.cs b/src/Seed.Engine/GameLogic/VrSnapTurnSystem.cs new file mode 100644 index 0000000..4d80a6c --- /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; + } + } + } + } + } +} 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; + } + } +} 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, 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, }; diff --git a/src/Seed.Engine/Rendering/ForwardPlusRenderSystem.cs b/src/Seed.Engine/Rendering/ForwardPlusRenderSystem.cs index a7161f1..7dd25b5 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; @@ -35,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 . @@ -51,6 +53,7 @@ public ForwardPlusRenderSystem( IPipeline lightCullingPipeline, IDescriptorSet lightCullingDescriptorSet, IDescriptorSet opaqueDescriptorSet, + IDescriptorSet transparentDescriptorSet, RenderResources resources, IntPtr imageAvailableSemaphore, IntPtr renderFinishedSemaphore, @@ -68,6 +71,7 @@ public ForwardPlusRenderSystem( _lightCullingPipeline = lightCullingPipeline; _lightCullingDescriptorSet = lightCullingDescriptorSet; _opaqueDescriptorSet = opaqueDescriptorSet; + _transparentDescriptorSet = transparentDescriptorSet; _resources = resources; _imageAvailableSemaphore = imageAvailableSemaphore; _renderFinishedSemaphore = renderFinishedSemaphore; @@ -90,6 +94,10 @@ public ForwardPlusRenderSystem( .WithRead() .WithRead() .Build(); + + _directionalLightQuery = new QueryBuilder() + .WithRead() + .Build(); } /// @@ -129,13 +137,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 +227,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); @@ -226,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++) { @@ -279,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, @@ -298,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) { @@ -307,6 +344,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/StereoCamera.cs b/src/Seed.Engine/Rendering/StereoCamera.cs index 336d1fe..4a0496f 100644 --- a/src/Seed.Engine/Rendering/StereoCamera.cs +++ b/src/Seed.Engine/Rendering/StereoCamera.cs @@ -28,6 +28,23 @@ 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; + + /// + /// The player's locomotion position in world space, updated by . + /// 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; } /// diff --git a/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs b/src/Seed.Engine/Rendering/StereoForwardPlusRenderSystem.cs index 2f3e58f..48e8e5d 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; @@ -51,12 +53,18 @@ 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; private readonly QueryDescription _meshQuery; private readonly QueryDescription _boundsQuery; private readonly QueryDescription _directionalLightQuery; + private readonly QueryDescription _stereoCameraQuery; /// /// Initializes a new . @@ -79,6 +87,8 @@ public StereoForwardPlusRenderSystem( IDescriptorSet rightLightCullingDescriptorSet, IDescriptorSet leftOpaqueDescriptorSet, IDescriptorSet rightOpaqueDescriptorSet, + IDescriptorSet leftTransparentDescriptorSet, + IDescriptorSet rightTransparentDescriptorSet, RenderResources resources, IntPtr inFlightFence, float nearPlane, @@ -87,7 +97,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; @@ -106,6 +120,8 @@ public StereoForwardPlusRenderSystem( _rightLightCullingDescriptorSet = rightLightCullingDescriptorSet; _leftOpaqueDescriptorSet = leftOpaqueDescriptorSet; _rightOpaqueDescriptorSet = rightOpaqueDescriptorSet; + _leftTransparentDescriptorSet = leftTransparentDescriptorSet; + _rightTransparentDescriptorSet = rightTransparentDescriptorSet; _resources = resources; _inFlightFence = inFlightFence; _nearPlane = nearPlane; @@ -115,6 +131,10 @@ public StereoForwardPlusRenderSystem( _shadowMap = shadowMap; _shadowPipeline = shadowPipeline; _boundarySystem = boundarySystem; + _desktopSwapChain = desktopSwapChain; + _desktopMirrorPipeline = desktopMirrorPipeline; + _desktopImageAvailableSemaphore = desktopImageAvailableSemaphore; + _desktopRenderFinishedSemaphore = desktopRenderFinishedSemaphore; _meshQuery = new QueryBuilder() .WithRead() @@ -132,6 +152,10 @@ public StereoForwardPlusRenderSystem( _directionalLightQuery = new QueryBuilder() .WithRead() .Build(); + + _stereoCameraQuery = new QueryBuilder() + .WithRead() + .Build(); } /// @@ -182,10 +206,19 @@ public void Execute(World world) XrViewPair views = viewsResult.Value; + 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); + views.Left, _nearPlane, _farPlane) * playerOffset; Matrix4x4 rightVP = StereoCameraHelper.ComputeViewProjection( - views.Right, _nearPlane, _farPlane); + views.Right, _nearPlane, _farPlane) * playerOffset; _device.WaitForFence(_inFlightFence); _device.ResetFence(_inFlightFence); @@ -207,6 +240,11 @@ public void Execute(World world) _commandBuffer.PipelineBarrier(); } + 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); if (leftAcquire.IsSuccess) @@ -219,17 +257,20 @@ public void Execute(World world) _commandBuffer.ComputeBarrier(); ExecuteOpaquePass( - world, leftVP, _leftOpaqueTarget, _leftOpaqueDescriptorSet); + world, leftVP, leftEyePos, + _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 @@ -244,17 +285,48 @@ public void Execute(World world) _commandBuffer.ComputeBarrier(); ExecuteOpaquePass( - world, rightVP, _rightOpaqueTarget, _rightOpaqueDescriptorSet); + world, rightVP, rightEyePos, + _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); + } + + // 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(); @@ -265,7 +337,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) { @@ -277,6 +355,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 { @@ -431,6 +515,7 @@ private void ExecuteLightCulling( private void ExecuteOpaquePass( World world, Matrix4x4 viewProjection, + Vector3 eyePosition, IRenderTarget opaqueTarget, IDescriptorSet opaqueDescriptorSet) { @@ -443,8 +528,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[208]; for (int s = 0; s < world.Storages.Count; s++) { @@ -492,11 +578,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(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, @@ -516,9 +606,62 @@ private void ExecuteOpaquePass( _commandBuffer.EndRenderPass(); } + private (Vector3 Position, Quaternion Rotation) FindPlayerTransform(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) + { + ref StereoCamera sc = ref chunk.GetComponent(scIdx, 0); + return (sc.PlayerPosition, sc.PlayerRotation); + } + } + } + + return (Vector3.Zero, Quaternion.Identity); + } + + 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) + ISwapChain swapChain, IDescriptorSet transparentDescriptorSet) { _commandBuffer.BeginRenderPass( swapChain, imageIndex, 0f, 0f, 0f, 1f); @@ -526,6 +669,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_opaque.frag.glsl b/src/Seed.Engine/Shaders/fp_opaque.frag.glsl index 6a1d9bf..90a851f 100644 --- a/src/Seed.Engine/Shaders/fp_opaque.frag.glsl +++ b/src/Seed.Engine/Shaders/fp_opaque.frag.glsl @@ -8,12 +8,19 @@ 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; float ao; float emissive; float _pad; + vec3 lightDir; + float lightIntensity; + vec3 lightColor; + float _lightPad; } pc; struct GpuPointLight { @@ -170,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 @@ -201,6 +208,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 128e1f5..39b7ecc 100644 Binary files a/src/Seed.Engine/Shaders/fp_opaque.frag.spv and b/src/Seed.Engine/Shaders/fp_opaque.frag.spv differ diff --git a/src/Seed.Engine/Shaders/fp_opaque.vert.glsl b/src/Seed.Engine/Shaders/fp_opaque.vert.glsl index 6e86329..eeeea39 100644 --- a/src/Seed.Engine/Shaders/fp_opaque.vert.glsl +++ b/src/Seed.Engine/Shaders/fp_opaque.vert.glsl @@ -10,17 +10,25 @@ layout(location = 2) out vec3 worldPosition; 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; float ao; float emissive; float _pad; + vec3 lightDir; + float lightIntensity; + vec3 lightColor; + float _lightPad; } pc; void main() { gl_Position = pc.mvp * vec4(position, 1.0); - worldNormal = normalize(normal); + vec4 wp = pc.model * vec4(position, 1.0); + worldPosition = wp.xyz; + worldNormal = normalize((pc.model * vec4(normal, 0.0)).xyz); fragTexCoord = texCoord; - worldPosition = position; } diff --git a/src/Seed.Engine/Shaders/fp_opaque.vert.spv b/src/Seed.Engine/Shaders/fp_opaque.vert.spv index d7bb287..72b03ba 100644 Binary files a/src/Seed.Engine/Shaders/fp_opaque.vert.spv and b/src/Seed.Engine/Shaders/fp_opaque.vert.spv differ 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 075e36d..5877a22 100644 Binary files a/src/Seed.Engine/Shaders/fp_transparent.frag.spv and b/src/Seed.Engine/Shaders/fp_transparent.frag.spv differ