Shadow: authentic terrain tilt + decal draw order (feet layer correctly)

User report: the ground shadow painted OVER the mech's feet.  Root cause: the
flat quad + big depth-bias (-0.004 ~= 2-4 world units) workaround for slope
burial -- a bias big enough to beat the TERRAIN also beat the FEET in the
depth test, and the shadow drew in the late alpha-blend pass (after the mech),
so z-arbitration was the only layering control.

Fix, four co-dependent parts (user-verified live: feet on top, opaque,
survives inclines/declines):

- TILT (mech.cpp UpdateShadowJoint): apply the SKL's own contract for
  jointshadow ("apply terrain angle to pitch and roll") -- quad up aligned to
  the surface normal via an orthonormal basis fed to the ENGINE's own
  LinearMatrix->EulerAngles conversion.  Hand-derived Euler signs are exactly
  how the prior tilt attempts "dug into the hillside"; the engine conversion
  is convention-proof.  ~35 deg cliff-guard cap.
- SAMPLER (mech4.cpp): surface gradient from the collision probes PLUS
  BTVisualGroundLift per probe -- the quad hugs the VISIBLE terrain, whose
  lift varies across a slope; world->local rotation by the TRUE yaw from
  localToWorld, not the drift-prone gDriveHeading mirror (the task-#48 bug
  class).  BT_SHADOW_LOG traces the normals.
- DRAW ORDER (new PASS_SHADOW, l4d3d.h/L4VIDRND/L4VIDEO): shadows draw
  between the STATIC terrain and the DYNAMIC opaque bodies -- the classic
  decal order.  The mech, drawn after, z-passes over the shadow: the bias
  can never paint the shadow over the feet, structurally.
- BIAS pairing (L4D3D.cpp): tilt on (default) -> decal epsilon -0.0008;
  BT_SHADOW_TILT=0 flat fallback -> the old -0.004.  BT_SHADOW_BIAS overrides.

KB (locomotion.md): the four-part arrangement recorded as co-dependent, with
the gait-desync-symptom diagnostic hint retained.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-09 16:59:48 -05:00
co-authored by Claude Fable 5
parent 099cad998d
commit b6fe686b22
7 changed files with 145 additions and 30 deletions
+46 -12
View File
@@ -294,18 +294,52 @@ void
return;
}
// The *_tshd proxy is a FLAT blob shadow (no light source, no projection). A flat
// quad cannot bend to a slope, so tilting it to the terrain angle only made its
// uphill edge dig into the hillside (and a nearby cliff wrenched it over). The
// renderer's depth-bias already draws it ON TOP of the terrain so it doesn't get
// buried/culled on elevation -- which was the reason the tilt was added. So keep
// the shadow FLAT (horizontal, riding the depth-bias) and only preserve the yaw
// channel (jointtshadow handles torso-twist yaw separately). ground_normal is now
// unused for pitch/roll; kept in the signature for the torso-yaw path + future use.
(void)ground_normal;
EulerAngles angles = shadowJointNode->GetEulerAngles(); // keep yaw
EulerAngles flat(0.0f, angles.yaw, 0.0f);
shadowJointNode->SetRotation(flat);
// APPLY THE TERRAIN ANGLE (task #49b -- the SKL's own contract for this
// joint: "apply terrain angle to pitch and roll"). ground_normal is the
// surface normal in the MECH-LOCAL frame (the caller rotates the world
// gradient by -yaw); pose the quad so its up (+Y) aligns with it and the
// quad LIES ON the slope. A flat quad + a big depth-bias was the previous
// workaround for slope burial, but a bias big enough to beat the terrain
// also beat the mech's FEET (the shadow painted OVER them); with the tilt
// the bias drops to a decal epsilon and the feet layer correctly.
// Cliff guard: cap the tilt (~35 deg) so a bogus gradient from probing
// across a cliff edge can't wrench the quad vertical.
Vector3D n = ground_normal;
if (n.y < 0.82f) // cap ~35 deg
{
Scalar xz = (Scalar)sqrtf((float)(n.x * n.x + n.z * n.z));
if (xz > 1e-6f)
{
const Scalar s = 0.5735f / xz; // sin(35 deg) / |xz|
n.x *= s; n.z *= s; n.y = 0.8192f; // cos(35 deg)
}
else
{
n.x = 0.0f; n.z = 0.0f; n.y = 1.0f;
}
}
// Convention-proof: build the tilted ORTHONORMAL BASIS (quad up = n) and let
// the ENGINE's own LinearMatrix -> EulerAngles conversion produce the angles
// (hand-derived Euler signs are exactly how the original tilt attempt "dug
// into the hillside"). Basis: Y = n; Z = world Z projected off n (so the
// tilt carries no yaw; jointtshadow handles torso-twist yaw separately);
// X = Y x Z (right-handed, matches the identity basis).
{
Vector3D zx(0.0f - n.x * n.z, 0.0f - n.y * n.z, 1.0f - n.z * n.z);
Scalar zl = (Scalar)sqrtf((float)(zx.x * zx.x + zx.y * zx.y + zx.z * zx.z));
if (zl < 1e-6f) { zx.x = 0.0f; zx.y = 0.0f; zx.z = 1.0f; zl = 1.0f; }
zx.x /= zl; zx.y /= zl; zx.z /= zl;
Vector3D xx(n.y * zx.z - n.z * zx.y,
n.z * zx.x - n.x * zx.z,
n.x * zx.y - n.y * zx.x);
LinearMatrix tiltM(1); // identity ctor
tiltM.SetFromAxis(X_Axis, UnitVector(xx.x, xx.y, xx.z));
tiltM.SetFromAxis(Y_Axis, UnitVector(n.x, n.y, n.z));
tiltM.SetFromAxis(Z_Axis, UnitVector(zx.x, zx.y, zx.z));
EulerAngles tilt;
tilt = tiltM; // the engine's own conversion
shadowJointNode->SetRotation(tilt);
}
}
//---------------------------------------------------------------------------//
+31 -5
View File
@@ -2210,17 +2210,43 @@ void
BoundingBox *bZ = gnode->FindBoundingBoxUnder(qZ, &hZ);
if (bC != 0 && bX != 0 && bZ != 0)
{
// surfaceY = probeY - downDistance; gradient -> world normal (-dH/dx, 1, -dH/dz)
const Scalar yC = baseY - hC, yX = baseY - hX, yZ = baseY - hZ;
// surfaceY = probeY - downDistance; then add the render-only
// VISUAL lift at each probe (task #49b): the quad is drawn on
// the VISIBLE terrain (btvisgnd conform), which runs 0..~2u
// above the collision solid by DIFFERENT amounts across a
// slope -- the VISUAL gradient, not the collision gradient,
// is the surface the quad must hug. Lift 0 (no data/gated
// off) degrades to the collision gradient.
extern float BTVisualGroundLift(float x, float y, float z);
Scalar yC = baseY - hC, yX = baseY - hX, yZ = baseY - hZ;
yC += BTVisualGroundLift((float)cx, (float)yC, (float)cz);
yX += BTVisualGroundLift((float)(cx+D), (float)yX, (float)cz);
yZ += BTVisualGroundLift((float)cx, (float)yZ, (float)(cz+D));
Vector3D wn(-(yX - yC) / D, 1.0f, -(yZ - yC) / D);
Scalar len = (Scalar)sqrtf(wn.x * wn.x + wn.y * wn.y + wn.z * wn.z);
if (len > 1e-6f) { wn.x /= len; wn.y /= len; wn.z /= len; }
// world -> mech-local: rotate by -gDriveHeading about Y (mech upright, yaw only)
const Scalar cth = (Scalar)cosf((float)gDriveHeading);
const Scalar sth = (Scalar)sinf((float)gDriveHeading);
// world -> mech-local: rotate by -yaw about Y. Use the TRUE
// heading from localToWorld (engine convention MATRIX.cpp:196:
// Z basis = (sin y, 0, cos y) -> yaw = atan2(z.x, z.z)) -- NOT
// the gDriveHeading scalar mirror, which is seeded to 0 and
// drifts from the real pose (the task-#48 goto-steering bug
// class; a wrong yaw here swings the tilt as the mech turns).
UnitVector zAxisS;
localToWorld.GetFromAxis(Z_Axis, &zAxisS);
const Scalar shYaw = (Scalar)atan2f((float)zAxisS.x, (float)zAxisS.z);
const Scalar cth = (Scalar)cosf((float)shYaw);
const Scalar sth = (Scalar)sinf((float)shYaw);
shadowNormal.x = wn.x * cth - wn.z * sth;
shadowNormal.y = wn.y;
shadowNormal.z = wn.x * sth + wn.z * cth;
if (getenv("BT_SHADOW_LOG"))
{
static float s_shLog = 0.0f; s_shLog += dt;
if (s_shLog >= 1.0f) { s_shLog = 0.0f;
DEBUG_STREAM << "[shtilt] wn=(" << wn.x << "," << wn.y << "," << wn.z
<< ") local=(" << shadowNormal.x << "," << shadowNormal.y
<< "," << shadowNormal.z << ") yaw=" << shYaw << "\n" << std::flush; }
}
}
}
UpdateShadowJoint(shadowNormal);