fix(render): repair particle CYpt preparation

Retain the GfxObj-authored SortCenter independently from the rendered AABB center and use its scaled, oriented world point for billboard/mode-2-5 CYpt. Keep the visual center unchanged.

Reserve prepared source payload only at Append, roll back the exact tail once on false or exception, cap retained preparation at 3000 CLIP plus 3000 ALPHA, preserve immediate duplicates, account the bounded scratch, and correct the static-record ordering comment. The lead-authorized plan edit records candidate 44e2bc227b's retail FAIL and this section 19 result.

Gates: Release solution build 0 warnings/0 errors; focused particle/driver/queue/order/bounds 95/95; register boundary/count 1/1; real allocations 2/2 at 0 B; shader/manifest 32/32; one-shot hermetic 16751/16751; InstalledDat 385 pass/10 documented fail/1 skip; diff-check pass. No graphical client.

Mutations: rendered pos first failed distance expected 40 actual 116; AABB center expected 40 actual 116; dropped size expected 40 actual 65; dropped orientation expected 40 actual 160; eager reservation first failed Assert.Empty with one payload; omitted false rollback first failed Assert.Single with two payloads; omitted exception rollback first failed Assert.Empty with one payload; removed caps first failed prepared count expected 6000 actual 6002.

Evidence note: the first ordinary InstalledDat filter produced 385 pass/2 expected fail/1 skip but excluded the eight required KnownFailure rows; the separate inclusive no-retry artifact produced the exact documented 10-failure set. Initial test fixture compilation/analyzer setup failures are recorded in packet section 19.5.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-04 13:57:19 +02:00
parent 8e0c6fb145
commit 01674bcc78
7 changed files with 727 additions and 32 deletions

View file

@ -177,7 +177,18 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
internal long RetainedAlphaScratchBytes => checked(
(long)_deferredAlpha.Capacity * Unsafe.SizeOf<DeferredParticleDraw>()
+ (long)_preparedAlpha.Length * Unsafe.SizeOf<DeferredParticleDraw>()
+ (long)_preparedInstanceOffsets.Length * sizeof(uint));
+ (long)_preparedInstanceOffsets.Length * sizeof(uint)
+ (long)_preparedCellAlphaScratch.Capacity
* Unsafe.SizeOf<PreparedParticleAlphaSubmission>());
internal (int Count, int Capacity, long RetainedBytes)
PreparedCellAlphaScratchDiagnostics =>
(
_preparedCellAlphaScratch.Count,
_preparedCellAlphaScratch.Capacity,
checked((long)_preparedCellAlphaScratch.Capacity
* Unsafe.SizeOf<PreparedParticleAlphaSubmission>())
);
private sealed class AlphaDrawSource(ParticleRenderer owner) : IRetailAlphaDrawSource
{
@ -366,6 +377,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
ParticleSubmissionOrdering.Sort(_submissionScratch);
Matrix4x4 viewProjection = camera.View * camera.Projection;
RetailAlphaQueue queue = _alphaQueue!;
int retainedClipCount = 0;
int retainedAlphaCount = 0;
for (int i = 0; i < _submissionScratch.Count; i++)
{
ParticleSubmission submission = _submissionScratch[i];
@ -377,34 +390,27 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
: default;
RetailAlphaMeshDecision decision = RouteParticleSubmission(
submission.Kind, translucency, colorArgb);
PreparedCellAlphaActions actions = ResolvePreparedCellAlphaActions(
decision,
ref retainedClipCount,
ref retainedAlphaCount);
if (decision.Action is RetailAlphaMeshAction.Append
or RetailAlphaMeshAction.AppendClipAndImmediate)
if (actions.Retain)
{
_dispatchDeferredParticle = submission.Kind == ParticleSubmissionKind.Billboard
? new DeferredParticleDraw(
submission.Kind,
_drawListScratch[submission.DrawIndex],
default,
viewProjection)
: new DeferredParticleDraw(
submission.Kind,
default,
_meshDrawListScratch[submission.DrawIndex],
viewProjection);
int token = ReserveDispatchDeferredParticle();
_preparedCellAlphaScratch.Add(new PreparedParticleAlphaSubmission(
queue,
decision.List,
_alphaSource,
token,
this,
submission.Kind,
submission.DrawIndex,
viewProjection,
decision.OverrideClipmap,
submission.DistanceSq,
submission.Sequence));
}
if (decision.Action is RetailAlphaMeshAction.Immediate
or RetailAlphaMeshAction.AppendClipAndImmediate)
if (actions.DrawImmediate)
{
_drawImmediateParticle(
viewProjection,
@ -417,6 +423,46 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
return CollectionsMarshal.AsSpan(_preparedCellAlphaScratch);
}
internal readonly record struct PreparedCellAlphaActions(
bool Retain,
bool DrawImmediate);
/// <summary>
/// Applies the physical 3,000-entry bound independently to the prepared
/// CLIP and ALPHA lists. Row 2's immediate duplicate is deliberately
/// independent of retention: even candidate 3,001 still draws now.
/// </summary>
internal static PreparedCellAlphaActions ResolvePreparedCellAlphaActions(
RetailAlphaMeshDecision decision,
ref int retainedClipCount,
ref int retainedAlphaCount)
{
bool requestsRetention = decision.Action is RetailAlphaMeshAction.Append
or RetailAlphaMeshAction.AppendClipAndImmediate;
bool retain = false;
if (requestsRetention)
{
if (decision.List == RetailAlphaList.Clip)
{
if (retainedClipCount < RetailAlphaQueue.ListCapacity)
{
retainedClipCount++;
retain = true;
}
}
else if (retainedAlphaCount < RetailAlphaQueue.ListCapacity)
{
retainedAlphaCount++;
retain = true;
}
}
return new PreparedCellAlphaActions(
retain,
decision.Action is RetailAlphaMeshAction.Immediate
or RetailAlphaMeshAction.AppendClipAndImmediate);
}
private void FinishDraw(ICamera camera, ParticleRenderPass renderPass)
{
if (_submissionScratch.Count == 0)
@ -584,6 +630,39 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
return token;
}
internal int ReservePreparedDispatchDeferredParticle(
ParticleSubmissionKind kind,
int drawIndex,
Matrix4x4 viewProjection)
{
DeferredParticleDraw deferred = kind == ParticleSubmissionKind.Billboard
? new DeferredParticleDraw(
kind,
_drawListScratch[drawIndex],
default,
viewProjection)
: new DeferredParticleDraw(
kind,
default,
_meshDrawListScratch[drawIndex],
viewProjection);
int token = _deferredAlpha.Count;
_deferredAlpha.Add(deferred);
return token;
}
internal void RollbackPreparedDispatchDeferredParticle(int token)
{
int tail = _deferredAlpha.Count - 1;
if (token != tail)
{
throw new InvalidOperationException(
$"Prepared particle rollback must target tail token {tail}, not {token}.");
}
_deferredAlpha.RemoveAt(tail);
}
private void DrawOrdered(ICamera camera)
{
DrawOrderedRhi(camera);
@ -612,6 +691,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
_deferredAlpha.Count,
_preparedAlphaCount);
_deferredAlpha.Clear();
_preparedCellAlphaScratch.Clear();
_preparedAlphaCount = 0;
int currentCapacity = Math.Max(
_deferredAlpha.Capacity,
@ -718,6 +798,12 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
gfxInfo = ResolveParticleGfxInfo(em);
gfxInfoResolved = true;
}
Quaternion orientation = ParticleOrientation(em, p);
Vector3 authoredSortPoint = p.Position
+ Vector3.Transform(gfxInfo.SortCenter * p.Size, orientation);
float distSq = Vector3.DistanceSquared(
authoredSortPoint,
cameraWorldPos);
bool additive = gfxInfo.HasMaterial
? gfxInfo.Additive
: (em.Desc.Flags & EmitterFlags.Additive) != 0;
@ -761,7 +847,6 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
}
else
{
Quaternion orientation = ParticleOrientation(em, p);
if (RetailParticleFacing.Faces(gfxInfo.DegradeMode)
&& toViewerLength > 1e-3f)
{
@ -795,8 +880,6 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
}
}
float distSq = Vector3.DistanceSquared(pos, cameraWorldPos);
int drawIndex = draws.Count;
draws.Add(new ParticleDraw(
key,
@ -1013,6 +1096,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
_textures.AcquireParticleTexture(emitter.Handle, desc.TextureSurfaceId),
Vector2.One,
Vector3.Zero,
Vector3.Zero,
additive: (desc.Flags & EmitterFlags.Additive) != 0,
hasMaterial: false,
surfaceId: desc.TextureSurfaceId);
@ -1086,6 +1170,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
texture,
Vector2.One,
Vector3.Zero,
gfx.SortCenter,
additive,
hasMaterial,
surfaceId);
@ -1108,6 +1193,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
texture,
new Vector2(sx, sy),
center,
gfx.SortCenter,
additive,
hasMaterial,
surfaceId);
@ -1170,6 +1256,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
axisX,
axisY,
center,
gfx.SortCenter,
false,
additive,
hasMaterial,
@ -1271,6 +1358,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
_firstDegradeModeByGfxObj.Clear();
_meshBlendBySurface.Clear();
_deferredAlpha.Clear();
_preparedCellAlphaScratch.Clear();
}
/// <summary>
@ -1289,6 +1377,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
Vector3 AxisX,
Vector3 AxisY,
Vector3 CenterOffset,
Vector3 SortCenter,
bool IsBillboard,
bool Additive,
bool HasMaterial,
@ -1300,6 +1389,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned,
Vector2.One,
Vector3.Zero,
Vector3.Zero,
additive: false,
hasMaterial: false,
surfaceId: 0);
@ -1308,6 +1398,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot,
Vector2 size,
Vector3 centerOffset,
Vector3 sortCenter,
bool additive,
bool hasMaterial,
uint surfaceId) =>
@ -1317,6 +1408,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
Vector3.UnitX,
Vector3.UnitY,
centerOffset,
sortCenter,
true,
additive,
hasMaterial,

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering;
@ -20,21 +21,42 @@ internal readonly record struct ParticleSubmission(
/// One scene-particle alpha record prepared at its owning cell turn but not
/// yet appended. S4-c3a lets <c>WalkFrameDriver</c> merge this retained CYpt
/// key with ordinary object parts before either source enters retail's FIFO
/// list. The source payload is reserved during preparation; <see
/// cref="RetailAlphaQueue.TryAppend"/> remains the sole visibility edge and
/// still owns capacity-drop cleanup by registering the source before a drop.
/// list. The source payload is retained here as reconstruction inputs only;
/// <see cref="Append"/> reserves it at the queue visibility edge and rolls
/// back the exact tail reservation when the queue rejects or throws.
/// </summary>
internal readonly record struct PreparedParticleAlphaSubmission(
RetailAlphaQueue Queue,
RetailAlphaList List,
IRetailAlphaDrawSource Source,
int Token,
ParticleRenderer Owner,
ParticleSubmissionKind Kind,
int DrawIndex,
Matrix4x4 ViewProjection,
bool OverrideClipmap,
float DistanceSq,
int Sequence)
{
internal void Append() =>
Queue.TryAppend(List, Source, Token, OverrideClipmap);
internal void Append()
{
int token = Owner.ReservePreparedDispatchDeferredParticle(
Kind,
DrawIndex,
ViewProjection);
bool accepted;
try
{
accepted = Queue.TryAppend(List, Source, token, OverrideClipmap);
}
catch
{
Owner.RollbackPreparedDispatchDeferredParticle(token);
throw;
}
if (!accepted)
Owner.RollbackPreparedDispatchDeferredParticle(token);
}
}
/// <summary>

View file

@ -14,9 +14,11 @@ namespace AcDream.App.Rendering.Walk;
/// <see cref="IWalkFrameWorldData"/>'s doc comment).
/// </summary>
/// <param name="Records">Already-classified <see cref="RenderProjectionRecord"/>s
/// for this turn's cell/building, in the SAME order they must enter the walk
/// stream (never re-sorted downstream — <see cref="WalkStaticStreamPopulator"/>'s
/// own contract). Campaign FW3.4a: a segment INTO <see cref="WalkProductionWorldData"/>'s
/// for this turn's cell/building, retaining authored traversal/registration
/// order. For ordinary cells, <see cref="WalkStaticStreamPopulator"/> builds
/// the combined static/dynamic part list and performs retail's per-cell CYpt
/// sort; these records are not final stream order. Campaign FW3.4a: a segment
/// INTO <see cref="WalkProductionWorldData"/>'s
/// per-frame arena, not a freshly allocated array — see that type's own doc
/// comment.</param>
/// <param name="TupleLandblockId">The clip-slot-resolving landblock id