Warp: restore the translocation-vortex look + fix texture-scroll precision collapse (task #52)

The death/respawn "blue whirlwind" (tsphere) now matches the original cabinet
photo (capture.png): a smooth spinning lavender vortex with a bright core.

Root cause of the long-standing "radial spokes" artifact was NOT the warp code
but a general engine bug: L4D3D::SetTextureScrolling computed its texture-matrix
offset as scrollDelta * absolute_time, which grows unbounded and collapses UV
float precision -> a smooth scrolled cloud shatters into grainy radial steps.
Wrapped with fmodf(..., 1.0f) (identical under REPEAT tiling, full precision).
This also cleans the scrolling bexp beam grit and any other SCROLL material.

Visual reconstruction (verified against the real 45-vtx TSPHERE.BGF bicone,
offline-rasterized then ported):
  - view ON-AXIS (eye centred on the throat) + spin in place -> concentric rings
    (decomp FUN_00453dc4 does spin-about-local-Z + submit; the port had stubbed it)
  - bintA cloud through a WIDE lavender ramp at full contrast (drawn as SKY);
    no geometry "bands", no log-polar twist, no off-axis tornado (all discarded)
  - tessellate the 12-facet bicone smooth; isotropic + trilinear; ramp baked into
    the texture and drawn SELECTARG1(TEXTURE) to avoid double-tinting

Env knobs (BT_WARP_*) default to the verified values; BT_WARP_SELFTEST/SELFSHOT
are an off-by-default visual-verification harness (backbuffer frame dump).

Docs: new context/translocation-warp.md (geometry/material/visual/lifecycle/env);
reconstruction-gotchas.md gains the accumulated-time precision-collapse bug class;
rendering.md / multiplayer.md / decomp-reference.md / CLAUDE.md cross-linked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-10 14:53:50 -05:00
co-authored by Claude Opus 4.8
parent a35f321ba1
commit 0bfb3d4ab3
10 changed files with 591 additions and 22 deletions
+178 -13
View File
@@ -2226,6 +2226,7 @@ namespace {
int gWarpPhase = 0;
float gWarpT = 0.0f; // phase clock (collapse/expand)
float gWaitClock = 0.0f; // WaitForReincarnate elapsed (wobble + stuck-black failsafe)
float gWarpSpin = 0.0f; // accumulated throat-axis (Z) spin -- the SPIRAL (decomp FUN_00453dc4)
float gWarpX = 0.0f, gWarpY = 0.0f, gWarpZ = 0.0f;
int gWarpPOV = 0; // 1 = centre on the local eye (own death/respawn); 0 = world-anchored (peer)
int gWarpMasked = 0; // 1 while we have raised the SetIsDead world mask
@@ -2335,6 +2336,19 @@ void
BTDrawTranslocationSpheres(LPDIRECT3DDEVICE9 device, const D3DXMATRIX *view,
float dt, Time frame_time)
{
// DIAG (off by default): BT_WARP_SELFTEST=1 forces a steady POV warp
// (held in WaitForReincarnate, world masked) so the swirl can be frame-captured and
// compared against the original (capture.png) in a solo game -- no death/respawn
// needed. Remove once the visual is signed off.
static int s_selftest = -1;
if (s_selftest < 0) s_selftest = getenv("BT_WARP_SELFTEST") ? 1 : 0;
if (s_selftest && gWarpPhase == 0)
{
gWarpPhase = 3; gWarpPOV = 1; gWaitClock = 0.0f;
gWarpX = gWarpY = gWarpZ = 0.0f;
if (!gWarpMasked) { BTSetWorldDead(1); gWarpMasked = 1; }
}
if (gWarpPhase == 0)
return;
@@ -2354,7 +2368,7 @@ void
// so our swirl was FROZEN. Set the authored scroll rates here so
// SetTextureScrolling animates it (u -0.1/s, v +0.5/s -> the churning
// swirl that "spins around"), REPEAT wrap so the scroll tiles.
dop->texture.doScroll = true;
dop->texture.doScroll = getenv("BT_WARP_NOSCROLL") ? false : true; // DIAG toggle
dop->texture.scrollUDelta = 0.1f;
dop->texture.scrollVDelta = 0.5f;
dop->texture.wrap_u = L4TEXOP::REPEAT;
@@ -2403,7 +2417,7 @@ void
// FAILSAFE: the respawn kicks Wait->Expand (BTStartWarpExpandPOV). If it never
// arrives (mission end / out of lives / dropped DropZoneReply) the SetIsDead
// world would stay BLACK forever -- so time out and un-mask after 12s.
if (gWaitClock > 12.0f)
if (!s_selftest && gWaitClock > 12.0f)
{
if (gWarpMasked) { BTSetWorldDead(0); gWarpMasked = 0; }
gWarpPhase = 0;
@@ -2457,30 +2471,84 @@ void
else
D3DXMatrixScaling(&local, s, s, s);
// THE SPIRAL (the piece I wrongly dropped): a continuous per-frame SPIN about the
// throat axis (mesh local Z). The 1995 binary's translocate Execute (decomp
// FUN_00453dc4) accumulates an angle and writes a Z-rotation into the sphere every
// frame; the WinTesla port STUBBED it out and I followed the stub, calling
// myRotateY "dead". Spin + the axial V texture-scroll = a HELIX = the smooth
// spiralling vortex, and the rotation SWEEPS the coarse 12 facet edges so the mesh
// stops reading as a faceted "sphincter". BT_WARP_SPIN = rad/s (default 4).
static float s_spinRate = -1.0e9f;
if (s_spinRate == -1.0e9f)
{
const char *sv = getenv("BT_WARP_SPIN");
s_spinRate = sv ? (float)atof(sv) : 4.0f;
}
gWarpSpin += s_spinRate * dt;
if (gWarpSpin > 6.2831853f) gWarpSpin -= 6.2831853f;
else if (gWarpSpin < 0.0f) gWarpSpin += 6.2831853f;
D3DXMATRIX spin;
D3DXMatrixRotationZ(&spin, gWarpSpin);
// tsphere is a 12-facet BICONE tunnel; its long axis (the vortex throat) is the
// mesh LOCAL Z, which Scale*inverse(view) already points down the view forward --
// so NO reorientation is needed (default tilt 0). BT_WARP_TILT (deg, pitch about
// X) is kept only to NUDGE the off-axis convergence toward screen-centre if wanted
// (the eye sits 8.25 below the throat axis, authentically, so it reads a touch
// high).
static float s_tiltDeg = -1.0e9f;
if (s_tiltDeg == -1.0e9f)
{
const char *tv = getenv("BT_WARP_TILT");
s_tiltDeg = tv ? (float)atof(tv) : 0.0f;
}
D3DXMATRIX tilt;
D3DXMatrixRotationX(&tilt, s_tiltDeg * (float)(3.14159265358979 / 180.0));
// EYE-ON-AXIS: bintA is smooth CLOUD noise (no rings of its own); the vortex rings
// come from that cloud mapped in POLAR (U=angle around the Z throat, V=radius) --
// which only reads as CONCENTRIC rings when you look straight DOWN the throat axis.
// The eye is authentically 8.25 below the axis, which skews the rings to diagonal
// bands (the "sphincter"). BT_WARP_EYE_UP shifts the eye ONTO the axis (mesh local
// +Y) so the rings go concentric; default 8.25 (the throat-axis offset), =0 for the
// raw authentic off-axis view.
static float s_eyeUp = -1.0e9f;
if (s_eyeUp == -1.0e9f)
{
const char *ev = getenv("BT_WARP_EYE_UP");
s_eyeUp = ev ? (float)atof(ev) : 8.25f;
}
D3DXMATRIX eyeUp;
D3DXMatrixTranslation(&eyeUp, 0.0f, -s_eyeUp, 0.0f);
D3DXMATRIX world;
if (gWarpPOV && view != 0)
{
D3DXMATRIX invView;
D3DXMatrixInverse(&invView, 0, view);
world = local * invView; // scale/throb about the eye, then eye->world
// throat-axis -> origin (eyeUp), SPIN about Z, scale/throb, tilt, eye->world.
// eyeUp before spin so the rotation is about the THROAT, not the off-axis eye.
world = eyeUp * spin * local * tilt * invView;
}
else
{
D3DXMATRIX anchorM;
D3DXMatrixTranslation(&anchorM, gWarpX, gWarpY, gWarpZ);
world = local * anchorM; // peer overlay: scale about the world anchor
world = local * anchorM; // peer overlay: external view, keep upright
}
gTLocSphere->SetLocalToWorld(world);
// The swirl COLOUR is the material's "sky" ramp remapping the grayscale bintA
// cloud per-texel (un-gated for tsphere_mtl in bgfload.cpp; L4D3D.cpp:480), so the
// bound texture is ALREADY blue-white -- MODULATE by WHITE = the ramp unchanged.
// The swirl COLOUR: the "sky" ramp already bakes the bintA cloud blue->white
// (bgfload.cpp un-gate; L4D3D.cpp:480), and we MODULATE that by the material's
// authored EMISSIVE {0.7,0.5,1} = 0xB380FF (the lavender-blue energy cast) which
// the LIGHTING-off path would otherwise drop -- that lavender is the original's
// signature colour. BT_WARP_COLOR=AARRGGBB overrides.
static DWORD s_warpColor = 0;
if (s_warpColor == 0)
{
const char *wc = getenv("BT_WARP_COLOR");
s_warpColor = wc ? (DWORD)strtoul(wc, 0, 16) : 0xFFFFFFFF;
if (s_warpColor == 0) s_warpColor = 0xFFFFFFFF;
s_warpColor = wc ? (DWORD)strtoul(wc, 0, 16) : 0xFFB380FF;
if (s_warpColor == 0) s_warpColor = 0xFFB380FF;
}
if (gWarpPOV)
@@ -2499,11 +2567,17 @@ void
dop->drawAsSky = true; dop->alphaTest = false; dop->drawAsDecal = false;
}
DWORD sZW, sAB, sCull, sLight, sTF, sCOp, sCA1, sCA2, sAOp, sAA1;
DWORD sMin, sMag, sMip, sAniso, sFog;
device->GetRenderState(D3DRS_ZWRITEENABLE, &sZW);
device->GetRenderState(D3DRS_ALPHABLENDENABLE, &sAB);
device->GetRenderState(D3DRS_CULLMODE, &sCull);
device->GetRenderState(D3DRS_LIGHTING, &sLight);
device->GetRenderState(D3DRS_FOGENABLE, &sFog);
device->GetRenderState(D3DRS_TEXTUREFACTOR, &sTF);
device->GetSamplerState(0, D3DSAMP_MINFILTER, &sMin);
device->GetSamplerState(0, D3DSAMP_MAGFILTER, &sMag);
device->GetSamplerState(0, D3DSAMP_MIPFILTER, &sMip);
device->GetSamplerState(0, D3DSAMP_MAXANISOTROPY, &sAniso);
device->GetTextureStageState(0, D3DTSS_COLOROP, &sCOp);
device->GetTextureStageState(0, D3DTSS_COLORARG1, &sCA1);
device->GetTextureStageState(0, D3DTSS_COLORARG2, &sCA2);
@@ -2512,21 +2586,82 @@ void
device->SetRenderState(D3DRS_ZWRITEENABLE, TRUE);
device->SetRenderState(D3DRS_ALPHABLENDENABLE, FALSE); // OPAQUE -> single write/pixel
device->SetRenderState(D3DRS_CULLMODE, D3DCULL_CW); // one winding -> no z-fight
{ // DIAG: BT_WARP_CULL = none|cw|ccw (default cw). The mesh is double-sided
// (every tri emitted fwd+rev); if CW doesn't cleanly cull one winding the two
// coincident surfaces z-fight into radial sparkle "spokes".
static DWORD s_cull = 0xffffffff;
if (s_cull == 0xffffffff) {
const char *cv = getenv("BT_WARP_CULL");
s_cull = (cv && cv[0]=='n') ? D3DCULL_NONE : (cv && cv[1]=='c') ? D3DCULL_CCW : D3DCULL_CW;
}
device->SetRenderState(D3DRS_CULLMODE, s_cull);
}
device->SetRenderState(D3DRS_LIGHTING, FALSE);
device->SetRenderState(D3DRS_FOGENABLE, FALSE); // tsphere_mtl is "IMMUNE 1" (fog-immune) -- world fog was washing the swirl
device->SetRenderState(D3DRS_TEXTUREFACTOR, s_warpColor);
device->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_MODULATE);
device->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE); // ramped blue-white swirl
device->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_TFACTOR);
// FILTERING: era-authentic ISOTROPIC linear+mip (default). My earlier
// anisotropic "fix" was the SPOKE driver: on the grazing funnel wall the texel
// footprint is stretched RADIALLY (down the throat/V), so anisotropy averages
// ALONG that (smears radially) while keeping azimuthal sharpness -> the soft
// concentric rings collapse into radial STREAKS/spokes. Plain isotropic linear
// keeps the rings soft + concentric. BT_WARP_ANISO=N re-enables it (diag only).
{
static int s_aniso = -2;
if (s_aniso == -2) { const char *a = getenv("BT_WARP_ANISO"); s_aniso = a ? atoi(a) : 0; }
if (s_aniso > 0)
{
device->SetSamplerState(0, D3DSAMP_MINFILTER, D3DTEXF_ANISOTROPIC);
device->SetSamplerState(0, D3DSAMP_MAGFILTER, D3DTEXF_LINEAR);
device->SetSamplerState(0, D3DSAMP_MIPFILTER, D3DTEXF_LINEAR);
device->SetSamplerState(0, D3DSAMP_MAXANISOTROPY, (DWORD)s_aniso);
}
else // force isotropic linear (undo any anisotropy a prior draw left set)
{
device->SetSamplerState(0, D3DSAMP_MINFILTER, D3DTEXF_LINEAR);
device->SetSamplerState(0, D3DSAMP_MAGFILTER, D3DTEXF_LINEAR);
// Trilinear mips. (The grainy radial "spokes" were NOT a mip problem -- they were
// the texture-scroll precision collapse, fixed in L4D3D::SetTextureScrolling via fmod.
// BT_WARP_MIP=0 forces base-level-only (diag).)
{ static int s_mip=-1; if(s_mip<0){const char*mv=getenv("BT_WARP_MIP"); s_mip=(mv&&mv[0]=='0')?0:1;}
device->SetSamplerState(0, D3DSAMP_MIPFILTER, s_mip?D3DTEXF_LINEAR:D3DTEXF_NONE); }
}
}
// The BANDS are the per-VERTEX Gouraud colour (concentric shade-ramp contours
// baked from the geometry in bgfload::finish); the bintA texture (lifted to a
// gentle range) MODULATES churn within them. COLOROP = TEXTURE x DIFFUSE(bands).
// BT_WARP_TEXMOD=0 shows the bands ALONE (diagnostic: is the geometry shade right).
device->SetRenderState(D3DRS_DIFFUSEMATERIALSOURCE, D3DMCS_COLOR1); // vertex colour = diffuse
{
static int s_texmod = -1;
// Ramp is BAKED into the texture now -> default SELECTARG1(TEXTURE) (else branch).
// BT_WARP_TEXMOD=1 re-enables the TEXTURExDIFFUSE modulate (diag: double-tints).
if (s_texmod < 0) { const char *t = getenv("BT_WARP_TEXMOD"); s_texmod = (t && t[0] == '1') ? 1 : 0; }
if (s_texmod)
{
device->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_MODULATE);
device->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE); // bintA cloud churn
device->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_DIFFUSE); // the band colour
}
else
{
device->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_SELECTARG1);
device->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE); // baked lavender ramp -- direct
}
}
device->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG1);
device->SetTextureStageState(0, D3DTSS_ALPHAARG1, D3DTA_TFACTOR);
gTLocSphere->Draw(PASS_SKY, view, frame_time);
device->SetSamplerState(0, D3DSAMP_MINFILTER, sMin);
device->SetSamplerState(0, D3DSAMP_MAGFILTER, sMag);
device->SetSamplerState(0, D3DSAMP_MIPFILTER, sMip);
device->SetSamplerState(0, D3DSAMP_MAXANISOTROPY, sAniso);
device->SetRenderState(D3DRS_ZWRITEENABLE, sZW);
device->SetRenderState(D3DRS_ALPHABLENDENABLE, sAB);
device->SetRenderState(D3DRS_CULLMODE, sCull);
device->SetRenderState(D3DRS_LIGHTING, sLight);
device->SetRenderState(D3DRS_FOGENABLE, sFog);
device->SetRenderState(D3DRS_TEXTUREFACTOR, sTF);
device->SetTextureStageState(0, D3DTSS_COLOROP, sCOp);
device->SetTextureStageState(0, D3DTSS_COLORARG1, sCA1);
@@ -2578,6 +2713,36 @@ void
device->SetTextureStageState(0, D3DTSS_ALPHAOP, sAOp);
device->SetTextureStageState(0, D3DTSS_ALPHAARG1, sAA1);
}
// DIAG (off by default): BT_WARP_SELFSHOT=<prefix> dumps a backbuffer FRAME
// SEQUENCE (<prefix>_00.png ..) once the warp has settled, so the swirl can be turned
// into a GIF / compared to capture.png WITHOUT bringing the window to the foreground.
// Retained as the warp's visual-verification harness (needed for the open peer/collapse work).
{
const char *shotPath = getenv("BT_WARP_SELFSHOT");
if (shotPath && gWarpPhase != 0)
{
static int s_shotN = 0;
++s_shotN;
// 40 frames, every 3rd, starting at frame 45 (~0.75s in) -> ~2s of the
// spinning/churning swirl at 60fps.
if (s_shotN >= 45 && ((s_shotN - 45) % 3 == 0))
{
int idx = (s_shotN - 45) / 3;
if (idx < 40)
{
char fn[600];
_snprintf(fn, sizeof fn, "%s_%02d.png", shotPath, idx);
IDirect3DSurface9 *bb = 0;
if (SUCCEEDED(device->GetBackBuffer(0, 0, D3DBACKBUFFER_TYPE_MONO, &bb)) && bb)
{
D3DXSaveSurfaceToFileA(fn, D3DXIFF_PNG, bb, 0, 0);
bb->Release();
}
}
}
}
}
}
//===========================================================================//