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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-29 16:22:45 +02:00
1 parent db10eef525
commit 188f16a4e8
3 files changed
+110 -49

No files matched your search

+58 -26
View File
@@ -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;
/// <summary>
@@ -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.
/// </summary>
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;
}
/// <summary>
/// Hybrid foreign-object pick: the data channels (metallic/smoothness/occlusion/emission) come
/// texel-exact from the material via <see cref="PickFromForeignMaterial"/> (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).
/// </summary>
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);
}
/// <summary>
/// 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
/// </summary>
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;
}
/// <summary>
/// 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 <see cref="PickFromScreen"/> and the hybrid albedo override.
/// </summary>
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;
}