From 188f16a4e8afed76438e2ae6b3cc56e55ce73042 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Mon, 29 Jun 2026 16:22:45 +0200 Subject: [PATCH] Make the eyedropper a hybrid material/screen pick On a foreign object the pipette now reads the data channels (metallic/smoothness/occlusion/emission) texel-exact from the material while taking albedo from the rendered, fully-lit on-screen pixel - so the picked colour honours baked lightmaps/ambient/SSAO while keeping the material's PBR fidelity. Falls back to a pure screen pick when no readable UV is available. Pull the render+readback out of PickFromScreen into a shared TrySampleScreenColor. The pipette now also arms on Ctrl (in addition to Shift), and Ctrl+wheel resizes the brush; the orbit camera ignores the wheel while either modifier is held so it does not zoom and resize at once. Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/MeshPainter.Pick.cs | 84 +++++++++++++++++-------- Assets/Scripts/MeshPainter.cs | 56 +++++++++++------ Assets/Scripts/ThirdPersonOrbitInput.cs | 19 +++++- 3 files changed, 110 insertions(+), 49 deletions(-) diff --git a/Assets/Scripts/MeshPainter.Pick.cs b/Assets/Scripts/MeshPainter.Pick.cs index d8372b6..c963f17 100644 --- a/Assets/Scripts/MeshPainter.Pick.cs +++ b/Assets/Scripts/MeshPainter.Pick.cs @@ -5,27 +5,23 @@ public partial class MeshPainter { // --- Pipette (eyedropper) --- // - // A sampling MODE rather than a paint channel: while active, a left-click raycasts onto - // ANY surface (not just the paint mesh) and reads every channel value at the hit point - // (albedo, metallic, smoothness, occlusion, emission) into the brush properties - with - // every opacity/alpha forced to 1 so re-painting reproduces the picked look at full - // strength. + // A sampling MODE rather than a paint channel: while active (Shift or Ctrl held, or the HUD toggle), a + // left-click raycasts onto ANY surface (not just the paint mesh) and reads every channel value at + // the hit point (albedo, metallic, smoothness, occlusion, emission) into the brush properties - + // with every opacity/alpha forced to 1 so re-painting reproduces the picked look at full strength. // - // Two read paths, chosen by what was hit: - // - Our own paint mesh: read texel-exact from the painted maps via ReadCanonical(), - // which converts both backends (GPU RenderTextures / CPU Texture2D mirrors) into the - // same canonical layout, so the pick matches what is stored bit-for-bit. - // - Any foreign object: read texel-exact from its material maps at the hit UV - // (BaseMap/MetallicGloss/Occlusion/Emission) combined with the material scalars, per - // the URP/Lit conventions. The UV comes from hit.textureCoord (non-convex MeshCollider) - // or, when the collider can't supply one (convex MeshCollider / primitive collider), is - // recovered from the renderer's mesh; it falls back to scalars where a map is absent and - // to the on-screen pixel where a base map exists but no UV can be resolved. + // On a FOREIGN object the pick is a HYBRID: the data channels (metallic/smoothness/occlusion/ + // emission) come texel-exact from the material (raw, UNLIT), while albedo is taken from the + // rendered, fully-lit on-screen pixel ("what you see" - honours baked lightmaps/ambient/SSAO). + // This combines the PBR fidelity of the material read with the lit look of the screen. When no + // texel-exact material read is possible (no readable UV / headless) it falls back to a pure screen + // pick (lit albedo, data channels at defaults). Our own paint mesh and terrain are read texel-exact. [Header("Brush - pipette (eyedropper)")] [Tooltip("Pipette mode: a click samples ALL channel values at the hit point on ANY surface " + "(albedo, metallic, smoothness, occlusion, emission) and adopts them into the brush " + - "(all alphas set to 1) instead of painting. Held button picks only once.")] + "(all alphas set to 1) instead of painting. On a foreign object the data channels are read " + + "texel-exact from the material while albedo comes from the lit on-screen pixel. Held button picks once.")] public bool pipette = false; /// @@ -48,7 +44,8 @@ public partial class MeshPainter /// - our paint mesh -> texel-exact from the painted maps (full PBR); /// - terrain ground -> blend of the terrain layers by their splat weights (full PBR); /// - terrain tree / detail -> the rendered on-screen colour (no per-instance renderer/UV); - /// - readable foreign mat -> texel/scalar from its material (full PBR); + /// - any foreign object -> hybrid: data channels texel-exact from the material, albedo from + /// the rendered, fully-lit on-screen pixel (baked lightmaps/ambient/SSAO); /// - anything else / sky -> the rendered on-screen colour. /// private void PickAtScreen(Vector2 screenPos) @@ -64,8 +61,7 @@ public partial class MeshPainter if (terrain) ok = PickFromTerrain(terrain, hit, screenPos); else - // Foreign object: texel/scalar material where readable, else the screen pixel. - ok = PickFromForeignMaterial(hit) || PickFromScreen(screenPos); + ok = PickFromForeignHybrid(hit, screenPos); } } else @@ -141,6 +137,29 @@ public partial class MeshPainter return true; } + /// + /// Hybrid foreign-object pick: the data channels (metallic/smoothness/occlusion/emission) come + /// texel-exact from the material via (raw, unlit), while + /// albedo is overwritten with the rendered, fully-lit on-screen pixel ("what you see" - honours + /// baked lightmaps/ambient/SSAO). When no texel-exact material read is possible (no readable UV / + /// headless) it falls back to a pure screen pick (lit albedo, data channels at defaults). + /// + private bool PickFromForeignHybrid(RaycastHit hit, Vector2 screenPos) + { + if (PickFromForeignMaterial(hit)) + { + // Keep the material's texel-exact PBR; replace only albedo with the lit on-screen colour. + // No camera (headless) -> keep the material's raw albedo rather than failing the pick. + if (TrySampleScreenColor(screenPos, out Color lit)) + brushColor = new Color(Mathf.Clamp01(lit.r), Mathf.Clamp01(lit.g), Mathf.Clamp01(lit.b), 1f); + return true; + } + + // No texel-exact material read possible (no renderer/UV, or non-readable mesh behind a convex + // collider): fall back to the lit screen pixel alone (data channels reset to defaults). + return PickFromScreen(screenPos); + } + /// /// Picks from a foreign object's material at the hit UV: texel-exact texture samples /// (where a UV at the hit can be resolved and the map exists) combined with the material @@ -329,6 +348,25 @@ public partial class MeshPainter /// private bool PickFromScreen(Vector2 screenPos) { + if (!TrySampleScreenColor(screenPos, out Color c)) return false; + + brushColor = new Color(Mathf.Clamp01(c.r), Mathf.Clamp01(c.g), Mathf.Clamp01(c.b), 1f); + metallic = 0f; + smoothness = 0f; + occlusion = 1f; + emissionColor = new Color(0f, 0f, 0f, 1f); + return true; + } + + /// + /// Reads the rendered pixel under the cursor (the paint camera re-rendered to a temporary target, + /// so it works on either render pipeline) without touching any brush channel. Returns the fully-lit + /// on-screen colour - what is visible, including baked lightmaps/ambient/SSAO. False when no camera + /// is bound (headless). Shared by and the hybrid albedo override. + /// + private bool TrySampleScreenColor(Vector2 screenPos, out Color color) + { + color = default; Camera cam = paintCamera; if (!cam) return false; int w = Mathf.Max(1, cam.pixelWidth), h = Mathf.Max(1, cam.pixelHeight); @@ -345,18 +383,12 @@ public partial class MeshPainter int py = Mathf.Clamp(Mathf.RoundToInt(screenPos.y), 0, h - 1); tmp.ReadPixels(new Rect(px, py, 1, 1), 0, 0, false); tmp.Apply(false); - Color c = tmp.GetPixel(0, 0); + color = tmp.GetPixel(0, 0); cam.targetTexture = prevTarget; RenderTexture.active = prevActive; RenderTexture.ReleaseTemporary(rt); if (Application.isPlaying) Destroy(tmp); else DestroyImmediate(tmp); - - brushColor = new Color(Mathf.Clamp01(c.r), Mathf.Clamp01(c.g), Mathf.Clamp01(c.b), 1f); - metallic = 0f; - smoothness = 0f; - occlusion = 1f; - emissionColor = new Color(0f, 0f, 0f, 1f); return true; } diff --git a/Assets/Scripts/MeshPainter.cs b/Assets/Scripts/MeshPainter.cs index 0038e8c..8b674a5 100644 --- a/Assets/Scripts/MeshPainter.cs +++ b/Assets/Scripts/MeshPainter.cs @@ -622,8 +622,8 @@ public partial class MeshPainter : MonoBehaviour return; } - // Shift modifiers: hold Shift for the pipette + Shift+wheel to resize the brush. Runs before - // the pipette/paint branches below so the same frame's pipette state reflects the held Shift. + // Modifier keys: Shift or Ctrl = pipette, Shift/Ctrl + wheel resizes the brush. Runs before the + // pipette/paint branches below so this frame's pipette state is current. HandleBrushModifiers(); if (updateColliderEveryFrame && _skinned) @@ -644,13 +644,13 @@ public partial class MeshPainter : MonoBehaviour // Brush preview (size + mask) at the hover point - independent of painting AND of the // pipette, so you always see the brush footprint: before pressing the button, while the - // momentary pipette holds Shift, and live as Shift+wheel resizes the brush. + // momentary pipette holds Shift/Ctrl, and live as Shift/Ctrl+wheel resizes the brush. if (previewMode != BrushPreviewMode.Off && _previewMat && paintCamera) UpdatePreview(screenPos); - // Pipette (eyedropper): a fresh click samples ALL channel values at the hit point - // and adopts them into the brush instead of painting. It takes over the click - // entirely (no painting), but the brush preview above stays visible. + // Pipette (eyedropper): a fresh click samples ALL channel values at the hit point and adopts + // them into the brush instead of painting. It takes over the click entirely (no painting), + // but the brush preview above stays visible. if (pipette) { EndStrokeIfActive(); // switching to the eyedropper ends any active stroke @@ -881,28 +881,31 @@ public partial class MeshPainter : MonoBehaviour #endif } - // --- Shift modifiers (paint mode) --- + // --- Shift/Ctrl modifiers (paint mode) --- - private bool _shiftHeld; // edge tracking for the momentary pipette - private bool _pipetteBeforeShift; // pipette toggle state to restore when Shift is released + private bool _pipetteModHeld; // edge tracking for the momentary pipette (Shift or Ctrl) + private bool _pipetteBeforeMod; // pipette toggle state to restore when the modifier is released /// - /// While Shift is held the brush enters pipette (eyedropper) mode and the mouse wheel resizes the - /// brush (the orbit camera ignores the wheel while Shift is held, so it does not also zoom). - /// Releasing Shift restores the pipette toggle's prior state. Called every paint-mode frame. + /// While Shift or Ctrl is held the brush enters the pipette (eyedropper), restoring the toggle's + /// prior state on release. Shift or Ctrl + the mouse wheel resizes the brush (the orbit camera + /// ignores the wheel while either is held, so it does not also zoom). Called every paint-mode frame. /// private void HandleBrushModifiers() { + // Momentary pipette: arm on the Shift/Ctrl press (remembering the toggle's state), force it on + // while held (re-arms after a one-shot pick), and restore the prior state on release. bool shift = ShiftHeld(); + bool ctrl = CtrlHeld(); + bool mod = shift || ctrl; + if (mod && !_pipetteModHeld) _pipetteBeforeMod = pipette; + if (mod) pipette = true; + else if (_pipetteModHeld) pipette = _pipetteBeforeMod; + _pipetteModHeld = mod; - // Momentary pipette: arm on the Shift press (remembering the toggle's state), force it on while - // held (re-arms after a one-shot pick), and restore the prior state on release. - if (shift && !_shiftHeld) _pipetteBeforeShift = pipette; - if (shift) pipette = true; - else if (_shiftHeld) pipette = _pipetteBeforeShift; - _shiftHeld = shift; - - if (!shift) return; + // Shift or Ctrl + wheel resizes the brush (the orbit camera ignores the wheel while either is + // held, so it does not also zoom). + if (!mod) return; float scroll = ReadScroll(); if (Mathf.Approximately(scroll, 0f)) return; @@ -928,6 +931,19 @@ public partial class MeshPainter : MonoBehaviour #endif } + private static bool CtrlHeld() + { +#if ENABLE_INPUT_SYSTEM + var kb = Keyboard.current; + if (kb != null) return kb.leftCtrlKey.isPressed || kb.rightCtrlKey.isPressed; +#endif +#if ENABLE_LEGACY_INPUT_MANAGER + return Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl); +#else + return false; +#endif + } + private static float ReadScroll() { #if ENABLE_INPUT_SYSTEM diff --git a/Assets/Scripts/ThirdPersonOrbitInput.cs b/Assets/Scripts/ThirdPersonOrbitInput.cs index 148dd8e..774baec 100644 --- a/Assets/Scripts/ThirdPersonOrbitInput.cs +++ b/Assets/Scripts/ThirdPersonOrbitInput.cs @@ -191,9 +191,9 @@ public class ThirdPersonOrbitInput : MonoBehaviour float scale = CurrentScale(); - // Shift+wheel is reserved for the paint brush resize (MeshPainter), so the orbit camera - // ignores the wheel while Shift is held - otherwise it would zoom and resize at once. - float scroll = ShiftHeld() ? 0f : ReadScroll(); + // Shift/Ctrl+wheel is reserved for the paint brush resize (MeshPainter), so the orbit camera + // ignores the wheel while either is held - otherwise it would zoom and resize at once. + float scroll = (ShiftHeld() || CtrlHeld()) ? 0f : ReadScroll(); if (!Mathf.Approximately(scroll, 0f)) { // One wheel notch = one zoom step (sign only), independent of the platform's raw wheel @@ -280,6 +280,19 @@ public class ThirdPersonOrbitInput : MonoBehaviour #endif } + static bool CtrlHeld() + { +#if ENABLE_INPUT_SYSTEM + var kb = Keyboard.current; + if (kb != null) return kb.leftCtrlKey.isPressed || kb.rightCtrlKey.isPressed; +#endif +#if ENABLE_LEGACY_INPUT_MANAGER + return Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl); +#else + return false; +#endif + } + Vector2 ReadMouseDelta() { #if ENABLE_INPUT_SYSTEM