From ae5fa631415b3120082019e36403d0222cb906bc Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Sat, 11 Jul 2026 18:09:14 +0200 Subject: [PATCH] Custom mouse cursor: normal/paint/pipette with recorder-aware software mode - CursorController: polls painter state in LateUpdate; paint cursor only while the brush preview decal actually draws (MeshPainter.BrushPreviewVisible), pipette while held/toggled, fallback to normal over UI (InputUtil.PointerOverUi) - Auto ForceSoftware while Unity Recorder is recording (reflection into editor-only assembly, 0.5s poll, editor-only) so the cursor is captured; back to hardware cursor when recording stops - 3 placeholder cursor PNGs (32x32, cursor import type) with per-cursor hotspots Co-Authored-By: Claude Fable 5 --- .../Components/MeshPainter/MeshPainter.cs | 6 + .../Scripts/Components/UI/CursorController.cs | 98 +++++++++++++++ .../Components/UI/CursorController.cs.meta | 2 + Assets/Textures/Cursors.meta | 8 ++ Assets/Textures/Cursors/Cursor_Normal.png | Bin 0 -> 237 bytes .../Textures/Cursors/Cursor_Normal.png.meta | 117 ++++++++++++++++++ Assets/Textures/Cursors/Cursor_Paint.png | Bin 0 -> 245 bytes Assets/Textures/Cursors/Cursor_Paint.png.meta | 117 ++++++++++++++++++ Assets/Textures/Cursors/Cursor_Pipette.png | Bin 0 -> 233 bytes .../Textures/Cursors/Cursor_Pipette.png.meta | 117 ++++++++++++++++++ 10 files changed, 465 insertions(+) create mode 100644 Assets/Scripts/Components/UI/CursorController.cs create mode 100644 Assets/Scripts/Components/UI/CursorController.cs.meta create mode 100644 Assets/Textures/Cursors.meta create mode 100644 Assets/Textures/Cursors/Cursor_Normal.png create mode 100644 Assets/Textures/Cursors/Cursor_Normal.png.meta create mode 100644 Assets/Textures/Cursors/Cursor_Paint.png create mode 100644 Assets/Textures/Cursors/Cursor_Paint.png.meta create mode 100644 Assets/Textures/Cursors/Cursor_Pipette.png create mode 100644 Assets/Textures/Cursors/Cursor_Pipette.png.meta diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.cs index 6f138e3..eeed0b1 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.cs @@ -422,6 +422,8 @@ public partial class MeshPainter : MonoBehaviour private bool _rayHitsSaturated; // log-once: a saturated buffer can silently drop our own collider // --- preview overlay --- + /// True while the brush preview decal is actually drawn this frame (hover hits the paintable surface). + public bool BrushPreviewVisible { get; private set; } public Shader previewShader; // optional; else Shader.Find("Hidden/MeshPaintPreview") private Material _previewMat; private Mesh _previewQuad; // unit quad in the tangent plane (flat decal mode) @@ -785,6 +787,7 @@ public partial class MeshPainter : MonoBehaviour // the same local space as the raycast hit and valid for skinned (baked) and static meshes. Mesh previewMesh = _collider ? _collider.sharedMesh : null; if (!previewMesh) return; + BrushPreviewVisible = true; Matrix4x4 m = transform.localToWorldMatrix; int sub = previewMesh.subMeshCount; for (int i = 0; i < sub; i++) @@ -804,6 +807,7 @@ public partial class MeshPainter : MonoBehaviour frame.SetColumn(3, new Vector4(pos.x, pos.y, pos.z, 1f)); Graphics.DrawMesh(_previewQuad, transform.localToWorldMatrix * frame, _previewMat, layer, paintCamera, 0); + BrushPreviewVisible = true; } } @@ -811,6 +815,8 @@ public partial class MeshPainter : MonoBehaviour private void Update() { + BrushPreviewVisible = false; // set again below only when the decal really draws this frame + if (!_setupValid) return; if (useGpu != _gpuActive) SwitchBackend(useGpu); diff --git a/Assets/Scripts/Components/UI/CursorController.cs b/Assets/Scripts/Components/UI/CursorController.cs new file mode 100644 index 0000000..ff72572 --- /dev/null +++ b/Assets/Scripts/Components/UI/CursorController.cs @@ -0,0 +1,98 @@ +using UnityEngine; + +/// +/// Swaps the hardware mouse cursor with the paint state of the local painter: normal outside +/// paint mode, brush while painting, eyedropper while the pipette is active. Lives once in the +/// Main scene; remote painters never have PaintingEnabled, so polling the manager's local +/// painter is unambiguous. While a camera rig locks/hides the cursor this is simply invisible. +/// +public class CursorController : MonoBehaviour +{ + [Tooltip("Source of the local painter. Found automatically when left empty.")] + [SerializeField] private GameModeManager modeManager; + + [Header("Cursors (import type: Cursor)")] + [SerializeField] private Texture2D normalCursor; + [Tooltip("Click point inside the texture, in pixels from the top-left.")] + [SerializeField] private Vector2 normalHotspot = Vector2.zero; + [SerializeField] private Texture2D paintCursor; + [SerializeField] private Vector2 paintHotspot = new(16f, 16f); + [SerializeField] private Texture2D pipetteCursor; + [SerializeField] private Vector2 pipetteHotspot = new(1f, 30f); + + private enum State { None, Normal, Paint, Pipette } + + private State _applied = State.None; + private CursorMode _appliedMode = CursorMode.Auto; + +#if UNITY_EDITOR + // Hardware cursors are composited by the OS and never end up in Unity Recorder captures. + // While a recording runs we force the software cursor (rendered by Unity -> captured). + // RecorderWindow lives in the editor-only Unity.Recorder.Editor assembly, hence reflection. + private static readonly System.Type RecorderWindowType = + System.Type.GetType("UnityEditor.Recorder.RecorderWindow, Unity.Recorder.Editor"); + private static readonly System.Reflection.MethodInfo RecorderIsRecording = + RecorderWindowType?.GetMethod("IsRecording"); + private Object _recorderWindow; + private float _nextRecorderPoll; + private bool _recorderActive; + + private bool RecorderActive() + { + if (RecorderIsRecording == null) return false; + if (Time.unscaledTime < _nextRecorderPoll) return _recorderActive; + _nextRecorderPoll = Time.unscaledTime + 0.5f; + if (!_recorderWindow) + { + var windows = Resources.FindObjectsOfTypeAll(RecorderWindowType); + _recorderWindow = windows.Length > 0 ? windows[0] : null; + } + _recorderActive = _recorderWindow && (bool)RecorderIsRecording.Invoke(_recorderWindow, null); + return _recorderActive; + } +#endif + + private void Awake() + { + if (!modeManager) modeManager = FindFirstObjectByType(); + } + + // LateUpdate: MeshPainter.Update settles this frame's pipette state (Shift/Ctrl momentary + // pipette) first, so the cursor never lags a frame behind the modifier. + private void LateUpdate() + { + var painter = modeManager ? modeManager.Painter : null; + State want = State.Normal; + // Over UI neither the pipette nor the brush can act -> normal arrow. + if (painter && painter.PaintingEnabled && !InputUtil.PointerOverUi()) + { + if (painter.pipette) want = State.Pipette; + // Paint cursor only while painting is actually possible, i.e. the brush preview + // decal is drawn at the hover point this frame; hovering off the mesh -> normal. + else if (painter.BrushPreviewVisible) want = State.Paint; + } + + var mode = CursorMode.Auto; +#if UNITY_EDITOR + if (RecorderActive()) mode = CursorMode.ForceSoftware; +#endif + + if (want == _applied && mode == _appliedMode) return; + _applied = want; + _appliedMode = mode; + + switch (want) + { + case State.Paint: Cursor.SetCursor(paintCursor, paintHotspot, mode); break; + case State.Pipette: Cursor.SetCursor(pipetteCursor, pipetteHotspot, mode); break; + default: Cursor.SetCursor(normalCursor, normalHotspot, mode); break; + } + } + + private void OnDisable() + { + Cursor.SetCursor(null, Vector2.zero, CursorMode.Auto); // back to the OS cursor + _applied = State.None; + _appliedMode = CursorMode.Auto; + } +} diff --git a/Assets/Scripts/Components/UI/CursorController.cs.meta b/Assets/Scripts/Components/UI/CursorController.cs.meta new file mode 100644 index 0000000..d6e3b06 --- /dev/null +++ b/Assets/Scripts/Components/UI/CursorController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: aa11882355996204c8d14bb57c6a1838 \ No newline at end of file diff --git a/Assets/Textures/Cursors.meta b/Assets/Textures/Cursors.meta new file mode 100644 index 0000000..746e8dc --- /dev/null +++ b/Assets/Textures/Cursors.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5e6c24c7713f1fa4c9294993fc8cf72f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Textures/Cursors/Cursor_Normal.png b/Assets/Textures/Cursors/Cursor_Normal.png new file mode 100644 index 0000000000000000000000000000000000000000..9ccc10e60551614160226c189bac613b54156555 GIT binary patch literal 237 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJEuJopAr*7pPQK08q9EX$`S}0; zBJ=D+$5a$g#p2^`b%XV!p2ngqDSW>{Ttu=$=TbC?{>;1-0 zZ5=b%*7MvGm}6Ke$C=(6=YHraTQwn!^Eo{LsYeJs-LB&TdKjJ>`;@-niE-to5ue`B*md<|> lonKRHQ%-TzepylRij~j6Q!HG7B?;($22WQ%mvv4FO#nK8U+w?^ literal 0 HcmV?d00001 diff --git a/Assets/Textures/Cursors/Cursor_Normal.png.meta b/Assets/Textures/Cursors/Cursor_Normal.png.meta new file mode 100644 index 0000000..bd0d528 --- /dev/null +++ b/Assets/Textures/Cursors/Cursor_Normal.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 1c39e79aab8ef894ebdf1c1bc6d1df3b +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 1 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 0 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 7 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Textures/Cursors/Cursor_Paint.png b/Assets/Textures/Cursors/Cursor_Paint.png new file mode 100644 index 0000000000000000000000000000000000000000..68758f0af3b4c5bb26ddf11038bb636ddec139eb GIT binary patch literal 245 zcmVd zTgC$rcmODwLrAsEp%wxf^OkW7WCkKfo7Z!Hwu~A8v-to*2XKe7@Q>c?J5^wG1!%aS z!T@+?R2Au050R+7U24n!(U&oS_wBImOBHvH|VKC<2{%LfCobJ v7-1Bv3VQo