feat(render): Campaign V slice V4a - port TextRenderer/BitmapFont/DebugLineRenderer/TextureCache onto IGpuDevice
TextRenderer, BitmapFont, DebugLineRenderer, and TextureCache's UI-texture
upload path (GetOrUploadRenderSurface/UploadRgba8) now issue every draw and
resource creation through the pinned IGpuDevice/IGpuFrame/IGpuPassEncoder
RHI contract instead of raw GL. This is the RHI's first real production
consumer - V0-V3 only established the contract, GL backend skeleton, and a
shader-dialect migration with no live GL exercise. TextRenderer owns one
IGpuPipeline (ui_text shader, straight-alpha blend, depth disabled) and
allocates a per-bucket ring each Flush; BitmapFont's atlas texture is
created and uploaded via device.CreateTexture/.Upload; DebugLineRenderer
mirrors the same one-pipeline-per-Flush shape for its line-list draws.
World-path TextureCache methods (GetOrUpload, the raw-GL layer-array
upload) are untouched - still legacy GL, still out of scope.
Frame lifecycle: GpuDeviceFrameLifetime (RenderFrameOrchestrator.cs) wraps
IGpuDevice.BeginFrame()/IGpuFrame.End() inside the existing
IRenderFrameLifetime bracket HostInputCameraCompositionPhase already opens
per callback, additively - no frame-graph restructuring. Ported renderers
reach the frame via ICurrentGpuFrameSource, a plain interface (not a
delegate field) so WorldSceneDiagnosticsController keeps passing its
existing "no stored window/delegate" architectural-conformance test.
Two real bugs surfaced by actually exercising the RHI against a live GL
context (nothing here was previously reachable before this slice):
- GlGpuDevice.BeginFrame() now resets the render-state cache every frame.
The cache assumes it is the sole writer of GL program/blend/depth/cull
state, which was true while it had zero real consumers, but every
still-legacy renderer (WbDrawDispatcher, terrain, particles, EnvCells)
mutates that same GL state directly and never informs the cache. Once a
legacy renderer ran between two RHI binds, the cache's belief about the
current GL program went stale, so a later BindPipeline(text shader)
skipped re-issuing glUseProgram and the following push-constant upload
threw GL_INVALID_OPERATION against whatever program was actually bound.
Reset() at the frame boundary is the same defensive move BeginPass
already makes after a forced clear (see its comment); it costs one
redundant state application on the frame's first bind.
- GL_MULTISAMPLE has no representation in the pinned contract. Added a
GL-backend-internal Multisample field to GlRenderStateSnapshot/Changes,
computed from GpuPipelineDescription.SampleCount at BindPipeline time -
mirrors how Vulkan bakes MSAA into the pipeline instead of a separate
toggle.
Collateral, scoped to keep the port real rather than a stub:
- GpuTextureSlot (Unassigned = uint.MaxValue, NOT 0) now flows through
every consumer of TextureCache.GetOrUploadRenderSurface/UploadRgba8 and
TextRenderer.DrawSprite - the entire retained UI layer, since a pervasive
Func<uint,(uint,int,int)> sprite-resolve delegate threads through nearly
every UI element/controller. Every prior `== 0` / `!= 0` "no texture"
check became `.IsAssigned` / `!.IsAssigned`; slot 0 is a real assigned
slot (the device's default white texture), so the old sentinel would
have produced live visual regressions if left in place.
- GpuTextureSlot/IGpuDevice/IGpuFrame are internal, so ~270 previously
public AcDream.App types that touched them (directly or transitively)
are now internal too - safe, since AcDream.App is an exe with no
external project references; only the two test projects consume it, via
InternalsVisibleTo. A handful of unrelated types the sweep caught
(ElementInfo/ImportedLayout's property-bag hierarchy, several enums used
as public [Theory] parameters, CursorFeedbackSnapshot's DragAcceptState)
were reverted back to public where making them internal would have
either cascaded into unrelated files or broken xUnit's public-member
discovery.
- ExternalViewportTextureBridge (new) registers the still-raw-GL FBO
color textures PrivateEntityViewportRenderer/PaperdollViewportRenderer
produce (V4g's scope) into the device's texture table for
UiViewport.TextureHandle, via a temporary
GlGpuDevice.RegisterExternalColorTexture escape hatch (internal, not
part of IGpuDevice) deleted when V4g ports those viewports.
- TextRenderGlStateScope.cs and its test deleted: the pipeline description
now bakes what it used to restore by hand.
- ResourceCleanupGroupTests/GlTextureOwnershipTests: the two source-text
conformance tests keyed to TextRenderer's old multi-resource
construction shape (Shader + per-flight FrameBufferSet array + white
texture + tracked VAO/VBO, all via ResourceCleanupGroup) no longer apply
- that shape is gone, replaced by one IGpuPipeline created through
IGpuDevice. The construction-order test is deleted; the checked-commit
texture-creation check now targets GlGpuTexture (which already used
the same GlResourceCommand.CreateName primitive before this slice).
Gates:
- dotnet build -c Release: 0 warnings, 0 errors (AcDream.App has
TreatWarningsAsErrors).
- dotnet test tests/AcDream.App.Tests -c Release: 3,840 passed / 3
skipped (was 3,843/3 entering this slice - net 3 fewer tests:
TextRendererFailureSafetyTests.cs deleted (2, tested the now-deleted
TextRenderGlStateScope) plus the one retired ResourceCleanupGroupTests
method). Full solution: 8,908 passed / 5 skipped across all nine test
projects.
- Offline pixel gate (tools/run-offline-pixel-gate.ps1, parent ec414d60
vs this commit): differing fraction 0.318% (1,791/563,200 compared
pixels), above the 0.001 threshold. Investigated pixel-by-pixel rather
than waved through: a diff heatmap plus 4x crops at the differing
clusters show zero differences anywhere in the retained UI, terrain,
scenery, or static meshes - every differing pixel sits on continuously-
animated ambient content (flying-insect sprites over the swamp, foliage
sparkle/dew glints) whose exact phase depends on elapsed wall-clock
time, the same category the gate's own sky-masking rationale already
documents and the campaign doc's coverage table explicitly excludes
("Not covered - particles"). Confirming evidence: two same-commit
captures at HEAD compare clean against each other (0.0025%), and two
same-commit captures at the parent compare clean against each other
(0.0044%) - only base-vs-head is consistently elevated, which is what
frame-pacing drift from genuinely new per-frame RHI work (BeginFrame,
ring resets, the render-state reset above) would produce against a
fixed wall-clock capture deadline, not a rendering defect. Recommend a
quick user visual check of this capture pair alongside the automated
result, matching how V2c's particle work was already handled in this
campaign (flagged for user visual confirmation rather than blocked on
an automated gate that cannot cover animated content).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ec414d60cd
commit
ceec3bc440
334 changed files with 3660 additions and 3840 deletions
|
|
@ -1,4 +1,4 @@
|
|||
// ClipPlaneSet.cs
|
||||
// ClipPlaneSet.cs
|
||||
//
|
||||
// Phase U.2c: turn a CellView (a cell's accumulated screen-space clip region,
|
||||
// in NDC) into a small set of clip-space half-space planes for the GPU's
|
||||
|
|
@ -6,18 +6,18 @@
|
|||
// convex plane set.
|
||||
//
|
||||
// This is the bridge between PortalVisibilityBuilder's 2D NDC view polygons and
|
||||
// the per-vertex clip the mesh/terrain shaders will perform (Phase U.2c → U.2e).
|
||||
// the per-vertex clip the mesh/terrain shaders will perform (Phase U.2c → U.2e).
|
||||
// Pure System.Numerics math; NO GL. The shader consumes each plane as
|
||||
// d = nx*clip.x + ny*clip.y + 0*clip.z + dw*clip.w (>= 0 ⇒ keep)
|
||||
// d = nx*clip.x + ny*clip.y + 0*clip.z + dw*clip.w (>= 0 ⇒ keep)
|
||||
// where (nx, ny, dw) = the plane's (normal.xy, offset). z is always 0 because a
|
||||
// screen-space (NDC) edge is a vertical slab in clip space — independent of depth.
|
||||
// screen-space (NDC) edge is a vertical slab in clip space — independent of depth.
|
||||
//
|
||||
// === The convexity rule (read before touching this file) =====================
|
||||
// gl_ClipDistance planes are a CONJUNCTION of half-spaces, i.e. exactly ONE
|
||||
// convex region (their intersection). A CellView with MORE THAN ONE polygon is a
|
||||
// UNION of convex regions, which is in general NOT convex and CANNOT be
|
||||
// represented by one plane set. Emitting just the first/largest polygon's planes
|
||||
// would clip away the others → a real visibility bug (under-inclusion).
|
||||
// would clip away the others → a real visibility bug (under-inclusion).
|
||||
//
|
||||
// Therefore From() NEVER emits a single polygon's planes when the CellView holds
|
||||
// several. Multi-polygon (and >8-edge) regions degrade to the UNION AABB scissor:
|
||||
|
|
@ -27,13 +27,13 @@
|
|||
//
|
||||
// === The three Count==0 states (how a consumer tells them apart) =============
|
||||
// Count == 0 can mean three different things; the consumer MUST distinguish:
|
||||
// (a) Empty — IsNothingVisible == true, UseScissorFallback == false.
|
||||
// The cell/region isn't visible at all → DRAW NOTHING. The
|
||||
// (a) Empty — IsNothingVisible == true, UseScissorFallback == false.
|
||||
// The cell/region isn't visible at all → DRAW NOTHING. The
|
||||
// ScissorNdcAabb is a degenerate inverted box (min > max) so that a
|
||||
// consumer which naively scissors on it still draws nothing.
|
||||
// (b) Scissor — UseScissorFallback == true, IsNothingVisible == false.
|
||||
// (b) Scissor — UseScissorFallback == true, IsNothingVisible == false.
|
||||
// The convex-plane budget was exceeded (multi-polygon or >8 edges)
|
||||
// → DRAW the ScissorNdcAabb box (a valid min<=max NDC rectangle).
|
||||
// → DRAW the ScissorNdcAabb box (a valid min<=max NDC rectangle).
|
||||
// There is no third Count==0 state produced by From(). (A separate "no-clip,
|
||||
// pass-all" slot 0 is constructed by the consumer directly, not via From().)
|
||||
// When Count > 0, Planes carries the convex gate and the scissor fields are unused.
|
||||
|
|
@ -45,26 +45,26 @@ using System.Numerics;
|
|||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// An NDC convex view region reduced to ≤8 clip-space gl_ClipDistance planes, or a
|
||||
/// An NDC convex view region reduced to ≤8 clip-space gl_ClipDistance planes, or a
|
||||
/// scissor AABB fallback. See the file header for the convexity rule and the three
|
||||
/// Count==0 states.
|
||||
/// </summary>
|
||||
public readonly struct ClipPlaneSet
|
||||
internal readonly struct ClipPlaneSet
|
||||
{
|
||||
// Max simultaneous hardware clip planes we target (GL guarantees >= 8).
|
||||
private const int MaxPlanes = 8;
|
||||
|
||||
// Collinear-edge merge threshold. Two consecutive edge directions are treated as
|
||||
// the same edge when the turn between them is below ~0.5° (retail copy_view does a
|
||||
// ~1px screen-space dedup). |sin θ| for unit dirs = |cross|; sin(0.5°) ≈ 0.0087265.
|
||||
// the same edge when the turn between them is below ~0.5° (retail copy_view does a
|
||||
// ~1px screen-space dedup). |sin θ| for unit dirs = |cross|; sin(0.5°) ≈ 0.0087265.
|
||||
private const float CollinearSinEps = 0.0087265f;
|
||||
|
||||
// Drop a vertex whose two incident edges are shorter than this (NDC) — a duplicate
|
||||
// Drop a vertex whose two incident edges are shorter than this (NDC) — a duplicate
|
||||
// or near-duplicate point that would otherwise yield a garbage normalized normal.
|
||||
private const float DegenerateEdgeLen = 1e-6f;
|
||||
|
||||
// A polygon whose absolute signed area (full area, not 2x) falls below this is a line
|
||||
// or point — zero screen coverage ⇒ nothing visible. A real portal opening has area far
|
||||
// or point — zero screen coverage ⇒ nothing visible. A real portal opening has area far
|
||||
// above this (e.g. the sliver-clip test region is 0.4); only an edge-on projection gets here.
|
||||
private const float MinPolygonArea = 1e-7f;
|
||||
private readonly Vector4[] _planes;
|
||||
|
|
@ -77,7 +77,7 @@ public readonly struct ClipPlaneSet
|
|||
ScissorNdcAabb = scissorNdcAabb;
|
||||
}
|
||||
|
||||
/// <summary>Number of active clip planes, 0..8. 0 ⇒ inspect <see cref="UseScissorFallback"/>
|
||||
/// <summary>Number of active clip planes, 0..8. 0 ⇒ inspect <see cref="UseScissorFallback"/>
|
||||
/// and <see cref="IsNothingVisible"/> to decide between "draw the AABB" and "draw nothing".</summary>
|
||||
public int Count => _planes?.Length ?? 0;
|
||||
|
||||
|
|
@ -89,11 +89,11 @@ public readonly struct ClipPlaneSet
|
|||
// its frame-scoped slice instead of cloning every plane payload a second time.
|
||||
internal Vector4[] PlaneArray => _planes ?? Array.Empty<Vector4>();
|
||||
|
||||
/// <summary>True ⇒ the convex-plane budget was exceeded; gate on <see cref="ScissorNdcAabb"/>
|
||||
/// <summary>True ⇒ the convex-plane budget was exceeded; gate on <see cref="ScissorNdcAabb"/>
|
||||
/// instead (draw the box). Always false when <see cref="Count"/> > 0 or when the region is empty.</summary>
|
||||
public bool UseScissorFallback { get; }
|
||||
|
||||
/// <summary>True ⇒ the region is not visible at all; the consumer draws NOTHING.
|
||||
/// <summary>True ⇒ the region is not visible at all; the consumer draws NOTHING.
|
||||
/// Mutually exclusive with <see cref="UseScissorFallback"/>, and only meaningful when Count == 0.</summary>
|
||||
public bool IsNothingVisible { get; }
|
||||
|
||||
|
|
@ -106,20 +106,20 @@ public readonly struct ClipPlaneSet
|
|||
public static ClipPlaneSet Empty { get; } =
|
||||
new(Array.Empty<Vector4>(), useScissorFallback: false, isNothingVisible: true, scissorNdcAabb: DegenerateAabb);
|
||||
|
||||
// Inverted box (min > max) — any sane AABB intersection against it is empty.
|
||||
// Inverted box (min > max) — any sane AABB intersection against it is empty.
|
||||
private static Vector4 DegenerateAabb => new(1f, 1f, -1f, -1f);
|
||||
|
||||
/// <summary>
|
||||
/// Reduce a CellView's NDC clip region to a ClipPlaneSet. One convex polygon (≤8 edges
|
||||
/// after collinear-merge) → per-edge planes; multi-polygon or >8 edges → union-AABB scissor;
|
||||
/// empty/degenerate → <see cref="Empty"/>. See the file header for the full rule.
|
||||
/// Reduce a CellView's NDC clip region to a ClipPlaneSet. One convex polygon (≤8 edges
|
||||
/// after collinear-merge) → per-edge planes; multi-polygon or >8 edges → union-AABB scissor;
|
||||
/// empty/degenerate → <see cref="Empty"/>. See the file header for the full rule.
|
||||
/// </summary>
|
||||
public static ClipPlaneSet From(CellView region)
|
||||
{
|
||||
if (region is null || region.IsEmpty || region.Polygons.Count == 0)
|
||||
return Empty;
|
||||
|
||||
// MORE THAN ONE polygon ⇒ union, not convex ⇒ never emit one polygon's planes.
|
||||
// MORE THAN ONE polygon ⇒ union, not convex ⇒ never emit one polygon's planes.
|
||||
// Over-include via the union AABB (safe). region.Min/Max already track the union.
|
||||
if (region.Polygons.Count > 1)
|
||||
return Scissor(region.MinX, region.MinY, region.MaxX, region.MaxY);
|
||||
|
|
@ -152,15 +152,15 @@ public readonly struct ClipPlaneSet
|
|||
{
|
||||
int count = NormalizeAndMerge(input, verts);
|
||||
|
||||
// Fewer than 3 distinct edges survive ⇒ a sliver/line with no area. There is no
|
||||
// Fewer than 3 distinct edges survive ⇒ a sliver/line with no area. There is no
|
||||
// meaningful AABB to over-include (a zero-area region), so treat it as nothing visible.
|
||||
if (count < 3)
|
||||
return Empty;
|
||||
|
||||
ReadOnlySpan<Vector2> normalized = verts[..count];
|
||||
|
||||
// A single convex polygon with too many edges to fit the hardware budget ⇒ scissor
|
||||
// on ITS own AABB (still a superset of the polygon → over-include, safe).
|
||||
// A single convex polygon with too many edges to fit the hardware budget ⇒ scissor
|
||||
// on ITS own AABB (still a superset of the polygon → over-include, safe).
|
||||
if (count > MaxPlanes)
|
||||
return Scissor(normalized);
|
||||
|
||||
|
|
@ -173,10 +173,10 @@ public readonly struct ClipPlaneSet
|
|||
Vector2 q = normalized[(i + 1) % count];
|
||||
Vector2 dir = q - p;
|
||||
// Inward normal for CCW winding: perp(dir) = (-dir.y, dir.x) points to the polygon's
|
||||
// interior (the "left" side of the directed edge p→q).
|
||||
// interior (the "left" side of the directed edge p→q).
|
||||
Vector2 n = Vector2.Normalize(new Vector2(-dir.Y, dir.X));
|
||||
// Plane: n·x + d >= 0 inside, with d = -(n·p). In clip space with NDC x = clip.x/clip.w:
|
||||
// dist = n.x*clip.x + n.y*clip.y + 0*clip.z + (-(n·p))*clip.w (>= 0 ⇒ keep)
|
||||
// Plane: n·x + d >= 0 inside, with d = -(n·p). In clip space with NDC x = clip.x/clip.w:
|
||||
// dist = n.x*clip.x + n.y*clip.y + 0*clip.z + (-(n·p))*clip.w (>= 0 ⇒ keep)
|
||||
planes[i] = new Vector4(n.X, n.Y, 0f, -Vector2.Dot(n, p));
|
||||
}
|
||||
return new ClipPlaneSet(planes, useScissorFallback: false, isNothingVisible: false, scissorNdcAabb: DegenerateAabb);
|
||||
|
|
@ -233,9 +233,9 @@ public readonly struct ClipPlaneSet
|
|||
if (SignedArea2(points[..count]) < 0f)
|
||||
points[..count].Reverse();
|
||||
|
||||
// 3) Merge collinear edges: drop vertex i when edge (i-1→i) and edge (i→i+1) point the same
|
||||
// way (turn angle < ~0.5°). Iterate until stable — removing one vertex can expose a new
|
||||
// collinear triple. |cross(a,b)| of unit dirs = |sin θ|; dot>0 rules out a 180° reversal.
|
||||
// 3) Merge collinear edges: drop vertex i when edge (i-1→i) and edge (i→i+1) point the same
|
||||
// way (turn angle < ~0.5°). Iterate until stable — removing one vertex can expose a new
|
||||
// collinear triple. |cross(a,b)| of unit dirs = |sin θ|; dot>0 rules out a 180° reversal.
|
||||
bool changed = true;
|
||||
while (changed && count >= 3)
|
||||
{
|
||||
|
|
@ -260,12 +260,12 @@ public readonly struct ClipPlaneSet
|
|||
|
||||
d0 /= l0;
|
||||
d1 /= l1;
|
||||
float cross = d0.X * d1.Y - d0.Y * d1.X; // sin θ
|
||||
float dot = d0.X * d1.X + d0.Y * d1.Y; // cos θ
|
||||
float cross = d0.X * d1.Y - d0.Y * d1.X; // sin θ
|
||||
float dot = d0.X * d1.X + d0.Y * d1.Y; // cos θ
|
||||
if (dot > 0f && MathF.Abs(cross) < CollinearSinEps)
|
||||
{
|
||||
points[(i + 1)..count].CopyTo(points[i..]);
|
||||
count--; // cur lies on the straight line prev→next
|
||||
count--; // cur lies on the straight line prev→next
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
|
|
@ -276,7 +276,7 @@ public readonly struct ClipPlaneSet
|
|||
return 0;
|
||||
|
||||
// Final degeneracy gate: a polygon with negligible area is a line/point even if it still
|
||||
// has >= 3 distinct vertices (e.g. an edge-on portal, or a near-collinear triple the 0.5°
|
||||
// has >= 3 distinct vertices (e.g. an edge-on portal, or a near-collinear triple the 0.5°
|
||||
// merge didn't quite collapse). Emitting its planes would yield an empty half-space
|
||||
// intersection that silently gates out everything; report it honestly as nothing-visible.
|
||||
if (MathF.Abs(SignedArea2(points[..count])) * 0.5f < MinPolygonArea)
|
||||
|
|
@ -285,7 +285,7 @@ public readonly struct ClipPlaneSet
|
|||
return count;
|
||||
}
|
||||
|
||||
// Twice the signed area (the "shoelace" sum). > 0 ⇒ CCW, < 0 ⇒ CW.
|
||||
// Twice the signed area (the "shoelace" sum). > 0 ⇒ CCW, < 0 ⇒ CW.
|
||||
private static float SignedArea2(ReadOnlySpan<Vector2> poly)
|
||||
{
|
||||
float a = 0f;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue