diff --git a/context/locomotion.md b/context/locomotion.md index 21dff2b..2f5facb 100644 --- a/context/locomotion.md +++ b/context/locomotion.md @@ -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). diff --git a/engine/MUNGA_L4/L4D3D.cpp b/engine/MUNGA_L4/L4D3D.cpp index 45be4e0..d65e313 100644 --- a/engine/MUNGA_L4/L4D3D.cpp +++ b/engine/MUNGA_L4/L4D3D.cpp @@ -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); diff --git a/engine/MUNGA_L4/L4VIDEO.cpp b/engine/MUNGA_L4/L4VIDEO.cpp index 7ebab85..5a915d3 100644 --- a/engine/MUNGA_L4/L4VIDEO.cpp +++ b/engine/MUNGA_L4/L4VIDEO.cpp @@ -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); diff --git a/engine/MUNGA_L4/L4VIDRND.cpp b/engine/MUNGA_L4/L4VIDRND.cpp index 8952e6c..7b8a687 100644 --- a/engine/MUNGA_L4/L4VIDRND.cpp +++ b/engine/MUNGA_L4/L4VIDRND.cpp @@ -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; iGetDrawOpCount(); i++) { @@ -187,6 +199,7 @@ void HierarchicalDrawComponent::Execute() } } } + } // close the non-shadow classification branch (task #49b) } } } diff --git a/engine/MUNGA_L4/l4d3d.h b/engine/MUNGA_L4/l4d3d.h index 46eabc5..a2a2ec7 100644 --- a/engine/MUNGA_L4/l4d3d.h +++ b/engine/MUNGA_L4/l4d3d.h @@ -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 { diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp index bc724b8..f60d24a 100644 --- a/game/reconstructed/mech.cpp +++ b/game/reconstructed/mech.cpp @@ -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); + } } //---------------------------------------------------------------------------// diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 7f775d4..13db2d2 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -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);