using System.Numerics; using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Selection; using AcDream.App.Rendering.Walk; using AcDream.Core.Lighting; using AcDream.Core.Meshing; using AcDream.Core.World; using DatReaderWriter.Enums; namespace AcDream.App.Rendering.Wb; /// /// Campaign FW stage FW3.2a: the walk-order population layer. This partial /// holds two things. /// /// The shared per-batch classify core (): /// extracted from ClassifyBatches (this file's sibling /// WbDrawDispatcher.cs) and ClassifyPackedBatches /// (WbDrawDispatcher.PackedOracle.cs), which carried byte-identical /// per-batch logic (the #426 untextured-subset gate, the #188 opacity /// promotion, texture resolution, Campaign VM foliage classification, the /// construction) with only their SURROUNDING /// bookkeeping differing (classic appends to an /// via caller-scoped instance fields; packed appends via explicit /// parameters). Both call sites now call this one method per batch and keep /// their own append logic — the classic and packed paths are unchanged in /// every observable way (same hermetic + Lane=InstalledDat suites /// stay the referee); this stage's OWN new walk classifier /// () is the third caller. /// /// The per-entity walk classify seam /// (): given ONE /// — the same shape ClassifyPackedEntity consumes, but read straight /// off the record's own via /// RenderInstanceCandidate.FromProjection rather than the packed /// route's frame-arena mesh-part flattening — yields one /// per surviving (part, batch) pair WITHOUT /// touching any , plus the per-part selection /// data AddPackedSelectionPart would have published, for /// WalkStaticStreamPopulator to publish itself (deliverable /// 1 leaves the publish decision — timing, stage/cell provenance — to the /// caller; see , the thin internal seam /// that keeps _selectionSink encapsulated). /// /// Scope: this stage classifies static content only (no production /// frame wiring — see plan §FW3.2a). Per-part translucency-fade /// (TranslucencyFadeManager/EntityOpacity) is NOT threaded /// through: that mechanic is TransparentPartHook, a retail LIVE-entity /// behavior keyed by ServerGuid, not something world statics undergo — every /// classified batch here carries Alpha = 1f. Likewise the async /// mesh-miss self-heal request (_missRequested/EnsureLoaded, /// frame-scoped state cleared by BeginEntityDispatch) is not fired /// here — this seam has no production frame to be scoped to yet; a missing /// mesh is simply skipped, matching every other unwired FW2/FW3.2a path. /// public sealed partial class WbDrawDispatcher { /// /// One walk-classified (entity, part, batch) draw candidate — exactly /// OrderedDrawCommand's per-instance field set (minus /// the walk-provenance Stage/CellId fields, which the /// populator stamps on since the classifier has no notion of either) /// plus two fields an opaque command has no use for but a translucent /// one needs to reach the alpha queue: (so the /// populator can route without re-deriving it from /// ) and /// (the authored GfxObj sort center RetailAlphaOrdering.ComputeViewerDistance /// transforms through — the same value /// InstanceGroup.LocalSortCenters carries per instance today). /// internal readonly record struct WalkClassifiedBatch( GroupKey Key, Matrix4x4 Transform, uint ClipSlot, InstanceLightSet Lights, uint IndoorFlag, float Alpha, Vector2 SelectionLighting, uint DetailCategory, bool IsOpaque, Vector3 LocalSortCenter); /// /// One retail-picking part surfaced by /// — the exact argument tuple AddPackedSelectionPart's /// publishSelection: true branch passes to /// IRetailSelectionRenderSink.AddVisiblePart. The classify seam /// surfaces this; is the caller's /// publish call. /// internal readonly record struct WalkClassifiedSelectionPart( uint ServerGuid, uint LocalEntityId, int PartIndex, uint GfxObjId, Matrix4x4 LocalToWorld); /// /// The shared per-batch classify core. Given one already-resolved /// and the batch at , /// applies — in this exact order, matching both ClassifyBatches and /// ClassifyPackedBatches before this extraction — the #426 /// untextured-subset-on-a-shell-only gate, the #188 mid-fade-forces- /// AlphaBlend promotion, texture resolution, and Campaign VM foliage /// classification, then builds the surviving batch's . /// /// Returns false when the batch does not survive — either the /// untextured-subset gate rejected it (in which case /// is always false: ResolveTexture /// is never called) or its resolved texture slot is unassigned (in which /// case still reflects whatever /// ResolveTexture reported — a caller must apply it to its own /// reusability/readiness tracking regardless of the false return, exactly /// as both existing callers did before this extraction). /// private bool TryClassifyBatch( ObjectRenderData renderData, int batchIndex, in RenderInstanceCandidate entity, MeshRef meshRef, PaletteCompositeIdentity paletteIdentity, float opacityMultiplier, bool entityHasCutoutSubset, out GroupKey key, out bool compositePending) { key = default; compositePending = false; ObjectRenderBatch batch = renderData.Batches[batchIndex]; // #426 — see RetailUntexturedSubsetPolicy for the retail citation. // ONE shared predicate for every classifier so they cannot drift. if (!RetailUntexturedSubsetPolicy.Draws(entity.IsBuildingShell, batch.Key.IsSolid)) return false; TranslucencyKind translucency = batch.Translucency; // #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; ResolvedTexture texture = ResolveTexture( in entity, meshRef, batch, paletteIdentity, out compositePending); if (!texture.Slot.IsAssigned) return false; // Classify from the RAW (pre-#188-promotion) batch.Translucency — a // mid-fade trunk is still a trunk, it just landed in the alpha-blend // group instead of opaque (Campaign VM VM6 review fix round). uint foliageFlags = FoliageWindClassification.Classify( entity.LocalEntityId, FoliageWindExclusions.Contains(meshRef.GfxObjId), batch.Translucency, entityHasCutoutSubset); key = new GroupKey( batch.FirstIndex, (int)batch.BaseVertex, batch.IndexCount, texture.Slot, texture.Layer, translucency, FoliageFlags: foliageFlags, CullMode: batch.CullMode); return true; } /// /// Classifies one static entity for the walk populator: resolves its clip /// slot / light set / selection lighting exactly as /// ClassifyPackedEntity does (via the shared /// / ResolveWalkLightSet /// helpers), walks its Setup parts or single mesh (mirroring /// ClassifyPackedEntity's own shape), and appends one /// per surviving batch to /// plus one /// per part to — WITHOUT touching /// _groups/_packedGroups or publishing selection itself (see /// this file's type doc comment). /// /// A culled entity ( returns /// Culled: true — not visible through the active clip route) /// contributes nothing, matching every other classifier's cull gate. /// internal void ClassifyEntityForWalk( in RenderProjectionRecord projection, uint tupleLandblockId, List batches, List selectionParts, bool liveDynamic = false, IWalkLookInViewSource? lookInViews = null, int lookInRouteIndex = -1, uint lookInCellId = 0) { ArgumentNullException.ThrowIfNull(batches); ArgumentNullException.ThrowIfNull(selectionParts); RenderInstanceCandidate entity = RenderInstanceCandidate.FromProjection( in projection, tupleLandblockId, animated: liveDynamic); (uint slot, bool culled) = ResolveSlotForFrame( _clipRoutingActive, entity.ServerGuid, entity.ParentCell, _cellIdToSlot, _outdoorSlot, _outdoorVisible); if (culled) return; ResolveWalkLightSet(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); uint detailCategory = entity.IsBuildingShell ? 1u : 0u; PaletteCompositeIdentity paletteIdentity = default; if (entity.PaletteOverride is not null) paletteIdentity = TextureCache.GetPaletteIdentity(entity.PaletteOverride); IReadOnlyList? meshRefs = projection.EntityPayload.MeshRefs; if (meshRefs is null) return; for (int partIndex = 0; partIndex < meshRefs.Count; partIndex++) { MeshRef meshRef = meshRefs[partIndex]; if (_meshAdapter.IsRuntimeHiddenMarker(meshRef.GfxObjId)) continue; ObjectRenderData? renderData = _meshAdapter.TryGetRenderData(meshRef.GfxObjId); if (renderData is null) { if (_missRequested.Add(meshRef.GfxObjId)) _meshAdapter.EnsureLoaded(meshRef.GfxObjId); continue; } if (renderData.IsSetup && renderData.SetupParts.Count > 0) { // Same entity-scoped OR the classic/packed classifiers compute // — a Setup composite's parts are separate GfxObjs with their // own independently cached HasCutoutSubset (Campaign VM VM6 // review fix round A4/F1). 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]; if (_meshAdapter.IsRuntimeHiddenMarker((uint)gfxObjId)) continue; ObjectRenderData? partData = _meshAdapter.TryGetRenderData(gfxObjId); if (partData is null) { if (_missRequested.Add(gfxObjId)) _meshAdapter.EnsureLoaded(gfxObjId); continue; } float opacity = liveDynamic ? WalkPartOpacity( entity.ServerGuid, entity.LocalEntityId, (uint)setupPartIndex) : 1f; if (opacity <= 0f) continue; Matrix4x4 restPose = partTransform * meshRef.PartTransform; Matrix4x4 model = restPose * entity.RootWorld; int selectionPartIndex = unchecked((partIndex << 16) | (setupPartIndex & 0xFFFF)); if (!PartVisibleInLookInTurn( lookInViews, lookInRouteIndex, lookInCellId, in entity, selectionPartIndex, (uint)gfxObjId, partData, model)) { continue; } EmitClassifiedBatches( partData, model, in entity, meshRef, paletteIdentity, entityHasCutoutSubset, slot, lights, indoor, selectionLighting, detailCategory, opacity, batches); selectionParts.Add(new WalkClassifiedSelectionPart( entity.ServerGuid, entity.LocalEntityId, selectionPartIndex, (uint)gfxObjId, model)); } } else { float opacity = liveDynamic ? WalkPartOpacity( entity.ServerGuid, entity.LocalEntityId, (uint)partIndex) : 1f; if (opacity <= 0f) continue; Matrix4x4 model = meshRef.PartTransform * entity.RootWorld; if (!PartVisibleInLookInTurn( lookInViews, lookInRouteIndex, lookInCellId, in entity, partIndex, (uint)meshRef.GfxObjId, renderData, model)) { continue; } EmitClassifiedBatches( renderData, model, in entity, meshRef, paletteIdentity, entityHasCutoutSubsetOverride: null, slot, lights, indoor, selectionLighting, detailCategory, opacity, batches); selectionParts.Add(new WalkClassifiedSelectionPart( entity.ServerGuid, entity.LocalEntityId, partIndex, (uint)meshRef.GfxObjId, model)); } } } private float WalkPartOpacity( uint serverGuid, uint localEntityId, uint setupPartIndex) { float opacity = EntityOpacity(serverGuid); if (opacity <= 0f) return 0f; if (!_translucencyFades.TryGetCurrentValue( localEntityId, setupPartIndex, out float translucency)) { return opacity; } return translucency >= 1f ? 0f : opacity * (1f - translucency); } private void ResolveWalkLightSet( in RenderInstanceCandidate entity, out InstanceLightSet lights, out bool indoor) { indoor = IndoorObjectReceivesTorches(entity.ParentCell); lights = InstanceLightSet.Disabled; IReadOnlyList? 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 selected = stackalloc int[LightManager.MaxLightsPerObject]; selected.Fill(-1); LightManager.SelectForObject( snapshot, center, radius, selected); lights = InstanceLightSet.From(selected); } private static bool PartVisibleInLookInTurn( IWalkLookInViewSource? lookInViews, int routeIndex, uint cellId, in RenderInstanceCandidate entity, int partIndex, uint gfxObjId, ObjectRenderData renderData, Matrix4x4 localToWorld) { if (lookInViews is null) return true; if (renderData.SelectionSphere is not { Radius: > 0f } sphere) return true; return LookInDrawingSphereVisible( lookInViews, routeIndex, sphere, localToWorld, out _, out _); } internal static bool LookInDrawingSphereVisible( IWalkLookInViewSource lookInViews, int routeIndex, DatReaderWriter.Types.Sphere sphere, Matrix4x4 localToWorld, out Vector3 center, out float radius) { ArgumentNullException.ThrowIfNull(lookInViews); ArgumentNullException.ThrowIfNull(sphere); center = Vector3.Transform(sphere.Origin, localToWorld); float scaleX = new Vector3( localToWorld.M11, localToWorld.M12, localToWorld.M13).Length(); float scaleY = new Vector3( localToWorld.M21, localToWorld.M22, localToWorld.M23).Length(); float scaleZ = new Vector3( localToWorld.M31, localToWorld.M32, localToWorld.M33).Length(); radius = sphere.Radius * MathF.Max(scaleX, MathF.Max(scaleY, scaleZ)); return lookInViews.SphereVisibleInLookInTurn( routeIndex, in center, radius); } /// /// Walks one resolved mesh's batches through /// and appends every surviving one to at /// Alpha = 1f (see this file's type doc comment for why statics /// never carry a per-part opacity multiplier here). /// private void EmitClassifiedBatches( ObjectRenderData renderData, Matrix4x4 model, in RenderInstanceCandidate entity, MeshRef meshRef, PaletteCompositeIdentity paletteIdentity, bool? entityHasCutoutSubsetOverride, uint slot, InstanceLightSet lights, bool indoor, Vector2 selectionLighting, uint detailCategory, float opacity, List sink) { bool entityHasCutoutSubset = entityHasCutoutSubsetOverride ?? renderData.HasCutoutSubset; for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++) { bool survives = TryClassifyBatch( renderData, batchIdx, in entity, meshRef, paletteIdentity, opacityMultiplier: opacity, entityHasCutoutSubset, out GroupKey key, out _); if (!survives) continue; sink.Add(new WalkClassifiedBatch( key, model, slot, lights, indoor ? 1u : 0u, Alpha: opacity, selectionLighting, detailCategory, IsOpaque: IsOpaque(key.Translucency), LocalSortCenter: renderData.SortCenter)); } } /// /// The thin internal publish seam WalkStaticStreamPopulator /// calls for every /// surfaced — same call shape as /// AddPackedSelectionPart's publishSelection: true branch. /// Keeps _selectionSink encapsulated: the populator lives outside /// the dispatcher and must not reach the field directly. /// internal void PublishWalkSelectionPart(in WalkClassifiedSelectionPart part) => _selectionSink?.AddVisiblePart( part.ServerGuid, part.LocalEntityId, part.PartIndex, part.GfxObjId, part.LocalToWorld); /// /// The walk populator's per-instance sibling of DeferTransparentGroups /// (see that method for the retail citations this mirrors): submits ONE /// translucent into the same /// _deferredAlpha/ machinery the /// classic material-grouped path uses, so scenery, particles, and walk /// content share retail's one stable far-to-near stream. Same /// view-projection consistency check, same /// call, same /// queue.Submit contract — the walk path denormalizes to one /// instance per call instead of flattening a material group. /// internal void SubmitWalkAlphaInstance( in WalkClassifiedBatch batch, Vector3 cameraWorldPosition, Matrix4x4 viewProjection) { RetailAlphaQueue queue = _alphaQueue ?? throw new InvalidOperationException( "SubmitWalkAlphaInstance requires an active RetailAlphaQueue."); if (_deferredAlpha.Count == 0) _deferredAlphaViewProjection = viewProjection; else if (_deferredAlphaViewProjection != viewProjection) throw new InvalidOperationException( "One retail alpha scope cannot combine different view-projection matrices."); float viewerDistance = RetailAlphaOrdering.ComputeViewerDistance( batch.LocalSortCenter, batch.Transform, cameraWorldPosition); if (!float.IsFinite(viewerDistance) || viewerDistance <= 0f) viewerDistance = 0f; int token = _deferredAlpha.Count; _deferredAlpha.Add(new DeferredAlphaInstance( batch.Key, batch.Transform, batch.ClipSlot, batch.Lights, batch.IndoorFlag, batch.DetailCategory, batch.Alpha, batch.SelectionLighting)); queue.Submit(_alphaSource, token, viewerDistance); } }