fix(render): S2 chunk 6 review round — one particle turn per cell per stamp, cheap empty cells, dead owner-set stub deleted
Retail-lens review of chunk 6 (no blocking finding). Fixed: - a cell that gets two object-list turns in one frame (a chamber reached through two portals) submitted its emitters twice; retail's particle parts sit in the same shadow_part_list as every other part and CPhysicsPart::Draw's frame stamp suppresses the second draw, so the walk now dedupes the particle turn with the same frame-scoped set that dedupes the cell shell; - every visited land cell paid the full per-cell draw setup even with no emitter; DrawForCell now returns after the cell lookup, retail's own cost (DrawPartCell 0x005a07a0 `num_shadow_parts > 0`); - CopyRenderableEmittersInCell maintains LastRenderScopeEmitterVisitCount; - the OutdoorSceneParticleEntityIds / outdoorOwnerIds stub chain (permanently empty, never read) is deleted through IWorldSceneRenderer, WorldScenePViewRenderer, IWorldScenePasses and the composition root; - AD-117 item 4 names the two behavioral residuals (owner-cell substitution; no per-emission AddPartToShadowCells); - ParticleHookSinkTests pins that an emitter's draw cell is its owner's pose cell and survives the projection-visibility switch across the per-frame view pass. Gates: Core 4,988/4,988 (Vfx 108/108), App hermetic 6,760/6,760, Runtime 1,884/1,884. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
7969c2e6ad
commit
6d5afcccde
11 changed files with 891 additions and 800 deletions
|
|
@ -313,8 +313,6 @@ public sealed class WorldSceneRendererTests
|
|||
|
||||
Assert.True(result.NormalWorldDrawn);
|
||||
Assert.Contains("particles:pviewScoped", rig.Calls);
|
||||
Assert.Same(rig.PView.OutdoorSceneParticleEntityIds, rig.Passes.ParticleOwners);
|
||||
Assert.Equal([0xCAFEu], rig.Passes.ParticleOwners);
|
||||
Assert.DoesNotContain("flat:weather", rig.Calls);
|
||||
}
|
||||
|
||||
|
|
@ -756,9 +754,6 @@ public sealed class WorldSceneRendererTests
|
|||
diagnosticPartition: null);
|
||||
}
|
||||
|
||||
public IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; } =
|
||||
new HashSet<uint> { 0xCAFEu };
|
||||
|
||||
public RetailPViewFrameInput? LastInput { get; private set; }
|
||||
|
||||
public bool ThrowOnDraw { get; set; }
|
||||
|
|
@ -800,7 +795,6 @@ public sealed class WorldSceneRendererTests
|
|||
|
||||
public float WeatherDayFraction { get; private set; }
|
||||
|
||||
public IReadOnlySet<uint>? ParticleOwners { get; private set; }
|
||||
|
||||
public void BeginFrame() => calls.Add("passes:begin");
|
||||
|
||||
|
|
@ -837,10 +831,8 @@ public sealed class WorldSceneRendererTests
|
|||
LoadedCell? clipRoot,
|
||||
ClipFrameAssembly? clipAssembly,
|
||||
in WorldCameraFrame camera,
|
||||
IReadOnlySet<uint> outdoorOwnerIds,
|
||||
string currentSignature)
|
||||
{
|
||||
ParticleOwners = outdoorOwnerIds;
|
||||
string kind = clipRoot switch
|
||||
{
|
||||
null => "global",
|
||||
|
|
|
|||
|
|
@ -522,6 +522,113 @@ public sealed class ParticleHookSinkTests
|
|||
.AnchorPos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OVERHAUL S2 chunk 6 (review F6): an emitter's draw membership
|
||||
/// is its OWNER's pose cell, published through the sink, and nothing else
|
||||
/// — no registry row, no owner "spatial" state. Retail
|
||||
/// <c>add_particle_shadow_to_cell</c> (0x00514a70) gives the emitter one
|
||||
/// shadow in its own cell, drawn at that cell's turn regardless of the
|
||||
/// parent's hidden state; the only presentation gate the sink owns is the
|
||||
/// explicit projection-visibility switch, which is NOT the retail Hidden
|
||||
/// state. Route retail-Hidden into that switch and the portal cloud
|
||||
/// vanishes again — this pin is what fails first.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EmitterKeepsItsOwnerCellAndStaysRenderableWithNoOwnerPresenceBesidesThePose()
|
||||
{
|
||||
const uint emitterId = 0x3200_0777u;
|
||||
const uint cell = 0x8A02015Eu;
|
||||
var registry = new EmitterDescRegistry();
|
||||
registry.Register(MakeDesc(emitterId, attachLocal: false, totalParticles: 0, totalDuration: 0f));
|
||||
var system = new ParticleSystem(registry, new Random(42));
|
||||
var poses = new CellPoseSource();
|
||||
poses.Publish(Owner, Matrix4x4.CreateTranslation(3, 4, 5), cell);
|
||||
var sink = new ParticleHookSink(system, poses);
|
||||
|
||||
sink.OnHook(Owner, new Vector3(3, 4, 5), Create(emitterId, logicalId: 7u));
|
||||
ParticleEmitter emitter = system.EnumerateLive().Single().Emitter;
|
||||
Assert.Equal(cell, emitter.OwnerCellId);
|
||||
|
||||
// The retail ShouldDrawParticles gate: the owner cell is in view.
|
||||
sink.RefreshAttachedEmitters();
|
||||
system.ApplyRetailView(new Vector3(3, 4, 5), new HashSet<uint> { cell }, hasCompletedView: true);
|
||||
Assert.True(emitter.PresentationVisible, "presentation visible");
|
||||
Assert.True(emitter.ViewEligible, "view eligible (owner cell in view, in range)");
|
||||
Assert.Equal(ParticleRenderPass.Scene, emitter.RenderPass);
|
||||
var byOwner = new List<ParticleEmitter>();
|
||||
system.CopyRenderableEmittersForOwners(
|
||||
ParticleRenderPass.Scene, new HashSet<uint> { Owner }, includeUnattached: false, byOwner);
|
||||
Assert.Single(byOwner);
|
||||
var inCell = new List<ParticleEmitter>();
|
||||
system.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, cell, inCell);
|
||||
Assert.Single(inCell);
|
||||
Assert.Same(emitter, inCell[0]);
|
||||
|
||||
// Only the sink's explicit projection-visibility switch removes it
|
||||
// from the cell's renderable set — and only until it flips back.
|
||||
sink.SetEntityPresentationVisible(Owner, false);
|
||||
system.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, cell, inCell);
|
||||
Assert.Empty(inCell);
|
||||
sink.SetEntityPresentationVisible(Owner, true);
|
||||
sink.RefreshAttachedEmitters();
|
||||
// The next frame's view pass re-evaluates eligibility (production
|
||||
// cadence: bindings refresh, then ApplyRetailView, then the walk).
|
||||
system.ApplyRetailView(new Vector3(3, 4, 5), new HashSet<uint> { cell }, hasCompletedView: true);
|
||||
system.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, cell, inCell);
|
||||
Assert.Single(inCell);
|
||||
Assert.Equal(cell, emitter.OwnerCellId);
|
||||
}
|
||||
|
||||
private sealed class CellPoseSource :
|
||||
IEntityEffectPoseSource,
|
||||
IEntityEffectCellSource,
|
||||
IEntityEffectPoseChangeSource
|
||||
{
|
||||
private readonly Dictionary<uint, (Matrix4x4 Root, uint Cell)> _poses = new();
|
||||
public event Action<uint>? EffectPoseChanged;
|
||||
|
||||
public void Publish(uint id, Matrix4x4 root, uint cellId)
|
||||
{
|
||||
_poses[id] = (root, cellId);
|
||||
EffectPoseChanged?.Invoke(id);
|
||||
}
|
||||
|
||||
public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
|
||||
{
|
||||
if (_poses.TryGetValue(localEntityId, out var pose))
|
||||
{
|
||||
rootWorld = pose.Root;
|
||||
return true;
|
||||
}
|
||||
rootWorld = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetPartPose(uint localEntityId, int partIndex, out Matrix4x4 partLocal)
|
||||
{
|
||||
// One identity part at index 0, the shape the sink resolves an
|
||||
// emitter anchor through before it spawns.
|
||||
if (partIndex == 0 && _poses.ContainsKey(localEntityId))
|
||||
{
|
||||
partLocal = Matrix4x4.Identity;
|
||||
return true;
|
||||
}
|
||||
partLocal = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetCellId(uint localEntityId, out uint cellId)
|
||||
{
|
||||
if (_poses.TryGetValue(localEntityId, out var pose))
|
||||
{
|
||||
cellId = pose.Cell;
|
||||
return true;
|
||||
}
|
||||
cellId = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MutablePoseSource :
|
||||
IEntityEffectPoseSource,
|
||||
IEntityEffectPoseChangeSource
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue