The Holtburg windmill axle (GfxObj 0x010010CE, 8 polygons, all
Stippling.NoPos + SurfaceType.Base1Solid) extracted to a 0-vertex mesh.
NoPos ("NO_POS_UVS", acclient.h:7380-7388) means "this side has no
texture coordinates" — true of every solid-colour polygon, since
nothing samples them — not "there is no positive face". Extraction read
it as the latter and dropped the polygon entirely, client-wide, for
every untextured polygon on every object.
Retail's D3DPolyRender::DrawMesh (@0x0059d4a0, named-retail decomp
~line 426048) draws an untextured subset on an ordinary object exactly
like a textured one; the only retail cases that skip an untextured
subset are a building shell (RenderDeviceD3D::DrawBuilding @0x0059f2a0
sets ObjBuildingOrBuildingPart=1) or an EnvCell interior
(RenderDeviceD3D::DrawEnvCell @0x0059f170, arg4=1). The #119
investigation's "retail's skipNoTexture never draws them either"
conclusion was itself wrong as a general rule.
- MeshExtractor.PrepareGfxObjMeshData / GfxObjMesh.Build: emit the
positive side whenever PosSurface is a valid index, regardless of
NoPos; the existing UV-index-0 fallback already produces zero
texcoords for a NoPos polygon with no UVs on the wire.
- RetailUntexturedSurfacePolicy.IsUntextured(SurfaceType): the one
place that answers "is this surface textured"
((type & (Base1Image|Base1ClipMap)) == 0), replacing the old
`isSolid = NoPos || Base1Solid` (which also mis-classified a NEG-side
batch by the POS-side's NoPos flag).
- RetailUntexturedSubsetPolicy.Draws(isBuildingShell, isUntextured):
the shared draw-time gate wired into WbDrawDispatcher.ClassifyBatches,
.PackedOracle.ClassifyPackedBatches, and
.DirectionalShadows.AddDirectionalShadowBatches — one predicate so the
three walks cannot drift (Campaign VM VM6 lesson).
- CellMesh.cs / MeshExtractor.PrepareCellStructMeshData deliberately
KEEP their NoPos-gated skip for cell-wall geometry — retail's
DrawEnvCell really does skip untextured subsets there; register row
AP-234 documents the NoPos-vs-Surface.Type approximation.
- PakFormat.CurrentBakeToolVersion 4->5 (LauncherInstallRecordStore in
lockstep): a pak baked by an older tool is missing every untextured
face. No bake was run as part of this commit.
Also fixed: WorldBuilder's own upstream ObjectMeshManager.cs has the
identical NoPos bug (ObjectMeshManager.cs:959,984) — our port had
faithfully carried it over, and our own conformance test
(Build_NoPosFlag_OnlyEmitsNegSide) asserted the bug as correct WB
conformance. Renamed/reworded to Build_NoPosFlag_EmitsBothPosAndNegSide
with a citation for why retail decomp overrides WB here.
Issue119UpNullGfxObjDumpTests re-run against the installed DAT:
#119's own two objects (0x010002B4 9/9 polys, 0x010008A8 1/1 poly) now
gate DRAWS on every polygon instead of extracting to nothing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
902 lines
32 KiB
C#
902 lines
32 KiB
C#
using System.Numerics;
|
|
using AcDream.Core.Rendering;
|
|
using AcDream.App.Rendering.Scene;
|
|
using AcDream.App.Rendering.Selection;
|
|
using AcDream.Core.Lighting;
|
|
using AcDream.Core.Meshing;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter.Enums;
|
|
|
|
namespace AcDream.App.Rendering.Wb;
|
|
|
|
/// <summary>
|
|
/// Scene-frame classification and production submission. The retained group
|
|
/// storage is also consumed by the G2/G3 compare oracle, while production
|
|
/// routes reuse the dispatcher's exact mesh, texture, light, translucency,
|
|
/// selection, upload, and draw owners.
|
|
/// </summary>
|
|
public sealed unsafe partial class WbDrawDispatcher
|
|
{
|
|
private readonly Dictionary<RenderProjectionId, RenderFrameEntityCandidate>
|
|
_packedEntityById = [];
|
|
private readonly Dictionary<GroupKey, InstanceGroup> _packedGroups = [];
|
|
private readonly List<GroupKey> _packedRetiredGroupKeys = [];
|
|
private readonly List<InstanceGroup> _packedOpaque = [];
|
|
private readonly List<InstanceGroup> _packedTransparent = [];
|
|
private readonly List<AlphaFingerprint>
|
|
_packedAlphaFingerprintScratch = [];
|
|
private readonly List<CurrentRenderDispatcherSubmission>
|
|
_packedSubmissions = [];
|
|
private readonly List<CurrentRenderSelectionFingerprint>
|
|
_packedSelectionParts = [];
|
|
private readonly HashSet<PackedSelectionKey> _packedSelectionKeys = [];
|
|
private readonly PackedProjectionClassificationCache
|
|
_packedClassificationCache = new();
|
|
private long _packedGroupFrame;
|
|
private long _nextPackedGroupRegistration = 1;
|
|
private int _nextPackedInstanceSubmissionOrder;
|
|
private bool _packedProductionFrameOpen;
|
|
private RenderSceneGeneration _packedProductionGeneration;
|
|
private ulong _packedProductionFrameSequence;
|
|
private int _packedProductionNextRange;
|
|
|
|
internal IReadOnlyList<CurrentRenderDispatcherSubmission>
|
|
PackedDispatcherSubmissions => _packedSubmissions;
|
|
|
|
internal IReadOnlyList<CurrentRenderSelectionFingerprint>
|
|
PackedSelectionParts => _packedSelectionParts;
|
|
|
|
internal PackedClassificationCacheSnapshot
|
|
PackedClassificationSnapshot =>
|
|
_packedClassificationCache.Snapshot;
|
|
|
|
internal void BuildPackedDispatcherOracle(
|
|
in RenderFrameView view,
|
|
uint tupleLandblockId,
|
|
Vector3 cameraWorldPosition)
|
|
{
|
|
if (_packedProductionFrameOpen)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The packed dispatcher oracle cannot replace an active production frame.");
|
|
}
|
|
|
|
BeginPackedFrameStorage(in view);
|
|
ReadOnlySpan<RenderFrameCandidateRange> ranges =
|
|
view.RouteRanges;
|
|
for (int rangeIndex = 0;
|
|
rangeIndex < ranges.Length;
|
|
rangeIndex++)
|
|
{
|
|
PackedRangeClassification classified =
|
|
ClassifyPackedRange(
|
|
in view,
|
|
rangeIndex,
|
|
tupleLandblockId,
|
|
publishSelection: false);
|
|
|
|
bool deferTransparent = ShouldDeferPackedTransparent(
|
|
classified.AnyVao,
|
|
_alphaQueue?.IsCollecting == true);
|
|
InstanceLayoutCounts counts = PartitionInstanceGroups(
|
|
_packedGroups.Values,
|
|
deferTransparent,
|
|
cameraWorldPosition,
|
|
_packedOpaque,
|
|
_packedTransparent);
|
|
_packedOpaque.Sort(CompareOpaqueSubmissionOrder);
|
|
if (!deferTransparent)
|
|
{
|
|
_packedTransparent.Sort(
|
|
CompareTransparentSubmissionOrder);
|
|
}
|
|
|
|
_packedSubmissions.Add(CreateDispatcherSubmission(
|
|
counts.VisibleInstances,
|
|
counts.ImmediateInstances,
|
|
deferTransparent,
|
|
_packedOpaque,
|
|
_packedTransparent,
|
|
cameraWorldPosition,
|
|
_packedAlphaFingerprintScratch));
|
|
}
|
|
|
|
_packedClassificationCache.EndFrame();
|
|
}
|
|
|
|
internal void BeginPackedProductionFrame(
|
|
in RenderFrameView view)
|
|
{
|
|
if (_packedProductionFrameOpen)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The packed dispatcher cannot begin a second production frame.");
|
|
}
|
|
|
|
BeginPackedFrameStorage(in view);
|
|
_packedProductionFrameOpen = true;
|
|
_packedProductionGeneration = view.Generation;
|
|
_packedProductionFrameSequence = view.FrameSequence;
|
|
_packedProductionNextRange = 0;
|
|
}
|
|
|
|
internal bool DrawPackedProductionRoute(
|
|
ICamera camera,
|
|
in RenderFrameView view,
|
|
RenderFrameCandidateRoute route,
|
|
int routeIndex,
|
|
uint cellId,
|
|
uint tupleLandblockId)
|
|
{
|
|
ValidatePackedProductionView(in view);
|
|
ReadOnlySpan<RenderFrameCandidateRange> ranges =
|
|
view.RouteRanges;
|
|
if (_packedProductionNextRange >= ranges.Length)
|
|
return false;
|
|
|
|
RenderFrameCandidateRange range =
|
|
ranges[_packedProductionNextRange];
|
|
if (range.Route != route
|
|
|| range.RouteIndex != routeIndex
|
|
|| range.CellId != cellId)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
int rangePosition = _packedProductionNextRange++;
|
|
bool diagnosticsEnabled = BeginEntityDispatch(
|
|
camera,
|
|
out Matrix4x4 viewProjection,
|
|
out Vector3 cameraWorldPosition);
|
|
PackedRangeClassification classified =
|
|
ClassifyPackedRange(
|
|
in view,
|
|
rangePosition,
|
|
tupleLandblockId,
|
|
publishSelection: true);
|
|
ExecuteClassifiedGroups(
|
|
viewProjection,
|
|
cameraWorldPosition,
|
|
classified.AnyVao,
|
|
_packedGroups.Values,
|
|
EntitySet.All,
|
|
classified.CandidateCount,
|
|
classified.MeshPartCount,
|
|
diagnosticsEnabled,
|
|
observeCurrentPath: false);
|
|
return true;
|
|
}
|
|
|
|
internal void CompletePackedProductionFrame(
|
|
in RenderFrameView view)
|
|
{
|
|
ValidatePackedProductionView(in view);
|
|
int expected = view.RouteRanges.Length;
|
|
if (_packedProductionNextRange != expected)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The packed dispatcher did not consume the complete route stream: "
|
|
+ $"consumed={_packedProductionNextRange} expected={expected}.");
|
|
}
|
|
|
|
_packedClassificationCache.EndFrame();
|
|
ResetPackedProductionFrame();
|
|
}
|
|
|
|
internal void AbortPackedProductionFrame()
|
|
{
|
|
if (!_packedProductionFrameOpen)
|
|
return;
|
|
|
|
_packedClassificationCache.EndFrame();
|
|
ResetPackedProductionFrame();
|
|
}
|
|
|
|
private void BeginPackedFrameStorage(
|
|
in RenderFrameView view)
|
|
{
|
|
if (_packedGroupFrame == long.MaxValue)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Packed dispatcher frame identity was exhausted.");
|
|
}
|
|
|
|
_packedGroupFrame++;
|
|
PruneInstanceGroupsUnusedBeforeFrame(
|
|
_packedGroups,
|
|
_packedRetiredGroupKeys,
|
|
_packedGroupFrame - 1);
|
|
_packedEntityById.Clear();
|
|
_packedSubmissions.Clear();
|
|
_packedSelectionParts.Clear();
|
|
_packedSelectionKeys.Clear();
|
|
_packedClassificationCache.BeginFrame(view.Generation);
|
|
|
|
ReadOnlySpan<RenderFrameEntityCandidate> entities =
|
|
view.EntityCandidates;
|
|
for (int index = 0; index < entities.Length; index++)
|
|
{
|
|
if (!_packedEntityById.TryAdd(
|
|
entities[index].Projection.Id,
|
|
entities[index]))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Packed dispatcher received duplicate projection "
|
|
+ $"{entities[index].Projection.Id}.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private PackedRangeClassification ClassifyPackedRange(
|
|
in RenderFrameView view,
|
|
int rangeIndex,
|
|
uint tupleLandblockId,
|
|
bool publishSelection)
|
|
{
|
|
ReadOnlySpan<RenderFrameCandidateRange> ranges =
|
|
view.RouteRanges;
|
|
if ((uint)rangeIndex >= (uint)ranges.Length)
|
|
throw new ArgumentOutOfRangeException(nameof(rangeIndex));
|
|
|
|
_nextPackedInstanceSubmissionOrder = 0;
|
|
foreach (InstanceGroup group in _packedGroups.Values)
|
|
group.ClearPerInstanceData();
|
|
|
|
uint anyVao = 0;
|
|
int meshPartCount = 0;
|
|
RenderFrameCandidateRange range = ranges[rangeIndex];
|
|
ReadOnlySpan<RenderProjectionRecord> routeCandidates =
|
|
view.RouteCandidates;
|
|
ReadOnlySpan<RenderFrameMeshPart> meshParts = view.MeshParts;
|
|
int end = checked(range.Offset + range.Count);
|
|
if ((uint)range.Offset > (uint)routeCandidates.Length
|
|
|| (uint)end > (uint)routeCandidates.Length)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Packed dispatcher route {rangeIndex} exceeds candidate storage.");
|
|
}
|
|
|
|
for (int candidateIndex = range.Offset;
|
|
candidateIndex < end;
|
|
candidateIndex++)
|
|
{
|
|
RenderProjectionRecord projection =
|
|
routeCandidates[candidateIndex];
|
|
if ((projection.Flags & RenderProjectionFlags.Draw) == 0)
|
|
continue;
|
|
if (!_packedEntityById.TryGetValue(
|
|
projection.Id,
|
|
out RenderFrameEntityCandidate source))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Packed route references absent entity {projection.Id}.");
|
|
}
|
|
|
|
int meshEnd = checked(
|
|
source.MeshPartOffset + source.MeshPartCount);
|
|
if ((uint)source.MeshPartOffset > (uint)meshParts.Length
|
|
|| (uint)meshEnd > (uint)meshParts.Length)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Packed entity {projection.Id} exceeds mesh part storage.");
|
|
}
|
|
|
|
meshPartCount = checked(
|
|
meshPartCount + source.MeshPartCount);
|
|
RenderInstanceCandidate candidate =
|
|
RenderInstanceCandidate.FromFrame(
|
|
in source,
|
|
tupleLandblockId);
|
|
ClassifyPackedEntity(
|
|
in projection,
|
|
in candidate,
|
|
meshParts.Slice(
|
|
source.MeshPartOffset,
|
|
source.MeshPartCount),
|
|
ref anyVao,
|
|
publishSelection);
|
|
}
|
|
|
|
return new PackedRangeClassification(
|
|
anyVao,
|
|
range.Count,
|
|
meshPartCount);
|
|
}
|
|
|
|
private void ValidatePackedProductionView(
|
|
in RenderFrameView view)
|
|
{
|
|
if (!_packedProductionFrameOpen
|
|
|| view.Generation != _packedProductionGeneration
|
|
|| view.FrameSequence != _packedProductionFrameSequence)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The packed dispatcher received a stale or foreign production frame.");
|
|
}
|
|
}
|
|
|
|
private void ResetPackedProductionFrame()
|
|
{
|
|
_packedProductionFrameOpen = false;
|
|
_packedProductionGeneration = default;
|
|
_packedProductionFrameSequence = 0;
|
|
_packedProductionNextRange = 0;
|
|
}
|
|
|
|
private readonly record struct PackedRangeClassification(
|
|
uint AnyVao,
|
|
int CandidateCount,
|
|
int MeshPartCount);
|
|
|
|
private void ClassifyPackedEntity(
|
|
in RenderProjectionRecord projection,
|
|
in RenderInstanceCandidate entity,
|
|
ReadOnlySpan<RenderFrameMeshPart> meshParts,
|
|
ref uint anyVao,
|
|
bool publishSelection)
|
|
{
|
|
(uint slot, bool culled) = ResolveSlotForFrame(
|
|
_clipRoutingActive,
|
|
entity.ServerGuid,
|
|
entity.ParentCell,
|
|
_cellIdToSlot,
|
|
_outdoorSlot,
|
|
_outdoorVisible);
|
|
if (culled)
|
|
return;
|
|
|
|
ResolvePackedLightSet(
|
|
in entity,
|
|
out InstanceLightSet lights,
|
|
out bool indoor);
|
|
Vector2 selectionLighting =
|
|
_selectionLighting?.TryGetLighting(
|
|
entity.ServerGuid,
|
|
entity.LocalEntityId,
|
|
out RetailSelectionLighting lighting) == true
|
|
? new Vector2(
|
|
lighting.Luminosity,
|
|
lighting.Diffuse)
|
|
: new Vector2(0f, 1f);
|
|
|
|
PackedProjectionClassificationEntry? cacheEntry = null;
|
|
if (!entity.Animated)
|
|
{
|
|
PackedClassificationIdentity identity =
|
|
PackedClassificationIdentity.From(in projection);
|
|
if (_packedClassificationCache.TryGetReusable(
|
|
entity.ProjectionId,
|
|
in identity,
|
|
projection.DirtyMask,
|
|
out cacheEntry))
|
|
{
|
|
if (anyVao == 0 && !meshParts.IsEmpty)
|
|
{
|
|
ObjectRenderData? firstRenderData =
|
|
_meshAdapter.TryGetRenderData(
|
|
meshParts[0].MeshRef.GfxObjId);
|
|
if (firstRenderData is not null)
|
|
anyVao = firstRenderData.VAO;
|
|
}
|
|
ReplayPackedClassification(
|
|
cacheEntry!,
|
|
in entity,
|
|
slot,
|
|
lights,
|
|
indoor,
|
|
selectionLighting,
|
|
publishSelection);
|
|
return;
|
|
}
|
|
|
|
cacheEntry = _packedClassificationCache.BeginRebuild(
|
|
entity.ProjectionId,
|
|
in identity);
|
|
}
|
|
else
|
|
{
|
|
_packedClassificationCache.RecordAnimatedClassification();
|
|
}
|
|
|
|
PaletteCompositeIdentity paletteIdentity = default;
|
|
if (entity.PaletteOverride is not null)
|
|
{
|
|
paletteIdentity = TextureCache.GetPaletteIdentity(
|
|
entity.PaletteOverride);
|
|
}
|
|
|
|
bool reusableAcrossFrames = !entity.Animated;
|
|
for (int meshIndex = 0;
|
|
meshIndex < meshParts.Length;
|
|
meshIndex++)
|
|
{
|
|
RenderFrameMeshPart packedPart = meshParts[meshIndex];
|
|
if (packedPart.ProjectionId != entity.ProjectionId)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Packed mesh part {packedPart.PartIndex} belongs "
|
|
+ $"to {packedPart.ProjectionId}, expected "
|
|
+ $"{entity.ProjectionId}.");
|
|
}
|
|
|
|
int partIndex = packedPart.PartIndex;
|
|
MeshRef meshRef = packedPart.MeshRef;
|
|
ObjectRenderData? renderData =
|
|
_meshAdapter.TryGetRenderData(meshRef.GfxObjId);
|
|
if (renderData is null)
|
|
{
|
|
reusableAcrossFrames = false;
|
|
if (_missRequested.Add(meshRef.GfxObjId))
|
|
_meshAdapter.EnsureLoaded(meshRef.GfxObjId);
|
|
continue;
|
|
}
|
|
if (anyVao == 0)
|
|
anyVao = renderData.VAO;
|
|
|
|
if (renderData.IsSetup
|
|
&& renderData.SetupParts.Count > 0)
|
|
{
|
|
// Campaign VM VM6 review fix round 2 (F1): same entity-
|
|
// scoped HasCutoutSubset OR the classic world-receiver loop
|
|
// computes (WbDrawDispatcher.cs) and the caster loop
|
|
// (DirectionalShadows.cs) — see
|
|
// FoliageWindClassification.ComputeEntityHasCutoutSubset's
|
|
// doc comment. A Setup composite's parts are separate
|
|
// GfxObjs with independently-cached HasCutoutSubset; without
|
|
// this bounded per-entity scan, the opaque trunk part of a
|
|
// production-classified tree would never get the trunk
|
|
// flag. The context-taking overload passes _meshAdapter
|
|
// explicitly to a static lambda (F3) — zero allocation per
|
|
// entity per frame.
|
|
bool entityHasCutoutSubset = FoliageWindClassification
|
|
.ComputeEntityHasCutoutSubset(
|
|
renderData.SetupParts,
|
|
_meshAdapter,
|
|
static (adapter, part) => adapter.TryGetRenderData(part.GfxObjId)
|
|
is { HasCutoutSubset: true });
|
|
|
|
for (int setupPartIndex = 0;
|
|
setupPartIndex < renderData.SetupParts.Count;
|
|
setupPartIndex++)
|
|
{
|
|
(ulong gfxObjId, Matrix4x4 partTransform) =
|
|
renderData.SetupParts[setupPartIndex];
|
|
ObjectRenderData? partData =
|
|
_meshAdapter.TryGetRenderData(gfxObjId);
|
|
if (partData is null)
|
|
{
|
|
reusableAcrossFrames = false;
|
|
if (_missRequested.Add(gfxObjId))
|
|
_meshAdapter.EnsureLoaded(gfxObjId);
|
|
continue;
|
|
}
|
|
|
|
float opacity = PackedPartOpacity(
|
|
entity.LocalEntityId,
|
|
(uint)setupPartIndex);
|
|
if (opacity < 1f)
|
|
reusableAcrossFrames = false;
|
|
if (opacity <= 0f)
|
|
continue;
|
|
|
|
Matrix4x4 restPose =
|
|
partTransform * meshRef.PartTransform;
|
|
Matrix4x4 model =
|
|
restPose * entity.RootWorld;
|
|
if (!ClassifyPackedBatches(
|
|
partData,
|
|
restPose,
|
|
model,
|
|
in entity,
|
|
meshRef,
|
|
paletteIdentity,
|
|
slot,
|
|
lights,
|
|
indoor,
|
|
selectionLighting,
|
|
opacity,
|
|
cacheEntry,
|
|
entityHasCutoutSubset))
|
|
{
|
|
reusableAcrossFrames = false;
|
|
}
|
|
int selectionPartIndex = unchecked(
|
|
(partIndex << 16)
|
|
| (setupPartIndex & 0xFFFF));
|
|
cacheEntry?.SelectionParts.Add(
|
|
new PackedClassifiedSelectionPart(
|
|
selectionPartIndex,
|
|
(uint)gfxObjId,
|
|
restPose));
|
|
AddPackedSelectionPart(
|
|
in entity,
|
|
selectionPartIndex,
|
|
(uint)gfxObjId,
|
|
model,
|
|
publishSelection);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// #188/#32: the packed part ordinal IS the retail CPartArray
|
|
// part ordinal TransparentPartHook.PartIndex addresses (one
|
|
// bare-GfxObj MeshRef per Setup.Parts[i] for flattened live
|
|
// entities; trivially 0 for single-part objects). The previous
|
|
// constant 0 mirrored the legacy dispatcher's false
|
|
// one-part assumption and kept the Bind Stone's four
|
|
// hook-hidden shard parts visible.
|
|
float opacity = PackedPartOpacity(
|
|
entity.LocalEntityId,
|
|
(uint)partIndex);
|
|
if (opacity < 1f)
|
|
reusableAcrossFrames = false;
|
|
if (opacity <= 0f)
|
|
continue;
|
|
|
|
Matrix4x4 restPose = meshRef.PartTransform;
|
|
Matrix4x4 model = restPose * entity.RootWorld;
|
|
if (!ClassifyPackedBatches(
|
|
renderData,
|
|
restPose,
|
|
model,
|
|
in entity,
|
|
meshRef,
|
|
paletteIdentity,
|
|
slot,
|
|
lights,
|
|
indoor,
|
|
selectionLighting,
|
|
opacity,
|
|
cacheEntry))
|
|
{
|
|
reusableAcrossFrames = false;
|
|
}
|
|
cacheEntry?.SelectionParts.Add(
|
|
new PackedClassifiedSelectionPart(
|
|
partIndex,
|
|
meshRef.GfxObjId,
|
|
restPose));
|
|
AddPackedSelectionPart(
|
|
in entity,
|
|
partIndex,
|
|
meshRef.GfxObjId,
|
|
model,
|
|
publishSelection);
|
|
}
|
|
}
|
|
|
|
if (cacheEntry is not null)
|
|
{
|
|
_packedClassificationCache.CompleteRebuild(
|
|
cacheEntry,
|
|
reusableAcrossFrames);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mirrors the production dispatcher's no-VAO early return. That return
|
|
/// records an empty submission with transparent deferral disabled even
|
|
/// when the frame alpha queue is collecting.
|
|
/// </summary>
|
|
internal static bool ShouldDeferPackedTransparent(
|
|
uint anyVao,
|
|
bool alphaQueueCollecting) =>
|
|
anyVao != 0 && alphaQueueCollecting;
|
|
|
|
private float PackedPartOpacity(
|
|
uint localEntityId,
|
|
uint setupPartIndex)
|
|
{
|
|
if (!_translucencyFades.TryGetCurrentValue(
|
|
localEntityId,
|
|
setupPartIndex,
|
|
out float translucency))
|
|
{
|
|
return 1f;
|
|
}
|
|
|
|
return translucency >= 1f
|
|
? 0f
|
|
: 1f - translucency;
|
|
}
|
|
|
|
private bool ClassifyPackedBatches(
|
|
ObjectRenderData renderData,
|
|
Matrix4x4 restPose,
|
|
Matrix4x4 model,
|
|
in RenderInstanceCandidate entity,
|
|
MeshRef meshRef,
|
|
PaletteCompositeIdentity paletteIdentity,
|
|
uint slot,
|
|
InstanceLightSet lights,
|
|
bool indoor,
|
|
Vector2 selectionLighting,
|
|
float opacity,
|
|
PackedProjectionClassificationEntry? cacheEntry,
|
|
// Campaign VM VM6 review fix round 2 (F1): mirrors the classic
|
|
// dispatcher's ClassifyBatches override exactly. HasCutoutSubset is
|
|
// a per-PART (per-GfxObj) fact; the caller passes the ENTITY/Setup-
|
|
// scoped OR across every resolved part for a Setup composite. null
|
|
// (the default) means "use renderData.HasCutoutSubset directly",
|
|
// which is already correct for a non-Setup single-mesh entity.
|
|
bool? entityHasCutoutSubsetOverride = null)
|
|
{
|
|
bool entityHasCutoutSubset =
|
|
entityHasCutoutSubsetOverride ?? renderData.HasCutoutSubset;
|
|
bool reusableAcrossFrames = true;
|
|
for (int batchIndex = 0;
|
|
batchIndex < renderData.Batches.Count;
|
|
batchIndex++)
|
|
{
|
|
ObjectRenderBatch batch =
|
|
renderData.Batches[batchIndex];
|
|
|
|
// #426: mirrors the classic ClassifyBatches gate exactly — see
|
|
// RetailUntexturedSubsetPolicy for the retail citation. ONE
|
|
// shared predicate so the classic and packed classifiers cannot
|
|
// drift (Campaign VM VM6).
|
|
if (!RetailUntexturedSubsetPolicy.Draws(entity.IsBuildingShell, batch.Key.IsSolid))
|
|
continue;
|
|
|
|
TranslucencyKind translucency = batch.Translucency;
|
|
if (opacity < 1f && IsOpaque(translucency))
|
|
translucency = TranslucencyKind.AlphaBlend;
|
|
|
|
ResolvedTexture texture = ResolveTexture(
|
|
in entity,
|
|
meshRef,
|
|
batch,
|
|
paletteIdentity,
|
|
out bool compositePending);
|
|
if (compositePending)
|
|
reusableAcrossFrames = false;
|
|
if (!texture.Slot.IsAssigned)
|
|
continue;
|
|
|
|
// Campaign VM VM6 review fix round 2 (F1 BLOCKER): the packed
|
|
// production classifier never computed FoliageFlags, so the
|
|
// production BatchData.flags word was always 0 for every
|
|
// scenery entity — the world geometry never swayed even though
|
|
// the independently-classified shadow caster did. Classify
|
|
// BEFORE constructing the key, from the RAW (pre-#188-
|
|
// promotion) batch.Translucency, exactly as the classic
|
|
// ClassifyBatches does — see that method's own comment for why
|
|
// raw translucency is used for classification but the (possibly
|
|
// promoted) local `translucency` is still what the key/group
|
|
// partitions draws by.
|
|
uint foliageFlags = FoliageWindClassification.Classify(
|
|
entity.LocalEntityId,
|
|
FoliageWindExclusions.Contains(meshRef.GfxObjId),
|
|
batch.Translucency,
|
|
entityHasCutoutSubset);
|
|
var key = new GroupKey(
|
|
batch.FirstIndex,
|
|
(int)batch.BaseVertex,
|
|
batch.IndexCount,
|
|
texture.Slot,
|
|
texture.Layer,
|
|
translucency,
|
|
FoliageFlags: foliageFlags,
|
|
CullMode: batch.CullMode);
|
|
var classified = new PackedClassifiedBatch(
|
|
key,
|
|
restPose,
|
|
renderData.SortCenter);
|
|
cacheEntry?.Batches.Add(classified);
|
|
AppendPackedClassification(
|
|
in classified,
|
|
model,
|
|
slot,
|
|
lights,
|
|
indoor,
|
|
entity.IsBuildingShell,
|
|
opacity,
|
|
selectionLighting);
|
|
}
|
|
|
|
return reusableAcrossFrames;
|
|
}
|
|
|
|
private void ReplayPackedClassification(
|
|
PackedProjectionClassificationEntry entry,
|
|
in RenderInstanceCandidate entity,
|
|
uint slot,
|
|
InstanceLightSet lights,
|
|
bool indoor,
|
|
Vector2 selectionLighting,
|
|
bool publishSelection)
|
|
{
|
|
for (int i = 0; i < entry.Batches.Count; i++)
|
|
{
|
|
PackedClassifiedBatch classified = entry.Batches[i];
|
|
AppendPackedClassification(
|
|
in classified,
|
|
classified.RestPose * entity.RootWorld,
|
|
slot,
|
|
lights,
|
|
indoor,
|
|
entity.IsBuildingShell,
|
|
opacity: 1f,
|
|
selectionLighting);
|
|
}
|
|
|
|
for (int i = 0; i < entry.SelectionParts.Count; i++)
|
|
{
|
|
PackedClassifiedSelectionPart part =
|
|
entry.SelectionParts[i];
|
|
AddPackedSelectionPart(
|
|
in entity,
|
|
part.PartIndex,
|
|
part.GfxObjId,
|
|
part.RestPose * entity.RootWorld,
|
|
publishSelection);
|
|
}
|
|
}
|
|
|
|
private void AppendPackedClassification(
|
|
in PackedClassifiedBatch classified,
|
|
Matrix4x4 model,
|
|
uint slot,
|
|
InstanceLightSet lights,
|
|
bool indoor,
|
|
bool buildingDetail,
|
|
float opacity,
|
|
Vector2 selectionLighting)
|
|
{
|
|
InstanceGroup group =
|
|
GetOrCreatePackedGroup(classified.Key);
|
|
AppendPackedInstance(
|
|
group,
|
|
model,
|
|
classified.LocalSortCenter,
|
|
_nextPackedInstanceSubmissionOrder++,
|
|
slot,
|
|
lights,
|
|
indoor,
|
|
buildingDetail,
|
|
opacity,
|
|
selectionLighting);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Appends one packed-route instance and every per-instance attribute in
|
|
/// lockstep. Keeping the writer in one testable seam prevents a newly
|
|
/// introduced storage binding from covering the legacy classifier while
|
|
/// leaving the production packed classifier with a shorter parallel list.
|
|
/// </summary>
|
|
internal static void AppendPackedInstance(
|
|
InstanceGroup group,
|
|
Matrix4x4 model,
|
|
Vector3 localSortCenter,
|
|
int submissionOrder,
|
|
uint slot,
|
|
InstanceLightSet lights,
|
|
bool indoor,
|
|
bool buildingDetail,
|
|
float opacity,
|
|
Vector2 selectionLighting)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(group);
|
|
group.Matrices.Add(model);
|
|
group.LocalSortCenters.Add(localSortCenter);
|
|
group.SubmissionOrders.Add(submissionOrder);
|
|
group.Slots.Add(slot);
|
|
group.LightSets.Add(lights);
|
|
group.IndoorFlags.Add(indoor ? 1u : 0u);
|
|
group.DetailCategories.Add(buildingDetail ? 1u : 0u);
|
|
group.Opacities.Add(opacity);
|
|
group.SelectionLighting.Add(selectionLighting);
|
|
}
|
|
|
|
private InstanceGroup GetOrCreatePackedGroup(GroupKey key)
|
|
{
|
|
if (_packedGroups.TryGetValue(
|
|
key,
|
|
out InstanceGroup? group))
|
|
{
|
|
group.LastUsedFrame = _packedGroupFrame;
|
|
return group;
|
|
}
|
|
if (_nextPackedGroupRegistration == long.MaxValue)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Packed instance-group registration space was exhausted.");
|
|
}
|
|
|
|
// Campaign VM VM6 review fix round 3 (N2a): both routes share the
|
|
// ONE InstanceGroup-from-key seam now — see CreateGroupFromKey's
|
|
// doc comment. This is exactly the seam that closed the F1 bug
|
|
// (FoliageFlags copied on the classic side, dropped here) by
|
|
// construction: there is now exactly one place either route can
|
|
// build an InstanceGroup, and it always copies every GroupKey field.
|
|
group = CreateGroupFromKey(
|
|
key,
|
|
registration: _nextPackedGroupRegistration++,
|
|
frame: _packedGroupFrame);
|
|
_packedGroups.Add(key, group);
|
|
return group;
|
|
}
|
|
|
|
private void ResolvePackedLightSet(
|
|
in RenderInstanceCandidate entity,
|
|
out InstanceLightSet lights,
|
|
out bool indoor)
|
|
{
|
|
indoor = IndoorObjectReceivesTorches(entity.ParentCell);
|
|
lights = InstanceLightSet.Disabled;
|
|
IReadOnlyList<LightSource>? snapshot = _pointSnapshot;
|
|
if (!indoor || snapshot is null || snapshot.Count == 0)
|
|
return;
|
|
|
|
Vector3 center =
|
|
(entity.Bounds.Minimum + entity.Bounds.Maximum) * 0.5f;
|
|
float radius =
|
|
(entity.Bounds.Maximum - entity.Bounds.Minimum)
|
|
.Length() * 0.5f;
|
|
Span<int> selected =
|
|
stackalloc int[LightManager.MaxLightsPerObject];
|
|
selected.Fill(-1);
|
|
LightManager.SelectForObject(
|
|
snapshot,
|
|
center,
|
|
radius,
|
|
selected);
|
|
lights = InstanceLightSet.From(selected);
|
|
}
|
|
|
|
private void AddPackedSelectionPart(
|
|
in RenderInstanceCandidate entity,
|
|
int partIndex,
|
|
uint gfxObjId,
|
|
Matrix4x4 localToWorld,
|
|
bool publishSelection)
|
|
{
|
|
if (!_packedSelectionKeys.Add(new PackedSelectionKey(
|
|
entity.LocalEntityId,
|
|
partIndex,
|
|
gfxObjId)))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (publishSelection)
|
|
{
|
|
_selectionSink?.AddVisiblePart(
|
|
entity.ServerGuid,
|
|
entity.LocalEntityId,
|
|
partIndex,
|
|
gfxObjId,
|
|
localToWorld);
|
|
return;
|
|
}
|
|
|
|
if (_selectionSink is not IRetailSelectionRenderOracle oracle
|
|
|| !oracle.TryCreateVisiblePart(
|
|
entity.ServerGuid,
|
|
entity.LocalEntityId,
|
|
partIndex,
|
|
gfxObjId,
|
|
localToWorld,
|
|
out RetailSelectionPart part))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_packedSelectionParts.Add(
|
|
new CurrentRenderSelectionFingerprint(
|
|
Sequence: _packedSelectionParts.Count,
|
|
ServerGuid: part.ServerGuid,
|
|
LocalEntityId: part.LocalEntityId,
|
|
PartIndex: part.PartIndex,
|
|
GfxObjId: gfxObjId,
|
|
LocalToWorld: part.LocalToWorld,
|
|
Geometry: CurrentRenderSceneOracle
|
|
.FingerprintSelectionGeometry(part.Mesh)));
|
|
}
|
|
|
|
private readonly record struct PackedSelectionKey(
|
|
uint LocalEntityId,
|
|
int PartIndex,
|
|
uint GfxObjId);
|
|
}
|