From 35ff288aabc03130a61228579f1b09bac4345b5c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 15 Jul 2026 21:30:50 +0100 Subject: [PATCH] Lower entity-forward follow cameras generically --- docs/authority-map.md | 8 ++ .../Runtime/EveUnityPlayableWorldCameraRig.cs | 52 +++++++++++- .../EveUnitySceneSurfaceLowererTests.cs | 82 +++++++++++++++++-- .../org.gamecult.eve.unity-scene/package.json | 2 +- 4 files changed, 133 insertions(+), 11 deletions(-) diff --git a/docs/authority-map.md b/docs/authority-map.md index 17e065d..235f69e 100644 --- a/docs/authority-map.md +++ b/docs/authority-map.md @@ -51,6 +51,14 @@ temporal pass must also advertise its current-sample, history, previous-view-projection, and reset-history ports; partial temporal programs fail closed. +The provider selects camera semantics and framing from its authoritative mode. +`planar.top-down-follow.v1` derives a downward presentation from the advertised +target and framing values. `perspective.entity-forward-follow.v1` derives its +view direction from the presented entity rotation and applies the same generic +distance, screen-position, damping, and lens contract. EveUnity owns only the +native camera transform. It cannot collapse distinct provider modes into one +camera opinion or infer a product camera from entity kind. + ## Shared Paths Initial connect discovers a Verse endpoint and selects a provider surface by diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs index 7b50fad..ba2d799 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs @@ -80,7 +80,9 @@ namespace GameCult.Eve.UnityScene return false; } - if (activeWorld.CameraRig == "planar.top-down-follow.v1" && !HasValidPlanarContract(activeWorld)) + if ((activeWorld.CameraRig == "planar.top-down-follow.v1" || + activeWorld.CameraRig == "perspective.entity-forward-follow.v1") && + !HasValidFramedFollowContract(activeWorld)) { ReleaseCamera(); RestoreAmbientEnvironment(); @@ -88,6 +90,7 @@ namespace GameCult.Eve.UnityScene } if (!string.IsNullOrWhiteSpace(activeWorld.CameraRig) && activeWorld.CameraRig != "planar.top-down-follow.v1" && + activeWorld.CameraRig != "perspective.entity-forward-follow.v1" && activeWorld.CameraRig != "arpg.orbital-follow.v1" && activeWorld.CameraRig != "third-person-orbit") { @@ -139,6 +142,8 @@ namespace GameCult.Eve.UnityScene } if (activeWorld.CameraRig == "planar.top-down-follow.v1") return ApplyPlanarTopDown(activeWorld, camera, cameraComponent, player.position, deltaTime); + if (activeWorld.CameraRig == "perspective.entity-forward-follow.v1") + return ApplyEntityForwardFollow(activeWorld, camera, cameraComponent, player, deltaTime); var bounds = CalculateVisualBounds( player, @@ -197,7 +202,50 @@ namespace GameCult.Eve.UnityScene return true; } - private static bool HasValidPlanarContract(EveUnityPlayableWorldProjection world) => + private static bool ApplyEntityForwardFollow( + EveUnityPlayableWorldProjection world, + Transform camera, + Camera? cameraComponent, + Transform target, + float deltaTime) + { + var resolvedDistance = world.CameraDistance; + var verticalFov = world.CameraVerticalFieldOfViewDegrees; + if (cameraComponent != null) + { + cameraComponent.orthographic = false; + cameraComponent.lensShift = Vector2.zero; + cameraComponent.fieldOfView = verticalFov; + cameraComponent.nearClipPlane = world.CameraNearClipPlane; + cameraComponent.farClipPlane = world.CameraFarClipPlane; + } + + var forward = target.forward; + forward.y = 0f; + if (forward.sqrMagnitude <= 0.0001f) + forward = Vector3.forward; + forward.Normalize(); + var rotation = Quaternion.LookRotation(forward, Vector3.up); + var right = rotation * Vector3.right; + var aspect = cameraComponent != null ? cameraComponent.aspect : 16f / 9f; + var verticalHalfExtent = resolvedDistance * Mathf.Tan(verticalFov * 0.5f * Mathf.Deg2Rad); + var horizontalHalfExtent = verticalHalfExtent * Mathf.Max(0.01f, aspect); + var horizontalOffset = (Mathf.Clamp01(world.CameraTargetScreenX) - 0.5f) * 2f * horizontalHalfExtent; + var verticalOffset = (Mathf.Clamp01(world.CameraTargetScreenY) - 0.5f) * 2f * verticalHalfExtent; + var desiredPosition = target.position - + (forward * resolvedDistance) - + (right * horizontalOffset) - + (Vector3.up * verticalOffset); + var damping = Mathf.Max(0f, world.CameraPositionDamping); + var t = deltaTime <= 0f || damping <= 0f + ? 1f + : 1f - Mathf.Exp(-damping * deltaTime); + camera.position = Vector3.Lerp(camera.position, desiredPosition, t); + camera.rotation = rotation; + return true; + } + + private static bool HasValidFramedFollowContract(EveUnityPlayableWorldProjection world) => world.CameraDistance > 0f && world.CameraVerticalFieldOfViewDegrees > 0f && world.CameraVerticalFieldOfViewDegrees < 180f && diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs index 1ab2b4a..891f07e 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -1265,6 +1265,65 @@ namespace GameCult.Eve.UnityScene.Tests } } + [Test] + public void PlayableWorldCameraRigFramesEntityForwardPerspectiveWithoutProviderTypes() + { + var rootObject = new GameObject("generic-forward-world-root"); + var hostObject = new GameObject("generic-forward-client"); + var cameraObject = new GameObject("generic-forward-camera"); + hostObject.SetActive(false); + try + { + var provider = hostObject.AddComponent(); + var nativeAssets = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument( + cameraRig: "perspective.entity-forward-follow.v1", + cameraDistance: "30", + cameraTargetScreenX: "0.64", + cameraTargetScreenY: "0.81", + cameraPositionDamping: "0", + cameraNearClipPlane: "1", + cameraFarClipPlane: "4096"), + Advertisement("aetheria.daemon.game"), + "cultmesh://generic/eve/surfaces/forward", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://generic/assets/manifest", + Array.Empty(), + "generic")); + + var host = hostObject.AddComponent(); + host.Configure(rootObject.transform, provider, provider, provider, provider, nativeAssets); + host.Connect(); + hostObject.SetActive(true); + var camera = cameraObject.AddComponent(); + camera.aspect = 16f / 9f; + var rig = hostObject.AddComponent(); + rig.Host = host; + rig.CameraTransform = cameraObject.transform; + + var player = rootObject.GetComponentInChildren(); + Assert.That(player, Is.Not.Null); + Assert.That(rig.ApplyRig(0f), Is.True); + Assert.That(Vector3.Dot(cameraObject.transform.forward, player!.transform.forward), Is.GreaterThan(0.999f)); + Assert.That(camera.fieldOfView, Is.EqualTo(60f).Within(0.001f)); + Assert.That(camera.nearClipPlane, Is.EqualTo(1f).Within(0.001f)); + Assert.That(camera.farClipPlane, Is.EqualTo(4096f).Within(0.001f)); + var viewport = camera.WorldToViewportPoint(player.transform.position); + Assert.That(viewport.x, Is.EqualTo(0.64f).Within(0.001f)); + Assert.That(viewport.y, Is.EqualTo(0.81f).Within(0.001f)); + Assert.That(cameraObject.transform.position.y, Is.LessThan(player.transform.position.y)); + } + finally + { + UnityEngine.Object.DestroyImmediate(cameraObject); + UnityEngine.Object.DestroyImmediate(hostObject); + UnityEngine.Object.DestroyImmediate(rootObject); + } + } + [Test] public void ProviderPrefabKeepsAuthoredScaleWhileFallbackUsesSemanticRadius() { @@ -1773,7 +1832,14 @@ namespace GameCult.Eve.UnityScene.Tests bool includeRaider = true, string playerPosition = "0,0,0", string skyboxAssetRef = "", - string reflectionAssetRef = "") + string reflectionAssetRef = "", + string cameraRig = "planar.top-down-follow.v1", + string cameraDistance = "150", + string cameraTargetScreenX = "0.9", + string cameraTargetScreenY = "0.55", + string cameraPositionDamping = "5", + string cameraNearClipPlane = "0.3", + string cameraFarClipPlane = "4096") { var playableChildren = new List { @@ -1910,15 +1976,15 @@ namespace GameCult.Eve.UnityScene.Tests ["zoneRenderSchema"] = "gamecult.aetheria.zone_render.v1", ["assetManifest"] = "cultmesh://aetheria/assets/manifest", ["inputProfile"] = "arpg-third-person", - ["cameraRig"] = "planar.top-down-follow.v1", + ["cameraRig"] = cameraRig, ["cameraTargetEntityId"] = "player-vanguard", - ["cameraDistance"] = "150", + ["cameraDistance"] = cameraDistance, ["cameraVerticalFieldOfViewDegrees"] = "60", - ["cameraTargetScreenX"] = "0.9", - ["cameraTargetScreenY"] = "0.55", - ["cameraPositionDamping"] = "5", - ["cameraNearClipPlane"] = "0.3", - ["cameraFarClipPlane"] = "4096", + ["cameraTargetScreenX"] = cameraTargetScreenX, + ["cameraTargetScreenY"] = cameraTargetScreenY, + ["cameraPositionDamping"] = cameraPositionDamping, + ["cameraNearClipPlane"] = cameraNearClipPlane, + ["cameraFarClipPlane"] = cameraFarClipPlane, ["ambientLightColor"] = "0.2,0.2,0.2", ["ambientLightIntensity"] = "1.46", ["skyboxAssetRef"] = skyboxAssetRef, diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index ea0ecf5..b2951a7 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.38", + "version": "0.3.39", "unity": "2021.3", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": {