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
+79 -3
View File
@@ -441,6 +441,12 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName)
// texChannel: which BSL bit-slice this texture samples (BMF tag 0x18;
// mech skins pack 4+ grayscale sheets per .BSL). Ignored for VTX/TGA.
Image img = decodeImage(tp, data.batches[i].texChannel);
if (getenv("BT_TLOC_LOG") && data.batches[i].warpBands > 0)
DEBUG_STREAM << "[tloc-bake] tsphere batch#" << i << " useRamp=" << useRamp
<< " hasRamp=" << data.batches[i].hasRamp << " tex='" << tp << "' img.ok=" << img.ok
<< " w=" << img.w << " h=" << img.h
<< " lo=(" << data.batches[i].rampLo[0] << "," << data.batches[i].rampLo[1] << "," << data.batches[i].rampLo[2] << ")"
<< "\n" << std::flush;
if (img.ok && img.w > 0 && img.h > 0)
{
LPDIRECT3DTEXTURE9 tex = NULL;
@@ -480,6 +486,64 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName)
// the texel LUMINANCE. argb = lerp(rampLo, rampHi, lum).
const float *lo = data.batches[i].rampLo;
const float *hi = data.batches[i].rampHi;
// TRANSLOCATION WARP (tsphere_mtl, warpBands>0): the swirl is the
// bintA cloud sampled in the bicone's cylindrical UVs (U=angle,
// V=axial) which, viewed down the throat axis, ARE log-polar
// coordinates -- exactly the mapping that matched the original
// (capture.png). Colour it by a NARROW lavender ramp at LOW
// contrast: compress the cloud luminance toward mid-grey before the
// ramp so the bands read as soft, intersecting spiral streaks (the
// "looking down a tornado" look) rather than hard concentric rings.
// BT_WARP_CONTRAST (default 0.55). Terrain/sky ramps (warpBands==0)
// stay full-contrast.
float warpContrast = 0.0f;
if (data.batches[i].warpBands > 0) {
static float s_ct = -1.0f;
// Full contrast (1.0 = cloud luminance untouched): the original's
// rings span the whole range dark->bright. Compressing toward mid-
// grey (<1) washed the swirl flat; 1.0 matched (cmp_final2.png).
if (s_ct < 0.0f) { const char *cv = getenv("BT_WARP_CONTRAST"); s_ct = cv ? (float)atof(cv) : 1.0f; if (s_ct < 0) s_ct = 0; if (s_ct > 4) s_ct = 4; }
warpContrast = s_ct;
}
// PRE-BLUR (tsphere only): the 64x64 bintA cloud is far too high-
// frequency for the huge throat wall -- minified there it ALIASES into
// the grainy radial "spokes" (the modern high-res render exposing what
// the 1995 low-res IG board low-passed away; the original swirl is soft
// concentric rings). Low-pass the cloud FIRST. Separable box blur,
// wrap-around (the texture tiles), 2 passes. BT_WARP_BLUR = radius
// (default 3; 0 = off).
std::vector<float> blurLum;
if (warpContrast > 0.0f)
{
static int s_blur = -1;
// Default OFF: with the scroll precision bug fixed the raw cloud reads
// as clean concentric rings (cmp_blur.png). Blur only ever masked the
// scroll "spokes"; >0 now just softens the rings. BT_WARP_BLUR=radius.
if (s_blur < 0) { const char *bv = getenv("BT_WARP_BLUR"); s_blur = bv ? atoi(bv) : 0; if (s_blur < 0) s_blur = 0; if (s_blur > 31) s_blur = 31; }
const int BW = img.w, BH = img.h;
blurLum.resize((size_t)BW * BH);
for (int y = 0; y < BH; y++)
for (int x = 0; x < BW; x++) {
uint32_t p = img.argb[(size_t)y * BW + x];
blurLum[(size_t)y*BW+x] = (0.299f*((p>>16)&0xFF)+0.587f*((p>>8)&0xFF)+0.114f*(p&0xFF))/255.0f;
}
if (s_blur > 0) {
std::vector<float> tmp((size_t)BW * BH);
const int R = s_blur; const float inv = 1.0f/(2*R+1);
for (int pass = 0; pass < 2; ++pass) {
for (int y = 0; y < BH; y++) // horizontal (wrap)
for (int x = 0; x < BW; x++) {
float s = 0; for (int k=-R;k<=R;k++){int xx=((x+k)%BW+BW)%BW; s+=blurLum[(size_t)y*BW+xx];}
tmp[(size_t)y*BW+x] = s*inv;
}
for (int y = 0; y < BH; y++) // vertical (wrap)
for (int x = 0; x < BW; x++) {
float s = 0; for (int k=-R;k<=R;k++){int yy=((y+k)%BH+BH)%BH; s+=tmp[(size_t)yy*BW+x];}
blurLum[(size_t)y*BW+x] = s*inv;
}
}
}
}
for (int y = 0; y < img.h; y++)
{
unsigned char *dst = (unsigned char*)lr.pBits + y * lr.Pitch;
@@ -487,7 +551,13 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName)
for (int x = 0; x < img.w; x++)
{
uint32_t p = src[x];
float lum = (0.299f*((p>>16)&0xFF) + 0.587f*((p>>8)&0xFF) + 0.114f*(p&0xFF)) / 255.0f;
float lum = blurLum.empty()
? (0.299f*((p>>16)&0xFF) + 0.587f*((p>>8)&0xFF) + 0.114f*(p&0xFF)) / 255.0f
: blurLum[(size_t)y*img.w + x];
if (warpContrast > 0.0f) { // soften the cloud toward mid-grey
lum = (lum - 0.5f) * warpContrast + 0.5f;
if (lum < 0.0f) lum = 0.0f; else if (lum > 1.0f) lum = 1.0f;
}
int rr = (int)((lo[0] + (hi[0]-lo[0])*lum) * 255.0f + 0.5f);
int gg = (int)((lo[1] + (hi[1]-lo[1])*lum) * 255.0f + 0.5f);
int bb = (int)((lo[2] + (hi[2]-lo[2])*lum) * 255.0f + 0.5f);
@@ -1179,8 +1249,14 @@ void d3d_OBJECT::SetTextureScrolling(const L4TEXOP *texture, Time targetRenderFr
D3DXMATRIX translate;
D3DXMatrixIdentity(&translate);
translate._31 = -texture->scrollUDelta * time;
translate._32 = texture->scrollVDelta * time;
// WRAP the scroll offset into [0,1) (the texture REPEAT-tiles, so only the
// fractional part is meaningful). Without this, scrollDelta*time grows without
// bound as the app runs; once the offset is large, adding it to the per-pixel UV
// COLLAPSES float precision -> the UVs quantise into coarse steps and a smooth
// texture shatters into grainy radial "spokes" (this was the translocation-warp
// swirl artifact, and would degrade every scrolling texture over a long session).
translate._31 = -fmodf(texture->scrollUDelta * time, 1.0f);
translate._32 = fmodf(texture->scrollVDelta * time, 1.0f);
mDevice->SetTransform(D3DTS_TEXTURE0, &translate);
}
}
+115 -1
View File
@@ -501,6 +501,7 @@ struct Builder {
bool currentPunch = false; // inside a PUNCH patch (SV_SPECIAL 0x2037, geogroup level)
bool currentShadowMat = false; // material name contains "shadow" (baked ground-shadow quads)
bool currentTSphere = false; // material is tsphere_mtl (translocation warp): ramp it despite normals
bool meshIsTSphere = false; // this OBJECT is the translocation warp -> smooth-tessellate the cone
bool currentHasEmissive = false;
float currentEmissive[3] = {0,0,0};
@@ -595,10 +596,33 @@ struct Builder {
currentHasEmissive && (currentColor & 0x00FFFFFFu) == 0;
const uint32_t vcol = pureEmissive ? 0xFF000000u
: (useRamp ? rampTint : currentColor);
// TRANSLOCATION WARP: tsphere's UVs run U=angle around the Z throat, V=axial
// along the tunnel -- i.e. cylindrical. Viewed down the throat axis (as the
// effect is), those ARE log-polar coordinates (angle, log-radius), the exact
// mapping the offline render matched to the original. Two shaping terms:
// * TWIST (BT_WARP_TWIST, default 0.3): a helical shear U += twist*V so the
// cloud SPIRALS inward (the "looking down a tornado" swirl) instead of
// forming plain concentric rings.
// * RADFREQ (BT_WARP_RADFREQ, default 0.8): axial (log-radius) scale -> band
// spacing. (BT_WARP_TILE_U still scales the angular frequency.)
// AUTHENTIC UVs (verified by rendering the true mesh vs the original -- see
// scratchpad/render_bicone.py + cmp_final2.png): the bicone is viewed ON its
// throat axis (eye centred via BT_WARP_EYE_UP=8.25) and merely SPUN in place, so
// the swirl reads as smooth CONCENTRIC rings. Any helical TWIST shears those
// rings into radial SPOKES (the "sphincter" the user rejected) -> default 0.
// RADFREQ 1.0 = the mesh's authored V (matches the original's ring spacing).
float tileU = 1.0f, tileV = 1.0f, twist = 0.0f;
if (currentTSphere) {
static float sTileU = -1.0f, sTileV = -1.0f, sTwist = -999.0f;
if (sTileU < 0.0f) { const char* e = getenv("BT_WARP_TILE_U"); sTileU = e ? (float)atof(e) : 1.0f; if (sTileU <= 0) sTileU = 1.0f; }
if (sTileV < 0.0f) { const char* e = getenv("BT_WARP_RADFREQ"); if (!e) e = getenv("BT_WARP_TILE_V"); sTileV = e ? (float)atof(e) : 1.0f; if (sTileV <= 0) sTileV = 1.0f; }
if (sTwist < -900.0f) { const char* e = getenv("BT_WARP_TWIST"); sTwist = e ? (float)atof(e) : 0.0f; }
tileU = sTileU; tileV = sTileV; twist = sTwist;
}
for (size_t i = 0; i < lx.size(); ++i) {
px.push_back(lx[i]); py.push_back(ly[i]); pz.push_back(lz[i]);
nx.push_back(0); ny.push_back(0); nz.push_back(0);
uu.push_back(lu[i]); vv.push_back(lv[i]);
uu.push_back(lu[i] * tileU + twist * lv[i]); vv.push_back(lv[i] * tileV);
col.push_back(vcol);
}
@@ -659,7 +683,15 @@ struct Builder {
batch.texPath = currentTex;
batch.texChannel = currentTexChannel;
batch.hasRamp = useRamp;
// tsphere warp flag: warpBands>0 signals L4D3D's ramp bake to CONTRAST-
// COMPRESS the bintA cloud toward mid-grey (BT_WARP_CONTRAST) before applying
// the narrow lavender ramp -> soft intersecting spiral streaks. (Value is
// just a nonzero flag now; the old per-vertex "band" model was wrong.)
if (currentTSphere)
batch.warpBands = 1;
for (int i = 0; i < 3; ++i) { batch.rampLo[i] = currentRampLo[i]; batch.rampHi[i] = currentRampHi[i]; }
batch.hasEmissive = currentHasEmissive;
for (int i = 0; i < 3; ++i) batch.emissive[i] = currentEmissive[i];
batch.lodNear = currentLodNear;
batch.lodFar = currentLodFar;
batch.punch = currentPunch;
@@ -897,6 +929,7 @@ struct Builder {
// and leave it raw gray. Flag it so the ramp bake runs anyway (the
// authentic look; matches L4VIDRND POVTranslocateRenderable).
currentTSphere = (full.find("tsphere_mtl") != std::string::npos);
if (currentTSphere) meshIsTSphere = true;
currentHasRamp = false;
if (res) {
MatInfo info = res->resolve(full);
@@ -909,6 +942,30 @@ struct Builder {
for (int i = 0; i < 3; ++i) currentEmissive[i] = info.emissive[i];
}
else { currentColor = colorForMaterial(full); currentTex.clear(); currentHasDiffuse = false; currentTexChannel = 0; }
// TRANSLOCATION WARP: override the material's coarse "sky" ramp with the
// NARROW LAVENDER ramp that matched the original (capture.png). The swirl
// is the bintA cloud coloured lo->hi by luminance; a wide sky ramp
// ({0,0,0.6}->white) posterises to hard concentric rings, whereas this
// narrow lavender ramp (LO ~mid lavender-blue, HI ~near-white, lavender
// cast) keeps it a soft, low-contrast spiral vortex. Env-tunable.
if (currentTSphere) {
static float wlo[3] = {-1.0f,0,0}, whi[3] = {0,0,0};
if (wlo[0] < 0.0f) {
const char *e;
// Wide lavender ramp (dark blue-violet -> near-white lavender):
// full luminance range matches the original's contrast + the
// bright central bloom (cmp_final2.png). Narrower ramps washed
// the swirl to a flat lavender.
e = getenv("BT_WARP_LO_R"); wlo[0] = e ? (float)atof(e) : 0.15f;
e = getenv("BT_WARP_LO_G"); wlo[1] = e ? (float)atof(e) : 0.12f;
e = getenv("BT_WARP_LO_B"); wlo[2] = e ? (float)atof(e) : 0.35f;
e = getenv("BT_WARP_HI_R"); whi[0] = e ? (float)atof(e) : 0.98f;
e = getenv("BT_WARP_HI_G"); whi[1] = e ? (float)atof(e) : 0.94f;
e = getenv("BT_WARP_HI_B"); whi[2] = e ? (float)atof(e) : 1.00f;
}
currentHasRamp = true;
for (int i = 0; i < 3; ++i) { currentRampLo[i] = wlo[i]; currentRampHi[i] = whi[i]; }
}
break;
}
for (const Chunk& ch : c.children) collect(ch);
@@ -930,7 +987,64 @@ struct Builder {
}
}
// SMOOTH the translocation warp cone. tsphere.bgf is a coarse 12-facet bicone;
// on the 1995 pod's low-res CRT (+ phosphor persistence + the fast spin) it read
// as a smooth tunnel, but on a modern high-res display the 12 flat facets stay
// crisp = the "12-sided funnel". Subdivide each triangle 4:1 and PROJECT the new
// vertices onto the true cone surface (revolution about the throat axis (0,8.25,z),
// ring radius 10.5 at z=0, apexes at z=+/-21), so the SHAPE becomes smooth while
// the UVs (hence the swirl) interpolate. BT_WARP_TESS = passes (default 3, 0=off).
void tessellateWarpCone() {
static int s_tess = -1;
if (s_tess < 0) { const char* e = getenv("BT_WARP_TESS"); s_tess = e ? atoi(e) : 3; if (s_tess < 0) s_tess = 0; if (s_tess > 5) s_tess = 5; }
const float AXIS_Y = 8.25f, RING_R = 10.5f, HALF_LEN = 21.0f;
for (int p = 0; p < s_tess; ++p) {
std::vector<uint32_t> out2;
out2.reserve(mesh->indices.size() * 4);
for (size_t i = 0; i + 3 <= mesh->indices.size(); i += 3) {
uint32_t tri[3] = { mesh->indices[i], mesh->indices[i+1], mesh->indices[i+2] };
uint32_t m[3];
for (int e = 0; e < 3; ++e) {
uint32_t a = tri[e], b = tri[(e + 1) % 3];
float mx = (px[a]+px[b])*0.5f, my = (py[a]+py[b])*0.5f, mz = (pz[a]+pz[b])*0.5f;
float t = std::fabs(mz) / HALF_LEN; if (t > 1.0f) t = 1.0f;
float rIdeal = RING_R * (1.0f - t);
float dy = my - AXIS_Y, r = std::sqrt(mx*mx + dy*dy);
if (r > 1e-5f) { float sc = rIdeal / r; mx *= sc; my = AXIS_Y + dy*sc; }
m[e] = (uint32_t)px.size();
px.push_back(mx); py.push_back(my); pz.push_back(mz);
nx.push_back(0); ny.push_back(0); nz.push_back(0);
uu.push_back((uu[a]+uu[b])*0.5f); vv.push_back((vv[a]+vv[b])*0.5f);
col.push_back(col[a]);
}
const uint32_t sub[12] = { tri[0],m[0],m[2], m[0],tri[1],m[1], m[2],m[1],tri[2], m[0],m[1],m[2] };
for (int k = 0; k < 12; ++k) out2.push_back(sub[k]);
}
mesh->indices.swap(out2);
}
mesh->tris = (int)(mesh->indices.size() / 3);
}
void finish() {
if (meshIsTSphere) {
tessellateWarpCone();
// tessellateWarpCone REBUILT mesh->indices -> the per-batch indexStart/
// indexCount set back in buildPmesh now point at the STALE pre-tessellation
// range, so the draw would render only that fragment (the "sliver").
// tsphere is a single ramped batch covering the whole cone: re-point it at
// the full new index list.
if (!mesh->batches.empty()) {
mesh->batches[0].indexStart = 0;
mesh->batches[0].indexCount = (uint32_t)mesh->indices.size();
}
// THE SWIRL (decomp FUN_00453dc4 does only spin+submit -> the pattern is the
// MATERIAL, i.e. the bintA cloud through the ramp, NOT geometry). Earlier we
// wrongly painted per-vertex "bands" by |z|/halfLen -> sterile concentric
// rings. The real look is the cloud in the bicone's log-polar UVs (angle,
// log-radius) with a helical twist + narrow lavender ramp at low contrast,
// all set up on the UVs/ramp above; the mesh just carries it and spins. So
// finish() only smooths the facets -- no vertex-colour override here.
}
size_t nv = px.size();
mesh->verts.resize(nv);
for (size_t i = 0; i < nv; ++i) {
+7
View File
@@ -38,6 +38,13 @@ struct BgfDrawBatch {
bool hasRamp = false;
float rampLo[3] = {0,0,0};
float rampHi[3] = {1,1,1};
// TRANSLOCATION WARP band count: the IG board realised a ramp as a QUANTISED
// shade colour-map (~16 discrete levels; the material 'dither' field exists to
// soften the band seams), which turns a smooth intensity into clean concentric
// CONTOUR BANDS. Our generic ramp bake is a continuous lerp (right for terrain/
// sky). For tsphere_mtl only, snap the ramp index to this many levels so the
// warp reads as bands, not a blotchy cloud. 0 = continuous (all other ramps).
int warpBands = 0;
// EMISSIVE (tag 0x26): self-lit material colour. Pure-emissive materials
// (diffuse black + emissive set, e.g. btpolar:pintBIceEmit_mtl -- the polar
// ice mounds) render as an UNLIT glow: tex x emissive, no sun/ambient.