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:
co-authored by
Claude Opus 4.8
parent
a35f321ba1
commit
0bfb3d4ab3
@@ -68,6 +68,7 @@ precise than anything you can infer.
|
||||
| Subsystems, the factory, heat/weapons/power | `context/subsystems.md` |
|
||||
| Damage zones, targeting, firing, death | `context/combat-damage.md` |
|
||||
| Rendering, LODs, materials, sky, shadows, beams | `context/rendering.md` |
|
||||
| The death/respawn translocation warp (tsphere vortex) | `context/translocation-warp.md` |
|
||||
| Cockpit gauges / MFD HUD | `context/gauges-hud.md` |
|
||||
| Multiplayer, replication, netcode | `context/multiplayer.md` |
|
||||
| Pod hardware, monitors, RIO, MFD surfaces | `context/pod-hardware.md` |
|
||||
|
||||
@@ -163,9 +163,18 @@ The authentic stack is DEFAULT-ON (`BTEnvOn`, mechrecon.hpp): `BT_GAIT_CUTOVER`,
|
||||
| `BT_AIM_LOG` | `[pick]` ray/box/hit diagnostics (Mech::PickRayHit) |
|
||||
| `BT_FIRE_ARC=<deg>` | OPT-IN external-camera fire-arc clamp (unset = authentic no-arc) |
|
||||
| `BT_START_INSIDE` | begin in the cockpit view (V toggles) |
|
||||
| `BT_TLOC_LOG` | translocation-warp lifecycle log (`[tloc] warp COLLAPSE/EXPAND/phase=…`) |
|
||||
| `BT_WARP_*` | translocation-warp visual tuning — all default to the verified values (see [[translocation-warp]]): `EYE_UP=8.25` (on-axis), `SPIN=4`, `TWIST=0`, `CONTRAST=1.0`, `LO_*/HI_*` (lavender ramp), `BLUR=0`, `TESS=3`, `MIP=1`, `CULL=cw`, `ANISO=0` |
|
||||
| `BT_WARP_SELFTEST` / `BT_WARP_SELFSHOT=<prefix>` | DIAG (off by default): force a held warp in a solo game / dump backbuffer frames to disk (visual-verification harness) |
|
||||
|
||||
Full render/locomotion gates (BT_RAMP, BT_MATPRI, BT_CULL, BT_SHADOW_*, BT_LODSEL, BT_ADDLOD,
|
||||
BT_PUNCH, …) are catalogued in [[rendering]].
|
||||
BT_PUNCH, …) are catalogued in [[rendering]]. Warp visuals: [[translocation-warp]].
|
||||
|
||||
**Translocation-warp addresses** [T1]: `POVTranslocateRenderable` = L4VIDRND.cpp:1749 / .h:638; its
|
||||
per-frame Execute (spin about local Z + submit, NO texcoord/ramp work) = **`FUN_00453dc4`**
|
||||
(`reference/decomp/all/part_007.c`), using axis-rotation helper `FUN_0040998c` (axis idx 2 = Z).
|
||||
Respawn SIM path: `DropZoneReply` `FUN_004bffd0` → `Mech::Reset` @0x4009fb74; `CreatePlayerVehicle`
|
||||
`FUN_004bfcac`. See [[translocation-warp]], [[multiplayer]].
|
||||
|
||||
---
|
||||
|
||||
|
||||
+13
-4
@@ -247,10 +247,19 @@ transition, HUD all landed since P6): console egg → mesh → RunningMission on
|
||||
player-attribute replication (SimulationState/DropZoneLocation) isn't wired (on a replicant both read
|
||||
0xCD… uninitialised — confirmed via `BT_TLOC_LOG`). Verified 2-node: on B's respawn the sphere
|
||||
COLLAPSES (scale ~100→5) then EXPANDS (→150) at the valid drop-zone origin, deduped to ≤2 active,
|
||||
respawn cycle un-regressed, no render errors. Env gate `BT_TLOC_LOG`. REMAINING (follow-up): the
|
||||
visual appearance (colour/translucency/size on screen) is unverified by logs — needs a live look;
|
||||
and the AUTHENTIC peer path (see OTHERS' spheres) needs player SimulationState/DropZoneLocation
|
||||
replication — until then the local-player sphere stands in.
|
||||
respawn cycle un-regressed, no render errors. Env gate `BT_TLOC_LOG`.
|
||||
- **✅ VISUAL DONE + LIVE-VERIFIED (task #52, follow-up):** the on-screen look now matches the
|
||||
original cabinet photo (`capture.png`) — a smooth spinning lavender vortex with a bright core. The
|
||||
full geometry/material/render authority is now **[[translocation-warp]]**; headlines: tsphere is a
|
||||
12-facet BICONE viewed ON-AXIS + spun in place (decomp `FUN_00453dc4` Z-spin, which the WinTesla
|
||||
port had stubbed), coloured by the `bintA` cloud through a WIDE lavender ramp, drawn as SKY. The
|
||||
defect that hid it for the whole effort was NOT the warp code — it was a general
|
||||
**texture-scroll precision-collapse bug** in `L4D3D::SetTextureScrolling` (offset = `scrollDelta ×
|
||||
absolute_time` → unbounded → UV precision loss → radial "spokes"), fixed with `fmodf`; that also
|
||||
cleans the scrolling beam grit. See [[reconstruction-gotchas]] §13. REMAINING: the AUTHENTIC peer
|
||||
path (see OTHERS' spheres) still needs player SimulationState/DropZoneLocation replication — until
|
||||
then the local-player sphere stands in; and confirm the COLLAPSE (death-side) warp fires on every
|
||||
MP death path (the force-damage test logged EXPAND reliably, COLLAPSE less so).
|
||||
NOTE the respawn-cycle SIM path is separate + done: `DropZoneReply` (FUN_004bffd0) → `Mech::Reset`
|
||||
(@0049fb74) + the level-2 "translocated" cockpit alarm; `CreatePlayerVehicle` (FUN_004bfcac,
|
||||
disassembled — make+link only, matches RP analog). The sphere is a pure RENDER-side watcher on the
|
||||
|
||||
@@ -203,6 +203,27 @@ AVs. Fill gaps with a shared read-only pad member. Same for a class's `<Name>Att
|
||||
|
||||
---
|
||||
|
||||
## 13. Accumulated-time precision collapse (rate × absolute-time in a matrix)
|
||||
|
||||
Any matrix element (or coordinate) computed as **`rate × absolute_runtime`** grows without bound. Float
|
||||
has ~7 significant digits, so once the value is large its FRACTIONAL precision is gone — and if that
|
||||
value is then added to a per-pixel/per-vertex quantity, the result **quantizes into coarse steps**. The
|
||||
visible signature is a smooth field shattering into grainy stair-steps or radial "spokes" that get
|
||||
worse the longer the app runs (and are invisible right after launch).
|
||||
|
||||
- **Archetype (the translocation-warp spokes):** `L4D3D::SetTextureScrolling` set a texture-matrix
|
||||
translate `_31 = -scrollUDelta * targetRenderFrame` (targetRenderFrame = absolute time). Within
|
||||
seconds the UV offset was large enough that adding it to the per-pixel UV collapsed precision → the
|
||||
scrolled cloud rendered as radial grain. It degraded EVERY scrolling texture (beams `bexp`, exhaust),
|
||||
but the full-screen warp on black made it obvious. **Fix:** wrap into the periodic range —
|
||||
`fmodf(rate*time, period)` — identical under REPEAT tiling / rotation, but full precision. Prefer
|
||||
delta-time accumulators that you wrap each frame, over `rate*absolute_time`. [T2]
|
||||
- **Tell from a symptom:** if a smooth animated/scrolled surface looks progressively grainier or
|
||||
"steps" and a STILL/offline render of the same data is clean, suspect an unwrapped time accumulator,
|
||||
not the geometry, texture, or filter. (Cost most of task #52's visual effort — see [[translocation-warp]].)
|
||||
|
||||
---
|
||||
|
||||
## Diagnostic recipe (the standard loop)
|
||||
1. Read the RAW decomp `reference/decomp/all/part_*.c` for the `FUN_xxxx`.
|
||||
2. Map `FUN_`/`DAT_`/`this+0xNN` to engine symbols via BT headers + WinTesla MUNGA source +
|
||||
|
||||
@@ -61,6 +61,15 @@ The `dpl_*` beam renderable was never ported → beams are drawn by `BTPushBeam`
|
||||
origin re-anchors to the live muzzle every frame (can't run out from under your own beam). See
|
||||
[[combat-damage]] for fire-rate/muzzle. [T2]
|
||||
|
||||
## Translocation warp (the respawn vortex) — see [[translocation-warp]]
|
||||
The death/respawn "blue whirlwind" is `tsphere.bgf` (a 12-facet bicone) spun on-axis with the `bintA`
|
||||
cloud through a wide lavender ramp, drawn as SKY. Full geometry/material/lifecycle/env in
|
||||
[[translocation-warp]]. ⚠ **Rendering-wide fix that came out of it:** `L4D3D::SetTextureScrolling`
|
||||
computed its texture-matrix offset as `scrollDelta × absolute_time`, which grew unbounded and collapsed
|
||||
UV float precision → grainy/spoked scrolled textures the longer a session ran. Wrapped with `fmodf(…,
|
||||
1.0f)`. This also cleans up the scrolling `bexp` beam grit and any other `SCROLL` material. [T2]
|
||||
(Bug class: [[reconstruction-gotchas]] §13.)
|
||||
|
||||
## Performance — the 10fps→60fps fix (~50-80× render speedup)
|
||||
The 10fps baseline had THREE stacked causes, all fixed (engine tree): (1) **`D3DCREATE_SOFTWARE_
|
||||
VERTEXPROCESSING`** — every vertex transformed+lit on the CPU → HARDWARE first; (2) D3DX `DrawSubset`
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
id: translocation-warp
|
||||
title: "Translocation warp — the death/respawn vortex (tsphere)"
|
||||
status: established
|
||||
source_sections: "multiplayer.md (lifecycle + trigger wiring, task #52); reference/decomp FUN_00453dc4 (translocate Execute); L4VIDRND POVTranslocateRenderable @1749/.h:638; scratchpad/render_bicone.py (offline mesh match vs capture.png)"
|
||||
related_topics: [rendering, multiplayer, combat-damage, reconstruction-gotchas, asset-formats, bgf-format]
|
||||
key_terms: [material-ramp]
|
||||
open_questions:
|
||||
- "AUTHENTIC peer path (seeing OTHERS' spheres) needs player SimulationState/DropZoneLocation replication; until then the LOCAL-player sphere stands in."
|
||||
- "In the 2-node force-damage test only the EXPAND (respawn) warp logs reliably; confirm the COLLAPSE (death-side) fires on every MP death path."
|
||||
---
|
||||
|
||||
# Translocation warp (the respawn vortex)
|
||||
|
||||
The "blue whirlwind" respawn effect from the 1995 pod: on death the pilot is sucked into a spinning
|
||||
energy vortex; on respawn it blows outward and the reborn mech's world snaps back. VWE calls it
|
||||
**translocate** (not "whirlwind" — that keyword search is why an earlier scout wrongly declared it
|
||||
"not a shipped asset"). It is a pure **render-side** effect watching the player's `SimulationState`
|
||||
dial; the respawn SIM path (DropZoneReply → `Mech::Reset`) is separate ([[multiplayer]], [[combat-damage]]).
|
||||
|
||||
This topic is the VISUAL/RENDER authority. The trigger wiring + the state machine's engine grounding
|
||||
live in [[multiplayer]] (task #52). The systemic bug the effect exposed is in [[reconstruction-gotchas]] §13.
|
||||
|
||||
## The asset — `content/VIDEO/GEO/TSPHERE.BGF` [T1, dumped]
|
||||
NOT a sphere — a coarse **12-facet BICONE** (two cones base-to-base along local **Z**), 2085 bytes:
|
||||
- 45 vertices, one `CONN` list = **40 single-sided triangles** (material `btfx:tsphere_mtl`).
|
||||
- Apexes at **z = ±21** (both at x=0, y=8.25, radius 0); shared **ring at z=0, radius 10.5** (a 12-gon).
|
||||
- Geometric axis = the line (x=0, **y=8.25**, z) — the "throat." Bound center (0, 8.25, 0), r≈21.
|
||||
- **UVs are cylindrical:** U = angle around the throat, authored into the narrow band **[0.131, 0.656]**
|
||||
(~half the texture, wrapped around the full 360°); V = axial, **[0, 2]** (ring at v=1.0, apexes at
|
||||
v≈0 / 2). Viewed straight down the throat these U,V ARE (angle, radius) — a polar map. This is why
|
||||
spinning it reads as concentric rings, and why UV twist shears rings into spokes.
|
||||
- Dump/parse tool: `scratchpad/dump_tsphere.py` (BGF magic `DIV-BIZ2` at 0; chunk tagword id =
|
||||
`tw & 0x2fff`, len-width from bits 0x8000/0x4000; vertex tag `0x0089` = XYZ|N|UV stride 32).
|
||||
|
||||
The loader **doubles** every tri (fwd `a b c` + rev `a c b`) → 80 tris (`emitTri`, bgfload.cpp), then
|
||||
`tessellateWarpCone()` subdivides 4:1 × `BT_WARP_TESS` (default 3) projecting each new midpoint onto
|
||||
the true cone surface (radius `10.5*(1-|z|/21)`) → ~5120 smooth tris. Untessellated, the 12 flat
|
||||
facets read as a "12-sided funnel"; tessellation is what makes the swirl smooth on a modern display.
|
||||
(The 1995 low-res CRT + phosphor + fast spin hid the facets for free.)
|
||||
|
||||
## The material — `btfx:tsphere_mtl` (in `VIDEO/MAT/<tod>/BTFX.BMF`) [T1]
|
||||
`tsphere_mtl` → texture **`tsphere_scr_tex`** (a scrolling texture, SPECIAL `SCROLL 0.0 0.0 0.1 0.5`) →
|
||||
image **`bintA`** (`VIDEO/TEX/BINTA.VTX`, `DIV-VTX2`, **64×64 RGB, pixels at offset 57**, a smooth
|
||||
cloud-noise field). Ramp ref `sky`; `IMMUNE 1` (fog-immune). So authentically the swirl COLOUR is the
|
||||
`sky` ramp remapping the grayscale bintA cloud, scrolled, fog-immune.
|
||||
|
||||
## The visual model — VERIFIED against `capture.png` [T2, in-game + offline-matched]
|
||||
The correct look = **the bintA cloud, on the bicone, viewed ON-AXIS, spun in place, coloured by a wide
|
||||
lavender ramp.** Concretely, and each of these was a hard-won correction:
|
||||
|
||||
- **On-axis, not off-axis.** The eye is parented so the bicone THROAT axis passes through it (matrix
|
||||
term `eyeUp = Translate(0,-8.25,0)` cancels the +8.25 authored offset). Looking straight down the
|
||||
throat, the cloud's polar UVs read as **concentric rings** converging to a bright center. Placing the
|
||||
eye off-axis (the authentic mesh-origin, 8.25 below the axis) gives an off-center "tornado" that does
|
||||
NOT match the original. `BT_WARP_EYE_UP` (default 8.25 = on-axis).
|
||||
- **Spun in place (this IS the animation).** The decomp translocate Execute **FUN_00453dc4** does ONLY:
|
||||
accumulate an angle (`+0x80 += +0x7c`, wrap 2π) and write a **Z-axis** quaternion rotation
|
||||
(`FUN_0040998c` axis idx 2), then submit. No texcoord/palette/ramp/texture-matrix work — proof the
|
||||
swirl is the MATERIAL, not geometry. The WinTesla port STUBBED this spin; restoring it is essential.
|
||||
Matrix order matters: `world = eyeUp * spin * local * tilt * invView` puts the axis ON the spin-Z
|
||||
BEFORE rotating, so it spins in place (no precession). `BT_WARP_SPIN` (rad/s, default 4).
|
||||
- **Wide lavender ramp, full contrast — NOT the sky ramp, NOT geometry bands.** The bake overrides the
|
||||
material's coarse `sky` ramp with a NARROW-hued but FULL-RANGE lavender: `LO=(0.15,0.12,0.35)`,
|
||||
`HI=(0.98,0.94,1.0)`, contrast 1.0. The bintA luminance indexes LO→HI per texel (baked into the
|
||||
texture, drawn WHITE). Dark→lavender across the full range = distinct soft rings + a bright core.
|
||||
(`BT_WARP_LO_R/G/B`, `BT_WARP_HI_R/G/B`, `BT_WARP_CONTRAST`.)
|
||||
- **No UV twist, radfreq 1.0.** U += twist·V shears the concentric rings into a spiral/spokes — wrong
|
||||
for this near-concentric target. `BT_WARP_TWIST` default **0**; `BT_WARP_RADFREQ`/`TILE_U` default 1.
|
||||
- **Isotropic linear + trilinear mips, no blur.** Anisotropic filtering smears the grazing throat wall
|
||||
radially → streaks; keep isotropic. Mips help (trilinear); base-only is sharper/worse. The pre-blur
|
||||
hook (`BT_WARP_BLUR`) defaults **0** — it only ever existed to mask the scroll bug (below); with that
|
||||
fixed the raw cloud reads as clean rings.
|
||||
|
||||
**Discarded WRONG models (do not revive):** per-vertex geometry "shade bands" (concentric contours from
|
||||
`|z|`); a screen-space **log-polar** billboard; a narrow low-contrast ramp; an off-axis "tornado"; a
|
||||
helical UV twist; anisotropic filtering; heavy texture blur; a "garbage autogen-mip" theory. Each was
|
||||
plausible and each was wrong — the real defect was the scroll bug.
|
||||
|
||||
## The draw [T2] (`btl4vid.cpp BTDrawTranslocationSpheres`, POV branch)
|
||||
Drawn as **SKY** (`drawAsSky`, `PASS_SKY`): OPAQUE, `CULLMODE=D3DCULL_CW` (one winding of the
|
||||
double-sided mesh → no z-fight), z-test on, **LIGHTING off**, **FOG off** (`IMMUNE 1`). `COLOROP =
|
||||
SELECTARG1(TEXTURE)` — the ramp is already baked into the texture, so draw it DIRECT; do NOT modulate
|
||||
by a TFACTOR/vertex tint (that double-tints the ramp — the ramp-bake KB note, `docs/PROGRESS_LOG.md`).
|
||||
The world behind is blacked by the `SetIsDead` mask (below) so only the vortex shows during the wait.
|
||||
Non-POV (observer over-the-shoulder view of a peer) is a port EXTENSION: alpha-blended in the alpha
|
||||
pass so it isn't sky-occluded.
|
||||
|
||||
## The lifecycle state machine [T1 grounding, T2 impl] — mirrors `POVTranslocateRenderable`
|
||||
`gWarpPhase`: 0 idle → **1 InitialCollapse** (scale `COLLAPSE_START 100`→1 over `COLLAPSE_TIME 1.3s`) →
|
||||
**3 WaitForReincarnate** (scale 1, Lissajous wobble `cos(t·3.33)/sin(t·2.5)·TRANSLATE_LIMIT 2.0`,
|
||||
raises the world mask) → **2 ExpandReveal** (scale 1→`EXPAND_END 150` over `EXPAND_TIME 1.0s`, drops
|
||||
the mask) → 0. Spins throughout.
|
||||
|
||||
**The world mask** = `L4Application::SetIsDead(bool)` (L4APP.h:54), reached via the `BTSetWorldDead(int)`
|
||||
bridge added in L4VIDRND.cpp. It is a PLAIN BOOL that gates ONLY render passes (world/decal/sky/alpha/
|
||||
particles/reticle IsDead() readers in L4VIDEO.cpp) — NOT camera/input/targeting/sim. Safe to drive
|
||||
directly. (⚠ An earlier regression came from pulsing the player `SimulationState` for the same effect —
|
||||
that DID corrupt camera/targeting; `SetIsDead` is the correct lever. See f053535.) Failsafes: the wait
|
||||
times out (12s) and force-unmasks on mission-end / no-DropZones so the world can never stay black.
|
||||
|
||||
## THE BUG that hid the swirl for the whole effort — texture-scroll precision collapse [T2]
|
||||
**Symptom:** grainy radial "spokes" from screen center, on black — nothing like the smooth rings.
|
||||
Survived every geometry/param/filter change; my offline mesh rasterizer (`scratchpad/render_bicone.py`)
|
||||
rendered the SAME mesh+texture+params **cleanly**. The one thing offline didn't do was **scroll**.
|
||||
|
||||
**Cause (`L4D3D.cpp SetTextureScrolling`):** the scroll set a texture-matrix translate
|
||||
`_31 = -scrollUDelta * time`, `_32 = scrollVDelta * time`, where `time = targetRenderFrame` is
|
||||
**absolute runtime**. That product grows without bound; within seconds the UV offset is large enough
|
||||
that adding it to the per-pixel UV **collapses float precision** — the coordinates quantize into coarse
|
||||
steps and the smooth cloud shatters into radial streaks + grain.
|
||||
|
||||
**Fix:** wrap each offset into [0,1) with `fmodf(scrollDelta*time, 1.0f)` — identical under REPEAT
|
||||
tiling, full precision. Smooth swirl restored. **This bug silently degraded EVERY scrolling texture
|
||||
(weapon beams `bexp`, exhaust) the longer a session ran** — the warp (full-screen, on black) just made
|
||||
it impossible to ignore. Generalized in [[reconstruction-gotchas]] §13.
|
||||
|
||||
## Trigger wiring (summary; full detail in [[multiplayer]]) [T2]
|
||||
Local player's own death → `BTStartWarpCollapsePOV()` (btplayer.cpp `VehicleDeadMessageHandler`,
|
||||
`deathCount==-1`, guarded `this==GetMissionPlayer()`). Respawn `DropZoneReply` → `BTStartWarpExpandPOV()`
|
||||
(local-guarded). `BTWarpForceUnmask()` on mission-end / no-DropZones. Observer replicant un-wreck fires
|
||||
the world-anchored `BTStartWarpEffect(x,y,z)` (mechdmg.cpp). The peer path is a stand-in until player
|
||||
`SimulationState`/`DropZoneLocation` replication exists.
|
||||
|
||||
## How it was solved (method) + verification [T2]
|
||||
1. **Dump the real asset** (`dump_tsphere.py`) — established it's a bicone with cylindrical UVs, ending
|
||||
a long chain of wrong guesses about the geometry.
|
||||
2. **Offline mesh rasterizer** (`render_bicone.py`) — projected + textured the true 45-vtx mesh with the
|
||||
game's exact transform order and swept eye-height / spin / ramp / contrast against a crop of
|
||||
`capture.png`. Matched at on-axis + wide ramp + no twist (`scratchpad/cmp_final2.png`). This proved
|
||||
the LOOK is achievable and isolated the in-game defect (offline was clean → the delta is scroll).
|
||||
3. **In-engine self-shot** (`BT_WARP_SELFSHOT=<prefix>` dumps backbuffer frames to disk; `BT_WARP_SELFTEST=1`
|
||||
forces a held warp in a solo game) — compared in-game frames to the target WITHOUT foregrounding the
|
||||
window. This is how the scroll cause was pinned (scroll-off frame was instantly clean).
|
||||
4. **Confirmed live** in the 2-node force-damage respawn test (`scratchpad/mp_respawn6.sh` pattern):
|
||||
victim node dies → masked swirl → EXPAND-reveal, un-regressed.
|
||||
|
||||
## Env gates (all default to the verified values)
|
||||
`BT_WARP_EYE_UP=8.25` · `BT_WARP_SPIN=4` · `BT_WARP_TWIST=0` · `BT_WARP_CONTRAST=1.0` ·
|
||||
`BT_WARP_LO_R/G/B=0.15/0.12/0.35` · `BT_WARP_HI_R/G/B=0.98/0.94/1.0` · `BT_WARP_BLUR=0` ·
|
||||
`BT_WARP_TESS=3` · `BT_WARP_MIP=1` · `BT_WARP_TILE_U=1` · `BT_WARP_RADFREQ=1` · `BT_WARP_ANISO=0` ·
|
||||
`BT_WARP_CULL=cw` · `BT_WARP_TINT=0` · `BT_WARP_TILT=0` · `BT_TLOC_LOG` (lifecycle log) ·
|
||||
`BT_WARP_SELFTEST` / `BT_WARP_SELFSHOT` (DIAG capture harness — off by default; retained for visual verification of the open peer/collapse work).
|
||||
|
||||
## Key files
|
||||
- `game/reconstructed/btl4vid.cpp` — state machine, matrix, POV/peer draw, self-test/self-shot hooks.
|
||||
- `engine/MUNGA_L4/bgfload.cpp` — tsphere detect (`tsphere_mtl`), cylindrical-UV + lavender-ramp
|
||||
override, `tessellateWarpCone`, batch `warpBands` flag.
|
||||
- `engine/MUNGA_L4/L4D3D.cpp` — ramp bake (lavender + contrast + optional blur) **and the
|
||||
`SetTextureScrolling` fmod fix**.
|
||||
- `engine/MUNGA_L4/L4VIDRND.cpp` — `BTSetWorldDead` bridge to `L4Application::SetIsDead`.
|
||||
|
||||
## Key Relationships
|
||||
- Draws through: [[rendering]] (L4D3D sky pass, material ramp, scrolling textures).
|
||||
- Triggered by / wired in: [[multiplayer]] (SimulationState dial, DropZone respawn), [[combat-damage]]
|
||||
(death transition, un-wreck).
|
||||
- Exposed the bug class: [[reconstruction-gotchas]] §13 (accumulated-time precision collapse).
|
||||
- Asset formats: [[asset-formats]] (VTX), [[bgf-format]] (BGF chunks, LOD/CONN).
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
+178
-13
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
|
||||
Reference in New Issue
Block a user