using jeanf.validationTools; using Unity.Entities; using Unity.Mathematics; using UnityEngine; namespace jeanf.scenemanagement { public enum ProxyColliderShape : byte { Box = 0, Sphere = 1, Capsule = 2, } /// /// One baked collider, expressed in the space of the /// that collected it. rebuilds it as a PhysX collider on a /// child of a proxy placed at the entity's LocalToWorld. /// public struct ProxyColliderElement : IBufferElementData { public ProxyColliderShape Shape; // Collider transform relative to the authoring transform. Applying this as a child of a // proxy placed at the authoring's world pose reproduces the collider's original world pose, // whatever its depth in the prefab. public float3 LocalPosition; public quaternion LocalRotation; public float3 LocalScale; /// The collider's own center offset (all shapes). public float3 Center; /// Box only. public float3 Size; /// Sphere and capsule. public float Radius; /// Capsule only. public float Height; /// Capsule only: 0 = X, 1 = Y, 2 = Z. public int Direction; public int Layer; public byte IsTrigger; } /// /// Drop this on a prop prefab and its colliders survive being baked into a SubScene: the shapes /// are baked to data and respawns them as real PhysX colliders /// near the player, so the CharacterController is blocked by them. /// /// It exists because baking STRIPS components — a chair authored in a /// SubScene renders through Entities Graphics but is not solid, and this project has no /// ECS physics package to replace it with. /// /// Put it anywhere in the prefab: offsets are resolved relative to THIS transform at bake time, /// so hierarchy depth and layout do not matter. In a classic (non-baked) additive scene the /// component is inert — the real colliders are already there and already work. /// [DisallowMultipleComponent] public class StaticColliderAuthoring : MonoBehaviour, IValidatable { [Tooltip("Also collect colliders on child GameObjects. Leave on unless the prop's children carry colliders that should NOT block the player.")] [SerializeField] private bool includeChildren = true; [Tooltip("Bake trigger colliders too. Off by default: triggers are not blockers, and baking them spends pool slots on volumes that stop nothing.")] [SerializeField] private bool includeTriggers = false; public bool IncludeChildren => includeChildren; public bool IncludeTriggers => includeTriggers; /// /// Valid when at least one bakeable (Box/Sphere/Capsule, non-trigger unless opted in) collider /// is in reach — without one the prop bakes to nothing and the player walks through it. /// Surfaced through the propertyDrawer validation framework (inspector banner, hierarchy /// highlight, play-mode console scan); the project-wide prefab sweep lives in /// Tools/Jeanf/SceneManagement/Validate Static Colliders. /// public bool IsValid { get { var colliders = includeChildren ? GetComponentsInChildren(true) : GetComponents(); foreach (var collider in colliders) { if (collider == null) continue; if (collider.isTrigger && !includeTriggers) continue; if (StaticColliderBake.TryDescribe(collider, out _)) return true; } return false; } } private class StaticColliderBaker : Baker { public override void Bake(StaticColliderAuthoring authoring) { var colliders = authoring.IncludeChildren ? GetComponentsInChildren() : GetComponents(); if (colliders == null || colliders.Length == 0) { Debug.LogWarning($"{StaticColliderBake.LogPrefix} StaticColliderAuthoring on '{authoring.name}': no collider found" + $"{(authoring.IncludeChildren ? "" : " on this GameObject (Include Children is OFF)")} — " + "nothing will block the player once this prop is baked into a SubScene.", authoring); return; } var entity = GetEntity(TransformUsageFlags.Dynamic); DynamicBuffer buffer = default; var added = 0; var authoringTransform = authoring.transform; foreach (var collider in colliders) { if (collider == null) continue; if (collider.isTrigger && !authoring.IncludeTriggers) continue; if (!StaticColliderBake.TryDescribe(collider, out var element)) { Debug.LogWarning($"{StaticColliderBake.LogPrefix} StaticColliderAuthoring on '{authoring.name}': " + $"'{collider.name}' is a {collider.GetType().Name}, which cannot be baked — only BoxCollider, " + "SphereCollider and CapsuleCollider are supported. That collider will NOT block the player in a " + "SubScene; replace it with primitives or keep this prop in a classic additive scene.", collider); continue; } // Dependencies: re-bake when the collider or its placement changes. DependsOn(collider); DependsOn(collider.transform); var local = math.mul( (float4x4)authoringTransform.worldToLocalMatrix, (float4x4)collider.transform.localToWorldMatrix); StaticColliderBake.DecomposeTrs(local, out var position, out var rotation, out var scale); if (StaticColliderBake.IsLossyPlacement(rotation, scale)) { Debug.LogWarning($"{StaticColliderBake.LogPrefix} StaticColliderAuthoring on '{authoring.name}': " + $"'{collider.name}' combines a rotation with non-uniform scale relative to the authoring transform. " + "That cannot be reproduced exactly by a child transform, so the proxy will be slightly off. " + "Un-rotate the collider, or make the scale uniform.", collider); } element.LocalPosition = position; element.LocalRotation = rotation; element.LocalScale = scale; element.Layer = collider.gameObject.layer; element.IsTrigger = (byte)(collider.isTrigger ? 1 : 0); if (added == 0) buffer = AddBuffer(entity); buffer.Add(element); added++; } if (added == 0) { Debug.LogWarning($"{StaticColliderBake.LogPrefix} StaticColliderAuthoring on '{authoring.name}': " + "found colliders but baked none of them (all were triggers or unsupported types) — " + "nothing will block the player once this prop is baked into a SubScene.", authoring); } } } } /// /// Pure bake-time helpers, kept static and side-effect free so they can be unit tested without /// a SubScene, a bake, or a live world. The placement math here is the part most likely to /// regress: getting it wrong silently misplaces every proxy. /// public static class StaticColliderBake { public const string LogPrefix = "[SceneManagement]"; /// /// Splits a transform matrix into translation, rotation and scale. Negative scale (a mirrored /// prop) is folded into the X axis so the rotation stays a proper (right-handed) rotation. /// public static void DecomposeTrs(float4x4 m, out float3 position, out quaternion rotation, out float3 scale) { position = m.c3.xyz; var axisX = m.c0.xyz; var axisY = m.c1.xyz; var axisZ = m.c2.xyz; var scaleX = math.length(axisX); var scaleY = math.length(axisY); var scaleZ = math.length(axisZ); // A negative determinant means the basis is mirrored; a quaternion cannot express that, // so the flip is attributed to X and the remaining basis stays right-handed. if (math.determinant(m) < 0f) scaleX = -scaleX; scale = new float3(scaleX, scaleY, scaleZ); axisX = math.abs(scaleX) > 1e-8f ? axisX / scaleX : new float3(1f, 0f, 0f); axisY = scaleY > 1e-8f ? axisY / scaleY : new float3(0f, 1f, 0f); axisZ = scaleZ > 1e-8f ? axisZ / scaleZ : new float3(0f, 0f, 1f); rotation = new quaternion(new float3x3(axisX, axisY, axisZ)); } /// /// True when a rotation is combined with non-uniform scale — the one case a single child /// TRS cannot reproduce faithfully, because the scale would have to be applied in the /// parent's frame rather than the child's. /// public static bool IsLossyPlacement(quaternion rotation, float3 scale, float tolerance = 1e-3f) { var abs = math.abs(scale); var maxScale = math.cmax(abs); var minScale = math.cmin(abs); if (maxScale - minScale <= tolerance) return false; // uniform scale is always exact // math.abs(w) ~= 1 means "no rotation" (q and -q are the same rotation). return math.abs(math.abs(rotation.value.w) - 1f) > tolerance; } /// /// Fills in the shape-specific fields for a supported primitive collider. Returns false for /// anything else (MeshCollider, TerrainCollider, ...), which the caller reports. /// public static bool TryDescribe(Collider collider, out ProxyColliderElement element) { element = default; switch (collider) { case BoxCollider box: element.Shape = ProxyColliderShape.Box; element.Center = box.center; element.Size = box.size; return true; case SphereCollider sphere: element.Shape = ProxyColliderShape.Sphere; element.Center = sphere.center; element.Radius = sphere.radius; return true; case CapsuleCollider capsule: element.Shape = ProxyColliderShape.Capsule; element.Center = capsule.center; element.Radius = capsule.radius; element.Height = capsule.height; element.Direction = capsule.direction; return true; default: return false; } } } }