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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Fable 5 committed 2026-07-11 18:09:14 +02:00
1 parent 9decea3501
commit ae5fa63141
10 files changed
+465

No files matched your search

@@ -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 ---
/// <summary>True while the brush preview decal is actually drawn this frame (hover hits the paintable surface).</summary>
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);
@@ -0,0 +1,98 @@
using UnityEngine;
/// <summary>
/// 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.
/// </summary>
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<GameModeManager>();
}
// 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;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: aa11882355996204c8d14bb57c6a1838
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5e6c24c7713f1fa4c9294993fc8cf72f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.

After

Width:  |  Height:  |  Size: 237 B

@@ -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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: ce31367b5800e9841a6a226c44bbe684
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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: 74a0bb51745955c4ea6d1e59c32b2a1a
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: