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