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
+22 -10
View File
@@ -86,16 +86,28 @@ them out) → poll `GetAsyncKeyState` per frame, gated on foreground. Virtual co
persistent throttle LEVER with a zero detent; A/D a momentary auto-centering stick. [T2]
## Shadow (default-on, `BT_SHADOW_TILT`)
BT 4.10's shadow is the flat `*_tshd.bgf` proxy drawn as blend-pass geometry, posed by `jointshadow`
(terrain tilt) + `jointtshadow` (torso twist). Terrain-tilt samples the collision surface gradient;
a depth-bias (`D3DRS_DEPTHBIAS`, decal-style) stops it z-fighting/vanishing on inclines. Polar maps
have STUB `*_tshd` (shadow authored-off on snow). Also the visual-ground conform (`btvisgnd.cpp`,
render-only lift to the visible terrain triangles — presentation only, never touches localOrigin). [T2]
**"Shadow vanishes on inclines" can be a GAIT-DESYNC symptom, not a bias/tilt bug.** The 2026-07-09
recurrence CO-RESOLVED with the gait v5 fix (user-confirmed live; no shadow code changed). The bias
margin is only ~2-4 world units, so anything that pops the displayed root pose (v4's out-of-phase leg
channel showing through) can push the flat quad under the slope → vanish [T2 observation; mechanism
T4]. Before touching bias/tilt for this report, check `[sync]`/`BT_SYNC_LOG` for channel drift first.
BT 4.10's shadow is the `*_tshd.bgf` proxy, posed by `jointshadow` (terrain tilt — the SKL's own
contract: "apply terrain angle to pitch and roll") + `jointtshadow` (torso twist). The working 2026-07-09
arrangement (user-verified live: feet layer over it, opaque, survives inclines/declines) has FOUR
co-dependent parts — change one, re-check the others:
1. **TILT** (mech.cpp `UpdateShadowJoint`): 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 two prior tilt attempts "dug into the hillside"/vanished. ~35° cliff-guard cap.
2. **SAMPLER** (mech4.cpp): gradient from the collision probes PLUS `BTVisualGroundLift` per probe
(the quad hugs the VISIBLE terrain, whose lift varies across a slope); world→local by the TRUE yaw
from `localToWorld` (NOT the `gDriveHeading` scalar mirror — the task-#48 drift bug class).
`BT_SHADOW_LOG` traces the normals.
3. **DRAW ORDER** (`PASS_SHADOW`, l4d3d.h/L4VIDRND/L4VIDEO): shadows draw between the STATIC terrain
and the DYNAMIC opaque bodies (classic decal order), so the mech z-passes over the shadow — the
bias can never paint the shadow OVER the feet, structurally.
4. **BIAS pairing** (L4D3D.cpp): tilt on (default) → decal epsilon 0.0008; `BT_SHADOW_TILT=0`
(flat A/B fallback) → the old 0.004 it needs to survive slope burial. `BT_SHADOW_BIAS` overrides.
Polar maps have STUB `*_tshd` (shadow authored-off on snow). The visual-ground conform (`btvisgnd.cpp`)
stays render-only (never touches localOrigin). [T2]
**"Shadow vanishes on inclines" can also be a GAIT-DESYNC symptom.** The first 2026-07-09 recurrence
CO-RESOLVED with the gait v5 fix (no shadow code changed): a popping root pose (out-of-phase leg
channel showing through) can push the quad past the bias margin. Check `[sync]`/`BT_SYNC_LOG` for
channel drift before touching the shadow itself. [T2 observation; mechanism T4]
## Key Relationships
- Detail: `docs/P3_LOCOMOTION.md`. Uses: [[asset-formats]] (SKL/ANI), [[decomp-reference]] (offsets).
+15 -2
View File
@@ -948,10 +948,23 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target
if (s_bias == -1e9f)
{
const char *bv = getenv("BT_SHADOW_BIAS");
s_bias = bv ? (float)atof(bv) : -0.004f;
if (bv)
s_bias = (float)atof(bv);
else
{
// Default pairs with the tilt (task #49b): with the quad TILTED
// onto the slope (BT_SHADOW_TILT on, the default) only a decal
// epsilon is needed -- the old big bias (2-4 world units) also
// beat the mech's FEET in the depth test, painting the shadow
// OVER them. The flat-quad A/B fallback (BT_SHADOW_TILT=0)
// still needs the big bias to survive slope burial.
const char *tv = getenv("BT_SHADOW_TILT");
const int tiltOn = (tv == 0 || tv[0] != '0');
s_bias = tiltOn ? -0.0008f : -0.004f;
}
}
mDevice->SetRenderState(D3DRS_DEPTHBIAS, *(DWORD *)&s_bias);
float slope = s_bias == 0.0f ? 0.0f : -1.0f; // steep-slope pixels get extra pull
float slope = s_bias == 0.0f ? 0.0f : -1.0f; // coplanar decal term
mDevice->SetRenderState(D3DRS_SLOPESCALEDEPTHBIAS, *(DWORD *)&slope);
}
mDevice->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_SELECTARG1);
+10
View File
@@ -7583,6 +7583,16 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
}
}
// GROUND SHADOWS (task #49b): the classic decal order -- terrain (statics,
// above) -> shadow decals (here) -> dynamic opaque bodies (below). The
// shadow's depth-bias only has to beat the TERRAIN already in the z-buffer;
// the mech drawn AFTER simply z-passes over the shadow, so the bias can
// never paint the shadow over the feet. Drawn with pass id PASS_ALPHABLEND
// so DrawMesh's mIsShadow state-block branch (blend + bias + restore) runs
// unchanged.
for (d3d_OBJECT *obj = mRenderLists[PASS_SHADOW]; obj != NULL; obj = obj->GetNext(PASS_SHADOW))
obj->Draw(PASS_ALPHABLEND, &viewTransform, mTargetRenderTime);
for (d3d_OBJECT *obj = mRenderLists[PASS_OPAQUE]; obj != NULL; obj = obj->GetNext(PASS_OPAQUE))
obj->Draw(PASS_OPAQUE, &viewTransform, mTargetRenderTime);
+13
View File
@@ -143,6 +143,18 @@ void HierarchicalDrawComponent::Execute()
{
if (isDeathDraw || !l4_application->IsDead())
{
// GROUND SHADOW (task #49b): tshd proxies go to the dedicated
// PASS_SHADOW list ONLY -- drawn after the static terrain but
// before the dynamic opaque bodies, so the mech's feet z-pass
// over the shadow instead of the biased shadow painting over
// the feet. (Their drawOps carry alphaTest=1, which would
// otherwise route them to the late alpha-blend pass.)
if (graphicalObject->GetIsShadow())
{
myRenderer->AddToPassList(graphicalObject, PASS_SHADOW);
}
else
{
bool addedToOpaqueList = false, addedToAlphaList = false, addedToDecalList = false, addedToSphereList = false, addedToSkyList = false;
for (int i=0; i<graphicalObject->GetDrawOpCount(); i++)
{
@@ -187,6 +199,7 @@ void HierarchicalDrawComponent::Execute()
}
}
}
} // close the non-shadow classification branch (task #49b)
}
}
}
+8 -1
View File
@@ -14,7 +14,14 @@
#define PASS_ALPHABLEND 2
#define PASS_SPHERE 3
#define PASS_SKY 4
#define PASS_TOTAL_COUNT 5
// GROUND-SHADOW list (task #49b): tshd proxies draw between the STATIC opaque
// geometry (terrain) and the DYNAMIC opaque list (mech bodies) -- the classic
// decal order. The feet then z-pass over the already-drawn shadow, so the
// shadow's big terrain depth-bias can never paint OVER the mech. (The list is
// drawn with pass id PASS_ALPHABLEND so d3d_OBJECT::DrawMesh's mIsShadow
// state-block branch runs unchanged.)
#define PASS_SHADOW 5
#define PASS_TOTAL_COUNT 6
struct L4VERTEX
{
+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);