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
+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) {