Add Cinemachine third-person orbit camera and fix paint raycast

- Install Cinemachine 3.1.7 and set up an orbital third-person camera rig
  (CinemachineBrain, OrbitalFollow, RotationComposer, Deoccluder).
- Add ThirdPersonOrbitInput: mouse-driven orbit and wheel zoom with a Tab
  toggle that unlocks the cursor and pauses rotation.
- Fix MeshPainter so the paint ray skips the player's CharacterController
  capsule (and any other collider) and always targets the mesh collider,
  so painting works from every camera angle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-22 14:31:45 +02:00
1 parent 792c8c964f
commit 6e8ab4bc6d
6 files changed
+545 -10

No files matched your search

+306 -8
View File
@@ -119,6 +119,238 @@ NavMeshSettings:
debug:
m_Flags: 0
m_NavMeshData: {fileID: 0}
--- !u!1 &172071430
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 172071436}
- component: {fileID: 172071435}
- component: {fileID: 172071434}
- component: {fileID: 172071433}
- component: {fileID: 172071432}
- component: {fileID: 172071431}
m_Layer: 0
m_Name: ThirdPersonCamera
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &172071431
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 172071430}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: df129be8fadab79498bd429a59bb98e4, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::ThirdPersonOrbitInput
horizontalSensitivity: 0.2
verticalSensitivity: 0.15
invertY: 0
toggleKey: 3
startInLookMode: 1
enableZoom: 1
zoomSpeed: 0.5
minRadius: 1.5
maxRadius: 8
lockCursorInLookMode: 1
--- !u!114 &172071432
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 172071430}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: dbe43d662878a7c43bcd44d43c9e2094, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Cinemachine::Unity.Cinemachine.CinemachineDeoccluder
CollideAgainst:
serializedVersion: 2
m_Bits: 1
IgnoreTag: Player
TransparentLayers:
serializedVersion: 2
m_Bits: 0
MinimumDistanceFromTarget: 0.5
AvoidObstacles:
Enabled: 1
DistanceLimit: 0
MinimumOcclusionTime: 0
CameraRadius: 0.2
UseFollowTarget:
Enabled: 0
YOffset: 0
Strategy: 0
MaximumEffort: 4
SmoothingTime: 0
Damping: 0.2
DampingWhenOccluded: 0
ShotQualityEvaluation:
Enabled: 0
OptimalDistance: 10
NearLimit: 5
FarLimit: 30
MaxQualityBoost: 0.2
--- !u!114 &172071433
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 172071430}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f38bda98361e1de48a4ca2bd86ea3c17, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Cinemachine::Unity.Cinemachine.CinemachineRotationComposer
Composition:
ScreenPosition: {x: 0, y: 0}
DeadZone:
Enabled: 0
Size: {x: 0.2, y: 0.2}
HardLimits:
Enabled: 0
Size: {x: 0.8, y: 0.8}
Offset: {x: 0, y: 0}
CenterOnActivate: 1
TargetOffset: {x: 0, y: 0, z: 0}
Damping: {x: 0.5, y: 0.5}
Lookahead:
Enabled: 0
Time: 0
Smoothing: 0
IgnoreY: 0
--- !u!114 &172071434
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 172071430}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 3b5d7c088409d9a40b7b09aa707777f8, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Cinemachine::Unity.Cinemachine.CinemachineOrbitalFollow
TargetOffset: {x: 0, y: 0, z: 0}
TrackerSettings:
BindingMode: 4
PositionDamping: {x: 1, y: 1, z: 1}
AngularDampingMode: 0
RotationDamping: {x: 1, y: 1, z: 1}
QuaternionDamping: 1
OrbitStyle: 0
Radius: 4.5
Orbits:
Top:
Radius: 2
Height: 5
Center:
Radius: 4
Height: 2.25
Bottom:
Radius: 2.5
Height: 0.1
SplineCurvature: 0.5
RecenteringTarget: 2
HorizontalAxis:
Value: 0
Center: 0
Range: {x: -180, y: 180}
Wrap: 1
Recentering:
Enabled: 0
Wait: 1
Time: 2
Restrictions: 0
VerticalAxis:
Value: 15
Center: 12
Range: {x: -20, y: 60}
Wrap: 0
Recentering:
Enabled: 0
Wait: 1
Time: 2
Restrictions: 0
RadialAxis:
Value: 1
Center: 1
Range: {x: 1, y: 1}
Wrap: 0
Recentering:
Enabled: 0
Wait: 1
Time: 2
Restrictions: 0
--- !u!114 &172071435
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 172071430}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f9dfa5b682dcd46bda6128250e975f58, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Cinemachine::Unity.Cinemachine.CinemachineCamera
Priority:
Enabled: 0
m_Value: 0
OutputChannel: 1
StandbyUpdate: 2
m_StreamingVersion: 20241001
m_LegacyPriority: 0
Target:
TrackingTarget: {fileID: 1774267802}
LookAtTarget: {fileID: 1774267802}
CustomLookAtTarget: 0
Lens:
FieldOfView: 40
OrthographicSize: 10
NearClipPlane: 0.1
FarClipPlane: 5000
Dutch: 0
ModeOverride: 0
PhysicalProperties:
GateFit: 2
SensorSize: {x: 21.946, y: 16.002}
LensShift: {x: 0, y: 0}
FocusDistance: 10
Iso: 200
ShutterSpeed: 0.005
Aperture: 16
BladeCount: 5
Curvature: {x: 2, y: 11}
BarrelClipping: 0.25
Anamorphism: 0
BlendHint: 0
--- !u!4 &172071436
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 172071430}
serializedVersion: 2
m_LocalRotation: {x: 0.1305262, y: 0.00000002667958, z: -0.0000000035124335, w: 0.9914449}
m_LocalPosition: {x: 0, y: 3.1646857, z: -4.3466663}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &200502118
GameObject:
m_ObjectHideFlags: 0
@@ -243,6 +475,7 @@ GameObject:
- component: {fileID: 330585545}
- component: {fileID: 330585544}
- component: {fileID: 330585547}
- component: {fileID: 330585548}
m_Layer: 0
m_Name: Main Camera
m_TagString: MainCamera
@@ -289,11 +522,11 @@ Camera:
y: 0
width: 1
height: 1
near clip plane: 0.3
far clip plane: 1000
field of view: 60
near clip plane: 0.1
far clip plane: 5000
field of view: 40
orthographic: 0
orthographic size: 5
orthographic size: 10
m_Depth: -1
m_CullingMask:
serializedVersion: 2
@@ -317,8 +550,8 @@ Transform:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 330585543}
serializedVersion: 2
m_LocalRotation: {x: 0.3420201, y: 0, z: 0, w: 0.9396927}
m_LocalPosition: {x: 0, y: 4.43, z: -7.8}
m_LocalRotation: {x: 0.1305262, y: 0, z: 0, w: 0.9914449}
m_LocalPosition: {x: 0, y: 3.1646857, z: -4.3466663}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
@@ -368,6 +601,38 @@ MonoBehaviour:
m_VarianceClampScale: 0.9
m_ContrastAdaptiveSharpening: 0
m_Version: 2
--- !u!114 &330585548
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 330585543}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 72ece51f2901e7445ab60da3685d6b5f, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Cinemachine::Unity.Cinemachine.CinemachineBrain
ShowDebugText: 0
ShowCameraFrustum: 1
IgnoreTimeScale: 0
WorldUpOverride: {fileID: 0}
ChannelMask: -1
UpdateMethod: 2
BlendUpdateMethod: 1
LensModeOverride:
Enabled: 0
DefaultMode: 2
DefaultBlend:
Style: 1
Time: 2
CustomCurve:
serializedVersion: 2
m_Curve: []
m_PreInfinity: 2
m_PostInfinity: 2
m_RotationOrder: 4
CustomBlends: {fileID: 0}
--- !u!1 &410087039
GameObject:
m_ObjectHideFlags: 0
@@ -517,7 +782,7 @@ MonoBehaviour:
brushSize: 2
textureSize: 1024
baseColor: {r: 0.8, g: 0.8, b: 0.8, a: 1}
paddingTexels: 1
paddingTexels: 2
paintCamera: {fileID: 0}
updateColliderEveryFrame: 0
brushShader: {fileID: 0}
@@ -869,7 +1134,7 @@ GameObject:
- component: {fileID: 1141039642}
m_Layer: 0
m_Name: Player
m_TagString: Untagged
m_TagString: Player
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
@@ -888,6 +1153,7 @@ Transform:
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1944972203}
- {fileID: 1774267802}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!143 &1141039641
@@ -939,6 +1205,37 @@ MonoBehaviour:
slideAcceleration: 20
maxSlideSpeed: 8
slideControl: 0.3
--- !u!1 &1774267801
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1774267802}
m_Layer: 0
m_Name: CameraTarget
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &1774267802
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1774267801}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 1, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1141039639}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!4 &1944972203 stripped
Transform:
m_CorrespondingSourceObject: {fileID: -8679921383154817045, guid: 554c0bdf86263754d862d7effd04045b, type: 3}
@@ -1004,3 +1301,4 @@ SceneRoots:
- {fileID: 721670973}
- {fileID: 963088882}
- {fileID: 200502122}
- {fileID: 172071436}
+22 -2
View File
@@ -89,6 +89,7 @@ public class MeshPainter : MonoBehaviour
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 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 int _dx0, _dy0, _dx1, _dy1; // dirty rectangle (CPU upload)
// --- GPU backend ---
@@ -480,8 +481,27 @@ public class MeshPainter : MonoBehaviour
private void PaintAtScreen(Vector2 screenPos)
{
Ray ray = paintCamera.ScreenPointToRay(screenPos);
if (!(Physics.Raycast(ray, out RaycastHit hit, 1000f) && hit.collider == _collider))
return;
// 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.
int hitCount = Physics.RaycastNonAlloc(ray, _rayHits, 1000f, ~0, QueryTriggerInteraction.Ignore);
RaycastHit hit = default;
float nearest = float.MaxValue;
bool found = false;
for (int i = 0; i < hitCount; i++)
{
if (_rayHits[i].collider != _collider) continue;
if (_rayHits[i].distance < nearest)
{
nearest = _rayHits[i].distance;
hit = _rayHits[i];
found = true;
}
}
if (!found) return;
if (_verts == null || hit.triangleIndex < 0) return;
// Bring the hit point into local mesh space (= collider/vertex space).
+186
View File
@@ -0,0 +1,186 @@
using UnityEngine;
using Unity.Cinemachine;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/// <summary>
/// Feeds mouse input into a <see cref="CinemachineOrbitalFollow"/> so the camera
/// orbits around the follow target like a classic third-person camera.
///
/// Rotation is always on by default and the cursor is locked/hidden while looking.
/// Pressing the toggle key (Tab) unlocks the cursor and disables camera rotation,
/// so you can freely use the mouse (e.g. paint the mesh with the left button or
/// click UI). Pressing it again re-locks the cursor and re-enables rotation.
///
/// 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).
/// </summary>
[RequireComponent(typeof(CinemachineOrbitalFollow))]
[DisallowMultipleComponent]
public class ThirdPersonOrbitInput : MonoBehaviour
{
[Header("Rotation")]
[Tooltip("Horizontal look sensitivity in degrees of yaw per pixel of mouse movement.")]
public float horizontalSensitivity = 0.2f;
[Tooltip("Vertical look sensitivity in degrees of pitch per pixel of mouse movement.")]
public float verticalSensitivity = 0.15f;
[Tooltip("If ON, moving the mouse up tilts the camera down (inverted pitch).")]
public bool invertY = false;
[Header("Toggle")]
[Tooltip("Key that toggles between look mode (cursor locked, camera rotates) and " +
"free mode (cursor unlocked, camera rotation disabled).")]
#if ENABLE_INPUT_SYSTEM
public Key toggleKey = Key.Tab;
#else
public KeyCode toggleKey = KeyCode.Tab;
#endif
[Tooltip("If ON, the camera starts in look mode (rotation enabled, cursor locked).")]
public bool startInLookMode = true;
[Header("Zoom")]
[Tooltip("If ON, the mouse wheel zooms the camera in and out (changes orbit radius).")]
public bool enableZoom = true;
[Tooltip("How much the orbit radius changes per mouse wheel notch.")]
public float zoomSpeed = 0.5f;
[Tooltip("Closest the camera can zoom in (orbit radius in units).")]
public float minRadius = 1.5f;
[Tooltip("Farthest the camera can zoom out (orbit radius in units).")]
public float maxRadius = 8f;
[Header("Cursor")]
[Tooltip("If ON, the cursor is locked and hidden while in look mode (rotation " +
"enabled), and freed in free mode so you can use the mouse normally.")]
public bool lockCursorInLookMode = true;
CinemachineOrbitalFollow _orbit;
// True = look mode (cursor locked, camera rotates). False = free mode.
bool _rotationEnabled = true;
void Awake()
{
_orbit = GetComponent<CinemachineOrbitalFollow>();
if (_orbit == null)
Debug.LogError("[ThirdPersonOrbitInput] No CinemachineOrbitalFollow found on this GameObject.", this);
}
void OnEnable()
{
_rotationEnabled = startInLookMode;
ApplyCursorState();
}
void OnDisable()
{
// Make sure we never leave the cursor locked/hidden when this is turned off.
SetCursorLocked(false);
}
void Update()
{
if (_orbit == null)
return;
// Tab toggles between look mode and free mode.
if (WasTogglePressed())
{
_rotationEnabled = !_rotationEnabled;
ApplyCursorState();
}
if (_rotationEnabled)
{
Vector2 delta = ReadMouseDelta();
if (delta.sqrMagnitude > 0f)
{
ApplyAxisDelta(ref _orbit.HorizontalAxis, delta.x * horizontalSensitivity);
float pitch = delta.y * verticalSensitivity * (invertY ? 1f : -1f);
ApplyAxisDelta(ref _orbit.VerticalAxis, pitch);
}
}
if (enableZoom)
{
float scroll = ReadScroll();
if (!Mathf.Approximately(scroll, 0f))
_orbit.Radius = Mathf.Clamp(_orbit.Radius - scroll * zoomSpeed, minRadius, maxRadius);
}
}
void ApplyCursorState()
{
if (lockCursorInLookMode)
SetCursorLocked(_rotationEnabled);
}
/// <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>
static void ApplyAxisDelta(ref InputAxis axis, float amount)
{
float value = axis.Value + amount;
float min = axis.Range.x;
float max = axis.Range.y;
if (axis.Wrap && max > min)
{
float span = max - min;
value = ((value - min) % span + span) % span + min;
}
else
{
value = Mathf.Clamp(value, min, max);
}
axis.Value = value;
}
static void SetCursorLocked(bool locked)
{
Cursor.lockState = locked ? CursorLockMode.Locked : CursorLockMode.None;
Cursor.visible = !locked;
}
bool WasTogglePressed()
{
#if ENABLE_INPUT_SYSTEM
return Keyboard.current != null && Keyboard.current[toggleKey].wasPressedThisFrame;
#else
return Input.GetKeyDown(toggleKey);
#endif
}
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;
#endif
}
float ReadScroll()
{
#if ENABLE_INPUT_SYSTEM
// Scroll y is reported in steps of ~120 per notch; normalize to ~1 per notch.
return Mouse.current != null ? Mouse.current.scroll.ReadValue().y / 120f : 0f;
#else
return Input.GetAxis("Mouse ScrollWheel") * 10f;
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: df129be8fadab79498bd429a59bb98e4
+1
View File
@@ -1,6 +1,7 @@
{
"dependencies": {
"com.coplaydev.unity-mcp": "https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#main",
"com.unity.cinemachine": "3.1.7",
"com.unity.ide.visualstudio": "2.0.27",
"com.unity.inputsystem": "1.19.0",
"com.unity.multiplayer.center": "1.0.1",
+28
View File
@@ -27,6 +27,16 @@
},
"url": "https://packages.unity.com"
},
"com.unity.cinemachine": {
"version": "3.1.7",
"depth": 0,
"source": "registry",
"dependencies": {
"com.unity.splines": "2.0.0",
"com.unity.modules.imgui": "1.0.0"
},
"url": "https://packages.unity.com"
},
"com.unity.collections": {
"version": "2.6.2",
"depth": 2,
@@ -132,6 +142,13 @@
"dependencies": {},
"url": "https://packages.unity.com"
},
"com.unity.settings-manager": {
"version": "2.1.1",
"depth": 2,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.com"
},
"com.unity.shadergraph": {
"version": "17.3.0",
"depth": 1,
@@ -141,6 +158,17 @@
"com.unity.searcher": "4.9.3"
}
},
"com.unity.splines": {
"version": "2.8.3",
"depth": 1,
"source": "registry",
"dependencies": {
"com.unity.mathematics": "1.2.1",
"com.unity.modules.imgui": "1.0.0",
"com.unity.settings-manager": "1.0.3"
},
"url": "https://packages.unity.com"
},
"com.unity.test-framework": {
"version": "1.6.0",
"depth": 1,