feat(#188): fading-wall + sliding-door translucency; hold open past animation settle
Lands the fading-secret-door feature and fixes the door "flip-back" that surfaced while testing it. #188 — fading-wall doors (e.g. "Pedestal Weak Spot") fade their wall part out via TransparentPartHook instead of swinging: - TranslucencyHookSink consumes TransparentPartHook -> TranslucencyFadeManager (per-(entity,part) linear translucency ramp; holds at End frame). - WbDrawDispatcher: new per-instance alpha SSBO (binding 7); ClassifyBatches takes opacityMultiplier (1 - translucency, per CMaterial::SetTranslucencySimple 0x005396f0) forcing AlphaBlend; fully-invisible parts skipped. - mesh_modern.vert/.frag: binding-7 InstanceAlphaBuf -> vOpacityMultiplier -> FragColor.a *= vOpacityMultiplier. - Register AP-89: the fade multiplies sampled texture alpha, not a separate D3D9 material alpha channel (observably identical for texture-alpha==1 surfaces). Door flip-back fix (affected BOTH #188 fading walls AND #187 sliding doors): a door/wall that finished opening holds a single unchanging frame, so the uncommitted IsEntityCurrentlyMoving cache-bypass narrowing dropped it onto the Tier-1 static cache -- which only remembers the REST pose + opacity 1.0 -- snapping it visually shut/opaque while physics stayed open. Reverted that narrowing: every Sequencer entity stays on the per-frame path (live pose + live fade opacity), the known-good pre-optimization behavior. The per-frame CPU cost that narrowing chased was a Debug-build artifact -- Release is GPU-bound (~200 fps in Sawato, measured), so the unconditional add is free where it matters. Left a code comment barring re-introduction. Tests: full Core suite green (2649 passed, 2 skipped). Live visual gate PASSED -- both fading-wall and sliding doors hold open. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e2e285b855
commit
3284dd0aed
12 changed files with 625 additions and 14 deletions
|
|
@ -799,6 +799,14 @@ public sealed class GameWindow : IDisposable
|
|||
// from the animation pipeline flip the matching LightSource.IsLit.
|
||||
private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
|
||||
|
||||
// #188 — TransparentPartHook fires from the animation pipeline drive
|
||||
// a per-(entity,part) translucency ramp; WbDrawDispatcher reads it
|
||||
// per frame. Wired into the hook router in OnLoad, advanced once per
|
||||
// frame in the main loop regardless of _animatedEntities membership
|
||||
// (a fade must keep running even if its entity's one-shot cycle ends).
|
||||
private readonly AcDream.Core.Rendering.TranslucencyFadeManager _translucencyFades = new();
|
||||
private AcDream.Core.Rendering.TranslucencyHookSink? _translucencySink;
|
||||
|
||||
// Phase G.1 sky renderer + shared UBO. Created once the GL context
|
||||
// exists in OnLoad; shared across every other renderer via
|
||||
// binding = 1 so terrain/mesh/instanced/sky all read the same
|
||||
|
|
@ -1425,6 +1433,11 @@ public sealed class GameWindow : IDisposable
|
|||
_lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting);
|
||||
_hookRouter.Register(_lightingSink);
|
||||
|
||||
// #188 — TransparentPartHook (per-part translucency fade, e.g.
|
||||
// the "fading wall" secret-passage doors) routes here.
|
||||
_translucencySink = new AcDream.Core.Rendering.TranslucencyHookSink(_translucencyFades);
|
||||
_hookRouter.Register(_translucencySink);
|
||||
|
||||
// Phase E.2 audio: init OpenAL + hook sink. Suppressible via
|
||||
// ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers.
|
||||
if (!_options.NoAudio)
|
||||
|
|
@ -2541,7 +2554,7 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
|
||||
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
|
||||
_classificationCache);
|
||||
_classificationCache, _translucencyFades);
|
||||
// A.5 T22.5: apply A2C gate from quality preset.
|
||||
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
|
||||
|
||||
|
|
@ -2648,7 +2661,10 @@ public sealed class GameWindow : IDisposable
|
|||
_worldState.TryGetLandblock(id, out var lb))
|
||||
{
|
||||
foreach (var ent in lb!.Entities)
|
||||
{
|
||||
_lightingSink.UnregisterOwner(ent.Id);
|
||||
_translucencyFades.ClearEntity(ent.Id); // #188
|
||||
}
|
||||
}
|
||||
_terrain?.RemoveLandblock(id);
|
||||
_physicsEngine.RemoveLandblock(id);
|
||||
|
|
@ -4708,6 +4724,7 @@ public sealed class GameWindow : IDisposable
|
|||
// double-register the new one. (Was gated on logDelete — harmless only
|
||||
// while live weenies registered no lights, which is no longer true.)
|
||||
_lightingSink?.UnregisterOwner(existingEntity.Id);
|
||||
_translucencyFades.ClearEntity(existingEntity.Id); // #188
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -7882,6 +7899,7 @@ public sealed class GameWindow : IDisposable
|
|||
// sort). Clear the owner's previous registration first: the
|
||||
// re-apply becomes idempotent, and a first apply is a no-op.
|
||||
_lightingSink.UnregisterOwner(entity.Id);
|
||||
_translucencyFades.ClearEntity(entity.Id); // #188
|
||||
var loaded = AcDream.Core.Lighting.LightInfoLoader.Load(
|
||||
datSetup,
|
||||
ownerId: entity.Id,
|
||||
|
|
@ -9045,6 +9063,12 @@ public sealed class GameWindow : IDisposable
|
|||
if (_animatedEntities.Count > 0)
|
||||
TickAnimations((float)deltaSeconds);
|
||||
|
||||
// #188 — advance translucency fades UNCONDITIONALLY (not gated on
|
||||
// _animatedEntities.Count): a one-shot open-cycle animation can
|
||||
// finish and drop its entity from _animatedEntities before the
|
||||
// fade's own Time has elapsed, but the ramp must keep running.
|
||||
_translucencyFades.AdvanceAll((float)deltaSeconds);
|
||||
|
||||
// Phase G.1: weather state machine — deterministic per-day roll
|
||||
// + transitions + lightning flash.
|
||||
var cal = WorldTime.CurrentCalendar;
|
||||
|
|
@ -9303,6 +9327,29 @@ public sealed class GameWindow : IDisposable
|
|||
// WalkEntitiesInto) treat null and an empty set identically, so an
|
||||
// always-non-null (possibly empty) set is behaviorally the same as
|
||||
// the old null-when-nothing-animated local.
|
||||
//
|
||||
// Every entity in _animatedEntities (i.e. every entity with a
|
||||
// Sequencer) is added UNCONDITIONALLY: membership here is the
|
||||
// "re-classify me every frame, don't use the Tier-1 static cache"
|
||||
// flag, and the Tier-1 cache captures an entity's REST pose +
|
||||
// opacity-1.0 exactly once. Any entity whose rendered state can
|
||||
// depart that rest — a door held at its final OPEN frame, a wall
|
||||
// held FADED-OUT by a TransparentPartHook — MUST stay on the
|
||||
// per-frame path so the live sequencer pose + TranslucencyFadeManager
|
||||
// opacity are read; otherwise it flips back to the stale cached
|
||||
// closed/opaque state the instant its animation settles.
|
||||
//
|
||||
// DO NOT re-narrow this to "only if the current cycle is multi-frame"
|
||||
// (the reverted IsEntityCurrentlyMoving gate, 2026-07-09): a settled
|
||||
// one-shot hold IS pose-stable frame-to-frame, but stable at the
|
||||
// HELD pose the cache does NOT contain — that is the door/fade
|
||||
// flip-back. The per-frame re-classification cost that narrowing
|
||||
// saved was a DEBUG-build artifact; Release is GPU-bound (the CPU
|
||||
// work hides under GPU time), so the unconditional add is free where
|
||||
// it matters. If a real Release CPU cost from static-prop
|
||||
// re-classification is ever measured, gate on "entity is at its
|
||||
// captured rest state" (default motion AND no active fade), never on
|
||||
// "is mid-cycle".
|
||||
_animatedIdsScratch.Clear();
|
||||
foreach (var k in _animatedEntities.Keys)
|
||||
_animatedIdsScratch.Add(k);
|
||||
|
|
@ -10440,6 +10487,12 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// IsEntityCurrentlyMoving REMOVED (2026-07-09): it powered a cache-bypass
|
||||
// narrowing that dropped settled-open doors / faded-out walls back onto the
|
||||
// stale Tier-1 rest-pose cache entry (the door/fade "flip-back"). See the
|
||||
// animatedIds build site — every Sequencer entity is now added
|
||||
// unconditionally, which is the known-good pre-optimization behavior.
|
||||
|
||||
// R3-W6: UpdatePlayerAnimation DELETED — the player's sequencer is
|
||||
// driven through the SAME MotionTableDispatchSink/DefaultSink funnel
|
||||
// remotes use (edge-driven DoMotion/StopMotion/set_hold_run in
|
||||
|
|
@ -11650,7 +11703,10 @@ public sealed class GameWindow : IDisposable
|
|||
_worldState.TryGetLandblock(id, out var lb))
|
||||
{
|
||||
foreach (var ent in lb!.Entities)
|
||||
{
|
||||
_lightingSink.UnregisterOwner(ent.Id);
|
||||
_translucencyFades.ClearEntity(ent.Id); // #188
|
||||
}
|
||||
}
|
||||
_terrain?.RemoveLandblock(id);
|
||||
_physicsEngine.RemoveLandblock(id);
|
||||
|
|
|
|||
|
|
@ -180,10 +180,13 @@ public static class RenderBootstrap
|
|||
// --- EntityClassificationCache (GameWindow ~217 — field initializer, new()) ---
|
||||
var classificationCache = new Wb.EntityClassificationCache();
|
||||
|
||||
// --- TranslucencyFadeManager (GameWindow — field initializer, new()) ---
|
||||
var translucencyFades = new AcDream.Core.Rendering.TranslucencyFadeManager();
|
||||
|
||||
// --- WbDrawDispatcher (GameWindow ~2377-2381) ---
|
||||
var drawDispatcher = new Wb.WbDrawDispatcher(
|
||||
gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter,
|
||||
bindless, classificationCache);
|
||||
bindless, classificationCache, translucencyFades);
|
||||
drawDispatcher.AlphaToCoverage = opts.Quality.AlphaToCoverage;
|
||||
|
||||
// --- Vitals dat font (GameWindow ~1820-1822) ---
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ in vec3 vWorldPos;
|
|||
in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert
|
||||
in flat uvec2 vTextureHandle;
|
||||
in flat uint vTextureLayer;
|
||||
in flat float vOpacityMultiplier; // #188
|
||||
|
||||
// uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test):
|
||||
// 0 = opaque pass — discard fragments with alpha < 0.95
|
||||
|
|
@ -102,5 +103,9 @@ void main() {
|
|||
|
||||
vec3 rgb = color.rgb * lit;
|
||||
rgb = applyFog(rgb, vWorldPos);
|
||||
FragColor = vec4(rgb, color.a);
|
||||
// #188: multiply the FINAL alpha only — the discard thresholds above stay
|
||||
// keyed on the raw sampled color.a, so the last few frames of a fade
|
||||
// (multiplier crossing under 0.05) still ramp smoothly toward zero rather
|
||||
// than popping invisible early against the discard cutoff.
|
||||
FragColor = vec4(rgb, color.a * vOpacityMultiplier);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,16 @@ layout(std430, binding = 6) readonly buffer InstanceIndoorBuf {
|
|||
uint instanceIndoor[];
|
||||
};
|
||||
|
||||
// #188: per-instance opacity multiplier, 1 per instance, parallel to the
|
||||
// binding=0 instance buffer (same instanceIndex). 1.0 = unmodified; <1.0
|
||||
// while a TransparentPartHook translucency fade is in flight for the
|
||||
// entity/part this instance belongs to (e.g. the "fading wall" secret-
|
||||
// passage doors). Multiplied against the sampled texture alpha in
|
||||
// mesh_modern.frag.
|
||||
layout(std430, binding = 7) readonly buffer InstanceAlphaBuf {
|
||||
float instanceAlpha[];
|
||||
};
|
||||
|
||||
// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal
|
||||
// alongside gl_Position. The array is sized 8 to match the CellClip plane budget
|
||||
// and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables
|
||||
|
|
@ -266,10 +276,12 @@ out vec3 vWorldPos;
|
|||
out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights)
|
||||
out flat uvec2 vTextureHandle;
|
||||
out flat uint vTextureLayer;
|
||||
out flat float vOpacityMultiplier; // #188
|
||||
|
||||
void main() {
|
||||
int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
|
||||
mat4 model = Instances[instanceIndex].transform;
|
||||
vOpacityMultiplier = instanceAlpha[instanceIndex]; // #188
|
||||
|
||||
vec4 worldPos = model * vec4(aPosition, 1.0);
|
||||
gl_Position = uViewProjection * worldPos;
|
||||
|
|
|
|||
|
|
@ -107,6 +107,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// miss-populate / hit-fast-path through the loop.
|
||||
private readonly EntityClassificationCache _cache;
|
||||
|
||||
// #188 — per-(entity, Setup-part) translucency ramp state (fading doors /
|
||||
// secret-passage walls). ClassifyBatches reads this per part to compute
|
||||
// the instance's opacity multiplier; never mutated here.
|
||||
private readonly AcDream.Core.Rendering.TranslucencyFadeManager _translucencyFades;
|
||||
|
||||
// ACDREAM_DISABLE_TIER1_CACHE=1 A/B diagnostic — forces every static
|
||||
// entity through the slow path. Read once in ctor.
|
||||
private readonly bool _tier1CacheDisabled =
|
||||
|
|
@ -150,6 +155,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// Mechanically a clone of _clipSlotData / _clipSlotSsbo.
|
||||
private uint _instIndoorSsbo;
|
||||
private uint[] _indoorData = new uint[256];
|
||||
|
||||
// #188: per-instance opacity multiplier (binding=7), one float per
|
||||
// instance, parallel to _instanceSsbo. 1.0 = unmodified (the dat's own
|
||||
// material/texture alpha, untouched); < 1.0 multiplies the shader's
|
||||
// sampled alpha for an entity mid-TransparentPartHook fade. Mechanically
|
||||
// a clone of _indoorData / _instIndoorSsbo, one binding higher.
|
||||
private uint _instAlphaSsbo;
|
||||
private float[] _alphaData = new float[256];
|
||||
// This frame's point-light snapshot, handed in by GameWindow before Draw via
|
||||
// SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1).
|
||||
private IReadOnlyList<LightSource>? _pointSnapshot;
|
||||
|
|
@ -340,7 +353,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
WbMeshAdapter meshAdapter,
|
||||
EntitySpawnAdapter entitySpawnAdapter,
|
||||
BindlessSupport bindless,
|
||||
EntityClassificationCache classificationCache)
|
||||
EntityClassificationCache classificationCache,
|
||||
AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gl);
|
||||
ArgumentNullException.ThrowIfNull(shader);
|
||||
|
|
@ -348,6 +362,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
ArgumentNullException.ThrowIfNull(meshAdapter);
|
||||
ArgumentNullException.ThrowIfNull(entitySpawnAdapter);
|
||||
ArgumentNullException.ThrowIfNull(classificationCache);
|
||||
ArgumentNullException.ThrowIfNull(translucencyFades);
|
||||
|
||||
_gl = gl;
|
||||
_shader = shader;
|
||||
|
|
@ -355,6 +370,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_meshAdapter = meshAdapter;
|
||||
_entitySpawnAdapter = entitySpawnAdapter;
|
||||
_cache = classificationCache;
|
||||
_translucencyFades = translucencyFades;
|
||||
|
||||
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
|
||||
_instanceSsbo = _gl.GenBuffer();
|
||||
|
|
@ -364,6 +380,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4
|
||||
_instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5
|
||||
_instIndoorSsbo = _gl.GenBuffer(); // #142 binding=6
|
||||
_instAlphaSsbo = _gl.GenBuffer(); // #188 binding=7
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1290,8 +1307,17 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
bool drewAny = false;
|
||||
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
|
||||
{
|
||||
foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
|
||||
// #188: setupPartIndex is the SAME index space
|
||||
// TransparentPartHook.PartIndex addresses — retail's CPartArray
|
||||
// numbers parts by their ordinal position in the Setup's own
|
||||
// part list (SetupPartTransforms.Compute is the other verified
|
||||
// consumer of this exact indexing: one Matrix4x4 per
|
||||
// Setup.Parts[i]). NOT the outer per-MeshRef loop index — a
|
||||
// MeshRef is acdream's own decomposition of top-level
|
||||
// attachments (weapon/shield/etc), a different concept.
|
||||
for (int setupPartIndex = 0; setupPartIndex < renderData.SetupParts.Count; setupPartIndex++)
|
||||
{
|
||||
var (partGfxObjId, partTransform) = renderData.SetupParts[setupPartIndex];
|
||||
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
|
||||
if (partData is null)
|
||||
{
|
||||
|
|
@ -1332,15 +1358,42 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
}
|
||||
|
||||
var restPose = partTransform * meshRef.PartTransform;
|
||||
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, collector);
|
||||
|
||||
// #188 retail CPhysicsPart::Draw (0x0050d7a0) early-out: once a
|
||||
// part's translucency hits EXACTLY 1.0 (fully invisible), retail
|
||||
// sets draw_state|=1 and skips the whole part outright — not a
|
||||
// blend to nothing. TranslucencyFadeManager.AdvanceAll guarantees
|
||||
// t=1 commits the bitwise-exact value so this check is safe.
|
||||
float opacityMultiplier = 1.0f;
|
||||
if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)setupPartIndex, out float translucencyValue))
|
||||
{
|
||||
if (translucencyValue >= 1.0f) continue; // skip this part's draw entirely
|
||||
opacityMultiplier = 1f - translucencyValue; // CMaterial::SetTranslucencySimple 0x005396f0
|
||||
}
|
||||
|
||||
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, opacityMultiplier, collector);
|
||||
drewAny = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var model = meshRef.PartTransform * entityWorld;
|
||||
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform, collector: collector);
|
||||
drewAny = true;
|
||||
// #188: a bare (non-Setup) GfxObj entity has exactly one part —
|
||||
// retail's CPartArray for such an object is a single-entry array,
|
||||
// so TransparentPartHook.PartIndex for it is always 0.
|
||||
float opacityMultiplier = 1.0f;
|
||||
bool fullyInvisible = false;
|
||||
if (_translucencyFades.TryGetCurrentValue(entity.Id, 0u, out float translucencyValue))
|
||||
{
|
||||
if (translucencyValue >= 1.0f) fullyInvisible = true;
|
||||
else opacityMultiplier = 1f - translucencyValue;
|
||||
}
|
||||
|
||||
if (!fullyInvisible)
|
||||
{
|
||||
var model = meshRef.PartTransform * entityWorld;
|
||||
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform, opacityMultiplier: opacityMultiplier, collector: collector);
|
||||
drewAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Track THIS entity for the next iteration's flush check. Only
|
||||
|
|
@ -1427,6 +1480,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
if (_indoorData.Length < totalInstances)
|
||||
_indoorData = new uint[totalInstances + 256];
|
||||
|
||||
// #188: per-instance opacity buffer, one float per instance, parallel to
|
||||
// _clipSlotData / _instanceData. Grown on demand like the others.
|
||||
if (_alphaData.Length < totalInstances)
|
||||
_alphaData = new float[totalInstances + 256];
|
||||
|
||||
_opaqueDraws.Clear();
|
||||
_translucentDraws.Clear();
|
||||
|
||||
|
|
@ -1462,6 +1520,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// #142: IndoorFlags[] is parallel to Matrices[]; write at the same
|
||||
// cursor so binding=6 instanceIndoor[] tracks binding=0 instances.
|
||||
_indoorData[cursor] = grp.IndoorFlags[i];
|
||||
// #188: Opacities[] is parallel to Matrices[]; write at the same
|
||||
// cursor so binding=7 instanceAlpha[] tracks binding=0 instances.
|
||||
_alphaData[cursor] = grp.Opacities[i];
|
||||
cursor++;
|
||||
}
|
||||
|
||||
|
|
@ -1553,6 +1614,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
fixed (uint* dp = _indoorData)
|
||||
UploadSsbo(_instIndoorSsbo, 6, dp, totalInstances * sizeof(uint));
|
||||
|
||||
// #188: per-instance opacity buffer (binding=7), one float per instance,
|
||||
// laid out parallel to _instanceData in Phase 3. Only [0..totalInstances)
|
||||
// is uploaded — stale tail never read (same guarantee as clip-slot above).
|
||||
fixed (float* ap = _alphaData)
|
||||
UploadSsbo(_instAlphaSsbo, 7, ap, totalInstances * sizeof(float));
|
||||
|
||||
// Fix B: global point-light buffer (binding=4) + per-instance light-set
|
||||
// buffer (binding=5). The global buffer is this frame's PointSnapshot; the
|
||||
// per-instance buffer holds 8 int indices into it per instance, laid out
|
||||
|
|
@ -2056,6 +2123,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
grp.Matrices.Add(model);
|
||||
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
|
||||
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
|
||||
// #188: cache-hit entities are always non-animated (the Tier-1 cache
|
||||
// gates on !isAnimated), and TranslucencyFadeManager only ever holds
|
||||
// state for entities whose animation hooks fired — so a cached
|
||||
// instance can never be mid-fade. Always unmodified opacity.
|
||||
grp.Opacities.Add(1.0f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -2181,6 +2253,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
ulong palHash,
|
||||
AcSurfaceMetadataTable metaTable,
|
||||
Matrix4x4 restPose,
|
||||
float opacityMultiplier = 1.0f,
|
||||
List<CachedBatch>? collector = null)
|
||||
{
|
||||
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
|
||||
|
|
@ -2199,6 +2272,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
: TranslucencyKind.Opaque;
|
||||
}
|
||||
|
||||
// #188: a mid-fade instance whose surface is otherwise Opaque/ClipMap
|
||||
// must route through the alpha-blend pass so mesh_modern.frag's
|
||||
// (blend-enabled) shader actually composites the reduced alpha —
|
||||
// the no-blend opaque pass would ignore it.
|
||||
if (opacityMultiplier < 1.0f && IsOpaque(translucency))
|
||||
translucency = TranslucencyKind.AlphaBlend;
|
||||
|
||||
ulong texHandle = ResolveTexture(entity, meshRef, batch, palHash);
|
||||
if (texHandle == 0) continue;
|
||||
|
||||
|
|
@ -2228,6 +2308,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
grp.Matrices.Add(model);
|
||||
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
|
||||
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
|
||||
grp.Opacities.Add(opacityMultiplier); // #188 — parallel to Matrices
|
||||
collector?.Add(new CachedBatch(key, texHandle, restPose));
|
||||
}
|
||||
}
|
||||
|
|
@ -2310,6 +2391,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); // Fix B binding=4
|
||||
if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5
|
||||
if (_instIndoorSsbo != 0) _gl.DeleteBuffer(_instIndoorSsbo); // #142 binding=6
|
||||
if (_instAlphaSsbo != 0) _gl.DeleteBuffer(_instAlphaSsbo); // #188 binding=7
|
||||
if (_gpuQueriesInitialized)
|
||||
{
|
||||
for (int i = 0; i < GpuQueryRingDepth; i++)
|
||||
|
|
@ -2509,5 +2591,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// for outdoor objects (gets the sun). Written into _indoorData at the same
|
||||
// cursor as Matrices, so binding=6 instanceIndoor[] tracks binding=0.
|
||||
public readonly List<uint> IndoorFlags = new();
|
||||
|
||||
// #188: per-instance opacity multiplier, parallel to Matrices.
|
||||
// Opacities[i] is 1.0=unmodified, or <1.0 while a TransparentPartHook
|
||||
// fade is in flight for the instance whose matrix is Matrices[i]. At
|
||||
// layout time the dispatcher writes Opacities[i] into _alphaData at
|
||||
// the same cursor, so the binding=7 instanceAlpha[] tracks binding=0.
|
||||
public readonly List<float> Opacities = new();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,8 +34,14 @@ namespace AcDream.Core.Physics;
|
|||
/// (animation-hook frame = damage frame for melee / thrown).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="TransparentPartHook"/> → #188
|
||||
/// <c>AcDream.Core.Rendering.TranslucencyHookSink</c> (per-Setup-part
|
||||
/// translucency ramp; see <c>TranslucencyFadeManager</c>).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="ReplaceObjectHook"/>,
|
||||
/// <see cref="TransparentHook"/>,
|
||||
/// <see cref="TransparentHook"/> (whole-object variant — NOT yet
|
||||
/// ported, #188 scope was the per-part case only),
|
||||
/// <see cref="LuminousHook"/>,
|
||||
/// <see cref="DiffuseHook"/>,
|
||||
/// <see cref="ScaleHook"/>,
|
||||
|
|
@ -43,7 +49,9 @@ namespace AcDream.Core.Physics;
|
|||
/// <see cref="SetOmegaHook"/>,
|
||||
/// <see cref="TextureVelocityHook"/>,
|
||||
/// <see cref="SetLightHook"/> →
|
||||
/// GfxObjMesh / renderer state mutations on the target entity.
|
||||
/// GfxObjMesh / renderer state mutations on the target entity
|
||||
/// (<see cref="SetLightHook"/> is the one exception already wired,
|
||||
/// via <c>AcDream.Core.Lighting.LightingHookSink</c>).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="AnimationDoneHook"/> → UI / controller notifications
|
||||
|
|
|
|||
167
src/AcDream.Core/Rendering/TranslucencyFadeManager.cs
Normal file
167
src/AcDream.Core/Rendering/TranslucencyFadeManager.cs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #188 — per-(entity, Setup-part) translucency ramp state. Ports retail's
|
||||
/// FPHook TRANSLUCENCY / PART_TRANSLUCENCY interpolation
|
||||
/// (<c>CPhysicsObj::SetPartTranslucency</c> 0x00511730,
|
||||
/// <c>FPHook::Execute</c> 0x0051baa0, <c>CPhysicsObj::process_fp_hook</c>
|
||||
/// 0x005135c0): a linear ramp from <c>Start</c> to <c>End</c> over
|
||||
/// <c>Time</c> seconds, ticked every frame rather than retail's per-object
|
||||
/// physics tick (behaviorally identical — both re-evaluate the same
|
||||
/// elapsed/duration ratio every simulated step).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's translucency scale is the OPPOSITE of "opacity": 0 = fully
|
||||
/// solid/opaque, 1 = fully invisible (<c>CMaterial::SetTranslucencySimple</c>
|
||||
/// 0x005396f0: <c>alpha = 1 - translucency</c>). Values here are stored on
|
||||
/// that same [0,1] translucency scale — callers convert to an alpha/opacity
|
||||
/// multiplier at the point of use.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's instant-apply shortcut: if the hook's <c>Time</c> is at or below
|
||||
/// ~0.2 ms, the target value is applied immediately with no ramp
|
||||
/// (<c>SetPartTranslucency</c>'s epsilon check). <see cref="TranslucencyInstantEpsilon"/>
|
||||
/// is that exact retail constant.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class TranslucencyFadeManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Retail's exact epsilon (<c>CPhysicsObj::SetPartTranslucency</c>
|
||||
/// 0x00511730): a hook <c>Time</c> at or below this is applied
|
||||
/// instantly, no ramp.
|
||||
/// </summary>
|
||||
public const float TranslucencyInstantEpsilon = 0.000199999995f;
|
||||
|
||||
private sealed class Fade
|
||||
{
|
||||
public float Elapsed;
|
||||
public float Duration;
|
||||
public float Start;
|
||||
public float End;
|
||||
}
|
||||
|
||||
// entityId -> partIndex -> in-flight ramp.
|
||||
private readonly Dictionary<uint, Dictionary<uint, Fade>> _activeFades = new();
|
||||
|
||||
// entityId -> partIndex -> current committed translucency value
|
||||
// ([0,1], retail scale). Persists after a fade completes so later
|
||||
// frames keep reading the settled value.
|
||||
private readonly Dictionary<uint, Dictionary<uint, float>> _committed = new();
|
||||
|
||||
/// <summary>
|
||||
/// Start (or replace) a translucency ramp for one Setup part of one
|
||||
/// entity. Mirrors <c>CPhysicsObj::SetPartTranslucency</c>: a
|
||||
/// near-zero <paramref name="time"/> applies <paramref name="end"/>
|
||||
/// immediately; otherwise the value ramps from
|
||||
/// <paramref name="start"/> to <paramref name="end"/> across
|
||||
/// <paramref name="time"/> seconds, advanced by <see cref="AdvanceAll"/>.
|
||||
/// </summary>
|
||||
public void StartPartFade(uint entityId, uint partIndex, float start, float end, float time)
|
||||
{
|
||||
if (time <= TranslucencyInstantEpsilon)
|
||||
{
|
||||
if (_activeFades.TryGetValue(entityId, out var activeForEntity))
|
||||
activeForEntity.Remove(partIndex);
|
||||
Commit(entityId, partIndex, end);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_activeFades.TryGetValue(entityId, out var fades))
|
||||
{
|
||||
fades = new Dictionary<uint, Fade>();
|
||||
_activeFades[entityId] = fades;
|
||||
}
|
||||
fades[partIndex] = new Fade { Elapsed = 0f, Duration = time, Start = start, End = end };
|
||||
|
||||
// FPHook's first Execute call (at elapsed=0) already reports
|
||||
// value=start — commit it now so a reader on the same frame the
|
||||
// hook fired sees the ramp's starting value, not the stale prior one.
|
||||
Commit(entityId, partIndex, start);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advance every in-flight fade by <paramref name="dt"/> seconds
|
||||
/// (plain linear interpolation, no easing — matches
|
||||
/// <c>FPHook::Execute</c>). A fade that reaches its duration commits
|
||||
/// the bitwise-exact <c>End</c> value and stops advancing (retail
|
||||
/// deletes the FPHook at that point); the committed value stays
|
||||
/// readable via <see cref="TryGetCurrentValue"/>.
|
||||
/// </summary>
|
||||
public void AdvanceAll(float dt)
|
||||
{
|
||||
if (_activeFades.Count == 0) return;
|
||||
|
||||
List<uint>? emptyEntities = null;
|
||||
foreach (var (entityId, fades) in _activeFades)
|
||||
{
|
||||
List<uint>? finished = null;
|
||||
foreach (var (partIndex, fade) in fades)
|
||||
{
|
||||
fade.Elapsed += dt;
|
||||
float t = fade.Duration <= TranslucencyInstantEpsilon
|
||||
? 1f
|
||||
: Math.Clamp(fade.Elapsed / fade.Duration, 0f, 1f);
|
||||
|
||||
float value = t >= 1f ? fade.End : fade.Start + (fade.End - fade.Start) * t;
|
||||
Commit(entityId, partIndex, value);
|
||||
|
||||
if (t >= 1f)
|
||||
{
|
||||
finished ??= new List<uint>();
|
||||
finished.Add(partIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (finished is not null)
|
||||
{
|
||||
foreach (var partIndex in finished)
|
||||
fades.Remove(partIndex);
|
||||
if (fades.Count == 0)
|
||||
{
|
||||
emptyEntities ??= new List<uint>();
|
||||
emptyEntities.Add(entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emptyEntities is not null)
|
||||
foreach (var entityId in emptyEntities)
|
||||
_activeFades.Remove(entityId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the current committed translucency value ([0,1], retail
|
||||
/// scale — 0 opaque, 1 invisible) for one entity part. Returns false
|
||||
/// if this part has never had a fade start (caller should fall back
|
||||
/// to the dat-authored default — a no-op).
|
||||
/// </summary>
|
||||
public bool TryGetCurrentValue(uint entityId, uint partIndex, out float value)
|
||||
{
|
||||
if (_committed.TryGetValue(entityId, out var parts) && parts.TryGetValue(partIndex, out value))
|
||||
return true;
|
||||
value = 0f;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Drop all fade state for an entity (despawn / unload).</summary>
|
||||
public void ClearEntity(uint entityId)
|
||||
{
|
||||
_activeFades.Remove(entityId);
|
||||
_committed.Remove(entityId);
|
||||
}
|
||||
|
||||
private void Commit(uint entityId, uint partIndex, float value)
|
||||
{
|
||||
if (!_committed.TryGetValue(entityId, out var parts))
|
||||
{
|
||||
parts = new Dictionary<uint, float>();
|
||||
_committed[entityId] = parts;
|
||||
}
|
||||
parts[partIndex] = value;
|
||||
}
|
||||
}
|
||||
38
src/AcDream.Core/Rendering/TranslucencyHookSink.cs
Normal file
38
src/AcDream.Core/Rendering/TranslucencyHookSink.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #188 — routes <see cref="TransparentPartHook"/> animation hooks to a
|
||||
/// <see cref="TranslucencyFadeManager"/>. Retail's
|
||||
/// <c>TransparentPartHook::Execute</c> (0x00526cc0) is a one-liner calling
|
||||
/// <c>CPhysicsObj::SetPartTranslucency(obj, part, start, end, time)</c> —
|
||||
/// this sink is that same one-line forward.
|
||||
///
|
||||
/// <para>
|
||||
/// Whole-object <see cref="TransparentHook"/> and <see cref="EtherealHook"/>
|
||||
/// are deliberately NOT handled here. Retail's <c>EtherealHook::Execute</c>
|
||||
/// (0x00526bc0) touches only <c>CPhysicsObj::set_ethereal</c> — collision
|
||||
/// state, zero rendering coupling — and acdream's collision-passthrough
|
||||
/// already applies server-authoritative via the <c>SetState</c> wire
|
||||
/// message, independent of this hook firing client-side.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class TranslucencyHookSink : IAnimationHookSink
|
||||
{
|
||||
private readonly TranslucencyFadeManager _fades;
|
||||
|
||||
public TranslucencyHookSink(TranslucencyFadeManager fades)
|
||||
{
|
||||
_fades = fades ?? throw new ArgumentNullException(nameof(fades));
|
||||
}
|
||||
|
||||
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
|
||||
{
|
||||
if (hook is not TransparentPartHook tph) return;
|
||||
_fades.StartPartFade(entityId, tph.PartIndex, tph.Start, tph.End, tph.Time);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue