namespace SceneTests.Components; [TestClass] public class CameraComponentExtrasTest { /// /// Creates a camera at the scene origin with identity rotation, a 90 degree /// horizontal field of view and a fixed 800x600 custom size so the screen /// math in these tests is deterministic. /// static CameraComponent CreateTestCamera( Scene scene ) { var go = scene.CreateObject(); var cam = go.Components.Create(); cam.FieldOfView = 90; cam.CustomSize = new Vector2( 800, 600 ); return cam; } /// /// A world point straight ahead of the camera maps to the centre of the /// screen: (0.5, 0.5) in normalized coordinates and half the custom size in /// pixel coordinates. /// [TestMethod] public void CenterPointMapsToScreenCenter() { var scene = new Scene(); using var sceneScope = scene.Push(); var cam = CreateTestCamera( scene ); var n = cam.PointToScreenNormal( new Vector3( 500, 0, 0 ) ); Assert.AreEqual( 0.5f, n.x, 0.01f ); Assert.AreEqual( 0.5f, n.y, 0.01f ); var px = cam.PointToScreenPixels( new Vector3( 500, 0, 0 ) ); Assert.AreEqual( 400f, px.x, 1.0f ); Assert.AreEqual( 300f, px.y, 1.0f ); } /// /// World-to-screen mapping respects the engine axis conventions: points to the /// left of the camera (+Y) land on the left half of the screen (x < 0.5) /// and points above (+Z) land on the upper half (y < 0.5). /// [TestMethod] public void ScreenMappingDirections() { var scene = new Scene(); using var sceneScope = scene.Push(); var cam = CreateTestCamera( scene ); var left = cam.PointToScreenNormal( new Vector3( 500, 100, 0 ) ); Assert.IsTrue( left.x < 0.5f, $"Point to the left should be on the left half: {left}" ); var right = cam.PointToScreenNormal( new Vector3( 500, -100, 0 ) ); Assert.IsTrue( right.x > 0.5f, $"Point to the right should be on the right half: {right}" ); var up = cam.PointToScreenNormal( new Vector3( 500, 0, 100 ) ); Assert.IsTrue( up.y < 0.5f, $"Point above should be on the upper half: {up}" ); var down = cam.PointToScreenNormal( new Vector3( 500, 0, -100 ) ); Assert.IsTrue( down.y > 0.5f, $"Point below should be on the lower half: {down}" ); } /// /// The isBehind out parameter reports whether the queried world position is /// behind the camera plane. /// [TestMethod] public void PointBehindCameraReportsBehind() { var scene = new Scene(); using var sceneScope = scene.Push(); var cam = CreateTestCamera( scene ); cam.PointToScreenNormal( new Vector3( -500, 0, 0 ), out var behind ); Assert.IsTrue( behind ); cam.PointToScreenNormal( new Vector3( 500, 0, 0 ), out var front ); Assert.IsFalse( front ); } /// /// Perspective ScreenPixelToRay: the centre pixel produces a ray along the /// camera forward from the camera position, and with a 90 degree horizontal /// FOV the left screen edge produces a ray at exactly 45 degrees. /// [TestMethod] public void PerspectivePixelRays() { var scene = new Scene(); using var sceneScope = scene.Push(); var cam = CreateTestCamera( scene ); var center = cam.ScreenPixelToRay( new Vector2( 400, 300 ) ); Assert.IsTrue( center.Position.Distance( cam.WorldPosition ) < 0.001f, "Perspective rays start at the camera" ); Assert.IsTrue( center.Forward.Distance( Vector3.Forward ) < 0.001f, "Centre pixel ray should be the camera forward" ); var leftEdge = cam.ScreenPixelToRay( new Vector2( 0, 300 ) ); var expected = new Vector3( 1, 1, 0 ).Normal; Assert.IsTrue( leftEdge.Forward.Distance( expected ) < 0.001f, $"Left edge ray should be 45 degrees: {leftEdge.Forward}" ); } /// /// ScreenNormalToRay is the normalized-coordinate version of ScreenPixelToRay - /// both produce the same ray for equivalent inputs. /// [TestMethod] public void NormalRayMatchesPixelRay() { var scene = new Scene(); using var sceneScope = scene.Push(); var cam = CreateTestCamera( scene ); var pixel = cam.ScreenPixelToRay( new Vector2( 200, 150 ) ); var normal = cam.ScreenNormalToRay( new Vector3( 0.25f, 0.25f, 0 ) ); Assert.IsTrue( pixel.Position.Distance( normal.Position ) < 0.0001f ); Assert.IsTrue( pixel.Forward.Distance( normal.Forward ) < 0.0001f ); } /// /// In orthographic mode all screen rays are parallel to the camera forward; /// the centre ray starts on the near plane and edge rays are offset /// perpendicular to the view direction by half the ortho width. /// [TestMethod] public void OrthographicRaysAreParallel() { var scene = new Scene(); using var sceneScope = scene.Push(); var cam = CreateTestCamera( scene ); cam.Orthographic = true; cam.OrthographicHeight = 600; cam.ZNear = 10; var center = cam.ScreenPixelToRay( new Vector2( 400, 300 ) ); var edge = cam.ScreenPixelToRay( new Vector2( 0, 300 ) ); Assert.IsTrue( center.Forward.Distance( edge.Forward ) < 0.0001f, "Orthographic rays should be parallel" ); Assert.IsTrue( center.Position.Distance( new Vector3( 10, 0, 0 ) ) < 0.001f, "Centre ray starts on the near plane along forward" ); // 600 ortho height on a 800x600 screen = 800 ortho width, so the edge is 400 away var delta = edge.Position - center.Position; Assert.AreEqual( 400f, delta.Length, 0.1f ); Assert.AreEqual( 0f, Vector3.Dot( delta, center.Forward ), 0.001f ); } /// /// The component pushes FieldOfView, ZNear, ZFar, Size and the orthographic /// settings into its internal SceneCamera whenever the screen math APIs /// refresh the camera transform. /// [TestMethod] public void PropertiesPropagateToSceneCamera() { var scene = new Scene(); using var sceneScope = scene.Push(); var cam = CreateTestCamera( scene ); cam.FieldOfView = 75; cam.ZNear = 5; cam.ZFar = 2048; cam.PointToScreenNormal( new Vector3( 100, 0, 0 ) ); Assert.AreEqual( 75f, cam.SceneCamera.FieldOfView ); Assert.AreEqual( 5f, cam.SceneCamera.ZNear ); Assert.AreEqual( 2048f, cam.SceneCamera.ZFar ); Assert.AreEqual( new Vector2( 800, 600 ), cam.SceneCamera.Size ); Assert.IsFalse( cam.SceneCamera.Ortho ); cam.Orthographic = true; cam.OrthographicHeight = 512; cam.PointToScreenNormal( new Vector3( 100, 0, 0 ) ); Assert.IsTrue( cam.SceneCamera.Ortho ); Assert.AreEqual( 512f, cam.SceneCamera.OrthoHeight ); } /// /// ScreenToWorld converts the centre of the screen to a world position on the /// near frustum plane, directly ahead of the camera. /// [TestMethod] public void ScreenToWorldProjectsAlongForward() { var scene = new Scene(); using var sceneScope = scene.Push(); var cam = CreateTestCamera( scene ); cam.ZNear = 10; var world = cam.ScreenToWorld( new Vector2( 400, 300 ) ); var dir = (world - cam.WorldPosition).Normal; Assert.IsTrue( dir.Distance( Vector3.Forward ) < 0.01f, $"Centre of screen should project straight ahead: {world}" ); Assert.IsTrue( world.Distance( cam.WorldPosition ) < 100.0f, "Projected point should be near the camera" ); } /// /// CameraComponent projection properties survive a serialize / deserialize /// round trip of the owning GameObject. /// [TestMethod] public void SerializationRoundTrip() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var cam = go.Components.Create(); cam.FieldOfView = 75; cam.FovAxis = CameraComponent.Axis.Vertical; cam.ZNear = 5; cam.ZFar = 4096; cam.Orthographic = true; cam.OrthographicHeight = 640; var json = go.Serialize().ToJsonString(); var jsonObject = Json.ParseToJsonObject( json ); Assert.IsNotNull( jsonObject ); SceneUtility.MakeIdGuidsUnique( jsonObject ); var copy = new GameObject( false ); copy.Deserialize( jsonObject ); copy.Enabled = true; var cam2 = copy.GetComponent(); Assert.IsNotNull( cam2 ); Assert.AreEqual( 75f, cam2.FieldOfView ); Assert.AreEqual( CameraComponent.Axis.Vertical, cam2.FovAxis ); Assert.AreEqual( 5f, cam2.ZNear ); Assert.AreEqual( 4096f, cam2.ZFar ); Assert.IsTrue( cam2.Orthographic ); Assert.AreEqual( 640f, cam2.OrthographicHeight ); go.Destroy(); copy.Destroy(); scene.ProcessDeletes(); } }