Fix cylinder wall-climb strafe lock and add terrain pipette support
- PlayerCharacterController: latch the wall-strafe sign once at strafe start instead of re-deriving it from the camera every frame, which flipped at the 90-degree point while circling a cylinder and caused a back-and-forth lock. Flat walls stay bit-identical; reset on enter/exit. - MeshPainter.Pick: dispatch terrain hits to a splat-layer blend (full PBR channels), terrain trees / grass / sky to rendered screen-color, keeping texel-accurate picks for the paint mesh and foreign materials. - Includes Unity auto-reserialization of SampleScene and three Supercyan pack assets (URP material upgrades, skybox/lighting) from the import. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
85bd7c7791
commit
9dab4a742e
6 files changed
+1153
-27
No files matched your search
+949
-12
File diff suppressed because it is too large.
Load diff
@@ -34,34 +34,66 @@ public partial class MeshPainter
|
|||||||
|
|
||||||
private bool _pickHeld; // edge tracking so a held button picks once, not every frame
|
private bool _pickHeld; // edge tracking so a held button picks once, not every frame
|
||||||
|
|
||||||
|
// A terrain hit higher than this above the heightfield is a tree (trees share the terrain
|
||||||
|
// collider but stand proud of the ground); on the surface it is bare ground.
|
||||||
|
private const float TerrainFoliageHeightEpsilon = 0.3f;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raycasts at a screen position against ANY collider, samples every channel value at the hit
|
/// Raycasts at a screen position against ANY collider, samples every channel value at the hit
|
||||||
/// (texel-exact where possible) and copies it into the brush, forcing all opacities/alphas to
|
/// (texel-exact where possible) and copies it into the brush, forcing all opacities/alphas to
|
||||||
/// 1 and enabling every paint channel. Reads regardless of which channels are currently toggled.
|
/// 1 and enabling every paint channel. Reads regardless of which channels are currently toggled.
|
||||||
|
/// Pick paths, by what was hit:
|
||||||
|
/// - 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);
|
||||||
|
/// - anything else / sky -> the rendered on-screen colour.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void PickAtScreen(Vector2 screenPos)
|
private void PickAtScreen(Vector2 screenPos)
|
||||||
{
|
{
|
||||||
if (!TryRaycastAny(screenPos, out RaycastHit hit)) return;
|
bool ok;
|
||||||
|
if (TryRaycastAny(screenPos, out RaycastHit hit))
|
||||||
bool ok = (hit.collider == _collider)
|
{
|
||||||
? PickFromPaintTextures(hit)
|
if (hit.collider == _collider)
|
||||||
: PickFromForeignMaterial(hit);
|
ok = PickFromPaintTextures(hit);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Terrain terrain = hit.collider is TerrainCollider ? hit.collider.GetComponent<Terrain>() : null;
|
||||||
|
if (terrain)
|
||||||
|
ok = PickFromTerrain(terrain, hit, screenPos);
|
||||||
|
else
|
||||||
|
// Foreign object: texel/scalar material where readable, else the screen pixel.
|
||||||
|
ok = PickFromForeignMaterial(hit) || PickFromScreen(screenPos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Ray left the scene entirely (e.g. foliage silhouetted against the sky): the
|
||||||
|
// on-screen pixel is the only thing to sample.
|
||||||
|
ok = PickFromScreen(screenPos);
|
||||||
|
}
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
// "Set all alpha values to 1": full-strength adoption across every opacity control.
|
FinalizePick();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Common tail of every successful pick: forces all opacities/alphas to 1, enables every paint
|
||||||
|
/// channel (a whole-material pick implies repainting all of it), leaves pipette mode (one-shot)
|
||||||
|
/// and notifies listeners (the in-game HUD) to re-sync their widgets.
|
||||||
|
/// </summary>
|
||||||
|
private void FinalizePick()
|
||||||
|
{
|
||||||
metallicSmoothnessOpacity = 1f;
|
metallicSmoothnessOpacity = 1f;
|
||||||
occlusionOpacity = 1f;
|
occlusionOpacity = 1f;
|
||||||
|
|
||||||
// Picking a whole material implies repainting all of it, so enable every channel.
|
|
||||||
paintAlbedo = true;
|
paintAlbedo = true;
|
||||||
paintMetallicSmoothness = true;
|
paintMetallicSmoothness = true;
|
||||||
paintOcclusion = true;
|
paintOcclusion = true;
|
||||||
paintEmission = true;
|
paintEmission = true;
|
||||||
|
|
||||||
// A pick is a one-shot action: leave pipette mode automatically so the next click paints.
|
|
||||||
pipette = false;
|
pipette = false;
|
||||||
|
|
||||||
// Let listeners (the in-game HUD) refresh their widgets to the adopted values + flags.
|
|
||||||
Picked?.Invoke();
|
Picked?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +200,147 @@ public partial class MeshPainter
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Picks at a terrain hit. The TerrainCollider also carries the painted TREES (which sit above
|
||||||
|
/// the heightfield and expose no per-instance renderer/UV here), and grass/mushroom DETAILS have
|
||||||
|
/// no collider at all:
|
||||||
|
/// - hit above the surface -> a tree: adopt the rendered on-screen colour;
|
||||||
|
/// - hit on a cell that has grass/mushroom detail -> adopt the on-screen colour;
|
||||||
|
/// - otherwise (bare ground) -> blend the terrain layers by their splat weights (full PBR).
|
||||||
|
/// </summary>
|
||||||
|
private bool PickFromTerrain(Terrain terrain, RaycastHit hit, Vector2 screenPos)
|
||||||
|
{
|
||||||
|
TerrainData td = terrain.terrainData;
|
||||||
|
if (!td) return false;
|
||||||
|
Vector3 tpos = terrain.transform.position;
|
||||||
|
Vector3 size = td.size;
|
||||||
|
if (size.x <= 1e-4f || size.z <= 1e-4f) return false;
|
||||||
|
|
||||||
|
// Trees share the terrain collider but stand proud of the heightfield.
|
||||||
|
float surfaceY = terrain.SampleHeight(hit.point) + tpos.y;
|
||||||
|
if (hit.point.y > surfaceY + TerrainFoliageHeightEpsilon)
|
||||||
|
return PickFromScreen(screenPos);
|
||||||
|
|
||||||
|
Vector3 local = hit.point - tpos;
|
||||||
|
float nx = Mathf.Clamp01(local.x / size.x);
|
||||||
|
float nz = Mathf.Clamp01(local.z / size.z);
|
||||||
|
|
||||||
|
// Grass/mushroom detail rendered on this ground cell -> what's visible there is the
|
||||||
|
// foliage (which has no collider of its own), so trust the screen pixel.
|
||||||
|
if (TerrainHasDetail(td, nx, nz))
|
||||||
|
return PickFromScreen(screenPos);
|
||||||
|
|
||||||
|
return PickFromTerrainLayers(td, local, nx, nz);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Blends the terrain's layers at a ground point by their splat (alphamap) weights: the weighted
|
||||||
|
/// diffuse colour becomes albedo and the weighted per-layer metallic/smoothness scalars feed
|
||||||
|
/// those channels; occlusion is 1 and emission black. Terrain has no Renderer/material, so this
|
||||||
|
/// is the only way to read its surface.
|
||||||
|
/// </summary>
|
||||||
|
private bool PickFromTerrainLayers(TerrainData td, Vector3 local, float nx, float nz)
|
||||||
|
{
|
||||||
|
int aw = td.alphamapWidth, ah = td.alphamapHeight;
|
||||||
|
int mapX = Mathf.Clamp(Mathf.FloorToInt(nx * aw), 0, aw - 1);
|
||||||
|
int mapZ = Mathf.Clamp(Mathf.FloorToInt(nz * ah), 0, ah - 1);
|
||||||
|
float[,,] weights = td.GetAlphamaps(mapX, mapZ, 1, 1);
|
||||||
|
|
||||||
|
TerrainLayer[] layers = td.terrainLayers;
|
||||||
|
int count = Mathf.Min(layers.Length, weights.GetLength(2));
|
||||||
|
Color albedo = Color.black;
|
||||||
|
float metallicVal = 0f, smoothVal = 0f, weightSum = 0f;
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
TerrainLayer L = layers[i];
|
||||||
|
if (L == null) continue;
|
||||||
|
float w = weights[0, 0, i];
|
||||||
|
if (w <= 0f) continue;
|
||||||
|
|
||||||
|
Color col = Color.gray;
|
||||||
|
if (L.diffuseTexture)
|
||||||
|
{
|
||||||
|
// Terrain tiles each layer across the world by tileSize (metres); sample that texel.
|
||||||
|
Vector2 uv = new Vector2(
|
||||||
|
(local.x - L.tileOffset.x) / Mathf.Max(1e-4f, L.tileSize.x),
|
||||||
|
(local.z - L.tileOffset.y) / Mathf.Max(1e-4f, L.tileSize.y));
|
||||||
|
col = SampleTexel(L.diffuseTexture, uv, true);
|
||||||
|
}
|
||||||
|
albedo += col * w;
|
||||||
|
metallicVal += L.metallic * w;
|
||||||
|
smoothVal += L.smoothness * w;
|
||||||
|
weightSum += w;
|
||||||
|
}
|
||||||
|
if (weightSum <= 1e-4f) return false;
|
||||||
|
float inv = 1f / weightSum; // alphamap weights normally sum to 1, but guard anyway
|
||||||
|
|
||||||
|
brushColor = new Color(Mathf.Clamp01(albedo.r * inv), Mathf.Clamp01(albedo.g * inv), Mathf.Clamp01(albedo.b * inv), 1f);
|
||||||
|
metallic = Mathf.Clamp01(metallicVal * inv);
|
||||||
|
smoothness = Mathf.Clamp01(smoothVal * inv);
|
||||||
|
occlusion = 1f;
|
||||||
|
emissionColor = new Color(0f, 0f, 0f, 1f);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when any grass/mushroom detail layer has coverage at the given normalized terrain
|
||||||
|
/// position - i.e. foliage (which carries no collider) is rendered on that ground cell.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TerrainHasDetail(TerrainData td, float nx, float nz)
|
||||||
|
{
|
||||||
|
int dw = td.detailWidth, dh = td.detailHeight;
|
||||||
|
if (dw <= 0 || dh <= 0) return false;
|
||||||
|
int dx = Mathf.Clamp(Mathf.FloorToInt(nx * dw), 0, dw - 1);
|
||||||
|
int dy = Mathf.Clamp(Mathf.FloorToInt(nz * dh), 0, dh - 1);
|
||||||
|
int layerCount = td.detailPrototypes.Length;
|
||||||
|
for (int i = 0; i < layerCount; i++)
|
||||||
|
{
|
||||||
|
int[,] d = td.GetDetailLayer(dx, dy, 1, 1, i);
|
||||||
|
if (d[0, 0] > 0) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fallback colour pick: reads the rendered pixel under the cursor (the paint camera re-rendered
|
||||||
|
/// to a temporary target, so it works on either render pipeline). Used for surfaces that can't be
|
||||||
|
/// sampled texel-exact - terrain trees and grass/mushroom details (no per-instance renderer/UV)
|
||||||
|
/// and anything hit without a readable material, plus rays that miss all geometry. Only albedo is
|
||||||
|
/// meaningful from a screen pixel; the data channels default to matte/unoccluded/no-emission.
|
||||||
|
/// </summary>
|
||||||
|
private bool PickFromScreen(Vector2 screenPos)
|
||||||
|
{
|
||||||
|
Camera cam = paintCamera;
|
||||||
|
if (!cam) return false;
|
||||||
|
int w = Mathf.Max(1, cam.pixelWidth), h = Mathf.Max(1, cam.pixelHeight);
|
||||||
|
|
||||||
|
RenderTexture rt = RenderTexture.GetTemporary(w, h, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
|
||||||
|
RenderTexture prevTarget = cam.targetTexture;
|
||||||
|
RenderTexture prevActive = RenderTexture.active;
|
||||||
|
cam.targetTexture = rt;
|
||||||
|
cam.Render();
|
||||||
|
RenderTexture.active = rt;
|
||||||
|
|
||||||
|
var tmp = new Texture2D(1, 1, TextureFormat.RGBA32, false, false);
|
||||||
|
int px = Mathf.Clamp(Mathf.RoundToInt(screenPos.x), 0, w - 1);
|
||||||
|
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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Material read helpers (name-tolerant: URP and legacy Standard) ---
|
// --- Material read helpers (name-tolerant: URP and legacy Standard) ---
|
||||||
|
|
||||||
private static Color GetColorProp(Material m, string nameA, string nameB, Color fallback)
|
private static Color GetColorProp(Material m, string nameA, string nameB, Color fallback)
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ public class PlayerCharacterController : MonoBehaviour
|
|||||||
private float _lostContactTimer;
|
private float _lostContactTimer;
|
||||||
private float _wallOffset; // current lean-out distance from the wall (mouse drag)
|
private float _wallOffset; // current lean-out distance from the wall (mouse drag)
|
||||||
private float _wallYaw; // body yaw offset from facing the wall (mouse drag); held until leaving
|
private float _wallYaw; // body yaw offset from facing the wall (mouse drag); held until leaving
|
||||||
|
private int _wallStrafeSign; // latched sideways direction (camera picks it once per strafe); 0 = unlatched
|
||||||
private bool _offsetDragging; // true while the offset/rotation is being set (pauses camera orbit)
|
private bool _offsetDragging; // true while the offset/rotation is being set (pauses camera orbit)
|
||||||
private bool _climbLeftGround; // true once the capsule has left the ground while climbing (enables ground-detach)
|
private bool _climbLeftGround; // true once the capsule has left the ground while climbing (enables ground-detach)
|
||||||
private readonly RaycastHit[] _wallProbe = new RaycastHit[8];
|
private readonly RaycastHit[] _wallProbe = new RaycastHit[8];
|
||||||
@@ -365,6 +366,7 @@ public class PlayerCharacterController : MonoBehaviour
|
|||||||
_slideSpeed = 0f;
|
_slideSpeed = 0f;
|
||||||
_wallOffset = 0f;
|
_wallOffset = 0f;
|
||||||
_wallYaw = 0f;
|
_wallYaw = 0f;
|
||||||
|
_wallStrafeSign = 0;
|
||||||
_climbLeftGround = false;
|
_climbLeftGround = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,8 +449,21 @@ public class PlayerCharacterController : MonoBehaviour
|
|||||||
if (wallUp.sqrMagnitude > 1e-6f) wallUp.Normalize();
|
if (wallUp.sqrMagnitude > 1e-6f) wallUp.Normalize();
|
||||||
Vector3 wallRight = Vector3.Cross(_wallNormal, wallUp);
|
Vector3 wallRight = Vector3.Cross(_wallNormal, wallUp);
|
||||||
if (wallRight.sqrMagnitude > 1e-6f) wallRight.Normalize();
|
if (wallRight.sqrMagnitude > 1e-6f) wallRight.Normalize();
|
||||||
if (cameraTransform && Vector3.Dot(cameraTransform.right, wallRight) < 0f)
|
|
||||||
|
// Pick the strafe sign ONCE from the camera when a strafe begins, then hold it.
|
||||||
|
// Re-deciding it every frame flips wallRight while rounding a curved (cylindrical)
|
||||||
|
// wall: exactly at 90 deg to the camera the dot crosses zero, so the sideways input
|
||||||
|
// inverts frame-to-frame and you lock there, jittering back and forth. Latching keeps
|
||||||
|
// a strafe continuous around the curve; it resets when the strafe input releases so
|
||||||
|
// the next one re-syncs to the camera.
|
||||||
|
bool strafing = Mathf.Abs(h) > 1e-4f;
|
||||||
|
if (!strafing)
|
||||||
|
_wallStrafeSign = 0;
|
||||||
|
else if (_wallStrafeSign == 0)
|
||||||
|
_wallStrafeSign = (cameraTransform && Vector3.Dot(cameraTransform.right, wallRight) < 0f) ? -1 : 1;
|
||||||
|
if (_wallStrafeSign < 0)
|
||||||
wallRight = -wallRight;
|
wallRight = -wallRight;
|
||||||
|
|
||||||
alongSurface = wallUp * v + wallRight * h;
|
alongSurface = wallUp * v + wallRight * h;
|
||||||
}
|
}
|
||||||
if (alongSurface.sqrMagnitude > 1f) alongSurface.Normalize();
|
if (alongSurface.sqrMagnitude > 1f) alongSurface.Normalize();
|
||||||
@@ -760,6 +775,7 @@ public class PlayerCharacterController : MonoBehaviour
|
|||||||
_verticalVelocity = 0f;
|
_verticalVelocity = 0f;
|
||||||
_wallOffset = 0f;
|
_wallOffset = 0f;
|
||||||
_wallYaw = 0f;
|
_wallYaw = 0f;
|
||||||
|
_wallStrafeSign = 0;
|
||||||
_offsetDragging = false;
|
_offsetDragging = false;
|
||||||
_climbLeftGround = false;
|
_climbLeftGround = false;
|
||||||
_mantling = false;
|
_mantling = false;
|
||||||
|
|||||||
+2
-2
@@ -56,7 +56,7 @@ Material:
|
|||||||
m_Scale: {x: 1, y: 1}
|
m_Scale: {x: 1, y: 1}
|
||||||
m_Offset: {x: 0, y: 0}
|
m_Offset: {x: 0, y: 0}
|
||||||
- _MainTex:
|
- _MainTex:
|
||||||
m_Texture: {fileID: 0}
|
m_Texture: {fileID: 2800000, guid: 1056b8bf593938a4399e74aa632e2a21, type: 3}
|
||||||
m_Scale: {x: 1, y: 1}
|
m_Scale: {x: 1, y: 1}
|
||||||
m_Offset: {x: 0, y: 0}
|
m_Offset: {x: 0, y: 0}
|
||||||
- _MetallicGlossMap:
|
- _MetallicGlossMap:
|
||||||
@@ -135,7 +135,7 @@ Material:
|
|||||||
- _ZWrite: 1
|
- _ZWrite: 1
|
||||||
- __dirty: 1
|
- __dirty: 1
|
||||||
m_Colors:
|
m_Colors:
|
||||||
- _BaseColor: {r: 1, g: 1, b: 1, a: 1}
|
- _BaseColor: {r: 1, g: 1, b: 1, a: 0.5}
|
||||||
- _Color: {r: 1, g: 1, b: 1, a: 1}
|
- _Color: {r: 1, g: 1, b: 1, a: 1}
|
||||||
- _ColorMultiply: {r: 1, g: 1, b: 1, a: 1}
|
- _ColorMultiply: {r: 1, g: 1, b: 1, a: 1}
|
||||||
- _ColorOverride: {r: 1, g: 1, b: 1, a: 0}
|
- _ColorOverride: {r: 1, g: 1, b: 1, a: 0}
|
||||||
|
|||||||
+2
-2
@@ -69,7 +69,7 @@ Material:
|
|||||||
m_Scale: {x: 1, y: 1}
|
m_Scale: {x: 1, y: 1}
|
||||||
m_Offset: {x: 0, y: 0}
|
m_Offset: {x: 0, y: 0}
|
||||||
- _MainTex:
|
- _MainTex:
|
||||||
m_Texture: {fileID: 0}
|
m_Texture: {fileID: 2800000, guid: d1de526d00e4052458506ce20689b585, type: 3}
|
||||||
m_Scale: {x: 1, y: 1}
|
m_Scale: {x: 1, y: 1}
|
||||||
m_Offset: {x: 0, y: 0}
|
m_Offset: {x: 0, y: 0}
|
||||||
- _MetallicGlossMap:
|
- _MetallicGlossMap:
|
||||||
@@ -149,7 +149,7 @@ Material:
|
|||||||
- _ZWrite: 1
|
- _ZWrite: 1
|
||||||
- __dirty: 1
|
- __dirty: 1
|
||||||
m_Colors:
|
m_Colors:
|
||||||
- _BaseColor: {r: 1, g: 1, b: 1, a: 1}
|
- _BaseColor: {r: 1, g: 1, b: 1, a: 0.5}
|
||||||
- _Color: {r: 1, g: 1, b: 1, a: 1}
|
- _Color: {r: 1, g: 1, b: 1, a: 1}
|
||||||
- _ColorMultiply: {r: 1, g: 1, b: 1, a: 1}
|
- _ColorMultiply: {r: 1, g: 1, b: 1, a: 1}
|
||||||
- _ColorOverride: {r: 1, g: 1, b: 1, a: 0}
|
- _ColorOverride: {r: 1, g: 1, b: 1, a: 0}
|
||||||
|
|||||||
@@ -884,7 +884,7 @@ GameObject:
|
|||||||
m_Icon: {fileID: 0}
|
m_Icon: {fileID: 0}
|
||||||
m_NavMeshLayer: 0
|
m_NavMeshLayer: 0
|
||||||
m_StaticEditorFlags: 0
|
m_StaticEditorFlags: 0
|
||||||
m_IsActive: 0
|
m_IsActive: 1
|
||||||
--- !u!81 &963194226
|
--- !u!81 &963194226
|
||||||
AudioListener:
|
AudioListener:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|||||||
Reference in new issue
Block a user