Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- // ============================================================================
- // Mesh4.cs — Mask ➜ Walkable FLOOR Mesh for Unity 2.5D / Adventure Creator
- // ============================================================================
- // Merge:
- // • Geometry (Floor Placement): from v1 — every mask-pixel becomes a ray
- // through the camera (ViewportPointToRay) and intersects the horizontal
- // plane y = floorY. This is the model that worked best when FOV changed.
- // maxFloorDistance/meshDistanceScale/depthCompression/
- // meshWorldOffset/targetSurfaceZ work as in v1.
- // • Mesh generation: from v3 — marching-squares contour trace +
- // Douglas-Peucker simplification + ear-clip triangulation. Many fewer
- // polygons than the old "one quad per pixel".
- // • Removed: Warp Grid (control points / RBF), Mesh Scaling
- // (scaleX/Y/Z, depthMultiplier), Live Preview toggle.
- // • Single mask (maskTexture) instead of array.
- // • Gizmo modes as in v3: None / WhenSelected / WhenCamera / Always.
- // • Player Spawn point (UV, draggable handle in Scene view) + button
- // "Place Player".
- // • Auto-invalidate bake: any parameter change (or camera/transform motion)
- // is detected via hash; if a baked asset exists, it's auto-deleted and
- // the collider reverts to live mesh.
- //
- // Setup (NavigationMesh — walkable floor):
- // 1. Empty GameObject (ideally layer "NavMesh").
- // 2. Add Mesh4 (adds MeshCollider itself). AC Target = NavigationMesh.
- // 3. Set maskTexture (white = walkable) and warpCamera. Click "AC Setup".
- // 4. Adjust floorY / maxFloorDistance / meshWorldOffset / targetSurfaceZ
- // so the floor gizmo aligns with the artwork.
- // 5. Bake when satisfied.
- //
- // Setup (Hotspot — clickable area with shape from mask instead of box):
- // 1. Add Mesh4 ON TOP of the AC Hotspot object (or on a new object).
- // 2. Set AC Target = Hotspot.
- // 3. Set maskTexture/Polygon so the shape matches the object.
- // 4. Click "AC Setup" → AC.Hotspot is added, the default BoxCollider is
- // removed, and the clickable shape comes from the (non-convex) MeshCollider.
- // ============================================================================
- using System.Collections.Generic;
- using UnityEngine;
- #if UNITY_EDITOR
- using UnityEditor;
- #endif
- [ExecuteAlways]
- [RequireComponent(typeof(MeshCollider))]
- [AddComponentMenu("Adventure Creator Tools/Mesh4 (Mask To Floor NavMesh)")]
- public class Mesh4 : MonoBehaviour
- {
- public enum GizmoMode { None, WhenSelected, WhenCamera, Always }
- /// <summary>
- /// Grid: one quad per (downsampled) pixel of the mask, as in "Script 1" — but with
- /// the robust unprojection of Mesh4 (no rejection/clamp-collapse near the horizon),
- /// guaranteeing 100% coverage of the mask. More polygons (controlled by downsampling).<br/>
- /// Contour: contour-trace + simplify + ear-clip — many fewer polygons, but
- /// Simplification slightly eats corners (Mask Dilation compensates).
- /// </summary>
- public enum MeshGenerationMode { Grid, Contour }
- /// <summary>
- /// Perspective (default, v1-style): X/Z come from ray↔(y=floorY) intersection. Correct
- /// for the NEAR portion of the mask, but depth "explodes" as v approaches the
- /// camera's eye-height (v≈0.5 for non-tilted camera) — Max Floor Distance simply
- /// "cuts" that explosion somewhere, without meaningful relation to hotspots.<br/>
- /// Linear: X = lerp(Left X, Right X, u), Z = lerp(Near Depth Z, Far Depth Z, v),
- /// Y = floorY. No camera/ray dependency — the mask is placed like a rectangle
- /// in world XZ with the 4 edges EXACTLY where you define them (e.g., at the X/Z
- /// of left/right/far hotspots).
- /// </summary>
- public enum DepthMode { Perspective, Linear }
- /// <summary>
- /// Mask: the mesh is generated from PNG mask + camera/Linear unprojection
- /// (see fields below).<br/>
- /// Polygon: the mesh is generated directly from a list of Transform (Outline Points)
- /// you place around in world space, in order. Each vertex = (point.x, floorY,
- /// point.z) — triangulated with EarClip. No camera/ray/FOV involved; the mesh
- /// edges match EXACTLY the points you placed (e.g., next to hotspots).
- /// </summary>
- public enum MeshSourceMode { Mask, Polygon }
- /// <summary>
- /// Which AC component this mesh "belongs to", for the auto-setup:<br/>
- /// NavigationMesh: walkable floor (AC.NavigationMesh, ignoreCollisions=off).<br/>
- /// Hotspot: clickable area with shape from mask/polygon instead of box (AC.Hotspot).
- /// The collider remains MeshCollider (non-convex) — as required by AC raycast
- /// in 3D mode — and the default BoxCollider is removed.
- /// </summary>
- public enum ACTargetMode { NavigationMesh, Hotspot }
- /// <summary>Which side (world XZ) of the mesh the extension attaches to.</summary>
- public enum ExtensionSide { Left, Right, Top, Bottom }
- /// <summary>
- /// A rectangular "section" of walkable area added to one side of the mesh, at the SAME Y —
- /// so the player can walk off-screen (e.g., at an exit point).
- /// Left/Right extend on the X axis (world), Top/Bottom on the Z axis.
- /// </summary>
- [System.Serializable]
- public class MeshExtension
- {
- [Tooltip("Name only for Inspector assistance.")]
- public string label = "";
- [Tooltip("Active/inactive without deletion.")]
- public bool enabled = true;
- [Tooltip("Which side of the mesh (world XZ) to attach to.")]
- public ExtensionSide side = ExtensionSide.Left;
- [Tooltip("How far 'outward' to extend (world units): Left/Right = X, Top/Bottom = Z.")]
- public float extent = 3f;
- [Tooltip("Length along the side (world units). 0 = the full side of the mesh.")]
- public float span = 0f;
- [Tooltip("Offset along the side (world units, +/-), for centering.")]
- public float slideAlongEdge = 0f;
- [Tooltip("Additional Y offset (world) for this section only (usually 0).")]
- public float yOffset = 0f;
- [Tooltip("Small overlap (world units) INSIDE the main mesh, to avoid a seam at the joint. 0.05-0.2 is usually enough.")]
- public float overlap = 0.1f;
- }
- // ─────────────────────────────────────────────
- // MESH SOURCE
- // ─────────────────────────────────────────────
- [Header("AC Target")]
- [Tooltip("What this mesh is used for, for the 'AC Setup' button:\n" +
- "NavigationMesh: walkable floor (AC.NavigationMesh).\n" +
- "Hotspot: clickable area with shape from mask/polygon (AC.Hotspot) — " +
- "place the script ON TOP of the Hotspot object, click AC Setup, and the " +
- "default BoxCollider is replaced by the mesh-shaped collider.")]
- public ACTargetMode acTargetMode = ACTargetMode.NavigationMesh;
- [Header("Mesh Source")]
- [Tooltip("Mask: generated from PNG mask + camera/Linear unprojection (fields below).\n" +
- "Polygon: generated directly from Outline Points — flat mesh at Y=floorY, " +
- "no camera/FOV. Edges match EXACTLY the points you place.")]
- public MeshSourceMode meshSourceMode = MeshSourceMode.Mask;
- // ─────────────────────────────────────────────
- // POLYGON OUTLINE (alternative to Mask)
- // ─────────────────────────────────────────────
- [Header("Polygon Outline (if Mesh Source = Polygon)")]
- [Tooltip("Outline points in LOCAL space (relative to this GameObject), IN ORDER. " +
- "The mesh fills their interior (EarClip). Click 'Initialize Polygon from Mask' " +
- "for automatic initial outline from the mask, then drag handles in Scene view " +
- "to fit it in 3D space. The Y of each point is ignored (floorY is used).")]
- public List<Vector3> outlinePoints = new List<Vector3>();
- [Range(6, 80)]
- [Tooltip("How many points to generate from 'Initialize Polygon from Mask'. " +
- "Fewer = easier editing; more = more accurate outline.")]
- public int initResolution = 16;
- [Tooltip("For 'Initialize Polygon from Camera Rect': world Z of the FAR edge " +
- "of the rectangle (v=1). The near edge (v=0) uses the camera's natural depth. Default 0.")]
- public float rectFarZ = 0f;
- [Range(1, 12)]
- [Tooltip("For 'Initialize Polygon from Camera Rect': how many segments per side " +
- "(more = more handles to 'bend' the rectangle into shape).")]
- public int rectSubdivisions = 1;
- [Tooltip("If true, the Polygon mesh has faces on BOTH sides (double-sided), " +
- "ensuring AC raycast hits it regardless of orientation. " +
- "Recommended ON for NavMesh.")]
- public bool doubleSidedPolygon = true;
- // ─────────────────────────────────────────────
- // MASK
- // ─────────────────────────────────────────────
- [Header("Mask (if Mesh Source = Mask)")]
- [Tooltip("PNG mask — white = walkable, black = non-walkable")]
- public Texture2D maskTexture;
- [Range(0.05f, 0.95f)]
- [Tooltip("Brightness threshold (0-1) above which a pixel is considered walkable")]
- public float threshold = 0.5f;
- [Header("Height Map (optional)")]
- [Tooltip("Grayscale PNG, SAME dimensions as maskTexture (or at least same aspect). " +
- "White = heightMax, Black = heightMin. Must have Read/Write Enabled in import settings.")]
- public Texture2D heightTexture;
- public float heightMin = 0f;
- public float heightMax = 2f;
- [Header("Depth Map (Depth Anything etc. — replaces v-based Z)")]
- public Texture2D depthMapTexture;
- public float depthNearZ = 0f; // world Z where depthValue = 1 (white/near)
- public float depthFarZ = 30f; // world Z where depthValue = 0 (black/far)
- [Range(0f, 1f)]
- [Tooltip("0 = ignore depth map (pure Perspective). 1 = fully replace Z with depth map. " +
- "Intermediate = slight correction on top of the already computed Perspective Z.")]
- public float depthMapInfluence = 0f;
- // ─────────────────────────────────────────────
- // CAMERA UNPROJECT / FLOOR (from v1 — works well with FOV tuning)
- // ─────────────────────────────────────────────
- [Header("Camera Unproject / Floor")]
- [Tooltip("The scene camera. Every mask-pixel is projected through this onto the floor.")]
- public Camera warpCamera;
- [HideInInspector]
- [Tooltip("Helper: where you see the horizon/eye-level in the artwork (0=bottom, 1=top). " +
- "The 'Set Camera Tilt' button calculates the X rotation so the camera's horizon falls there. " +
- "Custom-drawn next to Warp Camera.")]
- public float horizonV = 0.5f;
- [Tooltip("Perspective: Z from ray↔floorY intersection (correct near, 'explodes' near " +
- "camera eye-height). Linear: Z = lerp(Near/Far Depth Z, v) — no asymptote, " +
- "ideal when the mask extends high (e.g., as a door to another room).")]
- public DepthMode depthMode = DepthMode.Perspective;
- [Tooltip("Y position of the walkable plane in world space")]
- public float floorY = -2.72f;
- [Tooltip("Only for Perspective mode. Maximum distance (world units, in depth Z) from " +
- "the camera that a floor pixel is allowed to land. Beyond this, the pixel is " +
- "'cut' to a constant Z = camera.z ± Max Floor Distance (Y=floorY) — not discarded, " +
- "but the position there is arbitrary relative to hotspots.")]
- public float maxFloorDistance = 50f;
- [Tooltip("Only for Linear mode. World X at u=0 (left edge of the mask). " +
- "Set to the Transform.X of the left hotspot.")]
- public float leftX = -10f;
- [Tooltip("Only for Linear mode. World X at u=1 (right edge of the mask). " +
- "Set to the Transform.X of the right hotspot.")]
- public float rightX = 10f;
- [Tooltip("Only for Linear mode. World Z at v=0 (bottom/near edge of the mask). " +
- "Set to the same value as the Z that already works well in the near portion " +
- "(see Log Depth Range in Perspective mode before switching).")]
- public float nearDepthZ = 0f;
- [Tooltip("Only for Linear mode. World Z at v=1 (top/far edge of the mask). " +
- "Set to the Z where you want the far portion to 'touch' — e.g., the Transform.Z " +
- "of a far hotspot (Bedroom/Exit).")]
- public float farDepthZ = 30f;
- [Range(0.1f, 1f)]
- [Tooltip("Ignore mask pixels above this v (cut the region near the horizon). 1 = no limit.")]
- public float horizonClampV = 1f;
- [Range(0.05f, 1f)]
- [Tooltip("Scales the entire walkable TOWARD the camera position. " +
- "1 = exact physical floor. Smaller = closer to the camera.")]
- public float meshDistanceScale = 1f;
- [Range(0.05f, 1f)]
- [Tooltip("Compresses ONLY depth (Z) around the Target Surface Z, keeping X/Y fixed.")]
- public float depthCompression = 1f;
- [Tooltip("Manual offset (world units) applied on every regen, AFTER unprojection.")]
- public Vector3 meshWorldOffset = Vector3.zero;
- [Tooltip("Target world Z for the center of the surface — see 'Snap Surface to Z' button.")]
- public float targetSurfaceZ = -5f;
- [Tooltip("If true, logs the depth range (Z) of the mesh to Console after Generate.")]
- public bool logDepthRange = false;
- // ─────────────────────────────────────────────
- // MESH GENERATION
- // ─────────────────────────────────────────────
- [Header("Mesh Generation")]
- [Tooltip("Grid (default): one quad per (downsampled) pixel of the mask — like Script 1, " +
- "but with Mesh4's robust unprojection (no pixel is rejected/stuck near the " +
- "horizon), guaranteeing 100% coverage of the mask.\n" +
- "Contour: contour-trace + simplify + ear-clip — many fewer polygons, but " +
- "Simplification slightly eats corners (Mask Dilation compensates).")]
- public MeshGenerationMode meshGenerationMode = MeshGenerationMode.Grid;
- [Range(1, 20)]
- [Tooltip("Only for Grid mode: shrink the mask before grid (e.g., 8 = 1 quad per 8x8 px). " +
- "Smaller = more accurate outline but many more polygons/vertices.")]
- public int downsampling = 8;
- [Range(-30, 30)]
- [Tooltip("Changes the size of the walkable area BEFORE mesh generation:\n" +
- "> 0 = dilate (EXPANDS) — the collider is slightly larger than the " +
- "painted area (good when the player steps out at the edges).\n" +
- "< 0 = erode (SHRINKS) — the collider is slightly smaller.\n" +
- "0 = no change.")]
- public int maskDilation = 2;
- [Range(0f, 30f)]
- [Tooltip("Contour mode: Douglas-Peucker simplification in pixels (2-4 ideal).\n" +
- "Grid mode: maximum horizontal run length — merges consecutive walkable cells " +
- "in the same row into one wide quad (fewer triangles, same coverage). " +
- "1 = one quad/cell, larger = fewer/wider quads.\n" +
- "Polygon mode: ignored (mesh is defined by Outline Points).")]
- public float simplification = 2f;
- [Min(0)]
- [Tooltip("Only for Contour mode: ignore islands/holes smaller than this area (px²)")]
- public int minRegionArea = 100;
- public bool removeOriginalBoxCollider = true;
- // ─────────────────────────────────────────────
- // MESH EXTENSIONS (off-screen walkable)
- // ─────────────────────────────────────────────
- [Header("Mesh Extensions (off-screen walkable)")]
- [Tooltip("Rectangular sections of walkable area added to the sides of the mesh (same Y), " +
- "so the player can walk off-screen (e.g., at an exit point). " +
- "Works in ALL modes. Use the '+ Left/Right/Top/Bottom' buttons.")]
- public List<MeshExtension> extensions = new List<MeshExtension>();
- // ─────────────────────────────────────────────
- // GLOBAL RESIZE (entire mesh)
- // ─────────────────────────────────────────────
- [Header("Global Resize (entire mesh)")]
- [Range(0.1f, 3f)]
- [Tooltip("Uniform scale of the ENTIRE mesh around its center (world XZ). " +
- "1 = nothing, 0.9 = 90% (smaller), 1.2 = larger. Y does not change. " +
- "Applied AFTER extensions.")]
- public float meshScaleMultiplier = 1f;
- [Tooltip("Inset/Outset: cuts (>0, SHRINKS) or adds (<0, EXPANDS) a fixed " +
- "distance (world units) from ALL edges, toward the center. E.g., 0.1 = cut " +
- "0.1 units everywhere. Applied AFTER scale.")]
- public float meshInset = 0f;
- // ─────────────────────────────────────────────
- // BAKE
- // ─────────────────────────────────────────────
- [Header("Bake")]
- [Tooltip("If present, the MeshCollider uses this automatically. " +
- "Any parameter change auto-deletes it and reverts to live mesh.")]
- public Mesh bakedMeshAsset;
- [Tooltip("If ON and a baked asset exists: every change RE-BAKES in-place to the same " +
- "asset (overwrite), instead of deleting the bake and reverting to live. Thus the " +
- "mesh stays ALWAYS baked and you never lose the asset. (OFF = old behavior: " +
- "change ➜ clear bake ➜ live.)")]
- public bool autoReBake = true;
- // ─────────────────────────────────────────────
- // GIZMOS
- // ─────────────────────────────────────────────
- [Header("Gizmos")]
- public GizmoMode gizmoMode = GizmoMode.WhenSelected;
- public Color fillColor = new Color(0f, 1f, 0.4f, 0.25f);
- public Color wireColor = new Color(0f, 1f, 0.4f, 0.9f);
- [Tooltip("Shows two orange frames at Z = camera.z ± Max Floor Distance. " +
- "Helps you visually see if the far/top portion of the mesh is 'cut' (clamped) before " +
- "reaching the correct depth — e.g., compare it with the position of Bedroom/Exit hotspots.")]
- public bool showDepthLimitGizmo = false;
- // ─────────────────────────────────────────────
- // PLAYER SPAWN
- // ─────────────────────────────────────────────
- [Header("Player Spawn")]
- [Tooltip("Only for Mesh Source = Mask. Entry point in mask-space (0-1). " +
- "x=left→right, y=bottom→top. Default: center. Button " +
- "'Spawn ➜ Mesh Center' calculates it automatically.")]
- public Vector2 playerSpawnUV = new Vector2(0.5f, 0.5f);
- [Tooltip("Only for Mesh Source = Polygon. World spawn position (X,Z; Y is ignored, " +
- "floorY is used). Drag the yellow indicator in Scene view.")]
- public Vector3 polygonSpawnPoint = Vector3.zero;
- [Tooltip("If empty, searches for tag 'Player'")]
- public Transform playerTransform;
- // ─────────────────────────────────────────────
- // INTERNALS
- // ─────────────────────────────────────────────
- [System.NonSerialized] public int statVerts;
- [System.NonSerialized] public int statTris;
- Mesh liveMesh;
- MeshCollider meshCollider;
- int lastHash;
- // ═════════════════════════════════════════════
- // UNITY LIFECYCLE
- // ═════════════════════════════════════════════
- void Reset()
- {
- if (warpCamera == null) warpCamera = Camera.main;
- #if UNITY_EDITOR
- // If the script is added onto an existing AC.Hotspot, default to Hotspot mode
- // (don't add NavigationMesh or change layer to "NavMesh").
- bool onHotspot = FindACType("Hotspot") != null && GetComponent(FindACType("Hotspot")) != null;
- if (onHotspot) acTargetMode = ACTargetMode.Hotspot;
- #endif
- if (acTargetMode == ACTargetMode.NavigationMesh)
- {
- int navLayer = LayerMask.NameToLayer("NavMesh");
- if (navLayer >= 0) gameObject.layer = navLayer;
- }
- #if UNITY_EDITOR
- // Deferred: AddComponent inside Reset() is blocked by the editor in some Unity versions.
- EditorApplication.delayCall += () =>
- {
- if (this == null) return;
- SetupACComponents();
- };
- #endif
- }
- void OnEnable()
- {
- meshCollider = GetComponent<MeshCollider>();
- if (removeOriginalBoxCollider) RemoveBoxCollider();
- Refresh();
- lastHash = ComputeHash();
- }
- void OnDestroy()
- {
- if (liveMesh != null) DestroySafe(liveMesh);
- }
- void OnValidate()
- {
- horizonClampV = Mathf.Clamp(horizonClampV, 0.1f, 1f);
- playerSpawnUV.x = Mathf.Clamp01(playerSpawnUV.x);
- playerSpawnUV.y = Mathf.Clamp01(playerSpawnUV.y);
- }
- void Update()
- {
- #if UNITY_EDITOR
- if (Application.isPlaying) return;
- int h = ComputeHash();
- if (h != lastHash)
- {
- lastHash = h;
- if (bakedMeshAsset != null)
- {
- if (autoReBake) BakeToAsset(); // re-bake in-place, keep the asset
- else { ClearBakedAsset(true); RegenerateLive(); }
- }
- else
- {
- RegenerateLive();
- }
- SceneView.RepaintAll();
- }
- #endif
- }
- int ComputeHash()
- {
- unchecked
- {
- int h = 17;
- h = h * 31 + (int)meshSourceMode;
- h = h * 31 + meshScaleMultiplier.GetHashCode();
- h = h * 31 + meshInset.GetHashCode();
- if (extensions != null)
- foreach (var ex in extensions)
- {
- if (ex == null) continue;
- h = h * 31 + (ex.enabled ? 1 : 0);
- h = h * 31 + (int)ex.side;
- h = h * 31 + ex.extent.GetHashCode();
- h = h * 31 + ex.span.GetHashCode();
- h = h * 31 + ex.slideAlongEdge.GetHashCode();
- h = h * 31 + ex.yOffset.GetHashCode();
- h = h * 31 + ex.overlap.GetHashCode();
- }
- if (meshSourceMode == MeshSourceMode.Polygon)
- {
- if (outlinePoints != null)
- foreach (var p in outlinePoints)
- h = h * 31 + p.GetHashCode();
- h = h * 31 + (doubleSidedPolygon ? 1 : 0);
- h = h * 31 + polygonSpawnPoint.GetHashCode();
- h = h * 31 + floorY.GetHashCode();
- h = h * 31 + transform.position.GetHashCode();
- h = h * 31 + transform.rotation.GetHashCode();
- h = h * 31 + transform.lossyScale.GetHashCode();
- return h;
- }
- h = h * 31 + (maskTexture != null ? maskTexture.GetEntityId().GetHashCode() : 0);
- h = h * 31 + threshold.GetHashCode();
- h = h * 31 + (int)meshGenerationMode;
- h = h * 31 + downsampling;
- h = h * 31 + (int)depthMode;
- h = h * 31 + leftX.GetHashCode();
- h = h * 31 + rightX.GetHashCode();
- h = h * 31 + nearDepthZ.GetHashCode();
- h = h * 31 + farDepthZ.GetHashCode();
- h = h * 31 + floorY.GetHashCode();
- h = h * 31 + maxFloorDistance.GetHashCode();
- h = h * 31 + horizonClampV.GetHashCode();
- h = h * 31 + meshDistanceScale.GetHashCode();
- h = h * 31 + depthCompression.GetHashCode();
- h = h * 31 + meshWorldOffset.GetHashCode();
- h = h * 31 + targetSurfaceZ.GetHashCode();
- h = h * 31 + simplification.GetHashCode();
- h = h * 31 + minRegionArea;
- h = h * 31 + maskDilation;
- h = h * 31 + (heightTexture != null ? heightTexture.GetEntityId().GetHashCode() : 0);
- h = h * 31 + heightMin.GetHashCode();
- h = h * 31 + heightMax.GetHashCode();
- if (warpCamera != null)
- {
- h = h * 31 + warpCamera.transform.position.GetHashCode();
- h = h * 31 + warpCamera.transform.rotation.GetHashCode();
- h = h * 31 + warpCamera.fieldOfView.GetHashCode();
- h = h * 31 + warpCamera.aspect.GetHashCode();
- h = h * 31 + (warpCamera.orthographic ? 1 : 0);
- h = h * 31 + warpCamera.orthographicSize.GetHashCode();
- }
- h = h * 31 + transform.position.GetHashCode();
- h = h * 31 + transform.rotation.GetHashCode();
- h = h * 31 + transform.lossyScale.GetHashCode();
- return h;
- }
- }
- // ═════════════════════════════════════════════
- // FLOOR PLACEMENT (camera unproject — from v1)
- // ═════════════════════════════════════════════
- /// <summary>
- /// Viewport UV (0-1, v=0 bottom) ➜ world position.<br/>
- /// <b>Perspective</b>: ray through the camera, intersection with y=floorY. Correct near the
- /// camera, but depth "explodes" as v approaches the camera's eye-height
- /// (where the ray becomes parallel to the floor). Beyond maxFloorDistance in
- /// depth Z, it does NOT follow the ray to infinity (that would create a vertical
- /// "fin" near the camera). Instead, only X is projected via the X/Z ratio of the
- /// ray onto constant Z = camera.z ± maxFloorDistance, with Y=floorY — a horizontal
- /// "back edge", not a wall. The position there is arbitrary relative to hotspots.<br/>
- /// <b>Linear</b>: X = lerp(leftX, rightX, u), Z = lerp(nearDepthZ, farDepthZ, v),
- /// Y = floorY. No camera/ray dependency — the mask becomes a rectangle in world XZ
- /// with the 4 edges EXACTLY where you define them (e.g., at the X/Z of left/right/far hotspots).
- /// </summary>
- ///
- float SampleHeight(Vector2 uv)
- {
- // Sample based on alpha channel
- // If alpha is 0, return heightMin; if 1, return heightMax
- if (heightTexture == null) return 0f;
- Color c = heightTexture.GetPixelBilinear(uv.x, uv.y);
- float alpha = c.a;
- return Mathf.Lerp(heightMin, heightMax, alpha);
- }
- public Vector3 UnprojectViewportToWorld(Vector2 uv)
- {
- if (warpCamera == null) return transform.position;
- Vector3 camPos = warpCamera.transform.position;
- Vector3 worldHit;
- if (depthMode == DepthMode.Linear)
- {
- float x = Mathf.LerpUnclamped(leftX, rightX, uv.x);
- float z = Mathf.LerpUnclamped(nearDepthZ, farDepthZ, uv.y);
- worldHit = new Vector3(x, floorY, z);
- }
- else
- {
- Ray ray = warpCamera.ViewportPointToRay(new Vector3(uv.x, uv.y, 0f));
- Plane floorPlane = new Plane(Vector3.up, new Vector3(0f, floorY, 0f));
- bool gotFloorHit = floorPlane.Raycast(ray, out float dist) && dist > 0f;
- if (gotFloorHit)
- {
- worldHit = ray.GetPoint(dist);
- float dz = worldHit.z - camPos.z;
- if (Mathf.Abs(dz) > maxFloorDistance)
- worldHit = ProjectRayToZ(ray, camPos.z + Mathf.Sign(dz) * maxFloorDistance);
- }
- else
- {
- float sign = !Mathf.Approximately(ray.direction.z, 0f) ? Mathf.Sign(ray.direction.z) : 1f;
- worldHit = ProjectRayToZ(ray, camPos.z + sign * maxFloorDistance);
- }
- // ── Depth map correction (optional) ──
- if (depthMapTexture != null && depthMapInfluence > 0.001f)
- {
- float d = depthMapTexture.GetPixelBilinear(uv.x, uv.y).grayscale;
- float depthZ = Mathf.Lerp(depthFarZ, depthNearZ, d); // white(1)=near
- float correctedZ = Mathf.Lerp(worldHit.z, depthZ, depthMapInfluence);
- // Recalculate X to stay on the same ray at the new Z
- worldHit = ProjectRayToZ(ray, correctedZ);
- }
- }
- // Scale toward the camera position along the ray.
- if (meshDistanceScale < 0.999f)
- worldHit = camPos + meshDistanceScale * (worldHit - camPos);
- // Depth compression around targetSurfaceZ.
- if (depthCompression < 0.999f)
- worldHit.z = targetSurfaceZ + depthCompression * (worldHit.z - targetSurfaceZ);
- worldHit.y += SampleHeight(uv);
- worldHit += meshWorldOffset;
- return worldHit;
- }
- /// <summary>
- /// Projects the ray onto a constant world Z depth: calculates X via the X/Z ratio of
- /// the direction (same triangle as ray↔floorY intersection, but with Z as the constraint
- /// instead of Y), and sets Y=floorY. Used both for the depth-cap in Perspective mode
- /// and for EVERY point in Linear mode.
- /// </summary>
- Vector3 ProjectRayToZ(Ray ray, float targetZ)
- {
- float x = ray.origin.x;
- if (Mathf.Abs(ray.direction.z) > 1e-6f)
- x = ray.origin.x + ray.direction.x / ray.direction.z * (targetZ - ray.origin.z);
- return new Vector3(x, floorY, targetZ);
- }
- public Vector3 UnprojectViewportToLocal(Vector2 uv) =>
- transform.InverseTransformPoint(UnprojectViewportToWorld(uv));
- /// <summary>world ➜ viewport UV (camera projection, for the spawn handle).</summary>
- public Vector2 WorldToUV(Vector3 world)
- {
- if (warpCamera == null) return new Vector2(0.5f, 0.25f);
- Vector3 vp = warpCamera.WorldToViewportPoint(world);
- return new Vector2(vp.x, vp.y);
- }
- // ═════════════════════════════════════════════
- // SNAP SURFACE TO Z (from v1)
- // ═════════════════════════════════════════════
- public void SnapSurfaceToZ()
- {
- if (warpCamera == null)
- {
- Debug.LogWarning("[Mesh4] Snap Surface to Z requires warp camera.");
- return;
- }
- Mesh m = GetActiveMesh();
- if (m == null || m.vertexCount == 0)
- {
- RegenerateLive();
- m = liveMesh;
- }
- if (m == null || m.vertexCount == 0)
- {
- Debug.LogWarning("[Mesh4] No mesh to snap — generate first.");
- return;
- }
- var verts = m.vertices;
- double sumZ = 0;
- for (int i = 0; i < verts.Length; i++)
- sumZ += transform.TransformPoint(verts[i]).z;
- float centroidZ = (float)(sumZ / verts.Length);
- float delta = targetSurfaceZ - centroidZ;
- meshWorldOffset.z += delta;
- Debug.Log($"[Mesh4] Snap surface: centroid Z {centroidZ:F2} → {targetSurfaceZ:F2} (Δz {delta:+0.00;-0.00}).");
- #if UNITY_EDITOR
- EditorUtility.SetDirty(this);
- if (bakedMeshAsset != null)
- {
- if (autoReBake) { BakeToAsset(); return; }
- ClearBakedAsset(true);
- }
- #endif
- RegenerateLive();
- }
- // ═════════════════════════════════════════════
- // SPAWN POINT
- // ═════════════════════════════════════════════
- public Vector3 GetSpawnWorldPosition()
- {
- if (meshSourceMode == MeshSourceMode.Polygon)
- return new Vector3(polygonSpawnPoint.x, floorY, polygonSpawnPoint.z);
- return UnprojectViewportToWorld(playerSpawnUV);
- }
- /// <summary>
- /// Mask mode: calculates the (area-weighted) centroid of the largest walkable
- /// region of the mask and places the playerSpawnUV there.<br/>
- /// Polygon mode: calculates the centroid of the Outline Points and places
- /// the polygonSpawnPoint there.
- /// </summary>
- public void CenterSpawnOnMesh()
- {
- if (meshSourceMode == MeshSourceMode.Polygon)
- {
- if (outlinePoints == null || outlinePoints.Count < 3)
- {
- Debug.LogWarning("[Mesh4] Polygon mode: need at least 3 Outline Points.");
- return;
- }
- var poly = new List<Vector2>(outlinePoints.Count);
- foreach (var p in outlinePoints)
- {
- Vector3 wp = transform.TransformPoint(p);
- poly.Add(new Vector2(wp.x, wp.z));
- }
- if (poly.Count < 3) return;
- Vector2 c = PolygonCentroid(poly);
- #if UNITY_EDITOR
- Undo.RecordObject(this, "Center Spawn On Mesh");
- #endif
- polygonSpawnPoint = new Vector3(c.x, floorY, c.y);
- #if UNITY_EDITOR
- EditorUtility.SetDirty(this);
- #endif
- Debug.Log($"[Mesh4] Spawn ➜ polygon center, world ({c.x:0.##}, {floorY:0.##}, {c.y:0.##}).");
- return;
- }
- if (!ComputeWalkablePolygons(out var outers, out _, out int w, out int h))
- {
- Debug.LogWarning("[Mesh4] No walkable region found — cannot calculate center.");
- return;
- }
- List<Vector2> best = null;
- float bestArea = -1f;
- foreach (var o in outers)
- {
- float a = Mathf.Abs(SignedArea(o));
- if (a > bestArea) { bestArea = a; best = o; }
- }
- if (best == null) return;
- Vector2 c2 = PolygonCentroid(best);
- Vector2 uv = new Vector2(Mathf.Clamp01(c2.x / w), Mathf.Clamp01(c2.y / h));
- #if UNITY_EDITOR
- Undo.RecordObject(this, "Center Spawn On Mesh");
- #endif
- playerSpawnUV = uv;
- #if UNITY_EDITOR
- EditorUtility.SetDirty(this);
- #endif
- Debug.Log($"[Mesh4] Spawn ➜ mesh center, UV ({uv.x:0.##}, {uv.y:0.##}).");
- }
- static Vector2 PolygonCentroid(List<Vector2> poly)
- {
- float sixA = 0f, cx = 0f, cy = 0f;
- for (int i = 0; i < poly.Count; i++)
- {
- Vector2 p0 = poly[i], p1 = poly[(i + 1) % poly.Count];
- float cross = p0.x * p1.y - p1.x * p0.y;
- sixA += cross;
- cx += (p0.x + p1.x) * cross;
- cy += (p0.y + p1.y) * cross;
- }
- sixA *= 3f; // 6 * signed area
- if (Mathf.Abs(sixA) < 1e-9f)
- {
- Vector2 avg = Vector2.zero;
- foreach (var p in poly) avg += p;
- return avg / poly.Count;
- }
- return new Vector2(cx / sixA, cy / sixA);
- }
- public void PlacePlayerAtSpawn()
- {
- Transform t = playerTransform;
- if (t == null)
- {
- GameObject p = GameObject.FindWithTag("Player");
- if (p != null) t = p.transform;
- }
- if (t == null)
- {
- Debug.LogWarning("[Mesh4] Player not found (set playerTransform or tag 'Player').");
- return;
- }
- #if UNITY_EDITOR
- Undo.RecordObject(t, "Place Player At Spawn");
- #endif
- t.position = GetSpawnWorldPosition();
- #if UNITY_EDITOR
- EditorUtility.SetDirty(t);
- #endif
- Debug.Log("[Mesh4] Player ➜ " + t.position);
- }
- // ═════════════════════════════════════════════
- // HORIZON ➜ CAMERA TILT (helper for new backgrounds)
- // ═════════════════════════════════════════════
- /// <summary>
- /// Suggested X rotation (degrees, looking down = positive) so the camera's horizon
- /// falls at the artwork's horizonV.
- /// For a camera with pitch p (down positive), the horizon (where the ray is horizontal)
- /// appears at viewport v = 0.5 + p/FOV_v (linear around center). Solving for p:
- /// p = (horizonV - 0.5) * FOV_v.
- /// If horizonV > 0.5 (horizon high → you see a lot of floor), p > 0 → looking down.
- /// </summary>
- public float SuggestedTiltX()
- {
- float fovV = (warpCamera != null && !warpCamera.orthographic) ? warpCamera.fieldOfView : 45f;
- return (horizonV - 0.5f) * fovV;
- }
- public void ApplyHorizonTilt()
- {
- if (warpCamera == null)
- {
- Debug.LogWarning("[Mesh4] Set Camera Tilt: set warpCamera.");
- return;
- }
- float tilt = SuggestedTiltX();
- #if UNITY_EDITOR
- Undo.RecordObject(warpCamera.transform, "Set Camera Tilt From Horizon");
- #endif
- Vector3 e = warpCamera.transform.eulerAngles;
- e.x = tilt;
- warpCamera.transform.eulerAngles = e;
- #if UNITY_EDITOR
- EditorUtility.SetDirty(warpCamera.transform);
- #endif
- Debug.Log($"[Mesh4] Camera rotation X ➜ {tilt:0.##}° (horizon v={horizonV:0.##}, FOV={warpCamera.fieldOfView:0.#}). " +
- "Adjust floorY/FOV if needed.");
- RegenerateLive();
- }
- // ═════════════════════════════════════════════
- // REFRESH / MESH ASSIGNMENT
- // ═════════════════════════════════════════════
- public void Refresh()
- {
- if (meshCollider == null) meshCollider = GetComponent<MeshCollider>();
- if (bakedMeshAsset != null) { AssignMesh(bakedMeshAsset); return; }
- RegenerateLive();
- }
- public void RegenerateLive()
- {
- Mesh m = GenerateCollisionMesh();
- if (m == null) return;
- if (liveMesh != null) DestroySafe(liveMesh);
- liveMesh = m;
- liveMesh.hideFlags = HideFlags.DontSave;
- AssignMesh(liveMesh);
- }
- void AssignMesh(Mesh m)
- {
- if (meshCollider == null) meshCollider = GetComponent<MeshCollider>();
- if (meshCollider != null)
- {
- meshCollider.sharedMesh = null;
- meshCollider.convex = false;
- meshCollider.sharedMesh = m;
- }
- statVerts = (m != null) ? m.vertexCount : 0;
- statTris = (m != null) ? m.triangles.Length / 3 : 0;
- }
- public Mesh GetActiveMesh()
- {
- if (bakedMeshAsset != null) return bakedMeshAsset;
- if (liveMesh != null) return liveMesh;
- if (meshCollider == null) meshCollider = GetComponent<MeshCollider>();
- return meshCollider != null ? meshCollider.sharedMesh : null;
- }
- void RemoveBoxCollider()
- {
- var bc = GetComponent<BoxCollider>();
- if (bc == null) return;
- #if UNITY_EDITOR
- DestroyImmediate(bc);
- #else
- Destroy(bc);
- #endif
- var mr = GetComponent<MeshRenderer>();
- if (mr != null) mr.enabled = false;
- }
- static void DestroySafe(Object o)
- {
- if (o == null) return;
- if (Application.isPlaying) Destroy(o); else DestroyImmediate(o);
- }
- // ═════════════════════════════════════════════
- // MESH GENERATION (contour trace + simplify + ear-clip — from v3,
- // but vertices come from UnprojectViewportToWorld — v1 geometry)
- // ═════════════════════════════════════════════
- /// <summary>
- /// Mask ➜ threshold ➜ dilation ➜ horizon clamp ➜ contour trace ➜ simplify ➜
- /// sort into outer/hole loops (mask-pixel space). Used by both GenerateCollisionMesh
- /// and CenterSpawnOnMesh.
- /// </summary>
- bool ComputeWalkablePolygons(out List<List<Vector2>> outers, out List<List<Vector2>> holes, out int w, out int h)
- {
- outers = null; holes = null; w = 0; h = 0;
- if (maskTexture == null) return false;
- Color32[] pixels = ReadPixels(maskTexture, out w, out h);
- if (pixels == null || pixels.Length != w * h) return false;
- bool[] solid = new bool[w * h];
- int thr = Mathf.RoundToInt(threshold * 255f);
- for (int i = 0; i < solid.Length; i++)
- {
- Color32 c = pixels[i];
- solid[i] = (c.r + c.g + c.b) / 3 > thr;
- }
- // Resize walkable area BEFORE simplification:
- // > 0 dilate (expands, prevents fall-through at edges),
- // < 0 erode (shrinks).
- if (maskDilation > 0)
- solid = DilateMask(solid, w, h, maskDilation);
- else if (maskDilation < 0)
- solid = ErodeMask(solid, w, h, -maskDilation);
- // Horizon clamp applied AFTER dilation, so it stays a hard cutoff.
- int yMax = Mathf.RoundToInt(horizonClampV * h);
- for (int i = 0; i < solid.Length; i++)
- if (i / w >= yMax) solid[i] = false;
- List<List<Vector2>> loops = TraceContours(solid, w, h);
- if (loops.Count == 0) return false;
- outers = new List<List<Vector2>>();
- holes = new List<List<Vector2>>();
- foreach (var loop in loops)
- {
- if (Mathf.Abs(SignedArea(loop)) < minRegionArea) continue;
- var simp = SimplifyClosed(loop, simplification);
- if (simp.Count < 3) continue;
- if (SignedArea(simp) > 0f) outers.Add(simp);
- else holes.Add(simp);
- }
- return outers.Count > 0;
- }
- public Mesh GenerateCollisionMesh()
- {
- if (meshSourceMode == MeshSourceMode.Polygon)
- return GeneratePolygonMesh();
- if (maskTexture == null) return null;
- if (warpCamera == null)
- {
- Debug.LogWarning("[Mesh4] Missing warp camera — required for camera-unproject.");
- return null;
- }
- return meshGenerationMode == MeshGenerationMode.Grid ? GenerateGridMesh() : GenerateContourMesh();
- }
- /// <summary>
- /// Polygon mode: takes Outline Points (LOCAL space), uses (x,z) as the 2D
- /// outline with Y=floorY (in local), and triangulates with the same EarClip
- /// from Contour mode. No camera/ray/FOV. If doubleSidedPolygon=true, faces are
- /// added on both sides so AC raycast hits it reliably.
- /// </summary>
- Mesh GeneratePolygonMesh()
- {
- if (outlinePoints == null || outlinePoints.Count < 3)
- {
- Debug.LogWarning("[Mesh4] Polygon mode: need at least 3 Outline Points. " +
- "Click 'Initialize Polygon from Mask' or add points.");
- return null;
- }
- var poly2D = new List<Vector2>(outlinePoints.Count);
- foreach (var p in outlinePoints) poly2D.Add(new Vector2(p.x, p.z));
- if (Mathf.Abs(SignedArea(poly2D)) < 1e-6f)
- {
- Debug.LogWarning("[Mesh4] Polygon mode: Outline Points have zero area (collinear?).");
- return null;
- }
- List<int> tris = EarClip(poly2D);
- if (tris.Count < 3) return null;
- // Local-space vertices: floorY is world; convert to local Y via inverse.
- // Keep X/Z from points (already local) and set constant local-Y corresponding
- // to world floorY under this transform.
- float localFloorY = transform.InverseTransformPoint(new Vector3(0f, floorY, 0f)).y;
- var verts = new List<Vector3>(poly2D.Count);
- foreach (var p in poly2D) verts.Add(new Vector3(p.x, localFloorY, p.y));
- var indices = new List<int>(tris);
- // Top face points +Y. Ensure correct winding so RecalculateNormals
- // produces normal toward +Y (so AC raycast from above hits it).
- if (!FaceIsUpward(verts, indices))
- for (int i = 0; i + 2 < indices.Count; i += 3)
- {
- int tmp = indices[i + 1]; indices[i + 1] = indices[i + 2]; indices[i + 2] = tmp;
- }
- if (doubleSidedPolygon)
- {
- // Add same vertices again with reversed winding → bottom face.
- int baseIdx = verts.Count;
- verts.AddRange(verts.GetRange(0, baseIdx));
- int topCount = indices.Count;
- for (int i = 0; i + 2 < topCount; i += 3)
- {
- indices.Add(baseIdx + indices[i]);
- indices.Add(baseIdx + indices[i + 2]);
- indices.Add(baseIdx + indices[i + 1]);
- }
- }
- AppendExtensionsLocal(verts, indices);
- ApplyGlobalResize(verts);
- Mesh mesh = new Mesh { name = gameObject.name + "_NavMesh" };
- if (verts.Count > 65000)
- mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
- mesh.SetVertices(verts);
- mesh.SetTriangles(indices, 0);
- mesh.RecalculateNormals();
- mesh.RecalculateBounds();
- statVerts = verts.Count;
- statTris = indices.Count / 3;
- return mesh;
- }
- /// <summary>True if the first non-degenerate triangle has normal toward +Y.</summary>
- static bool FaceIsUpward(List<Vector3> verts, List<int> indices)
- {
- for (int i = 0; i + 2 < indices.Count; i += 3)
- {
- Vector3 nrm = Vector3.Cross(verts[indices[i + 1]] - verts[indices[i]],
- verts[indices[i + 2]] - verts[indices[i]]);
- if (nrm.sqrMagnitude > 1e-10f) return nrm.y > 0f;
- }
- return true;
- }
- /// <summary>
- /// Fills outlinePoints with an initial outline (local space) from the mask:
- /// trace the largest outer contour, simplify to ~initResolution points, and
- /// unproject each point through the current Mask placement (camera or Linear).
- /// This gives you an outline that already "sits" where the mask mesh sits,
- /// then you drag handles to perfect it.
- /// </summary>
- public void InitializePolygonFromMask()
- {
- if (maskTexture == null)
- {
- Debug.LogWarning("[Mesh4] Initialize from Mask: set Mask Texture first.");
- return;
- }
- if (meshSourceMode == MeshSourceMode.Mask && depthMode == DepthMode.Perspective && warpCamera == null)
- {
- Debug.LogWarning("[Mesh4] Initialize from Mask: Perspective placement requires warpCamera " +
- "(or set Depth Mode = Linear).");
- return;
- }
- if (!ComputeWalkablePolygons(out var outers, out _, out int w, out int h))
- {
- Debug.LogWarning("[Mesh4] Initialize from Mask: no walkable region found in mask.");
- return;
- }
- // Largest outer.
- List<Vector2> best = null; float bestArea = -1f;
- foreach (var o in outers)
- {
- float a = Mathf.Abs(SignedArea(o));
- if (a > bestArea) { bestArea = a; best = o; }
- }
- if (best == null || best.Count < 3) return;
- // Simplify to ~initResolution points: increase epsilon until reaching target
- // (binary-ish search, few iterations suffice).
- List<Vector2> reduced = ReducePointCount(best, Mathf.Max(6, initResolution));
- #if UNITY_EDITOR
- Undo.RecordObject(this, "Initialize Polygon From Mask");
- #endif
- outlinePoints = new List<Vector3>(reduced.Count);
- foreach (var pt in reduced)
- {
- Vector2 uv = new Vector2(pt.x / w, pt.y / h);
- Vector3 world = UnprojectViewportToWorld(uv);
- outlinePoints.Add(transform.InverseTransformPoint(world));
- }
- // Spawn = centroid.
- Vector2 c = PolygonCentroid(reduced);
- Vector2 cuv = new Vector2(c.x / w, c.y / h);
- Vector3 cworld = UnprojectViewportToWorld(cuv);
- polygonSpawnPoint = new Vector3(cworld.x, floorY, cworld.z);
- meshSourceMode = MeshSourceMode.Polygon;
- #if UNITY_EDITOR
- EditorUtility.SetDirty(this);
- #endif
- Debug.Log($"[Mesh4] Initialize from Mask: {outlinePoints.Count} points. " +
- "Drag handles in Scene view to fit in 3D space.");
- RegenerateLive();
- }
- /// <summary>
- /// Creates a rectangle outline at floorY WITHOUT a mask: the 4 corners are
- /// projected from the camera's viewport corners (bottom-left → bottom-right
- /// → top-right → top-left). The near edge (v=0) falls at the camera's natural
- /// depth, the far edge (v=1) at rectFarZ. With rectSubdivisions>1, intermediate
- /// points are added per side so you can 'bend' the rectangle into shape.
- /// Useful when the mask is out of bounds / unusable — start from a clean
- /// rectangle and drag it where you want.
- /// </summary>
- public void InitializePolygonFromCameraRect()
- {
- if (warpCamera == null)
- {
- Debug.LogWarning("[Mesh4] Initialize from Camera Rect: set warpCamera.");
- return;
- }
- // Viewport corners: v=0 (near) at natural depth, v=1 (far) at rectFarZ.
- // Get X from camera-unproject (Perspective floor hit) and manually set Z
- // so the rectangle is "clean" and controlled.
- Vector3 nearL = RectCorner(0f, 0f, false);
- Vector3 nearR = RectCorner(1f, 0f, false);
- Vector3 farR = RectCorner(1f, 1f, true);
- Vector3 farL = RectCorner(0f, 1f, true);
- int seg = Mathf.Max(1, rectSubdivisions);
- var ring = new List<Vector3>();
- AppendEdge(ring, nearL, nearR, seg); // bottom
- AppendEdge(ring, nearR, farR, seg); // right
- AppendEdge(ring, farR, farL, seg); // top
- AppendEdge(ring, farL, nearL, seg); // left
- #if UNITY_EDITOR
- Undo.RecordObject(this, "Initialize Polygon From Camera Rect");
- #endif
- outlinePoints = new List<Vector3>(ring.Count);
- foreach (var w in ring)
- outlinePoints.Add(transform.InverseTransformPoint(w));
- Vector3 center = (nearL + nearR + farR + farL) * 0.25f;
- polygonSpawnPoint = new Vector3(center.x, floorY, center.z);
- meshSourceMode = MeshSourceMode.Polygon;
- #if UNITY_EDITOR
- EditorUtility.SetDirty(this);
- #endif
- Debug.Log($"[Mesh4] Initialize from Camera Rect: {outlinePoints.Count} points " +
- $"(near→far Z, far={rectFarZ:0.##}). Drag handles for final shape.");
- RegenerateLive();
- }
- /// <summary>Rectangle corner at floorY: X via camera-unproject, Z manual if far.</summary>
- Vector3 RectCorner(float u, float v, bool far)
- {
- Ray ray = warpCamera.ViewportPointToRay(new Vector3(u, v, 0f));
- Vector3 camPos = warpCamera.transform.position;
- float targetZ;
- if (far)
- {
- targetZ = rectFarZ;
- }
- else
- {
- // Near edge: natural intersection with floorY (if exists), otherwise slightly in front
- // of the camera.
- Plane floor = new Plane(Vector3.up, new Vector3(0f, floorY, 0f));
- if (floor.Raycast(ray, out float d) && d > 0f)
- targetZ = ray.GetPoint(d).z;
- else
- targetZ = camPos.z + Mathf.Sign(rectFarZ - camPos.z) * 1f;
- }
- float x = ray.origin.x;
- if (Mathf.Abs(ray.direction.z) > 1e-6f)
- x = ray.origin.x + ray.direction.x / ray.direction.z * (targetZ - ray.origin.z);
- return new Vector3(x, floorY, targetZ);
- }
- static void AppendEdge(List<Vector3> ring, Vector3 a, Vector3 b, int segments)
- {
- // Adds a and intermediates, WITHOUT b (b is added by the next edge).
- for (int i = 0; i < segments; i++)
- ring.Add(Vector3.Lerp(a, b, (float)i / segments));
- }
- /// <summary>Reduces a closed polygon to ~target points by increasing RDP epsilon.</summary>
- static List<Vector2> ReducePointCount(List<Vector2> poly, int target)
- {
- if (poly.Count <= target) return new List<Vector2>(poly);
- float lo = 0.5f, hi = 0f;
- // bbox diagonal as upper epsilon bound.
- float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
- foreach (var p in poly) { minX = Mathf.Min(minX, p.x); minY = Mathf.Min(minY, p.y); maxX = Mathf.Max(maxX, p.x); maxY = Mathf.Max(maxY, p.y); }
- hi = new Vector2(maxX - minX, maxY - minY).magnitude;
- List<Vector2> bestFit = SimplifyClosed(poly, lo);
- for (int iter = 0; iter < 24; iter++)
- {
- float mid = (lo + hi) * 0.5f;
- var s = SimplifyClosed(poly, mid);
- if (s.Count > target) lo = mid;
- else { hi = mid; bestFit = s; }
- if (Mathf.Abs(s.Count - target) <= 1) { bestFit = s; break; }
- }
- return bestFit.Count >= 3 ? bestFit : new List<Vector2>(poly);
- }
- /// <summary>Adds a new point between the last and first (closed loop).</summary>
- public void AddOutlinePointAfterLast()
- {
- if (outlinePoints == null) outlinePoints = new List<Vector3>();
- #if UNITY_EDITOR
- Undo.RecordObject(this, "Add Outline Point");
- #endif
- Vector3 np;
- if (outlinePoints.Count >= 2)
- np = Vector3.Lerp(outlinePoints[outlinePoints.Count - 1], outlinePoints[0], 0.5f);
- else if (outlinePoints.Count == 1)
- np = outlinePoints[0] + Vector3.right;
- else
- np = transform.InverseTransformPoint(new Vector3(0f, floorY, 0f));
- outlinePoints.Add(np);
- #if UNITY_EDITOR
- EditorUtility.SetDirty(this);
- #endif
- RegenerateLive();
- }
- public void RemoveLastOutlinePoint()
- {
- if (outlinePoints == null || outlinePoints.Count == 0) return;
- #if UNITY_EDITOR
- Undo.RecordObject(this, "Remove Outline Point");
- #endif
- outlinePoints.RemoveAt(outlinePoints.Count - 1);
- #if UNITY_EDITOR
- EditorUtility.SetDirty(this);
- #endif
- RegenerateLive();
- }
- // ─────────────── Extensions add/remove
- public void AddExtension(ExtensionSide side)
- {
- if (extensions == null) extensions = new List<MeshExtension>();
- #if UNITY_EDITOR
- Undo.RecordObject(this, "Add Mesh Extension");
- #endif
- extensions.Add(new MeshExtension { side = side, label = side.ToString(), extent = 3f });
- #if UNITY_EDITOR
- EditorUtility.SetDirty(this);
- #endif
- RegenerateLive();
- }
- public void RemoveLastExtension()
- {
- if (extensions == null || extensions.Count == 0) return;
- #if UNITY_EDITOR
- Undo.RecordObject(this, "Remove Mesh Extension");
- #endif
- extensions.RemoveAt(extensions.Count - 1);
- #if UNITY_EDITOR
- EditorUtility.SetDirty(this);
- #endif
- RegenerateLive();
- }
- /// <summary>
- /// Grid mode — "Script 1" style: one quad per (downsampled) pixel of the mask. Each
- /// corner is projected with Mesh4's UnprojectViewportToWorld, which now NEVER
- /// fails (depth-clamp instead of radial-clamp) — so no quad is rejected near the
- /// horizon, and coverage matches the mask 1:1 (minus downsampling). More polygons
- /// than Contour mode.
- /// </summary>
- Mesh GenerateGridMesh()
- {
- Color32[] pixels = ReadPixels(maskTexture, out int texW, out int texH);
- if (pixels == null || pixels.Length != texW * texH) return null;
- bool[] solid = new bool[texW * texH];
- int thr = Mathf.RoundToInt(threshold * 255f);
- for (int i = 0; i < solid.Length; i++)
- {
- Color32 c = pixels[i];
- solid[i] = (c.r + c.g + c.b) / 3 > thr;
- }
- if (maskDilation > 0) solid = DilateMask(solid, texW, texH, maskDilation);
- else if (maskDilation < 0) solid = ErodeMask(solid, texW, texH, -maskDilation);
- int yMax = Mathf.RoundToInt(horizonClampV * texH);
- for (int i = 0; i < solid.Length; i++)
- if (i / texW >= yMax) solid[i] = false;
- int ds = Mathf.Max(1, downsampling);
- int gw = Mathf.Max(1, texW / ds);
- int gh = Mathf.Max(1, texH / ds);
- // Walkable map per grid cell.
- var cell = new bool[gw * gh];
- for (int gy = 0; gy < gh; gy++)
- {
- int py0 = gy * ds, py1 = Mathf.Min(py0 + ds, texH);
- for (int gx = 0; gx < gw; gx++)
- {
- int px0 = gx * ds, px1 = Mathf.Min(px0 + ds, texW);
- bool walkable = false;
- for (int py = py0; py < py1 && !walkable; py++)
- for (int px = px0; px < px1; px++)
- if (solid[py * texW + px]) { walkable = true; break; }
- cell[gy * gw + gx] = walkable;
- }
- }
- var vertsWorld = new List<Vector3>();
- var indices = new List<int>();
- // Simplification in Grid mode = greedy horizontal RUN merging: instead of
- // one quad per cell, merge consecutive walkable cells in the same row into ONE
- // wide quad. simplification defines the maximum run length (in cells):
- // 0 = no merge (one quad/cell, as before); larger = fewer, wider quads.
- // Coverage remains the same — just fewer triangles.
- int maxRun = Mathf.Max(1, Mathf.RoundToInt(simplification));
- if (maxRun <= 1) maxRun = 1;
- for (int gy = 0; gy < gh; gy++)
- {
- float v0 = (float)gy / gh, v1 = (float)(gy + 1) / gh;
- int gx = 0;
- while (gx < gw)
- {
- if (!cell[gy * gw + gx]) { gx++; continue; }
- int runStart = gx;
- int runLen = 0;
- while (gx < gw && cell[gy * gw + gx] && runLen < maxRun) { gx++; runLen++; }
- float u0 = (float)runStart / gw, u1 = (float)(runStart + runLen) / gw;
- int vi = vertsWorld.Count;
- vertsWorld.Add(UnprojectViewportToWorld(new Vector2(u0, v0)));
- vertsWorld.Add(UnprojectViewportToWorld(new Vector2(u1, v0)));
- vertsWorld.Add(UnprojectViewportToWorld(new Vector2(u0, v1)));
- vertsWorld.Add(UnprojectViewportToWorld(new Vector2(u1, v1)));
- indices.Add(vi); indices.Add(vi + 2); indices.Add(vi + 1);
- indices.Add(vi + 1); indices.Add(vi + 2); indices.Add(vi + 3);
- }
- }
- return FinalizeMesh(vertsWorld, indices);
- }
- /// <summary>
- /// Contour mode (from v3): contour trace + simplify + ear-clip. Many fewer polygons
- /// than Grid mode, but Simplification slightly eats corners (Mask Dilation compensates).
- /// </summary>
- Mesh GenerateContourMesh()
- {
- if (!ComputeWalkablePolygons(out var outers, out var holes, out int w, out int h)) return null;
- var holeGroups = new List<List<Vector2>>[outers.Count];
- for (int i = 0; i < outers.Count; i++) holeGroups[i] = new List<List<Vector2>>();
- foreach (var hole in holes)
- for (int i = 0; i < outers.Count; i++)
- if (PointInPolygon(hole[0], outers[i])) { holeGroups[i].Add(hole); break; }
- var verts2D = new List<Vector2>();
- var indices = new List<int>();
- for (int i = 0; i < outers.Count; i++)
- {
- List<Vector2> poly = MergeHoles(outers[i], holeGroups[i]);
- int baseIndex = verts2D.Count;
- List<int> tris = EarClip(poly);
- verts2D.AddRange(poly);
- for (int k = 0; k < tris.Count; k++) indices.Add(baseIndex + tris[k]);
- }
- if (indices.Count < 3) return null;
- // mask-px ➜ UV ➜ world floor (camera unproject)
- var vertsWorld = new List<Vector3>(verts2D.Count);
- for (int i = 0; i < verts2D.Count; i++)
- {
- Vector2 uv = new Vector2(verts2D[i].x / w, verts2D[i].y / h);
- vertsWorld.Add(UnprojectViewportToWorld(uv));
- }
- return FinalizeMesh(vertsWorld, indices);
- }
- /// <summary>
- /// Common final step: log depth, orient normals toward camera, world➜local,
- /// build Unity Mesh. Used by both generation modes.
- /// </summary>
- Mesh FinalizeMesh(List<Vector3> vertsWorld, List<int> indices)
- {
- if (indices.Count < 3 || vertsWorld.Count == 0) return null;
- AppendExtensions(vertsWorld, indices);
- ApplyGlobalResize(vertsWorld);
- if (logDepthRange)
- {
- float minZ = float.MaxValue, maxZ = float.MinValue;
- foreach (var p in vertsWorld) { minZ = Mathf.Min(minZ, p.z); maxZ = Mathf.Max(maxZ, p.z); }
- Debug.Log($"[Mesh4] Z range: {minZ:F2} → {maxZ:F2} (Δ {maxZ - minZ:F2}). Verts: {vertsWorld.Count}, Tris: {indices.Count / 3}.");
- }
- // Normals toward camera (Mask mode) or upward (Polygon mode, no camera),
- // so AC raycast hits the front face.
- Vector3 refDir = (warpCamera != null) ? warpCamera.transform.forward : Vector3.down;
- bool flip = false;
- for (int i = 0; i + 2 < indices.Count; i += 3)
- {
- Vector3 nrm = Vector3.Cross(vertsWorld[indices[i + 1]] - vertsWorld[indices[i]],
- vertsWorld[indices[i + 2]] - vertsWorld[indices[i]]);
- if (nrm.sqrMagnitude > 1e-10f) { flip = Vector3.Dot(nrm, refDir) > 0f; break; }
- }
- if (flip)
- for (int i = 0; i + 2 < indices.Count; i += 3)
- {
- int tmp = indices[i + 1]; indices[i + 1] = indices[i + 2]; indices[i + 2] = tmp;
- }
- var vertsLocal = new Vector3[vertsWorld.Count];
- for (int i = 0; i < vertsWorld.Count; i++)
- vertsLocal[i] = transform.InverseTransformPoint(vertsWorld[i]);
- Mesh mesh = new Mesh { name = gameObject.name + "_NavMesh" };
- if (vertsLocal.Length > 65000)
- mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
- mesh.vertices = vertsLocal;
- mesh.triangles = indices.ToArray();
- mesh.RecalculateNormals();
- mesh.RecalculateBounds();
- return mesh;
- }
- // -------------------------------------------------------- Extensions / Resize
- /// <summary>World-space extensions (Grid/Contour path).</summary>
- void AppendExtensions(List<Vector3> verts, List<int> indices) => AppendExtensionsImpl(verts, indices, false);
- /// <summary>Local-space extensions (Polygon path).</summary>
- void AppendExtensionsLocal(List<Vector3> verts, List<int> indices) => AppendExtensionsImpl(verts, indices, doubleSidedPolygon);
- void AppendExtensionsImpl(List<Vector3> verts, List<int> indices, bool doubleSided)
- {
- if (extensions == null || extensions.Count == 0 || verts.Count == 0) return;
- float minX = float.MaxValue, maxX = float.MinValue;
- float minZ = float.MaxValue, maxZ = float.MinValue;
- double sumY = 0;
- for (int i = 0; i < verts.Count; i++)
- {
- Vector3 p = verts[i];
- if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
- if (p.z < minZ) minZ = p.z; if (p.z > maxZ) maxZ = p.z;
- sumY += p.y;
- }
- float baseY = (float)(sumY / verts.Count);
- float midX = (minX + maxX) * 0.5f, midZ = (minZ + maxZ) * 0.5f;
- float fullX = maxX - minX, fullZ = maxZ - minZ;
- foreach (var ex in extensions)
- {
- if (ex == null || !ex.enabled || ex.extent <= 0f) continue;
- float y = baseY + ex.yOffset;
- float ov = Mathf.Max(0f, ex.overlap);
- float x0, x1, z0, z1;
- if (ex.side == ExtensionSide.Left || ex.side == ExtensionSide.Right)
- {
- float span = (ex.span > 0f) ? ex.span : fullZ;
- float cz = midZ + ex.slideAlongEdge;
- z0 = cz - span * 0.5f; z1 = cz + span * 0.5f;
- if (ex.side == ExtensionSide.Left) { x1 = minX + ov; x0 = minX - ex.extent; }
- else { x0 = maxX - ov; x1 = maxX + ex.extent; }
- }
- else
- {
- float span = (ex.span > 0f) ? ex.span : fullX;
- float cx = midX + ex.slideAlongEdge;
- x0 = cx - span * 0.5f; x1 = cx + span * 0.5f;
- if (ex.side == ExtensionSide.Top) { z0 = maxZ - ov; z1 = maxZ + ex.extent; }
- else { z1 = minZ + ov; z0 = minZ - ex.extent; }
- }
- int b = verts.Count;
- verts.Add(new Vector3(x0, y, z0));
- verts.Add(new Vector3(x1, y, z0));
- verts.Add(new Vector3(x0, y, z1));
- verts.Add(new Vector3(x1, y, z1));
- indices.Add(b); indices.Add(b + 2); indices.Add(b + 1);
- indices.Add(b + 1); indices.Add(b + 2); indices.Add(b + 3);
- if (doubleSided)
- {
- indices.Add(b); indices.Add(b + 1); indices.Add(b + 2);
- indices.Add(b + 1); indices.Add(b + 3); indices.Add(b + 2);
- }
- }
- }
- /// <summary>
- /// Uniform scale + inset of the ENTIRE mesh around its center (XZ centroid), in-place.
- /// Y does not change. Inset is converted to an equivalent scale based on the average
- /// radius, making it predictable for arbitrary shapes.
- /// </summary>
- void ApplyGlobalResize(List<Vector3> verts)
- {
- if (verts == null || verts.Count == 0) return;
- bool doScale = Mathf.Abs(meshScaleMultiplier - 1f) > 1e-4f;
- bool doInset = Mathf.Abs(meshInset) > 1e-4f;
- if (!doScale && !doInset) return;
- double sx = 0, sz = 0;
- for (int i = 0; i < verts.Count; i++) { sx += verts[i].x; sz += verts[i].z; }
- float cx = (float)(sx / verts.Count), cz = (float)(sz / verts.Count);
- float factor = doScale ? meshScaleMultiplier : 1f;
- if (doInset)
- {
- double sumR = 0;
- for (int i = 0; i < verts.Count; i++)
- {
- float dx = verts[i].x - cx, dz = verts[i].z - cz;
- sumR += Mathf.Sqrt(dx * dx + dz * dz);
- }
- float meanR = (float)(sumR / verts.Count);
- if (meanR > 1e-4f) factor *= Mathf.Max(0.01f, (meanR - meshInset) / meanR);
- }
- for (int i = 0; i < verts.Count; i++)
- {
- Vector3 p = verts[i];
- p.x = cx + (p.x - cx) * factor;
- p.z = cz + (p.z - cz) * factor;
- verts[i] = p;
- }
- }
- // -------------------------------------------------------------- Pixels
- static Color32[] ReadPixels(Texture2D tex, out int w, out int h)
- {
- w = tex.width; h = tex.height;
- try { if (tex.isReadable) return tex.GetPixels32(); } catch { }
- RenderTexture rt = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear);
- Graphics.Blit(tex, rt);
- RenderTexture prev = RenderTexture.active;
- RenderTexture.active = rt;
- Texture2D tmp = new Texture2D(w, h, TextureFormat.RGBA32, false);
- tmp.ReadPixels(new Rect(0, 0, w, h), 0, 0);
- tmp.Apply();
- RenderTexture.active = prev;
- RenderTexture.ReleaseTemporary(rt);
- Color32[] px = tmp.GetPixels32();
- DestroySafe(tmp);
- return px;
- }
- // -------------------------------------------------------------- Dilation
- /// <summary>
- /// Separable square dilation (OR over a (2*radius+1)x(2*radius+1) window),
- /// O(w*h) via sliding-window counts. Grows the walkable area outward by
- /// 'radius' pixels and shrinks small non-walkable holes by the same amount.
- /// </summary>
- static bool[] DilateMask(bool[] src, int w, int h, int radius)
- {
- if (radius <= 0) return src;
- var tmp = new bool[src.Length];
- for (int y = 0; y < h; y++)
- {
- int rowBase = y * w;
- int count = 0;
- for (int i = 0; i <= radius && i < w; i++)
- if (src[rowBase + i]) count++;
- for (int x = 0; x < w; x++)
- {
- tmp[rowBase + x] = count > 0;
- int addIdx = x + 1 + radius;
- int remIdx = x - radius;
- if (addIdx < w && src[rowBase + addIdx]) count++;
- if (remIdx >= 0 && src[rowBase + remIdx]) count--;
- }
- }
- var dst = new bool[src.Length];
- for (int x = 0; x < w; x++)
- {
- int count = 0;
- for (int i = 0; i <= radius && i < h; i++)
- if (tmp[i * w + x]) count++;
- for (int y = 0; y < h; y++)
- {
- dst[y * w + x] = count > 0;
- int addIdx = y + 1 + radius;
- int remIdx = y - radius;
- if (addIdx < h && tmp[addIdx * w + x]) count++;
- if (remIdx >= 0 && tmp[remIdx * w + x]) count--;
- }
- }
- return dst;
- }
- /// <summary>
- /// Erosion (shrink) of the walkable area by 'radius' pixels. Implemented as
- /// dilation of the COMPLEMENT: erode(A) = NOT dilate(NOT A). Thus the walkable area
- /// shrinks at edges and small islands ≤ radius disappear.
- /// </summary>
- static bool[] ErodeMask(bool[] src, int w, int h, int radius)
- {
- if (radius <= 0) return src;
- var inv = new bool[src.Length];
- for (int i = 0; i < src.Length; i++) inv[i] = !src[i];
- var dilatedInv = DilateMask(inv, w, h, radius);
- var dst = new bool[src.Length];
- for (int i = 0; i < dst.Length; i++) dst[i] = !dilatedInv[i];
- return dst;
- }
- // ------------------------------------------------------------- Contours
- static List<List<Vector2>> TraceContours(bool[] solid, int w, int h)
- {
- bool S(int x, int y) => x >= 0 && y >= 0 && x < w && y < h && solid[y * w + x];
- int stride = w + 1;
- int P(int x, int y) => y * stride + x;
- Vector2 ToV(int p) => new Vector2(p % stride, p / stride);
- var edges = new Dictionary<int, List<int>>();
- void AddEdge(int a, int b)
- {
- if (!edges.TryGetValue(a, out var list)) { list = new List<int>(2); edges[a] = list; }
- list.Add(b);
- }
- for (int y = 0; y < h; y++)
- for (int x = 0; x < w; x++)
- {
- if (!S(x, y)) continue;
- if (!S(x, y - 1)) AddEdge(P(x, y), P(x + 1, y));
- if (!S(x + 1, y)) AddEdge(P(x + 1, y), P(x + 1, y + 1));
- if (!S(x, y + 1)) AddEdge(P(x + 1, y + 1), P(x, y + 1));
- if (!S(x - 1, y)) AddEdge(P(x, y + 1), P(x, y));
- }
- var loops = new List<List<Vector2>>();
- while (edges.Count > 0)
- {
- int start = -1;
- foreach (var kv in edges) { start = kv.Key; break; }
- var loopPts = new List<int>();
- int current = start;
- float prevDX = 0f, prevDY = 0f;
- int guard = (w + 1) * (h + 1) * 4;
- bool closed = false;
- while (guard-- > 0)
- {
- if (!edges.TryGetValue(current, out var outs) || outs.Count == 0) break;
- int chosen = 0;
- if (outs.Count > 1 && loopPts.Count > 0)
- {
- float best = float.NegativeInfinity;
- Vector2 c = ToV(current);
- for (int i = 0; i < outs.Count; i++)
- {
- Vector2 nxt = ToV(outs[i]);
- float cross = prevDX * (nxt.y - c.y) - prevDY * (nxt.x - c.x);
- if (cross > best) { best = cross; chosen = i; }
- }
- }
- int next = outs[chosen];
- outs.RemoveAt(chosen);
- if (outs.Count == 0) edges.Remove(current);
- loopPts.Add(current);
- Vector2 cv = ToV(current), nv = ToV(next);
- prevDX = nv.x - cv.x; prevDY = nv.y - cv.y;
- current = next;
- if (current == start) { closed = true; break; }
- }
- if (closed && loopPts.Count >= 3)
- {
- var pts = new List<Vector2>(loopPts.Count);
- foreach (int p in loopPts) pts.Add(ToV(p));
- pts = RemoveCollinear(pts);
- if (pts.Count >= 3) loops.Add(pts);
- }
- }
- return loops;
- }
- static List<Vector2> RemoveCollinear(List<Vector2> pts)
- {
- int n = pts.Count;
- var res = new List<Vector2>(n);
- for (int i = 0; i < n; i++)
- {
- Vector2 prev = pts[(i - 1 + n) % n], cur = pts[i], next = pts[(i + 1) % n];
- float cross = (cur.x - prev.x) * (next.y - cur.y) - (cur.y - prev.y) * (next.x - cur.x);
- if (Mathf.Abs(cross) > 1e-6f) res.Add(cur);
- }
- return res;
- }
- static List<Vector2> SimplifyClosed(List<Vector2> pts, float eps)
- {
- if (eps <= 0.001f || pts.Count < 5) return new List<Vector2>(pts);
- int far = 0; float best = -1f;
- for (int i = 1; i < pts.Count; i++)
- {
- float d = (pts[i] - pts[0]).sqrMagnitude;
- if (d > best) { best = d; far = i; }
- }
- var a = new List<Vector2>(); var b = new List<Vector2>();
- for (int i = 0; i <= far; i++) a.Add(pts[i]);
- for (int i = far; i < pts.Count; i++) b.Add(pts[i]);
- b.Add(pts[0]);
- var sa = RDP(a, eps); var sb = RDP(b, eps);
- var res = new List<Vector2>(sa);
- res.RemoveAt(res.Count - 1);
- res.AddRange(sb);
- res.RemoveAt(res.Count - 1);
- return res;
- }
- static List<Vector2> RDP(List<Vector2> pts, float eps)
- {
- if (pts.Count < 3) return new List<Vector2>(pts);
- var keep = new bool[pts.Count];
- keep[0] = keep[pts.Count - 1] = true;
- var stack = new Stack<(int s, int e)>();
- stack.Push((0, pts.Count - 1));
- while (stack.Count > 0)
- {
- var (s, e) = stack.Pop();
- float maxD = -1f; int idx = -1;
- Vector2 A = pts[s], B = pts[e];
- for (int i = s + 1; i < e; i++)
- {
- float d = PointSegmentDistance(pts[i], A, B);
- if (d > maxD) { maxD = d; idx = i; }
- }
- if (idx != -1 && maxD > eps) { keep[idx] = true; stack.Push((s, idx)); stack.Push((idx, e)); }
- }
- var res = new List<Vector2>();
- for (int i = 0; i < pts.Count; i++) if (keep[i]) res.Add(pts[i]);
- return res;
- }
- static float PointSegmentDistance(Vector2 p, Vector2 a, Vector2 b)
- {
- Vector2 ab = b - a;
- float len2 = ab.sqrMagnitude;
- if (len2 < 1e-10f) return (p - a).magnitude;
- float t = Mathf.Clamp01(Vector2.Dot(p - a, ab) / len2);
- return (p - (a + ab * t)).magnitude;
- }
- // -------------------------------------------------------------- Geometry
- static float Cross(Vector2 a, Vector2 b) => a.x * b.y - a.y * b.x;
- static float SignedArea(List<Vector2> p)
- {
- float a = 0f;
- for (int i = 0; i < p.Count; i++)
- {
- Vector2 c = p[i], n = p[(i + 1) % p.Count];
- a += c.x * n.y - n.x * c.y;
- }
- return a * 0.5f;
- }
- static bool PointInPolygon(Vector2 p, List<Vector2> poly)
- {
- bool inside = false;
- for (int i = 0, j = poly.Count - 1; i < poly.Count; j = i++)
- {
- Vector2 a = poly[i], b = poly[j];
- if ((a.y > p.y) != (b.y > p.y) &&
- p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x)
- inside = !inside;
- }
- return inside;
- }
- static bool IsReflex(List<Vector2> poly, int i)
- {
- Vector2 a = poly[(i - 1 + poly.Count) % poly.Count];
- Vector2 b = poly[i];
- Vector2 c = poly[(i + 1) % poly.Count];
- return Cross(b - a, c - b) < 0f;
- }
- static bool PointInTriangle(Vector2 p, Vector2 a, Vector2 b, Vector2 c)
- {
- float d1 = Cross(b - a, p - a), d2 = Cross(c - b, p - b), d3 = Cross(a - c, p - c);
- bool hasNeg = (d1 < 0f) || (d2 < 0f) || (d3 < 0f);
- bool hasPos = (d1 > 0f) || (d2 > 0f) || (d3 > 0f);
- return !(hasNeg && hasPos);
- }
- static bool PointInTriangleStrict(Vector2 p, Vector2 a, Vector2 b, Vector2 c)
- {
- return Cross(b - a, p - a) > 1e-9f && Cross(c - b, p - b) > 1e-9f && Cross(a - c, p - c) > 1e-9f;
- }
- static List<Vector2> MergeHoles(List<Vector2> outer, List<List<Vector2>> holes)
- {
- var poly = new List<Vector2>(outer);
- if (holes == null || holes.Count == 0) return poly;
- float MaxX(List<Vector2> hh) { float m = float.NegativeInfinity; foreach (var v in hh) if (v.x > m) m = v.x; return m; }
- holes.Sort((h1, h2) => MaxX(h2).CompareTo(MaxX(h1)));
- foreach (var hole in holes) poly = MergeOneHole(poly, hole);
- return poly;
- }
- static List<Vector2> MergeOneHole(List<Vector2> poly, List<Vector2> hole)
- {
- int mi = 0;
- for (int i = 1; i < hole.Count; i++) if (hole[i].x > hole[mi].x) mi = i;
- Vector2 M = hole[mi];
- float bestX = float.PositiveInfinity; int bestEdge = -1; Vector2 I = M;
- for (int i = 0; i < poly.Count; i++)
- {
- Vector2 a = poly[i], b = poly[(i + 1) % poly.Count];
- if ((a.y > M.y) == (b.y > M.y)) continue;
- float x = a.x + (M.y - a.y) * (b.x - a.x) / (b.y - a.y);
- if (x >= M.x - 1e-5f && x < bestX) { bestX = x; bestEdge = i; I = new Vector2(x, M.y); }
- }
- if (bestEdge < 0) bestEdge = 0;
- Vector2 ea = poly[bestEdge], eb = poly[(bestEdge + 1) % poly.Count];
- int candIdx = ea.x > eb.x ? bestEdge : (bestEdge + 1) % poly.Count;
- Vector2 Pp = poly[candIdx];
- int chosen = candIdx; float bestMetric = float.PositiveInfinity; bool blocked = false;
- for (int i = 0; i < poly.Count; i++)
- {
- if (i == candIdx) continue;
- Vector2 v = poly[i];
- if (!IsReflex(poly, i)) continue;
- if (!PointInTriangle(v, M, I, Pp)) continue;
- Vector2 d = v - M;
- float angle = Mathf.Abs(Mathf.Atan2(d.y, d.x));
- float metric = angle * 1000f + d.sqrMagnitude * 1e-6f;
- if (!blocked || metric < bestMetric) { bestMetric = metric; chosen = i; blocked = true; }
- }
- var result = new List<Vector2>(poly.Count + hole.Count + 2);
- for (int i = 0; i <= chosen; i++) result.Add(poly[i]);
- for (int k = 0; k <= hole.Count; k++) result.Add(hole[(mi + k) % hole.Count]);
- result.Add(poly[chosen]);
- for (int i = chosen + 1; i < poly.Count; i++) result.Add(poly[i]);
- return result;
- }
- /// <summary>
- /// Ear quality = min/max edge length ratio of triangle (a,b,c). 1 = equilateral
- /// (good), ~0 = very thin "spoke" triangle (bad). Used to prefer "square"
- /// triangles (zigzag strip) over fan from a single point.
- /// </summary>
- static float EarQuality(Vector2 a, Vector2 b, Vector2 c)
- {
- float ab = (b - a).magnitude;
- float bc = (c - b).magnitude;
- float ca = (a - c).magnitude;
- float maxEdge = Mathf.Max(ab, Mathf.Max(bc, ca));
- if (maxEdge < 1e-9f) return 0f;
- float minEdge = Mathf.Min(ab, Mathf.Min(bc, ca));
- return minEdge / maxEdge;
- }
- static List<int> EarClip(List<Vector2> poly)
- {
- var tris = new List<int>();
- int n = poly.Count;
- if (n < 3) return tris;
- var idx = new List<int>(n);
- if (SignedArea(poly) < 0f) for (int i = n - 1; i >= 0; i--) idx.Add(i);
- else for (int i = 0; i < n; i++) idx.Add(i);
- // For small/medium polygons (after Simplification), at each step choose
- // the ear with the best quality (most "square" triangle) instead of the
- // first valid one — this gives zigzag-strip triangulation in narrow paths
- // instead of spokes/fan from one point. For very large polygons, revert to
- // the faster "first valid ear" so auto-regen doesn't slow down as you move the camera.
- bool bestEarMode = n <= 220;
- long guard = (long)n * n + 100;
- while (idx.Count > 3 && guard-- > 0)
- {
- int bestI = -1, bI0 = 0, bI1 = 0, bI2 = 0;
- float bestQ = -1f;
- for (int i = 0; i < idx.Count; i++)
- {
- int i0 = idx[(i - 1 + idx.Count) % idx.Count];
- int i1 = idx[i];
- int i2 = idx[(i + 1) % idx.Count];
- Vector2 a = poly[i0], b = poly[i1], c = poly[i2];
- if (Cross(b - a, c - b) <= 1e-9f) continue;
- bool ear = true;
- for (int j = 0; j < idx.Count; j++)
- {
- int vj = idx[j];
- if (vj == i0 || vj == i1 || vj == i2) continue;
- Vector2 p = poly[vj];
- if (p == a || p == b || p == c) continue;
- if (PointInTriangleStrict(p, a, b, c)) { ear = false; break; }
- }
- if (!ear) continue;
- if (!bestEarMode) { bestI = i; bI0 = i0; bI1 = i1; bI2 = i2; break; }
- float q = EarQuality(a, b, c);
- if (q > bestQ) { bestQ = q; bestI = i; bI0 = i0; bI1 = i1; bI2 = i2; }
- }
- if (bestI != -1)
- {
- tris.Add(bI0); tris.Add(bI1); tris.Add(bI2);
- idx.RemoveAt(bestI);
- continue;
- }
- // Fallback (no valid ear due to numerical noise): cut at the most "acute"
- // convex angle you find, whatever it causes.
- int besti = -1; float bestA = float.PositiveInfinity;
- for (int i = 0; i < idx.Count; i++)
- {
- int i0 = idx[(i - 1 + idx.Count) % idx.Count];
- int i1 = idx[i];
- int i2 = idx[(i + 1) % idx.Count];
- float cr = Cross(poly[i1] - poly[i0], poly[i2] - poly[i1]);
- if (cr > 0f && cr < bestA) { bestA = cr; besti = i; }
- }
- if (besti < 0) besti = 0;
- int a0 = idx[(besti - 1 + idx.Count) % idx.Count];
- int a1 = idx[besti];
- int a2 = idx[(besti + 1) % idx.Count];
- tris.Add(a0); tris.Add(a1); tris.Add(a2);
- idx.RemoveAt(besti);
- }
- if (idx.Count == 3) { tris.Add(idx[0]); tris.Add(idx[1]); tris.Add(idx[2]); }
- return tris;
- }
- // -------------------------------------------------------------- Bake
- #if UNITY_EDITOR
- public string GetDefaultBakePath()
- {
- string sceneName = (gameObject.scene.IsValid() && !string.IsNullOrEmpty(gameObject.scene.name))
- ? gameObject.scene.name : "Scene";
- return "Assets/NavMeshBakes/" + sceneName + "_" + gameObject.name + "_NavMesh.asset";
- }
- public void BakeToAsset()
- {
- Mesh fresh = GenerateCollisionMesh();
- if (fresh == null) { Debug.LogWarning("[Mesh4] Bake failed: no mesh generated."); return; }
- if (!AssetDatabase.IsValidFolder("Assets/NavMeshBakes"))
- AssetDatabase.CreateFolder("Assets", "NavMeshBakes");
- string path = GetDefaultBakePath();
- Mesh existing = AssetDatabase.LoadAssetAtPath<Mesh>(path);
- if (existing != null)
- {
- existing.Clear();
- existing.indexFormat = fresh.indexFormat;
- existing.vertices = fresh.vertices;
- existing.triangles = fresh.triangles;
- existing.normals = fresh.normals;
- existing.RecalculateBounds();
- EditorUtility.SetDirty(existing);
- AssetDatabase.SaveAssets();
- bakedMeshAsset = existing;
- DestroySafe(fresh);
- }
- else
- {
- AssetDatabase.CreateAsset(fresh, path);
- AssetDatabase.SaveAssets();
- bakedMeshAsset = fresh;
- }
- AssignMesh(bakedMeshAsset);
- lastHash = ComputeHash(); // prevents immediate auto-invalidation of the bake just created
- EditorUtility.SetDirty(this);
- Debug.Log("[Mesh4] Bake OK: " + path);
- }
- public void ClearBakedAsset(bool silent = false)
- {
- if (bakedMeshAsset != null)
- {
- string p = AssetDatabase.GetAssetPath(bakedMeshAsset);
- if (!string.IsNullOrEmpty(p)) AssetDatabase.DeleteAsset(p);
- }
- bakedMeshAsset = null;
- EditorUtility.SetDirty(this);
- Debug.Log(silent
- ? "[Mesh4] Parameter changed — bake auto-deleted, live mode."
- : "[Mesh4] Bake deleted — live mode.");
- }
- #endif
- // -------------------------------------------------------- AC Integration
- #if UNITY_EDITOR
- /// <summary>
- /// NavigationMesh mode: adds/configures AC.NavigationMesh (Ignore Collisions = off).
- /// Hotspot mode: adds AC.Hotspot and removes the default BoxCollider, so the
- /// clickable shape comes from this script's mesh-shaped MeshCollider.
- /// In both cases, AC.ConstantID is added (for saves).
- /// Works via reflection — if Adventure Creator is missing, just warns.
- /// </summary>
- public void SetupACComponents()
- {
- if (acTargetMode == ACTargetMode.Hotspot) SetupHotspot();
- else SetupNavigationMesh();
- // ── AC.ConstantID (so the object can be referenced in saves) ──
- var cidType = FindACType("ConstantID");
- if (cidType != null)
- {
- var cid = GetComponent(cidType);
- if (cid == null) cid = Undo.AddComponent(gameObject, cidType);
- var so = new SerializedObject(cid);
- var idProp = so.FindProperty("constantID");
- if (idProp != null && idProp.intValue == 0)
- {
- idProp.intValue = UnityEngine.Random.Range(int.MinValue + 1, int.MaxValue);
- so.ApplyModifiedProperties();
- }
- EditorUtility.SetDirty(cid);
- }
- else
- {
- Debug.LogWarning("[Mesh4] AC.ConstantID type not found — is Adventure Creator missing from the project?");
- }
- EditorUtility.SetDirty(this);
- }
- void SetupNavigationMesh()
- {
- var navType = FindACType("NavigationMesh");
- if (navType == null)
- {
- Debug.LogWarning("[Mesh4] AC.NavigationMesh type not found — is Adventure Creator missing from the project?");
- return;
- }
- var nav = GetComponent(navType);
- if (nav == null) nav = Undo.AddComponent(gameObject, navType);
- var so = new SerializedObject(nav);
- var ignoreCollisions = so.FindProperty("ignoreCollisions");
- if (ignoreCollisions != null)
- {
- ignoreCollisions.boolValue = false;
- so.ApplyModifiedProperties();
- }
- else LogMissingBoolField(so, "AC.NavigationMesh", "ignoreCollisions");
- EditorUtility.SetDirty(nav);
- }
- void SetupHotspot()
- {
- // The mesh-shaped MeshCollider is already here (added by RequireComponent).
- // Remove the default BoxCollider that AC may have placed on the Hotspot.
- RemoveBoxCollider();
- // The MeshCollider must remain non-convex & non-trigger: in 3D mode, AC
- // detects Hotspots via raycast on non-convex MeshCollider (like a Polygon
- // Collider 2D in 2D mode). Trigger + non-convex is not allowed by Unity physics.
- if (meshCollider == null) meshCollider = GetComponent<MeshCollider>();
- if (meshCollider != null)
- {
- meshCollider.convex = false;
- meshCollider.isTrigger = false;
- }
- var hotspotType = FindACType("Hotspot");
- if (hotspotType == null)
- {
- Debug.LogWarning("[Mesh4] AC.Hotspot type not found — is Adventure Creator missing from the project?");
- return;
- }
- var hs = GetComponent(hotspotType);
- if (hs == null)
- {
- hs = Undo.AddComponent(gameObject, hotspotType);
- Debug.Log("[Mesh4] AC.Hotspot added — set interactions in Inspector. " +
- "The clickable shape comes from the mesh collider (not a box).");
- }
- EditorUtility.SetDirty(hs);
- }
- static System.Type FindACType(string name)
- {
- foreach (var asm in System.AppDomain.CurrentDomain.GetAssemblies())
- {
- var t = asm.GetType("AC." + name) ?? asm.GetType(name);
- if (t != null) return t;
- }
- return null;
- }
- static void LogMissingBoolField(SerializedObject so, string componentName, string wanted)
- {
- var names = new List<string>();
- var prop = so.GetIterator();
- bool enterChildren = true;
- while (prop.NextVisible(enterChildren))
- {
- enterChildren = false;
- if (prop.propertyType == SerializedPropertyType.Boolean) names.Add(prop.name);
- }
- Debug.LogWarning($"[Mesh4] Field '{wanted}' not found in {componentName} (may have been renamed in a different AC version). " +
- $"Available bool fields: {string.Join(", ", names)}. Set it manually in the Inspector.");
- }
- #endif
- // ---------------------------------------------------------------- Gizmos
- void OnDrawGizmos()
- {
- if (gizmoMode == GizmoMode.Always) DrawNavGizmos();
- #if UNITY_EDITOR
- else if (gizmoMode == GizmoMode.WhenCamera && warpCamera != null && Selection.Contains(warpCamera.gameObject))
- DrawNavGizmos();
- #endif
- }
- void OnDrawGizmosSelected()
- {
- if (gizmoMode == GizmoMode.WhenSelected || gizmoMode == GizmoMode.WhenCamera)
- DrawNavGizmos();
- }
- void DrawNavGizmos()
- {
- Mesh m = GetActiveMesh();
- if (m != null)
- {
- Gizmos.matrix = transform.localToWorldMatrix;
- Gizmos.color = fillColor; Gizmos.DrawMesh(m);
- Gizmos.color = wireColor; Gizmos.DrawWireMesh(m);
- Gizmos.matrix = Matrix4x4.identity;
- }
- Vector3 sp = GetSpawnWorldPosition();
- float gizSize;
- if (meshSourceMode == MeshSourceMode.Polygon)
- {
- Bounds b = new Bounds(sp, Vector3.zero);
- if (outlinePoints != null)
- foreach (var p in outlinePoints)
- b.Encapsulate(transform.TransformPoint(p));
- gizSize = Mathf.Max(0.05f, b.size.magnitude * 0.02f);
- }
- else
- {
- if (warpCamera == null) return;
- Vector3 widthRef = UnprojectViewportToWorld(new Vector2(1f, 0f)) - UnprojectViewportToWorld(new Vector2(0f, 0f));
- gizSize = Mathf.Max(0.05f, widthRef.magnitude * 0.02f);
- }
- Gizmos.color = Color.yellow;
- Gizmos.DrawSphere(sp, gizSize);
- Gizmos.DrawLine(sp, sp + Vector3.up * gizSize * 5f);
- if (showDepthLimitGizmo && meshSourceMode == MeshSourceMode.Mask && depthMode == DepthMode.Perspective && warpCamera != null)
- DrawDepthLimitGizmo();
- }
- /// <summary>
- /// Two orange frames at Z = camera.z ± Max Floor Distance. If the "top"
- /// (far) portion of your mesh appears in front of some hotspot, compare
- /// their positions with these frames — if the hotspot is BEHIND the frame,
- /// the mesh there is cut by Max Floor Distance; increase it.
- /// </summary>
- void DrawDepthLimitGizmo()
- {
- Vector3 camPos = warpCamera.transform.position;
- float size = Mathf.Max(maxFloorDistance, 1f);
- Color c = new Color(1f, 0.45f, 0f, 0.9f);
- for (int s = -1; s <= 1; s += 2)
- {
- float z = camPos.z + s * maxFloorDistance;
- Vector3 a = new Vector3(camPos.x - size, floorY, z);
- Vector3 b = new Vector3(camPos.x + size, floorY, z);
- Vector3 ta = new Vector3(camPos.x - size, floorY + size * 0.5f, z);
- Vector3 tb = new Vector3(camPos.x + size, floorY + size * 0.5f, z);
- Gizmos.color = c;
- Gizmos.DrawLine(a, b);
- Gizmos.DrawLine(a, ta);
- Gizmos.DrawLine(b, tb);
- Gizmos.DrawLine(ta, tb);
- #if UNITY_EDITOR
- Handles.color = c;
- Handles.Label((a + tb) * 0.5f, $"Max Floor Distance limit (Z={z:0.#})");
- #endif
- }
- }
- }
- #if UNITY_EDITOR
- [CustomEditor(typeof(Mesh4))]
- public class Mesh4Editor : Editor
- {
- public override void OnInspectorGUI()
- {
- var m4 = (Mesh4)target;
- serializedObject.Update();
- // Walk all serialized properties in order; after specific fields,
- // insert the corresponding inline button.
- SerializedProperty prop = serializedObject.GetIterator();
- bool enter = true;
- while (prop.NextVisible(enter))
- {
- enter = false;
- if (prop.name == "m_Script") continue;
- EditorGUILayout.PropertyField(prop, true);
- switch (prop.name)
- {
- // ── Polygon: buttons next to outline / init fields ──
- case "initResolution":
- if (m4.meshSourceMode == Mesh4.MeshSourceMode.Polygon)
- InlineButton($"⟳ Initialize Polygon from Mask ({m4.initResolution} points)",
- () => m4.InitializePolygonFromMask());
- break;
- case "rectSubdivisions":
- if (m4.meshSourceMode == Mesh4.MeshSourceMode.Polygon)
- InlineButton($"▭ Initialize Polygon from Camera Rect (far Z = {m4.rectFarZ:0.##})",
- () => m4.InitializePolygonFromCameraRect());
- break;
- case "extensions":
- EditorGUILayout.BeginHorizontal();
- if (GUILayout.Button("+ Left")) { m4.AddExtension(Mesh4.ExtensionSide.Left); SceneView.RepaintAll(); }
- if (GUILayout.Button("+ Right")) { m4.AddExtension(Mesh4.ExtensionSide.Right); SceneView.RepaintAll(); }
- if (GUILayout.Button("+ Top")) { m4.AddExtension(Mesh4.ExtensionSide.Top); SceneView.RepaintAll(); }
- if (GUILayout.Button("+ Bottom")) { m4.AddExtension(Mesh4.ExtensionSide.Bottom); SceneView.RepaintAll(); }
- EditorGUILayout.EndHorizontal();
- using (new EditorGUI.DisabledScope(m4.extensions == null || m4.extensions.Count == 0))
- if (GUILayout.Button("- Last extension")) { m4.RemoveLastExtension(); SceneView.RepaintAll(); }
- break;
- case "outlinePoints":
- if (m4.meshSourceMode == Mesh4.MeshSourceMode.Polygon)
- {
- EditorGUILayout.BeginHorizontal();
- if (GUILayout.Button("+ Point")) { m4.AddOutlinePointAfterLast(); SceneView.RepaintAll(); }
- using (new EditorGUI.DisabledScope(m4.outlinePoints == null || m4.outlinePoints.Count == 0))
- if (GUILayout.Button("- Last")) { m4.RemoveLastOutlinePoint(); SceneView.RepaintAll(); }
- EditorGUILayout.EndHorizontal();
- }
- break;
- // ── Camera tilt helper next to warpCamera ──
- case "warpCamera":
- DrawHorizonTiltHelper(m4);
- break;
- // ── Surface snap next to targetSurfaceZ ──
- case "targetSurfaceZ":
- InlineButton($"Snap Surface to Z = {m4.targetSurfaceZ:0.##}", () => m4.SnapSurfaceToZ());
- break;
- // ── Baked asset: bake / clear next to the field ──
- case "bakedMeshAsset":
- EditorGUILayout.BeginHorizontal();
- if (GUILayout.Button(m4.bakedMeshAsset != null ? "Re-Bake" : "Bake to Asset"))
- m4.BakeToAsset();
- using (new EditorGUI.DisabledScope(m4.bakedMeshAsset == null))
- if (GUILayout.Button("Delete Bake")) m4.ClearBakedAsset();
- EditorGUILayout.EndHorizontal();
- break;
- // ── Spawn: buttons next to the spawn field of each mode ──
- case "playerSpawnUV":
- if (m4.meshSourceMode == Mesh4.MeshSourceMode.Mask) DrawSpawnButtons(m4);
- break;
- case "polygonSpawnPoint":
- if (m4.meshSourceMode == Mesh4.MeshSourceMode.Polygon) DrawSpawnButtons(m4);
- break;
- }
- }
- serializedObject.ApplyModifiedProperties();
- EditorGUILayout.Space();
- // ── General (not tied to a single field) ──
- EditorGUILayout.BeginHorizontal();
- if (GUILayout.Button("Regenerate", GUILayout.Height(26)))
- {
- if (m4.bakedMeshAsset != null && m4.autoReBake) m4.BakeToAsset();
- else
- {
- if (m4.bakedMeshAsset != null) m4.ClearBakedAsset(true);
- m4.RegenerateLive();
- }
- SceneView.RepaintAll();
- }
- string acLabel = m4.acTargetMode == Mesh4.ACTargetMode.Hotspot
- ? "AC Setup (Hotspot + ConstantID)"
- : "AC Setup (NavigationMesh + ConstantID)";
- if (GUILayout.Button(acLabel, GUILayout.Height(26)))
- m4.SetupACComponents();
- EditorGUILayout.EndHorizontal();
- EditorGUILayout.Space();
- DrawInfoBox(m4);
- }
- void InlineButton(string label, System.Action action)
- {
- if (GUILayout.Button(label))
- {
- action();
- SceneView.RepaintAll();
- }
- }
- void DrawSpawnButtons(Mesh4 m4)
- {
- EditorGUILayout.BeginHorizontal();
- if (GUILayout.Button("🎯 Spawn ➜ Center")) { m4.CenterSpawnOnMesh(); SceneView.RepaintAll(); }
- if (GUILayout.Button("▶ Place Player")) m4.PlacePlayerAtSpawn();
- EditorGUILayout.EndHorizontal();
- }
- void DrawHorizonTiltHelper(Mesh4 m4)
- {
- using (new EditorGUILayout.HorizontalScope())
- {
- EditorGUILayout.PrefixLabel(new GUIContent("Horizon v (artwork)",
- "Where you see the horizon/eye-level in the artwork (0=bottom, 1=top). " +
- "The button calculates the camera's X rotation so its horizon falls there."));
- m4.horizonV = EditorGUILayout.Slider(m4.horizonV, 0.05f, 0.95f);
- }
- if (GUILayout.Button($"⤢ Set Camera Tilt from Horizon v (= {m4.SuggestedTiltX():0.#}°)"))
- m4.ApplyHorizonTilt();
- }
- void DrawInfoBox(Mesh4 m4)
- {
- string bakeInfo = m4.bakedMeshAsset != null
- ? "BAKED" + (m4.autoReBake ? " (auto re-bake ON)" : "") + " -> " + AssetDatabase.GetAssetPath(m4.bakedMeshAsset)
- : "LIVE (auto-regenerate on every change)";
- string modeInfo; string warn = "";
- if (m4.meshSourceMode == Mesh4.MeshSourceMode.Polygon)
- {
- int npts = m4.outlinePoints != null ? m4.outlinePoints.Count : 0;
- modeInfo = "Generation: Polygon (" + npts + " points)";
- if (npts < 3) warn = "\n⚠ Need ≥3 Outline Points!";
- }
- else
- {
- modeInfo = "Generation: " + m4.meshGenerationMode +
- (m4.meshGenerationMode == Mesh4.MeshGenerationMode.Grid ? $" (downsampling {m4.downsampling})" : "");
- if (m4.warpCamera == null) warn = "\n⚠ Missing warpCamera!";
- }
- string spawnInfo = m4.meshSourceMode == Mesh4.MeshSourceMode.Polygon
- ? "Spawn world (" + m4.polygonSpawnPoint.x.ToString("0.##") + ", " + m4.polygonSpawnPoint.z.ToString("0.##") + ")"
- : "Spawn UV (" + m4.playerSpawnUV.x.ToString("0.##") + ", " + m4.playerSpawnUV.y.ToString("0.##") + ")";
- EditorGUILayout.HelpBox(
- bakeInfo + "\n" + modeInfo + "\n" +
- "Mesh: " + m4.statVerts + " verts / " + m4.statTris + " tris\n" + spawnInfo + warn,
- string.IsNullOrEmpty(warn) ? MessageType.Info : MessageType.Warning);
- }
- void OnSceneGUI()
- {
- var m4 = (Mesh4)target;
- if (m4.meshSourceMode == Mesh4.MeshSourceMode.Mask && m4.warpCamera == null) return;
- // ── Outline point handles (Polygon mode) ──
- if (m4.meshSourceMode == Mesh4.MeshSourceMode.Polygon && m4.outlinePoints != null)
- {
- // Edge lines (closed loop) for visual order.
- Handles.color = new Color(0f, 1f, 0.4f, 0.8f);
- int n = m4.outlinePoints.Count;
- for (int i = 0; i < n; i++)
- {
- Vector3 a = m4.transform.TransformPoint(m4.outlinePoints[i]);
- Vector3 b = m4.transform.TransformPoint(m4.outlinePoints[(i + 1) % n]);
- Handles.DrawLine(a, b);
- }
- for (int i = 0; i < n; i++)
- {
- Vector3 wp = m4.transform.TransformPoint(m4.outlinePoints[i]);
- float hs = HandleUtility.GetHandleSize(wp) * 0.09f;
- Handles.color = Color.cyan;
- EditorGUI.BeginChangeCheck();
- #if UNITY_2022_1_OR_NEWER
- Vector3 moved = Handles.FreeMoveHandle(wp, hs, Vector3.zero, Handles.DotHandleCap);
- #else
- Vector3 moved = Handles.FreeMoveHandle(wp, Quaternion.identity, hs, Vector3.zero, Handles.DotHandleCap);
- #endif
- if (EditorGUI.EndChangeCheck())
- {
- Undo.RecordObject(m4, "Move Outline Point");
- // Keep the point on the floorY plane (world), then back to local.
- Vector3 snapped = new Vector3(moved.x, m4.floorY, moved.z);
- m4.outlinePoints[i] = m4.transform.InverseTransformPoint(snapped);
- EditorUtility.SetDirty(m4);
- }
- Handles.Label(wp + Vector3.up * hs * 2.5f, i.ToString());
- }
- }
- // ── Spawn handle ──
- Vector3 pos = m4.GetSpawnWorldPosition();
- float size = HandleUtility.GetHandleSize(pos) * 0.15f;
- Handles.color = Color.yellow;
- EditorGUI.BeginChangeCheck();
- #if UNITY_2022_1_OR_NEWER
- Vector3 np = Handles.FreeMoveHandle(pos, size, Vector3.zero, Handles.SphereHandleCap);
- #else
- Vector3 np = Handles.FreeMoveHandle(pos, Quaternion.identity, size, Vector3.zero, Handles.SphereHandleCap);
- #endif
- if (EditorGUI.EndChangeCheck())
- {
- Undo.RecordObject(m4, "Move Spawn Point");
- if (m4.meshSourceMode == Mesh4.MeshSourceMode.Polygon)
- {
- m4.polygonSpawnPoint = new Vector3(np.x, m4.floorY, np.z);
- }
- else
- {
- Vector2 uv = m4.WorldToUV(np);
- m4.playerSpawnUV = new Vector2(Mathf.Clamp01(uv.x), Mathf.Clamp01(uv.y));
- }
- EditorUtility.SetDirty(m4);
- }
- Handles.Label(pos + Vector3.up * size * 2f, "Player Spawn");
- }
- }
- #endif
Advertisement
Add Comment
Please, Sign In to add comment