Trim comments and use implicit null checks for Unity objects

Reduce comments to non-obvious "why" notes and convert UnityEngine.Object
null checks to implicit bool (if (obj) / if (!obj)). Input System devices
(Mouse.current, Keyboard.current) and plain arrays keep explicit != null.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-22 17:26:41 +02:00
1 parent 3da613e1a1
commit f758c96dc5
8 files changed
+220 -427

No files matched your search

+10 -30
View File
@@ -1,58 +1,38 @@
using UnityEngine;
/// <summary>
/// Limits the game's frame rate.
///
/// Sets <see cref="Application.targetFrameRate"/> and adjusts VSync
/// (<see cref="QualitySettings.vSyncCount"/>) accordingly. IMPORTANT: as long as
/// VSync is active, Unity ignores targetFrameRate - the limit then only takes
/// effect when VSync is off. This script therefore disables VSync automatically
/// as soon as an FPS limit is set (see <see cref="disableVSyncWhenLimiting"/>).
///
/// Changes apply live: in the editor via OnValidate (also in Play mode while
/// tweaking values in the Inspector).
///
/// Simply attach to any (ideally persistent) GameObject in the scene.
/// Caps the frame rate via <see cref="Application.targetFrameRate"/>. VSync is
/// disabled automatically while limiting, because it would otherwise override
/// targetFrameRate. Changes apply live (also in Play mode) via OnValidate.
/// </summary>
[DisallowMultipleComponent]
public class FrameRateLimiter : MonoBehaviour
{
[Header("Limit")]
[Tooltip("ON = the frame rate is capped to 'Target FPS'. OFF = unlimited " +
"(targetFrameRate = -1, platform default).")]
[Tooltip("Cap the frame rate to 'Target FPS' (off = unlimited).")]
public bool limitFrameRate = true;
[Tooltip("Target frame rate in frames per second when the limit is active.")]
[Tooltip("Target frame rate when the limit is active.")]
[Range(1, 360)]
public int targetFps = 60;
[Header("VSync")]
[Tooltip("When an FPS limit is set, disable VSync automatically. " +
"Otherwise VSync would override targetFrameRate and the limit " +
"would have no effect. Normally leave this ON.")]
[Tooltip("Disable VSync while limiting (VSync would otherwise override the FPS limit). Normally ON.")]
public bool disableVSyncWhenLimiting = true;
[Tooltip("VSync count to use when NO limit is active " +
"(0 = VSync off, 1 = every screen refresh, 2 = every second refresh).")]
[Tooltip("VSync count when no limit is active (0 = off, 1 = every refresh, 2 = every second refresh).")]
[Range(0, 2)]
public int vSyncCountWhenUnlimited = 0;
void OnEnable()
{
Apply();
}
void OnEnable() => Apply();
void OnValidate()
{
// Live adjustment when changing values in the Inspector (also in Play mode).
if (Application.isPlaying)
Apply();
}
/// <summary>
/// Applies the current limit (FPS + VSync) to the Unity settings.
/// Can also be called externally.
/// </summary>
/// <summary>Applies the current FPS + VSync settings.</summary>
public void Apply()
{
if (limitFrameRate)
@@ -65,7 +45,7 @@ public class FrameRateLimiter : MonoBehaviour
else
{
QualitySettings.vSyncCount = vSyncCountWhenUnlimited;
Application.targetFrameRate = -1; // platform default / unlimited
Application.targetFrameRate = -1;
}
}
+8 -13
View File
@@ -1,29 +1,24 @@
/// <summary>
/// Base class for a high-level game mode (state) handled by
/// <see cref="GameModeManager"/>. A mode configures the relevant scene systems
/// when it becomes active (<see cref="Enter"/>), can clean up when it is left
/// (<see cref="Exit"/>) and may run per-frame logic in <see cref="Tick"/>.
///
/// Modes are plain C# classes (not MonoBehaviours). To add a new mode, derive
/// from this class and register an instance in GameModeManager.BuildModes().
/// Base class for a high-level game mode handled by <see cref="GameModeManager"/>.
/// Modes are plain C# classes (not MonoBehaviours); Enter/Exit/Tick are driven
/// manually by the manager. Derive from this and register an instance in
/// GameModeManager.BuildModes().
/// </summary>
public abstract class GameMode
{
/// <summary>The owning manager, providing access to the controlled systems.</summary>
protected GameModeManager Manager { get; private set; }
/// <summary>Human-readable name (shown in logs / debug UI).</summary>
/// <summary>Human-readable name (shown in logs).</summary>
public abstract string DisplayName { get; }
/// <summary>Wires the mode to its manager. Called once during registration.</summary>
internal void Bind(GameModeManager manager) => Manager = manager;
/// <summary>Called once when this mode becomes the active mode.</summary>
/// <summary>Called once when this mode becomes active.</summary>
public virtual void Enter() { }
/// <summary>Called once when this mode stops being the active mode.</summary>
/// <summary>Called once when this mode stops being active.</summary>
public virtual void Exit() { }
/// <summary>Called every frame while this mode is the active mode.</summary>
/// <summary>Called every frame while this mode is active.</summary>
public virtual void Tick() { }
}
+10 -24
View File
@@ -5,33 +5,21 @@ using UnityEngine.InputSystem;
#endif
/// <summary>
/// Central state manager that switches the game between high-level modes.
/// Currently two modes exist:
/// 0. Player Control - WASD movement, camera orbits freely with the mouse.
/// 1. Paint - player stands still, left mouse paints the mesh, the
/// camera orbits only while the right mouse button is held.
///
/// Pressing the switch key (Tab) cycles to the next registered mode, so adding
/// more modes later automatically extends the cycle.
///
/// The manager owns references to the systems a mode may toggle (player
/// controller, orbit camera input, mesh painter) and exposes them to the modes.
/// Modes are plain C# classes derived from <see cref="GameMode"/>; register
/// additional ones in <see cref="BuildModes"/>.
///
/// Attach this to a single, persistent GameObject in the scene.
/// Switches the game between high-level modes (Player Control, Paint). The switch
/// key (Tab) cycles to the next registered mode. Register additional modes in
/// <see cref="BuildModes"/>. Attach to a single, persistent scene GameObject.
/// </summary>
[DisallowMultipleComponent]
public class GameModeManager : MonoBehaviour
{
[Header("Controlled Systems")]
[Tooltip("Player movement controller. Empty = found automatically in the scene.")]
[Tooltip("Player movement controller. Empty = found automatically.")]
public PlayerCharacterController player;
[Tooltip("Third-person orbit camera input. Empty = found automatically in the scene.")]
[Tooltip("Third-person orbit camera input. Empty = found automatically.")]
public ThirdPersonOrbitInput cameraInput;
[Tooltip("Mesh painter. Empty = found automatically in the scene.")]
[Tooltip("Mesh painter. Empty = found automatically.")]
public MeshPainter painter;
[Header("Switching")]
@@ -42,10 +30,9 @@ public class GameModeManager : MonoBehaviour
public KeyCode switchKey = KeyCode.Tab;
#endif
[Tooltip("Index of the mode that is active at startup (0 = Player Control).")]
[Tooltip("Mode active at startup (0 = Player Control).")]
public int startModeIndex = 0;
// --- accessors used by the modes ---
public PlayerCharacterController Player => player;
public ThirdPersonOrbitInput CameraInput => cameraInput;
public MeshPainter Painter => painter;
@@ -59,10 +46,9 @@ public class GameModeManager : MonoBehaviour
private void Awake()
{
// Auto-resolve references so the manager works with minimal wiring.
if (player == null) player = FindFirstObjectByType<PlayerCharacterController>();
if (cameraInput == null) cameraInput = FindFirstObjectByType<ThirdPersonOrbitInput>();
if (painter == null) painter = FindFirstObjectByType<MeshPainter>();
if (!player) player = FindFirstObjectByType<PlayerCharacterController>();
if (!cameraInput) cameraInput = FindFirstObjectByType<ThirdPersonOrbitInput>();
if (!painter) painter = FindFirstObjectByType<MeshPainter>();
BuildModes();
}
+83 -137
View File
@@ -5,46 +5,28 @@ using UnityEngine.InputSystem;
#endif
/// <summary>
/// Live mesh painting in Play mode.
/// Paints a fixed color (brush) onto the mesh's texture with the mouse.
///
/// TWO BACKENDS (switchable via <see cref="useGpu"/>, also live in Play mode):
///
/// * GPU (default): True shader painting into a RenderTexture. The brush is
/// drawn via a vertex/fragment shader - the mesh is unwrapped into the texture
/// layout using its UVs, and per texel the actual 3D position is tested against
/// the brush sphere. Edge padding against seams runs as a GPU blit.
/// Fast, because the GPU rasterizes all texels in parallel.
///
/// * CPU: Rasterization of the hit triangles on the CPU; per texel the 3D
/// position is computed barycentrically and tested against the brush sphere.
/// Edge padding via CPU dilation. Identical visual result, but more expensive.
///
/// BOTH backends paint seamlessly: a 3D sphere around the hit point captures
/// triangles on both sides of a UV seam at once (front/back share the same 3D
/// edge), no matter where they lie in the UV layout.
///
/// Simply attach to the GameObject you want to paint (e.g. paintman_UV/paintman_UV).
/// Live mesh painting in Play mode: paints the brush color onto the mesh texture
/// with the mouse. Two backends (switchable live via <see cref="useGpu"/>): GPU
/// shader painting into a RenderTexture (fast, default) and CPU rasterization
/// (slower, identical result). Both paint seamlessly across UV seams because a 3D
/// sphere around the hit point captures triangles on both sides of a seam at once.
/// Attach to the GameObject you want to paint.
/// </summary>
[DisallowMultipleComponent]
public class MeshPainter : MonoBehaviour
{
/// <summary>
/// When false, mouse painting is ignored (the canvas/material stays as-is).
/// Toggled by the GameModeManager (painting is only enabled in paint mode).
/// </summary>
/// <summary>When false, mouse painting is ignored. Toggled by the GameModeManager.</summary>
public bool PaintingEnabled { get; set; } = true;
[Header("Backend")]
[Tooltip("ON = GPU painting (shader/RenderTexture, fast). OFF = CPU painting. " +
"Can be toggled live in Play mode - the painted content is carried over.")]
[Tooltip("ON = GPU painting (fast), OFF = CPU painting. Can be toggled live in Play mode.")]
public bool useGpu = true;
[Header("Brush")]
[Tooltip("Color used for painting.")]
public Color brushColor = Color.red;
[Tooltip("Brush radius in texels (visual size). Converted into a 3D radius per hit.")]
[Tooltip("Brush radius in texels. Converted into a 3D radius per hit.")]
[Range(1, 256)]
public int brushSize = 16;
@@ -55,9 +37,7 @@ public class MeshPainter : MonoBehaviour
[Tooltip("Base color of the texture at startup (if none exists).")]
public Color baseColor = Color.white;
[Tooltip("Edge padding (texels) against visible UV seams. The color is 'bled' beyond the " +
"UV island edges so that bilinear sampling there does not pull in the unpainted " +
"base color. 0 = off.")]
[Tooltip("Edge padding (texels) bled beyond UV island edges so bilinear sampling does not pull in the base color. 0 = off.")]
[Range(0, 16)]
public int paddingTexels = 4;
@@ -75,43 +55,41 @@ public class MeshPainter : MonoBehaviour
private Mesh _bakedMesh;
private Material _materialInstance;
// Mesh caches (in the transform's local space = collider space).
// Mesh caches (in local/collider space).
private Vector3[] _verts;
private int[] _tris;
private Vector2[] _uvs;
private Vector3[] _triCenter; // centroid per triangle (for fast CPU culling)
private float[] _triRadius; // circumscribed radius per triangle (for fast CPU culling)
private float _avgUvToLocal = 1f; // average conversion: UV unit -> local unit
private Vector3[] _triCenter; // centroid per triangle (CPU culling)
private float[] _triRadius; // circumscribed radius per triangle (CPU culling)
private float _avgUvToLocal = 1f; // average UV unit -> local unit
private bool _hasLastScreen;
private Vector2 _lastScreen;
private bool _gpuActive; // currently configured backend
private bool _gpuActive;
private bool _setupValid;
// --- CPU backend ---
private Texture2D _paintTexture;
private Color32[] _pixels;
private bool[] _painted; // true = painted by the brush (persistent dilation source)
private int[] _fillStamp; // transient marker for padding texels per dilation call
private int _dilateStamp; // incremented per call -> padding from previous frames does not count as a source
private bool[] _painted; // true = painted by the brush (dilation source)
private int[] _fillStamp; // per-call marker for padding texels
private int _dilateStamp; // bumped per call so old padding is not a source
private readonly List<int> _dilateScratch = new List<int>(512);
private readonly RaycastHit[] _rayHits = new RaycastHit[32]; // buffer so other colliders (player capsule, etc.) cannot block painting
private readonly RaycastHit[] _rayHits = new RaycastHit[32];
private int _dx0, _dy0, _dx1, _dy1; // dirty rectangle (CPU upload)
// --- GPU backend ---
public Shader brushShader; // optional; otherwise Shader.Find("Hidden/MeshPaintBrush")
public Shader dilateShader; // optional; otherwise Shader.Find("Hidden/MeshPaintDilate")
public Shader brushShader; // optional; else Shader.Find("Hidden/MeshPaintBrush")
public Shader dilateShader; // optional; else Shader.Find("Hidden/MeshPaintDilate")
private Material _brushMat;
private Material _dilateMat;
private RenderTexture _displayRT; // assigned to the material as _BaseMap
private RenderTexture _displayRT;
private RenderTexture _tempRT; // scratch for the dilation blit (ping-pong)
private bool _drewThisStroke;
private bool _prevSRGB;
// Step size (in screen pixels) for interpolation along the mouse movement.
private const float ScreenStepPixels = 5f;
// Upper bound of raycasts per frame (safeguard for very fast movements).
private const int MaxStepsPerFrame = 256;
private const float ScreenStepPixels = 5f; // interpolation step along mouse movement
private const int MaxStepsPerFrame = 256; // raycast safeguard for fast movement
private static readonly int BaseMapId = Shader.PropertyToID("_BaseMap");
private static readonly int MainTexId = Shader.PropertyToID("_MainTex");
@@ -124,11 +102,11 @@ public class MeshPainter : MonoBehaviour
private void Start()
{
if (paintCamera == null) paintCamera = Camera.main;
if (!paintCamera) paintCamera = Camera.main;
_renderer = GetComponent<Renderer>();
_skinned = GetComponent<SkinnedMeshRenderer>();
if (_renderer == null)
if (!_renderer)
{
Debug.LogError("[MeshPainter] No Renderer found on this GameObject.", this);
enabled = false;
@@ -147,9 +125,9 @@ public class MeshPainter : MonoBehaviour
return;
}
// Own material instance so the shared asset is not modified.
_materialInstance = _renderer.material; // automatically creates an instance
// Tint to white so the painted color appears unaltered.
// Own material instance so the shared asset is not modified; tint to white
// so the painted color appears unaltered.
_materialInstance = _renderer.material;
if (_materialInstance.HasProperty(BaseColorId)) _materialInstance.SetColor(BaseColorId, Color.white);
else if (_materialInstance.HasProperty(ColorId)) _materialInstance.SetColor(ColorId, Color.white);
@@ -160,14 +138,12 @@ public class MeshPainter : MonoBehaviour
"Hold the left mouse button and drag over the mesh.", this);
}
// ------------------------------------------------------------------
// Setup (shared)
// ------------------------------------------------------------------
// --- Setup (shared) ---
private void SetupCollider()
{
_collider = GetComponent<MeshCollider>();
if (_collider == null) _collider = gameObject.AddComponent<MeshCollider>();
if (!_collider) _collider = gameObject.AddComponent<MeshCollider>();
_collider.convex = false; // non-convex provides triangleIndex / textureCoord
_bakedMesh = new Mesh { name = "MeshPainter_Baked" };
@@ -176,7 +152,7 @@ public class MeshPainter : MonoBehaviour
private void BakeColliderMesh()
{
if (_skinned != null)
if (_skinned)
{
// Static skinned mesh: bake into local coordinates (scale = 1).
_skinned.BakeMesh(_bakedMesh);
@@ -186,7 +162,7 @@ public class MeshPainter : MonoBehaviour
else
{
var mf = GetComponent<MeshFilter>();
if (mf != null) _collider.sharedMesh = mf.sharedMesh;
if (mf) _collider.sharedMesh = mf.sharedMesh;
}
}
@@ -194,14 +170,14 @@ public class MeshPainter : MonoBehaviour
private void BuildMeshCaches()
{
Mesh m = _collider.sharedMesh;
if (m == null) return;
if (!m) return;
_verts = m.vertices;
_tris = m.triangles;
_uvs = m.uv;
// Fallback: UVs from the original mesh (BakeMesh usually keeps them, but better safe than sorry).
if ((_uvs == null || _uvs.Length != _verts.Length) && _skinned != null && _skinned.sharedMesh != null)
// Fallback to the original mesh UVs if the baked mesh dropped them.
if ((_uvs == null || _uvs.Length != _verts.Length) && _skinned && _skinned.sharedMesh)
_uvs = _skinned.sharedMesh.uv;
if (_uvs == null || _uvs.Length != _verts.Length) return;
@@ -232,21 +208,21 @@ public class MeshPainter : MonoBehaviour
if (sumAreaUv > 1e-9) _avgUvToLocal = (float)System.Math.Sqrt(sumArea3D / sumAreaUv);
}
/// <summary>Builds the base-color texels (copy of the existing material texture or baseColor).</summary>
/// <summary>Builds the base-color texels from the existing material texture or baseColor.</summary>
private Color32[] BuildBaseColors()
{
var px = new Color32[textureSize * textureSize];
Texture sourceTex = null;
var shared = _renderer.sharedMaterial;
if (shared != null)
if (shared)
{
if (shared.HasProperty(BaseMapId)) sourceTex = shared.GetTexture(BaseMapId);
if (sourceTex == null && shared.HasProperty(MainTexId)) sourceTex = shared.GetTexture(MainTexId);
if (!sourceTex && shared.HasProperty(MainTexId)) sourceTex = shared.GetTexture(MainTexId);
}
var src = sourceTex as Texture2D;
if (src != null)
if (src)
{
try
{
@@ -261,11 +237,8 @@ public class MeshPainter : MonoBehaviour
return px;
}
// ------------------------------------------------------------------
// Backend selection / switching
// ------------------------------------------------------------------
// --- Backend selection / switching ---
/// <summary>Sets up the desired backend and carries over the initial image content.</summary>
private void SetupBackend(bool gpu, Color32[] fromColor32)
{
if (gpu) SetupGpu(fromColor32);
@@ -273,10 +246,9 @@ public class MeshPainter : MonoBehaviour
_gpuActive = gpu;
}
/// <summary>Switches the backend at runtime and carries over the currently painted content.</summary>
/// <summary>Switches the backend at runtime, carrying over the painted content.</summary>
private void SwitchBackend(bool toGpu)
{
// save the current content as Color32
Color32[] current = (_gpuActive ? ReadGpuPixels() : (Color32[])_pixels.Clone());
if (_gpuActive) TeardownGpu();
@@ -288,9 +260,7 @@ public class MeshPainter : MonoBehaviour
Debug.Log($"[MeshPainter] Backend switched -> {(toGpu ? "GPU" : "CPU")} painting.", this);
}
// ------------------------------------------------------------------
// CPU backend
// ------------------------------------------------------------------
// --- CPU backend ---
private void SetupCpu(Color32[] initial)
{
@@ -317,22 +287,20 @@ public class MeshPainter : MonoBehaviour
private void TeardownCpu()
{
if (_paintTexture != null) { Destroy(_paintTexture); _paintTexture = null; }
if (_paintTexture) { Destroy(_paintTexture); _paintTexture = null; }
_pixels = null;
_painted = null;
_fillStamp = null;
}
// ------------------------------------------------------------------
// GPU backend
// ------------------------------------------------------------------
// --- GPU backend ---
private void SetupGpu(Color32[] initial)
{
if (brushShader == null) brushShader = Shader.Find("Hidden/MeshPaintBrush");
if (dilateShader == null) dilateShader = Shader.Find("Hidden/MeshPaintDilate");
if (!brushShader) brushShader = Shader.Find("Hidden/MeshPaintBrush");
if (!dilateShader) dilateShader = Shader.Find("Hidden/MeshPaintDilate");
if (brushShader == null || dilateShader == null)
if (!brushShader || !dilateShader)
{
Debug.LogError("[MeshPainter] GPU shaders not found (Hidden/MeshPaintBrush or " +
"Hidden/MeshPaintDilate). Falling back to CPU painting.", this);
@@ -348,8 +316,8 @@ public class MeshPainter : MonoBehaviour
_displayRT = CreateRT("MeshPainter_DisplayRT");
_tempRT = CreateRT("MeshPainter_TempRT");
// Bring the initial image content into the display RT. Alpha = 0 marks "unpainted"
// (so the base color is not mistaken for a dilation source).
// Seed the display RT. Alpha = 0 marks "unpainted" so the base color is not
// mistaken for a dilation source.
var seed = initial ?? BuildBaseColors();
UploadColorsToRT(seed, _displayRT, paintedAlpha: false);
@@ -358,10 +326,10 @@ public class MeshPainter : MonoBehaviour
private void TeardownGpu()
{
if (_displayRT != null) { _displayRT.Release(); Destroy(_displayRT); _displayRT = null; }
if (_tempRT != null) { _tempRT.Release(); Destroy(_tempRT); _tempRT = null; }
if (_brushMat != null) { Destroy(_brushMat); _brushMat = null; }
if (_dilateMat != null) { Destroy(_dilateMat); _dilateMat = null; }
if (_displayRT) { _displayRT.Release(); Destroy(_displayRT); _displayRT = null; }
if (_tempRT) { _tempRT.Release(); Destroy(_tempRT); _tempRT = null; }
if (_brushMat) { Destroy(_brushMat); _brushMat = null; }
if (_dilateMat) { Destroy(_dilateMat); _dilateMat = null; }
}
private RenderTexture CreateRT(string rtName)
@@ -383,14 +351,13 @@ public class MeshPainter : MonoBehaviour
private void UploadColorsToRT(Color32[] colors, RenderTexture rt, bool paintedAlpha)
{
var tmp = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, false);
// Alpha marks painted (255) vs unpainted (0) for the dilation pass.
if (paintedAlpha)
{
// mark the entire content as "painted" (alpha = 255) -> no dilation needed.
for (int i = 0; i < colors.Length; i++) colors[i].a = 255;
}
else
{
// mark the base color as "unpainted" (alpha = 0).
for (int i = 0; i < colors.Length; i++) colors[i].a = 0;
}
tmp.SetPixels32(colors);
@@ -413,26 +380,21 @@ public class MeshPainter : MonoBehaviour
return px;
}
// ------------------------------------------------------------------
// Material
// ------------------------------------------------------------------
// --- Material ---
private void AssignTextureToMaterial(Texture tex)
{
if (_materialInstance == null) return;
if (!_materialInstance) return;
if (_materialInstance.HasProperty(BaseMapId)) _materialInstance.SetTexture(BaseMapId, tex);
if (_materialInstance.HasProperty(MainTexId)) _materialInstance.SetTexture(MainTexId, tex);
}
// ------------------------------------------------------------------
// Update / input
// ------------------------------------------------------------------
// --- Update / input ---
private void Update()
{
if (!_setupValid) return;
// Live backend switching.
if (useGpu != _gpuActive) SwitchBackend(useGpu);
// Painting disabled by the game mode (e.g. player-control mode): do nothing.
@@ -442,7 +404,7 @@ public class MeshPainter : MonoBehaviour
return;
}
if (updateColliderEveryFrame && _skinned != null)
if (updateColliderEveryFrame && _skinned)
{
BakeColliderMesh();
BuildMeshCaches();
@@ -457,15 +419,15 @@ public class MeshPainter : MonoBehaviour
return;
}
if (paintCamera == null) return;
if (!paintCamera) return;
if (_gpuActive) BeginGpuStroke();
else { _dx0 = textureSize; _dy0 = textureSize; _dx1 = -1; _dy1 = -1; }
if (_hasLastScreen)
{
// Interpolate along the mouse movement in screen space and re-raycast at
// each step -> the stroke follows the mesh surface.
// Interpolate along the mouse movement and re-raycast at each step so the
// stroke follows the mesh surface.
float dist = Vector2.Distance(_lastScreen, screenPos);
int steps = Mathf.Clamp(Mathf.CeilToInt(dist / ScreenStepPixels), 1, MaxStepsPerFrame);
for (int s = 1; s <= steps; s++)
@@ -487,19 +449,14 @@ public class MeshPainter : MonoBehaviour
_hasLastScreen = true;
}
/// <summary>
/// Raycast at a screen position. If the ray hits the mesh, a 3D sphere is painted
/// around the hit point (seamless) - via GPU or CPU depending on the backend.
/// </summary>
/// <summary>Raycasts at a screen position and paints a 3D sphere around the mesh hit point.</summary>
private void PaintAtScreen(Vector2 screenPos)
{
Ray ray = paintCamera.ScreenPointToRay(screenPos);
// Collect all hits and pick the nearest one that belongs to the paint mesh.
// A plain Physics.Raycast returns only the closest collider, so anything in
// front of the mesh (the player's CharacterController capsule, future props,
// the camera-collision proxy) would block painting. Filtering by collider
// keeps painting working from every camera angle.
// Pick the nearest hit that belongs to the paint mesh. A plain raycast would
// return only the closest collider, so anything in front of the mesh (the
// player capsule, props, the camera-collision proxy) could block painting.
int hitCount = Physics.RaycastNonAlloc(ray, _rayHits, 1000f, ~0, QueryTriggerInteraction.Ignore);
RaycastHit hit = default;
float nearest = float.MaxValue;
@@ -517,11 +474,10 @@ public class MeshPainter : MonoBehaviour
if (!found) return;
if (_verts == null || hit.triangleIndex < 0) return;
// Bring the hit point into local mesh space (= collider/vertex space).
Vector3 pLocal = transform.InverseTransformPoint(hit.point);
// Convert the brush radius (texels) into a local 3D radius using the UV
// density of the hit triangle -> the brush stays visually the same size.
// Convert the brush radius (texels) into a local 3D radius using the hit
// triangle's UV density so the brush keeps a constant visual size.
float scale = _avgUvToLocal;
int t = hit.triangleIndex;
if (t * 3 + 2 < _tris.Length)
@@ -532,8 +488,7 @@ public class MeshPainter : MonoBehaviour
if (areaUv > 1e-9f)
{
float s = Mathf.Sqrt(area3D / areaUv);
// guard against degenerate UVs: keep close to the average.
scale = Mathf.Clamp(s, _avgUvToLocal * 0.25f, _avgUvToLocal * 4f);
scale = Mathf.Clamp(s, _avgUvToLocal * 0.25f, _avgUvToLocal * 4f); // guard degenerate UVs
}
}
@@ -544,15 +499,13 @@ public class MeshPainter : MonoBehaviour
else PaintSphere3D(pLocal, radiusLocal);
}
// ------------------------------------------------------------------
// GPU painting
// ------------------------------------------------------------------
// --- GPU painting ---
private void BeginGpuStroke()
{
_drewThisStroke = false;
_prevSRGB = GL.sRGBWrite;
GL.sRGBWrite = true; // correct sRGB encoding when writing into the sRGB RT (linear project)
GL.sRGBWrite = true; // correct sRGB encoding when writing into the sRGB RT
Graphics.SetRenderTarget(_displayRT);
_brushMat.SetColor(BrushColorId, brushColor);
}
@@ -574,8 +527,7 @@ public class MeshPainter : MonoBehaviour
{
Graphics.SetRenderTarget(null);
// Edge padding against UV seams: blit the display RT through the dilation
// shader into _tempRT and then swap (the result ends up in _displayRT again).
// Edge padding against UV seams: dilate the display RT into _tempRT, then swap.
if (_drewThisStroke && paddingTexels > 0)
{
_dilateMat.SetInt(PadId, paddingTexels);
@@ -588,13 +540,11 @@ public class MeshPainter : MonoBehaviour
GL.sRGBWrite = _prevSRGB;
}
// ------------------------------------------------------------------
// CPU painting (rasterization + dilation)
// ------------------------------------------------------------------
// --- CPU painting (rasterization + dilation) ---
/// <summary>
/// Paints a sphere in local 3D space: rasterizes all triangles in range into
/// the texture and colors every texel whose 3D position lies inside the sphere.
/// Paints a sphere in local 3D space: rasterizes triangles in range and colors
/// every texel whose 3D position lies inside the sphere.
/// </summary>
private void PaintSphere3D(Vector3 pLocal, float radiusLocal)
{
@@ -605,7 +555,7 @@ public class MeshPainter : MonoBehaviour
for (int t = 0; t < triCount; t++)
{
// Fast culling: sphere against the triangle's circumscribed circle.
// Fast culling: sphere vs. the triangle's circumscribed circle.
float reach = radiusLocal + _triRadius[t];
if ((_triCenter[t] - pLocal).sqrMagnitude > reach * reach) continue;
@@ -618,7 +568,6 @@ public class MeshPainter : MonoBehaviour
float p1x = uv1.x * ts, p1y = uv1.y * ts;
float p2x = uv2.x * ts, p2y = uv2.y * ts;
// Bounding box in pixel space.
int minX = Mathf.Clamp(Mathf.FloorToInt(Mathf.Min(p0x, Mathf.Min(p1x, p2x))), 0, textureSize - 1);
int maxX = Mathf.Clamp(Mathf.CeilToInt(Mathf.Max(p0x, Mathf.Max(p1x, p2x))), 0, textureSize - 1);
int minY = Mathf.Clamp(Mathf.FloorToInt(Mathf.Min(p0y, Mathf.Min(p1y, p2y))), 0, textureSize - 1);
@@ -630,7 +579,7 @@ public class MeshPainter : MonoBehaviour
if (Mathf.Abs(d) < 1e-7f) continue;
float invD = 1f / d;
const float eps = -1e-4f; // small tolerance against 1px cracks between triangles
const float eps = -1e-4f; // tolerance against 1px cracks between triangles
for (int y = minY; y <= maxY; y++)
{
@@ -668,9 +617,8 @@ public class MeshPainter : MonoBehaviour
}
/// <summary>
/// Edge padding / texture dilation against visible UV seams (CPU).
/// Colors a thin border (pad texels) around the freshly painted areas with their
/// color. The dilation source is exclusively brush-painted texels (_painted).
/// CPU edge padding against UV seams: bleeds the freshly painted colors outward
/// by 'pad' texels. The dilation source is only brush-painted texels (_painted).
/// </summary>
private void DilatePadding(int pad)
{
@@ -681,7 +629,7 @@ public class MeshPainter : MonoBehaviour
int x1 = Mathf.Min(textureSize - 1, _dx1 + pad);
int y1 = Mathf.Min(textureSize - 1, _dy1 + pad);
_dilateStamp++; // unique per call -> padding from previous frames does not count as a source
_dilateStamp++;
for (int iter = 0; iter < pad; iter++)
{
@@ -755,9 +703,7 @@ public class MeshPainter : MonoBehaviour
_paintTexture.Apply(false);
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
// --- Helpers ---
private static float Cross2D(Vector2 a, Vector2 b) => a.x * b.y - a.y * b.x;
@@ -785,8 +731,8 @@ public class MeshPainter : MonoBehaviour
private void OnDestroy()
{
if (_bakedMesh != null) Destroy(_bakedMesh);
if (_materialInstance != null) Destroy(_materialInstance);
if (_bakedMesh) Destroy(_bakedMesh);
if (_materialInstance) Destroy(_materialInstance);
TeardownCpu();
TeardownGpu();
}
+5 -6
View File
@@ -1,7 +1,6 @@
/// <summary>
/// Mode 2: the player stands still and the mesh is painted with the left mouse
/// button. The camera orbits only while the right mouse button is held, so
/// dragging with the left button paints instead of rotating the view.
/// Player stands still, the mesh is painted with the left mouse button. The
/// camera orbits only while the right mouse button is held.
/// </summary>
public class PaintMode : GameMode
{
@@ -9,13 +8,13 @@ public class PaintMode : GameMode
public override void Enter()
{
if (Manager.Player != null)
if (Manager.Player)
Manager.Player.ControlEnabled = false;
if (Manager.CameraInput != null)
if (Manager.CameraInput)
Manager.CameraInput.LookMode = ThirdPersonOrbitInput.OrbitLookMode.HoldRightMouseButton;
if (Manager.Painter != null)
if (Manager.Painter)
Manager.Painter.PaintingEnabled = true;
}
}
+78 -151
View File
@@ -4,49 +4,36 @@ using UnityEngine.InputSystem;
#endif
/// <summary>
/// Simple first/third-person style character controller driven by a
/// <see cref="CharacterController"/>: WASD for horizontal movement and Space to jump.
/// CharacterController-based player: camera-relative WASD movement, manual gravity
/// and jumping, sliding off too-steep slopes/edges, and a wall-climb mode.
///
/// Movement can be camera-relative (W moves away from the camera) so it feels
/// natural with the fixed scene camera. Gravity and jumping are integrated
/// manually because CharacterController does not apply physics on its own.
/// Wall-climb (entered on wall/ceiling contact) uses wall-relative controls: W
/// moves toward the wall and climbs up once against it, S backs off and detaches,
/// A/D strafe along the wall. Gravity and jumping are disabled while climbing;
/// reaching the top edge mantles the character over onto the surface.
///
/// Touching a wall or ceiling enters a wall-climb mode with
/// wall-relative controls: W moves slowly toward the wall and climbs up once
/// fully against it, S backs slowly away and detaches once far enough, and A/D
/// strafe left/right along the wall. Gravity and jumping are ignored while
/// climbing. Climbing up past the top edge mantles the character over onto the
/// surface instead of dropping back down.
///
/// The script reads input through the new Input System (Keyboard.current) when it
/// is enabled, and falls back to the legacy Input Manager otherwise. The project
/// currently runs the new Input System only.
///
/// Attach this to the Player GameObject (the one that owns the CharacterController).
///
/// Note: the class is intentionally named PlayerCharacterController to avoid a
/// name clash with Unity.VisualScripting's example "PlayerController" type.
/// Named PlayerCharacterController to avoid a clash with Unity.VisualScripting's
/// example "PlayerController".
/// </summary>
[RequireComponent(typeof(CharacterController))]
[DisallowMultipleComponent]
public class PlayerCharacterController : MonoBehaviour
{
[Header("Movement")]
[Tooltip("Horizontal movement speed in units per second.")]
[Tooltip("Horizontal movement speed (units/second).")]
public float moveSpeed = 4f;
[Tooltip("If ON, WASD is interpreted relative to the camera (W = away from " +
"the camera). If OFF, WASD moves along the world X/Z axes.")]
[Tooltip("Interpret WASD relative to the camera (off = world X/Z axes).")]
public bool cameraRelative = true;
[Tooltip("Camera used for camera-relative movement. Empty = Camera.main.")]
public Transform cameraTransform;
[Header("Rotation")]
[Tooltip("If ON, the character smoothly turns to face its movement direction.")]
[Tooltip("Smoothly turn the character to face its movement direction.")]
public bool rotateTowardMovement = true;
[Tooltip("Turn speed in degrees per second (used when 'Rotate Toward Movement' is on).")]
[Tooltip("Turn speed in degrees per second.")]
public float rotationSpeed = 720f;
[Header("Jump & Gravity")]
@@ -56,111 +43,93 @@ public class PlayerCharacterController : MonoBehaviour
[Tooltip("Gravity in units per second squared (negative = downward).")]
public float gravity = -19.62f;
[Tooltip("Small downward velocity kept while grounded so the controller " +
"stays firmly on the ground (and isGrounded stays reliable).")]
[Tooltip("Small downward velocity kept while grounded for reliable ground contact.")]
public float groundedStickVelocity = -2f;
[Header("Slope Sliding")]
[Tooltip("If ON, the character slides down surfaces steeper than the " +
"CharacterController's Slope Limit (and off sharp edges/corners) " +
"instead of balancing on them.")]
[Tooltip("Slide down surfaces steeper than the CharacterController's Slope Limit instead of balancing on them.")]
public bool slideDownSteepSlopes = true;
[Tooltip("How quickly the slide accelerates while on a too-steep surface " +
"(units per second squared).")]
[Tooltip("How quickly the slide accelerates (units/second squared).")]
public float slideAcceleration = 20f;
[Tooltip("Maximum downhill slide speed (units per second).")]
[Tooltip("Maximum downhill slide speed (units/second).")]
public float maxSlideSpeed = 8f;
[Tooltip("How much steering control the player keeps while sliding " +
"(0 = none, 1 = full normal control).")]
[Tooltip("Steering control kept while sliding (0 = none, 1 = full).")]
[Range(0f, 1f)]
public float slideControl = 0.3f;
[Header("Wall Climb")]
[Tooltip("If ON, the character enters a wall-climb mode after touching a " +
"wall or ceiling for a short time: it sticks to the surface, moves " +
"at reduced speed and ignores gravity and jumping until contact is lost.")]
[Tooltip("Enter wall-climb mode on wall/ceiling contact: stick to the surface, move at reduced speed, ignore gravity and jumping.")]
public bool enableWallClimb = true;
[Tooltip("Minimum surface angle from 'up' (in degrees) for a contact to " +
"count as a climbable wall or ceiling. Surfaces between the slope " +
"limit and this angle stay (sliding) slopes; at or above this they " +
"are treated as walls/ceilings.")]
[Tooltip("Minimum surface angle from 'up' (degrees) that counts as a climbable wall/ceiling rather than a slope.")]
[Range(50f, 180f)]
public float wallMinAngle = 80f;
[Tooltip("Speed multiplier for climbing up (W when against the wall) and " +
"strafing left/right (A/D) while wall-climbing (0.5 = half speed).")]
[Tooltip("Speed multiplier for climbing up and strafing while wall-climbing (0.5 = half speed).")]
[Range(0f, 1f)]
public float wallClimbSpeedMultiplier = 0.5f;
[Tooltip("Speed (units per second) for moving toward (W) or away from (S) the " +
"wall while climbing. Deliberately slow so approaching/detaching is gentle.")]
[Tooltip("Slow speed for moving toward (W) or away from (S) the wall while climbing.")]
public float wallApproachSpeed = 0.5f;
[Tooltip("Gap (in units) below which the character counts as fully against " +
"the wall, so pressing forward (W) climbs up instead of moving closer.")]
[Tooltip("Gap below which the character counts as fully against the wall (W then climbs up).")]
public float wallContactDistance = 0.05f;
[Tooltip("Gap (in units) from the wall at which holding back (S) leaves " +
"wall-climb mode.")]
[Tooltip("Gap from the wall at which holding back (S) leaves wall-climb mode.")]
public float wallDetachDistance = 0.25f;
[Tooltip("Grace period (seconds) of lost contact tolerated before leaving " +
"wall-climb mode (prevents flicker on bumpy geometry/edges).")]
[Tooltip("Grace period (seconds) of lost contact tolerated before leaving wall-climb mode.")]
public float wallExitGrace = 0.15f;
/// <summary>
/// When false, movement/jump input is ignored (the player stands still), but
/// gravity and ground contact keep running so the character stays grounded.
/// Toggled by the GameModeManager (e.g. disabled in paint mode).
/// When false, movement/jump input is ignored but gravity and ground contact
/// keep running. Toggled by the GameModeManager.
/// </summary>
public bool ControlEnabled { get; set; } = true;
/// <summary>True while the character is in wall-climb mode (stuck to a wall/ceiling).</summary>
/// <summary>True while in wall-climb mode.</summary>
public bool IsWallClimbing => _isWallClimbing;
private CharacterController _controller;
private float _verticalVelocity;
// Ground contact normal collected from the latest Move (used for slope sliding).
// Ground contact normal from the latest Move (for slope sliding).
private Vector3 _groundNormal = Vector3.up;
private bool _groundNormalUpdated;
private float _slideSpeed;
// Wall-climb state.
private bool _isWallClimbing;
private Vector3 _wallNormal; // surface normal of the climbed wall/ceiling
private Vector3 _wallNormal;
private Vector3 _wallNormalAccum; // summed wall normals during the latest Move
private bool _wallContactThisFrame; // any wall/ceiling touched during the latest Move
private float _lostContactTimer; // continuous lost-contact time while climbing
private readonly RaycastHit[] _wallProbe = new RaycastHit[8]; // buffer for the wall-distance probe
private bool _wallContactThisFrame;
private float _lostContactTimer;
private readonly RaycastHit[] _wallProbe = new RaycastHit[8];
// Mantle (top-out) state: climbing up past the wall's top edge pulls the
// character up and over onto the surface instead of dropping back down.
private bool _mantling; // a mantle is currently in progress
private bool _mantleClearedFeet; // phase 1 done: whole capsule is above the edge
private float _mantleOverDist; // phase 2 forward distance moved over the top
private float _mantleTimer; // safety cap so a mantle can never run forever
private float _reentryCooldown; // blocks re-grabbing the wall right after topping out
// Mantle (top-out) state.
private bool _mantling;
private bool _mantleClearedFeet; // phase 1 done: capsule fully above the edge
private float _mantleOverDist; // phase 2 forward distance over the top
private float _mantleTimer; // safety cap
private float _reentryCooldown; // blocks re-grabbing right after topping out
void Awake()
{
_controller = GetComponent<CharacterController>();
if (cameraTransform == null && Camera.main != null)
if (!cameraTransform && Camera.main)
cameraTransform = Camera.main.transform;
}
void Update()
{
// --- Read input -----------------------------------------------------
float h = 0f, v = 0f;
bool jumpPressed = false;
// Skip input entirely when control is disabled (e.g. paint mode); gravity
// and grounding below still run so the player stays planted on the ground.
// Skip input when control is disabled (e.g. paint mode); gravity and
// grounding below still run so the player stays planted.
if (ControlEnabled)
{
#if ENABLE_INPUT_SYSTEM
@@ -180,13 +149,12 @@ public class PlayerCharacterController : MonoBehaviour
#endif
}
// --- Build the horizontal move direction ----------------------------
Vector3 inputDir = new Vector3(h, 0f, v);
if (inputDir.sqrMagnitude > 1f)
inputDir.Normalize();
Vector3 moveDir;
if (cameraRelative && cameraTransform != null)
if (cameraRelative && cameraTransform)
{
// Project the camera axes onto the ground plane so movement stays horizontal.
Vector3 forward = cameraTransform.forward;
@@ -202,14 +170,10 @@ public class PlayerCharacterController : MonoBehaviour
moveDir = inputDir;
}
// --- Wall-climb state machine ---------------------------------------
// Evaluates the wall/ceiling contact gathered during the previous Move,
// then enters/leaves wall-climb mode accordingly.
UpdateWallClimbState();
// Latch the wall normal for this frame's movement, then reset the
// per-frame contact accumulators (OnControllerColliderHit refills them
// during the Move calls below / above).
// Latch the wall normal for this frame, then reset the per-frame contact
// accumulators (OnControllerColliderHit refills them during Move).
if (_wallContactThisFrame && _wallNormalAccum.sqrMagnitude > 1e-6f)
_wallNormal = _wallNormalAccum.normalized;
_wallContactThisFrame = false;
@@ -217,64 +181,51 @@ public class PlayerCharacterController : MonoBehaviour
if (_isWallClimbing)
{
// While climbing: wall-relative WASD, no gravity, no jump, no slope
// sliding, no forced alignment. Done for this frame.
ApplyWallClimbMovement(h, v, Time.deltaTime);
return;
}
// --- Evaluate the ground slope (for sliding) ------------------------
// The ground normal is gathered in OnControllerColliderHit during the
// previous Move. If it is steeper than the controller's Slope Limit, the
// surface is too steep to stand on (this also covers balancing on a sharp
// box edge/corner, whose contact normal is steep).
// A ground normal steeper than the slope limit is too steep to stand on
// (also covers balancing on a sharp box edge).
float slopeAngle = Vector3.Angle(_groundNormal, Vector3.up);
bool onSteepSlope = slideDownSteepSlopes
&& _controller.isGrounded
&& slopeAngle > _controller.slopeLimit + 0.01f;
// --- Gravity & jumping ---------------------------------------------
if (_controller.isGrounded)
{
// Reset accumulated fall speed and keep a small downward bias on the ground.
if (_verticalVelocity < 0f)
_verticalVelocity = groundedStickVelocity;
// No jumping off a slope that is too steep to stand on.
// No jumping off a slope too steep to stand on.
if (jumpPressed && !onSteepSlope)
_verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
}
_verticalVelocity += gravity * Time.deltaTime;
// --- Slope sliding --------------------------------------------------
Vector3 slideVelocity = Vector3.zero;
if (onSteepSlope)
{
// Steepest-descent direction along the slope surface.
Vector3 slideDir = Vector3.ProjectOnPlane(Vector3.down, _groundNormal).normalized;
// Accelerate the slide up to the maximum slide speed.
_slideSpeed = Mathf.Min(_slideSpeed + slideAcceleration * Time.deltaTime, maxSlideSpeed);
slideVelocity = slideDir * _slideSpeed;
// While sliding the player keeps only reduced steering control.
moveDir *= slideControl;
moveDir *= slideControl; // reduced steering while sliding
}
else
{
_slideSpeed = 0f;
}
// --- Apply movement -------------------------------------------------
Vector3 velocity = moveDir * moveSpeed + slideVelocity + Vector3.up * _verticalVelocity;
// Reset the per-frame ground-normal collection, then Move (which fires
// OnControllerColliderHit), then fall back to "up" if nothing was hit.
// Move fires OnControllerColliderHit, which refills the ground normal;
// fall back to "up" if nothing was hit.
_groundNormalUpdated = false;
_controller.Move(velocity * Time.deltaTime);
if (!_groundNormalUpdated)
_groundNormal = Vector3.up;
// --- Rotate toward the movement direction ---------------------------
if (rotateTowardMovement && moveDir.sqrMagnitude > 0.0001f)
{
Quaternion target = Quaternion.LookRotation(moveDir, Vector3.up);
@@ -284,14 +235,11 @@ public class PlayerCharacterController : MonoBehaviour
}
/// <summary>
/// Drives entry into wall-climb mode: any wall/ceiling contact enters the mode
/// immediately (or bails out when the feature/control is disabled). Leaving the
/// mode while climbing is handled in <see cref="ApplyWallClimbMovement"/>
/// (raycast-based).
/// Enters wall-climb mode on any wall/ceiling contact. Leaving while climbing
/// is handled in <see cref="ApplyWallClimbMovement"/>.
/// </summary>
private void UpdateWallClimbState()
{
// Cool down the post-mantle re-grab lock (runs whether climbing or not).
if (_reentryCooldown > 0f)
_reentryCooldown -= Time.deltaTime;
@@ -302,37 +250,29 @@ public class PlayerCharacterController : MonoBehaviour
return;
}
// The cooldown prevents instantly re-grabbing the edge we just mantled over.
if (!_isWallClimbing && _wallContactThisFrame && _reentryCooldown <= 0f)
{
// Any wall/ceiling contact enters wall-climb mode immediately. The
// cooldown above prevents instantly re-grabbing the edge we just
// mantled over (which would otherwise cause a climb/drop flicker).
_isWallClimbing = true;
_lostContactTimer = 0f;
_verticalVelocity = 0f;
_slideSpeed = 0f;
}
// While climbing, exit is decided in ApplyWallClimbMovement (intentional
// back-off with S, or losing the wall after the grace period).
}
/// <summary>
/// Moves the character along the climbed surface using wall-relative WASD:
/// W moves slowly toward the wall and, once fully against it, climbs up;
/// S moves slowly away and detaches once far enough; A/D strafe left/right
/// along the wall. The current wall normal and gap are measured each frame by
/// a raycast toward the surface. No gravity, no jump, no forced alignment.
/// Wall-relative movement while climbing: W approaches the wall then climbs up,
/// S backs off and detaches, A/D strafe along the wall. The wall normal and gap
/// are measured each frame by a raycast. No gravity, jump or forced alignment.
/// </summary>
private void ApplyWallClimbMovement(float h, float v, float dt)
{
// A mantle (top-out) overrides normal climbing until it finishes.
if (_mantling)
{
MantleOverEdge(dt);
return;
}
// Probe the wall ahead to refresh its normal and our distance to it.
bool found = ProbeWall(out Vector3 probedNormal, out float gap);
if (found)
{
@@ -341,9 +281,8 @@ public class PlayerCharacterController : MonoBehaviour
}
else
{
// The body-center ray lost the wall. If we were climbing up (W) and the
// wall still exists down at the feet, we have reached the top edge:
// mantle up and over onto the surface instead of dropping.
// The center ray lost the wall. If we are climbing up (W) and the wall
// still exists at foot level, we reached the top edge -> mantle over.
if (v > 0f && WallInFront(_controller.height * 0.5f))
{
_mantling = true;
@@ -354,7 +293,7 @@ public class PlayerCharacterController : MonoBehaviour
return;
}
// Otherwise the wall is genuinely gone: leave after the grace period.
// Wall genuinely gone: leave after the grace period.
_lostContactTimer += dt;
gap = wallDetachDistance + 1f; // treat as "far" so it cannot block exit
if (_lostContactTimer >= wallExitGrace)
@@ -364,31 +303,30 @@ public class PlayerCharacterController : MonoBehaviour
}
}
// Build a basis on the wall plane: "up along the wall" and "right".
// Basis on the wall plane: "up along the wall" and "right".
Vector3 climbUp = Vector3.ProjectOnPlane(Vector3.up, _wallNormal);
if (climbUp.sqrMagnitude < 1e-6f) // degenerate on a flat ceiling: fall back to facing
if (climbUp.sqrMagnitude < 1e-6f) // degenerate on a flat ceiling
climbUp = Vector3.ProjectOnPlane(transform.forward, _wallNormal);
climbUp.Normalize();
Vector3 right = Vector3.Cross(_wallNormal, climbUp).normalized;
float climbSpeed = moveSpeed * wallClimbSpeedMultiplier;
// A/D always strafe left/right along the wall.
Vector3 velocity = right * (h * climbSpeed);
Vector3 velocity = right * (h * climbSpeed); // A/D strafe
bool detach = false;
if (v > 0f)
{
// W: move closer to the wall while there is a gap, otherwise climb up.
// W: approach the wall while there is a gap, otherwise climb up.
if (gap > wallContactDistance)
velocity += -_wallNormal * (v * wallApproachSpeed); // slow approach
velocity += -_wallNormal * (v * wallApproachSpeed);
else
velocity += climbUp * (v * climbSpeed); // climb up
velocity += climbUp * (v * climbSpeed);
}
else if (v < 0f)
{
// S: slowly back away from the wall; detach once far enough.
velocity += -_wallNormal * (v * wallApproachSpeed); // v < 0 => away from wall
// S: back away (v < 0 => away from wall); detach once far enough.
velocity += -_wallNormal * (v * wallApproachSpeed);
if (gap >= wallDetachDistance)
detach = true;
}
@@ -400,11 +338,8 @@ public class PlayerCharacterController : MonoBehaviour
}
/// <summary>
/// Casts a ray from the character center toward the wall (opposite the wall
/// normal) to find the current surface normal and the gap between the
/// controller's side and the wall. The player's own colliders (controller and
/// the paintable mesh collider) are skipped. Returns false if no wall is found
/// within reach.
/// Raycasts from the character center toward the wall to find the current
/// normal and the gap to it. Our own colliders are skipped. False = no wall.
/// </summary>
private bool ProbeWall(out Vector3 normal, out float gap)
{
@@ -422,8 +357,7 @@ public class PlayerCharacterController : MonoBehaviour
bool found = false;
for (int i = 0; i < count; i++)
{
// Skip our own hierarchy (the CharacterController and the child mesh collider).
if (_wallProbe[i].collider.transform.IsChildOf(transform))
if (_wallProbe[i].collider.transform.IsChildOf(transform)) // skip our own colliders
continue;
if (_wallProbe[i].distance < best)
{
@@ -439,18 +373,15 @@ public class PlayerCharacterController : MonoBehaviour
}
/// <summary>
/// Pulls the character up and over the wall's top edge onto the surface.
/// Phase 1 rises along the wall face until the whole capsule is above the
/// edge; phase 2 moves forward over the top, after which wall-climb is left
/// and gravity settles the character onto the surface. A short re-grab
/// cooldown is armed so the freshly passed edge does not re-trigger a climb.
/// Pulls the character up and over the wall's top edge: phase 1 rises until the
/// capsule clears the edge, phase 2 moves forward onto the surface, then exits.
/// A short re-grab cooldown stops the passed edge from re-triggering a climb.
/// </summary>
private void MantleOverEdge(float dt)
{
_mantleTimer += dt;
float speed = moveSpeed * wallClimbSpeedMultiplier;
// Forward direction over the edge: horizontal, opposite the wall normal.
Vector3 forward = -_wallNormal;
forward.y = 0f;
forward = forward.sqrMagnitude > 1e-6f ? forward.normalized : transform.forward;
@@ -475,7 +406,7 @@ public class PlayerCharacterController : MonoBehaviour
}
}
// Safety: never mantle indefinitely (e.g. if blocked by other geometry).
// Safety: never mantle indefinitely (e.g. blocked by other geometry).
if (_mantleTimer >= 2f)
{
_reentryCooldown = 0.3f;
@@ -484,9 +415,8 @@ public class PlayerCharacterController : MonoBehaviour
}
/// <summary>
/// Casts a horizontal ray toward the wall from a point <paramref name="downOffset"/>
/// below the controller center, ignoring our own colliders. Used to tell a top
/// edge (wall still present lower down) from the wall being truly gone.
/// Horizontal ray toward the wall from a point below the center. Distinguishes a
/// top edge (wall still present lower down) from the wall being truly gone.
/// </summary>
private bool WallInFront(float downOffset)
{
@@ -517,14 +447,11 @@ public class PlayerCharacterController : MonoBehaviour
}
/// <summary>
/// Collects contact normals during <see cref="CharacterController.Move"/>.
/// Wall/ceiling contacts (angle at or above <see cref="wallMinAngle"/>) feed
/// the wall-climb detector; among the upward-facing contacts the flattest one
/// is kept as the ground normal the character is actually standing on.
/// Collects contact normals during Move: wall/ceiling contacts feed the
/// wall-climb detector, the flattest upward contact becomes the ground normal.
/// </summary>
private void OnControllerColliderHit(ControllerColliderHit hit)
{
// Detect walls/ceilings for wall-climbing (steep enough to not be a slope).
if (enableWallClimb)
{
float angle = Vector3.Angle(hit.normal, Vector3.up);
+5 -5
View File
@@ -1,6 +1,6 @@
/// <summary>
/// Mode 1: the player walks with WASD/Space and the camera orbits freely with
/// the mouse (cursor locked/hidden). Mesh painting is disabled.
/// Player walks with WASD/Space, the camera orbits freely with the mouse.
/// Painting is disabled.
/// </summary>
public class PlayerControlMode : GameMode
{
@@ -8,13 +8,13 @@ public class PlayerControlMode : GameMode
public override void Enter()
{
if (Manager.Player != null)
if (Manager.Player)
Manager.Player.ControlEnabled = true;
if (Manager.CameraInput != null)
if (Manager.CameraInput)
Manager.CameraInput.LookMode = ThirdPersonOrbitInput.OrbitLookMode.AlwaysActive;
if (Manager.Painter != null)
if (Manager.Painter)
Manager.Painter.PaintingEnabled = false;
}
}
+21 -61
View File
@@ -6,30 +6,14 @@ using UnityEngine.InputSystem;
/// <summary>
/// Feeds mouse input into a <see cref="CinemachineOrbitalFollow"/> so the camera
/// orbits around the follow target like a classic third-person camera.
///
/// How the orbit is driven is controlled by <see cref="LookMode"/>:
/// * AlwaysActive - rotates with every mouse move, cursor locked/hidden.
/// * HoldRightMouseButton - rotates only while the right mouse button is held;
/// the cursor is free otherwise (so the left button can paint).
/// * Disabled - no rotation, cursor free.
///
/// The look mode is normally set by the GameModeManager when switching modes,
/// but can also be configured directly in the Inspector for standalone use.
///
/// Horizontal/vertical mouse movement drives the orbital axes (yaw/pitch); the
/// mouse wheel zooms by changing the orbit radius. The script reads the new Input
/// System (Mouse.current / Keyboard.current) when enabled and falls back to the
/// legacy Input Manager otherwise. The project currently runs the new Input System only.
///
/// Attach this to the same GameObject as the CinemachineCamera /
/// CinemachineOrbitalFollow (the camera rig, not the player).
/// orbits the follow target. Mouse movement drives yaw/pitch, the wheel zooms.
/// The orbit behaviour depends on <see cref="LookMode"/> (usually set by the
/// GameModeManager). Attach to the camera rig (the CinemachineOrbitalFollow).
/// </summary>
[RequireComponent(typeof(CinemachineOrbitalFollow))]
[DisallowMultipleComponent]
public class ThirdPersonOrbitInput : MonoBehaviour
{
/// <summary>How the camera orbit is driven.</summary>
public enum OrbitLookMode
{
/// <summary>Rotate with every mouse move; cursor locked/hidden.</summary>
@@ -41,46 +25,39 @@ public class ThirdPersonOrbitInput : MonoBehaviour
}
[Header("Look Mode")]
[Tooltip("How the camera orbit is driven. Usually set by GameModeManager:\n" +
"AlwaysActive = rotates with every mouse move (cursor locked).\n" +
"HoldRightMouseButton = rotates only while RMB is held (cursor free otherwise).\n" +
"Disabled = no rotation.")]
[Tooltip("How the camera orbit is driven (usually set by GameModeManager).")]
[SerializeField] private OrbitLookMode lookMode = OrbitLookMode.AlwaysActive;
[Header("Rotation")]
[Tooltip("Horizontal look sensitivity in degrees of yaw per pixel of mouse movement.")]
[Tooltip("Horizontal look sensitivity (degrees of yaw per pixel).")]
public float horizontalSensitivity = 0.2f;
[Tooltip("Vertical look sensitivity in degrees of pitch per pixel of mouse movement.")]
[Tooltip("Vertical look sensitivity (degrees of pitch per pixel).")]
public float verticalSensitivity = 0.15f;
[Tooltip("If ON, moving the mouse up tilts the camera down (inverted pitch).")]
[Tooltip("Invert vertical look (mouse up tilts the camera down).")]
public bool invertY = false;
[Header("Zoom")]
[Tooltip("If ON, the mouse wheel zooms the camera in and out (changes orbit radius).")]
[Tooltip("Mouse wheel zooms the camera in and out (changes orbit radius).")]
public bool enableZoom = true;
[Tooltip("How many world units the orbit radius changes per mouse wheel notch.")]
[Tooltip("World units the orbit radius changes per mouse wheel notch.")]
public float zoomSpeed = 0.5f;
[Tooltip("Closest the camera can zoom in (orbit radius in units).")]
[Tooltip("Closest orbit radius (zoom in).")]
public float minRadius = 1.5f;
[Tooltip("Farthest the camera can zoom out (orbit radius in units).")]
[Tooltip("Farthest orbit radius (zoom out).")]
public float maxRadius = 8f;
[Header("Cursor")]
[Tooltip("If ON, the cursor is locked and hidden while the camera is actively " +
"rotating, and freed otherwise so you can use the mouse normally.")]
[Tooltip("Lock and hide the cursor while the camera is actively rotating.")]
public bool manageCursor = true;
CinemachineOrbitalFollow _orbit;
/// <summary>
/// How the camera orbit is currently driven. Setting this also updates the
/// cursor state immediately (e.g. when GameModeManager switches modes).
/// </summary>
/// <summary>Current orbit mode. Setting it also refreshes the cursor state.</summary>
public OrbitLookMode LookMode
{
get => lookMode;
@@ -90,30 +67,21 @@ public class ThirdPersonOrbitInput : MonoBehaviour
void Awake()
{
_orbit = GetComponent<CinemachineOrbitalFollow>();
if (_orbit == null)
if (!_orbit)
Debug.LogError("[ThirdPersonOrbitInput] No CinemachineOrbitalFollow found on this GameObject.", this);
}
void OnEnable()
{
ApplyCursorState();
}
void OnEnable() => ApplyCursorState();
void OnDisable()
{
// Make sure we never leave the cursor locked/hidden when this is turned off.
SetCursorLocked(false);
}
void OnDisable() => SetCursorLocked(false);
void Update()
{
if (_orbit == null)
if (!_orbit)
return;
bool rotating = ShouldRotate();
// Keep the cursor in sync every frame: in HoldRightMouseButton mode the
// lock state follows the right mouse button.
if (manageCursor)
SetCursorLocked(rotating);
@@ -133,16 +101,13 @@ public class ThirdPersonOrbitInput : MonoBehaviour
float scroll = ReadScroll();
if (!Mathf.Approximately(scroll, 0f))
{
// One wheel notch = one zoom step. Use only the sign so the step
// size is exactly zoomSpeed regardless of the platform's raw wheel
// magnitude (the new Input System reports ~1 per notch, the legacy
// Windows path used ~120). Scrolling up (positive) zooms in.
// One wheel notch = one zoom step (sign only), independent of the
// platform's raw wheel magnitude. Scrolling up zooms in.
_orbit.Radius = Mathf.Clamp(_orbit.Radius - Mathf.Sign(scroll) * zoomSpeed, minRadius, maxRadius);
}
}
}
/// <summary>Whether the camera should orbit this frame, based on the look mode.</summary>
bool ShouldRotate()
{
switch (lookMode)
@@ -159,10 +124,7 @@ public class ThirdPersonOrbitInput : MonoBehaviour
SetCursorLocked(ShouldRotate());
}
/// <summary>
/// Adds <paramref name="amount"/> to an input axis, respecting its range:
/// wrapping axes (e.g. yaw) wrap around, non-wrapping axes (e.g. pitch) clamp.
/// </summary>
/// <summary>Adds to an input axis; wrapping axes wrap, others clamp to their range.</summary>
static void ApplyAxisDelta(ref InputAxis axis, float amount)
{
float value = axis.Value + amount;
@@ -200,7 +162,6 @@ public class ThirdPersonOrbitInput : MonoBehaviour
Vector2 ReadMouseDelta()
{
#if ENABLE_INPUT_SYSTEM
// Mouse.current.delta is already the per-frame pixel movement (no deltaTime).
return Mouse.current != null ? Mouse.current.delta.ReadValue() : Vector2.zero;
#else
return new Vector2(Input.GetAxisRaw("Mouse X"), Input.GetAxisRaw("Mouse Y")) * 10f;
@@ -210,8 +171,7 @@ public class ThirdPersonOrbitInput : MonoBehaviour
float ReadScroll()
{
#if ENABLE_INPUT_SYSTEM
// Raw wheel delta. Only its sign is used in Update (one notch = one zoom
// step), so the platform-dependent magnitude does not matter here.
// Raw wheel delta; only its sign is used (one notch = one zoom step).
return Mouse.current != null ? Mouse.current.scroll.ReadValue().y : 0f;
#else
return Input.GetAxis("Mouse ScrollWheel");